first commit

This commit is contained in:
张乾 2024-10-16 10:18:29 +08:00
parent 9f7e624377
commit 4d66554867
131 changed files with 27252 additions and 0 deletions

View File

@ -0,0 +1,264 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 综合实现(一):如何实现面向对象编程?
你好,我是宫文学。
从20世纪90年代起面向对象编程的范式逐渐成为了主流。目前流行度比较高的几种语言比如Java、JavaScript、Go、C++和Python等都支持面向对象编程。
那么,为了支持面向对象编程,我们需要在语言的设计上,以及编译器和运行时的实现上,考虑到哪些问题呢?
这一讲,我就带你来探讨一下如何在一门语言里支持面向对象特性。这是一个很综合的话题,会涉及很多的知识点,所以很有助于帮你梳理和贯通与编译原理有关的知识。
那么,我们就先来分析一下,面向对象特性都包括哪些内容。
面向对象语言的特性
日常中,虽然我们经常会使用面向对象的语言,但如果要问,到底什么才是面向对象?我们通常会说得含含糊糊。最常见的情况,就是会拿自己所熟悉的某种语言的面向对象特性,想当然地认为这就是面向对象语言的全部特性。
不过,在我们的课程里,我想从计算机语言设计的角度,带你重新梳理和认识一下面向对象的编程语言,把面向对象按照清晰的逻辑解构,这样也便于讨论它的实现策略。在这个过程中,你可能会对面向对象产生新的认识。
特征1对象
面向对象编程语言的核心,是把世界看成了一个个的对象,比如汽车、动物等。这些对象包含了数据和代码。数据被叫做字段或属性,而代码通常又被叫做是方法。
此外,这些对象之间还会有一定的关系。比如,汽车是由轮子、发动机等构成的,这叫做聚合关系。而某个班级会有一个班主任,那么班级和作为班主任的老师之间,会有一种引用关系。
对象之间还可以互相发送消息。比如司机会“通知”汽车让它加速或者减速。在面向对象的语言中这通常是通过方法调用来实现的。但也并不局限于这种方式比如对象之间还可以通过异步的消息进行互相通讯不过一般的编程语言都没有原生支持这种通讯方式。我们在讨论Actor模式的时候曾经提到过Actor之间互相通讯的方式就有点像对象之间互发消息。
特征2类和类型体系
很多面向对象的语言都是基于类class并且类也是一种自定义的类型。这个类型是对象的模板。而对象呢则是类的实例。我们还可以再印证一下前面在探究元编程的实现机制时学过的Meta层次的概念。对象属于M0层而类属于M1层它为对象制定了一个标准也就是对象中都包含了什么数据和方法。
其实,面向对象的语言并不一定需要类这个概念,这个概念更多是来自于类型理论,而非面向对象的语言一样可以支持类型和子类型。类型的好处主要是针对静态编译的语言的,因为这样就可以通过类型,来限制可以访问的对象属性和方法,从而减少程序的错误。
而有些面向对象的语言比如JavaScript并没有类的概念。也有的像Python虽然有类的概念但你可以随时修改对象的属性和方法。
特征3重用继承Inheritance和组合Composition
在软件工程里我们总是希望能重用已有的功能。像Java、C++这样的语言能够让子类型重用父类型的一些数据和逻辑这叫做继承。比如Animal有speak()方法Cat是Animal的子类那么Cat就可以继承这个speak()方法。Cat也可以重新写一个方法把父类的方法覆盖掉让叫声更像猫叫。
不过并不是所有的面向对象编程语言都喜欢通过继承的方式来实现重用。你在网上可以找到很多文章都在分析继承模式的缺陷。像Go语言采用的是组合方式来实现重用。在这里我引用了一篇文章中的例子。在这个例子中作者首先定义了一个author的结构体并给这个结构体定义了一些方法
type author struct { //结构体author(作者)
firstName string //作者的名称
lastName string
bio string //作者简介
}
func (a author) fullName() string { //author的方法获取全名
return fmt.Sprintf("%s %s", a.firstName, a.lastName)
}
type post struct { //结构体:文章
title string //文章标题
content string //文章内容
author //文章作者
}
func (p post) details() { //文章的方法:获取文章的详细内容。
fmt.Println("Title: ", p.title)
fmt.Println("Content: ", p.content)
fmt.Println("Author: ", p.author.fullName())
fmt.Println("Bio: ", p.author.bio)
}
关于struct这里我想再给你强调几个知识点。熟悉C语言的同学应该都了解结构体struct。有一些比较新的语言比如Go、Julia和Rust也喜欢用结构体来作为复合数据类型而不愿意用class这个关键字而且它们也普遍摒弃了用继承来实现重用的思路。Go提倡的是组合而我们上一讲提到的泛型也开始在重用方面承担了越来越重要的角色就像在Julia语言里那样Rust和另外一些语言如Scala则使用了一种叫做Trait特征的技术。
Trait有点像Java和Go语言中的接口它也是一种类型。不过它比接口还多了一些功能那就是Trait里面的方法可以具体地实现。
我们用Trait可以替代传统的继承结构比如一个Cat可以实现一个Speakable的Trait而不需要从Animal那里继承。如果Animal还有其他的特征比如说reproduce()繁殖那也可以用一个Trait来代替。这样Cat就可以实现多个Trait。这会让类型体系更加灵活比如实现Speakable的不仅仅有动物还可以是机器人。
在像Scala这样的语言中Trait里不仅仅可以有方法还可以有成员变量而在Ruby语言中我们把这种带有变量和方法的可重用的单元叫做Mixin混入
无论Trait还是Mixin都是基于组合的原理来实现重用的。而且由于继承、接口、Trait和Mixin都可以看做是实现子类型的方式因此也都可以支持多态。因为继承、接口、Trait和Mixin一般都有多个具体的实现类所以在调用相同的方法时会有不同的功能。
特征4封装Encapsulation
我们知道软件工程中的一个原则是信息隐藏我们通常会称为封装encapsulation意思是软件只把外界需要知道的信息和功能暴露出来而内部具体的实现机制只有作者才可以修改并且不会影响到它的使用者。
同样的实现信息封装其实也不是面向对象才有的概念。有的语言的模块Module和包Package都可以作为封装的单元。
在面向对象的语言里通常对象的一些方法和属性可以被公共访问的而另一些方法和属性是内部使用的其访问是受限的。比如Java语言会对可以公共访问的成员加public关键字对只有内部可以访问的成员加private关键字。
好了,以上就是我们总结的面向对象语言的特征了。这里你要注意,面向对象编程其实是一个比较宽泛的概念。对象的概念是它的基础,然后语言的设计者再把类型体系、软件重用机制和信息封装机制给体现出来。在这个过程中,不同的设计者会有不同的取舍。所以,希望你不要僵化地理解面向对象的概念。比如,以为面向对象就必须有类,就必须有继承;以为面向对象才导致了多态,等等。这些都是错误的理解。
接下来,我们再来看看各门语言在实现这些面向对象的特征时,都要解决哪些关键技术问题。
如何实现面向对象的特性?
要实现一门面向对象的语言,我们重点要了解三个方面的关键工作:一是编译器在语法和语义处理方面要做哪些工作;二是运行期对象的内存布局的设计;三是在存在多态的情况下,如何实现方法的绑定。
我们首先从编译器在语法和语义处理上所做的工作开始学起。
编译器前端的工作
我们都知道,编译器的前端必须完成与类和对象有关的语法解析、符号表处理、引用消解、类型分析等工作。那么要实现一门面向对象的语言,编译器也需要完成这些工作。
第一,从语法角度来看,语言的设计者要设计与类的声明和使用有关的语法。
比如:
如何声明一个类?毕竟每种语言的风格都不同。
如何声明类的构造方法?
如何声明类与父类、接口、Trait等的关系
如何实例化一个对象像Java语言就需要new关键字而Python就不需要。
……
也就是说,编译器在语法分析阶段,至少要能够完成上述的语法分析工作。
第二,是要维护符号表,并进行引用消解。
在语义分析阶段,每个类会作为自定义类型被加入到符号表里。这样,在其他引用到该类型的地方,比如用该类型声明了一个变量,或者一个方法的参数或返回值里用到了该类型,编译器就能够做正确的引用消解。
另外,面向对象程序的引用消解还有一个特殊之处。因为父类中的成员变量、方法甚至类型的作用域会延伸到子类,所以编译器要能够在正确的作用域内做引用消解。比如,在一个方法体内,如果发现某个变量既不是本地变量,又不是参数,那么程序就要去找类的成员变量。在当前的类里找不到,那就要到父类中逐级去找。
还有一点,编译器在做引用消解的时候,还可以完成访问权限的检查。我们知道,对象要能够实现信息封装。对于编译器来说,这个功能实现起来很简单。在做引用消解的时候,检查类成员的访问权限就可以了。举个例子,假设你用代码访问了某个私有的成员变量,或者私有的方法,此时程序就可以报错;而在这个类内部的代码中,就可以访问这些私有成员。这样就实现了封装的机制。
第三,要做类型检查和推断。
使用类型系统的信息,在变量赋值、函数调用等地方,会进行类型检查和推断。我们之前学过的关于子类型、泛型等知识,在这里都可以用上。
OK以上就是编译器前端关于实现面向对象特性的重点工作了我们接下来看看编译器在运行时的一个设计重点就是对象的内存布局。
对象的内存布局
在第二个模块研究几个不同的编译器的时候我们已经考察过各种编译器在保存对象时所采用的内存布局。像Java、Python和Julia的对象一般都有一个固定的对象头。对象头里可以保存各种信息比如到类定义的指针、与锁有关的标志位、与垃圾收集有关的标志位等等。
对象头之后通常就是该类的数据成员。如果存在父类那么就既要保存父类中的成员变量也要保存子类中的成员变量。像Python这样的语言对内存的使用比较浪费通常是用一个内部字典来保存成员变量但对于Java这样的语言则要尽量节约着用内存。
我举个例子。假设某个Java类里有两个成员变量那么这两个成员变量会根据声明的顺序排在对象头的后面。如果成员变量是像Int这样的基础数据那么程序就要保存它的值而如果是String等对象类型那么就要保存一个指向该对象的指针。
在Java语言中当某个类存在父类的情况下那么父类的数据成员一定要排在前面。
这是为什么呢我给你举一个例子。在这个例子中有一个父类Base有两个子类分别是DerivedA和DerivedB。
图1Base类有两个子类
如果两个子类分别有一个实例a和b那么它们的内存布局就是下面的样子
图2两个子类的实例的内存布局
那么你可能要问了,为什么父类的数据成员要放在前面,子类的要放在后面?这也要从编译的角度说起。
我们知道,在生成的汇编代码里,如果要访问一个类的成员变量,其实都是从对象地址加上一个偏移量,来得到成员变量的地址。而这样的代码,针对父类和各种不同的子类的对象,要都能正常运行才行。所以,该成员变量在不同子类的对象中的位置,最好是固定的,这样才便于生成代码。
不过像C++这样的语言由于它经常被用来编写系统级的程序所以它不愿意浪费任意一点内存因此就不存在对象头这样的开销。但是由于C++支持多重继承所以当某个类存在多个父类的情况下在内存里安排不同父类的成员变量以及生成访问它们的正确代码就要比Java复杂一些。
比如下面的示例代码中c同时继承了a和b。你可以把对象obj的地址分别转换为a、b和c的指针并把这个地址打印出来。
class a { int a_; };
class b { int b_; };
class c : public a, public b { };
int main(){
c obj;
printf("a=0x%08x, b=0x%08x, c=0x%08x\n", (a*)&obj,(b*)&obj,(c*)&obj);
}
看到这段代码,你发现什么了呢?
你会发现a和c的指针地址是一样的而b的地址则要大几个字节。这是因为在内存里程序会先排a的字段再排b的字段最后再排c的字段。编译器做指针的类型转换cast的时候要能够计算出指针的正确地址。
图3多重继承情况下的内存布局
好,现在你就已经见识到了编译器在运行期针对对象的内存布局的设计了。接下来,我们再来看看针对多态的情况,编译器对实现方法的绑定是怎么做的。
方法的静态绑定和动态绑定
当你的程序调用一个对象的方法的时候这个方法到底对应着哪个实现有的时候在编译期就能确定了比如说当这个方法带有private、static或final关键字的时候。这样你在编译期就知道去执行哪段字节码这被叫做静态绑定Static Binding也可以叫做早期绑定Early Binding或者静态分派Static Dispathing
另外对于重载Overload的情况也就是方法名称一样、参数个数或类型等不一样的情况也是可以在编译期就识别出来的所以也可以通过静态绑定。
而在存在子类型的情况下我们到底执行哪段字节码只有在运行时根据对象的实际类型才能确定下来。这个时候就叫做动态绑定Dynamic binding也可以叫做后期绑定Late binding或者动态分派Dynamic Dispatching
动态绑定也是面向对象之父阿伦 · 凯伊Alan Kay所提倡的面向对象的特征绑定时机要尽量地晚。绑定时机晚意味着你在编程的时候可以编写尽量通用的代码也就是代码里使用的是类型树中更靠近树根的类型这种类型更通用也就可以让使用这种类型编写的代码能适用于更多的子类型。
那么动态绑定在运行时是怎么实现的呢对于Java语言来说其实现机制对于每个JVM可以是不同的。不过我们可以参考C++的实现机制就可以猜测出JVM的实现机制。
在C++语言中动态绑定是通过一个vtable的数据结构来实现的。vtable是Virtual Method Table或Virtual Table的简称。在C++里如果你想让基类中的某个方法可以被子类覆盖Override那么你在声明该方法的时候就要带上virtual关键字。带有虚方法的类及其子类的对象实例都带有一个指针指向一个表格这也就是vtable。
vtable里保存的是什么呢是每个虚方法的入口地址。我们来看一个例子这个例子中有Base、DerivedA和DerivedB三个类
图4vtable和动态绑定的原理
在对象a和b的vtable中各个方法的指针都会指向这个对象实际调用的代码的入口地址。
那么编译器的工作就简单了。因为它可以基于对象的地址得到vtable的地址不用去管这个对象是哪个子类的实例。
然后编译器只需要知道当前调用的方法在vtable中的索引值就行了这个索引值对于不同的子类的实例也都是一样的但是具体指向的代码地址可能会不同。这种调用当然需要比静态绑定多用几条指令因为对于静态绑定而言只需要跳转到某个特定的方法的代码地址就行了不需要通过vtable这个中间数据结构。不过这种代价是值得的因为它支持了面向对象中基于子类型的多态从而可以让你编写更具通用性的代码。
图4中我展示的只是一个示意结构。实际上vtable中还包含了一些其他信息比如能够在运行时确定对象类型的信息这些信息也可以用于在运行时进行指针的强制转换。
上面的例子是单一继承的情况。对于多重继承还会有多个vptr指针指向多个vtable。你参考一下多重继承下的内存布局和指针转换的情况应该就可以自行脑补出多重继承下的方法绑定技术。
我们接着回到Java。Java语言中调用这种可被重载的方法生成的是invokevirtual指令你在之前阅读Java字节码的时候一定遇到过这个指令。那么我现在可以告诉你这个virtual就是沿用了C++中虚方法的概念。
OK理解了实现面向对象特性所需要做的一系列重点工作以后我们来挑战一个难度更高的目标这也是一个有技术洁癖的人会非常想实现的目标就是让一切数据都表达为对象。
如何实现一切数据都是对象?
在Java、C++和Go这些语言中基础数据类型和对象类型是分开的。基础数据类型包括整型、浮点型等它们不像对象类型那样有自己的方法也没有类之间的继承关系。
这就给我们的编程工作造成了很多不便。比如针对以上这两类不同数据类型的编程方式是不一致的。在Java里你不能声明一个保存int数据的ArrayList。在这种情况下你只能使用Integer类型。
不过Java语言的编译器还是尽量提供了一些便利。举个例子下面示例的两个语句都是合法的。在需要用到一个Interger对象的时候你可以使用一个基础的int型数据反过来亦然。
Integer b = 2;
int c = b + 1;
在研究Java编译器的时候你已经发现它在语义分析阶段提供了自动装箱boxing和拆箱unboxing的功能。比如说如果发现代码里需要的是一个Interger对象而代码里提供的是一个int数据那么程序就自动添加相应的AST节点基于该int数据创建一个Integer对象这就叫做装箱功能。反之呢把Integer对象转换成一个int数据就叫做拆箱功能。装箱和拆箱功能属于一种语法糖它能让编程更方便一些。
说到这里你可能会想到既然编译器可以实现自动装箱和拆箱那么在Java语言里是不是根本就不用提供基础数据类型了全部数据都用对象表达行不行这样的话语言的表达性会更好我们写起程序来也更简单。
不过现在要想从头修改Java的语法是不可能了。但也有其他基于JVM的语言做到了这一点比如Scala。在Scala里所有数据都是对象各个类型构成了一棵有着相同根节点的类型树。对于对象化的整型和浮点型数据编译器可以把它们直接编译成JVM的基础数据类型。
可仅仅这样还不够。在Java里面需要自动装箱和拆箱机制很大一部分原因是Java的泛型机制。那些使用泛型的List、Map等集合类只能保存对象类型不能保存基础数据类型。但这对于非常大的一个集合来说用对象保存整型数据要多消耗几倍的内存。那么我们能否优化集合类的实现让它们直接保存基础数据而不是保存一个个整型对象的引用呢
通过上一讲的学习我们也知道了Java的泛型机制是通过类型擦除来实现的所以集合类里面只能保存对象引用无法保存基础数据。既然JVM平台缺省的类型擦除技术行不通那么是否可以对类型参数是值类型的情况下做特殊处理呢
这是可以做到的。你还记得C++实现泛型采用的是元编程技术。那么在JVM平台上你其实也可以通过元编程技术针对值类型生成不同的代码从而避免创建大量的小对象降低内存占用同时减少GC的开销。Scala就是这么做的它会通过注解技术来完成这项任务。如果你对Scala的具体实现机制感兴趣可以参考这篇文章。
课程小结
这一讲我通过面向对象这个话题,带你一起综合性地探讨了语言设计、编译器和运行时的多个知识点。你可以带走这几个关键知识点:
第一,要正确地理解面向对象编程的内涵,知道其实面向对象的语言可以有多种不同的设计选择,体现在类型体系、重用机制和信息封装等多个方面。对于不同的设计选择,你要都能够把它们解构,并对应到编译期和运行时的设计上。
第二,面向对象的各种特性,大多都是要在语义分析阶段进行检查和验证。
第三,对于静态编译的面向对象语言来说,理解其内存布局是关键。编译期要保证能够正确地访问对象的属性,并且巧妙地实现方法的动态绑定。
第四,如有可能,尽量让一切数据都表达为对象。让编译器完成自动装箱和拆箱的工作。
按照惯例,我把这节课的核心内容整理成了思维导图,供你参考和回顾知识点。
一课一思
有人曾经在技术活动上问Java语言之父詹姆斯 · 高斯林James Gosling如果重新设计Java语言他会怎么做他回答说他会去掉class也就是会取消类的继承机制。那么对于你熟悉的面向对象语言如果有机会重新设计的话你会怎么建议为什么欢迎分享你的观点。
参考资料
介绍Trait机制的论文。
在类型参数是值类型的情况下Scala以特殊的方式做实例化的文章。

View File

@ -0,0 +1,373 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 综合实现(二):如何实现函数式编程?
你好,我是宫文学。
近些年函数式编程正在复兴。除了一些纯函数式编程语言比如Lisp、Clojure、Erlang等众多的主流编程语言如Python、JavaScript、Go甚至Java它们都有对函数式编程的支持。
你应该会发现,现在人们对于函数式编程的讨论有很多,比如争论函数式编程和面向对象编程到底哪个更强,在语言里提供混合的编程模式到底对不对等等。
这些论战一时半会儿很难停息。不过我们的这一讲,不会涉及这些有争议的话题,而是试图从编译技术的角度,来探讨如何支持函数式编程,包括如何让函数作为一等公民、如何针对函数式编程的特点做优化、如何处理不变性,等等。通过函数式编程这个综合的主题,我们也再一次看看,如何在实现一门语言时综合运用编译原理的各种知识点,同时在这个探究的过程中,也会加深你对函数式编程语言的理解。
好,我们先来简单了解一下函数式编程的特点。
函数式编程的特点
我想,你心里可能多多少少都会有一点疑问,为什么函数式编程开始变得流行了呢?为什么我在开篇的时候,说函数式编程正在“复兴”,而没有说正在兴起?为什么围绕函数式编程会有那么多的争论?
要回答这几个问题,我会建议你先去了解一点历史。
我们都知道,计算机发展历史上有一个重要的人物是阿兰 · 图灵Alan Turing。他在1936年提出了一种叫做图灵机的抽象模型用来表达所有的计算。图灵机有一个无限长的纸带还有一个读写头能够读写数据并根据规则左右移动。这种计算过程跟我们在现代的计算机中用一条条指令驱动计算机运行的方式很相似。
不过,计算模型其实不仅仅可以用图灵机来表达。早在图灵机出现之前,阿隆佐 · 邱奇Alonzo Church就提出了一套Lambda演算的模型。并且计算机科学领域中的很多人其实都认为用Lambda演算来分析可计算性、计算复杂性以及用来编程会比采用图灵机模型更加简洁。而Lambda演算就是函数式编程的数学基础。
补充:实际上,邱奇是图灵的导师。当年图灵发表他的论文的时候,编辑看不懂,所以找邱奇帮忙,并推荐图灵成为他的学生,图灵机这个词也是邱奇起的。所以师生二人,对计算机科学的发展都做出了很大的贡献。
因为有Lambda演算的数学背景所以函数式编程范式的历史很早。上世纪50年代出现的Lisp语言就是函数式编程语言。Lisp的发明人约翰 · 麦卡锡John McCarthy博士是一位数学博士。所以你用Lisp语言和其他函数式编程语言的时候都会感觉到有一种数学思维的味道。
也正因如此与函数式编程有关的理论和术语其实是有点抽象的比如函子Functor、单子Monad、柯里化Currying等。当然对它们的深入研究不是我们这门课的任务。这里我想带你先绕过这些理论和术语从我们日常的编程经验出发来回顾一下函数式编程的特点反倒更容易一些。
我前面也说过目前流行的很多语言虽然不是纯粹的函数式编程语言但多多少少都提供了对函数式编程的一些支持比如JavaScript、Python和Go等。就连Java语言也在Java8中加入了对函数式编程的支持很多同学可能已经尝试过了。
我们使用函数式编程最多的场景恐怕是对集合的处理了。举个例子假设你有一个JavaScript的数组a你想基于这个数组计算出另一个数组b其中b的每个元素是a中对应元素的平方。如果用普通的方式写程序你可能会用一个循环语句遍历数组a然后针对每个数组元素做处理
var b = [];
for (var i = 0; i< a.length; i++){ //遍历数组a
b.push(a[i]*a[i]); //把计算结果加到数组b中
}
不过你也可以采用更简单的实现方法。
这次我们使用了map方法并给它传了一个回调函数。map方法会针对数组的每个元素执行这个回调函数并把计算结果组合成一个新的数组。
function sq(item){ //计算平方值的函数
return item*item;
}
var b = a.map(sq); //把函数作为参数传递
它还可以写成一种更简化的方式也就是Lambda表达式的格式
var b = a.map(item=>item*item);
通过这个简单的例子,我们可以体会出函数式编程的几个特点:
1.函数作为一等公民
也就是说函数可以像一个数值一样被赋给变量也可以作为函数参数。如果一个函数能够接受其他函数作为参数或者能够把一个函数作为返回值那么它就是高阶函数。像示例程序中的map就是高阶函数。
那函数式编程语言的优势来自于哪里呢?就在于它可以像数学那样使用函数和变量,这会让软件的结构变得特别简单、清晰,运行结果可预测,不容易出错。
根据这个特点,我们先来看看函数式编程语言中的函数,跟其他编程语言中的函数有什么不同。
2.纯函数Pure Function
在函数式编程里面,有一个概念叫做纯函数。纯函数是这样一种函数,即相同的输入,永远会得到相同的输出。
其实你对纯函数应该并不陌生。你在中学时学到的函数就是纯函数。比如对于f(x)=ax+b对于同样的x所得到的函数值肯定是一样的。所以说纯函数不应该算是个新概念而是可以回归到你在学习计算机语言之前的那个旧概念。
在C语言、Java等语言当中由于函数或方法里面可以引用外面的变量比如全局变量、对象的成员变量使得其返回值与这些变量有关。因此如果有其他软件模块修改了这些变量的值那么该函数或方法的返回值也会受到影响。这就会让多个模块之间基于共享的变量耦合在一起这种耦合也使得软件模块的依赖关系变得复杂、隐秘容易出错牵一发而动全身。这也是像面向对象语言这些命令式编程语言最令人诟病的一点。
而对于纯函数来说它不依赖外部的变量这个叫做引用透明Reference Transparency。纯函数的这种“靠谱”、可预测的特征就给我们的编程工作带来了很多的好处。
举个例子。既然函数的值只依赖输入那么就跟调用时间无关了。假设有一个函数式g(f(x))如果按照传统的求值习惯我们应该先把f(x)的值求出来再传递给g()。但如果f(x)是纯函数那么早求值和晚求值其实是无所谓的所以我们可以延迟求值Lazy Evaluation
延迟求值有很大的好处。比如在下面的伪代码中unless是一个函数f(x)是传给它的一个参数。在函数式编程语言中只有当condition为真时才去实际对f(x)求值。这实际上就降低了工作量。
//在满足条件时执行f(x)
unless(condition, f(x));
//伪代码
int unless(bool condition, f(x)){
if (condition)
return f(x);
}
再回到纯函数。我说纯函数的输出仅依赖输入有一点需要说明就是函数只有返回值这一种输出没有其他的输出。换句话说纯函数没有副作用Side Effect
什么是副作用呢?简单地说,就是函数在运行过程中影响了外界环境。比如,修改了一个全局变量或者是对象的属性、往文件里写入内容、往屏幕上打印一行字、往数据库插入一条记录、做了一次网络请求,等等。也就是说,纯函数要求程序除了计算,其他的事情都不要做。
如果函数有副作用的话那么我们前面说的时间无关性就被破坏了。比如说原来a函数是在屏幕上打印“欢迎b函数是屏幕输出你的名字最后形成“欢迎XXX”。那么a和b的前后顺序就不能颠倒。
你可能会说,一个有用的程序,哪能没有副作用呀。你说得对。在函数式编程里,程序会尽量把产生副作用的函数放在调用的外层,而完成内部功能的大部分函数,都保持是纯函数。比如,最外层的函数接受网络请求,并对客户端返回结果,它是有副作用的。而程序所使用的其他函数,都没有副作用。
纯函数的功能是如此地简单纯粹以至于它还能继续带来一些好处。比如说像Erlang这样的语言可以在运行时给某些函数升级而不用重启整个系统。为什么呢因为这些升级后的函数针对相同的输入程序得到的结果是一样的那么对这个函数的使用者来说就没有任何影响。这也是用Erlang写的系统会具有很高的可靠性的原因之一。
不过函数式编程语言里使用的也不全都是纯函数比如有的函数要做一些IO操作。另外闭包是函数引用了词法作用域中的自由变量而引起的所以也不是纯函数。
总结起来,在函数式编程中,会希望函数像数学中的函数那样纯粹,即不依赖外部(引用透明),也不改变外部(无副作用),从而带来计算时间、运行时替换等灵活性的优势。
好,说完了函数的不同,我们再来看看函数式编程语言里使用变量跟其他语言的不同。
3.不变性Immutability
我们都知道在数学里面当我们用到x和y这样的变量的时候它所代表的值在计算过程中是不变的。
没错,这也是函数式编程的一个重要原则,不变性。它的意思是,程序会根据需要来创建对象并使用它们,但不会去修改对象的状态。如果有需要修改对象状态的情况,那么去创建一个新对象就好了。
在前面的示例程序中map函数返回了一个新的数组而原来的数组保持不变。这就体现了不变性的特点。
不变性也会带来巨大的好处。比如说,由于函数不会修改对象的状态,所以就不存在并发程序中的竞争情况,进而也就不需要采用锁的机制。所以说,函数式编程更适合编写并发程序。这个优势,也是导致这几年函数式编程复兴的重要原因。
好,那么最后,我们再来注意一下函数式编程语言在编程风格上的不同。
4.声明式Declarative的编程风格
在计算机语言中,实现编程的方式主要有几种。
第一种实现方式我们会一步步告诉计算机该去怎么做计算循环访问a的元素计算元素的平方值并加到b中。这种编程风格叫做命令式Imperative编程即命令计算机按照你要求的步骤去做。命令式编程风格植根于现代计算机的结构因为机器指令本质上就是命令式的。这也是图灵机模型的特点。
而第二种实现方式叫做声明式Declarative编程。这种编程风格会要求计算机给出你想要的结果而不关心过程。比如在前面的示例程序中你关心的是对数组中的每个元素计算出平方值。至于具体的处理步骤是对数组a的元素顺序计算还是倒序计算你并不关心。
声明式编程风格的另一个体现,是递归函数的大量使用。这是因为我们描述一个计算逻辑的时候,用递归的方式表达通常会更简洁。
举个例子。你可能知道斐波纳契Fibonacci数列中的每个数是前两个数字的和。这个表达方式就是递归式的。写成公式就是Fibonacci(n)=Fibonacci(n-1)+Fibonacci(n-2)。这个公式与我们用自然语言的表达完全同构,也更容易理解。
我把计算斐波纳契数列的程序用Erlang这种函数式语言来写一下你可以进一步体会到声明式编程的那种简洁和直观的特点
%% 计算斐波那契的第N个元素
fibo(1) -> 1; %%第一个元素是1
fibo(2) -> 1; %%第二个元素也是1
fibo(N) -> fibo(N-1) + fibo(N-2). %%递归
好了,现在我们已经了解了函数式编程的一些关键特征。它的总体思想呢,就是像数学那样去使用函数和值,使可变动部分最小化,让软件的结构变得简单、可预测,从而获得支持并发、更简洁的表达等优势。那么下面,我们就一起来看看如何结合编译原理的相关知识点,来实现函数式编程的这些特征。
函数式编程语言的编译和实现
为了实现函数式语言,我们在编译期和运行时都要做很多工作。比如,要在编译器前端做分析和各种语义的检查; 要以合适的方式在程序内部表示一个函数;要针对函数式编程的特点做特别的优化,等等。接下来我们就从编译器的前端工作开始学起。
编译器前端的工作
函数式编程语言,在编译器的前端也一样要做很多的语法分析和语义分析工作。
你应该知道语言的设计者需要设计出如何声明一个函数。像是JavaScript语言会用function关键字Go语言用func关键字Rust语言用的是fn关键字而C语言根本不需要一个关键字来标识一个函数的定义另外如何声明函数的参数和返回值也会使用不同的语法。编译器都要能够正确地识别出来。
语义分析的工作则更多,包括:
符号表和引用消解:当声明一个函数时,要把它加入到符号表。而当程序中用到某个函数的时候,要找到该函数的声明。
类型检查和推导既然函数可以被当做一个值使用那么它一定也是有类型的也要进行类型检查和推导。比如在程序的某个地方只能接受返回值为int有一个参数为String的函数那么就需要被使用的函数是否满足这个要求。关于函数的类型一会儿我还会展开讲解。
语法糖处理在函数式编程中经常会使用一些语法糖。最常见的语法糖就是Lambda表达式Lambda表达式可以简化匿名函数的书写。比如前面JavaScript的示例代码中对数组元素求平方的函数可以写成一个Lambda表达式从而把原来的代码简化成了一行
var d = a.map(item=>item*item); //括号中是一个lambda表达式
在这个示例程序中,=>左边的是匿名函数的参数右边的是一个表达式这个表达式的计算结果就是匿名函数的返回值。你看通过一个Lambda表达式代替了传统的函数声明代码也变得更简洁了。
OK因为在编译器前端还要对函数做类型分析所以我们再来探究一下函数的类型是怎么一回事。
把函数纳入类型系统
这里我要先提一个问题,就是在函数式编程语言里,既然它能够把函数当做一个值一样去看待,那么也应该有相应的类型吧?这就要求语言的类型系统能够把函数包含进来。因此函数式编程语言在编译的时候,也要进行类型检查和类型推断。
不过,我们在谈论类型时,比较熟悉的是值类型(如整型、浮点型、字符型),以及用户自定义的类型(如结构、类这些),如果函数也是一种类型,那跟它们是什么关系呢?如果由你来设计,那么你会怎么设计这个类型体系呢?
在不同的语言里设计者们是以不同的方式来解决这个问题的。拿Python来说Python中一切都是对象函数也不例外。函数对象的ob_type字段也被设置了合适的类型对象。这里你可以再次回味一下Python的类型系统设计得是如何精巧。
我们再看看Scala的类型系统。上一讲我提出过Scala实现了一个很漂亮的类型系统把值类型和引用类型也就是自定义类做了统一。它们都有一个共同的根就是Any。由于Scala是基于JVM的所以这些类型最后都是以Java的类来实现的。
那么函数也不例外。因为Scala的函数最多支持22个参数所以Scala里有内置的Function1、Function2…Function22这些类作为函数的类型它们也都是Any的子类型。每个Scala函数实际上是这些类的实例。
另外Swift语言的文档对类型的定义也比较清楚。它以产生式的方式列出了type的语法定义。根据该语法类型可以是函数类型、数组类型、字典类型、元组类型等等这些都是类型。
并且它还把所有类型分成了两个大类别命名类型Named Type和复合类型Compound Type
命名类型包括类、结构体、枚举等,它们都有一个名称,比如自定义类的类名就是类型名称。
复合类型则没有名称,它是由多个其他类型组合而成的。函数和元组都属于复合类型。函数的类型是由参数的类型和返回值的类型组合而成的,它们都是编译器对函数类型进行计算的依据。
举例来说假设一个函数有两个参数分别是类型A和B而返回值的类型是C那么这个函数的类型可以计为(A, B)->C。这就是对函数的类型的形式化的表达。
那么进一步,我们如何在编译期里针对函数的类型做类型分析呢?它跟非复合的类型还真不太一样,因为编译器需要检查复合类型中的多个元素。
举个例子。在一个高阶函数g()里能够接收一个函数类型的参数f(A,B),要求其类型是(A, B)->C而实际提供的函数f2的类型是(A1, B1)->C1那么你在编译器里如何判断函数的类型是否合法呢这里的算法要做多步的检查
第一f2也必须有两个参数这点是符合的。
第二检查参数的类型。A1和B1必须是跟A和B相同的类型或者是它们的父类型这样f1才能正确地给f2传递参数。
第三检查返回值的类型。C1则必须是C的子类型这样f1才能接收f2的返回值。
好,说完了编译器的前端工作,我们再来看看函数在语言内部的实现。
函数的内部实现
在函数式编程里,所有一切都围绕着函数。但是在编译完毕以后,函数在运行时中是怎么表示的呢?
就像不同的面向对象的语言,在运行时是以不同的方式表示一个对象的,不同的函数式编程语言,在运行时中去实现一个函数的机制也是不太一样的。
在Python中一切都是对象所以函数也是一种对象它是实现了Callable协议的对象能够在后面加上一对括号去调用它。
在Scala和Java这种基于JVM的语言中函数在JVM这个层次没有获得原生支持因此函数被编译完毕以后其实会变成JVM中的类。
在Julia、Swift、Go、Rust这样编译成机器码的语言中函数基本上就是内存中代码段或文本段的一个地址。这个地址在编译后做链接的时候会变成一个确定的地址值。在运行时跳转到这个地址就可以执行函数的功能。
补充:再具体一点的话,编译成机器码的函数有什么特点呢?我们再来回顾一下。
首先,函数的调用者要根据调用约定,通过栈或者寄存器设置函数的参数,保护好自己负责保护的寄存器以及返回地址,然后调用函数。
在被调用者的函数体内通常会分为三个部分。头尾两个部分叫做序曲prelude和尾声epilogue分别做一些初始化工作和收尾工作。在序曲里会保存原来的栈指针以及把自己应该保护的寄存器存到栈里、设置新的栈指针等接着执行函数的主体逻辑。最后到尾声部分要根据调用约定把返回值设置到寄存器或栈恢复所保护的寄存器的值和栈顶指针接着跳转到返回地址。
返回到调用者以后,会有一些代码恢复被保护起来的寄存器,获取返回值,然后继续执行后面的代码。
这样,把上述整个过程的细节弄清楚了,你就知道如何为函数生成代码了。
最后,我们必须提到一种特殊情况,就是闭包。闭包是纯函数的对立面,它引用了上级作用域中的一些自由变量。闭包在运行时不仅是代码段中的一个函数地址,还必须保存自由变量的值。为了实现闭包的运行时功能,编译器需要生成相应的代码,以便在生成闭包的时候,可以在堆里申请内存来保存自由变量的值。而当该闭包不再被引用了,那么就会像不再被引用的对象一样,成为了内存垃圾,要被垃圾回收机制回收。
好了,到这里你可能会觉得,看上去函数的实现似乎跟命令式语言也没有什么不同。不过,接下来你就会看到不同点了,这就是延迟求值的实现。
延迟求值Lazy Evaluation
在命令式语言里我们对表达式求值是严格按照顺序对AST求值。但对于纯函数来说由于在任何时候求值结果都是一样的因此可以进行一定的优化比如延迟求值Lazy Evaluation从而有可能减少计算工作量或者实现像unless()那样的特别的控制结构。
那么针对这种情况,编译器需要做什么处理呢?
我举个例子,对于下面的示例程序(伪代码):
g(condition, x){
if (condition)
return x;
else return 0;
}
如果我们调用的时候在x参数的位置传入的是另一个函数调用f(y)也就是g(condition, f(y))那么编译器就会把g()的函数体内用到x的地方都转换成对f(y)的调用:
if (condition)
return f(y);
else return 0;
这种把对参数的引用替换成对函数调用的技术,叫做换名调用。
不过换名调用有一个缺点就是f(y)有可能会被多次调用,而且每次调用的结果都是一样的。这就产生了浪费。那么这时,编译器就要更聪明一点。
怎么办呢?那就是在第一次调用的时候,记录下它的值。如果下次再调用,则使用第一次调用的结果。这种方式叫做按需调用。
总而言之,纯函数的特征就导致了延迟求值在编译上的不同。而函数式编程另一个重要的特征,不变性,也会对编译和运行过程造成影响。
不变性对编译和运行时的影响
在遵守不变性原则的情况下,对程序的编译也会有很大的不同。
第一由于函数不会修改对象的状态所以就不存在并发程序中的竞争情况进而也就不需要采用锁的机制编译器也不需要生成与锁有关的代码。Java、JavaScript等语言中关于参数逃逸的分析也变得不必要了因为反正别的线程获得了某个对象或结构体也不会去修改它的状态。
第二,不变性就意味着,只可能是新的对象引用老的对象,老的对象不可能引用新的对象。这对于垃圾收集算法的意义很大。在分代收集的算法中,如果老对象被新对象引用,那必须等到新对象回收之后老对象才可能被回收,所以函数式编程的程序现在可以更容易做出决定,把老对象放到老一代的区域,从而节省垃圾收集算法的计算量;另外,由于对象不会被改变,因此更容易实现增量收集和并行收集;由于不可能存在循环引用,因此如果采用的是引用计数法的话,就没有必要进行循环引用的检测了。
第三,不变性还意味着,在程序运行过程中可能要产生更多的新对象。在命令式语言中,程序需要对原来的对象修改状态。而函数式编程,只能每次创建一个新对象。所以,垃圾收集算法需要能够尽快地收集掉新对象。
OK了解了不变性我们再来看看针对函数式编程语言的优化算法。其中最重要的就是对递归函数的优化。
对递归函数的优化
虽然命令式的编程语言也会用到递归函数但函数式编程里对递归函数的使用更加普遍比如通常会用递归来代替循环。如果要对一个整型数组求和命令式编程语言会做一个循环而函数式编程语言则更习惯于用递归的方式表达sum(a, i) = a[i] + sum(a, i-1)。
按照传统的函数调用的运行方式,对于每一次函数调用,程序都要增加一个栈桢。递归调用一千次,就要增加一千个栈桢。这样的话,程序的栈空间很快就会被耗尽。并且,函数调用的时候,每次都要有一些额外的开销,比如保护寄存器的值、保存返回地址、传递参数等等。
我在第7讲的优化算法里提到过尾调用优化也就是执行完递归函数后马上用return语句返回的情况。
f(x){
....
return g(...); //尾调用
}
在尾调用的场景下,上一级函数的栈桢已经没什么用了,程序可以直接复用。函数调用的过程,可以被优化成指令的跳转,不需要那些函数调用的开销。
不过对于递归调用的情况往往还需要对递归函数返回值做进一步的计算。比如在下面的求阶乘的函数示例中返回值是x*fact(x-1)。
//fact.c 求阶乘
int fact(int x){
if (x == 1)
return 1;
else
return x*fact(x-1); //对递归值要做进一步的计算
}
对于编译器来说它可以经过分析把这种情况转换成一个单纯的尾调用。具体地说就是它相当于引入了一个临时的递归函数fact2()并且用第一个参数acc来记录累计值
int fact(x){
if (x == 1)
return 1;
else
return fact2(x, x-1); //调用一个临时的递归函数
}
int fact2(int acc, int x){ //参数acc用来保存累计值
if (x == 1){
return acc;
}
else{
return fact2(acc * x, x-1); //一个单纯的尾调用
}
}
如果我们调用fact(5)其实际执行过程就会在acc参数中连续地做乘法从而实现阶乘
->fact(5)
->fact2(5,4)
->fact2(5*4,3)
->fact2(5*4*3,2)
->fact2(5*4*3*2,1)
->5*4*3*2
你可以观察一下编译器实际生成的汇编程序看看优化后的成果。如果用“clang -O1 -S -o fact.s fact.c”来编译fact函数就会得到一个汇编代码文件。我对这段代码做了注释你可以理解下它的逻辑。你可以发现优化后的函数没有做任何一次递归调用。
_fact: ## @fact
pushq %rbp # 保存栈底指针
movq %rsp, %rbp # 把原来的栈顶,设置为新栈桢的栈底
movl $1, %eax # %eax是保存返回值的。这里先设置为1
cmpl $1, %edi # %edi是fact函数的第一个参数相当于if(x==1)
je LBB0_3 # 如果相等跳转到LBB0_3就会直接返回1
movl $1, %eax # 设置%eax为1这里%eax会保存累计值
LBB0_2:
imull %edi, %eax # 把参数乘到%eax来
decl %edi # x = x-1
cmpl $1, %edi # x是否等于1
jne LBB0_2 # 如果不等跳到LBB0_2做连乘
LBB0_3:
popq %rbp # 回复原来的栈底指针
retq # 返回
要想完成这种转换就要求编译器能够基于IR分析出其中的递归结构然后进行代码的变换。
课程小结
这一讲,我们一起讨论了实现函数式编程特性的一些要点。我希望你能记住这些关键知识点:
第一函数式编程的理论根基可以追溯到比图灵机更早的Lambda演算。要理解函数式编程的特点你可以回想一下中学时代数学课中的内容。在函数式编程中函数是一等公民。它通过强调纯函数和不变性大大降低了程序的复杂度使软件不容易出错并且能够更好地支持并发编程。并且由于采用声明式的编程风格往往程序可以更简洁表达性更好。
第二,不同的语言实现函数的机制是不同的。对于编译成机器码的语言来说,函数就是一个指向代码的指针。对于闭包,还需要像面向对象的语言那样,管理它在内存中的生存周期。
第三,函数仍然要纳入类型体系中,编译器要支持类型的检查和推断。
第四,针对函数式编程的特点,编译器可以做一些特别的优化,比如延迟求值、消除与锁有关的分析、对递归的优化等等。
同样,我把这一讲的知识点梳理成了思维导图,供你参考:
一课一思
这节课中我提到,在很多情况下,用函数式编程表达一个计算逻辑会更简洁。那么,你能不能找到这样的一些例子?欢迎分享你的经验。
如果你身边也有对函数式编程感兴趣的朋友,那么也非常欢迎你把这节课分享给 TA。感谢你的阅读下一讲我们会一起解析华为的方舟编译器到时候再见

View File

@ -0,0 +1,285 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 成果检验:方舟编译器的优势在哪里?
你好,我是宫文学。到这里,咱们的课程就已经进入尾声了。在这门课程里,通过查看真实的编译器,你应该已经积累了不少对编译器的直观认识。前面我们研究的各种编译器,都是国外的产品或项目。而这一讲呢,我们则要看看一个有中国血统的编译器:方舟编译器。
通过阅读方舟编译器已经公开的代码和文档,在解析它的过程中,你可以检验一下自己的所学,谈谈你对它的认识。比如,跟你了解的其他编译器相比,它有什么特点?先进性如何?你是否有兴趣利用方舟编译器做点实际项目?等等。
不过到目前为止由于方舟编译器开源的部分仍然比较有限所以这一讲我们只根据已经掌握的信息做一些分析。其中涉及两个大的话题一是对方舟编译器的定位和设计思路的分析二是对方舟编译器所使用的Maple IR的介绍。
首先我借助Android对应用开发支持的缺陷来谈一下为什么方舟编译器是必要的。
Android的不足
为什么要研发一款自己的编译器?对于一个大的技术生态而言,语言的编译和运行体系非常重要。它处在上层应用和下层硬件之间,直接决定了应用软件能否充分地发挥出硬件的性能。对于移动应用生态而言,我国拥有体量最大的移动用户和领先的移动应用,也有着最大的手机制造量。可是,对于让上层应用和底层硬件得以发挥最大能力的编译器和运行时,我们却缺少话语权。
实际上我认为Android对应用开发的支持并不够好。我猜测掌控Android生态的谷歌公司对于移动应用开发和手机制造都没有关系到切身利益因此创新的动力不足。
我之所以说Android对应用开发的支持不够好这其实跟苹果的系统稍加对比就很清楚了。同样的应用在苹果手机上会运行得更流畅且消耗的内存也更低。所以Android手机只好增加更多的CPU内核和更多的内存。
你可能会问谷歌不是也有自己的应用吗对应用的支持也关系到谷歌自己的利益呀。那我这里其实要补充一下我说的应用开发指的是用Java和Kotlin开发的应用这也是大部分Android平台上的应用开发者所采用的语言。而像谷歌这样拥有强大技术力量的互联网巨头们通常对于性能要求比较高的代码是用C开发的。比如微信的关键逻辑就是用C编写的像手机游戏这种对性能要求比较高的应用底层的游戏引擎也是基于C/C++实现的。
这些开发者们不采用Java的原因是因为Java在Android平台上的编译和运行方式有待提高。Android为了提升应用的运行速度一直在尝试升级其应用运行机制。从最早的仅仅解释执行字节码到引入JIT编译机制到当前版本的ARTAndroid Runtime支持AOT、JIT和基于画像的编译机制。尽管如此Android对应用的支持仍然存在明显的短板。
第一个短板是垃圾收集机制。我们知道Java基于标记-拷贝算法的垃圾收集机制有两个缺陷。一是要占据更多的内存,二是在垃圾收集的时候会有停顿,导致应用不流畅。在系统资源紧张的时候,更是会强制做内存收集,引起整个系统的卡顿。
实际上Java的内存管理机制使得它一直不太适合编写客户端应用。就算在台式机上用Java编写的客户端应用同样会占用很大的内存并且时不时会有卡顿。你如果使用过Eclipse和IDEA应该就会有这样的体会。
第二个短板是不同语言的融合问题。Android系统中大量的底层功能都是C/C++实现而Java应用只是去调用它们。比如图形界面的绘制和刷新是由一个叫做Skia的库来实现的这个库是用C/C++编写的各种窗口控件都是在Skia的基础上封装出来的。所以用户在界面上的操作背后就有大量的JNI调用。
问题是Java通过JNI调用C语言的库的时候实现成本是很高的因为两种不同语言的数据类型、调用约定完全不同又牵涉到跨语言的异常传播和内存管理所以Java不得不通过虚拟机进行昂贵的处理效率十分低下。
据调查95%的顶级移动应用都是用Java和C、C++等混合开发的。所以,让不同语言开发的功能能够更好地互相调用,是一个具有普遍意义的问题。
第三个短板就是Android的运行时一直还是受Java虚拟机思路的影响一直摆脱不了虚拟机。虚拟机本身要占据内存资源和CPU资源。在做即时编译的时候也要消耗额外的资源。
那么如何解决这些问题呢?我们来看看方舟编译器的解决方案。
方舟编译器的解决方案
方舟编译器的目标并不仅仅是为了替代Android上的应用开发和运行环境。但我们可以通过方舟是如何解决Android应用开发的问题来深入了解一下方舟编译器。
我们先来看看,方舟编译器是怎么解决垃圾收集的问题的。
不过在讨论方舟的方案之前我们不妨先参考一下苹果的方案做个对照。苹果采用的开发语言无论是Objective-C还是后来的Swift都是采用引用计数技术。引用计数可以实时回收内存垃圾所以没有卡顿。并且它也不用像标记-拷贝算法那样,需要保留额外的内存。而方舟编译器,采用的是跟苹果一样的思路,同样采用了引用计数技术。
当然,这里肯定会有孰优孰劣的争论。我们之前也讲过,采用引用计数法,每次在变量引用对象的时候都要增加引用计数,而在退出变量的作用域或者变量不再指向该对象时,又要减少引用计数,这会导致一些额外的性能开销。当对象在多个线程之间共享的时候,增减引用计数的操作还要加锁,从而进一步导致了性能的降低。
不过,针对引用计数对性能的损耗,我们可以在编译器中通过多种优化算法得到改善,尽量减少不必要的增减计数的操作,也减少不必要的锁操作。另外,有些语言在设计上也会做一些限制,比如引入弱引用机制,从而降低垃圾收集的负担。
无论如何,在全面考察了引用计数方法的优缺点以后,你仍然会发现它其实更适合开发客户端应用。
关于第二个问题也就是不同语言的融合问题。华为采取的方法是让Java语言的程序和基于C、C++等语言的程序按照同一套框架做编译。无论前端是什么语言,都统一编译成机器码,同时不同语言的程序互相调用的时候,也没有额外的开销。
下图是方舟编译器的文档中所使用的架构图。你能看到它的设计目标是支持多种语言都统一转换成方舟IR然后进行统一的优化处理再生成机器码的可执行文件。
方舟编译器架构示意图
这个技术方案其实非常大胆。它不仅解决了不同语言之间的互相调用问题也彻底抛弃了植根于JVM的虚拟机思路。方舟编译器新的思路是不要虚拟机最大程度地以机器码的方式运行再加上一个非常小的运行时。
我说这个技术方案大胆是因为方舟编译器彻底抛弃了Java原有的运行方案包括内存布局、调用约定、对象结构、分层编译机制等。我们在第二个模块讲过Graal在仍然基于JVM运行的情况下JIT只是尽力做改良它随时都有一个退路就是退到用字节码解释器去执行。就算采用AOT以后运行时可以变得小一些但Java运行机制的大框架仍然是不变的。
我也介绍过GraalVM支持对多种语言做统一编译其中也包含了对C语言的支持并且也支持语言之间的互相调用。但即便如此它仍是改良主义它不会抛弃Java原来的技术积累。
而方舟编译器不是在做改良而是在做革命。它对Java的编译更像是对C/C++等语言的编译抛弃了JVM的那一套思路。
这个方案不仅大胆而且难度更高。因为这样就不再像分层编译那样有退路方舟编译器需要把所有的Java语义都静态编译成机器码。而对于那些比较动态的语义比如运行时的动态绑定、Reflection机制等是挑战比较大的。
那方舟编译器目前的成果如何呢根据华为官方的介绍方舟编译器可以使安卓系统的操作流畅度提升24%响应速度提升44%第三方应用操作流畅度提升高达60%。这就是方舟编译器的厉害之处,这也证明方舟编译器的大胆革新之路是走对了的。
我们目前只讨论了方舟编译器对Android平台的改进。其实方舟编译器的目标操作系统不仅仅是Android平台它本质上可移植所有的操作系统也包括华为自己的鸿蒙操作系统。对于硬件平台也一样它可以支持从手机到物联网设备的各种硬件架构。
所以,你能看出,方舟编译器真的是志存高远。它不是为了解决某一个小问题,而是致力于打造一套新的应用开发生态。
好了,通过上面的介绍,你应该对方舟编译器的定位有了一个了解。接下来的问题是,方舟编译器的内部到底是怎样的呢?
方舟编译器的开源项目
要深入了解方舟编译器还是必须要从它的源代码入手。从去年9月份开源以来方舟编译器吸引了很多人的目光。不过方舟编译器是逐步开源的由于开放出来的源代码必须在知识产权等方面能够经得起严格的审查因此到现在为止我们能看到的开源版本号还只是0.2版,开放出来的功能并不多。
我参照方舟的环境配置文档在Ubuntu 16.04上做好了环境配置。
注意:请尽量完全按照文档的要求来配置环境,避免出现不必要的错误。不要嫌某些软件的版本不够新。
接着你可以继续根据开发者指南来编译方舟编译器本身。方舟编译器本身的代码是用C++写的需要用LLVM加Clang编译这说明它到目前还没有实现自举。然后你可以编译一下示例程序。比如用下面的四个命令可以编译出HelloWorld样例。
source build/envsetup.sh; make; cd samples/helloworld/; make
这个“hellowold”目录原来只有一个HelloWorld.java源代码经过编译后形成了下面的文件
如果你跟踪查看编译过程,你会发现中间有几步的操作:
第一步执行java2jar这一步是调用Java的编译器把Java文件先编译成class文件然后打包成jar文件。
补充java2jar实际上是一个简单的脚本文件你可以查看里面的内容。
第二步执行jbc2mpl也就是把Java字节码转换成Maple IR。Maple IR是方舟编译器的IR我下面会展开介绍。编译后生成的Maple IR保存到了HelloWorld.mpl中。
第三步通过maple命令执行mpl2mpl和mplme这两项对Maple IR做分析和优化的工作。这其中很重要的一个步骤就是把Java方法的动态绑定用vtable做了实现并生成了一个新的Maple IR文件HelloWorld.VtableImpl.mpl。
最后一步调用mplcg命令将Maple IR转换成汇编代码保存到一个以.s结尾的文件里面。
注意我们目前还没有办法编译成直接可以执行的文件。当前开源的版本既没有编译器前端部分的代码也没有后端部分的代码甚至基于Maple IR的一些常见的优化比如内联、公共子表达式消除、常量传播等等都是没有的。目前开源的版本主要展现了Maple IR以及对Maple IR做的一些变换比如转换成SSA格式以便进行后续的分析处理。
到这里你可能会有一点失望因为当前开放出来的东西确实有点少。但是不要紧方舟编译器既然选择首先开放Maple IR的设计这说明Maple IR在整个方舟编译器的体系中是很重要的。
事实也确实如此。方舟编译器的首席科学家Fred Chow周志德先生曾发表过一篇论文The increasing significance of intermediate representations in compilers。他指出IR的设计会影响优化的效果IR的调整会导致编译器实现的重大调整。他还提出如果不同体系的IR可以实现转换的话就可以加深编译器之间的合作。
基于这些思想方舟编译器特别重视IR的设计因为方舟编译器的设计目标是将多种语言翻译成统一的IR然后共享优化算法和后端。这就要求Maple IR必须要能够兼容各种不同语言的差异性才行。
那接下来我们就具体看看Maple IR的特点。
志存高远的Maple IR
方舟开放的资料中有一个doc目录Maple IR的设计文档就在其中。这篇文档写得很细致从中你能够学习到IR设计的很多思想值得仔细阅读。
文档的开头一段指出由于源代码中的任何信息在后续的分析和优化过程中都可能有用所以Maple IR的目标是尽可能完整地呈现源代码中的信息。
这里我想提醒你注意不要忽略这一句话。它作为文档的第一段可不是随意而为。实际上像LLVM的作者Chris Lattner就认为LLVM的IR损失了一些源代码的信息而很多语言的编译器都会在转换到LLVM IR之前先做一个自己的IR做一些体现自己语言特色的分析工作。为了方便满足这些需求他后来又启动了一个新项目MLIR。你可以通过这篇论文了解Lattner的观点。
Maple IR则在一开头就注意到了这种需求它提供了对高、中、低不同层次的IR的表达能力。我们在分析别的编译器的时候比如Graal的编译器也曾经讲过它的IR也是分层次的但其实Graal对特定语言的高层次IRHIR的表达能力是不够强的。
HIR特别像高级语言特定于具体语言的分析和优化都可以在HIR上进行。它的特点是提供了很多语言结构比如if结构、循环结构等因为抽象层次高所以IR比较简洁。
与Graal和V8一样Maple IR也用了一种数据结构来表达从高到低不同抽象层次的操作。不过不同于Graal和V8采用了图的结构Maple IR采用的是树结构。在HIR这个层次这个树结构跟原始语言的结构很相似。这听上去跟AST差不多。
随着编译过程的深化抽象的操作被Lower成更低级的操作代码也就变得更多同时树结构也变得越来越扁平最后变成了指令的列表。
那么既然Maple IR是用同一个数据结构来表达不同抽象层次的语义它是以什么来划分不同的抽象层次呢答案是通过下面两个要素
被允许使用的操作码:抽象层次越高,操作码的种类就越多,有一些是某些语言特有的操作。而在最低的层次,只允许那些与机器码几乎一一对应的操作码。
代码结构:在较高的抽象层次上,树的层级也比较多;在最低的抽象层次上,会变成扁平的指令列表。
再进一步Maple IR把信息划分成了两类。一类是声明性的信息用于定义程序的结构比如函数、变量、类型等这些信息其实也就是符号表。另一类是用于执行的代码它们表现为三类节点叶子节点常量或存储单元、表达式节点、语句节点。
我用一个简单的示例程序Foo.java带你看看它所生成的Maple IR是什么样子的。
public class Foo{
public int atLeastTen(int x){
if (x < 10)
return 10;
else
return x;
}
public int exp(int x, int y){
return x*3+y+1;
}
}
示例程序编译后,会生成.mpl文件。这个文件是用文本格式来表示Maple IR你甚至可以用这种格式来写程序然后编译成可执行文件。当这个格式被读入方舟编译器后就会变成内存格式。另外Maple IR也可以表示成二进制格式。到这里你是不是会有似曾相识的感觉会联想到LLVM的IR对了LLVM也可以用三种方式来表示IR文本格式、内存格式、二进制格式
打开.mpl文件你首先会在文件顶部看到一些符号表信息包括类、方法等符号。
javaclass $LFoo_3B <$LFoo_3B> public
func &LFoo_3B_7C_3Cinit_3E_7C_28_29V public constructor (var %_this <* <$LFoo_3B>>) void
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32
var $__cinf_Ljava_2Flang_2FString_3B extern <$__class_meta__>
func &MCC_GetOrInsertLiteral () <* <$Ljava_2Flang_2FString_3B>>
接下来就是每个方法具体的定义了。比如exp方法对应的IR如下
func &LFoo_3B_7Cexp_7C_28II_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32, var %Reg4_I i32) i32 {
funcid 48155 #函数id
var %Reg2_R43694 <* <$LFoo_3B>>
var %Reg0_I i32 #伪寄存器
var %Reg1_I i32
dassign %Reg2_R43694 0 (dread ref %_this)
#INSTIDX : 0||0000: iload_1
#INSTIDX : 1||0001: iconst_3
dassign %Reg0_I 0 (constval i32 3)
#INSTIDX : 2||0002: imul
dassign %Reg0_I 0 (mul i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
#INSTIDX : 3||0003: iload_2
#INSTIDX : 4||0004: iadd
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg4_I))
#INSTIDX : 5||0005: iconst_1
dassign %Reg1_I 0 (constval i32 1)
#INSTIDX : 6||0006: iadd
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg1_I))
#INSTIDX : 7||0007: ireturn
return (dread i32 %Reg0_I)
}
这里我给你稍加解释一下示例代码中的IR。
以func关键字开头定义一个函数。函数名称里体现了原来Java类的名称和方法名称。public和virtual关键字也是继承自原来的Java方法在Java中这类public的方法都是virtual的需要动态绑定。
接下来要注意的是用var声明以%开头的三个伪寄存器。伪寄存器相当于本地变量,它的数量是无限的,在后端做寄存器分配的时候才对应成物理寄存器。
在这后面的是6个dassign语句。这是6个赋值语句其中的d是直接寻址的意思。有的dassgin操作符后面跟着的是常数constval有的跟着的是加法add或乘法mul表达式。而加法和乘法表达式里面又可能进一步用到其他的表达式。这里就体现出了Maple IR的特点即它是树状结构的。
那么总结起来示例函数体现了Maple IR最基本的结构特点程序被分成一个个的函数。函数里呢是顺序的一条条语句而每条语句都是一个树状结构树的节点可以是叶子节点、表达式或者其他的语句。如果把函数内的每条语句作为函数的子节点那么整个函数就是一个树状的数据结构。
另外,在示例程序中,还有一些以#开头的注释。这些注释代表了原来class文件中的字节码。目前方舟编译器里有一个字节码的前端能够把字节码翻译成Maple IR。这个注释就体现了字节码和Maple IR的对应关系。
不过上面的示例函数并没有体现出流程控制类的语句。我们再来看一下atLeastTen()方法对应的IR。atLeastTen()方法中有一个if语句它能否被翻译成Maple IR的if语句呢
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32 {
funcid 48154
var %Reg2_R43694 <* <$LFoo_3B>>
var %Reg0_I i32
dassign %Reg2_R43694 0 (dread ref %_this)
#INSTIDX : 0||0000: iload_1
#INSTIDX : 1||0001: bipush
dassign %Reg0_I 0 (constval i32 10)
#INSTIDX : 3||0003: if_icmpge
brtrue @label0 (ge i32 i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
#INSTIDX : 6||0006: bipush
dassign %Reg0_I 0 (constval i32 10)
#INSTIDX : 8||0008: ireturn
return (dread i32 %Reg0_I)
@label0 #INSTIDX : 9||0009: iload_1
#INSTIDX : 10||000a: ireturn
return (dread i32 %Reg3_I)
}
在Maple IR中提供了if语句其语法跟C语言或Java语言的语法差不多
if (<cond-expr>) {
<then-part> }
else {
<else-part>}
像if这样的控制流语句还有doloop、dowhile和while它们都被叫做层次化的控制流语句。
不过在阅读了atLeastTen()对应的IR以后你可能要失望了。因为这里面并没有提供if语句而是通过一个brture语句做了跳转。brtrue被叫做平面化的控制流语句它在满足某个条件的时候会跳转到另一个语句去执行。类似的控制流语句还有brfalse、goto、return、switch等。
补充:由于这个.mpl文件是从字节码直接翻译过来的但字节码里已经没有HIR级别的if结构了而是使用了比较低级的if_icmpge指令所以方舟编译器也就把它翻译成了同样等级的brtrue指令。
好了通过这样的示例你就直观地了解了Maple IR的特点。那么问题来了当前的开源项目都基于Maple IR做了哪些处理呀
你可以打开源代码中的src/maple_driver/defs/phases.def文件。这里面定义了一些对Maple IR的处理过程。比如
classhierarchy对类的层次结构进行分析
vtableanalysis为实现动态绑定而做的分析
reflectionanalysis对使用Reflection的代码做分析以便把它们静态化。
ssa把IR变成SSA格式
……
总的来说当前对Maple IR的这些处理有相当一部分是针对Java语言的特点来做一些分析和处理以便把Java完全编译成机器码。更多的分析和优化算法还没有开源我们继续期待吧。
课程小结
这一讲我主要跟你探讨了方舟编译器的定位、设计思路以及方舟编译器中最重要的数据结构Maple IR。
对于方舟编译器的定位和设计思路我认为它体现了一种大无畏的创新精神。与之相比脱离不了JVM模子的Android运行时倒有点裹足不前使得Android在使用体验上多年来一直有点落后。
但在大胆创新的背后也必须要有相应的实力支撑才行。据我得到的资料华为的方舟编译器依托的是早年在美国设立的实验室所积累下来的团队这个团队从2009年开始就依托编译技术做了很多研发为内部的芯片设计也提供了一种语言。最重要的是在这个过程中华为积累了几百人来做编译和虚拟机的团队。前面提到的首席科学家周志德先生就是全球著名的编译技术专家曾参与了Open64项目的研发。这些优秀的专家和人才是华为和国内其他团队未来可以在编译技术上有所作为的基础。那么我也非常希望学习本课程的部分同学以后也能参与其中呢。
对于Maple IR中分层设计的思想我们在Graal、V8等编译器中都见到过。Maple IR的一个很大的优点就是对HIR有更好地支持从而尽量不丢失源代码中的信息更好地用于分析和优化。
对于方舟编译器根据已开源的资料和代码我们目前只做了一些初步的了解。不过只分享这么多的话我觉得还不够满意你也会觉得很不过瘾。并且你可能还心存了很多疑问。比如说Graal和V8都选择了图的数据结构而Mapple IR选择了树。那么在运行分析和优化算法上会有什么不同呢我希望后续随着方舟编译器有更多部分的开源我会继续跟你分享
这节课的思维导图我也放在了这里,供你参考:
一课一思
你认为用一种IR来表示所有类型的语言的话都会有哪些挑战你能否通过对Maple IR的阅读找到Maple IR是如何应对这种挑战的欢迎在留言区分享你的观点。
如果你身边也有对华为的方舟编译器十分感兴趣的朋友,非常欢迎把这节课的内容分享给他,我们一起交流探讨。感谢你的阅读,我们期末答疑再见!

View File

@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
不定期加餐1 远程办公,需要你我具备什么样的素质?
你好,我是宫文学。到这里,咱们课程的第一模块“预备知识篇”就已经更新完了。通过这么多讲的学习,这些编译技术的核心基础知识,你掌握得怎么样了呢?是不是感觉自己已经构建了一个编译原理的知识框架了?
不过我也知道,要理解编译技术的这些核心概念和算法,可能不是一件很容易的事儿,在跟随我一起探索编译之旅的过程中,估计也耗费了你不少的脑细胞,那咱们是时候来轻松一下了。
今天,我就想借着这个加餐的环节,跟你聊一聊一个很有意思的话题:远程办公。
之所以选择这个话题,主要有两方面的原因。
首先,由于疫情的影响,春节之后,很多公司都采取了远程办公的方式。所以,如何在远程办公的情况下做好工作,对于员工和公司来说,都是一个挑战。
第二个原因,是我个人一直对于远程办公这种工作模式很感兴趣,这些年来也一直在做这方面的思考,关注这方面的实践,所以有了一些心得体会,想跟你分享一下。
不过,要想把远程办公这个话题聊清楚,确实不容易,分歧也比较大。有一些朋友会比较悲观,觉得远程办公根本不切实际;而另一些朋友则会很乐观,觉得远程办公马上就会普及。
今天,我就来分享一下我看待远程办公的一些视角。我会从公司和员工这两个角度,来分析远程办公带来的机遇和挑战,希望能给你带来一些启发,让你以更积极和务实的姿态,迎接远程办公的浪潮,甚至在这种工作模式转换的趋势下,抓住更多的发展机遇。
首先,我来聊一聊远程办公的那些吸引人的地方。
远程办公的好处
我对远程办公的了解最早是透过开源圈的朋友了解了一些故事后来自己也接触了一些酷酷的公司。很多做开源软件产品和技术服务的公司都是远程办公的他们的员工可能来自世界各地。我曾经接触过一个芬兰公司的CEO他们主要做嵌入式Linux的技术服务。一百多人的公司平常办公室是没什么人的。据说他们公司有的员工可以一边上班一边去全世界旅游。
我当时认为一百多人的公司全部都能远程办公并且管理良好就已经很不错了。但后来看了一篇文章讲到WordPress的母公司Automattic有上千名员工分布在全球75个国家全部都是远程办公。这就有点令人吃惊了我才意识到在互联网时代原来远程办公可以适用于任何规模的企业。
这次疫情中IT领域的很多公司都大量地采用了远程办公模式包括谷歌、Facebook、微软等大型企业。
现在新闻上说,疫情之后,世界再也回不到过去了。其实我觉得,在很多领域,我倒是宁愿它回不去了。比如,远程教育;再比如,远程工作。
因为远程,意味着你获得了一个难得的自由:位置自由。
现代社会,我们苦“位置”久已!因为很多资源都是跟位置绑定在一起的,比如说,教育资源与学区房。
我在北京的很多朋友,他们在孩子上学期间,一直都是租房子住的,因为要住得离孩子学校近,而自己的房子会租出去。这种状态要持续到孩子上大学为止。
而对于若干都市白领来说,在大城市上班,就意味着要把整个肉体在办公室和家之间移动,所以每天可能要在路上花两三个小时,很多时候还会在路上堵个半天。
如果我们真的获得了位置自由,那么整个生活的幸福指数会提高一大截吧!
对于远程教育来说,我比较希望见到的现象,是在偏远的乡村,一样能够通过线上教育获得最优质的知识资源。至于线下的老师,更多的是关注孩子的健康成长,多带着孩子亲近大自然,扮演“育”的角色,而不是“教”的角色。
工作也是一样。现在越来越多的工作,都可以在网上进行了。互联网电商的发展,虽然让一些线下店铺的营业状况受到了影响,但只要能通网络,很多人在网上就可以卖东西了呀。另外,随着外卖的兴起,很多餐饮企业也不再需要临街的店面了。
所以,通过远程办公,我们可能就不需要北漂、深漂等各种漂了,可以选择离自己的亲人更近一些,或者可以反过来,四海为家。并且,你还可能获得更多、更好的工作机会,你可以从全世界的公司里选择你喜欢的那份工作,并且也不需要离开你喜欢居住的地方。
并且,伴随着位置自由,往往也会给我们带来时间自由。因为远程后不再需要按时上班打卡了(很多在全球都招揽人才的公司,大家的作息时间都不一样,当然不可能统一打卡),所以管理体系会更加面向绩效,而不会管你到底是在什么时间来完成这些工作的(通常也没法管理)。这就意味着,你可以在家人需要你的时候,出现在他们身边(比如接孩子),然后选择自己合适的时间段来工作。
上面说的是远程办公对员工的好处。从企业的角度来看,远程办公其实也会带来一些潜在的好处。
首先,有些员工可能会在工作上做更多的投入(这跟某些员工会偷懒恰恰相反,所以可能出现两极分化)。这些人很享受自己的工作,每天上下班反倒降低了他可能做出的贡献。如果公司有一套良好的管理体系,那就可能会因此筛选出更适合自己的员工,而避开那些混日子、划水的员工,整个团队的素质反倒会得到提高。
我曾经跟MySQL的前CEO Mårten Mickos聊天。我问他管理远程办公的员工需要注意些什么
他思考了一下,说要建议员工跟家人一起住,至少要养条狗什么的。因为家人能帮助管理这些极客们的作息。不然由着这些极客们的性子,他们会昏天黑地、毫无规律地作息,不利于健康。就算养条狗,你也会因为要照料它们,而让自己的生活节奏健康一点。
他的回答其实出乎我的意料,我原本以为他会说什么公司的管理措施之类的。你体会一下,如果你是公司老板,你是不是会因为拥有这样热爱工作的员工而感到欣慰呢?
第二,因为没有了地域限制,公司也就可以充分任用全球各地的人才。这个方面在很多做开源软件的公司那里,得到了很好的体现。如果你喜欢某个开源产品,在社区里贡献自己的力量,那你很可能就会被邀请加入到该公司。
在互联网时代企业的组织方式也正在重构。滴滴打车、美团外卖这些采用新雇佣方式的公司不但可以更好地利用各地的人力资源TA们也提供了一些自由工作的机会。
第三,没有了地域的限制,公司也可能更容易拓展自己的市场。这个好处也是显而易见的,就不用我多说了。
远程办公的挑战
上面是我对远程的一些美好的憧憬。还是回到现实吧,因为更改现有的教育体制,可能是很难的。而让企业老板们改变公司的管理方式,难度也不低。
老板们都是理性的。真金白银投入做企业,是要见到效益的。可是,如何能保证采用远程办公模式,不会让企业变成一团散沙,纪律涣散、效率低下呢?
你可以问问,在春节后不得已实行远程办公的企业,对经营有没有产生影响。
说实话,在没做好充分的准备之前,仓促地采用远程办公,肯定会产生各种不适。
因为远程办公,对于管理体系,有着更高的要求。很多工作是难以直接度量绩效的,比如说研发工作就比销售工作更难衡量绩效。
而没有良好的管理体系仅凭员工的自觉是不可能产生良好的效果的。其实硅谷有一些IT公司很早就实行过远程办公但后来又取消了原因就是绩效不理想混日子的员工太多。
反过来,站在员工的角度,你真能做好自己的工作管理吗?在办公室工作的时候,迫于同事们的目光,你总得做点事情吧。可是,如果脱离了直接的监督,有多少人能够真正管好自己呢?
你说你的自我管理能力强那么请问有多少人能控制住自己每天刷手机的时间呢据说超过50%的成年人,都有手机上瘾症。在办公室的时候,尚且见缝插针地刷手机。如果在家办公,又会怎样呢?
有过远程工作经历的人都会经历这么一个时期。即使是你很有责任心、很有事业心但也要每天花费很多的精力来管理自己的行为。我认识的一个朋友她在IT行业主要做售前支持工作。之前跟她闲聊的时候她说自己花了3年的时间跟自己搏斗才养成了良好的居家工作习惯。而管理自己这件事情也是消耗注意力的。注意力本身又是个稀缺资源。所以在初期你会觉得对比在办公室里居家办公会更累在公司你不需要花精力来控制自己的行为因为环境和同事帮忙做了这件事情实际上节省了你的注意力。
我也听说,有的工程师会在网上直播自己编码的过程。这样做的一个原因,就是为了帮助管理自己的行为,因为这时候你必须更加集中注意力在自己的工作上。
还有一个是办公环境的因素。我们中国人的居住状态比较拥挤,在自己家里开辟出一个安静的、不被打搅的书房并不容易,这可能还跟中国的文化有关。而西方的文化,可能会更尊重个人的空间。
再说了,我们跟外国人的居住条件也确实不同。西方发达国家很早就开始了郊区化发展,大部分人会住在郊区和小城镇,自然环境比较好。而我们中国呢,大部分住在小区的楼房里。
不过,如果真的远程工作了,你也可以不住在大城市呀。网上有些视频经常会吸引我,某夫妇在乡村翻新出一栋漂亮的别墅,还拥有美丽的花园,等等。其实我目前就住在一个自然环境良好的山上,只不过从这个村子去办公室也很方便就是了。
远程办公还会产生心理上的挑战:白天晚上都在家里,会容易心理疲劳。而换个环境,反倒会让人兴奋起来。我就有个感觉,在家里工作久了,效率就会降低。而这时候再回公司工作的话,反倒更容易集中注意力。
而且,远程办公肯定也会降低沟通的效率。一些互联网公司,在设计办公室的时候,会故意设计一些空间,方便大家偶遇,闲聊几句。而做研发工作的同学都知道,这种看似随意的交流,有时候能激发出很多创新的思维。而如果总是自己苦思冥想,往往很快就会走入死胡同。这种线下偶遇式的沟通,往往见到了就会聊个几句。但在远程办公时,如果大家互相见不到面,还真就不聊了。
面对远程办公,我们要做好什么准备?
所以,我们需要实行一些积极的操作,来更好地应对远程办公给我们带来的挑战,这样也能更好地抓住远程办公给我们带来的机遇。
从公司的角度出发
那首先,我们来看看,对于企业来说,都需要做好什么准备。
第一,我觉得企业管理者要建立一个意识:远程办公是企业必须要面对的管理考验。
其实只要企业做大了,几乎都要面对远程管理的问题。比如你有了分公司,或者在各个城市设门店,甚至把生意做到国外。那么,突破地域的限制拓展业务,本来就是对企业能力的考验,是企业发展中必须踏过的门槛。
所以说,企业也一样需要获得位置自由。这些分公司、派出机构工作的人员,对于总部来说,本来就是远程工作的。有了这个意识,管理者就会开始放弃旧的思维,拥抱远程办公。
第二,从看住人,转换到管绩效。
很多比较传统的企业,他们的绩效标准都比较模糊,所以在远程办公的形势下,我们需要把绩效标准的清晰化、准确化放到第一位。像滴滴、外卖这些新职业,之所以能够迅速扩展规模,充分利用社会化人力资源,就是因为他们的工作绩效的标准是清晰而准确的。
第三,建立拥抱远程办公的文化,给员工授权和赋能。
像软件研发类的工作,它是知识密集型的,对员工的绩效评估比较难,人员更换的成本也相对较高。那么对于这类工作,我们可以多向那些开源软件公司学习,建立一个拥抱远程办公的公司文化,去吸引那些对工作充满兴趣和热爱的人参与进来。这些人,也会更加珍视公司给予的授权和自由。
第四充分利用IT技术。
管理一定要落实在工具上。我接触的那家芬兰公司就花了很多年的时间积累了一套成熟的内部管理系统。比如说作为软件公司你肯定要对项目进度、代码量、Bug数等基础指标有所管理才行吧
信息技术成本的降低也大大降低了远程管理的开销。这次疫情促进了视频会议在全世界的普及。对于中国的中小企业来说甚至可以0成本享受高品质的远程会议服务这真是一个了不起的福利
从员工的角度出发
OK说完了公司那我们再来看看从员工的角度出发我们都要具备什么素质才能更好地迎接远程办公模式。
第一,员工也要建立一个意识:无论是否远程办公,都要向绩效负责,管理好自己的工作。
即使你仍然在传统的办公模式下工作,如果你能像一个远程工作者那样对绩效负责,管理好自己的注意力,我想你很快就会获得领导的注意,从而赋予你更大的工作自由。你有没有听说过,张小龙经常睡懒觉迟到,而马化腾从来不管他?因为马化腾需要的是一个能做出微信来的张小龙,而不是一个每天按时打卡的张小龙。
第二,正视远程办公对自我管理的高要求,养成良好的工作习惯。
在办公室工作,会有环境来约束你。而当真的给了你位置自由以后,你其实要珍视这种自由,给自己定一些规矩,甚至给自己找一些监督(就像前面说的在网上直播),从而养成良好的工作习惯。
第三,建立激进的协作习惯。
由于远程工作对于协作的挑战,你必须建立激进的协作习惯,而不是仅仅停留在我们目前使用即时通讯工具和视频会议工具的习惯上。比如,你可以全时间视频在线、主动找人线上闲聊一小会儿、主动创造一些与人沟通的机会,等等。
第四,可能是最重要的:为兴趣而工作,为自己而工作。
人在没有很多督促的情况下,真正能驱动自己前行的动力,就是兴趣了。这个时候,你会把工作看作是促进自己成长的必要因素,从工作中成长,从成长中获得快乐。这个时候,你已经不是在为公司工作,而是为自己而工作。这样的人,才算获得了真正的自由。
小结
今天,我们讨论了远程办公对公司和员工的好处、挑战,以及我们需要做好的准备。我讲了两个主要的观点。第一个观点是对企业的:远程办公管理能力是企业未来必须具备的能力。第二个观点是对个人的:只有能够管理好自己的人,才能抓住远程办公带来的机遇。
那总体来说呢,信息技术的进步是不可阻挡的,它对于工作和生活方式的重塑,也绝不会停止。所以,远程办公一定会越来越成为现实,不管是对于企业还是员工,我们都要积极拥抱这样的未来。
讨论一下
你有想过,有一天会通过远程办公的方式上班吗?远程办公有哪些地方会吸引你?你有通过远程办公的方式上班的朋友吗?你觉得,为什么远程办公现在还没有真正普及开呢?你可以在留言区里,跟大家交流交流你的看法。
感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。

View File

@ -0,0 +1,172 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
不定期加餐2 学习技术的过程,其实是训练心理素质的过程
你好,我是宫文学。
最近,高考刚刚结束。每年一度的高考都牵动了很多人的心,学生和家长们都把高考看作是人生的一大关键关口。可是,等上了大学以后呢?很多同学也会感到不适应,因为缺少了一个像高考那样明确的学习目标,也没有老师和家长在旁边不停地鞭策和关注。到了工作以后,就更是如此了。
对于进入计算机领域的人而言呢,很多人迫于找一份好工作的压力,会刻苦学习一段时间,包括参加各种学习班。而一旦获得了一份工作,融入职场以后,很容易就进入舒适区。反正当前的工作也能应付过去,为什么还要费力再去学呢?毕竟,工作已经够辛苦了。
在这种情况下,人生的第二次转折点就出现了。
有的人能够管理好自己充分利用各种时间和机会不断地加深自己对技术的理解。虽然短时间看上去进步并不大但成年累月地积累下来效果就逐渐出现了TA们开始能够胜任一些关键岗位成了技术领头人。而另一些人则只能掌握那些比较容易掌握的技术时间一长就会显得平庸等年轻人赶上来的时候就更加没有竞争优势了。虽然这不是像高考一样能马上分出重点大学和普通大学的差别来但在进入职场5年、10年以后这两类人在发展上的差别并不比高考带来的差别小。
我说这些,不是在贩卖焦虑,而是想引出我们今天要讨论的话题:从心理的角度看待学习技术的过程。特别是自己管理自己学习的过程、跟自己相处的过程。
学习没有轻松的。尤其是学习像编译原理这样的基础技术,就会显得挑战更大。想要学好它,调整和保持一个良好的心态是非常重要的。而通常,我们在心理上会面对三大问题:
第一,我为什么要学习这么难的技术?学一些比较容易的、应用层面的技术不就行了吗?这是学习的目的和动力问题。
第二,以我的能力,能学会这么难的技术吗?这是自信心和勇气的问题。
第三,如何看待学习中经常遇到的挫折?总是找不到时间怎么办?等等。这是学习过程中的心态调节和习惯养成问题。
如果对这三方面的问题,你都获得了清晰的答案,那么你应该就能保持好持续学习、终生学习的心态,从而对自己的人生有更好的掌控力。
那接下来,我就给你解读一下,我对于这三类问题的理解。
首先,我们来说说学习目的问题。
为什么要学这么难的技术?
在做课程设计的时候,我和编辑同学都会尽量想着如何让这样的基础技术和你的日常工作关联起来,让你觉得它不是屠龙之术,而是能够在日常工作中发挥实际效用的。这确实是学习基础技术的收获之一。
不过,如果想长期坚持下去,我会建议你把心态调整成一种更高级的模式。用中国文化中的一句话来形容,就是“用出世的态度,做入世的事情”。如果一件事情你觉得应该去做,那就去做,不要太斤斤计较一些功利层面的东西。
那么对于学计算机而言什么是我们应该去做的呢那当然是要了解计算机领域的那些最基础的原理呀。如果搞了一辈子IT技术却不明白其中的道理那岂不是一辈子活得稀里糊涂的
我知道,大部分人不注重基础性知识的原因,可能是觉得它们不会马上发挥作用。可是,那些最重要的知识、那些构成你知识结构的底蕴的东西,往往就是那些看上去不会马上有用的东西。
我个人非常欣赏复旦大学做教育的一种态度就是教给学生无用之学。哲学、艺术、写作、演讲、逻辑学、历史等知识在西方教育中被称作Liberal Arts我们有时候翻译成通识教育或者博雅教育。这些教育对于我们从事任何专业的工作都是有用的。
比如说,美学素养。一个设计良好的系统架构,一定是优美的。新东方的元老之一王强,在美国学习计算机的时候,会把写完的程序拉开一定的距离看。如果看上去不够美观,那一定是程序设计得不够好。
你乍一听上去,可能会感觉是无稽之谈,但有经验的程序员一定会认同这个看法。那些写得有问题的程序,往往本身就是又臭又长、非常难读;而高质量的程序,往往是模块划分清晰、简洁易读的。做不出好的系统设计的人,肯定美学素养也不够高。像爱因斯坦等大科学家,往往驱动他们做出某个研究成果的动力,就是想去发现一条更加简洁、更具优美感的公式,因为真理往往是简洁的、优美的。
我之前公司的一名股东,他以前是一位很厉害的软件工程师,后来被一个外企挖走,担任了多年的销售副总。挖他去外企的原因,就是因为当时该外企刚开始在中国推广中间件的概念,他听了介绍以后就说,那不就跟我写的某软件的原理是一样的吗?并且一下子就说出了这类软件的关键技术点。于是,该外企下定决心要把他挖过去,并且是去负责销售。去年,他突然又写了一套科幻小说,名称是《云球》。我这里不是为他打广告,我是想说,做一个优秀的软件工程师、担任销售副总和小说家,这三个职业从表面上看相差很大,但其实背后依赖的基础素质都是一样的,都是那些乍一看上去没用的基础知识、基础素质。
所以,从这个角度,我是同意素质教育的理念的。一个缺乏美学素养、哲学素养和沟通能力等素质的软件工程师,潜力可能是有限的。
说到基础素养,我补充一个例子。有一次,我和前面说到的这位朋友在一起聊天,结果一个软件公司的老总给我们吹嘘他们公司开发的某软件平台。在说到一些特性的时候,听得我俩目瞪口呆。后来我们告诉这位老总,他声称的东西违背了基本的物理学和信息学的规律。在违背科学的底层规律的方向上做事情,那就相当于去造永动机,根本是虚妄的。这是很多项目失败的根本原因。
而另一些人,却具备抓住事情本质的能力。众所周知,马云并不懂技术。但就是不懂技术的马云,在懂技术的马化腾、李彦宏都认为云计算不是趋势,只不过是新瓶装旧酒的时候,果断拍板做云计算技术。期间,来自内部的反对声一直很强,大家都不愿意在内部使用尚不成熟的云计算技术。然而时间证明,马云的眼光更准。并且,力主开发云计算技术的王坚博士,他自己的专业也不是计算机专业。那么,为什么一拨非科班人士会比科班的技术大佬们看问题还准呢?我想可能是他们的无用之学学得更好,基础素质更全面吧。
所以这就是我对于像编译原理、操作系统、算法等基础知识的态度。你就把它们看做是无用之学好了。我不仅鼓励你把这些基础知识学明白并且我也希望你可以尽量再往深里挖一挖。比如像图灵那样去思考一下计算的本质到底是什么编译原理用到的形式语言也可以被继续深挖从而跟整个西方科学体系底层的形式逻辑体系挂钩以此去深入地理解希尔伯特猜想和哥德尔定理了解面向对象、函数式编程这样的编程范式跟人类的认知和思维模式的关系跟Lamda计算、范畴论等数学工具的关系你还可以去了解复杂科学领域的成果并用这样的思维去看待大型复杂的信息系统。
如果你觉得编译原理这样的技术没啥用,那你一定会觉得我刚才说的那些更加没用。但我知道,一个优秀的软件工程师,其实一定是对我说的那些话题有所涉猎、有兴趣的。
总结起来,一个人的基础素质,决定了他的思维方式、思维质量和眼光,那些看上去没用的基础知识、基础原理,其实是真正做大事、承担重任所需要的素质。那,你到底要不要去学习呢?
好,如果你认可我的观点,那么我们接下来再探讨第二个话题,关于学习的信心问题。
我能学得会吗?
很多人都会有一个担心,说某些基础技术、基础原理太难,自己的基础又不够好,那么能学得会吗?如果学了半天又学不会,那不是白费力气吗?
从能力角度,我必须承认,我们每个人都是有天赋的差异的。你让一个普通人去跟姚明比赛打篮球,那不是难为人吗?
学习这件事情也一样有天赋的问题。
我本人当年在高考的时候,是省里的前几名,但是等我到了北大,看到周围的同学通常也都是身手不凡;在记忆力方面,我也比不过很多同学,有的同学对普通的词汇书根本不感兴趣,会去背词典,甚至背专业领域的词典;在数学等需要逻辑思维的领域,我又比不过另一些同学,直到今天,对于一些涉及数学的问题,我都会去咨询这些同学的意见。
但从另一个角度讲,一些基础知识、基础原理,一定要有很强的天赋才能学会吗?
不是的。在人类知识的殿堂中,你要想增加一点新的理论、新的原理,那是非常难的。所以我们必须对那些大科学家们,那些计算机领域的先驱们顶礼膜拜。那些顶尖的工作,确实需要天赋,再加上努力和机缘。
不过,即使狭义相对论和广义相对论发明起来那么困难,但一般的理工科学生只要想学,都是可以弄明白的。这就证明了,发现知识和学习知识所需要的能力,是极大的不对称的。在高考季,经常会出现妈妈级、奶奶级的考生,从陪考到变成跟儿孙辈一起上大学的故事。人家奶奶都能考上大学,我们年轻大学生学不会本专业的一些基础原理,这个道理说得通吗?
同理你常常会听到的一个理由也是不成立的这个理由就是我不是科班出身。这个我就不认真去反驳了。你想想看吧费马的本职是律师而他“业余”是个大数学家数学家罗素却获得过诺贝尔文学奖比尔·盖茨进的是哈佛大学商学院我前面说的王坚博士是学心理的罗永浩的专业也肯定跟IT没关系刘慈欣是业余写小说的。
所以,那些所谓的困难,只是你给自己设的玻璃天花板。这不是个能力问题,而是个心理问题。儒家提倡“智、仁、勇”三种最高的道德标准,勇气是其中之一,它也是我们应该训练的一种品质呀。
好,如果你又一次认同了我的观点,那么我们再来讨论第三个问题,如何克服学习过程中的困难。
如何持之以恒?
在我看来,如果理顺了前两个问题,也就是为什么要学,以及信心和勇气的问题,那么你最大的心魔其实就破除了。
但毕竟,学习贵在持之以恒的坚持。在这个过程中,我们可能会遇到很多的困难。但对于这些困难,我们也要用正确的心法来对待。所以,接下来我就针对如何面对学习中的困难、如何保证学习时间、如何找到学习的乐趣等常见问题,谈谈我的看法。
困难是必须的
首先你得明白有价值的东西一定是要克服困难才能得到的这是公平的。所以不要指望学知识而不需要付出努力再好的教程和老师也只是起到辅助作用。这里你得注意一个问题就是不要被某些书籍和课程收了智商税比如说“7天学会XXX”“学英语其实不用背单词”等等。这种标题就是违背学习的基本规律的。
所以,当你知道了苦难不可避免这个道理,那你剩下的就只有面对这些苦难。在学习中,你可能经常会被一个难点阻碍住,这很正常。你正确的心态应该是这样的:
没有我拿不下的山头,正面拿不下从侧面,侧面不行走背面。多换几个角度,多几次尝试,多看点参考资料,总会成功;
那么多人都能学会,我没有道理学不会,一定有更好的方法;
这个问题既然有难度,那价值一定也大,所以一定不要放弃。
有了这样的心态,其实再苦再难的事儿都好说了。
在旅途中发现乐趣
我一个朋友最近正在从新疆骑行到西藏全程3000公里中间需要穿越无人区。这是他第三次做这样的骑行之前已经骑过川藏线、青藏线。虽然过程很艰苦但沿途美丽的风景和跟自己相处的过程就是这个旅途给他的回报。
我自己也喜欢户外。我家人有时不理解我,问我为什么要开着一辆大房车去那么远,累不累呀。我说,这就是旅行的意义呀。如果直接飞机过去,那有什么意思。
我用这两个例子作类比,是想告诉你:当我们学习那些有难度的知识的时候,其实肯定能发现出其中的乐趣来。比如,在学编译原理的时候,你去动手实现几个小例子,哪怕还不到实用的程度,但是好玩呀!当你找到了其中的乐趣,那么别人看你是在艰苦地学习,但其实你是乐在其中呢。就好像,别人看着一个人是在顶风冒雪一个人骑行,但他也是乐在其中呢!
另外呢,在互联网时代,各种不需要动脑的娱乐方式层出不穷。普通的人会在这种廉价的快乐中流连忘返。而如果你的目标是持续进步,那要培养自己另一种习惯,就是习惯于获得那些艰难的乐趣,这种乐趣是真正的充实的乐趣。
跟自己相处
我前面举的朋友骑行的例子,他是自己一个人。我也喜欢自己开车出去,因为没有了其他人,也就避免了因为人际关系而导致的分神,你只需要关注大自然和你自己。你能感受到自己跟自己对话的过程,自己跟大自然对话的过程。
学习在大多数情况下也是一个人前行的过程,学到的知识也只属于你一个人。在这个时候,就只剩下了你要去攻克的知识,和你自己。你能感受到自己跟自己对话的过程,自己跟知识对话的过程。当遇到困难了,你能发现自己的苦闷和焦虑;当解决问题了,你能感受到自己的欣喜。
真正有价值的成绩,都是在这样的跟自己独处、跟自己对话的过程中做出来的。这是一种值得追求的素质。
跟志同道合者相伴
独行难,众行易。除了那些内心特别强大的、从来都不屑于与普通人同行的天才,我们大部分普通人还是愿意有一些同伴一起结伴而行的,这样会大大降低驱动自己所需的努力。
我在读研时曾报过GRE的培训班。我感觉报班的最大作用其实不是跟着老师学到多少知识而是培训班乌泱乌泱的一大堆的同学给我提供了一种气场让我每天不想别的赶紧学习就是了。
这样的群体还会有效改变自己的学习标准。在学GRE之前我觉得一天背几十个单词已经挺辛苦的了。但到了GRE班我很快就接受了每天背200个的新标准因为其他人也是采用这个标准的。关键是就算每天背200个我也没觉得有多困难。所以你看人的潜力有多大的弹性而一个好的群体就是能无形中给人提供这种心理上的能量。
而且那时的同学都会有这种体会,就是每天如果不背单词就不舒服,上瘾。那段时间,随便看到一个单词,脑子里就会出现几个近义词和反义词,这种感觉很奇妙。再次印证了我前面说到的那种奋斗中的乐趣。
在软件领域,有很多技术社区,这些社区也能起到对人的心理加持作用,你可以善加利用。
最后,如果有要好的朋友和导师,能够鞭策你,那也非常难得。有管理经验的人都知道,虽然我们希望每个员工都有自我驱动的能力,但合适的外部驱动能降低员工驱动自己所需要消耗的努力。毕竟,我们大部分人其实是愿意工作在“低功耗模式”,能节省能量就节省能量。
使用运营思维
在互联网时代各种App在功能设计和运营上充满了心理学的套路以便培养用户的习惯。游戏公司更是会雇佣心理学专家来设计各种套路。
那么,与其让别人套路你,不如自己套路自己,同样利用心理学的知识来培养自己的学习习惯,把自己的时间、自己的命运把握在自己手里,不是更好吗?
心理学的基础原理并不难你自己就能从各种App的使用套路里体会到一些。比如说对取得的成绩即时给予奖励。从心理学的角度、从各种App背后的运营者的角度来看我们每个人其实就是巴甫洛夫实验室里的动物而已。通过这样的自我训练你可以达到一些很好的效果
建立良好的学习流程,有明确的开始和结束时间;确认一下每天的学习目标和学习成果,或者可以建立学习过程的仪式感;给自己一个良好的环境。
没有学习的时间?那是不可能的。这是因为你没有给学习安排出专门的时间来。
以输出带动输入。很多同学有写技术博客的习惯,这个习惯非常好。因为你要写出东西来,所以会逼迫自己把思路理清楚。
激进一点的:直播自己的学习过程,给自己提供外部监督和激励机制。
小结
今天这一讲,我聊了聊对于学习比较难的、比较基础的知识的心法的理解。总结起来,主要有三点:
第一,那些基础知识的素养,决定了一个人的发展潜力,这是你要学习它们的原因;
第二,没有学不懂的知识,真正的障碍是心理上的自我设限;
第三,学习的过程,就是砥砺前行的过程,经常能自省和调整自己的状态,就能养成自己的学习能力。
那么,你对于学习,有没有什么好的心法?欢迎在留言区跟大家交流!
感谢阅读。如果你也觉得很有收获,非常欢迎你把今天的内容分享出去,跟你身边的朋友一起做会学习的人。
我们接下来就要进入到期中复习周了,到时候你就可以来看看,在前半段的课程中,你都学习得怎么样!

View File

@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
不定期加餐3 这几年,打动我的两本好书
你好,我是宫文学。
在互联网时代,读书好像变成了一件挺奢侈的事情。因为我们现在获取信息的主要渠道,已经变成了网络。不过,在互联网统治力如此强劲的今天,我偶尔仍能发现一些好书,让我可以放下电脑和手机,对着厚厚的一大本,从头看到尾,甚至还会看很多遍。可见这些书确实是真正打动了我,让我这个理科背景的人,能以新的视角来看待世界,理解这个世界背后的运行规律。
我觉得一本书籍能达到这个阅读境界就很值得推荐了,因为这相当于是在帮助我们树立世界观、沉淀方法论。所以今天的加餐环节,我想给你分享两本打动我的好书,或者说是以其为代表的两类好书,跟你聊聊我读这些书的感受和收获,希望也能给你一些启迪。
那第一本书呢,就是《失控》。
《失控》
失控这本书的作者是《连线》杂志的主编凯文 · 凯利于1994年出版。这本书被很多人推崇据说张小龙就曾说过谁能看懂《失控》这本书谁就可以到他那里工作。
这本书的神奇之处在于它虽然成书于上个世纪90年代初但准确预测了后来互联网时代的一系列的创新更厉害的是它揭示了互联网时代背后蕴藏的道理。就如这本书的副标题所说的它揭示了“全人类的最终命运和结局”。
我自己呢,是在读过这本书后,对其中的内容感觉极为惊讶。我甚至怀疑,凯文 · 凯利到底是何方神圣,为何他能够写出这样的惊世之作。
我就拿《失控》中第二章的内容,跟你一起分享一下,做一次管中窥豹。
第二章的标题是“蜂群思维”。蜜蜂是一种社会性昆虫它们总是一大群一起生活。在研究蜂群的时候你会发现一群蜜蜂相当于构成了一个单独的生命体这个生命体比单只的蜜蜂更加高级。举个例子单只蜜蜂只有6天的记忆力而一个蜂群却拥有三个月的记忆时间这是个体记忆与群体记忆的区别之处。另外这个生命体会比单只蜜蜂拥有更长的寿命且具有繁殖能力能分化出新的蜂群。
这样看起来它似乎符合一个生命体的所有特征。而这种把很多个体连接起来构成一个更高级的存在的现象就叫做涌现Emergence
另一个能很好地解释涌现的例子,就是人类的大脑。大脑中的神经元,实际上就是一个很简单的个体,它们只知道在接收到信号的时候,对其他神经元发送信号。而基于很多亿的神经元所涌现出来的大脑,却具备记忆、推理、情感等很高级的能力。试想,如果你是一个神经元,你其实是根本无法理解,以亿万个你构成的一个超级生命体,竟然会拥有情感、逻辑推理这种东西。因为在一个神经元的世界里,它只有接收信号和发送信号这两件事情。
你再往下思考,就会发现人类社会正是由亿万个你我构成的,那人类社会是不是一个超级生命体呢?这个生命体在思考什么,我们作为一个神经元级别的存在,如何能理解呢?或者说,思考仅仅是我们这个级别的个体所能理解的事情。而这个超级生命体所做的事情,可能已经根本不是人类的思考这种层面的事情了呢?早期人类的宗教,以及宗教中的神,也是高于单个的人类个体的。那么,它们是不是这个超级生命体在人类历史中早期的一种呈现方式呢?
我们再来假设一下,当前的互联网时代,连接网络的计算机、各种智能手机、智能设备越来越多,甚至已经开始接近大脑神经元的数量了。那么,它们会不会涌现出一个超级生命体?这个生命体是否会具备自己难以撼动的意志,而我们必须屈服于这种意志呢?
怎么样?这本书里的观点,是否也能同样给你带来启发,开一个大大的脑洞?是不是也引起了你去一读的兴趣呢?
这个级别的内容,在《失控》里还有很多。再举一个例子:活系统的特征是“摇摇晃晃的平衡”,而处于稳定的系统就进入了死寂。从这个角度看,如果我们的生活中问题不断,其实正是活系统的特征,因为我们要谋求持续的不均衡,这样我们才有机会去改变它,这总好过稳定的、死寂的生活。你看,这样的结论都已经带有了哲学的特征,让我们在面对生活中的挫折时,会采取更加积极的心态。
于是,还是回到我前头提到的那个疑惑:为什么凯文 · 凯利会有这么深刻的洞察力,远远超越我们这些普通人呢?
经过研究,我发现原来书中的很多观点,其实是对从上个世纪中叶以来,各学科的科研成果的总结,然后通过一个资深科技编辑的叙述普及给大众。看到这里,我才放心了:原来并不是出了一个多么逆天的天才,而是我自己对科技发展的新成果,以及其中蕴含的新思想缺少了解。这些思想或理论呢,包括了很多同学都知道的系统论、控制论和信息论三大论,以及后来的协同学、博弈论、突变论、混沌理论、分型理论、耗散结构和复杂性理论,等等。它们在过去的几十年间不断地发展,并正在形成一个宏大的、崭新的世界观和方法论体系。现在的一个新兴学科——复杂科学,似乎就是这些元素的集大成者。
我以前对复杂科学了解得不多,但我觉得其实也不能怪我。因为我们在中学、大学学的那些知识,大部分都是用来描述简单系统的。比如在大多数情况下,天体的运行就是一个简单系统,我们用相对论这样的理论就能准确地预测天体的行为。
而复杂系统,其构成部分之间的相互作用关系比较复杂,难以预测。我还是拿天体来说,三颗星体的相互作用,就变得难以预测了,这就是著名的三体现象,也是刘慈欣小说名称的来源。蝴蝶效应、混沌系统,等等,说的也是这个现象。
可以说,复杂系统破除了对还原论的迷信。也就是,即使你把一个系统分割成一个个的“零件”,并且完全理解了每个“零件”,你也可能无法理解整体系统。因为整体不等于部分的简单相加,比如说,就算你理解了一个社会的经济体中的每个企业和消费者的行为,你也无法准确掌控宏观经济。
可是,了解这些,对你我有什么意义呢?
我先讲一个小的用途。作为软件架构师,你其实应该知道,当一个软件系统复杂到一定程度的时候,你要把它看成一个动态演化的有机体。你对系统做的任何改动,都可能会引起一些你完全预料不到的结果。这就是为什么,你可以花一点儿钱甚至是免费就能搭建一套简单的电商系统,但是像淘宝这样的大型系统,则需要几千人来建设和维护它。
再举个例子。我们现在都非常熟悉的微服务架构它的理念是一个大型软件系统是从一个个分布式的、自治的单元中涌现出来的。流媒体巨头NetFlix他们也是微服务架构的首批推动者之一。在NetFlix软件工程师们会设计一些叫做Monkey的程序随机地杀死一些服务看看系统能否正常地自动修复。发现了吗像微服务这样的复杂系统它的冗余和自愈的能力已经像是一个生命体了即使出现了一些突发的故障比如某些服务的宕机它也不会一下子全部瘫痪。
除了软件领域,与人类社会密切相关的系统,包括天气系统、生态系统、经济系统、社会系统,甚至包括人体本身,它们也都是复杂系统,所以现在的很多学科都在采用复杂系统的思维做研究。比如,采用演化的思维做心理学的研究,就形成了进化心理学的分支(其实更恰当的翻译方法是演化心理学,因为演化是没有方向性的)。这个学科的基本逻辑,就是现在人类具有的某种心理特质(比如为什么恋爱中男人更主动,女人更矜持),都是在进化中形成的。因为凡是没有这种心理特质的人类,都已经在进化过程中被淘汰了。
再进一步,其实你根据上面的信息可以得出一个结论:原来文科生研究的对象都是复杂系统。你一旦意识到这一点,你就可以通过复杂系统的研究成果,去研究原来属于文科生的研究范畴,比如说社会学、经济学、文学和哲学,从而拥有方法论上的优势。
给你简单举个例子,经济学中的宏观经济学部分,就是针对复杂系统的。这也是为什么大家总是说经济学家都是事后诸葛亮的原因:复杂系统是很难被简单地驾驭的。
甚至,你也可以用复杂科学的视角来重新审视哲学,特别是一些古代的哲学思维。因为基本上这些古老的哲学思想都是复杂系统的描述,是让你能够更好地适应自然系统和人类社会这两个复杂系统的一些解。比如说,儒家的思想,是理顺人际间的互动关系,从而缔造一个稳定的社会系统;而道家的思想,则是描述了包含人类社会和自然界的一个更大的系统规律。
有意思的是,凯文 · 凯利在《失控》的最后一讲,总结了复杂系统的特征,有很多地方跟道家的思想非常契合。比如说,“世界是从无中创造出来的”“从无数神经元的连接中,涌现出了大脑;而分子层面的连接,则涌现出了细胞”。
可以说,从《失控》这本书开始,就引起了我对复杂科学的兴趣,这个主题下的其他书籍,比如《复杂》,也非常值得你一读。
好,接下来,我再给你分享另一类好书,是关于文化的。而且它跟复杂科学这个主题,也是存在联系的。
文化与地方志
我从大学起,就对“文化”这个主题非常感兴趣,跟东西方文化有关的东西我都乱看了一气。大学时我读过一本书,是房龙的《人类的故事》,非常喜欢,因为它不但描述了历史事实,还描述了推动历史发展背后的原因和规律。我当时想,如果历史都这么写,那么大家学历史的时候肯定不会觉得枯燥。
因为我的思维特点是非常理科生的,我很难记住那些相互之间没有逻辑关系的事实,我也很难接受强加过来的一套体系,除非我能弄清楚它背后的逻辑。而如果一本书,它能讲清楚事实背后的因果关系的脉络,就比较令人愉悦了。
而我前面所说的复杂系统的一些研究成果,就可以用来理解这些文化背后的逻辑规律。我挺喜欢的一个独立学者王东岳,他写了一本书叫做《物演通论》。王东岳很喜欢解读东西方文化背后的脉络,看他写的书就让人有一下子把厚厚的书读薄的感受,非常过瘾。
不过我想,如果我没有读过《失控》及其相关理论,我可能又会对王东岳此人惊为天人,对其著作惊为天书。但在有了前面的知识积累以后,我就不会那么惊讶了。因为王东岳先生的思考,也是建立在大半个世纪以来的科研成果的基础上的。物演的“演”字,就是演化思维的体现。当然,他能够进行提炼和再创造,构造一个完整的知识体系,也相当值得敬佩。
其实说了这么多,我的意思是,文化可以用复杂科学的思维来解构。这个方法,特别适合像我这样的、擅长逻辑思维的理科生们。每当你观察到一个文化现象,你都能解构出这背后的原因,岂不是很有意思呢?
作为一个北方人,我这几年大部分的时间都在厦门,对这里的闽南文化做了饶有兴趣的观察。去过厦门旅游的同学,应该都知道厦门的文艺气氛还挺浓厚的。那为什么厦门会有这种调调呢?还有,你在旅游的时候,应该会发现厦门的一种小吃,叫做沙茶面。那为什么沙茶面会在厦门文化中涌现出来呢?
这就需要结合闽南这个地方的地理、历史等各个要素及其互动关系来做分析。不过,我准备在课后的留言里,再分享我对这几个问题的看法。你有兴趣的话,也可以发表你的观点。
类似的文化方面的问题,还能提出很多来,比如:
为什么泉州会成为海上丝绸之路的起点?
为什么孔圣人出在山东,而历代出状元最多的省份,却都在南方?
中国有很多古镇,每个古镇在历史上肯定都是富甲一方的地方,那究竟是什么因素才促使它们兴盛起来的?
如果某个地方有一个地理标识产品,是某种柿子,你能推测出那里的地质特点吗?
……
去年的时候,我因为一个项目,翻阅了某县的县志,结果没想到我会对县志如此感兴趣,读得津津有味。我才发现,通过县志我能了解一个地方的地理、历史、经济、文化、重要人物等各种信息。通过这些信息,我基本上就能看到一个由很多要素相互作用构造出来的一个复杂系统,就能读懂当地各种文化的成因,这非常有意思。
中国的很多文化积淀很丰富。如果有机会能够一点一点地解读过去,那该多好。我估计我会一直保持阅读并解读地方志的兴趣。最近回老家,家人又给了我一本我们县在民国时代的县志。看着这些书籍,我有一种强烈的感觉:即使你是在这里生、这里长的,你也不一定真的了解本地的文化。
我为什么会推荐你去读地方志和其他讲解文化现象的书,读懂自己的本地文化呢?
第一层原因是呼应我在加餐2“学习技术的过程其实是训练心理素质的过程”中提倡你多学点“无用之学”的观点。哪怕只是让你的灵魂更有趣不是也挺好的吗
第二层原因,是我作为一个理科生的思维方式。把自己所处的社会系统理解清楚,能够透过现象看到后面的逻辑,不是很有意思吗?
第三层原因,如果你能够运用复杂科学的思维,来理解现在的社会系统,其实是有实际意义的。举个例子,如果你要撰写一个商业计划,或者想给一个企业写一套软件,这就需要你理解其当前的商业系统、理解一个企业组织具体是如何运行的。而你之前的这些阅读积累,就会成为你的底蕴,成为你的智慧源泉呀!
小结
今天这一讲,我推荐了两本书,或者说是两类书。一类书,是以《失控》为代表,讲述与复杂性相关的话题。另一类书,是以地方志为代表的文化载体。之所以给你推荐这两类书,是因为它们给我如何观察和理解这个世界开启了一扇窗户,并且给我这样一个严谨的理科生,提供了一条去打开文史哲的大门的独特的、有优势的途径,希望能对你有所启发。
思考一下
你有没有阅读过《失控》?你对复杂科学有什么了解?复杂科学在你的领域里有什么应用?
你对自己出生地的文化了解吗?你有没有曾经发现一个文化现象背后的逻辑脉络?你觉得多研究点文化现象对于自己的职业生涯有好处吗?
欢迎在留言区发表你的观点。如果今天的加餐让你有所启发,也欢迎把它分享给你的朋友。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
不定期加餐4 从身边的牛人身上,我学到的一些优秀品质
你好,我是宫文学。
今天的加餐环节,我想跟你分享一下让我很敬佩的那些牛人,以及从他们身上我所能学到的优秀品质。我希望你也能从这些人的故事上得到启发。这里为了叙述方便,我就不提具体的名字了。你只需要了解他的故事,从中有所感悟就好了。
我把这些牛人分为了两类,一类是搞技术的,一类是创业的。由于我自己也身兼两重身份,所以我很关注这两类人中能带给我启发的人。
首先来说说第一类人,搞技术的,也就是我们常说的极客们。
我所理解的极客
我曾经在技术圈子里参加过比较多的活动,特别是开源圈子的活动,因此也接触了不少技术大牛,国内国外的都有。
早在2000年的时候我就听过理查德·斯托曼Richard Stallman的讲座听他布道自由软件。Stallman是GNU这个组织的创始人他也发起了GPL开源协议。更重要的是他是GCC编译器的主要作者所以跟我们这门课也是很有渊源的。记得当时他给我们放一个幻灯片的时候用的是Linux上的一个软件界面没有微软的PowerPoint那么酷炫。但你能想到Stallman是绝对不会用PowerPoint的。
后来在参加和组织开源技术活动的过程中,我也接触了不少国内国外的技术团队,他们在很多方面刷新了我的认知、拓宽了我的视野,也让我更理解极客都是一些什么样的人。
在我看来,这些人应该就是合格的极客。那么,怎样才能被称为极客?是技术水平高吗?我想不是的。技术水平高,其实只是一个结果。真正让极客显得与众不同的,其实是他们对待技术的态度,乃至是对待人生的态度。这些特质,也能给所有做技术的人一些启发。
首先,是热爱技术。
跟普通人只是把技术作为一个谋生的手段不同,极客们是真心喜欢技术,热衷于钻研和探讨各种技术细节。他们在对待工作的时候,绝不会把某项工作做到能交差就行,他们想要做到完美。
我之前公司的一位股东,他在做程序员的时候,曾经接到领导的一项任务,给了他一块语音板子,让他研究一下能否做呼叫中心的功能。两个星期以后,再问他,技术上是否可行?他说,已经做完了。不仅做完了,他还考虑了各种扩展性。比如,给他的板子只有八个语音口,但他写的程序考虑了用不同的板子,有不同的口的情况。以至于后来很多年,基于他的程序做的呼叫中心系统,底层都不用做很大的改动。
我这位朋友我在加餐2中也提到过。他因为对于底层软件的深刻理解力被挖到中间件公司做老总。后来又在创业什么的最近又写了一套科幻小说。不管什么时候我总能从他身上吸取到一些东西。
另一个例子,是我一个在苹果工作的同学提到的。这位同学负责苹果的文件系统的开发,我下面还会给你讲他的故事。这里是他讲的另一件事情。一次,一位博士分配到他们组,一时没有合适的工作给他干,就先让他做一阵测试。结果这位老兄,彻底升级了测试系统,对于大量的工作都实现了自动化测试,给整个团队带来了巨大的价值。
这个故事也让我更新了看待测试工作的视角。我现在基本上不会去招聘那些因为对自己的技术能力没有信心,而选择去做测试工作的人。我认为测试工作需要极大的技术热情才能做好。
我想,不管是从事什么岗位,能够热爱自己所做的事情,都是非常值得庆幸的。反过来,如果不喜欢自己所做的事情,为什么要去凑合呢?
换句话说能够做自己喜欢的事情其实是有所取舍、有所牺牲的。林纳斯·托瓦兹Linus Torvalds就喜欢领着一拨人折腾Linux。如果他去做某个大公司的CTO甚至是创业合伙人也无不可。但他选择的是自己喜欢的生活方式。他没有太去想自己因此损失了多少发财的机会。
这就涉及到了第二点,就是极客们洒脱的生活态度。
极客们所展现出来的这个特质,从某种意义上来看是更具魅力的。很多极客,都是不愿意以“生活所迫”为借口,选择自己不喜欢的工作和生活方式。
我在加餐1分享远程办公话题的时候就提到过一家芬兰公司。这家公司都是远程办公的其中有的员工是一边全球旅游一边工作的。这些技术型的公司正是以这种方式吸引那些真正的极客加入。
还有一次我参加一个技术活动我的朋友C指着一个老外说这家伙在泰国买了一个小岛自己住还弄了个度假村什么的。说实话这样的归园田居的生活方式对像我们这样浸染在中华文化中的人来说是有很大的吸引力的。但我们有多少人敢于不从众去选择自己喜欢的生活方式呢
我还有的朋友是依托自己的技术创业的。创业这件事当然很不容易,但他们通常都会保持积极乐观的态度,并没有因为自己的项目没有及时被社会认可,就变得垂头丧气。
那第三点,就是极客们看待这个世界的方式:用代码说话。
极客这群人是不大讲政治的。他们一般只认真实的本事。Linus就有一句名言“Talk is cheap, show me the code.”,这也代表了极客们的精神。一个人的水平如何,看看他写的代码,或者至少看看他发表的文章,其实差不多就有数了,这个是伪装不了的。
早在智能手机流行前有一次聚会我一个搞Linux的朋友F就拿出了一台手机里面装着Linux、图形界面、App什么的看着都还行。这都是他鼓捣出来的。其实再加把劲比如支持用Java开发应用这就是一个Android系统了。而Android的创始人安迪·鲁宾Andy Rubin差不多也是这样一个极客。前一阵我跟一个公司的老总聊天。他问我为什么中国搞不出安卓来我给他解释了原因。其实不是我们没有这样的技术在极客们的眼里最早的那个安卓版本也没什么大不了的。只不过我们没有掌握技术生态而已。
极客们一般对系统底层的技术都比较熟悉。像安卓系统这种看似很高大上的东西,不会让他们心生畏惧。这些人在互相交流的时候,也会谈论一些底层技术。几句话下来,心里已经有数。
然后呢?他们之间会缔结惺惺相惜的友谊。两个极客之间的交往可以极其简单,他们甚至不需要见过面,只需要见过对方的代码,或者读过对方的文章,就会认可彼此。如果有事情,直接打招呼就行。
某互联网大厂是如何把自己的底层技术搞扎实的呢据我了解就是找到一个开源圈的大牛。这位大牛进去了以后又给技术社区的其他人打招呼说这里有什么技术难题需要解决过来吧。于是就聚集了一个小组的牛人搞出了非常好的成绩。这就是极客们的特殊的社交方式他们知道who is who并且志同道合的人愿意聚到一起。如果光靠HR部门和猎头公司来做要想达成这样的结果是很难的。
Github在某种意义上也是把极客们的这种倾向给充分地引导了出来。它从一个代码托管工具几乎已经变成了程序员的社交网站。
这里我是想说明一个观点,那就是技术人并没有怀才不遇这一说。把真本事亮出来,所有的事情会变得简单很多。
好了,这就是我总结的极客们给我的三点印象:热爱技术、生活洒脱、凭本事说话。这些特质,都是我很欣赏的,也常常作为参照来调整自己。
比如说,我觉得自己也挺热爱技术的,但是在前些年,我觉得自己不够洒脱,做不好取舍,总是想各方面都兼顾,结果哪方面都顾不好。所以还不如在自己喜欢的事情上全情投入,不去计较太多得失,反倒会更加心情愉快,做事情的结果也更好。
你可能会问,那这些极客都发展得怎么样呢?
我所认识的极客,有的是在小公司工作,有的是在大公司工作,还有的是在创业。不过,不管从事什么岗位,似乎都发展得不错。我想,这是因为他们从底层上选择了一个更好的发展逻辑:首先是做好取舍,让自己能够专注技术;在拥有了比较好的技术底蕴以后,他们也有更好的施展自己才华的平台;在专注于技术价值的同时,他们的生活也变得简单和健康。
OK讲完了搞技术的我再讲讲搞创业的朋友的故事以及他们给我的启迪。
创业者这个物种
我周围的朋友有不少是搞创业的。这些人往往都有一些很特别的点,让我欣赏、赞叹乃至仰慕。
首先一点,是坚韧不拔的意志力。
我们都知道,创业肯定不是简单的事情。而让一个企业能够穿越惊涛骇浪,最重要的就是创始人坚韧不拔的意志品质。
我本科的同班同学中,就有一个创业者,他公司的主营业务是户外运动用品,目前已经做到上市了。他的性格就很坚韧,我给你讲两个故事。
第一个在他成为我北大的同学之前其实曾经考上了一所技术类的军校。但后来他发现自己并不喜欢那里于是就想退学。可是军校岂可以当作儿戏想来就来想走就走为了能够退学他想了很多办法包括自己注射大剂量的抗生素产生精神恍惚的效果以便让校医诊断为精神疾病此法不成又从3楼的阳台上滚下来想把胳膊摔断以此理由退学……后来校领导实在看他态度坚决也就同意了他退学。他又重新参加了高考选择了他喜欢的学校和专业。
第二个大约在2006年我们一些同学因为毕业十周年又聚到了一起去内蒙古草原上玩其中一项活动就是骑马。我的这位同学骑术很好在草原上策马狂奔。不过在一个地方突然马失前蹄他从马背上摔了下来。这真是很惊险的一个意外我们在场一群人看了都心惊肉跳。不过他休息了一会以后又要了一匹马上马继续策马狂奔。晚上我们问他为什么刚摔了又骑他说如果今天不骑以后就不敢骑了。
说到这我想再多讲一个例子。这是我同一级的另一个同学的故事他是社会学系的。如果我说他的名字很多同学应该都会知道。他从2000年开始做一个与汽车有关的网站结果后来互联网泡沫破裂然后投资人撤资。他就自己筹了2000万买下了投资人的股份坚持做了下去直到2011年上市。想想看那个年头的2000万是多大的压力。但他就是咬着牙挺过来了。
我不知道有多少人能拥有像他们这样钢铁般的意志力。并且,令我沮丧的是,我怀疑这种个性可能主要是天生的?反正我是万万难以做到的。所以,创业这件事情,其实不是每个人都适合去做。而我这两个同学能做到上市,也绝不是偶然。
不过,为了不让自己的希望完全破灭,我还是倾向于相信意志和勇气这样的事情,至少在部分上是可以后天磨炼的。我在大学的时候练习过拳击,因为我觉得拳击可以锻炼人的勇气。来拳的时候不能眨眼,是拳击运动员的基本素质。那么在创业中,如果我们每次都去积极地面对挑战,那面对困难的能力也会越来越强。
我认识的其他几个创业者,虽然不像这两位那么夸张,但在意志力方面,也都属于罕见的。比如,某个技术社区,其创始人能够做到天天更新内容,十年如一日,这就是常人所不及的。最近我通过写编译原理的课程,也对内容编写这件事有了一定的体会。这样的事情,做一个星期、一个月、一个季度,是凭着兴趣和热情就可以做到的。而长年累月地去做,你要说没有意志力的因素,那是不可能的。
说完了强大的意志力,我再来说说我钦佩的这些人的第二点品质,就是有主见,不从众。
我观察,这些创业成功的人,往往判断事情都有自己的标准,这些标准往往与大众是不一致的。
还是说我同班的那个同学。在学校读书的时候,他经常就会消失不见了,过一阵再重新出现,他告诉我们,这次去陕北了,有什么感受,怎样怎样。过一段时间又会消失,回来后,说自己在新疆沙漠里独自走了几天,遇到被人追赶,差点殒命,等等等等。
等到快期末考试的时候他拉着我在未名湖边给他补习高数说能及格就行。几年以后在创业的过程中他还读了个清华的MBA班也是连毕业证都没要。按他的意思来说就是学到知识就行了。证书什么的不重要。
而我们这些俗人,天天使劲读书。等到毕业以后,又根据习惯和潮流,很多人又去出国,虽然我敢说,大部分同学那时候都想不清楚出国到底要干嘛。
所以从某种意义上来讲,比尔·盖茨、马克·扎克伯格等人敢于辍学创业,本身就意味着他们不是一般人。
而作为对比,还有一些人,都不管自己什么年纪了还在花高价去混文凭,不停地想往自己身上贴一些标签,来为自己壮胆。我觉得,这些人不要说创业了,给他一个重要的职位都是一件很冒险的事情。
前面我也提到了有些极客,会基于自己的兴趣爱好来创业。他们喜欢的技术和产品,往往在很长的时间内都不会得到社会的认可,不能变成有经济价值的商品。然而他们就是会坚持自己的方向。这些人,也是我学习的榜样。
这些技术创业者有的发展比较顺利但似乎也不是刻意为之。比如上海的小X我跟他在技术活动上有几面之缘。他搞了一个用于物联网的小小的OS搞了很多年了前两年突然听说融了很多资估值不错。我觉得资本投在这些人身上是投对了。
也有的朋友,会经历一些坎坷。但是他们总是按照自己的方式去折腾,保持对科技发展趋势的敏锐观察。每隔一段时间,我总能从他们那里听到一些新的思想和动态。就拿我一个做移动端底层平台朋友来说,他做这个方向已经很多年了。我相信他肯定会做成。不过先不说未来结果如何,至少我觉得他的生活状态是洒脱的、阳光的、不纠结的。
小结
今天的加餐,我给你分享了周围搞技术的和做创业的一些朋友的故事。这些故事跟你有什么关联呢?
首先,你选择了编译原理这门课程,基本上已经说明你有成为一名极客的潜质,否则也不会给自己这个挑战。但是在这个过程中呢,你可能会遇到很多的困难和心理上的纠结。我希望通过我分享的故事,能够帮助你做好取舍,丢掉包袱,健康阳光地拥抱作为一个技术从业者的职业生涯。
而如果你不小心选择了创业这条路,我也希望你能够像故事中的人物一样,去磨炼自己的意志力,以及坚持自己的主见。成功不成功不敢保证,至少你的生活会是很有价值的,不会后悔的。
以上也是对我自己的勉励,希望能跟你共勉。如果你或你身边也有类似的故事,欢迎在留言区分享出来。同样,也非常欢迎你把这一讲分享出去。
感谢你的阅读,我们结束语见!

View File

@ -0,0 +1,266 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
划重点 7种编译器的核心概念与算法
你好,我是编辑王惠。
阶段性的总结复习和验证成果是非常重要的。所以在8月7日到8月12日这为期一周的期中复习时间里我们先来巩固一下“真实编译器解析篇”中的重点知识。你可以通过学习委员朱英达总结梳理的划重点内容以及涵盖了关键知识点的7张思维导图来回顾7种语言编译器的核心概念与算法。
另外宫老师还精心策划了10道考试题让你能在行至半程之时做好自检及时发现知识漏洞到时候一起来挑战一下吧
在期中复习周的最后,我还会邀请一位优秀的同学来做一次学习分享。通过他的学习故事,你也可以借此对照一下自己的编译原理学习之路。
好,下面我们就一起来复习这些核心的编译原理概念与算法知识吧。
Java编译器javac
Java是一种广泛使用的计算机编程语言主要应用于企业级Web应用开发、大型分布式系统以及移动应用开发Android。到现在Java已经是一门非常成熟的语言了而且它也在不断进化、与时俱进泛型、函数式编程、模块化等特性陆续都增加了进来。与此同时Java的编译器和虚拟机中所采用的技术也比 20 年前发生了天翻地覆的变化。
Java的字节码编译器javac是用Java编写的它实现了自举。启动Java编译器需要Java虚拟机默认是HotSpot虚拟机使用C++编写)作为宿主环境。
javac编译器的编译过程主要涉及到了这样一些关键概念和核心算法
词法分析阶段:基于有限自动机的理论实现。在处理标识符与关键字重叠的问题上,采用了先都作为标识符识别出来,然后再把其中的关键词挑出来的方式。
语法分析阶段使用了自顶向下的递归下降算法、LL(k)方式以及多Token预读处理左递归问题时采用了标准的改写文法的方法处理二元表达式时采用了自底向上的运算符优先级解析器。
语义分析阶段:会分为多个小的阶段,且并不是顺序执行的,而是各阶段交织在一起。
语义分析阶段主要包含ENTER建立符号表、PROCESS处理注解、ATTR属性分析、FLOW数据流分析、TRANSTYPES处理泛型、TRANSPATTERNS处理模式匹配、UNLAMBDA处理 Lambda和 LOWER处理其他所有的语法糖比如内部类、foreach 循环等、GENERATE 阶段生成字节码等。在ATTR和FLOW这两个阶段编译器完成了主要的语义检查工作。
注意:生成字节码是一个比较机械的过程,编译器只需要对 AST 进行深度优先的遍历即可。在这个过程中会用到前几个阶段形成的属性信息,特别是类型信息。
参考资料:
关于注解的官方教程,参考这个链接。
关于数据流分析的理论性内容参考龙书Compilers Principles, Techniques and Tools第二版的9.2和9.3节。也可以参考《编译原理之美》 的第27、28讲那里进行了比较直观的讲述。
关于半格这个数学工具可以参考龙书第二版的9.3.1部分也可以参考《编译原理之美》的第28讲。
Java语言规范第六章参考Java虚拟机指令集。
Java JIT编译器Graal
对于编译目标为机器码的Java后端的编译器来说主要可以分AOT和JIT两类如果是在运行前一次性生成就叫做提前编译AOT如果是在运行时按需生成机器码就叫做即时编译JIT。Java以及基于JVM的语言都受益于JVM的JIT编译器。
在JDK的源代码中你能找到src/hotspot目录这是 JVM 的运行时HotSpot虚拟机它是用C++编写的其中就包括JIT编译器。
Graal是Oracle公司推出的一个完全用Java语言编写的JIT编译器。Graal编译器有两个特点内存安全相比C++实现的Java JIT编译器而言与Java配套的各种工具比如ID更友好、更丰富。
Java JIT编译器的编译过程主要涉及到了这样一些关键概念和核心算法
分层编译C0解释器、C1客户端编译器、C2服务端编译器。不同阶段的代码优化激进的程度不同且存在C2降级回C1的逆优化。
IR采用了“节点之海Sea of Nodes整合了控制流图与数据流图符合 SSA 格式,有利于优化算法的编写和维护。
两个重要的优化算法:内联优化和逃逸分析。
几个重要的数据结构HIR硬件无关的IR、LIR硬件相关的IR、CFG控制流图
寄存器分配算法LinearScan。
金句摘录:“编译器开发的真正的工作量,都在中后端。”
参考资料:
GraalVM项目的官方网站Graal的Github地址Graal项目的出版物。
基于图的IR的必读论文程序依赖图-J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization. July 1987Click的论文-A Simple Graph-Based Intermediate Representation介绍Graal IR的论文-Graal IR: An Extensible Declarative Intermediate Representation。
关于优化算法:多态内联-Inlining of Virtual Methods逃逸分析-Escape Analysis for Java部分逃逸分析-Partial Escape Analysis and Scalar Replacement for Java。
Python编译器CPython
Python诞生于上个世纪90年代初作者是荷兰计算机程序员吉多·范罗苏姆Guido van Rossum。Python语言的特点是自身语法简单容易掌握强调一件事情只能用一种方法去做具备丰富的现代语言特性如OOP、FP等其实现机制决定了易于集成C++扩展不仅便于利用一些已有的、经典开源的高性能的C/C++库同时也可以很方便地编写自己的C++扩展,实现一些高性能模块。
另外Python使用了pgen这样的生成编译器的工具。pgen能够基于语法规则生成解析表Parse Table供语法分析程序使用。你可以通过修改规则文件来修改Python语言的语法pgen能给你生成新的语法解析器。它是把EBNF转化成一个NFA然后再把这个NFA转换成DFA。基于这个DFA在读取Token的时候编译器就知道如何做状态迁移并生成解析树。Python用的是 LL(1) 算法。
CPython编译器编译器的编译过程主要涉及到了这样一些关键概念和核心算法
语法分析首先是生成CSTConcret Syntax Tree具体语法树接着生成AST抽象语法树。CST的特点是完全基于源程序结构构建出树结构它比AST啰嗦但更精确地反映了语法推导的过程。而AST的特点是去掉了树结构上繁杂冗余的树枝节点更简明扼要更准确地表达了程序的结构。
语义分析Python通过一个建立符号表的过程来做相关的语义分析包括做引用消解等。Python语言使用变量的特点是不存在变量声明每个变量都是赋值即声明因此在给一个变量赋值时需要检查作用域确认当前操作是给全局的变量赋值还是在局部给声明新变量。
生成字节码:这个工作实际上包含了生成 CFG、为每个基本块生成指令以及把指令汇编成字节码并生成 PyCodeObject 对象的过程。另外,生成的字节码在最后放到解释器里执行之前,编译器还会再做一步窥孔优化工作。
运行时机制Python的运行时设计的核心就是PyObject对象Python对象所有的特性都是从PyObject的设计中延伸出来的。其虚拟机采用了栈机的架构。
参考资料:
python.org网站下载3.8.1版本的源代码。
GDB的安装和配置参考这篇文章。
Python的开发者指南网站。
pgen的工具程序Parser/pgen。-
由于CPython最新的Master分支上的代码调整此处pgen的链接地址调整为CPython3.9版本分支上的pgen相关代码。
Python的字节码的说明。
Python的内置类型。
JavaScript编译器V8
V8是谷歌公司在2008年推出的一款JavaScript编译器主要由C++编写而成。V8主要应用于Chrome浏览器后来也被开源社区中诸如Node.js等项目所使用。其最为突出的特点就是“快”由于JavaScript是在浏览器下载完页面后马上编译并执行它对编译速度有更高的要求。因此V8采用了一系列技术手段优化编译和启动阶段运行速度。
在设计上V8结合了分阶段懒解析、空间换时间等设计思路突出了解析、启动阶段运行的时间开销。
对源代码的Parse进行了流Stream化处理也就是边下载边解析。
预解析PreParse处理也就是所谓懒解析最核心的设计思想每个JS函数只有被执行的时候才会解析函数体的内部逻辑。
另外V8的很多地方体现出了与Java编译器异曲同工之处。比如它将JavaScript源代码的编译分为了由Ignition字节码解释执行和TurboFan的JIT编译机器代码执行两部分组成类似于Java编译器的字节码解释执行和Graal优化编译后执行两阶段TurboFan编译器的IR也采用了Sea of Nodes这一点类似于Java的Graal编译器且也涉及到了内联优化和逃逸分析算法。
其运行方式分为两类:
常规情况下Ignition字节码解释执行
编译器判定热点代码TurboFan JIT编译成机器码执行并且TurboFan会依赖一些Ignition解释执行过程中的运行时数据来进行进一步优化使机器码尽可能高效。
因为JavaScript是动态类型语言因此对函数参数类型的推断以及针对性优化是一个V8的核心技术。V8涉及到的其他优化算法有
隐藏类Hidden Class。相同“形状”的JS对象会被以同一个隐藏类维护其数据结构。
内联缓存Inline Caching。针对隐藏类查找属性值时的指针偏移量进行内联缓存这属于结合隐藏类技术做进一步性能的优化。
参考资料:
V8项目的官网以及V8的源代码-官方文档。
了解V8的解析器为什么速度非常快Blazingly fast parsing, part 1: optimizing the scannerBlazingly fast parsing, part 2: lazy parsing。
了解Ignition的设计Ignition Design Doc宫老师在Github上也放了一个拷贝。
了解Ignition的字节码Understanding V8s bytecode。
V8的指针压缩技术Pointer Compression in V8。
介绍V8基于推理的优化机制An Introduction to Speculative Optimization in V8。
关于Ignition字节码做优化的论文Register equivalence optimization宫老师在Github上也放了一份拷贝。
Julia的编译器
Julia语言最初发行于2012年其最初是为了满足高性能数值分析和计算科学的需要而设计的。Julia同时兼具了静态编译型和动态解释型语言的优点一方面它的性能很高可以跟Java和C语言媲美另一方面它又是动态类型的编写程序时不需要指定类型。
Julia编译器的特点是
作为动态类型语言却能拥有静态类型语言的执行速度最关键的原因是使用了LLVM作为编译器后端针对动态类型的变量在运行时由编译器JIT编译生成多个版本的目标代码保证程序的高性能
由C、C++、Lisp和Julia四种语言开发而成编译器前端主要采用Lisp实现
考虑到对函数式编程的支持有别于面向对象编程的“单一分派”Julia的编译器提供了“多重分派”的功能。
Julia编译器的编译过程主要涉及到了这样一些关键概念和核心算法
Julia的编译器也是采用了递归下降算法来实现语法分析。
其内部IR采用了SSA格式主要作用是完成类型推断和内联优化。
Julia的IR会被转化成LLVM的IR从而进一步利用LLVM的功能。在转换过程中会用Julia的内建函数这些内建函数代表了Julia语言中抽象度比较高的运算功能。
参考资料:
LLVM的官网以及LLVM的源代码。
Julia的开发者文档中有对如何使用LLVM的介绍Working with LLVM。
对LLVM中的各种Pass的介绍LLVMs Analysis and Transform Passes。
《编译原理之美》的第25讲和第26讲宫老师对LLVM后端及其命令行工具做了介绍并且还手工调用LLVM的API示范了针对不同的语法结构比如if结构应该如何生成LLVM IR最后即时编译并运行。你可以参考一下。
Go语言编译器gc
Go语言是Google开发的一种静态强类型、编译型、并发型并具有垃圾回收功能的编程语言又名Golang。Go广泛应用于Google的产品以及许多其他组织和开源项目其创建的初衷就是主要面向于部署于大量服务器之间的分布式程序也就是我们今天所说的“云”。因此Go的主要优势聚焦于服务端高并发场景。
Go语言编译器的特点是
gc编译器除了少量标准库的内容是用汇编写的以外其绝大部分逻辑都是用Go语言本身写的因此实现了较为完整的自举Bootstraping从前端到后端的整个流程都使用Go语言实现在编程语言中是较为罕见的
教科书级别的设计,源代码完全自举、代码干净利索,因此非常适合作为学习参考。
Go语言编译器的编译过程主要涉及到了这样一些关键概念和核心算法
编译器前端gc编译器的词法分析和语法分析使用的都是非常经典、传统的算法如手写的递归下降算法、处理二元表达式时使用操作符优先级算法。
中间代码阶段SSA格式的IR基于CFG的IR利于“死代码”的发现与删除多遍Pass的优化框架。
机器码生成阶段线性扫描算法官方的gc编译器并没做指令重排这是基于编译过程中时间开销的考虑。
参考资料:
介绍gc编译器的主要结构Introduction to the Go compiler官方文档。
介绍gc编译器的SSAIntroduction to the Go compilers SSA backend官方文档。
Go compiler internals: adding a new statement to Go - Part 1、Part2。在这两篇博客里作者做了一个实验如果往Go里面增加一条新的语法规则需要做哪些事情。我们能够很好地、贯穿性地了解一个编译器的方法。
介绍gc编译器的SSA优化规则描述语言的细节Go compiler: SSA optimization rules description language。
介绍Go汇编的细节A Primer on Go Assembly和A Quick Guide to Gos Assembler。gc编译器采用的汇编语言是它自己的一种格式是“伪汇编”。
MySQL的编译器
MySQL是一个开放源码的关系数据库管理系统原开发者为瑞典的MySQL AB公司后几经辗转目前归属于Oracle旗下产品。在过去MySQL性能高、成本低、可靠性好因此成为了最流行的开源数据库。SQL可以称得上是最成功的DSL特定领域语言之一MySQL中的SQL解析模块则是这门DSL的非常具有可参考性的一个实现。MySQL使用C++编写有少量几个代码文件是用C语言编写的。
MySQL的编译器的特点是
SQL作为DSL中最具有代表性的一种语言学习它的编译器的实现可以为我们自己设计面向业务的DSL提供参考。
手写的词法分析、用工具bisonGNU的yacc生成的语法分析。
基于LALR处理二元表达式。
中后端优化方面MySQL是解释执行并没有做太多的机器相关优化在机器无关优化方面除了一些编译领域传统的优化技术之外还做了一些针对数据库特定场景的优化方式。
MySQL的编译器的编译过程主要涉及到了这样一些关键概念和核心算法
词法分析和语法分析
词法分析:手写的词法分析器。
语法分析由bison生成。bison是一种基于EBNF生成语法分析程序的工具可视为GNU版的yacc。
bison支持的语法分析算法LALR算法。
语义分析
MySQL中一些重要的数据结构THD线程对象、Table_ident对象。
上下文处理基于contextualize的上下文处理。
基于数据库“业务逻辑”的引用消解:库名、表名、列名、入口、查找和作用域(子查询)
机器无关优化
典型的优化:常量传播、死代码消除、常量折叠。
针对数据库场景的优化产生执行计划、生成JOIN对象。
机器相关优化
MySQL实际上对表达式是解释执行所以并没有真正意义上做任何机器相关优化。
列举了PG另一种类似MySQL的DB有通过LLVM的JIT优化。
参考资料:
下载MySQL的源代码跟踪MySQL的执行过程要用Debug模式编译MySQL具体步骤可以参考这篇开发者文档。
MySQL的内行手册MySQL Internals Manual。它能给我们提供一些重要的信息但文档内容经常跟源代码的版本不同步比如介绍源代码的目录结构的信息就过时了。需要注意一下。
bison的手册。
如果要加深对MySQL内部机制的了解宫老师推荐了两本书OReilly的《Understanding MySQL Internals》以及《Expert MySQL》。

View File

@ -0,0 +1,131 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
期末答疑与总结 再次审视学习编译原理的作用
你好,我是宫文学。到这里,咱们这门课程的主要内容就要结束了。有的同学在学习课程的过程中呢,提出了他感兴趣的一些话题,而我自己也会有一些想讲的话题,这个我也会在后面,以加餐等方式再做一些补充。接下来,我还会给你出一套期末测试题,帮你检测自己在整个学习过程中的所学所得。
那么,在今天这一讲,我们就来做个期末答疑与总结。在这里,我挑选了同学们提出的几个有代表性的问题,给你解答一下,帮助你更好地了解和掌握本课程的知识内容。
问题1学习了编译原理对于我学习算法有什么帮助
@无缘消受人间富贵:老师,想通过编译器学算法,单独学算法总是不知道有什么意义,每次都放弃,老师有什么建议吗?但是看到评论说用到的都是简单的数据结构,编译器用不到复杂的数据结构和算法?
针对这位同学提出的问题,我想谈一谈我对算法学习的感受。
前一阵,我在跟同事聊天时,提到了一个观点。我说,大部分的程序员,其实从来都没写过一个像样的算法。他们写的程序,都是把业务逻辑简单地翻译成代码。那么,如果一个公司写出来的软件全是这样的代码,就没有什么技术壁垒了,很容易被复制。
反之,一些优秀的软件,往往都是有几个核心的算法的。比如,对于项目管理软件,那么网络优化算法就很关键;对于字处理软件,那么字体渲染算法就很关键,当年方正的激光照排系统,就是以此为基础的;对于电子表格软件,公式功能和自动计算的算法很关键;对于视频会议系统,也必须掌握与音视频有关的核心算法。这样,因为有了算法的技术壁垒,很多软件就算是摆在你的面前,你也很难克隆它。
所以说,作为一名软件工程师,你就必须要有一定的算法素养,这样才能去挑战那些有难度的软件功能。而作为一个软件公司,其实要看看自己在算法上有多少积淀,这样才能构筑自己的技术壁垒。
那么,编译原理对于提升你的算法素养,能带来什么帮助呢?我给你梳理一下。
编译原理之所以硬核,也是因为它涉及了很多的算法。
在编译器前端主要涉及到的算法有3个
有限自动机构造算法这是在讲词法分析时提到的。这个算法可以根据正则文法自动生成有限自动机。它是正则表达式工具的基础也是像grep等强大的Linux命令能够对字符串进行模式识别的核心技术。
LL算法这是在讲自顶向下的语法分析时涉及的。根据上下文无关文法LL算法能够自动生成自顶向下的语法分析器中间还涉及对First和Follow集合的计算。
LR算法这是在讲自底向上的语法分析时涉及的。根据上下文无关文法LR算法能自动生成自底向上的语法分析器中间还涉及到有限自动机的构造算法。
总的来说,编译器前端的算法都是判断某个文本是否符合某个文法规则,它对于各种文本处理工作都很有效。有些同学也在留言区里分享,他在做全文检索系统时就会用到上述算法,使得搜索引擎能更容易地理解用户的搜索请求。
在编译器后端主要涉及到的算法也有3个
指令选择算法;
寄存器分配算法;
指令重排序(指令调度)算法。
这三个算法也有共同点它们都是寻找较优解或最优解而且它们都是NP CompleteNP完全的。简单地说就是这类问题能够很容易验证一个解对不对多项式时间内但求解过程的效率却可能很低。对这类问题会采用各种方法求解。在讲解指令选择算法时我介绍了贪婪策略和动态规划这两种不同的求解思路而寄存器选择算法的图染色算法则采用了一种启发式算法这些都是求解NP完全问题的具体实践。
在日常工作中,我们其实也会有很多需要求较优解或最优解的需求。比如,在文本编辑软件中,需要把一个段落的文字分成多行。而如何分行,就需要用到一个这样的算法。再比如,当做一个报表软件,并且需要分页打印的时候,如何分页也是同类型的问题。
其他类似的需求还有很多。如果你没有求较优解或最优解的算法思路,对这样的问题就会束手无策。
而在编译器的中端部分涉及的算法数量就更多了但是由于这些算法都是对IR的各种分析和变换所以IR采用不同的数据结构的时候算法的实现也会不同。它不像前端和后端算法那样在不同的编译器里都具有很高的一致性。
不过IR基本上就是三种数据结构树结构、图结构和基于CFG的指令列表。所以这些算法会训练你处理树和图的能力比如你可以在树和图中发现一些模式以此对树和图进行变换等等。这在你日常的很多编程工作中也是非常重要的因为这两种数据结构是编程中最常使用的数据结构。
那么,总结起来,认真学好编译原理,一定会给你的算法素养带来不小的提升。
问题2现代编程语言这么多我们真的需要一门新语言吗
@蓝士钦前不久看到所谓的国产编程语言“木兰”被扒皮后发现是Python套层壳真的是很气愤。想要掌握编译原理设计一门自己的语言但同时又有点迷茫现代编程语言这么多真的需要再多一门新语言吗从人机交互的角度来看任何语言都是语法糖。
关于是否需要一门新语言的话题,我也想跟你聊聊我自己的看法,主要有三个方面。当然,你也可以在此过程中思考一下,看看有没有什么跟我不同的见解,欢迎与我交流讨论。
第一,编程语言其实比我们日常看到的要多,很多的细分领域都需要自己的语言。
我们平常了解的都是一些广泛流行的通用编程语言而进入到每个细分领域其实都需要各自领域的语言。比如SaaS的鼻祖Salesforce就设计了自己的Apex语言用于开发商业应用。华为的实验室在研发方舟编译器之前也曾经研发了一门语言Cm服务于DSP芯片的研发。
第二,中国技术生态的健康发展,都需要有自己的语言。
每当出现一个新的技术生态的时候总是有一门语言会成为这个技术生态的“脚本”服务于这个技术生态。比如C语言就是Unix系统的脚本语言JavaScript、Java、PHP等等本质上都是Web的脚本语言而Objective-C和Swift显然是苹果设备的脚本语言Android虽然一开始用了Java但最近也在转成Kotlin这样Google更容易掌控。
那么,从这个角度看,当中国逐步发展起自己的技术生态的时候,也一定会孕育出自己的语言。以移动计算生态而言,我们有全球最大的移动互联网用户群和最丰富的应用,手机的制造量也是全球最高的。而位于应用和硬件之间的应用开发平台,我们却没有话语权,这会使中国的移动互联网技术生态受到很大的掣肘。
我在第40讲也已经分析过了Android系统经过了很多年的演化但技术上仍然有明显的短板使得Android平台的使用体验始终赶不上苹果系统。为了弥补这些短板各个互联网公司都付出了很大的成本比如一些头部应用的核心功能采用了C/C++开发。
并且Android系统的编译器在支持新的硬件上也颇为保守和封闭让中国厂商难以参与。这也是华为之所以要做方舟编译器的另一个原因。因为华为现在自研的芯片越来越多要想充分发挥这些芯片的能力就必须要对编译器有更大的话语权。方舟编译器的问世也证明了我们其实是有技术能力的可以比国外的厂商做得更好。既然如此我们为什么要受别人的制约华为方舟编译器团队其实也很渴望在方舟编译器之后推出自己的语言。至于华为内部是否已经立项这就不太清楚了但我觉得这是顺理成章的事情。
另外除了在移动端的开发上会受到很多掣肘在云端其实也一样。比如说Java是被大量后端开发的工程师们所掌握的语言但现在Java是被Oracle掌控的。你现在使用Java的时候可能已经多多少少感受到了一种不愉快。先不说Java8之后的收费政策就说我们渴望的特性如协程、泛型中支持基础数据类型等一直没有被满足就会感觉不爽。
我在讲到协程的时候就指出Java语言目前支持协程其实是很别扭的一种状态它都是一些第三方的实现并没有官方的支持。而如果Java的技术生态是由我们主导可能就不是这样了。因为我国互联网的并发用户数如此之多我们对更好的并发特性其实是更关切的。到目前为止像微信团队解决高并发的问题是用C++加上自己开发的协程库才实现的。而对于很多没有如此强大的技术能力的公司来说,就只能凑合了。
第三,实现一款优秀的软件,一定会用到编译技术。
每一款软件当发展到极致的时候都会变得像一个开发平台。这也是《黑客与画家》的作者保罗·格雷厄姆Paul Graham表达的思维。他原来的意思是每个软件写到最后都会包含一个Lisp的变种。实际上他所要表达的意思就跟我说的一样。
我前一段时间在北京跟某公司的老总探讨一个优秀的行业应用软件。这个软件在上世纪90年代就被开发出来了也被我国广泛采用。一方面它是一个应用软件另一方面它本身也是一个开发平台。所以它可以经过定制满足不同行业的需求。
但是,我们国内的软件行业的情况是,在去客户那里实施的时候,几乎总是要修改源代码,否则就不能满足用户的个性化需求。
很多软件公司想去克隆一下我刚才说的那套软件,结果都放弃了。除了有对领域模型理解的困难以外,缺少把一个应用软件做成软件开发平台的能力,是其中很大的一个障碍。
实际上,目前在很多领域都是这样。国外的软件就是摆在那里,但中国的工程师就是做不出自己的来。而对于编译技术的掌握和运用,就是能够提升国内软件水平的重要途径。
我在开头跟同事交流的时候,也提出了软件工程师技术水平修养提升的几个境界。其中一个境界,就是要能够利用编译技术,做出在更大范围内具有通用性的软件。如果你能达到这个境界,那么也一定有更大的发展空间。
问题3如何判断某门语言是否适合利用LLVM作为后端
@ヾ(◍°∇°◍)ノ゙老师很多语言都声称使用LLVM提升性能但是在Lua领域好像一直是LuaJIT无法超越
这个问题涉及到了如何利用后端工具的问题,比较有代表性。
LLVM是一个通用的后端工具。在它诞生之初首先是用于支持C/C++语言的。所以一门语言在运行机制上越接近C/C++语言用LLVM来做后端就越合适。
比如Rust用LLVM就很成功因为Rust语言跟C/C++一样,它们的目标都是编写系统级的程序,支持各种丰富的基础数据类型,并且也都不需要有垃圾收集机制。
那么如果换成Python呢你应该记得Python不会对基础数据类型进行细粒度的控制不需要把整型区分成8位、16位、32位和64位的它的整型计算可以支持任意长度。这种语义就跟C/C++的相差比较远所以采用LLVM的收益相对就会小一些。
而对于JavaScript语言来说浏览器的应用场景要求了编译速度要尽量地快但在这方面LLVM并没有优势。像我们讲过的隐藏类Shapes和内联缓存Inline Caching这样的对JavaScript很重要的机制LLVM也帮不上忙。所以如果在项目时间比较紧张的情况下你可以暂时拿LLVM顶一顶Safari浏览器中的JavaScript引擎之前就这么干过。但是要想达到最好的效果你还是编写自己的后端更好一些。
那对于Lua语言其实你也可以用这个思路来分析一下是采用LLVM还是自己写后端会更好一些。不过由于Lua语言比较简单所以实现后端的工作量应该也相对较小。
小结
这一讲,我主要回答了几个比较宏观的问题,它们都涉及到了编译原理这门课的作用。
第一个问题,我是从提升算法素养的角度来展开介绍的。编译原理知识里面涉及了大量的算法,我把它总结成了三大类,每类都有自己的特点,希望能对你宏观把握它们有所帮助。
第二个问题,其实是这门课程的一条暗线。我并没有在课程里去情绪化地鼓吹,一定要有自己的编译器、自己的语言。我的方式其实是想做一点具体的事情,所以在第二个模块中,我带着你一起探究了现有语言的编译器都是怎么实现的,破除你对编译器的神秘感、距离感;在第三个模块,我们又一起探讨了一下实现一门语言中的那些关键技术点,比如垃圾收集、并行等,它们都是如何实现的。
在课程最后呢,我又带你了解了一下具有中国血统的方舟编译器。我想说的是,其实我们不但能做出编译器和语言来,而且可能会做得更好。虽然我们对方舟编译器的分析还没有做完,但通过分析它的技术思路,你应该或多或少地感受到了它的优秀。所以,针对“我们真的需要一门新语言吗”这个问题,我的回答是确定的。并且,即使你不去参与实现一门通用的语言,在实现自己领域的语言,以及把自己的软件做得更具通用性这点上,编译原理仍然能发挥巨大的作用,对你的职业生涯也会有切实的帮助。
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。

View File

@ -0,0 +1,164 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
热点问题答疑 如何吃透7种真实的编译器
你好,我是宫文学。
到这里咱们就已经解析完7个编译器了。在这个过程中你可能也积累了不少问题。所以今天这一讲我就把其中有代表性的问题给你具体分析一下。这样能帮助你更好地掌握本课程的学习思路。
问题1如何真正吃透课程中讲到的7种编译器
在课程中,我们是从解析实际编译器入手的。而每一个真实的编译器里面都包含了大量的实战技术和知识点,所以你在学习的时候,很容易在某个点被卡住。那第一个问题,我想先给你解答一下,“真实编译器解析篇”这个模块的学习方法。
我们知道,学习知识最好能找到一个比较缓的坡,让自己可以慢慢爬上去,而不是一下子面对一面高墙。那么对于研究真实编译器,这个缓坡是什么呢?
我的建议是,你可以把掌握一个具体的编译器的目标,分解成四个级别的任务,逐步提高难度,直到最后吃透。
第一个级别,就是听一听文稿,看一看文稿中给出的示例程序和源代码的链接就可以了。
这个级别最重要的目标是什么?是掌握我给你梳理出来的这个编译器的技术主线,掌握一张地图,这样你就能有一个宏观且直观的把握,并且能增强你对编译原理的核心基础知识点的认知,就可以了。
小提示关于编译器的技术主线和知识地图你可以期待一下在期中复习周中即将发布的“划重点7种编译器的核心概念和算法”内容。
在这个基础上,如果你还想再进一步,那么就可以挑战第二级的任务。
第二个级别,是要动手做实验。
你可以运行一下我给出的那些使用编译器的命令,打印输出调试信息,或者使用一下课程中提到的图形化工具。
比如在Graal和V8编译器你可以通过修改命令行的参数观察生成的IR是什么样子。这样你就可以了解到什么情况下才会触发即时编译、什么时候才会触发内联优化、生成的汇编代码是什么样子的等等。
这样,通过动手做练习,你对这些编译器的认识就会更加具体,并且会有一种自己可以驾驭的感觉,赢得信心。
第三个级别,是看源代码,并跟踪源代码的运行过程,从而进入到编译器的内部,去解析一个编译器的真相。
完成这一级的任务对你动手能力的要求更高。你最容易遇到的问题是搭建一个调试环境。比如调试Graal编译器要采用远程调试的模式跟你调试一个普通应用还是不大一样的。而采用GDB、LLDB这样的工具对很多同学来说可能也是一个挑战。
而且你在编译源代码和调试的过程中也会遇到很多与配置有关的问题。比如我用GDB来调试Julia和MySQL的时候就发现最好是使用一个Linux虚拟机因为macOS对GDB的支持不够好。
不过,上述困难都不是说真的有多难,而是需要你的耐心。遇到问题就解决问题,最终搭建出一个你能驾驭的环境,这个过程也会大大提升你的动手实践能力。
环境搭建好了,在跟踪程序执行的过程中,一样要需要耐心。你可能要跟踪执行很多步,才能梳理出程序的执行脉络和实现思路。我在课程中建议的那些断点的位置和梳理出来程序的入口,可以给你提供一些帮助。
可以说,只要你能做好第三级的工作,终归是能吃透编译器的运行机制的。这个时候,你其实已经差不多进入了高手的行列。比如,在实际编程工作中,当遇到一个特别棘手的问题的时候,你可以跟踪到编译器、虚拟机的内部实现机制上去定位和解决问题。
而我前面说了,掌握一个具体的编译器的目标,是有四个级别的任务。那你可能要问,都能剖析源代码了,还要进一步挑战什么呢?
这第四个级别呢,就是把代码跟编译原理和算法结合起来,实现认识的升华。
在第三级,当你阅读和跟踪程序执行的时候,会遇到一个认知上的挑战。对于某些程序,你每行代码都能看懂,但为什么这么写,你其实不明白。
像编译器这样的软件,在解决每一个关键问题的时候,肯定都是有理论和算法支撑的。这跟我们平常写一些应用程序不大一样,这些应用程序很少会涉及到比较深入的原理和算法。
我举个例子在讲Java编译器中的语法分析器的时候我提到几点。第一它是用递归下降算法的第二它在避免左递归时采用了经典的文法改写的方法第三在处理二元表达式时采用了运算符优先级算法它是一种简单的LR算法。
我提到的这三点中的每一点,都是一个编译原理的知识点或算法。如果对这些理论没有具体的了解,那你看代码的时候就看不出门道来。类似的例子还有很多。
所以,如果你其实在编译原理的基础理论和算法上都有不错的素养的话,你会直接带着自己的假设去代码里进行印证,这样你就会发现每段程序,其实都是有一个算法去对应的,这样你就真的做到融会贯通了。
那如何才能达到第四级的境界,如何才能理论和实践兼修且互相联系呢?
第一,你要掌握“预备知识”模块中的编译原理核心基础知识和算法。
第二你要阅读相关的论文和设计文档。有一些论文是一些经典的、奠基性的论文。比如在讲Sea of Nodes类型的IR的时候我介绍了三篇重要的论文需要你去看。还有一些论文或设计文档是针对某个编译器的具体的技术点的这些论文对于你掌握该编译器的设计思路也很有帮助。
达到第四级的境界,你其实已经可以参与编译器的开发,并能成为该领域的技术专家了。针对某个具体的技术点加以研究和钻研,你也可以写出很有见地的论文。
当然,我不会要求每个同学都成为一个编译器的专家,因为这真的要投入大量的精力和实践。你可以根据自己的技术领域和发展规划,设定自己的目标。
我的建议是:
首先,每个同学肯定要完成第一级的目标。这一级目标的要求是能理解主线,有时候要多读几遍才行。
对于第二级目标我建议你针对2~3门你感兴趣的语言上手做一做实验。
对于第三级目标我希望你能够针对1门语言去做一下深入探索找一找跟踪调试一个编译器、甚至修改编译器的源代码的感觉。
对于第四级目标,我希望你能够针对那些常见的编译原理算法,比如前端的词法分析、语法分析,能够在编译器里找到并理解它们的实现。至于那些更加深入的算法,可以作为延伸任务。
总的来说呢,“真实编译器”这个模块的课程内容,为你的学习提供了开放式的各种可能性。
好,接下来,我就针对同学们的提问和课程的思考题,来做一下解析。
问题2多重分派是泛型实现吗
@d:“多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。”宫老师,多重分派是泛型实现吗?
由于大多数同学目前使用的语言,采用的都是面向对象的编程范式,所以会比较熟悉像这样的一种函数或方法派发的方式:
Mammal mammal = new Cow(); //Cow是Mammal的一个子类
mammal.speak();
这是调用了mammal的一个方法speak。那这个speak方法具体指的是哪个实现呢根据面向对象的继承规则这个方法可以是在Cow上定义的。如果Cow本身没有定义就去它的父类中去逐级查找。所以speak()具体采用哪个实现是完全由mammal对象的类型来确定的。这就是单一分派。
我们认为mammal对象实际上是speak方法的第一个参数虽然在语法上它并没有出现在参数列表中。而Java的运行时机制也确实是这么实现的。你可以通过查看编译生成的字节码或汇编代码来验证一下。你如果在方法中使用“this”对象那么实际上访问的是方法的0号参数来获取对象的引用或地址。
在采用单一分派的情况下,对于二元(或者更多元)的运算的实现是比较别扭的,比如下面的整型和浮点型相加的方法,你需要在整型和浮点型的对象中,分别去实现整型加浮点型,以及浮点型加整型的计算:
Integer a = 2;
Float b = 3.1;
a.add(b); //采用整型对象的add方法。
b.add(a); //采用浮点型对象的add方法。
但如果再增加新的类型怎么办呢?那么所有原有的类都要去修改,以便支持新的加法运算吗?
多重分派的情况,就不是仅仅由第一个参数来确定函数的实现了,而是会依赖多个参数的组合。这就能很优雅地解决上述问题。在增加新的数据类型的时候,你只需要增加新的函数即可。
add(Integer a, Float b);
add(Float b, Integer a);
add(Integer a, MyType b); //支持新的类型
不过,这里又有一个问题出现了。如果对每种具体的类型,都去实现一个函数的话,那么实现的工作量也很大。这个时候,我们就可以用上泛型了,或者叫参数化类型。
通过泛型的机制,我们可以让相同的实现逻辑只书写一次。在第三个模块“现代语言设计篇”中,专门有一讲给你进一步展开泛型的实现机制,到时你可以去深入学习下。
问题3安全点是怎么回事为什么编译器生成的某些汇编代码我看不懂
@智昂张智恩震请问老师和JVM握手就是插入safepoint的过程吗具体的握手是在做什么
你在查看编译器生成的汇编代码的时候,经常会看到一些辅助性的代码逻辑。它们的作用不是要把你的代码翻译成汇编代码才生成的,而是要去实现一些运行时机制。
我举几个例子。
第一个例子是做逆优化。比如V8中把函数编译成机器码是基于对类型的推断。如果实际执行的时候编译器发现类型跟推断不符就要执行逆优化跳转到解释器去执行。这个时候你就会看到汇编代码里有一些指令是用于做逆优化功能的。
第二个例子是在并行中会遇到的抢占式调度问题。协程这种并发机制是应用级的并发。一个线程上会有多个协程在运行。但是如果其中一个协程的运行时间很长就会占据太多的计算资源让这个线程上的其他协程没有机会去运行。对于一些比较高级的协程调度器比如Go语言的调度器就能够把一个长时间运行的协程暂停下来让其他协程来运行。怎么实现这种调度呢那就要编译器在生成的代码里去插入一些逻辑配合调度器去做这种调度。
第三个例子是垃圾收集。根据编译器所采用的垃圾收集算法在进行垃圾收集时可能会做内存的拷贝把一个对象从一个地方拷贝到另一地方。这在某些情况下会导致程序出错。比如当你读一个Java对象的成员变量的值的时候生成的汇编代码会根据对象的地址加上一定的偏移量得到该成员变量的地址。但这个时候这个对象的地址被垃圾收集器改变了那么程序的逻辑就错了。所以在做垃圾回收的时候相关的线程一定要停在叫做“安全点safepoint”的地方在这些地方去修改对象的地址程序就不会出错。
@智昂张智恩震 同学提出的问题就针对垃圾收集这种场景的。在Java生成的汇编代码里程序要在安全点去跟运行时做一下互动握手。如果需要的话当前线程就会被垃圾收集器停下以便执行垃圾收集操作。
所以你看,只有了解了一门语言的运行时机制,才能懂得为什么要生成这样的代码。关于垃圾收集和并发机制,我也会在第三个模块中跟你去做进一步的探讨。
问题4SSA只允许给变量赋一次值循环中的变量是多次赋值的不是矛盾了吗
@qinsi关于思考题SSA只允许给变量赋一次值如果是循环的话就意味着要创建循环次数那么多的临时变量了
@qinsi 同学问的这个问题其实挺深入,也很有意思。
是这样的。我们在做编译的时候大部分时候是做静态的分析也就是仅仅基于程序从词法角度Lexically的定义不看它运行时的状态。注意我这里说的词法不是指词法分析的词法而是指程序文本中体现的“使用和定义”use-def关系、控制流等。词法作用域Lexical Scope中的词法也是同一个意思。
所以SSA中说的赋值实际上是对该变量或称作值做了一个定义体现了变量之间的“使用和定义”use-def关系也就是体现了变量之间的数据依赖或者说是数据流因此可以用来做数据流分析从而实现各种优化算法。
小结
这一讲的答疑,我首先帮你梳理了学习真实世界编译器的方法。一个真实的编译器里涉及的技术和知识点确实比较多,但有挑战就有应对方法。我给你梳理了四级的学习阶梯,你探索内容的多少,也可以根据自己的需求和兴趣来把握。按照这个学习路径,你既可以去做一些宏观的了解,也可以在某个具体点上去做深入,这是一个有弹性的学习体系。
另外,我也挑了几个有意思的问题做了解答,在解答中也对涉及的知识点做了延伸和扩展。其中一些知识点,我还会在第三个模块中做进一步的介绍,比如垃圾收集机制、并发机制,以及泛型等。等你学完第三个模块,再回头看实际编译器的时候,你的认知会再次迭代。
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。

View File

@ -0,0 +1,105 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 易昊程序员不止有Bug和加班还有诗和远方
你好,我是编辑王惠。在处理这门课的留言时,我注意到易昊同学一直在跟随着宫老师的脚步,学习和实践编译原理的相关知识,留言的内容十分有见地、提出的问题也能看出是经过了他深入的思考。同时,咱们这门课也具有很强的互动性,所以我邀请他来和我们分享一下他的心得体会。
Hi我是易昊目前在武汉做Android开发已经工作12年了。很高兴能在这里跟你分享关于我学习编译原理的一些心得体会。
为什么我要再学编译原理?
首先我想给你解释一下我为什么会起“程序员不止有Bug和加班还有诗和远方”这样一个标题呢
这是因为作为一名应用开发者我经常会觉得自己只是在和源源不断的Bug以及项目进度作斗争日常工作好像已经无法给我带来技术上的成就感了。但我能肯定的是我对于技术的情怀并没有消失。我也认为我不应该只满足于完成日常的普通开发任务而是应该去做点更有挑战性的事情来满足自己精神上的追求。
那么,我为什么会选择学习编译技术呢?
首要的原因,是这门课的内容不像编程语言、数据结构那样清晰直观。加上在大学时期,学校安排的课时较短,只有半个学期,自己又没有对它足够重视起来,导致这门课只学了个一知半解,从而造成自己对计算机底层工作原理没有掌握透彻,留下了遗憾。
《程序员的自我修养链接、装载与库》里有句话让我印象深刻“真正了不起的程序员对自己程序的每一个字节都了如指掌。”虽然说得有点夸张但一个优秀的程序员确实应该理解自己的程序为什么能在计算机上运行起来。而不是在写完代码后忐忑不安地看着IDE在进行编译等终于能运行起来时大喊一声“哇能编译运行了好神奇”。所以工作了几年之后我一直想找机会能够弥补一下大学时期的遗憾把编译知识学扎实。
另外,编译技术的巨大挑战性,也是我想重拾学习的重要原因之一。
你可能听过一个段子:程序员的三大浪漫,是自己实现编译器、操作系统和渲染引擎。
其实一开始,我并不理解为什么编译器会在这其中,我猜大概是因为特别困难吧。
我刚接触编程的时候觉得能学好C、Java这样的编程语言已经很不容易了更何况要自己去用编译器实现一门语言。
而且编译原理这门课中还有相当多比较深奥、晦涩的理论想要掌握起来非常困难它完全不像学习普通技术那样几行代码就能运行一个hello world。同时你光有理论又完全不够编译器中包含了很多巧妙的工程化处理。比如会用抽象语法树来表示整个代码的语法结构在函数调用时参数和返回值在栈帧中的内存地址顺序等。
所以,不得不说,编译器是一个非常复杂的工程,编译原理是一个非常不容易消化、掌握的基础技术,让人望而生畏。
但是一旦掌握了之后,可以说就打通了计算机技术的任督二脉,因为你已经掌握了计算机运行中相当底层部分的原理,那么再去看其他技术就不会有什么大的障碍了。
而且在工作中即使没有什么机会需要自己去创造语言或者写编译器编译原理对我也很有帮助。举个例子吧Android有很多动态化的技术像ReactNative、Weex都会使用JavaScript作为脚本语言来调用原生接口那么为了实现原生语言如Java、Objective-C和JavaScript的通信ReactNative和Weex都在框架中嵌入了JavaScriptCore这个组件因为JavaSciptCore包含了一个JavaScript的解释器可以在运行时执行JavaScript。
那么如果你也想在自己的项目中使用类似的动态化技术但又不愿意被ReactNative和Weex技术绑死就需要自己去剖析JavaScriptCore的工作原理甚至要去实现一个类似的脚本语言执行框架。
而真的要自己去下载JavaScriptCore的源码来看你就会发现如果没有一定的编译原理知识作为基础是很难看懂的。但是如果你具备了编译原理知识基础其实会发现这些源码里面也不外乎就是词法分析、语法分析、IR等这些都是编译原理中的常用概念和算法。
还有就是日常工作中会碰到的某些比较棘手的问题,如果你不理解编译技术,可能就无法找到出现问题的根源。
比如我早期的工作中在开发C++代码的时候经常会遇到链接时找不到符号的问题那个时候我对“符号是怎么产生的”并不理解所以遇到这类问题我就会在IDE或者代码里盲目地尝试修改。
后来重新学习了编译技术之后,我就理解了编译器在编译过程中会产生符号表,每个符号包含了符号的名称、类型、地址等信息。之所以出现这类问题,可能是依赖的静态库没有包含这些符号,或者是类型不正确,再或者是这些符号的地址无法被正确解析,所以我用相应的工具去检查一下静态库文件的符号表,一般就可以解决问题了。
你看编译技术总能帮我解决一些我之前挠破头都想不出解决方案的问题。到现在我工作了十几年以后就会有一个越来越强烈的感悟最重要的还是底层知识。以前我也曾经感慨过计算机技术发展之快各种技术层出不穷就拿Android技术来说各种什么插件化、组件化、动态化技术让人眼花缭乱要不就是今天谷歌在提倡用Kotlin明天又开始推Flutter了。
所以有一段时间我比较迷茫,“我究竟该学什么呢”,但我后来发现,虽然那些新技术层出不穷,但万变不离其宗,计算机核心的部分还是编译原理、操作系统那些底层技术。如果你对底层技术真正吃透了,再来看这些时髦的新技术,就不会感到那么神秘了,甚至是可以马上就弄明白它背后的技术原理。原理都搞懂了,那掌握起来也就非常快了。
我是怎么学习专栏的?
我开始意识到自己需要重新学习编译技术是在2015年当时为了能够在Android的原生代码里运行JavaScript脚本我依次尝试了WebView、JavaScriptCore、Rhino等方案觉得这种多语言混合开发的技术挺强大也许能够改变主流的应用开发模式于是就想继续研究这些框架是怎么工作的。但是我发现自己对编译原理知识掌握得不牢导致学习这些技术的时候有点无从下手。
那个时候还没有极客时间这样针对性较强的学习平台,我是自己买了些书,有权威的龙书和虎书,也有《两周自制脚本语言》这样的速成书籍,但总是不得要领。
像龙书、虎书这样的,主要花了大量的篇幅在讲理论。但面对工作和家庭的压力,又不允许我有那么大片的时间去学习理论,而且没有实践来练习的话,也很容易忘掉。
像速成书籍这样的,虽然实现了一个很简单的编译器,但它大量的篇幅是在讲代码的一些细节,原理又讲得太少,知其然而不知其所以然。后来我就没有继续深入地学下去了。
直到偶然地在极客时间上看到了《编译原理之美》这门课,我简单看了下目录之后,就立马买了下来,因为感觉非常实用,既对理论部分的重点内容有深入浅出地讲解,也有与实际问题的紧密联系。学完这门课后,我感觉有信心去尝试写一点东西了,正好临近春节,就计划着在春节期间自己写些代码来验证学习的成果。结果不成想遇到了疫情封城,因为没法复工,就索性在家自己照着龙书和课程中给出的思路,看看能否自己实现个编译器出来。
结果我花了快两个月的时间真的写出来了一个简单的编译器能够把类似C语言风格的代码编译成汇编执行。
回想那段时间,虽然疫情很让人焦虑,但当我全身心地投入到对编译技术的钻研时,可以暂时忘记疫情带来的困扰。通过写这个小项目,我算是对编译器的工作过程有了个切身的体会,还把多年未碰的汇编又重新拾起来投入使用,可以说是收获颇丰,疫情带给我的回忆也没有那么痛苦了,从另一个角度看,甚至还有一定的成就感。
这个简单的编译器项目完成了之后,就激发了我更大的兴趣。因为我毕竟只是实现了一个玩具型的编译器,那么工业界的编译器是如何工作的呢?
这个编译器我是用LL算法来实现的语法分析但龙书上还大篇幅地讲了LR、SLR、LALR那么实际中到底是使用LL算法还是LR算法呢
还有,我的编译器没有什么优化的功能,真实的编译器都有哪些优化措施呢?
这些问题吸引着我要去寻找答案。结果正巧,宫老师又推出了《编译原理实战课》,深入浅出地讲解各大编译器的工作原理,这可正好对我的胃口,于是又毫不犹豫地买下了。
上了几节课之后觉得收获很大特别是讲解javac和Graal编译器的部分。这两部分都给出了如何基于源码去剖析编译器的原理实际操作性很强我跟着宫老师给出的步骤下载和一步步地调试、跟踪源码印象十分深刻。特别是宫老师介绍的javac在处理运算符优先级时引入了LR算法从而避免了教科书上介绍的当使用LL方式时为了消除左递归要写多级Tail函数的问题这些都让我对编译技术的实际应用理解得更加深刻了。在学习Graal时我也是花了不少的时间去配置Windows环境包括下载安装Kali Linux、OpenJDK等才终于把Graal跑起来。通过这样的实际操作体验到了“折腾”的乐趣对动手能力也是一种锻炼。
除此之外,课程中还有丰富的流程图、类图和思维导图,因此我可以按图索骥,去研究自己感兴趣的知识点,不用再苦苦地从海量的源码中去大海捞针,学习效率得到了很大提升。
如何更好地学习编译原理?
通过这段时间的学习后,我发现,编译原理其实并没有想象中的那么困难。我觉得计算机技术的一个特点就是像在搭积木,有时候只是知识点多、层次关系复杂而已。
编译技术尤其如此从前端到后端从词法分析到语法分析到语义分析、IR最后到优化。这些地方包含着各种知识点但这些知识也不是凭空变出来的而是环环相扣、层层叠加出来的。因此你在学习编译技术时一定要静下心来一点一点地去吃透里面的技术细节并且还需要不断地总结和提炼否则对知识的理解可能就不够透彻浮于表面。
另外你需要多去动手实践特别是和算法相关的部分。比如在编译器的语法分析阶段有个内容是计算First集合和Follow集合其实理解起来并不困难但它包含的细节不少容易算错所以需要在课后多去练习。
我就是在课下把宫老师布置的习题和龙书上相关的题目都算了一遍还写了计算First集合和Follow集合的程序。不过就算是做到了这些我感觉对这部分的理解还不够透彻但是我对编译技术的恐惧感已经消除了对后续进一步的深入挖掘也打下了基础。所以说静下心来学勤动手去练对学习编译原理这门课程来说非常重要。
我还有一个体会就是学习编译原理没有捷径可走。因为编译技术是一个复杂而精密的工程它的知识是环环相扣的。比如说如果你对词法分析和语法分析的知识掌握得不够牢固不熟悉一些常用语法规则的推导过程那后面的IR、三地址代码、CFG等你就会学得一头雾水。
所以,我们只能够一步一个脚印地去学习。
当然了如果你和我一样是一个有家有口的上班族只利用碎片时间可能不好做到连续学习那我建议你在学习课程的时候可以稍微花点时间去参考一下宫老师介绍的开源编译器代码比如JavaCompiler的源码、Graal的源码。这样就可以和课程中的内容结合起来。因为看代码和调试代码会更加直观也更容易理解。
同时,你也可以看看别人的代码设计,有哪些地方是可以做成组件的,哪些函数是可以自己去实现来练手的。然后,你可以先给自己定一个小目标,就是利用业余时间去完成这些组件或者函数的开发,因为单个组件的工作量并不是太大,因此还是可以尝试完成的。这样在巩固理论知识的同时,还能锻炼自己的动手能力,我想热爱编程的你,应该可以从中得到不少快乐。
我相信,只要最终坚持下来,你和我,都可以掌握好编译这门“屠龙”技术。

View File

@ -0,0 +1,451 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
知识地图 一起来复习编译技术核心概念与算法
你好,我是学习委员朱英达。
在“预备知识篇”这个模块,宫老师系统地梳理了编译过程中各个阶段的核心要点,目的就是让我们建立一个编译原理的基础知识体系。那到今天为止,我们就学完了这部分内容,迈出了编译之旅中扎实的第一步。不知道你对这些知识掌握得怎样了?
为了复习,也为了检测我们的学习成果,我根据自己的知识积累和学习情况,整理了一张知识大地图,你可以根据这张地图中标记的七大编译阶段,随时速查常用的编译原理概念和关键算法。
如果你也总结了知识地图,那你可以对照着我这个,给自己一个反馈,看看它们之间有哪些异同点,我们可以在留言区中一起交流和讨论。
不过知识地图的形式,虽然便于你保存、携带、速查,但考虑到图中涉及的概念等内容较多,不方便查看和检索。所以,我还把地图上的知识点,用文字的形式帮你梳理出来了。你可以对照着它,来复习和回顾编译技术的核心概念和算法的知识点,构建自己的知识框架。
你在学习这些预备知识的过程中,可能会发现,宫老师并没有非常深入地讲解编译原理的具体概念、理论和算法。所以,如果你想继续深入学习这些基础知识,可以根据宫老师在每讲最后给出的参考资料,去学习龙书、虎书、鲸书等经典编译原理书籍。当然,你也可以去看看宫老师的第一季专栏课《编译原理之美》。
在我看来,相较于编译方面的教科书而言,《编译原理之美》这门课的优势在于,更加通俗易懂、与时俱进,既可以作为新手的起步指导,也能够帮助已经熟悉编译技术的工程师扩展视野,我很推荐你去学习这门课。所以,我邀请编辑添加了相应的知识点到《编译原理之美》的文章链接,如果你有深入学习的需要,你会很方便地找到它。
好了,一起开始复习吧!
一、词法分析根据词法规则把字符串转换为Token
核心概念:正则文法
正则文法:词法分析工作的主要文法,它涉及的重要概念是正则表达式。
正则表达式:正则文法的一种直观描述,它是按照指定模式匹配字符串的一种字符组合。
正则表达式工具字符串的模式匹配工具。大多数程序语言都内置了正则表达式的匹配方法也可以借助一些编译工具自动化根据正则表达式生成字符串匹配程序例如C++的Lex/Yacc以及Java的ANTLR。
具体实现:手工构造词法分析器、自动生成词法分析器
手工构造词法分析器
构造词法分析器使用的计算模型有限自动机FSA。它是用于识别正则文法的一种程序实现方案。
其组成的词法单元是Token也就是指程序中标记出来的单词和标点符号它可以分成关键字、标识符、字面量、操作符号等多个种类。
在实际的编译器中,词法分析器一般都是手写的。
自动生成词法分析器
具体实现思路把一个正则表达式翻译成NFA然后把NFA转换成DFA。
DFA确定的有限自动机。它的特点是该状态机在任何一个状态基于输入的字符都能做一个确定的状态转换。
NFA不确定的有限自动机。它的特点是该状态机中存在某些状态针对某些输入不能做一个确定的转换。这里可以细分成两种情况一种是对于一个输入它有两个状态可以转换另一种是存在ε转换的情况也就是没有任何字符输入的情况下NFA也可以从一个状态迁移到另一个状态。
技术难点
首先你需要注意NFA和DFA都有各自的优缺点以及不同的适用场景。
NFA优点是在设计上更简单直观缺点是它无法避免回溯问题在某些极端的情况下可能会造成编译器运行的性能低下。主要适用于状态较为简单且不存在回溯的场景。
DFA优点是它可以避免回溯问题运行性能较高缺点是DFA通常不容易直接设计出来需要通过一系列方案基于NFA的转换而得到并且需要占用额外的空间。主要适用于状态较为复杂或者对时间复杂度要求较为严苛的工业级词法分析器。
其次你需要了解基于正则表达式构造NFA再去进行模式匹配的算法思路。
从正则表达式到NFA这是自动生成词法分析器的一种算法思路。它的翻译方法是匹配一个字符i —>匹配“或”模式s|t —> 匹配“与”模式st —> 重复模式,如“?”“*”和“+”等符号它们的意思是可以重复0次、0到多次、1到多次注意在转换时要增加额外的状态和边。
从NFA到DFANFA的运行可能导致大量的回溯所以我们可以把NFA转换成DFA让字符串的匹配过程更简单。从NFA转换成DFA的算法是子集构造法具体的算法思路你可以参考第16讲。
二、语法分析:依据语法规则,编写语法分析程序,把 Token 串转化成 AST
核心概念:上下文无关文法
上下文无关的意思:在任何情况下,文法的推导规则都是一样的。
语法规则由4个部分组成一个有穷的非终结符或变元的集合、一个有穷的终结符的集合、一个有穷的产生式集合、一个起始非终结符变元。符合这四个特点的文法规则就是上下文无关文法。
两种描述形式一种是巴科斯范式BNF另一种是巴科斯范式的一种扩展形式EBNF它更利于自动化生成语法分析器。其中产生式、终结符、非终结符、开始符号是巴科斯范式的基本组成要素。
上下文无关文法与正则文法的区别:上下文无关文法允许递归调用,而正则文法不允许。上下文无关文法比正则文法的表达能力更强,正则文法是上下文无关文法的一个子集。
具体实现:自顶向下、自底向上
一种是自顶向下的算法思路它是指从根节点逐层往下分解形成最后的AST。
递归下降算法它的算法思路是按照语法规则去匹配Token串。优点程序结构基本上是跟文法规则同构的。缺点会造成左递归和回溯问题。注意递归下降是深度优先DFS只有最左边的子树都生成完了才会往右生成它的兄弟节点。
LL算法对于一些比较复杂的语法规则来说这个算法可以自动计算出选择不同产生式的依据。方法从左到右地消化掉 Token。要点计算 First 和 Follow 集合。
另一种是自底向上的算法思路它是指从底下先拼凑出AST的一些局部拼图并逐步组装成一棵完整的AST。
自底向上的语法分析思路移进把token加入工作区规约在工作区内组装AST的片段。
LR算法和 LL 算法一样,也是从左到右地消化掉 Token。
技术难点
首先你需要掌握LL算法的要点也就是计算First和Follow集合。
其次你要了解LL算法与LR算法的异同点。
LL算法优点是较为直观、容易实现缺点是在一些情况下不得不处理左递归消除和提取左因子问题。
LR算法优点是不怕左递归缺点是缺少完整的上下文信息编译错误显示不友好。
三、语义分析:检查程序是否符合语义规则,并为后续的编译工作收集语义信息
核心概念:上下文相关文法
属性文法上下文相关文法对EBNF进行了扩充在上下文无关的推导过程中辅助性解决一些上下文相关的问题。
注意上下文相关文法没有像状态图、BNF那样直观的分析范式。
应用场景:控制流检查、闭包分析、引用消解等。
场景案例
1.控制流检查
像return、break 和continue等语句都与程序的控制流有关它们必须符合控制流方面的规则。在 Java 这样的语言中,语义规则会规定:如果返回值不是 void那么在退出函数体之前一定要执行一个 return 语句,那么就要检查所有的控制流分支,是否都以 return 语句结尾。
2.闭包分析
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
具体实现:引用消解、符号表、类型系统、属性计算
引用消解
概念解释:引用消解是一种非常重要的上下文相关的语义规则,它其实就是从符号表里查找被引用的符号的定义。
作用域:指计算机语言中变量、函数、类等起作用的范围。对于变量来说,为了找到正确的引用,就需要用到作用域。一般来说,它有两种使用场景,一种是标识符作用域,一种是词法作用域。
符号表
符号表内包含的信息:名称、分类、类型、作用域等。
存储形式:线性表格、层次化表格。
符号表的作用维护程序中定义的标识符ID类Token提供给编译器的各个环节进行操作。
建立符号表的过程整个编译器前端都会涉及到词法分析阶段将ID类Token收集到符号表中语法分析阶段可进行读取和补充语义分析阶段做引用消解时符号表的作用至关重要。
注意:符号表跟编译过程的多个阶段都相关。
类型系统
类型:在计算机语言里,类型是数据的一个属性,它的作用是来告诉编译器或解释器,程序可以如何使用这些数据。
类型系统:类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。
类型检查:这是与类型有关的分析和处理工作之一。主要用于对源程序代码中的一些类型不匹配的情况进行隐式类型转换或直接抛错。
子类型:面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。把这里的结论抽象成一般意义上的类型理论,就是子类型。
类型转换比如说表达式“a=b+10”如果 a 的类型是浮点型,而右边传过来的是整型,那么一般就要进行缺省的类型转换。
参数化类型/泛型:泛型是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。
属性计算
编译器一般会采用属性计算的方法,来计算出每个 AST 节点的类型属性,然后检查它们是否匹配。
属性文法属性计算的方法就是基于语法规则来定义一些属性计算的规则在做语法解析或遍历AST的时候执行这些规则我们就可以计算出属性值。这种基于语法规则定义的计算规则被叫做属性文法Attribute Grammar
属性计算S属性综合属性、I属性继承属性
形成的数据结构Annotated AST带有标注信息的AST
语法制导的翻译:属性计算的特点是会基于语法规则,增加一些与语义处理有关的规则,我们把这种语义规则的定义叫做语法制导的定义,如果变成计算动作,就叫做语法制导的翻译。
四、运行时机制:程序的两种不同的执行模式
通常情况下,程序有两种执行模式:基于物理机、基于虚拟机。
在物理机上运行
举例C、C++、Golang。
程序运行的原理:基于指令指针寄存器的控制,顺序从内存读取指令执行。
CPU运行指令的地方。
多种架构x86、ARM、MIPS、RISC-V、PowerPC等。
关键构成:寄存器、高速缓存、功能单元。
汇编代码:操作码(让 CPU 执行的动作)、操作数(指令的操作对象,可以是常数、寄存器和某个内存地址)。
内存:执行指令相关的另一个硬件。
1.代码区:存放编译完成以后的机器码。-
2.静态数据区:保存程序中全局的变量和常量。-
3.栈:适合保存生存期比较短的数据,比如函数和方法里的本地变量。
重要知识点:栈帧的构成、活动记录、逐级调用过程。
栈的特点:申请和释放—修改栈顶指针,生存期与作用域相同。
4.堆:适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。
重要知识点通过操作系统API手动申请和释放。
管理机制:自动管理、手动管理。
操作系统:除了硬件支撑,程序的运行还需要软件,这就是运行时系统。
定义:除了硬件支撑,程序的运行还需要软件,这些软件叫做运行时系统。
操作系统:对于把源代码编译成机器码在操作系统上运行的语言来说(比如 C、C++),操作系统本身就可以看做是它们的运行时系统。
管理CPU资源分时执行。比如时间片轮转算法将CPU以时钟周期为单元执行多进程任务实现并发。
管理内存资源逻辑内存系统内核对内存地址的划定、物理内存硬件中具体每一个bit实际的硬件存储情况、虚拟内存基于操作系统内核对内存管理问题的抽象通常有一部分虚拟内存实际上是存在磁盘上的
在虚拟机上运行
举例Java、Python、Erlang、Lua。
程序运行的原理:虚拟机是计算机语言的另一种运行时系统。虚拟机上运行的是中间代码,而不是 CPU 可以直接认识的指令。
基于栈的虚拟机指令在操作数栈的栈顶获取操作数如JVM、Python虚拟机
优点:易于生成代码。
缺点:代码数量较多、不能充分利用寄存器。
基于寄存器的虚拟机类似于物理机从寄存器取操作数如Erlang、Lua、Dalvik、Ignition
优点与缺点:与栈机相反。
二者的区别:主要在于如何获取指令的操作数。
五、中间代码:运行各种优化算法、代码生成算法的基础
在这门课程中宫老师主要从用途和层次、解释执行、呈现格式和数据结构等角度来给你讲解IR这一关键概念。如果你想要更深入地了解IR的特点理解如何生成IR来实现静态编译的语言你可以去看《编译原理之美》的第24、25、26讲。
IR的用途和层次从抽象层次的角度来划分
第一类用途基于源语言做一些分析和变换HIR
第二类用途独立于源语言和CPU架构做分析和优化MIR
第三类用途依赖于CPU架构做优化和代码生成LIR
IR的解释执行
P-code直接用于解释执行的IR。由于它与具体机器无关因此可以很容易地运行在多种电脑上。这类IR对编译器来说就是做编译的目标代码。
注意P-code也可能被进一步编译形成可以直接执行的机器码。如Java的字节码。
IR的呈现格式
大部分IR没有像源代码和汇编代码那样的书写格式。
大多数的IR跟AST一样只是编译过程中的一个数据结构而已或者说只有内存格式。比如LLVM的IR在内存里是一些对象和接口。
为了调试的需要你可以把IR以文本的方式输出用于显示和分析。
IR的数据结构
第一种类似TAC的线性结构。
第二种:树结构。
第三种DAG-有向无环图。
第四种PDG-程序依赖图。
SSA格式的IR
概念SSA即静态单赋值。这是IR的一种设计范式它要求一个变量只能被赋值一次。
要点使用SSA的形式体现了精确的“使用-定义Use-def”关系并且由于变量的值定义出来以后就不再变化使得基于SSA更容易运行一些优化算法。
注意现代语言用于优化的IR很多都是基于SSA的包括Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器以及LLVM工具等。
六、代码分析与优化:优化程序对计算机资源的使用,以提高程序的性能
优化分类
是否与机器有关
机器无关:指与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。
机器有关:需要利用某硬件特有的特征,比如 SIMD 指令可以在一条指令里完成多个数据的计算。
优化范围
本地优化/局部优化:基本块内。
全局优化:函数(过程)内。
过程间优化:跨函数(过程)。
优化方法
1.把常量提前计算出来
常数折叠程序里的有些表达式肯定能计算出一个常数值那就不要等到运行时再去计算干脆在编译期就计算出来。比如“x=2*3”可以优化成“x=6” 。
常数传播:如果你一旦知道 x 的值其实是一个常量,那你就可以把所有用到 x 的地方,替换成这个常量。
稀疏有条件的常数传播:基于常数传播,还可以进一步导致分支判断条件转化为常量,导致一个分支的代码不会被执行。
2.用低代价的方法做计算
代数简化利用代数运算规则所做的简化比如“x=x*0”可以简化成“x=0”。
3.消除重复的计算
拷贝传播:遇到相同引用的变量,拷贝替换为同一个,节省内存到寄存器的操作,以此提升运算速度。
值编号VN和GVN把相同的值在系统里给一个相同的编号并且只计算一次。
公共子表达式消除CSE也会减少程序的计算次数。比如“x:=a+b”和“y:=a+b”x和y右边的形式是一样的就可以让y等于x从而减少了一次对“a+b”的计算。
部分冗余消除PRE是公共子表达式消除的一种特殊情况。
4.化零为整,向量计算
超字级并行SLP把基本块中的多个变量组成一个向量用一个指令完成多个变量的计算。
循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
5.化整为零,各个优化
聚合体的标量替换SROA很多语言都有结构和对象这样的复合数据类型内部包含了多个成员变量这种数据类型叫做聚合体aggregates
编译器可以把聚合体的成员变量放在寄存器中进行计算,不需要访问内存。
6.针对循环,重点优化
归纳变量优化:归纳变量是指在循环体内由循环变量派生出来的变量,其变化是很有规律的,因此可以尝试做强度折减优化。
边界检查消除:在循环体内每次循环都会执行的边界检查代码,将其整合抽离出来,避免每次重复判断。
循环展开:通过把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。
循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
重组:在循环结构中,使用代数简化和重组,能获得更大的收益。
循环不变代码外提LICM在循环结构中如果发现有些代码其实跟循环无关那就应该提到循环外面去避免一次次重复计算。
代码提升:在条件语句中,如果多个分支条件里都执行了同一句代码,可将其提升至判断条件之前;如果是在循环体内,还可以继续借助循环不变代码外提优化,进一步提升到循环体之外,从而降低计算量。
7.减少过程调用的开销
尾调用优化和尾递归优化:尾调用就是一个函数的最后一句,是对另一个函数的调用。如果函数最后一句调用的函数是自己,就称为尾递归。尾调用可以将函数调用栈合并,尾递归还可以转换成循环,从而进一步做一系列针对循环语句的优化工作。
内联:内联也叫做过程集成,就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。
内联扩展:内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
叶子程序优化:叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。
8.对控制流做优化
不可达代码的消除:根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如 return 语句后面的代码。
死代码删除:通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。
if简化在讲常量传播时我们就见到过如果有可能if条件肯定为真或者假那么就可以消除掉 if 结构中的then块、else块甚至整个消除if结构。
循环简化:也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。
循环反转:这是对循环语句常做的一种优化,就是把一个 while 循环改成一个 repeat…until 循环(或者 do…while 循环)。这样会使基本块的结构更简化,从而更有利于其他优化。
拉直:如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。
反分支:也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的 if 分支挪到循环外面去,先做 if 判断,然后再执行循环,这样总的执行 if 判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。
分析方法
控制流分析CFA 基于程序的控制语句(如条件语句、循环语句、分支语句和基本块语句等)进行分析,建立对程序执行过程的理解,从而进一步做出优化。
数据流分析DFA基于数据流分析框架包含“方向(D)”“值(V)”“转换函数(F)”“初始值(I)”和“交运算(Λ)”5 个元素)等方式,建立对程序中数据变化情况的理解,从而进一步做出优化。
依赖分析分析出程序代码的控制依赖Control Dependency和数据依赖Data Dependency关系。这对指令排序和缓存优化很重要。
别名分析:在 C、C++ 等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
优化方法的重要性和顺序
重要性
对所有语言都重要:循环优化等。
面向对象语言:内联、逃逸等。
函数式语言:尾递归优化等。
顺序
要点:机器无关-早期,机器相关-后期。
注意:一个优化会导致另一个优化,同一个优化会多遍运行。
七、目标代码生成:编译器最后一个阶段的工作,生成针对不同架构的目标代码,也就是生成汇编代码
生成针对不同架构的目标代码
x86CISC指令Register-Memory架构。在指令里可以混合使用内存地址和寄存器。
ARMRISC指令Load-Store架构。在ARM的汇编中从寄存器到内存要使用str也就是Store指令而从内存到寄存器要使用ldr也就是Load指令。在这种架构下指令被分为内存访问Load和Store和ALU操作两大类且后者只能在寄存器上操作。
策略编写“代码生成器的生成器”。也就是把CPU架构的各种信息描述出来基于这些信息生成目标代码的生成器就像根据语法规则用ANTLR、bison这样的工具来生成语法解析器一样。
生成目标代码时的优化工作
1.指令选择
做指令选择的原因:生成更精简、性能更高的代码;使得同一个功能可以由多种方式实现。
算法树覆盖算法、自底向上的重写系统BURS
2.寄存器分配
原理:两个变量,不同时活跃,可以共享寄存器。
算法:图染色算法(优点-更优化,缺点-速度慢)、线性扫描算法(优点-不够优化,缺点-速度快)
3.指令排序
原理CPU内部的单元可并行。
实现基于数据依赖图的List Scheduling算法。
4.窥孔优化
思路提供一个固定大小的窗口比如能够容纳10条指令并检查窗口内的指令看看是否可以优化然后往下滑动窗口再次检查优化机会。
调用约定的影响
调用约定你可以发明自己的调用约定比如哪些寄存器用来放参数、哪些用来放返回值。但是如果要使用别的语言编译好的目标文件或者想让自己的编译器生成的目标文件被别人使用那就要遵守某种特定的ABI标准。
Unix和Linux系统的调用约定System V AMD64 ABI。
ABI即Application Binary Interface应用程序的二进制接口。
整体的处理过程
典型过程:指令选择->指令排序->寄存器分配->指令排序->Emit目标代码
要点基于LIR并且不断lower。
知识地图

View File

@ -0,0 +1,111 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 实战是唯一标准!
你好,我是宫文学。
转眼之间,“编译原理实战课”计划中的内容已经发布完毕了。在这季课程中,你的感受如何?有了哪些收获?遇到了哪些困难?
很多同学可能会觉得这一季的课程比上一季的“编译原理之美”要难一些。不过为什么一定要推出这么一门课,来研究实际编译器的实现呢?这是因为我相信,实战是检验你是否掌握了编译原理的唯一标准,也是学习编译原理的真正目标。
计算机领域的工程性很强。这决定了我们学习编译原理,不仅仅是掌握理论,而是要把它付诸实践。在我们学习编译原理的过程中,如果遇到内心有疑惑的地方,那不妨把实战作为决策的标准。
这些疑惑可能包括很多,比如:
词法分析和语法分析工具,应该手写,还是用工具生成?
应该用LL算法还是LR算法
后端应该用工具,还是自己手写?
我是否应该学习后端?
IR应该用什么数据结构
寄存器分配采用什么算法比较好?
……
上述问题,如果想在教科书里找到答案,哪怕是“读万卷书”,也是比较难的。而换一个思路,“行万里路”,那就很容易了。你会发现每种语言,因为其适用的领域和设计的目标不同,对于上述问题会采用不同的技术策略,而每种技术策略都有它的道理。从中,你不仅仅可以为上述问题找到答案,更重要的是学会权衡不同技术方案的考虑因素,从而对知识点活学活用起来。
我们说实战是标准。那你可能会反问,难道掌握基础理论和原理就不重要了吗?这是在很多领域都在争论的一个话题:理论重要,还是实践重要。
理论重要,还是实践重要?
理论和原理当然重要,在编译原理中也是如此。形式语言有关的理论,以及前端、中端和后端的各个经典算法,构筑了编译原理这门课坚实的理论基础。
但是,在出现编译原理这门课之前,在出现龙书虎书之前,工程师们已经在写编译器了。
你在工作中,有时候就会遇到理论派和实践派之争。举例来说,有时候从理论角度,某一个方案会“看上去很美”。那到底是否采用该方案呢?这个时候,就需要拿实践来说话了。
我拿Linux内核的发展举个例子。当年Linus推出Linux内核的时候并没有采用学术界推崇的微内核架构为此Linus还跟Minix的作者有一场著名的辩论。而实践证明Linux内核发展得很成功而GNU的另一个采用微架构的内核Hurd发展了20多年还没落地。
客观地说Linux内核后来也吸收了很多微内核的设计理念。而声称采用微内核架构的Windows系统和macOS系统其实在很多地方也已经违背了微内核的原则而具备Linux那样的单内核的特征。之所以有上述的融合其实都是一个原因就是为了得到更好的实用效果。所以实践会为很多历史上的争论划上句号。
在编译技术和计算机语言设计领域,也存在着很多的理论与实践之争。比如,理论上,似乎函数式编程更简洁、更强大,学术界也很偏爱它,但是纯函数的编程语言,至今没有成为主流,这是为什么呢?
再比如,是否一定要把龙书虎书都读明白,才算学会了编译原理呢?
再进一步,如果你使用编译技术的时候,遇到一个实际的问题,是跟着龙书、虎书还有各种课本走,还是拿出一个能解决问题的方案就行?
在课程里,我鼓励你抛弃一些传统上学习编译原理的困扰。如果龙书、虎书看不明白,那也不用过于纠结,这并不会挡住你学习的道路。多看实际的编译器,多自己动手实践,在这个过程中,你自然会明白课本里原来不知所云的知识点。
那么如何以实践为指导,从而具备更好的技术方案鉴别力呢?在本课程里,我们有三个重点。包括研究常用语言的编译器、从计算机语言设计的高度来理解编译原理,以及从运行时的实现来理解编译原理。
对于你所使用的语言,应该把它的编译器研究透
这门课程的主张是,你最好把自己所使用语言的编译器研究透。这个建议有几个理由。
第一因为这门语言是你所熟悉的所以你研究起来速度会更快。比如可以更快地写出测试用的程序。并且由于很多语言的编译器都已经实现了自举比如说Go语言和Java语言的编译器所以你可以更快地理解源代码以及对编译器本身做调试。
第二这门语言的编译器所采用的实现技术一定是体现了该语言的特性的。比如V8会强调解析速度快Java编译器要支持注解特性等值得你去仔细体会。
第三研究透编译器会加深你对这门语言的理解。比如说你了解清楚了Java的编译器是如何处理泛型的那你就会彻底理解Java泛型机制的优缺点。而C++的模板机制对于学习C++的同学是有一定挑战的。但一旦你把它在编译期的实现机制弄明白就会彻底掌握模板机制。我也计划在后续用一篇加餐把C++的模板机制给你拆解一下。
那么,既然编译器是为具体语言服务的,所以,我们也在课程里介绍了计算机语言设计所考虑的那些关键因素,以及它们对编译技术的影响。
从计算机语言设计的高度,去理解编译技术
在课程里你已经体会到了,语言设计的不同,必然导致采用编译技术不同。
其实,从计算机语言设计的高度上看,编译器只是实现计算机语言的一块底层基石。计算机语言设计本身有很多的研究课题,比如类型系统、所采用的编程范式、泛型特性、元编程特性等等,我们在课程里有所涉猎,但并没有在理论层面深挖。有些学校会从这个方向上来培养博士生,他们会在理论层面做更深入的研究。
什么样的计算机语言是一个好的设计?这是一个充满争议的话题,我们这门课程尽量不参与这个话题的讨论。我们的任务,是要知道当采用不同的语言设计时,应该如何运用编译技术来落地。特别是,要了解自己所使用的语言的实现机制。
如果说计算机语言设计,是一种偏理论性的视角,那么程序具体的运行机制,则是更加注重落地的一种视角。
从程序运行机制的角度,去理解编译技术
学习编译原理的一个挑战,就在于你必须真正理解程序是如何运行的,以及程序都可以有哪几种运行方式。这样,你才能理解如何生成服务于这种运行机制的目标代码。
最最基础的你需要了解像C语言这样的编译成机器码直接运行的语言它的运行机制是怎样的。代码放在哪里又是如何一步步被执行的。在执行过程中栈是怎么变化的。函数调用的过程中都发生了些什么事情。什么数据是放在栈里的什么数据是放在堆里的等等。
在此基础上如果从C语言换成C++呢C++多了个对象机制那对象在内存里是一个什么结构多重继承的时候是一个什么结构在存在多态的时候如何实现方法的正确绑定这些C++比C语言多出来的语义你也要能够在运行时机制中把它弄清楚。
再进一步到了Go语言仍然是编译成机器码运行的但跟C和C++又有本质区别。因为Go语言的运行时里包含了垃圾收集机制和并发调度机制这两个机制要跟你的程序编译成的代码互相配合所以编译器生成的目标代码里要体现内存管理和并发这两大机制。像Go语言这种特殊的运行机制还导致了跨语言调用的难度。用Go语言调用C语言的库要有一定的转换和开销。
然后呢语言运行时的抽象度进一步增加。到了Java语言就用到一个虚拟机。字节码也正式登台亮相。你需要知道栈机和寄存器机这两种不同的运行字节码的解释器也要知道它们对应的字节码的差别。而为了提升运行速度JIT、分层编译和逆优化机制又登场栈上替换OSR技术也产生。这个时候你需要理解解释执行和运行JIT生成的本地代码是如何无缝衔接的。这个时候的栈桢又有何不同。
然后是JavaScript的运行时机制就更加复杂了。V8不仅具备JVM的那些能力在编译时还要去推断变量的类型并且通过隐藏类的方式安排对象的内存布局以及通过内联缓存的技术去加快对象属性的访问速度。
这样从最简单的运行时,到最复杂的虚拟机,你都能理解其运行机制的话,你其实不仅知道在不同场景下如何使用编译技术,甚至可以参与虚拟机等底层软件的研发了。
不再是谈论,来参与实战吧!
今天,我们学习编译原理,目标不能放在考试考多少分上。中国的技术生态,使得我们已经能够孕育自己的编译器、自己的语言、自己的虚拟机。方舟编译器已经带了个头。我想,中国不会只有方舟编译器孤军奋战的!
就算是开发普通的应用软件,我们也要运用编译技术,让它们平台化,让中国制造的软件,具有更高的技术含量,颠覆世界对于“中国软件”的品牌认知。这样的颠覆,在手机、家电等制造业已经发生了,也应该轮到软件业了。
而经验告诉我们,一旦中国的厂商和工程师开始动起来,那么速度会是非常快的。编译技术并没有多么难。我相信,只要短短几年时间,中国软件界就会在这个领域崭露头角!
这就是我们这门课程的目的。不是为了学习而学习,而是为了实战而学习。
当然课程虽然看似结束了但也代表着你学习的重新开始。后面我计划再写几篇加餐会针对C++、Rust等编译器再做一些解析拓展你的学习地图。并且针对方舟编译器我还会进一步跟你分享我的一些研究成果希望我们可以形成一个持续不断地对编译器进行研究的社群让学习和研究不断深入下去不断走向实用。
另外我还给你准备了一份毕业问卷题目不多希望你能在问卷里聊一聊你对这门课的看法。欢迎你点击下面的图片用12分钟时间填写一下期待你畅所欲言。当然了如果你对课程内容还有什么问题也欢迎你在留言区继续提问我会持续回复你的留言。
我们江湖再见!

View File

@ -0,0 +1,120 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 练好基本功,优秀工程师成长第一步
你好我是彭东网名LMOS。很高兴在极客时间和你相遇一起开启计算机基础的修炼之旅。
先来介绍一下我自己。我是 Intel 傲腾项目开发者之一,曾经为 Intel 做过内核层面的开发工作,也对 Linux、BSD、SunOS 等开源操作系统,还有 Windows 的 NT 内核很熟悉。
这十几年来我一直专注于操作系统内核研发。先后开发了LMOS基于x86_64的多进程支持SMP的操作系统和LMOSEM基于ARM32支持软实时的嵌入式操作系统还写过《深度探索嵌入式操作系统》一书。去年5月份我在极客时间上更新了《操作系统实战45讲》这个专栏和你分享了我多年来开发操作系统的方法和经验。
通过课程的互动交流,我发现很多同学因为基础知识并不扎实,所以学操作系统的时候非常吃力。而计算机的基础知识,不但对于深入理解操作系统有帮助,对我们工程师的技术提升也是一门长期收益的必修课。
打牢计算机基础有什么用?
就拿我的亲身经历来说我既做过前端、后端的工作也做过内核的开发。出现Bug和故障的时候我总能快速理清排查思路选用合适的工具、技术来分析问题高效Debug一个项目摆在我面前迅速分析出项目的痛点、难点整理出实现功能需要哪些技术框架也是驾轻就熟。
很多同事跟朋友对这样的能力心向往之,好奇我有什么“秘诀”。其实,能来回穿梭于底层与高层之间,不至于手忙脚乱,我最大的依仗就是深厚的计算机基础。
无论你是计算机初学者,还是已经工作了几年的老同学,对于“打牢基础很重要”、“基础不牢、地动山摇”这样的话,估计耳朵都要听得磨出茧子了。但到底计算机基础威力有多大呢?
举个例子就像你编写你人生的第一个程序——Hello World。这个程序非常简单同时也非常复杂简单到你只要明白调用函数“printf(“Hello World\n”);”就能在屏幕上打印出Hello World的字符难的是这个程序的背后细节尽管这个程序不过数行代码却需要芯片、编程语言、进程、内存、IO等多种基础设施的配合才能完成看似简单的功能。
当然在写Hello World程序这个起步阶段我们只要知道printf函数如何使用就行了这是因为这程序简单到只是输出Hello World就结束了不会给系统或者其它软件带来副作用。
但若是我们要开发大规模应用系统,如电商服务系统,问题就会变得复杂。比如:
这个服务应用要用什么语言来编写?-
是采用单体进程,还是用多个进程来协同工作?-
如何管理长期使用的内存空间如何避免系统IO抖动-
如何处理网络带来的各种问题,比如通信拥堵、拒绝请求,甚至掉线?
这些问题显然不是我们知道这些方面的几个接口函数就能解决的。发现没有你可以用很短的时间跑起来一个Hello World但想保障一个电商系统运转如常感觉难度上是天壤之别。工程复杂度带来的差异让我们不得不继续钻研试着“理解”计算机。
我再说一个MySQL的例子在往生产数据库中导入部分数据时会造成客户端的访问超时。你可能怀疑这是MySQL自身问题也可能怀疑是服务器系统的问题。其实两者都不是此时即使你对MySQL的各种操作都了然于胸还是对解决这类问题一头雾水。
如果你没能掌握文件系统、Cache、IO等基础的话就很难想到用iotop、iostat等工具去查看IO操作也就无从发现MySQL在导入数据时还会产生大量的日志而这些日志也需要存盘引发大量IO操作导致IO带宽爆满造成访问超时。更不用说想到可以用MySQL的innodb_flush_log_at_trx_commit来控制MySQL的log行为了。
再比方说如果你不知道操作系统与CPU、RAM等硬件的交互原理就很难理解JVM为啥要抽象出堆、虚拟机栈和本地方法栈、程序计数器、方法区之类的概念来屏蔽硬件差异更别说理解JVM、JUC中的内存管理、多线程安全的核心设计思想了。你看写不出高并发、安全可靠程序的瓶颈深究起来欠缺的竟然是底层基础知识。
除了复杂的软件工程问题,日新月异的前沿技术也离不开计算机基础的软硬件知识。
系统设计领域只有研究过对CPU提供的SIMD指令集才会联想到可以像ClickHouse一样基于向量化执行来提升计算速度在云原生方面只有熟知文件系统的系统调用和运作原理才能设计出一款优质的分布式文件系统或者设计出基于UnionFS的Docker 镜像机制让容器真正发挥优势AI领域同样如此只有透彻理解了语言与指令、内存与应用才有可能通过基础的软硬件技术配合优化存储层次最终调优加速AI框架……
总之想要成为优秀工程师就需要你深入芯片、内存、语言、应用、IO与文件等这些基础组件学习研究甚至还要钻研语言指令的运转搞懂芯片尤其是CPU的机制原理。这些基础不仅仅是对计算机本身很重要对从事计算机的任何细分行业的每个人都很重要。
计算机基础要怎么学?
也许你跟我一样,不是计算机专业科班出身,所以起步时更加步履维艰。通常被后面这几类问题困扰:不确定学什么,不知道怎么学,硬记了概念不明白技术原理,更别说学以致用了。
这些问题让我们面对内容繁多的计算机知识时不知如何下手于是开始自我怀疑总想打退堂鼓。从只会用C写个Hello World到可以用C语言自研操作系统内核我同样经历了漫长的修炼之旅。我也遇到过各种各样的问题通过不断地学习和实践才解决了诸多疑难杂症。
我希望把自己积累的大量计算机学习基础方法经验通过这门课分享给你帮你把计算机从底层到应用的关键知识点串联起来。除了学习原理概念、理顺知识点动手实践的环节也不可或缺配套的执行和调试代码我之后都会放在Gitee上方便你随堂练习。
这个专栏我是这样安排的:
历史
一个东西,从何而来,何至于此,这就是历史。学计算机基础,我们需要先学习它的历史,学习计算机是怎么一步步发展到今天这个样子的,再根据今天的状况推导出未来的发展方向。
我并不会长篇累牍地给你讲什么编年史而是重点带你了解可编程架构是怎么创造出来的、CPU从何而来、CISC和RISC又各有什么优缺点。知道了这些你就能理解为什么现在国家要提倡发展芯片产业RISC-V为何会大行其道。
芯片
万丈高楼从地起,欲盖高楼先打地基。芯片是万世之基,这是所有软件基础的开始,执行软件程序的指令,运算并处理各种数据都离不开它。
因此了解芯片的工作机制对写出优秀的应用软件非常重要。为了简单起见我选择了最火热的RISCV芯片。这个模块里我们将一起设计一个迷你RISCV处理器。哪怕未来你不从事芯片设计工作了解芯片的工作机制也对写出优秀的应用软件非常重要。
环境
学习讲究“眼到,手到,心到”,很多知识如果想牢牢掌握,就离不开动手实践。
而搭建好编译环境和执行环境就是实践的前提方便后面的学习里我们去调试程序验证理论。环境篇我们最终会跑出RISC-V平台的Hello World程序作为这一关的阶段性成果。
语言
一个合格的程序员必须要掌握多种编程语言这是开发应用软件的基础所以我选择了最常用的C语言以它为例让你理解高级语言是如何转换成低级的RISCV汇编语言的。
我不光会带你学习C语言各种类型的形成、语句与函数的关系还会给你搭建一座理解C和汇编对应关系的桥梁。汇编语言方面我会以RISC-V为例介绍其算术指令、跳转指令、原子指令和访存指令并带你学会调试这些指令加深你对指令的理解。
应用
具备了编程语言的知识基础,我们就可以开发应用了。应用往往与内存分不开,我们一起来了解应用的舞台——内存地址空间,接着会引入物理内存、虚拟内存。理解了内存,理解进程也会手到擒来。
虚拟内存跟物理内存如何映射和转换?应用堆和栈内存有什么不同?应用内存是如何动态分配的?为什么操作系统中能并行运行多个不同或者相同的应用?多个应用之间如何通信?这些重难点问题,我们一个都不会漏掉。
IO
跟软件应用直接关联的除了芯片和内存之外就是IO即输入输出系统了。无论是交互式应用、还是数据密集型应用都不得不接收各种数据的输入然后执行相应计算和处理之后产生输出。
有的应用性能不佳实时性不强更有甚者丢失数据面对这些令人头疼的问题不懂IO就无法处理。我们想要开发高性能的应用程序就不得不学习IO相关的基础知识了。因此我们会重点学习IO的操作方式、IO调度、IO缓存Cache以及Linux操作系统是如何管理IO设备的。我还会引入iotop和iostate工具带你掌握怎么用它们来攻克应用的IO性能瓶颈。
文件
很少有应用不需要储存读写文件的,特别是各种网络应用和数据库应用,一个合格的开发者必须对文件了如指掌。
想要提升应用读写数据性能做好数据加密特别是优化网络数据库应用深入了解文件和文件系统都是相当关键的。理清文件的基础知识点之后我们还会研究一个Linux文件系统实例的内部细节检验之前所学。
综合应用
经历了前面这些关卡,在综合应用篇里,我会带你了解如何从底层角度审视前端技术跟后端架构。优秀工程师通常具备超强的知识迁移能力,能够透过各种多变的技术表象,快速抓住技术的本质。这将是你未来拓展学习更多应用层技术,顺利解决日常业务里前后端性能问题的良好开端。
技术雷达
最后,我还设置了技术雷达的加餐内容,和你聊聊云计算、大数据跟智能制造。这些热门领域其实都是对基础技术的综合应用,有助于你开阔视野,给工作选择增加更多可能性。
这个加餐,我安排在正文结束之后的一个月和你见面(每周更新一节课,共五节课),这一个月是留给你吸收消化前面所学内容的时间。
总之,在你学习更多应用层技术以前,通过这门课补充前置知识很有必要。这既是所有有志于成为高手的工程师绕不开的必修内容,同样也是我多年职业生涯里,通过技术修炼沉淀而来的“学习笔记”。
在我看来一个人的自我学习能力和态度决定着技术成就不然只会陷入CRUD Boy或者API Caller的圈子里终日忙忙碌碌却依旧原地踏步。IT人就是要时刻保持学习如果要给这个保持学习的习惯加个期限那就是“终身”。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 RISC特性与发展RISC-V凭什么成为“半导体行业的Linux”
你好我是LMOS。
上节课我带你见证了两种计算机指令集的设计结构——CISC与RISC。而今天我们的“主角”就是RISC中的一个代表性特例它就是RISC-V。
作为未来芯片指令集的主流RISC-V是今后芯片设计的最佳方案甚至可以说它就是硬件行业里的Linux。
为什么这么说呢这节课我会从RISC-V发展历史、原理与技术特性几个方面入手带你弄明白为什么RISC-V在半导体行业中发展得如此迅猛。
RISC-V从何而来
让我们“穿越时空”把时间线拉回到2010年。在加州伯克利分校的校园中Krste Asanovic教授正为了学生们学习计算机架构而发愁。由于现存芯片指令集冗余且专利许可费用昂贵还有很多IP法律问题没有一款合适的CPU用于学习。
于是他想要带领一个研究团队来设计一款用于学生学习的CPU。研究团队在选择架构的时候对比了传统已经存在的ARM、MIPS、SPARC以及x86架构等发现这些架构指令集要么极其复杂要么极其昂贵。所以他们的研究团队最终决定设计一套全新的指令集。
这个研究团队最开始只有4个人却在三个月之内完成了指令集原型开发其能力可见一斑。后来这个项目被计算机体系结构方面的泰斗 David Patterson 发现,并且得到了他的大力支持。
早在1981年伯克利分校已经设计出了第一代RISC指令集之后又陆续设计了四代RISC指令集的架构。有了这些设计经验在设计RISC-V指令集架构时研究团队就变得驾轻就熟。
用RISC-V来命名该指令集架构有两层意思RISC-V中的“V”一方面代表第5代RISC另一方面“V”取“ Variation”之意代表变化。
虽然RISC-V在2010年才开始研发但在第二年2011年就进行了首次流片流片就是按照芯片设计生产出可以工作的芯片成品。从这以后基于RISC-V的CPU设计或者在SOC中集成RISC-V架构各种软件工具链的开发和各种基于RISC-V架构的操作系统移植在不断涌现。这给CPU整个行业带来了不小轰动当然这也离不开泰斗 David Patterson 的号召作用。
一个产品的成功技术当然是非常重要的原因但也需要非常给力的运营。在2015年由最初的25个成员一起成立了非盈利性组织RISC-V基金会RISC-V Foundation
之后有多达300个单位加入RISC-V基金会其中包括阿里、谷歌、华为、英伟达、高通、麻省理工学院、普林斯顿大学、印度理工学院、中科院计算所、联发科等。这些学术机构、芯片开发公司、设计服务与系统厂商的加入为RISC-V的发展建立了良好的生态环境。
RISC-V是什么
通过上节课我们知道了RISC就是指精简指令集计算机体系结构。而前面也说了RISC-V是伯克利分校设计出的第五代RISC指令集架构。但既然迅速走红的是RISC-V相比其他的RISC它必然有过人之处这样才能立足于市场不然很可能只沦为学生们的学习工具。
如果只是对RISC-V下一个定义会相当简单RISC-V是一套开放许可证书、免费的、由基金会维护的、一个整数运算指令集外加多个扩展指令集的CPU架构规范。
任何硬件开发商或者相关组织都可以免费使用这套规范构建CPU芯片产品。如果我们的目的只是想对RISC-V有个概念了解前面这些信息就足够了。不过想知道RISC-V为什么流行这个秘密显然我们还需要更详细的信息才能深入了解。
指令集命名方式
现在假定我们是一家芯片公司的芯片工程师公司有了新的业务对CPU提出了更高的要求让我们基于RISCV指令集架构实现一款全新的CPU。根据我们公司的业务场景我们只需要选择RISC-V架构中的一部分指令CPU的位宽也有特定的要求。
因此现在需要一些命名方式来对我们选择的RISCV架构进行命名。这样用户在阅读该CPU文档时马上就能了解这款CPU是多少位的、有哪些指令集它们具体有什么功能。
其实这个命名方式在RISC-V规范中有相关定义说明以RV为前缀然后是位宽最后是代表指令集的字母集合具体形式如下
RV[###][abc……xyz]
我用表格为你说明一下这个格式,如下所示:
举个例子比如RV64IMAC就表示64位的RISC-V支持整数指令、乘除法指令、原子指令和压缩指令。
指令集模块
接着我们一起来看看指令集模块。指令集是一款CPU架构的主要组成部分是CPU和上层软件交互的核心也是CPU主要功能的体现。
但RISC-V规范只定义了CPU需要包含基础整形操作指令如整型的储存、加载、加减、逻辑、移位、分支等。其它的指令称为可选指令或者用户扩展指令比如乘、除、取模、单精度浮点、双精度浮点、压缩、原子指令等这些都是扩展指令。扩展指令需要芯片工程师结合功能需求自定义。
所以RISC-V采用的是模块化的指令集易于扩展、组装。它适应于不同的应用场景可以降低CPU的实现成本。
我给你列了一张图图里展示的是RISC-V 指令集的各个组成部分。
RISC-V的基本指令集和扩展指令集你有个大致印象就可以更详细的技术讲解后面第五节课我再展开。下面我们去看看RISC-V的寄存器。
RISC-V寄存器
指令的操作数是来源于寄存器精简指令集架构的CPU都会提供大量的寄存器RISC-V当然也不例外。RISC-V 的规范定义了32个通用寄存器以及一个 PC寄存器这对于RV32I、RV64I、RV128I指令集都是一样的只是寄存器位宽不一样。
如果实现支持 F/D 扩展指令集的CPU则需要额外支持 32个浮点寄存器。而如果实现只支持RV32E指令集的嵌入式CPU则可以将32个通用寄存器缩减为16个通用寄存器。
为了帮助你聚焦要点不常用的32个浮点寄存器并没有列在这张表里。表中的ABI名称即应用程序二进制接口你可以理解为寄存器别名高级语言在生成汇编语言的时候会用到它们。
比如C语言处理函数调用时用哪些寄存器传递参数、返回值调用者应该保护哪些寄存器用什么寄存器管理栈帧等等。
定义好ABI标准我们就能在语言间互相调用函数了。比如C语言函数调用汇编语言函数这里我先卖个关子后面语言与指令的篇章再给你详细展开。
RISC-V特权级
研究完了RISC-V寄存器我们再来看看RISC-V的特权级。不同的特权级能访问的系统资源不同高特权级能访问低特权级的资源反之则不行。RISC-V 的规范文档定义了四个特权级别privilege level特权等级由高到低排列如下表所示。
一个RISC-V硬件线程hart相当于一个CPU内的独立的可执行核心在任一时刻只能运行在某一个特权级上这个特权级由CSR控制和状态寄存器指定和配置。
具体分级如下:
机器特权级MRISC-V中hart可以执行的最高权限模式。在M模式下运行的hart对内存、I/O和一些必要的底层功能启动和系统配置有着完全的控制权。因此它是唯一一个所有标准RISC-V CPU都必须实现的权限级。实际上普通的RISC-V微控制器仅支持机器特权级。
虚拟机监视特权级H为了支持虚拟机监视器而定义的特权级。
管理员特权级S主要用于支持现代操作系统如Linux、FreeBSD和Windows。
用户应用特权级U用于运行应用程序同样也适用于嵌入式系统。
好了关于RISC-V的特权级你了解这些在现阶段已经足够了。需要把握的重点是特权级起到了怎样的作用。
有了特权级的存在,就给指令加上了权力,从而去控制用指令编写的程序。应用程序只能干应用程序该干的事情,不能越权操作。操作系统则拥有更高的权力,能对系统的资源进行管理。
RISC-V因何流行
RISC-V指令集架构在2010年才开发出来到今天不过10多年的时间。这个时间从CPU行业的发展看是非常短的也可以说是非常年轻的。相比x86的40多岁的年纪还有ARM、MIPS、SPARC的30多的年纪RISC-V简直是个孩子。
要知道ARM、MIPS、SPARC都是RISC系的MIPS和SPARC甚至已经进入了死亡阶段。按道理讲RISC-V不应该在这么短的时间内流行起来成为芯片行业一颗耀眼的新星。
那么RISC-V流行起来肯定有其优势一是RISC-V完全开放二是RISC-V指令简单三是RISC-V实行模块化设计易于扩展。
我们先来看看为什么说RISC-V是开放的。之前硬件和软件一样都是小心地保护自己的“源代码”因为那是自己的命脉。
直到后来软件界出现了开源的Linux一经开源就迅速席卷了全球。在今天的互联网、云计算、手机等领域Linux已经无处不在。但是硬件依然保护着自己的“源代码”Intel和AMD还是以售卖x86芯片为主而ARM直接售卖ARM CPU的“源代码”连生产芯片的步骤都省了。
这种模式下无论厂商还是个人要获得CPU都要付出昂贵的代价。这时RISC-V应运而生它完全毫无保留地开放了CPU设计标准任何人都可以使用该标准自由地设计生产CPU不需要支付任何费用也没有任何法律问题。这相当于硬件界的“Linux”推动了开放硬件的运动和发展。
然后我们来看看为什么说RISC-V很简单RISC-V提供了一个非常强大且开放的精简指令集架构只有32个通用寄存器、40多条常用指令、4个特权级。如果需要其它功能则要进行指令集的扩展单核心的规范文档才不到300页一个人在一周之内就能搞清楚。
相比ARM、x86动不动就有8000多页的规范文档这实在是太简单了。其实简单也意味着可靠和高效同时可以让学生或者硬件开发者迅速入手降低学习和开发成本。
最后我们来说说RISC-V的模块化设计。RISC-V虽然简单但这并不意味着功能的缺失。通过模块化的设计就能实现对各种功能组件的剪裁和扩展。
事实上现代IT架构已经发生了巨大的改变。举几个我们身边的例子吧。你正在使用的网卡上面越来越多的网络处理任务和功能都从主处理器上移到了网卡中由网卡芯片自己来处理了。
数据处理器 (DPU) 也体现了这一点。由于通用处理器对大规模数据处理能力的限制所以我们需要专用的数据处理器。而人工智能领域现在也已经开始通过GPU运行相关算法。
这些例子都在告诉我们专用处理器芯片的需求在大量激增而这正是RISC-V的用武之地。RISC-V的标准开放指令功能模块可以自由组合所以用RISC-V就能定制一款满足特殊用途的处理器。芯片工程师会自由组合RISC-V现有的指令功能模块按需对齐进行修改优化或者实现一个新的指令功能模块就像你根据需要修改和使用Linux内核一样。
正是因为RISC-V开放、简单和模块化这三大特点硬件工程师和软件工程师才能站在巨人的肩膀上开发自由地调用和组装功能模块快速去实现特定业务场景下的芯片需求。因此才有了RISC-V引爆芯片行业迅速火热起来的现象这是推动开放硬件的革命性壮举。
重点回顾
今天的课程又到了尾声,我们还是来看一下,在这节课中,我们都学习了什么。
首先我们了解了RISC-V从何而来明白了RISC-V发源于加州伯克利分校是该校第五代RISC指令集即第五代精简指令集。起初是为了学生有一套用来学习研究的指令集。后来因为有技术泰斗David Patterson的加入又成立RISC-V基金会RISC-V慢慢流行了起来。
之后我们研究了RISC-V是什么我带你了解了RISC-V指令集的命名方式、组成模块、寄存器与特权级。这些部分共同组成了RISC-V指令集架构规范。任何硬件厂商都可以按照这个规范实现自己的RISC-V处理器。
最后我们讨论了RISC-V因何流行。RISC-V是开放的没有任何法律和许可证问题又极其简单指令集是模块化的易于剪裁和扩展。这种开放、简单、易于扩展的特点使得硬件工程师非常容易上手和定制满足特定功能需要的处理器这直接推动了开放硬件的革命。
课程里的重点内容,我整理成了导图,供你参考。
思考题
为什么RISC-V要定义特权级
欢迎你在留言区跟我交流互动如果觉得内容还不错也推荐你把这节课分享给更多朋友。下节课我们就进入手写miniCPU的部分敬请期待

View File

@ -0,0 +1,218 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 硬件语言筑基从硬件语言开启手写CPU之旅
你好我是LMOS。
我们都知道自己国家的芯片行业被美国“吊打”这件事了吧尤其是像高端CPU这样的芯片。看到相关的报道真有一种恨铁不成钢的感觉。你是否也有过想亲自动手设计一个CPU的冲动呢
万丈高楼从地起欲盖高楼先打地基芯片是万世之基这是所有软件基础的开始这个模块我会带你一起设计一个迷你RISC-V处理器为了简单起见我选择了最火热的RISCV芯片。哪怕未来你不从事芯片设计工作了解芯片的工作机制也对写出优秀的应用软件非常重要。
这个处理器大致是什么样子呢我们将使用Verilog硬件描述语言基于RV32I指令集设计一个32位五级流水线的处理器核。该处理器核包括指令提取单元、指令译码单元、整型执行单元、访问存储器和写回结果等单元模块支持运行大多数RV32I的基础指令。最后我们还会编写一些简单汇编代码放在设计出来的处理器上运行。
我会通过两节课的篇幅带你快速入门Verilog为后续设计迷你CPU做好准备。这节课我们先来学习硬件描述语言基础芯片内部的数字电路设计正是由硬件语言完成的。
一个芯片的内部电路是怎么样的?
作为开发你日常最常用的编程语言是什么也许是C语言、Java、Go、PHP……这些高级编译语言吧。而硬件设计领域里也有专门的硬件描述语言。为什么会出现专门的硬件描述语言呢这还要先从芯片的内部结构说起。
一般情况下你所接触到的处理器芯片已经不是传统意义上的CPU了比如在业界很有名的国产手机芯片华为麒麟990芯片。我把麒麟990的功能模块图贴在了后面对照图片会更直观。这样一款芯片包含了CPU核、高速缓存、NPU、GPU、DDR、PMU等模块。
而在芯片设计时,根据不同模块的功能特点,通常把它们分为数字电路模块和模拟电路模块。
模拟电路还是像早期的半导体电路那样,处理的是连续变化的模拟信号,所以只能用传统的电路设计方法。而数字电路处理的是已经量化的数字信号,往往用来实现特定的逻辑功能,更容易被抽象化,所以就产生了专门用于设计数字电路的硬件描述语言。
硬件描述语言从发明到现在已经有20多年历史。硬件描述语言可以让你更直观地去理解数字电路的逻辑关系从而更方便地去设计数字电路。
现在业界的 IEEE标准主要有VHDL和Verilog HDL 这两种硬件描述语言。在高层次数字系统设计领域,大部分公司都采用 Verilog HDL完成设计我们后面的实现也会用到Verilog。
千里之行始于足下。在Verilog学习之前我们需要先完成思路转换也就是帮你解决这个问题写软件代码和写硬件代码的最大区别是什么搞明白了这个问题你才能更好地领会Verilog语言的设计思想。
Verilog代码和C语言、Java等这些计算机编程语言有本质的不同在可综合这里的“可综合”和代码“编译”的意思差不多的Verilog代码里基本所有写出来的东西都对应着实际的电路。
所以我们用Verilog的时候必须理解每条语句实质上对应着什么电路并且要从电路的角度来思考它为何要这样设计。而高级编程语言通常只要功能实现就行。
我再举几个例子来说明声明变量的时候如果指定是一个reg那么这个变量就有寄存数值的功能可以综合出来一个实际的寄存器如果指定是一段wire那么它就只能传递数据只是表示一条线。在Verilog里写一个判断语句可能就对应了一个MUX数据选择器写一个for可能就是把一段电路重复好几遍。
最能体现电路设计思想的就是always块了它可以指定某一个信号在某个值或某个跳变的时候执行块里的代码。通过使用Verilog语言我们就能完成芯片的数字电路设计工作了。没错芯片前端设计工程师写Verilog代码的目的就是把一份电路用代码的形式表示出来然后由计算机把代码转换为所对应的逻辑电路。
芯片如何设计?
说到这里你可能还有疑惑,听起来芯片设计也没那么复杂啊?其实这事儿说起来简单,但实践起来却相当复杂。接下来,我就说说,一个工业级的芯片在设计阶段大致会怎么规划。
在开始一个大的芯片设计时往往需要先从整个芯片系统做好规划在写具体的Verilog代码之前把系统划分成几个大的基本的功能模块。之后每个功能模块再按一定的规则划分出下一个层次的基本单元。
这和Verilog语言的module模块化设计思想是一致的上一层模块对下一层子模块进行例化就像其他编程语言的函数调用一样。根据包含的子功能模块一直例化下去最终就能形成hierarchy结构。
这种自顶向下的设计方法,可以用后面的树状结构图来表示:
从上图我们也可以看出Verilog都是基于模块进行编写的一个模块实现一个基本功能大部分的Verilog逻辑语句都放在模块内部。
从一段代码入门Verilog
说完语言思路和硬件的模块化设计接下来我带你学习一下Verilog的基本模块和逻辑语句是怎么写的。
很多Verilog初学者刚开始都是从一些基础知识慢慢去看比如基本语法、数据类型、赋值语句、条件语句……总想着把Verilog的全部基础知识看完了再开始动手写代码。
但是你有没有想过,这些详细的基础知识,一两天自然是看不完的。而当你坚持了一段时间把它看完,以为可以上手写代码的时候,又会发现前面的基本语句全都忘了。这样的学习方法并不可取,效果也不好,所以我换个方法带你入门。我们先不去罗列各种详细的基础知识,而是从学习一段代码开始。
我会以一个4位十进制计数器模块为例让你对Verilog模块代码有更直观的认识然后根据这段代码模块给你讲讲Verilog语言基础。这里先把完整代码列出来后面再详细拆解。
module counter(
//端口定义
input reset_n, //复位端,低有效
input clk, //输入时钟
output [3:0] cnt, //计数输出
output cout //溢出位
);
reg [3:0] cnt_r ; //计数器寄存器
always@(posedge clk or negedge reset_n) begin
if(!reset_n) begin //复位时计时归0
cnt_r <= 4'b0000 ;
end
else if (cnt_r==4'd9) begin //计时10个cycle时计时归0
cnt_r <=4'b0000;
end
else begin
cnt_r <= cnt_r + 1'b1 ; //计时加1
end
end
assign cout = (cnt_r==4'd9) ; //输出周期位
assign cnt = cnt_r ; //输出实时计时器
endmodule
看了这段代码,也许你云里雾里,或者之前没接触过硬件语言,心里有点打鼓。不过别担心,入门硬件语言并不难,我们按照代码顺序依次来看。
模块结构
首先让我们看一看这段代码的第一行和最后一行。没错一个模块的定义是以关键字module开始以endmodule结束。module关键字后面跟的counter就是这个模块的名称。
看着有没有熟悉的感觉?你可能觉得,这个看着跟其他编程语言的函数定义也没多大区别吧?别急着下结论,再仔细看看接口部分,发现没有?这就和函数传入的参数很不一样了。
module counter(-
//接口部分-
input reset_n,-
input clk,-
output [3:0] cnt,-
output cout-
);-
…… //逻辑功能部分-
endmodule
Verilog模块的接口必须要指定它是输入信号还是输出信号。
输入信号用关键字input来声明比如上面第4行代码的 input clk输出信号用关键字output来声明比如代码第5行的output [3:0] cnt还有一种既可以输入又可以输出的特殊端口信号这种双向信号我们用关键字inout来声明。
数据类型
前面我提到过在可综合的Verilog代码里基本所有写出来的东西都会对应实际的某个电路。而Verilog代码中定义的数据类型就能充分体现这一点。
parameter SIZE = 2b01;-
reg [3:0] cnt_r;-
wire [1:0] cout_w;
比如上面代码的第9行表示定义了位宽为4 bit 的寄存器reg类型信号信号名称为cnt_r。
寄存器reg类型表示抽象数据存储单元它对应的就是一种寄存器电路。reg默认初始值为X不确定值换句话说就是reg电路在上电之后输出高电平还是低电平是不确定的一般是在系统复位信号有效时给它赋一个确定值。比如例子中的cnt_r在复位信号reset_n等于低电平时就会给cnt_r赋“0”值。
reg类型只能在always和inital语句中被赋值如果描述语句是时序逻辑即always语句中带有时钟信号寄存器变量对应为触发器电路。比如上述定义的cnt_r就是在带clk时钟信号的always块中被赋值所以它对应的是触发器电路如果描述语句是组合逻辑即always语句不带有时钟信号寄存器变量对应为锁存器电路。
我们常说的电子电路也叫电子线路所以电路中的互连线是必不可少的。Verilog代码用线网wire类型表示结构实体例如各种逻辑门之间的物理连线。wire类型不能存储数值它的值是由驱动它的元件所决定的。驱动线网类型变量的有逻辑门、连续赋值语句、assign等。如果没有驱动元件连接到线网上线网就默认为高阻态“Z”。
为了提高代码的可读性和可维护性Verilog还定义了一种参数类型通过parameter来声明一个标识符用来代表一个常量参数我们称之为符号常量即标识符形式的常量。这个常量实际上就是电路中一串由高低电平排列组成的一个固定数值。
数值表达
说到数值我们再了解一下Verilog中的数值表达。还是以前面的4位十进制计数器代码为例我们定位到第13行代码
cnt_r <= 4b0000;
这行代码的意思是给寄存器cnt_r赋以4b0000的值。
这个值怎么来的呢其中的逻辑“0”低电平对应电路接地GND。同样的逻辑“1”则表示高电平对应电路接电源VCC。除此之外还有特殊的“X”和“Z”值。逻辑“X”表示电平未知输入端存在多种输入情况可能是高电平也可能是低电平逻辑“Z”表示高阻态外部没有激励信号是一个悬空状态。
当然为了代码的简洁明了Verilog可以用不同的格式表示同样的数值。比如要表示4位宽的数值“10”二进制写法为4b1010十进制写法为4d10十六进制写法为4ha。这里我需要特殊说明一下数据在实际存储时还是用二进制位宽表示储存时二进制占用宽度。
运算符
接下来我们看看Verilog的运算符对于运算符Verilog和大部分的编程语言的表示方法是一样的。
比如算术运算符 + - * / % ,关系运算符 > < <= >= == !=,逻辑运算符 && || !(与或非),还有条件运算符 也就是C语言中的三目运算符。例如a?b:c表示a为真时输出b反之为c。
但在硬件语言里,位运算符可能和一些高级编程语言不一样。其中包括 ~ & | ^(按位取反、按位与,按位或,以及异或);还有移位运算符,左移 << 和右移>> ,在生成实际电路时,左移会增加位宽,右移位宽保存不变。
条件、分支、循环语句
还有就是条件语句if和分支语句case由于它们的写法和其它高级编程语言几乎一样基本上你掌握了某个语言都能理解。
这里我们重点来对比不同之处也就是用Verilog实现条件、分支语句有什么不同。用if设计的语句所对应电路是有优先级的也就是多级串联的MUX电路。而case语句对应的电路是没有优先级的是一个多输入的MUX电路。设计时只要我们合理使用这两个语句就可以优化电路时序或者节省硬件电路资源。
此外,还有循环语句,一共有 4 种类型,分别是 whileforrepeat和 forever 循环。注意,循环语句只能在 always 块或 initial 块中使用。
过程结构
下面我们来说说过程结构最能体现数字电路中时序逻辑的就是always语句了。always 语句块从 0 时刻开始执行其中的行为语句每当满足设定的always块触发条件时便再次执行语句块中的语句如此循环反复。
因为always 语句块的这个特点芯片设计师通常把always块的触发条件设置为时钟信号的上升沿或者下降沿。这样每次接收到一个时钟信号always块内的逻辑电路都会执行一次。
前面代码例子第11行的always语句就是典型的时序电路设计方法有没有感觉到很巧妙
always@(posedge clk or negedge rstn) begin-
…… //逻辑语句-
end
还有一种过程结构就是initial 语句。它从 0 时刻开始执行,且内部逻辑语句只按顺序执行一次,多个 initial 块之间是相互独立的。理论上initial 语句是不可以综合成实际电路的,多用于初始化、信号检测等,也就是在编写验证代码时使用。
到这里在我看来比较重要的Verilog基础知识就讲完了这门语言的知识脉络我也为你搭起了骨架。当然了Verilog相关知识远远不止这些。如果你对深入学习它很感兴趣推荐你翻阅《Verilog HDL高级数字设计》等相关资料拓展学习。
总结回顾
今天是芯片模块的第一节课我们先了解了芯片的内部电路结构。一个芯片的内部电路往往分为数字电路模块和模拟电路模块。对于数字电路模块可以使用Verilog硬件描述语句进行设计。
尽管Verilog这样的硬件语言你可能不大熟悉但只要抓住本质再结合代码例子建立知识脉络学起来就能事半功倍。
要想熟悉硬件语言我们最关键的就是做好思路转换。硬件语言跟高级编程语言本质的不同就是使用Verilog的时候必须理解每条语句实质上对应的什么电路并且要从电路的角度来思考它为何要这样设计而高级编程语言通常只要实现功能就行。
我再带你回顾一下Verilog语言和高级编程语言具体有哪些不同
模块结构Verilog的模块结构和其他语言的函数定义不一样它既可以有多个输入信号也可以输出多个结果。而且模块上的接口信号必须要指定是输入信号和输出信号。-
数据类型跟我们在高级编程语言见到的变量类型相比Verilog定义的数据类型也有很大不同。reg类型对应的是寄存器电路wire类型对应的是电路上的互连线标识符对应的是一串固定的高低电平信号。-
数据表达Verilog代码中的数据本质上就是高低电平信号。“0”代表低电平“1”代表高电平不能确定高低电平的就用“X”来表示。-
运算符Verilog中的大部分运算符和其他语言是一样的但是要注意位操作运算符它们对应的是每一位电平按指定逻辑跳变还有移位操作一定要注意移位信号的数据位宽。-
条件、分支、循环语句Verilog中的条件if语句是有优先级的而case语句则没有优先级合理利用它们可以优化电路时序或节省硬件电路资源。循环语句则是把相同的电路重复好几遍。-
过程结构这是实现时序电路的关键。我们可以利用alway块语句设定一个时钟沿用来触发相应逻辑电路的执行。这样我们就可以依据时钟周期来分析电路中各个信号的逻辑跳变。而initial语句常在验证代码中使用它可以从仿真的0时刻开始设置相关信号的值并将这些值传输到待验证模块的端口上。-
下节课,我会带你设计一个简单的电路模块,既能帮你复习今天学到的知识,还能通过实践体会一下代码是怎样生成电路的,敬请期待。
思考题
为什么很多特定算法用Verilog设计并且硬件化之后要比用软件实现的运算速度快很多
欢迎你在留言区跟我交流互动,也推荐你把这节课分享给更多朋友。

View File

@ -0,0 +1,300 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 硬件语言筑基_ 代码是怎么生成具体电路的?
你好我是LMOS。
上节课我们学习了硬件描述语言Verilog的基础知识。今天我会带你一起用Verilog设计一个简单的电路模块。通过这节课你不但能复习巩固上节课学到的硬件语言知识还能在动手实践中体会代码是怎么生成具体电路的。
Verilog代码编写
如果你学过计算机组成原理的课程或图书应该对ALU并不陌生。算术逻辑单元Arithmetic&logical UnitALU是CPU的执行单元是所有中央处理器的核心组成部分。
利用Verilog我们可以设计一个包含加、减、与、或、非等功能的简单ALU模块代码如下
module alu(a, b, cin, sel, y);
input [7:0] a, b;
input cin;
input [3:0] sel;
output [7:0] y;
reg [7:0] y;
reg [7:0] arithval;
reg [7:0] logicval;
// 算术执行单元
always @(a or b or cin or sel) begin
case (sel[2:0])
3'b000 : arithval = a;
3'b001 : arithval = a + 1;
3'b010 : arithval = a - 1;
3'b011 : arithval = b;
3'b100 : arithval = b + 1;
3'b101 : arithval = b - 1;
3'b110 : arithval = a + b;
default : arithval = a + b + cin;
endcase
end
// 逻辑处理单元
always @(a or b or sel) begin
case (sel[2:0])
3'b000 : logicval = ~a;
3'b001 : logicval = ~b;
3'b010 : logicval = a & b;
3'b011 : logicval = a | b;
3'b100 : logicval = ~((a & b));
3'b101 : logicval = ~((a | b));
3'b110 : logicval = a ^ b;
default : logicval = ~(a ^ b);
endcase
end
// 输出选择单元
always @(arithval or logicval or sel) begin
case (sel[3])
1'b0 : y = arithval;
default : y = logicval;
endcase
end
endmodule
通过上面的代码我们实现了一个8位二进制的简单运算模块。其中a和b是输入的两个8位二进制数cin是a和b做加法运算时输入的进位值4bit位宽的sel[3:0] 则是CPU中通常所说的指令操作码。
在这个ALU模块中逻辑功能代码我们把它分成三个部分分别是运算单元、逻辑处理单元和输出选择单元。运算单元是根据输入指令的低三位sel[2:0]来选择执行加减等运算。同理逻辑处理单元执行与或非门等操作。最后根据指令的最高位sel[3]来选择Y输出的是加减运算单元结果还是逻辑处理的结果。
你还记得上节课的例子么当时我们一起研究了一个4位10进制的计算器里面用到了时钟设计。也就是说这个计算器是通过时序逻辑实现的所以always块中的赋值语言使用了非阻塞赋值“<=”。
always@(posedge clk or negedge reset_n) begin
if(!reset_n) begin //复位时计时归0
cnt_r <= 4'b0000 ;
end
而今天我们实现的ALU模块用到的是组合逻辑所以always块中使用阻塞赋值“=”。
怎么区分阻塞赋值和非阻塞赋值呢?阻塞赋值对应的电路结构往往与触发沿没有关系,只与输入电平的变化有关;而非阻塞赋值对应的电路结构往往与触发沿有关系,只有在触发沿时,才有可能发生赋值的情况。
另外在前面8位二进制的代码里算术执行单元和逻辑处理单元的两个always块是并行执行的。所以它们的运算结果几乎是同时出来这里值得你好好理解一下。如果你没有发现两个块并行可以暂停下来回顾一下。
如何通过仿真验证代码
就像我们开发软件需要代码编译器和模拟器一样Verilog这种硬件语言的代码也需要运行验证。那怎么来运行验证呢现在很多企业采用的是VCS—verilog仿真器或者是NC-verilog仿真器这些工具都需要花重金去购买才能使用普通人用起来成本太高了。
除了重金购买这些EDA工具之外我们还有更节约成本、也更容易学习入门的选择。我给你推荐两个轻量级开源软件分别是Iverilog和GTKWave。Iverilog是一个对Verilog进行编译和仿真的工具而GTKWave是一个查看仿真数据波形的工具。
Iverilog运行于终端模式下安装完成之后我们就能通过Iverilog对verilog执行编译再对生成的文件通过vvp命令执行仿真配合GTKWave即可显示和查看图形化的波形。
在Linux系统下安装Iverilog和GTKWave非常简单。以Ubuntu为例我们通过apt-get就可以直接安装。
安装Iverilogsudo apt-get install iverilog-
安装GTKWavesudo apt-get install gtkwave
安装完成之后我们需要使用which命令查看安装路径确认是否安装成功。
which iverilog-
which vvp-
which gtkwave
有了软件和Verilog代码。在运行仿真前我们还需要设计一个重要的文件即仿真激励文件也就是TestBench。在仿真时要把TestBench放在所设计模块的顶层以便对模块进行系统性的例化调用。
我们把TestBench放在设计模块的顶层以便对模块进行系统性的例化调用所设计的各个模块并对其进行仿真。
针对上面的ALU模块设计了一个给ALU产生运算指令和数据的TestBench并且把ALU的运算结果打印出来TestBench的代码如下
`timescale 1 ns / 1 ns
module alu_tb;
reg[7:0] a, b;
reg cin;
reg[3:0] sel;
wire[7:0] y;
integer idx;
//对alu模块进行例化类似于软件程序中的函数调用
alu u_alu(.a(a), .b(b), .cin(cin), .sel(sel), .y(y));
initial
begin
//给 a 和 b 赋初值
a = 8'h93;
b = 8'hA7;
for (idx = 0; idx <= 15; idx = idx + 1)
begin
// 循环产生运算指令 sel 的值
sel = idx;
// 当指令 sel = 7 时是加法操作设定进位值cin=1
if (idx == 7)
cin = 1'b1;
else
cin = 1'b0;
//每产生一个指令延时10ns
#10
// 延时之后打印出运算结果
$display("%t: a=%h, b=%h, cin=%b, sel=%h, y=%h", $time, a, b, cin, sel, y);
end
end
initial
begin
$dumpfile("wave.vcd"); //生成波形文件vcd的名称
$dumpvars(0, alu_tb); //tb模块名称
end
endmodule
这里我要说明一下TestBench是不可以综合成具体电路的只用于仿真验证但和上一节课介绍的可综合的Verilog代码语法类似。
设计工作告一段落。我们终于可以打开终端开始跑仿真了。你需要在Verilog代码所在的文件目录下执行以下指令
iverilog -o wave -y ./ alu_tb.v alu.v-
vvp -n wave -lxt2
可以看到,运行结果输出如下:
LXT2 info: dumpfile wave.vcd opened for output.-
10: a=93, b=a7, cin=0, sel=0, y=93 //指令0y = a;-
20: a=93, b=a7, cin=0, sel=1, y=94 //指令1y = a + 1;-
30: a=93, b=a7, cin=0, sel=2, y=92 //指令2y = a - 1;-
40: a=93, b=a7, cin=0, sel=3, y=a7 //指令3y = b;-
50: a=93, b=a7, cin=0, sel=4, y=a8 //指令4y = b + 1;-
60: a=93, b=a7, cin=0, sel=5, y=a6 //指令5y = b - 1;-
70: a=93, b=a7, cin=0, sel=6, y=3a //指令6y = a + b;-
80: a=93, b=a7, cin=1, sel=7, y=3b //指令7y = a + b + cin;-
90: a=93, b=a7, cin=0, sel=8, y=6c //指令8y = ~a;-
100: a=93, b=a7, cin=0, sel=9, y=58 //指令9y = ~b;-
110: a=93, b=a7, cin=0, sel=a, y=83 //指令10y = a & b;-
120: a=93, b=a7, cin=0, sel=b, y=b7 //指令11y = a | b;-
130: a=93, b=a7, cin=0, sel=c, y=7c //指令12y = ~(a & b);-
140: a=93, b=a7, cin=0, sel=d, y=48 //指令13y = ~(a | b);-
150: a=93, b=a7, cin=0, sel=e, y=34 //指令14y = a ^ b;-
160: a=93, b=a7, cin=0, sel=f, y=cb //指令15y = ~(a ^ b);
有了运行结果我们就可以打开GTKWave查看仿真波形了这里需要在终端执行如下指令
gtkwave wave.vcd
从打开的波形可以到ALU模块输出的信号Y这是根据输入指令sel和输入的数据a、b和cin的值经过加减运算或者逻辑运算得到的。
代码是如何生成具体电路的?
经过上面的仿真,从打印的结果上已经看到了我们设计的模块功能。而通过查看仿真波形,我们同样也能知道各个信号的跳变关系。
但是你可能还有个疑惑不是说设计的Verilog语句基本都会对应一份电路吗怎样才能看到Verilog对应了哪些电路呢
别急这就是我马上要讲的逻辑综合。通过逻辑综合我们就能完成从Verilog代码到门级电路的转换。而逻辑综合的结果就是把设计的Verilog代码翻译成门级网表Netlist。
逻辑综合需要基于特定的综合库不同的库中门电路基本标准单元Standard Cell的面积、时序参数是不一样的。所以选用的综合库不一样综合出来的电路在时序、面积上也不同。因此哪怕采用同样的设计选用台湾的台积电TSMC工艺和上海的中芯国际SMIC的工艺最后生产出来的芯片性能也是有差异的。
通常工业界使用的逻辑综合工具有Synopsys的Design CompilerDCCadence的 RTL CompilerSynplicity的Synplify等。然而这些EDA工具都被国外垄断了且需要收取高昂的授权费用。
为了降低学习门槛和费用这里我们选择Yosys它是一个轻量级开源综合工具。虽然功能上还达不到工业级的EDA工具但是对于我们这门课的学习已经完全够用了。
-
如上图所示利用Yosys软件可以帮助我们把RTL代码层次的设计转换为逻辑门级的电路。
我先大致带你了解下这个软件怎么安装和使用。在Ubuntu中安装Yosys非常简单在终端中依次执行以下命令即可
sudo add-apt-repository ppa:saltmakrell/ppa-
sudo apt-get update-
sudo apt-get install yosys
完成了安装我们就能使用Yosys对上面设计的ALU模块做简单的综合了。
直接在终端输入“yosys”启动Yosys软件。启动成功后我们通过后面这五条指令就能得到到ALU的逻辑电路图文件了。
第一步在Yosys中读取Verilog文件。
read_verilog alu.v
第二步,使用后面的命令,检查模块例化结构。
hierarchy -check
接着是第三步,执行下一条命令,来完成高层次的逻辑综合。
proc; opt; opt; fsm; memory; opt
到了第四步我们就可以用write_verilog生成网表文件。
write_verilog alu_synth.v
最后,我们再用下方的命令,输出综合后的逻辑图。
show -format dot -prefix ./alu
这一套动作完成后我们终于迎来了收获成果的时刻。打开生成的alu.dot文件我们就可以看到ALU模块的门级电路图了如下所示
可以看到这张图是由基本的and、or、not、add、sub、cmp、mux等电路单元组成。如果你还想进一步了解它们底层电路结构可以自行查阅大学里学过的《数电》《模电》。
当然Yosys功能还不只这些这里我只是做个简易的演示。更多其它功能如果你感兴趣的话可以到官网上学习。
到这里类似于CPU里面的核心单元ALU电路我们就设计完成了。
总结回顾
今天我们一起了解了怎么把Verilog代码变为具体的电路。为了实现代码编写、验证和仿真的“一站式”体验。我还向你推荐了几个开源软件。我们来回顾一下这节课的重点。
首先我们用Verilog编写了一个类似CPU内部的ALU模块该模块实现了加、减、与、或、非等基本运算功能。
针对上面的ALU模块我们还设计了一个用于产生运算指令和数据的TestBench并且把ALU的运算结果打印出来。利用这个TestBench可以验证ALU模块的功能是否正确。
接下来我们还用到了两个轻量级开源软件分别是Iverilog和GTKWave。Iverilog是一个对Verilog进行编译和仿真的工具GTKWave可以查看仿真数据波形的工具。利用这两个软件我们完成了ALU模块的仿真和验证。
此外我还推荐了一款轻量级开源综合工具Yosys。通过这个工具我们把上面设计的ALU模块综合出了具体的门级电路。
感谢你耐心看到这里,我还给你准备了一张知识导图,总结今天所学的内容。
扩展阅读
仅仅一两节课的内容就想要把所有Verilog的相关知识学完是不可能的。因此在课程之外需要你去多搜索多阅读多动手编写Verilg代码才能更好地掌握Verilog的相关知识这里我精心为你整理了一些参考资料供你按需取用
首先是硬件描述语言Verilog HDL的语言标准文件《IEEE Standard Verilog Hardware Description Language (1364-2001)》。-
如果你对底层的基本电路还不熟悉,不妨复习一下大学所学的教材。这里我推荐由童诗白和华成英编写的《模拟电子技术基础》第四版,以及阎石编写的《数字电子技术基础》。-
你要是想全面学习数字集成电路的设计、仿真验证、逻辑综合等相关知识可以看看电子工业出版社出版的《Verilog HDL高级数字设计》。-
最后你要是真的想学芯片设计从更深层次去理解数字电路设计推荐阅读这本Mohit Arora撰写、李海东等人翻译的图书——《硬件架构的艺术——数字电路的设计方法与技术》。
思考题
既然用Verilog很容易就可以设计出芯片的数字电路为什么我们国家还没有完全自主可控的高端CPU呢
期待你在留言区记录自己的学习收获或者疑问。如果这节课对你有帮助也推荐你分享给更多朋友我们一起来手写迷你CPU。

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 指令架构RISC-V在CPU设计上到底有哪些优势
你好我是LMOS。
上节课我们学习了设计一个CPU所需要的相关基础知识并带你认识了一些后面将会用到的EDA软件工具。看完课程的讲解还有上手运行的Demo你是否对接下来要设计CPU已经蠢蠢欲动了
哈哈先别着急我们在设计CPU之前还有一些很关键的知识需要补充学习。没错就是CPU的指令集架构。
指令集可以说是一个CPU的基石要实现CPU的计算和控制功能就必须要定义好一系列与硬件电路相匹配的指令系统。所以在设计CPU之初工程师就应该清楚CPU应该具有怎样的指令架构。
什么是指令集?
在第一节课我们讲历史的时候曾经提到过CPU既是程序指令的执行者又被程序中相关的指令所驱动。不过我并没有具体说明什么是指令。其实指令就是我们交代CPU要执行的操作。
那到底什么是指令集呢?
我给你打个比方:假如你有一条狗,经过一段时间的训练,它能“听懂”了你对它说一些话。当你对它说“坐下”,它就乖乖地坐在地上;当你对它说“汪汪叫”;它就汪汪汪地叫起来,当你对它说“躺下”,它马上就会躺下来……这里你说的“坐下”、“汪汪叫”、“躺下”这些命令,就相当于计算机世界里的指令。
当然你还可以继续训练狗让它识别更多指令我们把所有的这些指令汇总在一起就是一个指令集。如果指令集里面没有“上厕所”这个命令那么即使你对狗下这个命令它也不会去执行。CPU也一样必须要有特定的指令集才能工作。
不同的CPU有不同的指令集根据它们的繁简程度可以分为两种复杂指令集CISC和精简指令集RISC。
指令集架构(英文叫 Instruction Set Architecture缩写为ISA是软件和硬件的接口不同的应用需求会有不同的指令架构。我们要想设计一款CPU指令集体系就是设计的出发点。
RISC-V指令集架构
在开始设计一款处理器之前我们需要选定它的指令集架构。学过前面的课程我们知道RISC-V指令集具有明显的优势一是RISC-V完全开放二是RISC-V指令简单三是RISC-V实行模块化设计易于扩展。
我给你列了一个表用于给你展示一下RISC-V基础指令集和扩展指令集如下所示
要满足现代操作系统和应用程序的基本运行RV32G指令集或者RV64G指令集就够了G是通用的意思 而I只是整数指令集G包含I注意RV32G指令集或者RV64G指令集只有寄存器位宽和寻址空间大小不同这些指令按照功能可以分为如下几类。
整数运算指令:实现算术、逻辑、比较等运算操作。-
分支转移指令:实现条件转移、无条件转移等操作。-
加载存储指令实现字节、半字、字、双字RV64I的加载、存储操作采用的都是寄存器相对寻址方式。-
控制与状态寄存器访问指令:实现对系统控制与系统状态寄存器的原子读-写、原子读-修改、原子读-清零等操作。-
系统调用指令:实现系统调用功能。-
原子指令:用于现在你能看到的各种同步锁。-
单双浮点指令:用于实现浮点运算操作。
从上表我们也可以看到RISC-V指令集具有模块化特点。这就允许我们根据自己的应用需求选择一个基础指令集加上若干个扩展指令集灵活搭配就可以得到我们想要的指令集架构进而根据这样的指令架构设计出贴合我们应用需求的CPU。
作为一个初学者我们先从RISC-V的核心开始。它最核心的部分是一个基础整数指令集叫做RV32I。RV32I就表示32位的RISC-V。指令集的命名方式我在[第二节课]给你提到过如果你印象不深了可以去回顾一下。仅仅依靠RV32I我们就可以运行一个完整的软件栈。
RV32I包含的指令是固定的永远不会改变。这为编译器设计人员操作系统开发人员和汇编语言程序员提供了稳定的基础知识框架。
接下来我们看一张图,这是 RV32I 基础指令集的名称表示。
这些名称,你乍一看是不是有点眼花缭乱?先别慌,我讲一下命名规则,你就能明白了。
从图中我们可以看到,有些字母带有下划线。我们把带有下划线的字母从左到右连接起来,就可以组成一个 RV32I 的指令。对于每一个指令名称,集合标志{}内列举了指令的所有变体变体用加下划线的字母或下划线字符_表示。如果大括号内只有下划线字符_则表示对于此指令变体不需用字母表示。
我们再结合例子加深一下理解。下图表示了这四个 RV32I 指令bgebltbgeubltu。
通过前面[第三节课]硬件描述语言基础的学习我们知道了CPU的硬件逻辑里只有“0”和“1”那么问题来了怎么用“0”和“1”来表示出上述的指令呢
要想回答这个问题,我们需要依次去了解指令格式,指令中要用到的寄存器以及指令集中各种功能的指令。
指令格式
我们先从RV32I的指令格式说起。从下图可以看到RISCV总共也就只有6种指令格式。-
六种指令各司其职,我把它们的作用整理成了表格,这样你看起来一目了然。
不要小看这些指令,我们来分析一下它们到底有哪些优势。这些指令格式规整有序,结构简单。因为指令只有六种格式,并且所有的指令都是 32 位长度的所以这些指令解码起来就比较简单可以简化解码电路提高CPU的性能功耗比。
上图中的opcode代表指令操作码imm代表立即数funct3和funct7代表指令对应的功能rs1、rs2和rd则分别代表源寄存器1、源寄存器2以及目标寄存器。RISC-V的一个指令中可以提供三个寄存器操作数而不是像 x86一样让源操作数和目的操作数共享一个字段因此相比x86指令RISC-V 减少了软件的程序操作。
而且源寄存器rs1和rs2和目标寄存器rd都设计固定在所有RISC-V指令同样的位置上指令译码相对简单。所以指令在CPU流水线中执行时可以先开始访问寄存器然后再完成指令解码。
此外这些指令格式的所有立即数的符号位总是在指令的最高位。这个设计有什么好处呢它意味着有可能成为关键路径的立即数符号扩展可以在指令解码前进行。这样可以加速符号扩展电路有利于CPU流水线的时序优化。
RV32I 寄存器
之前讲指令格式时我们说到了源寄存器rs1、rs2和目标寄存器rd那你知道指令中的寄存器都有哪些吗
在RISC-V的规范里定义了32个通用寄存器。其中有 31 个是常规寄存器1 个恒为 0 值的 x0 寄存器。0值寄存器的设置是为了满足汇编语言程序员和编译器编写者的使用需要他们可以使用x0寄存器作为操作数来完成功能相同的操作。
比如说,我们如果需要插入一个空操作,就可以使用汇编语句 “addi x0 , x0, 0 ”相当于0+0=0来代替其他指令集中的nop空指令。
由于访问寄存器中的数据要比访问存储器的速度快得多,一般每条 RISC-V 指令最多用一个时钟周期执行忽略缓存未命中的情况而ARM-32 或者x86-32 则需要多个时钟周期执行的指令。因为ARM-32只有16个寄存器而X86-32仅仅只有8个寄存器。
因此,寄存器越多,编译器和汇编程序员的工作就会越轻松。
之前[第二节课]我给你列过RV32I的32个通用寄存器这里我再带你复习一下温故知新。表中的ABI全称为Application Binary Interface即应用程序二进制接口也就是寄存器的别名在汇编程序设计时会用到。
寄存器的内容我们就先讲这些后面实现CPU的时候具体用到了我再详细解释。
RV32I的各类指令解读
接下来我们研究一下RV32I的各种指令。如果你写过汇编程序应该知道一般用得较多的就是算术和逻辑处理语句了我们先从这类指令说起。
算术与逻辑指令
在RV32I的指令中包括算术指令add, sub、数值比较指令slt、逻辑指令and, or, xor以及移位指令 sll, srl, sra这几种指令。
这些指令和其他指令集差不多,它们从寄存器读取两个 32 位的值,并将 32 位的运算结果再写回到目标寄存器。RV32I 还提供了这些指令的立即数版本就是如下图所示的I型指令
同样的RV32I也提供了寄存器和寄存器操作的指令包括加减运算、数值比较、逻辑操作和移位操作。这些指令的功能和前面的立即数指令相似不同的是这里把指令中的立即数对应位置替换成了源寄存器 rs2。
寄存器和寄存器操作的指令如下表所示:
需要指出的是,在寄存器和寄存器操作的算术指令中,必须要有减法指令,这和立即数操作指令有所不同。
RV32I 的Load和Store
与CISC指令集具有众多的寻址方式不同RV32I 省略了像 x86-32 指令集那样的复杂寻址模式。在 RISC-V 指令集中,对内存的读写只能通过 LOAD 指令和 STORE 指令实现。而其他的指令,都只能以寄存器为操作对象。
你可以看看后面的这张图里面列出了Load 指令和Store指令格式
如上图所示加载和存储的寻址模式只能是符号扩展12位的立即数加上基地址寄存器得到访问的存储器地址。因为没有了复杂的内存寻址方式这让CPU流水线可以对数据冲突提前做出判断并通过流水线各级之间的转送加以处理而不需要插入空操作NOP极大提高了代码的执行效率。
分支跳转指令
学习了前面的第二节课相信你对RISC-V指令架构特点已经有所了解RISC-V遵循的是大道至简的原则。它的指令数目非常简洁基本指令只有40多条其中只有6条有条件跳转指令减少了跳转指令的条数这样硬件设计上更为简单。
下面我们分别来看看RV32I条件跳转指令和无条件跳转指令的运行原理。这些原理只要你耐心听我讲完就能理解而且之后也会应用在我们的在MiniCPU实现中。
有条件分支跳转
RV32I 中的条件跳转指令是通过比较两个寄存器的值并根据比较结果进行分支跳转。比较可以是相等beq不相等 bne大于等于bge或小于blt
如下图所示大于等于bge和小于blt则跳转指令为有符号数比较RV32I 也提供了相应的无符号数的比较指令分别为bgeu和 bltu。剩下的两个比较关系大于和小于等于可以通过简单地交换两个操作数位置来完成相同的比较。例如 x < y 可以表示为y > x ,同样的, x ≤ y也表示为 y ≥ x。-
无条件分支跳转
除了有条件分支跳转RV32I还提供了无条件跳转指令无条件跳转指令还可以细分为直接跳转和间接跳转这两种指令。
直接跳转指令JAL如下图所示。RISC-V 为 JAL 指令专门定义了 J-TYPE 格式。
-
JAL指令的执行过程是这样的。首先它会把 20 位立即数做符号位扩展,并左移一位,产生一个 32 位的符号数。然后,将该 32 位符号数和 PC 相加来产生目标地址这样JAL 可以作为短跳转指令,跳转至 PC±1 MB 的地址范围内)。
同时JAL 也会把紧随其后的那条指令的地址,存入目标寄存器中。这样,如果目标寄存器是零,则 JAL 就等同于 GOTO 指令如果目标寄存器不为零JAL 可以实现函数调用的功能。
间接跳转指令JALR 如上图所示。JALR 指令会把 12 位立即数和源寄存器相加,并把相加的结果末位清零,作为新的跳转地址。同时,和 JAL 指令一样JALR 也会把紧随其后的那条指令的地址,存入到目标寄存器中。
RV32I的其他指令
除了内存地址空间和通用寄存器地址空间外RISC-V 中还定义了一个独立的控制与状态寄存器Control Status RegisterCSR地址空间。
每个处理器实现的CSR会因设计目标不同而有差异但这些CSR的访问方式却是一致的访问这些 CSR 的指令定义在了用户指令集中Zicsr 指令集扩展)。
有了上图这些CSR指令能够让我们轻松访问一些程序性能计数器。这些计数器包括系统时间、时间周期以及执行的指令数目。
在 RISC-V 指令集中还有其他的一些指令例如用于系统调用的ecall指令在调试时用于将控制转移到调试环境的ebreak 指令等。对于这些扩展的指令,这里就不展开讲了。
到这里我们就把RISC-V的基础整数指令集——RV32I大体梳理了一遍。你可能感慨比起训练一条狗训练“CPU”要复杂得多。不过通过RV32I这个最核心的指令集我们也看到了 RISC-V的很多设计优势。
相比CISCRISCV确实更容易学习和使用。学习了这些基本指令的功能我们就可以设计出简单的CPU了。
重点回顾
好了,今天的课程就到这里,让我们来回顾一下今天学到的内容。
首先我们知道了什么是CPU的指令集并选择 RISC-V最核心的基础整数指令集RV32I 重点学习。RV32I包含的指令是固定的永远不会改变。我们学好RV32I不但能为学习RISC-V的扩展指令集打下基础也能为编译器设计、操作系统开发和汇编程序设计搭建好前置的基础知识框架。
RISC-V到底在CPU设计上有哪些优势我们从指令格式、寄存器以及指令解读这几个方面入手做了不少讨论。
RISC-V仅有6种指令格式它们分别是R类型指令、 I 型指令、 S 型指令、B 类型指令、 U 型指令和 J 型指令。这些指令格式规整有序结构简单所以指令解码起来比较简单有利于简化解码电路提高了CPU的性能功耗比。
此外在RISC-V的规范里定义了32个通用寄存器。其中有 31 个常规寄存器,一个恒 0 值的 x0 寄存器。由于 RISC-V的寄存器有数量上的优势使得基于RISC-V设计CPU不用那么频繁地去访问存储器指令执行起来更快也让编译器和汇编程序员的工作更加轻松。
之后我们了解到RV32I的指令包括算术指令、数值比较指令、逻辑指令以及移位指令这些指令和其他指令集差不多。但是 RISC-V与CISC指令集具有众多的寻址方式不同RV32I 省略了如 x86-32 指令集的复杂寻址模式。在 RISC-V 指令集中,对内存的读写只能通过 LOAD 指令和 STORE 指令实现。
RISC-V遵循的是大道至简的原则它的指令数目非常简洁基本指令只有40多条而分支跳转指令只有8条其中6条是带条件跳转指令2条是无条件跳转指令。这些指令条数的减少使硬件设计更简单。
除了上面提到的指令RISC-V还有其他的一些指令比如还定义了一个独立的控制与状态寄存器地址空间其地址宽度是 12 位的。根据每个设计的目标不同,每个处理器实际实现的 CSR 可能会有所不同。对于剩余没有介绍的一些指令如果你感兴趣的话可以自己查阅相关资料比如RISC-V的官方手册来学习。
最后我为你梳理了这节课的知识导图,供你参考。
思考题
今天我们讲到了RISC-V 中的分支跳转指令 JAL。想想看为什么要通过调整立即数的某些位从 U-TYPE指令得到J-TYPE指令格式呢这样调整以后有什么好处
期待你记录自己这节课学完的收获或者疑问我在留言区等你。如果这节课对你有启发也推荐你分享给更多朋友。下节课我们就要着手设计迷你CPU了敬请期待。

View File

@ -0,0 +1,303 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 手写CPU迷你CPU架构设计与取指令实现
你好我是LMOS。
经过上一节课的学习我们已经知道了一个基于RISC-V指令集设计的CPU必须要实现哪些指令。从这节课开始我们就可以着手设计和实现MiniCPU了。
我会先跟你讲讲什么是流水线在CPU中使用流水线的好处是什么然后我们再以经典的五级流水线为例讲解CPU流水线的五个阶段。接着设计出我们MiniCPU的总体结构并根据规划的五级流水线完成流水线的第一步——取指模块的设计。课程的配套代码可以从这里下载。
话不多说,让我们正式开始今天的学习吧。
什么是CPU流水线
说到流水线你是否会马上想到我们打工人的工厂流水线没错高大上的CPU流水线其实和我们打工人的流水线是一样的。
假如我们在冰墩墩工厂上班,生产流水线分为五个步骤,如下图所示:
在冰墩墩生产线上需要至少五个工人,各自负责模具制作、模具清洗、模具抛光、硅胶塑形和融入图案这五个环节中的一个。最简单的方法自然是:同一时刻只有一个冰墩墩在制作。但是冬奥会的热度让市场上的冰墩墩供应不足,为了早日实现“人手一墩”的目标,有什么提升生产效率的办法呢?
稍微想想就知道,生产线上一个人在制作冰墩墩的时候,另外四个工人都处于空闲状态,显然这是对人力资源的极大浪费。想要提高效率,我们不妨在第一个冰墩墩模具制作出来进入清洗阶段的时候,马上开始进行第二个冰墩墩模具的制作,而不是等到第一个冰墩墩全部步骤做完后,才开始制作下一个。
这样,后续生产中就能够保证五个工人一直处于工作状态,不会造成人员的闲置而产线的冰墩墩就好像流水一样源源不断地产出,因此我们称这种生产方式为流水线。
在CPU中也是使用类似的流水线作业。以经典的五级流水线为例流水线中一条指令的生命周期分为五个阶段
取指阶段Instruction Fetch取指阶段是指将指令从存储器中读取出来的过程。程序指针寄存器用来指定当前指令在存储器中的位置。读取一条指令后程序指针寄存器会根据指令的长度自动递增或者改写成指定的地址。
译码阶段Instruction Decode指令译码是指将存储器中取出的指令进行翻译的过程。指令译码器对指令进行拆分和解释识别出指令类别以及所需的各种操作数。
执行阶段Instruction Execute指令执行是指对指令进行真正运算的过程。例如指令是一条加法运算指令则对操作数进行相加操作如果是一条乘法运算指令则进行乘法运算。在“执行”阶段最关键的模块为算术逻辑单元Arithmetic Logical UnitALU它是实施具体运算的硬件功能单元。
访存阶段Memory Access访存是指存储器访问指令将数据从存储器中读出或写入存储器的过程。
写回阶段Write-Back写回是指将指令执行的结果写回通用寄存器的过程。如果是普通运算指令该结果值来自于“执行”阶段计算的结果如果是存储器读指令该结果来自于“访存”阶段从存储器中读取出来的数据。
和上述的冰墩墩生产线的流水作业一样为了提高效率CPU使用流水线也是为了提高处理器的性能。
对照上图CPU在第一个时钟周期T内完成取指操作。然后在第二个时钟周期2T内对上一条指令进行译码的同时取下一条指令。接着在第三个时钟周期3T内就有取指、译码和执行3个操作同时进行……以此类推五级流水线的CPU内就可以同时进行5个操作。这样平均下来就相当于每条指令只需要五分之一的时钟周期时间来完成。
总体上看,流水线提高了指令的处理速度,缩短了程序执行的时间。
那我们能不能把流水线的思想引入到我们的MiniCPU中呢答案是肯定的。具体如何实现呢我们接着往下看。
MiniCPU的架构
先明确一下我们想实现的目标使用Verilog硬件描述语言基于RV32I指令集设计一个32位的经典五级流水线的处理器核。它将会支持运行大多数RV32I的基础指令。
那什么样的架构设计才能实现这个目标呢参照CPU流水线的五个步骤我们可以对处理器核的各个功能模块进行划分主要模块包括指令提取单元、指令译码单元、整型执行单元、访问存储器和写回结果等单元模块。
根据上面的模块划分我们可以设计出MiniCPU的整体框架如下图所示
这张图片中一个方框就表示一个模块,方框里面的文字就是模块的名字,箭头则表示模块与模块之间的信号传输关系。
从图中可以看到我们要设计的不仅仅是一个CPU内核了它更像是一个SOCSystem on Chip的缩写
因为我们要对它进行一些仿真验证就必须要包含存放指令、数据的ROM和RAM还有一些简单的外设。比如用于串口通信的UART以及一些通用输入、输出端口GPIO都属于外设。CPU通过系统总线System Bus和这些外设进行通信。
下面我们先快速了解一下在我们这个CPU架构中体现五级流水线的主要模块有哪些。
首先我们来看 pre_if模块这里我把它叫作分支预测或者预读取模块因为它主要是先对上一个指令进行预处理判断是不是分支跳转指令。如果是跳转指令则产生跳转后的PC值并对下一条指令进行预读取。
然后是取指通路模块,即 if_id模块。它是取指到译码之间的模块上面的指令预读取之后就会首先送入if_id模块如果当前流水线没有发出指令清除信号if_id模块就会把指令送到译码模块。
接下来是 id_ex模块它是译码到执行之间的模块用于将完成指令译码之后的寄存器索引值以及指令执行的功能信息根据流水线控制模块的控制信号选择性地发送给执行模块去执行。
指令译码之后便可以进行指令执行ex_mem模块负责指令执行之后将数据写入存储器中或者从存储器中读出数据的过程。
最后由 mem_wb模块将指令执行的运算结果或者从存储器读出的数据写回到通用寄存器。到这里处理器流水线的总体结构就设计好啦。
接下来我们先完成流水线第一步,即取指模块的设计与实现。
流水线的第一步:指令预读取
我们的MiniCPU流水线的第一步是指令预读取也就是先把指令从存储器中读出。
由于我们的指令长度是32位的也就是一条指令在存储器中占有4个字节的空间所以一般情况下CPU中的程序计数器PC是以4递增的。
但是,如果你熟悉计算机程序就应该知道,我们的程序通常不是从头到尾执行一次就完事了,往往还需要调用函数或者循环执行某一段程序的操作。
而这样的操作在硬件底层的CPU里面就涉及分支跳转指令了。为了实现程序分支跳转功能就需要我们的预读取模块来处理。
我先把这个模块的Verilog代码给你展示一下再具体给你讲解
module pre_if (
input [31:0] instr,
input [31:0] pc,
output [31:0] pre_pc
);
wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); //条件跳转指令的操作码
wire is_jal = (instr[6:0] == `OPCODE_JAL) ; //无条件跳转指令的操作码
//B型指令的立即数拼接
wire [31:0] bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
//J型指令的立即数拼接
wire [31:0] jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
//指令地址的偏移量
wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4;
assign pre_pc = pc + adder;
endmodule
我们来看看第八行和第九行代码分别是根据指令的低7位操作码判断是否是条件跳转指令或是无条件跳转指令。
其实上一节课的RISC-V指令架构中我们讲过RISC-V指令集中有两类分支跳转指令分别是条件跳转指令和无条件跳转指令。
条件跳转指令格式如下表所示:
从这张表格我们可以发现条件跳转指令的操作码也就是指令中的低7位数都是 7b1100011。根据这一特点我们就可以在指令解码之前判断出接下来可能会发生跳转。
我们结合代码来看看。下面的Verilog语句就是跳转指令的判断其中的`OPCODE_BRANCH 已经通过宏定义为 7b1100011。
wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); //条件跳转指令的操作码
条件跳转指令执行时是否发生跳转,要根据相关的数据来判断,这就需要指令执行之后才能知道是否需要跳转(具体如何判断,我们后面第十节课再展开)。
但是我们的CPU是多级流水线架构一条指令执行需要多个时钟周期。如果要等到跳转指令执行完成之后再去取下一条指令就会降低我们的指令执行效率。
而指令预读取模块刚好可以解决这个问题。不管指令是否跳转都提前把跳转之后的下一条指令从存储器中读取出来以备流水线的下一阶段使用这就提高了CPU的执行效率。
以下代码就是根据条件跳转指令的格式对指令中的立即数进行拼接为指令跳转时的PC提供偏移量。
//B型指令的立即数拼接
wire [31:0] bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
同样地无条件跳转指令也用这种方式进行预处理。如下图的jal跳转指令的格式它的操作码为7b1101111。-
根据指令的操作码预译码电路就可以判断出是否为无条件跳转指令。下面就是无条件跳转指令的判断的Verilog语句其中的`OPCODE_BRANCH已经通过宏定义为 7b1101111。
wire is_jal = (instr[6:0] == `OPCODE_JAL) ; //无条件跳转指令的操作码
顾名思义无条件跳转指令就是不需要判断其他的任何条件直接跳转。我们继续结合代码理解这行代码的意思是根据jal指令的格式对指令中的立即数进行拼接为指令跳转时的PC提供偏移量。
//J型指令的立即数拼接
wire [31:0] jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
最后预读取电路会根据当前的PC值和指令的偏移量相加得到预测的PC值并用预测的PC值提前读出下一条指令。其Verilog代码如下
//指令地址的偏移量
wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4;
assign pre_pc = pc + adder;
取指数据通路模块
由上述的指令预读取模块把指令从存储器中读取之后,需要把它发送给译码模块进行翻译。但是,预读取模块读出的指令,并不是全部都能发送后续模块去执行。
例如上面的条件分支指令在指令完成之前就把后续的指令预读取出来了。如果指令执行之后发现跳转的条件不成立这时预读取的指令就是无效的需要对流水线进行冲刷flush把无效的指令都清除掉。
取指通路模块 if_id 主要产生3个信号。首先是给后面解码模块提供的指令信号 reg_instr。如果流水线没有发生冲突也就是没有发出清除信号flush则把预读取的指令保存否则把指令清“0”。
//指令通路
always @(posedge clock) begin
if (reset) begin
reg_instr <= 32'h0;
end else if (flush) begin
reg_instr <= 32'h0;
end else if (valid) begin
reg_instr <= in_instr;
end
end
第二个是更新PC值如果指令清除信号flush=“0”则把当前指令对应的PC值保存为reg_pc否则就把reg_pc清“0”。
//PC值通路
always @(posedge clock) begin""
if (reset) begin
reg_pc <= 32'h0;
end else if (flush) begin
reg_pc <= 32'h0;
end else if (valid) begin
reg_pc <= in_pc;
end
end
最后一个是流水线冲刷的标志信号 reg_noflush。当需要进行流水线冲刷时reg_noflush=“0”否则reg_noflush=“1”。
//流水线冲刷标志位
always @(posedge clock) begin
if (reset) begin
reg_noflush <= 1'h0;
end else if (flush) begin
reg_noflush <= 1'h0;
end else if (valid) begin
reg_noflush <= in_noflush;
end
end
以下就是if_id模块的完整代码
// IF_ID
module if_id(
input clk,
input reset,
input [31:0] in_instr,
input [31:0] in_pc,
input flush,
input valid,
output [31:0] out_instr,
output [31:0] out_pc,
output out_noflush
);
reg [31:0] reg_instr;
reg [31:0] reg_pc;
reg [31:0] reg_pc_next;
reg reg_noflush;
assign out_instr = reg_instr;
assign out_pc = reg_pc;
assign out_noflush = reg_noflush;
//指令传递
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_instr <= 32'h0;
end else if (flush) begin
reg_instr <= 32'h0;
end else if (valid) begin
reg_instr <= in_instr;
end
end
//PC值转递
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_pc <= 32'h0;
end else if (flush) begin
reg_pc <= 32'h0;
end else if (valid) begin
reg_pc <= in_pc;
end
end
//流水线冲刷标志位
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_noflush <= 1'h0;
end else if (flush) begin
reg_noflush <= 1'h0;
end else if (valid) begin
reg_noflush <= 1'h1;
end
end
endmodule
好了到这里CPU流水线的第一步——取指我们就讲完了。在取指阶段就是把存储器里的指令读出并传递给后续的译码模块进行处理。
重点回顾
今天我们终于开启了MiniCPU的设计与实现之旅为此我们做了很多准备恭喜你坚持到这里。
在开始设计之前我先带你了解了流水线的设计思想。工厂里的流水线设计在CPU里也可以借鉴通过这种方法就能提高CPU的性能。
真正的CPU流水线要根据应用需求来设计应用场景不一样设计的流水线也不一样。为了让你在弄懂原理的基础上能快速上手我们的MiniCPU采用了经典的五级流水线设计。这个流水线里一条指令的五个阶段分别是取指、译码、执行、访存和写回。
从MiniCPU的架构设计上也能看到我们的重心放在了最能体现五级流水线的模块。不过麻雀虽小五脏俱全这个架构里已经包含了CPU内核用于存放指令、数据的ROM和RAM以及一些简单的外设。CPU会通过系统总线System Bus和这些外设进行通信。
CPU架构里的五个主要模块你可以参考后面的导图其中前两个模块我们这节课已经拿下了其它模块之后的课程里我们再展开学习。-
明确了设计思想和架构以后,我带你迈出了流水线的第一步,也就是取指令。
我们现实通过指令预读取模块,在程序发生分支跳转的之前,对指令进行分析,预测指令跳转的方向,并提前读取跳转后的指令。这么做能提高指令在流水线中执行效率。
最后在if_id模块中会根据是否需要进行流水线冲刷来判断预读取的指令能否传递给后面的译码模块。如果指令在流水线中发生冲突需要进行流水线冲刷就把预读取的指令清除否则就把预读取的指令传递给后续的译码模块。
那之后指令是如何译码的呢?译码是流水线很关键的一步,让我们下节课一起解锁这部分内容吧。
思考题
为什么要对指令进行预读取?直接取指然后译码、执行不可以吗?
欢迎你在留言区提问或者记录今天的收获如果感觉这节课还不错也推荐你分享给身边的朋友和他一起手写CPU。

View File

@ -0,0 +1,380 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 手写CPU如何实现指令译码模块
你好我是LMOS。
上节课我们了解了什么是CPU的流水线并决定采用经典的五级流水线来设计我们的MiniCPU之后梳理了我们将要设计的MiniCPU架构长什么样最后完成了流水线的第一步——取指。
取指阶段把存储器里的指令读出以后,就会传递给后续的译码模块进行处理。那之后指令是如何译码的呢?这就要说到流水线的第二步——译码(代码从这里下载)。
指令是如何翻译的?
[第五节课]我们已经讲过了RISC-V指令架构明确了我们的MiniCPU选用的是RV32I指令集。其中每条指令都是32位且分为6种指令格式不同格式的指令中包含了不一样的指令信息。
如上图所示的6种指令格式其中R型指令包含了操作码opcode、目标寄存器索引rd、功能码funct3和funct7以及源寄存器索引rs1和rs2。而I型指令则是包含操作码opcode、目标寄存器索引rd、功能码funct3、源寄存器索引rs1以及立即数imm。
与此类似后面的S型指令、B型指令、U型指令和J型指令也有特定的操作码、功能码、源寄存器索引、目标寄存器索引和立即数。
不过指令格式不同,指令译码模块翻译指令的工作机制却是统一的。首先译码电路会翻译出指令中携带的寄存器索引、立即数大小等执行信息。接着,在解决数据可能存在的数据冒险(这个概念后面第九节课会讲)之后,由译码数据通路负责把译码后的指令信息,发送给对应的执行单元去执行。
译码模块的设计
通过上面的分析,你是否对译码模块的设计已经有了头绪?是的,译码模块就是拆解从取指模块传过来的每一条指令。译码时,需要识别出指令的操作码,并根据对应的指令格式提取出指令中包含的信息。
译码模块具体的Verilog设计代码如下
module decode (
input [31:0] instr, //指令源码
output [4:0] rs1_addr, //源寄存器rs1索引
output [4:0] rs2_addr, //源寄存器rs2索引
output [4:0] rd_addr, //目标寄存器rd索引
output [2:0] funct3, //功能码funct3
output [6:0] funct7, //功能码funct7
output branch,
output [1:0] jump,
output mem_read,
output mem_write,
output reg_write,
output to_reg,
output [1:0] result_sel,
output alu_src,
output pc_add,
output [6:0] types,
output [1:0] alu_ctrlop,
output valid_inst,
output [31:0] imm
);
localparam DEC_INVALID = 21'b0;
reg [20:0] dec_array;
//---------- decode rs1、rs2 -----------------
assign rs1_addr = instr[19:15];
assign rs2_addr = instr[24:20];
//---------- decode rd -----------------------
assign rd_addr = instr[11:7];
//---------- decode funct3、funct7 -----------
assign funct7 = instr[31:25];
assign funct3 = instr[14:12];
// ----------------------------- decode signals ---------------------------------
// 20 19-18 17 16 15 14 13-12 11 10 9--------3 2---1 0
// branch jump memRead memWrite regWrite toReg resultSel aluSrc pcAdd RISBUJZ aluctrlop validInst
localparam DEC_LUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b01, 1'b0, 1'b0, 7'b0000100, 2'b00, 1'b1};
localparam DEC_AUIPC = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b1, 7'b0000100, 2'b00, 1'b1};
localparam DEC_JAL = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b0, 1'b0, 7'b0000010, 2'b00, 1'b1};
localparam DEC_JALR = {1'b0, 2'b11, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1};
localparam DEC_BRANCH = {1'b1, 2'b00, 1'b0, 1'b0, 1'b0, 1'b0, 2'b00, 1'b0, 1'b0, 7'b0001000, 2'b10, 1'b1};
localparam DEC_LOAD = {1'b0, 2'b00, 1'b1, 1'b0, 1'b1, 1'b1, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1};
localparam DEC_STORE = {1'b0, 2'b00, 1'b0, 1'b1, 1'b0, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0010000, 2'b00, 1'b1};
localparam DEC_ALUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b01, 1'b1};
localparam DEC_ALUR = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b0, 1'b0, 7'b1000000, 2'b01, 1'b1};
assign {branch, jump, mem_read, mem_write, reg_write, to_reg, result_sel, alu_src, pc_add, types, alu_ctrlop, valid_inst} = dec_array;
always @(*) begin
case(instr[6:0])
`OPCODE_LUI : dec_array <= DEC_LUI;
`OPCODE_AUIPC : dec_array <= DEC_AUIPC;
`OPCODE_JAL : dec_array <= DEC_JAL;
`OPCODE_JALR : dec_array <= DEC_JALR;
`OPCODE_BRANCH : dec_array <= DEC_BRANCH;
`OPCODE_LOAD : dec_array <= DEC_LOAD;
`OPCODE_STORE : dec_array <= DEC_STORE;
`OPCODE_ALUI : dec_array <= DEC_ALUI;
`OPCODE_ALUR : dec_array <= DEC_ALUR;
default : begin
dec_array <= DEC_INVALID;
end
endcase
end
// -------------------- IMM -------------------------
wire [31:0] Iimm = {{21{instr[31]}}, instr[30:20]};
wire [31:0] Simm = {{21{instr[31]}}, instr[30:25], instr[11:7]};
wire [31:0] Bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
wire [31:0] Uimm = {instr[31:12], 12'b0};
wire [31:0] Jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
assign imm = {32{types[5]}} & Iimm
| {32{types[4]}} & Simm
| {32{types[3]}} & Bimm
| {32{types[2]}} & Uimm
| {32{types[1]}} & Jimm;
endmodule
这段代码看起来很长其实整个代码可以分为三个部分第28行到37行负责完成指令的源寄存器、目标寄存器、3位操作码和7位操作码的译码第40行至73行负责完成指令格式类型的识别第75行至87行负责完成立即数译码。
首先我们来看指令中源寄存器、目标寄存器、3位操作码和7位操作码的译码。仔细观察上面提到的6种指令格式我们可以发现一定的规律全部的目标寄存器索引 rd 都位于指令的第711位源寄存器索引 rs1 位于指令的第1519位源寄存器索引 rs2 位于指令的第2024位三位的操作码 funct3 位于指令的第1214位七位的操作码 funct7 位于指令的第2531位。
它们的位置分布如下图所示:
上述这些信号在不同指令格式中的位置比较固定。因此我们就可以根据这些位置特点直接从指令中截取从而得到它们相应的信息具体实现的Verilog代码如下对应整体代码的2737行
//---------- decode rs1、rs2 -----------------
assign rs1_addr = instr[19:15];
assign rs2_addr = instr[24:20];
//---------- decode rd -----------------------
assign rd_addr = instr[11:7];
//---------- decode funct3、funct7 -----------
assign funct7 = instr[31:25];
assign funct3 = instr[14:12];
在所有的指令格式中还有一段最为特殊的信息码。这段信息码是每条指令都有的且位置和位宽保持不变。没错它就是指令的操作码opcode。
对照RISC-V的官方手册我为你整理出了RV32I指令集的操作码对照表如下所示
我们再来回顾一下RISC-V的指令格式这次我们重点观察指令操作码的位置。
不难发现所有指令操作码都位于指令的第06位。根据这7位的操作码就可以判断出一条指令是什么类型它对应的是什么指令格式。进而可以产生指令执行信号为后续的指令执行单元的操作提供依据。
以下就是指令操作码的译码和产生相关指令控制信号的Verilog代码对应整体代码的3972行
// ----------------------------- decode signals ---------------------------------
// 20 19-18 17 16 15 14 13-12 11 10 9--------3 2---1 0
// branch jump memRead memWrite regWrite toReg resultSel aluSrc pcAdd RISBUJZ aluctrlop validInst
localparam DEC_LUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b01, 1'b0, 1'b0, 7'b0000100, 2'b00, 1'b1};
localparam DEC_AUIPC = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b1, 7'b0000100, 2'b00, 1'b1};
localparam DEC_JAL = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b0, 1'b0, 7'b0000010, 2'b00, 1'b1};
localparam DEC_JALR = {1'b0, 2'b11, 1'b0, 1'b0, 1'b1, 1'b0, 2'b10, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1};
localparam DEC_BRANCH = {1'b1, 2'b00, 1'b0, 1'b0, 1'b0, 1'b0, 2'b00, 1'b0, 1'b0, 7'b0001000, 2'b10, 1'b1};
localparam DEC_LOAD = {1'b0, 2'b00, 1'b1, 1'b0, 1'b1, 1'b1, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b00, 1'b1};
localparam DEC_STORE = {1'b0, 2'b00, 1'b0, 1'b1, 1'b0, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0010000, 2'b00, 1'b1};
localparam DEC_ALUI = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b1, 1'b0, 7'b0100000, 2'b01, 1'b1};
localparam DEC_ALUR = {1'b0, 2'b00, 1'b0, 1'b0, 1'b1, 1'b0, 2'b00, 1'b0, 1'b0, 7'b1000000, 2'b01, 1'b1};
assign {branch, jump, mem_read, mem_write, reg_write, to_reg, result_sel, alu_src, pc_add, types, alu_ctrlop, valid_inst} = dec_array;
always @(*) begin
case(instr[6:0])
`OPCODE_LUI : dec_array <= DEC_LUI;
`OPCODE_AUIPC : dec_array <= DEC_AUIPC;
`OPCODE_JAL : dec_array <= DEC_JAL;
`OPCODE_JALR : dec_array <= DEC_JALR;
`OPCODE_BRANCH : dec_array <= DEC_BRANCH;
`OPCODE_LOAD : dec_array <= DEC_LOAD;
`OPCODE_STORE : dec_array <= DEC_STORE;
`OPCODE_ALUI : dec_array <= DEC_ALUI;
`OPCODE_ALUR : dec_array <= DEC_ALUR;
default : begin
dec_array <= DEC_INVALID;
end
endcase
end
从上面的代码我们可以看到译码的过程就是先识别指令的低7位操作码instr[6:0]根据操作码对应的代码标识产生分支信号branch、跳转信号jump、读存储器信号mem_read……这些译码之后的指令控制信息。然后把译码得到的信息交到CPU流水线的下一级去执行。
此外还有指令中的立即数需要提取。观察上述的6种指令格式你会发现除了R型指令不包含立即数其他5种指令类型都包含了立即数。
前面我已经讲过了怎么去识别指令的类型。那指令里的立即数怎么提取呢?其实这跟提取指令的索引、功能码差不多。
我们根据不同指令类型中立即数的分布位置就能直接提取指令的立即数。最后也是根据指令的类型选择性输出I型、S型、B型、U型或者J型指令的立即数即可具体的代码如下
// -------------------- IMM -------------------------
wire [31:0] Iimm = {{21{instr[31]}}, instr[30:20]};
wire [31:0] Simm = {{21{instr[31]}}, instr[30:25], instr[11:7]};
wire [31:0] Bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
wire [31:0] Uimm = {instr[31:12], 12'b0};
wire [31:0] Jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
assign imm = {32{types[5]}} & Iimm
| {32{types[4]}} & Simm
| {32{types[3]}} & Bimm
| {32{types[2]}} & Uimm
| {32{types[1]}} & Jimm;
译码控制模块设计
前面的译码模块得到的指令信号可以分为两大类。一类是由指令的操作码经过译码后产生的指令执行控制信号如跳转操作jump信号、存储器读取mem_read信号等另一类是从指令源码中提取出来的数据信息如立即数、寄存器索引、功能码等。
为了能对流水线更好地实施控制,这里我们需要把译码后的数据和控制信号分开处理。首先来看译码控制模块的实现:
module id_ex_ctrl(
input clk,
input reset,
input in_ex_ctrl_itype,
input [1:0] in_ex_ctrl_alu_ctrlop,
input [1:0] in_ex_ctrl_result_sel,
input in_ex_ctrl_alu_src,
input in_ex_ctrl_pc_add,
input in_ex_ctrl_branch,
input [1:0] in_ex_ctrl_jump,
input in_mem_ctrl_mem_read,
input in_mem_ctrl_mem_write,
input [1:0] in_mem_ctrl_mask_mode,
input in_mem_ctrl_sext,
input in_wb_ctrl_to_reg,
input in_wb_ctrl_reg_write,
input in_noflush,
input flush,
input valid,
output out_ex_ctrl_itype,
output [1:0] out_ex_ctrl_alu_ctrlop,
output [1:0] out_ex_ctrl_result_sel,
output out_ex_ctrl_alu_src,
output out_ex_ctrl_pc_add,
output out_ex_ctrl_branch,
output [1:0] out_ex_ctrl_jump,
output out_mem_ctrl_mem_read,
output out_mem_ctrl_mem_write,
output [1:0] out_mem_ctrl_mask_mode,
output out_mem_ctrl_sext,
output out_wb_ctrl_to_reg,
output out_wb_ctrl_reg_write,
output out_noflush
);
reg reg_ex_ctrl_itype;
reg [1:0] reg_ex_ctrl_alu_ctrlop;
reg [1:0] reg_ex_ctrl_result_sel;
reg reg_ex_ctrl_alu_src;
reg reg_ex_ctrl_pc_add;
reg reg_ex_ctrl_branch;
reg [1:0] reg_ex_ctrl_jump;
reg reg_mem_ctrl_mem_read;
reg reg_mem_ctrl_mem_write;
reg [1:0] reg_mem_ctrl_mask_mode;
reg reg_mem_ctrl_sext;
reg reg_wb_ctrl_to_reg;
reg reg_wb_ctrl_reg_write;
reg reg_noflush;
……………… //由于这里的代码较长,结构相似,这里省略了一部分
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_noflush <= 1'h0;
end else if (flush) begin
reg_noflush <= 1'h0;
end else if (valid) begin
reg_noflush <= in_noflush;
end
end
endmodule
上面就是译码控制模块的Verilog设计代码。
上一节课学习取指模块的时候我们说过,并不是所有从存储器中读取出来的指令,都能够给到执行单元去执行的。比如,当指令发生冲突时,需要对流水线进行冲刷,这时就需要清除流水线中的指令。同样的,译码阶段的指令信号也需要清除。
译码控制模块就是为了实现这一功能当指令清除信号flush有效时把译码模块产生的jump、branch、mem_read、mem_write、reg_write……这些控制信号全部清“0”。否则就把这些控制信号发送给流水线的下一级进行处理。
译码数据通路模块设计
和译码模块类似译码数据通路模块会根据CPU相关控制模块产生的流水线冲刷控制信号决定要不要把这些数据发送给后续模块。
其中译码得到的数据信息包括立即数imm、源寄存器索引rs1和rs2、目标寄存器索引rd以及功能码funct3和funct7。具体的设计代码如下所示
module id_ex(
input clk,
input reset,
input [4:0] in_rd_addr,
input [6:0] in_funct7,
input [2:0] in_funct3,
input [31:0] in_imm,
input [31:0] in_rs2_data,
input [31:0] in_rs1_data,
input [31:0] in_pc,
input [4:0] in_rs1_addr,
input [4:0] in_rs2_addr,
input flush,
input valid,
output [4:0] out_rd_addr,
output [6:0] out_funct7,
output [2:0] out_funct3,
output [31:0] out_imm,
output [31:0] out_rs2_data,
output [31:0] out_rs1_data,
output [31:0] out_pc,
output [4:0] out_rs1_addr,
output [4:0] out_rs2_addr
);
reg [4:0] reg_rd_addr;
reg [6:0] reg_funct7;
reg [2:0] reg_funct3;
reg [31:0] reg_imm;
reg [31:0] reg_rs2_data;
reg [31:0] reg_rs1_data;
reg [31:0] reg_pc;
reg [4:0] reg_rs1_addr;
reg [4:0] reg_rs2_addr;
………… //由于代码较长结构相似这里省略了一部分完整代码你可以从Gitee上获取
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_rs2_addr <= 5'h0;
end else if (flush) begin
reg_rs2_addr <= 5'h0;
end else if (valid) begin
reg_rs2_addr <= in_rs2_addr;
end
end
endmodule
我们以目标寄存器的索引地址reg_rd_addr信号为例分析一下它是怎么流通的。当流水线冲刷信号flush有效时目标寄存器的索引地址reg_rd_addr直接清“0”否则当信号有效标志valid为“1”时把目标寄存器的索引地址传递给流水线的下一级。
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_rd_addr <= 5'h0;
end else if (flush) begin
reg_rd_addr <= 5'h0;
end else if (valid) begin
reg_rd_addr <= in_rd_addr;
end
end
类似地当流水线冲刷信号flush有效时把译码模块得到的源操作数1、源操作数2、立即数、目标寄存器地址……等等这些数据全部清“0”。否则就把这些数据发送给流水线的下一级进行处理。
重点回顾
指令译码是CPU流水线中比较重要的一步在译码阶段一定不能出错否则流水线后续的执行就全都乱了。今天我们设计出了指令译码的相关模块我带你回顾一下这节课的要点。
首先我们针对RV32I指令集的6种指令格式分析了它们各自包含了哪些指令信号。根据这些信息的位置不同指令译码模块就可以从不同类型的指令格式中把每条指令包含的信息提取出来。
之后,根据上面分析的译码思路,我们就可以设计译码模块了。经过观察,我们发现指令中的操作码、目标寄存器索引、源寄存器索引和功能码,在不同指令格式中的位置比较固定,所以这些信息可以直接从指令中截取得到。
由于指令的操作码有特殊的指令标识作用我们可以根据操作码产生指令控制信息给到CPU流水线的下一级去执行。此外还可以根据不同指令类型中立即数的分布位置特点通过截取得到指令的立即数。
译码得到的指令信号分为两大类:一类是由指令的操作码经过译码后产生的指令执行控制信号,另一类是从指令源码中提取出来的数据信息。为了让译码后的信息,能更好地分发给流水线后续模块去执行,这里我们把译码后的数据和控制信号分开处理,分别设计了数据通路模块和译码控制模块。
思考题
在6种指令格式中S型、J型和B型指令里的立即数是不连续的这是为什么
欢迎你在留言区跟我交流互动也推荐你把这节课分享给更多朋友组团一起来跟我折腾CPU!

View File

@ -0,0 +1,271 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 手写CPU如何实现指令执行模块
你好我是LMOS。
上一节课我们完成了CPU流水线的指令译码模块设计。我们一起探讨了RISC-V指令是如何翻译的还学会了提取不同类型指令中的信息。最后根据流水线的需要我们设计出了译码控制模块和数据通路模块。
接下来我们利用译码后的这些信息继续设计流水线的下一级——执行单元。指令执行算是CPU流水线中最复杂的一个阶段了不过别担心经过前面课程的准备我们一定可以搞定它。
CPU的执行概述
回顾前面我们已经设计完成的CPU流水线步骤
取指模块根据程序计数器PC寻址到指令所在的存储单元并从中取出指令。-
译码模块对取出的指令进行翻译,得到功能码、立即数、寄存器索引等字段,然后根据某些字段读取一个或两个通用寄存器的值。
经过流水线的这两个步骤之后,下一步就需要把这些指令信息发送给执行单元去执行相关操作。根据译码之后的指令信息,我们可以把指令分为三类,分别是算术逻辑指令、分支跳转指令、存储器访问指令。
[上节课]我们已经详细解读了RISC-V指令集的指令格式正是因为格式上比较简单而且规整所以不同类别的指令执行过程也是类似的。这样RISC执行单元的电路结构相比CISC就得到了简化。
所以在指令执行阶段上述的这三类指令都能通过ALU进行相关操作。比如存储访问指令用ALU进行地址计算条件分支跳转指令用ALU进行条件比较算术逻辑指令用ALU进行逻辑运算。
上图就是ALU模块的设计框图。在ALU模块中指令可以分成三类来处理第一类是普通的ALU指令包括逻辑运算、移位操作等指令第二类指令负责完成存储器访问指令Load和Store的地址生成工作第三类是负责分支跳转指令的结果解析和执行。这就是流水线执行阶段的核心模块ALU的设计思路。
执行控制模块的设计
根据上节课设计的译码模块,我们已经得到了指令的功能码、立即数、寄存器索引等字段信息。
你是否还记得我们在译码模块里根据指令的7位操作码opcode字段还产生了一个ALU执行的指令控制字段aluCrtlOp。这正是上文提到的ALU模块把指令分成三类执行的控制信号。
具体的信号编码,你可以参考后面的表格:
根据2位执行类型字段aluCrtlOp以及指令译码得到的操作码funct7和funct3就可以设计我们的执行控制模块了。
结合前面的表格我们来看看执行控制模块如何根据aluCrtlOp信号做判断。
如果aluCrtlOp等于00对应的指令类型就是Load和Store指令也就是通过加法运算来计算访存地址如果aluCrtlOp等于01相应的指令类型就是ALUI/ALUR同样也是根据输入的funct7和funct3字段决定执行哪些算术运算比如加减运算、移位操作等如果类型字段等于10就对应着分支跳转指令流水线就会相应去完成条件分支的解析工作。
表格最后一列你先不用关注,扩展功能时才可能用到,这里先关注前三类情况即可。
具体设计的Verilog代码如下
module ALUCtrl (
input [2:0] funct3,
input [6:0] funct7,
input [1:0] aluCtrlOp,
input itype,
output reg [3:0] aluOp
);
always @(*) begin
case(aluCtrlOp)
2'b00: aluOp <= `ALU_OP_ADD; // Load/Store
2'b01: begin
if(itype & funct3[1:0] != 2'b01)
aluOp <= {1'b0, funct3};
else
aluOp <= {funct7[5], funct3}; // normal ALUI/ALUR
end
2'b10: begin
case(funct3) // bxx
`BEQ_FUNCT3: aluOp <= `ALU_OP_EQ;
`BNE_FUNCT3: aluOp <= `ALU_OP_NEQ;
`BLT_FUNCT3: aluOp <= `ALU_OP_SLT;
`BGE_FUNCT3: aluOp <= `ALU_OP_GE;
`BLTU_FUNCT3: aluOp <= `ALU_OP_SLTU;
`BGEU_FUNCT3: aluOp <= `ALU_OP_GEU;
default: aluOp <= `ALU_OP_XXX;
endcase
end
default: aluOp <= `ALU_OP_XXX;
endcase
end
endmodule
这里要注意的是当aluCtrlOp等于01需要根据funct3和funct7产生ALU的操作码。在前面的译码模块中已经为我们提供了I型指令类型的判断信号itype。如果是itype信号等于“1”操作码直接由funct3和高位补“0”组成如果不是I型指令ALU操作码则要由funct3和funct7的第五位组成。
根据上述的三类指令就会产生一个4位的ALU操作信号aluOp为后面的ALU模块做相关逻辑运行提供操作码。
通用寄存器
在ALU模块开始执行运算之前我们还需要提前完成一个操作——读取通用寄存器。在参与ALU逻辑运算的两个操作数中至少有一个来自于通用寄存器另一个可以来自于通用寄存器或者指令自带的立即数如下图所示
由于第七节课提到的6种指令中的R型指令有三个操作数分别对应于两个源寄存器rs1和rs2以及一个目标寄存器rd。一条R指令类似于有三个参数的函数如addrdrs1rs2完成的功能就是先读取rs1、rs2两个参数然后相加最后把结果写入到rd参数中。
对应到每条指令,则需要从通用寄存器模块中读取其中两个寄存器中的数据,之后还要把运算结果写入另一个通用寄存器。每读取一个寄存器,就需要输入一个寄存器索引,并输出一个通用寄存器中的值。两个操作数对应的寄存器需要同时读取,所以通用寄存器模块需要两个读地址接口和两个读数据输出接口。
此外,处于流水线上的指令是并发执行的,在读取通用寄存器的同时,可能还需要写入数据到通用寄存器,所以需要一套写地址和写数据接口。因此,通用寄存器模块的设计框图如下:
根据上述的设计思路,我们就可以设计和实现通用寄存器代码了。
module gen_regs (
input clk,
input reset,
input wen,
input [4:0] regRAddr1, regRAddr2, regWAddr,
input [31:0] regWData,
output [31:0] regRData1,
output [31:0] regRData2
);
integer ii;
reg [31:0] regs[31:0];
// write registers
always @(posedge clk or posedge reset) begin
if(reset) begin
for(ii=0; ii<32; ii=ii+1)
regs[ii] <= 32'b0;
end
else if(wen & (|regWAddr))
regs[regWAddr] <= regWData;
end
// read registers
assign regRData1 = wen & (regWAddr == regRAddr1) ? regWData
: ((regRAddr1 != 5'b0) ? regs[regRAddr1] : 32'b0);
assign regRData2 = wen & (regWAddr == regRAddr2) ? regWData
: ((regRAddr2 != 5'b0) ? regs[regRAddr2] : 32'b0);
endmodule
这里添加了一个写控制使能信号wen。因为写寄存器是边沿触发的在一个时钟周期内写入的寄存器数据需要在下一个时钟周期才能把写入的数据读取出来。为了提高读写效率在对同一个寄存器进行读写时如果写使能wen有效就直接把写入寄存器的数据送给读数据接口这样就可以在一个时钟周期内读出当前要写入的寄存器数据了。-
从前面的章节中我们知道通用寄存器总共有32个所以通用寄存器模块上的读写地址都是5位\(2^{5}\)=32
其中还有一个寄存器比较特殊从代码中也可以看到它的特殊处理即读地址regRAddr1 = 5b0 时的寄存器。我们把第一个寄存器叫做0值寄存器因为在RISC-V指令架构中就规定好了第一个通用寄存器必须编码为0也就是把写入该寄存器的数据忽略而在读取时永远输出为0。
ALU模块设计
当操作码和操作数都准备好后我们就可以开始ALU模块的实现了。
上述执行控制模块根据三类指令产生的ALU操作信号aluOp在ALU模块就能以此为依据执行相应的运算了。操作码对应的ALU操作如下表所示
根据表格中的操作编码和对应的运行操作很容易就可以设计出ALU模块具体的设计代码如下
module alu (
input [31:0] alu_data1_i,
input [31:0] alu_data2_i,
input [ 3:0] alu_op_i,
output [31:0] alu_result_o
);
reg [31:0] result;
wire [31:0] sum = alu_data1_i + ((alu_op_i[3] | alu_op_i[1]) ? -alu_data2_i : alu_data2_i);
wire neq = |sum;
wire cmp = (alu_data1_i[31] == alu_data2_i[31]) ? sum[31]
: alu_op_i[0] ? alu_data2_i[31] : alu_data1_i[31];
wire [ 4:0] shamt = alu_data2_i[4:0];
wire [31:0] shin = alu_op_i[2] ? alu_data1_i : reverse(alu_data1_i);
wire [32:0] shift = {alu_op_i[3] & shin[31], shin};
wire [32:0] shiftt = ($signed(shift) >>> shamt);
wire [31:0] shiftr = shiftt[31:0];
wire [31:0] shiftl = reverse(shiftr);
always @(*) begin
case(alu_op_i)
`ALU_OP_ADD: result <= sum;
`ALU_OP_SUB: result <= sum;
`ALU_OP_SLL: result <= shiftl;
`ALU_OP_SLT: result <= cmp;
`ALU_OP_SLTU: result <= cmp;
`ALU_OP_XOR: result <= (alu_data1_i ^ alu_data2_i);
`ALU_OP_SRL: result <= shiftr;
`ALU_OP_SRA: result <= shiftr;
`ALU_OP_OR: result <= (alu_data1_i | alu_data2_i);
`ALU_OP_AND: result <= (alu_data1_i & alu_data2_i);
`ALU_OP_EQ: result <= {31'b0, ~neq};
`ALU_OP_NEQ: result <= {31'b0, neq};
`ALU_OP_GE: result <= {31'b0, ~cmp};
`ALU_OP_GEU: result <= {31'b0, ~cmp};
default: begin
result <= 32'b0;
end
endcase
end
function [31:0] reverse;
input [31:0] in;
integer i;
for(i=0; i<32; i=i+1) begin
reverse[i] = in[31-i];
end
endfunction
assign alu_result_o = result;
endmodule
在上面的ALU模块代码中输入信号aluIn1和aluIn2分别是源操作数1和源操作数2信号aluOp是执行控制模块产生的ALU运算控制码。ALU的功能就是根据运算码aluOp来完成两个源操作数的逻辑运算并把结果通过信号aluOut输出。
ALU模块的总体代码比较简单但里面这段代码第16行第19行不好理解别担心我这里专门拿出来给你讲一下。
wire [31:0] sum = aluIn1 + ((aluOp[3] | aluOp[1]) ? -aluIn2 : aluIn2);
wire neq = |sum;
wire cmp = (aluIn1[31] == aluIn2[31]) ? sum[31]
: aluOp[0] ? aluIn2[31] : aluIn1[31];
首先代码中的sum信号其实就是两个源操作数的和不过当运算码aluOp的第3位和第1位为“1”时做的是相减运算这是为减法指令或者后面的比较大小而准备的运算。你可以对照上面的ALU运算表格来理解。
neq信号表示的是比较两个操作数是否相等这就是根据前面的两个操作相减的结果判断如果它们的差不为“0”也就是sum信号按位与之后不为“0”则表示两个操作数不相等。
cmp信号表示两个操作数的大小比较如果它们的最高位也就是符号位相等则根据两个操作数相减的差值的符号位也是数值的最高位判断。如果是正数表示源操作数1大于源操作数2否则表示源操作数1小于源操作数2。
如果它们的最高位不相等则根据ALU运算控制码aluOp的最低位判断。如果aluOp最低位为“1”表示是无符号数比较直接取操作数2的最高位作为比较结果。如果aluOp最低位为“0”表示是有符号数比较直接取操作数1的最高位作为比较结果。
下面我们再来看看移位操作相关的代码其中的shamt信号是取自源操作数2的低五位表示源操作数1需要移多少位25=32。shin信号是取出要移位的数值根据aluOp判断是左移还是右移如果是右移就直接等于源操作数1如果是左移就先对源操作数的各位数做镜像处理。
shift信号是根据aluOp判断是算术右移还是逻辑右移如果是算术右移则在最高位补一个符号位。shiftt信号是右移之后的结果这里用到了\(signed()函数对移位前的数据shift进行了修饰\)signed()的作用是决定如何对操作数扩位这个问题。
具体的过程是,在右移操作前,$signed()函数先把操作数的符号位扩位成跟结果相同的位宽然后再进行移位操作而shiftr就是右移后的结果。
我们再专门看看ALU模块代码的第20行到第25行这部分主要用来完成移位操作。
wire [ 4:0] shamt = aluIn2[4:0];
wire [31:0] shin = aluOp[2] ? aluIn1 : reverse(aluIn1);
wire [32:0] shift = {aluOp[3] & shin[31], shin};
wire [32:0] shiftt = ($signed(shift) >>> shamt);
wire [31:0] shiftr = shiftt[31:0];
wire [31:0] shiftl = reverse(shiftr);
请你注意左移的结果shiftl是由右移后的结果进行位置取反得到的。因为对于需要左移的操作数在前面已经做了位置取反所以移位操作时也是进行右移处理最后把结果再一次做位置取反就可以了。-
好了恭喜你走到这里CPU流水线中执行阶段的内容就告一段落了。下一节课我们继续完成流水线的访存模块的设计。
重点回顾
这节课告一段落,我来给你做个总结。
指令执行算是CPU流水线中最复杂的一个阶段了需要我们慢慢推导细细思考才能理清楚里面的逻辑关系。这节课的内容和前面的第五节课到第七节课的知识关联比较多不懂的地方你可以再回去看看。
下面我们一起来回顾一下今天的重点内容。为了实现CPU的指令执行模块我们先梳理了设计思路。我们把指令分成逻辑运算、存储器访问、条件分支判断这三类指令进行处理。这三类指令经过ALU执行相关操作之后统一由数据通路来输出结果。
接着我们设计了执行控制模块。根据译码模块里产生的指令控制字段aluCrtlOp执行控制模块可以根据上述的三类指令相应产生一个4位的ALU操作信号aluOp为后面的ALU模块提供运算执行码。
根据指令在流水线中执行时对通用寄存器的读写特点我们为32个通用寄存器组设计了由两个套读接口和一套写接口组成的通用寄存器模块这三套接口可以支持其他模块对通用寄存器进行同时读写。
最后根据执行控制模块产生的ALU运算控制信号aluOp我们设计出了ALU模块。在ALU模块中可以完成加减法计算两个操作数的大小比较操作数的左移右移等操作。
如果你有兴趣的话可以参考前面RISC-V指令架构里列出的指令自己试试实现更多的指令操作。下节课我们继续探索访存相关模块如何设计和实现敬请期待。
思考题
在ALU模块代码中为什么要把左移操作转换为右移进行处理
欢迎你在留言区跟我交流讨论积极参与思考有助于你深化理解。如果觉得这节课还不错别忘了分享给身边的朋友邀他跟你一起手写CPU

View File

@ -0,0 +1,320 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 手写CPU如何实现CPU流水线的访存阶段
你好我是LMOS。
先简单回顾一下上一节课我们设计了MiniCPU流水线的执行相关模块。其中包括执行控制模块、通用寄存器模块以及可以进行加减法运算、大小比较、移位操作的ALU模块。
指令执行之后就到了流水线的下一级——访存。这节课我们就重点聊聊怎么设计实现访存的相关模块。在你的设想里,访存模块必要的组成部分有哪些呢?
如果你的第一反应是访存控制模块,我只能说你只答对了一部分。访存控制模块虽然是流水线的主线,但你可能忽略了流水线中的数据相关性问题。因此,今天我们先想办法解决流水线的数据冒险问题,然后再完成流水线访存阶段相关模块的设计。
这节课的代码你可以从这里获取。
流水线数据冒险
在开始设计访存模块之前,我们得先解决一个问题,即流水线的数据冒险。
在CPU流水线里执行不同的指令时会发生这样的情况一条指令B它依赖于前面还在流水线中的指令A的执行结果。当指令B到达执行阶段时因为指令A还在访存阶段所以这时候就无法提供指令B执行所需要的数据。这就导致指令B无法在预期的时钟周期内执行。
当指令在流水线中重叠执行时,后面的指令需要用到前面的指令的执行结果,而前面的指令结果尚未写回,由此导致的冲突就叫数据冒险。
我再举个更具体些的例子。比如,有一条减法指令,它需要用到前面一条加法指令的运算结果作为被减数:
add x2,x0,x1
sub x6,x2,x3
结合下面的示意图我们可以看到在不做任何干预的情况下sub依赖于add的执行结果这导致sub指令要等到add指令走到流水线的第五个阶段把结果写回之后才能执行这就浪费了三个时钟周期。
这种数据冒险将会严重地阻碍CPU的流水线设置流水线目的就是为了提升效率让某个时间点上有多条指令可以同时执行这种让指令“干等”的状态显然不是最佳选项。那我们怎么解决这样的问题呢方法其实不止一种让我带你分析分析。
结合前的例子,我们最直接的处理办法就是通过编译器调整一些指令顺序。不过指令存在依赖关系的情况经常发生,用编译器调整的方式会导致延迟太长,处理的结果无法让我们满意。
别灰心我们再另寻出路。把前面的加法指令add放到流水线中通过观察整个运算过程我们不难发现其实add加法运算的结果经过流水线的执行阶段也就是第三个模块EX之后就已经出来了只是还没把结果写回到x2寄存器而已。
所以,另一种解决办法也就有了头绪,能不能通过向内部资源添加额外的硬件,来尽快找到缺少的运算项呢?
这当然可以。对于上述的指令序列一旦ALU计算出加法指令的结果就可以将其作为减法指令执行的数据输入不需要等待指令完成就可以解决数据冒险的问题。
如上图所示将add指令执行阶段运算的结果x2中的值直接传递给sub指令作为执行阶段的输入替换sub指令在译码阶段读出的寄存器x2的值。这种硬件上解决数据冒险的方法称为前递forwarding
好,思路既然理清楚了,我们就把它落地到实际设计里。
数据前递模块的设计
通过上节课执行模块的设计我们知道了ALU的逻辑运算需要两个操作数一个来自于通用寄存器另一个来自于通用寄存器或者指令自带的立即数。
但是当需要读取的通用寄存器的值依赖于前面第一条或者第二条指令的运算结果时就出现了刚才我们提到的数据冒险问题。为了解决这个问题就需要我们专门设计一个数据前递模块forwarding它在流水线中的位置如下图所示
所谓前递,顾名思义,就是把流水线中后面阶段产生的数据向前传递的过程。
正如上图中的forwarding模块可以看到它的数据来自于流水线中的执行模块EX、访存模块MEM、写回模块WB的输出经过forwarding模块处理后把数据传递到执行模块的输入。
然后,流水线根据当前指令的译码信号,选择读取通用寄存器的数据作为执行模块的操作数,或者选择来自前递模块的数据作为执行模块的操作数。
那么具体是如何选择前递数据作为执行模块的操作数呢让我们结合下面forwarding模块的代码来寻找答案
module forwarding (
input [4:0] rs1,
input [4:0] rs2,
input [4:0] exMemRd,
input exMemRw,
input [4:0] memWBRd,
input memWBRw,
input mem_wb_ctrl_data_toReg,
input [31:0] mem_wb_readData,
input [31:0] mem_wb_data_result,
input [31:0] id_ex_data_regRData1,
input [31:0] id_ex_data_regRData2,
input [31:0] ex_mem_data_result,
output [31:0] forward_rs1_data,
output [31:0] forward_rs2_data
);
//检查是否发生数据冒险
wire [1:0] forward_rs1_sel = (exMemRw & (rs1 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs1 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [1:0] forward_rs2_sel = (exMemRw & (rs2 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs2 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [31:0] regWData = mem_wb_ctrl_data_toReg ? mem_wb_readData : mem_wb_data_result;
//根据数据冒险的类型选择前递的数据
assign forward_rs1_data = (forward_rs1_sel == 2'b00) ? id_ex_data_regRData1 :
(forward_rs1_sel == 2'b01) ? ex_mem_data_result :
(forward_rs1_sel == 2'b10) ? regWData : 32'h0;
assign forward_rs2_data = (forward_rs2_sel == 2'b00) ? id_ex_data_regRData2 :
(forward_rs2_sel == 2'b01) ? ex_mem_data_result :
(forward_rs2_sel == 2'b10) ? regWData : 32'h0;
endmodule
我们分别看看代码中的各种信号。前递模块输入的端口信号rs1和rs2来自于指令译码后得到的两个通用寄存器索引。exMemRd信号是来自访存模块的对通用寄存器的访问地址。exMemRw是流水线访存阶段对通用寄存器的写使能控制信号。memWBRd 和 memWBRw分别是写回模块对通用寄存器的地址和写使能控制信号。
利用这些信号就可以判断是否发生数据冒险,我们结合下面这段代码继续分析分析:
//检查是否发生数据冒险
wire [1:0] forward_rs1_sel = (exMemRw & (rs1 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs1 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
wire [1:0] forward_rs2_sel = (exMemRw & (rs2 == exMemRd) & (exMemRd != 5'b0)) ? 2'b01
:(memWBRw & (rs2 == memWBRd) & (memWBRd != 5'b0)) ? 2'b10
: 2'b00;
当需要读取的通用寄存器的地址等于访存或者写回阶段要访问通用寄存器地址时也就是rs1 == exMemRd和rs1 == memWBRd就判断为将要发生数据冒险。
当然由于通用寄存器中的零寄存器的值永远为“0”所以不会发生数据冒险需要排除掉这种特殊情况也就是exMemRd != 5b0 和 memWBRd != 5b0。根据这样的判断结果就会产生前递数据的两个选择信号forward_rs1_sel和forward_rs2_sel。
发生数据冒险的情况就是这样,那不发生数据冒险又是什么情况呢?下面是选择前递的数据对应的代码段,我们结合这段代码继续分析。
//根据数据冒险的类型选择前递的数据
assign forward_rs1_data = (forward_rs1_sel == 2'b00) ? id_ex_data_regRData1 :
(forward_rs1_sel == 2'b01) ? ex_mem_data_result :
(forward_rs1_sel == 2'b10) ? regWData : 32'h0;
assign forward_rs2_data = (forward_rs2_sel == 2'b00) ? id_ex_data_regRData2 :
(forward_rs2_sel == 2'b01) ? ex_mem_data_result :
(forward_rs2_sel == 2'b10) ? regWData : 32'h0;
我们先把目光聚焦到id_ex_data_regRData1和id_ex_data_regRData2这两个信号上。它们来自于指令译码之后读出通用寄存器的两个操作数这是流水线不发生数据冒险时流水线正常选择的数据通路。
而ex_mem_data_result 信号是访存阶段需要写到通用寄存器的数据regWData是回写阶段需要更新到通用寄存器的数据。这样通过判断将要发生数据冒险的位置前递模块选择性地把处于流水线中的数据前递就可以巧妙地解决流水线中的数据冒险问题了。
访存控制模块设计
好了,解决了流水线的数据冒险问题,让我们回到流水线设计的主线来,继续完成流水线的第四级——访存相关模块的设计。
在[第六节课]讲CPU流水线的时候我们提到过流水线中一条指令的生命周期分为五个阶段。流水线的访存阶段就是指将数据从存储器中读出或写入存储器的过程。这个阶段会出现由 LOAD / STORE 指令产生的内存访问。
因为访存阶段的功能就是对存储器读写所以访存控制信号中最重要的两个信号就是存储器读控制信号memRead 和写控制信号memWrite。当然访存的控制信号通路也会受流水线冲刷等流水线管理信号的控制具体的代码如下
module ex_mem_ctrl(
input clk,
input reset,
input in_mem_ctrl_memRead, //memory读控制信号
input in_mem_ctrl_memWrite, //memory写控制信号
input [1:0] in_mem_ctrl_maskMode, //mask模式选择
input in_mem_ctrl_sext, //符合扩展
input in_wb_ctrl_toReg, //写回寄存器的数据选择“1”时为mem读取的数据
input in_wb_ctrl_regWrite, //寄存器写控制信号
input flush, //流水线数据冲刷信号
output out_mem_ctrl_memRead,
output out_mem_ctrl_memWrite,
output [1:0] out_mem_ctrl_maskMode,
output out_mem_ctrl_sext,
output out_wb_ctrl_toReg,
output out_wb_ctrl_regWrite
);
reg reg_mem_ctrl_memRead;
reg reg_mem_ctrl_memWrite;
reg [1:0] reg_mem_ctrl_maskMode;
reg reg_mem_ctrl_sext;
reg reg_wb_ctrl_toReg;
reg reg_wb_ctrl_regWrite;
assign out_mem_ctrl_memRead = reg_mem_ctrl_memRead;
assign out_mem_ctrl_memWrite = reg_mem_ctrl_memWrite;
assign out_mem_ctrl_maskMode = reg_mem_ctrl_maskMode;
assign out_mem_ctrl_sext = reg_mem_ctrl_sext;
assign out_wb_ctrl_toReg = reg_wb_ctrl_toReg;
assign out_wb_ctrl_regWrite = reg_wb_ctrl_regWrite;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_mem_ctrl_memRead <= 1'h0;
end else if (flush) begin
reg_mem_ctrl_memRead <= 1'h0;
end else begin
reg_mem_ctrl_memRead <= in_mem_ctrl_memRead;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_mem_ctrl_memWrite <= 1'h0;
end else if (flush) begin
reg_mem_ctrl_memWrite <= 1'h0;
end else begin
reg_mem_ctrl_memWrite <= in_mem_ctrl_memWrite;
end
end
………… //由于代码较长结构相似这里省略了一部分完整代码你可以从Gitee上获取
endmodule
虽然代码有几十行但过程还是很容易理解的。首先要根据流水线的冲刷控制信号flush判断访存阶段的控制信号是否需要清零。
如果flush等于“0”就把上一阶段送过来的控制信号比如存储器读控制信号memRead、存储器写控制信号memWrite……等通过寄存器保存下来然后发送给存储器读写控制模块dmem_rw.v或者流水线的下一级使用。
访存数据通路模块设计
接下来,我们继续完成访存数据通路模块的设计。访存数据通路就是把访存阶段读取到的存储器数据,或者是指令执行产生的结果发送流水线的下一级处理。
由于下一级也就是流水线的最后一级——写回所以访存的数据通路主要包括要写回的通用寄存器地址regWAddr、访问存储器读取的数据regRData2、指令运算的结果result等。
访存的数据通路也会受流水线冲刷等流水线管理信号的控制,具体代码如下:
module ex_mem(
input clk,
input reset,
input [4:0] in_regWAddr, //写回寄存器的地址
input [31:0] in_regRData2, //读存储器的数据
input [1:0] ex_result_sel, //执行结果选择
input [31:0] id_ex_data_imm, //指令立即数
input [31:0] alu_result, //ALU运算结果
input [31:0] in_pc, //当前PC值
input flush, //流水线数据冲刷控制信号
output [4:0] data_regWAddr,
output [31:0] data_regRData2,
output [31:0] data_result,
output [31:0] data_pc
);
reg [4:0] reg_regWAddr;
reg [31:0] reg_regRData2;
reg [31:0] reg_result;
reg [31:0] reg_pc;
wire [31:0] resulet_w = (ex_result_sel == 2'h0) ? alu_result :
(ex_result_sel == 2'h1) ? id_ex_data_imm :
(ex_result_sel == 2'h2) ? (in_pc +32'h4) : 32'h0;
assign data_regWAddr = reg_regWAddr;
assign data_regRData2 = reg_regRData2;
assign data_result = reg_result;
assign data_pc = reg_pc;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_regWAddr <= 5'h0;
end else if (flush) begin
reg_regWAddr <= 5'h0;
end else begin
reg_regWAddr <= in_regWAddr;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_regRData2 <= 32'h0;
end else if (flush) begin
reg_regRData2 <= 32'h0;
end else begin
reg_regRData2 <= in_regRData2;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_result <= 32'h0;
end else if (flush) begin
reg_result <= 32'h0;
end else begin
reg_result <= resulet_w;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_pc <= 32'h0;
end else if (flush) begin
reg_pc <= 32'h0;
end else begin
reg_pc <= in_pc;
end
end
endmodule
和上面的访存控制模块类似访存数据通路模块也是根据流水线的冲刷控制信号flush判断访存阶段的数据是否需要清零。如果不需要清零就把上一阶段送过来的数据通过寄存器保存下来。
对于代码的第21到第23行代码我为你单独解释一下。
ex_result_sel就是对流水线执行阶段的结果进行选择。当ex_result_sel == 2h0就选择ALU的运算结果ex_result_sel == 2h1就会选择指令解码得到的立即数其实就是对应LUI指令ex_result_sel == 2h2选择PC加4的值也就是下一个PC的值。
wire [31:0] resulet_w = (ex_result_sel == 2'h0) ? alu_result :
(ex_result_sel == 2'h1) ? id_ex_data_imm :
(ex_result_sel == 2'h2) ? (in_pc +32'h4) : 32'h0;
重点回顾
这节课的内容到这里就告一段落了,我给你做个总结吧。
今天我们在设计访存模块之前先探讨了流水线中的数据冒险问题。在执行指令时如果发生了数据冒险就可能使流水线停顿等待前面的指令执行完成后才能继续执行后续的指令严重影响了指令在CPU流水线中并行执行。因此我们设计了数据前递模块来解决数据冒险的问题。
但是添加前递模块并不能避免所有的流水线停顿。比如当一条读存储器指令LOAD之后紧跟一条需要使用其结果的R型指令时就算使用前递也需要流水线停顿。因为读存储器的数据必须要在访存之后才能用但load指令正在访存时后一条指令已经在执行。所以在这种情况下流水线必须停顿通常的说法是在两条指令之间插入气泡。
最后,我们根据流水线的控制信号,完成了访存控制信号通路和访存数据通路的模块设计。这节课的要点你可以参考下面的导图。
通过课程的讲解CPU流水线中访存阶段的设计实现的思路相信你已经心中有数了别忘了课后结合配套代码再找找“手感”。下节课我们将会介绍流水线的最后一级——写回模块的设计敬请期待。
思考题
除了数据冒险我们的CPU流水线是否还存在其它的冲突问题你想到解决方法了么
欢迎你在留言区和我交流踊跃提问或者记录笔记对我们加深理解有很大的帮助。如果你觉得这节课还不错别忘了分享给更多朋友和他一起手写CPU。

View File

@ -0,0 +1,333 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 手写CPUCPU流水线的写回模块如何实现
你好我是LMOS。
今天我们一起来完成迷你CPU的最后一个部分——写回相关模块的设计课程代码在这里
简单回顾一下上节课我们完成了CPU流水线的访存相关模块的设计。在设计访存模块之前我们发现流水线中存在数据冒险的问题。为了解决这个问题我们设计了数据前递模块。
但是我们采用的数据前递模块只局限于解决算术操作和数据传输中的冒险问题。在CPU流水线中还可能存在结构冒险和控制冒险的问题我们在进行流水线规划时已经合理地避免了结构冒险。但是控制冒险还可能出现下面我们就来探讨一下流水线的控制冒险问题。
流水线控制冒险
还记得前面我们说过的条件分支指令吗就是根据指令设置的数值比较结果改变并控制跳转的方向比如beq和bne指令。
假如在流水线取出分支指令后,紧跟着在下一个时钟周期就会取下一条指令。但是,流水线并不知道下一条指令应该从哪里取,因为它刚从存储器中取出分支指令,还不能确定上一条分支指令是否会发生跳转。
上面这种流水线需要根据上一条指令的执行结果决定下一步行为的情况,就是流水线中的控制冒险。这时候该怎么办呢?
控制冒险可以使用流水线停顿的方法解决,就是在取出分支指令后,流水线马上停下来,等到分支指令的结果出来,确定下一条指令从哪个地址取之后,流水线再继续。
如上图所示每当遇到条件分支指令时流水线就停顿以避免控制冒险。但是这种方法对性能的影响是很大的。因为条件分支指令要等到执行之后的访存阶段才能决定分支跳转是否发生这就相当于流水线停顿了2个时钟周期。我们MiniCPU只有五级流水线就停顿了这么久像intel 的酷睿 i7处理器流水线它的深度有十几级如果也用停顿的方法那延时损失就更大了。
既然阻塞流水线直到分支指令执行完成的方法非常耗时,浪费了太多流水线的时钟周期。那么,有没有一种方法既能解决控制冒险问题,又不影响流水线的性能呢?
很遗憾,答案是否定的。到目前为止,我们还没有找到根本性的解决控制冒险问题的方法。
但是,这并不代表我们没有办法去优化它,我们可以采用分支预测的方法提升分支阻塞的效率。
具体思路是这样的当每次遇到条件分支指令时预测分支会发生跳转直接在分支指令的下一条取跳转后相应地址的指令。如果分支发生跳转的概率是50%,那么这种优化方式就可以减少一半由控制冒险带来的性能损失。
其实我们[第六节课]取指阶段设计的预读取模块if_pre.v实现的就是这个功能相关代码如下
wire is_bxx = (instr[6:0] == `OPCODE_BRANCH); //条件挑转指令的操作码
wire is_jal = (instr[6:0] == `OPCODE_JAL) ; //无条件跳转指令的操作码
//B型指令的立即数拼接
wire [31:0] bimm = {{20{instr[31]}}, instr[7], instr[30:25], instr[11:8], 1'b0};
//J型指令的立即数拼接
wire [31:0] jimm = {{12{instr[31]}}, instr[19:12], instr[20], instr[30:21], 1'b0};
//指令地址的偏移量
wire [31:0] adder = is_jal ? jimm : (is_bxx & bimm[31]) ? bimm : 4;
assign pre_pc = pc + adder;
看到这你可能还有疑问,如果条件分支不发生跳转的话又会怎么样呢?这种情况下,已经被读取和译码的指令就会被丢弃,流水线继续从不分支的地址取指令。
要想丢弃指令也不难只需要把流水线中的控制信号和数据清“0”即可也就是当预测失败的分支指令执行之后到达访存阶段时需要将流水线中处于取指、译码和执行阶段的指令清除。
我先展示一下控制冒险模块的整体代码,之后再详细解读。代码如下所示:
module hazard (
input [4:0] rs1,
input [4:0] rs2,
input alu_result_0,
input [1:0] id_ex_jump,
input id_ex_branch,
input id_ex_imm_31,
input id_ex_memRead,
input id_ex_memWrite,
input [4:0] id_ex_rd,
input [1:0] ex_mem_maskMode,
input ex_mem_memWrite,
output reg pcFromTaken,
output reg pcStall,
output reg IF_ID_stall,
output reg ID_EX_stall,
output reg ID_EX_flush,
output reg EX_MEM_flush,
output reg IF_ID_flush
);
wire branch_do = ((alu_result_0 & ~id_ex_imm_31) | (~alu_result_0 & id_ex_imm_31));
wire ex_mem_taken = id_ex_jump[0] | (id_ex_branch & branch_do);
wire id_ex_memAccess = id_ex_memRead | id_ex_memWrite;
wire ex_mem_need_stall = ex_mem_memWrite & (ex_mem_maskMode == 2'h0 | ex_mem_maskMode == 2'h1);
always @(*) begin
if(id_ex_memAccess && ex_mem_need_stall) begin
pcFromTaken <= 0;
pcStall <= 1;
IF_ID_stall <= 1;
IF_ID_flush <= 0;
ID_EX_stall <= 1;
ID_EX_flush <= 0;
EX_MEM_flush <= 1;
end
else if(ex_mem_taken) begin
pcFromTaken <= 1;
pcStall <= 0;
IF_ID_flush <= 1;
ID_EX_flush <= 1;
EX_MEM_flush <= 0;
end
else if(id_ex_memRead & (id_ex_rd == rs1 || id_ex_rd == rs2)) begin
pcFromTaken <= 0;
pcStall <= 1;
IF_ID_stall <= 1;
ID_EX_flush <= 1;
end
else begin
pcFromTaken <= 0;
pcStall <= 0;
IF_ID_stall <= 0;
ID_EX_stall <= 0;
ID_EX_flush <= 0;
EX_MEM_flush <= 0;
IF_ID_flush <= 0;
end
end
endmodule
首先我们来看看在控制冒险模块中,内部产生的几个信号都起到了怎样的作用。-
branch_do 信号就是条件分支指令的条件比较结果由ALU运算结果和立即数的最高位符合位通过“与”操作得到ex_mem_taken是确认分支指令跳转的信号由无条件跳转jump“或”条件分支指令branch产生。
id_ex_memAccess是存储器的选通信号当对存储器的“读”或者“写”控制信号有效时产生ex_mem_need_stall信号表示流水线需要停顿当执行sb或者sh指令时就会出现这样的情况。
然后,再来看看我们这个模块要输出的几个信号。
wire branch_do = ((alu_result_0 & ~id_ex_imm_31) | (~alu_result_0 & id_ex_imm_31));
wire ex_mem_taken = id_ex_jump[0] | (id_ex_branch & branch_do);
wire id_ex_memAccess = id_ex_memRead | id_ex_memWrite;
wire ex_mem_need_stall = ex_mem_memWrite & (ex_mem_maskMode == 2'h0 | ex_mem_maskMode == 2'h1);
pcFromTaken是分支指令执行之后判断和分支预测方向是否一致的信号。pcStall是控制程序计数器停止的信号如果程序计数器停止那么流水线将不会读取新的指令。IF_ID_stall是流水线中从取指到译码的阶段的停止信号。ID_EX_stall是流水线从译码到执行阶段的停止信号。
此外当流水线需要冲刷时就会产生取指、译码、执行、访存阶段的清零信号分别对应着ID_EX_flush、EX_MEM_flush和IF_ID_flush信号。
output reg pcFromTaken, //分支指令执行结果,判断是否与预测方向一样
output reg pcStall, //程序计数器停止信号
output reg IF_ID_stall, //流水线IF_ID段停止信号
output reg ID_EX_stall, //流水线ID_EX段停止信号
output reg ID_EX_flush, //流水线ID_EX段清零信号
output reg EX_MEM_flush, //流水线EX_MEM段清零信号
output reg IF_ID_flush //流水线IF_ID段清零信号
什么情况下才会产生上面的控制信号呢?一共有三种情况,我这就带你依次分析一下。-
第一种情况是解决数据相关性问题。数据相关指的是指令之间存在的依赖关系。当两条指令之间存在相关关系时,它们就不能在流水线中重叠执行。
例如前一条指令是访存指令Store后一条也是Load或者Store指令因为我们采用的是同步RAM需要先读出再写入占用两个时钟周期所以这时要把之后的指令停一个时钟周期。
if(ID_EX_memAccess && EX_MEM_need_stall) begin
pcFromTaken <= 0;
pcStall <= 1;
IF_ID_stall <= 1;
IF_ID_flush <= 0;
ID_EX_stall <= 1;
ID_EX_flush <= 0;
EX_MEM_flush <= 1;
end
第二种情况是分支预测失败的问题当分支指令执行之后如果发现分支跳转的方向与预测方向不一致。这时就需要冲刷流水线清除处于取指、译码阶段的指令数据更新PC值。
// 分支预测失败需要冲刷流水线更新pc值
else if(EX_MEM_taken) begin
pcFromTaken <= 1;
pcStall <= 0;
IF_ID_flush <= 1;
ID_EX_flush <= 1;
EX_MEM_flush <= 0;
end
第三种情况就是解决[上一节课]提到的数据冒险问题。当前一条指令是 Load后一条指令的源寄存器 rs1和rs2依赖于前一条从存储器中读出来的值需要把 Load 指令之后的指令停顿一个时钟周期而且还要冲刷ID _EX阶段的指令数据。
else if(ID_EX_memRead & (ID_EX_rd == rs1 || ID_EX_rd == rs2)) begin
pcFromTaken <= 0;
pcStall <= 1;
IF_ID_stall <= 1;
ID_EX_flush <= 1;
end
解决了流水线的冒险问题,我们才能确保指令经过流水线执行后,得到的结果是正确的,这时候才能把执行结果写回到寄存器。接下来,让我们来继续完成写回阶段的模块设计。
写回控制模块设计
现在我们来到了流水线的最后一级——结果写回。先来看看写回控制模块,这个模块实现起来就非常简单了,它的作用就是选择存储器读取回来的数据作为写回的结果,还是选择流水线执行运算之后产生的数据作为写回结果。
具体代码如下:
module mem_wb_ctrl(
input clk,
input reset,
input in_wb_ctrl_toReg,
input in_wb_ctrl_regWrite,
output data_wb_ctrl_toReg,
output data_wb_ctrl_regWrite
);
reg reg_wb_ctrl_toReg;
reg reg_wb_ctrl_regWrite;
assign data_wb_ctrl_toReg = reg_wb_ctrl_toReg;
assign data_wb_ctrl_regWrite = reg_wb_ctrl_regWrite;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_wb_ctrl_toReg <= 1'h0;
end else begin
reg_wb_ctrl_toReg <= in_wb_ctrl_toReg;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_wb_ctrl_regWrite <= 1'h0;
end else begin
reg_wb_ctrl_regWrite <= in_wb_ctrl_regWrite;
end
end
endmodule
代码里有两个重要的信号需要你留意。一个是写回寄存器的数据选择信号wb_ctrl_toReg当这个信号为“1”时选择从存储器读取的数值作为写回数据否则把流水线的运算结果作为写回数据。另一个是寄存器的写控制信号wb_ctrl_regWrite当这个信号为“1”时开始往目标寄存器写回指令执行的结果。
写回数据通路模块设计
和写回的控制模块一样,流水线的最后一级的写回数据通路上的信号也变得比较少了。
写回数据通路模块产生的信号主要包括写回目标寄存器的地址reg_WAddr流水线执行运算后的结果数据result从存储器读取的数据readData。
写回数据通路的模块代码如下:
module mem_wb(
input clk,
input reset,
input [4:0] in_regWAddr,
input [31:0] in_result,
input [31:0] in_readData,
input [31:0] in_pc,
output [4:0] data_regWAddr,
output [31:0] data_result,
output [31:0] data_readData,
output [31:0] data_pc
);
reg [4:0] reg_regWAddr;
reg [31:0] reg_result;
reg [31:0] reg_readData;
reg [31:0] reg_pc;
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_regWAddr <= 5'h0;
end else begin
reg_regWAddr <= in_regWAddr;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_result <= 32'h0;
end else begin
reg_result <= in_result;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_readData <= 32'h0;
end else begin
reg_readData <= in_readData;
end
end
always @(posedge clk or posedge reset) begin
if (reset) begin
reg_pc <= 32'h0;
end else begin
reg_pc <= in_pc;
end
end
assign data_regWAddr = reg_regWAddr;
assign data_result = reg_result;
assign data_readData = reg_readData;
assign data_pc = reg_pc;
endmodule
仔细观察代码,你是否发现和流水线的前面几级的数据通路模块相比,少了两个控制信号呢?-
是的写回阶段的模块没有了流水线的停止控制信号stall和流水线的冲刷控制信号flush。这是因为写回阶段的数据经过了数据冒险和控制冒险模块的处理已经可以确保流水线产生的结果无误了所以写回阶段的数据不受停止信号stall和清零信号flush的控制。
到这里我们要设计的迷你CPU的五级流水线就基本完成啦。
重点回顾
最后我给你做个总结吧。
这节课我们先分析了流水线中存在的控制冒险问题也就是当流水线中出现条件分支指令时下一条指令还不确定从哪里取的问题。最容易想到的解决方案就是在取出分支指令后流水线马上停下来等到分支指令的结果出来确定下一条指令从哪个地址获取之后流水线再继续。但是这里流水线停顿的方式缺点很明显它会带来很多CPU的性能损失。
于是我们采用了分支预测的方法预测每一条分支指令都会发生跳转直接在分支指令的下一条取跳转后相应地址的指令。如果分支发生跳转的概率是50%,那么这种优化方式就可以减少一半由控制冒险带来的性能损失。
最后根据整个流水线执行后的数据我们完成了流水线的最后一级也就是写回控制模块和数据通路模块的设计。写回控制模块要么选择存储器读取回来的数据作为写回结果要么选择流水线执行运算之后产生的数据作为写回结果。数据通路模块则包含了写回目标寄存器的地址、ALU的运算结果以及访存阶段读存储器得到的数据。
到这里我们终于把CPU的五级流水线的最后一级设计完成了这代表基于指令集RV32I的迷你CPU核心代码设计已经完成。很快就可以让它跑程序了你是不是很期待呢下一节课我们就可以看到效果了
思考题
除了流水线停顿和分支预测方法,是否还有其他解决控制冒险问题的办法?
欢迎你在留言区跟我交流互动或者记录下你的思考与收获。如果觉得这节课还不错别忘了分享给身边的朋友我们一起来手写CPU

View File

@ -0,0 +1,359 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 手写CPU如何让我们的CPU跑起来
你好我是LMOS。
通过前面几节课的学习我们已经完成了MiniCPU五级流水线的模块设计现在距离实现一个完整的MiniCPU也就一步之遥。
还差哪些工作没完成呢还记得我们在第六节课设计的MiniCPU架构图吗回想一下我们已经设计完成的五级流水线都包含下图的哪些模块
上图的CPU核心模块也就是CPU Core包含的模块的设计这些我们已经在前面几节课里完成了。除了五级流水线的模块我们还设计了用于保存操作数和运算结果的通用寄存器组设计了解决数据冒险问题的forwarding模块以及解决控制冒险问题的hazard模块。
接下来我们还需要搞定一些外围组件也就是图里虚线框外的系统总线、ROM、RAM、输入输出端口GPIOGPIO比较简单课程里没专门讲和UART模块。
学完这节课我们就可以把这个CPU运行起来了最终我还会带你在这个CPU上跑一个RISC-V版本的Hello World程序课程代码从这里下载是不是很期待话不多说我们这就开始
系统总线设计
首先让我们看看CPU的系统总线。
总线是连接多个部件的信息传输线它是各部件共享的传输介质。在某一时刻只允许有一个部件向总线发送信息而多个部件可以同时从总线上接收相同的信息。MiniCPU的系统总线用来连接CPU内核与外设完成信息传输的功能。
系统总线在整个MiniCPU中是一个很关键的模块。你可以这样理解总线就是CPU内核跟其他外设部件的“联络员”。举几个例子总线可以从ROM中读取指令再交给CPU去执行CPU运行程序时的变量也会交由总线保存到RAM中用来实现芯片与外部通信的UART模块也需要通过总线跟CPU进行信息交换……
那总线的代码具体要怎么设计呢?我先展示一下写好的整体代码,再带你具体分析。
module sys_bus (
// cpu -> imem
input [31:0] cpu_imem_addr,
output [31:0] cpu_imem_data,
output [31:0] imem_addr,
input [31:0] imem_data,
// cpu -> bus
input [31:0] cpu_dmem_addr,
input [31:0] cpu_dmem_data_in,
input cpu_dmem_wen,
output reg [31:0] cpu_dmem_data_out,
// bus -> ram
input [31:0] dmem_read_data,
output [31:0] dmem_write_data,
output [31:0] dmem_addr,
output reg dmem_wen,
// bus -> rom
input [31:0] dmem_rom_read_data,
output [31:0] dmem_rom_addr,
// bus -> uart
input [31:0] uart_read_data,
output [31:0] uart_write_data,
output [31:0] uart_addr,
output reg uart_wen
);
assign imem_addr = cpu_imem_addr;
assign cpu_imem_data = imem_data;
assign dmem_addr = cpu_dmem_addr;
assign dmem_write_data = cpu_dmem_data_in;
assign dmem_rom_addr = cpu_dmem_addr;
assign uart_addr = cpu_dmem_addr;
assign uart_write_data = cpu_dmem_data_in;
always @(*) begin
case (cpu_dmem_addr[31:28])
4'h0: begin //ROM
cpu_dmem_data_out <= dmem_rom_read_data;
dmem_wen <= 0;
uart_wen <= 0;
end
4'h1: begin // RAM
dmem_wen <= cpu_dmem_wen;
cpu_dmem_data_out <= dmem_read_data;
uart_wen <= 0;
end
4'h2: begin // uart io
uart_wen <= cpu_dmem_wen;
cpu_dmem_data_out <= uart_read_data;
dmem_wen <= 0;
end
default: begin
dmem_wen <= 0;
uart_wen <= 0;
cpu_dmem_data_out <= 0;
end
endcase
end
endmodule
这里我们设计的系统总线其实是一个“一对多”的结构也可以说是“一主多从”结构就是一个CPU内核作为主设备Master多个外设作为从设备Slave。-
CPU内核具有系统总线的控制权它可以通过系统总线发起对外设的访问而外设只能响应从CPU内核发来的各种总线命令。因此每个外设都需要有一个固定的地址作为CPU访问特定外设的标识。
以下就是给从设备分配的地址空间:
// 设备地址空间-
// 0x0000_0000 -ROM (word to byte )-
// 0x1000_0000 -RAM (word to byte )-
// 0x2000_0000 -uart (word to byte )-
// 0x3000_0000 -other(word to byte )
从代码的第3960行也可以看到总线根据地址的高4 bit的值就可以判断出CPU访问的是哪个从设备。
cpu_dmem_addr[31:28] = 4h0 CPU访问的是ROM把从ROM返回的数据赋给总线cpu_dmem_addr[31:28] = 4h1 CPU访问的是RAM把CPU的写使能cpu_dmem_wen赋给RAM的写使能信号dmem_wen同时把从RAM返回的数据赋给总线cpu_dmem_addr[31:28] = 4h2 CPU访问的是串行通信模块UART把CPU的写使能cpu_dmem_wen赋给uart的写使能信号uart_wen同时把从UART返回的数据赋给总线。这就是MiniCPU总线的工作过程。
只读存储器ROM的实现
接下来,我们看看连接在总线上的存储器要如何实现。
ROM是个缩写它表示只读存储器Read Only Memory。ROM具有非易失性的特点。什么是非易失性呢说白了就是在系统断电的情况下仍然可以保存数据。
正是因为这一特点ROM很适合用来存放计算机的程序。由于历史原因虽然现在使用的ROM中有些类型不仅是可以读还可以写但我们还是习惯性地把它们称作只读存储器。比如现在电子系统中常用的EEPROM、NOR flash 、Nand flash等都可以归类为ROM。
在我们的MiniCPU中目前没有真正使用上述的ROM作为指令存储器。让我们看看MiniCPU中实现ROM功能的代码再相应分析我们的设计思路。
module imem (
input [11:0] addr1,
output [31:0] imem_o1,
input [11:0] addr2,
output [31:0] imem_o2
);
reg [31:0] imem_reg[0:4096];
assign imem_o1 = imem_reg[addr1];
assign imem_o2 = imem_reg[addr2];
endmodule
为了方便学习和仿真我们使用了寄存器reg临时定义了一个指令存储器imem并在仿真的顶层tb_top.v使用了$readmemh函数把编译好的二进制指令读入到imem中以便CPU内部读取并执行这些指令。这里我们设置的存储器在功能上是只读的。
以下就是仿真的顶层tb_top.v调用$readmemh函数的语句。
$readmemh(`HEXFILE, MiniCPU.u_imem.imem_reg);
函数里面有两个参数一个是存放二进制指令的文件HEXFILE另一个就是实现ROM功能的寄存器imem_reg。这条语句可以在我们启动CPU仿真时把二进制的指令一次性读入到imem中这样CPU运行的过程中就可以取imem中的指令去执行了。
随机访问存储器RAM
除了存放指令的ROM我们还需要一个存放变量和数据的RAMRandom Access Memory
RAM和特点跟ROM正好相反它是易失性存储器通常都是在掉电之后就会丢失数据。但是它具有读写速度快的优势所以通常用作CPU的高速缓存。
RAM之所以叫做随机访问存储器是因为不同的地址可以在相同的时间内随机读写。这是由RAM的结构决定的RAM使用存储阵列来存储数据只要给出行地址和列地址就能确定目标数据而且这一过程和目标数据所处的物理位置无关。
和ROM一样为了方便对设计的MiniCPU进行仿真验证我们还是用寄存器reg临时构建了一个数据存储器dmem作为MiniCPU中的RAM使用。虽然临时构建的存储器和实际的ROM有点差别但我们还在初期学习阶段这已经足够了。
下面就是实现RAM功能的数据存储器dmem的代码
module dmem(
input [11:0] addr,
input we,
input [31:0] din,
input clk,
output reg [31:0] dout
);
reg [31:0] dmem_reg[0:4095];
always @(posedge clk) begin
if(we) begin
dmem_reg[addr] <= din;
end
dout <= dmem_reg[addr];
end
endmodule
代码的第11~16行可以看到我们使用了时钟信号clk说明这里的dmem实现的是一个时钟同步RAM。而且当写使能信号we为“1”时才能往RAM里写数据否则只能读取数据。
外设UART设计
为了让MiniCPU能和其他电子设备进行通信我们还要设计UART模块。
同样地设计代码之前我先带你快速了解一下UART是什么它的工作原理是怎样的。
UART的全称叫通用异步收发传输器Universal Asynchronous Receiver/Transmitter它是一种串行、异步、全双工的通信协议是电子设备间进行异步通信的常用模块。
UART负责对系统总线的并行数据和串行口上的串行数据进行转换通信双方采用相同的波特率。在不使用时钟信号线的情况下仅用一根数据发送信号线和一根数据接收信号线Rx和Tx就可以完成两个设备间的通信因此我们也把UART称为异步串行通信。
串行通信是指利用一条传输线将数据按顺序一位位传送的过程。UART的发送模块会把来自CPU总线的并行数据转换为串行数据再以串行方式将其发送到另一个设备的UART接收端。然后由UART的接收模块把串行数据转换为并行数据以便接收设备存储和使用这些数据。
UART的数据传输格式如下图所示
从图里我们可以看到UART传输数据包括起始位、数据位、奇偶校验位、停止位和空闲位。UART数据传输线通常在不传输数据时保持在高电平。
这么多名词是不是有点应接不暇?我挨个解释一下,你就清楚了。
起始位是在数据线上先发出一个逻辑低电平“0”信号表示数据传输的开始。
数据位是由5~8位逻辑高低电平表示的“1”或“0”信号。
校验位在传输的数据位的后面加1bit表示“1”的位数应为偶数偶校验或奇数奇校验
停止位是一个数据位宽的1倍、1.5倍、或者2倍的高电平信号它是一次数据传输的结束标志。
空闲位是数据传输线处于逻辑高电平状态,表示当前线路上处于空闲状态,没有数据传送。
跟数据发送信号线TX、数据接收信号线RX相对应我们的UART也分别设计了发送模块uart_tx和接收模块uart_rx。如果你想了解具体的功能实现可以课后查看我们的MiniCPU的项目代码。
这里只放出来发送模块的端口信号,如下所示:
module uart_tx(
input clk , // Top level system clock input.
input resetn , // Asynchronous active low reset.
output uart_txd , // UART transmit pin.
output uart_tx_busy, // Module busy sending previous item.
input uart_tx_en , // Send the data on uart_tx_data
input [7:0] uart_tx_data // The data to be sent
);
UART接收模块的端口信号如下
module uart_rx(
input clk , // Top level system clock input.
input resetn , // Asynchronous active low reset.
input uart_rxd , // UART Recieve pin.
input uart_rx_en , // Recieve enable
output uart_rx_break, // Did we get a BREAK message?
output uart_rx_valid, // Valid data recieved and available.
output reg [7:0] uart_rx_data // The recieved data.
);
端口信号的代码你结合上面的注释很容易就能理解后面CPU跑程序的时候就会用到这部分的功能。
在CPU上跑个Hello World
现在来到我们的最后一个环节编写程序并把它放到我们的MiniCPU上跑起来。
为了能更直观看到CPU的运行效果这里我们使用RISC-V汇编指令设计了一段用UART发送“Hello MiniCPU!”字符串的代码,然后让串口接收端把发送的字符串在电脑上打印出来。
具体的代码如下:
# Assembly Description
main:
li x2, 0x20000000 # uart address
li x6, 0x1500 #x6 <== 0x1500, delay 1ms
addi x7, x0, 0 #x7 <== 0
addi x5, x0, 0x48 #x5 <== "H"
sw x5, 0(x2)
delay1: addi x7, x7, 1 #x7 <== x7 + 1
bne x7, x6, delay1 #x6 != x7
addi x7, x0, 0 #x7 <== 0
addi x5, x0, 0x65 #x5 <== "e"
sw x5, 0(x2)
delay2: addi x7, x7, 1 #x7 <== x7 + 1
bne x7, x6, delay2 #x6 != x7
addi x7, x0, 0 #x7 <== 0
addi x5, x0, 0x6c #x5 <== "l"
sw x5, 0(x2)
delay3: addi x7, x7, 1 #x7 <== x7 + 1
bne x7, x6, delay3 #x6 != x7
addi x7, x0, 0 #x7 <== 0
addi x5, x0, 0x6c #x5 <== "l"
sw x5, 0(x2)
………… //由于代码较长结构相似这里省略了一部分完整代码你可以从Gitee上获取
delay13: addi x7, x7, 1 #x7 <== x7 + 1
bne x7, x6, delay13 #x6 != x7
addi x7, x0, 0 #x7 <== 0
addi x5, x0, 0x21 #x5 <== "!"
sw x5, 0(x2)
end: j end
ret
有了代码我们还需要把它编译成能在CPU上运行的机器码才能把它放在CPU上跑。
下面的代码就是放在课程代码中的Makefile作用是编译汇编代码还有定义好CPU仿真需要用到的一些命名规则。
SOURCE_TB := ./tb/tb_top.v
TMP_DIR := ./tmp
SOURCE := ./rtl.f
TARGET := ${TMP_DIR}/tb_top.o
TEST_HEX := ./sim/asm/build/test.dat
# 编译汇编程序,输出二进制指令
asm:
make -C ./sim/asm
python ./sim/asm/word2byte.py
# 对CPU进行仿真
cpu:
rm -f ${TMP_DIR}/*
cp ${SOURCE_TB} ${TMP_DIR}
sed -i 's#.hex#${TEST_HEX}#' ${TMP_DIR}/tb_top.v
iverilog -f ${SOURCE} -o ${TARGET}
vvp ${TARGET}
# 查看波形
wave:
gtkwave ${TMP_DIR}/tb_top.vcd &
# 清除临时文件
clean:
make -C ./sim/asm clean
rm ./tmp/* -rf
从Makefile的代码中可以看到我们一共定义了4个目标命令它们的作用分别是完成汇编程序编译的asm命令、执行MiniCPU仿真的cpu命令、用软件GTKwave打开仿真后的波形wave命令以及清除仿真过程中产生的临时文件的clean命令。
通过在终端上执行“make asm”命令便可以把上面设计的汇编程序编译成二进制指令test.dat。然后我们再输入“make cpu”命令就启动MiniCPU的仿真了运行结果如下图所示
到此我们的MiniCPU就设计完成啦祝贺你一路进行到这里。看到页面上输出Hello MiniCPU的时候是不是感觉还挺好玩的
如果你觉得意犹未尽,还可以在项目文件夹里的“./mini_cpu/sim/asm/src/miniCPU_sim.asm”这个文件中编写你自己的RISC-V汇编程序然后就可以在我们的MiniCPU上玩出更多花样了。
重点回顾
这节课我们把MiniCPU的几个外部模块设计完成这几个模块是让CPU“跑起来”的必要组件。
我们首先设计了MiniCPU的系统总线。有了它就能连接CPU内核与外设完成信息传输的功能相当于CPU内核与外部设备的一座桥梁。
接下来的模块就是ROM和RAM。ROM是存放CPU指令的只读存储器。为了方便学习和仿真我们通过寄存器临时定义了一个指令存储器然后在仿真的顶层使用了$readmemh函数把编译好的二进制指令读入到指令存储器中这样CPU运行时就可以读取和执行这些指令了。
RAM用来存放数据它在掉电之后会丢失数据但是读写速度快通常用来作为CPU的高速缓存。跟ROM的实现思路一样我们还是用寄存器临时构建了一个数据存储器dmem作为MiniCPU中的RAM使用。
为了让MiniCPU能和其他设备通信我们还设计了异步串行通信模块UART它用一根数据发送信号线和一根数据接收信号线就可以完成两个设备间的通信。
MiniCPU设计好了之后我们进入运行调试环节用RISC-V指令编写了一段用UART发送“Hello MiniCPU!”字符串的汇编程序然后让串口接收端把发送的字符串在电脑上打印出来。如果字符串显示正常说明我们的miniCPU已经可以正常运行了。
到这里我们RISC-V处理器的实现就全部完成了。这节课要点你可以参考下面的导图。
你有兴趣的话还可以课后做更多的探索比如给它添加更多的RISCV指令功能在CPU总线上挂载更多的外设……后面的课程里我会带你学习更多的RISC-V指令敬请期待
思考题
计算机两大体系结构分别是冯诺依曼体系结构和哈弗体系结构请问我们的MiniCPU属于哪一种体系结构呢
期待你在留言区跟我交流互动说说这个模块学习下来的感受如果觉得手写CPU很酷别忘了分享给身边更多的朋友。

View File

@ -0,0 +1,239 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 QEMU支持RISC-V的QEMU如何构建
你好我是LMOS。
工欲善其事,必先利其器。作为开发者,学习过程中我们尤其要重视动手实践,不断巩固和验证自己学到的知识点。而动手实践的前提,就是要建立一个开发环境,这个环境具体包括编译环境、执行环境,以及各种常用的工具软件。
我会用两节课带你动手搭好环境,今天这节课咱们先热个身,搞清楚什么是主环境,还有怎么基于它生成交叉编译工具。
代码你可以从这里下载。
主环境
主环境有时也叫作HOST环境也就是我们使用的计算机环境即使用什么样的操作系统、什么架构的计算机作为开发环境。
比方说我们经常用PC机作为开发机使用它实际就是一个基于x86架构或其他架构的硬件平台再加上Windows或者Linux等操作系统共同组成的开发环境。
普通用户的电脑上经常安装的操作系统是Windows因为界面友好方便、操作简单且娱乐影音、游戏办公等应用软件也是不胜枚举。
Windows对普通用户来说的确非常友好。但是作为软件开发者对于志存高远、想要精研技术的我们而言更喜欢用的是Linux系统。
它虽然没有漂亮的GUI却暴露了更多的计算机底层接口也生产了更多的开发工具和各种各样的工具软件。比如大名鼎鼎的编译器GCC、声名远扬的编辑器EMACS、VIM还有自动化的脚本工具shell、make等。这些工具对开发者非常友好配合使用可以让我们的工作事半功倍后面你会逐渐体会到这点。
当然Linux只是一个内核我们不能直接使用还需要各种工具、库和桌面GUI把这些和Linux打包在一起发行这就构成了我们常说的Linux发行版。
我最喜欢的Linux发行版是Deepin和Ubuntu。为了统一我建议你使用Deepin最新版你也可以使用Ubuntu它们是差不多的。只是操作界面稍有不同。我先给你展示下我的Deepin如下图刚装上它的时候我就觉得它颇为惊艳。
这里最基础的安装我就不讲了因为安装Deepin十分简单无论是虚拟机还是在物理机上安装我相信你通过互联网都可以自行解决搞不定也可以看看这里。
这两种方式我也替你对比过虚拟机中的Linux较物理机上的Linux性能稍差一点但并不影响我们实验操作和结果。
为什么需要交叉编译
虽然主环境搞定了,但现在我们还不能直接跑代码。为什么呢?
先回想一下平时我们正常开发软件需要什么我猜哪怕你不能抢答也会知道个大概需要电脑PC、特定的操作系统比如Windows或Linux等在这个操作系统上还能运行相应的编辑器和编译器。编辑器用来编写源代码而编译器用来把源代码编译成可执行程序。
似乎不需要更多东西了毕竟我们日常开发的软件宿主平台和目标平台是相同的。如果我们把限制条件变一变情况就不同了。如果我们想尝试在RISC-V平台上跑程序要怎么办呢
你或许会说这简单买一台RISC-V的机器不就行了。可是先不说购买硬件的经济成本实际上很多RISC-V平台硬件资源如内存、SD卡容量有限不足以运行复杂的编译器软件有的甚至没有操作系统更别说在上面运行编译器或者编辑器软件了。
面对这样的困境,就要用到交叉编译了。什么是交叉编译呢?简单来说,就是在一个硬件平台上,生成另一个硬件平台的可执行程序。
举个例子我们在x86平台上编译生成ARM平台的可执行程序再比如说之后的课里我们将在x86平台上生成RISC-V的可执行程序。这些都属于交叉编译在这个过程中编译生成可执行程序的平台称为宿主机或者主机执行特定程序的平台如ARM或者RISC-V平台称为目标机。
我特意准备了图解为你展示在x86平台上交叉编译生成RISC-V平台可执行程序的过程你可以仔细看看
如何构建RISC-V交叉编译器
前面说了交叉编译的本质就是生成其他平台体系上的可执行程序这个体系又不同于我们宿主平台。我们的目的很简单就是要在x86平台上编写源代码然后编译出RISC-V平台的可执行程序最后放在RISC-V平台上去运行。
因此我们需要用宿主机编译器A编译出一个编译器B这个编译器B是本地平台上的可执行程序。
说得再具体点你可以把编译器B看作是 x86 Linux上的一个应用。但它的特殊之处就是能根据源代码生成RISC-V平台上的可执行程序。补充一句这里的编译器A和B都是C语言编译器。
下面我们开始构造编译器B。编译器B不仅仅是C语言编译器还有很多额外的程序。比如RISC-V平台上使用的二进制文件分析objcopy、反汇编objdump、elf结构分析工具readelf、静态库归档ar、汇编器as、链接器ld、GDB、C语言库Newlib、Glib、Multlib等。
为了简单、便于区分我们把这些对应于RISC-V平台的编译器相关的软件统称为 RISC-V工具链。
构建RISC-V工具链的主要步骤如下
安装依赖工具在宿主平台上安装编译器A以及相应的工具和库。-
下载RISC-V工具链的源代码-
配置RISC-V工具链-
编译RISC-V工具链并安装在宿主平台上。
第一步:安装依赖工具
我们先从第一步开始编译器A主要是宿主平台上的GCC工具主要是Make、Git、Autoconf、Automake、CURL、Python3、Bison、Flex等。这里GCC主要在build-essential包中我们只要在Linux终端中输入如下指令就可以了
sudo apt-get install git autoconf automake autotools-dev curl python3 libmpc-dev libmpfr-dev libgmp-dev gawk build-essential bison flex texinfo gperf patchutils bc libexpat-dev libglib2.0-dev ninja-build zlib1g-dev pkg-config libboost-all-dev libtool libssl-dev libpixman-1-dev libpython-dev virtualenv libmount-dev libsdl2-dev
如果不出意外这些工具和库会通过网络由Linux的apt包管理器全自动地给你安装完毕。
第二步:下载工具链源代码
接着进入第二步下载RISC-V工具链源代码。通常来说我们只要用Git克隆一个riscv-gnu-toolchain仓库即可其它的由riscv-gnu-toolchain仓库中的仓库子模块自动处理。
手动配置环节
由于众所周知的网络原因你可能连riscv-gnu-toolchain仓库都下载不下来更别说自动下载仓库子模块了。为了照顾卡壳的人我把手动处理的情况也顺便讲一下能够直接自动安装的同学可以跳过这部分直接翻到7条指令之后的最终截图对一下结果就行。
子模块如下:
riscv-qemu虚拟机
riscv-newlib (用嵌入式的轻量级C库)
riscv-binutils(包含一些二进制工具集合如objcopy等)
riscv-gdb(用于调试代码的调试器)
riscv-dejagnu(用于测试其它程序的框架)
riscv-glibc(GNU的C库)
riscv-gcc (C语言编译器)
这些子模块我们需要手动从Gitee网站上下载。下载前我们先在终端上输入后面的指令建立一个目录并切换到该目录中
mkdir RISCV_TOOLS
cd RISCV_TOOLS
把RISC-V工具链的源代码手动下载好步骤稍微多了一些我在后面分步骤列出方便你跟上节奏。
其实也就是7条指令的事儿并不复杂。先统一说明下后面这些命令都是切换到riscv-gnu-toolchain目录的终端下输入我给你列出的指令即可。
开始下载riscv-gnu-toolchain命令如下
git clone https://gitee.com/mirrors/riscv-gnu-toolchain
cd riscv-gnu-toolchain
下载RISC-V平台的C语言编译器源代码仓库输入如下指令
git clone -b riscv-gcc-10.2.0 https://gitee.com/mirrors/riscv-gcc
下载测试框架源代码仓库即riscv-dejagnu。输入如下指令
git clone https://gitee.com/mirrors/riscv-dejagnu
下载GNU的C库源代码仓库也就是riscv-glibc输入如下指令
git clone -b riscv-glibc-2.29 https://gitee.com/mirrors/riscv-glibc
下载用于嵌入式的轻量级C库源代码仓库即riscv-newlib。输入如下指令
git clone https://gitee.com/mirrors/riscv-newlib
下载二进制工具集合源代码仓库riscv-binutils输入如下指令
git clone -b riscv-binutils-2.35 https://gitee.com/mirrors/riscv-binutils-gdb riscv-binutils
最后下载GDB软件调试器源代码仓库riscv-gdb输入如下指令
git clone -b fsf-gdb-10.1-with-sim https://gitee.com/mirrors/riscv-binutils-gdb riscv-gdb
现在所有的RISC-V工具链的源代码我们已经下载完了。我们一起来同步一下确保你我的riscv-gnu-toolchain目录下的目录和文件完全一致。
在riscv-gnu-toolchain目录的终端下输入ls指令你应该得到和后面这张图一样的结果。
第三步:配置工具链
在我们用宿主编译器编译所有的RISC-V工具链的源代码之前还有最重要的一步那就是配置RISC-V工具链的功能。
RISC-V工具链有很多配置选项不同的配置操作会生成具有特定功能的RISC-V工具链。此外配置操作还有一个功能就是检查编译RISC-V工具链所依赖的工具和库。检查通过就会生成相应的配置选项文件还有用于编译操作的Makefile文件。
下面我们开始配置操作。为了不污染源代码目录我们可以在riscv-gnu-toolchain目录下建立一个build目录用于存放编译RISC-V工具链所产生的文件。还是在切换到riscv-gnu-toolchain目录的终端下输入如下指令
mkdir build #建立build目录
#配置操作终端一定要切换到build目录下再执行如下指令
../configure --prefix=/opt/riscv/gcc --enable-multilib --target=riscv64-multlib-elf
我给你解释一下指令里的关键内容。
prefix表示RISC-V的工具链的安装目录我们一起约定为“/opt/riscv/gcc”这个目录。
enable-multilib表示使用multlib库使用该库编译出的RISC-V工具链既可以生成RISCV32的可执行程序也可以生成RISCV64的可执行程序而默认的Newlib库则不行它只能生成RISCV32/64其中之一的可执行程序。
target表示生成的RISC-V工具链中软件名称的前缀是riscv64-multlib-elf-xxxx。若配置操作执行成功了build目录中会出现如下所示的文件
第四步:编译工具链
最后我们来完成第四步编译RISC-V工具链。只要配置操作成功了就已经成功了90%。其实编译操作是简单且高度自动化的我们只要在切换到build目录的终端下输入如下指令即可
sudo make -j8
这个指令在编译完成后会自动安装到“/opt/riscv/gcc”目录由于要操作“/opt/riscv/gcc”目录需要超级管理员权限所以我们要记得加上sudo。
另外如果你的宿主机的CPU有n个核心就在make 后面加-jn*2这样才能使用多线程加速编译。
好了,一通操作猛如虎,现在最重要的事情是等待计算机“搬砖”了。你不妨播放音乐,泡上一杯新鲜的热茶,一边听歌,一边喝茶……估计要喝很多杯茶,才会编译完成。最最重要的是这期间不能断电,否则几个小时就白费了。
如果终端中不出现任何错误,就说明编译成功了。我们在终端中切换到“/opt/riscv/gcc/bin”目录下执行如下指令
riscv64-unknown-elf-gcc -v
上述指令执行以后会输出riscv64-unknown-elf-gcc的版本信息这证明RISC-V工具链构建成功了。如下所示
到这里我们环境已经成功了一半有了交叉编译器并且这种交叉编译器能生成32位的RISC-V平台的可执行程序也能生成64位的RISC-V平台的可执行程序。
你可能会好奇,成功了一半,那另一半呢?这需要我们接着干另一件事。什么事呢?容我先在这里卖个关子,下节课再揭秘。
重点回顾
通过这节课的学习我们成功构建了RISC-V工具链这样就能在X86平台上生成RISC-V平台的可执行程序了。下面让我们一起回顾一下这节课中都做了些什么。
我们首先约定了宿主环境需要用到Ubuntu或者Deepin的Linux发行版无论你是将它们安装在物理PC上还是安装在虚拟机上。
然后我们了解了什么是交叉编译。为了方便后面课程学习动手实践我们要在x86平台的宿主机上编译生成RISC-V平台的可执行程序。
明确了目标我们一起动手开始构建了一个RISC-V交叉编译器。你会发现其中不只是C/C++编译器还有很多处理二进制可执行程序的工具我们把这些统称为RISC-V工具链。
思考题
请你说一说交叉编译的过程?
期待你再留言区分享自己的实验笔记,或者与我交流讨论。也推荐你把这节课分享给更多朋友,我们一起玩转交叉编译。

View File

@ -0,0 +1,206 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 小试牛刀跑通RISC-V平台的Hello World程序
你好我是LMOS。
在上一课中我们一起约定了主环境安装了编译工具和依赖库构建了交叉编译RISC-V工具链。
今天我们继续构建RISC-V版的模拟器QEMU代码你可以从这里下载让它成为“定制款”更匹配我们的学习需要。为此我们需要设置好主环境的环境变量安装好VSCode及其插件这样才能实现编辑、编译、运行、调试RISC-V程序的一体化、自动化。
话不多说,我们开始吧。
RISC-V运行平台
有了上节课成功构建好的交叉编译器有很多同学可能按捺不住急着想写一个简单的Hello World程序来测试一下刚刚构建的交叉编译器。
恕我直言这时你写出来的Hello World程序虽然会无警告、无错误的编译成功但是只要你一运行铁定会出错。
这是为什么呢因为你忘记了交叉编译器生成的是RISC-V平台的可执行程序这样的程序自然无法在你的宿主机x86平台上运行它只能在RISC-V平台上运行。
摸着自己的荷包你可能陷入了沉思难道我还要买一台RISC-V平台的计算机这样成本可太高了不划算。
贫穷让人学会变通为了节约成本我们希望能用软件模拟RISC-V平台。嘿这当然可以而且前辈们早已给我们写好了这样的软件它就是QEMU。
揭秘QEMU
什么是QEMUQEMU是一个仿真器或者说是模拟器软件与市面上BOCHS类似由软件来实现模拟。
QEMU就像计算机界的“孙悟空”变化多端能模拟出多种类型的CPU比如IA32、AMD64、ARM、MIPS、PPC、SPARC、RISC-V等。QUEM通过动态二进制转换来模拟CPU。除了CPU它还支持模拟各种IO设备并提供一系列的硬件模型。这使得QEMU能模拟出完整的硬件平台使得QEMU能运行各种操作系统如Windows和Linux。
你可以把QEMU当做一个“双面间谍”因为在它上面运行的操作系统也许还认为自己在和硬件直接打交道其实是同QEMU模拟出来的硬件打交道QEMU再将这些指令翻译给真正硬件进行操作。通过这种模式QEMU运行的操作系统就能和宿主机上的硬盘、网卡、CPU、CD-ROM、音频设备、USB设备等进行交互了。
由于QEMU的以上这些特点导致QEMU在宿主平台上可以模拟出其它不同于宿主平台的硬件体系这是QEMU的优点。
不过由于是用了软件来实现的模拟所以性能很差这也是QEMU的缺点。正因为这个缺点后来就出现了 QEMU和KVM结合使用的解决方案。
KVM基于硬件辅助的虚拟化技术主要负责比较繁琐的CPU和内存虚拟化而QEMU则负责 I/O设备的模拟两者合作各自发挥自身的优势成就了强强联合的典范。
回归主题关于QEMU现阶段你最需要记住的就是它有两种主要工作模式系统模式和用户模式。
在系统工作模式下QEMU能模拟整个计算机系统包括CPU及其他IO设备。它能运行和调试不同平台开发的操作系统也能在宿主机上虚拟不同数量、不同平台的虚拟电脑。而在用户工作模式QEMU能建立一个普通进程运行那些由不同体系处理器编译的应用程序比如后面我们要动手编写的RISC-V版的Hello World程序。
构建我们的“定制款”QEMU
说了这么多其实是想让你更加了解QEMU。
下面我们来办正事儿——构建适合我们的QEMU如果我们不是有特殊要求——模拟RISC-V平台且带调试功能的QEMU用不着亲自动手去构建只需要一条安装指令就完事了。
构建QEMU用四步就能搞定首先下载QEMU源代码接着配置QEMU功能选项然后编译QEMU最后安装QEMU。
我们需要从QEMU官网上下载稳定版本的QEMU源代码。如果你和我一样觉得在浏览器上点来点去非常麻烦也可以在切换到RISCV_TOOLS目录的终端下输入如下指令
wget https://download.qemu.org/qemu-6.2.0.tar.xz #下载源码包
tar xvJf qemu-6.2.0.tar.xz #解压源码包
这里跑完第一条指令以后下载下来的是压缩的QEMU源码包。所以在下载完成后你要用第二条指令来解压。
由于[上节课]我们构建RISC-V工具链时已经统一安装了构建QEMU所需要的相关依赖库所以这里就不用安装相关依赖库了。
解压成功后我们就要开始配置QEMU的功能了。同样为了不污染源代码目录我们可以先在qemu-6.2.0目录下建立一个build目录然后切换到build目录下进行配置输入如下指令
mkdir build #建立build目录
cd build #切换到build目录下
../configure --prefix=/opt/riscv/qemu --enable-sdl --enable-tools --enable-debug --target-list=riscv32-softmmu,riscv64-softmmu,riscv32-linux-user,riscv64-linux-user #配置QEMU
上述配置选项中prefix表示QEMU的安装目录我们一起约定为“/opt/riscv/qemu”目录enable-sdl表示QEMU使用sdl图形库 enable-tools表示生成QEMU工具集enable-debug表示打开QEMU调试功能。
最重要的是 target-list 这个选项它表示生成QEMU支持的模拟目标机器。不同选项所支持的平台不同我们的选择如下表所示
如果你什么都不选的话它会默认生成QEMU支持的所有平台。按前面我们讲的操作配置配置成功后build目录下会生成后面截图里展示的文件和目录。
配置好功能选项之后下一步就是编译QEMU了。只要配置成功了编译这事儿就非常简单了我们只要输入如下指令然后交给计算机编译就好了。别忘了等待期间泡杯茶不知道你会不会像我一样哼起那首歌“世上有没有人安静的等待你一直不愿回神……”
sudo make -j8
最后就是安装QEMU经过漫长等待以后我们终于迎来编译的成功。这时你还需要输入如下指令进行安装。
sudo make install
这里说明一下QEMU不像RISC-V工具链那样会在编译结束后自动安装它需要手动安装。
我们在终端中切换到“/opt/riscv/qemu/bin”目录下执行如下指令
qemu-riscv32 -version && qemu-riscv64 -version && qemu-system-riscv32 -version && qemu-system-riscv64 -version
上述指令会输出qemu-riscv32、qemu-riscv64、qemu-system-riscv32、qemu-system-riscv64的版本信息以证明能运行RISC-V平台可执行程序的QEMU构建成功。你可以对照一下后面的截图。
到这里RISC-V平台的编译环境和执行环境已经构建完成并且能生成和执行32位或者64位的RISC-V平台的可执行程序无论是RISC-V平台的应用程序还是RISC-V平台的操作系统。
处理环境变量
不知道你发现了没有我们运行QEMU和RISC-V工具链相关的程序都要切换到/opt/riscv/xxxx/bin目录中才可以运行而不是像Linux中的其它程序可以直接在终端中直接运行。
革命还未成功我们还得努力。这是因为我们没有将QEMU和RISC-V工具链的安装目录加入到Linux的环境变量中。
接下来我们就开始处理环境变量,修改环境的方法有好几种。这里我为你演示比较常用的一种,那就是在当前用户目录下的“.bashrc”文件中加入相关的指令。
这里说的“当前用户的目录”就是在终端中执行”cd ~” 指令。怎么操作呢?我们切换到当前用户目录下,来执行这个指令。然后,在文件尾部加上两行信息就行了。具体指令如下所示:
cd ~ #切换到当前用户目录下
vim ./.bashrc #打开.bashrc文件进行编辑
#在.bashrc文件末尾加入如下信息
export PATH=/opt/riscv/gcc/bin:$PATH
export PATH=/opt/riscv/qemu/bin:$PATH
上述操作完成以后,你会看到下图所示的结果:-
随后我们按下键盘上ESC键接着输入”:wq”以便保存并退出Vim。这样操作后你会发现环境变量并没有生效。
这里还差最后一步,我们在终端中输入如下指令,让环境变量生效:
source ./.bashrc
现在你在任何目录之下输入QEMU和RISC-V工具链相关的程序命令它们就都可以正常运行了。
安装VSCode
有了QEMU和RISC-V工具链相关的程序命令我们虽然可以编译调试和执行RISC-V平台的程序了但是必须在终端中输入多条指令才能完成相关的工作。
这对于很多同学来说肯定觉得很陌生特别是在图形化盛行的今天我们更期待能有个轻量级的IDE。
这里我们约定使用VSCode它安装起来也很简单。在 VSCode官网上下载deb包下载后双击deb安装或者切换到刚才下载VSCode目录的终端中输入如下指令就行了。
sudo apt-get install -f *.deb
安装好后在你的桌面会出现VSCode图标双击打开后的页面如下所示
不过有了VSCode我们目前只能写代码还不能编译和调试代码所以需要给VSCode安装C/C++扩展。我们只需打开VSCode按下ctrl+shift+x就能打开VSCode的扩展页面在搜索框中输入C/C++就可以安装了,如下所示:
至此我们的VSCode及其需要的扩展组件就安装完成了。
下一步,我们还需要在你的代码目录下建立一个.vscode文件夹并在文件里写上两个配置文件。这两个配置文件我已经帮你写好了如下所示。
在.vscode文件夹中有个tasks.json文件它主要负责完成用RISC-V编译器编译代码的功能还有用QEMU运行可执行文件的功能。
我们先说说这里的编译工作是怎么完成的。具体就是通过调用make读取代码目录中的Makefile脚本在这个脚本中会调用riscv64-unknown-elf-gcc完成编译。等编译成功后才会调用QEMU来接手由它运行编译好的可执行程序。代码注释已经写得很清楚了你可以停下来仔细看看。
不过tasks.json文件虽然解决了编译与运行的问题但是它也是被其它文件调用的。被谁调用呢那就是我们的调试配置文件launch.json文件它用于启动调试器GDB只不过这里启动的不是宿主平台上的GDB而RISC-V工具链中的GDB。其内容如下所示
当我们写好代码后按下F5键后VSCode就会执行launch.json文件的调试操作了。这里调试器和要调试的可执行程序已经制定好了。不过由于preLaunchTask的指定开始执行调试命令之前VSCode会首先执行tasks.json文件中的操作即编译和运行。
运行Hello World
下面我们一起来写下那个著名的程序——Hello World写好后在main函数所在的行前打上一个断点按下F5键就会看到如下界面。
如果不出意外,哈哈,放心,按我提供给你的步骤,也出不了意外,你一定会看到以上界面。
我们重点来观察红色方框中的信息可以查看代码变量值、CPU的寄存器值、函数的调用栈、断点信息、源代码以及程序执行后在VSCode内嵌终端中输出的信息。有了这些信息我们就能清楚地看到一个程序运行过程的状态和结果。
走到这里我们的定制款QEMU以及VSCode就搭好了可以去图形化编辑、编译、运行和调试RISC-V平台的可执行程序了。
重点回顾
好了我们的RISC-V平台的Hello World也是我们在宿主机上开发的第一个非宿主机的程序现在已经成功运行这说明我们之前的工作完成得很完美今天的课程不知不觉也接近了尾声。
下面来回顾一下,这节课我们都做了些什么。
首先我们构建了能运行RISC-V可执行程序的QEMU模拟器这使得我们不必购买RISC-V平台的机器就能在宿主机上运行RISC-V可执行程序。这不但大大方便了我们的开发工作而且节约了成本。
然后我们处理了环境变量方便我们在任何目录下都可以随意使用RISC-V工具链中的命令和QEMU相关的命令。
最后我们安装了VSCode还在其中安装C/C++扩展并对其进行了相应的配置。以后我们在VSCode图形环境下编写代码、编译代码和调试代码就能一气呵成了。
这节课的要点我整理了导图,供你参考。
恭喜你坚持到这里通过两节课的内容我们拿下了开发环境这一关这对我们后续课程中的实验相当重要。下一模块讲解和调试RISC-V汇编指令的时候你会进一步体会到环境搭建好的便利先好好休息一下咱们下节课见。
思考题
处理环境变量后为什么要执行source ./.bashrc才会生效
欢迎你在留言区提问或者晒晒你的实验记录。如果觉得有收获,也推荐你把这节课分享给你的朋友。

View File

@ -0,0 +1,208 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 走进C语言高级语言怎样抽象执行逻辑
你好我是LMOS。
前面我们设计了迷你CPU相信你已经对CPU如何执行指令有了一定的了解。
而CPU执行的指令正是由工程师敲出来的高级编程语言产生的比如我们熟知的C、C++、Java等。
平时我们做编程的时候底层CPU如何执行指令已经被封装好了因此你很少会想到把底层和语言编译联系在一起。但从我自己学习各种编程语言的经历看从这样一个全新视角重新剖析C语言有助于加深你对它的理解。
这节课我们首先要了解CPU执行指令的过程然后再来分析C语言的编译过程掌握C语言的重要组成最后我们再重点学习C语言如何对程序以及程序中的指令和数据进行抽象变成更易于人类理解的语言代码从这里下载
CPU执行指令的过程
通过之前的学习我们已经知道了CPU执行一条特定指令的详细过程它们是取指、译码执行、访存、回写。这是一个非常详细的硬件底层细节我们现在再迈一个台阶站高一点从软件逻辑的角度看看CPU执行多条指令的过程。
这个过程描述起来很简单,就是一个循环。为了让你分清每个步骤,我分点列一下:
以PC寄存器中值为内存地址A读取内存地址A中的数据;-
CPU把内存地址A中的数据作为指令执行具体执行过程为取指、译码执行、访存、写回;-
将PC寄存器中的值更新为内存地址A+(一条指令占用的字节数);-
回到第一步。
上述过程就是CPU执行指令的逻辑过程。下面我们动手来写几行代码调试一下观察一下内存的内容和CPU寄存器的变化这样你就更加清楚了。代码如下
.text
.global main
main: # main函数
add t1,zero,1 # x6 = 1
add t2,zero,2 # x7 = 2
add t0,t1,t2 # x5 = x6 + x7
add a0,zero,zero # x10 = 0 相当于main函数中的return 0
ret
这是一段RV32I指令集编写的汇编代码现在你无法完全理解这段代码也没关系能看懂注释就行了。
下面我们一起打开我为你们准备工程代码调试一下。如何用VSCode调试代码我在环境那节课讲过了记不清了可以回顾一下。
这里我们用VSCode打开本课的目录设置好断点按下“F5”键就行了如下所示。
我们看到t0、t1、t2寄存器中的数据和我们预期的一样。PC寄存器从0x10120一直变化到0x1012c每执行一条指令PC寄存器的值都要加4这是因为每条RV32I指令都占用4字节的内存空间。
我们在调试控制台中执行“-exec x/16xb 0x10120”命令即可显示从0x10120开始的16字节内存数据刚好4条指令的数据。我还在文稿里画了一幅图它展示了内存中的情况如下所示
对照示意图我来为你解释一下大致逻辑是这样的最开始由CPU控制单元通过控制总线发出要读取数据的控制信号。接着通过地址总线发送地址信号当前情况下地址数据来源PC寄存器0x10120然后通过数据总线传送指令数据(0x00100313)最后执行单元拿到指令数据开始执行并增加PC寄存器使之指向下一条指令。重复这个过程内存中的指令就能一条一条地执行了。
C语言编译过程
了解了CPU执行逻辑过程之后我们再来看看内存中的指令数据是如何产生的。
其实数据产生的方式有很多,我们可以用手写,也可以用电子编程器。只不过这样的方式,太古老了,也太低效了,更加容易出错。所以人们之后设计汇编语言时,实现了指令符号化,这看似高级了不少,但是依然是低效且难以理解的。
直到后来人们开发出了高级语言进一步抽象形成更容易让人们理解的形式。但是因为CPU始终只认识那些二进制数据就需要把高级语言转化成为二进制数据这个转化的过程叫编译过程完成这个转化的工具软件就叫编译器。
比如下面要讲的C语言编译器编译C语言的过程。我们先通过示意图来理解这一过程建立一个整体印象如下所示
现代的C预处理器、C编译器、汇编器、链接器是独立的程序可以分开独立工作并不是一个程序完成上图中所有的工作。
因为我们不开发编译器这里你不需要理解词法、语法是如何分析的中间代码是怎样优化的。我们要关注的焦点是从C源代码到二进制机器指令数据的转化过程。
C语言的重要组成
想要弄清楚C如何跟二进制指令数据转化首先要清楚C语言的重要组成部分。你可能会说C语言的重要组成当然是C语言代码。这个说法当然没错但代码只是一个统称。从不同层次抽象里面的内容是不一样的从高层次看代码中只有声明和定义下一层看代码只有函数和变量变量进一步分解还有不同的类型。
硬背这些分类只会让你晕头转向。接下来我们不妨分析一下想要让一段C语言代码编译通过需要哪些重要成分和逻辑结构。
我们在C语言中经常容易混淆声明和定义这两个概念我们先来看看声明。
声明是给变量、函数、结构体等命名,表明在程序代码中有该变量、函数、结构体,我们来看看下图中的代码:
我们在declaration.c程序文件中声明了一个整型变量一个结构体变量一个函数。然后我们编译它确实能编译成功这说明在C语言文法中仅仅需要有声明就可以当然空文件也是可以的。声明不会分配内存空间。
这里需要注意的是,只有声明的代码确实能编译成功,但链接的时候就不一定了,我们这里之所以能链接成功。是因为在其它代码中没有对这些声明进行了引用。
下面我们来看看定义,定义是具体给变量分配内存空间。这个内存空间可以是初始化的,也可以是没有初始化的、给出具体函数的实现。
具体函数可以是空函数,函数中没有语句什么都不做也可以,唯一必需的就是指明结构体成员。结构体也是变量,只不过结构体是多个变量的组合,同样要分配内存空间,可以初始化也可以不做初始化。
我们写代码验证一下对不对,如下图所示:
我们还是在definition.c程序文件中定义了一个整型变量一个结构体变量一个函数。我们同样能成功编译它。这说明C语言文法中没有声明只有定义也可以成功编译的其实C语言文法的原则是声明可以出现很多次定义有且只能出现一次。声明和定义也可以同时出现。
现在我来总结一下,其实编译的其中一个过程,就是用某种编程语言的文法来检查所写语言(代码)是否正确。你可以这么理解,语言的文法就是对这种语言的最高抽象,所以我们可以说 C语言最重要的组成部分就是声明或者定义。
声明或者定义中又包含变量和函数,变量又有指针、数组、结构体,它们又包含各种类型,而函数中包含了各种表达式,各种表达式对变量进行操作。
编译器的语法分析过程,就是这样层层递归推导下去,最终构建出语法树,从而检查语言是否正确无误、是否符合该语言文法的规则定义,都符合编译才能通过。就像你学英文一样,你怎么判断一条英语句子是否正确呢?你会拿主谓宾等等约定俗成的语法去套,如果能套上去,就是正确的。
C语言对程序的抽象
前面我们已经从整体上了解了C语言的重要组成可以看出一段程序从语法角度来说就是声明加上定义。
现在我们继续深入了解C语言对程序的抽象平时我们最多是使用设计好的C语言。而现在我们要站在C语言设计者的角度想一想一门既能写程序又更容易让人类理解的语言要怎么设计其实这门语言的“设计过程”就是C语言对机器语言的抽象也就是C语言对程序的抽象。
我特意为你绘制了一幅图我们从C语言的核心语言元素开始了解。这些元素就像英文里的各种固定词型知道了这些元素在C语言中发挥的作用你就找到了理解C语言的钥匙。
我们看到C语言中包含声明和定义可以声明变量和函数由图中绿色箭头指向。也可以定义变量和函数由图中蓝色箭头指向注意定义只能出现一次声明可以出现多次。
我故意安排指针在最前端是因为从C语言特性讲指针能指向任一变量和函数由图中红色箭头指向从另一个角度看指针就是内存能自由寻址读写内存空间但能否读写内存则要看操作系统给的权限指针就是C语言中的“上帝之手”。同时图中黑色线条还表示指针可以有相应的类型并且能参与运算这是我把指针放在比函数更高位置的原因。
需要注意的是,各种类型的变量是可以定义在函数以外的,这些定义在函数以外的变量是全局变量,而定义在函数内部的变量叫局部变量。
如果我们要用C语言完成一个实际功能一定要写一个函数。函数就是C语言中对一段功能代码的抽象。一个函数就是一个执行过程有输入参数也有返回结果根据需要可有可无代码如下所示
void func()
{
return;
}
上面代码中的函数是空函数C语言是允许的当然这样的函数不会完成任何功能。
如果我们要完成点什么功能,就要在函数中写代码语句。代码语句又被抽象成表达式和流程控制。这也是为什么上图中函数下面包含了表达式和流程控制。
接下来,我们写个完成求两数之差、求两数之和的函数,代码如下所示:
int func(int op, int a, int b)
{
if(op < 1) //表达式op < 1
{
return a - b;//表达式a - b
}
return a + b;//表达式a + b
}
上面代码中有三个表达式注释中已经写明了含有三种流程控制if判断分支控制return返回控制还有默认的从上至下的代码顺序。你可以把上述代码拿到上图中去套以证明C语言对代码语句的抽象你会发现一套一个准。
下面我们继续研究一下表达式。从前面的图里可以看到C语言表达式包含了变量和运算符。
变量又有各种类型单个变量也是表达式但是运算符不能单独存在变成表达式所以C语言表达式要么是单个变量要么是变量加运算符一起。根据运算符的类型不同可以分成运算表达式、逻辑表达式、赋值表达式等。
下面我们使用代码实例来找找感觉,如下所示:
int sumdata = 0;//全局整型变量sumdata
void func()
{
int i = 1;//局部整型变量i
int *p; //局部整型指针变量p
p = &sumdata;//把sumdata变量的地址赋值给p变量从而指向sumdata变量
while(1)//循环流程控制
{
if(i > 100)
{
break;//跳出循环,流程控制
}
(*p) += i;//相当于sumdata = sumdata + i
i = i + 1;
}
return;
}
上述代码所有的表达式中,涉及了一个全局变量,两个局部变量。其中局部变量中有一个是指针变量,指向全局的变量。包含了更多的流程控制语句,可以明显地看到表达式就是:变量和运算符组合在一起,完成了对变量的操作。而变量代表了数据,最终就能实现对数据的运算。但是变量有各种类型,这些类型只是规范了变量的位宽和大小,下一节我们会有更详细的介绍。
现在我们就可以总结一下C语言是如何抽象程序的如下表所示。
这就是C语言对程序的抽象。到这里今天的课程也到了尾声你是否像我一样想起了那个著名的公式程序=算法+数据结构?
没错C 语言就是函数 + 变量。函数表示算法操作,变量存放数据,即数据结构,合起来就是程序 = 算法 + 数据结构。
C语法的运算符和流程控制可以实现各种算法而各种类型的变量组合起来就能实现各种复杂的数据结构。理解了这些你就抓住了C语言的本质也为后续学习打下了良好的基础。
重点回顾
到这里今天的课程就告一段落了,我来总结一下这节课的重点。
首先我们研究了CPU执行指令过程和C语言编译过程。理解了这两个过程后续的学习就有了良好基础。
接着我们进入到C语言内部从宏观上理解了定义和声明这两个C语言的重要组成部分。声明只是一种说明性质的东西不产生机器指令而定义则是实现会产生对应的机器指令。
最后我们从C语言的核心语言元素入手抽丝剥茧层层解构。C语言由函数构成函数中又包含多条语句语句由流程控制和表达式构成表达式由各种类型的变量和各种运算符构成。这些东西组合在一起就把机器执行的程序抽象成了人类易于掌握和理解的概念——C语言。文稿里我总结的那张图你不妨保存下来作为你学习理解C语言的导航图。
下节课我们继续研究C语言和汇编语言的对应关系敬请期待。
思考题
为什么C语言中为什么要有流程控制
期待你在留言区踊跃发言积极思考有助于你更好地领会课程内容。也推荐你把这节课分享给身边的朋友说不定就能让他进一步掌握C语言了。

View File

@ -0,0 +1,456 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 C与汇编揭秘C语言编译器的“搬砖”日常
你好我是LMOS。
通过上一节课的学习我们已经清楚了C语言可以把程序抽象成一个函数这样的函数又抽象成表达式和流程控制表达式又能进一步抽象成各种类型的变量和各种运算符。并且我们还搞懂了变量就是数据运算符就是操作而变量的运算结合起来就能对数据施加操作。这样拆分下来是不是C语言就没那么神秘了
今天让我们就来继续揭秘C语言编译器的日常工作搞清楚各种类型变量、各种运算符、流程控制以及由它们组成的函数是如何对应到机器指令的代码从这里下载
此外,我还会带你了解函数间的调用规范,这能让我们在以后写代码时,清楚自己的每行代码都会被编译器转化为什么样的机器指令。
C变量
现在我们从最基本的C语言的变量开始说起。
C语言是如何把各种类型的变量转换成对应的汇编语言呢高级语言更容易被工程师理解而汇编语言这样的低级语言则更容易被机器解读。这是因为汇编语言里的大部分内容都跟机器语言一一对应你可以这样理解汇编语言就是把机器语言符号化。
我举个例子让你加深理解机器对应的加法指令是一段很长的二进制数据在汇编语言中这个指令就对应着“add”这个指令。无论是机器指令还是寄存器经过汇编语言“翻译”出来都是符号。
汇编器会将汇编源代码生成二进制程序文件。在程序二进制文件里有很多段。其中text段和data段在文件里占用位置空间text段存放了程序指令的二进制数据data段放着各种已经初始化的数据。二进制文件里还有个更特殊的bss段它不占用文件的位置空间而是在文件头里记录bss段的大小。
一旦text、data段加载到内存中运行就会占用内存空间自然也就对应到实际的内存。至于bss段操作台会根据文件头里记录的大小给它分配内存空间并初始为0。
有了这些基础,我们就可以写代码来进行验证了,如下所示:
//定义整型变量
int i = 5;
//定义字符变量
char chars = 'a';
//定义结构体
struct data
{
int a;
char c;
};
//定义结构体变量并初始化
struct data d = {10, 'b'};
我们在代码中定义了三个不同类型的变量。在GCC编译器后面加上-save-temps 选项就能留下GCC编译器各个步骤生成的临时文件方便我们查看GCC逐步处理的结果。
我已经为你写好了makefile文件你用VSCode打开项目代码按下F5就会生成相应的临时文件xxxx.i、xxxx.s、xxxx.bin。
其中xxxx.i是gcc编译器生成的预处理文件xxxx.s是gcc编译器生成的汇编文件xxxx.o是gcc编译器生成的可链接的目标文件xxxx.bin是去除了ELF文件格式数据的纯二进制文件这是我用objcopy工具生成的这个文件可以方便我们后续观察。
下面我们打开项目代码中的variable.s文件如下所示
.globl i #导出全局标号i
.section .sdata,"aw" #创建sdata段,属性动态分配可读写
.size i, 4 #占用4字节大小
i: #标号i
.word 5 #定义一个字初始化为5
.globl chars #导出全局标号chars
.size chars, 1 #占用1字节大小
chars: #标号chars
.byte 97 #定义一个字节初始化为97正是a字符的ascii码
.globl d #导出全局标号d
.size d, 8 #占用8字节大小
d: #标号d
.word 10 #定义一个字初始化为10
.byte 98 #定义一个字节初始化为98正是b字符的ascii码
.zero 3 #填充3个字节数据为0
上面的汇编代码和注释已经写得很清楚了C语言的变量名变成了汇编语言中的标号根据每个变量的大小用多个汇编代码中定义数据的操作符比如.byte、.word进行定义初始化。
C语言结构体中的字段则要用多个.byte、.word操作符的组合实现变量定义汇编器会根据.byte、.word来分配变量的内存空间标号就是对应的地址。这个变量的内存空间当程序处于非运行状态时就反映在程序文件中一旦程序加载到内存中运行其中的变量就会加载到内存里对应在相应的内存地址上。
上述代码仍然是可读的文本代码下面我们看看汇编器生成的二进制文件variable.bin如下所示。
从这张文件截图里我们能清楚地看到二进制文件variable.bin一共有16字节第5到第7个字节和第13到第15个字节为填充字节这是为了让地址可以按32位对齐。我们可以看到i变量占用4个字节空间chars变量占用1个字节空间d结构体变量占用8个字节里面有两个成员变量a和c。
截图中反映的情况相当于从0开始分配地址空间当然后面链接器会重新分配地址空间的这里i变量地址为0chars变量地址为4d变量地址为8。
现在我们总结一下C语言转化成汇编语言时发生了什么样的变化C语言的变量名成了汇编语言的标号C语言的变量对应的空间变成了汇编语言.byte、.word之类的定义数据操作符。最终汇编器会根据.byte、.word分配内存空间。这些对应关系我们通过对二进制文件的分析已经再清楚不过了。
C语言表达式
下面我们来看看C语言表达式了解C语言是怎么把各种表达式转换成对应的汇编语言的。
我先说明一下这里本来应该介绍运算符的但是C语言的运算符不能独立存在必须要与变量结合起来形成表达式所以这里我把运算符归纳到表达式里一起给你讲解你学起来也更清晰。
我们先来写一个表达式,如下所示:
int add()
{
//定义三个局部整形变量
int a, b, c;
//赋值表达式
a = 125;
b = 100;
//运算表达式
c = a + b;
//返回表达式
return c;
}
代码注释我为你标注了表达式的类型至于代码的含义任何一个C语言初学者都能明白就不过多讲解了。
接下来我们直接看GCC编译器生成的汇编代码。GCC在编译代码时我加了“-O0”这表示让GCC不做代码优化如下所示
add:
addi sp,sp,-32
sw s0,28(sp)
addi s0,sp,32
li a5,125
sw a5,-20(s0)
li a5,100
sw a5,-24(s0)
lw a4,-20(s0)
lw a5,-24(s0)
add a5,a4,a5
sw a5,-28(s0)
lw a5,-28(s0)
mv a0,a5
lw s0,28(sp)
addi sp,sp,32
jr ra
上述的汇编代码你看不懂也没关系,且听我分段给你拆解。它们分别是:在栈中分配变量的内存空间、给变量赋值、进行运算、处理返回值、回收栈中分配的空间、返回。
我们首先看看C语言中的“int a,b,c;”这是三个局部变量。在C语言中局部变量是放在栈中的栈在后面的课程里我再介绍。这里就是给a、b、c这三个变量在栈中分配变量的内存空间对应的代码如下所示
# int a,b,c;
addi sp,sp,-32 #把栈指针寄存器减去32相当于在栈中分配了32字节的空间
sw s0,28(sp) #首先把s0寄存器存放在sp+28的内存空间中
addi s0,sp,32 #然后把s0寄存器设为原来sp寄存器的值
上述代码通过减去sp寄存器的值在栈中分配了内存空间。因为栈是由高地址内存空间向低地址内存空间生长的所以分配栈内存空间是减去一个值。
接着我们来看看C语言中的“a=125;b=100;”,这两行代码就是给变量赋值,也可以叫做赋值表达式,对应的汇编代码如下所示:
# a=125;b=100;
li a5,125 #125加载到a5寄存器中
sw a5,-20(s0) #把a5寄存器储存到s0-20的内存空间中即栈中
li a5,100 #100加载到a5寄存器中
sw a5,-24(s0) #把a5寄存器储存到s0-24的内存空间中即栈中
现在我们已经看到了“=”赋值运算,被转化为机器的数据传输指令,即储存、加载和寄存器之间的传输指令。从-20、-24这些地址偏移量我们可以推导出ab两个整型变量各占4字节大小的空间。
然后我们来看看C语言里“c = a + b;”这一行代码,它就是运算表达式,同时也赋值表达式,但运算表达式的优先级更高,对应的汇编代码如下所示:
#c=a+b;
lw a4,-20(s0) #把s0-20内存空间中的内容加载到a4寄存器中
lw a5,-24(s0) #把s0-24内存空间中的内容加载到a5寄存器中
add a5,a4,a5 #a4寄存器加上a5寄存器的结果送给a5寄存器
sw a5,-28(s0) #把a5寄存器储存到s0-28的内存空间中即栈中
上述代码中我们再一次看到C语言中的加法运算符被转化成了机器的加法指令运算表达式中的变量放在寄存器中就成了加法指令的操作数。但是运算结果也被放在寄存器中而后又被储存到内存中了。
最后我们来看看C语言中“return c;”这一行代码,也就是返回表达式。对应的汇编代码如下所示:
#return c;
lw a5,-28(s0) #把s0-28内存空间中的内容加载到a5寄存器中
mv a0,a5 #a5寄存器送给a0寄存器,作为返回值
lw s0,28(sp) #恢复s0寄存器
addi sp,sp,32 #把栈指针寄存器加上32相当于在栈中回收了32字节的空间
jr ra #把ra寄存器送给pc寄存器实现返回
从上述代码块可以看到先把c变量加载到a5寄存器中又把a5寄存器送给了a0寄存器。
在语言调用标准中a0寄存器是作为返回值寄存器使用的return语句是流程控制语句它被转换为机器对应的跳转指令即jr指令。jr指令会把操作数送给pc寄存器这样就能实现程序的跳转。
到这里C语言表达式是怎么变成汇编语言的我们就弄明白了。
C语言流程控制
如果只存在表达式,代码只能从上到下顺序执行,很多算法都无法实现,毕竟顺序执行就是“一条道走到黑”,这显然还不够。如果我们要控制代码的执行顺序,就需要流程控制。
通过流程控制C语言就能把程序的分支、循环结构转换成汇编语言。下面我们以C语言中最常用的for循环为例来理解流程控制。for循环这个例子很有代表性因为它包括了循环和分支代码如下所示。
void flowcontrol()
{
//定义了整型变量i
int i;
for(i = 0; i < 5; i++)
{
;//什么都不做
}
return;
}
可以看到上述代码中for关键字后面的括号中有三个表达式。
开始第一步先执行的是第一个表达式i = 0; 接着第二步执行第二个表达式。如果表达式的运算结果为false就跳出for循环然后到了第三步执行大括号“{}”中的语句这里是空语句什么都不做最后的第四步执行第三个表达式i++,再回到第二步开始下一次循环。
下面我们看看这四步对应的汇编程序,如下所示:
flowcontrol:
addi sp,sp,-32
sw s0,28(sp)
addi s0,sp,32 # int i 定义i变量
sw zero,-20(s0) # i = 0 第一步 第一个表达式
j .L2 # 无条件跳转到.L2标号处
.L3:
lw a5,-20(s0) # 加载i变量
addi a5,a5,1 # i++ 第四步 第三个表达式
sw a5,-20(s0) # 保存i变量
.L2:
lw a4,-20(s0) # 加载i变量
li a5,4 # 加载常量4
ble a4,a5,.L3 # i < 5 第二步 第二个表达式 如果i <= 4就跳转.L3标号否则就执行后续指令跳出循环
lw s0,28(sp) # 恢复s0寄存器
addi sp,sp,32 # 回收栈空间
jr ra # 返回
有了前面的基础,上面这段代码应该很容易理解。
你可能有点疑惑为什么代码的注释中没有看到第三步的内容这是因为我们写了空语句编译器没有生成相应的指令。一般CPU会提供多种形式的跳转指令来实现程序的流程控制后面课程里我们在专门研究和调试跳转指令这里你先有个印象就行。
你不妨试着想象一下图灵机那个读头在纸带上来回移动的情景。上面的代码中jjr都是无条件的跳转指令ble是带比较的条件分支指令比较的结果为真则跳转到相应的地址上继续执行否则就会执行后面的下一条指令。
现在已经很清楚了C语言正是用了这些跳转、条件分支指令才实现了如if、for、while、goto、return等程序流程控制逻辑。
C语言函数
我们再来看看C语言函数了解一下C语言是怎么把函数转换成汇编语言的。
通过前一节课的学习我们知道了函数是C语言中非常重要的组成部分。我们要用C语言完成一个实际的功能就需要至少写一个函数可见函数就是C语言中对一段功能代码的抽象。一个函数就是一个执行过程有输入参数也有返回结果根据需要可有可无可以调用其它函数也被其它函数调用。
让我们去写函数验证一下,如下所示:
//定义funcB
void funcB()
{
return;
}
//定义funcA
void funcA()
{
//调用funcB
funcB();
return;
}
上述代码中定义了funcA、funcB两个函数函数funcA调用了函数funcB而函数funcB是个空函数什么也不做。
下面我们直接看它们的汇编代码,如下所示:
funcB:
addi sp,sp,-16
sw s0,12(sp) #储存s0寄存器到栈中
addi s0,sp,16
nop
lw s0,12(sp) #从栈中加载s0寄存器
addi sp,sp,16
jr ra #函数返回
funcA:
addi sp,sp,-16
sw ra,12(sp)
sw s0,8(sp) #储存ras0寄存器到栈中
addi s0,sp,16
call funcB #调用funcB函数
nop
lw ra,12(sp) #从栈中加载ras0寄存器
lw s0,8(sp)
addi sp,sp,16
jr ra #函数返回
从上面的汇编代码可以看出函数就是从一个标号开始到返回指令的一段汇编程序并且C语言中的函数名就是标号对应到汇编程序中就是地址。
即使是什么也不做的空函数C语言编译器也会把它翻译成相应的指令分配栈空间保存或者恢复相应的寄存器回收栈空间这相当于一个标准的代码模板。
其中的call其实完成了两个动作一是把call下一条指令的地址保存到ra寄存器中二是把后面标号地址赋值给pc寄存器实现程序的跳转。由于被跳转的程序段最后会执行jr ra即把ra寄存器赋值给pc寄存器然后再跳转到call指令的下一条指令开始执行这样就能实现函数的返回。
总结一下C语言编译器把函数名转换成了标号也就是汇编程序里的某个地址并且把函数的功能翻译成各种指令。
这样我们写下一个函数经过C语言编译器加工就变成了CPU能够“听懂”的各种运算指令、流程控制指令。之后CPU就能定位到相应的汇编代码段在这些代码段之间跳来跳去实现函数之间的调用。
C语言调用规范
现在我们来探讨另一个问题,就是一个函数调用另一个函数的情况,而且这两个函数不是同一种语言所写。
比如说在汇编语言中调用C语言或者反过来在C语言里调用汇编语言。这些情况要怎么办呢这时候就需要有一种调用约定或者规范。
这个规范有什么用呢前面的课程我们说过CPU中的一些寄存器有特定作用的自然不能在函数中随便使用。即使用到了也要先在栈里保存起来然后再恢复。
这就引发了三个问题一是需要规定好寄存器的用途二是明确哪些寄存器需要保存和恢复第三则是规定函数如何传递参数和返回值比如用哪些寄存器传递参数和返回值。关于CPU寄存器的规定你可以回顾一下[第二节课]。
首先我们看一下C语言下的数据类型在RISC-V平台下所占据内存的大小这也是调用规范的一部分如下表
下面我们结合实例来理解。我们先来写一段汇编代码和C代码用汇编代码调用C函数它们属于不同的文件这些文件我已经在工程里给你准备好了。
首先,汇编代码如下:
.text //表明下列代码放在text段中
.globl main //导出main符号链接器必须要找的到main符号
main:
addi sp,sp,-16
sw s0,12(sp) //保存s0寄存器
addi s0,sp,16
call C_function //调用C语言编写的C_function函数
li a0,0 //设置main函数的返回值为0
lw s0,12(sp) //恢复s0寄存器
addi sp,sp,16
jr ra //返回
上述代码放在了main_asm.S文件中这些代码我都替你手动写好了你需要从main开始代码的作用你可以参考一下注释说明。
这段代码主要处理了栈空间保存了s0寄存器然后调用了C语言编写的C_function函数该函数我放在了main_c.c文件中如下所示
#include "stdio.h"
void C_function()
{
printf("This is C_function!\n");
return;
}
我们用VSCode打开工程文件夹按下“F5”键就会出现后面图里显示的结果。-
我们看到代码运行了打印出了This is C_function!而且没有出现任何错误这说明我们通过汇编代码调用C函数成功了。你可以想一想这个过程还有什么疏漏么
以上代码的功能很简单很多寄存器没有用到所以并没有保护和恢复相应的寄存器。在复杂的情况下调用者函数应该保存和恢复临时寄存器t0~t6整数寄存器ft0~ft11浮点寄存器。被调用者函数应该保存和恢复的寄存器s0~s11整数寄存器fs0~fs11浮点寄存器
现在只剩最后一个问题了C语言函数有参数和返回值。如果没有相应规范一个C语言函数就不知道如何给另一个C语言函数传递参数或者接收它的返回值。
我们同样用代码来验证一下,如下所示:
int addtest(int a, int b, int c,int d, int e, int f, int g, int h, int i)
{
return a + b + c + d+ e + f + g + h + i;
}
void C_function()
{
int s = 0;
s = addtest(1,2,3,4,5,6,7,8,9);
printf("This is C_function! s = %d\n", s);
return;
}
这段代码很简单为了验证参数的传递我们给addtest函数定义了9个参数在C_function函数中调用它并打印出它的返回值。
我们直接看看它生成的汇编代码,如下所示:
addtest:
addi sp,sp,-48
sw s0,44(sp)
addi s0,sp,48 #让s0变成原sp的值
#依次将a0~a78个寄存器放入栈中
sw a0,-20(s0)
sw a1,-24(s0)
sw a2,-28(s0)
sw a3,-32(s0)
sw a4,-36(s0)
sw a5,-40(s0)
sw a6,-44(s0)
sw a7,-48(s0)
#从栈中加载8个整型数据相加
lw a4,-20(s0)
lw a5,-24(s0)
add a4,a4,a5
lw a5,-28(s0)
add a4,a4,a5
lw a5,-32(s0)
add a4,a4,a5
lw a5,-36(s0)
add a4,a4,a5
lw a5,-40(s0)
add a4,a4,a5
lw a5,-44(s0)
add a4,a4,a5
lw a5,-48(s0)
add a4,a4,a5
#从栈中加载第9个参数的数据参考第4行代码
lw a5,0(s0)
add a5,a4,a5
#把累加的结果放入a0寄存器,作为返回值
mv a0,a5
lw s0,44(sp)
addi sp,sp,48 #恢复栈空间
jr ra #返回
C_function:
addi sp,sp,-48
sw ra,44(sp)
sw s0,40(sp)
addi s0,sp,48
sw zero,-20(s0)
li a5,9
sw a5,0(sp) #将9保存到栈顶空间中
li a7,8
li a6,7
li a5,6
li a4,5
li a3,4
li a2,3
li a1,2
li a0,1 #将1~8加载到a0~a78个寄存器中作为addtest函数的前8个参数
call addtest #调用addtest函数
sw a0,-20(s0) #addtest函数返回值保存到s变量中
lw a1,-20(s0) #将s变量作为printf函数的第二个参数
lui a5,%hi(.LC0)
addi a0,a5,%lo(.LC0)
call printf
nop
lw ra,44(sp)
lw s0,40(sp)
addi sp,sp,48 #恢复栈空间
jr ra #返回
根据上面的代码我们来总结一下C语言函数用a0~a7这个8个寄存器传递了一个函数的前8个参数。注意如果是浮点类型的参数则使用对应的浮点寄存器fa0~fa7从第9个参数开始依次存放在栈中,而函数的返回值通常放在a0寄存器中。
到这里C语言调用规范我们就搞清楚了。
重点回顾
这节课我们一起研究了C语言编译器的“搬砖日常”讨论了C语言跟汇编语言的对应关系。现在我们来回顾一下这节课的重点。
C语言变量经过编译器的加工其变量名变成了汇编语言中的标号也就是地址。变量空间由汇编语言中.byte、.word等操作符分配空间有的空间存在于二进制文件中有的空间需要OS加载程序之后再进行分配。
接着是C语言表达式C语言表达式由C语言变量和C语言运算符组成C语言运算符被转换成了对应的CPU运算指令。变量由内存加载到寄存器变成了指令的操作数一起完成了运算功能。
之后我们借助for循环这个例子发现C语言函数会被编译器“翻译”成一段带有标号的汇编代码里面包含了流程控制指令比如跳转指令和各种运算指令。这些指令能修改PC寄存器使之能跳转到相应的地址上运行实现流程控制。
最后我们讨论了C语言的调用规范。“没有规矩不成方圆”调用规范解决了函数之间的调用约束比如哪些寄存器由调用者根据需要保存和恢复哪些寄存器由被调用者根据需要保存和恢复函数之间如何传递参数又如何接收函数的返回值等等的问题。
为了奖励你看到这里,我还准备了一张知识导图,供你复习回顾要点。
下节课起,我们将会开始汇编指令的深入学习,敬请期待。
思考题
请问C语言函数如何传递结构体类型的参数呢
欢迎你在留言区跟我交流互动,积极参与思考有助于你更深入地学习。如果觉得这节课还不错,别忘了分享给身边的同事。

View File

@ -0,0 +1,381 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 RISC-V指令精讲算术指令实现与调试
你好我是LMOS。
通过前面的学习我们已经了解了在C语言编译器的“视角”下C语言的各种表达式是如何转换成各种机器汇编指令的。从这节课开始我会带你进一步深入学习各种汇编指令的细节。
只要你耐心跟我学完这节课对RISC-V的各种指令你就能了如指掌了。这里我们将从RV32I的算术指令开始先学习加减指令add、sub接着了解一下数值比较指令slt。这些指令都有两个版本一个是立即数版本一个是寄存器的版本。话不多说我们开始吧。
课程配套代码从这里下载。
加减指令
上小学时我们都学过四则运算最基础的是加减法即一个数加上或者减去一个数对应到CPU中就是一条加法指令和一条减法指令。
一个CPU要执行基本的数据处理计算加减指令是少不了的否则基础的数学计算和内存寻址操作都完成不了用这样的CPU做出来的计算机将毫无用处。
不过想让CPU实现加减法我们需要用到它能“理解”的语言格式这样才能顺畅交流。所以在研究指令之前我们先来看看RISC-V指令的格式。
RISC-V指令的格式
RISC-V机器指令是一种三操作数指令其对应的汇编语句格式如下
指令助记符 目标寄存器源操作数1源操作数2
例如“add a0a1a2”其中add就是指令助记符表示各种指令add是加法指令a0是目标寄存器目标寄存器可以是任何通用寄存器a1a2是源操作数1与源操作数2源操作数1可以是任何通用寄存器源操作数2可以是任何通用寄存器和立即数。立即数就是写指令中的常数比如0、1、100、1024等。
立即数加减法如何实现
我们先来看看加法指令,加法指令有两种形式。一种形式是一个寄存器和一个立即数相加,结果写入目标寄存器,我们称之为立即数加法指令。另一种形式是一个寄存器和另一个寄存器相加,结果写入目标寄存器,我们称之为寄存器加法指令。
我们先来看看立即数加法指令,形式如下:
addi rdrs1imm
#addi 立即数加法指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
上述代码rd、rs1可以是任何通用寄存器。 imm立即数可以是-2048~2047其完成的操作是将rs1寄存器里的值加上立即数计算得到的数值会写到rd寄存器当中也就是rd = rs1 + imm。
写代码验证之前,我们需要先明确代码应该写在哪里。而一个程序编译成二进制之后,运行的时候都是从 main 函数开始执行的。
所以我们先构建一个main.c文件在里面用C语言写上main函数想让链接器工作这一步必不可少。接着我们写一个汇编文件addi.S并在里面用汇编写上addi_ins函数。
addi_ins函数的代码如下所示
addi_ins:
addi a0a05 #a0 = a0+5a0是参数又是返回值这样计算结果就返回了
jr ra #函数返回
上节课我们提到过C函数的函数名对应到汇编语言中就是标号这里加上一条“jr ra”返回指令就构成了一个C语言中的函数。
这里a0寄存器里的数值即是C语言函数里的第一个参数也是返回值。所以这个汇编函数完成的功能就是把传递进来的参数加上5再把这个结果作为返回值返回。
下面我们在C语言的main函数中调用addi_ins然后打印一下结果如下所示
#include "stdio.h"
int addi_ins(int x); //声明一下汇编语言中的函数addi_ins
int main()
{
int result = 0;
result = addi_ins(4); //result = 9 = 4 + 5
printf("This result is:%d\n", result);
return 0;
}
你可以用VSCode打开工程目录按下“F5”键调试一下输出的结果为9因为4+5肯定等于9。效果如下所示
上图中是程序刚刚执行完addi a0a05指令之后执行jr ra指令之前的状态。可以看到a0寄存器中的值已经变成了9这说明运算的结果是正确的。
addi_ins函数返回后输出的结果如下图所示
上图的结果已经证明了addi指令完成的功能和执行的结果符合我们的预期。
我们趁热打铁在addi.S文件中再写一个函数也就是addi_ins2函数代码如下所示
.globl addi_ins2
addi_ins2:
addi a0a0-2048 #a0 = a0-2048a0是参数又是返回值这样计算结果就返回了
jr ra #函数返回
addi_ins2函数的指令和addi_ins函数一样只不过立即数变成了负数。我们很清楚所谓减法就是加上一个负数所以通过addi_ins2函数就实现了立即数减法指令。
同样地我们在main函数中调用它代码如下所示
#include "stdio.h"
int addi_ins(int x); //声明一下汇编语言中的函数addi_ins
int addi_ins2(int x); //声明一下汇编语言中的函数addi_ins2
int main()
{
int result = 0;
result = addi_ins(4); //result = 9 = 4 + 5
printf("This result is:%d\n", result);
result = addi_ins2(2048); //result = 0 = 2048 - 2048
printf("This result is:%d\n", result);
return 0;
}
接着我们再按下“F5”键调试一下第二个printf输出的结果为0因为2048-2048 肯定等于0。如下所示
和之前一样上图中是刚刚执行完addi a0a0-2048指令之后执行jr ra指令之前的状态。这时a0寄存器中的值已经变成了0这说明运算的结果正确。
addi_ins2函数返回后输出的结果如下图所示
上图中已经证明了结果符合我们的预期用addi指令完成了立即数的减法计算。这也是RISC-V指令集中没有立即数据减法指令的原因。为了保证这一特性所有的立即数必须总是进行符号扩展这样就可以用立即数表示负数所以我们并不需要一个立即数版本的减法指令。
最后为了进一步搞清楚这条指令的机器码数据我们一起看看addi_ins函数和addi_ins2函数的二进制数据什么样。
让我们打开工程目录下的addi.bin文件如下所示
以上是四条指令数据其中两个0x00008067数据为两个函数的返回指令jr ra0x00550513它对应的汇编语句addi a0a050x80050513对应汇编语句addi a0a0-2048。
第五节课我们总体了解过RISC-V的指令格式这里我们一起来详细拆分一下addi指令的各位段的数据看看它是如何编码的。
对照上图我们可以看到一条指令数据为32位其中操作码占7位目标寄存器和或者源寄存器各占5位。通过5位二进制数正好可以编码32个通用寄存器。上图中寄存器编码对应10正好是x10也即a0寄存器立即数占12位。由于RISC-V指令总是按有符号数编码所以立即数只能表示-2048~2047的范围。
寄存器版本的加减法如何实现
立即数的加减法已经搞定了,下面我们来看看寄存器版本的加减法如何实现。
寄存器版本的加法指令的形式如下:
add rdrs1rs2
#add 加法指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2
类似立即数加法指令寄存器版本的加法指令也是两个源寄存器相加结果放在目标寄存器中代码中rd、rs1、rs2可以是任何通用寄存器计算操作也和前面addi指令一样。
还是通过写代码来做个验证我们写一个addsub.S文件并在其中用汇编写上add_ins函数 ,如下所示:
add_ins:
add a0a0a1 #a0 = a0+a1a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
a0a1是C语言函数调用的第一、二个参数如果你想不明白可以回顾一下之前我们讲的函数调用规范。
这里我们用VSCode打开工程目录按下“F5”键调试一下输出的结果为2因为1+1的结果肯定等于2。
上图展示的是执行完add a0a0a1指令之后执行jr ra指令之前的状态。这时a0寄存器中的值确实已经变成了2这说明运算的结果正确。
当add_ins函数返回后输出的结果如下图所示
这个结果证明了add指令执行的结果符合我们的预期。
我们加点速一鼓作气把减法指令也拿下。在addsub.S文件中再写一个函数也就是sub_ins函数代码如下
sub_ins:
sub a0a0a1 #a0 = a0-a1a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
这段代码就是减法指令和加法指令的模式一样除了助记符是sub实现的操作是a0 = a0 - a1。sub指令后的目标寄存器、源寄存器可以是任何通用寄存器。-
我们按下“F5”键调试一下其结果应为1如下所示
上图中依然是执行完sub a0a0a1指令之后执行jr ra指令之前的状态。这时a0寄存器中的值确实已经变成1了证明运算结果没问题。
当sub_ins函数返回后就会输出下图所示的结果。
经过调试sub指令执行的结果也符合我们的预期了。
下面我们继续研究机器编码来看看add_ins函数和sub_ins函数的二进制数据。打开工程目录下的addsub.bin文件如下所示
以上4个32位数据是四条指令其中两个0x00008067数据是两个函数的返回指令即jr ra0x00b50533为add a0a0a10x40b50533为sub a0a0a1。
我们还是来拆分一下add、sub指令的各位段的数据看看它们是如何编码的。如下所示
从图里可以看到操作码占了7位目标寄存器和两个源寄存器它们各占5位。目标寄存器和源寄存器编码对应10正好是x10即a0寄存器。而源寄存器2编码对应11正好是x11也即是a1。其它位段为功能编码add、sub指令就是用高段的功能码区分的。
比较指令
加减指令我们就讲到这里,不过光能计算加减还不够,接下来我们看看比较指令。现在大多数处理器都会包含数据比较指令,用于判断数值大小,以便做进一步的处理。
有无符号立即数版本slti、sltiu指令
RISC-V指令集中有四条比较指令这四条又分为有无符号立即数版本和有无符号寄存器版本分别是slti、sltiu、slt、sltu。
slti、sltiu指令的形式如下所示
slti rdrs1imm
#slti 有符号立即数比较指令
#rd 目标寄存器
#rs1 源寄存器1有符号数据
#imm 有符号立即数(-2048~2047)
sltiu rdrs1imm
#sltiu 无符号立即数比较指令
#rd 目标寄存器
#rs1 源寄存器1无符号数据
#imm 有符号立即数(-2048~2047)
上述代码中rd、rs1可以是任何通用寄存器。有、无符号是指rs1寄存器中的数据有符号立即数imm的数值范围是-2048~2047。
slti、sltiu完成的操作用伪代码描述如下
if(rs1 < imm)
rd = 1;
else
rd = 0;
下一步又到了写代码验证的环节。我们建立一个slti.S文件在其中用汇编写上slti_ins、sltiu_ins函数然后写下这两个函数
.global slti_ins
slti_ins:
slti a0, a0, -2048 #if(a0<-2048) a0=1 else a0=0a0是参数又是返回值这样计算结果就返回了
jr ra #函数返回
.global sltiu_ins
sltiu_ins:
sltiu a0a02047 #if(a0<2047) a0=1 else a0=0a0是参数又是返回值这样计算结果就返回了
jr ra #函数返回
slti_ins与sltiu_ins函数我已经帮你写好了分别执行了slti和sltiu指令都是拿a0寄存器和一个立即数比较如果a0小于立即数就把1写入a0寄存器。
下面我们在C语言的main函数中调用它然后打印一下结果用VSCode打开工程按“F5”调试后的效果如图
上图中是执行完slti a0a0-2048指令之后执行jr ra指令之前的状态。如果看到a0寄存器中的值确实已经变成1了就说明运算的结果是正确的。
当slti_ins函数返回后输出的结果如下所示
因为-2049比-2048确实要小所以返回1这证明结果是正确的。
sltiu_ins函数的调试方法也差不多。你不妨对照后面的图看一下。
上图中依然是执行完sltiu a0a02047指令之后执行jr ra指令之前的状态我们已经看到a0寄存器中的值变成0了这说明a0的数据不小于2047。
当sltiu_ins函数返回后输出的结果如下
图里输出的结果0这和执行完sltiu指令后a0的值是一致的。看到这可能你就有疑问了传递的参数是-2048它应该远小于2047为什么输出结果不是1呢
别忘了sltiu指令的属性它是无符号的比较指令也就是说sltiu指令看到的数据是无符号的
而-2048数据编码为0xfffff800如果把这个数据当成无符号数则远大于2047所以返回0。
有无符号寄存器版本slt、sltu指令
接着我们再来看看slt、sltu指令这是寄存器与寄存器的有无符号比较指令它们的形式如下所示。
slt rdrs1rs2
#slt 有符号比较指令
#rd 目标寄存器
#rs1 源寄存器1有符号数据
#rs2 源寄存器2有符号数据
sltu rdrs1rs2
#sltu 无符号比较指令
#rd 目标寄存器
#rs1 源寄存器1无符号数据
#rs2 源寄存器2无符号数据
上述代码中rd、rs1、rs2可以是任何通用寄存器。有、无符号同样代表rs1、rs2寄存器中的数据。
我们先看看slt、sltu这两个指令完成的操作用伪代码怎么描述
if(rs1 < rs2)
rd = 1;
else
rd = 0;
我们依然在slti.S文件中用汇编写上slt_ins、sltu_ins函数 ,如下所示:
.globl slt_ins
slt_ins:
slt a0, a0, a1 #if(a0<a1) a0=1 else a0=0a0a1是参数a0是返回值这样计算结果就返回了
jr ra #函数返回
.globl sltu_ins
sltu_ins:
sltu a0, a0, a1 #if(a0<a1) a0=1 else a0=0a0a1是参数a0是返回值这样计算结果就返回了
jr ra #函数返回
这里已经写好了slt_ins与sltu_ins函数分别是执行slt和sltu指令都是拿a0寄存器和a1寄存器比较如果a0小于a1寄存器就把1写入到a0寄存器否则写入0到a0寄存器。
接下来的调试环节你应该很熟悉了。VSCode当中按F5调试的效果如下
上图中是执行完slt a0a0a1指令之后执行jr ra指令之前的状态。对照截图可以看到执行指令之后a0寄存器中的值确实已经变成1了这说明比较运算的结果是正确的。
当slt_ins函数返回后输出的结果如下
因为1确实小于2所以结果返回1通过调试表明运算结果是正确的。
sltu_ins函数的调试我们也如法炮制。
上图是执行完sltu a0a0a1指令之后执行jr ra指令之前的状态。如果我们看到a0寄存器中的值变成0就说明a0的数据不小于a1。
当sltu_ins函数返回后输出的结果如下
是不是有点困惑结果是0可是传递的参数是-2和1-2应该小于1啊出现这个结果是因为sltu指令所看到a0中的数据-2是无符号的。而-2的数据编码为0xfffffffe由于它是无符号数所以远大于1返回0才是正确的。
调试工作告一段落接下来我们再研究一下slti_ins、sltiu_ins、slt_ins、sltu_ins函数的二进制数据。打开工程目录下slti.bin文件如下所示
以上8个32位数据是八条指令其中四个0x00008067数据是四个函数的返回指令即jr ra0x80052513为slti a0a0-20480x7ff53513为sltiu a0a020470x00b52533为slt a0a0a10x00b53533为sltu a0a0a1。
同样地我们也来拆分一下slti、sltiu、slt、sltu指令的各位段的数据看看它们是如何编码的。
从上图可以发现,立即数版本和寄存器版本的指令格式不一样,操作码也不一样,而它们之间的有无符号是靠功能位段来区分的,而立即数位段和源寄存器与目标寄存器位段,和之前的指令是相同的。
到这里,四条比较指令我们就全部讲完了。建议你自己课后跟着课程练练手,加深印象。
重点回顾
这节课我们一起学习了加减指令和比较指令,让我们一起来回顾一下。
加减指令是CPU里最基本的指令。addi、add、sub这三条指令能对数据和寄存器进行加减运算可以先把数据装入寄存器中然后对寄存器与寄存器执行加减操作也可以寄存器和立即数进行加减操作。
接着我们还学习了比较指令比较指令能对数据进行比较操作一共包括四条指令。按照有无符号立即数版本和有无符号寄存器版本划分分别是slti、sltiu、slt、sltu。这个有无符号是对应操作数中的寄存器的数据立即数永远是有符号数据。
加减指令主要用于加减法运算比较指令用于对数据比较判断数值大小再结合后面要学的跳转指令就可以实现if-else语句了。
下节课我们继续学习逻辑指令和移位指令,敬请期待。
思考题
请写出机器码0x00000033对应的指令。
欢迎把你的思考和想法分享在留言区。如果这节课对你有帮助,别忘了分享给身边的朋友,邀他一起学习。

View File

@ -0,0 +1,478 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 RISC-V指令精讲算术指令实现与调试
你好我是LMOS。
上节课我们学习了算术指令中的加减指令和比较指令。不过一个CPU只能实现这两类指令还不够。如果你学过C语言应该对“<<、>>、&、|、!”这些运算符并不陌生这些运算符都需要CPU提供逻辑和移位指令才可以实现。
今天我们就继续学习逻辑指令and、or、xor和移位指令 sll、srl、sra。代码你可以从这里下载。话不多说我们开始吧。
逻辑指令
从CPU芯片电路角度来看其实CPU更擅长执行逻辑操作如与、或、异或。至于为什么你可以看看CPU的基础门电路。
RISC-V指令集中包含了三种逻辑指令这些指令又分为立即数版本和寄存器版本分别是andi、and、ori、or、xori、xor这六条指令。我们学习这些指令的方法和上节课类似也涉及到写代码验证调试的部分。
按位与操作andi、and指令
首先我们来学习一下andi、and指令它们的形式如下所示
andi rdrs1imm
#andi 立即数按位与指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
and rdrs1rs2
#and 寄存器按位与指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2
上述代码中rd、rs1、rs2可以是任何通用寄存器imm是立即数。
andi、and这两个指令完成的操作我们用伪代码描述如下
//andi
rd = rs1 & imm
//and
rd = rs1 & rs2
按位与的操作就是把rs1与imm或者rs1与rs2其中的每个数据位两两相与。两个位都是1结果为1否则结果为0。
下面我们在工程目录下建立一个and.S文件写代码验证一下这两个指令如下所示
.globl andi_ins
andi_ins:
andi a0a00xff #a0 = a0&0xffa0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl and_ins
and_ins:
and a0a0a1 #a0 = a0&a1a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
这里我们已经写好了andi_ins与and_ins函数分别去执行andi和and指令。
andi指令是拿a0寄存器和立即数0xff进行与操作。由于立即数是0xff所以总是返回a0的低8位数据and指令则是拿a0和a1寄存器进行与操作再把结果写入到a0寄存器。
下面我们用VSCode打开工程按下“F5”调试一下如下所示
上图中是执行完andi a0a00xff指令之后执行jr ra指令之前的状态。可以看到a0寄存器中的值确实已经变成2了这说明运算的结果是符合预期的。
andi_ins函数返回后输出的结果如下图所示
因为2的二进制数据是0b00000000000000000000000000000010与上0xff的二进制数据是0b00000000000000000000000011111111结果确实是2所以返回2结果是正确的。
接下来我们对and_ins函数进行调试。
上图展示的是执行完and a0a0a1指令之后执行jr ra指令之前的状态。我们看到a0寄存器中的值已经变成了1这说明运算的结果是正确的。
and_ins函数返回后输出的结果如下图所示
上图中因为1的二进制数据是0b00000000000000000000000000000001与上1的二进制数据是0b00000000000000000000000000000001确实是1所以返回1结果完全正确。
按位或操作ori、or指令
按位与操作说完了我们接着来学习一下或指令ori、or它们的形式如下
ori rdrs1imm
#ori 立即数按位或指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
or rdrs1rs2
#or 寄存器按位或指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2
同样地上述代码中rd、rs1、rs2可以是任何通用寄存器imm表示立即数。
我们还是从伪代码的描述入手看看ori、or完成的操作。
//ori
rd = rs1 | imm
//or
rd = rs1 | rs2
按位或的操作就是把rs1与imm或者rs1与rs2其中的每个数据位两两相或两个位有一位为1结果为1否则结果为0。
我们在and.S文件中写写代码做个验证如下所示
.globl ori_ins
ori_ins:
ori a0a00 #a0 = a0|0a0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl or_ins
or_ins:
or a0a0a1 #a0 = a0|a1a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
上述代码中ori_ins与or_ins函数分别执行了ori和or指令。
ori指令是拿a0寄存器和立即数0进行或操作由于立即数是0所以总是返回a0原本的数据or指令是拿a0和a1寄存器进行或操作再把结果写入到a0寄存器。
我们还是到VSCode里按下“F5”调试一下如下所示
上图中是执行完ori a0a00指令之后执行jr ra指令之前的状态。如果a0寄存器中的值确实已经变成0xf0f0了就说明运算的结果正确。
ori_ins函数返回后输出的结果如下图所示
因为0xf0f0的二进制数据是0b00000000000000001111000011110000或上0的二进制数据是0b00000000000000000000000000000000按位或操作是“有1为1”所以返回0xf0f0结果是正确的。
我们再用同样的方法调试一下or_ins函数如下图所示
上图展示的是执行完or a0a0a1指令之后执行jr ra指令之前的状态。如果我们看到a0寄存器中的值确实已经变成0x1111了就说明运算的结果正确符合预期。
or_ins函数返回后输出的结果如下
上图中or_ins函数第一个参数为0x1000的二进制数据是0b00000000000000000001000000000000第二个参数为0x1111的二进制数据是0b00000000000000000001000100010001两个参数相或而按位或操作是“有1为1”所以返回0x1111结果是正确的。
按位异或操作xori、xor指令
最后我们再说说逻辑指令中的最后两条指令xori、xor即异或指令的立即数版本和寄存器版本它们的形式如下所示
xori rdrs1imm
#xori 立即数按位异或指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
xor rdrs1rs2
#xor 寄存器按位异或指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2
形式上和前面与操作、或操作差不多,就不过多重复了。
xori、xor完成的操作用伪代码描述如下
//xori
rd = rs1 ^ imm
//xor
rd = rs1 ^ rs2
按位异或的操作是把rs1与imm或者rs1与rs2其中的每个数据位两两相异或两个位如果不相同结果为1。如果两个位相同结果为0。
在and.S文件中写代码验证一下如下所示。
.globl xori_ins
xori_ins:
xori a0a00 #a0 = a0^0a0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl xor_ins
xor_ins:
xor a0a0a1 #a0 = a0^a1a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
我们已经写好了xori_ins与xor_ins函数分别是执行xori和xor指令。xori指令是拿a0寄存器和立即数0进行异或操作由于立即数是0而且各个数据位相同为0不同为1所以同样会返回a0原本的数据 而xor指令是拿a0和a1寄存器进行或操作再把结果写入到a0寄存器。
下面我们按下“F5”调试一下如下所示
上图中是执行完xori a0a00指令之后执行jr ra指令之前的状态我们已经看到a0寄存器中的值已经变成0xff了这说明运算的结果正确。
xori_ins函数返回后输出的结果如下图所示
结合上面这张截图不难发现我们传递给xori_ins函数的参数是0xff因为0xff的二进制数据是0b00000000000000000000000011111111异或上0的二进制数据是0b00000000000000000000000000000000按位异或操作是“相同为0不同为1”所以返回0xff结果是正确的。
我们再来调试一下xor_ins函数。xor a0a0a1指令执行完成之后执行jr ra指令之前的状态如图所示
我们看到a0寄存器中的值已经变成0了这说明运算的结果正确符合预期。
xor_ins函数返回后输出的结果如下图所示
由于我们给xor_ins函数传递了两个相同的参数都是0xffff。因为0xffff的二进制数据是0b00000000000000001111111111111111两者异或按位异或操作是“相同为0不同为1”所以返回0结果是正确的。
下面我们看一下andi、and、ori、or、xori、xor这六条指令的二进制数据。
我们打开工程目录下的and.bin文件如下所示
上述图中的12个32位数据是12条指令其中六个0x00008067数据是六个函数的返回指令。
具体的指令形式,还有对应的汇编语句,我用表格帮你做了整理。
同样地我带你拆分一下andi、and、ori、or、xori、xor指令的各位段的数据看看它们是如何编码的。
从上图中可以发现立即数版本和寄存器版本的and、or、xor指令通过操作码区分而它们之间的寄存器和立即数版本是靠功能位段来区分立即数位段和源寄存器与目标寄存器位段和之前的指令是相同的。
到这里六条逻辑指令已经拿下了,咱们继续学习移位指令。
移位指令
移位指令和逻辑操作指令一样都是CPU电路很容易就能实现的。
RISC-V指令集中的移位指令包括逻辑左移、逻辑右移和算术右移它们分别有立即数和寄存器版本所以一共有六条。逻辑右移和算术右移是不同的等我们后面用到时再专门讲解。
逻辑左移指令slli、sll指令
我们先看看逻辑左移指令也就是slli、sll指令它们的形式如下所示
slli rdrs1imm
#slli 立即数逻辑左移指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数rs1左移的位数0~31
sll rdrs1rs2
#sll 寄存器逻辑左移指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2rs1左移的位数
上述代码中rd、rs1、rs2可以是任何通用寄存器。imm是立即数其实在官方文档中这里是shamt表示rs1 左移 shamt 位。这里我为了和之前的形式保持一致才继续沿用了imm。
slli、sll它们俩完成的操作用伪代码描述如下
//slli
rd = rs1 << imm
//sll
rd = rs1 << rs2
逻辑左移的操作是把rs1中的数据向左移动imm位或者把rs1中的数据向左移动rs2位右边多出的空位填 0 并写入 rd 中。
我们用图解来表达这一过程,这样你就能一目了然了。
接下来我们在工程目录下建立一个sll.S文件写代码验证一下如下所示
.globl slli_ins
slli_ins:
slli a0, a0, 4 #a0 = a0<<4a0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl sll_ins
sll_ins:
sll a0, a0, a1 #a0 = a0<<a1a0a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
这里已经写好了slli_ins与sll_ins函数它们会分别执行slli和sll指令。立即数逻辑左移slli指令是把a0中的数据左移4位。而逻辑左移sll指令是把a0中的数据左移左移多少位要取决于a1中的数据完成移动后再把结果写入到a0寄存器。
我们还是用VSCode打开工程按下“F5”调试如下所示
上图中是进入slli_ins函数执行完slli a0a04指令之后执行jr ra指令之前的状态我们给slli_ins函数传进来的参数是0xffff。现在对照图示就能看到a0寄存器中的值确实已经变成0xffff0了这说明运算结果是正确的。
slli_ins函数返回后输出的结果如下
因为0xffff二进制数据是0b00000000000000001111111111111111逻辑左移4位后的结果是0xffff0它的二进制数据是0b00000000000011111111111111110000结果正确无误。
下面我们接着对sll_ins函数进行调试如下所示
上图中是进入sll_ins函数执行完sll a0a0a1指令之后执行jr ra指令之前的状态我们给sll_ins函数传进来的参数是0xeeeeeeee和31a1寄存器。如果看到a0寄存器中的值确实已经变成0了这说明运算结果是正确的。
sll_ins函数返回后输出的结果如下图所示
第一个参数0xeeeeeeee的二进制数据是0b11101110111011101110111011101110逻辑左移31位后的结果是0因为它把所有的二进制数据位都移出去了然后空位补0所以结果正确无误。
逻辑右移指令srli、srl
有逻辑左移就有逻辑右移。逻辑右移指令srli、srl分别对应着立即数和寄存器版本它们的形式如下
srli rdrs1imm
#srli 立即数逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数rs1右移的位数0~31
srl rdrs1rs2
#srl 寄存器逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2rs1右移的位数
上述代码中rd、rs1、rs2可以是任何通用寄存器。imm是立即数。为了和之前的形式保持一致我们还是沿用imm而非官方文档中的shamt。
srli、srl完成的操作可以用后面的伪代码来描述
//srli
rd = rs1 >> imm
//srl
rd = rs1 >> rs2
逻辑右移的操作是把rs1中的数据向右移动imm位。或者把rs1中的数据向右移动rs2位左边多出的空位填 0 并写入 rd 中。
你可以对照我画的图示来理解这一过程。
光看看格式自然不够我们在sll.S文件中写段代码来验证一下如下所示
.globl srli_ins
srli_ins:
srli a0, a0, 8 #a0 = a0>>8,a0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl srl_ins
srl_ins:
srl a0, a0, a1 #a0 = a0>>a1,a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
逻辑右移的两个函数srli_ins与srl_ins我已经帮你写好了。代码中立即数逻辑右移srli指令是把a0中的数据右移8位。逻辑右移srl指令则是把a0中的数据右移右移多少位要看a1中数据表示的位数是多少再把结果写入到a0寄存器。-
两条右移指令做了哪些事儿咱们说完了老规矩打开工程按下“F5”就可以调试了效果如图
上图中是进入srli_ins函数执行完srli a0a08指令之后执行jr ra指令之前的状态我们给srli_ins函数传进来的参数是0xffff。现在对照截图可以看到a0寄存器中的值确实已经变成0xff了这说明运算结果正确。
srli_ins函数返回后输出的结果如下图所示
因为调用函数srli_ins的参数0xffff的二进制数据是0b00000000000000001111111111111111逻辑右移8位后的结果是0xff它的二进制数据是0b00000000000000000000000011111111结果正确符合我们的预期。
拿下了srli_ins函数接下来就是srl_ins函数的调试如下所示
上图中是调用进入srl_ins函数执行完srl a0a0a1指令之后执行jr ra指令之前的状态给srl_ins函数传进来的参数是0xaaaaaaaa。可以看到a0寄存器中的值确实已经变成0xaaaa了所以运算结果也是正确的。
srl_ins函数返回后输出的结果如下图所示
给srl_ins函数传进来的第一个参数是0xaaaaaaaa的二进制数据是0b10101010101010101010101010101010逻辑右移16位后的结果是0xaaaa其二进制数据为0b00000000000000001010101010101010 因为它把低16位二进制数据位移出去了然后高16位的空位补0所以结果正确无误。
算术右移指令srai、sra
最后还有两个算术右移指令它们和逻辑右移的最大区别是数据在逻辑右移之后左边多出空位用0填充而数据在算术右移之后左边多出的空位是用数据的符号位填充。如果数据的符号位为1就填充1如果为0就填充0。
它们的形式和伪代码与逻辑右移是一样的只不过指令助记符由srli、srl变成了srai、sra。
下面我们直接在sll.S文件中写代码进行验证。
.globl srai_ins
srai_ins:
srai a0, a0, 8 #a0 = a0>>8,a0是C语言调用者传递的参数a0也是返回值这样计算结果就返回了
jr ra #函数返回
.globl sra_ins
sra_ins:
sra a0, a0, a1 #a0 = a0>>a1,a0、a1是C语言调用者传递的参数a0是返回值这样计算结果就返回了
jr ra #函数返回
上述代码中的两个函数srai_ins与sra_ins可以实现算术右移。先看立即数算术右移srai指令它把a0中的数据右移了8位。而算术右移srl指令是把a0中的数据右移右移多少位由a1中的数据表示的位数来决定之后再把结果写入到a0寄存器。
我们按下“F5”调试的结果如下
上图中是进入立即数算术右移函数srai_ins执行完srai a0a08指令之后执行jr ra指令之前的状态。对照图里红框的内容可以看到给srai_ins函数传进来的参数是0x1111。如果a0寄存器中的值确实已经变成0x11了就代表运算结果正确。
srai_ins函数返回后输出的结果如下
因为我们给立即数算术右移函数srai_ins的参数0x1111其二进制数据是0b00000000000000000001000100010001符号位为0所以算术右移8位后的结果是0x11它的二进制数据是0b00000000000000000000000000010001结果非常正确。
我们接着调试一下sra_ins函数如下所示
上图中是进入算术右移函数sra_ins执行完sra a0a0a1指令之后执行jr ra指令之前的状态。对照图里左侧红框的部分我们就能知道sra_ins函数传进来的参数是0xaaaaaaaa你可能判断a0寄存器里输出的结果应该是0x0000aaaa但调试显示的实际结果却是0xffffaaaa。
出现这个结果你很奇怪是不是但这恰恰说明运算结果是正确的。我们先看看sra_ins函数返回后输出的结果是什么然后再分析原因。
因为我们给算术右移函数sra_ins的参数是0xaaaaaaaa和16这表明对0xaaaaaaaa算术右移160xaaaaaaaa的二进制数据是0b10101010101010101010101010101010注意其符号位为1所以算术右移16位后的结果是0xffffaaaa它的二进制数据是0b11111111111111111010101010101010结果是符合预期的。输出的结果也证实了这一点。
下面我们还是要看一下slli、sll、srli、srl、srai、sra这六条指令的二进制数据我们打开工程目录下的sll.bin文件。
可以看出图中的12个32位数据是12条指令其中六个0x00008067数据是六个函数的返回指令。具体的指令形式还有对应的汇编语句你可以参考后面的表格。
我们拆分一下slli、sll、srli、srl、srai、sra指令的各位段的数据看看它们是在内存中如何编码的你可以结合示意图来理解。
我虽然给你详细展示了这些指令如何编码,但并不需要你把细节全部硬记下来,重点是观察其中的规律。
从上图中我们可以发现sll、srl、sra指令的立即数版本和寄存器版本要通过操作码区分而它们之间是靠功能位段来区分的源寄存器与目标寄存器所在的位段和之前的指令是相同的。需要注意的是这些立即数版本的立即数位段在官方文档中叫shamt位段并且只占5位而其它指令的立即数占12位这里为了一致性还是沿用立即数。
到这里,六条移位指令我们就讲完了。
重点回顾
今天我们学习了逻辑指令和移位指令。
逻辑操作的指令包括andi、ori、or、xori、xor分别能对寄存器与寄存器、寄存器与立即数进行与、或、异或操作。有了这些操作CPU才能对数据进行逻辑运算在一些情况下还能提升CPU的执行性能。更多的应用后面课程里我们还会继续学习。
数据移位指令包括slli、sll、srli、srl、srai、sra也能分别能对寄存器与寄存器、寄存器与立即数进行逻辑左移、逻辑右移、算术右移操作。这些指令与逻辑指令一起执行数据的位运算时相当有用在特定情况下能代替乘除法指令。
经过漫长的学习我们用两节课程的篇幅一鼓作气学习了RISC-V全部的算术指令分为加减、比较、逻辑、移位四大类别一共有19条指令。这些指令作用于数据的运算在应用程序中扮演着重要角色。
但是CPU有了这些算术指令就够了吗这显然是不行的起码还需要流程控制指令和数据加载储存指令我们会在后续课程中继续讨论。
思考题
为什么指令编码中目标寄存器源寄存器1源寄存器2占用的位宽都是5位呢
欢迎你在留言区记录自己的疑问或收获,参与越多,你对内容的理解也更深入。如果觉得这节课内容不错,别忘了分享给更多朋友。

View File

@ -0,0 +1,236 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 RISC-V指令精讲跳转指令实现与调试
你好我是LMOS。
在[第五节课]我们曾经提到RV32I有两种跳转指令即无条件跳转指令和有条件的跳转指令。
不过,前面我们只是简单了解了跳转指令长什么样,并没有深入讲解。接下来的两节课,我们就好好研究一下跳转指令的原理,挨个指令做调试。
这节课我们从源头说起,弄明白为什么需要有跳转指令存在,然后再熟悉一下无条件跳转指令。至于有条件跳转指令,我们放在下节课继续学习。这节课代码,你可以从这里下载。
为什么要有跳转指令
我们不妨回忆一下C语言中if、for、goto等流程控制语句都是如何实现的还有C语言的函数是如何调用和返回的
通过前面的学习我们了解到CPU执行指令是由PC寄存器指向的。每次执行完指令CPU的PC寄存器就会自动增加一条指令大小的数值使之指向下一条指令如此循环这就导致CPU只能在PC寄存器的引导下顺序地执行指令而C语言函数就是一条条指令组成的。显然只靠这样的机制C语言无法实现流程控制和函数的调用与返回。
如果现在有一种机制它能够修改CPU里PC寄存器的值或者根据特定的条件来修改CPU的PC寄存器的值让PC寄存器能指向特定的内存地址读取里面的指令并运行。这样上述问题就会迎刃而解了。
让我用一段C语言代码为例给你分解一下这个原理如下所示
int add()
{
int sum = 0;
for(int i = 0; i < 100; i++)
sum++;
return sum;
}
上述代码中for函数包含了条件流程控制和循环流程控制。在编译过程中C语言编译器会将它拆分为三段伪代码如下所示
add:
int sum = 0;
int i = 0;
label1:
i < 100 = false goto lable2
sum++;
i++;
goto label1
lable2:
return sum;
以上伪代码中的goto用来修改CPU的PC寄存器的值使之指向lable1或者lable2这样CPU才能执行不同的代码段从而实现流程控制。这里的goto语句就对应着后面要讲的跳转指令。
说到这里,如果你能再次想起图灵机的读头在那条无限的纸带上跳来跳去的情景,就说明你已经深刻理解了代码的执行原理。
RISC-V的跳转指令格式
前面我们说了CPU必须依赖某个机制修改PC寄存器的值让程序能够跳转执行达到程序流程控制的目的。
这个机制离不开CPU提供的跳转指令只要执行跳转指令就能修改PC寄存器了。在研究无条件跳转指令之前我们先来看看RISC-V的跳转指令格式它对应的汇编语句格式如下
指令助记符 目标寄存器源操作数1源操作数2
对于无条件跳转指令来说指令助记符可以是jal和jalr目标寄存器可以是任何通用寄存器而源操作数1可以是任何通用寄存器源操作数2可以是任何通用寄存器和立即数。
为什么是目标寄存器而不是PC寄存器呢继续往下看我会带你找到答案。
无条件跳转指令jal指令
我们先来看看jal指令这是一条无条件的跳转并链接的指令。它的汇编代码书写形式如下
jal rdimm
#jal 无条件的跳转并链接的指令
#rd 目标寄存器
#imm 立即数
上述代码中rd可以是任何通用寄存器。立即数imm为20位二进制数据。有的文档里会把imm称为偏移为了课程前后文的一致性我们继续沿用立即数的叫法。
jal完成的操作用伪代码描述如下
rd = pc + 4
pc = pc + 符号扩展imm << 1
对照代码不难发现jal指令首先把pc+4即下一条指令地址送给了rd然后把PC寄存器中的32位无符号数据加上imm<并且进行符号位扩展因为指令总是2或者4字节地址对齐的所以最低位永远为0再送给PC寄存器。这样就实现了程序的跳转。
接下来,我们一起写代码验证一下。
为了方便调试我们的代码组织结构是这样的写一个main.c文件在里面写上main函数因为这是链接器所需要的。然后我们需要再写一个jal.S文件用汇编在里面写上jal_ins函数。
类似的操作前面两节课反复试验过,就不过多重复了。代码如下所示:
.text
.global jal_ins
jal_ins:
jal a0imm_l1 #a0=add x0x0x0的地址跳转到imm_l1地址处开始运行
add x0x0x0 #相当于nop,什么也不做
add x0x0x0
imm_l1:
jal a0imm_l2 #a0=imm_l2的地址跳转到imm_l2地址处开始运行
imm_l2:
jr ra #函数返回
我已经把jal_ins函数为你写好了。第一条指令跳转到imm_l1地址处开始运行a0寄存器保存下一条指令的地址即add x0x0x0的地址。
这条指令没什么实际的实现x0是个只读寄存器始终返回0。imm_l1地址处又是一条跳转指令跳转到jr ra指令地址即imm_l2处开始运行。a0等于imm_l2的地址也会作为函数的返回值返回。
你可以用VSCode打开工程目录按下“F5”键调试一下。首先我们把断点停在jal a0imm_l1 指令处,效果如下所示:
上图中的状态是执行jal a0imm_l1指令之前pc寄存器指向0x10174地址这个地址对应的正是这条jal a0 imm_l1指令。
我们一旦单步调试程序代码就会跳到jal a0imm_l2 指令处pc + 12 等于0x10180a0等于0x10178状态如下所示
果不其然a0等于0x10178而pc等于0x10180正是jal a0imm_l2指令。
我们继续做单步调试程序代码会跳到jr ra 指令处pc + 4 等于0x10184a0也会等于0x10184存放jr ra 指令的地址而a0作为函数的返回值进行返回也就是jal_ins函数最后一条指令的地址。
我们再次进行单步调试程序将会回到main函数中并打印出返回值如下所示
可以看到上图中输出的结果确实是符合预期的这说明jal指令的功能确实跟我们前面描述的一致能够无条件跳转并链接。
无条件跳转指令jalr指令
让我们加把劲在jal.S文件中再写一个函数——jalr_ins函数。在这个函数中我们用jalr指令实现函数调用具体就是给jalr_ins函数传递一个函数指针通过这个函数指针调用这个函数。
写代码之前我们先来了解一下jalr指令它同样是一条无条件的跳转并链接的指令。jalr指令与jal指令字面上的不同点无非就是多了一个字母“r”这个“r”表示寄存器相当于jal指令的寄存器版本能够通过寄存器传递跳转地址。
jalr的汇编代码书写形式如下
jalr rdrs1imm
#jalr 无条件的跳转并链接的指令
#rd 目标寄存器
#rs 源寄存器1
#imm 立即数
上述代码中rd、rs1可以是任何通用寄存器立即数imm为12位二进制数据。jalr完成的操作用伪代码描述如下
rd = pc + 4
pc = (rs1 + 符号扩展(imm << 1)) & 0xfffffffe
对比之后我们不难发现以上代码中和jal相同的地方是开始第一步由jalr指令把pc+4即下一条指令地址送给rd。
而不同之处是jalr指令的下一步操作会让rs1中的32位无符号数据加上imm<并且进行符号位扩展后与上0xfffffffe这也是为了指令要以2字节或者4字节地址对齐所以最低位必须为0形成一个地址值。完成以上过程后这个地址值会送给pc从而实现程序的跳转。
下面我们一起写代码验证一下。
.global jalr_ins
jalr_ins:
addi spsp-16 #在栈中分配16字节的空间
sw ra0(sp) #保存ra寄存器到栈中
jalr raa00 #ra = lw ra0(sp)指令的地址跳转到a0+0的地址处开始运行
lw ra0(sp) #从栈中恢复ra寄存器
addi spsp16 #回收栈中分配的16字节的空间
jr ra #函数返回
这段代码3~4行和6~7行代码的作用是在栈中分配和回收内存空间的指令用于保存和恢复ra寄存器的内容。
因为在第5行代码中跳转别的代码中运行正是用ra寄存器来保存地址的然而在跳转到jalr_ins函数处运行的时候同样是使用ra寄存器保存返回地址的如果不保存和恢复ra寄存器jalr_ins函数将无法返回。
现在我们调试一下,如下所示:
上图中是执行jalr raa00指令之前的状态a0寄存器中的值是0x101a0这正是testjalr函数的地址。这条指令能完成类似函数调用的功能我们一旦单步调试程序就会跳到testjalr函数内部开始运行状态如下所示
由上图可知jalr_ins函数确实调用了testjalr函数也打印出了testjalr的地址。下一步将要执行testjalr函数的返回语句会返回jalr_ins函数的地址。
我们继续做单步调试看看能不能返回到jalr_ins函数中如下所示
调试结果验证了确实如此代码流程再次回到了jalr_ins函数中。在jalr_ins函数中我们恢复了之前的ra寄存器a0寄存器中保存着testjalr函数的返回值即jalr_ins函数的地址。
继续单步调试代码流程就会回到main函数。
如上图所示main函数中继续打印出了jalr_ins函数的地址这个结果是正确的代码流程也符合预期。
通过调试我们已经了解了jal、jalr指令的功能细节。
下面我们来看看jal_ins函数和jalr_ins函数的二进制数据。其实我们调试bug或者做逆向工程很多时候都需要研究机器码正好借这次研究指令的机会我们一起练习一下怎么分析。
我们打开终端切换到工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins。打开这个文件就会看到jal_ins函数和jalr_ins函数的二进制数据我的操作截图如下所示
上图中的反汇编代码中使用了一些伪指令比如ret的机器码是0x00008067它就是jr ra但是jr ra也是伪指令实际的指令是jalr x0ra0指令伪指令是为了方便汇编编程人员才使用的。
我们再来说说上图中的机器码0x0040056f为jal a0imm_l20x101840x000500e7为jalr raa00图里的jalr a0就是jalr raa00 。
我们继续拆分jal指令和jalr指令的各位段的数据看看它们具体是如何编码的。你不妨结合后面的示意图来理解
jal指令与jalr指令是靠操作码区分的。jal指令的立即数部分编码非常乱这部分跟芯片设计有关就不深入讨论了其数据正常组合起来是0b00000000000000000010这个二进制数据左移1位等于十六进制数据0x4。为什么是这样呢
回到前面看看jal指令的操作你就明白了pc+4正好是imm_l2的地址即0x10184而jalr指令编码非常简单12位立即数为0源寄存器是a0目标寄存器是rax1寄存器的编码就是1。
到这里jal指令与jalr指令我们就讲完了。它们都是无条件跳转指令并且都可以保存跳转指令的下一条指令的地址用于返回。但jal指令与jalr指令跳转的地址大小范围有差别这主要取决于它们地址数据的编码形式和计算方式。jal指令是用当前pc值加上20位立即数jalr指令是通用寄存器加上11位立即数。
重点回顾
说到这里,这节课的内容就告一段落了,我来给你做个总结。
因为不管什么程序都不能永无止境地顺序运行下去,所以需要控制程序流程,对数据进行比较判断,根据结果执行相应的动作。这就需要程序能够跳转,所以,一套指令集里就必须要有跳转指令来支持。
跳转指令又分成有条件跳转指令和无条件跳转指令。我们按照先易后难的顺序这节课重点研究了无条件的跳转指令一共是两条指令即jal指令和jalr指令。它们在跳转的同时还能保存下一条指令的地址这类指令常用来实现高级语言如C语言里的函数调用。
这节课的要点我给你准备了导图,你可以做个参考。调试验证环节,我建议你自己课后动手多多练习,加深印象。
下节课我们继续研究有条件跳转指令,敬请期待。
思考题
既然已经有jal指令了为什么还需要jalr指令呢
期待你在留言区记录收获或疑问,认真思考和主动练习都能让你加深印象。如果感觉这节课还不错,也推荐你把这节课分享给更多朋友。

View File

@ -0,0 +1,291 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 RISC-V指令精讲跳转指令实现与调试
你好我是LMOS。
前面我们学习了无条件跳转指令但是在一些代码实现里我们必须根据条件的判断状态进行跳转。比如高级语言中的if-else 语句,这是一个典型程序流程控制语句,它能根据条件状态执行不同的代码。这种语句落到指令集层,就需要有根据条件状态进行跳转的指令来支持,这类指令我们称为有条件跳转指令。
这节课我们就来学习这些有条件跳转指令。在RISC-V指令集中一共有6条有条件跳转指令分别是beq、bne、blt、bltu、bge、bgeu。
这节课的配套代码,你可以从这里下载。
比较数据是否相等beq和bne指令
我们首先来看看条件相等跳转和条件不等跳转指令即beq指令和bne指令它们的汇编代码书写形式如下所示
beq rs1rs2imm
#beq 条件相等跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
bne rs1rs2imm
#bne 条件不等跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
上述代码中rs1、rs2可以是任何通用寄存器imm是立即数也可称为偏移量占用13位二进制编码。请注意beq指令和bne指令没有目标寄存器就不会回写结果。
我们用伪代码描述一下beq指令和bne指令完成的操作。
//beq
if(rs1 == rs2) pc = pc + 符号扩展imm << 1
//bne
if(rs1 != rs2) pc = pc + 符号扩展imm << 1
你可以这样理解这两个指令。在rs1、rs2寄存器的数据相等时beq指令就会跳转到标号为imm的地方运行。而rs1、rs2寄存器的数据不相等时bne指令就会跳转到imm标号处运行。
下面我们一起写代码来验证。在工程目录下我们需要建立一个beq.S文件在文件里用汇编写上beq_ins、bne_ins函数代码如下所示
.global beq_ins
beq_ins:
beq a0a1imm_l1 #a0==a1跳转到imm_l1地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l1:
addi a0zero1 #a0=1
jr ra #函数返回
.global bne_ins
bne_ins:
bne a0a1imm_l2 #a0!=a1跳转到imm_l2地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l2:
addi a0zero1 #a0=1
jr ra #函数返回
我们先看代码里的 beq_ins函数完成了什么操作如果a0和a1相等则跳转到imm_l1处将a0置1并返回否则继续顺序执行将a0置0并返回。然后我们再看下 bne_ins函数的操作如果a0和a1不相等则跳转到imm_l2处将a0置1并返回否则继续顺序执行将a0置0并返回。
我们在main.c文件中声明一下这两个函数并调用它们然后用VSCode打开工程目录按下“F5”键来调试情况如下所示
上图是执行“beq a0a1imm_l1”指令后的状态。由于a0、a1寄存器内容不相等所以没有跳转到imm_l1处运行而是继续顺序执行beq后面的下一条指令最后返回到main函数中。
函数返回结果如下图所示:
从图里我们能看到首先会由main函数调用beq_ins函数然后调用printf输出返回的结果在终端中的输出为0。这个结果在我们的预料之中也验证了beq指令的效果和我们之前描述的一致。
下面我们继续调试就会进入bne_ins函数中如下所示
上图中是执行“bne a0a1imm_l2”指令之后的状态。同样因为a0、a1寄存器内容不相等而bne指令是不相等就跳转。这时程序会直接跳转到imm_l2处运行执行addi a0zero1指令将a0寄存器置为1后返回到main函数中如下所示
上图中第二个printf函数打印出bne_ins函数返回的结果输出为1。bne指令会因为数据相等而跳转将a0寄存器置为1导致返回值为1这个结果是正确的。
经过上面的调试验证我们不难发现其实bne是beq的相反操作作为一对指令搭配使用完成相等和不相等的流程控制。
小于则跳转blt和bltu指令
有了bqe、bne有条件跳转指令后就能实现C语言 ==和 != 的比较运算符的功能。但这还不够,除了比较数据的相等和不等,我们还希望实现比较数据的大小这个功能。
这就要说到小于则跳转的指令即blt指令与bltu指令bltu指令是blt的无符号数版本。它们的汇编代码书写形式如下
blt rs1rs2imm
#blt 条件小于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
bltu rs1rs2imm
#bltu 无符号数条件小于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
和bqe、bne指令一样上述代码中rs1、rs2可以是任何通用寄存器imm是立即数也可称为偏移量占用13位二进制编码它们同样没有目标寄存器不会回写结果。
blt指令和bltu指令所完成的操作可以用后面的伪代码描述
//blt
if(rs1 < rs2) pc = pc + 符号扩展imm << 1
//bltu
if((无符号)rs1 < (无符号)rs2) pc = pc + 符号扩展imm << 1
你可以这样理解这两个指令。当rs1小于rs2时且rs1、rs2中为有符号数据blt指令就会跳转到imm标号处运行。而当rs1小于rs2时且rs1、rs2中为无符号数据bltu指令就会跳转到imm标号处运行。
我们同样通过写代码验证一下加深理解。在beq.S文件中我们用汇编写上blt_ins、bltu_ins函数代码如下所示
.global blt_ins
blt_ins:
blt a0a1imm_l3 #a0<a1跳转到imm_l3地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l3:
addi a0zero1 #a0=1
jr ra #函数返回
.global bltu_ins
bltu_ins:
bltu a0a1imm_l4 #a0<a1跳转到imm_l4地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l4:
addi a0zero1 #a0=1
jr ra #函数返回
blt_ins函数都做了什么呢如果a0小于a1则跳转到imm_l3处将a0置1并返回否则继续顺序执行将a0置0并返回。
接着我们来看bltu_ins函数的操作如果a0中的无符号数小于a1中的无符号数程序就会跳转到imm_l4处将a0置1并返回否则继续顺序执行将a0置0并返回。
我们还是用VSCode打开工程目录按下“F5”键来调试验证。下图是执行“blt a0,a1,imm_l3”指令之后的状态。
由于a0中的有符号数小于a1中的有符号数而blt指令是小于就跳转这时程序会直接跳转到imm_l3处运行执行addi a0zero1指令将a0寄存器置为1后返回到main函数中。返回结果如下所示
对照上图可以发现main函数先调用了blt_ins函数然后调用printf在终端上打印返回的结果输出为1。这个结果同样跟我们预期的一样也验证了blt指令的功能确实是小于则跳转。
我们再接再厉继续调试进入bltu_ins函数中如下所示
图里的代码表示执行“bltu a0a1imm_l4”指令之后的状态。
由于bltu把a0、a1中的数据当成无符号数所以a0的数据小于a1的数据而bltu指令是小于就跳转这时程序就会跳转到imm_l4处运行执行addi a0zero1指令将a0寄存器置为1后就会返回到main函数中。
对应的跳转情况,你可以对照一下后面的截图:
我们看到上图中调用bltu_ins函数传递的参数是3和-1应该返回0才对。然而printf在终端上输出为1这个结果是不是出乎你的意料呢
我们来分析一下原因没错这是因为bltu_ins函数会把两个参数都当成无符号数据把-1当成无符号数是0xffffffff远大于3。所以这里返回1反而是bltu指令正确的运算结果。
大于等于则跳转bge和bgeu指令
有了小于则跳转的指令我们还是需要大于等于则跳转的指令这样才可以在C语言中写出类似”a >= b”这种表达式。在RISC-V指令中为我们提供了bge、bgeu指令它们分别是有符号数大于等于则跳转的指令和无符号数大于等于则跳转的指令。
这是最后两条有条件跳转指令,它们的汇编代码形式如下:
bge rs1rs2imm
#bge 条件大于等于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
bgeu rs1rs2imm
#bgeu 无符号数条件大于等于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
代码规范和前面四条指令都相同,这里不再重复。
下面我们用伪代码描述一下bge、bgeu指令如下所示
//bge
if(rs1 >= rs2) pc = pc + 符号扩展imm << 1
//bgeu
if((无符号)rs1 >= (无符号)rs2) pc = pc + 符号扩展imm << 1
我们看完伪代码就能大致理解这两个指令的操作了。当rs1大于等于rs2且rs1、rs2中为有符号数据时bge指令就会跳转到imm标号处运行。而当rs1大于等于rs2时且rs1、rs2中为无符号数据bgeu指令就会跳转到imm标号处运行。
我们继续在beq.S文件中用汇编写上bge_ins、bgeu_ins函数进行调试验证代码如下所示
.global bge_ins
bge_ins:
bge a0a1imm_l5 #a0>=a1跳转到imm_l5地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l5:
addi a0zero1 #a0=1
jr ra #函数返回
.global bgeu_ins
bgeu_ins:
bgeu a0a1imm_l6 #a0>=a1跳转到imm_l6地址处开始运行
mv a0zero #a0=0
jr ra #函数返回
imm_l6:
addi a0zero1 #a0=1
jr ra #函数返回
结合上面的代码我们依次来看看bge_ins函数和bgeu_ins函数都做了什么。先看bge_ins函数如果a0大于等于a1则跳转到imm_l5处将a0置1并返回否则就会继续顺序执行将a0置0并返回。
而bgeu_ins函数也类似如果a0中无符号数大于等于a1中的无符号数则跳转到imm_l6处将a0置1并返回否则继续顺序执行将a0置0并返回。
我们用VSCode打开工程目录按“F5”键调试情况如下
上图中是执行“bge a0a1imm_l5”指令之后的状态由于a0中的有符号数大于等于a1中的有符号数。而bge指令是大于等于就跳转所以这时程序将会直接跳转到imm_l5处运行。执行addi a0zero1指令将a0寄存器置为1后就会返回到main函数中。
对照下图可以看到调用bge_ins(4,4)函数后之后就是调用printf在终端上打印其返回结果输出为1。
因为两个数相等所以返回1这个结果正确也验证了bge指令的功能确实是大于等于则跳转。
下面我们继续调试就会进入bgeu_ins函数之中如下所示
上图中是执行“bgeu a0a1imm_l6”指令之后的状态。
由于bgeu把a0、a1中的数据当成无符号数所以a0的数据小于a1的数据。而bgeu指令是大于等于就跳转这时程序就会就会顺序运行bgeu后面的指令“mv a0zero”将a0寄存器置为0后返回到main函数中。
可以看到意料外的结果再次出现了。你可能疑惑下图里调用bgeu_ins函数传递的参数是3和-1应该返回1才对然而printf在终端上的输出却是0。
出现这样的情况跟前面bltu_ins函数情况类似bgeu_ins函数会把两个参数都当成无符号数据把-1当成无符号数是0xffffffff3远小于0xffffffff所以才会返回0。也就是说图里的结果恰好验证了bgeu指令是正确的。
到这里我们已经完成了对beq、bne、blt、bltu、bge、bgeu指令的调试熟悉了它们的功能细节现在我们继续一起看看beq_ins、bne_ins、blt_ins、bltu_ins、bge_ins、bgeu_ins函数的二进制数据。
沿用之前查看jal_ins、jalr_ins函数的方法我们将main.elf文件反汇编成main.ins文件然后打开这个文件就会看到这些函数的二进制数据如下所示
上图里的反汇编代码中使用了一些伪指令,它们的机器码以及对应的汇编语句、指令类型,我画了张表格来梳理。
-
有了这些机器码数据,我们同样来拆分一下这些指令各位段的数据,在内存里它们是这样编码的:
看完图片我们可以发现bqe、bne、blt、bltu、bge、bgeu指令的操作码是相同的区分指令的是功能码。
这些指令的立即数都是相同的这和我们编写的代码有关其数据正常组合起来是0b00000000110这个二进制数据左移1位等于十六进制数据0xc。看看那些bxxx_ins函数代码你就明白了bxxx指令和imm_lxxx标号之间包含标号正好间隔3条一条指令4字节其偏移量正好是12pc+12正好落在imm_lxxx标号处的指令上。
重点回顾
这节课就要结束了,我们做个总结。
RISC-V指令集中的有条件跳转指令一共六条它们分别是beq、bne、blt、bltu、bge、bgeu。
bne和beq指令用于比较数据是否相等它们是一对相反的指令操作搭配使用就能完成相等和不相等的流程控制。blt、bltu是小于则跳转的指令bge、bgeu是大于等于则跳转的指令区别在于有无符号数。这六条跳转指令的共性是都会先比较两个源操作数然后根据比较结果跳转到具体的偏移地址去运行。
这节课的要点我给你准备了导图,供你参考复习。
到这里我们用两节课的时间掌握了RISC-V指令集的八条跳转指令。正是这些“辛勤劳作”的指令CPU才获得了顺序执行之外的新技能进而让工程师在高级语言中顺利实现了函数调用和流程控制与比较表达式。
下节课我们继续挑战访存指令,敬请期待。
思考题
我们发现在RISC-V指令集中没有大于指令和小于等于指令这是为什么呢
别忘了在留言区记录收获,或者向我提问。如果觉得课程还不错,别忘了推荐给身边的朋友,跟他一起学习进步。

View File

@ -0,0 +1,486 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 RISC-V指令精讲原子指令实现与调试
你好我是LMOS。
通过前面的课程我们学过了RISC-V的各种跳转指令以及这些指令的各种变形并且了解了它们的机器编码。
今天我们开始学习RISC-V下的原子指令原子指令是RISC-V的指令扩展命名为 A。这个扩展指令中包含两部分分别是LR/SC指令和AMO指令。
我们先搞明白为什么需要原子指令什么情况用得上它们。再分别学习和对比LR/SC指令与AMO指令另外我还会让你知道这些指令各自的使用场景是什么。
课程代码你可以从这里下载。话不多说,让我们直接开始吧。
为什么需要原子指令
你对学生时代上的物理课还有什么印象么?那时候我们就接触过“原子”这个概念了。“原子”是物质的最小组成,即原子是不可分割的。虽然到现在科学家已经发现在原子内部有更小的成分,但是在广义上原子仍然保持“不可分割”的语义。
那么在芯片中的原子指令是什么呢?它延续了“不可分割”这个含义,表示该指令的执行是不可分割的,完成的操作不会被其它外部事件打断。
我们结合一段代码,来了解原子指令的具体作用和使用场景。
//全局变量A
int A = 0;
//线程A执行的函数
void thread_a()
{
A++;
printf("ThreadA A is:%d\n"A);
return;
}
//线程B执行的函数
void thread_b()
{
A++;
printf("ThreadB A is:%d\n"A);
return;
}
以上两个函数分别由不同的线程运行都是对全局变量A加1后打印出来。让我们暂停一下想想看你认为程序的打印结果是什么
也许你的判断是两种情况即输出A值1、 2A值2、2。但你把代码跑一下试试就会发现结果出乎意料。除了前面两种情况还多了一个可能A值1、1。这就很奇怪了为什么出现这种情况呢
原因便是A++不是原子指令实现的不可分割操作它可以转化为后面这样的CPU指令形式。
load regA #加载A变量到寄存器
Add reg1 #对寄存器+1
store Areg #储存寄存器到A变量
我们已经看到了A++被转换成了三条指令有可能线程A执行了上面第一行指令线程B也执行了上面第一行指令这时就会出现线程A、B都输出1的情况。其本质原因是这三条指令是独立、可分割的。
解决这个问题的方案不止一种。我们可以使用操作系统的线程同步机制让线程A和线程B串行执行即thread_a函数执行完成了再执行thread_b函数。另一种方案是使用原子指令利用原子指令来保证对变量A执行的操作也就是加载、计算、储存这三步是不可分割的即一条指令能原子地完成这三大步骤。
现实中,小到多个线程共享全局变量,大到多个程序访问同一个文件,都需要保证数据的一致性。对于变量可以使用原子指令,而文件可以利用原子指令实现文件锁,来同步各个进程对文件的读写。这就是原子指令存在的价值。
为了实现这些原子操作一款CPU在设计实现时就要考虑提供完成这些功能的指令RISC-V也不例外原子指令是现代CPU中不可或缺的一种指令除非你的CPU是单个核心没有cache且不运行操作系统。显然RISC-V架构的CPU不是那种类型的CPU。
搞清楚了为什么需要原子指令我们接下来就去看看RISC-V究竟提供了哪些原子指令
LR/SC指令
首先RISC-V提供了LR/SC指令。这虽然是两条指令但却是一对好“搭档”它们需要配合才能实现原子操作缺一不可。看到后面你就会知道这是为什么了我们先从这两条指令用在哪里说起。
在原子的比较并交换操作中常常会用到LR/SC指令这个操作在各种加锁算法中应用广泛。我们先来看看这两条指令各自执行了什么操作。
LR指令是个缩写全名是Load Reserved即保留加载而SC指令的缩写展开是Store Conditional即条件存储。
我们先来看看它们在汇编代码中的书写形式,如下所示:
lr.{w/d}.{aqrl} rd(rs1)
#lr是保留加载指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
sc.{w/d}.{aqrl} rdrs2(rs1)
#sc是条件储存指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2
上述代码中rd、rs1、rs2可以是任何通用寄存器。“{}“中的内容不是必须填写的,汇编器能根据当前的运行环境自动设置。
LR指令和SC指令完成的操作用伪代码可以这样描述
//lr指令
rd = [rs1]
reservation_set(cur_hart)
//sc指令
if (is_reserved(rs1)) {
*rs1 = rs2
rd = 0
} else
rd = 1
clean_reservation_set(cur_hart)
观察上述伪代码我们先看看LR指令做了什么rs1寄存器的数据就是内存地址指定了LR指令从哪里读取数据。LR会从该地址上加载一个32位或者64位的数据存放到rd寄存器中。这个地址需要32位或者64位对齐加载之后会设置当前CPU hartRISC-V中的核心读取该地址的保留位。
而SC指令则是先判断rs1中对应地址里的保留位reservation set有没有被设置。如果被设置了则把rs2的数据写入rs1为地址上的内存中并在rd中写入0否则将向rd中写入一个非零值这个值并不一定是1最后清除当前对应CPU hartRISC-V中的核心在该地址上设置的保留位。
从上面的描述我们发现SC指令不一定执行成功只有满足后面这四个条件它才能执行成功
LR和SC指令成对地访问相同的地址。-
LR和SC指令之间没有任何其它的写操作来自任何一个hart访问同样的地址。-
LR和SC指令之间没有任何中断与异常发生。-
LR和SC指令之间没有执行MRET指令。
而这些条件正是LR/SC指令保持原子性的关键所在。
下面我们一起写代码验证一下。为了方便调试我们的代码组织结构还是从写一个main.c文件开始然后在其中写上main函数因为这是链接器所需要的。接着我们写一个lrsc.S文件并在里面用汇编写上lrsc_ins函数这些操作在前面课程中我们已经反复做过了。
代码如下所示:
.globl lrsc_ins
#a0内存地址
#a1预期值
#a2所需值
#a0返回值如果成功则为0否则为1
lrsc_ins:
cas:
lr.w t0(a0) #加载以前的值
bne t0a1fail #不相等则跳转到fail
sc.w a0a2(a0) #尝试更新
jr ra #返回
fail:
li a01 #a0 = 1
jr ra #返回
这样lrsc_ins函数就写好了。
我结合上面的代码再带你理解一下这个函数首先通过LR指令把a0中的数据也就是地址信息加载到t0中如果t0和a1不相等则跳转到fail处将a0置1并返回否则继续顺序执行通过SC指令将a2的数据写入到a0为地址的内存中写入成功则将a0置0不成功则置为非零。SC指令执行成功与否要看是否满足上面那4个条件最后返回。
我们在main.c文件中声明一下这两个函数并调用它再用VSCode打开工程目录按下“F5”键调试一下如下所示
上图是执行“lr.w t0(a0)”指令后的状态。下一步我们将执行bne比较指令继续做两步单步调试目的是执行SC指令如下所示
上图是执行“sc.w a0a2(a0)”指令后的状态。由于SC指令执行时满足上述四大条件所以SC会把a2的内容写入a0为地址的内存中并将a0置0最后返回到main函数中如下所示
上图描述的过程是main函数调用lrsc_ins函数后然后调用printf输出返回的结果在终端中的输出为result:0val:1。这个结果在我们的预料之中也验证了LR/SC指令正如我们前面所描述的那样。
通过这种LR/SC指令的组合确实可以实现原子的比较并交换的操作在计算机行业中也称为CAS指令。这种CAS指令是实现系统中各种同步锁的基础设施这也是为什么我在写代码时同时使用lrsc_ins和cas两个标号的用意。
我们再看一个例子加深印象,代码如下所示:
int cas(int* lock, int cmp, int lockval); // 声明cas函数
int lock = 0;
//初始化锁
void LockInit(int* lock)
{
*lock = 0;
return;
}
//加锁
int Lock(int* lock)
{
int status;
status = cas(lock, 0, 1);
if(status == 0)
{
return 1;//加锁成功
}
return 0; //加锁失败
}
//解锁
int UnLock(int* lock)
{
int status;
status = cas(lock, 1, 0);
if(status == 0)
{
return 1;//解锁成功
}
return 0; //解锁失败
}
上述代码是一个加解锁的例子返回1表示加、解锁操作成功返回0表示加、解锁操作失败lock为0表示解锁状态为1则表示上锁状态。加、解锁操作最关键的点在于这个操作是原子的不能被打断而这正是LR/SC指令的作用所在。
经过刚刚的调试LR/SC指令的功能细节我们已经心中有数了。现在我们继续一起看看它的二进制数据。
打开终端切换到工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins。我们打开这个文件就会看到它们的二进制数据如下所示
我们一起看看上图中的反汇编代码这里编译器为了节约内存使用了一些压缩指令也就是RISC-V的C类扩展指令。
比如ret的机器码是0x8082li a01的机器码为0x4505它们只占用16位编码即二字节。
上图机器码与汇编语句的对应关系如下表所示:
让我们继续一起来拆分一下LR、SC指令的各位段的数据看看它是如何编码的。对照后面的示意图你更容易理解
LR/SC指令的操作码和功能码都是相同的它们俩是靠27位~31位来区分的。其它的寄存器位段在前面的课程中已经介绍得相当详细了而aq-rl位段是用来设置计算储存顺序的使用默认的就行这里我们就不深入研究了。
AMO指令
前面我们通过例子演示了LR/SC指令如何实现锁的功能。基于此我们给操作对象加锁就能执行更多逻辑上的“原子”操作。但这方式也存在问题实现起来很复杂对于单体变量使用这种方式代价很大。
因此AMO类的指令应运而生。这也是一类原子指令它们相比LR/SC指令用起来更方便。因为也属于原子指令所以每个指令完成的操作同样是不可分割不能被外部事件打断的。
AMO 是 Atomic Memory Operation 的缩写即原子内存操作。AMO 指令又分为几类,分别是原子交换指令、原子加法指令、原子逻辑指令和原子取大小值指令。
大部分调试指令的操作,我们都在前几节课里学过了,这里我们不再深入调试,只是用这些指令来写一些可执行的代码,方便我们了解其原理就行了。调试过程和前面的一样。你自己有兴趣可以自己动手调试。
首先我们来看看原子交换指令,它能执行寄存器和内存中的数据交换,并保证该操作的原子性,其汇编代码形式如下所示:
amoswap.{w/d}.{aqrl} rd,rs2,(rs1)
#amoswap是原子交换指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2
上述代码中rd、rs1、rs2可以是任何通用寄存器。“{}“中的可以不必填写,汇编器能根据当前的运行环境自动设置。
我们用伪代码来描述一下amoswap指令完成的操作你会看得更清楚。
//amoswap
rd = *rs1
*rs1 = rs2
观察上述伪代码amoswap指令是把rs1中的数据当成内存地址加载了该地址上一个32位或者64位的数据到rd寄存器中。然后把rs2中的数据写入到rs1指向的内存单元中实现rs2与内存单元的数据交换该地址需要32位或者64位对齐。这两步操作是原子的、不可分割的。
下面我们在工程目录中建立一个amo.S文件并在其中用汇编写上amoswap_ins函数代码如下所示
.globl amoswap_ins
#a0内存地址
#a1将要交换的值
#a0返回值
amoswap_ins:
amoswap.w a0, a1, (a0) #原子交换a0=[a0]=a1
jr ra #返回
我们直接看代码里的amoswap_ins函数其中amoswap指令的作用是把a0地址处的内存值读取到a0中然后把a1的值写入a0中的地址处的内存中完成了原子交换操作。你可以自己进入工程调试一下。
接着我们来看看原子加法指令,这类指令能把寄存器和内存中的数据相加,并把相加结果写到内存里,然后返回内存原有的值。原子加法指令的汇编代码形式如下所示。
amoadd.{w/d}.{aqrl} rd,rs2,(rs1)
#amoadd是原子加法指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2
上述代码中除了指令符和原子交换指令不同其它都是一样的amoadd指令完成的操作用伪代码描述如下
//amoadd
rd = *rs1
*rs1 = *rs1 + rs2
我们观察一下amoadd指令都做了什么。它把rs1中的数据当成了内存地址先把该地址上一个32位或者64位的数据读到rd寄存器中。然后把rs2的数据与rs1指向的内存单元里的数据相加结果写入到该地址的内存单元中该地址仍需要32位或者64位对齐。这两步操作是不可分割的。
下面我们在amo.S文件中用汇编写上amoadd_ins函数代码如下
.globl amoadd_ins
#a0内存地址
#a1相加的值
#a0返回值
amoadd_ins:
amoadd.w a0, a1, (a0) #原子相加a0=[a0] [a0]=[a0] + a1
jr ra #返回
上述代码中amoadd_ins函数中的amoadd指令把a0中的地址处的内存值读取到a0中然后把a1的值与a0中的地址处的内存中的数据相加结果写入该地址的内存单元中这操作是原子执行的完成了原子加法操作。指令的调试你可以课后自己练一练。
我们继续研究原子逻辑操作指令,一共有三条,分别是原子与、原子或、原子异或。它们和之前的逻辑指令功能相同,只不过它们在保证原子性的同时,还能直接对内存地址中的数据进行操作。
原子逻辑操作指令的汇编代码形式如下所示:
amoand.{w/d}.{aqrl} rd,rs2,(rs1)
amoor.{w/d}.{aqrl} rd,rs2,(rs1)
amoxor.{w/d}.{aqrl} rd,rs2,(rs1)
#amoand是原子按位与指令
#amoor是原子按位或指令
#amoxor是原子按位异或指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2
上述代码中三条指令除了指令符不同其它是一样的rd、rs1、rs2可以是任何通用寄存器。“{}“中的可以不必填写,汇编器能根据当前的运行环境自动设置。
amoand、amoor、amoxor三条指令各自完成的操作我们分别用伪代码描述一下如下所示
//amoand
rd = *rs1
*rs1 = *rs1 & rs2
//amoor
rd = *rs1
*rs1 = *rs1 | rs2
//amoxor
rd = *rs1
*rs1 = *rs1 ^ rs2
上面的伪代码中都是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后进行相应的按位与、或、异或操作最后把结果写入该地址的内存单元中。这些操作是不可分割的且地址必须对齐到处理器位宽。
下面我们在amo.S文件中用汇编写上三个函数代码如下
.globl amoand_ins
#a0内存地址
#a1相与的值
#a0返回值
amoand_ins:
amoand.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = [a0] & a1
jr ra #返回
.globl amoor_ins
#a0内存地址
#a1相或的值
#a0返回值
amoor_ins:
amoor.w a0, a1, (a0) #原子相或a0 = [a0] [a0] = [a0] | a1
jr ra #返回
.globl amoxor_ins
#a0内存地址
#a1相异或的值
#a0返回值
amoxor_ins:
amoxor.w a0, a1, (a0) #原子相异或a0 = [a0] [a0] = [a0] ^ a1
jr ra #返回
这段代码中amoand_ins、amoor_ins、amoxor_ins三个函数都是把a0中数据作为地址把该地址内存单元中的值读取到a0中。然后再对a1的值与该地址内存单元中的数据进行与、或、异或操作把结果写入该地址的内存单元中这样就完成了原子与、或、异或操作。调试的思路和前面指令一样我就不重复了。
最后,我们来看看原子取大小值的指令,它包括无符号数和有符号数版本,一共是四条指令,分别是:原子有符号取大值指令、原子无符号取大值指令、原子有符号取小值指令、原子无符号取小值指令。
汇编代码形式如下所示:
amomax.{w/d}.{aqrl} rd,rs2,(rs1)
amomaxu.{w/d}.{aqrl} rd,rs2,(rs1)
amomin.{w/d}.{aqrl} rd,rs2,(rs1)
amominu.{w/d}.{aqrl} rd,rs2,(rs1)
#amomax是原子有符号取大值指令
#amomaxu是原子无符号取大值指令
#amomin是原子有符号取小值指令
#amominu是原子无符号取小值指令
#{可选内容}W32位、D64位
#aqrl为内存顺序,一般使用默认的
#rd为目标寄存器
#rs1为源寄存器1
#rs2为源寄存器2
上述代码中四条指令,除了指令符不同,其它内容是一样的。
我们用伪代码来描述一下amomax、amomaxu、amomin、amominu四条指令各自完成的操作形式如下
max(a,b)
{
if(a > b)
return a;
else
return b;
}
min(a,b)
{
if(a < b)
return a;
else
return b;
}
exts(a)
{
return 扩展符号(a)
}
//amomax
rd = *rs1
*rs1 = max(exts(*rs1),exts(rs2))
//amomaxu
rd = *rs1
*rs1 = *rs1 = max(*rs1,rs2)
//amomin
rd = *rs1
*rs1 = min(exts(*rs1),exts(rs2))
//amominu
rd = *rs1
*rs1 = *rs1 = min(*rs1,rs2)
观察上面的伪代码我们可以看到max函数可以返回两数之间的大数、min函数可以返回两数之间的小数exts函数负责处理数据的符号。
我们对比学习这几条指令理解起来更容易。上面的amomax、amomaxu指令都是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后与rs2进行比较。最后把两者之间大的那个数值写入该地址的内存单元中区别是比较时的数据有无符号。
而amomin、amominu指令则是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后与rs2进行比较最后把两者之间小的数值写入该地址的内存单元中。这两个指令的区别同样是比较时的数据有无符号。
下面我们在amo.S文件中用汇编写上四个函数代码如下所示
.globl amomax_ins
#a0内存地址
#a1相比的值
#a0返回值
amomax_ins:
amomax.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = max([a0] , a1)
jr ra #返回
.globl amomaxu_ins
#a0内存地址
#a1相比的值
#a0返回值
amomaxu_ins:
amomaxu.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = maxu([a0] , a1)
jr ra #返回
.globl amomin_ins
#a0内存地址
#a1相比的值
#a0返回值
amomin_ins:
amomin.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = min([a0] , a1)
jr ra #返回
.globl amominu_ins
#a0内存地址
#a1相比的值
#a0返回值
amominu_ins:
amominu.w a0, a1, (a0) #原子相与a0 = [a0] [a0] = minu([a0] , a1)
jr ra #返回
上述代码中amomax_ins、amomaxu_ins、amomin_ins、amominu_ins四个函数都是把a0中数据作为地址把该地址内存单元中的值读取到a0中然后把a1的值与该地址内存单元中的数据进行比较操作结果取大或者取小最后把结果写入该地址的内存单元中这些操作都是原子执行的、不可分割。你可以自己进入工程调试一下。
下面我们一起把这些amo指令进行测试相关代码我已经帮你写好了我们工程项目按下“F5”来调试。下面是指令调用后的打印结果截图你可以对照一下。
截图中的输出与我们预期的结果分毫不差,这说明我们用相关指令编写的汇编函数所完成的功能是正确无误的。
至此关于RISC-V所有的原子指令一共有11条指令我们就全部学完了。这些指令分别完成不同的功能重要的是它们的原子特性特别是AMO类指令在处理一些全局共享的单体变量时相当有用。
重点回顾
现在我们一起来回顾一下今天所学内容。
首先,我们讨论了为什么一款芯片需要有原子指令,从这里入手来了解原子指令的特性,它具有操作不可分割性。所以,原子指令是现代高级通用芯片里不可缺少的,是系统软件或者应用软件现实共享数据保护,维护共享数据一致性的重要基础依赖设施。
RISC-V的原子指令中包含两部分分别是LR/SC指令和AMO指令。
LR/SC指令必须成对使用才能达到原子效果在执行LR指令的同时处理器会设置相应的标志位用于监控其内存地址上有没有其它hart访问有没有产生中断异常有没有执行MRET指令。只要发生上述情况里的一种就会导致SC指令执行失败。通过这样的规则才能确保LR与SC指令之间的操作是原子的。
不过有时候LR/SC指令用起来还是挺复杂的所以AMO类指令即原子内存操作应运而生。RISC-V提供了一系列AMO类指令它们是原子交换指令、原子加法指令、原子逻辑指令、原子取大小指令这些指令相比LR、SC指令使用起来更加方便。
思考题
请你尝试用LR、SC指令实现自旋锁。
期待你在留言区记录自己的收获,或者向我提问。如果觉得这节课还不错,别忘了推荐给身边更多朋友,跟他一起学习进步。

View File

@ -0,0 +1,303 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 RISC-V指令精讲加载指令实现与调试
你好我是LMOS。
之前我们已经学过了RISC-V中的算术指令、逻辑指令、原子指令。这些指令主要的操作对象是寄存器即对寄存器中的数据进行加工这是RISC体系的重要特性。
但你是否想过寄存器中的数据从哪里来呢?答案是从内存中来,经过存储指令加载到寄存器当中。
RISC-V是一个典型的加载储存体系结构这种体系类型的CPU只有加载与储存指令可以访问内存运算指令不能访问内存。这节课我们就来学习一下RISC-V的加载指令。
顾名思义,加载指令就是从一个地址指向的内存单元中,加载数据到一个寄存器中。根据加载数据大小和类型的不同,加载指令还可以细分成五条加载指令,分别是加载字节指令、无符号加载字节指令、加载半字指令、无符号加载半字指令、加载字指令。
这节课的代码,你可以从这里下载。
加载字节指令lb指令
我们先从加载字节指令开始说起。在研究加载字节指令之前我们先来看看RISC-V的加载指令的格式其对应的汇编语句格式如下
指令助记符 目标寄存器源操作数2(源操作数1
对于加载指令指令助记符可以是lb、lbu、lh、lhu、lw目标寄存器可以是任何通用寄存器源操作数1也可以是任何通用寄存器源操作数2则是立即数。
我们用汇编代码来描述一下加载字节指令,形式如下:
lb rd,imm(rs1)
#lb 加载字节指令
#rd 目标寄存器
#rs1 源寄存器
#imm 立即数(-2048~2047
上述代码中rd和rs1可以是任何通用寄存器。立即数imm为12位二进制数据其范围是-2048~2047前面课程已经说明了RISC-V指令集中所有的立即数都是有符号数据这里的imm在其他的文档里也称为偏移量为了一致性我们继续沿用立即数的叫法。
lb指令完成的操作用伪代码描述如下所示
rd = 符号扩展([rs1+imm][7:0]
我来为你解释一下,上面的伪代码执行的操作是怎样的。
首先lb指令会从内存单元里rs1+imm这个地址里取得8位数据也就是第0位到第7位的数据。然后把这个数据进行符号扩展扩展成32位数据。如果符号位为1则该32位的高24位为1否则为0。最后lb指令再把这个32位的数据赋给rd。
下面我们一起写代码验证一下。为了方便之后的调试我们需要先设计好代码的组织结构这个过程前面几节课我们反复做过现在估计你已经相当熟练了。首先创建main.c文件并在上面写好main函数。然后写一个load.S文件用汇编写上lb_ins函数。
lb_ins函数的代码如下所示
.text
.globl lb_ins
#a0内存地址
#a0返回值
lb_ins:
lb a0, 0(a0) #加载a0+0地址处的字节到a0中
jr ra #返回
对照代码我们可以看到这个函数只有两条指令第一条指令把a0+0地址处的字节加载到a0中第二条指令就是返回指令a0作为函数的返回值返回。
你可以用VSCode打开工程目录按下“F5”键调试一下。首先我们把断点停在lb a00(a0) 指令处,如下所示:
上图中是刚刚执行完lb a00(a0)指令之后执行jr ra指令之前的状态。
我们可以看到a0寄存器中的值已经变成了0xfffffffb我们继续单步调试返回到main函数中执行printf函数打印一下lb_ins函数返回的结果如下图所示
如上图所示byte变量的值为-5其补码为0xfb我们把byte的地址强制为无符号整体传给lb_ins函数。
调用规范告诉我们C语言函数用a0寄存器传递第一个参数。lb指令虽然只加载了内存地址处的8位数据0xfb但是它会用数据的符号位把数据扩展成32位0xfffffffb再传给目标寄存器即a0寄存器这样a0就会作为返回值返回所以结果为0xfffffffb。这证明了lb指令工作是正常的。
无符号加载字节指令lbu指令
接着我们来看一看lb指令的另一个版本就是无符号加载字节指令它的汇编代码是这样写的
lbu rd,imm(rs1)
#lbu 无符号加载字节指令
#rd 目标寄存器
#rs1 源寄存器
#imm 立即数(-2048~2047
上述代码里rdrs1imm与lb指令的用法和规则是一样的。lbu指令完成的操作我们用伪代码描述如下
rd = 符号扩展([rs1+imm][7:0]
因为lbu指令获取8位数据的位置还有把数据扩展成32位赋给rd的过程都和lb指令一样我就不重复了。注意是无符号扩展即符号位为0。
接下来咱们写个代码验证一下同样在load.S文件中用汇编写上lbu_ins函数 ,代码如下所示:
.globl lbu_ins
#a0内存地址
#a0返回值
lbu_ins:
lbu a0, 0(a0) #加载a0+0地址处的字节到a0中
jr ra #返回
在lbu_ins函数中第一条指令把a0+0地址处的字节加载到a0中之后a0会作为函数的返回值返回。
同样地用VSCode打开工程目录这里我们需要在lbu a00(a0) 指令处打下断点随后按下“F5”进行调试如下所示
上图中是执行完lbu a00(a0)指令之后执行jr ra指令之前的状态现在a0寄存器中的值已经变成了0xfb。
我们继续单步调试返回到main函数中让printf函数打印lbu_ins函数返回的结果如下图所示
同样的byte变量的值为-5其补码为0xfb我们把byte变量的地址强制为无符号整体传给lbu_ins函数并调用它。
在lbu_ins函数中lbu指令只加载内存地址处的8位数据(0xfb但是它与lb指令不同它会用0把数据扩展成32位0x000000fb再传给目标寄存器即a0寄存器。这样a0就会作为返回值返回故而result为0xfb251。这证明了lbu指令是正常工作的。lbu指令的这种无符号扩展特性非常易于处理无符号类型的变量。
加载半字指令lh指令
有了能够加载一个字节的指令我们还需要加载双字节的指令也叫加载半字指令。在RISC-V规范中一个字是四字节所以两个字节也称为半字。
下面我们一起来学习加载半字指令。我们还是先从汇编代码的书写形式来熟悉它,如下所示:
lh rd,imm(rs1)
#lh 加载半字指令
#rd 目标寄存器
#rs1 源寄存器
#imm 立即数(-2048~2047
上述代码中rd和rs1可以是任何通用寄存器。立即数imm为12位二进制数据其范围是-2048~2047。
lh指令完成的操作用伪代码描述如下
rd = 符号扩展([rs1+imm][15:0]
经过前面的学习相信你已经找到了规律现在自己也能解读这样的伪代码了。还是熟悉的过程先读取数据找到内存单元rs1+imm这个地址从里面获取第0位到第15位的数据再对这个16位数据进行符号扩展扩展为32位数据接着根据符号位分情况处理如果符号位为1则该32位的高16位为1否则为0最后把这个32位数据赋值给rd。
下面是写代码验证时间。我们在load.S文件中用汇编写上lh_ins函数代码如下
.globl lh_ins
#a0内存地址
#a0返回值
lh_ins:
lh a0, 0(a0) #加载a0+0地址处的半字到a0中
jr ra #返回
上面的lh_ins函数中第一条指令把a0+0地址处的半字加载到a0中而a0将会作为函数的返回值返回。
我们用VSCode打开工程目录在lh a00(a0) 指令处打下断点随后按下“F5”键调试一下如下所示
上图中是执行完lh a00(a0)指令之后执行jr ra指令之前的状态。从图中我们可以看到a0寄存器中的值已经变成了0xffffffff。
我们继续单步调试返回到main函数中让printf函数打印一下lh_ins函数返回的结果如下图所示
对照图片不难发现short类型的half变量占用两个字节其值为-1它的补码为0xffff我们把half的地址强制为无符号整体传给lh_ins函数。
在lh_ins函数中lh指令虽然只加载内存地址处的16位数据0xffff但是它会用数据的符号位把数据扩展成32位0xffffffff再把扩展后的数据传递给a0寄存器这样a0就会作为返回值返回故而result为0xffffffff。这证明了lh指令工作正常。
无符号加载半字指令lhu指令
加载半字指令也分为两种版本即有符号版本和无符号版本。我们再看看无符号加载半字指令lhu它的汇编代码书写形式如下所示。
lhu rd,imm(rs1)
#lhu 无符号加载半字指令
#rd 目标寄存器
#rs1 源寄存器
#imm 立即数(-2048~2047
上述代码中rdrs1imm与lh指令的用法和规则是一样的。
我用伪代码为你描述一下lhu指令完成的功能。
rd = 符号扩展([rs1+imm][15:0]
lhu指令的操作过程与lh指令一样我就不重复了但符号位为0lhu会进行无符号扩展即数据的高16位为0。
接下来就是代码验证环节我们同样在load.S文件中用汇编写上lhu_ins函数代码如下所示
.globl lhu_ins
#a0内存地址
#a0返回值
lhu_ins:
lhu a0, 0(a0) #加载a0+0地址处的半字到a0中
jr ra #返回
可以看到上面的lhu_ins函数中第一条指令会把a0+0地址处的字节加载到a0中而a0将会作为函数的返回值返回。
我们用VSCode打开工程目录在lhu a00(a0) 指令处打下断点随后按“F5”键调试如下所示
上图是执行完lhu a00(a0)指令之后执行jr ra指令之前的状态可以看到a0寄存器中的值已经变成了0xffff。
我们继续单步调试返回到main函数中让printf函数打印一下lhu_ins函数返回的结果如下图所示
如上图所示我们把half的地址强制为无符号整体传给lhu_ins函数。在lhu_ins函数中lh指令虽然只加载内存地址处的16位数据0xffff但是它会用数据的符号位把数据扩展成32位0x0000ffff给a0寄存器作为返回值返回故而result为0xffff也就是65535。这证明了lhu指令工作正常。与lbu指令一样这里同样是为了让编译器方便处理无符号类型的变量。
加载字指令lw指令
对于一款处理器来说最常用的是加载其自身位宽的数据为32位的RISC-V处理器加载字指令是非常常用且必要的指令一个字的储存大小通常和处理器位宽相等。
现在。我们一起来学习最后一条加载指令即加载字指令。我们先来看看加载字指令lw它的汇编代码书写形式如下
lw rd,imm(rs1)
#lw 加载字指令
#rd 目标寄存器
#rs1 源寄存器
#imm 立即数(-2048~2047
lw指令完成的操作用伪代码描述是这样的
rd = [rs1+imm][31:0]
我们看看上面的伪代码执行的操作。首先找到内存单元rs1+imm这个地址从里面获取第0位到第31位的数据注意数据无需进行符号扩展最后把这个32位数据赋值给rd。
写代码验证的思路现在你应该也很熟悉了。同样还是在load.S文件中用汇编写上lw_ins函数代码如下所示
.globl lw_ins
#a0内存地址
#a0返回值
lw_ins:
lw a0, 0(a0) #加载a0+0地址处的字到a0中
jr ra #返回
我们可以看到lw_ins函数完成的操作就是先把a0+0地址处的一个字加载到a0中再把a0作为函数的返回值返回。
用VSCode打开工程目录在lw a00(a0) 指令处打下断点随后按下“F5”键调试调试截图如下所示
上图中是执行完lw a00(a0)指令之后执行jr ra指令之前的状态现在a0寄存器中的值已经变成了0xffffffff。继续单步调试执行就可以返回到main函数中。
我们通过printf函数打印一下lw_ins函数返回的结果如下图所示
这里我们把word的地址强制为无符号整体传给lw_ins函数。在lw_ins函数中lw指令会直接加载内存地址处的32位数据(0xffffffff)给a0寄存器作为返回值返回result值为0xffffffff但因为它是有符号类型故而0xffffffff表示为-1。而word为无符号整形0xffffffff则表示为4294967295这证明了lw指令功能是正确无误的。
到这里我们已经完成了对lb、lbu、lh、lhu、lw这五条指令的调试也熟悉了它们的功能细节。现在我们继续研究一下lb_ins、lbu_ins、lh_ins、lhu_ins、lw_ins函数的二进制数据。
你只需要打开终端切换到该工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins。接着我们打开这个文件就会看到上述函数的二进制数据如下所示
上图反汇编代码中包括伪指令和两个字节的压缩指令。比如ret的机器码是0x8082lw a0,0(a0)机器码是0x4108它们只占用16位编码即二字节。截图里五条加载指令的机器码与指令的对应关系你可以参考后面这张表格。
下面我们继续一起拆分一下lb、lbu、lh、lhu、lw指令的各位段的数据看看它们都是如何编码的。如下图所示
对照上图可以看到lb、lbu、lh、lhu、lw指令的功能码都不一样我们可以借此区分这些指令。而这些加载指令的操作码都一样立即数也相同都是0这和我们编写的代码有关。
需要注意的是lw a0,0(a0)指令,上图的情况和反汇编出来的数据可能不一致,这是因为编译器使用了压缩指令。
我还原了lw a0,0(a0)正常的编码你可以手动在lw_ins函数中插入这个数据0x00052503进行验证。怎么插入这个数据使之变成一条指令呢代码如下所示
.globl lw_ins
#a0内存地址
#a0返回值
lw_ins:
.word 0x00052503 #lw a0, 0(a0) #加载a0+0地址处的字到a0中
jr ra #返回
重点回顾
今天我们一共学习了五条加载指令,分别是加载字节指令、无符号加载字节指令、加载半字指令、无符号加载半字指令、加载字指令,它们可以加载不同大小的数据,同时又能处理数据的符号。
而且这五条指令组合起来既可以加载不同位宽的数据又能处理加载有、无符号的数据。这些指令为高级语言实现有无符号的类型变量提供了基础让我们的开发工作更便利。比方说在C语言中实现的各种数据类型unsigned、int、char、unsigned、char等都离不开加载指令。
最后我给你总结了一张导图,供你参考复习。下节课,我们继续学习储存指令,敬请期待。
思考题
为什么加载字节与加载半字指令,需要处理数据符号问题,而加载字指令却不需要呢?
欢迎你在留言区跟我交流,也推荐你把这节课分享给更多同事、朋友。

View File

@ -0,0 +1,200 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 RISC-V指令精讲访存指令实现与调试
你好我是LMOS。
上节课我们说了RISC-V是加载储存体系结构的典型只有加载指令和储存指令才有资格访问内存。
计算机运算完成的结果一开始会放在寄存器中但最终归宿还是内存此时就需要存储指令发挥作用了。这节课我们就来看看RISC-V提供的存储指令一共有三条分别是储存字节指令、储存双字节指令和储存字指令。
课程的代码你可以从这里下载。话不多说,咱们进入正题。
储存字节指令sb指令
我们先从储存字节指令即sb指令学起。
这个指令存储的字节单位是一个字节也就是8位数据。说得再具体一些这个指令会把一个通用寄存器里的低[7:0]位,储存到特定地址的内存单元里。而这个特定地址,要由另一个通用寄存器和一个立即数之和来决定。
储存字节指令的汇编代码,书写形式如下所示:
sb rs2,imm(rs1)
#sb 储存字节指令
#rs2 源寄存器2
#rs1 源寄存器1
#imm 立即数(-2048~2047
上述代码中rs1和rs2可以是任何通用寄存器。立即数imm为12位二进制数据其范围是-2048~2047。因为rs1、rs2以及立即数imm的规定对后面的sh指令和sw指令同样适用后面我就不重复说了。
sb指令完成的操作用伪代码描述是这样的
[rs1+imm]= rs2[7:0]
我来为你解释一下伪代码执行的操作。首先取得rs2寄存器第0位到第7位这8位数据即一个字节。然后把这个字节数据储存到rs1+imm为地址的内存单元中。
接着是代码验证环节为了方便调试我们在工程目录下新建一个store.S文件并在其中用汇编写上sb_ins函数。代码如下所示
.text
.globl sb_ins
#a0内存地址
#a1储存的值
sb_ins:
sb a1, 0(a0) #储存a1低8位到a0+0地址处
jr ra #返回
sb_ins函数我已经帮你写好了只有两条指令第一条指令把a1寄存器的低8位数据储存到a0+0地址处的内存单元中第二条指令就返回了。
现在我们一起用VSCode打开工程目录把断点打在“sb a1, 0(a0) ”指令处按下“F5”键调试一下效果如下图
图片里对应的是刚刚执行完sb a10(a0)指令之后执行jr ra指令之前的状态。这时候a0寄存器中的值是0x20a80这是byte变量的地址a1是0x80正是十进制数据128。
我们继续单步调试返回到main函数中执行printf函数打印一下byte变量的值如下图所示
从图中可以看到byte变量的初始值为-5。调用sb_ins函数时我们把byte的地址强制为无符号整数传给sb_ins函数第一个参数把整数128传给sb_ins函数第二个参数。
C语言调用规范告诉我们sb_ins函数会通过a0、a1寄存器传递第一个、第二个参数之后printf函数输出byte变量的值为128这证明了sb指令是正常工作的。
储存双字节指令sh指令
接下来要说的是储存半字指令,也是储存双字节指令。它可以把一个通用寄存器中的低[15:0]位一共16位的数据即两个字节储存到特定地址的内存单元中这个地址由另一个通用寄存器与一个立即数之和决定。
储存半字指令的汇编代码,书写形式是这样的:
sh rs2,imm(rs1)
#sh 储存半字指令
#rs2 源寄存器2
#rs1 源寄存器1
#imm 立即数(-2048~2047
sh指令完成的操作用伪代码描述如下所示
[rs1+imm]= rs2[15:0]
我来为你解释一下上面的伪代码执行了怎样的操作。首先取得rs2的第0位到第15位的数据。然后把这两个字节16位数据的数据储存到rs1+imm这个地址的内存单元中。
咱们写个代码来验证一下。在store.S文件中用汇编写上sh_ins函数。代码如下所示
.globl sh_ins
#a0内存地址
#a1储存的值
sh_ins:
sh a1, 0(a0) #储存a1低16位到a0+0地址处
jr ra #返回
与sb_ins函数一样sh_ins函数只有两条指令但第一条指令是把a1寄存器的低16位数据储存到a0+0地址处的内存单元中第二条指令同样是返回指令。
现在我们一起用VSCode打开工程目录在“sh a1, 0(a0) ”指令处打上断点按“F5”键调试的截图如下所示
图片对应的是刚刚执行完sh a1,0(a0)指令之后执行jr ra指令之前的状态a0寄存器中的值是half变量的地址a1寄存器中的值是0xa5a5。
我们继续进行单步调试返回到main函数中执行printf函数打印一下half变量的值。
如上图所示half变量的初始值为-1。随后调用sh_ins函数我们把half的地址强制为无符号整数传给sh_ins函数第一个参数再把整数0xa5a5传给sh_ins函数第二个参数之后printf函数输出half变量的值为0xa5a5。这证明了sh指令工作正常。
储存字指令sw指令
最后我们来学习一下储存字指令就是储存32位四字节指令也是最常用的储存指令它是把一个32位的通用寄存器储存到特定地址的内存单元中这个地址由另一个通用寄存器与一个立即数之和决定。
储存字指令的汇编代码书写形式如下所示:
sw rs2,imm(rs1)
#sw 储存字指令
#rs2 源寄存器2
#rs1 源寄存器1
#imm 立即数(-2048~2047
上述代码中rs1和rs2可以是任何通用寄存器。立即数imm为12位二进制数据其范围是-2048~2047。
然后我们看看sw指令完成的操作对应的伪代码描述如下
[rs1+imm]= rs2
这段伪代码执行的操作就是把rs2的32位数据即四个字节数据储存到rs1+imm为地址的内存单元中。
下面我们一起写代码验证一下在store.S文件中用汇编写上sw_ins函数。代码如下
.globl sw_ins
#a0内存地址
#a1储存的值
sw_ins:
sw a1, 0(a0) #储存a1到a0+0地址处
jr ra #返回
sw_ins函数只有两条指令第一条指令是把a1寄存器储存到a0+0地址处的内存单元中第二条指令同样是返回指令。
毕竟眼见为实咱们调试观察一下。用VSCode打开工程目录在“sw a1, 0(a0) ”指令处打上断点按下“F5”键调试如下所示
上图是刚刚执行完sw a1,0(a0)指令之后执行jr ra指令之前的状态。a0寄存器中的值是word变量的地址a1寄存器中的值是0执行完这个sw_ins函数后word变量的值应该变为0了。
我们继续单步调试执行返回到main函数中执行printf函数打印一下word变量的值如下图所示
可以看到图中word变量的初始值为0xfffffffff随后调用sw_ins函数我们把word变量的地址强制为无符号整数传给sw_ins函数第一个参数把整数0传给sw_ins函数第二个参数之后printf函数输出word变量的值确实为0。这证明了sw指令工作正常。
我们已经对sb、sh、sw指令进行了调试了解了它们的功能现在我们继续一起看看sb_ins、sh_ins、sw_ins函数的二进制数据。
打开终端切换到该工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins我们打开这个文件就会看到上述这些函数的二进制数据如下所示
可以看到在图片里的反汇编代码中不但有伪指令还有两个字节的压缩指令。编译器为了节约内存所以会把指令压缩。比如说ret的机器码是0x8082sw a1,0(a0)机器码是0xc10c它们只占用16位编码即二字节。
截图里五条加载指令的机器码与指令的对应关系,你可以参考后面这张表格。
我画了示意图帮你拆分一下sb、sh、sw指令各位段的数据这样更容易看清楚它们是如何编码的。如下所示
对照上图可以看到sb、sh、sw指令的功能码都不一样借此就能区分它们。而这些储存指令的操作码都相同立即数也相同都是0这和我们编写的代码有关。
我还想提示你注意一下sw指令图片里的情况跟反汇编出来的数据可能不一致原因是编译器使用了压缩指令。图片里我还原的是sw a1,0(a0)正常的编码。
你可以手动在sw_ins函数中插入0x00b52023这个数据进行验证。怎么插入这个数据使之变成一条指令呢参考[上节课]还原lw指令的讲解我相信你这次自己也能搞定它。
关于RISC-V的三条储存指令已经介绍完了它们可以将字节、双字节、四字节储存到内存中去。实现了保存运算指令运算结果的功能给高级语言实现各种类型的变量提供了基础。
重点回顾
今天我们一口气学完了三条储存指令。有了三条储存指令加上我们上节课学过的五条加载指令就构成了RISC-V的访存指令。
RISC-V提供的储存字节指令、储存半字指令和储存字指令。储存指令可以把寄存器的运算结果或者其他数据储存到特定的内存空间中。储存单位可以是一个字节、两个字节或者四个字节。有了这些指令不同大小、位宽的数据处理起来都很方便。
运算指令的运算结果,要通过储存指令保存到内存中,这也给高级语言实现各种类型的变量,打下了基础。
我照例用导图梳理了这节课的要点,你可以做个参考。
思考题
为什么三条储存指令,不需要处理数据符号问题呢?
期待你在留言区跟我互动,也可以记录一下自己的收获。如果觉得课程还不错,也别忘了分享给更多朋友。

View File

@ -0,0 +1,196 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 内存地址空间:程序中地址的三种产生方式
你好我是LMOS。
前面我们一起探讨了RISC-V芯片设计和实现了一个迷你CPU。之后还深入研究了CPU上面运行的语言和指令系统它们是构成程序的重要要素。依托于芯片和语言、指令我们就可以编写和执行程序了。
不过我们开发应用的时候,还有个打交道最频繁的模块——内存。很多工程问题你不懂内存,就会举步维艰。你也许觉得内存知识太难了,不但关联知识又多又散乱,而且深挖下去感觉没有尽头。但计算机的硬核基础,内存是必修关卡,只要你跟住我的节奏坚持下来,一定可以把内存的本质、内存系统的来龙去脉都弄明白,一起加油。
这节课让我们迈出认识内存的第一步我们先搞清楚CPU怎么访问内存然后再来分析内存地址从何而来最终让你建立对内存地址空间的理解。这节课的配套代码你可以从这里下载。
CPU如何访问内存
CPU怎么访问内存我们回想一下之前讲过的高级语言和低级语言转化过程。
我们先思考一下C语言把我们写出来的变量和函数都转换成了什么呢如果记不太清了可以回顾[第十五节课]。没错C语言把变量名和函数名都转换成了汇编语言里的标号而汇编语言的标号就是机器更好理解的符号。符号具体包括符号类型、符号名称和符号地址这几个属性。其中符号地址是由一个叫链接器的东西生成的。
汇编语言的标号就表示为一段内存地址的开始。再根据我们RISC-V里访存指令的操作可回顾[第二十一节课]和[第二十二节课]进一步分析看看加载字指令lw指令它会从一个地址指向的内存单元中把数据加载到寄存器上储存字指令sw指令则是跟lw指令相反把寄存器里的数据存到特定内存单元当中。这些指令里源寄存器和立即数组成的数据其实就叫内存地址。
结合这些信息我们就能推出这个结论CPU正是通过内存地址来访问内存的。这个地址本质上是一个整数数据。而这个整数代表了一个内存单元的索引号CPU访问内存的时候硬件的地址译码器会负责把索引号转换成相应的地址信号和片选信号帮助CPU“寻路”找到特定的内存单元位置。
我来给你画图描述一下,对照图解你更容易理解。
从上图中得知内存最小编址单位为一个字节一个字节能储存8个二进制位即给出一个地址就能够精确地定位到某个内存字节单元。两个连续的字节为半字储存16个二进制位四个连续的字节为一个字也就是储存32个二进制位。
我们对照上图看一下看起来0~0xFFFFFFFF这之间任意整数形成的地址都能索引并访问到对应的内存单元。不过这只是理想状态现实里并非如此。真正的实现方案中一些地址上对应的不一定是内存单元还可能是系统寄存器设备寄存器、设备内存、主内即我们经常说内存情况如下图所示
示意图里描述的更接近真实情况,在一台现代的物理计算机上,各种设备和内存都是统一编址的,不同的地址段能访问到不同的设备。
比如上图中CPU发出了0x00000004地址这时经过地址译码访问的就不是某个内存单元了而是系统寄存器如果CPU发出的地址在0xC0000000到0xE0000000之间就会访问到设备上的内存而CPU发出的地址是0x60000000到0xBFFFFFFF之间和0x100000000到0x19FFFFFFF之间才能访问到主存也就是内存。
还有一些地址并没有对应到具体的设备即为无效地址如果CPU访问了无效地址就会得到无效数据或者收到硬件错误的反馈。
现在我们已经清楚地知道了CPU把一个整数数据当成地址放在地址总线上由地址译码器选择该地址正确索引的设备或者内存进行访问。
从另一个角度看数据在物理电路上是由不同的电子信号的组合来表示的。正是有了这些电子信号组合才能做到索引相应的设备和内存。CPU通过什么访问内存以及地址的本质是什么我们已经找到答案了用一句话概括就是CPU通过地址访问内存地址的本质是整数数据而整数数据的本质是电子信号的组合。
内存地址从何而来
好,让我们继续分析,搞清楚地址是从何而来的。
你现在已经知道了CPU要通过地址访问内存。但是如果我问你这个地址从何而来你是不是有些惊讶发现自己一下子可能回答不上来或者只知道个大概。
比较容易想到的思路是,访问内存的是相应的程序,那么自然内存地址是从程序代码中来。只是我们没有认真思考过,程序代码的地址是怎么产生的?
下面,我们就通过几行代码来一步步探索这个问题,代码如下所示:
//ls.S文件
.text
.globl sw_ins
#a0内存地址
#a1储存的值
sw_ins:
sw a1, 0(a0) #储存a1到a0+0地址处
jr ra #返回
//main.c文件
unsigned int word = 0xffffffff;
int main()
{
sw_ins((unsigned int)&word, 0);
return 0;
}
上述代码分别来源于工程目录中的ls.S文件和main.c文件代码功能逻辑很简单就是C语言的main函数调用汇编代码sw_ins对word变量做修改把它从0xffffffff修改为0。
请你注意,我们现在不是研究代码本身,而是研究代码编译后的链接过程,通过这个线索来分析程序代码地址如何产生。
为此我帮你写了一个链接脚本来控制链接过程和传递相关信息。同时我们还要修改Makefile文件的内容让链接脚本生效Makefile内容如下所示
上图中红色框中是修改内容尤其是第7行你要仔细看看其中-T ld.lds 表示使用ld.lds文件作为链接脚本文件-Map main.map表示链接器将链接后的内存map信息输出到main.map文件里。
接下来我们重点研究一下ld.lds代码如下所示
//输出格式
OUTPUT_FORMAT(elf32-littleriscv)
//配置内存空间起始地址为0x10000长度为0x50000
MEMORY
{
RAM (xrw) : ORIGIN = 0x10000 , LENGTH = 0x50000
}
//定义输出节
SECTIONS
{
//定义text节包含链接文件的所有以.text开头的节
.text :
{
*(.text) *(.text.*)
} > RAM
//定义data节包含链接文件的所有以.data、.sdata、.sdata2、.rodata开头的节
.data :
{
*(.data .data.*) *(.sdata .sdata.*) *(.sdata2.*) *(.rodata) *(.rodata*)
} > RAM
//定义bss节包含链接文件的所有以.bss、.sbss、.common开头的节
.bss :
{
*(.sbss*) *(.bss*) *(COMMON*)
} > RAM
}
从链接脚本中我们看到Id.lds文件首先配置了一个内存空间这个空间从0x10000地址开始一共有0x50000个字节。然后链接器把所有参与链接文件里-
以.text、.data、.sdata、.bss、.sbss、.COMMON开头的节按照上述链接脚本的顺序合并成可执行程序文件这个文件的地址从0x10000地址开始到0x60000结束。
这个合并过程中,需要对符号进行绑定和地址重定位,我特意为你画了一幅图,展示这个过程。
看了图片你是不是对链接器生成地址的过程更加清楚了呢如上图所示ls.o、main.o文件是可链接的目标文件格式也是ELF的其中有.text节、.data节、.bss节等不同的数据会放到不同的节里如下表所示
链接器所做的工作就是根据lds文件中的定义完成“合并同类项”的整理工作也就是把相同的节合并成一个更大的节。比如ls.o的.text节与main.o的.text节合并成main.elf的.text节而.data、.bss节也是类似的合并过程合并之后就要执行更重要的工作。
程序重定位也叫分配内存地址。我也举个例子帮助你理解比如main.elf程序要从内存地址0x10000开始并且这个地址开始存放的是.text节即指令部分.data节放在.text节之后。
链接器根据.text节的大小就能算出.data节的开始地址。比如在上面的例子里就是0x10030。.data节中有一个变量word是一个字大小所以word变量地址会从0x10030开始存放占用4字节下一个变量地址将从0x10034开始。
既然word变量存放内存地址是0x10030那么链接器就需要修改指令具体就是修改指令中表示word变量地址的数据让地址数据变成0x10030或者通过一种计算方式得到0x10030这样程序中的相关指令才能最终访问到word变量。这也是在main.o中的main函数里一些指令数据与main.elf中的main函数指令数据不一样的原因。
还有一个关键的地方我再讲讲main函数中调用了sw_ins函数链接器也要进行处理确保jalr指令能跳转到sw_ins函数的地址上即0x10000地址。
链接器产生地址的过程我们讲完了,概括说就是链接多个程序模块,并且分配程序在运行过程中的地址。
当然了,除了这种方式,你可以在程序代码中直接给出一个地址,代码如下:
int main()
{
//把整数0x20000强制转换为int类型的指针
int *p = (int*)0x20000;
*p = 0;
//动态分配一个int类型大小的内存空间其首地址返回给addr指针
void* addr = malloc(sizeof(int));
return 0;
}
这段代码就是让p直接指向0x20000地址然后向这个地址上写入0。不过这个操作极其危险除非你确切地知道自己在干什么因为0x20000可能是其它重要数据也可能不是真正的内存单元而是设备寄存器更可能什么也没有即这个地址没有连接任何有效设备。
代码中的第三种情况是程序在运行过程中动态分配的内存,返回该内存的首地址,这相对于第一种方式更加安全可靠。
现在我们已经搞清楚了程序中的地址是怎么产生的:第一种方式是链接器;第二种方式是直接定义;第三种方式是动态分配内存。
物理地址空间和虚拟地址空间
我们已经搞清楚了,地址从何而来,但一个地址肯定身处某一个地址空间中,我们下一个探讨话题正是地址空间。
首先地址不过是一个整数而已一旦这个整数被编码到CPU相应访存指令中的相关位段里CPU就会把它放到地址总线上。这样CPU访问内存的时候就会通过地址译码器获得这段整数信息从而索引到具体的设备单元上。这个设备单元可以是设备寄存器可以是内存单元。
那么地址空间其实就是一个这样的整数所表示的范围。具体落实到CPU电路上就是地址总线位数所表示的数据范围。
比方说CPU有8根地址线它能编码2的8次方即256个数据地址0到地址255这个地址数据的范围其实就是这个8位地址总线的CPU的地址空间如果是32位地址总线的CPU那么它地址的空间范围就是0~0xFFFFFFFF。从0到0xFFFFFFFF这之间的每个整数编码就是一个地址合起来就是地址空间。
那什么是内存地址空间呢当然就是能索引到内存单元的地址合集。我们再稍微扩展一下你知道CPU的物理地址空间吗其实它就是CPU地址总线位数所表示的数据范围由于不同的CPU甚至同一体系CPU的不同版本其地址总线数设计实现不同物理地址空间也是不同的。
聊完了物理地址空间,咱们当然还得说说虚拟地址空间。现在的计算机系统中,我们写的程序链接时的地址和运行时的地址,都使用了虚拟地址。
虚拟地址空间的大小和CPU中的一个设备MMU内存管理单元有关。虚拟地址之所以称为虚拟地址是因为这种地址是假的它不能真正索引到具体的设备单元无论该单元属于设备寄存器还是内存自然也就无法访问内存。还需要一个转换机构把虚拟地址转换成真正的物理地址才能访问相应的设备。这个转换机构就是CPU的MMU关于MMU的细节这里我先卖个关子放在后面的课程再说。
讲到这里,我们知道了,地址空间和我们所在的自然空间的寓意不是一样的,它们仅仅是为了表示某一位宽下的二进制数所有的编码合集。所谓内存地址空间,自然也就是内存地址编码的合集。
有了这个概念我们就知道程序指令在内存中是如何组织的一旦我们的程序出现了问题我们就能精准地分析定位问题所在。同时我们也明白了CPU如何通过地址访问内存读取其中指令和数据也就是CPU运行程序的基本逻辑机理。
重点回顾
今天我们为了弄明白内存地址空间是怎么一回事儿,做了不少探索,现在我带你回顾一下这节课的要点。
首先我们分析了CPU如何访问内存。一个整数数据就是一个地址CPU会把该数据放在地址总线上由地址译码器选择该地址正确索引的设备或者内存进行访问。
访问内存要先知道“地址”,那内存地址是从何而来的,怎么产生的呢?我们结合例子,了解到内存地址有几种产生方式:一种是链接器对程序重定位后执行地址绑定,这地址是静态的;第二种是在代码中直接定义地址;第三种是动态分配内存,返回内存空间的首地址。
明白了CPU访问内存的方式也知道了内存地址如何产生我们再理解内存地址空间也不是难事儿了。所谓内存地址空间本质就是内存地址位宽下地址编码的合集。
内存的相关知识才刚刚开始,内存知识相对有点挑战,但跟着我的步伐,你也可以搞懂里面的门道。这节课最后我捎带讲了讲虚拟内存地址空间,更多虚拟内存的故事,且听我下节课分解。
思考题
你觉得链接器使用的地址是物理内存地址,还是虚拟内存地址?
欢迎在留言区记录你的思考或疑问,也推荐你把今天这节课分享给更多朋友,说不定也能刷新他对内存的认识。

View File

@ -0,0 +1,236 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 虚实结合:虚拟内存和物理内存
你好我是LMOS。
上一课中学习了内存地址空间,我们搞清楚了内存地址与地址空间的本质。
今天我们开始学习虚拟内存与物理内存。其实虚拟内存也好物理内存也罢我们从储存并索引数据的角度来看内存的重要组成部分就两个一个是地址另一个就是储存字节单元即能存放8个二进制位的容器。把两者合起来我们可以将内存理解为能索引到具体储存字节单元的地址集合。
这节课我会带你解决以下三个问题:
虚拟内存的本质是什么?-
物理内存是什么,它的结构长什么样?-
虚拟内存如何与物理内存结合在一起,真正实现储存数据的功能?
课程配套代码你可以从这里下载。让我们带着上面的问题,正式开始今天的探索之旅吧!
虚拟内存
上节课我们了解了内存地址的产生方式,以及应用程序的链接过程,也知道了内存就是能索引到具体储存单元的地址集合。但是程序中的地址能否索引到具体储存单元呢?具体的储存单元,又是如何分配的呢?下面我们用两个问题来说明其中的原理。
第一个问题
我的第一个问题来了,应用程序中使用的地址是什么内存地址?是不是感觉情况有很多种,一时很难回答清楚?遇到这种状况不要慌,我们只要动手写一个简单的程序就可以验证。
好,我们立刻动手写一写,代码如下:
#include "stdio.h"
#include "stdlib.h"
void func_a()
{
//定义地址0x40000000
int* p = (int*)0x40000000;
printf("内存地址:%p\n", p);
//向该地址写入数据
*p = 0xABABBABA;
printf("内存地址:%p处的值:%x\n", p, *p);
return;
}
int main()
{
func_a();
return 0;
}
上述应用程序非常简单我们在main函数中调用函数func_a而在函数func_a中我们定义一个整型指针C语言中指针就是内存地址其地址值为0x40000000。
代码我给你存到了课程相关的工程目录中你可以打开工程目录make一下就会自动编译好。然后你需要在终端下运行这个main.elf程序首先会出现“内存地址0x40000000”接着会出现“段错误程序异常退出”的提示。
出现了段错误提示,在你的预料之中么?我来解释一下,为什么会出现这种情况,这是因为我们使用了一个没有分配的地址。很显然,如果一个地址真的能索引到内存,该地址就能访问内存,除非这地址是个假地址,在内部需要某种机制进行转换才能访问内存。这个转换机制可能需要一些表或者数据结构进行控制,并且这个控制权掌握在操作系统的手里。
由于操作系统管理内存的规则,是先分配后使用,所以,我们就猜想操作系统分配内存的时候,就会处理控制地址转换的相关表和数据结构。接下来我们写段代码,来验证一下猜想,如下所示:
#include "stdio.h"
#include "stdlib.h"
void func_b()
{
//分配内存,返回其地址
int* p = (int*)malloc(sizeof(int));
if(p)
{
printf("内存地址:%p\n", p);
//向该地址写入数据
*p = 0xABABBABA;
printf("内存地址:%p处的值:%x\n", p, *p);
}
return;
}
int main()
{
func_b();
return 0;
}
这次我们编译运行,就会正确地输出结果了。
其实malloc函数在内部最终会调用Linux内核的API函数在该进程的虚拟地址空间中分配一小块虚拟内存返回其首地址。这个过程我用一幅图来为你展示如下所示
由于代码优化的原因malloc函数并不是每次调用都会导致Linux内核建立一个vm_area_struct数据结构。我们假定malloc函数导致Linux内核建立了一个vm_area_struct数据结构该结构中有描述虚拟内存的开始地址、大小、属性等相关字段表示已经分配的虚拟内存空间。
许多个这样的结构可以一起表示进程的虚拟地址空间分配情况。但是这个从vm_area_struct数据结构中返回的地址仍然是虚拟的、是假的是不能索引到内存单元的直到访问该地址时会发生另一个故事如下图所示
上图中CPU拿着一个虚拟地址访问内存首先会经过MMU对于调用malloc函数的情况是该虚拟地址没有映射到物理内存所以会通知CPU该地址禁止访问。
上图中1到4个步骤为硬件自动完成的然后CPU中断到Linux内核地址错误处理程序软件开始工作也就是说Linux内核会对照着当前进程的虚拟地址空间去查找对应的vm_area_struct数据结构找不到就证明虚拟地址未分配直接结束进程会发出段错误若是找到了则证明虚拟地址已经分配接着会分配物理内存建立虚拟地址到物理地址的映射关系接着程序就可以继续运行了。
当然了实际情况比图中的复杂这里我们只是要理清楚malloc函数的逻辑并且明确malloc是返回的虚拟内存地址就可以了。
第二个问题
我们要想清楚的第二个问题就是直接使用物理内存地址会出现什么后果我们来看一个程序下面这段代码是一个简单版的memset函数。
void mymemset(void* start, char val, int size)
{
char* buf = (char*)start;
for(int i = 0; i < size; i++)
{
buf[i] = val;
}
return;
}
我们提出一个假设:这个函数被不同的应用程序调用,且使用的地址就是物理地址,能直接访问物理内存单元。
你可以想一想,如果假设成立,恶果就是一个程序可以改变另一个程序的内存,甚至是全部的内存。想想吧!这是何等可怕。通过这个例子,我们发现物理地址不能有效地隔离内存,达到保护内存的结果。
想要隔离内存,就需要依赖虚拟内存这个东西。我画了一幅图,带你总结一下虚拟内存的本质,如下所示:
由上图可知我们各种应用都可以拥有从0到最大虚拟地址的完整的虚拟内存空间并且可以任意使用这个虚拟内存空间。每个应用都认为自己拥有整个内存这一点可以从所有的应用程序使用相同的链接脚本进行链接得到佐证。各个应用程序调用malloc函数可能得到相同地址是另一个佐证。
我们现在终于知道了,虚拟地址真的只是一个整数,一系列的这种整数集合,就构成了虚拟内存空间。这个整数能索引一个字节的虚拟内存单元,但这个虚拟内存单元不会对应到真正的物理设备,因此它虽然可以独立存在,但却需要下层的物理内存作为支撑,才能实现访问和储存数据。
物理内存
上一课中我们了解到物理地址空间是CPU地址线位宽所能表示最大整数集合只是一个地址它能索引物理设备或者什么都不索引这里的物理设备中就包括了物理内存。
下面我们来看看真实的内存长什么样,如下所示:
从上图可以看到,在 PCB 板上有内存颗粒芯片主要是用来存放数据的。SPD 芯片用于存放内存自身的容量、频率、厂商等信息。还有最显眼的金手指,用于连接数据总线和地址总线、电源等。
其实内存应该叫 DRAM即动态随机存储器。内存储存颗粒芯片中的存储单元是由电容和相关元件做成的电容存储电荷的多、少代表数字信号 0 和 1。而随着时间的流逝电容存在漏电现象就会引起电荷不足的情况导致存储单元的数据出错。所以DRAM 需要周期性刷新,以保持电荷状态。
DRAM 结构比较简单且集成度很高,通常用于制造内存条中的储存颗粒芯片。我们无需过多关注内存硬件层面的技术规格标准,这里重点需要关注的是,逻辑上内存和硬件系统的连接方式和结构。
我还是画幅图来说明吧,这样方便你建立直观印象,如下图所示:
我们假定从物理地址0开始索引的是物理内存CPU发出的地址是虚拟地址经由MMU转换变成物理地址物理地址经由地址译码单元就会对应到具体的内存字节储存单元。一个字节单元能储存8个二进制位即一个地址能对应到8个二进制位。
你可以通过dmsg命令查看你物理机上的情况。在我的x86机器里情况如下图所示
从图里我们可以看到usable类型的物理地址区间对应的是DRAM即内存。其它的则是保留的或者硬件设备的地址空间这些空间程序是不能当作内存来使用的。
讲到这里,我们就明白了,逻辑上物理内存相当于几个地址上不连续的字节数组,始终有一个物理地址能索引到其中一个字节。
虚实结合
提出虚拟内存这个概念,一是为了让应用认为自己享有完整的地址空间,拥有整个内存的使用权。二是要对物理内存进行保护,即使各个应用程序都存放在物理内存之中,也不能随意访问自己的物理内存,更不能侵犯别的应用程序所占用的物理内存,不然就会出现互相改写对方内存的情况,一旦出现这样的情况后果就严重了,任何应用程序都不能正常运行了。
那接下来要考虑的问题就是,虚拟内存跟物理内存要如何对应起来?
虚拟内存必须要落实到物理内存才能真正完成工作,最简单的方案是让虚拟地址能够索引到物理内存单元,但是虚拟地址和物理地址显然不能一一对应,如果那样的话,虚拟地址等于物理地址且不受控制,这样虚拟地址就没有任何意义了。
因此我们需要在虚拟地址空间与物理地址空间之间加一个机构这个机构相当于一个函数p=f(v) 。对这函数传入一个虚拟地址,它就能返回一个物理地址。该函数有自己的计算方法,对于没法计算的地址或者没有权限的地址,还能返回一个禁止访问。
这个函数用硬件实现出来就是CPU中的MMU即内存管理单元。CPU发出的虚拟地址首先经过MMUMMU内部计算得出物理地址最后用物理地址去访问内存。MMU的结构如下图所示
上图中展示了CPU发出的虚拟地址经过MMU转换出物理地址进而访问内存的过程但我们并没有弄清楚MMU是使用什么方法进行转换的所以下面我们继续探讨MMU的地址转换过程。
你不妨想一想把一个数据转换成另一个数据最简单的方案是什么当然是建立一个对应表格对照表格进行查询就行了。MMU也是使用一个地址转换表但是它做很多优化和折中处理。不做任何折中处理的话这种方案是无法实施的。
你可以想象一下32位的地址空间有4G个虚拟地址和4G个物理地址。在这种情况下每8个字节存放两个地址数据想要装下所有的地址这个表有多大应该放在哪里查询代价有多大所以这个方案直接pass掉。
我们现在来看看通常情况下MMU是如何解决这个问题的一共有三个关键环节。
首先MMU对虚拟地址空间和物理地址空间进行分页处理一个页大小可以是4KB、16KB、2MB、4MB、1GB不等。这是为了增加地址的粒度避免采用每个字节一个地址现在一页一个地址地址数量就会大大减少从而减少转换表的大小。
其次MMU采用的转换表也称为页表其中只会对应物理页地址不会储存虚拟地址而是将虚拟地址作为页表索引这进一步缩小了页表的大小。
最后MMU对页表本身进行了拆分变成了多级页表。假如不分级4GB内存空间 按照4KB大小分页有1M个页表项每个页表项只占用4个字节也需要4MB空间。如果页表分级在建立页表时就可以按需建立页表而不是一次建立4MB大小的页表。
我们一起来画一幅图来描述一下这个过程,如下所示:
对照图片我们可以看到虚拟内存页和物理内存页是同等大小的都为4KB各级页表占用的空间也是一个页即为4KB。MMU把虚拟地址分为5个位段各位段的位数根据实际情况有所不同按照这些位段的数据来索引各级页表中的项一级一级往下查找直到页表项最后用页表项中的地址加页内偏移就得到了物理地址。
我再画一幅图,为你描述这一过程。
看到这幅图我们就清楚了MMU用虚拟地址转换物理地址的过程。如果转换成功就可以直接访问内存了但如果转换失败MMU就会通知CPU地址转换失败让CPU产生一个异常中断进而通知操作系统内核让操作系统内核来处理这个异常就像malloc分配内存的过程那样。
我们已经知道了虚拟地址如何转换成物理地址,但是如果只是按部就班地转换可不行,别忘了,还需要对物理内存进行保护。这个保护物理内存的问题的关键就是,想清楚一个虚拟地址在什么情况下能被转换成物理地址。
这就要说到MMU是如何控制转换动作的。要进行控制就需要相关的控制信息聪明如你大概已经猜到了控制信息就放在页表项中MMU在转换过程中首先就会查看那些信息以此作出判断。
下面我们看一下控制信息的格式,如下所示:
从上图中可以看到页表项中的低12位为属性位段这里保存一个物理内存页面的读写、执行、存在的相关权限还有页面是否存在、可不可以缓存是否已经访问或者写入大小等信息。这些信息统统编码在12个二进制位中。
为什么表示各种页面地址的页表项能让出12位用于编码这些信息呢这是因为一个页面最小也是4KB且与4KB对齐那么页面开始地址的低12位永远为0所以可以挪为它用。
到这里我们就已经搞清楚虚拟地址如何转换成物理地址并且知道了MMU如何控制转换过程恭喜你解锁了虚实结合的思路和过程。
现在你可能隐约感觉到,只要操作系统牢牢控制页表数据,就能实现对内存的完全控制和保护,使得各个应用程序在自己的虚拟地址空间中安全地运行,不被打扰,也不能打扰别人。每个应用程序都有相同的虚拟内存,但却占用着不同的物理内存。
重点回顾
今天的课程就要结束了,下面我们来回顾一下今天的内容。
首先我们从两个实际问题出发,研究了虚拟内存的本质。虚拟内存的应用,一是为了保护内存,二是为了限制访问内存。让应用程序拥有独立的地址空间,误以为自己能享用全部的内存。
接着我们分析了物理内存了解了DRAM的特性和结构因为DRAM就是我们常说的内存设备。这里你重点要关注的是内存的逻辑结构和系统连接方式。
最后我们讨论了虚实结合究竟是怎么实现的。硬件工程设计了MMU让它把虚拟内存地址通过页表中的信息转换成物理地址并控制转换过程。如果转换失败就会通知CPU然后CPU产生地址异常中断最后由操作系统处理这个异常。操作系统将会通过修改页表的数据来修复这个问题进而完全控制内存的访问。
我画了一张导图梳理这节课内容,供你参考。
应用程序的虚拟地址空间里还有更多奥秘,我会在下节课继续为你展开,敬请期待。
思考题
请问页表数据究竟放在什么地方呢?
欢迎你在留言区跟我交流互动,说说你对虚实结合的认识。如果觉得这节课还不错,也推荐你把它分享给身边的朋友。

View File

@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 延迟分配:提高内存利用率的三种机制
你好我是LMOS。
通过前面的学习,我相信你已经感觉到了物理内存资源的宝贵。为了尽可能有效利用它,操作系统在内存管理上花了很多心思,之前学过的虚拟内存、虚实结合的故事也佐证了这一点。
为了提高内存利用率还有一些巧妙的机制等待我们探索。今天我就跟你聊聊其中的三种“玩法”分别是写时复制、请求调页和mmap系统调用。这节课的代码你可以从这里下载。
写时复制
什么是写时复制呢用极为通俗的语言可以这样概括写时复制是一种计算机编程领域中的优化技术Copy-on-write简称COW
其核心原理是,如果有多个应用同时请求相同资源,会共同获取相同的指针,指向相同的资源。这个资源或许是内存中的数据,又或许是硬盘中的文件,直到某个应用真正需要修改资源的内容时,操作系统才会真正复制一份该资源的专用副本给该应用,而其他应用所见的最初资源仍然保持不变,操作系统使得该过程对其他应用都是透明的。
COW的优点是如果应用没有修改该资源就不会产生副本因此多个应用只是在读取操作时可以共享同一份资源从而节省内存空间。
关于COW的原理我们先说到这里。接下来我们研究一下实际的Linux系统是如何应用COW的。
Linux下对COW最直接的应用就是fork系统使用fork是建立进程的系统调用因为我们现在还没有讲到进程你先把进程当成运行中的应用就行。
在 Linux 系统中,一个应用调用 fork 创建另一个应用时会复制一些当前应用的数据结构比如task_struct代表一个运行中的应用、mm_struct代表应用的内存、vm_area_struct代表应用的虚拟内存空间、files_struct应用打开的文件等等。
但是创建的时候并不会把当前应用所有占用的内存页复制一份而是先让新建应用与当前应用共用相同的内存页。只有新建应用或者当前应用中的一个对内存页进行修改时Linux系统才会分配新的页面并进行数据的复制。
光看文字描述你可能还是没法领会,让我们写一个小程序开开胃,代码如下所示:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t pid;
printf("当前应用id = %d\n",getpid());
pid = fork();
if(pid > 0){
printf("这是当前应用当前应用id = %d 新建应用id = %d\n", getpid(), pid);
}else if(pid == 0){
printf("这是新建应用新建应用id = %d\n", getpid());
}
return 0;
}
正如其名字一样fork代表分叉。这里fork以应用A为蓝本复制出应用B。因为当fork返回之前系统中已经存在应用A和应用B了所以应用A会从fork返回应用B也会从fork返回。对于应用Afork返回的是应用B的ID对于应用Bfork返回的是0系统通过修改应用B的CPU上下文数据就能做到这一点。而getpid返回的是调用它的应用的ID。
下面我们运行这段程序,运行结果如下图所示:
图中绿色部分是应用A和应用B都会运行的代码片段。我们看一下运行结果应用A调用fork返回的pid与应用B调用getpid返回的pid是完全一样的。这验证了我们之前对fork的描述。
只不过第一个printf函数来自于应用A的运行而第二个printf函数来自应用B的运行为什么会出现这种情况呢
这就是fork的妙处了fork会复制应用A的很多关键数据但不会复制应用A对应的物理内存页面而是要监测这些物理内存的读写只有这样才能让应用A和应用B正常运行。
我画幅图表示一下这个过程,你看后就更清楚了:
上面的图里fork把应用A的重要数据结构复制了一份就生成了应用B。有一点很重要那就是应用A与应用B的页表指向了相同的物理内存页并对其页表都设置为只读属性。
讲到这里,你可能会想:“这不是相当于内存共享吗?”这样想对也不对,我们得分成应用写入数据和读取数据这两个情况来讨论。
先看看写入数据会发生什么样的故事。这时无论是应用A还是应用B去写入数据这里我们假定应用B向它的栈区、数据区、指令区等虚拟内存空间写入数据结果一定是产生MMU转换地址失败。
这是因为对应的页表是只读的即不允许写入。此时MMU就会继续通知CPU产生缺页异常中断进而引起Linux内核缺页处理程序运行起来。然后缺页处理程序执行完相应的检查发现问题出在COW机制上这时候才会把一页物理内存也分配给相关应用解除页表的只读属性并且把应用A对应的物理内存页的数据复制到新分配的物理内存页中。
这个过程你可以结合后面的示意图来加深理解这张图描述了COW机制的过程
观察上图我给你总结一下写时复制的机制。COW的机制保证了应用最终真正写入数据的时候才能分配到宝贵的物理内存资源只要不是写入数据系统坚决不分配新的内存。
而前面你理解的共享内存更符合这个情况的是读取数据比如上图中的应用A与应用B的指令区这大大节约了物理内存。由于不是完全复制所有的内存页面所以fork的执行很快最终效果就是Linux创建进程的性能非常高。
请求调页
搞清楚了写时复制,我们来看看请求调页是怎么一回事儿。
请求调页是一种动态内存分配技术,更是一种优化技术,它把物理内存页面的分配推迟到不能再推迟为止。
请求调页机制之所以能实现,是因为应用程序开始运行时,并不会访问虚拟内存空间中的全部内容。由于程序的局部性原理,使得应用程序在执行的每个阶段,真正使用的内存页面只有一小部分,对于暂时不用的物理内存页,就可以分配由其它应用程序使用。因此,在不改变物理内存页面数量的情况下,请求调页能够提高系统的吞吐量。
请求调页与写时复制的区别是什么呢当MMU转换失败CPU产生缺页异常时在相关页表中请求调页没有对应的物理内存页面需要分配一个新的物理内存页面再填入到页表中而写时复制有对应的物理内存页面只不过是只读共享的也需要分配一个新的物理内存页面填入页表中并进行复制。
接下来,我们就来写写代码,验证一下,代码如下所示:
int main()
{
size_t msize = 0x1000 * 1024;
void* buf = NULL;
printf("当前应用id = %d\n",getpid());
buf = malloc(msize);
if(buf == NULL)
{
printf("分配内存空间失败\n");
}
printf("分配内存空间地址:%p 大小:%ld\n", buf, msize);
//防止程序退出
waitforKeyc();
return 0;
}
上述代码主要是用malloc函数分配了1000个页面的内存。这1000个页面的内存空间是虚拟内存空间而waitforkeyc函数的作用是让应用程序不要急着退出。好让我们通过“sudo cat /proc/55285/smaps > main.smap”命令观察相应的统计数据。
这个命令是不是有点眼熟?在[上一节课]我们介绍过它不过这次是读取smaps文件其中的信息更为详细。
现在我们还是运行一下这段代码,看看结果如何。我把我的运行结果截图如下所示:
上图绿色方框里就是malloc分配的虚拟内存空间。可以看到这次malloc没有在堆中分配它选择了在映射区分配这个内存空间。绿色方框中size为4100KB这正是我们分配内存的大小多出的大小是为了存放管理信息和对齐
我们需要重点关注的是其中的RSS它代表的是实际分配的物理内存这部分物理内存现在已经分配好了因此使用过程不会产生缺页中断。
同时RSS也包含了应用的私有内存和共享内存。我们看到这里已经分配了4KB即一个页面。按常理应该分配1024个物理内存页面可是这里才分配了一个页面这是为什么呢
把这个问题想清楚,请求调页的原理你就明白了。如果你不向该内存中写入数据,它就不会真正分配物理内存,并且一次只分配一个物理内存页面,当你继续写入下一个虚拟内存页面时,它才会继续分配下一个物理内存页面。
下面我们加一行代码,如下所示:
int main()
{
size_t msize = 0x1000 * 1024;
void* buf = NULL;
printf("当前应用id = %d\n",getpid());
buf = malloc(msize);
if(buf == NULL)
{
printf("分配内存空间失败\n");
}
memset(buf, 0xaf, msize);
printf("分配内存空间地址:%p 大小:%ld\n", buf, msize);
//防止程序退出
waitforKeyc();
return 0;
}
我们在代码中加入memset函数用于把malloc函数分配的空间全部写入为0xaf。
我们运行上述程序后,就会得到如下图所示的结果:
我们看到绿色方框中的有些数据发生了变化。RSS代表的应用占用的物理内存现在变成了4100KB而Private_Dirty代表应用的脏内存即写入数据的内存的大小也是4100KB转换成页面刚好是1025个页面。1025个页减去malloc分配时写入的1个页刚好和我们分配的1024页面是相等的。
现在我们知道了,请求调页是虚拟内存下的一个优化机制。在分配虚拟内存空间时,并不会直接分配相应的物理内存页面,而是由访问虚拟内存引起缺页异常,驱动操作系统分配物理内存页面,将物理内存分配推迟到使用的最后一刻,这就是请求调页。
映射文件
在Linux等通用操作系统中请求调页还有一个更深层次的应用即映射文件。
一般情况下我们操作文件要反复调用read、write等系统调用。而映射文件的方式能让我们像读写内存一样读写就是我们只要读写一段内存其数据就会反映在相应的文件中这样操作文件就更加方便了。
在Linux中有个专门的系统调用来实现这个映射文件的功能它就是mmap调用。我们先来看一看mmap函数原型声明如下所示
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
上述代码就是mmap函数的原型。是不是感觉参数很多但我们每个参数都要搞清楚我给你一个个列举出来如下所示
start指定要映射的内存地址一般设置为NULL以便让操作系统自动分配合适的内存地址。
length指定映射内存空间的字节数。
prot指定映射内存的访问权限。可取如下几个值PROT_READ可读, PROT_WRITE可写, PROT_EXEC可执行, PROT_NONE不可访问
flags指定映射内存的类型MAP_SHARED共享的 MAP_PRIVATE私有的, MAP_FIXED表示必须使用 start 参数作为开始地址如果失败不进行修正其中MAP_SHARED , MAP_PRIVATE必选其一而 MAP_FIXED 则不推荐使用。
fd指定要映射的打开的文件句柄。
offset指定映射文件的偏移量一般设置为 0 ,表示从文件头部开始映射。
了解了mmap调用是不是觉得可以进入写代码环节了先别急我们先熟悉熟悉mmap内部的原理和机制。
当调用 mmap() 时Linux会在当前应用由task_struct表示的虚拟内存由mm_struct表示创建一个 vm_area_struct 结构让其指向虚拟内存中的某个内存区并且把其中vm_file成员指向要映射的文件对象file
然后,调用文件对象的 mmap 接口就会对 vm_area_struct 结构的 vm_ops 成员进行初始化。接着vm_ops成员会初始化具体文件系统的相关函数。
这里我们不需要深入到文件系统只要明白后面这个逻辑就行当应用访问这个vm_area_struct 结构表示的虚拟内存地址时会产生缺页异常。随即在这个缺页异常的驱动下最终会调用vm_ops中的相关函数读取文件数据到物理内存页中并进行映射。
我们用一幅图来展示这一过程,如下所示:
Linux内核在调用open函数打开文件时会在内存中建立诸如file、dentry、inode、address_space等数据结构实例用来表示一个文件及其文件数据。这些结构的细节现在你不必了解只需要了解它们之间的关系就足够了。
有了open返回的fd文件句柄mmap就可以工作了。mmap调用首先会建立一个vm_area_struct结构表示文件映射的虚拟内存。然后根据参数fd文件句柄找到打开的文件即file结构并且让它们关联起来。
最后应用访问mmap函数返回的一个地址应用程序访问这个地址就会导致缺页异常。在缺页异常处理程序的驱动下CPU会找到这个地址对应的vm_operations_struct结构这个结构中封装了大量的虚拟内存操作 。
我们说说这些虚拟内存的操作是什么。第一次缺页异常处理时会调用vm_operations_struct中的map_pages 函数,用来给文件分配相应的物理内存页。不过这时虽然有了物理内存页,但里面并没有文件数据,所以内核会在页表上做标记,标记该页不存在于内存里,这样还是会导致缺页异常。
接下来这次异常操作就不同了这次会调用vm_operations_struct结构中的fault函数读取对应的文件数据并和address_space结构联系起来。最终CPU就能访问文件的内容一步步通过前面讲过的请求调页方式把对应文件的内容加载到物理内存中了。
下面我们写代码测试一下,代码如下所示:
int main()
{
size_t len = 0x1000;
void* buf = NULL;
int fd = -1;
printf("当前应用id = %d\n",getpid());
//当前目录下打开或者建立testmmap.bin文件
fd = open("./testmmap.bin", O_RDWR|O_CREAT, 777);
if(fd < 0)
{
printf("打开文件失败\n");
return 0;
}
//建立文件映射
buf = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_PRIVATE, fd, 0);
if(buf == NULL)
{
printf("映射文件失败\n");
return 0;
}
printf("映射文件的内存地址:%p 大小:%ld\n", buf, len);
//防止程序退出
waitforKeyc();
return 0;
}
上述代码中先调用open函数这个函数带有O_CREAT标志表示打开一个testmmap.bin文件若文件不存在就会新建一个名为testmmap.bin的文件。接着会调用mmap函数建立文件映射虚拟内存区间由操作系统自动选择长度为4KB该区间可以读写而且是私有的从文件头开始映射。请注意这里我们没有对文件映射区进行任何操作。
现在我们运行一下这个应用并查看一下对应进程的smaps文件信息如下所示
如上图所示mmap返回的地址是0x7f3fa9aaf000大小为4KB。对照右边绿色方框中的信息刚好吻合。其中RSS为0说明此时没有分配物理内存因为我们没有这个虚拟内存区间做任何操作。
下一步,我们往这个虚拟内存区间写入数据,代码如下所示:
int main()
{
size_t len = 0x1000;
void* buf = NULL;
int fd = -1;
printf("当前应用id = %d\n",getpid());
fd = open("./testmmap.bin", O_RDWR|O_CREAT|O_TRUNC, S_IRWXU|S_IRWXG|S_IRWXO);
if(fd < 0)
{
printf("打开文件失败\n");
return 0;
}
//因为mmap不能扩展空文件空文件没有物理内存页所以先要改变文件大小否则会产生总线错误
ftruncate(fd, len);
buf = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
if(buf == NULL)
{
printf("映射文件失败\n");
return 0;
}
printf("映射文件的内存地址:%p 大小:%ld\n", buf, len);
//向文件映射区间写入0xff
memset(buf, 0xff, len);
close(fd);
//防止程序退出
waitforKeyc();
return 0;
}
和前面代码相比这里我们只是增加了扩展文件大小的功能接着mmap文件最后调用memset函数文件映射区的虚拟内存地址buf处写入0x1000个0xff。
我们运行一下这段代码,结果如下图所示:
对比前一张图我们可以看出绿色方框的RSS中Private_Dirty的数据有所变化。这是因为memset函数写入数据导致缺页异常从而分配物理内存页并关联到testmmap.bin文件。当close函数被调用时物理内存页中的数据就会同步到硬盘中。我们可以打开testmmap.bin文件查看一下即上图中蓝色方框中的数据。
讲到这里我们就清楚了mmap函数的底层原理就是对请求调页的扩展。这种方式在处理超大文件的随机读写过程中性能相当不错。当只有文件中一部分被读写的时候就不必读取整个文件占用大量内存了。
对内存资源“精打细算”的操作系统通过文件映射的机制让物理内存页的分配管理更加精细了等到应用实际要用到文件的哪一部分系统才会去分配真正的物理内存。文件映射的内容到这里就告一段落了其实在Windows、Mac OSX 也有这种函数,只是名字和参数有所区别而已,感兴趣的话你可以课后自行探索一下。
重点回顾
今天的内容讲完了,我们来回顾一下这节课的学习重点。
无论是写时复制还是请求调页都是一种内存优化技术需要MMU等硬件的支持才能实施。正是因为物理内存的使用被推迟了才导致多个应用可以看到的物理内存页面还有很多因为总是在最后需要内存的时刻才会分配物理内存。这种延迟分配的方式可以更好地利用空闲内存同时运行更多的应用总体上让系统产生更大的吞吐量。
写时复制是一种延迟分配内存的技术可以优化内存的使用。我们一起研究了fork调用发现Linux在fork创建新应用时使用了COWCopy-on-write技术。fork通过对当前应用的关键数据结构复制即可得到一个新应用但当前应用和新应用会以只读方式共享物理内存只有当其中一个应用试图修改数据时就会为其分配一个物理内存页将数据复制到新的物理内存页中。
请求调页的核心思路就是将内存推迟到使用时才分配。由于应用程序的局部性原理,使得应用总是会访问常用的页面,而不是在一定时间内顺序访问所有的页面。请求调页的思路就是等到应用产生了缺页异常,才为其分配一个物理内存页,这大大提高物理内存的整体利用率。
最后,我们学习了文件映射,其作用是让开发人员能把操作内存的动作反映到相应的文件中。但是底层核心却是请求调页的扩展应用,它将映射到应用程序的虚拟内存区间。访问这个虚拟内存区间就会产生缺页异常,在其异常的驱动下,一次分配一个物理内存页,将文件内容加载到内存页,或者将其中的内容写入到文件中。
我把这节课的要点梳理成了后面这张导图,你可以做个参考。
思考题
请简单说一下写时复制和请求调页的区别。
期待在留言区看到你的“随堂笔记”或者疑问,也可以试试回答别人的问题。如果觉得这节课还不错,别忘了分享给身边更多朋友。

View File

@ -0,0 +1,432 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 应用内存管理Linux的应用与内存管理
你好我是LMOS。
前面几节课我们学了不少内存相关的基础知识,今天我们来研究一下应用程序的内存管理。
应用程序想要使用内存必须得先找操作系统申请我们有必要先了解一下Linux内核怎么来管理内存这样再去分析应用程序的内存管理细节的时候思路才更顺畅。
之后我还选择了现在最流行的Golang语言作为参考带你梳理内存管理中各式各样的数据结构为你揭秘Golang为什么能够实现高效、自动化地管理内存。
这节课的配套代码,你可以从这里下载。让我们进入正题吧!
硬件架构
现代计算机体系结构被称为Non-Uniform Memory AccessNUMANUMA下物理内存是分布式的由多个计算节点组成每个 CPU 核都会有自己的本地内存。CPU 在访问它的本地内存的时候就比较快,访问其他 CPU 核内存的时候就比较慢。
我们最熟悉的PC机和手机就可以看作是只有一个计算节点的NUMA这算是NUMA中的特例我来为你画一幅逻辑视图你一看就明白了如下图所示
我们看到每个节点都是由CPU、总线、内存组成的。节点之间的内存大小可能不同但是这些内存都是统一编址到同一个物理地址空间中的即无论是节点0的内存还是节点1的内存都有唯一的物理地址在一个节点内部的物理内存之间可能存在空洞节点和节点间的物理内存页可能有空洞。何谓地址空洞就是这一段地址是不对应到内存单元里的。
一般情况下手机和个人电脑都只有一个节点。服务器和大型计算机可能有多个节点节点甚至可以动态插入或者移除。关于硬件架构我们就回顾到这里下面我们去看看Linux是如何在NUMA硬件架构上管理内存的。
Linux物理内存管理
上面的NUMA体系架构上节点内部的内存和节点之间的内存其访问速度是不一样的这无疑是提升了Linux的内存管理的复杂度。因此Linux用了大量的数据结构来表示计算节点内存、内存页面以及它们之间的关系。
我为你列出了一张表格,梳理不同的数据结构:-
在计算机系统中至少有一个默认的pglist_data结构如果计算节点增加pglist_data结构也会随之增加。
pglist_data结构中包含自身节点CPU的id有指向本节点和其它节点的内存区zone结构的指针。而在zone结构中包含一个free_area结构的数组用于挂载本内存区中的所有物理内存页也就是page结构。
Linux的物理内存分配过程是这样的通过pglist_data结构先找到自己节点的zone结构的指针如果不能满足要求则查找其它节点的zone结构然后找到zone结构中的free_area结构数组最后要找到其中的page结构并返回。释放过程则是分配过程的相反过程。
下面,我为你画一幅表示这些数据结构的关系图,你看看图就明白了。
有了上图应该能帮助你在大脑中建立Linux物理内存分配释放的运行蓝图。Linux的虚拟内存是建立在物理内存之上的关于虚拟内存你可以回到应用与内存部分前面的三节课复习一下。
关于Linux内核的内存管理我们就研究到这里如果你想更细致地了解Linux内核的内存管理可以阅读我的上一门课程《操作系统实战45讲》中[第二十二节课]和[第二十三节课],那里有非常详尽的讨论。
Golang内存管理
现在到了我们今天课程的重点—— 搞清楚Golang语言是如何管理内存的。后面的代码我选择的是Go 1.5这个版本 。
Golang又称Go是Google公司于2007年召集了三位大神罗伯特·格瑞史莫Robert Griesemer罗勃·派克Rob Pike及肯·汤普逊Ken Thompson开发了Unix和C语言开发的一种静态强类型、编译型、并发型并具有垃圾回收功能的编程语言。业界戏称为C2.0。
到了2016年Go发展起来了它被软件评价公司 TIOBE 选为“TIOBE 2016 年最佳语言”。现在Go语言在业界是非常流行的编程语言。它的语法接近C语言支持垃圾回收功能。Go的并行模型是以东尼·霍尔的通信顺序进程CSP为基础与C++相比Go并不包括如枚举、异常处理、继承、泛型、断言、虚函数等功能但增加了切片(Slice) 型、并发、管道、垃圾回收、接口Interface等特性的语言级支持。
关于Go语言的历史和基本情况我先点到为止回到我们的主题——Go的内存管理。
像Go这种支持内存管理和并行功能的语言一般都是有一个运行时runtime它就像针对这个语言开发的一个小型os为该语言开发的程序提供一个标准可靠的执行环境。这个环境提供了内存管理和并行模型等一些其它功能每个Go程序加载之时就会先执行Go运行时环境。
下面我们看一看Go语言运行时的内存空间结构如下图所示
看完这张示意图,你可能会想:“这和普通应用的内存空间结构并没有什么区别啊?”
是的但是普通应用程序是调用malloc或者mmap向OS申请内存而Go程序是通过Go运行时申请内存Go运行时会向OS申请一大块内存然后自己进行管理。Go应用程序分配内存时是直接找Go运行时这样Go运行时才能对内存空间进行跟踪最后做好内存垃圾回收的工作。
Go运行时中提供了一些向操作系统分配和释放内存的函数我举两个例子runtime.sysAlloc会从操作系统中获取一大块可用的内存空间可能为几百 KB 或者几 MBruntime.sysFree 会在程序发生内存不足时释放内存。
这些函数形成了一个Go运行时访问内存时的抽象层在不同的操作系统上这些个函数调用操作系统API也是不同的。比方说在Linux上调用的是 mmap、munmap 和 madvise 等系统调用。
下面咱们看一看runtime.sysAlloc的代码如下所示
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {
p, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)
if err != 0 {
if err == _EACCES {
print("runtime: mmap: access denied\n")
exit(2)
}
if err == _EAGAIN {
print("runtime: mmap: too much locked memory (check 'ulimit -l').\n")
exit(2)
}
return nil
}
sysStat.add(int64(n))
return p
}
上面第二行代码中调用mmap调用是匿名私有、可读写映射fd传的参数是-1表示映射的虚拟空间与文件不相关。-
但是Go运行时调用runtime.sysAlloc函数返回一个大块内存空间之后是怎么管理的呢我们继续往下看。
Go运行时把这个大块内存称为arena区域其中又划分为8KB大小页其结构如下图所示
上图中的页和操作系统中的页不是一回事这里的页是Go运行时定义的通常是操作系统页的整数倍。
Golang内存管理数据结构
看到上图你还是感觉十分空洞么那是因为你没有弄清楚Go内存管理的数据结构Go内存管理的有五大数据结构分别是mheap、heapArena、mcentral、mcache、mspan你或许不知道这些结构是什么含义下面就让我挨个为你拆解一下。
mheap数据结构
首先我们一起来看看mheap数据结构。一个Go应用中只有一个mheap数据结构它负责管理应用所有变量、数据对象使用的内存。
mheap结构在应用启动时由Go运行时初始化。需要注意的是mheap结构并不负责管理heapArena、mcentral、mcache、mspan这些数据结构实例所占的内存也就是说这些结构占用的内存不是由Go内存管理负责的而是由Go在运行时直接通过系统内存API函数来分配内存空间。
mheap结构的代码如下所示
type mheap struct {
//全局锁
lock mutex
//页面分配的数据结构
pages pageAlloc
//所有的mspan结构指针
allspans []*mspan
……略
//heapArena结构指针数组
arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena
……略
//当前区的开始和结束地址
curArena struct {
base, end uintptr
}
//mcentral结构数组
central [numSpanClasses]struct {
mcentral mcentral
pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte
}
……略
}
我删除了mheap结构中的许多字段这不影响我们理解代码流程逻辑。-
我们接着来看看heapArena结构如下所示
type heapArena struct {
//存储此区域中位图的指针
bitmap [heapArenaBitmapBytes]byte
//每个区的mspan指针数组
spans [pagesPerArena]*mspan
//pageInUse是一个位图指示哪些span处于使用状态
pageInUse [pagesPerArena / 8]uint8
……略
//zeroedBase记录此区中第一页的第一个字节地址
zeroedBase uintptr
}
heapArena结构可以管理一个区这个区的大小一般为64MB。我把具体情况画一幅图你就明白了如下所示-
上图中展示了多个页合并一个heapArena的过程多个heapArana由mheap管理这显然是为了方便mheap对整个内存空间进行扩大和缩小。
mcentral数据结构
在mheap结构中还有一个重要的mcentral数据结构数组。这个命名是想表达的是它是中央的、核心的非常重要的。
mcentral数据结构里到底有什么重要的内容呢我们结合代码来揭秘代码如下所示
type mcentral struct {
//跨度类
spanclass spanClass
//具有空闲对象的mspan列表
partial [2]spanSet
//具有非空闲对象的mspan列表
full [2]spanSet
}
type spanSet struct {
spineLock mutex
//指向spanSetBlock
spine unsafe.Pointer
//Spine长度
spineLen uintptr
//略……
}
const (
//常量
spanSetBlockEntries = 512 // 4KB on 64-bit
spanSetInitSpineCap = 256 // Enough for 1GB heap on 64-bit
)
type spanSetBlock struct {
//mspan结构指针数组
spans [spanSetBlockEntries]*mspan
}
通过上述代码我们发现mcentral结构中的跨度类就是一个整数。至于这个整数有什么作用我们后面再说。-
这里的spanSet相当于一个管理动态数组的结构spanSet里面包括spanSetBlock指针和长度而spanSetBlock中才是mspan指针。你可以把spanSet和spanSetBlock的组合理解为一个动态增长的列表该列表中保存着mspan指针。
那为什么mcentral结构中的partial和full要定义成两个元素的数组呢这是为了对mspan进行分类优化垃圾回收器的性能。
让我们回到mheap结构中可以看到有一个mcentral结构数组大小与跨度类有关。我们用一幅图来总结一下这几个数据结构的关系如下图所示
上图中展示了从mheap到mcentral再到mspan的关系通过mheap这个全局的数据结构就能找到内存相关的全部数据结构。
不过我们始终没有搞清楚Go运行时如何利用这些个数据结构管理分配内存的。想解决这个问题我们先得理解一个叫做mspan的数据结构。
mspan数据结构
mspan数据结构是Go运行时内存管理的基本单元mspan中的起始地址指向一大块内存这块内存是由一片连续的、8KB的页组成的。这个8KB页就是arean区的页其中还有mspan分配对象的大小规格、占用页的数量等内容。
我们下面看一看它的代码,如下所示:
type mspan struct {
// mspan双向链表
next *mspan
prev *mspan
// 该mspan页的开始地址
startAddr uintptr
// 该mspan包含的页数
npages uintptr // number of pages in span
// 略……
// 用于定位下一个可用的object, 大小范围在 0- nelems 之间
freeindex uintptr
// mspan里object的数量
nelems uintptr
// 用于缓存freeindex开始的bitmap, 缓存的bit值与原值相反
// ctz函数可以通过这个值快速计算出下一个free object的index
allocCache uint64
//allocBits标记mspan中的对象哪些是被使用的哪些是未被使用的
allocBits *gcBits
//gcmarkBits标记mspan中的对象哪些是被标记了的哪些是未被标记的
gcmarkBits *gcBits
// 已经分配的object的数量
allocCount uint16
// 跨度类
spanclass spanClass
// mspan状态
state mSpanStateBox
// 决定分配的对象是否需要初始化为0
needzero uint8
// object的大小
elemsize uintptr
// mspan结束地址
limit uintptr
}
上述代码中,字段用于管理一个由几个页面组成的空间,这个空间会切成一个个小块空间。这些小块儿空间我们称为对象,相关字段中记录了对象的大小和个数。-
你看看我后面画的这幅图就明白了。
对照示意图我们可以看到两个mspan结构中各自有2个页面8个对象两组位图。这两组位图里一组位图用于分配对象另一组位图用于垃圾回收器扫描时的标记标记哪些对象是空闲且已经被扫描过了等待回收。
对象的多少决定了位图的大小而对象的个数和大小决定了页面的多少。那么在创建mspan时怎么确定这些数据呢这时就不得不说那个早该说的跨度类了。其实spanClass类型就是uint8类型它是一个数组索引0~67现在我们看一看它到底索引的是什么 ,代码如下所示:
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
这是两个数组class_to_size数组表示当前spanClass对应的mspan中对象的大小class_to_allocnpages数组表示当前spanClass对应的mspan中占用的页面数 有了这些数据就能指导我们建立mspan结构了。-
Google官方给出了一个方便观察的数据表如下所示。
// 索引值 对象大小 mspan的大小(页) 对象数量 末尾浪费的内存 最大浪费 最小对齐
// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
看到这里我们就知道分配小块内存就是找到对应的mspan数据结构然后在该mspan结构中分配一个对象返回如果没有对应的mspan就要建立一个。-
那下个问题就是如何找到对应的mspan呢让我们继续探索另一个数据结构——mcache。
mcache数据结构
在说明这个mcache数据结构之前你需要先明白Go是支持并行化的我们可以用go关键字建立很多个协程 Goroutine并行运行。那么Go是如何实现高度并行化的呢这就不得不提到Go中的三个基本对象G、M、P 。
它们到底是什么,我给你列了一个表格,如下所示:-
知道了这三个基本对象的意思我们还得聊一聊Go运行时是怎样工作的。开始Go运行时会建立一个系统上的线程M每一个运行的M会绑定一个P线程M有了P之后会去检查并执行G对象即协程。然后每一个P中都保存着一个协程G的队列除了每个P自身保存的G的队列外还有一个全局的G队列。最后M通过P从它的本地队列或者全局队列中获取G并执行。
G、M、P三者的关系如下图所示
逻辑处理器P不仅仅能获取局部或者全局G队列其中还有一个指向mcahe数据结构的指针指向各自独立的mcache数据结构。
mcache数据结构到底是什么我们一起来看看代码
type mcache struct {
// 触发堆采样相关的
nextSample uintptr
// 分配的可扫描堆字节数
scanAlloc uintptr
// tiny堆指针指向当前tiny块的起始指针
tiny uintptr
// 当前tiny块的位置
tinyoffset uintptr
// 拥有当前mcache的P执行的tiny分配数;
tinyAllocs uintptr
// mspan指针数组数组中指针指向不同大小对象的mspan
alloc [numSpanClasses]*mspan
// 略
}
上述代码的mcache结构中字段tiny代表一个指针指向一个内存块这个内存块不由mspan管理而是直接找操作系统申请。当申请对象小于16B的时候就会使用 Tiny allocator 分配器该分配器会根据tiny、tinyoffset 和 tinyAllocs 这三个字段的情况进行分配。分配算法类似于操作系统里brk系统调用的分配方法你可以回顾前面第二十五节课的内容。-
我们知道mspan管理的对象它的大小规格数据共有67类前面讲跨度类的时候我提到过。Go运行时中定义的虽然是 _NumSizeClasses = 68 但其中包含一个大小为0的规格我单独拎出来说一说这个规格表示大对象即 >32KB这种对象只会分配到heap上这个内存也不归mspan管理所以不可能出现在alloc数组中。
剩下16-32KB大小的内存都会通过这个alloc数组里的 mspans 分配。每种类型的mspan有两个一个是mspan列表中不包含指针的对象另一个是mspan列表中包含指针的对象。这种区别让垃圾收集的工作变得更容易因为它不必扫描不包含任何指针的范围。
为了让你更好地理解mcache结构我为你画了一幅图如下所示
结合之前讲的G、M、P三者的关系你是不是突然对mcache有了新一层理解了呢正如其名mcache的作用正是给P和在P上运行的G缓存mspan。这个设计的好处就是减少从mcentral和mheap全局数据结构中查找mspan的工作量进而降低由此产生的锁冲突带来的性能损耗。
Golang内存分配过程
前面我们对mheap、mcentral、mspan、mcache数据以及G、M、P对象的关系有了深入的理解现在我们就可以梳理出Go内存的分配过程了。
根据G、M、P对象的关系我们不难看出一个规律同一个M在同一时刻只能执行一个P而P又只能运行一个协程。换句话说分配内存始终是从P上运行一个协程开始的。
分配过程一共四步,我们分别来看看。
第一步根据分配对象的大小选用不同的结构做分配。包括3种情况1.小于16B的用mcache中的tiny分配器分配2.大于32KB的对象直接使用堆区分配3.16B和32KB之间的对象用mspan分配。现在我们假定分配对象大小在16B和32KB之间。
第二步在mcache中找到合适的mspan结构如果找到了就直接用它给对象分配内存。我们这里假定此时没有在mcache中找到合适的mspan。
第三步因为没找到合适的mspan所以需要到mcentral结构中查找到一个mspan结构并返回。虽然mcentral结构对mspan的大小和是否空闲进行了分类管理但是它对所有的P都是共享的所以每个P访问mcentral结构都要加锁。mcentral结构就是一个中心缓存我们假定Go运行时在进行了一些扫描回收操作之后在mcentral结构还是没有找到合适的mspan。
接着是第四步因为始终没找到合适的mspanGo运行时就会建立一个新的mspan并找到heapArea分配相应的页面把页面地址的数量写入mspan中。然后把mspan插入mcentral结构中返回的同时将mspan插入mcache中。最后用这个新的mspan分配对象返回对象地址。
Go分配内存的流程就是这样只要搞清楚那些数据结构的关系相信你很容易就能理解。Go语言是开源的你可以课后自己阅读一下。
Go程序中的分配的内存不需要程序手动释放而是由Go运行时中的垃圾回收器自动回收。程序分配的内存如果不使用就会成为“垃圾”在运行过程中的某个时机就会触发其中的垃圾回收协程执行垃圾扫描和回收操作。
Go的垃圾回收器实现了一种三色标记的算法。一个对象可以被标记成白色、黑色、灰色三种颜色之一。白色是对象的初始颜色如果扫描完成后对象依然还是白色的说明此对象是垃圾对象黑色表示对象是可达的即使用中的对象黑色是已经被扫描的对象灰色表示被黑色对象直接引用的对象但还没对它进行扫描。
三色标记的规则是黑色对象不能指向白色对象,黑色对象可以指向灰色对象,灰色对象可以指向白色对象。
最后我再简单说说三色标记算法的主要流程。首先是初始状态,所有对象都被标记为白色;接着会寻找所有对象,比如被线程直接引用的对象,找到后就把该对象标记为灰色;下一步,把灰色对象标记为黑色,并把它们引用的对象标记为灰色;然后,持续遍历每一个灰色对象,直到没有灰色对象;最后,剩余的白色对象为垃圾对象。
这种方法看似很好但是将垃圾回收协程和其它工作协程放在一起并行执行会因为CPU的调度出现问题导致对象引用和颜色出现错误以至于回收了不能回收的对象。Go为了解决这个问题又加入了写入内存屏障。这里我就不过多展开了有兴趣的话你可以参考这里。
重点回顾
这节课我们首先回顾了计算机硬件架构然后学习了Linux是如何在NUMA硬件架构上管理内存的。之后我们把重点放在了Golang的内存管理上面。
其实这节课我带你梳理Golang内存管理的思路你以后研究其他源码的时候也可以试试看。我的切入点就是从源码里拎出最重要的几个数据结构然后搞清楚这些数据结构之间的关系。在我们梳理代码关系的时候如果脑子里无法建立准确关联可以画图来辅助。
我的经验是,基本能图画出来的话,关系也就理清楚了。万一有些关联不确定,你可以做些猜想假设,并通过写点代码来验证。
最后我画了一幅图为你总结Golang内存管理所用数据结构的关系如下所示
思考题
Golang有了mcentral结构可以查找到mspan结构为什么还要建立mcache结构呢
欢迎你在留言区一起交流,积极互动有助于加深理解。另外也推荐你把这节课分享给更多的朋友,跟他一起学习进步。

View File

@ -0,0 +1,300 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 进程调度:应用为什么能并行执行?
你好我是LMOS。
你知道为什么在计算机上,我们能一边听着音乐,一边刷着网页,顺便还能跟朋友畅聊天下大事吗?这得益于计算机里的各种应用,更得益于支撑各种应用同时运行的操作系统。那么操作系统为什么能同时运行多个应用,具体是用什么机制让多个应用并行执行呢?
这节课我们来一起探索前面这两个问题的答案。我会带你先从最简单的shell开始了解一个应用的运行过程然后和你聊聊进程的本质以及它的“生老病死”最后再给你讲讲多进程调度是怎么回事儿。
这节课的配套代码,你可以从这里下载。
最简单的shell
为什么要先从shell开始了解呢因为熟悉了它你才能知道Linux上怎么运行一个应用程序才能明白Linux内部怎么表示一个正在运行的应用程序。
通常情况下我们在Linux上运行程序都是在终端下输入一个命令这个命令其实大部分都是Linux系统里相应应用程序的文件名。
而终端也是Linux系统上一个普通的应用程序从UNIX开始它就叫shell但是shell只是一个别名在你的系统上它的文件名可能是sh也可能是bash。shell实现的功能有别于其它应用它的功能是接受用户输入然后查找用户输入的应用程序最后加载运行这个应用程序。
shell的机制里只用到了两个系统调用——fork和execl我给你画了一张示意图展示其中的逻辑
结合图片我们可以发现shell应用首先调用了fork通过写时复制写时复制的机制可以回顾[第二十六节课]创建了一个自己的副本我们暂且称为shell子应用。
然后shell子应用中调用了execl该函数会通过文件内容重载应用的地址空间它会读取应用程序文件中的代码段、数据段、bss段和调用进程的栈覆盖掉原有的应用程序地址空间中的对应部分。而且execl函数执行成功后不会返回而是会调整CPU的PC寄存器从而执行新的init段和text段。从此一个新的应用就产生并开始运行了。
我们照此逻辑写一个最简单的shell感受一下代码如下所示
int run(char* cmd)
{
pid_t pid;
int rets;
//建立子进程
pid = fork();
if(pid > 0)
{ //等待子进程退出
wait(&rets);
}
else if(pid == 0)
{ //新进程加载新应用
if(execl(cmd, cmd, NULL) == -1)
{
printf("未找到该应用\n");
exit(0);
}
}
return 0;
}
int shell_run()
{
char instr[80];
while(1)
{
printf("请输入应用名称:");
//获取用户输入
scanf("%[^\n]%*c", instr);
//判断是exit就退出
if(strncmp("exit", instr, 4) == 0)
{
return 0;
}
//执行命令
run(instr);
}
return 0;
}
int main()
{
return shell_run();
}
可以看到上述代码shell_run函数中循环读取用户输入然后调用run函数在run函数中fork建立子进程。如果子进程建立成功子进程最初和父进程执行相同的代码当子进程进入执行时会调用execl系统调用加载我们输入的应用程序并覆盖原有的进程数据这就是一个新进程诞生的过程。-
写完代码以后别忘了代码验证环节。为了证明我们这个shell是正确的我们要在当前工程目录下建立一个子目录app在app目录写个main.c文件作为新应用并在其中写下后面这段代码。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("这是新应用 id = %d\n", getpid());
return 0;
}
我们在终端中切换到该工程目录下执行make就编译好了。然后就可以借助这个终端shell加载我们的shell如下图所示-
对照截图我来说明一下验证过程。首先执行make编译然后加载我们的shell。第一次我们使用系统中的date命令进行测试该命令会输出当前日期和时间。我们看到上图中显示的是正确的第二次我们使用自己的应用测试如上图中的输出正是我们应用程序运行之后的结果。
我们一起开发了一个简单shell。通过动手练习相信你已经对一个应用的运行过程有了初步了解由fork建立起应用的生命载体也就是接下来要讲的进程由execl来建筑应用程序的血肉也就是用新的应用程序的数据覆盖进程的地址空间。
什么是进程
什么是进程呢进程这个概念可以追溯到上世纪60年代初是麻省理工学院的MULTICS操作系统下提出并引入的。概念是对事物本质的抽象那么进程到底是对何种事物本质的抽象呢
想要解决这个问题,需要我们综合多个视角来理解进程,才能得出一个全面、客观的判断。下面我们从应用程序、资源管理和代码实现这三个角度分别来探讨。
应用程序角度
从我们前面实现的极简shell过程来看进程像极了操作系统制作的一个盒子。这个盒子能装下一个应用程序的指令和数据而应用程序只能在这个盒子里做一些简单的工作盒子之外的工作就要请示操作系统介入并代替应用去完成相应的工作了。
这种盒子操作系统可以制作很多,不同应用各自使用自己的盒子,也可以让操作系统内部使用多个盒子装入同一个应用。其逻辑结构如下图所示:
我们知道应用程序由源代码编译而成,没有运行之前它是一个文件,直到它被装入内存中运行、操作数据执行相应的计算,完成相应的输入输出。从这个层面来看,进程不仅仅类似一个盒子或者容器,更像是一个具有独立功能的程序,是关于某个数据集合的一次运行活动。也就是说,运行状态的进程是动态的,是一个活动的实体。
理论上,操作系统能制造无数个叫作进程的盒子,装入无数道应用程序运行。然而理想是美好的,现实是骨感的,制造进程需要消耗系统资源,比如内存。下面我们就从资源角度继续看看进程是什么。
资源管理角度
在计算机中CPU、内存、网络、各种输入、输出设备甚至文件数据都可以看成是资源操作系统就是这些资源的管理者。
应用程序要想使用这些“资本”,就要向操作系统申请。比方说,应用程序占用了多少内存,使用了多少网络链接和通信端口,打开多少文件等,这些使用的资源通通要记录在案。记录在哪里比较合适呢?当然是代表一个应用程序的活动实体——进程之中最为稳妥。
由此,我们推导出一个运行中的应用程序包含了它分配到的内存,通常包括虚拟内存的一个区域。
我们梳理一下这个区域里存放了哪些内容:
可运行代码;
保存该应用程序运行时中途产生的数据,比如输入、输出、调用栈、堆;
分配给该应用程序的文件描述表、数据源和数据终端;
安全特性,即操作系统允许该应用程序进行的操作,比如应用程序拥有者、应用程序的权限集合。
处理寄存器、MMU页表内容
……-
我还画了一张逻辑结构示意图,如下所示:
结合上图我们发现,进程可以看作操作系统用来管理应用程序资源的容器,通过进程就能控制和保护应用程序。看到这,你可能又产生了疑问,一个进程记录了一个应用运行过程中需要用到的所有资源,那操作系统到底是通过什么样的机制来实现这一点呢?
代码实现角度
在计算机的世界中,不管实现何种功能或者逻辑,首先都要把功能或者逻辑进行数理化,变成一组特定意义的数据,然后把这组数据结构化、实例化,这是实现功能和逻辑的手段和方法。
回到进程的主题上,如果让你实现进程这一功能,你该怎么做呢?
你首先会想到进程包含了什么。刚刚资源管理角度我们分析过进程包含进程id用于标识、进程状态、地址空间用于装载应用程序的代码和数据还有堆和栈、CPU上下文用于记录进程的执行过程、文件描述表用于记录进程使用了哪些资源记住资源也可以抽象为文件、权限、安全等信息。
现在,我们需要把这些信息汇总,变成一个数据结构中的各种字段,或者子数据结构。这个数据结构和许多子数据结构组合在一起,就可以代表一个进程了。
眼见为实我们这就来看看Linux的进程数据结构如下所示
struct task_struct {
struct thread_info thread_info;//处理器特有数据CPU上下文
volatile long state; //进程状态
void *stack; //进程内核栈地址
refcount_t usage; //进程使用计数
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;//采用EDF算法调度实时进程的调度实体
struct sched_info sched_info;//用于调度器统计进程的运行信息
struct list_head tasks;//所有进程的链表
struct mm_struct *mm; //指向进程内存结构
struct mm_struct *active_mm;
pid_t pid; //进程id
struct task_struct __rcu *parent;//指向其父进程
struct list_head children; //链表中的所有元素都是它的子进程
struct list_head sibling; //用于把当前进程插入到兄弟链表中
struct task_struct *group_leader;//指向其所在进程组的领头进程
u64 utime; //用于记录进程在用户态下所经过的节拍数
u64 stime; //用于记录进程在内核态下所经过的节拍数
u64 gtime; //用于记录作为虚拟机进程所经过的节拍数
unsigned long min_flt;//缺页统计
unsigned long maj_flt;
struct fs_struct *fs; //进程相关的文件系统信息
struct files_struct *files;//进程打开的所有文件
struct vm_struct *stack_vm_area;//内核栈的内存区
};
代码中struct开头的结构都属于进程的子数据结构。task_struct数据结构非常巨大为了帮你掌握核心思路我省略了进程的权限、安全、性能统计等相关内容有近 500 行代码,你如果有兴趣,可以点击这里自行阅读。这里你只需要明白,在内存中,一个 task_struct 结构体的实例变量代表一个 Linux 进程就行了。-
接下来我们也瞧一瞧Linux里表示进程内存空间的数据结构也就是在task_struct中mm指针指向的数据结构如下所示
struct mm_struct {
struct vm_area_struct *mmap; //虚拟地址区间链表VMAs
struct rb_root mm_rb; //组织vm_area_struct结构的红黑树的根
unsigned long task_size; //进程虚拟地址空间大小
pgd_t * pgd; //指向MMU页表
atomic_t mm_users; //多个进程共享这个mm_struct
atomic_t mm_count; //mm_struct结构本身计数
atomic_long_t pgtables_bytes;//页表占用了多个页
int map_count; //多少个VMA
spinlock_t page_table_lock; //保护页表的自旋锁
struct list_head mmlist; //挂入mm_struct结构的链表
//进程应用程序代码开始、结束地址,应用程序数据的开始、结束地址
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;
};
不难发现mm_struct结构中包含了应用程序的代码区、数据区、堆区、栈区等各区段的地址和大小其中的 vm_area_struct 结构是用来描述一段虚拟地址空间的。mm_struct 结构中也包含了 MMU 页表相关的信息。
其它数据结构我们就不继续跟踪下来了有兴趣的同学可以自行阅读Linux代码。这里带你观察源码的目的只是为了让你感受一下从抽象概念转化到数据结构的过程从而明白进程是什么——进程在开发人员眼里就是一堆数据结构。
多个进程
我们试想一下如果整个计算机上只运行一道应用程序。我们是不是需要进程这个东西答案是否定的因为此时所有的计算机资源比如CPU内存、IO设备、网络都归这一道应用程序独享。在这种情形下是不需要有进程这种东西的。
但是实际情况是一个应用程序不会同时用到系统中所用资源这就导致单个应用程序对系统资源使用效率不高的问题最常见的情况是CPU你可以回想一下开头的shell。在shell中CPU的速度远大于我们输入命令的速度所以此时CPU必须等待我们的键盘输入。
其实不仅仅是等待键盘CPU还可能在等待磁盘、等待网络、等待声卡具体在等什么取决于应用程序要申请什么资源。既然CPU“工作量”不饱和这个等待的时间我们可不可以让CPU去执行别的应用程序呢
这当然是可行的于是进程这玩意开始提上日程进程的提出就是为了实现多进程让一个进程在等待某一个资源时CPU去执行另外的进程。我们来画一幅图展示这个过程如下所示
可以看到每个进程都有自己的CPU上下文来保护进程的执行状态和轨迹。我们选择一个进程对其CPU上下文保存和加载这样就实现了进程的调度进而演化出各种进程调度算法调度算法的细节我们这里就不详细讨论了先把进程调度的关键思路梳理清楚。
进程调度涉及到给进程设置相应的状态,我们看看通常进程有哪些状态。人有生老病死,进程也是一样。一个进程从建立到运行,还可能因为资源问题不得不暂停运行,最后进程还要退出系统。这些过程,我们称为进程的生命周期。
在系统实现中,通常用进程的状态来表示进程的生命周期。进程通常有五种状态,分别是运行状态、睡眠状态、等待状态、新建状态、僵死状态。其中进程僵死状态,表示进程将要退出系统,不再进行调度。
那么进程的各种状态之间是如何转换的呢?别急,我画一幅图解释一下,如下所示:
上图中已经为你展示了,从进程建立到进程退出过程里,系统各状态之间的转换关系和需要满足的条件。
讲到这,我们就明白了计算机为什么能让我们同时听音乐、聊微信、刷网页了,这正是因为操作系统在背后不断执行进程调度,从一个进程切换到另一个进程。
因为切换的速度很快而且CPU运行速度远高于其他设备的速度才会造成多个应用同时运行的假象。单CPU多进程的前提下一个进程不得不停下来等待CPU执行完其他进程再处理自己的请求实际上同一时刻还是只有一个进程在运行。
多核多进程
时至今日市面上的软件数以百万计用户常用的软件也有成十上百了能同时运行高效率完成各种工作。可是当系统中可运行的进程越来越多CPU又只有一个这时CPU开始吃不消了CPU只好在各种可运行进程间来回切换累得满头大汗。哪怕加大风扇机器依然持续发热但我们仍然感觉电脑很慢有的程序失去响应甚至开始卡顿。
对称多处理器系统
这时硬件工程师们也意识到了问题并着手解决他们开始提升单颗CPU的频率但收益不大而且频率还有上限不能无限提升。
于是工程师开始聚焦在并行计算上让多个进程能真正并行运行起来不是像从前那样一个进程运行一小段时间轮流着来。他们不再琢磨怎样提升频率而是开始拼装CPU把多颗相同的CPU封装在一起形成多核CPU这就是著名的SMP即对称多处理器系统。
SMP是一种应用十分广泛的并行技术它在一个计算机上汇集了一组处理器多CPU各CPU之间共享内存以及总线结构。SMP系统的逻辑结构和实施结构如下图所示
我画的是典型的4核8线程CPU结构请注意上图中的实施结构更接近于真实的情况。一个芯片上封装了四个CPU内核每个CPU内核都有具有相同功能的执行单元在一个执行单元上实现了两个硬件线程这里硬件线程就是一套独立的寄存器由执行单元调度就像是操作系统调度进程。只是这里由硬件完成软件开发人员不知道操作系统开发者只感觉到这是一颗逻辑CPU可以用来执行程序。
多核心调度
SMP系统的出现对应用软件没有任何影响因为应用软件始终看到是一颗CPU然而这却给操作系统带来了麻烦操作系统必须使每个CPU都正确地执行进程。
我们来看看操作系统都需要解决哪些问题?
首先操作系统要开发更先进的同步机制解决数据竞争问题。之前同一时刻下只有一个CPU能运行进程对系统中的全局数据的读写没有任何竞争问题现在不同了同一时间下有多个CPU能执行进程。比如说CPU1执行的进程读写全局数据A时同时CPU2执行进程也在读写全局数据A这就是读写竞争问题会导致数据A状态不一致进而引发更为致命的错误。
为解决这样的问题,操作系统就要开发出原子变量、自旋锁、信号量等高级的同步机制。用这些锁对全局数据进行保护,确保同一时刻只有一个进程读写数据。
解决了数据竞争问题之后我们还得解决进程调度问题这就需要使得多个CPU尽量忙起来否则多核还是等同于单核。让CPU忙起来的方法很简单就是让它们不停地运行进程要让每个CPU都有“吃不消”的感觉。
为此操作系统需要对进程调度模块进行改造。单核CPU一般使用全局进程队列系统所有进程都挂载到这个队列上进程调度器每次从该队列中获取进程让CPU执行。多核下如果使用全局队列需要同步会导致性能下降所以多核下一般是每个CPU核心一个队列如下图所示
多核心系统下每个CPU一个进程队列虽然提升了进程调度的性能但同时又引发了另一个问题——每个CPU的压力各不相同。这是因为进程暂停或者退出会导致各队列上进程数量不均衡有的队列上很少或者没有进程有的队列上进程数量则很多间接地出现一部分CPU太忙吃不消而其他CPU太闲处于饥饿空闲状态的情况。
怎么解决呢这就需要操作系统时时查看各CPU进程队列上的进程数量做出动态调整把进程多的队列上的进程迁移到较少进程的队列上使各大进程队列上的进程数量尽量相等使CPU之间能为彼此分担压力。这就叫负载均衡这种机制能提升系统的整体性能。
进程调度看似简单就是选择一个进程投入运行但里面却有很多利害关系。要知道有些进程很重要需要先运行有些进程对时间要求很高一旦到点就要运行有的进程是IO型的需要及时响应有的进程是计算型需要提高吞吐量。这些问题想通过调度算法解决好是非常复杂的你想了解更多的话可以参考我的上一季课程《操作系统实战45讲》中[Linux进程调度的详细讲解]。
我们从加载应用的shell入手讨论了进程是什么再从单个进程到多个进程最后还聊到了多核心多进程。看到这里我们明白了正是因为进程的存在操作系统才能并发执行多个应用。现在我们概括一下“进程”到底是什么进程是操作系统用于刻画应用程序的运行动态是操作系统用于管理和分配资源的单元是操作系统的调度实体。
重点回顾
学完这节课,我们揭开了计算机支持我们同时刷网页、听音乐和聊微信背后的故事。操作系统支持并行执行应用程序,而并行执行依赖于进程概念的提出和实现。
现在,我为你系统总结一下进程的特性,给今天的课程画一个圆满的句号。
进程具备四大特性。首先是动态特性。进程的本质是程序在操作系统中的一次执行过程进程是动态建立、动态消亡的有自己的状态和生命周期其次是并行特性。任何进程都可以同其他进程一起在操作系统中并行执行尽管在单CPU上是伪并行进程还具备独立特性。进程是操作系统分配和管理资源的独立单元同时进程也是一个被操作系统独立调度和执行的基本实体最后是异步特性。由于进程需要操作系统的资源而被制约使进程具有执行的间断性即进程之间按各自独立的、不可预知的速度向前推进执行。
今天讲的内容有点多,我画了一张导图帮你梳理内容。你也可以自己整理一下思路,把自己最关注的点整理成笔记。
思考题
多个不同的进程可以包含相同的程序吗,为什么?
期待在留言区和你交流,你也可以聊聊自己对进程的理解。如果觉得这节课还不错,别忘了分享给身边更多朋友。

View File

@ -0,0 +1,278 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 应用间通信详解Linux进程IPC
你好我是LMOS。
通过前面的学习,我们对进程有了一定的认知,进程之间是独立的、隔离的,这种安排,使得应用程序之间绝对不可以互相“侵犯”各自的领地。
但是应用程序之间有时需要互相通信互相协作才能完成相关的功能。这就不得不由操作系统介入实现一种通信机制。在这种通信机制的监管之下让应用程序之间实现通信。Linux实现了诸如管道、信号、消息队列、共享内存这就是Linux进程IPC。我们用两节课的时间分别讨论这些通信机制。这节课我们先学习管道和信号。
课程的配套代码,你可以从这里下载。
管道
顾名思义通常管道就是你家一端连接着水池另一端连着水龙头的、能流通水的东西。在Linux中管道作为最古老的通信方式它能把一个进程产生的数据输送到另一个进程。
比方说我们在shell中输入“ls -al / | wc -l”命令来统计根目录下有多少文件和目录。该命令中的“|”就是让shell创建ls进程后建立一个管道连接到wc进程使用ls的输出经由管道输入给wc。由于ls输出的是文本行一个目录或者一个文件就占用一行wc通过统计文本行数就能知道有多少目录和文件。
下面我们手动建立一个管道,代码如下所示:
int main()
{
pid_t pid;
int rets;
int fd[2];
char r_buf[1024] = {0};
char w_buf[1024] = {0};
// 把字符串格式化写入w_buf数组中
sprintf(w_buf, "这是父进程 id = %d\n", getpid());
// 建立管道
if(pipe(fd) < 0)
{
perror("建立管道失败\n");
}
// 建立子进程
pid = fork();
if(pid > 0)
{
// 写入管道
write(fd[1], w_buf, strlen(w_buf));
// 等待子进程退出
wait(&rets);
}
else if(pid == 0)
{
// 新进程
printf("这是子进程 id = %d\n", getpid());
// 读取管道
read(fd[0], r_buf, strlen(w_buf));
printf("管道输出:%s\n", r_buf);
}
return 0;
}
上面的代码是一份代码两个进程父进程经过fork产生了子进程子进程从25行代码开始运行。其中非常重要的是调用pipe函数作用是建立一个管道。函数参数fd是文件句柄数组其中fd[0]的句柄表示读端而fd[1]句柄表示写端。-
我们立马来测试一下,如下图所示:
上图中子进程通过管道获取了父进程写入的信息可是为什么我们通过pipe和fork可以很轻松地在父子进程之间建立管道呢
如果你把管道想象成一个只存在于内存中的、共享的特殊文件,就很好理解了。不过你要注意,该文件有两个文件描述符,一个是专用于读,一个专用于写。我再给你画一幅图帮你梳理逻辑,如下所示:
上图中pipe函数会使Linux在父进程中建立一个文件和两个file结构分别用于读取和写入。调用fork之后由于复制了父进程的数据结构所以子进程也具备了这两个file结构并且都指向同一个inode结构。inode结构在Linux中代表一个文件这个inode会分配一些内存页面来缓存数据。但对于管道文件来说这些页面中的数据不会写入到磁盘。
这也是为什么在应用程序中管道是用文件句柄索引,并使用文件读写函数来读写管道,因为管道本质上就是一个内存中的文件。
和读写文件一样读写管道也有相应的规则当管道中没有数据可读时进程调用read时会阻塞即进程暂停执行一直等到管道有数据写入为止当管道中的数据被写满的时候进程调用write时阻塞直到有其它进程从管道中读走数据。
如果所有管道写入端对应的文件句柄被关闭则进程调用read时将返回0如果所有管道的读取端对应的文件句柄被关闭则会调用write从而产生SIGPIPE信号这可能导致调用write进程退出。这些规则由Linux内核维护应用开发人员不用操心。
如果要写入的数据量小于管道内部缓冲时Linux内核将保证这次写入操作的原子性。但是当要写入的数据量大于管道内部缓冲时Linux内核将不再保证此次写入操作的原子性可能会分批次写入。
这些读写规则都是基于管道读写端是阻塞状态下的情况你可以调用fcntl调用把管道的读写端设置非阻塞状态。这样调用write和read不满足条件时将直接返回相应的错误码而不是阻塞进程。
管道是一种非常简单的通信机制由于数据在其中像水一样从水管的一端流动到另一端故而得名管道。注意管道只能从一端流向另一端不能同时对流。之所以说管道简单正是因为它是一种基于两个进程间的共享内存文件实现的可以继承文件操作的api接口这也符合Linux系统一切皆文件的设计思想。
信号
Linux信号也是种古老的进程间通信方式不过这里的信号我们不能按照字面意思来理解。Linux信号是一种异步事件通知机制类似于计算机底层的硬件中断。
我举个生活化的例子来帮助你理解。比如我们最熟悉的闹钟,闹钟会在既定的时间提醒你“该起床啦”。闹钟发出声音,类似于产生信号,你因为闹钟声音被叫醒,然后关掉闹钟并起床,开始一天的美好生活,这就类似于处理信号。
简单来说信号是Linux操作系统为进程设计的一种软件中断机制用来通知进程发生了异步事件。事件来源可以是另一个进程这使得进程与进程之间可以互相发送信号事件来源也可以是Linux内核本身因为某些内部事件而给进程发送信号通知进程发生了某个事件。
从进程执行的行为来说,信号能打断进程当前正在运行的代码,转而执行另一段代码。信号来临的时间和信号会不会来临,对于进程而言是不可预知的,这说明了信号的异步特性。
下面我们就来小试牛刀,用定时器在既定的时间点产生信号,发送给当前运行的进程,使进程结束运行。代码如下所示:
void handle_timer(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_timer 信号码:%d\n", signum);
printf("进程:%d 退出!\n", getpid());
// 正常退出进程
exit(0);
return;
}
int main()
{
struct sigaction sig;
// 设置信号处理回调函数
sig.sa_sigaction = handle_timer;
sig.sa_flags = SA_SIGINFO;
// 安装定时器信号
sigaction(SIGALRM, &sig, NULL);
// 设置4秒后产生信号SIGALRM信号
alarm(4);
while(1)
{
;// 死循环防止进程退出
}
return 0;
}
上面的main函数中发生了很多事情我们一步一步来梳理。-
第一步main函数中通过sigaction结构设置相关信号例如信号处理回调函数和一个信号标志。接着是第二步安装信号通过sigaction函数把信号信息传递给Linux内核Linux内核会在这个进程上根据信号信息安装好信号。
之后是第三步产生信号alarm函数会让Linux内核设置一个定时器到了特定的时间点后内核发现时间过期了就会给进程发出一个SIGALRM信号由Linux内核查看该进程是否安装了信号处理函数以及是否屏蔽了该信号。确定之后Linux内核会保存进程当前上下文然后构建一个执行信号处理函数的栈帧让进程返回到信号处理函数运行。
我们来运行代码证明一下,如下图所示:
可以看到程序运行起来等待4秒后内核产生了SIGALRM信号然后开始执行handle_timer函数。请注意我们在main函数没有调用handle_timer函数它是由内核异步调用的。在handle_timer函数中输出了信号码然后就调用exit退出进程了。
信号码是什么呢它就是一个整数是一种信号的标识代表某一种信号。SIGALRM定义为14。你可以用kill -l 命令查看Linux系统支持的全部信号。我把常用的一些信号列出来了如下表所示
-
上面都是Linux的标准信号它们大多数来源于键盘输入、硬件故障、系统调用、应用程序自身的非法运算。一旦信号产生了进程就会有三种选择忽略、捕捉、执行默认操作。其实大多数应用开发者都采用忽略信号或者执行信号默认动作这是一种“信号来了我不管”的姿态。
一般信号的默认动作就是忽略有一些信号的默认动作可能是终止进程、终止进程并保存内存信息、停止进程、恢复进程你可以自己对照上表看看具体是哪些信号。还有一些信号比如SIGKILL、SIGSTOP它是不能由应用自己捕捉处理的也不能被忽略只能执行操作系统的默认操作。为什么要这么规定呢
我们想一想如果SIGKILL、SIGSTOP信号能被捕捉和忽略那么超级用户和系统自己就没有可靠的手段使进程终止或停止了。
好,现在我们已经了解了信号的基本知识,知道了信号来源、如何发出信号、以及捕获处理信号。可是我们还不知道要如何给其它进程发送信号,以及如何在信号中传送信息。
下面我们就把前面那个“闹钟”程序升一下级。代码如下所示:
static pid_t subid;
void handle_sigusr1(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_sigusr1 信号码:%d\n", signum);
//判断是否有数据
if (ucontext != NULL)
{
//保存发送过来的信息
printf("传递过来的子进程ID:%d\n", info->si_int);
printf("发送信号的父进程ID:%d\n", info->si_pid);
// 接收数据
printf("对比传递过来的子进程ID:%d == Getpid:%d\n", info->si_value.sival_int, getpid());
}
// 退出进程
exit(0);
return;
}
int subprocmain()
{
struct sigaction sig;
// 设置信号处理函数
sig.sa_sigaction = handle_sigusr1;
sig.sa_flags = SA_SIGINFO;
// 安装信号
sigaction(SIGUSR1, &sig, NULL);
// 防止子进程退出
while (1)
{
pause(); // 进程输入睡眠,等待任一信号到来并唤醒进程
}
return 0;
}
void handle_timer(int signum, siginfo_t *info, void *ucontext)
{
printf("handle_timer 信号码:%d\n", signum);
union sigval value;
// 发送数据,也可以发送指针
value.sival_int = subid; // 子进程的id
// 调用sigqueue向子进程发出SIGUSR1信号
sigqueue(value.sival_int, SIGUSR1, value);
return;
}
int main()
{
pid_t pid;
// 建立子进程
pid = fork();
if (pid > 0)
{
// 记录新建子进程的id
subid = pid;
struct sigaction sig;
// 设置信号处理函数
sig.sa_sigaction = handle_timer;
sig.sa_flags = SA_SIGINFO;
// 安装信号
sigaction(SIGALRM, &sig, NULL);
alarm(4);// 4秒后发出SIGALRM信号
while (1)
{
pause(); // 进程输入睡眠,等待任一信号到来并唤醒进程
}
}
else if (pid == 0)
{
// 新进程
subprocmain();
}
return 0;
}
上面的代码逻辑很简单首先我们在主进程中调用fork建立一个子进程。接着子进程开始执行subprocmain函数并在其中安装了SIGUSR1信号处理函数让子进程进入睡眠。4秒钟后主进程产生了SIGALRM信号并执行了其处理函数handle_timer在该函数中调用sigqueue函数向子进程发出SIGUSR1信号同时传递了相关信息。最后子进程执行handle_sigusr1函数处理了SIGUSR1信号打印相应信息后退出。-
运行结果如下图所示:
上图输出的结果正确地展示了两个信号的处理过程第一个SIGALRM信号是Linux内核中的定时器产生而第二个SIGUSR1信号是我们调用sigqueue函数手动产生的。
sigqueue的函数原型如下所示
typedef union sigval {
int sival_int;
void *sival_ptr;
} sigval_t;
// pid 发送信号给哪个进程就是哪个进程id
// sig 发送信号的信号码
// 附加value值整数或指针
// 函数成功返回0失败返回-1
int sigqueue(pid_t pid, int sig, const union sigval value);
到这里我们就可以总结一下。信号是Linux内核基于一些特定的事件并且这些事件要让进程感知到从而实现的一种内核与进程之间、进程与进程之间的异步通信机制。
我们画一幅图来简单了解一下Linux内核对信号机制的实现如下所示
无论是硬件事件还是系统调用触发信号都会演变成设置进程数据结构task_struct中pending对应的位。这其中每个位对应一个信号设置了pending中的位还不够我们还要看一看blocked中对应的位是不是也被设置了。
如果blocked中对应的位也被设置了就不能触发信号这是给信号提供一种阻塞策略对于有些信号没有用如SIGKILL、SIGSTOP等否则就会触发该位对应的action根据其中的标志位查看是否捕获信号进而调用其中sa_handler对应的函数。
那怎么判断信号最终是不是抵达了呢这会表现为异步调用了进程某个函数。到这里Linux提供的进程间异步通信——信号我们就讲完了。
进程间的通信方法还有消息队列和共享内存,我们下节课再展开。
重点回顾
进程之间要协作就要有进程间通信机制Linux实现了多种通信机制今天我们重点研究了管道和信号这两种机制。
管道能连接两个进程一个进程的数据从管道的一端流向管道另一端的进程。如果管道空了则读进程休眠管道满了则写进程休眠。这些同步手段由操作系统来完成对用户是透明的。shell中常使用“|”在两个进程之间建立管道,让一个进程的输出数据,成为另一个进程的输入数据。
除了管道信号也是Linux下经典的通信方式。信号比较特殊它总是异步地打断进程使得正在运行的进程转而去处理信号。信号来源硬件、系统和其它进程。发送信号时也能携带一些数据。
这节课的要点,我梳理了导图,供你参考。
思考题
请概述一下管道和信号这两种通信机制的不同。
期待你在留言区跟我交流互动,也希望你可以把这节课分享给更多朋友。

View File

@ -0,0 +1,322 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 应用间通信详解Linux进程IPC
你好我是LMOS。
上节课,我们学习了信号和管道这两种通信方法,这节课我们接着看看消息队列和共享内存这两种通信方式。在大型商业系统中,通常会把功能拆分成几大模块,模块以应用形式存在,就需要消息队列和内存共享来使模块之间进行通信和协作,这就是利用通信机制将应用解耦。
这节课的配套代码,你可以从这里下载。话不多说,我们正式开讲吧!
消息队列
消息队列是Linux提供的一种进程间通信方法它能让进程之间互相发送消息。这些消息的形式由进程自己决定可以是文本也可以是二进制的格式可以随意只要另一个进程认识就行。
你可以把消息想象成一个数据记录,并且这个记录具有特定的格式以及特定的顺序。消息队列实质就是一个“收纳”消息的链表,消息会依次挂入这个叫做队列的链表中,一个链表节点就对应着一个消息。
接下来的问题就是,谁有权操作这个消息队列?答案是对这个消息队列有写权限的进程可以向其中插入新的消息;对消息队列有读权限的进程,则可以从其中读出消息。逻辑结构如下图所示:
Linux采用消息队列来实现消息传递新的消息总是放在队列的末尾但接收的时候通常是从队列头开始也可以从中间抽取。发送消息的方式可以是同步的也可以是异步的。在同步方式的情况下发送方在消息队列为满时要进入等待状态。接收方在消息队列为空时也要进入等待状态而异步方式中发送方和接收方都不必等待而是直接返回。
Linux系统下进程间传输消息要分三步走建立消息队列、发送消息、接收消息。
我猜,聪明的你已经发现了,这三步正好就对应着三个接口函数, 代码如下所示:
//获取已经存在的消息队列,或者建立一个新的消息队列
// __key是一个整数可以自己定义
// __msgflg是建立消息队列的标志和权限
//返回-1 表示失败,其他正整数为消息队列标识,像文件句柄一样
int msgget (key_t __key, int __msgflg);
//向__msqid表示的消息队列发送一个新的消息
// __msqid表示消息队列
// __msgp表示消息结构
// __msgsz表示消息大小
// __msgflg同步、异步等标志
//返回-1 表示失败,其他表示发送成功
int msgsnd (int __msqid, const void *__msgp, size_t __msgsz, int __msgflg);
//在__msqid表示的消息队列接收消息
// __msqid表示消息队列
// __msgp表示消息结构用于接收消息内容
// __msgsz表示接收消息大小
// __msgtyp表示接收消息类型
// __msgflg同步、异步等标志
//返回-1 表示失败,其他表示成功接收消息的大小
ssize_t msgrcv (int __msqid, void *__msgp, size_t __msgsz, long int __msgtyp, int __msgflg);
Linux内核运行过程中缓存了所有的消息队列这也是为什么msgget函数能打开一个已经存在的消息队列。只有在Linux内核重启或者显示删除一个消息队列时这个消息队列才会真正被删除。记录消息队列的数据结构struct ipc_ids位于Linux内核中Linux系统中的所有消息队列都能在该结构中访问。
在最新版本2.6以上的版本的Linux中ipc_ids包含在ipc_namespace结构体中而且Linux又定义了一个ipc_namespace结构的全局变量init_ipc_ns用来保存ipc_namespace结构的实例。这里我就不再往下展开了你有兴趣可以自行研究。
现在我们结合实战练练手试着用Linux消息队列机制建立一个“自说自话”的聊天软件。这个聊天软件是这样设计的首先在主进程中建立一个消息队列然后建立一个子进程在子进程中等待主进程发过来的消息并显示出来最后主进程等待用户输入消息并将消息发送给消息队列。
按照这个设计看上去要分成这样三步去实现首先我们需要建立消息队列。具体就是调用msgget函数还要提供一个消息队列的键这个键用于表示该消息队列的唯一名字。当这个键对应的消息队列存在的时候msgget函数将返回该消息队列的标识如果这个队列不存在就创建一个消息队列然后返回这个消息队列的标识。
代码如下所示:
//消息类型
#define MSG_TYPE (041375)
//消息队列键
#define MSG_KEY (752364)
//消息大小
#define MSG_SIZE (256)
int main()
{
pid_t pid;
msgid = msgget(MSG_KEY, IPC_CREAT | 0666);
if (msgid < 0)
{
perror("建立消息队列出错\n");
}
// 建立子进程
pid = fork();
if (pid > 0)
{
}
else if (pid == 0)
{
}
return 0;
}
结合代码我们可以看到msgget函数的__mflg参数是IPC_CREAT | 0666其中的IPC_CREAT表示没有MSG_KEY对应的消息队列就新建一个0666则表示该消息队列对应的权限即所有用户可读写。
接着是第二步实现成功建立消息队列后开始调用fork函数建立子进程。在子进程里什么也没干我们这就来写写子进程的代码如下所示
//消息体
typedef struct Msg
{
long type;
char body[MSG_SIZE];
} msg_t;
//子进程运行的函数 用于接收消息
int receive_main(int mid)
{
msg_t msg;
while (1)
{
ssize_t sz = msgrcv(mid, &msg, MSG_SIZE, MSG_TYPE, MSG_NOERROR);
if (sz < 0)
{
perror("获取消息失败");
}
printf("新消息:%s\n", msg.body);
//判断是exit就退出
if (strncmp("exit", msg.body, 4) == 0)
{
printf("结束聊天\n");
exit(0);
}
}
return 0;
}
我来描述一下这段代码的内容。子进程中在一个循环里调用了msgrcv函数接收mid标识的消息队列中的消息存放在msg结构体中消息的大小和类型都与发送消息一样MSG_NOERROR表示消息太大也不会出错。随后打印出消息内容如果是exit的消息内容则结束子进程。
最后,我们来完成第三步,有了接收消息的代码,还得有发送代码的程序,我们马上写好它,如下所示:
int send_main(int mid)
{
msg_t msg;
while (1)
{
// 设置消息类型
msg.type = MSG_TYPE;
// 获取用户输入并放在消息体中
scanf("%[^\n]%*c", msg.body);
// 发送消息
msgsnd(mid, &msg, MSG_SIZE, 0);
//判断是exit就退出
if (strncmp("exit", msg.body, 4) == 0)
{
return 0;
}
}
return 0;
}
对照代码可以看到发送代码的就是send_main函数这个函数由主进程调用它会在一个循环中设置消息类型后获取用户输入的消息内容并放入msg消息结构体中。然后调用msgsnd函数向mid标识的消息队列发送消息消息来自于msg结构体变量指定MSG_SIZE为消息大小并且以同步方式发送消息。
现在我们调试一下,如下图所示:
你也可以动手验证一下,如果出现跟我截图中相同的结果,就说明调试成功。
这就是Linux系统提供给消息队列机制其作用就是方便进程之间通信让我们轻松地实现一个简单的聊天软件。不过聊天是一种特例更多的时候是进程互相发送消息通知对方记录数据或者要求对方完成某些工作。
现在我们已经明白了消息队列机制是怎么回事Linux的进程间通信机制中还有共享内存这种机制我们继续往下看。
共享内存
进程间通信实则是进程间传输数据为了实现更高效率的通信Linux实现了共享内存这一机制。
共享内存其实是把同一块物理内存映射到不同进程的虚拟地址空间当中不同的进程直接通过修改各自虚拟地址空间当中的内容就可以完成通信。共享内存几乎不需要进行内存数据拷贝就能实现即数据从进程A的虚拟内存空间中写入数据立即就能被进程B感知。其它的进程间通信机制需要经过Linux内核这种中转站进行多次的数据拷贝操作才可以。因此使用共享内存通信比较高效。
Linux内核提供了两种共享内存的实现一种是基于物理内存映射另一种是基于mmap文件映射这个mmap函数我们在前面的课程中多次见过了你可以回顾之前的课程。
这里我们仅仅讨论基于物理内存映射的实现它与消息队列很像。Linux内核会建立一个shmid_kernel结构通过ipc_namespace结构的全局变量init_ipc_ns结构就能找到系统中所有的shmid_kernel结构。该shmid_kernel结构会关联到一组物理内存页面最后这组物理内存页面会映射到各自进程虚拟内存空间中的相关区域。
基于物理内存映射的实现方式,大致逻辑如下图所示:
Linux系统下进程间共享内存也分两步分别是建立共享内存区和绑定进程内存区然后就可以读写共享内存了。
这两步对应两个接口函数,代码如下所示:
//获取已经存在的共享内存,或者建立一个新的共享内存
// __key是一个整数可以自己定义
// __size是建立共享内存的大小
// __shmflg是建立共享内存的标志和权限
//返回-1 表示失败,其他正整数为共享内存标识,像文件句柄一样
int shmget (key_t __key, size_t __size, int __shmflg);
// 绑定进程内存地址到__shmid的共享内存
// __shmid表示建立的共享内存
// __shmaddr绑定的地址传NULL则系统自动分配
// __shmflg是绑定地址区间的读写权限
// 返回-1,表示失败,其它是成功绑定的地址
void *shmat (int __shmid, const void *__shmaddr, int __shmflg);
// 解除绑定内存地址
// __shmaddr为之前绑定的地址
// 返回-1,表示失败
int shmdt (const void *__shmaddr)
有了几个接口,我们就来写代码测试一下。我们依然采用建立两个进程的方式,在主进程中写入共享内存,在子进程中读取共享内存,但是我们首先要在主进程中建立共享内存。
我们马上写代码实现它们,如下所示:
#define SHM_KEY (752364)
#define SHM_BODY_SIZE (4096-8)
#define SHM_STATUS (SHM_BODY_SIZE)
typedef struct SHM
{
long status;
char body[SHM_BODY_SIZE];
} shm_t;
int main()
{
pid_t pid;
// 建立共享内存
shmid = shmget(SHM_KEY, sizeof(shm_t), IPC_CREAT | 0666);
if (shmid < 0)
{
perror("建立共享内存出错\n");
}
// 建立子进程
pid = fork();
if (pid > 0)
{
// 主进程
send_main(shmid);
}
else if (pid == 0)
{
// 新的子进程
receive_main(shmid);
}
return 0;
}
上述代码中调用了shmget函数传入了IPC_CREAT表示没有SHM_KEY对应的共享内存就建立一块共享内存大小为shm结构体的大小。
建立好共享内存就可以开始创建子进程了创建成功后主进程开始执行send_main函数子进程运行receive_main函数。下面我们开始编写这两个函数
int receive_main(int mid)
{
// 绑定共享内存
int ok = 0;
shm_t* addr = shmat(mid, NULL, 0);
if ((long)addr < 0)
{
perror("绑定共享内存失败\n");
}
printf("子进程访问共享内存的地址:%p\n", addr);
while (1)
{
if(addr->status == SHM_STATUS)
{
for (int i = 0; i < SHM_BODY_SIZE; i++)
{
if (addr->body[i] != (char)0xff)
{
printf("检查共享数据失败:%x\n", addr->body[i]);
}
else
{
ok++;
}
}
printf("检查共享数据成功:%d\n", ok);
return 0;
}
sleep(2);
}
return 0;
}
int send_main(int mid)
{
// 绑定共享内存
shm_t* addr = shmat(mid, NULL, 0);
if ((long)addr < 0)
{
perror("绑定共享内存失败\n");
}
printf("主进程访问共享内存的地址:%p\n", addr);
memset(addr, 0xff, sizeof(shm_t));
// 相当于同步通知子进程数据已经写入
addr->status = SHM_STATUS;
// 等待子进程退出
wait(NULL);
return 0;
}
对照代码可以看到两个函数都是调用shmat函数它们为各自进程绑定了一个虚拟内存地址并基于该地址访问共享内存。
在send_main函数中先把共享的内存写入0xff。最后设置 status 字段用来同步因为Linux对共享不提供任何同步机制所以需要我们自己处理。receive_main函数中会循环检查status 字段如果是SHM_STATUS就对addr->body中的数据进行一个个检查并且记录检查结果。
我们来看看执行结果,如下图所示:
上图中的结果证明了我们的设计和预期相符,我们只要往共享内存中写入数据,其它进程立马就感知到了,并且得到了数据。
这就是共享内存的妙处,通过把物理内存页面映射到多个进程的虚拟内存中,使其访问相同的物理内存,数据不需要在各进程之间复制,这是一种性能非常高的进程间通信机制。
重点回顾
课程告一段落,我们做个总结。
进程之间要协作就需要进程之间可以进行通信。为此Linux实现了多种通信机制这节课我们主要探讨了消息队列和共享内存。
消息队列能使进程之间互相发送消息,这些消息的形式格式可以随意设定。从数据结构的角度看,消息队列其实是一个挂载消息的链表。发送消息的进程把消息插入链表,接收消息的进程则从链表上获取消息。同步手段由内核提供,即消息链表空了则接收进程休眠,消息链表满了发送进程就会休眠。
共享内存的实现是把同一块物理内存页面,映射到不同进程的虚拟地址空间当中,进程之间直接通过修改各自虚拟地址空间当中的内容,就能完成数据的瞬间传送。一个进程写入修改了共享内存,另一个进程马上就可以感知到。
不知道你是不是已经发现了一个问题,这些进程通信方式,只能用于本机进程间通信,不能用于远程通信,如果需要让计算机之间的进程远程通信,就需要使用套接字。套接字是一种网络通信编程接口,有兴趣的同学可以自己了解一下。
这节课的导图如下,供你参考:-
好,今天的课程讲完了,我们下一次再见。
思考题
进程间通信哪些是同步的,哪些是异步的?
期待你在留言区跟我交流互动,也推荐你把这节课分享给更多朋友,共同进步。

View File

@ -0,0 +1,142 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 外设通信IO Cache与IO调度
你好我是LMOS。
从这节课开始我们进入IO相关基础知识的学习想要开发高性能的应用程序这些基础知识必不可少。
前面的课程里我们已经对进程和内存有了一定了解。进程在运行时刻和CPU是紧密相关的抽象出进程就是为了提高CPU的利用率。因此我们关注进程和内存等同于关注CPU和RAM。
一个计算机系统无论是PC还是手机除了有CPU和RAM还有各种外设如键鼠、硬盘、显卡、以太网卡、声卡等各种USB扩展设备。
这些设备独立在CPU和内存之外统称为外设。但是外设通信的速度、大小、数据类型和传输方式各不相同所以为了实现系统的整体效率最大化操作系统实现了IO Cache和IO调度。今天我们就来研究它们。
IO Cache
顾名思义Cache即为缓存IO是指令外设传输IN/OUT数据的操作。
缓存是怎么回事我们都知道由此我们就可以这样理解IO Cache把外设的IO操作的数据保存起来当重新执行IO操作时先从之前保存的地方开始查找若找到需要的数据即为命中这时就不要去操作外设了若没有命中就去操作外设。其中的数据会根据IO操作频率进行组织把操作最频繁的内容放在最容易找到的位置达到性能最优化。
我们在终端中输入如下命令,感受一下
free -m
该命令是用来显示Linux系统上内存的使用情况的单位以MB计。
输入这条命令,我们会得到如下图所示的情况:
上图中的buff/cache就是我们所说的IO Cache占用的内存。从这个角度是不是看得更透彻了所谓IO Cache不过是操作系统基于某种算法管理的一块内存空间用该内存空间缓存IO设备的数据应用多次读写外设数据会更方便而不需要反复发起IO操作。
其实早期的Cache是位于CPU和内存之间的高速缓存由于硬件实现的Cache芯片的速度仅次于CPU而内存速度远小于CPUCache只是为了缓存内存中的数据加快CPU的性能避免CPU等待内存。而Buffer是在内存中由软件实现的用于缓存IO设备的数据缓解由于IO设备过慢带来系统性能下降。
但是现在Buffer和Cache成了在计算机技术中被用滥的两个名词。在Linux的内存管理中Buffer指Linux内存的Buffer Cache而Cache是指Linux内存中的Page Cache翻译成中文可以叫做缓冲区缓存和页面缓存用来缓存IO设备的读、写数据。补充一句这里的IO设备主要指的是块设备文件和文件系统上的普通文件。
在当前的Linux内核中BufferCache建立Page Cache之上如下图所示
在现代Linux的实现中远比上图画得要复杂得多不过我们只需要关注这个层次结构就行了。Buffer Cache中有多个小块组成块大小通常为512字节在Linux内核中用一个struct Bio结构来描述块而一个物理内存页中存在多个块多个struct Bio结构形成Buffer Cache多个这种页就形成了Page Cache。
在操作系统理论中这一套实现机制被抽象为IO Cache。但是各种操作系统的实现的叫法不同在此不必展开了我们只需要明白它们能在内存中缓存设备数据就行了。
我们明白了Buffer Cache和Page Cache的概念下面我们以Linux读写硬盘的过程为例研究一下IO操作时IO Cache发挥的作用。
一般情况下Linux内核中的IO操作会从上至下经过三大逻辑层具体如下
文件系统层。因为Linux中万物皆为文件IO操作首先会经过文件系统Linux为了兼容不同的文件系统对文件、目录等文件系统对象进行了抽象形成了VFS层也是IO操作经历的第一层。
块层。Linux内核把各种设备分成块设备字符设备、网络设备和硬盘都属于块设备块层主要负责管理块设备的 IO 队列,对 IO 请求进行合并、排序等操作。
设备层。具体设备驱动通过 DMA 与内存交互,完成数据和具体设备之间的交换,此例子中的设备为硬盘。
我们画一幅图,表示一下这个过程:
IO操作在到达Linux的VFS层后会根据相应的IO操作标志确定是DirectIO还是BufferedIO如果是前者则不经过Cache直接由块层发送到设备层完成IO操作如果是后者则IO操作到达Page Cache之后就返回了。
在某一时刻Linux会启动pdflush线程该线程会扫描PageCache中的脏页进而找到对应的Bio结构然后把Bio结构发送给块层的IO调度器调度器会对bio进行合并、排序以提高IO效率。
之后调用设备层的相关函数将Bio转发到设备驱动程序处理设备驱动程序函数对IO请求队列中每个Bio进行分别处理根据Bio中的信息向磁盘控制器发送命令。处理完成后调用Bio完成函数以通知上层完成了操作。这便是一个IO操作的过程。
IO调度
在前面我们已经明白了IO Cache的概念它本质是把IO操作的数据保存在内存中使得在读取外设数据时能直接从内存中读取或者数据缓存到一定量时由一个特定任务在以后的某个时间批量地写入外设这不但会提高系统整体吞吐量还能保护设备以延长寿命。
我们把IO操作缓存起来了这样操作系统就对IO操作有了控制权具体点说就是可以对IO操作进行调度。
我先不直接说明IO调度是干什么的先结合例子带你一起分析看看。我们从软件层面来看一个场景假如一个应用程序往硬盘中写入1GB大小的文件但是这个应用程序很调皮它每次只写入一个字节。如果没有 IO Cache和IO调度可以想见这需要发生多少次IO操作才能完成如果硬件能说话估计要骂人。
再来说说硬件自己结构的问题,这里以机械硬盘为主。千万不要感觉机械硬盘已经淘汰了,其实在很多服务器上仍然大量使用它。硬盘结构如下所示:
一个硬盘中有多个盘片一个盘片上有多个同心圆组成的多条磁道每条磁道上有多个扇区一个扇区512字节磁头来回移动经过多个同心圆形成的柱面定位到一个扇区。很显然找到一个扇区花费的时间等于磁头移动时间加上盘片旋转的时间。这些运动都是机械运动是非常缓慢的。
以上两个场景提醒我们有两个问题需要考虑一是怎么降低IO操作次数二是如何优化硬盘寻址。这两个问题解决好了都能大大提升系统性能。想解决第一个问题我们可以对IO操作进行缓存和合并而对于第二个问题我们可以对IO操作进行排序能让硬盘磁头按照一定的顺序定位扇区解决这些问题的就是IO调度器。
有了IO调度器还得有相应的调度算法IO调度器提供了多种调度算法来适应不同的IO请求场景。有的场景需要的是提高IO吞吐量比如数据库后台的储存引擎有的场景则是要降低IO响应时间比如游戏应用程序。
我们先看看第一种调度算法该算法名为Noop。Noop是最简单的IO调度算法其实可以说它是没有“调度”的IO调度因为Noop会把所有的IO请求几乎按照先来后到的顺序放入先进先出队列之中。
之所以说“几乎”是因为Noop在先进先出队列的基础上还做了相邻IO操作的合并而不是完完全全按照先进先出的规则满足IO操作。我来给你画一幅图展示一下这个算法实施的操作如下所示
一个个BIo结构进入Noop IO调度器产生request结构这个结构中包含Bio链表。Noop IO调度器把扇区相邻的Bio合并在一起形成request结构然后将requset结构挂载到块设备的requset_queue中块设备通常是你的硬盘。
然后我们来看看第二种调度算法该算法名为CFQ全称为Completely Fair Queuing。由于传统的机械硬盘上硬盘寻址花去了绝大多数的IO操作的时间所以要优化硬盘寻址所花的时间。
CFQ调度器的出发点就是对IO操作扇区地址进行排序比如硬盘旋转到1号扇区很快就旋转到2号扇区如果你先访问2号扇区再次访问1号扇区则要等到硬盘旋转一周后才能到达1号扇区。CFQ调度器对其进行排序后就能通过尽量少的硬盘旋转次数来满足尽可能多的IO操作。CFQ调度器算法执行逻辑如下图所示
我们看到在CFQ调度器下将多个BIO结构生成requset结构时会按照扇区地址升序挂载到块设备的requset_queue中这会使机械硬盘的吞吐量大大提高。
相比Noop调度器不知道你有没有发现一个问题先来的IO操作并不一定能被满足还可能会出现饿死的情况。比如先来一个IO操作扇区地址是1000然后不停地进入扇区地址小于1000的IO操作就会出现饿死现象。
我们来看一看最后一种IO调度算法该算法名为DeadlineDeadline调度器提供了两个红黑树以及两个先进先出队列两个红黑树分别对读、写的IO操作按照其扇区地址排序同时给每个IO操作添加超时时间并插入到对应的读、写先进先出的队列尾部。这样一来一个IO操作会同时挂在红黑树和先进先出队列中。
当Deadline调度器在发送一个IO操作时会综合考虑IO操作是否超时、是否饥饿由此决定到底发送哪个IO操作发送IO操作之后会将该IO操作同时在红黑树和先进先出队列中删除。
我来画一幅图,展示一下这个算法实施的操作,如下所示:
上图中读写队列分开同时用红黑树对其排序而且还加入了超时机制。硬盘驱动会找Deadline IO调度器获取IO requestDeadline IO调度器根据这些数据结构和算法分配request完美地解决了CFQ IO调度器的缺陷由于读写分开且读优先于写导致该算法非常适合数据库这种随机读写的场景。
我们发现IO调度器算法多种多样那么要怎么选择呢
其实选择IO调度器算法既要考虑硬件特性也要考虑应用程序场景。在传统的机械硬盘上CFQ、Deadline算法是不错的选择对于专属的数据库服务器Deadline IO调度器的IO吞吐量和IO响应时间综合性能都表现非常好。
然而在新兴的固态硬盘比如SSD、NVMe上最简单的NOOP IO调度器反而是最好的IO调度器。因为CFQ和Deadline调度算法最主要是为缩短机械硬盘寻址时间而优化的而固态硬盘没有所谓的机械运动寻址部件需要的时间而且很快能准备好数据所以IO响应时间非常短。
重点回顾
今天我们一起学习了外设通信中的重要组件——缓存它主要是在内存中开辟一大空间来暂时保存与外设通信的大量数据。这一点我们通过在Linux上输入free命令已经看到其实其它操作系统也具有类似机制这里我们只是以Linux为例子。
为了搞明白IO Cache的概念我们从Linux的缓存结构入手发现Linux用物理内存页面为基础建立了Page Cache。在这个Page Cache之上又建立了Buffer CacheBufferCache组织了传输到IO设置上的数据块。我们通过对IO流程的探讨发现IO操作可以不经过IO Cache而是直接到达设备。
之后我们对软件场景和硬盘结构进行了讨论发现有了IO Cache以后还需要对IO请求进行调度才能使IO效率最大化针对不同的场景有不同IO调度器我们重点讨论了三种IO调度算法分别是Noop、CFQ、Deadline其中综合性能最好的是Deadline。然而硬件技术的升级又产生了固态硬盘导致这些IO调度器没有了用武之地不调度就是最好的调度。
这节课的导图如下所示:
思考题
操作系统为什么要开发 IO Cache
欢迎你在留言区和我交流讨论,如果觉得这节课对你有启发,别忘了分享给更多朋友!

View File

@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 IO管理Linux如何管理多个外设
你好我是LMOS。
在上一节课中我们通过对IO Cache的学习知道了IO Cache缓存了IO设备的数据这些数据经过IO 调度器送给块层进而发送给IO设备。
今天我们再往下一层探索以Linux为例看看Linux是如何管理多个IO外设的。我们先从例子出发了解一下设备在Linux中的与众不同然后看看设备分类及接口分析一下应用开发人员应该如何使用它们最后我会带你一起实现一个设备加深理解。
这节课的配套代码,你可以从这里下载。话不多说,我们开始吧。
文件和外设的关系
用几十行代码在Linux上读写一个文件我们都很熟悉吧。若是不熟悉百度、谷歌都可以让我们熟悉。
我们今天要写的这个小例子就是从读取一个文件开始的。想要读取文件首先得知道文件在哪里也就是需要知道文件路径名知道了文件路径名再进行“三步走”就可以打开它、读取它、关闭它。一句话open、read、close一气呵成。
那么这个文件是什么呢,路径名如下所示:
"/dev/input/event3"
看了路径名我们知道enent3文件在根目录下dev目录的input目录之下。从名称上看这好像与设备、输入、事件有关系我这里先卖个关子看完后面的讲解你自然就知道答案了。
我们先来搞清楚读取这个文件能得到什么数据,读取该文件得到的不是一个字符流,而是由时间、类型、码值以及状态数据封装成的一个结构。每读取一次,就能得到一个这样的结构,该结构如下所示:
struct input_event {
struct timeval time; //时间
__u16 type;
__u16 code;
__s32 value;
};
这个结构看上去好像是某个事件的信息,或者产生的数据。-
现在我们知道了读什么文件,也知道了读取该文件能得到什么样的内容,接下来我们立刻编写代码练练手。让我们写代码来实现读写”/dev/input/event3”文件如下所示
#define KB_DEVICE_FILE "/dev/input/event3"
int main(int argc, char *argv[])
{
int fd = -1, ret = -1;
struct input_event in;
char *kbstatestr[] = {"弹起", "按下"};
char *kbsyn[] = {"开始", "键盘", "结束"};
//第一步:打开文件
fd = open(KB_DEVICE_FILE, O_RDONLY);
if (fd < 0)
{
perror("打开文件失败");
return -1;
}
while (1)
{
//第二步读取一个event事件包
ret = read(fd, &in, sizeof(struct input_event));
if (ret != sizeof(struct input_event))
{
perror("读取文件失败");
break;
}
//第三步解析event包
if (in.type == 1)
{
printf("------------------------------------\n");
printf("状态:%s 类型:%s 码:%d 时间:%ld\n", kbstatestr[in.value], kbsyn[in.type], in.code, in.time.tv_usec);
if (in.code == 46)
{
break;
}
}
}
//第四步:关闭文件
close(fd);
return 0;
}
上述代码逻辑很简单,首先打开了/dev/input/event3这个文件然后在一个循环中反复读取该文件并打印出数据读取错误和码值等于46时就跳出循环最后关闭该文件程序退出。
接下来就是测试环节。我们用VSCode打开对应的工程目录编译一下然后运行。效果如下所示
你按下键盘上一个键,终端中它就会输出一行,松开键又会输出一行,输出“按下”或“弹起”的状态、键盘码以及按下弹起所花费的时间,这些数据精确地反映了键盘按键动作。
一个文件就能反映键盘的动作和数据,难道不奇怪吗?你是不是猛然醒悟了,原来/dev/input/event3这个文件就代表键盘这个文件是特殊的设备文件访问这种文件就是访问IO硬件设备。其实dev目录下全部的文件都是设备文件不知道你的脑海中是不是浮现出了熟悉的Linux设计哲学——一切都是文件。
你可以在dev目录下找到系统的所有设备它们都是以文件的形式存在的。从这种角度看这里的文件是抽象的是一种资源对象的标识。从上面的例子我们也可以看出设备的操作完全符合文件的操作方式。设备输入、输出数据的操作对应了文件的读写设备的启动或者停止则对应文件的打开或关闭。
说到这,你可能要反对我了:设备的操作不只是输入输出数据,还有设置设备功能、配置设备电源等操作么?例如设置声卡音量、设置处理器进入待机状态以减少功耗等等。
可是你别忘了文件还有一个操作——ioctrl通过它来给设备发送命令或者设置相关功能。这样一个设备的所有操作就和文件对上了。不过可不要想着用这种方案干坏事哦比如获取别人输入的敏感信息。
设备分类
设想一下,你需要管理你家里的日常用品,你通常会怎么做?你是不是首先会对这些物品进行分类。你可能会按物品的功能用途分类,也可能按物品归属于哪位家庭成员来分类。
对于Linux这个计算机大总管也是如此什么设备有什么功能、是用来做什么的、有多少个这种类型的设备、它们接入系统的方式是什么……这些信息Linux都需要了解得非常清楚才可以。
在了解Linux如何对设备进行分类之前我们应该先了解一下常规情况下系统中都有哪些设备。我为你画了一幅图如下所示
上图是一个典型的计算机系统,你先不管物理机器的结构和形式,逻辑上就是这样的。实际情况可能比图中有更多或者更少的总线和设备。
各种设备通过总线相连。这里我们只需要记住计算机中有很多的设备Linux 会把这些设备分成几类,分别是:网络设备、块设备、字符设备、杂项设备以及伪设备。具体情况你可以参考我后面梳理的示意图:
我们先来看看网络设备。网络设备在Linux上被抽象成一个接口设备相当于网线插口任何网络通信都要经过网络接口。接口就是能与其他主机交换数据的设备像是电子信号从网口流到另一个网口一样。
Linux使用一套传输数据包的函数来与网络设备驱动程序通信它们与字符设备和块设备或者文件的read()和write()接口不同所以网络设备在Linux中是一个独特的存在。
一般情况下接口对应于物理网卡但也可能是纯软件实现的比如输入ifconfig命令查看网口时会输出一个eth0、一个lo等信息lo就是网络回环loopback接口。Linux会给每个网络接口分配一个唯一的名字比如eth0、eth1等方便其它软件访问这些接口但这个名字在文件系统中并没有对应的文件名。
然后我们来看看块设备块设备这种设备类型也是Linux下的一个大类。块设备的特点是能按一块一块的方式传输数据而且能随机访问设备中的任一地址具体是通过/dev目录下的文件系统节点来访问。常见的块设备包括硬盘、flash、ssd、U盘、SD卡等。
块设备上通常能够安装文件系统即能被格式化。比如你的机器上有一块硬盘硬盘上有4个分区。那么在Linux系统中的表现就是这样的
这些设备文件可以像访问普通文件一样使用你只要计算好硬盘地址就能把数据写入到硬盘扇区中。比方说我们可以用cat /dev/sda1 > sda1.bk 命令,对硬盘的分区一进行备份。
然后我们来看看字符设备。字符设备也是Linux下的一个基础类设备比如键盘、鼠标串口声卡等都属于字符设备。字符设备是顺序访问的不能随机访问它只能像是访问字符数据字节流一样被访问只有在设备响应后才能读到相应信息这些功能由设备驱动程序保证和维护。
字符设备的驱动程序通常要实现打开、关闭、读取和写入回调函数供Linux使用。Linux会将应用程序中的调用转发给设备驱动程序的回调函数。字符设备的对应的文件名都在/dev目录下每一个文件对应一个字符设备或者块设备。
我们在/dev目录下可以使用ls -l命令查看详细信息第一个字母为“c”的即为字符设备文件第一个字母为“b”的即为块设备文件。
最后我们说说杂项设备和伪设备它们都是基于字符设备实现的本质上是属于字符设备。而伪设备则与其它设备不同它不对应物理硬件只是通过软件实现了一些功能比如读取random设备能产生一个随机数再比如把数据写入null设备数据会有去无回直接被丢弃还有通过读取kmsg设备获取内核输出的信息。
现在我们已经搞清楚了Linux是根据设备传输数据大小和传输方式来对设备进行分类的下面我们就可以亲手去创造一个设备了。
创造一个设备
一个再普通不过的计算机系统中也有种类繁多的设备。每种设备都有自己的编程控制方式所以Linux内核才用分而治之的方法把控制设备代码独立出来形成内核驱动程序模块。
这些驱动程序模块由驱动开发人员或设备厂商开发会按照Linux内核的规则来编写并提供相应接口供Linux内核调用。这些模块既能和Linux内核静态链接在一起也能动态加载到Linux内核这样就实现了Linux内核和众多的设备驱动的解耦。
你可能已经想到了一个驱动程序既可以是Linux内核的一个功能模块也能代表或者表示一个设备是否存在。
我们不妨再思考一个问题Linux内核所感知的设备一定要与物理设备一一对应吗
我们拿储存设备来举例,其实不管它是机械硬盘,还是 TF 卡或者是一个设备驱动程序它都可以向Linux内核表明它是储存设备。但是它完全有可能申请一块内存空间来储存数据不必访问真正的储存设备。所以Linux内核所感知的设备并不需要和物理设备对应这取决于驱动程序自身的行为。
现在我们就知道了创造一个设备等同于编写一个对应驱动程序。Linux内核只是和驱动程序交互而不需要系统中有真实存在的物理设备只要驱动程序告诉Linux内核是什么设备就行。
明白了驱动程序的原理我们这就来写一个驱动程序。先从Linux内核模块框架开始吧代码如下所示
#include <linux/module.h>
#include <linux/init.h>
//开始初始化函数
static int __init miscdrv_init(void)
{
printk(KERN_EMERG "INIT misc dev\n");
return 0;
}
//退出函数
static void __exit miscdrv_exit(void)
{
printk(KERN_EMERG "EXIT,misc\n");
}
module_init(miscdrv_init);
module_exit(miscdrv_exit);
//版权信息和作者
MODULE_LICENSE("GPL");
MODULE_AUTHOR("LMOS");
你看不到20行代码就构成了一个Linux内核模块。
从这个例子我们可以发现一个内核模块必须要具备两个函数一个是开始初始化函数在内核模块加载到Linux内核之后。首先就会调用该函数它的作用通常是创造设备另一个是退出函数内核模块退出到Linux内核之前首先就会调用该函数用于释放系统资源。
有了Linux内核模块之后我们现在还不能调用它这是因为我们没有创造设备对应用程序而言是无法使用的。那么怎么创建一个设备呢
Linux内核的驱动框架为我们提供了接口和方法只需要按照接口标准调用它就行了。这里我们需要创造一个杂项设备就需要调用misc_register函数。我们只要给这个函数提供一个杂项设备结构体作为参数就能在Linux内核中创造并注册一个杂项设备。
代码如下所示:
#define DEV_NAME "miscdevtest"
//文件操作方法结构体
static const struct file_operations misc_fops = {
.read = misc_read, //读回调函数
.write = misc_write, //写回调函数
.release = misc_release, //关闭回调函数
.open = misc_open, //打开回调函数
};
//杂项设备结构体
static struct miscdevice misc_dev = {
.fops = &misc_fops, //设备文件操作方法
.minor = 255, //次设备号
.name = DEV_NAME, //设备名/dev/下的设备节点名
};
static int __init miscdrv_init(void)
{
misc_register(&misc_dev);//创造杂项设备
printk(KERN_EMERG "INIT misc dev\n");
return 0;
}
对照这段代码我们看到Linux用一个miscdevice结构体表示一个杂项设备其实它内部包含了用于表示字符设备的cdev结构体所以杂项设备就是字符设备。
其实miscdevice结构体还有很多成员不过那些我们不用处理只需要设置以下三个成员就行了一是设备文件操作方法结构它是一些函数指针二是次设备号我们设置成最大值即255让系统自动处理三是设备名称就是在dev目录下的文件名。
完成上述操作最后只要在Linux内核模块的初始化miscdrv_init函数中调用misc_register函数就行了。
这里比较重要的是文件操作方法结构体中的回调函数它们是完成设备功能的主要函数应用程序对设备文件的打开、关闭、读、写等操作都会被Linux内核分发调用到这些函数。
举例来说在打开函数中你可以让设备加电工作起来而在读、写函数中你可以向设备传输数据。Linux内核并不在意你在这些函数做了什么也不在乎这些操作是不是直接作用于物理设备Linux内核只在乎是否有这些函数或者这些函数的执行状态是什么。
下面我们就来写好这些函数,如下所示:
//读回调函数
static ssize_t misc_read (struct file *pfile, char __user *buff, size_t size, loff_t *off)
{
printk(KERN_EMERG "line:%d,%s is call\n", __LINE__, __FUNCTION__);
return 0;
}
//写回调函数
static ssize_t misc_write(struct file *pfile, const char __user *buff, size_t size, loff_t *off)
{
printk(KERN_EMERG "line:%d,%s is call\n", __LINE__, __FUNCTION__);
return 0;
}
//打开回调函数
static int misc_open(struct inode *pinode, struct file *pfile)
{
printk(KERN_EMERG "line:%d,%s is call\n", __LINE__, __FUNCTION__);
return 0;
}
//关闭回调函数
static int misc_release(struct inode *pinode, struct file *pfile)
{
printk(KERN_EMERG "line:%d,%s is call\n", __LINE__, __FUNCTION__);
return 0;
}
上述各种操作的回调函数非常简单都只调用了printk函数打印内核log这些log信息可以在/dev/kmsg设备文件中读取。
为了测试这个设备能否正常工作,我们还要写个应用程序对其访问,即对其进行打开、读、写、关闭这些操作,代码如下所示:
#define DEV_NAME "/dev/miscdevtest"
int main(void)
{
char buf[] = {0, 0, 0, 0};
int i = 0;
int fd;
//打开设备文件 O_RDWR, O_RDONLY, O_WRONLY,
fd = open(DEV_NAME, O_RDWR);
if (fd < 0)
{
printf("打开 :%s 失败!\n", DEV_NAME);
}
//写数据到设备
write(fd, buf, 4);
//从设备读取数据
read(fd, buf, 4);
//关闭设备 可以不调用,程序关闭时系统自动调用
close(fd);
return 0;
}
我替你把所有的代码都准备好了可以从课程配套代码获取我们在工程目录下make一下就可以编译好了。成功编译后你会得到一个miscdrv.ko这是编译好的Linux内核模块文件还有一个是App文件这个是应用程序。
我们在测试之前先打开一个终端在其中输入sudo cat /dev/kmsg以便观察结果。然后再打开一个终端在其中输入sudo insmod miscdrv.ko把miscdrv.ko这个Linux内核模块安装加载到系统中。加载好了我们输入sudo app就可以看结果了如下图所示
通过截图,我们看到右边终端通过读取/dev/kmsg设备输出了正确的结果这说明我们的设备工作正常。只不过我们这个设备没有完成任何功能也没有对应真正的物理设备但是却真实地反映了设备的工作流程。
到这里我们已经理解了Linux管理设备的核心机制贯彻一切皆文件的思想Linux内核会在相应目录下建立特殊的文件节点用文件的形式表示一个设备。而内核操控设备的方式实质上就是把文件操作转发给对应的设备驱动程序回调函数来处理。
重点回顾
今天的课程就要结束了,现在我们一起来回顾一下今天的重点。
首先我们从一个例子开始写下了一个读取文件的应用程序。运行之后我们一按下键盘应用程序就能获取键盘数据这证明了我们读取的文件是一个设备间接地证明了Linux以文件的方式管理设备操作设备与操作文件相同。
然后我们一起探讨了Linux设备类型还分析了不同设备的特性。Linux按照设备的工作方式和数据传输类型对市面上的各种设备做了分类分成了字符设备、块设备、网络设备、杂项设备和伪设备。
最后我们创造了一个杂项设备了解了Linux如何感知设备、又是如何让应用程序访问到设备的。我们发现Linux用文件节点关联了Linux内核驱动程序模块为了操控设备内核会转发应用程序对文件的操作以此来调用驱动程序中的回调函数。
这就是Linux管理多个IO设备的方式但是Linux驱动模型远比今天课程所介绍的复杂得多其中还有支持总线和支持设备热拔插的机制。如果你想详细了解Linux驱动模型的实现可以阅读我的上一季课程《操作系统实战 45 讲》中的第二十八节课到三十一节课。
思考题
请问Linux网络通信的接口是什么
期待你在留言区聊聊你的学习收获或者提出疑问,如果觉得这节课还不错,别忘了分享给身边更多的朋友。

View File

@ -0,0 +1,350 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 lotop与lostat命令聊聊命令背后的故事与工作原理
你好我是LMOS。
前面的课程里我们学习了IO Cache、IO调度和IO管理的相关知识但怎样度量和检测一个应用使用IO的情况呢我们今天就来聊聊这个问题。
这节课我想带你认识两大监控IO操作的神器——iostat与iotop让你掌握安装、使用它们的方法以及它们的工作原理。在Linux系统上iostat和iotop这两个IO数据工具非常常用。它们都是性能分析领域中不可缺少的工具性软件也经常被Linux网络服务器运维人员用于分析某些服务器的IO类性能与故障。
安装iostat与iotop
在带你安装这两个工具之前我先简单介绍下这两个工具的功能。iostat可以用来分析Linux系统整体IO的使用情况而iotop作为iostat增强版和功能升级版可以分析Linux系统每一个进程使用IO的情况。
在我们日常使用的Linux发行版中是不包含iostat与iotop两个IO工具软件包的需要我们自行安装它们才可以使用。
各大Linux发行版软件包管理方法并不统一导致安装应用软件的方式不尽相同。考虑到Ubuntu、Deepin都是基于Debain开发的所以我们这里以Debain系的Linux发行版为例进行操作。
我们只要在终端中输入如下命令就可以安装iostat与iotop了。
//安装iostat
sudo apt-get install sysstat
//安装iotop
sudo apt-get install iotop
不过我们并不能直接安装iostat这样会提示找不到iostat软件包因为它是包含在sysstat软件包中所以我们必须先安装sysstat而iotop却是独立的软件包直接安装它就好了。如果你的Linux系统软件源和网络没有问题肯定能安装成功。
你可能对这两个命令的使用方法不熟悉,没事,我们不妨在终端里输入这两个命令试一试,看看会出现什么效果。后面截图展示的是我自己机器上的情况:
上图中左边是iostat命令执行的结果右边是iotop命令执行的结果。如果你现在还看不懂这些信息也没有关系我们后面再介绍这里仅仅是为了给你一个参考你输入命令后显示效果类似上图的话就说明安装成功了。
iostat命令
在前面我们已经成功安装了iostat命令接下来我们重点聊聊它的使用方法还有输出的数据表示的是什么。
iostat命令是用来展示系统中的IO设备和CPU使用情况的。它的最大优势在于能汇报所有块设备活动的统计情况同时也能汇报出CPU使用情况。但是iostat命令有一个缺陷就是它不能对每个应用程序进程进行深入分析只能分析系统的整体情况。
我们先来看iostat如何使用它的使用形式如下
iostat [选项] [参数]
“[]”中的内容可以省略我们直接在终端中输入iostat就会输出相关的全部信息。但是我们如果要观察特定信息就需要使用相关选项了我给你列了一个表梳理了最常用的部分如下所示
了解了选项,还有个参数我们可能用得上,这个参数可以指定为设备名,比如/dev/sda。如果不带参数就会默认显示所有IO储存设备的情况。
我们就这来练练手使用iostat命令看看我们自己机器上的IO设备主要是硬盘的使用情况。这里我们使用iostat -d -m -p -x 这个命令,该命令可以显示所有硬盘及其分区的信息,在我的机器上情况如下所示:
上图中展示了所有硬盘及其分区的IO情况第一列就是设备名后面几列的相关说明我用表格方式给你做了梳理
有了这些量化数据我们就能判断每个硬盘分区的IO使用情况从而分析出哪个设备占用IO请求量高或者低、占用时间多少、读取或者写入的数据量有多少。这样性能瓶颈出现在哪个设备上我们心中就有数了。
接下来我们继续讲解iostat的工作原理。iostat命令只是一个应用软件它的功能就是计算统计数据并且显示。IO设备操作的数据肯定来源于内核那iostat怎么获取这些数据就成了关键。
Linux的内核数据都是以文件的形式提供的。换句话说就是我们想要获取什么数据就相应去读取什么文件。
下面我们手动读取一些文件,体验一下具体是什么情况,如下所示:
对比iostat产生的数据是不是感觉和上面读取的三个文件得到的数据很相似是的你猜的没有错这些文件就是iostat命令的数据来源主要的数据来源是/proc/diskstats文件它记录着块设备IO操作的全部统计信息。
下面我列了一个表,梳理了文件名和对应的统计信息,你可以看看:
我们来看一看/proc/diskstats文件的数据结构它的每一行代表一个块设备或者块设备的分区总共20列数据每一列的内容如下表
Linux块设备层在处理每个IO请求的时候都会更新这些数据具体的流程这里不展开了iostat只使用了其中部分数据。由于这些数据是线性增加的iostat只需要经过多次采集进行简单的运算就可以了。
iostat只是负责的工作其实很简单就是采集数据并计算显示。我们通过一段时间的IO请求数据、写入和读取的数据量、IO请求等待时间等等这些数据就可以评估一个设备的IO性能了。好了关于iostat的工作原理我们讲到这里我们接着探索iotop命令。
iotop命令
我们前面刚学过的iostat这个IO工具只能收集量化到每个块设备的读写情况但如果我们想知道每个进程是如何使用IO的就做不到这就要用到iotop命令了。
iotop命令是一个top类工具可以监视磁盘I/O使用状况还可以作为iostat的升级工具使用。iotop命令具有与Linux系统自带的top相似的UI只是top更关注进程而iotop更关注IO。
iotop命令它是使用Python语言编写而成需要用Python2.5以上的版本和Linux内核2.6以上的版本。iotop提供了源代码和二进制软件包可以自己选择安装。在前面我们已经安装了iotop如果你没有安装好请回到前面看看怎么安装的。
像iostat一样我们一起看看iotop如何使用它的使用形式如下
iotop [选项]
“[]”中的内容可以省略直接在终端中输入iotop就会输出相关的全部信息这一点与iostat相同但是我们如果要观察特定信息就需要使用相关选项了。我给你列了一个表梳理选项如下所示-
我们马上来测试一下使用sudo iotop 命令注意该命令需要root权限才能运行在前面要加上sudo。这条不带任何选项的命令会显示所有用户的所有进程使用IO的情况在我的机器上情况如下所示
上图中展示了所有进程读写硬盘的情况头部的数据显示了每一秒钟所有硬盘和当前硬盘的读写数据量。而下面的每一行代表一个进程每一行的第一列就是进程id也可以在运行过程中近“p”切换为线程id那一行就表示一个线程。后面几列的相关说明我给你列出了一个表格如下所示
有了这些量化数据我们就能判断哪些进程是IO型进程哪些进程是计算型进程每个进程的访问IO的数据一目了然。
根据这些数据我们还能进一步分析出哪个进程使用IO的量是高或者低、占用时间多少、进程优先级多少。IO性能瓶颈出现在哪个进程上需要优化哪个进程的IO模型我们心中就有底了。
我们已经了解iotop的作用是观察所有进程的IO操作情况那iotop的工作原理是什么呢与iostat命令一样iotop只是一个应用软件用来统计所有进程的IO数据并显示。进程和IO操作数据必定来源于Linux内核那iotop怎么获取这些数据呢
在Linux上这些内核数据都是以文件的形式提供的即要获取什么数据就读取什么文件。为了验证这个想法下面我们试验一下看看iotop是不是也是读取了一些/proc目录下的文件呢。
其实iotop是开源的我们不妨下载它的代码来研究一下命令如下
//下载
wget http://guichaz.free.fr/iotop/files/iotop-0.4.4.tar.gz
//解压
tar zxf iotop-0.4.4.tar.gz
我已经帮你下载好了代码放在了课程的工程目录中。我们进入工程目录就可以发现iotop是用python写的入口点是iotop.py文件。
在iotop/ui.pi里这个文件中会调用主函数main主函数进而会调用run_iotop_window函数执行主要功能。在run_iotop_window函数中会调用ProcessList对象获取所有进程的相关信息。
我们不妨看一看它的代码片段,如下所示:
class ProcessList(DumpableObject):
def __init__(self, taskstats_connection, options):
# {pid: ProcessInfo}
self.processes = {}
self.taskstats_connection = taskstats_connection
self.options = options
self.timestamp = time.time()
self.vmstat = vmstat.VmStat()
# A first time as we are interested in the delta
self.update_process_counts()
def get_process(self, pid):
"""Either get the specified PID from self.processes or build a new
ProcessInfo if we see this PID for the first time"""
process = self.processes.get(pid, None)
if not process:
process = ProcessInfo(pid)
self.processes[pid] = process
if process.is_monitored(self.options):
return process
def list_tgids(self):
if self.options.pids:
return self.options.pids
tgids = os.listdir('/proc')
if self.options.processes:
return [int(tgid) for tgid in tgids if '0' <= tgid[0] <= '9']
tids = []
for tgid in tgids:
if '0' <= tgid[0] <= '9':
try:
tids.extend(map(int, os.listdir('/proc/' + tgid + '/task')))
except OSError:
# The PID went away
pass
return tids
def list_tids(self, tgid):
if not self.options.processes:
return [tgid]
try:
tids = map(int, os.listdir('/proc/%d/task' % tgid))
except OSError:
return []
if self.options.pids:
tids = list(set(self.options.pids).intersection(set(tids)))
return tids
def update_process_counts(self):
new_timestamp = time.time()
self.duration = new_timestamp - self.timestamp
self.timestamp = new_timestamp
for tgid in self.list_tgids():
process = self.get_process(tgid)
if not process:
continue
for tid in self.list_tids(tgid):
thread = process.get_thread(tid, self.taskstats_connection)
stats = self.taskstats_connection.get_single_task_stats(thread)
if stats:
thread.update_stats(stats)
thread.mark = False
return self.vmstat.delta()
我们来梳理一下上述代码都做了什么。在ProcessList类的构造方法init中会调用update_process_counts方法接着在其中调用list_tgids方法该方法会打开/proc目录获取所有以数字命名的目录名称那就是TGID。
TGID就是线程组ID对于同一进程中的所有线程TGID都是一致的也就是该进程的进程ID。接着循环调用get_process方法在该方法中会构造ProcessInfo对象以获取每个进程的数据。
ProcessInfo类的代码如下所示
class ProcessInfo(DumpableObject):
def __init__(self, pid):
self.pid = pid
self.uid = None
self.user = None
self.threads = {} # {tid: ThreadInfo}
self.stats_delta = Stats.build_all_zero()
self.stats_accum = Stats.build_all_zero()
self.stats_accum_timestamp = time.time()
def get_uid(self):
if self.uid:
return self.uid
try:
uid = os.stat('/proc/%d' % self.pid)[stat.ST_UID]
except OSError:
# The process disappeared
uid = None
if uid != self.uid:
# Maybe the process called setuid()
self.user = None
self.uid = uid
return uid
def get_user(self):
uid = self.get_uid()
if uid is not None and not self.user:
try:
self.user = safe_utf8_decode(pwd.getpwuid(uid).pw_name)
except KeyError:
self.user = str(uid)
return self.user or '{none}'
def get_proc_status_name(self):
try:
first_line = open('/proc/%d/status' % self.pid).readline()
except IOError:
return '{no such process}'
prefix = 'Name:\t'
if first_line.startswith(prefix):
name = first_line[6:].strip()
else:
name = ''
if name:
name = '[%s]' % name
else:
name = '{no name}'
return name
def get_cmdline(self):
# A process may exec, so we must always reread its cmdline
try:
proc_cmdline = open('/proc/%d/cmdline' % self.pid)
cmdline = proc_cmdline.read(4096)
except IOError:
return '{no such process}'
#……
return safe_utf8_decode(cmdline)
def did_some_io(self, accumulated):
if accumulated:
return not self.stats_accum.is_all_zero()
for t in self.threads.itervalues():
if not t.stats_delta.is_all_zero():
return True
return False
def get_ioprio(self):
priorities = set(t.get_ioprio() for t in self.threads.itervalues())
if len(priorities) == 1:
return priorities.pop()
return '?dif'
def set_ioprio(self, ioprio_class, ioprio_data):
for thread in self.threads.itervalues():
thread.set_ioprio(ioprio_class, ioprio_data)
def ioprio_sort_key(self):
return ioprio.sort_key(self.get_ioprio())
def get_thread(self, tid, taskstats_connection):
thread = self.threads.get(tid, None)
if not thread:
thread = ThreadInfo(tid, taskstats_connection)
self.threads[tid] = thread
return thread
def update_stats(self):
stats_delta = Stats.build_all_zero()
for tid, thread in self.threads.items():
if thread.mark:
del self.threads[tid]
else:
stats_delta.accumulate(thread.stats_delta, stats_delta)
nr_threads = len(self.threads)
if not nr_threads:
return False
stats_delta.blkio_delay_total /= nr_threads
stats_delta.swapin_delay_total /= nr_threads
self.stats_delta = stats_delta
self.stats_accum.accumulate(self.stats_delta, self.stats_accum)
return True
以上代码,无一例外都是从/proc目录下那些数字命名的子目录里获取数据。我们不妨打开proc目录观察一下并且我们还要选择一个特定的、数字命名的子目录进入如下所示
这是谷歌浏览器的进程里面包含很多子目录这些子目录中包括了进程的状态、属性、应用程序命令、打开的文件、IO、网络、虚拟内存空间、工作目录、权限、调度信息等大量信息数据。关于进程的所有信息我们从这里都可以找到。而iotop也正是从这里获取数据然后计算和显示的这就是iotop的工作原理。
重点回顾
这节课我们一起学习了两大监控IO操作的神器即iostat和iotop。它们俩在以后的性能调优路上将是我们最忠诚的伙伴一个观察系统全局IO情况另一个用来查看单个进程的IO情况。有了它们我们就能精确定位Linux服务器上IO性能瓶颈所在。
现在让我们一起来回顾一下今天所学。首先我们安装了iostat和iotop。由于iostat包含在sysstat中需要安装sysstat软件包才能得到iostat。安装成功后别忘了进行测试。
然后我们学习了iostat怎么用熟悉了它的选项和参数以及iostat输出的数据表示什么。之后我们研究了iostat实现原理它是通过读取/proc目录下的一些文件做到的。
iotop工具是一个用python语言编写的工具它能监视全局硬盘的IO性能和每个进程的IO情况是一个全面的IO监视工具。和iostat一样它也是通过读取/proc目录下每个进程目录中的一些文件获取其中的数据再经过计算把数据展示给我们。
这节课的导图如下所示,供你参考:
你是否想对/proc文件系统有更深的了解写出更强大的监视工具呢其实你需要的大部分数据源都可以在/proc目录中找到读取它们就能做出更符合自己业务需求的监视工具赶快去大胆尝试吧。
思考题
请说一说 iostat与 iotop的不同之处
欢迎你在留言区记录自己的收获或疑问,如果觉得这节课还不错,也别忘了推荐给自己身边的朋友。

View File

@ -0,0 +1,190 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 文件仓库:初识文件与文件系统
你好我是LMOS。
通过之前的学习相信你或多或少都体会到“Linux之下一切皆文件”的思想了。
数据是以文件的形式储存下来的而文件数量一多就需要文件系统来管理文件而文件系统正是建立在之前我们学过的IO块设备之上今天我就带你了解一下什么是文件什么是文件系统。
还是延续之前的风格,学习过程中有动手实践的部分。这节课的配套代码,你可以从这里下载。让我们正式开始今天的探索之旅吧!
什么是文件
在日常生活中,我们提到的文件通常是指公文、信件,而计算机中的文件与日常见到的文件载体不同,是以计算机硬盘为载体、存储在计算机上的信息集合。
这些信息集合的呈现形式非常多样,可以是文本文档、图片、音乐、视频、应用程序等。文件通常由文件名进行标识和索引。
只说个概念的话你很难对文件是什么有更深的理解所以下面我们写代码建立一个文件感受一下。Linux把建立文件的操作包含在了open调用中open调用既可以打开一个已经存在的文件又可以建立一个新文件代码如下所示
int main()
{
int fd = -1;
// 打开并建立文件,所有用户可读写
fd = open("empty.file", O_RDWR|O_CREAT, S_IRWXU|S_IRWXO|S_IRWXG);
if(fd < 0)
{
printf("建立文件失败\n");
return -1;
}
// 关闭文件
close(fd);
return 0;
}
上面的代码很简单我们建立一个名为empty.file的文件。但是你需要注意的是我们并没向该文件中写入任何数据并且你可以在当前目录下看到该文件的大小为0。
这说明了文件不一定要有数据,它只是一个标识,这个标识可以既标识数据、设备,还可以标识接口(如/proc目录下的那些文件其实内核提供给进程的、用来访问特殊数据的接口
现在我们再给文件下个定义,就可以说文件从广义上就是一种资源标识或者对象标识。
我们继续基于前面的代码,完善一下,给向程序里写三个字节的数据,并且获取一下文件大小,代码如下:
int main()
{
struct stat filestat;
int fd = -1;
char ch[] = {0, 1, 0xff, 'L', 'M', 'O', 'S'};
// 打开并建立文件,所有用户可读写
fd = open("empty.file", O_RDWR|O_CREAT, S_IRWXU|S_IRWXO|S_IRWXG);
if(fd < 0)
{
printf("建立文件失败\n");
return -1;
}
// 向文件中写入3个字节010xff它们来源于ch数组
write(fd, ch, 3);
// 获取文件信息,比如文件大小
fstat(fd, &filestat);
printf("文件大小:%ld\n", filestat.st_size);
// 关闭文件
close(fd);
return 0;
}
接下来,我们运行代码看看运行结果,如下图所示:
从截图里我们看到了文件大小为3同时我们打开empty.file文件观察里面的内容也会发现是三个字节与我们在代码中定义的一样。看到这个现象我们应该明白了从狭义说常规文件是一个信息数据集合单位是字节。
我们继续修改代码写入7个字节看看是什么情况。这里我们只需要修改write调用里的第三个参数把它改为7就行了。代码我已经为你改写好了我们直接看运行结果如下所示
同样地我们看到了文件大小为7再次打开empty.file文件同样也是七个字节与代码中定义完全一样对字符数据是存储它对应的ASCII码。
看到这里,你有没有对文件产生什么新的思考呢?有没有发现,所谓普通文件的结构,本质上是一个可以动态增长的线性字节数组。无论文件是什么类型或者多大的数据,都会映射到对应的字节,占用一个或者多个字节空间。
我们现在理解了文件是一种标识,也推理解出了文件储存数据的结构是什么样子,但是我不知道你有没有想过,描述一个文件自身也需要很多信息,我们可以把这些信息称为文件元信息。
比如上面用来表示文件大小的信息,就是文件的元信息。不过,文件不光有大小的信息,还有其它别的元信息。
我们继续来修改代码,试着获取文件的部分元信息。为什么是“部分“元信息呢?因为在应用层有些元信息我们是获取不到的,操作系统也不会提供相应接口。修改后的代码运行情况如下所示:
上图中dev表示文件所在的设备号而rdev则是当文件是设备类型时的设备号。文件模式能表示文件或者目录文件节点则表示该文件在文件系统中对应的inode号码。
inode是文件系统中标识一个文件的元信息上面这些信息大多都来自inode结构这些信息访问、修改、状态改变的时间是以秒为单位的。上面的数据相同是因为我们在一瞬间完成了文件的创建和修改。用户id和用户组id则表示该文件是哪个用户建立的属于哪个用户组。
文件除了本身大小,在文件系统中还分成了块来存储,所以文件有块大小和文件占用了多少块这些信息。下面我们通过一幅逻辑结构图总结一下什么是文件:
由上图可知普通文件的元信息中,不仅仅保存了文件相关的设备、时间、创建者、大小等相关信息,还保存了文件数据块的索引信息,这样才能找到这些数据块。这也从侧面证明了,一个普通文件必须有两个部分组成:一个部分为文件元数据,一部分为文件储存的数据。
文件在硬盘上以块为单位储存,这些块的块号在元信息中按照顺序索引起来,就是整个文件的数据,这就是一个普通的数据文件。普通数据文件的信息都存在储存设备上,这个设备通常是硬盘或硬盘分区(硬盘的一部分)。
如果只有一个软件,我们只要确定元数据和文件数据分别放在哪些扇区就可以,无论是查找、读写、删除,怎么处理都很容易。不幸的是,文件不可能只有一个,而是有成千上万甚至更多,所以这就必须要设计出一套系统方案,来解决多个文件的操作管理。
接下来,我们就聊聊文件管理系统,它是操作系统中一个巨大的功能模块。
文件系统
我们先来搞清楚文件系统概念文件系统是操作系统在存储设备常见的是硬盘、U盘、CD或者其分区上构建的储存文件方法和数据结构也就是在存储设备上组织文件的方法。由于这个功能模块规模很大操作系统专门起了一个名称把负责管理和存储文件的功能模块称为文件管理系统简称文件系统。
文件系统由三部分组成,分别是文件系统的接口、对文件操作和管理的功能集、文件及其属性。
从操作系统角度来看,文件系统的职责是组织和分配存储设备的空间、文件存储,以及对存入的文件进行保护和检索。具体点来说,文件系统给用户提供了文件相关操作的一条龙服务,包括为用户建立、存入、读出、修改、转储文件,控制文件的存取读取文件,当用户不再使用时还会删除文件。
一个硬盘中的各个分区上可以使用不同的文件系统,但是在使用之前,我们要对该分区进行格式化。所谓格式化,就是向该分区写入文件系统相关的信息,以及分配分区中相关扇区的数据结构。有了这些数据结构和信息,用户应用才能在文件系统里存放文件。
虽然文件系统的核心数据结构,现在我们还没法直观地感受到,但是它在上层为用户或者进程提供了一个逻辑视图,也就是目录结构,这是一个倒置的树形结构。
树的分支结构上是目录或者文件。从最上层的 /(根)目录开始,就能找到每个文件、每个目录和每个目录下的所有文件。目录对文件进行分层分类,目的是方便用户对众多文件做管理。我为你画了一幅图来展示这个结构,如下图所示:
如上图所示,这是一棵倒树,根据上图中的各种路径,就可以找到其中的任意文件或者目录。例如我们在系统中输入:/home/user1/file1这就表示其根“/”目录下“home”目录里的“file1”文件。
了解了文件系统的逻辑结构之后,我们不妨进一步思考一下,假如让你来设计实现一个文件系统,你会怎样梳理它的结构呢?
我们先得设计描述整个文件系统信息的结构其次要有描述目录的信息结构然后是描述文件元信息结构最后别忘了文件数据块结构。其实Linux上众多文件系统都是这么实现的即使各文件系统在细节上有些变化但是都具有类似的通用结构其中心概念离不开超级块、目录结构、inode节点以及数据块。下面我们分别进行讨论。
让我们从数据块说起,由于文件系统数据结构也是存放在数据块中的,所以第一个就要把它搞清楚。
对于这么多文件系统设计文件系统首先会把硬盘或者硬盘分区划分为一个个数据块每个数据块大小是硬盘扇区的整数倍典型的数据块大小是1024字节或者4096字节。
这个大小,既可以在格式化硬盘或者硬盘分区创建文件系统的时候决定,也可以由管理员手动指定,还可以在文件系统的创建时根据硬盘分区的大小,动态选择一个较合理的值。
我们再来看看超级块。超级块一般会放在硬盘分区的第一个或者第二个数据块中。超级块中的数据是描述文件系统的控制信息。
有关该文件系统的大部分信息都保存在超级块中比如硬盘分区中有多少个数据块每个数据块的大小、有多少个空闲数据块、文件系统状态、有多少目录或者文件、文件系统名称、UUID、位图信息等。这些信息可以用来控制和描述一个可正常工作的文件系统。
接下来要说的是目录结构。目录结构很简单里面就是文件名称和inode号组成的目录项一个目录项可以是另一个目录也可以是一个文件所有的目录项共同组成了目录文件特殊的文件。根据目录项的inode节点号我们就可以找到对应的文件的inode。那inode是什么呢我们接着往下看。
之前的课程里已经讲过文件数据都存放在数据块中我们还必须使用一个数据结构来存储文件的元信息这种存储文件元信息的数据结构叫做inode即索引节点也经常叫作inode节点。其实刚刚我们讲文件的时候就提过inode记不清的话你自己再回顾一下。
每一个文件都有对应的inodeinode包含了文件的元信息。也就是说除了文件名以外的所有文件信息都保存在inode之中文件名称在目录条目中主要有文件的字节数、文件的所属uid、文件的所属组GID、文件的读、写、执行权限以及文件的创建、修改时间等。
最重要的是inode节点中包括数据块的地址用于索引文件对应的数据。但inode节点中只有少量数据块数的地址如果需要更多就需要动态分配指向数据块的地址空间。这些动态分配的数据块是间接地址数据块为了找到数据块必须先找到间接地址数据块的然后从里面找到文件的数据块地址。
有了上述四大核心结构就可以表示一个文件系统了。其实Linux对超级块结构、目录结构inode结构以及数据块还做了进一步抽象把这些结构加入了操作函数集合形成了VFS即虚拟文件系统。
只要软件模块能提供上述四大核心结构的操作函数集合生成超级块结构就可以形成一个文件系统实例安装到VFS中。有了VFS层就可以向上为应用程序提供统一的接口向下兼容不同的文件系统让Linux能够同时安装不同的文件系统。
我为你画了一幅图,表示其架构,如下所示:
你有没有发现?在计算机科学领域的很多问题,都可以通过增加一个中间的抽象层来解决,上图中 Linux 的 VFS 层就是应用和许多文件系统之间的抽象层。
VFS ,向下规范了一个文件系统要接入 VFS 必需要实现的机制。为此VFS 提供了一系列数据结构如files、superblock、dentry、inode结构还规定了具体文件系统应该实现生成这些数据结构的回调函数。这样一个文件系统模块就可以被安装到 VFS 中了。操作具体文件时VFS 会根据需要调用具体文件系统的函数。
从此文件系统的细节就被 VFS 屏蔽了应用程序只需要调用标准的接口就行了。也正因如此Linux 可以支持 EXT、XFS、BTRFS、FAT、NTFS 等多达十几种不同的文件系统,但不管在什么储存设备上使用什么文件系统,也不管访问什么文件,都可以统一地使用一套类似 open()、read()、write()、close() 的接口。
关于VFS我们就介绍到这里更详细的VFS讲解你可以参考我的另一门课程《操作系统实战 45 讲》中第三十五节课[《瞧一瞧Linux虚拟文件系统如何管理文件》]。
重点回顾
这节课我带你了解了文件和文件系统。
文件是一种资源对象的标识,可以标识最简单常见的数据文件,也可以标识一个设备或者一种访问内核的数据接口。普通文件有许多元信息和数据块组成,它们通常保存硬盘中的扇区中。
而文件数量一多,就需要文件系统来管理。文件系统为应用程序提供了一个逻辑视图,具体是一棵倒立的树结构,方便用户管理众多文件。
为了把众多文件存储到硬盘中文件系统用一棵倒立的目录树来存放各种类型的文件从根目录开始就可以找到所有的目录和文件。其次我们还了解到了文件系统的内部概念如超级块目录结构inode节点数据块等。
Linux系统为了支持多种类型的文件系统还进一步抽象出了VFS。任何文件系统模块只要符合VFS对数据结构和操作函数集合的要求都可以安装到VFS层中。VFS的出现使得Linux支持多种文件系统成为可能。
我还给你准备了一张导图,你可以做个参考。
看到这里我知道你意犹未尽或者还有许多疑问我们将在下一节课深入探讨EXT文件系统的内部实现细节相信那时你会对文件系统是怎么一回事有个更深的理解。
思考题
一般的Linux上的文件系统都有哪些内部结构
期待你在留言区分享你的学习收获或疑问,如果这节课对你有帮助,别忘了分享给更多朋友,说不定就能让他对文件系统有个新认识。

View File

@ -0,0 +1,272 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 Linux文件系统Linux如何存放文件
你好我是LMOS。
上一节课我们一起了解了什么是文件和文件系统。接下来的两节课我们继续深入学习Linux上的一个具体的文件系统——Ext3搞清楚了文件究竟是如何存放的。
这节课我会带你建立一个虚拟硬盘并在上面建立一个文件系统。对照代码实例相信你会对Ext3的结构有一个更深入的认识。课程配套代码你可以从这里下载。话不多说我们开始吧。
建立虚拟硬盘
要想建立文件系统就得先有硬盘,我们直接用真正的物理硬盘非常危险,搞不好数据就会丢失。所以,这里我们选择虚拟硬盘,在这个虚拟硬盘上操作,这样怎么折腾都不会有事。
其实我们是用Linux下的一个文件来模拟硬盘的写入硬盘的数据只是写入了这个文件中。所以建立虚拟硬盘就相当于生成一个对应的文件。比如我们要建立一个 100MB 的硬盘,就意味着我们要生成 100MB 的大文件。
下面我们用 Linux 下的 dd 命令(用指定大小的块拷贝一个文件,并在拷贝的同时进行指定的转换)生成 100MB 的纯二进制的文件(就是向 1100M 字节的文件里面填充为 0 ),代码如下所示:
dd bs=512 if=/dev/zero of=hd.img count=204800
;bs:表示块大小这里是512字节
;if表示输入文件/dev/zero就是Linux下专门返回0数据的设备文件读取它就返回0
;of表示输出文件即我们的硬盘文件
;count表示输出多少块
下面我们就要在虚拟硬盘上建立文件系统了,所谓建立文件系统就是对虚拟硬盘放进行格式化。可是,问题来了——虚拟硬盘毕竟是个文件,如何让 Linux 在一个文件上建立文件系统呢?
这个问题我们要分成两步来解决。
第一步,把虚拟硬盘文件变成 Linux 下的回环设备,让 Linux 以为这是个设备。下面我们用 losetup 命令,将 hd.img 这个文件变成 Linux 的回环设备,代码如下:
sudo losetup /dev/loop0 hd.img
第二步,由于回环设备就是 Linux 下的块设备用户可以将其看作是硬盘、光驱或软驱等设备并且可以用mount命令把该回环设备挂载到特定目录下。这样我们就可以用 Linux 下的 mkfs.ext3 命令,把这个 /dev/loop0 回环块设备格式化进而格式化hd.img文件在里面建立 Ext3 文件系统。
sudo mkfs.ext3 -q /dev/loop0
需要注意的是loop0可能已经被占用了我们可以使用loop1、loop2等你需要根据自己电脑的情况处理。
我们可以用 mount 命令将hd.img挂载到特定的目录下如果命令执行成功就能验证我们虚拟硬盘上的文件系统成功建立。命令如下所示
sudo mount -o loop ./hd.img ./hdisk/ ;挂载硬盘文件
这行代码的作用是将hd.img这个文件使用 loop 模式挂载在 ./hdisk/目录之下通过这个hdisk目录就能访问到hd.img虚拟硬盘了。并且我们还可以用常用的mkdir、touch命令在这个虚拟硬盘中建立目录和文件。
Ext3文件系统结构
我们建好了硬盘对其进行了格式化也在上面建立了Ext3文件系统。下面我们就来研究一下Ext3文件系统的结构。
Ext3文件系统的全称是Third extended file system已经有20多年的历史了是一种古老而成熟的文件系统。Ext3在Ext2基础上加入了日志机制也算是对Ext2文件系统的扩展并且也能兼容Ext2。Ext3是在发布Linux2.4.x版本时加入的支持保存上TB的文件保存的文件数量由硬盘容量决定还支持高达255字节的文件名。
Ext3的内部结构是怎样的呢Ext3将一个硬盘分区分为大小相同的储存块每个储存块可以是2个扇区、4个扇区、8个扇区分别对应大小为1KB、2KB、4KB。
所有的储存块又被划分为若干个块组每个块组中的储存块数量相同。每个块组前面若干个储存块中依次放着超级块、块组描述表、块位图、inode节点位图、inode节点表、数据块区。需要注意的是超级块和块组描述表是全局性的在每个块组中它们的数据是相同的。
我再帮你画一个逻辑结构图,你就容易理解了,如下所示:
上图中第1个储存块是用于安装引导程序或者也可以保留不使用的。超级块占用一个储存块在第2个储存块中即储存块1储存块的块号是针对整个分区编码的从0开始。其中的块组描述符表、块位图、inode节点位图、inode节点表的占用大小是根据块组多少以及块组的大小动态计算的。
下面我们分别讨论这些重要结构。
Ext3文件系统的超级块
我们首先要探讨的是Ext3文件系统的超级块它描述了Ext3的整体信息例如有多少个inode节点、多少个储存块、储存块大小、第一个数据块号是多少每个块组多少个储存块等。
Ext3文件系统的超级块存放在该文件系统所在分区的2号扇区占用两个扇区。当储存块的大小不同时超级块所在块号是不同的。
比如说当储存块大小为1KB时0号块是引导程序或者保留储存块超级块起始于1号块储存当块大小为2KB时超级块起始于0号储存块其位于0号储存块的后1KB前1KB是引导程序或者保留当储存块大小为4KB时超级块也起始于0号储存块其位于0号块的1KB处。总之超级块位于相对于分区的2号~3号扇区这一点是固定的。
下面我们看一看用C语言定义的超级块代码如下所示
struct ext3_super_block {
__le32 s_inodes_count; //inode节点总数
__le32 s_blocks_count; // 储存块总数
__le32 s_r_blocks_count; // 保留的储存块数
__le32 s_free_blocks_count;// 空闲的储存块数
__le32 s_free_inodes_count;// 空闲的inode节点数
__le32 s_first_data_block; // 第一个数据储存块号
__le32 s_log_block_size; // 储存块大小
__le32 s_log_frag_size; // 碎片大小
__le32 s_blocks_per_group; // 每块组包含的储存块数
__le32 s_frags_per_group; // 每块组包含的碎片
__le32 s_inodes_per_group; // 每块组包含的inode节点数
__le32 s_mtime; // 最后挂载时间
__le32 s_wtime; // 最后写入时间
__le16 s_mnt_count; // 挂载次数
__le16 s_max_mnt_count; // 最大挂载次数
__le16 s_magic; // 魔数
__le16 s_state; // 文件系统状态
__le16 s_errors; // 错误处理方式
__le16 s_minor_rev_level; // 次版本号
__le32 s_lastcheck; // 最后检查时间
__le32 s_checkinterval; // 强迫一致性检查的最大间隔时间
__le32 s_creator_os; // 建立文件系统的操作系统
__le32 s_rev_level; // 主版本号
__le16 s_def_resuid; // 默认用户保留储存块
__le16 s_def_resgid; // 默认用户组保留储存块
__le32 s_first_ino; // 第一个非保留inode节点号
__le16 s_inode_size; // inode节点大小
__le16 s_block_group_nr; // 当前超级块所在块组
__le32 s_feature_compat; // 兼容功能集
__le32 s_feature_incompat; // 非兼容功能集
__le32 s_feature_ro_compat;// 只读兼容功能集
__u8 s_uuid[16]; // 卷的UUID全局ID
char s_volume_name[16]; // 卷名
char s_last_mounted[64]; // 文件系统最后挂载路径
__le32 s_algorithm_usage_bitmap; // 位图算法
//省略了日志相关的字段
};
以上的代码中我省略了日志和预分配的相关字段而__le16 __le32在x86上就是u16、u32类型的数据。le表示以小端字节序储存数据定义成这样是为了大小端不同的CPU可以使用相同文件系统或者已经存在的文件系统的前提下方便进行数据转换。
Ext3文件系统的块组描述符表
接着我们来看看Ext3文件系统的块组描述符里面存放着用来描述块组中的位图块起始块号、inode节点表起始块号、空闲inode节点数、空闲储存块数等信息文件系统中每个块组都有这样的一个块组描述符与之对应。所有的块组描述符集中存放就形成了块组描述符表。
块组描述符表的起始块号位于超级块所在块号的下一个块,在整个文件系统中,存有很多块组描述符表的备份,存在的方式与超级块相同。
下面我们看一看用C语言定义的单个块组描述符结构如下所示
struct ext3_group_desc
{
__le32 bg_block_bitmap; // 该块组位图块起始块号
__le32 bg_inode_bitmap; // 该块组inode节点位图块起始块号
__le32 bg_inode_table; // 该块组inode节点表起始块号
__le16 bg_free_blocks_count; // 该块组的空闲块
__le16 bg_free_inodes_count; // 该块组的空闲inode节点数
__le16 bg_used_dirs_count; // 该块组的目录计数
__u16 bg_pad; // 填充
__le32 bg_reserved[3]; // 保留未用
};
对照上述代码我们可以看到多个ext3_group_desc结构就形成了块组描述符表而__le16 __le32类型和超级块中的相同。如果想知道文件系统中有多少个块组描述符可以通过超级块中总块数和每个块组的块数来进行计算。
Ext3文件系统的位图块
接下来要说的是Ext3文件系统的位图块它非常简单每个块组中有两种位图块一种用来描述块组内每个储存块的分配状态另一种用于描述inode节点的分配状态。
位图块中没有什么结构就是位图数据即块中的每个字节都有八个位。每个位表示一个相应对象的分配状态该位为0时表示相应对象为空闲可用状态为1时则表示相应对象是占用状态。例如位图块中第一个字节表示块组0~7号储存块的分配状态第二个字节表示块组8~15号储存块的分配状态 ……依次类推。位图块的块号可以从块组描述符中得到。
Ext3文件系统的inode节点
接下来我们再深入研究一下inode节点。上节课我们提过inode节点用来存放跟文件相关的所有信息但是文件名称却不在inode节点之中文件名称保存在文件目录项中。
inode节点中包含了文件模式、文件链接数、文件大小、文件占用扇区数、文件的访问和修改的时间信息、文件的用户ID、文件的用户组ID、文件数据内容的储存块号等这些重要信息也被称为文件的元数据。
那么用C语言如何定义单个inode节点结构呢代码如下所示
struct ext3_inode {
__le16 i_mode; // 文件模式
__le16 i_uid; // 建立文件的用户
__le32 i_size; // 文件大小
__le32 i_atime; // 文件访问时间
__le32 i_ctime; // 文件建立时间
__le32 i_mtime; // 文件修改时间
__le32 i_dtime; // 文件删除时间
__le16 i_gid; // 建立文件的用户组
__le16 i_links_count; // 文件的链接数
__le32 i_blocks; // 文件占用的储存块 */
__le32 i_flags; // 文件标志
union {
struct {
__u32 l_i_reserved1;
} linux1;
struct {
__u32 h_i_translator;
} hurd1;
struct {
__u32 m_i_reserved1;
} masix1;
} osd1; //操作系统依赖1
__le32 i_block[EXT3_N_BLOCKS];// 直接块地址
__le32 i_generation; // 文件版本
__le32 i_file_acl; // 文件扩展属性块
__le32 i_dir_acl; // 目录扩展属性块
__le32 i_faddr; // 段地址
union {
struct {
__u8 l_i_frag; //段号
__u8 l_i_fsize; //段大小
__u16 i_pad1;
__le16 l_i_uid_high;
__le16 l_i_gid_high;
__u32 l_i_reserved2;
} linux2;
struct {
__u8 h_i_frag; //段号
__u8 h_i_fsize; //段大小
__u16 h_i_mode_high;
__u16 h_i_uid_high;
__u16 h_i_gid_high;
__u32 h_i_author;
} hurd2;
struct {
__u8 m_i_frag; //段号
__u8 m_i_fsize; //段大小
__u16 m_pad1;
__u32 m_i_reserved2[2];
} masix2;
} osd2; //操作系统依赖2
__le16 i_extra_isize;
__le16 i_pad1;
};
这就是inode节点它包含文件的所有信息。文件的数据内容的储存块号保存在i_block中这个i_block数组前十二元素保存的是1~12这12个储存块号第十三个元素开始保存的是一级间接储存块块号、二级间接储存块块号、三级间接储存块块号。
那问题来了,什么是间接储存块?我给你画幅图,你就明白了。
由上图可知一个inode节点中有11个直接储存块其中存放的是块号能直接索引11个储存块。
如果每个储存块大小是1KB的话可以保存11KB的文件数据当文件内容大于11KB时就要用到一级间接储存块。
这时一级间接储存块里的块号索引的储存块中不是文件数据而是储存的指向储存块的块号它可以储存1024/4个块号即可索引1024/4个储存块。二级、三级间接块则依次类推只不过级别更深保存的块号就更多能索引的储存块就更多储存文件的数据量就更大。
Ext3文件系统的目录项
讲到这里我们已经对Ext3文件系统若干结构都做了梳理现在你应该对Ext3文件系统如何储存文件有了一定认识。
可是文件系统中还有许多文件目录,文件目录是怎么处理的呢?
Ext3文件系统把目录当成了一种特殊的文件即目录文件目录文件有自己的inode节点能读取其中数据。在目录文件的数据中保存的是一系列目录项目录项用来存放文件或者目录的inode节点号、目录项的长度、文件名等信息。
下面我们看一看用C语言定义的单个目录项结构长什么样
#define EXT3_NAME_LEN 255
struct ext3_dir_entry {
__le32 inode; // 对应的inode节点号
__le16 rec_len; // 目录项长度
__u8 name_len; // 文件名称长度
__u8 file_type; // 文件类型:文件、目录、符号链接
char name[EXT3_NAME_LEN];// 文件名
};
目录项结构大小不是固定不变的这是由于每个文件或者目录的名称不一定是255个字符一般情况下是少于255个字符这就导致name数组不必占用完整的空间。所以目录项是动态变化需要结构中的rec_len字段才能知道目录项的真实大小。
重点回顾
今天的课程我们就结束了,我们一起回顾一下学习的重点。
首先为了体验一下怎么建立文件系统同时为了避免我们在物理硬盘的误操作导致丢失数据所以我们用文件方式建立了一个虚拟硬盘并在上面格式化了Ext3文件系统。
接着我们从逻辑上了解Ext3文件系统重点了解了它的几个重要结构超级块用于保存文件系统全局信息了块组描述符用于表示硬盘的一个个块组位图用于分配储存块和inode节点inode节点用于保存文件的元数据还有文件数据块的块号最后还有目录结构用来存放者文件或者目录的inode节点号、目录项的长度、文件名等信息。
这节课的导图如下所示,供你参考:
下节课我们继续聊聊怎么读取文件系统的文件,敬请期待。
思考题
请问Ext3文件系统的超级块放在硬盘分区的第几个扇区中。
欢迎你在留言区记录自己的收获,或者向我提问。如果觉得这节课还不错,别忘了分享给身边的朋友。

View File

@ -0,0 +1,315 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 Linux文件系统Linux如何存放文件
你好我是LMOS。
通过上节课的学习我们已经对Ext3文件系统的结构非常了解了。这种了解究竟正确与否还是需要通过写代码来验证。这节课我会带你读取Ext3文件系统中的文件帮你加深对Ext3的理解。
我假定你已经学会了怎么建立一个虚拟硬盘并将其格式化为Ext3文件系统。如果记不清了请回到[上节课]复习一下。课程的配套代码,你需要从这里下载。
打开虚拟硬盘
想要从虚拟硬盘读取文件,首先要做的当然是打开虚拟硬盘。但我们的硬盘是个文件,所以这就变成了打开了一个文件,然后对文件进行读写就行。这些操作我们已经非常熟悉了,不过多展开。
这次我们不用read命令来读取虚拟硬盘文件数据因为那样做还需要处理分配临时内容和文件定位的问题操作比较繁琐。这里我们直接用mmap将整个文件映射到虚拟文件中这样就能像访问内存一样很方便地访问文件了。
下面我们首先实现mmap映射读取文件这个功能代码如下所示
int init_in_hdfile()
{
struct stat filestat;
size_t len = 0;
void* buf = NULL;
int fd = -1;
// 打开虚拟硬盘文件
fd = open("./hd.img", O_RDWR, S_IRWXU|S_IRWXG|S_IRWXO);
if(fd < 0)
{
printf("打开文件失败\n");
return -1;
}
// 获取文件信息,比如文件大小
fstat(fd, &filestat);
// 获取文件大小
len = filestat.st_size;
// 映射整个文件到进程的虚拟内存中
buf = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
if(buf == NULL)
{
printf("映射文件失败\n");
return -2;
}
// 保存地址 长度大小 文件句柄 到全局变量
hdaddr = buf;
hdsize = len;
hdfilefd = fd;
return 0;
}
我们把打开硬盘文件以及将其映射到进程的虚拟内存中的功能封装在init_in_hdfile函数中并把映射返回的地址、文件长度、文件句柄保存到全局变量中以便后面使用。
获取Ext3文件系统超级块
作为硬盘的文件已经完成映射下面我们就来获取其中的Ext3文件系统超级块。
Ext3文件系统超级块固定存放在硬盘2号扇区的开始地址硬盘扇区从0开始计数。我们需要把扇区号转换成文件中对应的偏移量然后把这个偏移量转换成文件映射虚拟内存中的地址才能访问到正确的数据。
下面我们开始写代码,如下所示:
// 将扇区号转换成文件映射的虚拟内存地址
void* sector_to_addr(__u64 nr)
{
return (void*)((__u64)hdaddr + (nr * SECTOR_SIZE));
}
// 将储存块号转换成文件映射的虚拟内存地址
void* block_to_addr(__u64 nr)
{
return (void*)((__u64)hdaddr + (nr * block_size));
}
// 获取超级块的地址
struct ext3_super_block* get_superblock()
{
return (struct ext3_super_block*)sector_to_addr(2);
}
Ext3的超级级块结构定义在工程目录下的ext3fs.h头文件中。代码的get_superblock函数中正是通过sector_to_addr函数对第二号扇区做了转换之后还加上了映射文件的首地址才能访问硬盘文件中的超级块。
我们可以调用dump_super_block函数打印超级块的一些信息如下图所示
从上面的截图我们能知道文件系统的全局信息也就是该文件系统有多少个储存块、inode、储存块大小每个块组多少个储存块等相关信息。
获取Ext3文件系统块组描述符表
我们知道Ext3文件系统将硬盘分区划分成一个个块组在超级块的下一个储存块中保存着块组描述符表。如果超级块在0号储存块中块组描述符表就是1号储存块中如果超级块在1号储存块块组描述符表就在2号储存块中。
一个块组中有储存块位图块有inode节点位图块也有inode节点表。要获取Ext3文件系统块组描述符表我们只要知道它所在的储存块就能读取其中的信息。
下面我们用代码实现这一步:
void get_group_table(struct ext3_group_desc** outgtable, int* outnr )
{
// 计算总块组数
int gnr = super->s_blocks_count / super->s_blocks_per_group;
// 获取块组描述表的首地址
struct ext3_group_desc* group = (struct ext3_group_desc* ) block_to_addr(2);
*outgtable = group;
*outnr = gnr;
return;
}
以上获取块组描述符表的函数,我们可以通过参数,返回两个块组描述符表的首地址和个数。
这里我已经为你写好了dump_all_group函数你只要调用它就可以直接获取块组描述符表信息了。
接下来我们看看打印出来的块组描述符表信息,如下所示:
获取Ext3文件系统根目录
要想在文件系统中读取文件就必须从其根目录开始一层一层查找直到找到文件的inode节点。
可是根目录在哪里呢它就在第一个块组中inode节点表中的第2个inode也就是根目录的inode节点这个inode节点对应的数据块中储存的目录项数据。目录项可以指向一个目录也可以指向一个文件就这样一层层将目录或者文件组织起来了。
下面我们就来写代码实现这一步,如下所示:
// 获取根目录的inode的地址
struct ext3_inode* get_rootinode()
{
// 获取第1个块组描述符
struct ext3_group_desc* group = (struct ext3_group_desc* ) block_to_addr(2);
// 获取该块组的inode表的块号
__u32 ino = group->bg_inode_table;
// 获取第二个inode
struct ext3_inode* inp = (struct ext3_inode* )((__u64)block_to_addr(ino)+(super->s_inode_size*1));
return inp;
}
// 获取根目录的开始的数据项的地址
struct ext3_dir_entry* get_rootdir()
{
// 获取根目录的inode
struct ext3_inode* inp = get_rootinode();
// 返回根目录的inode中第一个数据块的地址就是根目录的数据
return (struct ext3_dir_entry*)block_to_addr(inp->i_block[0]);
}
上面代码中有两个函数一个是获取根目录inode的地址有了它才能获取根目录的数据由于我们的文件系统没有太多目录和文件所以只用一块储存块就能放下所有的目录项目。
我已经为你写好了代码用于显示根目录下所有的目录和文件现在你只要调用dump_dirs函数可以了如下所示
由上可知根目录下有5个子目录分别是.、…、lost+found、ext3fs、info。ext3fs和info是我主动建立的用于测试。我还在ext3fs目录下建立了一个ext3.txt文件并在其中写入了“Hello EXT3 File System!!”数据,下面我们就去读取它的文件数据。
获取Ext3文件系统文件
现在我们要读取Ext3文件系统中的/ext3fs/ext3.txt文件但是我们必须要从根目录开始查找ext3fs目录对应inode节点。然后在ext3fs目录数据中找到ext3.txt文件对应的inode节点读取该inode中直接或者间接地址块中块号对应的储存块那里就是文件的真实数据。
目前我们已经能读取根目录的数据了只要再操作两步就可以查到ext3.txt对应的inode。
下面我们开始写代码,如下所示:
// 判定文件和目录
struct ext3_dir_entry* dir_file_is_ok(struct ext3_dir_entry* dire, __u8 type, char* name)
{
// 比较文件和目录类型和名称
if(dire->file_type == type)
{
if(0 == strncmp(name, dire->name, dire->name_len))
{
return dire;
}
}
return NULL;
}
// 查找一个块中的目录项
struct ext3_dir_entry* find_dirs_on_block(void* blk, size_t size, __u8 type, char* name)
{
struct ext3_dir_entry* dire = NULL;
void* end = (void*)((__u64)blk + size);
for (void* dir = blk; dir < end;)
{
// 判定是否找到
dire = dir_file_is_ok((struct ext3_dir_entry*)dir, type, name);
if(dire != NULL)
{
return dire;
}
// 获取下一个目录项地址
dir = get_next_dir_addr((struct ext3_dir_entry*)dir);
}
return NULL;
}
// 在一个目录文件中查找目录或者文件
struct ext3_dir_entry* find_dirs(struct ext3_inode* inode, __u8 type, char* name)
{
struct ext3_dir_entry* dir = NULL;
__s64 filesize = inode->i_size;
// 查找每个直接块
for (int i = 0; (i < (EXT3_N_BLOCKS - 3))&&(filesize > 0); i++, filesize -= (__s64)block_size)
{
// 查找一个储存块
dir = find_dirs_on_block(block_to_addr(inode->i_block[i]), (size_t)filesize, type, name);
if(dir != NULL)
{
return dir;
}
}
return NULL;
}
上述代码中的三个函数的作用就是查找我们需要的目录和文件。具体是这样的find_dirs用来查找整个inodefind_dirs_on_block用来查找inode中一个储存块dir_file_is_ok用于判定每个查找到的目录项如果找到就返回对应的地址否则返回NULL。
下面我们在read_file函数中调用上述函数如下所示
void read_file()
{
struct ext3_dir_entry* dir = NULL;
// 查找ext3fs目录
dir = find_dirs(rootinode, 2, "ext3fs");
if(dir == NULL)
{
printf("没有找到ext3fs目录\n");
return;
}
// 显示ext3fs目录的目录项信息
dump_one_dir(dir);
// 查找ext3fs目录下的ext3.txt文件
dir = find_dirs(get_inode(dir->inode), 1, "ext3.txt");
if(dir == NULL)
{
printf("没有找到ext3.txt\n");
return;
}
// 显示ext3.txt文件的目录项信息
dump_one_dir(dir);
return;
}
以上代码的作用是这样的第一步查找ext3fs目录第二步查找ext3fs目录下的ext3.txt文件并把它们相应的信息显示出来。-
我们把程序运行一下,如下所示:
上图中已经显示了ext3.txt文件的inode号根据这个inode号我们就能找到对应inode节点下面我们进一步写代码读取文件中的数据。代码如下所示
void dump_inode_data(struct ext3_inode* inode)
{
// 获取文件大小
__s64 filesize = inode->i_size;
printf("----------------------------------------\n");
// 展示文件inode的元信息
dump_inode(inode);
printf("----------------------------------------\n");
for (int i = 0; (i < (EXT3_N_BLOCKS - 3))&&(filesize > 0); i++, filesize -= (__s64)block_size)
{
// 读取并打印每个储存块中数据内部
printf("%s\n", (char*)block_to_addr(inode->i_block[i]));
}
return;
}
void read_file()
{
struct ext3_dir_entry* dir = NULL;
// 查找ext3fs目录
dir = find_dirs(rootinode, 2, "ext3fs");
if(dir == NULL)
{
printf("没有找到ext3fs目录\n");
return;
}
// 显示ext3fs目录的目录项信息
dump_one_dir(dir);
// 查找ext3fs目录下的ext3.txt文件
dir = find_dirs(get_inode(dir->inode), 1, "ext3.txt");
if(dir == NULL)
{
printf("没有找到ext3.txt\n");
return;
}
// 显示ext3.txt文件的目录项信息
dump_one_dir(dir);
// 显示ext3.txt文件的内容信息
dump_inode_data(get_inode(dir->inode));
return;
}
在上面的dump_inode_data函数中我之所以能用printf打印文件内存是因为我清楚ext3.txt文件存放写入的是文本数据。如果是其它别的数据就不能这样做了。-
除了打印文件内容,我们还展示了文件元信息。让我们运行一下,看看结果:
从上图我们已经清楚地看到文件大小、创建时间、所属用户、占用哪个储存块最后还打印出了文件的内容——Hello EXT3 File System!!这与我们之前写入的数据分毫不差。到这里我们已经验证了Ext3文件系统结构也完成了读文件信息的各类实践。
重点回顾
只要认真学完这两节课我相信你对Ext3文件系统已经有了更深入的了解硬件上的数据修改是完全可以做到的成为数据修复大师也指日可待。不过不能利用这些知识去干坏事哦。
今天为了验证上节课学到的一系列Ext3结构我们通过写代码的方式在文件系统中读取了文件数据。我们通过获取超级块、块组的描述符表一步步完整地把文件内容读取出来打印在屏幕上。对比之下这正好跟我们先前输入的内容是一样的也就验证了Ext3文件系统结构。
这节课的导图如下所示,供你参考:
思考题
请问inode号是对应于硬盘分区全局还是相对于块组的
进入下个章节之前,希望你可以留言说说学习的感受,或者向我提问。如果觉得课程还不错,别忘了分享给身边更多朋友。

View File

@ -0,0 +1,162 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 浏览器原理(一):浏览器为什么要用多进程模型?
你好,我是 LMOS。
前面我们学过了很多基础理论,你可能已经迫不及待,想把这些知识运用到应用层开发里了。所以从这应用篇开始,我们会学以致用,分析一些开发工作中的实际问题,挑战几个典型的综合应用场景。
这节课我会从浏览器开始讲起,浏览器是目前使用范围最广、使用人数最多的终端应用程序之一。作为互联网中最重要的端口,浏览器伴随着互联网的高速发展,发展也是日新月异。通过接下来的两节课,我希望带你看看巨型软件应用优秀的架构设计,同时也带你了解一下平时用到的浏览器里,有哪些技术原理比较关键。
浏览器原本是很简单的东西,只能渲染简单的页面,后来才逐步迈进百花齐放的阶段。
浏览器内核的演变史
我先带你梳理一下浏览器的发展过程。了解了这段历史你就会重新理解WebKit内核和Chrome浏览器的地位知道它们是怎么演变而来的。
说起浏览器我们就不得不提到1994年诞生的网景浏览器从Mosaic浏览器衍生而来。虽然网景浏览器只能展示最简单的 HTML 静态页面不支持动态的脚本JavaScript和样式CSS但是它仍然获得了很大的成功。
在操作系统中,内核是最基本的功能,随着浏览器的发展,在浏览器中,现在同样也存在内核的概念。浏览器内核的作用相对更加简化,浏览器内核的英文名为 Rendering Engine你可以把它理解成一个渲染引擎用途就是把文件资源转化成可视化的图像结果。
浏览器常见的浏览器内核有Blink、WebKit、Gecko、Trident 等,目前 WebKit 内核占据了非常大的的市场,包括 Chrome、Safari、安卓浏览器等市面上的主流浏览器都使用了 WebKit 内核。
从WebKit看浏览器内核架构
既然 WebKit 这么经典我们就以它为例来看一下浏览器内核的架构。浏览器内核主要包含HTML ParserCSS ParserLayoutJavaScript Engine 几部分,如下图所示:
我们简单看一下,上图中的几个关键部分承担了什么工作:
HTML ParserHTML 解析器,负责 HTML 文本的解析,将 HTML 解析为可编程结构 —— DOM (文档对象模型)树;
CSS ParserCSS 解析器是层叠样式的解析器,用来计算布局所需要的节点样式信息 —— CSSOM样式
Layout布局在 得到 DOM 树和 CSSOM 树后需要计算出DOM树中可见元素的几何位置生成布局树 —— Layout Tree
JavaScript EngineJavaScript语言的解析引擎执行页面的动态逻辑并可以访问 DOM 和 CSSOM 数据接口;
操作系统支持 —— 移植WebKit 代码中,因为其天生具有跨平台性质,所以部分平台相关的能力需要做跨平台兼容的移植。
上面是一个简略的浏览器内核的功能,不过它仅仅是完成了核心的渲染过程,实际上浏览器则要复杂得多。
在 2013 年Chromium 发布了 Blink 项目。这个项目是从 WebKit 项目独立出来的,它抽离出了一套新的编程接口和进程模型接口,同时浏览器内核屏蔽了 Chromium 底层的进程模型实现。
Chromium 浏览器架构解读
我们会以 Chromium 浏览器为例,来分析浏览器及其内核。后面是 Chromium 的简易架构图,为方便分析,我删去了部分细节。
在上图中比较重要的是Content 模块以及 Content 接口。
你可以这样理解Content 模块和接口是浏览器对渲染过程的抽象它们将浏览器的渲染、插件、沙箱等功能进行了包装和抽象提供一个接口层方便上层的应用调用。Chromium 中我们可以看到的浏览器可视化界面,它构建在 Content 接口之上用于接收用户交互和展示界面content shell 是一个简易版的浏览器,通常被第三方浏览器软件进行二次开发,它在 Andriod 系统上也应用广泛。
浏览器下的多进程与多线程模型
Chromium和 Blink最大的一个特性就是采用了新的进程模型和线程模型。在前面进程篇的课程中我们了解到进程是应用程序运行时操作系统进行资源分配的最小容器这些资源包括指令集、独立内存空间、IO、PCB 等等。
不过,进程虽然能帮助我们更方便地分配资源,也会引发一些问题:
进程切换上下文的开销比较大。由于虚拟内存的存在,我们需要从硬盘中频繁读写;-
多进程应用通讯复杂度高。由于操作系统的保护策略,系统资源跨进程是无法共享的。如果需要跨进程共享资源就要采用 IPC 通讯 ,但是成本相对高。
线程则是CPU调度的基本单位。线程的优点显而易见切换成本很低只有少量 CPU 寄存器、堆栈等内容,线程的创建、销毁本身也有性能成本,但这个成本相对较低,而且通常可以通过线程池优化。
不过我们也要关注到不足之处,多线程应用编码复杂度高,这会带来后面这几个问题:
线程可以共享进程内的所有资源,但需要考虑资源竞态问题;-
线程间的指令时序不可预测,无法保证代码按照预期的顺序执行;-
单个进程崩溃可能会影响其他线程。
那么在目前常见的 Chrome 浏览器里,采用的是多进程还是单进程多线程模型呢?在我们的电脑中,我打开进程管理器,就可以看到浏览器的后台进程占用情况。通常后台都存在多个进程:
其实在一些旧的浏览器中,采用的是单进程多线程的模型,如 IE 浏览器;但是以 Chromium 浏览器为例的现代浏览器,采用的都是多进程架构。
那么为什么现代浏览器采用的是多进程架构呢?我们需要先分析一下,浏览器如果是单进程多线程会引发哪些问题。
首先是稳定性的问题。因为一个浏览器程序,是可以同时启动多个 Tab 的,浏览器多进程化的最大的好处就是,单个 Tab 的卡死、崩溃不会影响其它 Tab。
我们在浏览器程序内新建一个 Tab 时就会启动一个新的渲染进程Chrome 支持四种不同的进程模型模型:
Process-per-site-instance。这种进程模型会为每一个同一个域的实例都会创建一个Renderer进程。
Process-per-site。这种进程模型会为不同一个域创建独立的进程同一域的不同实例共享同一个进程。
Process-per-tab。这种进程模型会为每个标签页创建一个 Renderer 进程。
Single process。这种进程模型不为页面创建任何独立的进程所有渲染工作都在browser进程中这种模式是实验性质的不推荐使用
Chromium默认采用 Process-per-site-instance 方式,不过我们可以在浏览器启动时传递一个命令行开关,用来指定浏览器的进程模型。
其次,是加载速度的问题。由于整个程序只存在一个进程,浏览器的 JS 代码和插件逻辑和页面渲染是运行在同一个进程中的,如果存在一些计算量很大的操作,这些计算量大的线程会抢占大量资源,从而导致其他的渲染逻辑无法正常执行。这会严重影响页面的加载速度,甚至造成崩溃。在多进程架构下,将插件提取为单独的进程,不会存在插件卡顿和崩溃影响整个浏览器的情况。
最后还有安全性的考虑。由于 JS 脚本和插件的存在,很容易利用浏览器的系统漏洞,进而获得整个计算机的权限,从而造成安全问题。而多进程架构很容易就可以实现沙箱控制。
虽然采用多进程模型就可以解决上述几个问题,但是多进程模型也不是银弹,它同样也会引发一些问题。
比如性能的问题由于基础的指令无法共享多进程会带来很大程度的资源浪费这也是我们很多同学吐槽的“Chrome 非常吃内存”这个问题。由于每个 Tab 和插件都是一个独立的进程,所以在打开多个 Tab 或者插件的情况下,我们会看到系统的内存会疯狂飙升。
这其实是设计上存在的问题,随着时间的发展,硬件性能瓶颈的突破也会推动软件架构发展。现在计算机内存越来越大,没有过去那么宝贵了,所以不同的选择,都是有利有弊。
Chrome 有哪些进程
现在,我们知道了浏览器采用的是多进程模型,那么具体有哪些常见进程呢?我们结合后面这张图来看看。
图中的方框表示具体的进程,连线表示进程间进行的通信,没有连线则表示不会发生进程通信。比如多个渲染进程之间不会进行通信,它们会通过主进程交互, NPAPI插件也不会和 GPU 进行交互,因为它太过于古老,所以没有 GPU 的接口实现。
图中我们可以看到以下几种进程:
浏览器进程:主要负责用户交互、子进程管理和文件储存等功能;
网络进程:浏览器主进程和渲染进程通过他来向操作系统申请端口以及与操作系统的协议栈进程通信;
渲染进程主要职责是把从网络下载的HTML、JavaScript、CSS、图片等资源解析为可以显示和交互的页面
插件进程:主要负责单个插件功能的运行;
GPU 进程主要负责3D效果的实现以及UI的绘制。
这里我们着重了解一下渲染进程renderer内的主要线程。渲染进程内部是多线程的在内核控制下各线程相互配合以保持同步。那么渲染进程内部具体有哪些线程呢一共有八类
GUI 线程:负责渲染浏览器中的页面,并解析 HTMLCSS
JS 线程:负责处理 JavaScript 脚本程序;
事件触发线程:归属于浏览器而不是 JS 引擎,用来控制事件循环;
定时触发器线程:浏览器的定时任务,如 setInterval 与 setTimeout事件也包括浏览器内部的一些定时任务。
IO 线程:用来和其他进程进行 IPC 通信,接受发送消息;
异步http请求线程处理所有的异步请求如果有回调函数就放入异步事件队列由事件触发线程处理
WebWorker 线程:每声明一个 WebWorker 就会新建一个 WebWorker 线程处理;
合成线程在GUI渲染后执行将GUI渲染线程生成的产物转换为位图。
重点回顾
这节课告一段落,我给你做个总结吧。
今天我们先简单分析了WebKit和Chromium的架构它们俩在浏览器里是非常经典的设计。
然后,我们讨论了 Chrome 浏览器里采用的模型是多进程还是单进程多线程。Chrome 浏览器使用了多进程模型解决了一些历史问题它内部有负责统领全局的浏览器主进程、负责网络交互的网络进程、负责页面渲染的渲染进程、负责插件运行的独立插件进程以及负责3D效果的 GPU 进程。
这里重点说说渲染进程,它主要负责单个 Tab 内的页面渲染逻辑,在渲染进程内又是多线程的,在浏览器内核控制下各线程相互配合以保持同步,高效协作。
在各个进程和线程的配合下,浏览器会进行一系列的动作完成渲染,我们称之为渲染流水线,就是从接受网络请求开始,到将其处理成可展示的图形和用户进行交互的过程。那这个过程具体如何运转呢?我们下节课再继续讨论。
这节课导图如下所示,供你复习回顾:
思考题
浏览器的多进程模型下,进程之间是如何通信的呢?
期待你在留言区说说自己的思考。也推荐你把这节课分享给更多同事、朋友。

View File

@ -0,0 +1,298 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
38 浏览器原理(二):浏览器进程通信与网络渲染详解
你好我是LMOS。
通过前面的学习,你应该对浏览器内的进程和线程已经有了一个大概的印象,也知道了为了避免一些问题,现代浏览器采用了多进程架构。
这节课我们首先要说的是Chrome中的进程通信。这么多的进程它们之间是如何进行IPC通信的呢要知道如果IPC通信设计得不合理就会引发非常多的问题。
Chrome如何进行进程间的通信
[上节课]我们提了一下Chrome进程架构Chrome有很多类型的进程。这些进程之间需要进行数据交换其中有一个浏览器主进程每个页面会使用一个渲染进程每个插件会使用一个插件进程。除此之外还有网络进程和GPU进程等功能性进程。
进程之间需要进程通信渲染进程和插件进程需要同网络和GPU等进程通信借助操作系统的功能来完成部分功能。其次同一类进程如多个渲染进程之间不可以直接通信需要依赖主进程进行调度中转。
进程与进程之间的通信也离不开操作系统的支持。在前面讲IPC的时候我们了解过多种实现方式。这里我们来看看Chrome的源码Chrome中IPC的具体实现是通过IPC::Channel这个类实现的具体在 ipc/ipc_channel.cc 这个文件中封装了实现的细节。
但是在查阅代码的过程中,我发现 Chrome 已经不推荐使用IPC::Channel机制进行通信了Chrome 实现了一种新的 IPC 机制—— Mojo。
目前IPC::Channel 底层也是基于 Mojo 来实现的,但是上层接口和旧的 Chrome IPC 保持兼容IPC::Channel 这种方式即将被淘汰,所以这里我们先重点关注 Mojo后面我们再简单了解一下 Chrome IPC 接口。
Mojo
Mojo 是一个跨平台 IPC 框架,它源于 Chromium 项目主要用于进程间的通信ChromeOS 用的也是Mojo框架。
Mojo官方文档给出的定义是这样的
“Mojo是运行时库的集合这些运行时库提供了与平台无关的通用IPC原语抽象、消息IDL格式以及具有用于多重目标语言的代码生成功能的绑定库以方便在任意跨进程、进程内边界传递消息。”
在Chromium中有两个基础模块使用 Mojo分别是 Services 和 IPC::Channel。
Services 是一种更高层次的 IPC 机制,底层通过 Mojo 来实现。Chromium 大量使用这种IPC机制来包装各种功能服务用来取代 IPC::Channel ,比如 device 服务performance 服务audio 服务viz 服务等。
Mojo 支持在多个进程之间互相通信这一点和其他的IPC有很大的不同其他IPC大多只支持2个进程之间进行通信。
这些由Mojo组成的、可以互相通信的进程就形成了一个网络。在这个网络内任意两个进程都可以进行通信并且每个进程只能处于一个 Mojo 网络中,每一个进程内部有且只有一个 Node每一个 Node 可以提供多个 Port每个 Port 对应一种服务,这点类似 TCP/IP 中的 IP 地址和端口的关系。一个 Node:port 对可以唯一确定一个服务。
Node 和 Node 之间通过 Channel 来实现通信,在不同平台上 Channel 有不同的实现方式在Linux上是Domain Socket在Windows上是Named Pipe在macOS平台上是 Mach Port。
在 Port 的上一层Mojo 封装了3个“应用层协议”分别为MessagePipeDataPipe和SharedBuffer这里你是不是感觉很像网络栈在 TCP 上封装了 HTTP。整体结构如下图
我们在 Chromium 代码中使用 Mojo是不必做 Mojo 初始化相关工作的因为这部分Chromium 代码已经做好了。如果我们在 Chromium 之外的工程使用 Mojo还需要做一些初始化的工作代码如下
int main(int argc, char** argv) {
// 初始化CommandLineDataPipe 依赖它
base::CommandLine::Init(argc, argv);
// 初始化 mojo
mojo::core::Init();
// 创建一个线程用于Mojo内部收发数据
base::Thread ipc_thread("ipc!");
ipc_thread.StartWithOptions(
base::Thread::Options(base::MessageLoop::TYPE_IO, 0));
// 初始化 Mojo 的IPC支持只有初始化后进程间的Mojo通信才能有效
// 这个对象要保证一直存活否则IPC通信就会断开
mojo::core::ScopedIPCSupport ipc_support(
ipc_thread.task_runner(),
mojo::core::ScopedIPCSupport::ShutdownPolicy::CLEAN);
// ...
}
MessagePipe 用于进程间的双向通信类似UDP消息是基于数据报文的底层使用 Channel通道SharedBuffer 支持双向块数据传递,底层使用系统 Shared Memory 实现DataPipe 用于进程间单向块数据传递类似TCP消息是基于数据流的底层使用系统的 Shared Memory实现。
一个 MessagePipe 中有一对 handle分别是 handle0 和 handle1MessagePipe 向其中一个handle写的数据可以从另外一个handle读出来。如果把其中的一个 handle 发送到另外一个进程,这一对 handle 之间依然能够相互收发数据。
Mojo 提供了多种方法来发送 handle 到其他的进程,其中最简单的是使用 Invitation。要在多个进程间使用 Mojo必须先通过 Invitation 将这些进程“连接”起来这需要一个进程发送Invitation另一个进程接收 Invitation。
发送Invitation的方法如下
// 创建一条系统级的IPC通信通道
// 在Linux上是 Domain Socket, Windows 是 Named PipemacOS是Mach Port该通道用于支持跨进程的消息通信
mojo::PlatformChannel channel;
LOG(INFO) << "local: "
<< channel.local_endpoint().platform_handle().GetFD().get()
<< " remote: "
<< channel.remote_endpoint().platform_handle().GetFD().get();
mojo::OutgoingInvitation invitation;
// 创建1个essage Pipe用来和其他进程通信
// 这里的 pipe 就相当于单进程中的pipe.handle0
// handle1 会被存储在invitation中随后被发送出去
// 可以多次调用以便Attach多个MessagePipe到Invitation中
mojo::ScopedMessagePipeHandle pipe =
invitation.AttachMessagePipe("my raw pipe");
LOG(INFO) << "pipe: " << pipe->value();
base::LaunchOptions options;
base::CommandLine command_line(
base::CommandLine::ForCurrentProcess()->GetProgram());
// 将PlatformChannel中的RemoteEndpoint的fd作为参数传递给子进程
// 在posix中fd会被复制到新的随机的fdfd号改变
// 在windows中fd被复制后会直接进行传递fd号不变
channel.PrepareToPassRemoteEndpoint(&options, &command_line);
// 启动新进程
base::Process child_process = base::LaunchProcess(command_line, options);
channel.RemoteProcessLaunchAttempted();
// 发送Invitation
mojo::OutgoingInvitation::Send(
std::move(invitation), child_process.Handle(),
channel.TakeLocalEndpoint(),
base::BindRepeating(
[](const std::string& error) { LOG(ERROR) << error; }));
在新进程中接收 Invitation 的方法如下:
// Accept an invitation.
mojo::IncomingInvitation invitation = mojo::IncomingInvitation::Accept(
mojo::PlatformChannel::RecoverPassedEndpointFromCommandLine(
*base::CommandLine::ForCurrentProcess()));
// 取出 Invitation 中的pipe
mojo::ScopedMessagePipeHandle pipe =
invitation.ExtractMessagePipe("my raw pipe");
LOG(INFO) << "pipe: " << pipe->value();
上面使用 Mojo 的方法是通过读写原始的 buffer ,还是比较原始的。-
Chromium 里面使用了更上层的 bindings 接口来进行 IPC 通信。它先定义了一个 mojom 的接口文件然后生成相关的接口cpp代码。发送方调用cpp代码接口接收方去实现cpp代码接口。这种用法类似 Protocol Buffers。
我们不需要显式地去建立进程间的IPC连接因为这些Chromium代码已经做好了。Chromium的每个进程都有一个Service Manage它管理着多个Service。每个Server又管理着多个Mojo接口。在Chromium中我们只需要定义Mojo接口然后在恰当的地方去注册接口、实现接口即可。
legacy IPC
说完Mojo我还想带你简单看一下 legacy IPC。虽然它已经被废弃掉但是目前还有不少逻辑仍在使用它你可以在这里看到目前还在使用它的部分都是一些非核心的消息。所以我们还是要大致理解这种用法。
后面这张图是官方的经典图解:
-
我们看到每个Render进程都有一条Legacy IPC 通过 Channel 和 Browser 连接ResourceDispacher通过 Filter 同 Channel进行连接。IPC 里面有几个重要的概念:
IPC::Channel一条数据传输通道提供了数据的发送和接收接口
IPC::Message在Channel中传输的数据主要通过宏来定义新的Message
IPC::Listener提供接收消息的回调创建Channel必须提供一个Listener
IPC::Sender提供发送IPC::Message的Send方法IPC::Channel就实现了IPC::Sender接口
IPC::MessageFilter也就是Filter用来对消息进行过滤类似管道的机制它所能过滤的消息必须由其他Filter或者Listener传给它
IPC::MessageRouter一个用来处理 Routed Message 的类。
Legacy IPC的本质就是通过IPC::Channel接口发送IPC::MessageIPC::Channel是封装好的类IPC::Message需要用户自己定义。
IPC::Message 有两类,一类是路由消息 “routed message”一类是控制消息 “control message”。
唯一不一样的就是 routing_id() 不同,每一个 IPC::Message都会有一个 routing_id控制消息的 routing_id 始终是 MSG_ROUTING_CONTROL ,这是一个常量。除此之外,所有 routing_id 不是这个常量的消息,都是路由消息。
网页渲染的流程
前面我们讲了浏览器的架构,进程/线程模型以及浏览器内的 IPC 通信实现,有了这些铺垫,我们再来理解浏览器内部的进程模型的工作机制,就更容易了。进程通信会伴随着网络渲染的过程,所以,我推荐你从实际的渲染过程来观察,也就是搞明白浏览器是怎么借助计算机进行页面图像渲染的。
浏览器接收到用户在地址栏输入的URL以后浏览器的网络进程会利用操作系统内核网络栈进行资源获取。在第一季的网络篇我们曾经用了一节课的时间讲解[网络数据包是在网络中如何流转的]。如果你想要详细了解,可以去看看。这里我们着重关注浏览器收到响应后的渲染过程。
在浏览器启动后,浏览器会通过监听系统的某个指定端口号,监听数据的变化。在浏览器收到网络数据包后,会根据返回的 Content-Type 字段决定后续的操作如果是HTML那么浏览器则会进入渲染的流程。
在渲染过程中主要工作交由渲染进程处理我们可以简要分为几个部分建立数据传输管道、构建DOM树、布局阶段、绘制以及合成渲染。下面我们分别进行讲解。
建立数据传输管道
当网络进程接收到网络上出来的 HTML 数据包的时候渲染进程不会等网络进程完全接受完数据才开始渲染流程。为了提高效率渲染进程会一边接收一边解析。所以渲染进程在收到主进程准备渲染的消息后会使用Mojo接口通过边解析变接收数据的方式和网络进行IPC通信建立数据传输的管道将数据提交到渲染进程。
构建 DOM 树
渲染进程收到的是 HTML 的字符串,是一种无法进程结构化操作的数据,于是我们需要将纯文本转为一种容易操作、有结构的数据 —— DOM 树。
DOM树本质上是一个以 document 为根节点的多叉树DOM 树是结构化、易操作的,同样浏览器也会提供接口给到开发者,浏览器通过 JS 语言来操作 DOM 树,这样就可以动态修改页面内容了。
在渲染进程的主线程内部,存在一个叫 HTML解析器HTMLParser的东西想要将文本解析为 DOM 离不开它的帮助。HTML 解析器会将 HTML 的字节流,通过分词器转为 Token 流,其中维护了一个栈结构,通过不断的压栈和出栈,生成对应的节点,最终生成 DOM 结构。
在 DOM 解析的过程中当解析到标签时,它会暂停 HTML 的解析,渲染进程中的 JS 引擎加载、解析和执行 JavaScript 代码完成后,才会继续解析。
在 JS 解析的过程中JS 是可能进行 CSS 操作的,所以在执行 JS 前还需要解析引用的 CSS 文件,生成 CSSOM 后,才能进行 JS 的解析。CSSOM 是 DOM 树中每个节点的具体样式和规则对应的树形结构,在构建完 CSSOM 后,要先进行 JS 的解析执行,然后再进行 DOM 树的构建。
布局阶段 —— layout
这时已经构建完 DOM 树和 CSSOM 树,但是还是无法渲染,因为目前渲染引擎拿到的只是一个树形结构,并不知道具体在浏览器中渲染的具体位置。
布局就是寻找元素几何形状的过程,具体就是主线程遍历 DOM 和计算样式,并创建包含 xy 坐标和边界框大小等信息的布局树。
布局树可能类似于 DOM 树的结构,但它只包含与页面上可见内容相关的信息。比如说,布局树构建会剔除掉内容,这些内容虽然在 DOM 树上但是不会显示出来如属性为display: none的元素其次布局树还会计算出布局树节点的具体坐标位置。
绘制
渲染进程拿到布局树已经有具体节点的具体位置,但是还缺少一些东西,就是层级。我们知道,页面是类似 PS 的图层,是有图层上下文顺序的,而且还有一些 3D 的属性浏览器内核还需要处理这些专图层并生成一棵对应的图层树LayerTree
有了图层的关系,就可以开始准备绘制了,渲染进程会拆分出多个小的绘制指令,然后组装成一个有序的待绘制列表。
合成渲染
从硬件层面看,渲染操作是由显卡进行的,于是浏览器将具体的绘制动作,转化成待绘制指令列表。
浏览器渲染进程中的合成线程会将数据传输到栅格化线程池从而实现图块的栅格化最终把生成图块的指令发送给GPU。然后在GPU中执行生成图块的位图并保存在GPU的内存中。
此时显示器会根据显示器的刷新率,定期从显卡的内存中读取数据。这样,图像就可以正常显示,被我们看到了。
浏览器渲染的流程比较复杂,其中的细节也比较多,如果要详细分析,还可以拆成一篇超长篇幅,所以这里我们只是了解简单过程。你如果想要了解完整过程,可以阅读拓展材料中的 Chrome 开发者的官方博客。
Chromium 的文件结构解析
前面课程里,我们通过一些概念和例子简单了解了 WebKit 和 Chromium 的架构,不过这两者是非常庞大的项目,代码量也是非常的巨大,除去其中依赖的第三方库,这两个项目的代码量都是百万级别的,如果直接阅读的话是非常困难的。
但是良好的代码组织结构,很好地帮助了开发者和学习者们。下面我大致介绍一下它们的目录结构及其用处,方便你快速地理解整个项目。
因为里面的一二级目录非常多和深,所以我们把焦点放在核心的部分即可。我们可以通过 GitHub 将 Chromium 的源码下载下来阅读,但是源码非常大,如果你不想下载,可以通过这个链接 访问在线版本。
├── android_webview - 安卓平台webview的 `src/content` 目录所需要的接口
├── apps - chrome打包 apps 的代码
├── base - 基础工具库,所有的子工程公用
├── build - 公用的编译配置
├── build_overrides //
├── cc - 合成器
├── chrome - chrome 相关的稳定版本实现比如渲染进程中的某些API 的回调函数和某些功能实现
├── app - 程序入口
├── browser - 主进程
├── renderer - 渲染进程
...
├── chromecast
├── chromeos - chromeos 相关
├── components - content层调用的一些组件模块
├── content - 多进程模型和沙盒实现的代码
├── app - contentapi 的部分 app 接口
├── browser - 主进程的实现
├── common - 基础公共库
├── gpu - gpu 进程实现
├── ppapi_plugin - plugin 进程实现
├── public - contentapi 接口
├── renderer - 渲染进程实现
...
├── courgette
├── crypto - 加密相关
├── device - 硬件设备的api抽象层
├── docs - 文档
├── gpu - gpu 硬件加速的代码
├── headless - 无头模式,给 puppeteer 使用
├── ipc - ipc 通信的实现,包括 mojo 调用和 ChromeIPC
├── media - 多媒体相关的模块
├── mojo - mojo 底层实现
├── native_client_sdk
├── net - 网络栈相关
├── pdf - pdf 相关
├── ppapi - ppapi 代码
├── printing - 打印相关
├── sandbox - 沙箱项目,安全用防止利用漏洞攻击操作系统和硬件
├── services
├── skia - Android 图形库,直接从 Android 代码树中复制过来的
├── sql - 本地数据库实现
├── storage - 本地存储实现
├── third_party - 三方库
├── Webkit
...
├── tools
├── ui - 渲染布局的基础框架
├── url - url 解析和序列化
└── v8 - V8 引擎
重点回顾
今天,我们学习了 Chrome 下的多进程之间的协作方式。
老版本的 Chrome 使用 Legacy IPC 进行 IPC 通信,它的本质就是通过 IPC::Channel 接口发送 IPC::Message。而新版本的 Chrome 使用了 Mojo 进行 IPC 通信Mojo 是源于 Chrome 的 IPC 跨平台框架。Chrome 在不同的操作系统下的 IPC 实现方式有所不同在Linux上是 Domain SocketWindows 是 Named PipemacOS是Mach Port。
之后,我们通过网页渲染的例子深入了解了,不同进程之间如何协作来进行渲染。最后我给你列举了 Chrome 项目的基本目录结构,如果你对其感兴趣,可以自行下载源码,深入探索。
这节课的导图如下,供你参考:
扩展阅读
浏览器是一个极为庞大的项目,仅仅通过两节课的内容,想要完全了解浏览器的特性是不太可能的。希望这两节课能抛砖引玉,更多的内容需要你自己去进行探索。
这里我为你整理了一些参考资料,如果你能够认真阅读,相信会获得意想不到的收获。
首先是 Chromium 官方的设计文档,包含了 Chromium and Chromium OS 的设计思维以及对应源码。
其次是 Chrome 开发者的官方博客,里面的系列文章详细介绍了 Chrome 渲染页面的工作流程。
还有Mojo 的官方文档从这里你可以了解Mojo 的简单使用以及实现。
最后就是《WebKit技术内幕》这本书详细介绍了WebKit的渲染引擎和 JavaScript 引擎的工作原理
思考题
为什么JS代码会阻塞页面渲染从浏览器设计的角度看浏览器可以做哪些操作来进行优化在开发前端应用过程中又可以做哪些优化呢
欢迎你在留言区和我交流讨论。如果这节课对你有启发,别忘了分享给身边更多朋友。

View File

@ -0,0 +1,315 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
39 源码解读V8 执行 JS 代码的全过程
你好我是LMOS。
前面我们学习了现代浏览器架构也大致了解了浏览器内核的工作原理。在浏览器的内核中V8 是一个绕不开的话题。在浏览器中Chrome 的重要地位不用赘述而V8不仅是 Chrome 的核心组件,还是 node.js 等众多软件的核心组件所以V8的重要程度亦不用多言。
不过V8涉及到的技术十分广泛包括操作系统、编译技术、计算机体系结构等多方面知识为了带你先从宏观角度系统学习和了解V8项目这节课我会从源码理解讲起带你了解了V8 执行 JS 代码的全过程。
如何阅读 V8 源码和搭建 V8 开发环境
前面两节课,我带你简单了解了 Chromium 和 Webkit 项目的目录结构,在这里我们继续看一下如何学习 V8 源码。
Chromium 项目中包含了可运行的 V8 源码,但是从调试的角度看,我们一般使用 depot_tools来编译调试 V8 源码它是V8的编译工具链下载和编译代码都需要用到它你可以直接点击 depot_tools bundle 下载。
解压后,我们需要将 depot_tools 工具添加到环境变量,注意这些操作需要你保证本机可以访问 Google 浏览器。
我们以 Mac 系统为例,添加到环境变量的代码命令如下:
export PATH=`pwd`/depot_tools:"$PATH"
然后,你可以在命令行中测试 depot_tools 是否可以使用:
gclient sync
下载完 depot_tools 后,我们就可以下载 V8 代码进行编译调试了“
mkdir v8
cd v8
fetch v8
cd v8/src
下载好 V8 源码后,我们需要使用 GN 来配置工程文件。下面是我们用到的几个编译的参数:
is_component_build = true // 编译成动态链接库以减少体积
is_debug = true // 开启调试
v8_optimized_debug = true // 关闭一些代码优化
symbol_level = 0 将所有的debug符号放在一起加速二次编译和链接过程;
ide=vs2022 / ide=xcode // 选择编译 IDE
我们这节课就不展开讲解 gn 命令了,如果你有兴趣了解更多内容,可以自行查阅资料。说回正题,我们继续聊配置工作。
Windows 的配置情况如下
gn gen out.gn/x64.release --args='is_debug=true target_cpu="x64" v8_target_cpu="arm64" use_goma=true is_component_build=true v8_optimized_debug = true symbol_level = 0'
我们再来看看 Mac 下的情况Mac 下我们需要更新 xcode 依赖,代码如下:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
gn gen out/gn --ide=xcode
执行完成后,我们可以通过 IDE 进入相应的工程文件下,后面是我的操作截图,供你参考:
我们看到,在工程文件下有一个名为 samples 的目录,上图中打开的文件 hello-world.cc 也是这个目录下的一个文件,它是 V8 项目中的一个实例文件我们后面的学习也会从hello-world.cc文件入手。
我们来看一下这个文件的具体代码:
int main(int argc, char* argv[]) {
// Initialize V8.
v8::V8::InitializeICUDefaultLocation(argv[0]);
v8::V8::InitializeExternalStartupData(argv[0]);
std::unique_ptr<v8::Platform> platform = v8::platform::NewDefaultPlatform();
v8::V8::InitializePlatform(platform.get());
v8::V8::Initialize();
// Create a new Isolate and make it the current one.
v8::Isolate::CreateParams create_params;
create_params.array_buffer_allocator =
v8::ArrayBuffer::Allocator::NewDefaultAllocator();
v8::Isolate* isolate = v8::Isolate::New(create_params);
{
v8::Isolate::Scope isolate_scope(isolate);
// Create a stack-allocated handle scope.
v8::HandleScope handle_scope(isolate);
// Create a new context.
v8::Local<v8::Context> context = v8::Context::New(isolate);
// Enter the context for compiling and running the hello world script.
v8::Context::Scope context_scope(context);
{
// Create a string containing the JavaScript source code.
v8::Local<v8::String> source =
v8::String::NewFromUtf8Literal(isolate, "'Hello' + ', World!'");
// Compile the source code.
v8::Local<v8::Script> script =
v8::Script::Compile(context, source).ToLocalChecked();
// Run the script to get the result.
v8::Local<v8::Value> result = script->Run(context).ToLocalChecked();
// Convert the result to an UTF8 string and print it.
v8::String::Utf8Value utf8(isolate, result);
printf("%s\n", *utf8);
我们简单看看 hello-world.cc 这个文件,它是用 C++ 程序编写的,主要做了下面几件事:
初始化了 V8 程序;-
运行了一段基于 JavaScript 语言程序的 “hello world” 并输出;-
运行了一段基于 JavaScript 语言程序的加法运算并输出;-
执行完成后卸载了 V8。
上节课我们有提到V8 是一个 JS 的执行引擎,在这个 helloworld 的代码中除去运行JS 代码的两部分,其它的代码都是为 JS 代码运行提供的准备工作。
我们现在就看一下运行时都做了哪些基本的准备工作。
V8 在运行时的表现
上面代码是 hello-world 代码的主函数也是核心的部分。我们梳理一下关键过程有哪些首先hello- world代码的主函数调用了 v8::V8::Initialize() 方法对 V8 进行初始化;然后,调用了 v8::Isolate::New 来创建 Isolate接着创建完成后调用了 v8::Script::Compile 来进行编译;最后,调用 script->Run 用来执行 JS 代码。
我们后面会围绕上述关键过程做分析。你可以结合下面这张图,看看 hello-world.cc 的执行过程,还有这个过程里涉及到的核心方法和重要数据结构。
好,让我们进入具体分析环节,先从内存申请开始说起。
V8启动时的内存申请
申请内存从 InitReservation 方法开始,它主要处理的操作就是为 V8 引擎向 OS 申请内存,代码在 src/utils/allocation.cc 这个目录中:
// Reserve a region of twice the size so that there is an aligned address
// within it that's usable as the cage base.
VirtualMemory padded_reservation(params.page_allocator,
params.reservation_size * 2,
reinterpret_cast<void*>(hint));
if (!padded_reservation.IsReserved()) return false;
// Find properly aligned sub-region inside the reservation.
Address address =
VirtualMemoryCageStart(padded_reservation.address(), params);
CHECK(padded_reservation.InVM(address, params.reservation_size));
申请内存的时候InitReservation 会先申请两倍的内存,保证内存对齐,再从两倍内存中找到一个适合对齐地址,这是 V8 真正使用的内存地址。这块申请出来的内存后面的工作里用得上。完成申请后,还会再调用 padded_reservation.Free() 方法,将刚开始申请的内存释放掉。
下面我带你看看 VirtualMemoryCage 数据结构,它是 V8 内存管理的主要数据结构。V8的内存方式采用的段页式和 OS 的内存数据结构比较类似,但区别是 V8 只有一个段OS 会有多段,但是 V8 会有很多页。
VirtualMemeoryCage 的数据结构位于allocation.h 文件中,如下所示:
// +------------+-----------+----------- ~~~ -+
// | ... | ... | ... |
// +------------+-----------+------------ ~~~ -+
// ^ ^ ^
// start cage base allocatable base
//
// <------------> <------------------->
// base bias size allocatable size
// <-------------------------------------------->
// reservation size
reservation size 是 V8 实际申请的内存start 是内存基址cage base 是页表的位置allocatable 是 V8 可分配内存的开始,用来创建 Isolate。
Isolate
Isolate是一个完整的V8实例有着完整的堆和栈。V8是虚拟机Isolate才是运行JavaScript的宿主。一个Isolate是一个独立的运行环境包括但不限于堆管理器heap、垃圾回收器GC等。
在同一个时间有且只有一个线程能在Isolate中运行代码也就是说同一时刻只有一个线程能进入Iisolate而多个线程可以通过切换来共享同一个Isolate。
Isolate 对外的接口是 V8_EXPORT ,定义在 include/v8.h 文件中其他程序可以调用它。这个接口也可以理解为JavaScript的运行单元多个线程也就是多个任务它们可以共享一个运行单元主要涉及到几个 V8 的概念:
Context上下文所有的JS代码都是在某个V8 Context中运行的。
Handle一个指定JS对象的索引它指向此JS对象在V8堆中的位置。
Handle Scope包含很多handle的集合用来统一管理多个handle当Scope被移出堆时它所管理的handle集合也会被移除。
Isolate 还有一个对内的数据结构 V8_EXPORT_PRIVATE也是一个核心的数据结构内部的很多重要的结构都会用到它后面编译流程我还会讲到。
编译
V8 的编译流程也是 V8 的核心流程,我们先简单看下编译的大概流程:
tokenize (分词):将 JS 代码解析为 Token 流Token 是语法上的不可拆分的最小单位;
parse (解析):语法分析,将上一步生成的 token 流转化为 AST 结构AST 被称为抽象语法树;
ignite (解释):通过解释器,生成字节码。
接着,我们再看看这个过程的关键数据结构 V8_EXPORT_PRIVATE ParseInfo代码在 src/parsing/parse-info.cc 目录下:
ParseInfo 这个数据结构就是JS 代码生成token再生成 AST 的过程AST 的数据结构位置在 src/ast/ast.h。
生成 AST后解释器会根据 AST生成字节码并解释执行字节码。字节码是介入 AST和机器码之间的一种数据结构你先留个印象我们后面再详细说。
代码执行
经过编译,最终生成了字节码。我们继续来看 Exectuion 这个数据结构,这个结构承载着 JS 代码运行过程前后的相关信息:
class Execution final : public AllStatic {
public:
// Whether to report pending messages, or keep them pending on the isolate.
enum class MessageHandling { kReport, kKeepPending };
enum class Target { kCallable, kRunMicrotasks };
// Call a function, the caller supplies a receiver and an array
// of arguments.
//
// When the function called is not in strict mode, receiver is
// converted to an object.
//
V8_EXPORT_PRIVATE V8_WARN_UNUSED_RESULT static MaybeHandle<Object> Call(
Isolate* isolate, Handle<Object> callable, Handle<Object> receiver,
int argc, Handle<Object> argv[]);
通过前面关键过程和数据结构的讲解,相信你已经基本了解了 V8 运行时的核心流程,下面我们从宏观层面看一下这个过程。
V8 编译 —— V8 执行 JS 的过程
JS代码是给人看的并不能由机器直接运行需要很多中间步骤的转换执行这些步骤的就是JS解析器。
主要过程是这样首先对JS源代码进行词法分析将源代码拆分成一个个简单的词语即Token然后以这些Token为输入流进行语法分析形成一棵抽象语法树即AST并检查其语法上的错误最后由语法树生成字节码由JS解析器运行。下面我们分别讨论这几个步骤。
词法分析
词法分析是将 JS 代码拆分成对应的 TokenToken 是能拆分的最小单位,固定 type 表述类型/属性value 表示对应的值,如下图 Token。
[{
"type": "Keyword",
"value": "let"
}, {
"type": "Identifier",
"value": "name"
}, {
"type": "Punctuator",
"value": "="
}, {
"type": "string",
"value": "LMOS"
}]
语法分析
在进行词法分析转为 Token 之后,解析器会继续根据生成的 Token 生成对应的 AST。说起AST相信前端同学并不陌生也是热词之一无论是在 Vue、React 中表示虚拟 DOM ,或者表示 Babel 对 JS 的转译,都需要先将其转化为对应的 AST。
字节码
在解析器Parser将 JS 代码解析成 AST 之后解释器Ignition根据 AST 来生成字节码(也称中间码)。前文提到 CPU 只能识别机器码,对字节码是识别不了的,这里就衍生出一个问题,如果 CPU 识别不了字节码,那为什么还要在中间插一步来耗费资源转成字节码呢?效率不是很低吗?
在计算机学科里聊效率,都逃避不了时间和空间这两个概念,绝大部分的优化都是空间换时间或时间换空间,两者的平衡,效率如何达到最高,是一个很值得深入研究的问题。
拿之前版本的 V8 引擎执行 JS 来说,是没有转字节码这一步骤的,而是直接从 AST 转成机器码,这个过程称为编译过程,所以每次拿到 JS 文件的时候,首先都会编译,而这个过程还是比较浪费时间的,这是一件比较头疼的事情,需要一个解决办法。
V8 中的优化细节
V8 执行 JS 的主要过程我们说完了其实在这个过程中V8 利用 JIT 的能力做了很多方面的优化,现在我们看一下具体有哪些。
缓存机器码
一个网页只要第一次打开过,关闭再次去打开,大部分情况下,还是和原来 JS 文件一致的,除非开发者修改了代码,但这个可以暂时不考虑。毕竟哪个网站也不会一天闲得无聊,不停地修改,上传替换。
按照这个思路,既然绝大多数情况下,文件不会修改,那编译后的机器码可以考虑缓存下来,这样一来,下次再打开或者刷新页面的时候就省去编译的过程了,可以直接执行了。
存储机器码可以分成两种情况:一个是浏览器未关闭时候,直接存储到浏览器本地的内存中;一个是浏览器关闭了,直接存储在磁盘上,而早期的 V8 也确实是这么做的,典型的牺牲空间换时间。
热代码
在代码中,常常会有同一部分代码,被多次调用,同一部分代码如果每次都需要解释器转二进制代码再去执行,效率上来说,会有些浪费,所以在 V8 模块中会有专门的监控模块,来监控同一代码是否多次被调用,如果被多次调用,那么就会被标记为热代码,这有什么作用呢?我们继续往下看。
优化编译器
TurboFan (优化编译器) 这个词,相信关注手机界的同学并不陌生,华为、小米等这些品牌,在近几年产品发布会上都会出现这个词,主要的能力是通过软件计算能力来优化一系列的功能,使得效率更优。
接着热代码继续说当存在热代码的时候V8 会借助 TurboFan 将为热代码的字节码转为机器码并缓存下来,这样一来,当再次调用热代码时,就不再需要将字节码转为机器码。当然,热代码相对来说还是少部分的,所以缓存也并不会占用太大内存,并且提升了执行效率,同样此处也是牺牲空间换时间。
反优化
JS 语言是动态语言,非常之灵活,对象的结构和属性在运行时是可以发生改变的,我们设想一个问题:如果热代码在某次执行的时候,突然其中的某个属性被修改了,那么编译成机器码的热代码还能继续执行吗?
答案是肯定不能。这个时候就要使用到优化编译器的反优化了,它会将热代码退回到 AST 这一步,这个时候解释器会重新解释执行被修改的代码;如果代码再次被标记为热代码,那么会重复执行优化编译器的这个步骤。
总结
这节课我们先通过编译源码的方式搭建了 V8 的环境,又通过 V8 项目中的 hello_world 项目一步步学习了 V8 执行 JS代码的过程最后我们又从宏观角度了解了 V8 执行 JS 代码的全过程。
这节课的要点,你可以结合后面的导图看一下。
在这个过程中,我们通过 V8 项目的关键代码和数据结构深入的了解了 V8 这个项目。在学习一个开源巨石项目的过程中,我们要掌握一定的学习方式,切不可以在初学习的阶段就过度自底而上地纠结于各种代码细节。
我们可以通过这样的方式进行学习:
初步建立印象:自顶而上的了解项目的结构和架构,形成一个初步的宏观视觉;
梳理主线:进入程序源码的角度,理解代码的主要脉络,建议从一个简单的例子入手;
关注重要过程:关注过程中的关键代码输入输出,运行过程中的几个重要中间阶段、重要中间结果和数据结构;
查漏补缺:补充细节知识点的查漏补缺,结合自己情况深入学习。
V8 在执行 JS 的过程中又可以进行很多优化,具体方式就是在运行 JS 过程中持续记录代码语句执行情况,以及变量类型的变化情况。若推测代码执行次数较多(热点代码)且变量类型较固定时,就会调用优化器优化这部分代码,缓存这部分机器码 + 跳过这部分类型判断逻辑,从而实现性能优化。
思考题
V8 在执行 JS 的过程中可以做哪些性能优化?
欢迎你在留言区与我交流讨论,也推荐你把课程分享给更多朋友。

View File

@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
40 内功心法(一):内核和后端通用的设计思想有哪些?
你好我是LMOS。
前面我们学过了很多基础知识点,但你也许心中还是有点打鼓。要想跳出“边学边忘”的糟糕循环,除了温故知新,加深记忆,更重要的是把“内功心法”迁移到更多场景中。理解了技术的本质之后,在底层和应用层穿梭不是问题,在前端和后端切换也会更加游刃有余。
接下来的两节课,我会带你一起看看内核和后端通用的设计思想都有哪些,它们又是如何用在具体技术里的?这节课我先分享三大通用“心法”,分别是并行化、异步和调度。
内功心法之并行化
我们专栏最前面讲过图灵机,刚开始接触到它的时候,是不是感觉图灵机的串行纸带模型对计算机做了非常好的抽象呢?然而,现实世界里我们如果只使用串行模型来解决问题,恐怕就比较低效了。
那么如何才能解决串行处理的低效问题呢?这就不得不说到并行化了。
关键路径和阿姆达尔定律
我先描述一个现象,你看看是不是很熟悉:一段程序放在面前,你需要对它进行性能优化,但你辛辛苦苦调了许久,优化效果却并不明显。
之所以会遇到这样的问题,核心原因是我们没有梳理清楚这段程序的关键路径,并对关键路径做有效优化。那么如何使用关键路径这种工具呢?我给你讲个番茄炒蛋盖饭的故事。
你没走错片场,咱们梳理一下做一道番茄炒蛋盖饭,都需要做什么。我们先在脑中把整个过程拆解成下图中的具体步骤。然后,在每一个步骤上标出这个步骤的耗时。你可以参考后面这张流程图看一下。
对照示意图我们就会发现吃上盖浇饭的最短时间其实是实线部分的35分钟这条最短路径就是做成这件事情的关键路径。
当我们想要优化做这道菜的时间的时候我们可以先考虑优化黑线中的关键路径比如我们可以考虑买10个电饭锅并行化让每个电饭锅煮少一点这样可以熟得更快一些把煮饭时间也缩短到5分钟。这样整体时间就会得到优化。这个例子可能和实际做饭的情况不大一样不过这里我们主要是为了说明并行化这件事也期待你找到一个更贴切的事情做类比。
其实我们做程序优化的时候也是如此,很多时候明明优化了却不太见效,本质上是因为没有找对程序运行中的关键路径。
那怎么解决这种问题呢我们可以根据日志等信息把整个程序的运行步骤梳理清楚绘制出上面这样的PERT图之后优化的重点就一目了然了。也许这时候你会发现之前自己根本就没有优化对地方。
有了前面把关键路径上的某个环节并行化的例子,你可能会好奇,是不是并行化无所不能,以后就靠并行化来优化系统就行了呢?
其实不然,并行化也有自己的局限性,这里就要提到阿姆达尔定律了。阿姆达尔定律是计算机工程中的一条经验法则,它的定义是:在并行计算中用多处理器的应用,加速受限于程序所需的串行时间百分比。
只说定义不好理解举个例子如果你有一段程序其中有一半是串行的另一半是并行的那么这段程序的最大加速比例就是2。
这就意味着不管你如何优化程序,无论是让它运行在多核,或者分布到不同的机器上,这个加速比例都没有办法提高。这种情况下,我们可以优先考虑改进串行的算法,可能会带来更好的提升。
后端场景中的并行化思想
内核中并行化思想有很多应用。比如说支持SMP处理器、并行IO、使用MMX/SSE/AVX指令基于向量化的计算方式优化程序性能之类的操作本质上都是在用并行化的思路来提升性能。
而在后端场景下,并行化思想其实又进一步做了扩展。后端的并行化并不仅仅局限于单机上的物理机资源的并行化,我们还可以基于多进程/线程/协程等抽象的概念,并发请求网络上的不同机器进行计算,从而实现更高的效率。
当然需要注意的是发起多(进程/线程/协程)调用的客户端节点,有可能是单核的,也可能是多核心的。如果是多核心情况下的调用,我们称之为并行;而单核心时我们会叫做并发。
虽然概念和实现略有不同但并行化的核心思想本质是相通的。举个例子吧比如当我们使用下边这段程序开启多个协程来同时发起http请求的时候本质就是在借助并行化的思想来提升效率
func main() {
i := 0
// 使用WaitGroup原语等一组goroutine全部完成之后再继续
wg := &sync.WaitGroup{}
for i < 10 {
// 增加计数器
wg.Add(1)
url := "https://time.geekbang.org"
go func(url string) {
resp, _ := http.Get(url)
defer resp.Body.Close()
data, _ := ioutil.ReadAll(resp.Body)
// 释放计数器
wg.Done()
}(url)
i++
}
// 阻塞住,等待所有协程执行完毕时再释放
wg.Wait()
fmt.Println("end")
}
不难发现虽然发起了10次请求但其实多个Goroutine是并发发起请求的所以最终响应时间只取决于最慢的那一次请求。我们可以发现整体耗时要比串行发起10次请求短多了。
内功心法之异步化
学习了并行化这个思想之后,我再来说说其他更有趣的优化思路——异步化思想,这也是我们经常用来解决问题的一个神器。
当一个事情处理起来比较消耗资源,我们就会考虑把这个事情异步化。比如我们去某个网红饭店点菜,如果是同步处理的话,我们需要每隔一分钟就把服务员叫过来,问一次菜好了没有。这样做,会同时占用你和服务员的资源,估计问不了几次你就崩溃了。
这时候聪明的服务员就想到了一个办法,当你点单之后就给你发一个“号码牌”。等菜做好了之后,服务员再按照号码牌把菜送上来。这样,在等待的过程中你还可以干点别的事情,服务员也不会被一桌客人给“锁定住”,无法服务别的顾客。由此效率就得到了提升,这样的操作就是异步化处理。
我们在之前虚拟内存的时候(可以回顾[第二十四节课]),其实就已经接触过异步化了。当我们的程序配置好中断之后,就可以运行别的逻辑去了。这样当中断发生的时候,内核才会调用对应的中断处理函数,这其实就是一种异步化的思路。
内核里异步化思想随处可见不光中断机制Linux内核中的信号机制、工作队列workqueue_struct其实也都大量使用了异步化思想。
异步化思想在后端中架构中也有很多应用比如为了提高后端服务的吞吐能力我们可以使用AIO、epoll做IO、消息处理的异步化。当我们有较多写入请求为了避免击穿下游系统我们也可以用下图中的队列思路来进行异步的削峰填谷。
再比如一个A系统原本通过直接调用耦合了下游B、C、D子系统需要等下游处理完毕才能返回的时候我们也可以基于队列进行异步处理从而降低耦合、提升响应时间。你可以对照后面的流程图理解一下这段话
掌握了异步化思想之后,你就可以基于相同的思路,举一反三来设计出分布式事务、分布式计算框架之类等更多有用的中间件啦!
内功心法之调度
现实世界里,我们手里的资源往往是有限的,但需求却往往趋近于无限。怎么平衡这种矛盾呢?没错,为了更好地利用资源,就出现了调度这个概念。调度思想的核心就是通过各种调度手段,让有限的资源尽可能得到更高效的利用。
操作系统内核中调度无处不在。比如为了更好地抽象CPU资源OS内核抽象出了进程/线程面对CPU资源有限、有CPU资源需求的进程/线程可能有无限多的情况OS内核设计出了各种调度算法。
就拿CFS调度器来说它在调度上非常“公平”它记录了每个进程的执行时间哪个进程运行时间最少就让那个进程运行。更多细节我在第一季《操作系统实战45讲》[第二十七课]详细分享过,感兴趣的话你可以去看看。
再比如为了更好地使用物理内存OS内核抽象出了虚拟内存那如何调度这些内存呢内核又设计出了页面调度算法。还有就是为了管理磁盘中的数据OS抽象出了文件概念。这还没完如何提读写升效率呢OS又设计出了各种磁盘调度算法。
调度思想在后端架构中其实也很常见。以Golang、Java编程语言为例在语言内的运行时库中也会包含对进程/线程/协程、内存的调度管理策略。
在业务层面我们很多时候也会开发很多后台作业为了提升这些作业的性能和作业的可用性我们也会基于分布式任务调度框架进行后台作业的分布式调度。我给你举个具体点的例子带你看看Apache DolphinScheduler的架构图
当然为了满足分布式、大数据领域的各种业务场景Apache DolphinScheduler设计的其实比较复杂但是到回归架构设计上我们发现大多数分布式任务调度系统都会包含这以下五个部分
控制台:用于展示调度任务的配置、依赖关系、任务状态等信息;-
接入:将控制台的作业转化、下发给调度器模块,并且向注册中心注册任务;-
调度器:接收接入下发的调度任务,进行任务拆分下发,在注册中心找执行器,然后把任务下发到执行器执行,同时也注册到注册中心;-
执行器:接收调度任务,并且上报状态给注册中心;-
注册中心:主要用于节点、任务状态的协调与同步。
虽然这五个部分看起来有点复杂,但是我们回归到设计一个调度系统问题的本质上来思考。调度系统解决的关键问题,其实是将一些“资源”分配给一些“活”,并且保证“活”能按照一定的顺序、在一定资源开销的前提下处理完。顺着这条主线理解起来,就会清晰很多了。
总结
今天我带你了解了三种内核和后端通用的设计思想。我也举了不少例子,方便你了解这些思想,如何用在后端应用层和内核软件里。
其实,今天的课程内容属于偏抽象的架构思想,目的是帮你拓宽思路,把学过的知识融会贯通。因此建议你学习完了之后,再结合你自己的兴趣自行拓展延伸。如果你领会到了这思想的本质,不妨试试应用在技术实践上,相信会让你的开发工作更得心应手。
另外,我还挑选了三个代表性的项目,它们很好地应用了今天所讲的设计思想,你可以课后了解一下:
并行化可以参考Hadoop项目https://hadoop.apache.org/
异步化可以参考Pulsar项目https://pulsar.apache.org/
调度可以参考前文中提到的DolphinScheduler项目https://dolphinscheduler.apache.org/
最后我给你梳理了一张导图,供你做个参考:
思考题
今天,我们学习了在计算机系统中常用的并行化、异步化和调度这三种通用的设计思想,那么请你思考一下,自己工作、生活中还有哪些场景用到了这些思想呢?
期待看到你的分享,我在留言区等你。如果觉得这节课还不错,别忘了转发给更多朋友,跟他一起交流学习。

View File

@ -0,0 +1,159 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
41 内功心法(二):内核和后端通用的设计思想有哪些?
你好我是LMOS。
上节课,我们学习了并行化、异步化、调度思想这三种内功心法,无论是内核设计还是后端场景里,你总能找到这些“心法”的影子。看完以后是不是感觉有点意犹未尽?
这节课,我再给你分享三种设计思想,分别是池化、分层和缓存。无论是操作系统内核,还是后端当中,这三种设计思想也是通用的。这两节课属于偏抽象的架构思想,因此建议你学完之后结合自己的具体工作实践进一步理解、消化。好,让我们进入正题。
内功心法之池化
如果你是一家水果店的店长,肯定要走批发采购、平时贩卖零售的路线。要是有顾客来选购一种水果的时候,我们才去采购、运输过来实在是费事费力,太过低效。但如果我们提前采购好一批水果,顾客来购买的时候我们直接拿给顾客,这样就可以有效节约时间和运输成本。
这种降低开销的思路,在工程中也是类似的。为了提升资源(计算、存储、网络)的复用性、避免重复创建的开销,我们通常会提前统一创建一批资源,放入资源池,在使用时直接向资源池申请,使用完毕之后再归还给资源池,这样的思路我们称为池化思想。
之前讲内存管理的时候,我们提到过,为了减少内存分配或销毁时的时间开销,避免内存碎片的产生,我们会维护一个内存链表。-
当编程语言申请内存的时候OS内核就会根据内存分配算法在链表中查找一段可以分配的内存分配出去。等到内存需要释放的时候不但需要进行查找、释放操作还需要整理这些操作都比较耗时。怎么解决这个问题呢
答案就是内存池技术,我们可以提前申请一大块内存来进行统一管理,然后每次需要分配内存时,都从这块内存中取出,并标记下这块内存被用了,释放时标记此内存被释放了。请注意,释放时,并不是真的把内存释放给了操作系统。只有等到一大块内存都空闲的时候,才会释放给操作系统。这样,就减少了分配、释放的操作次数,从而提高了效率。
后端领域中,我们也会经常使用池化思想。比如内存池、链接池、线程池等等,都是后端常用的池化思想的工程应用方式。
内存池
我们先从内存池说起。其实内存池也我们的老朋友了,因为之前讲[Golang内存管理]时学过编程语言运行时库中也会实现内存池。其实Golang的内存池的设计也是借鉴了TCMalloc内存池的管理思路。
为了更好地理解内存池的设计思路首先让我们来看一下TCMalloc的架构设计吧
Back-end会向操作系统申请内存当用户代码使用TCMalloc内存池时则会复用Back-end申请的内存。内存不够的时候TCMalloc还会继续向操作系统申请更多内存在用户代码释放内存的时候TCMalloc也会主动进行内存回收。
连接池
接着我们说说连接池。连接池的作用是管理链接的创建和销毁,以及复用链接。从事后端开发的同学,最常见的可能就是数据库连接池了,那我们为什么要使用数据库连接池呢?
要解答这个问题我们可以先设想一下如果没有连接池一次查询MySQL都会经历哪些步骤
第一步建立链接MySQL协议是基于TCP的所以MySQL Client和MySQL Server端建立链接的时候也会经历TCP的三次握手。
第二步是认证环节。记不记得我们链接MySQL的时候还指定了帐号密码没错这里我们还需要进行认证。根据 MySQL官方文档中的协议章节我们可以把认证部分大概细分成以下三个部分
服务端向客户端发送密钥;
客户端使用密钥加密用户名、密码等信息,然后再把加密后的包发给服务端(这样可以防止被中间人盗取到明文帐号密码);
服务端再根据客户端发送过来的包,验证是否是合法的帐号密码,然后给客户端响应认证结果。
第三步客户端发送需要执行的SQL语句。
第四步服务端返回该SQL语句的执行结果。
第五步MySQL执行完毕触发关闭操作。
第六步TCP链接随之断开这时候就会经历“四次分手”啦。
我们发现整个过程中如果不使用连接池复用连接每执行一条SQL语句就会在网络IO、认证之类的事情上浪费很多资源和时间。但如果使用连接池复用了这些连接的话就只需要执行SQL语句的请求响应开销了这显然是一个不错的思路。
其实连接池在后端中很常用比如Java的httpclient、Golang的net/http包中都在使用连接池的思想来提升HTTP请求的性能。如果对这些有兴趣你可以课后自行探索。
线程池
线程中执行的就是我们写的应用程序,在后端领域中,为了提升响应速度,我们经常会为每一个请求分配一个独立的线程来处理。不过别忘了,线程的创建、销毁、调度都是有开销的,所以当我们创建的线程比较多的时候,系统性能也有可能会下降。
这时候,还得继续引入我们的池化思想,开启“复用大法”。思路是这样的:我们可以通过提前创建一批线程,当需要使用的时候直接从里面取一个“现成的”线程,用完之后归还。
没错Java J.U.C中ThreadPoolExecutor线程池的核心设计思路也是类似的你可以点击文稿中的简化版ThreadPoolExecutor线程池的设计图来对照理解一下。
内功心法之分层
计算机科学家Butler Lampson有一句名言All problems in computer science can be solved by another level of indirection.
说的是在计算机科学中,所有问题都可以通过引入一个分层来解决。分层可以有效控制系统的复杂性、简化系统设计,让不同的人专注做某一层次的事情。
想象一下,如果你要设计一款网络程序,却没有分层,该是一件多么痛苦的事情,因为这要求你必须是一个通晓网络的全才:要知道各种网络设备的接口是什么样的,以便将数据包发送给它;要关注数据传输的细节;还需要处理类似网络拥塞,数据超时重传这样的复杂问题;当然了,你更需要关注数据如何在网络上安全传输,避免数据被别人窥探和篡改。
而有了分层的设计,你只需要专注设计应用层的程序就可以了。其他的问题,都可以交给下面几层来完成。
分层之后,有些层具有很高的复用性,可以提升工程效率。比如,我们在设计系统 A 的时候,发现某一层具有一定的通用性,那么我们可以把它抽取独立出来,在设计系统 B 的时候用起来,这样可以减少研发周期,提升研发的效率。
分层架构还更容易做横向扩展。很多时候如果系统没有一个好的分层,流量增加时,我们就需要对整个系统进行扩展了,这样开销其实是比较大的。
举个例子比如有一段代码里面需要进行图形图像计算这时候CPU开销可能会比较高。
如果没有分层,代码和业务逻辑耦合在了一起,就需要扩容整个服务,这显然是没必要的开销。但如果我们做了合理的分层,把计算密集型的代码全部收敛到同一层,这样进行扩展时,我们就可以轻松抽离这部分代码,这不但节约了资源,还提升了系统的可维护性和可扩展性。
操作系统里处处可见分层思想。比如Linux内核中对网络部分就是按照网络协议层、网络设备层、设备驱动功能层和网络媒介层这个分层体系来设计的。这样设计的好处是网络驱动功能层主要通过网络驱动程序实现。这种分层结构与网络协议的结构匹配既能简化数据包处理流程又便于扩展和维护。
除了内核系统后端开发里分层也很常见。根据《阿里巴巴Java 开发手册》中的建议,我们可以看到后端项目常用的分层模式如下图:
对照示意图,我们分别看看不同层起到的作用。
终端显示层,这一层主要用于各端模板渲染并执行显示的层。当然,前后端分离之后,这个层有时候也会被前端技术栈所接管。开放接口层用于将 Service 层方法封装成开放的接口,同时进行网关安全控制以及流量控制等工作。
接着是请求处理层也就是Web 层主要是对访问控制进行转发做一些基础的参数校验和一些简单的业务处理工作。再往下是Service 层,用于承载主要的业务逻辑。
之后是通用业务处理层也可以叫Manager 层。这一层主要有两点要注意。首先,你可以将原先 Service 层的一些通用的能力(比如与缓存和存储交互策略、中间件的接入)放到这一层;其次,你也可以在这一层封装对第三方接口的调用,比如调用支付服务,调用审核服务等。
接下来是数据访问层我们通常叫它DAO 层,用来与底层 MySQL、Redis、MongoDB、Elasticsearch、HBase 等数据库进行数据交互。
最后还有外部接口或第三方平台,包括其它部门的 RPC 开放接口、基础平台、其它公司的 HTTP 接口。
内功心法之缓存
在计算机系统中,我们经常会遇到读写速度不匹配的场景。为了解决内存读写速度不匹配的问题,能不能把一部分常用的数据,放在读取速度更快的存储空间里呢?这个想法就是缓存思想的雏形。
当然引入了缓存之后,在传递的过程中,缓存数据和原始数据可能会因为各种原因导致不一致,这又引入了缓存一致性的问题。
在 SMP 系统中,处理器的每个核都有独立的一级缓存,因此同一内存位置的数据,可能在多个核一级缓存中存在多个副本,所以存在数据一致性的问题。目前主流的缓存一致性协议是 MESI 协议及其衍生协议。
为了维护缓存一致性处理器之间需要通信MESI 协议提供了以下消息:
Read读包含想要读取的缓存行的物理地址。
Read Response读响应包含读消息请求的数据。读响应消息可能是由内存控制器发送的也可能是由其他处理器的缓存发送的。如果一个处理器的缓存行有想要的数据并且处于修改状态那么必须发送读响应消息。
Invalidate使无效包含想要删除的缓存行的物理地址。所有其他处理器必须从缓存行中删除对应的数据并且发送使无效确认消息来应答。
Invalidate Acknowledge使无效确认处理器收到使无效消息必须从缓存行中删除对应的数据并且发送使无效确认应答消息。
Read Invalidate读并且使无效包含想要读取的缓存行的物理地址同时要求从其他缓存中删除数据。它是读消息和使无效消息的组合 ,需要接收者发送读响应消息和使无效确认消息。
Writeback写回包含想要写回到内存的地址和数据。
由此我们看到,为了保证缓存在各处理器间的一致性,需要进行核间消息的处理。因此即使像原子变量这种看似没有消耗的同步机制,也是有开销的。
缓存思想在后端中也十分常用根据应用场景我们可以大致把缓存分为四大类第一类CDN 缓存。CDN 即内容分发网络CDN 边缘节点会将数据缓存起来;第二类是反向代理缓存,比如 Nginx 的缓存;第三类是本地缓存,典型例子有 Caffeine Cache 和 Guava Cache最后就是分布式缓存Redis等各种缓存系统都归属于这一类。
重点回顾
这节课,我们主要学习了池化、分层和缓存这三种思想。我来带你回顾下重点。
内存池、链接池、线程池等等,都是后端常用的池化思想的工程应用方式,这可以帮助我们复用资源、提高资源使用效率。
分层思想可以让复杂项目的结构变得更清晰,学过业界常用的后端项目设计规范之后,我想你更能体会到这一点。
缓存思想则告诉我们可以通过缓存提高数据读取性能,解决内存读写速度不匹配的问题。不过另一方面,这可能引入缓存一致性的风险。因此在实际工程应用的时候,我们一定要仔细权衡。
我还把这节课的要点画成了导图,供你参考:
思考题
这节课,我们学习了池化、分层、缓存这三招内功心法,请你思考一下在你的日常工作中有没有哪里用到了这几类设计思想呢?
欢迎你在留言区跟我一起交流,如果这节课对你有启发,别忘了分享给身边更多朋友。

View File

@ -0,0 +1,184 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
42 性能调优性能调优工具eBPF和调优方法
你好我是LMOS。
在之前的学习中我们了解到了很多计算机基础相关的知识也学过了iostat等观察系统运行状态的命令。但在我们的实际工程中排查分析一些具体的性能优化问题或者定位一些故障时可能需要在不同的命令间来回切换、反复排查。
那么有没有一款工具可以贯穿操作系统的各个模块帮我们准确分析运行的程序、系统的运行细节呢当然有答案就是eBPF。
从BPF到eBPF
eBPF是怎么来的还要从1992年说起。当年伯克利实验室的Steven McCanne和Van Jacobso为了解决高性能的抓包、分析网络数据包的问题在BSD操作系统上设计出了一种叫做伯克利数据包过滤器BSD Packet Filter的机制并发表了《The BSD Packet Filter:A New Architecture for User-level Packet Capture》这篇论文论文链接在这里
为了让内核态能够高效率地处理数据包这套机制引入了一套只有2个32位寄存器、16个内存位和32个指令集的轻量级虚拟机包过滤技术的性能因此提升了20多倍。
因为这套方案设计的太好用了后来在1997年的时候Linux操作系统从Linux2.1.75版本开始就把BPF合并到了内核中了。
早期的BPF的架构是这样子的
从这张架构图中我们可以看出当数据报文从设备驱动上传输过来之后首先会被分流到BPF这时候BPF会执行内部的过滤逻辑处理数据报文当然这个处理逻辑也是可以灵活自定义的。
然后BPF就会把处理好的数据报文转给对应的用户程序。如果某些设备驱动发过来的数据,找不到对应的BPF处理逻辑的话则会由正常的协议栈来处理。
听完这段原理,你会不会觉得,这是一个只适合抓包分析网络数据的机制?
没错早期的时候BPF机制确实也是用在tcpdump之类的抓包分析工具上的。只是后来随着技术的发展BPF机制也有了升级扩展不但加入了JIT即时编译技术来提升性能还引入了如Seccomp之类的安全机制。
这么优秀灵活的机制只用来分析网络数据未免大材小用。所以后来2014年的时候Alexei Starovoitov 和 Daniel Borkmann沿着这条路设计出了更强大的eBPF机制。
eBPF不仅仅能实现传统的数据报文过滤还把自己变成了一个运行在操作系统内核中的沙盒基于它可以在不修改内核代码、不加载额外的内核模块的前提下安全、高效地扩展内核的功能。有了它我们就可以让自己的程序站在操作系统内核的“上帝视角”随时灵活地监控调整程序的运行状态堪称神器。
讲了这么多有没有勾起你对eBPF的好奇那么让我们先来看一下eBPF的架构简图吧
首先我们编写好的BPF程序会被Clang、LLVM等工具编译成BPF的字节码因为BPF程序并不是普通的ELF程序而是要运行在虚拟机中的字节码。eBPF程序中还会包含配置的事件源所谓事件源其实就是一些需要hook的挂载点。
加载器会在程序运行前通过eBPF系统调用加载到内核这时候验证器会验证字节码的安全性比如校验循环次数必须在有限时间内结束等。当校验通过后一旦挂载的事件发生回调到你的字节码就会在eBPF虚拟机中执行字节码中的逻辑了。
如何使用eBPF
接下来我们说说怎么使用eBPF。我们需要在Ubuntu 20.04 系统上执行 sudo apt-get install -y bpftrace命令安装bpftrace工具。
然后编写后面这段man.go测试代码
package main
func main() {
println(sum(3, 5))
}
func sum(a, b int) int {
return a + b
}
接下来我们要执行go build -gcflags=“-l” ./main.go 命令关闭内联优化编译代码。因为如果内联优化了的话很可能Go的编译器会在编译期消除函数调用这样我们的eBPF就会找不到函数对应的探针了。-
下一步我们使用bpftrace监控这个函数调用就会发现下面的输出
shell> bpftrace -e '
uprobe:./main:main.sum {printf("a: %d b: %d\n", reg("ax"), reg("bx"))}
uretprobe:./main:main.sum {printf("retval: %d\n", retval)}
'
a: 3 b: 5
retval: 8
你看我们写的代码一行都没改eBPF却帮我们把程序运行中的变量捕获出来了是不是很神奇那么eBPF是怎么实现这么神奇的功能的呢这个问题你听我讲完eBPF的原理就明白了。
eBPF的核心原理
在讲解eBPF的原理之前我们先来看看eBPF的整体架构图。根据架构图一步步了解核心原理。
eBPF 整体结构图如下:
我们对照结构图分析一下eBPF的工作原理。
eBPF分为两部分分别是运行在用户空间的程序和运行在内核空间的程序。用户空间程序负责把BPF字节码加载到内核空间的eBPF虚拟机中并在需要的时候读取内核返回的各种事件信息、统计信息而内核中的BPF虚拟机负责执行内核中的特定事件如果需要传递数据就将执行结果通过BPF map 或perf缓冲区中的perf-events 发送至用户空间。
那这两部分是怎么“沟通”的呢两者可以使用BPF map数据结构实现双向的数据通信上图右下角的BPF MAP这为内核中运行的BPF字节码程序提供了更灵活的控制能力和数据交换能力。
内核中用户空间程序与BPF字节码交互的主要过程是这样的首先我们可以使用LLVM 或GCC工具将程序从BPF 代码编译为BPF 字节码。然后我们通过Loader加载器将字节码加载到内核中。内核使用验证组件是用来保证执行字节码的安全性避免内核异常的。在确认字节码安全执行后加载器会加载相应的内核模块。
BPF程序的类型包括kprobes、uprobes、tracepoint、perf_events几种具体含义如下
kprobes是一种在内核中实现动态追踪的机制可以跟踪Linux内核中的函数入口或返回点但这套ABI接口并不稳定。不同的内核版本的变化带来的ABI差异有可能会导致跟踪失败。
uprobes用来实现用户态程序动态追踪的机制。与kprobes类似区别在于跟踪的函数是用户程序中的函数而已。
tracepoints内核中的静态跟踪。Tracepoints是内核开发者维护的tracepoint可以提供稳定的ABI接口但是由于开发者维护数量和场景可能会受到限制。
perf_events定时采样处理器中的性能监控计数寄存器Performance Monitor Counter
所以看到这里你可能看出门道了原来eBPF能用上帝视角观察各种程序关键就在于“内核中有自己人”。
eBPF 虚拟机则是相当于一个在内核中的、安全的“后门”而在虚拟机上运行的BPF 字节码程序可以使用BPF map数据结构和perf-event这两种机制将测量数据“偷偷”传递到用户空间。
eBPF还能应用在哪里
由于eBPF强大的扩展能力目前业界已经有很多项目用它来实现生产环境中的观测、调试、性能优化、动态扩展等功能了。
我们都知道开源项目是工程师的技术学习宝藏通过学习开源项目我们可以学习到业界最前沿的工程应用实战思路。接下来我就给你介绍一些基于eBPF的优秀开源项目吧。
Cilium
Cilium是一个为Kubernetes集群和其他容器编排平台等云原生环境提供网络、安全和可观察性的开源项目。
Cilium的基础当然也是eBPF啦它能够将安全逻辑、可见性逻辑和网络控制等逻辑动态插入Linux内核十分强大。基于这些扩展出的内核能力Cilium可以提供像高性能网络、多集群和多云能力、高级负载平衡、透明加密、网络安全能力、透明可观察性等很多能力。
看到这里可能熟悉后端的小伙伴就好奇了“我们明明也可以用Wireshark、tcpdump之类的工具来分析网络也可以基于K8S的Pod机制轻松实现Sidecar架构模式以此透明地扩展容器的功能那么我们为什么还要用Cilium呢
其实在现代分布式系统架构中不仅仅有传统的TCP协议、HTTP协议还会引入像GRPC、QUIC等比较新的协议。同时随着分布式系统规模的增加会引入很多类似于Kafka、Elasticsearch、Redis之类的各种中间件这也使得传统的抓包分析工具越来越捉襟见肘。
基于Pod的Sidecar架构模式中容器本质也是运行在用户态的进程所以免不了增加用户态/内核切换、拷贝等操作带来的开销。
而Cilium则是基于eBPF直接把这些逻辑动态的扩展到了内核中所以性能会远高于传统的方法你可以对照下面的架构图看一看。
从图中我们可以看得出Cilium使用了eBPF机制在Linux内核中直接扩展出了对容器中的各种网络协议的观测能力。这样既实现了高性能对系统进行观测又扩展更多字节码程序进而支持更多种类的协议。
Falco
你听说过“容器逃逸”这种黑客攻击手段么这种攻击手法可以让运行在容器沙盒中的恶意程序跳出沙盒攻击到宿主机中从而实现突破限制、获得更高的权限的目的。比如著名的CVE-2019-5736、CVE-2019-14271、脏牛等就是典型的容器逃逸漏洞。
如果你对容器的了解还不深入,可能觉得奇怪。容器不是类似于一台“虚拟机”么?不是说容器内外是隔离的,容器内的操作不会影响容器外么?那这类逃逸的攻击方式又要怎么防御呢?
其实每一个运行中的容器只是同一个宿主机上不同的进程特殊的地方是用namespace、cgroup、UnionFS之类的技术手段给容器中的每个进程创造出一种独占一台机器的假象。
这意味着运行在同一台机器上的每个容器,虽然从表面上看他们是互相隔离的,但实际上他们都共用了同一套操作系统内核。这也就为容器安全埋下了隐患。容器实现机制的更多内容,你有兴趣的话,可以看看第一季里[第四十四节课]的讲解。
那么这类问题是不是就无解了呢显然不是Falco这个项目就是为解决这类安全问题而生的。
Falco的核心思想是把自己定位成一个嵌入到Linux内核中的监控摄像头实时监控各种 Linux 系统调用的行为,并根据其不同的调用、参数及调用进程的属性来触发警告。
Falco 可以检测的范围非常广,比如:
容器内运行的 Shell
服务器进程产生意外类型的子进程
敏感文件读取(如 /etc/shadow
非设备文件写入至 /dev
系统的标准二进制文件(如 ls产生出站流量
有了这些能力之后Falco就可以根据安全策略来决定什么时候是安全的行为什么时候是异常的攻击行为从而做到防患于未然提升系统的安全性。
eBPF for Windows
微软也发现了eBPF的强大功能和潜力在2021年5月的时候微软也发布了eBPF for Windows这个项目用于在Windows 10 和 Windows Server 2019或者之后的版本上支持运行eBPF程序。
这个设计也是比较巧妙的eBPF 工具链编译出的字节码首先会发到用户态的静态验证器来进行验证。当验证字节码通过了验证之后就会被加载到Windows NT 内核中这时候eBPF程序就可以hook调用eBPFshim模块来提供暴露的各种API了。这样Windows系统也就拥有了eBPF的强大的动态扩展能力了。
整个过程如下图所示:
重点回顾
今天我们学习了eBPF的形成历史、设计思想和核心原理。了解了eBPF是怎么做到像乐高积木一样灵活动态地扩展内核功能。另外我还给你分享了eBPF在业界比较优秀的开源实践项目。
这节课的要点我梳理了导图,供你参考。
如果今天讲的内容你只能记住一件事那请记住eBPF是如何把“自己人”送进内核最终和“自己人”里应外合、传递各种信息的思路。
思考题
请你思考一下eBPF如果被误用有没有可能带来新的安全问题呢
期待你在留言区和我交流互动,也推荐你把这节课,分享给更多朋友。

View File

@ -0,0 +1,17 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
先睹为快迷你CPU项目效果演示
你好我是LMOS。
这个模块里,我们将一起设计一个迷你 RISCV 处理器。我准备了演示视频让你先睹为快看看我们最后做出的迷你CPU长什么样子。
看了视频你有什么感受呢先别急着动手敲代码我们接下来会通过九节课的时间从Verilog语言讲起然后再进行迷你CPU的设计和实现。
希望通过这一阶段的学习为你铺垫一些硬件的基础知识也给你推开RISC-V的一扇窗。一起加油希望在后面的留言区看到你的身影。

View File

@ -0,0 +1,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐01 云计算基础自己动手搭建一款IAAS虚拟化平台
你好我是LMOS。
必学内容已经更新结束,不知道这一个月以来,你是否跟上了大部队的步伐,有什么样的学习收获?在你学习追更期间,我也在为你精心准备技术雷达专题的加餐。
这节课我会带你一起了解KVM并带你动手搭建一套私有化的IAAS平台这其实是一个既有趣又有价值的事情。首先让我们从全局的角度来看一下目前业界常用的云计算平台它的架构分层是什么样子。
云计算的分层架构
业界一般会把云计算分为三层,分层模型图如下所示:
具体定义你可以看Intel的这篇文章这里就不展开了。
从架构分层上我们看得出IAAS是整个云计算的基础而KVM虚拟化技术则又是IAAS平台的基础堪称云计算大厦的地基。如果IAAS层离开了虚拟化技术软件系统就会在一定程度上失去屏蔽硬件差异和弹性伸缩迁移的能力。
另外从产品发展历史的角度来看不论是国外的Amazon云还是国内的腾讯云、阿里云早期最先上线开始售卖的产品其实都是弹性云虚拟机。
我们可以脑补一个场景如果你的老板发现公司业务发展得不错但是一直把核心业务数据放在公有云上会有一定的风险。这时候需要你来搭建一套私有化的IAAS平台你能做得到么
如果我们想要在虚拟化领域做出一款IAAS平台仅仅只了解核心原理远远不够。因为要想实现一个工业级的IAAS我们直接操作底层API就会导致上层和底层的强耦合这不利于提高通用性。
KVM虚拟化的基础能力
所以我们不妨结合软件工程思想分析一下,如果我们想解除耦合,需要怎么办呢?
没错我们可以统一抽象出上层API。而接下来我们要讲的libvirt就是一套主流的KVM虚拟机管理程序接口和工具。
初识libvirt
libvirt主要包含3个部分分别是命令行管理工具virsh、API接口库、守护进程libvirtd。其中libvirtd进程主要负责执行对虚拟机的各种管理工作在后面我们会展开讲解。
libvirt的核心概念包括节点Node、虚拟机监控器(VMM/Hypervisor)、域Domain
节点就是一台具体的物理机;虚拟机监控器是指用来管理物理机上多个虚拟机的软件;而域指的是具体运行在物理机上的一台虚拟机,有些云计算平台也把它称作客户机/实例,但其实表达的是同一个意思。
具体的概念逻辑关系图如下所示:-
libvirtd主要负责执行其他管理工具发送过来的虚拟化管理指令。各种客户端比如virsh可以通过链接远程或者本地的libvirtd进程来执行虚拟机的开关机、重启迁移等操作还可以收集物理机和虚拟机各种资源的使用状况和运行状态。
如果我们想要通过libvirt管理远程或者本地的Node就必须安装libvirtd。
libvirt核心API
如果想要理解libvirt的功能效率最高的方式也许就是先看一下它的核心API。libvirt API主要包含8个部分我们分别来看看。
想要管理虚拟机首先要和VMM/Hypervisor建立连接。这就需要用到连接VMM/Hypervisor的相关API其命名方式一般是以virtConnect为前缀的函数。
第二部分是节点管理相关的API命名方式一般是以virNode为前缀的函数。这部分API用于管理运行着域的物理节点具体可以用来查询物理节点上的CPU、内存等基本信息也可以控制物理节点暂停/启动等操作。
第三部分是域管理相关的API命名方式一般是以virDomain为前缀的函数。这部分API主要用于对各个节点上的域进行管理操作包括控制域的生命周期、查询域的信息等操作。
第四、五部分是存储相关的API。存储卷管理相关的API命名方式一般是以virStoreVol为前缀的函数。这类API用来管理虚拟机镜像虚拟机镜像一般是qed、vmdk、raw、qcow2等格式而存储池管理相关的API命名方式一般是以virStorePool为前缀的函数。存储池管理的是网络共享文件系统、本地文件系统、iSCSI共享文件系统、LVM分区等等。
第六部分是网络管理相关的API命名方式一般是以virNetwork、virtInterface为前缀的函数。这些函数可以用于创建、释放网络接口、查询网络接口的相关状态以及管理网桥。
第七部分是数据流管理相关的API命名方式一般是以virStream为前缀的函数。这些函数可以用于数据流的传输管理。
最后是第八部分事件管理相关的API命名方式一般是以virEvent为前缀的函数。libvirt的事件管理机制可以让我们注册自定义的事件处理逻辑当某些事件比如虚拟机暂停、恢复、启停等发生的时候我们可以根据这些事件发生后的通知信息来处理后续逻辑。
怎么使用virah工具
其实实际工作中并不是所有场景都要写个程序去调用libvirt API的我们可以通过KVM系统提供的virah工具来进行命令行管理这样可以省去一些开发工作量。
virsh程序是一个用来管理虚拟机的命令行客户端在我们日常运维、debug排查问题的时候使用这个工具会比较方便。大多数virsh命令的用法是这样的
virsh [选项] … <命令> <> [参数] …
我把常用命令用表格做了梳理,供你参考:-
更多指令操作也和上表列出的类似,你可以参考官方文档进一步了解。
动手搭建一款自己的IAAS虚拟化平台
网上有很多讲IAAS平台的教程。上来就拿OpenStack、Ovirt、ProxmoxVE之类的商用软件开始讲这其实存在一定的问题。
首先,这些软件是商业用途,对外提供服务需要额外的授权费用。
其次,这些软件为了兼容太多的业务场景做了很多复杂的设计,这并不利于新手学习。
最关键的是这些商用软件针对自己的理解对很多底层API做了封装。这样虽然简化了使用开发、优化了使用体验但也让我们失去了对底层细节的直接操纵能力容易导致学习理解不够透彻。
所以我们选择了基于更加轻量级的KVM Web管理系统——WebVirtCloud用它来搭建我们的学习和实验环境。
硬件配置&操作系统版本
KVM是一种依赖于硬件虚拟化扩展支持的技术因此我们首先要选择一款支持Intel VT/AMD-V指令集的CPU。
这里我选择的是Intel® Xeon® CPU E5-2680 v4这款CPU的服务器你可以使用下面的命令查看你的CPU是否支持虚拟化
cat /proc/cpuinfo | grep vmx ## Intel的CPU
cat /proc/cpuinfo | grep svm ## Intel的CPU
如果出现类似下图中的显示则说明这款CPU是支持硬件虚拟化指令集的。-
当然有一些电脑的CPU虽然支持硬件虚拟化但是有可能默认并未启用这时候就需要我们在BIOS设置中开启硬件虚拟化功能才可以使用具体操作你可以自行Google
其次因为后续可能需要开多个虚拟机内存占用可能会略高建议你选择RAM大于8GB的电脑这里我选择了128GB的内存。
除了了硬件配置我们还得约定一下操作系统版本。这里我选择的是Ubuntu 20.04 LTS版本LTS版本支持的更久基础库也更稳定关于Ubuntu系统的安装网上有很多教程相信你有能力搞定它。
安装依赖并修改配置
硬件配置要求和操作系统版本我们约定好了,我们还要通过执行下面的命令来安装必要的依赖:
sudo apt-get install vim libvirt-daemon-system libvirt-clients
sudo apt-get install sasl2-bin libsasl2-modules bridge-utils
然后,我们需要修改 vim /etc/default/libvirtd 的配置找到libvirtd_opts修改为libvirtd_opts=“-l”。
为了暴露远程调用能力,我们需要修改 vim /etc/libvirt/libvirtd.conf 配置文件从而开启tcp、sasl。
# 允许tcp监听
listen_tcp = 1
listen_tls = 0
# 开放tcp端口
tcp_port = "16509"
# 监听地址修改为 0.0.0.0,或者 127.0.0.1
listen_addr = "0.0.0.0"
# 配置tcp通过sasl认证
auth_tcp = sasl
之后我们需要开启vnc端口监听编辑 vim /etc/libvirt/qemu.conf找到 “# vnc_listen = …” ,将前面的 # 注释修改为:
vnc_listen = "0.0.0.0"
为了用户组下的用户能够使用libvirt我们需要继续修改上面的配置文件找到 user 和 group 这两个选项,取消注释,修改为 libvirt-qemu具体命令是
user = "libvirt-qemu"
group = "libvirt-qemu"
最后,我们需要重启一下服务,命令是:
sudo service libvirtd restart
重启之后,我们可以通过下面的命令来查看服务状态。
sudo service libvirtd status
如果出现下图这样的效果,就说明配置成功了。-
创建管理员账号
为了方便管理,我们需要创建管理员账号。在创建管理员账号之前,我们需要先确认一下/etc/sasl2/libvirt.conf文件的最后一行是不是sasldb_path: /etc/libvirt/passwd.db以及mech_list的值是不是digest-md5。
我机器上的运行效果如下图所示:-
接下来我们就可以为libvirtd创建用户名和密码了客户端连接时需要用到它们。
sudo saslpasswd2 -a libvirt virtadmin
当然,如果你想要查询已经创建好的用户,就可以使用这条命令:
sudo sasldblistusers2 -f /etc/libvirt/passwd.db
然后我们需要重启libvirtd服务让刚刚创建的用户生效。重启命令如下
sudo service libvirtd restart
安装WebVirtCloud
安装好了libvirt之后你可能会觉得通过命令行管理KVM虚拟机会比较繁琐那么能不能像使用云主机那样通过Web UI来可视化的管理虚拟机呢当然是可以的这时候我们就需要安装一下WebVirtCloud了。
WebVirtCloud是一个基于libvirt的开源的轻量级Web客户端它是基于Python的Django框架进行开发的整体代码结构比较清晰代码量虽然不多但已经包含了一个生产可用的IAAS平台所需的大部分功能了。这个项目的GitHub地址你可以点这里查看。
它不像OpenStack之类的开源项目那样太过复杂非常适合刚入门虚拟化IAAS平台开发的工程师学习和使用。
为了节约安装时间,我们可以使用官方提供的快速安装脚本一键安装:
wget https://raw.githubusercontent.com/retspen/webvirtcloud/master/install.sh
chmod 744 install.sh
# 需要在root权限下运行
sudo ./install.sh
安装完毕后我们可以使用下面的命令重启Nginx和supervisor
sudo service nginx restart
sudo service supervisor restart
接下来我们来看看WebVirtCloud是正确启动了查看命令如下
sudo supervisorctl status
如果WebVirtCloud的进程处于运行状态则说明安装成功了。
创建虚拟机
安装好WebVirtCloud我们继续实验一起完成创建虚拟机的操作。
首先我们需要在浏览器访问 http://127.0.0.1/ 这个地址然后填写用户名密码“admin/admin”就可以进入到Web控制台。
要想基于当前物理机创建虚拟机,我们先要点击计算节点菜单,把前面创建好的账号添加到计算节点,如下图所示:
接下来,我们需要点击刚刚创建成功的计算节点上的眼睛图标,进入存储菜单添加存储资源池:
现在,我们就可以点击实例菜单的加号,创建新实例(虚拟机)啦:
创建好虚拟机之后你就可以选择安装自己喜欢的操作系统了下图就是我安装的AlmaLinux操作系统的运行状态-
好了到目前为止你已经成功地搭建了一套自己的IAAS平台并在上面运行起了AlmaLinux操作系统这说明这套IAAS已经拥有了和主流云虚拟机平台类似的基础能力。
如果是家用的场景我们还可以在此基础上搭建自己的NAS存储、软路由、家庭影院等常用软件。如果用在公司我们可以用它搭建开发环境、测试环境、生产环境等等。更多用途等待着你的探索和发掘。
重点回顾
这节课我们使用KVM、libvirt和WebVirtCloud从零开始搭建了一套自己的IAAS平台。
首先我带你了解了云计算的分层架构。从架构分层上就能看出IAAS是整个云计算的基础。IAAS层离不开虚拟化技术的支撑。
接着我为你介绍了主流的KVM虚拟机管理程序接口和工具——libvirt带你了解了它的核心API。最后是动手搭建的实操环节推荐你跟着课程里的讲解亲自动手实验一下这样才会有更深的体会。
另外在第一季专栏《操作系统实战45讲》中我曾经给你分享了KVM的核心原理和部分核心代码逻辑如果感兴趣可以去看看《43 | 虚拟机内核KVM是什么》。
思考题
请你思考一下WebVirtCloud是如何把页面上创建虚拟机的操作传递给libvirt并找出对应的关键代码的呢

View File

@ -0,0 +1,135 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐04 谈谈容器云与和CaaS平台
你好我是LMOS。
在前面几节课程中我们学习了解了IAAS、PAAS以及大数据相关的实现基础这节课我们学习另外一个云计算相关的概念就是CaaS。
CaaS也是我持续关注的一个主题刚好和你分享分享。作为加餐我们可以轻松一点把重点放在了解它大概是什么又能给我们提供什么样的支持最后我还会分享几个优秀的开源项目。
容器即服务——CaaS
CaaS其实是个简称全称是Containers-as-a-Service中文是容器即服务。CaaS是一款云服务可以帮助使用者基于容器的抽象来管理和部署应用以此实现关键IT功能的自动化。
想理解一个新概念我们不妨和熟悉的概念来关联、比较一下。CaaS调度的基本单元是容器。说起容器我们应该都不陌生它是云原生资源和微服务资源的常见部署形式。
那容器都有哪些优势呢?我画了一张表格来梳理。-
如果你想更深入地了解容器,还可以看看第一季[第四十四节课]的内容。
IT运维团队可以在CaaS上对容器云集群进行管理和编排。容器提供了一致的环境方便研发人员快速开发和交付可以在任何地方运行的云原生应用这样也就实现了对资源的自动化运维和管理。研发团队则可以按照自己的需求申请或自助使用资源。
CaaS通常被认为是IaaS的一种以容器为载体的子集CaaS介于IaaS和PaaS之间它起到了屏蔽底层系统IaaS支撑并丰富上层应用平台PaaS的作用。
这里又一次体现了分层的思想(关于分层,我们这一季前面[第四十一节课]讨论过。有了CaaS就可以将底层的IaaS封装成一个大的资源池我们只要把自己的应用部署到这个资源池中不再需要关心资源的申请、管理以及与业务开发无关的事情。
有了Kubernetes为什么还需要CaaS
常见的CaaS平台都是基于原生的Kubernetes提供Kubernetes集群进行完整的全生命周期管理。
从根本上说Kubernetes和CaaS都与容器管理相关不过Kubernetes是容器平台而CaaS是订阅型服务。二者不同之处在于一个是基础设施解决方案而另一个是管理解决方案。当我们需要大规模运行生产工作负载时二者都至关重要。
Kubernetes集群能够提供各种资源支持开发者高效开发用户选择和灵活性是它与生俱来的优势。与传统PaaS系统不同的是Kubernetes能够支持多种工作负载。容器出现故障时其还能够自我修复或重新启动替代及淘汰无法在必要时响应的容器。作为容器级别运作的平台Kubernetes会提供部分PaaS常见功能但这些都不是Kubernetes的内建功能。
作为订阅型服务Caas提供了部署、扩展和平衡负载并将日志记录、监控和警报解决方案集成为可选插件。CaaS提供商通常会使用Kubernetes平台来管理容器借助Kubernetes提供平衡负载、自动装载存储系统、打包功能还能描述已部署应用的预期状态。
不过直接使用Kubernetes会有很多痛点主要是使用复杂度、存储、网络、安全等方面的问题。
首先是使用复杂度。Kubernetes作为一个编排引擎本身就有很高的复杂度和学习门槛。像声明式API、CRD、Operator等概念对于传统应用开发者来说也属于新鲜事物。对于开发者他们更关注的是怎样屏蔽底层复杂度还有如何实现对业务快速上线的支持。
而对于应用的运维管理人员来说他们希望厂商能提供对基础设施IaaS和Kubernetes统一管理的能力来帮助他们运维好开发者所编写的应用。这种让不同用户只需关心自己事情的能力是降低Kubernetes使用门槛的关键所在。
另外Kubernetes的工作负载由多个对象组成在别的技术中很简单的操作在Kubernetes的语境中可能就会变得复杂。所以对于一些技术能力不足的用户来说哪怕只是安装、部署、使用过程中遇到一些小阻碍可能都没有能力自行排查和解决问题。
所以这给CaaS创造了机会好的CaaS产品需要保证操作的简单、不出错同时提供排查异常情况的方式比如明确的错误码。
我们再看看存储方面的痛点。现在容器化的应用越来越广泛,复杂的大规模容器的容器应用也越来越常见。最初容器只是用于隔离资源的简单无状态的业务单体,发展至今,越来越多的企业和应用将生产级别、复杂度高和高性能计算的有状态应用通过容器的方式管理部署。
应用迭代快、服务更新频繁是云原生应用的重要特征也是云原生应用场景中绕不开的强需求。虽然Kubernetes在许多方面非常有用例如可伸缩性、可移植性和管理能力但受限于其架构设计思想原生Kubernetes缺乏对存储状态的支持因此持久化存储一直以来都是容器技术的一大挑战。
Pod和容器可以自我修复和复制而且在不断动态变化的过程中它们的生命周期是十分短暂的。如何让持久化存储应对不断变化的容器、保证容器的可移植性这个问题就变得很复杂。
此外,存储技术本来就门类众多,例如私有云、公有云、裸金属等,因此用户对于在不同存储上面的迁移也是需要考虑的问题。
最后我们再来看看网络方面的问题。Kubernetes将网络建立在pod级别每个pod都可以获取一个IP地址但需要确保pod之间的连接性以及node无需NAT网络地址转换就可与pod进行连接。这种模型的优点是无论pod是否在同一台物理机上所有pod都会通过IP直接访问其他pod。
如果用户之前有一定的虚拟化经验这种模型不会带来过多技术迁移的负担如果应用程序之前在虚拟机中工作那么几乎可以保证它可以在Kubernetes上运行的pod中工作。
另外不同的应用程序对网络要求差异会很大。与存储同理Kubernetes不是在单个解决方案中解决所有这些需求而是将网络从Kubernetes本身中抽象出来允许供应商构建特定的解决方案来满足不同的需求这就要提到CNI容器网络接口的概念。
用户对网络要求不同相应地主流的CNI也各具特点用户如何能选择到最适合自己业务的CNI也需要仔细考虑。
CaaS平台具有哪些功能
分析了Kubernetes的使用痛点我们也简单聊聊一个企业级的容器云平台需要具备哪些能力。
CaaS平台首先要满足Kubernetes集群的基本调度和生命周期管理这是最基础的能力。CaaS平台可以自动化完成Kubernetes集群的部署、扩容、升级无需人工操作。通过不同的IaaS provider插件可以将Kubernetes集群部署在IaaS服务或其他云服务上。
CaaS平台还要具备 Kubernetes集群高可用的调度能力HA deploy通过部署多 master/etcd 节点实现高可用当IaaS支持高级放置策略时也支持将master/etcd节点放置于不同的节点上进一步提升可用性。
当发现Kubernetes集群节点健康状态异常时可以自动将其隔离并创建新节点加入集群以保证集群服务能力始终符合预期。而升级Kubernetes集群时将使用滚动升级策略保证集群中应用无需停止服务。还要支持多种Kubernetes版本所以无需同时升级所有集群。
容器网络与安全能力也很关键对于容器网络Kubernetes提供了CNI的能力而CaaS平台需要支持Calico等主流开源CNI。当然也可以根据需要推出自己的CNI提供Pod网络接口管理、IPAM、ServiceClusterIP/NodePort based on Kube-proxy/iptables、NetworkPolicy功能。
内部网络的对外暴露一般通过Ingress将服务暴露到Kubernetes集群之外。负载均衡支持开箱即用的MeteralLB也支持用户自己配置已有的Load Balance方案。
CaaS还要提供监控、告警、日志管理、分析、可视化在内的一系列可观测性功能展示所有 Kubernetes集群资源消耗的统计数据。
Kubernetes集群的监控指标将被实时采集用户可以定制可视化面板的展示和基于监控指标的告警规则同时支持电子邮件、短信的实时通知方式。Kubernetes 集群、节点、pod、container等资源的日志将被聚合到 logging 中,提供日志搜索、限流、归档等功能。
除了上述功能CaaS还需要具备以下功能我同样梳理了表格。-
CaaS这么强大支撑它的核心技术就是—— Cluster API简称CAPI
CaaS 平台的核心技术——Cluster API
这是Kubernetes社区中一个非常开放、活跃和成熟的开源项目遵循Apache License v2.0。
Cluster API项目创建于2018年由Kubernetes Cluster Lifecycle Special Interest Group负责管理。Cluster API吸纳了其他开源的 Kubernetes 部署工具的优点提供一套声明式的Kubernetes风格的API以及相关工具来简化 Kubernetes 集群的创建、扩容、缩容、更新配置、升级、删除等完整的Kubernetes集群生命周期管理操作。
Cluster API实现了灵活可扩展的框架支持在vSphere、AWS、 Azure、GCP、OpenStack 等多种云平台中部署Kubernetes集群。开发人员可以增加新的Cluster API Cloud Provider以支持更多的云平台。Cluster API还支持Kubernetes组件参数配置、Kubernetes控制平面高可用、自动替换故障节点、节点自动伸缩等高级功能。
很多开源项目和商业产品都在使用Cluster API比如VMware Tanzu、Red Hat OpenShift、SUSE Rancher、Kubermatic等。
一般的云厂商都会基于Cluster API 框架自主研发的一种Cluster API Cloud Provider来适配自身的物理集群。
常见的容器云开源项目
接下来我分享几个CaaS的优质开源项目它们都使用了Cluster API。
VMware Tanzu
VMware Tanzu 社区版是一个功能齐全、易于管理的Kubernetes平台适合学习者和用户特别是在小规模或生产前环境中工作的用户。Tanzu 的主要产品是商业化版本,核心的 TKG 和 TCE 等开源,开源部分主要是 Tanzu 自己在维护。
Rancher
Rancher 是一个企业级商用 Kubernetes 管理平台。它解决了跨任何基础架构管理多个 Kubernetes 集群的运营和安全挑战同时为DevOps团队提供了运行容器化工作负载的集成工具。Rahcher2.5版本通过使用 RKE 来创建工作节点2.6后的版本也使用了Cluster API来创建节点。
KubeSphere
KubeSphere 是国产厂商青云主导开发的一款开源容器PaaS方案通过社区贡献目前已经有了上万的star社区活动比较活跃KubeSphere的后端设计中沿用了Kubernetes声明式API的风格所有可操作的资源都尽可能地抽象成为CR。它还提供了管理集群和workload集群的能力通过一个管理集群来管理多个工作集群。
OpenShift
红帽 OpenShift 是一个领先的企业级Kubernetes平台在其部署的任何地方都能实现云体验。无论是在云端、本地还是在边缘红帽OpenShift都能让企业轻松选择构建、部署和运行应用的位置并提供一致的体验。
凭借红帽OpenShift的全堆栈自动化运维以及面向开发人员的自助服务置备团队可以紧密携手合作更有效地推动创意从开发过渡到生产阶段。
OpenShift和Rancher或者Kubesphere不一样它没有管理集群和workload集群这种概念它不管理其他集群它不是在现有Kubernetes集群上安装套件而是基于Kubernetes内核通过Operator设计重新构建了一套集群它自身就是一个PaaS平台是Kubernetes的开箱即用功能完备的企业发行版。
思考与总结
在这节课的内容中我们了解了云计算场景下IAAS、PAAS 平台之外的又一种概念——CaaS平台也就是容器云管理平台。在当今容器化呼声越来越高的场景下容器云平台呼声也是越来越高常见的容器云平台依托于 Google的开源容器集群管理系统—— Kubernetes扩容了 Kubernetes的功能让Kubernetes集群的管理变得更容易。
企业级容器云和Kubernetes管理平台的结合正在为企业提供更快捷、高效的云计算服务。企业级CaaS平台相比Kubernetes集群管理有很多优势。建议你在课后体验一下这几个 CaaS 平台,看下他们具有哪些功能,解决了企业云资产管理的哪些痛点。
另外当前CaaS平台中最重要的项目就是 Cluster API我推荐了使用它的几个优秀开源 CaaS 项目。如果想更加深入地了解容器云相关的知识,你可以阅读上面开源项目的代码以及 Cluster API的代码。

View File

@ -0,0 +1,173 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐05 分布式微服务与智能SaaS
你好我是LMOS。
在之前的课程中我们学习到了云计算的IaaS、PaaS层的技术。
这节课让我们一起了解一下分布式微服务和智能SaaS层应用开发技术吧。它们可以帮助你构建出可扩展、可维护更强的应用程序这对于任何计算机开发人员来说都是很有价值的。
初识微服务架构
我们先简单聊聊微服务架构是什么,优缺点有哪些。
什么是微服务架构
微服务架构是一种架构风格,它提倡将单个应用拆分成若干个小的服务,每个服务运行在其独立的进程中,并且通过网络调用来协同工作。每个服务都围绕着特定的业务能力构建,并且通常会使用不同的技术栈来实现。
这种架构风格有利于维护和开发,因为每个服务都相对较小,团队可以独立开发和部署。同时,微服务架构也支持快速迭代和持续交付,因为单个服务的变更不会影响整个系统的稳定性。
微服务架构当然也不是银弹,它也有自己的优点和缺点,为了方便理解,我整理了一张表格来帮你做对比。-
微服务架构的关键要素
接下来,我们看看微服务架构中有哪些关键要素。后面是简化版的微服务架构图。
从图中我们可以看出,微服务架构的核心要素包括五个部分。
第一部分是服务注册与发现,微服务架构中的服务需要使用服务注册中心进行注册和发现,使得服务之间能够互相调用。
第二部分是负载均衡。在微服务架构中,负载均衡组件会将请求按照一定规则,转发到不同的服务实例上,从而提高系统的吞吐量和可用性。
第三部分是服务调用。微服务架构中的服务之间通常使用远程过程调用RPC或者 HTTP 接口来进行通信。
第四部分是服务熔断和降级。在微服务架构中,服务之间的依赖关系非常复杂,为了防止出现故障扩散并保证系统可用性,我们需要使用服务熔断和降级机制来保护服务。
最后还有第五部分,监控和日志。在微服务架构中,需要对每个服务的性能和故障情况进行监控和记录,以便及时发现和解决问题。
这里我也列出了微服务架构的其他要素,你可以参考后面的表格。-
看完上面构成微服务的关键要素之后,不知道你会不会发出感叹——这么多组件,我要是一个一个实现一遍,那需要花多长时间啊!
其实不用慌目前业界已经有了很多优秀的开源实践了基于Spring Cloud框架的Spring Cloud Alibaba就是最佳实践之一接下来我们就来简单了解一下这个框架。
Spring Cloud Alibaba的架构与核心组件
Spring Cloud Alibaba是一个基于Spring Cloud实现的分布式微服务框架它整合了阿里巴巴的中间件产品并提供了与Spring Cloud相似的编程模型和开发体验。Spring Cloud Alibaba的架构基于Spring Cloud实现主要组件可以参考后面的表格。-
Spring Cloud Alibaba的开发模型基于Spring Cloud的注解驱动开发使用者通过在代码中使用注解的方式即可使用这些组件的功能。例如使用者可以用@EnableNacosDiscovery注解来启用Nacos服务发现功能,使用@SentinelResource注解来保护服务的流量和熔断降级等
Spring Cloud Alibaba还提供了与Spring Cloud相似的编程模型和开发体验方便使用者将Spring Cloud Alibaba与现有的Spring Cloud应用轻松地集成起来。比方说可以使用Spring Cloud的Feign客户端来调用Dubbo服务或者使用Spring Cloud 的 Stream框架来使用RocketMQ等。
Spring Cloud Alibaba这个框架还提供了许多辅助工具和插件。例如使用者可以使用Alibaba Cloud的扩展来快速部署应用到阿里云服务器或者使用 Seata 扩展来实现分布式事务处理。
总之Spring Cloud Alibaba是一个功能强大的分布式微服务框架可以帮助使用者快速构建基于阿里巴巴中间件的微服务应用。它提供了丰富的组件和工具方便我们轻松地实现服务注册、发现、负载均衡、流量控制、熔断降级、分布式事务等功能。
为什么选择Spring Cloud Alibaba 构建微服务
使用Spring Cloud Alibaba的优势我同样梳理了表格你可以参考一下。-
然而正是因为Spring Cloud Alibaba功能太过强大组件比较多为了节约搭建微服务脚手架的时间精力我们选择了RuoYi-Cloud这款脚手架进行二次开发。-
上图来自 RuoYi-Cloud 官网RuoYi-Cloud是一套基于Spring Cloud Alibaba 的企业级快速开发脚手架你会在里面发现很多经典技术的组合Spring Boot、Spring Cloud Alibaba、Vue、Element
RuoYi-Cloud功能十分强大非常适合我们专注于业务进行SaaS应用开发。里面内置了很多开箱即用的模块比如系统参数、日志管理、菜单及按钮授权、数据权限、部门管理、角色用户、代码生成等等。此外它还支持在线定时任务的配置和集群部署以及多数据源的管理。
智能 SaaS 应用开发
前面聊到的这些技术具体怎么落地应用呢?我们接着往下看。
SCRMSocial Customer Relationship Management系统是一种分布式智能的软件即服务SaaS应用旨在帮助企业管理和提升客户关系。
SCRM 系统通常涵盖跨越多个渠道的客户互动如电子邮件、IM、社交媒体、网站、移动应用等。这些互动可以是实时的也可以是异步的因为面向的用户量比较大、业务逻辑比较复杂所以比较适合使用分布式微服务架构进行设计。
而LinkWeChat则是一款基于企业微信的开源 SCRM 系统,是我参与设计与开发的。它比较适合作为分布式微服务架构在业务中落地实践的学习案例,这里也简单分享一下。
LinkWechat
LinkWeChat项目基于 RuoYi-Cloud 后台开发框架这离不开RuoYi-Cloud 的开源。
前后端的技术栈你可以对照表格简单了解一下。
这里也简单列了一下这个项目的结构。首先是前端结构。
├── linkwe-pc // 后台项目
├── linkwe-mobile // 移动端项目包含移动工作台、任务宝、群裂变等H5
然后是后端结构。
├── linkwe-api // 系统业务接口模块
├── linkwe-auth // 角色权限部门用户认证等模块
├── linkwe-common // 公共组件模块
├── linkwe-fileservice // 文件服务模块
├── linkwe-framework // 框架配置
├── linkwe-gateway // 网关服务
├── linkwe-scheduler // 定时任务相关模块
├── linkwe-service // 系统service层抽取与数据库相关交互
├── linkwe-wecome // 企微接口实现
├── linkwe-wx-api // 系统中设计微信公众号相关接口模块
基于容器的LinkWechat项目部署实践
大概了解了这个项目,怎么启动它呢?
首先你需要按照加餐03的步骤安装好docker和docker-compose。然后创建目录并拉取代码。
mkdir link-wechat && cd link-wechat
git clone https://gitee.com/LinkWeChat/link-wechat
git clone https://gitee.com/LinkWeChat/link-we-chat-front
git clone https://gitee.com/LinkWeChat_admin/linkwechat-docker
接下来开始打包文件。
# 服务端打包
cd link-wechat
# 重要
cp ../docker-compose/bootstrap.yml config/run/bootstrap.yml
mvn clean package
# pc前端
cd link-we-chat-front/linkwe-pc
yarn install
yarn build
# mobile前端
cd link-we-chat-front/linkwe-mobile
yarn install
yarn build
然后还需要修改配置和SQL。
sql文件
没有变动不需要操作有需要就加到mysql/db文件夹内
增加nacos配置文件
默认已添加到 mysql/db/config.sql
增加xxl-job配置文件
默认已添加
然后打开linkwechat-docker目录依次运行。这样LinkWechat项目就启动起来啦。
# 拷贝指定文件到对应目录
sh copy.sh
# 启动基础模块
sh deploy.sh base
# 启动项目其他模块
sh deploy.sh modules
重点回顾
这节课我们学到了在之前的IaaS、PaaS的架构思路的基础上是如何演进出分布式微服务技术来为智能SaaS应用提供支撑的。
作为一个开源的工业级SaaS应用这个项目可以帮助你初步了解一款分布式SaaS产品是如何设计开发并通过微服务架构落地的。如果学完这节课之后还觉得意犹未尽想要进一步学习分布式、微服务和智能SaaS产品方面的业务经验欢迎加入 LinkWechat项目一起共同建设。
到这里,我们的技术雷达加餐就结束了。恭喜你学完全部内容,也期待你在留言区和我交流互动。

View File

@ -0,0 +1,31 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
国庆策划01 知识挑战赛:检验一下学习成果吧!
你好,我是课程编辑小新。现在还是国庆假期,先祝你假期快乐!
到现在为止课程已经更新过半不知道你的学习进度如何了是不是还在和前面某节课相互“battle”或者定了个计划预备先梳理完前置知识再来学习课程但忙着忙着就忘了……悠闲的假期正是你沉下心深度学习跟上大部队节奏的好机会。
我和LMOS老师商量之后特意策划了三期加餐内容作为国庆的特别策划。今天是第一期我们先做做题检验一下自己的学习成果查漏补缺第二期我会邀请两位课代表分享分享他们的学习方法、经验第三期我们再公布今天主观题的参考答案。
接下来就让我们进入知识挑战赛这个环节吧点击下面的按钮即可挑战客观题一共10道题目5道单选题5道多选题满分100分系统自动评分。
接下来是两道主观题,请听题。
第一题
在前面课程里我们一起揭秘了C语言编译器的“搬砖”日常搞清楚了C语言会如何处理各种类型变量、各种运算符、流程控制以及由它们组成的函数并把这些内容加以转换对应到机器指令。你知道在这个转换过程中C编译器为了提高程序的执行性能会有哪些额外的操作呢试试概括一下这些操作
第二题
在[堆与栈的区别和应用]这节课中我们知道了堆与栈区别。同时我们也清楚了C语言的函数的局部变量和返回地址都保存在栈中如果有人对这栈中数据破坏就会导致安全隐患例如改写返回地址使之指向别的恶意程序。那问题来了请问我们有什么栈保护机制么可以用你的语言描述一下么
期待你的回答,我们下节课见!

View File

@ -0,0 +1,129 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
国庆策划02 来自课代表的学习锦囊
你好,我是课程编辑小新。
这里是国庆特别策划的第二期,我特意邀请了两位“课代表”来分享一下他们学习方法。
第一位靳同学目前在中科院研究所实习。特别巧的是第二季的课程我们选择了RISC-V体系结构而他之前就做过一个RISC-V处理器。
第二位是青玉白露同学他也是两季课程的老粉丝了今年5月份的时候他在忙活面试但还是抽出时间参与了第二季专栏目录的调研反馈。另外听说国庆期间他的flag要是把前面更新的课都看完。
借此机会,也感谢这两位同学的积极参与,还有其他更多没“出镜”的潜水同学,也谢谢你们对这门课程的建议和支持。接下来,我们就听听“课代表”们的分享吧。
@靳同学
你好,我是靳培泽。
作为一名本科应届毕业生,现在我在中国科学院计算技术研究所的一个项目组中实习。
在之前的课程群中我得知彭老师的课程要“进军”RISC-V处理器设计知道以后我很兴奋因为那时我正在参加中国科学院大学的“一生一芯”培养计划已经完成了当初的第一个“玩具”处理器。
我之前做的RISC-V处理器
我做的是一个普通的五级流水CPU实现了所有RV64I指令并且能够上板运行交叉编译后的小程序编译出来的结果一定要看看不然有可能会编译出不支持的指令。同时我还为我的小CPU“量身定做”了一个LCD显示屏模块小CPU采用MMIO方式通过DMEM直接访问显存在显示屏打印某些字符。这是突发奇想的一个实现还是挺有趣的。
本来我还打算与一位擅长游戏开发的朋友合作,进行一些小扩展,例如我向他提供库函数,让他来编写游戏。可惜到最后临近毕业,也没有能实现这个想法。
虽然我的这次项目代码量相当少并且没有涉及到任何高级体系结构方面的实现但是它为我现在正在进行的、更深入的工作提供了很扎实的知识。值得一提的是这次开发过程中我采用的difftest差分测试极大地提升了我的debug效率感兴趣的朋友们可以去了解一下相关内容。
通过整个项目我直接体会到使用RISC-V设计处理器有多么简洁我相信这样的开源指令集也会在工业界和学术界一年比一年火爆。但现在大部分人对它还没太多了解所以我也想借这次机会为RISC-V的推广尽一份绵薄之力。于是我就联系编辑想看看有没有什么能帮上忙的也对彭老师的教学内容和计划有了一定的了解。
学习经验
这次很高兴能收到邀请,分享一些我在设计或者学习过程中的“踩坑”经验,希望对你有启发。
在理论学习过程中,碰到问题是一件在所难免的事。我在“一生一芯”学习期间有个比较重要的感受就是,我们首先应当学习的是如何获取知识的知识。听着是不是有点像绕口令?说简单点就是学习获取知识的方法。这方面我在下面贴一个链接,各位可以去观赏观赏。
第一个链接是提问的智慧,当时一经发出就被奉为经典,详细描述了提问的人事前应该做好什么,以及不该做什么。第二个是“一生一芯”的提问模板。
我的另一条经验就是理论和实践是一体两面两者都重要。哪怕你觉得已经把理论全搞明白了到了实践环节coding还是会出各种问题。
不过,不知道你是不是也有这样的体会:实践之前最折磨人的环节,就是花一段比较长的时间学理论。除非你积累足够深,不然想快速突击学几天就进入敲代码环节几乎不可能。
举个cache实现的例子。先从理论部分聊起教科书上通常都是一些老生常谈的描述
地址映射方式包括直接映射、多路组相联、全相联;
write through和write back方法
write allocate和no write allocate方法
……
知道了这些?就能立刻进行代码实践了么?答案是否定的。因为理论里“隐藏了”非常非常多的细节部分,而这些细节涉及到你实践环节是顺利跑通还是举步维艰。只停留在教科书理论,没有进行自主思考和探索,是无法进行实践的。
那怎么判断自己掌握的知识够不够呢还是接着用cache实现为例我们把要实现什么描述得再具体一些假设我们现在要开始实现一个四路组相联一个cacheline为16B的4KB instruction cache。你可以想一想前面列的那些知识点你掌握了足够么
显然还不够所以我罗列了一系列问题这些问题都是在实现cache过程中101%会碰到的,我们需要考虑清楚的问题,也邀请你试着回答一下:
主存和icache的交互单位最小是多少
更新主存时采用write back还是write through?
如果采用write back那么需要对每个理论上的line进行怎样的改造实现这种改造需要多少多余的bit
当cache读miss时也就是在我们必须通过总线访问主存时CPU应该进行什么样的行为在cache读取回miss的line后应该怎么办是使用read allocate还是read through
如果使用read allocate的话应该如何定位到该被替换的cacheline如何决定替换哪一组的哪一个cacheline
替换成功后CPU应该进行什么样的行为
并不是前面的理论学习不重要,而是我们通过理论学习环节,对某个知识有了全局把握以后,还要结合更实际的问题来挑战自己,并且在摸索实现的过程里,把局部、具体的细节一一攻破。这些细节可能很散,不比我们学理论需要的耐心少。但经历了这个环节,你才能真正在理论学习的基础上迈出下一步,拿出实践结果,从而在学习和工作里更快地成长。
最后,我想说的是,虽然我们有时在某种程度上只是被动去学习,但是我们也可以换个角度,主动给自己“出题”,驱动自己主动分析、解决问题,类似于“解谜游戏”一样。在我眼中,计算机领域具有它自己的独特魅力,出于兴趣的缘故,我以后也打算在计算机体系结构领域一直探索。
这次的分享就到这里希望我们都能在学完这门课程后跟着LMOS精进自身的技术功力一起加油。
@青玉白露
你好,我是青玉白露,现在是在字节跳动做后端,今年校招刚入职。
作为彭东第一季课程的忠实“粉丝”,第二季我上线的时候就入手了。编辑听说我国庆准备把目前更新的课程都学完,邀请我分享自己的学习体会,这里我就简单聊聊我的收获和方法。
学习收获
如果用一句话概括彭东老师这门课给我带来的收获,那就只能是:能让我把计算机体系从上到下、里里外外的脉络都了然于胸。
现代计算机体系无非是硬件与软件的结合:硬件提供高效的计算能力、存储能力;而软件则是各种应用,如操作系统、影音软件、游戏、工程软件等等。
那么,两者分别是怎么实现的?又是通过什么联系在一起的呢?这门课给了我答案。
硬件其实就是由各种基础元器件所构成的总和。理论上来说所有的电路都可以通过直接画图的方式来表达但是对于复杂电路就无能为力了而Verilog语言则是让我们能够以编码的方式对电路进行抽象让计算机来进行处理生成我们所需要的电路。
软件,归根结底是依托于硬件所提供的各种“指令”,这些指令,就像是一个个不同的功能开关。软件基于这些功能开关,就能实现人们所想要的功能。至于机器语言、汇编语言、高级语言等等,也仅仅是指令不同层级的表述而已,一通百通,不外如是。
说得更具体一些就拿前面手写CPU这个章节来说我学到了怎么通过Verilog语言来编写CPU头一次站在“CPU设计者”的视角上观察CPU是怎么工作的还知道了怎么基于我所写的CPU实现一些简单的功能……
学习方法
归纳一下我的学习方法,可以概括成三个关键词:兴趣为王、辐射四周、以点带面。
兴趣为王很好理解。不管学习什么知识,兴趣是必不可少的东西,它是让你在学习过程中遇到问题还能继续坚持的动力。如果有兴趣,那就好好学;如果没有兴趣,多想想诗与远方,人不可能原地踏步,必须一往无前。
而辐射四周的意思是基于课程提供的知识和线索,自己再做些额外扩展。在学习过程中,我会尽可能了解所学知识所涉及到的东西,有些东西课程里没讲,但基于好奇心我会记录下来当时的疑问,之后自己去探索。
举个例子在学习手写CPU的章节过程中我比较好奇的是CPU的基础硬件是怎么实现的为此我找到英特尔的介绍文档了解到了从沙子到CPU最终成型的过程我觉得对这个成形过程还不够了解于是又回过头去看了电子管到晶体管的演变数电和模电……
最后一招就是以点带面。学海无涯,但时间是有限的,我们的精力是有限的。虽然我说了可以通过辐射四周来扩展知识面,但这并不意味着我们需要方方面面都刨根究底,只需要把握一个大致的脉络即可。之后的某天,当你工作需要的时候,回过头来再去深究也不迟。沿着这个思路,我会把课程里一些关键的要点记录下来,成为日后深入探索的“索引。”
学习资料
最后给你列了一些我自己感觉不错的学习资料,供你参考:
【科普】从沙子到芯片的制作过程
超形象鬼畜动画一看就懂,二极管工作原理
2.1 Verilog 基础语法 | 菜鸟教程
汇编语言入门教程
RISCV中文手册
无论是学生时代,还是现在的工作中,我越来越觉得计算机学科的未来永无止境。漫漫征途中,很高兴能通过这门课程跟彭东老师结缘,跟优秀的同学们结缘。希望我的分享对你也有帮助,我们一起加油。
课代表分享的全部内容就是这些,也欢迎你留言区,聊聊自己的学习方法或者课程收获。

View File

@ -0,0 +1,57 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
国庆策划03 揭秘代码优化操作和栈保护机制
你好我是LMOS。
今天是国庆假期策划的第三期。我们来公布第一期主观题的答案。希望你先尝试自己梳理思路,尝试回答问题以后,再来查看参考答案。
第一题
在前面课程里我们一起揭秘了C语言编译器的“搬砖”日常搞清楚了C语言会如何处理各种类型变量、各种运算符、流程控制以及由它们组成的函数并把这些内容加以转换对应到机器指令。你知道在这个转换过程中C编译器为了提高程序的执行性能会有哪些额外的操作吗试试概括一下这些操作
第一题参考答案
存在额外的操作,概括来说是对代码进行优化操作。
为了提高程序的执行性能C语言编译器在经过语义分析的阶段之后会生成平台无关的中间代码然后经历三次不同级别的代码优化。
这里首先要经历中间代码级的代码优化;而后,编译器把中间代码优化的结果作为输入,生成机器相关的目标代码;之后还会再经过一次目标代码级别的代码优化,这个优化策略和具体机器的硬件结构高度相关,且不通用。
完成了整个优化过程后,就会产生最终运行机器平台上的目标代码了。一般通用的优化代码操作具体包括四个方面,我们挨个来看看。
第一类操作是删除多余运算。编译器分析中间代码的时候,可能会发现一些计算操作属于重复计算。因为有些计算并没有让结果发生变化,它们是多余的,完全可以删除。
第二类是代码外提操作,一般用在优化循环代码,可以减少循环中代码的总数。它的原理是这样的:如果循环中的计算结果不改变某个代码段,我们就把这段代码外提,放在循环外。这种变换把计算结果不受循环执行次数影响的表达式,提到了循环的前面,使之只在循环外计算一次。
第三类是强度削弱操作。强度削弱的本质是把强度大的运算换算成强度小的运算。举例来说把加法换成乘法运算强度会更小。比如循环过程每循环一次变量的值增加1又不与循环相关每次总是增加相同的数据。因此可以把循环中计该值的加法运算变换成在循环前进行一次乘法运算。
最后一类操作是合并已知量和复写传播。有时很多运算结果都是编码时已知的,所以在代码编译时就可以计算出它们的值,我们把这种变换称为合并已知量。
还有多个变量之间的互相引用比如变量A被变量B引用而变量B又被变量C引用如果A与C之间没有能够改变B的代码就直接让C引用A这种变换称为复写传播。
第二题
在[堆与栈的区别和应用]这节课中我们知道了堆与栈区别。同时我们也清楚了C语言的函数的局部变量和返回地址都保存在栈中如果有人对这栈中数据破坏就会导致安全隐患例如改写返回地址使之指向别的恶意程序。那问题来了请问我们有什么栈保护机制么可以用你的语言描述一下么
第二题参考答案
栈保护机制有很多,我给你分享比较典型的几种。
首先是由编译器在编译程序时稍微做个检查看看是否存在栈内缓冲区溢出的错误。程序代码中采用大量的字符串或者内存操作的函数比较适合做这样的检查。通过给gcc加上 -D_FORTIFY_SOURCE=1或者2时在编译或者代码运行时通过判断数组大小来替换strcpy、memcpy、memset等函数名将它们替换成编译器中带有检查代码的函数从而防止缓冲区溢出。
通过操作系统对页表的NX位进行设置这种方法也很常见。NX即No-eXecute意思是不可执行。带NX位的页表所指向的内存中的数据是不可执行的当程序溢出成功转入恶意代码时程序会尝试在数据页面上执行指令此时CPU就会抛出异常不去执行恶意代码主要防止恶意代码在数据区溢出。
还有一种简称为ASLR的方法即地址空间分布随机化。内存空间地址随机化机制可以将进程的mmap基地址、heap基地址、栈基地址、共享库基地址随机化。这样能有效阻止攻击者在堆、栈上运行恶意代码。
最后还有栈溢出保护canary这是一种由编译器支持的技术。在Linux中将cookie信息称为canary。攻击者在覆盖返回地址的时候往往也会将cookie信息给覆盖掉导致栈保护检查失败。
而canary技术的大致思路是这样的当启用栈溢出保护后编译器会插入相关代码在函数开始执行的时候就会向栈里写入cookie信息。当函数真正返回的时候就会通过编译器插入的代码来验证cookie信息是否合法。如果不合法程序就会停止运行这样就能阻止恶意攻击代码的执行。
通过这两道题目,我们又补充了代码优化和栈保护机制的知识。接下来,我们继续回到课程主线的学习,期待你把精神状态拉满,之后学有所成!

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
温故知新 思考题参考答案(一)
你好,我是编辑小新。首先,祝你元旦快乐。
计算机基础的学习并非一蹴而就,希望课程里讲到的内容,像火种一样点燃你的学习探索兴趣。为了辅助你检验每节课的学习效果,我们留下了很多思考题。
今天这节答疑课,就是为了把思考题环节做个“闭环”,我们会公布每节课的参考答案。在对答案之前,还是建议你先自己尝试回答问题,哪怕只是大致整理一下思路,然后再对比看看老师给的思路,查漏补缺。
后面就是前四章,第一节课到第二十二节课的思考题参考答案,希望对你有帮助。
我在结束语里,看到有同学留言说:“学习计算机基础真的很开心,打通的感觉最为舒畅!”看到这样的留言,我和老师都非常开心。也非常欢迎学完一遍课程的同学常回来二刷、三刷,温故知新,有什么新的体会,也欢迎继续在留言区记录分享。
[第一节课]
Q为什么RISC的CPU能同时执行多条指令
A因为CPU内核中有多条指令流水线取指、译码、执行、访存、回写这些逻辑部件能同时和独立工作。
[第二节课]
Q为什么RISC-V要定义特权级
A因为RISC-V要支持操作系统和虚拟化它们需要管理资源需要用相应的特权来保护资源不被其它软件恶意使用。
[第三节课]
Q为什么很多特定算法用Verilog设计并且硬件化之后要比用软件实现的运算速度快很多
A因为Verilog设计的电路是并行执行的没有受到CPU流水线的限制所以速度会快很多。
[第四节课]
Q既然用Verilog很容易就可以设计出芯片的数字电路为什么我们国家还没有完全自主可控的高端CPU呢
A这是一个开放性的话题这里根据我的理解列举几点
芯片是一个需要技术积累的行业,从设计到生产,每一个环节都有技术壁垒,发展起来至少需要十到二十年。-
我国很多芯片行业起步晚在CPU方面国外早就有像Intel、AMD这样的公司形成垄断中国很难赶超Intel。-
芯片行业,品牌效应很重要,初创公司做出的芯片可能面临没人敢买的尴尬局面。-
芯片是一个很烧钱的且需要长期投入的行业,整个中国在集成电路方面的投入可能还不如国外一个大公司的投入多。-
5.高端芯片是需要一个成熟生态支撑的,需要软件和硬件配套使用,两个需要同步更新、互相促进,才能一直保持领先。
[第五节课]
Q今天我们讲到了RISC-V中的分支跳转指令JAL。想想看为什么要通过调整立即数的某些位从U-TYPE指令得到J-TYPE指令格式呢这样调整以后有什么好处
AJAL在立即数处编码了一个有符号偏移量这个偏移量加到pc上后形成跳转目标地址并将跳转指令后面的指令地址pc+4加载到rd跳转范围为±1MB这样就可以得到更大的跳转范围了。
[第六节课]
Q为什么要对指令进行预读取直接取指然后译码、执行不可以吗
A预读取是为了让流水线执行指令更高效特别是在执行分支跳转指令的时候预读取提供了简单的分支预测功能可以在发生跳转之前预测跳转方向并提前读取后续的指令。
[第七节课]
Q在6种指令格式中S型、J型和B型指令里的立即数是不连续的这是为什么
A为了让不同指令格式中尽可能多的字段信息保持位置重合降低译码难度同时减少硬件通路上mux数量从而减少硬件逻辑延迟。
[第八节课]
Q在ALU模块代码中为什么要把左移操作转换为右移进行处理
A把左移操作转换为右移操作可以复用右移操作的电路节省硬件电路的资源。
[第九节课]
Q除了数据冒险我们的CPU流水线是否还存在其它的冲突问题你想到解决方法了么
A流水线中除了数据冒险还可能存在结构冒险和控制冒险下节课我们将会讲解控制冒险。
[第十节课]
Q除了流水线停顿和分支预测方法是否还有其他解决控制冒险问题的办法
A控制冒险的第三种解决方法称为延迟转移也就是延迟转移顺序执行下一条指令并在该指令后执行分支。这需要用到汇编器对指令进行自动排序它会在延迟转移指令的后面放一条不受该分支影响的指令并且指令重新编排了后面的指令地址会发生变化。
[第十一节课]
Q计算机两大体系结构分别是冯诺依曼体系结构和哈弗体系结构请问我们的 MiniCPU属于哪一种体系结构呢
A哈弗结构是一种将程序指令存储和数据存储分开的存储器结构而冯·诺依曼结构的数据空间和地址空间不分开。显然我们的MiniCPU是把数据空间和地址空间分开的所以是哈弗结构。
[第十二节课]
Q请你说一说交叉编译的过程
A首先在主环境上用相应的编辑器写好源代码然后运行主环境上的交叉编译器对源代码进行编译最后生成目标平台的可执行程序。
[第十三节课]
Q处理环境变量后为什么要执行source ./.bashrc才会生效
Asource命令和“.”是一样的,所以也可以是. ./.bashrcsource命令与终端.bashrc脚本命令的区别是source是在当前bash环境下执行命令而运行脚本是启动一个子终端进程来执行其中的命令。这样如果把设置环境变量的命令写进.bashrc脚本文件中就只会影响子进程无法改变当前的bash环境。所以通过.bashrc脚本文件设置环境变量时需要source命令。
[第十四节课]
Q为什么C语言中为什么要有流程控制
A因为程序不能一直顺序执行如果没有分支和循环这是程序的三大流程结构。也正因如此我们才能实现各种算法你可以再想想图灵机就能明白了。
[第十五节课]
Q请问C语言函数如何传递结构体类型的参数呢
A如果结构体有多于8个成员的情况下前8个成员会被放在寄存器中剩下部分被存放在栈上sp指向第一个没有被存放在寄存器上的结构体成员。结构体中如果第i个成员是整型类型那么就存放在整型寄存器a(i)上如果第i个成员是浮点数类型那么就存放在浮点寄存器fa(i)上0<=i<=7
[第十六节课]
Q请写出机器码0x00000033对应的指令。
A0x00000033对应的指令是add x0x0x0
[第十七节课]
Q为什么指令编码中目标寄存器源寄存器1源寄存器2占用的位宽都是5位呢
A因为5位二进制数据就是2的5次方所能表示的编码范围是0~31正好索引RISC-V的32个通用寄存器。
[第十八节课]
Q既然已经有jal指令了为什么还需要jalr指令呢
A因为jal只能通过立即数传递跳转地址只能跳转±2k的地址空间如果想要跳转到更远的地址就得通过寄存器来传递跳转地址。
[第十九节课]
Q我们发现RISC-V指令集中没有大于指令和小于等于指令为什么呢
A因为实现大于指令和小于等于指令的功能只需要把小于指令和大于等于指令的两个操作数互换一下位置就行了。
[第二十节课]
Q请你尝试用LR、SC指令实现自旋锁。
A代码如下所示
/*********************************/
//lrsc.S
.text
.globl cas
#a0内存地址
#a1预期值
#a2所需值
#a0返回值如果成功则为0否则为1
cas:
lr.w t0, (a0) #加载以前的值
bne t0, a1, fail #不相等则跳转到fail
sc.w a0, a2, (a0) #尝试更新
jr ra #返回
fail:
li a0, 1 #a0 = 1
jr ra #返回
/*********************************/
//lock.c
//定义锁类型
typedef struct Lock
{
int LockVal; //锁值
}Lock;
//自旋锁初始化
void SpinLockInit(Lock* lock)
{
//锁值初始化为0
lock->LockVal = 0;
return;
}
//自旋锁加锁
void SpinLock(Lock* lock)
{
int status;
do
{
status = cas(&lock->LockVal, 0, 1); //加锁
}while(status); //循环加锁,直到成功
return;
}
//自旋锁解锁
void SpinUnLock(Lock* lock)
{
SpinLockInit(lock);//直接初始化 解锁
return;
[第二十一节课]
Q为什么加载字节与加载半字指令需要处理数据符号问题呢而加载字指令却不需要
A首先加载指令是从内存到寄存器。
其次加载到寄存器中的数据会参与运算,数据的运算就需要考虑数据的符号问题。
最后加载字指令是加载32位数据占用整个寄存器不需要处理符号位问题只需要原样加载内存中的数据就行了内存中的数据有符号就有符号没有符号那就是没有符号。
[第二十二节课]
Q为什么三条储存指令不需要处理数据符号问题呢
A首先储存指令是把寄存器中的数据储存到内存其次储存到内存中的数据不参与运算时不需要考虑符号问题。只需要原样保存在内存中就行了。

View File

@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 我是怎样学习Verilog的
你好,我是咻咻咻。
先做个自我介绍,我是学 FPGA的 写Verilog的。今年参加了工信部组织的集创赛全国大学生集成电路创新创业大赛取得了省级一等奖国家级三等奖的成绩。
成绩还不错,但主要是团队能力比较强,我深知自己还有很大的提升空间。刚比赛归来,就听说 LMOS 老师开了一门新课,还是手撸 CPU 的,于是我便兴致冲冲地准备完成男人的两大梦想(手撸 CPU造高达之一。
因为之前参赛时也用到了RISC-V主要编程语言也是Verilog回来继续学习的时候比赛的熟悉感又扑面而来了。这次用户故事我主要想和你聊聊我对 Verilog 和专栏学习的一些粗浅理解,希望对你有启发。
我是怎样学Verilog的
想必没有人是看完一整本谭的 C 语言再去写“helloworld!”的吧?
学Verilog也是一样如果你从头开始学Verilog中的数据类型、符号常量、运算符条件、分支、循环语句过程结构……学完这些再去看课里的配套代码结果很可能是看每个词都模模糊糊有点印象但连起来就一脸懵逼。而且这样做往往进度很慢大多数人多半坚持不下去。
因此在学习前我们先要明确在这门课里Verilog只是一种实现迷你CPU的工具我们真正需要掌握的是这个迷你CPU的实现原理。Verilog 够用就行,不需要过多深究它的原理。
那我们学 Verilog 语言怎样上手才有效呢?如果一直在纸面理论徘徊,大概率会把自己绕晕。
所以,我的方法就是从简单的开始,多写写,代码写的多了就会了。比如说,阻塞与非阻塞想不通,不妨直接写程序看仿真,毕竟仿真波形更加清晰明了,这比跟理论说明的几行字较劲更管用。
如果一定这样你还是觉得Verilog还是太难建议先刷题、补补基础语法我用的参考资料列在了最后
How-What-Why三步代码学习法
另外,有了比赛经历练习代码,再回看专栏突然觉得课程里代码量并没那么多,但非常规范和完善。
我试着按“How-What-Why”的三步走策略执行感觉效果还不错因此分享出来给你提供一个思路。
简单说说我的做法。首先解决How的问题具体就是把LMOS老师提供的例程 CTRL+CCTRL+V整个跑一遍。
跑通后你就能看到仿真与通过Yosys生成的RTL图这能帮你大概感知到整个程序是如何运转的让你提升信心消除陌生感的同时也能建立一个大体的框架更加明确各部分作用与相互关系学起来也连贯。
接下来是What也是核心部分。跑通了老师的代码后我们就可以开始慢慢看程序详细代码补足这部分配套代码相关的Verilog语法知识。我建议将程序分割成很多的小部分一点一点去了解各部分是什么程序中起什么作用。这样以问题为导向来理解知识学起来也相对轻松。
最后就是理解Why的环节此时我们需要回到文章中结合课程尤其是老师给出的代码注释做理解。在理解过程中别忘了结合coding去实践自己的想法。实践的时候总会有些与理想状况的差异而这些差异往往可以促进我们更快成长。
与Verilog语法相比确实是完全理解CPU内部的思想和工作原理更难我对完全理解的定义是脑子里一想到这个知识点就可以熟练拓展开明白它大致的作用涉及到的原理为什么这样写。
遇到想不通的地方,建议暂时先记录下来,相信随着你课程看得多了,有些问题会迎刃而解。如果实在不懂的,也可以留言提问。
当然,这样做往往会花费你大量的时间,对于一个零基础的人来说,可能理解完二十行代码(前提:每行代码都有截然不同的作用),四五个小时就过去了。但我们刚起步,这也很正常,不用认为自己理解力差,不适合这门课。
如此来回往复不断学习Verilog语言你也会不知不觉学个七七八八。更重要的是你将掌握构建迷你CPU的核心内容不仅能明白 CPU的基本原理也会获得如何手写一个CPU的实操经验。
什么是以问题为导向?
在学习课程的时候,“以问题为导向”这个原则对我很有帮助。这里最重要的是明确自己的目的,通过不断给自己提出问题,使自己专注,提高效率。
专栏每节课的内容信息密度都很高,硬着头皮从头看到尾,很容易让自己信息“过载”了。
我个人会对一篇内容进行分块学习,先借助思维导图/重点回顾了解整节课的结构/重点,知道主线是什么,了解大体框架,再选自己最有兴趣的部分上手,或者自己给自己提几个问题,带着问题细读课程。
只有当你自己感兴趣、有问题,想不明白又特别好奇的时候,才会充分调动自己的大脑去思考,拼尽全力去尝试解决问题。这个过程中,你并不是被动输入,而是主动地加工理解。最终把一个问题弄明白的时候,也会非常有成就感。
对此,我有两个建议。第一点,先想清楚自己想要找什么,再去搜寻答案。如果有问题,但不明确搜寻方向,可以从这几个方面下手去提问:是什么、有什么用、为什么要这样写。明确问题,可以帮助你提升阅读效率。
以 [06手写CPU迷你CPU架构设计与取指令实现] 这节课为例“CPU流水线”这部分内容可以分解为以下几个问题
1.流水线是什么什么是五级流水线What-
2.CPU为什么要使用流水线这么做的好处是什么Why-
3.流水线思想在代码中是如何体现的How
第二点建议,阅读的时候分清“主次”,明确哪些对自己解决这个问题有帮助,哪些对自己解决问题没有帮助。时刻注意这一点,这样做可以很好的帮你把注意力拉回来,不会读着读着就忘了目的。
还是拿第六课为例,我当时的第一个疑惑是 “CPU流水线是什么”带着这个疑惑我通过页内搜索找到第一处出现“流水线”这个词汇的地方。从这个地方开始往下读。
在这个过程中,因为想着我疑惑的问题,我会下意识着重寻找作者对流水线的定义,相关词汇以及作者的理解。
在看到相关词汇的时候,我会停下快速瞟一眼上下文做一个简单的判断,判断这是对“流水线”的解释还是定义。如果是定义,就停下仔细读。如果不是的话,就忽略掉这个词,接着往下找。
比如这句话:
说到流水线,你是否会马上想到我们打工人的工厂流水线?没错,高大上的 CPU 流水线其实和我们打工人的流水线是一样的。
在这句话中看到“工厂流水线”我暂时停下快速看了看这个词的前后发现这段话其实是作者为解释“CPU流水线”做的一个类比用来辅助大家理解并不是给“流水线”下的结论是“次要的”可以忽略。于是我接着往下读。
然后我注意到这样一句话:
这样,后续生产中就能够保证五个工人一直处于工作状态,不会造成人员的闲置而产线的冰墩墩就好像流水一样源源不断地产出,因此我们称这种生产方式为流水线。
可以看到,在最后有一句“称……为流水线”这句话,不难推测这就是作者对流水线的定义,是“主要的”。于是,我开始仔仔细细精读这段话。
这句话,大致可以省略为:……保证……一直处于工作状态,不会造成人员的闲置……源源不断地产出,……称这种生产方式为流水线。
然后我会用自己的话重新概括,比如这样概括流水线:“不会造成人员闲置,可以不断产出商品的一种生产方式”。这样,我们就把“流水线”的定义这个问题解决了。如果看不懂,也没关系,重新返回前文,借助冰墩墩的例子辅助理解即可。
接下来的学习也是如法炮制比如在理解完流水线回顾的时候发现在工厂中每个工人是同时工作的那么就可以思考“在CPU运行时每个地方也是同时工作的么”这样的问题带着这个疑问你再回看内容就会发现在CPU运行时也是同时工作的。
接下来你可能又会去思考在Verilog代码中又是怎么实现同时工作的呢如果你是按课程顺序学习的可能就会想起第四课的这段内容
Verilog 代码和 C 语言、Java 等这些计算机编程语言有本质的不同,在可综合(这里的“可综合”和代码“编译”的意思差不多)的 Verilog 代码里,基本所有写出来的东西都对应着实际的电路。
在留言中也会看到这样一句话:
硬件设计是特定电路实现更符合项目,并且是真正的并行结构,软件是在特定的处理器下进行项目实现,顺序结构。效率远低于直接硬件设计实现。
再综合一些相关知识你可能就理解了Verilog是一个硬件描述语言编译以后生成的是电路。每一个模块、每一个always/assign 语句是同时进行的,这与软件不一样,软件编程是顺序结构,是从上而下依次执行的……
概括一下整个过程就是:不断给自己提问,再结合课程解谜,然后整合搜集到的“线索”,用自己的话概括复盘。
做完这些你可能对“CPU流水线”在脑中已经有了一个大概的理解可以去做一个总结概述最好写下来验证自己对这个知识点是否理解透彻。如果有什么阐述不流畅的地方写不出来这就说明你还未对这个知识点掌握透彻还需要重新去看文章或搜索 Google 加强理解。
当然,如果没有疑问,也可以去作者文章里“找找茬”。一切都出于自己的兴趣去搜寻,不必强逼自己。
好,例子就说到这,我实践下来的感觉就是。自问自答很容易有一种成就感,让自己开心一整天,内容理解也更加扎实。
学习资料
最后。我列了一些觉得不错的学习资料:
HDLBits (全英文刷题网站,无需科学上网,解析详尽。如果英语好/想提升英语水平推荐使用。)
verilog基础语法 | 菜鸟教程 Berilog(中文) 学习网站
入行十年我总结了这份FPGA学习路线 FPGA入门思维导图
阻塞赋值与非阻塞赋值的区别 (阻塞与非阻塞解答,建议自己手写直接看仿真理解)
【硬件科普】带你认识CPU CPU 相关科普课程)
RISC-V中文手册
verilog宏定义用法 define
RISCV部分原理 不完全解答 讲解_哔哩哔哩_bilibili感谢Geek_6a1eb9的推荐这里一并列出
我的分享就到这里。我继续去啃代码了。各位加油一起实现RISC-V 五级流水线 CPU呀fighting

View File

@ -0,0 +1,65 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 心若有所向往,何惧道阻且长
你好我是LMOS。
首先恭喜你学完了这门课在过去的四个多月里我们从芯片到语言、从内存到应用、从IO到网络由上而下地掌握了计算机各个领域的基础核心知识。
你可能会好奇,为什么我会做这样一门课?其实在此之前,我还有一门《[操作系统实战45讲]》,主题是如何实现一个五脏俱全的小型操作系统。我曾经以为这门课的深晦会让它石沉大海,但却出乎意料的异常火爆。
这让我看到了同学们对操作系统的热爱,但也从很多留言里发现了一个更严重的问题——很多同学的计算机基础不扎实。
我觉得这也是大环境所致,什么有用学什么、什么来钱用什么、什么能快速产出用什么,这会抽象提炼出一个大大的功利主义。
比如用Java能高速构建各种网络系统因为Java本质上就是提供了各种类和组件。你用这些组件能快速出活自然不用了解其内部的实现和对底层的依赖美其名日“站在巨人的肩膀上”。效率提升了但同时也导致我们对巨人的成长过程不闻不问。殊不知巨人倒下之后我们将无所适从就算巨人只是生个病发生漏洞带来的损失也不可估量。
于是我萌生了写计算机基础课程的想法。操作系统是我心中之光,我是乐此不疲的追光者,在我眼中,就算是复杂精密的操作系统,经过一层层分割、拆解,最基础的也是这门课里你学到的内容。
简单而无用?
不知道你学起来的感受是怎样的?从留言区和课程群的各种评论和反馈来看,这门课学起来还是相对轻松的,单独拎出某个知识点来看,甚至显得简单且无趣。所以你学的时候也许会疑惑,这么简单的知识,究竟有用么?
“1+1” 简单吗?简单。有用吗?单独存在时好像也没有用,但是它却是数理基础。同样地,计算机基础电路晶体管简单吗?简单,而且单独存在时,作用也非常有限。
在我看来,基础性的东西,共性就是简单,如此才能形成一个个知识点,便于我们“存储”到大脑中形成记忆。
以应用开发环节为例,开发一个大型应用的时候,我们都会把应用的功能拆开,形成一个个功能模块,这些功能模块彼此独立,模块之间有共同调用的函数库。模块中还会继续划分成一个个类,类中还会进一步拆分出多个函数。
当我们把视线聚焦到这些具体函数时,你会发现这些函数就会用上我们所讲的基础性知识,大到建立进程、操作读写设备、文件与网络,小到内存复制、处理字符串,这些“基本动作”是不是很眼熟?
没错这些就是基础。而这些基础之下还有更基础的东西层层下推无外乎就是IO、编程语言与内存最终落到物理芯片这一层。看似无用的基础它们堆叠、扩展、相互协作就能形成功能强大的产品。
“道生一,一生二,二生三,三生万物”,这句话老子在两千多年前就说过。其实越是简单的基础,越能扩展出世间万物,任何领域的基础性知识都是这样的。
所以对计算机基础知识来说,我们第一步要做的就是广泛吸纳知识,各种基础知识点照单全收,不要管有什么用,收入大脑存起来,就像给自己不停增加各种不同的库函数,这样就做到了“博闻”。
学以致用
为了摆脱“一学就会,一用就废”的怪圈,把这些零散的基础知识“长久存储”,融会贯通,还需要经历一个重要环节——学以致用。
只有把知识真正用起来进入“运行时”才算真正激活了它们。比如你想用代码读写文件立马就联想到open、close、read、write等函数想操作网络你立马就能想到套接字接口。这一切是基于你长期运用这些函数的结果。
这一点感悟来自我的亲身体会。在自学计算机的道路上,我同样磕磕绊绊,但因为好奇心和兴趣的驱动,脚步虽慢,却未曾停止。
在看过了很多书籍资料之后,我开始尝试把自己的想法用代码实现出来,在真正的计算机上验证一下。在动手实验的过程里,我才遇到了许多先前只看理论时根本无法想到的问题,技术水平也迅速提升。
纸上得来终觉浅,绝知此事要躬行。你学会了一种知识,然后将其运用在工作中,感受是不一样的。在这个过程中你会不断对各种知识进行强化,从而加深理解,深入思考背后的理论原理,到一定的程度你就会“悟”,会有一种豁然开朗的感觉,那就是了解真理的感觉,那就是认知升级的时刻。
一旦认知升级,你脑中的机制,自然就会对已经清楚的知识进行打包,思维体系会继续向上一层,追求新的高度。当你日复一日,年复一年的坚持学而用之,认知体系就会自动的一次又一次的迭代升级,随着每次认知体系的升级,都会增强我们的自信心,提升我们的工作能力。
当我们知识越来越多,也就越来越接近山顶了。这时,你会真正地体会到“会当凌绝顶,一览众山小”的感觉,悄无声息地步入了高手行列。
到这里,真的要和你说再见了。虽然课程结束了,但是这些内容会持续存在,你可以时不时地复习一下。如果你遇见了什么问题,也欢迎继续给我留言。
学习是一辈子的事情,千万别停止学习。道阻且长,行则将至。知行合一,未来可期!
最后,我很想知道你学习这个专栏的感受。这里我为你准备了一份毕业问卷,题目不多,希望你可以花两分钟填一下。

View File

@ -0,0 +1,107 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 怎样成长为优秀的软件架构师?
你好,我是许式伟。从今天起,我想和你一起来聊聊架构的话题。
开始之前,我先来和你简单介绍下我自己。
我是2000年开始工作的曾经做过WPS的首席架构师也在盛大从事过技术研究方面的工作后来在2011年创立了七牛云现在我是一名创业者、CEO。但不管角色怎么轮换我觉得我的另一面始终是一名程序员、架构师如果你想了解更多我的经历可以观看下面的短视频
让我们来想象一下,如果把信息世界看成一座大厦,把程序员看成这个世界的建筑师,那么,现在的你在负责什么样的工作呢?
当我们把程序员类比成建筑师时,按照能力水平来分,我觉得大体可以分为三个层次:搬砖师、工程师、架构师。
软件搬砖师之名对应到建筑行业的建筑工人,他们的编程能力和业务基本上停留在堆叠代码,按照要求去实现功能需求的层面。
只要能让程序跑起来,能正确地实现业务逻辑,就可以称为“会编程”的人。有时候,我们也会看见程序员自称为“码农”“搬砖的”,虽然二者的工种不同,但从基础工作的相似度来说,确实有可类比的成分。
很多外行的人都会觉得程序员是一个很神秘的职业但实际上程序员的基础门槛并不算高。我自己从2016年2月开始至今一直在教几位8~12岁的小朋友学习编程。这个实践经验告诉我小学生完全有能力学编程。而且并不是只有部分小学生可以而是任何一位小学生都可以学会。
然而,只让代码跑起来是不够的。这个世界是不断变化的,作为程序员,我们更多的时间是用来维护代码:增加新的需求,对已有的功能进行调整,修改之前代码遗留下来的问题,优化性能等等。
这是因为一个软件诞生之后,后续就是需要花费大量的代价去维护它,演进它。一个人是完全维护不过来的,需要更多的人,很多的团队一起协作。如果面临了员工离职、岗位调整等情况,还会导致软件代码在不同人之间流转。
所以,一些有追求的程序员会关注代码的质量。代码质量的评判可以有这样一些基本维度:可阅读性(方便代码流转)、可扩展性/可维护性(方便修改功能,添加新功能)、可测试性(质量管理)、可复用性(简化后续功能开发的难度)。
这一类致力于不断提升软件代码的工程质量的程序员,我们可以称他们为软件工程师。
工程师不会简单把写代码看作一门工作,把任务交代过去就完事。他们会有“洁癖”,代码在他们眼里是一种艺术,是自己生命的一部分。
他们会把写出来的代码改了又改,直到让自己满意为止。阅读和维护软件工程师写的代码会有一种赏心悦目的感觉。
但是,大部分商业软件都是一项极其复杂的工程,它们远比很多传统的建筑工程复杂得多,无论是涉及的人力、时间还是业务的变数都要多很多。
人力上,大部分大型的软件系统都有几千甚至几万人的规模,而这几千几万人中,却没有两个人的工作是重复的,他们都是在从事着前所未有的创造性工作。
时间上,只要软件还在服务客户中,程序员们的创造过程便不会停止,软件系统仍然持续迭代更新,以便形成更好的市场竞争力。
这些都与传统建筑工程的模式大相径庭。一幢建筑自它完成之后,所有的变化便主要集中在一些软装的细节上,很少会再发生剧烈的变动,更不会持续地发生变动。但软件却不是这样,它从诞生之初到其生命周期结束,自始至终都在迭代变化,从未停止。
所以,光靠把控软件工程师的水平,依赖他们自觉保障的工程质量,是远远不够的。软件工程是一项非常复杂的系统工程,它需要依赖一个能够掌控整个工程全局的团队,来规划和引导整个系统的演变过程。这个团队就是架构师团队。
软件架构师的职责,并不单单是我们通常理解的,对软件系统进行边界划分和模块规格的定义。
从根本目标来说,软件架构师要对软件工程的执行结果负责,这包括:按时按质进行软件的迭代和发布、敏捷地响应需求变更、防范软件质量风险(避免发生软件质量事故)、降低迭代维护成本。
那怎么才能成长为优秀的软件架构师?软件架构师和软件工程师最根本的差别又在哪里?我认为关键在于四个字:掌控全局。
掌控全局,就是对系统的全貌了然于胸。从传统的建筑工程来说,建筑架构师并不单单要会画建筑图纸,而是要对地基构建、土质、材料、建筑工艺等等所有有可能影响建筑质量的因素都要了然于胸。
掌控全局,并不是无所不能,不是成为全栈。怎么做到掌控全局?核心在于对知识脉络的体系化梳理。这是架构能力构建和全面提升的关键。这种方法不单单是在软件工程中适用。
比如学数学,我个人非常喜欢做的一件事情是自己去推导书上所有的公式。每一个公式我都亲自推导而来。
这样做的核心意义在于我在尝试从0开始去构建整个精彩纷呈的数学世界整个数学发展史在自己的笔下重新演绎了一遍来龙去脉清清楚楚。有时候你甚至会推导出还没有学到的公式但是在后面学到了。这种体验非常有趣而又让人满足。
是的,掌控全局的前提是:在自己心中去重新构建出整个世界。在这个过程中,你不需要一上来沉浸在某个技术的实现细节(除非它影响了你对这个世界构建过程的理解),但是你知道整个世界的脉络,知道整个世界的骨架。
这个时候,你对这个世界的感觉是完全不同的,因为,你已经成为了这个世界的构建者。
而架构的本质,不也正是构建和创造么?
作为一个软件行业的从业人员,我们可能接触各种各样的技术书籍。有讲编程语言的、讲数据结构与算法的、讲操作系统的、讲编译原理的、讲架构设计的,还有领域技术类的(比如数据库、存储、大数据、人工智能之类)。
大部分类别的技术书,多多少少都能够找到几本经典著作。但是,架构设计很可能是个例外,当我想推荐一本经典的架构设计书时,我并不能非常快速地想到应该推荐哪本。
从个人经验来说,我接触过的与架构相关的图书,大概有如下这些分类。
架构思维类。这类图书通常从一些著名的架构理论讲起,比如开闭原则、单一职责原则、依赖倒置原则、接口分离原则,等等。这种图书的问题在于过度理论化。计算机科学归根到底属于工程技术类,实践第一。
设计模式类。这一类图书则一下子进入架构的局部细节,每个模式的来龙去脉并不容易理解。就算理解了某个具体的模式,但是也很难真正做到活学活用,不知道还是不知道。
分布式系统架构设计类。这类图书通常从服务端的通用问题如一致性、高可用、高并发挑战等话题讲起,讲大型业务系统面临的挑战。这些知识是非常有价值的,但无法延伸到通用业务架构,对大部分企业的架构实践并不具备真正的指导意义。
重构类。这类图书主要讲怎么把坏代码一步步改进到好代码。我认为这是最实用的一类。但在没有优秀架构师主导的情况下,大部分公司的代码不可避免地越变越坏,直到不堪重负最后不得不重写。实际上,一个模块最初的地基是最重要的,基本决定了这座大厦能够撑多久,而重构更多侧重于大厦建成之后,在服务于人的前提下怎么去修修补补,延长生命。
这些架构类的图书并没有达到我个人的期望。因为它们都没有揭开架构设计的全貌。
我自己在职业生涯中前后大概做过十几次的架构类演讲,这也是我最为重视、重复次数最多的一类演讲。但同样地,这样零星的演讲对于传递架构设计思想来说,仍然远远不够。
所以一直以来,我就心存着这样一个念头:“要写一本不一样的架构类图书”。这个念想,也正是今天这个专栏的由来。
这个专栏的内容组织算是我的一次尝试。它和今天你看得到的大部分架构书并不太一样。我基本上围绕着两个脉络主线来展开内容:
如何从零开始一步步构建出整个信息世界;
在整个信息世界的构建过程中,都用了哪些重要的架构思维范式,以及这些范式如何去运用于你平常的工程实践中。
这两大脉络相辅相成。首先,我们通过还原信息世界的构建过程,剥离出了整个信息世界的核心骨架,这也是最真实、最宏大的架构实践案例。其次,我们结合这个宏大的架构实践来谈架构思维,避免因对架构思维的阐述过于理论化而让人难以理解。
我想,每个程序员都有一颗成为架构师的心。所以,从内容设计来说,我希望这是一个门槛最低的架构设计专栏,也希望它可以帮助到想成为架构师的初学者,达成自己的目标。
在行文上,我会尽量避免深奥的术语,尽可能以通俗易懂的文字,来描述信息世界构建者们的所思所想。如果你在阅读的过程中遇到了理解上的障碍,非常欢迎你来给我留言,我将尽可能地根据你的反馈,做出必要的调整。
如果你已经成为了架构师,我也希望可以为你规避一些错误的经验。在过去的工作经历里,我看到不少架构师都会倾向于把架构看作一项纯技术性的行为。他们的工作流程是这样的:产品经理根据用户的需求做出产品设计,然后架构师再依据产品设计给出实现,也就是软件的架构设计方案。
在我看来,这其实是个误解。架构关乎的是整个复杂的软件工程,它关乎实现它的人,它又因团队的能力而异。
同时,架构也关乎用户需求,作为架构师,我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。预测什么不会发生最为重要,只有做到这一点,才能真正防止架构的过度设计,把简单的事情复杂化。
谈了这么多,那么,应该怎样成长为优秀的软件架构师?我想,一靠匠心,二靠悟心。 架构设计并无标准答案,但我仍然希望把我这些年的所思所想分享给你,更希望这些内容能给你一些启发。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 架构设计的宏观视角
你好,我是七牛云许式伟。今天我们来谈谈架构设计的宏观视角。
在信息科技高度发展的今天我们每个人随时随地都可以接触到由程序驱动的智能电子设备包括手机如iPhone、oppo拍照手机、平板电脑如iPad、手表如iWatch、小天才智能手表、音箱如天猫精灵、汽车如特斯拉等等。
这些东西背后是怎么工作的?单就其中的软件系统而言,这些小小的设备上往往运行着成千上万个软件模块,这些模块是如何如此精密地一起协作的?
对此,我过去接触过很多的软件开发工程师,或者架构师,很多人对这些原理也是一知半解,虽然“知其然”,但却“不知其所以然”。甚至有些朋友可能觉得,学这些有什么用处呢,在我看来,这部分内容恰恰是我们成为架构师很重要的一门基础课。
为什么需要建立宏观视角?
如同造房子有建筑工人(负责搬砖)和建筑师(负责架构设计)一样,软件系统的开发过程同样需要有程序员(负责搬“砖”)和架构师(负责架构设计)。作为架构师,我们需要的第一个能力是宏观的全局掌控能力。
如果把应用程序比作一座大厦,那么我们作为大厦的架构师,需要把大厦的结构搭建好,让程序员可以把砖填充进去,我们都知道,一个大厦的结构建得是否稳固,与地基密不可分。
所以,我们首先就需要从大厦的地基开始,熟悉这座大厦。毕竟,你对所依赖的基础架构了解得越全面,做业务架构设计就会越发从容。
介绍基础架构的知识点并不是让你真的去实现它们。但你仍然需要懂得它们的核心思想是什么,知道有哪些信息是你必须深刻理解的,以便可以更好地驾驭它们。
我们的整个专栏内容也会从基础架构开始讲起,最后逐步过渡到业务架构,到最终完成一个完整应用程序的设计过程。
那么,在今天的开篇第一篇,我们需要站在宏观视角,从基础架构开始,逐渐来解剖一个应用程序的整体构成,我希望,通过今天的文章,可以让你对于一个程序的全貌,形成完整的认识。
我们从头开始。
应用程序的基础架构
我们想学习一个程序的基础架构,其实就是弄清楚电脑的工作原理,以及程序的运行原理。
无论是什么样的智能电子设备,手机也好,汽车也罢,它们都可以称为“电脑”。所有的电脑都可以统一看作由“中央处理器+存储+一系列的输入输出设备”构成。
中央处理器也就是我们平常说的CPU负责按指令执行命令存储负责保存数据包括我们要执行的命令也是以数据形式保存在存储中的。
每次在打开电脑的电源后,中央处理器都会从存储的某个固定位置处开始读入数据(也就是指令),并且按指令执行命令,执行完一条指令就会继续执行下一条指令。电脑就这样开始工作了。
你可能会说,就这么简单?是的,就是这么简单。
那这么简单的话,为何电脑能够完成这么多复杂而多样化的工作?
这整个过程,在我看来主要依赖两点。
第一是可编程性。 大体来说中央处理器CPU的指令分为如下这几类。
计算类也就是支持我们大家都熟知的各类数学运算如加减乘除、sin/cos等等。
I/O类从存储读写数据从输入输出设备读数据、写数据。
指令跳转类,在满足特定条件下跳转到新的当前程序执行位置。
虽然, CPU 指令是一个很有限的指令集但是CPU 执行的指令序列(或者叫“程序”)并不是固定的,而是依赖保存在存储中的数据—— 由软件工程师(或者叫“程序员”)编写的软件来决定。指令序列的可能性是无穷的,这也就意味着电脑能够做的事情的可能性也是无穷的。
第二是开放设计的外部设备支持。 虽然我们电脑可以连接非常非常多种类的外部设备比如键盘、打印机、屏幕、汽车马达等等但CPU 并不理解这些设备具体有什么样的能力,它只和这些设备交换数据。它能够做的是从某个编号的设备(通常这个设备编号被称为“端口”)读入一段数据,或者向设备的端口写入一段数据。
例如当你在键盘上按下了A的时候CPU 可以从键盘连接的端口读到一段数据通过这段数据来表达你按了“A”可能CPU 会向打印机连接的端口发送一段数据来驱动打印机打印特定的文本还有可能CPU 会向汽车马达所在的端口发送数据,来驱动马达转动,从而让汽车按照预期来行驶。
值得注意的是CPU 知道的是如何和这些设备交换数据但是并不理解数据代表什么含义。这些外部设备的厂商在提供设备硬件的同时往往也需要提供和硬件匹配的软件来完成和CPU 的协作,让软件工程师可以轻松使用这些设备。
从上面可以看出,电脑的 CPU 是一个非常简洁的模型,它只读入和写出数据,对数据进行计算。 这也是为什么我们往往把电脑也叫作“计算机”,这是因为 CPU 这个计算机的大脑的确只会做“计算”。
这个基础的设计体系我们很多人都知道这就是冯·诺依曼计算机体系。1945年6月冯·诺依曼以“关于EDVAC的报告草案”为题起草的长达101页的总结报告定义了“冯·诺依曼体系结构”他现在也被称为计算机之父。我想看到这里你应该不难理解他的伟大之处了吧
有了这个基础的计算机体系之后,我们就可以编写软件了。
当然我们遇到的第一个问题是直接用机器指令编写软件太累,而且这些机器指令像天书一样没人看得懂,没法维护。
所以,编程语言+编译器就出现了。编译器负责把我们人类容易理解的语言,转换为机器可以理解的机器指令,这样一来就大大解放了编写软件的门槛。
在编写软件不是问题时,我们遇到的第二个问题,就是多个软件在同一个电脑上怎么共处。多个软件大家往同一个存储地址写数据冲突怎么办?一起往打印机去发送打印指令怎么办?有的软件可能偷偷搞破坏怎么办?
于是,操作系统就出现了。
它首先要解决的是软件治理的问题。 它要建立安全保护机制,确保你的电脑免受恶意软件侵害。同时,它也要建立软件之间的协作秩序,让大家按照期望的方式进行协作。比如存储你写到这里,那么我就要写到别处;使用打印机要排队,你打完了,我才能接着去打印。
操作系统其次解决的是基础编程接口问题。 这些编程接口一方面简化了软件开发,另一方面提供了多软件共存(多任务)的环境,实现了软件治理。
例如,对于屏幕设备,操作系统需要提供多任务窗口系统,以避免屏幕被多个软件画得乱七八糟;对于键盘输入设备,操作系统引入焦点窗口,以确定键盘输入的事件被正确发送到正确的软件程序。
你会发现,今天的我们开发软件的时候,已经处于一些基础的架构设计之中。像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。
基础架构解决的是与业务无关的一些通用性的问题,这些问题往往无论你具体要做什么样的应用都需要面对。而且,基础架构通常以独立的软件存在,所以也称为基础软件。
例如我们熟知的Linux、Nginx、MySQL、PHP 等这些软件都属于基础软件,这些基础软件极大地降低了应用开发的难度。在今天软件服务化的大趋势下,很多基础软件最终以互联网服务的方式提供,这就是所谓的“云计算”。
完整的程序架构是怎样的?
讲完了程序的地基,让我们来总览一下程序的完整架构。
在越强大的基础架构支撑下,应用程序开发需要关注的问题就越收敛,我们的开发效率就越高。** 在我们只需要关注应用程序本身的业务问题如何构建时,我们说自己是在设计应用程序的业务架构(或者叫“应用架构”)。**
业务架构虽然会因为应用的领域不同而有很大的差异,但不同业务架构之间,仍然会有许多共通的东西。它们不只遵循相同的架构原则,还可以遵循相同的设计范式。
一些设计范式被人们以应用程序框架的方式固化下来。例如在用户交互领域有著名的MVC 框架如JavaScript 语言的AngularPHP 语言的ZendPython 语言的 Django在游戏开发领域有各种游戏引擎如JavaScript 语言的 PhaserC# 语言的 Unity3D等等。
对于一个服务端应用程序来说,其完整的架构体系大体如下:
对于客户端应用程序来说,和服务端的情况会有非常大的差别。客户端首先面临的是多样性的挑战。
单就操作系统来说PC 就有Windows、Mac、Linux 等数十种手机也有Android、iOSWindows Mobile 等等。而设备种类而言就更多了,不只有笔记本、平板电脑,还有手机、手表、汽车,未来只会更加多样化。
第一个想消除客户端的多样性,并且跨平台提供统一编程接口的,是浏览器。
可能在很多人看来,浏览器主要改变的是软件分发的方式,让软件可以即取即用,无需安装。但从技术角度来说,底层操作系统对软件的支持同样可以做到即取即用。
这方面苹果在iOS 上已经在尝试大家可能已经留意到如果你一个软件很久没有用iPhone 就会把这个软件从本地清理出去,而在你下一次使用它时又自动安装回来。
假如软件包足够小,那么这种行为和 Web 应用就毫无区别。不同之处只在于Web 应用基于的指令不是机器码,而是更高阶的 JavaScript 脚本。
JavaScript 因为指令更高阶,所以程序的尺寸比机器码会有优势。但另一方面来说 JavaScript 是文本指令,表达效率又要比机器码低。
但这一点也在发生变化,近年来 WebAssembly 技术开始蓬勃发展JavaScript 作为浏览器的机器码的地位会被逐步改变,我们前端开发会面临更多的可能性。
浏览器的地位非常特殊,我们可以看作操作系统之上的操作系统。一旦某种浏览器流行起来,开发人员都在浏览器上做应用,那么必然会导致底层操作系统管道化,这是操作系统厂商所不愿意看到的。
而如果浏览器用户量比较少,那么通过它能够触达的用户量就太少,消除不同底层操作系统差异的价值就不存在,开发人员也就不乐意在上面开发应用。
我们知道PC 的浏览器之战打到今天基本上就剩下Chrome、Internet Explorer、Safari、Firefox 等。
有趣的是,移动浏览器的战场似乎是从中国开始打起的,这就是微信引发的小程序之战,它本质上是一场浏览器的战争。
浏览器是一个基础软件,它能够解决多大的问题,依赖于它的市场占有率。但是基于同样的浏览器技术核心也可以构建出跨平台的应用框架。我们看到 React Native 就是沿着这个思路走的。当然这不是唯一的一条路,还有人会基于类似 QT 这样的传统跨平台方案。
整体来说,对于一个客户端应用程序来说,其完整的架构体系大体如下:
对于架构师来说,不仅仅只是想清楚业务应该怎么去做好分解,整个应用从底到最顶层的上层建筑,每一层都需要进行各种决策。先做 iOS 版本,还是先做小程序?是选择 Java 还是 Go 语言?这些都是架构的一部分。
结语
今天,我们从“计算机是如何工作”开始,一起登高鸟瞰,总览了程序完整的架构体系。
可能有人看到今天的内容心里会有些担心:“原来架构师要学这么多东西,看来我离成为架构师好远。”
好消息是:我们就是来打消这个担心的。如果我们把写代码的能力比作武功招式,那么架构能力就好比内功。内功修炼好了,武功招式的运用才能得心应手。
而架构能力的提升,本质上是对你的知识脉络(全身经络)的反复梳理与融会贯通的过程。 具备架构思维并不难,而且极有必要。不管今天的你是不是团队里的一位架构师,对任何一位程序员来说,具备架构思维将会成为让你脱颖而出的关键。
这就像你没有从事云计算行业,但是你仍然需要理解云计算的本质,需要驾驭云计算。你也不必去做出一个浏览器,但是你需要理解它们的思考方式,因为你在深度依赖于它们。
接下来我们将进一步展开来谈这个程序架构体系里面的每一个环节。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 大厦基石:无生有,有生万物
你好,我是七牛云许式伟。
在上一讲中,我们把“构建一个应用程序”类比成“构建一座大厦”,并从宏观全局的视角剖析了应用程序这座大厦的构成。今天,我们将更加近距离地去解剖这座大厦的地基:冯·诺依曼体系结构。
解剖架构的关键点是什么?
在解剖之前,我想和你先谈谈“解剖学”:我们应该如何去分析架构设计中涉及的每一个零部件。换一句话说,当我们设计或分析一个零部件时,我们会关心哪些问题。
第一个问题,是需求。 这个零部件的作用是什么?它能被用来做哪些事情?(某种意义上来说更重要的是)它不会被用来做哪些事情?
你可能会说,呀,这个问题很简单,既然我设计了这个零部件,自然知道它是用来干嘛的。但实质上这里真正艰难的是“为什么”:为何这个零件被设计成用来干这些事情的,而不是多干一点事情,或者为什么不是少干某些事情?
第二个问题,是规格。 这个零部件接口是什么样的?它如何与其他零件连接在一起的?
规格是零部件的连接需求的抽象。符合规格的零部件可以有非常多种可能的实现方案,但是,一旦规格中某个条件不能满足了,它就无法正常完成与其他零件的连接,以达到预期的需求目标。
规格的约束条件会非常多样化,可能是外观(比如形状和颜色),可能是交互方式(比如用键盘、鼠标,或者语音和触摸屏),也可能是质量(比如硬度、耐热性等等)。
那么,冯·诺依曼体系结构的需求和规格又是什么样的呢?
为“解决一切的问题”而生
冯·诺依曼体系结构不但是应用程序这座大厦的地基,同时也是整个信息科技的地基。
当我们去审视整个信息科技时,仅把它形容为一座大厦显得如此不贴切,甚至你也不能用“一个城市”去形容它,事实上,它更像是一个无中生有的全新世界:在其中,有个体、有族群、有生态,还有喜怒哀乐。
冯·诺依曼体系结构的迷人之处在于,从需求来说,它想解决一切问题。解决一切可以用“计算”来解决的问题。
“计算”的边界在哪里?今天我们还没有人能够真正说得清。计算能不能解决“智能”的问题?通过计算能力,计算机是否终有一天可以获得和人类一样的智能?
今天人工智能热潮的兴起,证明对于这个问题我们很乐观:计算终将解决智能的问题。尽管我们不能确定什么时候能够达到,但是让人欣慰的是,我们一直在进步 —— 如果人类智能无法完成进一步的进化,那么我们就一直一直在前进,最终无限逼近甚至超越人类智能。
甚至有科幻小说家设想例如在Google的“AlphaGo”大热后霍炬和西乔创作的漫画“BetaCat”计算机演进出超过人类的智能是生物进化的一个自然演进路径它将取代人类成为新的食物链顶端并最终基于其悠久的生命力去完成人类有限生命无法实现的星际航行之路。
冯·诺依曼体系的规格
为了实现“解决一切可以用‘计算’来解决的问题”这个目标,冯·诺依曼引入了三类基础零部件:
中央处理器;
存储;
输入输出设备。
首先我们来看看存储。它负责存放计算涉及的相关数据,作为计算的输入参数和输出结果。
我们日常见到的存储设备非常的多样化。比如中央处理器自己内置的寄存器、内存、传统机械硬盘、USB固态硬盘、光盘等等。
从中央处理器的角度存储可简单分为两类一类是内置支持的存储通过常规的处理器指令可直接访问比如寄存器、内存、计算机主板的ROM。一类是外置存储它们属于输入输出设备。中央处理器本身并不能直接读写其中的数据。
冯·诺依曼体系中涉及的“存储”,指的是中央处理器内置支持的存储。
我们再来看看输入输出设备。它是计算机开放性的体现,大大拓展了计算机的能力。每个设备通过一个端口与中央处理器连接。通过这个端口地址,中央处理器可以和设备进行数据交换。数据交换涉及的数据格式由设备定义,中央处理器并不理解。
但这并不影响设备的接入。设备数据交换的发起方(设备使用方)通常理解并可以解释所接收的数据含义。为了方便使用,设备厂商或操作系统厂商通常会提供设备相关的驱动程序,把设备数据交换的细节隐藏起来,设备的使用方只需要调用相关的接口函数就可以操作设备。
最后我们来看看中央处理器。它负责程序(指令序列)的执行。指令序列在哪里?也存放在存储里面。计算机加电启动后,中央处理器从一个固定的存储地址开始执行。
中央处理器支持的指令大体如下(我们在第一篇文章中也曾提到过):
计算类也就是支持我们大家都熟知的各类数学运算如加减乘除、sin/cos等等
I/O类从存储读写数据从输入输出设备读数据、写数据
指令跳转类,在满足特定条件下跳转到新的当前程序执行位置、调用自定义的函数。
和“解决一切可以用‘计算’来解决的问题”这个伟大的目标相比,冯·诺依曼体系的三类零部件的规格设计显得如此精简。
为什么这么简洁的规格设计,居然可以解决这么复杂的需求?
需求是怎么被满足的?
我们来设想一下:假如今天让我们从零开始设计一个叫电脑的东西,我们的目标是“解决一切可以用‘计算’来解决的问题”。
对于这么含糊的需求,如果你是“电脑”这个产品的主架构师,你会如何应对?
让我们来分析一下。
一方面,需求的变化点在于,要解决的问题是五花八门包罗万象的。如何以某种稳定但可扩展的架构来支持这样的变化?而另一方面,需求的稳定之处在于,电脑的核心能力是固定的,怎么表达电脑的核心能力?
电脑的核心能力是“计算”。什么是计算?计算就是对一个数据(输入)进行变换,变为另一个数据(输出)。在数学中我们把它叫“函数”。如下:
y = F(x)
这里 x、y 是数据。它们可能只是一个简单的数值,也可能是文本、图片、视频,各种我们对现实问题进行参数化建模后的测量值,当然也可能是多个输入数据。但无论它的逻辑含义为何,物理上都可以以一段连续的字节内容来表达。用 Go 的语法表达就是:
func F(x []byte) (y []byte)
那么 x、y 物理上在哪里?思路推理到这里,“存储” 这个概念自然就产生了:存储,就是存放计算所要操作的数据的所在。
下面的问题是:一个具体的计算(也就是 F 函数)怎么表达?
这里的难点在于F 对于电脑的架构师来说是未知的。那么,怎么设计一种系统架构让用户可以表达任意复杂的计算(函数)?
逻辑上来看,无论多复杂的自定义函数,都可以通过下面这些元素的组合来定义:
内置函数比如整数或小数运算加减乘除、sin/cos等
循环和条件分支;
子函数(也是自定义函数)。
这样一来,对于任意的一个具体的计算(自定义函数)来说,都可以用一组指令序列来表达。
那么函数 F 物理上在哪里?以指令序列形式存放在存储里面。所以,存储不只存放计算所要操作的数据,也存放“计算”本身。
只是存储里面存放的“计算”只是数据需要有人理解并执行这些数据背后的计算行为才变成真正意义的“计算”。这个执行者就是中央处理器CPU。它支持很多计算指令包括执行内置函数、循环和条件分支、执行子函数等。
所以,有了中央处理器+存储,就可以支持任意复杂的“计算”了。
只是如果电脑只有“中央处理器+存储”,那它就如同一个人只有头脑而没有四肢五官,尽管很可能很聪明,但是这种聪明无法展现出来,因为它没法和现实世界发生交互。
交互,抽象来看就是输入和输出。对人来说,输入靠的是五官:眼睛看、耳朵听、鼻子闻、舌头尝,以及肌肤接触产生的触觉。输出靠语言(说话)和各种动作,如微笑、眨眼、皱眉、手势等等。
对于电脑来说,输入输出的需求就更多了,不只是四肢五官,而可能是千肢万官。
从输入需求来说可能采集静态图像、声音、视频也可能采集结构化数据如GPS位置、脉搏、心电图、温度、湿度等还可能是用户控制指令如键盘按键、鼠标、触摸屏动作等。
从输出需求来说,可能是向屏幕输出信息;也可能是播放声音;还可能是执行某项动作,如交通灯开关、汽车马达转动、打印机打印等。
但不管是什么样交互用途的器官(设备),我们要做的只是定义好统一的数据交换协议。这个数据交换机制,和网络上两台电脑通过互联网,需要通过某种数据交换协议进行通讯,需求上没有实质性的差别。
也就是说除了纯正的“计算”能力外中央处理器还要有“数据交换”能力或者叫IO能力。最终电脑可以被看做由 “中央处理器+存储+一系列的输入输出设备” 构成。如下图:
尽管输入输出设备引入的最初灵感可能是来自于“交互”,但是当我们去审视输入输出设备到底是什么的时候,我们很自然发现,它能够做的不单单是交互。
比如常见的外置存储如机械硬盘、光盘等,它们也是输入输出设备,但并不是用于交互,而是显著提升了电脑处理的数据体量。
输入输出设备从根本上解决的问题是什么?
是电脑无限可能的扩展能力。
最重要的一点,输入输出设备和电脑是完全异构的。输入输出设备对电脑来说就只是实现了某项能力的黑盒子。
这个黑盒子内部如何没有规定。它可以只是一个原始的数字化的元器件也可以是另一台冯·诺依曼架构的电脑还可以是完全不同架构的电脑比如GPU电脑、量子计算机。
你可以发现,引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
架构思维上我们学习到什么?
架构的第一步是需求分析。从需求分析角度来说,关键要抓住需求的稳定点和变化点。需求的稳定点,往往是系统的核心价值点;而需求的变化点,则往往需要相应去做开放性设计。
对于“电脑”这个产品而言,需求的稳定点是电脑的“计算”能力。需求的变化点,一是用户“计算”需求的多样性,二是用户交互方式的多样性。
电脑的“计算”能力,最终体现为中央处理器的指令集,这是需求相对稳定的部分。
用户“计算”需求的多样性,最终是通过在存储中的指令序列实现。计算机加电启动后,中央处理器并不是按自己固有的“计算”过程进行,而是从一个固定的存储地址加载指令序列执行。
通常这个固定的存储地址指向计算机主板的ROM上的一段启动程序BIOS。这段启动程序通常包含以下这些内容。
存储设备的驱动程序用以识别常规的外置存储设备比如硬盘、光驱、U盘。
基础外部设备的驱动程序,比如键盘、鼠标、显示器(显卡)。
设备和启动配置的基础管理能力。
在外置存储上执行程序的能力(中央处理器只支持在内存上执行程序,当然它也为在外置存储执行程序提供了一些支持,比如内存页缺失的中断处理)。
将执行权转移到外置存储(第一次安装操作系统的时候可能是光驱甚至是网络存储,平常通常是硬盘)上的操作系统启动程序。这样,操作系统就开始干活了。
这样一来“计算”需求的多样性只需要通过调整计算机主板上的BIOS程序乃至外置存储中的操作系统启动程序就可以实现而不必去修改中央处理器本身。
用户交互方式的多样性,则通过定义外部设备与中央处理器的数据交换协议实现。
当我们把所有的变化点从电脑的最核心部件中央处理器剥离后,中央处理器的需求变得极其稳定,可独立作为产品进行其核心价值的演进。
结语
总结一下,今天,我们近距离地去解剖了整个信息世界地基:冯·诺依曼体系结构。
冯·诺依曼体系结构的不凡之处在于,它想“解决一切可以用‘计算’来解决的问题”。
为了实现这个目标,冯·诺依曼引入了三类基础零部件:中央处理器、存储、输入输出设备。所有计算机都可以看做由 “中央处理器+存储+一系列的输入输出设备” 构成。
为了方便理解,我在尝试用 Go 语言模拟来实现冯·诺依曼架构体系的电脑:
https://github.com/qiniu/arch/tree/master/von
如果你对此感兴趣,欢迎 fork 并对其进行修改迭代。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,122 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 汇编:编程语言的诞生
你好,我是七牛云许式伟。
在上一讲中,我们一起解剖了架构大厦的地基:冯·诺依曼体系。接下来,我们就开始沿着这座大厦攀登,一起来聊聊编程语言。
对于现代计算机来说,虽然 CPU 指令是一个很有限的指令集但是CPU 执行的指令序列(或者叫“程序”)并不是固定的,它依赖于保存在存储中的数据,由软件工程师(或者叫“程序员”)编写的软件决定。
从上一讲中我们可以知道计算机的程序可能被保存在计算机主板的ROM上这段程序也叫计算机的启动程序也可能被保存在外置的存储设备比如硬盘并在合适的时机加载执行。
程序称得上是计算机的灵魂。指令序列的可能性是无穷的,程序的可能性就是无穷的。今天计算机创造的世界如此多姿多彩,正是得益于程序无穷的可能性。
那么,软件工程师是怎么编写程序的?
编程的史前时代
在第一门面向程序员的编程语言出现前人们只能通过理解CPU指令的二进制表示将程序以二进制数据方式刻录到存储比如ROM或硬盘上。
这个时期的编程无疑是痛苦的,效率是极其低下的:且不说我们怎么去修改和迭代我们的程序,光将我们的想法表达出来就极其困难。
我们首先要把表达的执行指令翻译成二进制的比特数据,然后再把这些数据刻录到存储上。
这个时候软件和硬件的边界还非常模糊,并不存在所谓软件工程师(或者叫“程序员”)这样的职业。写程序也并不是一个纯软件的行为,把程序刻录到存储上往往还涉及了硬件的电气操作。
为了解决编程效率的问题汇编语言和解释它的编译器诞生了。汇编语言的编译器将汇编语言写的程序编译成为CPU指令序列并将其保存到外置的存储设备比如硬盘上。
汇编语言非常接近计算机的CPU 指令一条汇编指令基本上和CPU指令一一对应。
与机器对话
汇编语言的出现,让写程序(编程)成为一个纯软件行为(出现“程序员”这个分工的标志),人们可以反复修改程序,然后通过汇编编译器将其翻译成机器语言,并写入到外置的存储设备(比如硬盘)。并且,程序员可以按需执行该程序。
在表达能力上,汇编语言主要做了如下效率优化。
用文本符号symbol表达机器指令例如 add 表示加法运算,而不用记忆对应的 CPU 指令的二进制表示。
用文本符号symbol表达要操作的内存地址并支持内存地址的自动分配。比如我们在程序中使用了“Hello” 这样一段文本,那么汇编编译器将为程序开辟一段静态存储区(通常我们叫“数据段”)来存放这段文本,并用一个文本符号(也就是“变量名-variable”指向它。用变量名去表达一段内存数据这样我们就不用去关注内存的物理地址而把精力放在程序的逻辑表达上。
用文本符号symbol表达要调用的函数function也叫“过程-procedure”地址。对 CPU 指令来说,函数只有地址没有名字。但从编程的角度,函数是机器指令的扩展,和机器指令需要用文本符号来助记一样,函数的名称也需要用文本符号来助记。
用文本符号symbol表达要跳转的目标地址。高级语言里面流程控制的语法有很多比如 goto、if .. else、for、while、until 等等。但是从汇编角度来说只有两种基本的跳转指令无条件跳转jmp和条件跳转(je、jne)。同样,跳转的目标地址用文本符号(也就是“标签-label”有助于程序逻辑的表达而不是让人把精力放在具体的指令跳转地址上。
总结来说,汇编从指令能力上来说,和机器指令并无二致,它只不过把人们从物理硬件地址中解脱出来,以便专注于程序逻辑的表达。
但是这一步所解放的生产力是惊人的毕竟如果有选择的话没有人会愿意用0101这样的东西来表达自己的思想。
可自我迭代的计算机
从探究历史的角度,你可能会期望了解最真实的历史发展过程。比如:怎么产生了现代计算机(以键盘作为输入,显示器作为输出)?怎么产生了汇编语言?怎么产生了操作系统?
不过本专栏是以架构设计为目的我们目的并不是还原最真实的历史。架构的意义在于创造。我们甚至可以设想一个有趣的场景假设今天我们的信息科技的一切尚不存在那么从架构设计角度我们从工程上来说如何更高效地完成从0到1的信息科技的构建
最早的输入输出设备并不是键盘和显示器而是打孔卡和打印机。用打孔卡来作为机器指令的输入早在18世纪初就被用在织布机上了。早期的数字计算机就是用打孔卡来表达程序指令和输入的数据。
下图是 IBM 制造的打孔卡:
我们可以想象一下,第一台以键盘+显示器为标准输入输出的现代计算机出现后一个最小功能集的计算机主板的ROM上应该刻上什么样的启动程序换句话说这个现代计算机具备的最基本功能是什么
从高效的角度(不代表真实的历史,真实历史可能经历过很多曲折的发展过程),我想,它最好具备下面的这些能力。
键盘和显示器的驱动程序。
当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
可以执行一段保存在外置存储设备中的机器代码程序。
本质上,我们是要实现一个最小化的计算能力可自我迭代的计算机。
这个时期还没有操作系统当然把ROM上的启动程序BIOS看做一种最小化的操作系统我觉得也可以但毕竟不是现实中我们说的操作系统
汇编语言的出现要早于操作系统。操作系统的核心目标是软件治理,只有在计算机需要管理很多的任务时,才需要有操作系统。
所以在没有操作系统之前BIOS 包含的内容很可能是下面这样的:
外置存储设备的驱动程序;
基础外部设备的驱动程序,比如键盘、显示器;
汇编语言的编辑器、编译器;
把程序的源代码写入磁盘,从磁盘读入的能力。
最早期的计算机毫无疑问是单任务的,计算的职能也多于存储的职能。每次做完任务,计算机的状态重新归零(回到初始状态)都没有关系。
但是,有了上面这样一个 BIOS 程序后,计算机就开始发展起它存储的能力:程序的源代码可以进行迭代演进了。
这一步非常非常重要。计算机的存储能力的重要性如同人类发明了纸。纸让人类存储了知识,一代代传递下去并不断演进,不断发扬光大。
而同样有了存储能力的计算机,我们的软件程序就会不断被传承,不断演进发扬光大,并最终演进出今天越来越多姿多彩的信息科技的世界。
结语
今天我们一起回到了编程的史前时代,共同回溯了编程语言诞生的历史。
为了不再用“0101”表达自己的思想人们创造了汇编语言这一步让编程成为一个纯软件行为程序员这一个分工也由此诞生。
为了进一步支持程序员这个职业我们设计了MVP版最小化可行产品的可自我迭代的计算机。有了这个计算机我们就可以不断演进并最终演进出今天越来越多姿多彩的信息科技的世界。
架构上的思考题
在上一讲中,我们谈架构思维时提到,我们在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
今天,我们假设要实现一个最小化的计算能力可自我迭代的计算机,需求如上所述。
那么,它的变化点和稳定点分别是什么?为此,你会怎么设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
欢迎把你的想法告诉我,我们一起讨论。感谢你的收听,再见。

View File

@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 编程语言的进化
你好,我是七牛云许式伟。今天我们继续来聊聊编程语言。
编程语言的出现,诞生了软件工程师(也叫程序员)这样一个职业,而汇编语言则可以看做是软件工程师这个分工出现的标志。
通过编程语言,软件工程师和计算机可以进行交互,表达自己的思想。但是,如果我们把掌握编程语言看做是软件工程师的专业技能,其实又低估了编程语言带来的革命性变化。
编程语言在信息科技发展中的位置,如同人类文明中语言所在的位置。而编程语言写出来的软件(及其源代码),如同人类文明中不断被传承下来的图书典籍。
软件是活的书籍
我个人一直有一个观点:软件是活的书籍,是我们人类知识传承能力的一次伟大进化。书籍能够通过文字来记载事件、传递情感、揭示规律、传承技术。书籍能够让人们进行远程的沟通(飞鸽传书),也能够让我们了解古人的生活习性,与古人沟通(虽然是单向的)。
这些事情软件都可以做到,而且做得更好。为什么我说软件是活的书籍,有两方面的原因。
其一,表达方式的多样性。 书籍只能通过文字描述来进行表达,这种表达方式依赖于人们对文字的理解,以及人的想象能力对场景进行还原。软件除了能够通过文字,还能够通过超链接、声音、动画、视频、实时的交互反馈等方式来还原场景。
其二,对技术的现场还原。 书籍只能通过文字来描述技术,但是因为人与人对同样的文字理解不同,领悟能力不同,这些都可能导致技术的传承会出现偏差,如果文字的记载不够详尽,可能就会出现“谁也看不懂,学不会”的情况,从而导致技术的失传。
但是,软件对技术的还原可以是精确的,甚至软件本身可以是技术的一部分。当软件是技术的一部分的时候,技术传承就是精确的,失传的概率就大大降低(除非技术本身适应不了潮流,退出了历史舞台)。
信息科技发展到今天,已经影响人类活动的方方面面。无论你从事什么职业,不管你是否会从事软件开发的工作,你都无法和信息科技脱节。如果希望能够站在职业发展的至高点,你就需要理解和计算机沟通的语言,也就需要理解软件工程师们的语言。
不仅如此,如果你把编程语言升华为人类知识传承能力的进化,你就更能够清晰地预判到这样的未来:每一个小孩的基础教育中一定会有编程教育,就如同每一个小孩都需要学习物理和数学一样。
编程范式的进化
编程语言从汇编开始,到今天还只有六十多年的历史,但是迭代之迅速,远超自然语言的迭代速度。从思想表达的角度来说,我们通常会听到以下这些编程范式。
其一是过程式。过程式就是以一条条命令的方式,让计算机按我们的意愿来执行。 今天计算机的机器语言本身就是一条条指令构成,本身也是过程式的。所以过程式最为常见,每个语言都有一定过程式的影子。过程式语言的代表是 Fortran、C/C++、JavaScript、Go 等等。
过程式编程中最核心的两个概念是结构体(自定义的类型)和过程(也叫函数)。通过结构体对数据进行组合,可以构建出任意复杂的自定义数据结构。通过过程可以抽象出任意复杂的自定义指令,复用以前的成果,简化意图的表达。
其二是函数式。函数式本质上是过程式编程的一种约束,它最核心的主张就是变量不可变,函数尽可能没有副作用(对于通用语言来说,所有函数都没副作用是不可能的,内部有 IO 行为的函数就有副作用)。
既然变量不可变,函数没有副作用,自然人们犯错的机会也就更少,代码质量就会更高。函数式语言的代表是 Haskell、Erlang 等等。大部分语言会比较难以彻底实施函数式的编程思想,但在思想上会有所借鉴。
函数式编程相对小众。因为这样写代码质量虽然高,但是学习门槛也高。举一个最简单的例子:在过程式编程中,数组是一个最常规的数据结构,但是在函数式中因为变量不可变,对某个下标的数组元素的修改,就需要复制整个数组(因为数组作为一个变量它不可变),非常低效。
所以,函数式编程里面,需要通过一种复杂的平衡二叉树来实现一个使用界面(接口)上和过程式语言数组一致的“数组”。这个简单的例子表明,如果你想用函数式编程,你需要重修数据结构这门课程,大学里面学的数据结构是不顶用了。
其三是面向对象。面向对象在过程式的基础上,引入了对象(类)和对象方法(类成员函数),它主张尽可能把方法(其实就是过程)归纳到合适的对象(类)上,不主张全局函数(过程)。面向对象语言的代表是 Java、C#、C++、Go 等等。
从“面向对象”到“面向连接”
面向对象的核心思想是引入契约,基于对象这样一个概念对代码的使用界面进行抽象和封装。
它有两个显著的优点。
其一是清晰的使用界面,某种类型的对象有哪些方法一目了然,而不像过程式编程,数据结构和过程的关系是非常松散的。
其二是信息的封装。 面向对象不主张绕过对象的使用接口侵入到对象的内部实现细节。因为这样做破坏了信息的封装,降低了类的可复用性,有一天对象的内部实现方式改变了,依赖该对象的相关代码也需要跟着调整。
面向对象还有一个至关重要的概念是接口。通过接口,我们可以优雅地实现过程式编程中很费劲才能做到的一个能力:多态。
由于对象和对象方法的强关联,我们可以引入接口来抽象不同对象相同的行为(比如鸟和猪是不同的对象,但是它们有相同的方法,比如移动和吃东西)。这样不同对象就可以用相同的代码来实现类似的复杂行为,这就是多态了。
多数面向对象语言往往还会引入一个叫继承的概念。大家对这个概念褒贬不一。虽然继承带来了编码上的便捷性,但也带来了不必要的心智负担:本来复合对象的唯一构造方法是组合,现在多了一个选择,继承。
究竟什么时候应该用继承,什么时候应该用组合?这着实会让人纠结。不过,这件事情最完美的答案是 Go 语言给出来的:放弃继承,全面强化组合能力(要了解 Go 语言强大的组合能力,参阅我的演讲)。
不同编程范式并不是互斥的。虽然有些编程语言会有明确的编程范式主张,比如 Java 是纯正的面向对象语言,它反对全局过程。但是,也有一些语言明确主张说自己是多范式的,典型代表是 C++。
当然,可能 C++ 不是一个好例子,因为它太复杂了,让人觉得多范式会大大增加语言的复杂性,虽然其实 C++ 的复杂性和多范式并没有什么关系。
可能 Go 语言是多范式更好的例子。它没有声称自己是多范式的,但是实际上每一种编程范式它都保留了精华部分。这并没有使得 Go 语言变得很复杂,整个语言的特性极其精简。
Go 语言之所以没有像 C++ 那样声称是多范式的,是因为 Go 官方认为 Go 是一门面向连接的语言。
什么是面向连接的语言?在此之前,你可能并没有听过这样的编程范式,这应该算 Go 自己发明出来的范式名称。在我看来,所谓面向连接就是朴素的组合思想。研究连接,就是研究人与人如何组合,研究代码与代码之间怎么组合。
面向对象创造性地把契约的重要性提高到了非常重要的高度,但这还远远不够。这是因为,并不是只有对象需要契约,语言设计的方方面面都需要契约。
比如,代码规范约束了人的行为,是人与人的连接契约。如果面对同一种语言,大家写代码的方式很不一样,语言就可能存在很多种方言,这对达成共识十分不利。所以 Go 语言直接从语言设计上就消灭掉那些最容易发生口水的地方,让大家专注于意图的表达。
再比如,消息传递约束了进程(这里的进程是抽象意义上的,在 Go 语言中叫 goroutine的行为是进程与进程的连接契约。** 消息传递是多核背景下流行起来的一种编程思想,其核心主张是:尽可能用消息传递来取代共享内存,从而尽可能避免显式的锁,降低编程负担。**
Go 语言不只是提供了语言内建的消息传递机制channel同时它的消息传递是类型安全的。这种类型安全的消息传递契约机制大大降低了犯错的机会。
其他方面的进化
除了编程范式,编程语言的进化还体现在工程化能力的完善上。工程化能力主要体现在如下这些方面。
package即代码的发布单元。
版本version即包的依赖管理。
文档生成doc
单元测试test
从语言的执行器的行为看,出现了这样三种分类的语言。
编译的目标文件为可执行程序。典型代表是 Fortran、C/C++、Go 等。
生成跨平台的虚拟机字节码,有独立的执行器(虚拟机)执行字节码 。典型代表为 Java、Erlang 等。
直接解释执行。典型代表是 JavaScript。当然现在纯解释执行的语言已经不多。大多数语言也只是看起来直接执行内部还是会有基于字节码的虚拟机以提升性能。
语言对架构的影响是什么?
我们思考一个问题:从架构设计角度来看,编程语言的选择对架构的影响是什么?
我们在第一篇“架构设计的宏观视角”中,介绍了服务端程序和客户端程序的整体架构图。细心的读者可能留意到了,在架构图中我用了三种不同的颜色来表示不同层次的依赖。
无论服务端,还是客户端,我们可以统一将其架构图简化为下图所示。
图中淡紫色是硬件层次的依赖,是我们程序工作的物理基础。淡绿色的是软件层次的依赖,是我们程序工作的生态环境。桔色的是库或源代码层次的依赖,是我们程序本身的组成部分。细分的话它又可以分两部分:一部分是业务无关的框架和基础库,还有一部分是业务架构。
从软件的业务架构来说,本身应该怎么拆分模块,每个模块具体做什么样的事情(业务边界是什么),这是业务需求本身决定的,和编程语言并没有关系。但在我们描述每个模块的规格时,采用的规格描述语言会面临如下两种选择:
选择某种语言无关的接口表示;
选择团队开发时采用的语言来描述接口。
两种选择的差异并不是实质性的。只要团队内有共识,选哪一种都无所谓。本着“如无必要勿增实体”的原则,我个人会倾向于后者,用开发语言来做接口表示。在七牛云的话自然就是选 Go 了。
站在唯技术论的角度,业务架构与语言无关,影响的只是模块规格的描述语法。但语言的选择在实践中对业务架构决策的影响仍然极其关键。
原因之一是开发效率。 抛开语言本身的开发效率差异不谈,不同语言会有不同的社区资源。语言长期以来的演进,社区所沉淀下来的框架和基础库,还有你所在的企业长期发展形成的框架和基础库,都会导致巨大的开发效率上的差异。
原因之二是后期维护。 语言的历史通常都很悠久,很难实质性地消亡。但是语言的确有它的生命周期,语言也会走向衰落。选择公司现在更熟悉的语言,还是选择一个面向未来更优的语言,对架构师来说也是一个两难选择。
结语
今天我们抛开具体的语言发展史,而从编程范式演进的角度来谈编程语言的进化。过程式、函数式、面向对象,这些都是大家熟悉的编程范式;所以我们把重点放在了介绍由 Go 语言带来的面向连接的编程思想,并将其与面向对象做了详细的对比。
未来编程语言还将出现什么样的新思想,我们不得而知。但可以预见,出现新的创造性思维的挑战将越来越大。历史的发展是曲折螺旋上升的。
要想有所突破,需要建立批判性思维。一种新思潮的兴起过程很容易用力过猛。面向对象是一个很好的例子。面向对象是非常重要的进步,但是继承是一个过度设计,不只让软件工程师在组合与继承中纠结,也产生了复杂的对象继承树。我们只有在实践中不断总结与反思,才有可能产生新的突破。
你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,184 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 思考题解读:如何实现可自我迭代的计算机?
你好,我是七牛云许式伟。
在“03 | 汇编:编程语言的诞生”中,我给出了一个架构思考题:
第一台以键盘+显示器为标准输入输出的现代计算机出现后,一个最小功能集,但计算能力可自我迭代的计算机应该是什么样的?
从需求上来说,我们期望它有如下能力。
键盘和显示器的驱动程序。
当时最主流的外置存储设备(不一定是现代的硬盘)的驱动程序。
一个汇编程序编辑器。可从存储中读取汇编程序代码,修改并保存到存储中。
一个汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到存储中。
支持执行一段保存在外置存储设备中的机器代码程序。
那么,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
需求分析
我们前面谈架构思维时提到:做架构,第一件事情要学会做需求分析。
需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。我个人认为,架构师在整个架构的过程中,至少应该花费三分之一的精力在需求分析上。
这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定,原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。
所以,一个优秀的架构师除了需要“在心里对需求反复推敲”的严谨态度外,对客户反馈的尊重之心也至关重要。只有心里装着客户,才能理解好需求,做好架构。
前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
那么今天我们来实战一番,要实现一个最小化的计算能力可自我迭代的计算机,我们怎么做需求分析。
怎么实现可自我迭代的计算机?
通过前面对计算机工作原理的分析,我们已经知道,计算机分为三大类的零部件:
中央处理器;
存储;
输入输出设备。
中央处理器作为“计算”能力的核心,我们已经对它的工作范畴解剖清晰,这里不提。
存储,一方面作为“计算”的输入输出,另一方面作为“计算”本身的承载(也就是程序),主要的变数在后者。存储上的程序主要是:
计算机主板ROM上的启动程序BIOS
外置存储上的软件。
接下来我们要考虑清楚的是BIOS 负责做什么,外置存储上的软件负责做什么。这里我们先不展开。
输入输出设备,除了键盘和显示器外,还有外置存储。键盘和显示器我们只需要准备好对应的驱动程序,并没有特别需要考虑的内容。主要的变数在外置存储上。
外置存储在我们为它准备好了驱动程序后,就可以对它进行数据的读写了,但是我们接着需要考虑的问题是:我们准备把外置存储的数据格式设计成什么样?
回答这个问题前,先回顾下我们要做什么。目前我们已知的功能需求有如下这些。
键盘和显示器的驱动程序。
外置存储设备的驱动程序。
汇编程序编辑器。可从外置存储中读取汇编程序代码,修改并保存到外置存储中。
汇编编译器。可将汇编程序代码编译成机器代码程序,并保存到外置存储中。
支持执行一段保存在外置存储设备中的机器代码程序。
我们可以看到,外置存储需要保存的内容有:
汇编程序的源代码;
汇编编译器编译出来的可执行程序。
可见,外置存储它不应该只能保存一个文件,而是应该是多个。既然是多个,就需要组织这些文件。那么,怎么组织呢?
今天我们当然知道操作系统的设计者们设计了文件系统这样的东西来组织这些文件。虽然文件系统的种类有很多比如FAT32、NTFS、EXT3、EXT4 等等),但是它们有统一的抽象:文件系统是一颗树;节点要么是目录,要么是文件;文件必然是叶节点;根节点是目录,目录可以有子节点。
但是文件系统File System是否是唯一的可能性当然不是。键值存储Key-Value 存储)也挺好,尤其是早期外置存储容量很可能极其有限的情况下。可以做这样统一的抽象:
每个文件都有一个名字Key通过名字Key可以唯一定位该文件以进行文件内容的读写
为了方便管理文件可以对文件名做模糊查询List查询List操作支持通配符比如我们现在习惯用的*和?
未来外置存储的空间有可能很大需要考虑文件管理的延展性问题可以考虑允许每个文件设定额外的元数据Meta例如创建时间、编辑时间、最后访问时间、以及其他用户自定义的元数据。通过元数据我们也可以检索Search到我们感兴趣的文件。
聊完了外置存储,让我们再回来看看 BIOS 和外置存储的软件怎么分工。
首先BIOS 和外置存储上的软件分工的标准是什么BIOS 是刻在计算机主板ROM上的启动程序它的变更非常麻烦。所以 BIOS 负责的事情最好越少越好,只做最稳定不变的事情。
我们一一来看当前已知的需求。
首先是外部设备的驱动程序:键盘和显示器的驱动程序、外置存储设备的驱动程序。一方面,只要键盘、显示器、外置存储没有大的演进,驱动程序就不变,所以这块是稳定的;另一方面,它们是 BIOS 干其他业务的基础。所以,这个事情 BIOS 必然会做。
其次是汇编程序编辑器。 编辑器的需求是模糊的,虽然我们知道它支持用户来编写程序,但是整个编辑器的操作范式是什么样的,没有规定。所以它不像是给键盘写一个驱动程序那样,是一个确定性的需求,而有很多额外的交互细节,需要去进一步明确。
你可以留意下自己日常使用的编辑器,去试着列一下它们的功能列表。你会发现小小的编辑器,功能远比你接触的大部分常规软件要多得多。
再次是汇编编译器。 汇编编译器从输入输出来看,似乎需求相对确定。输入的是汇编源代码,输出的是可执行程序。但认真分析你会发现,它实际上也有很大的不确定性。
其一CPU 会增加指令这时候汇编指令也会相应地增加。对于大部分应用程序CPU 新增的指令如果自己用不到,可以当它不存在。但是汇编语言及编译器需要完整呈现 CPU 的能力,因此需要及时跟进。
其二,虽然汇编指令基本上和机器指令一一对应,但是它毕竟是面向程序员的生产力工具,所以汇编语言还是会演进出一些高阶的语法,比如宏汇编指令。
所谓宏汇编指令就是用一个命令去取代一小段汇编指令序列它和C语言里面的宏非常类似。所以汇编语言并不是稳定的东西它和其他高级语言类似也会迭代变化。这就意味着汇编编译器也需要相应地迭代变化。
最后,执行一段保存在外置存储设备中的机器代码程序。 这个需求看似比较明确,但是实际上需求也需要进一步细化。它究竟是基于外置存储的物理地址来执行程序,还是基于文件系统中的文件(文件内容逻辑上连续,但是物理上很可能不连续)来执行程序?
实现上,这两者有很大的不同。前者只需要依赖外置存储的驱动程序就可以完成,后者则还需要额外理解文件系统的格式才能做到。
那么BIOS 到底怎么把执行控制权交到外置存储呢?
在学冯·诺依曼结构的时候我们提到过CPU 加电启动时,它会从存储的一个固定地址开始执行指令,这个固定地址指向的正是 BIOS 程序。
类似的,我们的 BIOS 也可以认定一个外置存储的固定地址来加载程序并执行,而无需关心磁盘的数据格式是什么样的。这个固定地址所在的数据区域,我们可以把它叫做引导区。
引导区的存在非常重要,它实际上是 BIOS 与操作系统的边界。
对于 BIOS 来说执行外置存储上的程序能力肯定是需要具备的否则它没有办法把执行权交给外置存储。但是这个能力可以是非常简约的。BIOS 只需要执行引导区的程序,这个程序并不长,完全可以直接读入到内存中,然后再执行。
我们是否需要基于文件系统中的文件来执行程序的能力?答案是需要。因为汇编编译器编译后的程序在外置存储中,需要有人能够去执行它。
综上,我们确认 BIOS 需要负责的事情是:
键盘和显示器的驱动程序;
外置存储设备的驱动程序;
支持执行外置存储中引导区的机器代码程序;
跳转到外置存储的固定地址,把执行权交给该地址上的引导程序。
而汇编程序编辑器、汇编编译器 ,以及支持执行文件系统中的程序,则不应该由 BIOS 来负责。
那么,外置存储上的引导程序拿到执行权后干什么呢?
我们再来总结下当前我们遇到的需求。
需要有人负责支持外置存储的数据格式提供统一的功能给其他程序使用。无论它是文件系统还是Key-Value存储系统。
需要有人提供管理外置存储的基础能力比如查询List一下外置存储里面都有些什么文件。它可以实现为一个独立的程序比如我们命名为 ls。
需要有人执行外置存储上的可执行程序。它可以实现为一个独立的程序,比如我们命名为 sh。
汇编程序编辑器。其实这个程序和汇编语言没什么关系,就是一个纯正的文本编辑器。我们可以把这个程序命名为 vi。
汇编编译器。它可以实现为一个独立的程序,比如我们命名为 asm。
引导程序拿到执行权后,我们不管它额外做了哪些事情,最终它要把执行权交给 sh 程序。因为sh 程序算得上是可自我迭代的计算机扩展性的体现:通过 sh 程序来执行外置存储上的任意程序,这也相当于在扩展 CPU 的指令集。
结语
我们来回顾一下今天的内容。一个最小功能集、计算能力可自我迭代的计算机,它的变化点和稳定点分别是什么?为此,你会怎么设计,设计出哪些子系统,每个子系统的规格是什么?扩展性上有哪些考虑?
需求的变化点在于下面这几点。
外置存储的数据格式。对此我们设计文件系统或Key-Value存储子系统来负责这件事情。另外我们也提供了 ls 程序来管理外置存储中的文件。
用户最终拿到这个计算机后,会迭代出什么能力。对此,我们设计了 sh 程序,让它支持在外置存储上执行任何应用程序。
编辑器的交互范式。对此,我们设计了 vi 程序,让它迭代编辑器的能力。
汇编语言的使用范式。对此,我们设计了 asm 程序,让它响应 CPU 指令集的迭代,以及汇编语言进化的迭代。
最终,我们设计出来的“可自我迭代的计算机”,它的系统架构看起来是这样的:
你的需求分析和系统设计跟上面的架构一致吗?
不一致非常正常,架构并无标准答案。但通过对比别人的方案与自己的不同之处,可以加深你对架构设计在决策上的体会。
另外,在 “可自我迭代的计算机” 这样相对模糊需求的细化过程中,也会很自然出现不太一样的理解,这些都是正常的,这也是需求分析的重要性所在,它本身就是一个需求从模糊到细化并最终清晰定义的过程。
如果你觉得系统过于复杂,不知道如何下手,也不要紧,设计“一个可自我迭代的计算机” 的确是一个复杂的系统,它并不是一个非常适合架构新手的任务。但是我仍然希望通过这样一个例子的剖析,你对需求分析中稳定点和变化点的判断有所感悟。
如果你有什么样的想法和疑问,欢迎你给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 操作系统进场
你好,我是七牛云许式伟。
在编程语言出现后,软件生产效率得到了大幅度的提升。随着越来越多软件的出现,自然而然就诞生了多个软件如何共处,也就是软件治理的需求。比如下面的这些需求场景。
多个软件如何同时运行(多任务的需求)?
多个软件如何共同使用计算机上的存储空间(内存管理、文件系统的需求)?
多个软件如何共同使用同一个外部设备(设备管理的需求)?
多个软件如何相互通讯,如何进行数据交换(进程间通讯、共享内存的需求)?
病毒、恶意软件如何治理(安全管理的需求)?
如果没有一个中间的协调方,软件与软件之间并不知道彼此的存在,你不难想象出,这种没有统一规则约束下的场面,会有多么凌乱。
于是,操作系统就出现了。对于软件而言,它像一个大法官,制定规则并据此约束大家的行为。
操作系统的启动过程
操作系统是怎么获得执行权的?
这是计算机主板ROM上的启动程序BIOS交给它的。
计算机加电启动后中央处理器CPU会从一个固定的存储地址加载指令序列执行。通常这个固定的存储地址指向计算机主板的ROM上的一段启动程序BIOS。这段启动程序通常包含以下这些内容。
存储设备的驱动程序用以识别常规的外置存储设备比如硬盘、光驱、U盘。
基础外部设备的驱动程序,比如键盘、鼠标、显示器。
设备和启动配置的基础管理能力。
支持执行外置存储中引导区的机器代码程序。
跳转到外置存储引导区的固定地址,把执行权交给该地址上的引导程序。
引导区的引导程序有长度限制(关于这一点我在上一讲已经介绍过),只能做非常少的事情。在常规情况下,它只是简单地跳转到真正的操作系统的启动程序,但有时计算机上安装了多个操作系统,此时引导程序会提供菜单让你选择要运行的操作系统。
这样,操作系统就开始干活了。
操作系统的需求演进
那么,操作系统是做什么的?前面我们说的“软件治理”是否可以涵盖它完整的目标?
让我们从操作系统的发展历程说起。
最早期的计算机是大型机。这个时期的计算机笨重、昂贵,并且操作困难,主要使用人群是搞科研性质的科学家或其他高端人群。
虽然这个时期催生了 IBM 这样的硬件巨头,但大多数人根本就意识不到,这玩意儿对后世人们的生活能够产生如此翻天覆地的变化。
这个时期的计算机还是单任务的,以计算为主,软件为操作硬件服务。如果我们认为“软件治理”是操作系统的根源需求的话,那么可以认为这个时期还不存在操作系统。但的确会有一些辅助工具库来简化用户使用计算机的负担,我们可以把它看做操作系统的萌芽。
从这个意义来说,提供计算机的“基础编程接口”,降低软件开发的负担,是操作系统更为原始的需求。
此后小型机和个人计算机PC的崛起分别诞生了 UNIX 和 DOS 这两个影响深远的操作系统。 UNIX 就不用说了,它几乎算得上今天所有现代操作系统的鼻祖。
DOS 的历史非常有趣。首先是 IBM 没把操作系统当回事儿,把这个活儿包给了微软。然后是微软只花了 5 万美元向西雅图公司购买了 86-DOS 操作系统的版权,更名为 MS-DOS。
那么 86-DOS 是怎么来的西雅图公司的一个24岁小伙叫蒂姆·帕特森Tim Paterson单枪匹马花了4个月时间写出来的。
可以看到这个时期人们对操作系统并没有太深刻的认知多数人只把它看做硬件的附属品。IBM 不把它当回事,西雅图公司也没把它当回事,几万就把它卖了。只有微软认认真真地把它当做生意做了起来(在此之前微软的生意是卖 BASIC 语言的解析器起家,所以微软一直对 BASIC 语言情有独钟,直到很久以后微软搞出了 C# 语言后,情况才有所改变)。
等到 IBM 意识到操作系统是个金蛋,改由自己做 PC-DOS 操作系统的时候,微软已经通过推动 PC 兼容机的发展,让操作系统不再依赖特定的硬件设备,微软也就因此脱离 IBM 的臂膀,自己一飞冲天了。
回到问题。要回答操作系统在做什么,我们可以从客户价值和商业价值两个维度来看。
客户价值来说,操作系统首先要解决的是软件治理的问题,大体可分为以下六个子系统:进程管理、存储管理、输入设备管理、输出设备管理、网络管理、安全管理等。
操作系统其次解决的是基础编程接口问题。 这些编程接口一方面简化了软件开发,另一方面提供了多软件共同运行的环境,实现了软件治理。
商业价值来说,操作系统是基础的刚需软件。计算机离开了操作系统就是一堆废铜烂铁。随着个人计算机采购需求的急速增加,光靠软件 License 的费用就让操作系统厂商赚翻了。
虽然第一个广为人知的操作系统是 UNIX但从商业上来说最成功的操作系统则是 DOS/Windows成就了微软的霸主地位。
为什么是 DOS/Windows 赢得了市场这无关技术优劣关键在于两者的商业路线差异UNIX 走的是企业市场,而 DOS/Windows 选择了更为巨大的市场个人计算机PC市场。
操作系统也是核心的流量入口。占领了操作系统,就占有了用户,想推什么内容给用户都很容易。微软对这一点显然心知肚明。
这也是为什么当年网景推 Netscape 浏览器的时候,微软很紧张。因为浏览器是另一个软件治理的入口,本质上是操作系统之上的操作系统。如果软件都运行在浏览器上,那么本地操作系统就沦为和硬件一般无二的管道了。
虽然早期操作系统没有应用市场AppStore但是通过操作系统预装软件的方式向软件厂商收租这是一直以来都有的盈利方式。国内盗版的番茄花园 Windows 发行版就是通过在 Windows 系统上预装软件来盈利。
当然预装软件只是一种可能性,流量变现的方式还有很多。苹果的 iOS 操作系统开启了新的玩法它构建了新的商业闭环账号Account、支付Pay、应用市场AppStore
我们把这个商业模式叫收税模式。帐号(注意是互联网账号,不是过去用于权限管理的本地账号)是前提。没有帐号,就没有支付系统,也没有办法判断用户是否购买过某个软件。
应用市场实现了应用的分发,既解决了系统能力的无限扩展问题(客户价值),也解决了预装软件的软件个数总归有限的问题(商业价值)。支付则是收税模式的承载体,无论是下载应用收费,还是应用内购买内容收费,都可以通过这个关卡去收税。
无论是本地操作系统 iOS 和 Android还是 Web 操作系统(浏览器)如微信小程序,都实现了“帐号-支付-应用市场”这样的商业闭环。这类操作系统,我们不妨把它叫做现代操作系统。
操作系统的边界在哪里?
架构的第一步是需求分析。上一讲我提到了在架构设计过程中,需求分析至少应该花费三分之一的精力。通过这一节我们对操作系统演进过程的回顾,你可能更容易体会到这一点。
当我们说要做一个操作系统的时候,实际上我们自己对这句话的理解也是非常模糊的。尤其是我们正准备去做的事情是一个新生事物时,我们对其理解往往更加粗浅。
在本专栏[开篇词]中我也提过,架构也关乎用户需求,作为架构师我们不只是要知道当前的用户需求是什么,我们还要预测需求未来可能的变化,预判什么会发生,而什么一定不会发生。
我们可以问一下自己我是否能够预料到有一天支付Pay系统会成为操作系统的核心子系统如果不能那么怎么才能做到
操作系统的边界到底在哪里?
要回答这个问题,我们需要看清楚这样三个角色的关系:
硬件(个人计算机、手机或其他);
操作系统;
浏览器。
首先我们来看操作系统与硬件的关系。如果操作系统厂商不做硬件会怎样我们知道个人计算机PC市场就是如此。微软虽然占据了 PC 操作系统DOS/Windows绝大部分江山但是它自身并不生产硬件。这里面PC 兼容机的发展对 DOS/Windows 的发展有着至关重要的支撑意义。它让操作系统厂商有了独立的生存空间。
到了移动时代Google 收购 Android 后,通过免费策略占领移动操作系统的大半江山,一定程度上复制了微软的过程,但实际上并没有那么理想。
首先Android 是免费的Google 并没有从中收取软件 License 费用,而是借助 Android 的市场占有率来推动 Google 的服务例如搜索、Gmail 等等),通过 Google 服务来获取商业回报。
其次iOS 操作系统引入的 “账号-支付-应用市场” 的收税模式受益方是硬件手机厂商而非操作系统厂商。其中最关键的一点几乎所有手机厂商都不接受把支付Pay这个核心系统交给 Google。
最后,不止支付系统,一旦手机厂商长大立足 Google 服务也会被逐步替换。所以 Google 和 Android 手机厂商之间的联盟并不可靠,养肥的手机厂商会不断试探 Google 的底线,而 Google 也会尝试去收紧政策,双方在博弈中达到平衡。
之所以会这样,我觉得原因有这么几个:
其一历史是不可复制的人们对操作系统的重要性认知已经非常充分。所以大部分手机厂商都不会放弃操作系统的核心子系统的主控权。Android 系统的开源策略无法完全达到预期的目标,这也是 Google 最终还是免不了要自己做手机的原因。
其二,手机是个性化产品,硬件上并没有 PC 那么标准化。所以个人计算机有兼容机,而手机并没有所谓的标准化硬件。
分析完操作系统和硬件的关系,我们再来看它和浏览器的关系。在 PC 时期,操作系统和浏览器看起来至少需求上是有差异化的:操作系统,是以管理本地软件和内容为主(对内)。浏览器,是以管理互联网内容为主(对外)。
但,这个边界必然会越来越模糊。
操作系统不涉足互联网内容这是不可能的。应用市场AppStore其实就是典型的互联网内容而另一方面在浏览器的生态里也有一些特殊角色网址导航、搜索引擎、Web 应用市场,它们共同构成了探索互联网世界的“地图”。
问题在于:
操作系统、浏览器和(互联网)搜索引擎的关系是什么;
移动时代的浏览器会是什么样的;它和操作系统的关系又如何相处?
欢迎把你对这几个问题的想法告诉我。
结语
让我们简单回顾下今天我所讲到的内容。
从客户需求来说,操作系统的核心价值在于:
实现软件治理,让多个软件和谐共处;
提供基础的编程接口,降低软件开发难度。
从商业价值来说,操作系统是刚性需求,核心的流量入口,兵家必争之地。所以,围绕它的核心能力,操作系统必然会不断演化出新的形态。
我们把引入了 “账号-支付-应用市场” 商业闭环的收税模式的操作系统,称为现代操作系统。
操作系统的边界到底在什么地方?我们通过分析硬件、操作系统、浏览器三者的关系,也做了定性的分析。这样的分析将有助于你对需求发展做出预判。
最后,你可以在留言区给我留言,分享你对于操作系统技术、商业的看法,让我们一起交流。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 软件运行机制及内存管理
你好,我是七牛云许式伟。
操作系统的核心职能是软件治理,而软件治理的一个很重要的部分,就是让多个软件可以共同合理使用计算机的资源,不至于出现争抢的局面。
内存作为计算机最基础的硬件资源有着非常特殊的位置。我们知道CPU 可以直接访问的存储资源非常少只有寄存器、内存RAM、主板上的 ROM。
寄存器的访问速度非常非常快,但是数量很少,大部分程序员不直接打交道,而是由编程语言的编译器根据需要自动选择寄存器来优化程序的运行性能。
主板上的 ROM 是非易失的只读的存储。所谓非易失是计算机重新启动后它里面的数据仍然会存在。这不像内存RAM计算机重新启动后它上面的数据就丢失了。ROM 非易失和只读的特点决定了它非常适合存储计算机的启动程序BIOS
所以你可以看到,内存的地位非常特殊,它是唯一的 CPU 内置支持,且和程序员直接会打交道的基础资源。
内存有什么用?前面我们在 “[02 | 大厦基石:无生有,有生万物]” 一讲中介绍冯·诺依曼结构的时候,画过一个图:
从图中可以看出,存储的作用有两个:一个是作为 “计算” 的操作对象,输入和输出数据存放的所在;另一个是存放 “计算” 本身,也就是程序员写的程序。
这里说的存储,主要指的就是内存。
计算机运行全过程
当然,这是从 CPU 角度看到的视图:对于 CPU 来说,“计算” 过程从计算机加电启动,执行 BIOS 程序的第一条指令开始,到最后计算机关机,整个就是一个完整的 “计算” 过程。这个过程有多少个“子的 ‘计算’过程”,它并不关心。
但是从操作系统的视角来看,计算机从开机到关机,整个 “计算” 过程,由很多软件,也就是子 “计算” 过程,共同完成。从时序来说,计算机完整的 “计算” 过程如下:
整个 “计算” 过程的每个子过程都有其明确的考量。
首先BIOS 程序没有固化在 CPU 中,而是独立放到主板的 ROM 上,是因为不同历史时期的计算机输入输出设备很不一样,有键盘+鼠标+显示器的,有触摸屏的,也有纯语音交互的,外置存储则有软盘,硬盘,闪存,这些变化我们通过调整 BIOS 程序就可以应对,而不需要修改 CPU。
引导区引导程序则是程序从内置存储ROM转到外置存储的边界。引导区引导程序很短BIOS 只需要把它加载到内存执行就可以,但是这样系统的控制权就很巧妙地转到外置存储了。
引导区引导程序不固化在 BIOS 中,而是写在外置存储的引导区,是为了避免 BIOS 程序需要经常性修改。毕竟 BIOS 还是硬件,而引导区引导程序已经属于软件范畴了,修改起来会方便很多。
OS 引导程序,则是外置存储接手计算机控制权的真正开始。这里 OS 是操作系统Operating System的缩写。操作系统从这里开始干活了。这个过程发生了很多很多事情这里我们先略过。但是最终所有的初始化工作完成后操作系统会把执行权交给 OS Shell 程序。
OS Shell 程序负责操作系统与用户的交互。最早的时候计算机的交互界面是字符界面OS Shell 程序是一个命令行程序。DOS 中叫 command.com而在 Linux 下则叫 sh 或者 bash 之类。这里的 sh 就是 shell 的缩写。
这个时期启动一个软件的方式就是在 Shell 程序中输入一个命令行Shell 负责解释命令行理解用户的意图,然后启动相应的软件。到了图形界面时期,在 Shell 中启动软件就变成点点鼠标,或者动动手指(触摸屏)就行了,交互范式简化了很多。
在了解了计算机从开机到关机的整个过程后,你可能很快会发现,这里面有一个很关键的细节没有交代:计算机是如何运行外置存储上的软件的?
这和内存管理有关。
结合内存的作用,我们谈内存管理,只需要谈清楚两个问题:
如何分配内存(给运行中的软件,避免它们发生资源争抢);
如何运行外置存储(比如硬盘)上的软件?
在回答这两个问题之前我们先了解一个背景知识CPU 的实模式和保护模式。这两个模式 CPU 对内存的操作方式完全不同。在实模式下CPU 直接通过物理地址访问内存。在保护模式下CPU 通过一个地址映射表把虚拟的内存地址转为物理的内存地址,然后再去读取数据。
相应的,工作在实模式下的操作系统,我们叫实模式操作系统;工作在保护模式下的操作系统,我们叫保护模式操作系统。
实模式下的内存管理
先看实模式操作系统。
在实模式操作系统下,所有软件包括操作系统本身,都在同一个物理地址空间下。在 CPU 看来,它们是同一个程序。操作系统如何分配内存?至少有两种可行的方法。
其一,把操作系统内存管理相关的函数地址,放到一个大家公认的地方(比如 0x10000 处),每个软件要想申请内存就到这个地方取得内存管理函数并调用它。
其二,把内存管理功能设计为一个中断请求。所谓中断,是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断。
内存的全局有一个中断向量表,本质上就是在一个大家公认的地方放了一堆函数地址。比如键盘按了一个键,它会触发 9 号中断。在 CPU 收到中断请求时,它会先停下手头的活来响应中断请求(到中断向量表找到第 9 项对应的函数地址并去执行它),完成后再回去干原来的活。
中断机制设计之初本来为响应硬件事件之用,但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。比如我们约定 77 号中断为内存管理中断,操作系统在初始化时把自己的内存管理函数写到中断向量表的第 77 项。
所以,上面两种方法实质上是同一个方法,只是机制细节有所不同而已。中断机制远不止是函数向量表那么简单。比如中断会有优先级,高优先级中断可以打断低优先级中断,反之则不能。
那么,在实模式下,操作系统如何运行外置存储(比如硬盘)上的软件?
很简单,就是把软件完整从外置存储读入到内存然后执行它。不过,在执行前它干了一件事情,把浮动地址固定下来。为什么会有浮动地址?因为软件还没有加载到内存的时候并不知道自己会在哪里,所以有很多涉及数据的地址、函数的地址都没法固定下来,要在操作系统把它加载到内存时来确定。
整体来说,实模式内存管理的机制是非常容易理解的。因为它毕竟实质上是一个程序被拆分为很多个软件(程序代码片段),实现了程序代码片段的动态加载而已。
保护模式下的内存管理
但实模式有两个问题。
其一是安全性。操作系统以及所有软件都运行在一起,相互之间可以随意修改对方的数据甚至程序指令,这样搞破坏就非常容易。
其二是支持的软件复杂性低,同时可运行的软件数量少。
一方面,软件越复杂,它的程序代码量就越多,需要的存储空间越大,甚至可能出现单个软件的大小超过计算机的可用内存,这时在实模式下就没法执行它。
另一方面,哪怕单个软件可运行,但是一旦我们同时运行的软件多几个,操作系统对内存的需求量就会急剧增加。相比这么多软件加起来的内存需求量,内存的存储空间往往仍然是不足的。
但是为什么平常我们可以毫无顾忌地不断打开新的软件,从来不曾担心过内存会不足呢?
这就是保护模式的作用了。保护模式下,内存访问不再是直接通过物理内存,而是基于虚拟内存。虚拟内存模式下,整个内存空间被分成很多个连续的内存页。每个内存页大小是固定的,比如 64K。
这样,每次 CPU 访问某个虚拟内存地址中的数据,它都会先计算出这是要访问哪个内存页,然后 CPU 再通过一个地址映射表,把虚拟的内存地址转为物理的内存地址,然后到这个物理内存地址去读取数据。地址映射表是一个数组,下标是内存页页号,值是该内存页对应的物理内存首地址。
当然,也有可能某一个内存页对应的物理内存地址还不存在,这种情况叫缺页,没法读取数据,这时 CPU 就会发起一个缺页的中断请求。
这个缺页的中断请求会被操作系统接管。发生缺页时,操作系统会为这个内存页分配物理的内存,并恢复这个内存页的数据。如果没有空闲的物理内存可以分配,它就会选择一个最久没有被访问的内存页进行淘汰。
当然,淘汰前会把这个内存页的数据保存起来,因为下次 CPU 访问这个被淘汰的内存页时一样会发生缺页中断请求,那时操作系统还要去恢复数据。
通过这个虚拟内存的机制,操作系统并不需要一上来就把整个软件装进内存中,而是通过缺页中断按需加载对应的程序代码片段。多个软件同时运行的问题也解决了,内存不够用的时候,就把最久没有用过的内存页淘汰掉,腾出物理内存出来。
运行软件的问题解决了。那么,操作系统如何分配内存给运行中的软件?
其实,内存分配的问题也解决了,并不需要任何额外的机制。反正内存地址空间是虚拟的,操作系统可以一上来就给要运行的软件分配超级大的内存,你想怎么用随你。软件如果不用某个内存页,什么都不发生。软件一旦用了某个内存页,通过缺页中断,操作系统就分配真正的物理内存给它。
通过引入虚拟内存及其缺页机制CPU 很好地解决了操作系统和软件的配合关系。
每个运行中的软件,我们把它叫进程,都有自己的地址映射表。也就是说,虚拟地址并不是全局的,而是每个进程有一个自己独立的虚拟地址空间。
在保护模式下,计算机的基础架构体系和操作系统共同在努力做的一件事情,就是让每个软件“感觉”自己在独占整个计算机的资源。独立的虚拟地址空间很好地伪装了这一点:看起来我独自在享用所有内存资源。在实模式下的浮动地址的问题也解决了,软件可以假设自己代码加载的绝对地址是什么,不需要在加载的时候重新调整 CPU 指令操作的地址。
这和实模式很不一样。在实模式下,所有进程都在同在物理内存的地址空间里,它们相互可以访问对方的数据,修改甚至破坏对方的数据,进而导致其他进程(包括操作系统本身的进程)崩溃。内存是进程运行的基础资源,保持进程基础资源的独立性,是软件治理的最基础的要求。这也是保护模式之所以叫“保护”模式的原因。
架构思维上我们学到什么?
虚拟内存它本质上要解决这样两个很核心的需求。
其一,软件越来越大,我们需要考虑在外置存储上执行指令,而不是完整加载到内存中。但是外置存储一方面它的数据 CPU 并不知道怎么读;另一方面就算知道怎么读,也不知道它的数据格式是什么样的,这依赖文件系统的设计。让 CPU 理解外置存储的实现细节?这并不是一个好的设计。
其二要同时运行的软件越来越多计算机内存的供给与软件运行的内存需求相比捉襟见肘。怎么才能把有限的内存的使用效率最大化一个很容易想到的思路是把不经常使用的内存数据交换到外置存储。但是问题仍然是CPU 并不了解外置存储的实现细节,怎么才能把内存按需交换出去?
通过把虚拟内存地址分页,引入缺页中断,我们非常巧妙地解决了这个问题。缺页中断很像是 CPU 留给操作系统的回调函数,通过它对变化点实现了很好的开放性设计。
结语
总结一下。我们今天先概要地阐述了计算机运行的全过程,并对每一步的核心意义做了简单的介绍。然后我们把话题转到我们这一节的重心:内存管理。
谈内存管理,需要谈清楚两个核心问题:
如何分配内存(给运行中的软件,避免它们发生资源争抢);
如何运行外置存储(比如硬盘)上的软件?
我们分别就在实模式下和保护模式下的内存管理进行了讨论。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,169 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 操作系统内核与编程接口
你好,我是七牛云许式伟。
今天我们在开发软件的时候,已经处于一些基础的架构设计之中,像冯·诺依曼计算机体系,像操作系统和编程语言,这些都是我们开发一个应用程序所依赖的基础架构。
在上一讲“[07 | 软件运行机制及内存管理]” 中,我们着重介绍了软件是如何被运行起来的。** 今天,我们着重聊一聊软件如何利用它所依赖的基础架构。**
首先是冯·诺依曼计算机体系,它由 “中央处理器+存储+一系列的输入输出设备” 构成。这一层提供了编程接口的是中央处理器CPU编程接口是 CPU 指令,但 CPU 指令非常难用。
为此,人们发明了编程语言来降低 CPU 指令的使用门槛。编程语言面向人类CPU 指令面向机器,编译器负责将人类容易理解和掌握的编程语言的程序,翻译成机器能够理解的 CPU 指令序列。
其次是编程语言。虽然编程语言出现的起因是降低 CPU 指令的使用门槛,第一门编程语言汇编语言的能力也很接近 CPU 指令,但是语言的自然演化会越来越脱离 CPU 所限制的条条框框,大部分语言都会演化出很多基础的算法库。
比如字符串算法库有字符串连接concat、字符串子串substring字符串比较compare、字符串长度length等等。
系统调用
最后就是操作系统了。
操作系统和前两者非常不同。软件都是某种编程语言写成的,而 CPU 和编程语言的能力,统一以语言的语法或者库体现。
操作系统则属于基础软件,它和我们编写的软件并不在同一个进程(进程是软件的一个运行后产生的实例,同一个软件可以运行多次得到多个进程)中。
如果是实模式下的操作系统,大家都在同一个地址空间下,那么只需要知道操作系统的接口函数地址,理论上就可以直接访问。但是今天主流的操作系统都是保护模式的,操作系统和软件不在同一个进程,软件怎么才能使用操作系统的能力呢?
你可能想说,那就用进程与进程之间的通信机制?
的确,操作系统提供了很多进程与进程之间通讯的机制,后面我们也会涉及。但是今天我们讲的操作系统的编程接口是更为基础的机制,它是所有软件进程使用操作系统能力的基础,包括进程与进程之间通讯的机制,也是建立在这个基础之上。
它应该是一种成本非常非常低的方案,性能上要接近函数调用,否则我们为保护模式付出的成本就太高了。
有这样的机制么?有,就是上一讲我们已经提到过的“中断”。
中断的设计初衷是 CPU 响应硬件设备事件的一个机制。当某个输入输出设备发生了一件需要 CPU 来处理的事情,它就会触发一个中断;但是 CPU 也提供了指令允许软件触发一个中断,我们把它叫软中断。
大部分情况下,操作系统的能力通过软中断向我们写的软件开放,为此还专门引入了一个术语叫 “系统调用syscall”。
系统调用是怎么工作的?
我们需要先理解下 CPU 的代码执行权限等级。
在保护模式下CPU 引入了 “保护环Protection Rings” 的概念。说白了,代码有执行权限等级的,如果权限不够,有一些 CPU 指令就不能执行。
这一点比较容易理解:上一讲我们介绍过,从内存管理的角度,虚拟内存机制让软件运行在一个沙盒中,这个沙盒让软件感觉自己在独享系统的内存。但如果不对软件的执行权限进行约束,它就可以打破沙盒,了解到真实的世界。
我们通常说的操作系统是很泛的概念。完整的操作系统非常庞大。根据与应用的关系,我们可以把操作系统分为内核与外围。
所谓操作系统内核,其实就是指那些会向我们写的应用程序提供系统服务的子系统的集合,它们管理着计算机的所有硬件资源,也管理着所有运行中的应用软件(进程)。
操作系统内核的执行权限等级,和我们常规的软件进程不同。像 Intel CPU 通常把代码执行权限分为 Ring 0-3 四个等级。
操作系统内核通常运行在 Ring 0而常规的软件进程运行在 Ring 3当然近年来虚拟化机制流行为了更好地提升虚拟化的效率Intel CPU 又引入了 Ring -1 级别的指令,这些指令只允许虚拟机所在的宿主操作系统才能调用)。
系统调用所基于的软中断,它很像一次间接的“函数调用”,但是又颇有不同。在实模式下,这种区别并不强烈。但是在保护模式下,这种差异会十分明显。
原因在于,我们的应用程序运行在 Ring 3我们通常叫用户态而操作系统内核运行在 Ring 0我们通常叫内核态。所以一次中断调用不只是“函数调用”更重要的是改变了执行权限从用户态跃迁到了内核态。
但是这似乎不够。我们之前说了,操作系统与我们编写的软件并不同属一个进程,两边的内存地址空间都是独立的,我们系统调用请求是过去了,但是我们传给操作系统的内存地址,对它真的有意义吗?
答案在于,从虚拟内存机制的视角,操作系统内核和所有进程都在同一个地址空间,也就是,操作系统内核,它是所有进程共享的内存。示意如下:
这非常有趣。操作系统内核的代码和数据,不只为所有进程所共享,而且在所有进程中拥有相同的地址。这样无论哪个进程请求过来,对内核来说看起来都是一次本进程内的请求。
从单个进程的视角,中断向量表的地址,以及操作系统内核的地址空间是一个契约。有了中断向量表的地址约定,用户态函数就可以发起一次系统调用(软中断)。
当然你可能要问:既然操作系统内核和我同属一个地址空间,我是否可以跳过中断,直接访问调用内核函数?
这不单单是执行权限的问题。你可能会说,也许某个内核函数里面没有调用任何特权指令,我是否可以调用?
当然不能。这涉及虚拟内存中的内存页保护机制。内存页可以设置 “可读、可写、可执行” 三个标记位。操作系统内核虽然和用户进程同属一个地址空间,但是被设置为“不可读、不可写、不可执行”。虽然这段地址空间是有内容的,但是对于用户来说是个黑洞。
编程接口
理解了操作系统内核,以及它的调用方法 “系统调用”,我们来聊一聊操作系统的编程接口。
自然,最原始的调用方式,是用软中断指令。在汇编语言里面通常是:
int <中断号> ; // 对每个操作系统来说中断号是固定的,比如 Linux 是 0x80
这里的 int 不是整数integer的缩写而是中断interrupt的缩写。
当然用汇编语言来写软件并不是一个好主意。大部分高级语言都实现了操作系统编程接口的封装。
前面我们说,操作系统(内核)有六大子系统:存储管理、输入设备管理、输出设备管理、进程管理、网络管理、安全管理。除了安全管理是一个“润物细无声”的能力外,其他子系统都会有所包装。
我们以 C 语言和 Go 语言为例给一个简表,方便大家索引:
这些标准库的能力,大部分与操作系统能力相关,但或多或少进行了适度的包装。
例如HTTP 是应用层协议,和操作系统内核关联性并不大,基于 TCP 的编程接口可以自己实现,但由于 HTTP 协议细节非常多,这个网络协议又是互联网世界最为广泛应用的应用层协议,故此 Go 语言提供了对应的标准库。
进程内通讯最为复杂。虽然操作系统往往引入了 thread 这样的概念,但 Go 语言自己搞了一套goroutine 这样的东西,原因是什么,我们在后面讨论 “进程管理” 相关的内容时,再做详细讨论。
动态库
从操作系统的角度来说,它仅仅提供最原始的系统调用是不够的,有很多业务逻辑的封装,在用户态来做更合适。但是,它也无法去穷举所有的编程语言,然后一一为它们开发各种语言的基础库。那怎么办?
聪明的操作系统设计者们想了一个好办法:动态库。几乎所有主流操作系统都有自己的动态库设计,包括:
Windows 的 dllDynamic Link Library
Linux/Android 的 soshared object
Mac/iOS 的 dylibMach-O Dynamic Library
动态库本质上是实现了一个语言无关的代码复用机制。它是二进制级别的复用,而不是代码级别的。这很有用,大大降低了编程语言标准库的工作量。
动态库的原理其实很简单,核心考虑两个东西。
浮动地址。动态库本质上是在一个进程地址空间中动态加载程序片段,这个程序片段的地址显然在编译阶段是没法确定的,需要在加载动态库的过程把浮动地址固定下来。这块的技术非常成熟,我们在实模式下加载进程就已经在使用这样的技术了。
导出函数表。动态库需要记录有哪些函数被导出export这样用户就可以通过函数的名字来取得对应的函数地址。
有了动态库,编程语言的设计者实现其标准库来说就多了一个选择:直接调用动态库的函数并进行适度的语义包装。大部分语言会选择这条路,而不是直接用系统调用。
操作系统与编程语言
我们这个专栏从计算机硬件结构讲起,然后再到编程语言,到现在开始介绍操作系统,有些同学可能会觉得话题有那么一些跳跃。虽然每一节的开头,我其实对话题的脉络有所交代,但是,今天我还是有必要去做一个梳理。
编程语言和操作系统是两个非常独立的演化方向,却又彼此交融,它们有点像是某种“孪生关系”。虽然操作系统的诞生离不开编程语言,但是操作系统和 CPU 一样,是编程语言背后所依赖的基础设施。
和这个话题相关的,有这么一些有趣的问题:
先有编程语言,还是先有操作系统;
编程语言怎么做到自举的比如用C语言来实现C语言编译器
操作系统开发的环境是什么样的,能够做到操作系统自身迭代本操作系统(自举)么?
对于第一个问题:先有编程语言,还是先有操作系统?这个问题的答案比较简单,先有编程语言。之所以有这个疑问,是因为两点:
其一,大部分人习惯认为运行软件是操作系统的责任。少了责任方,软件是怎么跑起来的?但实际上软件跑起来是很容易的,看 BIOS 程序把控制权交给哪个软件。
其二,大部分常见的应用程序都直接或间接依赖操作系统的系统调用。这样来看,编程语言编译出来的程序是无法脱离操作系统而存在的。但是实际上常见的系统级语言(比如 C 语言)都是可以编写出不依赖任何内核的程序的。
对于第二个问题:编程语言怎么做到自举的?
从鸡生蛋的角度,编译器的进化史应该是这样的:先用机器码直接写第一个汇编语言的编译器,然后汇编语言编译器编出第一个 C 语言编译器。有了 C 语言编译器后,可以反过来用 C 语言重写汇编语言编译器和 C 语言编译器,做更多的功能增强。
这个过程理论上每出现一种新 CPU 指令集、新操作系统,就需要重新来一遍。但是人是聪明的。所以交叉编译这样的东西产生了。所谓交叉编译就是在一种 “CPU +操作系统” 架构下,生成另一种 “CPU +操作系统” 架构下的软件。这就避免了需要把整个编译器进化史重新演绎一遍。
对于第三个问题:操作系统能够做到自身迭代本操作系统(自举)么?
当然可以。通常一门新的操作系统开发之初,会用上面提到的交叉编译技术先干出来,然后等到新操作系统稳定到一定程度后再实现自举,也就是用本操作系统自己来做操作系统的后续迭代开发。
结语
这一节我们介绍了我们的基础架构中央处理器CPU、编程语言、操作系统这三者对应用软件开放的编程接口。总结来看就是下面这样一幅图
其中,我们着重介绍的是操作系统的系统调用背后的实现机理。通过系统调用这个机制,我们很好地实现了操作系统和应用软件的隔离性和安全性,同时仍然保证了极好的执行性能。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,131 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 外存管理与文件系统
你好,我是七牛云许式伟。
在 “[07 | 软件运行机制及内存管理]”中,我们已经聊了内存管理,这一讲我们聊聊外置存储设备的管理。
外存的分类
计算机有非常多样化的外置存储设备比如磁带、光盘、硬盘、U盘、SSD 等等。外置存储设备的种类是不可穷尽的。随着科技的发展,新的存储设备会不断涌现,有着更低的单位能耗(存储量/每日能源消耗成本),更低的单位存储成本(存储量/可存储的时间/设备价格),或者更高的访问性能。
但不管这些存储设备内部如何存储数据的原理怎么变,改变的主要是质量,而不是它的功能。对操作系统来说,管理它们的方式是非常一致的。这些外置存储设备依据其功能特性不同,简单可以分为如下三类。
顺序读写型。如:磁带。
随机只读型。更准确说是单次完整写入多次读取型,也就是每次写数据都是整个存储介质一次性完整写入数据。如:光盘(含可擦写光盘)。
随机读写型。如软盘、硬盘、U盘、SSD 等等。
顺序读写型的外置存储(如磁带)我们日常并不常见,它的主要应用场景是归档,也就是数据备份。今天我们略过不提。
随机只读型的外置存储如光盘我们日常有较多应用常见的应用场景是资料分发和归档。资料发布的内容很广泛比如软件、娱乐媒体包括电影、MTV、音乐等等。
随机读写型的外置存储最为常见我们今天在所有“能够称得上叫电脑”的设备上无论是PC、笔记本、手机还是手表、汽车随处可见它们的身影。
外存的数据格式
外置存储和内存最大的区别是什么?
毫无疑问,外置存储是持久存储,它的目的是用来存储资料的。而内存是临时存储,虽然是存储,但是它实质上是为 CPU 的计算服务的。
那么,怎么让很多的软件进程同时使用这些外置存储设备,而不会乱呢?直接基于物理的存储地址进行读写肯定是行不通的,过上几个月你自己可能都不记得什么数据写到哪里了。
所以和内存管理不同,外部存储的管理,我们希望这些写到存储中的数据是“自描述”的某种数据格式,我们可以随时查看之前写了哪些内容,都什么时候写的。
这就是文件系统的来源。
文件系统把存储设备中的数据组织成为了一棵树。节点可以是目录(也叫“文件夹”),也可以是文件。
树的根节点为目录,我们叫根目录。如果是目录,那么它还可以有子节点,子节点同样可以是子目录或文件。文件则是叶节点,保存我们希望存储的资料。
每个节点,无论是目录还是文件,都有自己的名字、创建时间、最后编辑时间、最后访问时间等信息。有些文件系统还会提供最近一段时间的操作日志。这些信息有助于提醒我们有什么内容,以前都做过什么。
尽管几乎所有文件系统的接口是非常一致的,但文件系统的实现却有很多。对于随机只读型的外置存储(如光盘),常见的文件系统有如下这些。
由于这类存储设备的写特征是批量写,一次把所有的数据写完,所以它的数据格式通常偏向于读优化(存储系统一般都有读写操作,所谓读优化是指在数据结构和算法设计时尽可能考虑让读操作更高效)。整个文件系统的元数据和文件数据都会非常紧凑,比如文件数据不必支持分块等等。
对于随机读写型的存储(如硬盘),常见的文件系统有如下这些。
从文件系统格式的设计角度来说,它和架构关联性不大,更多的是数据结构与算法的问题;而且,不是基于内存的数据结构,而是基于外存的数据结构,这两者非常不同。
尽管文件系统的种类非常多但是它们的设计思路其实基本相似。大部分现代文件系统都基于日志journal来改善文件系统的防灾难能力比如突然断电或不正常的 unmount 行为),基于 B 树或 B+ 树组织元数据。
古老的 DOS 引入的 FAT 文件系统(典型代表为 FAT32是个例外它直接把目录当作一个特殊的文件里面依次列出了这个目录里的所有子节点的元信息。
这个结构简单是简单了,但是缺点非常明显,如果目录树深、目录里的子节点数量多,都会大幅降低文件系统的性能。
对于随机读写型的存储设备,操作系统往往还支持对其进行分区,尤其是在这个存储设备的容量非常大的情况下。分区是一个非常简单而容易理解的行为,本质上只是把一个存储设备模拟成多个存储设备来使用而已。
一般来说,拿到一块存储设备,我们往往第一步是对其进行分区(当然也可以省略这一步,把整个设备看做一个分区)。
第二步是对每个分区进行格式化。 所谓格式化就是给这个分区生成文件系统的初始状态。格式化最重要的是标记分区的文件系统格式(用来告诉别人这个分区是数据是怎么组织的),并且生成文件系统的根目录。
第三步是把该分区挂载mount到操作系统管理的文件系统名字空间中。 完成挂载后,该分区的文件系统管理程序就工作起来了,我们可以对这个文件系统进行目录和文件的读取、创建、删除、修改等操作。
外存的使用接口
怎么使用这些外置存储设备?
最简单的方式是用操作系统提供的命令行工具。例如:
目录相关ls, mkdir, mv, cp, rmdir 等。
文件相关cat, vi, mv, cp, rm 等。
当然,最原始的方式还是我们上一讲介绍的 “系统调用”。但大部分编程语言对此都有相应的封装,例如 Go 语言中的相关功能如下所示。
目录相关os.Mkdir, os.Rename, os.Remove 等。
文件相关os.Open/Create/OpenFile, os.Rename, os.Remove 等。
有意思的是,在早期,操作系统试图将所有的输入输出设备的接口都统一以 “文件” 来抽象它。
最典型的代表就是标准输入stdin和标准输出stdout这两个虚拟的文件分别代表了键盘和显示器。在 UNIX 系里面有个 “一切皆文件” 的口号,便由此而来。
但事实证明 UNIX 错了。输入输出设备太多样化了,所谓的 “一切皆文件” 不过是象牙塔式的理想。就拿键盘和显示器来说,图形界面时代到来,所谓标准输入和标准输出就被推翻了,编程接口产生颠覆性的变化。
有了文件系统的使用接口,进程就可以互不影响地去使用这些外置存储设备。除非这些进程要操作的文件或目录的路径产生冲突(所谓路径,是指从根目录到该节点的访问序列。例如路径 /a/b/c 是从根目录访问子目录a再访问子子目录b最后访问节点c一般情况下它们并不需要感知到其他进程的存在。
路径冲突是可以避免的,只要我们对路径取名进行一些基础的名字空间约定,但有时候也会故意利用这种路径的冲突,来实现进程间的通讯。
操作系统提供了一些冲突检查的机制。例如 “检查文件是否存在,不存在就创建它”,这个语义在保证原子性的前提下,就可以用于做进程间的互斥。例如,我们希望一个软件不要运行多个进程实例,就可以基于这个机制来实现。
虚拟内存的支持
前面我们在 “[07 | 软件运行机制及内存管理]” 一讲中提到,在物理内存不足的时候,操作系统会利用外存把一些很久没有使用的内存页的数据,保存到外存以进行淘汰。
在 UNIX 系的操作系统中,操作系统为此分配了一个磁盘分区叫 swap 分区,专门用于内存页的保存和恢复。在 Windows 操作系统中则通过一个具有隐藏属性的 .swp 文件来实现。
在缺页发生比较频繁时,内存页的数据经常性发生保存和恢复,这会发生大量的磁盘 IO 操作,非常占用 CPU 时间,这时候我们通常能够非常明显感觉到计算机变得很慢。
在计算机变慢,并且计算机的硬盘灯不停闪烁的时候,我们基本可以确定是物理内存严重不足,不能满足运行中的软件的内存需要。
结语
回顾一下我们今天的内容。整体来说,外存管理从架构角度来说比较简单,复杂性主要集中在外存数据格式,也就是文件系统的设计上。
文件系统的实现非常多。如果你希望进一步研究某个文件系统的具体实现细节,我这里推荐一个由七牛云开源的 BPL 语言Binary Processing Language二进制处理语言。地址如下
https://github.com/qiniu/bpl
顾名思义BPL 语言主要用于分析二进制数据格式。应用场景包括:文件格式分析(含磁盘分区格式,因为一个磁盘分区可以把它理解为一个大文件)、网络协议分析。
我们在后面的介绍文本处理相关的章节,还会专门拿出 BPL 语言进行讨论。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,144 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 输入和输出设备:交互的演进
你好,我是七牛云许式伟。
前面我们已经介绍了操作系统的存储管理:内存和外存。今天,让我们来聊一聊操作系统是如何管理输入和输出设备的。
输入和输出设备有非常非常多。例如输入设备除了你常见的键盘、鼠标、触摸屏外还有一些采集信息的传感器如GPS位置、脉搏、心电图、温度、湿度等。我们会把关注点收敛在人机交互相关的设备管理上。
交互的演进
在计算机外部设备的演进上,人机交互设备的演进毫无疑问是最为剧烈的部分。
计算机刚出现的时候,人们使用“打孔卡+打印机”作为人机交互方式。这个时期还没有操作系统,表达意图非常痛苦,只能在打孔卡上打孔来发送指令。
但很快,人们找到了 “键盘+显示器”, 这对最为经典的人机交互设备。而随着计算机使用人群越来越多,以及使用场景的变化,人机交互的方式也相应地发生了一次又一次的改变。
总结这些改变,我们会发现,人机交互在往越来越自然的方向发展。所谓自然,就是越来越接近于两个人直接的面对面沟通。
那么,这些人机交互的改变,对于操作系统来说又意味着什么呢?
输入设备
我们先看输入设备:键盘、鼠标、麦克风、摄像头。
键盘
键盘输入的管理机制和窗口系统的设计密切相关。为了让用户清楚键盘输入的目的地,窗口系统往往会有一个焦点窗口。
在窗口系统里面,窗口间还有父子关系,焦点窗口还会有父窗口,还有父窗口的父窗口,这些窗口属于活动窗口。
大部分情况下,键盘输入的事件会先发给焦点窗口,焦点窗口不处理则发给其父窗口,按此传递,直到有人处理了该按键事件,或者直到顶层窗口。
键盘从功能上来说,有两个不同的能力:其一是输入文本,其二是触发命令。 从输入文本的角度来说要有一个输入光标在Windows里面叫Caret来指示输入的目的窗口。目的窗口也必然是焦点窗口否则就会显得很不自然。
这个交互的呈现方式非常稳定,从 DOS到Windows/Mac到iOS/Android 都是如此。但是从触发命令的角度来说,命令的响应并不一定是在焦点窗口,甚至不一定在活跃窗口。
比如Windows下就有热键HotKey的概念能够让非活跃窗口Inactive Window也获得响应键盘命令的机会。一个常见的例子是截屏软件往往需要一个热键来触发截屏。
到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。
不过移动设备不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大或调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。
鼠标
鼠标输入的管理机制和键盘一样,和窗口系统的设计密切相关。但鼠标因为有位置,确定鼠标事件的目的地相比键盘事件要简单的多,大部分情况下,鼠标事件总是交给鼠标位置所属的窗口来处理,但也会有一些例外的场景,比如拖放。
为了支持拖放Windows操作系统引入了鼠标捕获Mouse Capture的概念一旦鼠标被某个窗口捕获哪怕鼠标已经移出该窗口鼠标事件仍然会继续发往该窗口。
到了移动时代鼠标已经完全消失虽然在智能手机之前还是出现过WinCE这样的支持鼠标的移动操作系统取而代之的是触摸屏。窗口系统也和PC时期完全不同在屏幕可见范围内只有单个应用程序占满整个屏幕这让交互的目的地确认不再是个问题。
麦克风
麦克风是一个非常有潜力的下一代输入设备。今天 IoT 领域如汽车、智能音箱都是很好的发展语音交互能力的场景。包括今天大行其道的手机,语音交互也是一个很好的补充。
交互方式不管怎么变化,其核心需要实现的都是这样的两大能力:输入文本和触发命令,这一点是不变的。
语音交互今天仍然还很不成熟,究其原因,语音交互在 IoT 领域还停留在触发命令为主,且哪怕是触发命令这一件事情,也还有重重关卡需要去突破。
在手机软件中,语音输入文本在部分软件中已经有较多应用,但是主要优势还在日常用语和长文本,在个性化场景如“输入人名之类”,较难达到好的结果。
从更本源的角度看,语音交互今天仍然在相对封闭的应用技术场景里面发展为主,而作为操作系统的主体交互手段,其能力必须是开放的。因为操作系统是开放的,场景是开放的。
摄像头
摄像头作为交互设备,除了引入语音,也引入了手势、表情。从表达能力来说,这是最为丰富也是最为自然的一种表达方法。但是技术所限,这种交互方式还只在萌芽阶段。微软的 Kinect 是一个非常经典的案例,它能够让玩家通过语音和手势发指令来玩游戏。
输出设备
输出设备主要负责向用户反馈信息。比如:显示器(显卡)、音箱(声卡)、打印机。输出设备的演化并不大,最主要的输出设备还是以显示器为主。
显示器
显示器虽然经历了 CRT 到液晶屏多代更新,但也只是支持的色彩更多(从黑白到彩色到真彩色),分辨率越来越高。实际上,从操作系统的软件治理角度来看,显示器并没有发生过实质性的变化。
为了让不同软件可以在同一显示器屏幕上呈现操作系统引入了窗口系统的概念。每个软件有一个或多个窗口Window有时候也叫视图即View。在 PC 操作系统中不同窗口还可以层叠Cascade或平铺Tile
通过引入窗口,操作系统在逻辑上把显示器屏幕这个有限的设备资源,分配给了多个软件。和 PC 不同的是,移动设备由于屏幕过小,所以操作系统选择了让软件的顶层窗口全屏占据整个屏幕。这让显示器屏幕的管理变得更为简单。
除了窗口系统,显示设备管理的另一大挑战是绘制子系统。窗口里面的内容是什么,呈现成什么样子,完全是软件来决定的,这就意味着软件需要绘制能力。
绘制能力牵涉面非常之广在操作系统里面往往有一个独立的子系统通常叫GDI与之对应。这里我们简单罗列一下GDI子系统会涉及哪些东西。
2D图形相关。 包含 Path(路径)、Brush(画刷)、Pen(画笔) 等概念。
3D图形相关。 包含 Model(模型)、Material(材质)、Lighting(光照) 等概念。
文本相关。 包含 Font(字体) 等概念。而字体又分点阵字体和 TrueType 字体。TrueType 字体的优势是可以自由缩放。今天我们见到的大部分字体都是 TrueType 字体。
图像处理相关。 包含 Bitmap(位图) 对象及常见图像格式的编解码器(Encoder/Decoder)。
窗口系统结合输入设备对应的事件管理系统、绘制(GDI)系统,我们就可以随心所欲地实现各类用户体验非常友好的视窗软件了。
但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:
静态文本 (Label)
按钮 (Button)
单选框 (RadioBox)
复选框 (CheckBox)
输入框 (Input也叫EditBox/EditText)
进度条 (ProgressBar)
……
不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。
音箱
相比显示器的管理,音箱的设备管理要简单得多。我们很容易做到多个软件同时操作设备,而有合理的结果。
例如,调整音量我们遵循覆盖原则即可,谁后设置音量就听谁的。而声音的播放则可进行混音处理,多个软件播放的声音同时播放出来,让人听起来像是同时有多个人在说话。
当然,特定情况下要允许某个软件禁止其他软件播放出来的声音,比如接听电话的软件,需要在电话接通的时候屏蔽掉所有其他软件播放的声音。
打印机
打印机的管理方式又很不一样,软件使用打印机的过程基本上是互斥的。一个软件在打印文档的时候,其他的软件只能等待它打印完毕后,才能进行打印。
打印机的使用是以文档为互斥的单位。为了避免软件之间出现长时间的相互等待,操作系统往往在打印机的管理程序中引入很大的打印缓冲。
软件操作打印机的时候,并不是等待打印机真把内容打印出来,而是把文档打印到打印缓冲中就完成打印。这样,在大部分情况下多个软件不需要因为使用打印机而出现相互等待。
结语
后面我们在谈“桌面开发”一章中,还会涉及人机交互的更多细节,这一章侧重点在于领域无关的通用操作系统相关的问题域,相关的内容这里仅做概要性的阐述。
但是,仅通过简单对比所有输入和输出设备的管理方式,我们就可以看出,不同输入和输出设备的管理方法差异非常大,没有太大的共性可言。
尽管对 CPU 而言,所有外部设备有着相同的抽象,但这些设备的业务逻辑却如此不同,并不能统一抽象它们。正是因为有了操作系统这样的基础软件,这些设备业务逻辑的复杂性才从我们的软件开发过程中解放出来。
人机交互演化的核心变化是输入设备的变化。我们看到,输入手段的变化是非常剧烈的,且每一次演变都是颠覆性的变化。
事实上输入意图的理解越来越难了因为交互在朝着自然Nature和智能Intelligence的方向发展。 我们不可能让每一个软件都自己去做输入意图的理解(今天的现状是每个软件自己做),在未来,必然将由操作系统来实现智能交互的基础架构。
今天的内容就到这里。你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 多任务:进程、线程与协程
你好,我是七牛云许式伟。
到现在为止,我们已经介绍了操作系统的存储管理:内存与外存;也已经介绍了输入与输出设备的管理。
当然,考虑到输入与输出设备属于人机交互范畴,我们主要会留到下一章 “桌面软件开发” 去详细介绍,这一章,我们仅概要地回顾输入与输出设备的需求演进过程。
CPU + 存储 + 输入与输出,软件开发最基础的内容基本上就都覆盖到了。 今天开始,我们就来聊一聊多任务。
多任务与执行体
多任务的需求是随处可见的。常见的场景,比如我们想边工作边听音乐;又或者我们需要跑一个后台监控程序,以报告随时可能发生的异常。
那么,怎么才能做到多任务?
我们先从物理层面看。最早期的 CPU 基本上都是单核的,也就是同一时间只能执行一条指令。尽管如此,大家可能都听过 “摩尔定律”,简单地说就是,每隔一年半到两年,同样的钱能买到的计算力能够翻一倍。
这当然不是什么严谨的物理学定律,更多的是一定历史时期下的经验之谈。早期 CPU 工艺的发展,基本上是通过提高电子元器件的密集程度实现的;但是电子元器件大小总归有个极限,不可能无限小下去。
那么怎么办?不能更小的话,那就横向多铺几个,一颗 CPU 多加几颗核心。这样多核技术就出现了。多核的意思是说,单核速度我提不上去了,多给你几个,价格一样。
所以物理层面的多任务,有两个方法:一个是多颗 CPU一个是单颗 CPU 多个核心。
在桌面端,大多数情况用的是后者,因为桌面端的产品(个人计算机、手机、手表等)还是很在意产品的体积如何尽可能做得更小;而服务器领域,通常同时使用两者,它更多关注的是如何尽可能提升单台计算机的计算力密度。
但如果我们实际就只有一个单核的 CPU是否就没办法实现多任务呢
当然可以。方法是把 CPU 的时间切成一段段时间片,每个时间片只运行某一个软件。这个时间片给软件 A下一个时间片给软件 B。因为时间片很小我们会感觉这些软件同时都在运行。这种分时间片实现的多任务系统我们把它叫分时系统。
分时系统的原理说起来比较简单,把当前任务状态先保存起来,把另一个任务的状态恢复,并把执行权交给它即可。这里面涉及的问题有:
任务是什么,怎么抽象任务这样一个概念;
任务的状态都有什么?怎么保存与恢复;
什么时机会发生任务切换?
从今天的现实看,任务的抽象并不是唯一的。大部分操作系统提供了两套:进程和线程。有的操作系统还会提供第三套叫协程(也叫纤程)。
我个人喜欢统一用来 “执行体” 一词来统称它们。所谓执行体,是指可被 CPU 赋予执行权的对象,它至少包含下一个执行位置(获得执行权后会从这里开始执行)以及其他的运行状态。
任务的状态都有什么?
从 CPU 的角度执行程序主要依赖的是内置存储寄存器和内存RAM它们构成执行体的上下文。
先看寄存器。寄存器的数量很少且可枚举,我们直接通过寄存器名进行数据的存取。
在我们把 CPU 的执行权从软件 A 切换到软件 B 的时候,要把软件 A 所有用到的寄存器先保存起来(以便后续轮到软件 A 执行的时候恢复),并且把寄存器的值恢复到软件 B 上一次执行时的值,然后才把执行权交给软件 B。
这样,在软件 A 和 B 的视角看来,它们好像一直都是独自在使用 CPU从未受到过其他软件的打扰。
我们再看内存RAM。CPU 在实模式和保护模式下的内存访问机制完全不同,我们分别进行讨论。在实模式下,多个执行体同在一个内存地址空间,相互并无干扰(非恶意情况下)。
在保护模式下,不同任务可以有不同的地址空间,它主要通过不同的地址映射表来体现。怎么切换地址映射表?也是寄存器。
所以,总结就一句话:执行体的上下文,就是一堆寄存器的值。要切换执行体,只需要保存和恢复一堆寄存器的值即可。 无论是进程、线程还是协程,都是如此。
进程与线程
那么,不同的执行体究竟有何不同?为何会出现不同种类的执行体?
进程是操作系统从安全角度来说的隔离单位,不同进程之间基于最低授权的原则。
在创建一个进程这个事情上UNIX 偷了一次懒,用的是 fork分叉语义。所谓 fork就是先 clone 然后再分支,父子进程各干各的。
这样创建进程很讨巧,不用传递一堆的参数,使用上非常便利。但我认为从架构设计的角度,这是 UNIX 操作系统设计中最糟糕的 API没有之一。而更不幸的是 Linux 把这一点继承下来了。
为什么进程 fork 是糟糕的?这是因为:进程是操作系统最基本的隔离单元,我们怕的就是摘不清楚,但是 fork 偏偏要藕断丝连。
这一点 Windows 要清晰很多,哪些文件句柄在子进程中还要用到,一一明确点名,而不是 fork 一下糊里糊涂就继承过去了。
事实上我个人那么多年工程经验表明,除了会接管子进程的标准输入和标准输出,我们几乎从来不会通过向子进程传递文件句柄来通讯。
所以 fork 这种传递进程上下文的方式,是彻头彻尾的一次过度设计。甚至严重一点说,是设计事故。
线程的出现,则是因为操作系统发现同一个软件内还是会有多任务的需求,这些任务处在相同的地址空间,彼此之间相互可以信任。
从线程角度去理解 UNIX 的 fork能够稍微理解一些设计者们当年的考量。
早期操作系统中没有线程的概念,也不会有人想到要搞两套执行体。所以进程实际上承担了一部分来自线程的需求:我需要父进程的环境。
协程与goroutine
协程并不是操作系统内核提供的,它有时候也被称为用户态线程。这是因为协程是在用户态下实现的。如果你感兴趣,也可以自己实现一个。
但为什么会出现协程呢?看起来它要应对的需求与线程一样,但是功能比线程弱很多?
答案是因为实现高性能的网络服务器的需要。对于常规的桌面程序来说,进程+线程绰绰有余。 但对于一个网络服务器,我们可以用下面这个简单的模型看它:
对网络服务器来说,大量的来自客户端的请求包和服务器的返回包,都是网络 IO在响应请求的过程中往往需要访问存储来保存和读取自身的状态这也涉及本地或网络 IO。
如果这个网络服务器有很多客户,那么整个服务器就充斥着大量并行的 IO 请求。
操作系统提供的标准网络 IO 有以下这些成本:
系统调用机制产生的开销;
数据多次拷贝的开销(数据总是先写到操作系统缓存再到用户传入的内存);
因为没有数据而阻塞,产生调度重新获得执行权,产生的时间成本;
线程的空间成本和时间成本(标准 IO 请求都是同步调用,要想 IO 请求并行只能使用更多线程)。
在一些人心目中会有一个误区:操作系统的系统调用很慢。这句话很容易被错误地理解为系统调用机制产生的开销很大。
但这是很大的误解。系统调用虽然比函数调用多做了一点点事情,比如查询了中断向量表(这类似编程语言中的虚函数),比如改变 CPU 的执行权限(从用户态跃迁到内核态再回到用户态)。
但是注意这里并没有发生过调度行为,所以归根结底还是一次函数调用的成本。怎么理解操作系统内核我们示意如下:
从操作系统内核的主线程来说,内核是独立进程,但是从系统调用的角度来说,操作系统内核更像是一个多线程的程序,每个系统调用是来自某个线程的函数调用。
为了改进网络服务器的吞吐能力,现在主流的做法是用 epollLinux或 IOCPWindows机制这两个机制颇为类似都是在需要 IO 时登记一个 IO 请求,然后统一在某个线程查询谁的 IO 先完成了,谁先完成了就让谁处理。
从系统调用次数的角度epoll 或 IOCP 都是产生了更多次数的系统调用。从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。
既然不希望用太多的线程,网络服务器就不能用标准的同步 IOread/write来写程序。知名的异步 IO 网络库 libevent 就是对 epoll 和 IOCP 这些机制包装了一套跨平台的异步 IO 编程模型。
Node.js 一炮而红,也是因为把 JavaScript 的低门槛和 libevent 的高性能结合起来,给了前端程序员一个“我也能搞高性能服务器”的梦想。
但是异步 IO 编程真的很反人类,它让程序逻辑因为 IO 异步回调函数而碎片化。我们开始怀念写同步 IO 的那些日子了。
让我们再回头来看:我们为什么希望减少线程数量?因为线程的成本高?我们分析一下。
首先,我们看下时间成本。它可以拆解为:
执行体切换本身的开销,它主要是寄存器保存和恢复的成本,可腾挪的余地非常有限;
执行体的调度开销,它主要是如何在大量已准备好的执行体中选出谁获得执行权;
执行体之间的同步与互斥成本。
我们再看线程的空间成本。它可以拆解为:
执行体的执行状态;
TLS线程局部存储
执行体的堆栈。
空间成本是第一根稻草。默认情况下 Linux 线程在数 MB 左右,其中最大的成本是堆栈(虽然,线程的堆栈大小是可以设置的,但是出于线程执行安全性的考虑,线程的堆栈不能太小)。
我们可以算一下,如果一个线程 1MB那么有 1000 个线程就已经到 GB 级别了,消耗太快。
执行体的调度开销,以及执行体之间的同步与互斥成本,也是一个不可忽略的成本。虽然单位成本看起来还好,但是盖不住次数实在太多。
我们想象一下:系统中有大量的 IO 请求,大部分的 IO 请求并未命中而发生调度。另外,网络服务器的存储是个共享状态,也必然伴随着大量的同步与互斥操作。
综上,协程就是为了这样两个目的而来:
回归到同步 IO 的编程模式;
降低执行体的空间成本和时间成本。
但是,大部分你看到的协程(纤程)库只是一个半吊子。它们都只实现了协程的创建和执行权的切换,缺了非常多的内容。包括:
协程的调度;
协程的同步、互斥与通讯;
协程的系统调用包装,尤其是网络 IO 请求的包装。
这包含太多的东西,基本上你看到的服务端操作系统所需的东西都要包装一遍。而且,大部分协程库,连协程的基础功能也是半吊子的。这里面最难搞的是堆栈。
为什么协程的堆栈是个难题?因为,协程的堆栈如果太小则可能不够用;而如果太大则协程的空间成本过高,影响能够处理的网络请求的并发数。理想情况下,堆栈大小需要能够自动适应需要。
所以,一个完备的协程库你可以把它理解为用户态的操作系统,而协程就是用户态操作系统里面的 “进程”。
这世界上有完备的协程库么有。有两个语言干了这事儿Erlang 和 Go 语言。Erlang 语言它基于虚拟机但是道理上是一致的。Go 语言里面的用户态 “进程” 叫 goroutine。它有这样一些重要设计
堆栈开始很小(只有 4K但可按需自动增长
坚决干掉了 “线程局部存储TLS” 特性的支持,让执行体更加精简;
提供了同步、互斥和其他常规执行体间的通讯手段,包括大家非常喜欢的 channel
提供了几乎所有重要的系统调用(尤其是 IO 请求)的包装。
架构师的批判性思维
多任务的需求非常复杂。
为了满足需要,人们不只发明了三套执行体:进程、线程和协程,还发明了各种五花八门的执行体间的通讯机制(可以参考 “[08 | 操作系统内核与编程接口]” 中我们给出的表格)。有一些执行体间的通讯机制在逐渐消亡,退出历史舞台。
操作系统内核之中,不乏无数精妙的设计思想。但是,前辈们也并非圣贤,也可能会出现一些决策上失误,留下了诸多后遗症。
这非常正常。操作系统内核是非常庞大而复杂的基础软件。它并不像计算机基础体系结构,简洁优雅。
对 CPU 而言,统一的、接口一致的输入输出设备,到了操作系统这里,它需要依据每一种设备的需求特性,抽象出对应的更加用户友好的使用接口。这个工作既繁重,又需要极强的预见性。
而作为后辈的我们,在体会这些精妙的设计思想的同时,也要批判性去吸收。日常我们天天依赖于这些基础架构,受到它们的影响与约束,这些实在是最佳的学习材料。
结语
今天我们重点介绍了多任务,以及多任务带来的复杂需求,由此介绍了进程、线程和协程等三套执行体的设计。后面我们还会分进程内和进程间来介绍进程的通讯机制。
执行体的设计有非常多值得反思的地方。UNIX 的 fork API 是否是一个好的设计?线程的设计是否成功?如果线程的设计是优良的,是不是就不再有 Go 语言这种在用户态重造执行体和 IO 子系统的必要性?
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,365 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 进程内协同:同步、互斥与通讯
你好,我是七牛云许式伟。
上一讲开始我们进入了多任务的世界,我们详细介绍了三类执行体:进程、线程和协程,并且介绍了每一种执行体的特点。
既然启动了多个执行体,它们就需要相互协同,今天我们先讨论进程内的执行体协同。
考虑到进程内的执行体有两类:用户态的协程(以 Go 语言的 goroutine 为代表)、操作系统的线程,我们对这两类执行体的协同机制做个概要。如下:
让我们逐一详细分析一下它们。
原子操作
首先让我们看一下原子操作。需要注意的是,原子操作是 CPU 提供的能力,与操作系统无关。这里列上只是为了让你能够看到进程内通讯的全貌。
顾名思义,原子操作的每一个操作都是原子的,不会中途被人打断,这个原子性是 CPU 保证的,与执行体的种类无关,无论 goroutine 还是操作系统线程都适用。
从语义上来说,原子操作可以用互斥体来实现,只不过原子操作要快得多。
例如:
var val int32
...
newval = atomic.AddInt32(&val, delta)
等价于:
var val int32
var mutex sync.Mutex
...
mutex.Lock()
val += delta
newval = val
mutex.Unlock()
执行体的互斥
互斥体也叫锁。锁用于多个执行体之间的互斥访问,避免多个执行体同时操作一组数据产生竞争。其使用界面上大概是这样的:
func (m *Mutex) Lock()
func (m *Mutex) Unlock()
锁的使用范式比较简单:在操作需要互斥的数据前,先调用 Lock操作完成后就调用 Unlock。但总是存在一些不求甚解的人对锁存在各种误解。
有的人会说锁很慢。甚至我曾看到有 Go 程序员用 channel 来模拟锁理由就是锁太慢了尽量不要用锁。产生“锁慢channel 快”这种错觉的一个原因,可能是人们经常看到这样的忠告:
不要通过共享内存来通信要通过通信channel来共享内存。
不明就里的人们看到这话后可能就有了这样的印象锁是坏的锁是性能杀手channel 是好的,是 Go 发明的先进武器,应该尽可能用 channel而不要用锁。
快慢是相对而言的。锁的确会导致代码串行执行,所以在某段代码并发度非常高的情况下,串行执行的确会导致性能的显著降低。但平心而论,相比其他的进程内通讯的原语来说,锁并不慢。从进程内通讯来说,比锁快的东西,只有原子操作。
例如 channel作为进程内执行体间传递数据的设施来说它本身是共享变量所以 channel 的每个操作必然是有锁的。事实上channel 的每个操作都比较耗时。关于这一点,在下文解释 channel 背后的工作机理后,你就会清楚知道。
那么锁的问题在哪里?锁的最大问题在于不容易控制。锁 Lock 了但是忘记 Unlock 后是灾难性的,因为相当于服务器挂了,所有和该锁相关的代码都不能被执行。
比如:
mutex.Lock()
doSth()
mutex.Unlock()
在考虑异常的情况下,这段代码是不安全的,如果 doSth 抛出了异常,那么服务器就会出现问题。
为此 Go 语言还专门发明了一个 defer 语法来保证配对:
mutex.Lock()
defer mutex.Unlock()
doSth()
这样可以保证即使 doSth 发生异常mutex.Unlock 仍然会被正确地执行。这类在异常情况下也能够正常工作的代码,我们称之为 “对异常安全的代码”。如果语言不支持 defer而是支持 try .. catch那么代码可能是这样的
mutex.Lock()
try {
doSth()
} catch (e Exception) {
mutex.Unlock()
throw e
}
mutex.Unlock()
锁不容易控制的另一个表现是锁粒度的问题。例如上面 doSth 函数里面如果调用了网络 IO 请求,而网络 IO 请求在少数特殊情况下可能会出现慢请求,要好几秒才返回。那么这几秒对服务器来说就好像挂了,无法处理请求。
对服务器来说这是极为致命的。对后端程序员来说,有一句箴言要牢记:
不要在锁里面执行费时操作。
这里 “锁里面” 是指在mutex.Lock和mutex.Unlock之间的代码。
在锁的最佳编程实践中,如果明确一组数据的并发访问符合 “绝大部分情况下是读操作,少量情况有写操作” ,这种 “读多写少” 特征,那么应该用读写锁。
所谓读写锁,是把锁里面的操作分为读操作和写操作两种,对应调用不同的互斥操作。
如果是读操作,代码如下:
mutex.RLock()
defer mutex.RUnlock()
doReadOnlyThings
如果是锁里面是写操作,代码就和普通锁一样,如下:
mutex.Lock()
defer mutex.Unlock()
doWriteThings
为什么在 “读多写少” 的情况下,这样的使用范式能够优化性能?
因为从需求上来说,如果当前我们正在执行某个读操作,那么再来一个新的读操作,是不应该挡在外面的,大家都不修改数据,可以安全地并发执行。但如果来的是写操作,就应该挡在外面,等待读操作执行完。整体来说,读写锁的特性就是:
读操作不阻止读操作,阻止写操作;-
写操作阻止一切,不管读操作还是写操作。
执行体的同步
聊完了执行体的互斥,我们再来看下执行体之间的同步。
同步的一个最常见的场景是把一个大任务分解为n个小任务分配给n个执行体并行去做等待它们一起做完。这种同步机制我们叫 “等待组”。
其使用界面上大概是这样的:
func (wg *WaitGroup) Add(n int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()
用法上大概是这样的:
var wg WaitGroup
...
wg.Add(n)
for 循环n次 {
go func() {
defer wg.Done()
doTaski // 执行第i个任务
}()
}
wg.Wait()
简而言之,在每个任务开始的时候调用 wg.Add(1),结束的时候调用 wg.Done(),然后在主执行体调用 wg.Wait() 等待这些任务结束。
需要注意的是wg.Add(1) 是要在任务的 goroutine 还没有开始就先调用,否则可能出现某个任务还没有开始执行就被认为结束了。
条件变量Condition Variable是一个更通用的同步原语设计精巧又极为强大。强大到什么程度像 channel 这样的通讯机制都可以用它来实现。
条件变量的使用界面上大概是这样的:
func NewCond(l Locker) *Cond
func (c *Cond) Broadcast()
func (c *Cond) Signal()
func (c *Cond) Wait()
那么,怎么用条件变量?
我们先看下初始化。条件变量初始化的时候需要传入一个互斥体它可以是普通锁Mutex)也可以是读写锁RWMutex。如下
var mutex sync.Mutex // 也可以是 sync.RWMutex
var cond = sync.NewCond(&mutex)
...
为什么创建条件变量需要传入锁?因为 cond.Wait() 的需要。Wait 内部实现逻辑是:
把自己加入到挂起队列
mutex.Unlock()
等待被唤醒 // 挂起的执行体会被后续的 cond.Broadcast 或 cond.Signal() 唤醒
mutex.Lock()
初始化了条件变量后,我们再来看看它的使用方式。条件变量的用法有一个标准化的模板,看起来大概是这样的:
mutex.Lock()
defer mutex.Unlock()
for conditionNotMetToDo {
cond.Wait()
}
doSomething
if conditionNeedNotify {
cond.Broadcast()
// 有时可以优化为 cond.Signal()
}
看起来有些复杂,让我们来解释一下。加锁后,先用一个 for 循环判断当前是否能够做我们想做的事情,如果做不了就调用 cond.Wait() 进行等待。
这里很重要的一个细节是注意用的是 for 循环,而不是 if 语句。这是因为 cond.Wait() 得到了执行权后不代表我们想做的事情就一定能够干了,所以要再重新判断一次条件是否满足。
确定能够做事情了,于是 doSomething。在做的过程中间如果我们判断可能挂起队列中的部分执行体满足了重新执行的条件就用 cond.Broadcast 或 cond.Signal 唤醒它们。
cond.Broadcast 比较粗暴,它唤醒了所有在这个条件变量挂起的执行体,而 cond.Signal 则只唤醒其中的一个。
什么情况下应该用 cond.Broadcast什么情况下应该用 cond.Signal最偷懒的方式当然是不管三七二十一用 cond.Broadcast 一定没问题。但是本着经济的角度,我们还是要交代清楚 cond.Signal 的适用范围:
挂起在这个条件变量上的执行体,它们等待的条件是一致的;
本次 doSomething 操作完成后,所释放的资源只够一个执行体来做事情。
Cond 原语虽然叫条件变量,但是实际上它既没有明白说变量具体是什么样的,也没有说条件具体是什么样的。变量是指 “一组要在多个执行体之间协同的数据”。条件是指做任务前 Wait 的 “前置条件”,和做任务时需要唤醒其它人的 “唤醒条件”。
这样的介绍相当的抽象。我们拿 Go 语言的 channel 开刀,自己实现一个。代码如下:
type Channel struct {
mutex sync.Mutex
cond *sync.Cond
queue *Queue
n int
}
func NewChannel(n int) *Channel {
if n < 1 {
panic("todo: support unbuffered channel")
}
c := new(Channel)
c.cond = sync.NewCond(&c.mutex)
c.queue = NewQueue()
// 这里 NewQueue 得到一个普通的队列
// 代码从略
c.n = n
return c
}
func (c *Channel) Push(v interface{}) {
c.mutex.Lock()
defer c.mutex.Unlock()
for c.queue.Len() == c.n { // 等待队列不满
c.cond.Wait()
}
if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
c.cond.Broadcast()
}
c.queue.Push(v)
}
func (c *Channel) Pop() (v interface{}) {
c.mutex.Lock()
defer c.mutex.Unlock()
for c.queue.Len() == 0 { // 等待队列不空
c.cond.Wait()
}
if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
c.cond.Broadcast()
}
return c.queue.Pop()
}
func (c *Channel) TryPop() (v interface{}, ok bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.queue.Len() == 0 { // 如果队列为空,直接返回
return
}
if c.queue.Len() == c.n { // 原来队列是满的,可能有人等着写数据,通知它们
c.cond.Broadcast()
}
return c.queue.Pop(), true
}
func (c *Channel) TryPush(v interface{}) (ok bool) {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.queue.Len() == c.n { // 如果队列满,直接返回
return
}
if c.queue.Len() == 0 { // 原来队列是空的,可能有人等待数据,通知它们
c.cond.Broadcast()
}
c.queue.Push(v)
return true
}
对着这个 Channel 的实现,你是否对条件变量有感觉很多?顺便提醒一点,这个 Channel 的实现不支持无缓冲 channel也就是不支持 NewChannel(0) 的情况。如果你感兴趣,可以改改这个问题。
执行体的通讯
聊完同步与互斥,我们接着聊执行体的通讯:怎么在执行体间收发消息。
管道是大家都很熟知的执行体间的通讯机制。规格如下:
func Pipe() (pr *PipeReader, pw PipeWriter)
用法上先调用pr, pw := io.Pipe()得到管道的写入端和读出端,分别传给两个并行执行的 goroutine其他语言也类似然后一个 goroutine 读,一个 goroutine 写就好了。
管道用处很多。一个比较常见的用法是做读写转换,例如,假设我手头有一个算法:
func Foo(w io.Writer) error
这个算法生成的数据流,需要作为另一个函数的输入,但是这个函数的输入是 io.Reader原型如下
func Bar(r io.Reader)
那么怎么把它们串起来呢?用管道我们很容易实现这样的变换:
func FooReader() io.ReadCloser {
pr, pw := io.Pipe()
go func() {
err := Foo(pw)
pw.CloseWithError(err)
}()
return pr
}
这个 FooReader 函数几句话就把 Foo 变成了一个符合 io.Reader 接口的对象,它就可以很方便的和 Bar 函数结合了。
其实 Go 语言中引入的 channel 也是管道,只不过它是类型安全的管道。具体用法如下:
c := make(chan Type, n) // 创建一个能够传递 Type 类型数据的管道,缓冲大小为 n
...
go func() {
val := <-c // 从管道读入
}()
...
go func() {
c <- val // 向管道写入
}()
我们后面在 “服务端开发” 一章,我们还会比较详细讨论 channel今天先了解一个大体的语义。
结语
总结一下,我们今天主要聊了执行体间的协同机制:原子操作、同步、互斥与通讯。我们重点聊了锁和同步原语 “条件变量”。
锁在一些人心中是有误解的,但实际上锁在服务端编程中的比重并不低,我们可能经常需要和它打交道,建议多花精力理解它们。
条件变量是最复杂的同步原语,功能强大。虽然平常我们直接使用条件变量的机会不是太多,大部分常见的场景往往有更高阶的原语(例如 channel可以取代。但是它的设计精巧而高效值得细细体会。
你会发现,操作系统课本上的信号量这样的同步原语,我们这里没有交代,这是因为它被更强大而且性能更好的同步原语 “条件变量” 所取代了。
上面我们为了介绍条件变量的用法,我们实作了一个 channel你也可以考虑用信号量这样的东西来实现一遍然后分析一下为什么我们说基于 “条件变量” 的版本是更优的。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。在下期,我们将讨论进程与进程之间的协同:进程间的同步互斥、资源共享与通讯。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,204 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 进程间的同步互斥、资源共享与通讯
你好,我是七牛云许式伟。
在上一讲,我们介绍了进程内执行体之间的协同机制。今天我们接着聊进程与进程之间的协同。
这些协同机制大体可分为:互斥、同步、资源共享以及通讯等原语。对于这些协同机制,我们对比了 Linux、Windows、iOS 这三大操作系统的支持情况,整理内容如下:
在逐一详细分析它们之前,我们先讨论一个问题:从需求角度来讲,进程内协同与进程间协同有何不同?
在早期,操作系统还只有进程这个唯一的执行体。而今天,进程内的执行体(线程与协程)被发明出来并蓬勃发展,事情发生了怎样的变化?
请先思考一下这个问题。我们在这一讲最后总结的时候一起聊聊。
启动进程
在讨论进程间的协同前,我们先看下怎么在一个进程中启动另一个进程。这通常有两种方法:
创建子进程;
让Shell配合执行某个动作。
前面在 “[11 | 多任务:进程、线程与协程]” 一讲中我们已经提到过,创建子进程 UNIX 系的操作系统都用了 fork API它使用上很简洁但是从架构角度来说是一个糟糕的设计。Windows 中我们用 CreateProcess这个函数有很多的参数。
iOS 很有意思,它并不支持创建子进程。在进程启动这件事情上,它做了两个很重要的变化:
软件不再创建多个进程实例,永远是单例的;
一个进程要调用另一个进程的能力,不是去创建它,而是基于 URL Scheme 去打开它。
什么是 URL Scheme ?我们平常看到一个 URL 地址。比如:
https://www.qiniu.com/
ftp://example.com/hello.doc
这里面的 https 和 ftp 就是 URL Scheme它代表了某种协议规范。在 iOS 下,一个软件可以声明自己实现了某种 URL Scheme比如微信可能注册了“weixin”这个 URL Scheme那么调用
UIApplication.openURL("weixin://...")
都会跳转到微信。通过这个机制,我们实现了支付宝和微信支付能力的对接。
URL Scheme 机制并不是 iOS 的发明它应该是浏览器出现后形成的一种扩展机制。Windows 和 Linux 的桌面也支持类似的能力,在 Windows 下调用的是 ShellExecute 函数。
同步与互斥
聊完进程的启动,我们正式开始谈进程间的协同。
首先我们来看一下同步和互斥体。从上一讲 “[12 | 进程内协同:同步、互斥与通讯]”看,同步互斥相关的内容有:
Mutex
读写锁RWMutex
信号量Semaphore
等待组WaitGroup
条件变量Cond
进程间协同来说主流操作系统支持了锁Mutex和信号量Semaphore。Windows 还额外支持了事件Event同步原语这里我们略过不提。
进程间的锁Mutex语义上和进程内没有什么区别只不过标识互斥资源的方法不同。Windows 最简单用名称Name标识资源iOS 用路径PathLinux 则用共享内存。
从使用接口看Windows 和 iOS 更为合理,虽然大家背后实现上可能都是基于共享内存(对用户进程来说,操作系统内核对象都是共享的),但是没必要把实现机理暴露给用户。
我们再看信号量。
信号量Semaphore概念是 Dijkstra学过数据结构可能会立刻回忆起图的最短路径算法对的就是他发明的提出来的。信号量本身是一个整型数值代表着某种共享资源的数量简记为 S。信号量的操作界面为 PV 操作。
P 操作意味着请求或等待资源。执行 P 操作 P(S) 时S 的值减 1如果 S < 0说明没有资源可用等待其他执行体释放资源
V 操作意味着释放资源并唤醒执行体。执行 V 操作 V(S) 时S 的值加 1如果 S <= 0则意味着有其他执行体在等待中唤醒其中的一个。
看到这里,你可能敏锐地意识到,条件变量的设计灵感实际上是从信号量的 PV 操作进一步抽象而来,只不过信号量中的变量是确定的,条件也是确定的。
进程间的同步与互斥原语并没有进程内那么丰富(比如没有 WaitGroup也没有 Cond甚至没那么牢靠。
为什么?因为进程可能会异常挂掉,这会导致同步和互斥的状态发生异常。比如,进程获得了锁,但是在做任务的时候异常挂掉,这会导致锁没有得到正常的释放,那么另一个等待该锁的进程可能就会永远饥饿。
信号量同样有类似的问题甚至更麻烦。对锁来说进程挂掉还可能可以把释放锁的责任交给操作系统内核。但是信号量做不到这一点操作系统并不清楚信号量的值S应该是多少才是合理的。
资源共享
两个进程再怎么被隔离,只要有共同的中间人,就可以相互对话(通讯)。中间人可以是谁?共享资源。进程之间都有哪些共享的存储型资源?比较典型的是:
文件系统;
剪贴板。
文件系统本身是因存储设备的管理而来。但因为存储设备本身天然是共享资源,某个进程在存储设备上创建一个文件或目录,其他进程自然可以访问到。
因此,文件系统天然是一个进程间通讯的中间人。而且,在很多操作系统里面,文件的概念被抽象化,“一切皆文件”。比如,命名管道就只是一种特殊的 “文件” 而已。
和文件系统相关的进程间协同机制有:
文件;
文件锁;
管道(包括匿名管道和命名管道);
共享内存。
这里我们重点介绍一下共享内存。
共享内存其实是虚拟内存机制的自然结果。关于虚拟内存的详细介绍,可以参阅 “[07 | 软件运行机制及内存管理]” 一讲。虚拟内存本来就需要在内存页与磁盘文件之间进行数据的保存与恢复。
将虚拟内存的内存页和磁盘文件的内容建立映射关系,在虚拟内存管理机制中原本就存在。
只需要让两个进程的内存页关联到同一个文件句柄,即可完成进程间的数据共享。这可能是性能最高的进程间数据通讯手段了。
Linux 的共享内存的使用界面大体是这样的:
func Map(addr unsafe.Pointer, len int64, prot, flags int, fd int, off int64) unsafe.Pointer
func Unmap(addr unsafe.Pointer, len int64)
其中Map 是将文件 fd 中的[off, off+len)区间的数据,映射到[addr, addr+len) 这段虚拟内存地址上去。
addr 可以传入 nil 表示选择一段空闲的虚拟内存地址空间来进行映射。Unmap 则是将[addr, addr+len)这段虚拟内存地址对应的内存页取消映射,此后如果代码中还对这段内存地址进行访问,就会发生缺页异常。
在 Windows 下共享内存的使用界面和 Linux 略有不同,但语义上大同小异,这里略过不提。
真正值得注意的是 iOS你会发现基于文件系统的进程间通讯机制一律不支持。为什么因为 iOS 操作系统做了一个极大的改变软件被装到了一个沙箱Sandbox里面不同进程间的存储完全隔离。
存储分为内存和外存。内存通过虚拟内存机制实现跨进程的隔离,这个之前我们已经谈到过。现在 iOS 更进一步,外存的文件系统也相互独立。软件 A 创建的文件,软件 B 默认情况下并不能访问。在一个个软件进程看来,自己在独享着整个外存的文件系统。
文件系统之外,进程间共享的存储型资源,就剩下剪贴板了。
但剪贴板并不是一个常规的进程间通讯方式。从进程间通讯角度来说它有很大的限制:剪贴板只有一个,有人共享数据上去,就会把别人存放的数据覆盖掉。
实践中,剪贴板通常作为一种用户实现跨进程交互的手段,而不太会被用来作为进程间的通讯。相反它更可能被恶意程序所利用。比如,写个木马程序来监听剪贴板,以此来窃取其他程序使用过程中留下的痕迹。
收发消息
那么,不用文件系统和剪贴板这样的共享资源,还有其他的通讯机制么?
有,基于网络。很重要的一个事实是:这些进程同在一台机器上,同在一个局域网中。
套接字作为网络通讯的抽象,本身就是最强大的通讯方式,没有之一。进程间基于套接字来进行通讯,也是极其自然的一个选择。
况且UNIX 还发明了一个专门用于本地通讯的套接字UNIX 域。UNIX 域不同于常规套接字的是,它通过一个 name 来作为访问地址而不是用ip:port来作为访问地址。
Windows 平台并不支持 UNIX 域。但是有趣的是Windows 的命名管道NamedPipe也不是一个常规意义上的管道那么简单它更像是一个管道服务器PipeServer一个客户端连上来可以分配一个独立的管道给服务器和客户端进行通讯。从这个事实看Windows 的命名管道和 UNIX 域在能力上是等价的。
关于套接字更详细的内容,后文在讨论网络设备管理时我们会进一步介绍。
架构思维上我们学习到什么?
对比不同操作系统的进程间协同机制,差异无疑是非常巨大的。
总结来说进程间协同的机制真的很多了五花八门我们这里不见得就列全了。但是有趣的是iOS 把其中绝大部分的协同机制给堵死了。
创新性的系统往往有其颠覆性,带着批判吸收的精神而来,做的是大大的减法。
iOS 就是这样的一个操作系统。它告诉我们:
软件不需要启动多份实例。一个软件只需启动一个进程实例。
大部分进程间的协同机制都是多余的。你只需要能够调用其他软件的能力URL Scheme、能够互斥、能够收发消息就够了。
这的确是一个让人五体投地的决策。虽然从进程间协同机制的角度,看起来 iOS 少了很多能力。但这恰恰也给了我们一个启示:这么多的进程通讯机制,是否都是必需的?
至少从桌面操作系统的视角看,进程间协同的机制,大部分都属于过度设计。当然,后面在 “服务端开发” 一章中,我们也会继续站在服务端开发视角来谈论这个话题。
并不是早期操作系统的设计者们喜欢过度设计。实际上这是因为有了线程和协程这样的进程内多任务设施之后,进程的边界已经发生了极大的变化。
前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。那么需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:
子系统职责范围的定义;
子系统的规格(接口),子系统与子系统之间的边界;
需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。
从架构角度来看,进程至少应该是子系统级别的边界。子系统和子系统应该尽可能是规格级别的协同,而不是某种实现框架级别的协同。规格强调的是自然体现需求,所以规格是稳定的,是子系统的契约。而实现框架是技巧,是不稳定的,也许下次重构的时候实现框架就改变了。
所以站在架构视角,站在子系统的边界看进程边界,我们就很清楚,进程间协同只需要有另一个进程能力的调用,而无需有复杂的高频协作、高度耦合的配合需求。
不过,为什么 iOS 会如此大刀阔斧地做出改变,除了这些机制的确多余之外,还有一个极其核心的原因,那就是安全。关于这一点,我们在后面探讨操作系统的安全管理时,会进一步进行分析。
结语
今天我们从进程启动开始入手,介绍了同步与互斥、资源共享、收发消息等进程间的协同机制。通过对比不同操作系统,我们会发现以 “剧烈变动” 来形容进程间协同的需求演进一点也不过分。
我认为 iOS 是对的。大刀阔斧干掉很多惯例要支持的功能后,进程这个执行体,相比线程和协程就有了更为清晰的分工。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到这一讲为止,我们单机软件相关的内容就介绍完了。从下一讲开始我们将进入多姿多彩的互联网世界。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,228 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 IP 网络:连接世界的桥梁
你好,我是七牛云许式伟。
到目前为止,我们介绍了操作系统的六大子系统中的四项:进程、存储、输入、输出。当你理解了这些东西背后的道理,基本上做一款单机软件就游刃有余了。
但是,如果仅仅局限于单机,一台计算机并不见得比计算器高明太多,网络对整个信息科技的重要性不言而喻。它让计算机连接在了一起,这一连接就发生了巨大的变化。
没有了网络,我们只能用 Office 软件,玩玩扫雷。没有网络,就没有 QQ 和微信,不会有淘宝和支付宝,也不会有 BAT。
网络连接一切。它连接了人(个人和企业)、服务(由软件系统构建的服务接口)和物(大自然产物和智能终端),构建了多姿多彩的互联网。
它让地球上的任何两个人都可以随时随地进行沟通,远程做生意。在互联网出现之前,旧的商业文明我们可以一言以蔽之:一手交钱,一手交货。而建立在互联网之上的新商业文明,我们一手下单付款,一手收钱发货,足不出户,货物就通过便捷的物流服务送到了你手上。
这是多么巨大的效率变革,但这一切是怎么做到的呢?
数据的封包过程
网络和其他所有的输入输出设备一样,只能交换数据。无论你要对方做什么,你首先需要发送对方理解得了的数据给它。所以双方要就沟通的语言达成共识,这就是网络协议。
网络协议是计算机与计算机远程沟通的数据格式。它包含很多信息。这些信息不同部分的内容,有不同的职责,关心它的人也各有不同。
网络是传递数据的,是数字内容的物流。作为类比,我们可以看看实物快递的物流协议是什么样的。下面这张快递面单大家应该都很熟悉:
这个快递面单包含很多内容,其中最重要的当然是寄件人信息和收件人信息。有了收件人信息,物流系统才能够知道怎么把要邮寄的物品进行一站站中转,并最终到达目的地。
有了寄件人信息,收件人收到信息才知道是谁寄过来的,如果回复的话应该邮寄往何方。而在物流过程如果出错的话,物流系统也知道如何与寄件人沟通协调错误的处置方法。
寄件人关心什么?他很可能关心物流订单号。这是他掌握物流状态的唯一凭据。另外,为了传输过程的便捷,物流系统可能还会给我们要邮寄的物品用信封袋或者包装箱进行封装。
所有这些寄件人信息、收件人信息、物流订单号、信封袋、包装箱,都不是寄件人要邮寄的内容,而是物流系统对物流协议所产生的需求。
为了支持整个物流系统的不间断运转,我们会有很多不同的部门。有负责最后一公里的快递员,也有负责骨干线路的航空运输部门,或者火车货运部门等等。
同样的数字物流系统也有很多不同的部门有的部门负责局域网LAN内最后一公里的也有人负责广域网WAN骨干线路的运输的。网络协议作为数字物流的载体会收到来自这些不同部门的需求。
我们平常可能经常听人提及,网络协议有 OSI 模型,它把网络协议分成了七层结构,从上到下分别是:应用层、表示层、会话层、传输层、网络层、数据链路层、物理层。但这样描述有点过于抽象,我们不妨用下图来理解网络协议的分层结构。
让我们设想:我们要传输一部电影,它就是我们的 “物品”,或者用技术术语叫 “应用层数据”,怎么传?
第一步,分批次。 数字物流系统单次能够传递的数据大小是有限的。如果数据太大就必须分开多次来传输。从物理网络视角看数据并不是流stream而是一个个大小有明确限制的数据包。
第二步,套上信封,写好寄件单位的部门(源端口号)、收件单位的部门(目标端口号)、批次编号。 为什么要写部门(端口号)?方便知道由谁(哪个应用程序软件)来负责收件。为什么要写批次编号?是为了防止寄丢了,寄丢了就得重新传一份该批次编号的东西。
这层我们叫传输层。它主要是为了解决传输可靠性的问题。当然传输层有两套协议(两种信封),一套是 TCP 协议,另一套是 UDP 协议。UDP 协议不保证对方一定收到,信封上就没写批次编号。
第三步,再套上一层信封,上面写上寄件单位地址(源 IP 地址)、收件单位地址(目标 IP 地址)。 有了地址这封信在广域网WAN上流转就知道自己应该去往何方了。这一层叫做网络层它定义的信封格式叫 IP 协议。
互联网的复杂性是在于,它不是一家数字物流公司的事。信寄到某处,可能就换一家物流公司了。它是所有的数字物流公司通力协作的结果。
所以 IP 协议最核心的意义是标准化,解决跨物流公司传输的问题。为什么我们要这样一层信封套一层信封?因为越往外层的信封,内容越和具体的物流公司相关。但无论你外层套的信封如何各有不同,拆到这一层,信的格式就是标准化的。
第四步,再套上一层信封,这层我们叫它数据链路层。 具体信封上写什么,完全是具体负责这段路程的物流公司说了算。当信件从一个物流公司转到另一个物流公司做交接的时候,这一层信封拆掉,重新换上新的信封。
数据链路层的信封格式网络协议非常多样化。局域网LAN现在最流行的是以太网Ethernet协议广域网WAN现在常见的有 HDLC、PPP、Frame-Relay 等网络协议。
无论如何,写完了特定物流公司所需要的信息,信件就可以进入数字物流系统(物理层)去流转了。
网络协议
上面那一层套一层的信件(网络协议)放的是用户要邮寄的东西,比如一部电影。但为了支持整个邮寄过程的顺利进行,获得更好的用户体验,还会有一些辅助用途的信件(网络协议)在网络上传递,有的是面向用户的,有的是面向数字网络系统内部的。
完整来说,在整个数字物流系统中,与数据传输这件事本身有关的网络协议,我们整理如下:
在这个图中链路层协议最为复杂MAC+LLC、PPP、HDLC、Frame-Replay这些是目前最为常见的。未来也必然会出来很多新的网络通讯技术用的是全新的协议。链路层之上IP -> TCP/UDP这些协议我们最为耳熟能详上面我们也已经介绍过了。
其他都还有些什么?除了 ICMP 和 IGMP 协议,这些协议都和网络地址的解析有关。所以,在谈协议用途前,我们先聊一聊网络地址。
要通讯,首先要有地址。数字物流世界的地址有三层。最底下的是链路层地址。不同链路层协议的地址表示非常不同。
比如局域网所采纳的以太网Ethernet协议用的是 MAC 地址。一台计算机有一个或多个网卡,每个网卡会有自己的唯一标识即 MAC 地址。这个标识跟随网卡设备存在和网络环境无关。你把计算机从北京搬到上海MAC 地址保持不变。
链路层的网络地址我们平常接触并不多,常规我们理解的网络地址是位于第二层的 IP 地址。
IP 地址类似于门牌号你家住在哪个城市哪条路几号。它决定了网络路由怎么走信息如何到达你的计算机网卡。IP 地址已经发展了两代,分别为 IPv4 和 IPv6。升级的原因是 IPv4 地址空间太小,只有 4G即40多亿个地址。
就像我们通常会更喜欢用 “我要去金茂大厦” 而不是 “我要去上海市浦东新区世纪大道88号” 一样IP地址并不容易记忆所以就有了第三层的网络地址域名。
比如,我们会用 www.qiniu.com 这个地址来找到七牛云的官网,而不是记住枯燥的 IP 地址。
理解了这三类网络地址,我们一一介绍下这些协议的用途。
首先是 DNS 协议。 这个协议就像是个地址簿,主要负责 “域名” => “IP地址” 的查询。每次我们要邮寄信件之前都要拿出来查一查。
其次是 DHCP 协议。 DHCP 全称叫动态主机配置协议Dynamic Host Configuration Protocol主要负责计算机接入网络时的初始化。计算机刚开始就只有网卡的 MAC 地址,通过 DHCP 可以给它分配 IP 地址,并得到默认网关地址(这很重要,不知道网关就上不了网)和 DNS 服务器的地址。有了这些东西,这台计算机就可以和外界通讯了。
然后是 ARP 协议。 ARP 全称叫地址解析协议Address Resolution Protocol它服务于现在局域网中最流行的以太网协议。在以太网中ARP 协议负责解析远程主机 IP 地址对应的 MAC 地址。之所以需要 ARP 协议,是因为我们平常应用程序连接目标计算机进行网络通讯时,都是提供了域名或 IP 地址。但对以太网来说,要想发信件出去,它要的是对方的 MAC 地址。
然后是 RARP 协议。 RARP 全称叫反向地址转换协议Reverse Address Resolution Protocol。顾名思义它和 ARP 协议相反,负责的是 MAC 地址到 IP 地址的转换。RARP 协议已经被上面的 DHCP 协议所取代,平常用不太到了。
然后是 ICMP 协议。 ICMP 全称叫互联网控制报文协议Internet Control Message Protocol它能够检测网路的连线状况以保证连线的有效性。基于这个协议实现的常见程序有两个ping 和 traceroute它们可以用来判断和定位网络问题。
最后是 IGMP 协议。 IGMP 全称叫互联网组管理协议Internet Group Management Protocol它负责 IP 组播Multicast成员管理。本文略过这块的内容。
数据传输过程
了解了数据包的结构,也了解了数据传输相关的网络协议,接下来我们聊一聊数据传输的过程。为了方便理解,我们画了一幅数据传输的示意图:
简化理解来说,我们可以认为,在需要传输数据的源主机和目标主机之间,它们通过若干路由器或交换机连接。我们分以下几种情况来分析:
情形一,源主机和目标主机在同一个局域网内,中间通过交换机连接,采用了最常见的以太网协议。
通讯开始的时候,源主机只有目标主机的 IP 地址,并没有 MAC 地址。但以太网通讯要的是 MAC 地址,所以源主机会发起一个 ARP 请求去获得目标 IP 对应的 MAC 地址。
当然,源主机会缓存这个对应关系。第二次继续给相同 IP 发信息的时候,就不需要重新发起 ARP 请求了。
无论是 ARP 请求还是普通的数据包都会先到达交换机。ARP 是一个广播请求,所以交换机会转发给所有其他主机,目标主机发现这个 IP 地址是自己的,于是返回自己的 MAC 地址。
有了目标主机的 MAC 地址,源主机就可以发数据了。同样的,所有数据包都发给了交换机。
交换机是性能极高的网络数据交换设备。它通常工作在网络协议的第二层,也就是数据链路层。这一层只认 MAC 地址,不认 IP 地址。MAC 地址本身是个唯一身份标识,就像我们的身份证号,并没有可寻址的作用。那么交换机怎么做到这么高的数据传输的效率?
交换机在工作的过程中会不断地收集资料去创建一个地址映射表MAC 地址 => 交换机端口。这个表很简单它记录了某个MAC 地址是在哪个端口上被发现的。
交换机收到一个数据包后,首先会进行学习,把源 MAC 地址和收到数据包的交换机端口对应起来。然后交换机查看数据包的目标 MAC 地址,并在地址映射表中找,如果找到对应的端口,那么就往这个端口转发数据包。
如果没找到,交换机可能会把这个数据包 “扩散” 出去,就好像收到广播数据包一样。这时如果目标主机收到广播过来的数据包后,回复了这个数据包,那么它的 MAC 地址和交换机端口的映射关系就也会被学习到。
当交换机初次加入网络时,由于地址映射表是空的,所以,所有的数据包将发往局域网内的全部端口,直到交换机 “学习” 到各个MAC 地址为止。这样看来,交换机刚刚启动时与传统的共享式集线器类似,直到地址映射表比较完整地建立起来后,它才真正发挥它的高性能。
我们总结一下,当一台交换机安装配置好之后,其工作过程如下。
收到某端口设为AMAC 地址为 X 的计算机发给 MAC 地址为 Y 的计算机的数据包。交换机从而记下了 MAC 地址 X 在端口 A。这称为学习learning
交换机还不知道 MAC 地址 Y 在哪个端口上,于是向除了 A 以外的所有端口转发该数据包。这称为泛洪flooding
MAC 地址 Y 的计算机收到该数据包,向 MAC 地址 X 发出确认包。交换机收到该包后,从而记录下 MAC 地址 Y 所在的端口。
交换机向 MAC 地址 X 转发确认包。这称为转发forwarding
交换机收到一个数据包查表后发现该数据包的来源地址与目的地址属于同一端口。交换机将不处理该数据包。这称为过滤filtering
交换机内部的 “MAC 地址 => 端口” 查询表的每条记录采用时间戳记录最后一次访问的时间。早于某个阈值用户可配置的记录被清除。这称为老化aging
情形二,源主机和目标主机都有公网 IP 地址,它们中间经过若干交换机和路由器相连。
路由器和交换机不太一样,交换机因为没有门牌号,通讯基本靠吼。好的一点是,圈子比较小,吼上一段时间后,路都记住了,闭着眼睛都不会走错。
但广域网太大了,靠吼没几个人听得见。所以路由器工作在网络协议的第三层,也就是网络层。网络层看到的是 IP 协议,能够知道数据传输的源 IP 地址和目标 IP 地址。
有了 IP 地址,就相当于有了门牌号,开启导航按图索骥就可以把东西带过去了。这也是路由器为什么叫路由器的原因,它有导航(路由)功能,知道哪些目标 IP 地址的数据包应该往哪条路走的。
路由器可以拥有一部分交换机的能力,比如,如果发现请求是局域网内的话,也可以引入类似交换机那样的基于 MAC 地址的映射表实现高速通讯。但总体来说,路由器要考虑的问题复杂很多,因为涉及 “最佳路由路径” 的问题。
简单说,所谓 “最佳路由路径” 是指,到达目标主机的路有很多种可能性,我应该选择哪一条。大家在大学可能都学过带权的有向图,路由器面临的正是这种情况。而且情况可能更复杂的是,每一小段路径的权重都是动态的,因为网络状况一直在变。
如果你对路由算法感兴趣,可以在维基百科查找 “路由” ,进一步研究。
路由器除了解决路由问题,它往往还要解决异构网络的封包转换问题。作为局域网的接入方,它可能走的是固网或 WiFi 网络。
作为 Internet 的接入方,它可能走的是光纤宽带。所以它需要把局域网的数据链路层的封包解开并重组,以适应广域网数据链路协议的需求。
理解了以上两点,我们回到话题:广域网的两台具备公网 IP 的主机之间如何完成数据传输?
大体来说,整个过程如下。
首先,源主机发送的数据包,经由交换机(可选),到达本局域网的公网网关(路由器)。这个过程属于局域网内通讯,同情形一。
路由器收到了数据包,发现目标主机是 Internet 上的某个远端的目标主机,于是对数据包进行拆包重组,形成新的数据包。
循着自身的路由表,把这个新数据包层层转发,最后到达目标主机对应的公网网关(路由器)上。
路由器发现是发给本局域网内的目标主机,于是再拆包重组,形成新的数据包。
新数据包转到局域网内,经由交换机(可选),并最终到达目标主机。如此,整个数据传输过程就结束了。
情形三,源主机和目标主机至少有一方在局域网内且只有私有 IP 地址,它们中间经过若干交换机和路由器相连。
解释一下私有 IP 地址。在 IPv4 地址区间中,有一些区段,比如 10.0.0.0 ~ 10.255.255.255、172.16.0.0 ~ 172.31.255.255、192.168.0.0 ~ 192.168.255.255 ,这几个 IP 地址区间都是私有 IP 地址,只用于局域网内通讯。
常规来说,只有私有 IP 而没有公网 IP 的主机只能和局域网内的主机通讯,而无法和 Internet 上的其他主机相互通讯。
但这一点又和我们日常的感受不符:比如家庭用户往往网络结构是一个 WiFi 路由器连接公网,所有的家庭设备如手机、平板、笔记本,都以 WiFi 路由器为网关构成一个局域网。那么我们的这些设备是怎么上网的呢?
答案是 NATNetwork Address Translation网络地址转换技术。它的原理比较简单假设我们现在源主机用的IP+端口为 iAddr:port1经过 NAT 网关后NAT 将源主机的 IP 换成自己的公网 IP比如 eAddr端口随机分配一个比如 port2。
也就是从目标主机看来,这个数据包看起来是来自于 eAddr:port2。然后目标主机把数据包回复到 eAddr:port2NAT 网关再把它转发给 iAddr:port1。
也就是说NAT 网关临时建立了一个双向的映射表 iAddr:port1 <=> eAddr:port2一旦完成映射关系的建立在映射关系删除前eAddr:port2 就变成了 iAddr:port1 的 “替身”。这样,内网主机也就能够上网了。
NAT 网关并不一定是公网网关(路由器),它可以由局域网内任何一台有公网 IP 的主机担当。但显然如果公网网关担当 NAT 网关,链路的效率会高一点。
我们家用的 WiFi 路由器,就充当了 NAT 网关的作用,这也是我们能够上网的原因。
那么,最极端的情形,源主机和目标主机在不同的局域网内,且都没有公网 IP它们是否可以通讯呢
答案是不确定。
首先,在这种情况下,源主机和目标主机没法直接通讯,需要中间人去帮忙搭建通讯的链路。怎么做呢?找一个有公网 IP 的主机作为中间人服务器,目标主机向它发包,这样,在目标主机的 NAT 网关就形成了一对双向的映射表:
iDestAddr:portDest1 <=> eDestAddr:portDest2
然后,中间人服务器再把 eDestAddr:portDest2 告诉源主机。这样源主机就可以通过向 eDestAddr:portDest2 发送数据包来和目标主机 iDestAddr:portDest1 通讯了。
我们不少 P2P 软件就利用了这个技术实现 NAT 穿透,让两台不同内网的计算机相互能够直接通讯。
那么,答案为什么是不确定?因为上面这个机制只有在目标主机的 NAT 网关是 Full cone NAT即一对一one-to-oneNAT 网关时才成立。
什么是 Full cone NAT它是指 NAT 网关临时建立了 iAddr:port1 <=> eAddr:port2 双向映射后,任何主机给 eAddr:port2 发送数据包,都会被转给 iAddr:port1并不局限于构建这个映射时数据包发送的目标主机是谁。
但在其他类型的 NAT 网关下,一般都对回包的主机 IP 地址有约束。也就是说NAT 网关形成的双向映射表是因为哪个目标主机建立的,那么回包也必须来自哪台主机。
这种情况下,中间人服务器就没办法来搭桥让它们直接通讯了,数据包需要由中间人服务器来中转。
结语
总结一下,今天我们介绍了数据封包过程、与 IP 数据包传输相关的网络协议,并对数据传输过程做了整体的描述。
我们主要介绍的重点是 IP 协议之下的网络工作过程。我们不讨论如何进行数据重传,更不关心数据到达应用层我们收到数据包后,如何去处理它们。
互联网背后的世界,和互联网一样精彩。精妙之处,值得细细体会。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将进一步来探讨一下网络世界的编程接口。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,348 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 可编程的互联网世界
你好,我是七牛云许式伟。
前面我们讨论架构思维的时候说过,架构的第一步是做需求分析。需求分析之后呢?是概要设计。概要设计做什么?是做子系统的划分。它包括这样一些内容:
子系统职责范围的定义;
子系统的规格(接口),子系统与子系统之间的边界;
需求分解与组合的过程,系统如何满足需求、需求适用性(变化点)的应对策略。
对于我们理解这个精彩的互联网世界来说,理解它的子系统的划分思路是非常非常重要的。
网络应用程序的全视图
在上一讲 “[14 | IP 网络:连接世界的桥梁]” 中我们介绍了 IP 网络的工作原理。我们还画了一幅与数据传输这件事本身有关的网络协议图,如下:
那么,从一个典型的网络应用程序角度来说,它的完整视图又是什么样子的呢?
上图是我给出的答案。当然,它并不代表所有的网络应用程序,但这不影响我们借它的结构来解释网络世界是怎么划分子系统的,每个子系统都负责了些什么。
第一层是物理层。 你可以理解为网络设备的原生能力,它定义了硬件层次来看的基础网络协议。
第二层是数据链路层。 它负责解决的是局部网络世界的数据传输能力。网络数据传输技术会层出不穷今天主流有固网、WiFi、3G/4G明天有 5G/6G未来也必然还会出现更快速的网络新技术。
这些网络技术虽然都有自己独特的链路层协议,但都可以很自然融入整个互联网世界。原因在于什么?在于 IP 网络。
所以第三层是 IP 网络层,它负责的是互联网世界的一体化,彼此包容与协作。 如果拿单机的应用程序的全视图来类比的话IP 网络类似于单机体系中的操作系统。
在单机体系操作系统是一台计算机真正可编程的开始。同样地互联网世界的体系中IP 网络是互联网 “操作系统” 的核心,是互联网世界可编程的开始。
第四层是 TCP/UDP 传输层。 它也是互联网 “操作系统” 的重要组成部分,和 IP 网络一起构成互联网 “操作系统” 的内核。IP 网络解决的是网如何通的问题,而传输层解决的是如何让互联网通讯可信赖的问题,从而大幅降低互联网应用程序开发的负担。
互联网并不是世界上的第一张网。但是只有拥有了 TCP/IP 这一层 “操作系统”,这才真正实现了网络价值的最大化:连接一切。
有了操作系统,应用软件才得以蓬勃发展。上图我们列出的应用层协议,仅仅只是沧海一粟。但是,要说当前最主流的应用层协议,无疑当属 HTTP 协议超文本传输协议HyperText Transfer Protocol和 SMTP/POP3 协议了。
HTTP 协议是因为万维网World Wide Web简称 WWW这个应用场景而诞生冲着传输静态网页而去的。但是由于设计上的开放性几经演进到今天已经俨然成为一个通用传输协议了。
通用到什么程度DNS 地址簿这样的基础协议,也搞出来一个新的 HTTP DNS。当然今天 HTTP DNS 还只是传统 DNS 协议的补充,使用还并不广泛。但由此可知人们对 HTTP 协议的喜爱。
除了呈现网页之外HTTP 协议也经常被用来作为业务开放协议 RESTful API 的承载。另外,一些通用 RPC 框架也基于 HTTP 协议,比如 Google 的 gRPC 框架。
SMTP/POP3 协议是电子邮件Email应用所采用的它们没有像 HTTP 协议那么被广泛借用,只是局限于电子邮件应用领域。但 SMTP/POP3 协议使用仍然极为广泛,原因是因为电子邮件是最通用的连接协议,它连接了人和人,连接了企业和企业。
我们都很佩服微信的成功,因为它连接了几乎所有的中国人。但是相比电子邮件,微信仍然只是小巫见大巫,因为电子邮件连接了世界上的每一个人和企业。
这是怎么做到的?因为开放的力量。如果说有谁能够打败微信,那么我个人一个基本的思考是:用微信的方式打败微信恐怕很难,但微信是封闭协议,开放也许是一个打败微信的机会?
还有其他很多应用层协议上图没有列出来,比如 FTP、NFS、Telnet 等等。它们大都应用范围相对小,甚至有一些渐渐有被 HTTP 协议替代的趋势。
对于一个网络应用程序来说它往往还依赖存储和数据库DB/Storage。目前存储和数据库这块使用 HTTP 的还不多除了对象存储Object Storage大部分还是直接基于 TCP 协议为主。
对象存储作为一种最新颖的存储类型,现在主流都是基于 HTTP 协议来提供 RESTful API比如七牛云的对象存储服务。
所以你可以看到,网络应用程序所基于的基础平台,比单机软件要庞大得多。前面我们介绍的单机软件所依赖的 CPU + 编程语言 + 操作系统就不说了,它一样要依赖。
上图所示的网络世界所构建的庞大基础平台,从物理层 -> 数据链路层 -> 网络层 -> 传输层 -> 应用平台层,也都是我们业务架构的依赖点。选择自定义网络协议,基于 gRPC还是基于 HTTP 提供 RESTful API ?这是架构师需要做出的决策之一。
应用层协议与网关
上一讲 “[14 | IP 网络:连接世界的桥梁]” 中我们谈到两台主机是如何通讯时,我们介绍了让局域网主机能够上网的 NAT 技术。NAT 网关本质上是一个透明代理(中间人),工作在网络协议的第四层,即传输层,基于 TCP/UDP 协议。
如果我们限定传输的数据包一定是某种应用层协议时,就会出现所谓的应用层网关,工作在网络协议的第七层,所以有时候我们也叫七层网关。
我们熟知的 Nginx、Apache 都可以用作应用层网关。应用层协议通常我们采用的是 HTTP/HTTPS 协议。
为什么 HTTP 协议这么受欢迎,甚至获得了传输层协议才有的待遇,出现专用的网关?
这得益于 HTTP 协议的良好设计。
我们一起来看一看 HTTP 协议长什么样。先看获取资源的 GET 请求Request
GET /abc/example?id=123 HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Accept: */*
HTTP 协议的请求Request分协议头和正文两部分中间以空行分隔。GET 请求一般正文为空。
协议头的第一行是请求的命令行,具体分为三部分,以空格分隔。第一部分为命令,常见有 GET、HEAD、PUT、POST、DELETE 等。第二部分是请求的资源路径。第三部分为协议版本。
协议头从第二行开始每行均为请求的上下文环境或参数我们不妨统一叫字段Field。格式为
字段名: 字段值
HTTP 服务器收到一个请求后往往会返回这样一个回复Response
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 68
ETag: W/"fb751fe2cb812eb5d466ed9e3c3cd519"
<html><head><title>Hello</title></head><body>qiniu.com</body></html>
HTTP 请求Request和回复Response格式上只有第一行不同。回复的第一行也分为三部分以空格分割。
第一部分为协议版本。
第二部分是状态码Status Code用来表征请求的结果200 表示成功4xx 通常表示请求Request本身不合法5xx 则通常表示 HTTP 服务器有异常。
第三部分是状态文本Status Text方便接收方看到回复后可以立刻判断问题而不用去查状态码对应的文档。
当协议正文非空的时候,往往还需要用 Content-Type 字段来指示协议正文的格式。例如这里我们用 text/html 表征返回的协议正文是一个 html 文档。Content-Length 字段则用来指示协议正文的长度。
我们再来看一下修改资源的 POST 请求:
POST /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/x-www-form-urlencoded;charset=utf-8
Content-Length: 18
id=123&title=Hello
和 GET 不一样,修改资源往往需要授权,所以往往会有 Authorization 字段。另外这里我们用 Content-Type 字段表示我们协议正文用了表单form格式。
最后我们看下删除资源的 DELETE 请求:
DELETE /abc/example HTTP/1.1
Host: api.qiniu.com
User-Agent: curl/7.54.0
Authorization: Qiniu dXNlcj14dXNoaXdlaSZwYXNzd2Q9MTIzCg
Content-Type: application/json
Content-Length: 11
{"id": 123}
删除和修改完全类似。除了我这里刻意换了一种 Content-Type协议正文用 json 格式了。实际业务中当然不是这样,通常会选择一致的表达方法。
大致了解了 HTTP 协议的样子,我们一起来分析一下它到底好在哪里?
毫无疑问,最关键的是它的协议头设计。具体表现在如下这些方面。
极其开放的协议头设计。虽然 HTTP 定义了很多标准的协议头字段Field但是用户还是可以加自己的字段惯例上以 X- 开头。例如,七牛引入了 X-Reqid 作为请求的内部调用过程的跟踪线索。关于 X-Reqid 本专栏后续我们还会继续谈到。
规范了业务的表达范式。虽然业务有千千万万种可能,但是实质上不外乎有什么资源,以及对资源的 CURD创建-修改-读取-删除。相对应地在HTTP 协议中以 “资源路径” 表达资源,以 PUT-POST-GET-DELETE 表达 CURD 操作(也有一些服务以 POST 而不是用 PUT 请求来创建资源)。
规范了应用层的路由方式。我们知道,在传输层网络的路由基于 IP 地址但是对于应用而言IP 地址是一个无意义的字段,在 HTTP 协议头中,有一个字段是强制的,那就是 Host 字段,它用来表征请求的目标主机。通常,在正式生产环境下它是个域名,比如 api.qiniu.com 。以域名来表征目标主机,无疑更加能够体现业务特性。故而,对应用层而言,“域名+资源路径” 是更好的路由依据,方便进行业务的切分。
正因为 HTTP 协议的这些好处,逐渐地它成为了网络应用层协议的模板。无论业务具体是什么样子的,都可以基于 HTTP 协议表达自己的业务逻辑。
TCP/IP 层编程接口
理解清楚了我们网络应用程序的结构,也理解了我们最主流的应用层协议 HTTP 协议,那么我们就可以考虑去实现一个互联网软件了。
从编程接口来说,网络的可编程性是从网络层 IP 协议开始。这是最底层的网络 “操作系统” 的能力体现。
从基于 IP 协议的网络视角来看数据并不是源源不断的流stream而是一个个大小有明确限制的 IP 数据包。IP 协议是无连接的,它可以在不连接对方的情况下向其发送数据。规格示意如下:
package net
type IPAddr struct {
IP IP
Zone string // IPv6 scoped addressing zone
}
func DialIP(network string, laddr, raddr *IPAddr) (*IPConn, error)
func ListenIP(network string, laddr *IPAddr) (*IPConn, error)
func (c *IPConn) Read(b []byte) (int, error)
func (c *IPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *IPConn) ReadFromIP(b []byte) (int, *IPAddr, error)
func (c *IPConn) Write(b []byte) (int, error)
func (c *IPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *IPConn) WriteToIP(b []byte, addr *IPAddr) (int, error)
func (c *IPConn) Close() error
IP 协议本身只定义了数据的目标 IP那么这个 IP 地址对应的计算机收到数据后,究竟应该交给哪个软件应用程序来处理收到的数据呢?
为了解决这个问题,在 IP 协议的基础上定义了两套传输层的协议UDP 和 TCP 协议。它们都引入了端口port的概念。
端口很好地解决了软件间的冲突问题。一个IP地址+端口,我们通常记为 ip:port代表了软件层面上来说唯一定位的通讯地址。每个软件只处理自己所使用的 ip:port 的数据。
当然,既然 IP 和端口被传输层一起作为唯一地址,端口上一定程度上缓解了 IPv4 地址空间紧张的问题。
虽然从设计者的角度来说,最初端口的设计意图,更多是作为应用层协议的区分。例如 port = 80 表示 HTTP 协议port = 25 表示 SMTP 协议。
应用协议的多样化很容易理解,这是应用的多样化决定的。尽管从架构的角度,我们并不太建议轻易去选择创造新的协议,我们会优先选择 HTTP 这样成熟的应用层协议。但是随着时间的沉淀,还是会不断诞生新的优秀的应用层协议。
但是为什么需要有多套传输层的协议TCP 和 UDP
还是因为应用需求是多样的。底层的 IP 协议不保证数据是否到达目标也不保证数据到达的次序。出于编程便捷性的考虑TCP 协议就产生了。
TCP 协议包含了 IP 数据包的序号、重传次数等信息,它可以解决丢包重传,纠正乱序,确保了数据传输的可靠性。
但是 TCP 协议对传输协议的可靠性保证,对某些应用场景来说并不是一个好特性。最典型的就是音视频的传输。在网络比较差的情况下,我们往往希望丢掉一些帧,但是由于 TCP 重传机制的存在,可能会反而加剧了网络拥塞的情况。
这种情况下UDP 协议就比较理想,它在 IP 协议基础上的额外开销非常小基本上可以认为除了引入端口port外并没有额外做什么非常适合音视频的传输需求。
编程接口来说TCP 的编程接口看起来是这样的:
package net
type TCPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Close() error
func (l *TCPListener) Accept() (Conn, error)
func (l *TCPListener) AcceptTCP() (*TCPConn, error)
func (l *TCPListener) Close() error
UDP 的编程接口看起来是这样的:
package net
type UDPAddr struct {
IP IP
Port int
Zone string // IPv6 scoped addressing zone
}
func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)
func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)
func (c *UDPConn) Read(b []byte) (int, error)
func (c *UDPConn) ReadFrom(b []byte) (int, Addr, error)
func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)
func (c *UDPConn) Write(b []byte) (int, error)
func (c *UDPConn) WriteTo(b []byte, addr Addr) (int, error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)
func (c *UDPConn) Close() error
对比看IP 和 UDP 的区别非常小,都是无连接的协议,唯一差别就是 UDPAddr 在 IPAddr 基础上增加了一个端口。也正因为如此,我们很少有应用程序会直接基于 IP 协议来编程。
客户端来说,无论 TCP 还是 UDP使用方式都很像其示意代码如下
c, err := net.Dial("tcp", addrServer)
c.Write(...)
c.Read(...)
c.Close()
net.Dial 背后会根据 network 字段选择调用 DialTCP 还是 DialUDP。然后我们就像操作一个文件一样来操作就行理解上非常简单只是 UDP 的读写在应用层面需要考虑可能会丢包。
但是服务端不太一样。服务端并不知道谁会给自己发信息,它只能监听自己的 “邮箱”,不时看看是不是有人来信了。
对于 TCP 协议,服务端示意代码如下:
l, err := net.Listen("tcp", addrServer)
for {
c, err := l.Accept()
if err != nil {
错误处理
continue
}
go handleConnection(c)
}
对于 UDP 协议,服务端示意代码如下:
c, err := net.ListenUDP("udp", addrServer)
for {
n, srcAddr, err := c.ReadFromUDP(...)
if err != nil {
错误处理
continue
}
// 根据 srcAddr.IP+port 确定是谁发过来的包,怎么处理
}
由于 TCP 基于连接connection所以每 Accept 一个连接后我们可以有一个独立的执行体goroutine去处理它。但是 UDP 是无连接的,需要我们手工根据请求的来源 IP+port 来判断如何分派。
HTTP 层编程接口
尽管基于 TCP/IP 层编程是一个选择,但是在当前如果没有特殊的理由,架构师做业务架构的时候,往往还是优先选择基于 HTTP 协议。
我们简单来看一下 HTTP 层的编程接口:
package http
func Get(url string) (*Response, error)
func Post(url, contentType string, body io.Reader) (*Response, error)
func PostForm(url string, data url.Values) (*Response, error)
func NewRequest(method, url string, body io.Reader) (*Request, error)
var DefaultClient = new(Client)
func (c *Client) Do(req *Request) (*Response, error)
func NewServeMux() *ServeMux
func (mux *ServeMux) Handle(pattern string, handler Handler)
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request))
func ListenAndServe(addr string, handler Handler) error
func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error
对于 HTTP 客户端,使用上要比 TCP/UDP 简单得多,常见情况下直接调用 Get、Post 这些函数调用就满足业务需求。
在需要在 HTTP 协议头写一些额外字段的,会略微麻烦一点,需要先 NewRequest 生成一个请求并添加一些字段Field然后再调用 Client.Do 去发起请求。整体上比调用 Read/Write 这样的基础 IO 函数要简便得多。
对于 HTTP 服务端,使用上的示意代码如下:
mux := http.NewServeMux()
mux.HandleFunc("/abc/example", handleAbcExampe)
mux.HandleFunc("/abc/hello/", handleAbcHello)
http.ListenAndServe(addServer, mux)
简单解释一下,一个 HTTP 服务器最基础的就是需要有根据 “资源路径” 的路由能力,这依赖 ServeMux 对象来完成。
简单对比可以看出,基于 HTTP 协议的编程接口,和基于 TCP/IP 协议裸写业务,其复杂程度完全不可同日而语。前者一个程序的架子已经呈现,基本上只需要填写业务逻辑就好。这也是采纳通用的应用层协议的威力所在。
结语
这一讲我们希望给大家呈现的是应用程序的全貌。当然,我们现在看到的仍然是非常高维的样子,后面在 “服务端开发” 一章,我们将进一步展开所有的细节。
在应用层协议介绍上,我们很难有全面的介绍,因而我们把侧重点放在 HTTP 协议的概要介绍上。同样,后面我们在 “服务端开发” 一章会进一步介绍 HTTP 协议。
最后,我们整理了基于 TCP/UDP 协议编程和基于 HTTP 协议编程的主体逻辑。虽然介绍非常简要,但通过对比我们仍然可以感受到业务架构基于成熟的应用层协议的优势所在。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。网络编程本章就到此结束,后面我们有专门的章节来进一步展开。下一讲,我们将探讨操作系统的最后一个子系统:安全管理。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,250 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 安全管理:数字世界的守护
你好,我是七牛云许式伟。今天我们要聊的话题是操作系统的最后一个子系统:安全管理。
数字世界是高效的,但数字世界也是脆弱的。在越来越多的日常生活被数字化的今天,安全问题也越来越凸显出了它的重要性。
有经验的安全工程师都知道,做好安全的基本逻辑是:不要开太多的门和窗,最好所有人都在同一道门进出,安全检查工作就可以非常便利地进行。
要想构建一个安全可靠的环境,从最底层就开始设计显然是最好的。所以安全管理是一个基础架构问题。现代操作系统必然会越来越关注安全性相关的问题。因为一旦安全问题严重到触及人们的心里防线,整个数字世界都有可能随之崩塌。
让我们从头回顾一下操作系统安全能力的演进。
病毒与木马
首先是实模式的操作系统,以微软的 DOS 系统为代表。实模式的操作系统进程都运行在物理地址空间下。
这意味着,每个软件进程都可以访问到其它软件进程(包括操作系统)的内存数据,也可以随意地修改它。所以这个时期的计算机是非常脆弱的,它选择的是信任模式:我相信你不会搞破坏。
不过,好在这个时期网络还并不发达,所以一个单机版本的恶意软件,能够干的真正恶意的事情也很有限。这一时期恶意软件以计算机病毒为主,其特征主要是繁衍自己(复制自己),对计算机系统本身做某种程度的破坏。
现代操作系统基本上都是保护模式的操作系统。保护模式就是让软件运行的内存地址空间隔离,进程之间相互不能访问(除非基于共享内存技术,那也是进程自己主动选择,与被动无感知的情况下被人窥视不同)。
这从安全角度来说,是很重要的进步。不管怎么说,内存数据是最为敏感的,因为它无所不包。况且,从 Windows 开始,互联网逐步进入人们的视野。计算机的联网,一下子让安全问题变得严峻起来。
恶意软件目的开始变得不单纯。它不再只是黑客的技术炫耀,而是切切实实的黑色产业链的关键依赖。
这一时期恶意软件开始以木马为主。木马和病毒一样会去繁衍自己(复制自己),但是它较少以破坏计算机的运行为目的,相反它默默隐藏起来,窃取着你的隐私。然后,它再通过互联网把窃取的信息默默地传递出去(比如通过电子邮件)。
哪些信息是木马感兴趣的?有很多。比如以下这些信息:
键盘按键;
剪贴板的内容;
内存数据;
文件系统中关键文件的内容;
……
你可能奇怪,前面不是说保护模式已经把内存数据隔离了么,为什么木马还是能够取到内存数据?
其实这一点不难想明白,虽然跨进程已经无法取得数据了,但是木马本来就是靠复制自己,把自己伪装成正常软件的一部分。这样,木马程序和正常的软件代码同属于一个进程内,所有信息对其仍然一览无余。
为了彻底阻止木马程序篡改正常的应用程序,聪明的操作系统创造者们想到了好方法:数字签名。
这本质上是白名单技术。所有正常发布的软件都到操作系统厂商那里登记一下。这样,一旦木马去修改软件,把自己附加上去,这个软件的签名验证就通不过,也就直接暴露了。
其实 Windows 操作系统已经引入了数字签名的概念,可以用以鉴别软件的可信度。但是考虑到从开放转向封闭有极大的历史负担,所以无论是 Windows 还是 macOS都没有完全杜绝无签名的软件最多当你运行无数字签名的软件时会给个不可信的警告。
第一个大规模把软件发布变成一个封闭环境的是苹果的 iOS 操作系统。 苹果通过引入 App Store要求所有应用发布都必须通过 App Store 进行。今天无论是 Android 还是 iOS 操作系统都基于应用市场这样的封闭软件发布的形态。
这样一来,软件无法被非法修改,木马基本上就无所遁形了。当然,这并不代表木马在这些平台上就消失了。虽然不容易,但是通过感染开发人员的软件开发环境,还是可以在软件编译或其它环节中把木马注入到要发布的软件中。
要发现这种异常iOS 和 Android 系统的厂商对软件进行数字签名前,往往会对其进行安全扫描,以发现各种潜在的安全风险。一旦某个软件被鉴定为恶意软件,就无法通过数字签名,也无法发布到应用市场上。
通过这些机制,木马很难再有机会得到传播。
软件的信息安全
但是,这意味着我们没有安全风险了么?当然不是。在移动设备上,安全问题的大环境发生了巨大的变化。
首先移动时代随着我们数字世界对现实生活影响的加深我们越来越多的敏感信息更加容易被软件触及。有很多新增的敏感信息是PC时代所不具备的例如
通讯录和通话记录;
短信;
个人照片和视频;
个人地理位置GPS信息
移动支付的支付密码、支付验证码;
录像和录音权限;
通话权限;
.……
正因为如此,尽管操作系统正变得越来越安全,但我们面临的安全威胁却也在日趋严重。
其实, iOS 操作系统在安全管理上的考虑不可谓不周全。
首先,在软件隔离机制上,除了基于 CPU 的保护模式确保软件之间的内存隔离外iOS 还引入了沙盒系统Sandbox确保软件之间文件系统隔离相互之间不能访问对方保存在磁盘上的文件。
其次,通过上面我们已经提及的数字签名机制,防止了软件被恶意篡改,让病毒和木马无法传播繁衍。
最后,对涉及敏感信息的系统权限进行管控。各类敏感信息的授予均是在应用程序使用的过程中进行提示,提醒用户注意潜在的安全风险。
在这一点上Android 操作系统往往则是在安装软件时索要权限。这两者看似只是时机不同,但是从安全管理角度来说, iOS 强很多。
还没有见到软件真身就让用户判断要不要给权限,用户往往只能无脑选择接受。而如果是在软件运行到特定场景时再索要权限,那么权限给不给就有合理的场景支持决策。
但是,在利益面前,软件厂商们是很难抵御住诱惑的。所以不仅仅是恶意软件会去过度索要系统权限,很多我们耳熟能详的常规软件也会索要运行该软件所不需要的权限。
移动时代,恶意软件的形态已经再一次发生变化。它既不是病毒也不是木马,而是“具备实用功能,但背地却通过获取用户的敏感信息来获利”的应用软件。
它通过诱导用户下载,然后在软件安装或者使用时索要敏感信息的获取权限。
一个软件到底是正常的还是恶意的?边界已经越来越模糊了。
以前病毒和木马都有复制和繁衍自己这样一个显著的特征,但如今它们的复制繁衍能力已经被操作系统的安全机制所阻止,所以恶意软件和普通软件一样,都是通过某种手段吸引用户下载安装。
怎么保护好用户的隐私信息?道高一尺,魔高一丈。攻防之间的斗争仍将继续下去。
网络环境的信息安全
如果我们不轻易尝试不可信的软件,就可以一切安全无虞?并不然,我们还要考虑我们的计算机所处的网络环境安全问题。
我们上网过程需要经过一系列的中间节点,有交换机,有路由器。我们的上网产生的所有数据包,都经由这些中间节点,这意味着我们有以下三个级别的安全风险。
被窃听的风险。可能会有人在这些节点上监听你访问和提交的内容。
被篡改的风险。可能会有人在这些节点上截获并修改你访问的内容。
被钓鱼的风险。可能会有人冒充你要访问的服务提供方和你通讯。
虽然大部分的中间节点由网络运营商提供,我们刨除这些节点被黑客所黑的情形,基本上认为可信。但这并不绝对,至少在中国,运营商修改中转的数据包这样的事情是干得出来的,常见的手法有:
在正常的 HTML 页面插入广告;
修改用户下载的 apk 文件,替换成自己想分发的 apk 文件;
修改 404 类型的 HTML 页面,替换成自己的搜索引擎的搜索页;
.……
其次是 WiFi 路由器。WiFi 路由器因为其提供方鱼龙混杂,天生是安全问题的大户。运营商能够干的事情它全都可以干,甚至可以更加肆无忌惮,以李鬼替换李逵,钓鱼的风险并不低。
比如你以为登录的是交通银行官网,它可能给你一个一模一样外观的网站,但是一旦你输入用户名和密码就会被它偷偷记录下来。
怎么解决中间人问题?
首先是怎么防篡改。应用场景是电子合同/公章、网络请求授权(例如你要用七牛的云服务,需要确认这个请求的确是你,而不是别人发出的)等。这类场景的特征是不在乎内容是否有人看到,在乎的是内容是不是真的是某个人写的。
解决方法是数字签名技术。一般来说,一个受数字签名保护的文档可示意如下:
其中,“要防篡改的内容” 是信息原文。“密钥提示” 是在数字签名的 “密钥” 有多个的情况下,通过 “密钥提示” 找到对应的 “密钥”。如果用于保护信息的 “密钥” 只有一个,那么可以没有 “密钥提示”。“指纹” 则是对信息使用特定 “密钥” 和信息摘要算法生成的信息摘要。
大部分情况下,数字签名的信息摘要算法会选择 HMAC MD5 或者 HMAC SHA1。在 Go 语言中,使用上示意如下:
import "crypto/hmac"
import "crypto/sha1"
import "encoding/base64"
textToProtected := "要防篡改的内容"
keyHint := "123"
key := findKey(keyHint) // 根据 keyHint 查找到 key []byte
h := hmac.New(sha1.New, key) // 这里用sha1也可以改成别的
h.Write([]byte(textToProtected))
textDigest := base64.URLEncoding.EncodeToString(h.Sum(nil))
textResult := textToProtected + ":" + keyHint + ":" + textDigest
得到的 textResult 就是我们期望的不可篡改信息。验证信息是否被篡改和以上这个过程相反。
首先根据 textResult 分解得到 textToProtected、keyHint、textDigest然后根据 keyHint 查找到 key再根据 textToProtected 和 key 算一次我们期望的信息摘要 textDigestExp。
如果 textDigestExp 和 textDigest 相同,表示没被篡改,否则则表示信息不可信,应丢弃。
如果我们希望更彻底的隐私保护,避免被窃听、被篡改、被钓鱼,那么数字签名就不顶用了,而需要对内容进行加密。
加密算法上一般分为对称加密和非对称加密。对称加密是指用什么样的密钥key加密就用什么样的密钥解密这比较符合大家惯常的思维。
非对称加密非常有趣。它有一对钥匙分私钥private key和公钥public key。私钥自己拿着永远不要给别人知道。公钥顾名思义是可以公开的任何人都允许拿。
那么公私钥怎么配合?首先,通过公钥加密的文本,只有私钥才能解得开。这就解决了定向发送的问题。网络中间人看到加密后的信息是没有用的,因为没有私钥解不开。
另外,私钥拥有人可以用私钥对信息进行数字签名(防止篡改),所有有公钥的人都可以验证签名,以确认信息的确来自私钥的拥有者,这就解决了请求来源验证的问题。
那么 A、B 两个人怎么才能进行安全通讯呢首先A、B两人都要有自己的公私钥并把公钥发给对方。这样 A 就有 A-private-key、B-public-keyB 就有 B-private-key、A-public-key。通讯过程如下所示。
A 向 B 发信息 R。具体来说A 首先用 A-private-key 对 R 进行签名得到RR-digest然后用 B-public-key 对RR-digest加密得到 encodedRR-digest然后把最终的加密信息发出去。
B 收到 encodedRR-digest用 B-private-key 解密得到RR-digest然后再用 A-public-key 验证信息的确来自 A。
B 理解了 R 后,回复信息给 A。这时两人的角色互换其他同上。
非对称加密机制非常有效地解决了在不可信的网络环境下的安全通讯问题。但是它也有一个缺点,那就是慢。相比之下,它的速度比对称加密慢很多。
所以,一个改善思路是结合两者。非对称加密仅用于传输关键信息,比如对称加密所需的密码。完整的通讯过程如下所示。
A 生成一个临时用的随机密码 random-key。
A 向 B 发送 random-key机制用的就是上面的非对称加密基于 B-public-key。
B 收到 A 发送的 random-key把它记录下来并回复 A 成功。回复的信息可以基于 random-key 做对称加密。
此后A 向 B 发、B 向 A 发信息,都用 random-key 作对称加密,直到本次会话结束。
你可能发现,整个过程中 A 自己已经不再需要非对称的公私钥对了。只要 A 事先有 B 的公钥B-public-key就可以。
当然,上面我们的讨论,没有涉及 B 如何把自己的 B-public-key 交给对方的。在假设网络不可信的前提下,这似乎是个难题。
我觉得有两个可能性。一个是 A 和 B 很熟悉,平常都经常一起玩。那么他们交换 public-key 完全可以不依赖任何现代通讯设备,包括电话和互联网,而是写在一张纸上,某天聚会的时候交换给对方。
另一个是更为常见的互联网世界场景:我要访问一个网站。我怎么才能避免被窃听、被篡改、被钓鱼?
通常我们用 HTTPS 协议。
在 HTTPS 协议中,第一步是 A 作为客户端Client去获取 B 作为网站的公钥B-public-key
怎么获取?如果我们认为网络不可信,那么我们就需要找一个可信的中间人,第三方权威机构 G由它来证明我们网站 B 返回客户端 A 的公钥B-public-key的确来自于 B中间没有被其他人篡改。
这意味着网站 B 不能直接返回自己的公钥B-public-key给客户端 A而是需要返回由权威机构 G 做了数字签名的公证书(简称数字证书),里面记录了网站 B 的域名domain和对应的公钥B-public-key还有证书的颁发人 G 的代号。
这张数字证书的作用是什么?最重要的并不是它怎么在网络上传递的。而是它记录了这样一个事实:域名 domain 对应的公钥是 B-public-key它是由权威机构 G 做出的公证,因为上面有 G 的数字签名。
所以这张数字证书并不需要临时生成,而是提前在网站部署时就已经生成好了,而且也可以随意传递给任何人,因为它是完全公开的信息。
当然这里还有一个前提,我们客户端 A 已经提前拥有第三方权威机构 G 的公钥G-public-key了。整个过程如下
客户端 A 向网站 B 请求网站的数字证书。
网站 B 返回它的数字证书。
客户端 A 收到数字证书,用 G-public-key 验证该数字证书的确由权威机构 G 认证,于是选择相信证书里面的 (domain, public-key) 信息。
客户端 A 检查证书中的 domain和我们要访问的网站 B 域名是否一致。如果不一致,那么说明数字证书虽然是真的,但是是别人找权威机构 G 认证的其他域名的证书,于是结束会话;如果一致,于是相信证书中的 public-key 就是网站 B 的公钥B-public-key
有了 B-public-key客户端 A 就可以愉快地上网,不必担心网络通讯的安全了。
但是HTTPS 并不能完全解决钓鱼问题。它假设用户对要访问的网站域名domain可靠性有自己的判断力。
这当然并不全是事实。所以,高级一点的浏览器(例如 Google Chrome它会建立不靠谱网站域名的数据库在用户访问这些网站时进行风险提示。
更多的信息安全话题
上面我们更多从服务终端用户角度,操作系统和浏览器以及我们的应用程序需要考虑的是信息安全问题。有以下这些信息安全问题没有涉及:
服务器的安全问题DDOS 攻击、漏洞与入侵);
企业信息安全;
社会工程学的安全问题;
……
结语
总结一下,我们今天聊了软件安全态势的演变过程,从最早的病毒和木马,演化到今天敏感信息如通讯录等内容的窃取,正常软件与恶意软件的判断边界越来越模糊。
我们也聊了网络环境带来的安全问题。今天主流的假设是网络链路是不可信的,在不可信的网络之上如何去做安全的通讯,可以做到防窃听、防篡改、防钓鱼。这也是苹果前几年强制要求 iOS App 必须走 HTTPS 协议的原因。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章关于操作系统的话题到此就结束了。下一讲我们结合前面的内容,讨论并实战架构第一步,怎么做需求分析。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,193 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 架构:需求分析 (上)
你好,我是七牛云许式伟。
前面我们多次提到过,架构的第一步是需求分析。那么,为什么要做需求分析?如何做好需求分析?
今天让我们一起聊一聊需求分析这个话题。
关于需求分析的那些事
为何要做需求分析?
首先,当然是因为我们做软件本身就是为了满足用户需求。那么,用户需求到底为何,我们需要清楚定义。
其次,需求边界定义的需要。用户需求理清楚了,不代表产品理清楚了。用户需求的满足一定会有行业分工,我们做什么,合作伙伴做什么,需要厘清大家的边界。
最后,架构设计的需要。架构需要切分子系统,需要我们梳理并对用户需求进行归纳与抽象。架构还需要防止过度设计,把简单的事情复杂化。
但什么是过度设计?不会发生的事情你考虑了并且为它做足了准备,就是过度设计。所以判断是不是过度设计是很困难的,需要对需求未来演化有很强的判断力。
从这几个维度来看,需求分析过程必然会涉及以下这些内容。
我们要面向的核心用户人群是谁?
用户原始需求是什么?最核心问题是哪几个?
已经有哪些玩家在里面?上下游有哪些类型的公司,在我们之前,用户是怎么解决他们的问题的?我们的替换方案又是怎样的?
进而,我们的产品创造的价值点是什么?用户最关注的核心指标是什么?
用户需求潜在的变化在哪些地方?区分出需求的变化点和稳定点。
当然,我并不是说,我们应该在需求分析的文档中完整地回答这些问题。需求分析文档目的并不是回答这些问题。但是在我们梳理需求的过程中,我们无法回避对这些问题的思考。
可能有人会认为,这些问题是 CEO 或产品经理这样的角色需要回答的,而不是架构师需要回答的。
某种意义上来说这句话没错。回答这些问题的首要责任方是 CEO 或产品经理。他们有责任让团队中的每一个人理解我们的产品逻辑。
但是,如果架构师只是被动地接受产品需求,以按图索骥的方式来做架构设计,是不足以成为顶级架构师的。原因在于两点。
一方面,用户需求的深层理解是很难传递的。 你看到的产品文档,是产品经理和用户沟通交流后的二次理解,是需求的提炼和二次加工,很难原汁原味地传递用户的述求。
所以架构师自己亲身近距离地接触用户,和用户沟通,去体会用户的述求是非常有必要的。
况且,大部分人并不会那么仔仔细细地阅读别人写的文档。当然这不完全是看文档的人单方面的原因,如果团队文档平均质量不高的话,也会影响到阅读者的心态。
另一方面,产品设计过程需要架构师的深度参与,而不是单向的信息传递。 产品经理非常需要来自架构师的建设性意见。
为什么我会有这样的看法呢?这涉及我对产品的理解。产品本身是运用先进的技术来满足用户需求过程的产物。
用户需求的变化是缓慢的,真正改变的是需求的满足方式。而需求满足方式的变化,深层次来说,其背后往往由技术迭代所驱动。
从这个角度来说,产品是桥,它一端连接了用户需求,一端连接了先进的技术。 产品经理是需要有技术高度的,他不一定要深刻了解技术的原理,但是一定要深刻理解新技术的边界。
某项技术能够做什么,不能做到什么,顶级产品经理甚至比实现这项技术的开发人员还要清楚。
认为产品经理不需要理解技术,这可能是我们普遍存在的社会现象,但很可能并不符合这个岗位的内在诉求。
回到架构师这个角色。
我经常说一个观点,产品经理和架构师其实是一体两面。两者都需要关心用户需求与产品定义。
只不过产品经理更多从用户需求出发,而架构师更多从技术实现出发,两者是在产品这座桥的两端相向而行,最终必然殊途同归。
这也是我为什么说架构师需要深度参与产品设计的原因。产品经理很可能会缺乏他应该有的技术广度,这就需要架构师去补位。产品定义过程需要反复推敲琢磨,并最终成型。
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
需求分析的重要性怎么形容都不过分。准确的需求分析是做出良好架构设计的基础。
前面我也说过,我个人认为架构师在整个架构设计的过程中,至少应该花费三分之一的精力在需求分析上。
这也是为什么很多非常优秀的架构师换到一个新领域后,一上来并不能保证一定能够设计出良好的架构,而是往往需要经过几次迭代才趋于稳定。
原因就在于:领域的需求理解是需要一个过程的,对客户需求的理解不可能一蹴而就。
怎么做需求分析
那么怎么才能做好需求分析?
首先,心态第一,心里得装着用户。 除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
其次,对问题刨根究底,找到根源需求。 有很多用户反馈需求的时候,往往已经带着他自己给出的解决方案。
这种需求反馈已经属于二次加工的需求,而非原始需求。这个时候我们要多问多推敲,把它还原到不带任何技术实现假设的根源需求。
如上图所示,根源需求可能会有非常非常多的技术方案可以满足它。我们上面示意图中的小圆点是一个个用户反馈的需求。在用户提这些需求的时候,往往可能会带着他熟悉的技术方案的烙印。
对于那些我们明显不关心的需求,如上图的小红点,相对容易排除在外。毕竟产品的边界意识大家还是会有的,产品不可能无限制膨胀下去。
但是对于上面的小绿点,决策上就比较难了。不做?可能会丢了这个客户。做?如果我们手放宽一点,最后产品需求就会被放大(如上图中蓝色的圆圈),做出一个四不像的产品。
最后,在理清楚需求后,要对需求进行归纳整理。 一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
前面我们也强调过:在需求分析时,要区分需求的变化点和稳定点。稳定点往往是系统的核心能力,而变化点则需要对应地去考虑扩展性上的设计。
要注意的是,在讨论需求的变化点和稳定点的时候,我们需要有明确参考的坐标系。在不同视角下,稳定点和变化点的判断是完全不同的。
所以需要明确的一点是,当我们说需求的变化点和稳定点时,这是站在我们要设计的产品角度来说的。
比如我们要设计一台计算机,那么多样化的外部设备是一个变化点。但是如果我们今天是在设计一台显示器,问题域就完全变了,需求的变化点和稳定点也就完全发生了变化。
本质上来说,对变化点的梳理,是一次产品边界的确立过程。所谓的开放性设计,就是说我把这个功能交给了合作伙伴,但是我得考虑怎么和合作伙伴配合的问题。
开放性设计并不是一个纯粹的用户需求问题,它通常涉及技术方案的探讨。因此,产品边界的确立不是一个纯需求,也不是一个纯技术,而是两者合而为一的过程。
对变化点的梳理至关重要。产品功能必须是收敛的,必须是可完成的。
如果某个子类别的需求呈现出发散而无法收敛的趋势,这个事情,团队一定要坐下来一起去反复推敲。不断拷问,不断明确响应需求的正确姿势到底为何。
产品定义
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义并不是简单的产品需求的归类。
上面我也说过,产品是桥,它一端连接了用户需求,一端连接了先进的技术。所以产品定义不可能做到和技术方案完全没关系。
首先,需要明确产品中有哪些元素,或者叫资源,以及这些资源的各类操作方式。 如果我们从技术的视角来理解,这就是定义对象和方法。当然这仅仅是这么理解,实际上一个我们技术上的对象方法,从产品需求角度会有多条路径的操作方式来达到相同的目的。
其次,需要对产品如何满足用户需求进行确认。 用户的使用场景未必全部是我们的产品所能直接满足的,面向特定的行业,有可能需要相应的行业解决方案,把我们的产品整合进去。-
-
我们要避免把行业方案视作产品的一部分。更多的情况下,需要我们更加开放的心态来看待这件事情,优先寻找合作伙伴来一起完成这类行业的需求覆盖。
最后,产品定义还需要考虑市场策略,我们的产品如何进入市场,和既有市场格局中的其他主流解决方案的关系是什么样的。
我们希望获取的用户,可能大部分都已经有一个既有的产品和技术方案,在满足他的需求。在考虑如何让客户从既有方案迁移到我们的产品后,我们确定产品的边界时又会复杂很多。
在一些极其关键的市场,我们有可能会把迁移需求视作产品需求的一部分。但更多的情况下,我们产品上只为这些市场上的主流方案提供迁移路径,而不是完整的迁移方案。
为何架构课从基础平台开始?
很抱歉我说得很抽象,但是总结需求分析的方法论的确是一件很难的事情。
为什么我们谈架构会从 “基础平台” 讲起?为什么从硬件架构,到编程语言,再到操作系统,我们似乎绕了一大圈,还没有谈到架构?
有两个原因。
最直接的原因是 “基础平台” 是我们所依赖的环境,是我们应用的业务架构的一部分。越了解我们所处的环境,我们就越能够运用自如。
但还有一个重要的原因是架构的探讨容易过度抽象。 所以我并没有先长篇大论谈架构方法论,谈需求应该怎么怎么去分析,而是围绕着基础平台的演进过程来谈需求分析。
信息世界的构建过程,本身就是一个最宏大的架构实践。我们通过对信息世界的骨架构成的参悟,自然能够感悟到架构思维的要点。
学内功需要悟心,学架构也需要悟心。怎么准确研判需求,对需求演进进行预测,这并不是靠技术技能,而是靠谦和求取的心态。
所以我们第一章 “基础平台” 篇整体来说,内容介绍以产品的需求分析为主、核心技术原理为辅。我们尝试把整个基础平台融为一个整体,宏观上不留任何疑惑。
实际上这一章的内容很难做到只看一遍就可以,可能要时时看,反复看。还需要查阅一些资料,也可以与人一起探讨。当然,我们也欢迎留言一起交流。
这一章我们介绍的内容,大部分内容都有一些对应的经典书籍,在后面 “基础平台篇: 回顾与总结” 一讲中,我也会给大家推荐一些经典的图书。
但我们并不是要重复这些书籍中的内容。** 我们的关注点在于:一是构建信息世界的宏观骨架,二是需求演进。**
经典书籍虽然好,但是它们写作时候的历史背景和今天有很大不同。从架构视角来说,结合我们今天的现实情况来看,一方面我们可以总结今天区别于当初的所有变化,另一方面主动去思考为什么发生了这样的变化。以这样的视角去读经典书籍,会别有一番滋味。
结语
在我们介绍完第一章 “基础平台” 篇的所有内容后,今天我们终于正式开始谈架构思维。我们探讨的是架构的第一步:需求分析。
需求分析并不是纯技术的东西,和编程这件事情无关。它关乎的是用户需求的梳理、产品的清晰定义、可能的演变方向。
怎么提升需求分析能力,尤其是预判能力?
首先,心态第一,心里得装着用户。除了需要 “在心里对需求反复推敲” 的严谨态度外,对用户反馈的尊重之心也至关重要。
其次,对问题刨根究底,找到根源需求。
最后,对需求进行归纳整理。一方面,将需求分别归类到不同的子类别中。另一方面,形成需求的变化点和稳定点的基本判断。
需求分析的目标和最终结果,都是要最终形成清晰的产品定义。产品定义将明确产品的元素,明确产品的边界,与产业上下游、合作伙伴的分工。
为什么我们的架构课从日常最平常之处,我们日日接触的基础平台讲起?
你真了解它们吗?你真感悟到它们的不凡之处了吗?
学习架构,关键在于匠心与悟心。
用思考的方式去记忆,而不是用记忆的方式去思考。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是 “架构: 需求分析(下)· 实战案例”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,181 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 架构:需求分析 (下) · 实战案例
你好,我是七牛云许式伟。
今天,我们继续上一讲关于架构第一步 “需求分析” 的讨论。为了能够获得更加具体的观感,我们选了两个实战的案例,如下:
打造 “互联网”;
存储新兵 “对象存储”。
案例: 打造 “互联网”
从对信息科技的影响面来说,最为标志性的两个事件,一个是计算机的诞生,另一个是互联网的诞生。
我们前面在 “[05 | 思考题解读: 如何实现可自我迭代的计算机?]”这一讲中,已经剖析过一个 MVP 版本的计算机是什么样的。
今天,我们就以 “互联网” 这个产品为题,看看应该怎么去做需求分析。
我们想象一下,把我们自己置身于互联网诞生之前。互联网并不是第一张网。在此之前的信息世界中,更多的是某个企业专用的局域网。不同的企业会选择不同公司所提供的网络方案。这些网络方案缺乏统一的规划,彼此并不兼容。
那么,怎么才能打造一个连接人与人、企业与企业,甚至是物与物,能够 “连接一切” 的 “互联网”?
首先,从根源需求来说,我们期望这不是某个巨头公司的网,也不是政府的网。这是需求的原点,这一点上的不同,产生的结果可能就很不一样。
如果我们忽略这一点就有可能会把它做成微信网WechatNet或者中国网ChinaNet。它们可能会是一张巨大的网但都不是 “互联网”。
所谓 “互联网” 首先应该是一张开放的网。它应该可以让很多国家很多公司参与其中,形成合力。它不应该存在 “造物主”,一个可以在这张网络中主宰一切的人。
开放,最基础的层次来说,意味着需要定义网络协议标准,尤其是跨网的数据交换标准。这里的跨网,指的是跨不同的网络设备,不同的网络运营商。
开放,从另一个角度来说,是对应用程序软件的开放。想要 “互联网” 真正能够连接一切,只是把物理的网络连接在一起是不够的,还要有能够丰富的 “连接一切” 的应用。
为了能够让更多应用可以更便捷地连接网络,我们需要提供方便应用接入的高层协议。这个协议需要屏蔽掉网络连接的复杂性(丢包重传等)。
但这还不够。“互联网” 这样的基础设施,启动阶段没有应用去吸引用户是不行的。所以我们需要 “吃自己的狗粮”,开发若干互联网应用的典型代表。
有一些需求可能非常非常重要,但是我们需要阶段性放弃,例如安全。加密传输并没有作为互联网的内建特性,这极大降低了互联网的实施难度。
从另一个角度考虑,为什么不把安全放在最底层,也要考虑方案的可持续性。一个安全方案是否能够长期有效,这非常存疑。
但是物理网络一旦存在,就很难做出改变(想想我们从 IPv4 过渡到 IPv6 需要多少年吧)。所以从这个角度来说,我们也不希望安全是一个网络的底层设施。
这并不意味着安全问题可以不解决,只是把这事儿留给了软件层,留给操作系统和应用程序。这是一个极其明智的选择。相比物理网络而言,软件层更加能够经受得起变更。
总结来说,要想把 “互联网” 这个项目做成,需要考虑这样一些事情。
一个能够连接所有既有网络的协议标准我们不妨叫它互联网协议Internet Protocol简称 IP 协议。
一张连接城市的骨干网络,至少有两个城市互联的试点。
打通骨干网络和主流企业专用网络的路由器。
一套方便应用开发的高阶网络协议,工作在 IP 协议之上。
一份支撑互联网应用程序的基础网络协议栈源代码或包package方便主流操作系统厂商、网络设备厂商集成。
若干典型互联网应用如电子邮件Email、万维网WWW等。
一份安全传输的网络协议方案远期及其源代码或包package
让我们先来看下物理网络的构建。
首先,构建骨干网络。不同城市可以由若干个骨干网路由器相连。骨干路由器可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示。
每个端口可能和其他城市相连,也可能和该城市内的某些大型局域网相连。一个局域网和城际网络从抽象视角看,没有非常本质的不同,只不过是采用的网络技术有异,使用的网络协议有异。
一个局域网可以简化理解为由若干台交换机连接所有的计算机设备。而交换机同样也可以看做是由一个负责路由算法的计算机,和若干网络端口构成,如下图所示:
剩下的问题是怎么对接骨干网络和局域网。这需要有人负责进行网络协议转换,它就是路由器。一台路由器上有两类端口,一类端口为本地端口,连接局域网内的设备,比如交换机,或者直接连普通的计算机。另一类端口为远程端口,负责接入互联网。
理清楚了物理网络后我们再来看应用构建。我们打算打造两个杀手级应用Killer Application电子邮件Email和万维网WWW
在考虑应用的用户交互体验时,我们发现,物理网络能够处理的 IP 地址和人类方便记忆的地址非常不同故而我们决定引入域名domain作为人与人交流用途的地址。为此我们引入了 DNS 地址簿协议,用于将域名解析为物理网络可理解的 IP 地址。
综上分析,最终我们得到 MVP 版本的 Internet 项目的各子系统如下:
案例: 存储新兵 “对象存储”
对象存储是非常新兴的一种存储系统。是什么样的需求满足方式的变化,导致人们要创造一种新的存储呢?
对象存储是伴随互联网的兴起,尤其是移动互联网的兴起而产生的。
首先,互联网应用兴起,软件不再是单机软件,用户在使用应用软件的过程中产生的数据,并不是跟随设备,而是跟随账号。 这样,用户可以随心所欲地切换设备,不必考虑数据要在设备间倒来倒去的问题。
数据跟随账号,这是互联网应用的第一大特征,区别于单机软件的关键所在。
其次,用户交互方式的变化。 用户不再打字用纯文本沟通,而是用照片、视频、语音等多媒体内容来表达自己的想法。
移动化加剧了这一趋势,在手机上打字是非常痛苦的事情。拍拍照、拍拍视频、说说话(语音输入)更加符合人的天性,尤其是手机用户覆盖面越来越宽,大部分用户属于没有经过专业培训的普通用户,这些手段是最低准入门槛的交互方式。
最后,用户体验诉求的提升。 计算机显示器早年是黑白的后来有了256色有了真彩色TrueColor显示器的屏幕分辨率也从320x240到640x480到今天我们再也不关心具体分辨率是多大。随之发生变化的是一张照片从100K到几兆到几十兆。
这些趋势,对存储系统带来的挑战是什么?
其一,规模。 那么多用户的数据,一台机器显然放不下了,要很多很多台机器一起来保存。
其二,可靠。 用户单机对存储的要求并不高,机器硬盘出问题了,不会想着找操作系统厂商或者软件应用厂商去投诉。但是,用户数据在服务端,数据丢了那就是软件厂商的责任,要投诉。
其三,成本。 从软件厂商来说,那么多的用户数据,怎么做才能让成本更低一些。
其四,并发吞吐能力。 大量的用户同时操作,有读有写,怎么保证系统是高效的。
另外,从存储系统的操作接口来说,我们分为关系型存储(数据库,结构化数据)和文件型存储(非结构化数据)。我们今天的关注点在文件型存储上。
对于文件型存储来说,相关的备选解决方案有很多,我们简单罗列如下。
-
第一类是大家最熟悉的、最古老的存储系统:本地文件系统。 虽然有很多种具体的实现方案,但是它们的使用接口大同小异,实现方案也只是在有限的几种选择中平衡。我们在 “[09 | 外存管理与文件系统]” 这一讲中已经有过详细的介绍,这里不提。
第二类是网络文件系统,可以统称为 NAS如上面的 NFS、FTP、SambaCIFS、WebDAV都只是 NAS 存储不同的访问接口。
第三类是数据库,它通常用于存储结构化数据,比较少作为文件型存储。但也有人在这么做,如果单个文件太大,会切成多个块放到多行。
第四类是 SAN它是块存储。块存储和关系型存储、文件型存储都不同它模拟的是硬盘是非常底层的存储接口。很少会有应用直接基于块存储更多的是 mount 到虚拟机或物理机上,然后供应用软件需要的存储系统使用。
第五类是分布式文件系统 GFS/HDFS。GFS 最早是为搜索引擎网页库的存储而设计,通常单个文件比较大,非常适合用于日志类数据的存储。这也是为什么 Hadoop最后从大数据领域跑出来原因就是因为大数据处理的就是日志。
你可以看到,除了数据库和 SAN我们不用细分析就知道它们不是文件型存储的最佳选择其他几类包括本地文件系统、NAS、GFS/HDFS 有一个共同特征就是它们的使用接口都是文件系统FileSystem
那么我们就来看下文件系统FileSystem对于大规模的文件型存储来说有什么问题。
最大的问题是文件系统是一棵树Tree。除了对单个文件的操作只需要锁住该文件外所有对树节点的修改操作比如把 A 节点移到 B 处,都是一次事务操作,需要锁住整棵树。
这对规模和并发吞吐能力都是伤害。从规模来说,分布式事务是很难的(这也是为什么分布式数据库很难做的原因),做出来性能也往往好不到哪里去。从并发吞吐能力来说,如果系统存在大锁,即在锁里面执行费时的操作,就会大幅降低系统的并发吞吐能力。
传统的 NAS 出现比较早,所以它没有考虑“大规模条件下存储会有什么样的挑战”是非常正常的。
GFS/HDFS 为什么没有考虑大规模问题?这是 Google 设计 GFS 的背景导致的,网页库存储,或者日志型存储的共同特征是单个文件很大,可以到几个 G 级别,这样的话文件系统的元数据就会减少到单台机器就可以存储的级别。
所以对象存储出现了。它打破了文件型存储访问接口一定是文件系统FileSystem的惯例。它用的是键值存储Key-Value Storage
从使用接口来说首先选择文件所在的桶Bucket它类似于数据库的表Table只是一个逻辑划分的手段然后选择文件的键Key就可以存取文件了。
这意味着文件之间并不存在关联(树型结构是文件之间的一种关联),可以通过某种算法将文件元信息分散到不同的机器上。
那么为什么文件型存储不必考虑文件之间的关联因为关系都在数据库里面文件型存储只需要负责文件内容的存储有个键Key能够找到文件内容即可。
从本质上来说,这是因为服务端和桌面软件面临的用户场景是完全不同的。文件系统是在桌面软件下的产物,桌面系统是单用户使用的,没有那么高的并发访问需求。
服务端一上来就面临着并发访问的问题,所以很早就出现了数据库这样的存储中间件。数据库的出现,其实已经证明文件系统并不适合服务端。只不过因为文件型存储在早期的服务端开发的比重并不大,所以没有被重视。
但是,互联网的发展极大地加速了文件型存储的发展。互联网增加的 90% 以上的数据,都是非结构化数据,包括图片、音频、视频、日志。
对象存储能够支撑的文件数量规模上非常非常大。比如七牛云存储,我们已经支持万亿级别的文件。
这在传统 NAS 这种基于文件系统访问接口的存储是难以想象的,我们看到的 NAS 存储 POC 测试要求,基本上都是要能够支持 1-2 亿级别的文件存储规模。
另外,对象存储的高速发展,很大程度上会逐步侵蚀 Hadoop 生态的市场。因为 HDFS 这种日志型存储,其实只是对象存储里面的一个特例。在人们习惯了对象存储后,他们并不希望需要学习太多的存储系统;所以大数据的整个生态会逐步过渡到以对象存储为基石。
这已经发生了。这两年你可能也能够听到Hadoop 生态的公司活得挺不好的,几家公司合并了也没有解决掉没落的问题。这和大数据生态向对象存储迁徙是分不开的,只不过这方面我们国内还处在相对比较落后的阶段。
案例分析
通过对打造“互联网”和存储新兵“对象存储”这两个案例的分析,我们可以看出不同市场差异还是很大的。“互联网” 这个产品它并不是替换某种既有的方案,而是把既有的方案连接在一起。所以 “互联网” 的历史包袱很少,基本上不太需要考虑历史问题。
“对象存储” 产品则不同。在对象存储之前,存储已经经历了很长时间的发展。只不过因为文件型的数据爆发式的增长,带来了存储系统的新挑战,从而给对象存储这样的新技术一个市场机会。
当然,另外一个原因是云服务的诞生,让存储有了新的交付形态。我们不再需要拿着硬件往用户家里搬,这就出现了一个新的空白市场。
但是解决了空白市场的需求后,对象存储还是要面临 “既有市场中用户采用的老存储方案怎么搬迁” 的问题。所以存储网关这样的产品就出现了。存储网关做什么?简单说,就是把对象存储包装成 NAS提供 NFS、FTP、SambaCIFS、WebDAV 这些访问接口给用户使用。
结语
需求分析相关的讨论就到此结束了。不同市场差异非常大,并不存在大一统的产品定义和市场策略,需要具体问题具体分析。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲将是我们第一章的回顾与总结。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 基础平台篇:回顾与总结
你好,我是七牛云许式伟。
到今天为止,我们第一章 “基础平台篇” 就要结束了。今天,让我们对整章的内容做一个回顾与总结。
抽象信息世界的骨架
基础平台篇主要涉及的内容如下。
这些内容如果展开来讲,每一系统(或模块)都会是很厚的一本书。我们的目的,当然不是为了取代这里每一个领域知识相关的专业书籍。
我们的核心目标是以架构为导向,抽象出系统的骨架,融会贯通,把这些领域知识串起来,拼出完整的信息世界的版图。
抽象出系统骨架的过程时信息必然是有损的,怎么才能做到忽略掉众多的实现细节,把系统以简洁易于理解的方式呈现出来?
这很大程度取决于你对系统的理解程度和抽象能力。如果我们把系统想象成一个人,大部分情况下我们比较容易对其进行详尽而具体的描述,好比下图。
这相对容易。因为你只需要陈述你看到的事实,而不必拷问背后的原因。但实际上为了在最短的时间里让别人理解你的想法,你也许应该这样来描述它,见下图。
当你不是在描述这个系统本身,而是描述它与其他系统的相互关系时,你可能需要进一步简化它,变成如下图这样。
抽象有助于记忆,因为骨架需要逻辑的自洽。
这种抽象能力之所以重要,是因为它是融会贯通、疏通整个信息世界的知识脉络的关键。当你做到对世界的认知可宏观、可微观,自然一切皆在掌握。
比如本章我们首先介绍的是冯·诺依曼体系结构我们把它抽象为“中央处理器CPU+ 存储 + 一系列的输入输出设备”,并给出了系统的示意图如下。
这个图相当笼统并没有涉及中央处理器CPU指令设计的真正细节。比如我们没有介绍栈stack这个概念虽然它实际上也非常关键。
为什么需要引入栈?它在中央处理器中起到了什么样的作用?
要了解这个问题,你就需要深入到中央处理器的架构设计中去。如果你对梳理中央处理器的架构设计感兴趣,可以尝试写一篇介绍它的文字。
做这样的事情会对你非常的锻炼。** “你自己理解一个事物”和“把你的理解表述成文,去引导其他人也能够理解它”**,是完全不同难度的事情。
如果你对中央处理器的设计细节感兴趣,可以进一步查阅相关的参考资料。也欢迎与我分享你的心得体会。
基础平台篇的内容回顾
这一章前面我们讲了些什么?为了让大家对第一章内容有个宏观的了解,我画了一幅图,如下。
首先,我们介绍了冯·诺依曼体系结构。 从需求演进角度看,虽然我们信息科技发展日新月异,但是底层设计并没有发生过变化,非常稳定。从这一点来说,我们不能不佩服他们的远见。
随后,我们介绍了编程语言的演进。 从汇编语言的诞生,出现了程序员这个新职业开始,此后编程语言的演进便进入高速发展期。
然而尽管语言很多但是编程范式的演进却并不剧烈。大家熟知的过程式、函数式、面向对象基本上能够把几乎所有的语言都囊括其中。Go 语言独树一帜地宣称自己是面向连接的语言,我们着重对比了面向对象与面向连接思想上的差异。
编程语言本身与业务架构的设计关联性不大,虽然模块规格的描述会借助语言的文法。** 但是语言长期演进所沉淀下来的社区资源,是我们架构设计所依赖的重要基础。** 充分利用好这些资源可以大大降低系统的研发成本。
最后,我们开始聊操作系统。 从 UNIX => DOS => Windows/Mac/Linux => iOS/Android从用户交互、进程管理、安全管理等角度看操作系统的需求演变非常剧烈。
传统操作系统主要包含五个子系统:设备管理(包括存储设备、输入/输出设备、网络设备)、进程管理和安全管理。
输入/输出设备主要和交互有关,我们概要描述,基本上一笔带过。我会在后面 “桌面软件开发” 这一章再详加讨论。而服务端的交互比较简单,命令行基本上就满足需求,所以 “服务端开发” 一章我们不会再特意去展开。
另外,操作系统的商业模式也发生了剧烈的变化。
早期操作系统的营收模式以软件销售收入为主。但是从苹果的 iOS 开始,操作系统都无一例外地增加了以下三个模块:
账号Account
支付Pay
应用市场AppStore
注意,这里我们说的账号是指互联网账号。传统操作系统虽然也有账号概念,但是,它是本地账号,属于多用户权限隔离所需。
而互联网账号的价值完全不同,它是支付和应用商店的基础。没有账号,就没有支付系统,也没有办法判断用户是否在应用市场上购买过软件。
实现了“帐号-支付-应用市场”这样的商业闭环,意味着操作系统的商业模式,从软件销售转向了收税模式。这类操作系统,我们称之为现代操作系统。所有现代操作系统,所凭借的都是自己拥有巨大的流量红利。
基础平台篇的参考资料
概要回顾了我们 “基础平台篇” 的内容后,我们这里补充一下有助于理解我们内容的相关资料,如下。
有了本专栏梳理的骨架,相信对你学习和理解以上这些材料会一定的指引意义。
如果你有什么推荐的优秀参考资料,也欢迎在留言区分享,我补充到这个表格中来,我们一起来完善它。
架构之美在于悟
信息世界是无中生有创造出来的,我们不需要去记忆,而是要找到创造背后的骨架和逻辑。
架构即创造。
学架构在于匠心和悟心。它靠的是悟,不是记忆。** 用思考的方式去记忆,而不是用记忆的方式去思考。**
我们日常所依赖的基础平台,随处可见的架构之美,看到了,悟到了,就学到了。 如果你只能从你自己写业务代码中感受架构之道,那么你可能就要多留些心思了。
比如,如果你日常用的是 Go 语言,那么你可以做一个作业:“谈谈 Go 语言之美”。你从Go语言的设计中感悟到了什么样的架构思维当然如果你不常接触 Go 语言,可以给自己换一个题目,比如 “Java 语言之美”。
作为架构师,如何构建需求分析能力,尤其是需求的预判能力?
首先,归纳总结能力很重要。 分析现象背后的原因,并对未来可能性进行推测。判断错了并不要紧,分析一下你的推测哪些地方漏判了,哪些重要信息没有考虑到。
另外,批判精神也同样至关重要。 批判不是无中生有的批评,而是切实找到技术中存在的效率瓶颈和心智负担。尤其在你看经典书籍的时候,要善于找出现状与书的历史背景差异,总结技术演进的螺旋上升之路,培养科学的批判方法论。
结语
今天我们对本章内容做了概要的回顾,并借此对整个基础平台的骨架进行了一次梳理。
我们最为依赖,也最为强调的,是抽象能力。它对于构建信息世界的骨架至关重要。为此我们需要不断改造自己的抽象体系。例如,前面 “[02 | 大厦基石:无生有,有生万物]” 这一讲中提到过:
引入了输入输出设备的电脑,不再只能做狭义上的“计算”(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的“计算”问题变得无所不包。
有同学留言问:输入/输出设备提供的明明是一种 IO 能力,怎么能够算得上是“计算”?
但是实际上,我们人类其实就是在这种“否定自己,不断延展自己的抽象体系”,补全自己的想象力。我们以数学中最为基础的 “数” 为例子。数的演化大概经历了:
自然数 => 整数 => 有理数 => 实数 => 复数
输入/输出能力算不算是“计算”?我们不妨以广义的“计算”角度来看。
输入Input无非是采集物理世界的信息将其数字化所以一个输入设备其实可以看作是一个模数转换的“算子”。只不过这个算子非 CPU 的指令可以表达。
输出Output无非是将数字内容反作用于物理世界一个输出设备其实可以看作是一个数模转换的“算子”。同样这个算子非 CPU 的指令可以表达。
计算机 CPU 自身只能做数数转换,输入是比特信息,输出还是比特信息。结合了输入/输出设备提供的数模和模数转换的 “算子”,连接了数字世界和物理世界的计算机,在数学上也就完备了。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。本章到此结束,我们将开始第二章:桌面开发的宏观视角。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,127 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 桌面开发的宏观视角
你好,我是七牛云许式伟。今天开始,我们进入第二章,谈谈桌面软件开发。
从架构的角度无论你在什么样的终端设备比如PC、手机、手表、手机等等也无论你在做 Native 应用,还是基于 Web 和小程序,我们都可以统一称之为桌面程序。
如前文所述,一个桌面程序完整的架构体系如下:
对于桌面程序,最核心的话题是交互。为了把关注点收敛到交互上,我们下面重点讨论操作系统对交互范式的设计。
从需求角度看,桌面程序的交互方式并不稳定,它的交互范式经历了很多次的迭代。
命令行交互
最早出现的交互范式是命令行交互程序。使用的交互设备为键盘+显示器。
输入被抽象为一段以回车Enter键为结束的文本通常是单行文本要输入多行文本需要在行末输入“ \ ”对回车进行转义)。
输入方式有二一是命令行二是标准输入stdin。 输出也是文本但输出目标可能是标准输出stdout也可能是标准错误stderr
正常情况下标准输出stdout和标准错误stderr都是向屏幕输出。这种情况下肉眼并不能区分输出的内容是标准输出还是标准错误。
命令行交互程序的输入输出可以被重定向。一个程序的输出,可以被重定向写入到文件(标准输出和标准错误可以输出到不同的文件以进行区分),也可以通过管道功能重定向为另一个程序的输入。
总结一下,命令行交互程序的结构可示意如下:
但命令行程序的限制过大了,人们很容易发现,在很多需求场景下这是非常反人类的,最典型的就是编辑器。稍微想象一下,你就会为怎么做好交互设计而头疼不已。
字符界面
于是,字符界面程序出现了。使用的交互设备仍然是键盘+显示器但是输入不再是一段文本而是键盘按键事件KeyDown 和 KeyUp
输出也不是一段文本而是可以修改屏幕任何位置显示的字符屏幕被分割成M*N的格子每个格子可以显示一个字符
这个时候键盘的功用在需求上分化为两个一是输入文本二是输入命令通常通过扩展键比如方向键或者组合键比如Ctrl-A、Alt-X。从输入文本的角度需要有当前输入的光标Caret位置。
字符界面程序保留命令行输入的方式,但一般不太使用标准输入。其结构示意如下:
-
上图的 TDI 含义是字符设备接口Text Device Interface它指的是一组向屏幕绘制文本的方法集合。大体看起来是这样的
func ClearScreen()
func DrawText(x, y int, text string)
...
但是,字符界面程序也有很大的局限。最典型的需求场景是游戏。一些简单的游戏比如俄罗斯方块是可以基于字符界面做出来的,但大部分情况下,字符界面对于游戏类场景能够做的事情非常有限。
图形界面
于是,图形界面程序出现了。使用的交互设备是键盘+鼠标+显示器+音箱。从交互演进角度,这是一个划时代的变化。
与字符界面时期相比图形界面时代变化的根源是输出的变化从字符变成像素。屏幕被分割为精度更高的M * N的格子每个格子显示的是一个很小很小的像素每个像素可以有不同的颜色。
为什么会出现鼠标?因为屏幕精度太高,用键盘的方向键改变当前位置,不只是看起来非常笨拙,而且操作上也很不自然。
为什么出现音箱的原因则比较平凡,它只不过是声音设备演进的自然结果。事实上在字符交互时期声音设备就已经有了,计算机主板上有内置的喇叭。
这个喇叭最大的用途是出现重大错误(比如计算机启动失败)的时候会响几声给予提示。
开发人员可以通过向标准输出stdout或标准错误stderr输出一个特殊的字符让喇叭响一声。
前面我们说过,输出到标准输出和标准错误对肉眼来说不可区分,所以如果我们向标准错误输出文本前让喇叭响一声,也是一个不错的一种交互范式。
与字符界面程序相比,图形界面程序还有一个重大变化,是多窗口(当然,部分复杂的字符界面程序也是多窗口的,比如 Turbo C++ 3.0,它用的是 Turbo Vision 这个知名的字符界面库)。
窗口Window也有人会把它叫视图View是一个独立可复用的界面元素。复杂的窗口可以切分出多个逻辑独立的子窗口以降低单个窗口的程序复杂性。
窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。例如,键盘和鼠标事件的目标窗口的确定,需要一系列复杂的逻辑。
为了降低编程的负担,窗口系统往往接管了桌面程序的主逻辑,提供了一套基于事件驱动的编程框架,业务代码由窗口系统提供的界面框架所驱动。整个程序的结构如下所示。
上图的 GDI 含义是图形设备接口Graphic Device Interface它指的是一组向指定窗口注意不是屏幕绘制图形的方法集合。绘制的对象包括有几何图形、图像、文本等。
此后,到了移动时代,手机成了最主流的计算机。使用的交互设备发生了变化,变成了触摸屏+麦克风+内置扬声器。
鼠标交互方式被淘汰,变成了多点触摸。** 键盘+鼠标+显示器**的能力被融合到触摸屏上。
音箱也被内置到手机中,变成内置扬声器。这些变化都因移动设备便携性的述求引起。从架构的角度,它们并没有引起实质性的变化,只是鼠标事件变成了触摸事件。
智能交互
麦克风让计算机多了一个输入:语音。 有三种典型的用法。
一是在应用内把语音录下来,直接作为类似照片视频的媒体消息,或者识别为文本去应用(比如语音搜索)。
二是作为语音输入法输入文本(逻辑上可以认为是第一种情况的特例,只不过输入法在操作系统中往往有其特殊的地位)。
三是基于类似 Siri 语音助手来交互。
语音助手是被寄予厚望的新的交互范式。它可能开启了新的交互时代,我们不妨把它叫智能交互时代。但当前它与图形界面程序结构并不相容,而是完全自成体系,如下:
为什么语音交互和图形界面交互没法很好地融合在一起?我认为有两个原因。
一是语音交互有很强的上下文,所以语音交互程序通常其业务代码也由语音交互系统提供的框架所驱动。 框架的特点是侵入性强,框架与框架之间很难融合。
二是语音交互还不成熟,所以独立发展更简单,如果有一天成熟了,完全可以重写框架,把语音和触摸屏结合起来,形成全新的交互范式。
未来交互会怎样?智能交互很可能不会止步于语音,而是视频(同是兼顾视觉和听觉),且与触摸屏完美融合。使用的交互设备有触摸屏+摄像头+麦克风+内置扬声器。整个程序的结构如下所示。
结语
通过以上对交互演化历程的回顾,我们看到交互范式的演进是非常剧烈的。交互体验越来越自然,但从编程的角度来说,如果没有操作系统支持,实现难度也将越来越高。
这也说明了一点,桌面操作系统和服务端操作系统的演进方向非常不一样。桌面操作系统的演进方向主要是交互范式的迭代,在向着越来越自然、越来越智能的交互前进。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将介绍:“图形界面程序的框架”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,139 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 图形界面程序的框架
你好,我是七牛云许式伟。
上一讲我们回顾了交互的演化历程。今天,我们将关注点收敛到现在仍然占主流地位的图形界面程序。它的结构如下图所示。
实现一个图形界面程序,最大的复杂性在于不同操作系统的使用接口完全不同,差异非常巨大。这给开发一个跨平台的图形界面程序带来巨大挑战。
好在,尽管操作系统的使用接口有异,但基本的大逻辑差不多。今天我们从统一的视角来看待,谈谈图形界面程序的框架。
事件
无论是什么桌面操作系统每个进程都有一个全局的事件队列Event Queue。当我们在键盘上按了一个键、移动或者点击鼠标、触摸屏幕等等都会产生一个事件Event并由操作系统负责将它扔到进程的事件队列。整个过程大体如下。
键盘、鼠标、触摸屏等硬件产生了一个硬件中断;
操作系统的硬件中断处理程序收到对应的事件Event
确定该事件的目标进程;
将事件放入目标进程的事件队列Event Queue
窗口与事件响应
窗口Window也有人会把它叫视图View是一个独立可复用的界面元素UI Element。一个窗口响应发送给它的事件Event修改内部的状态然后调用 GDI 绘制子系统更新界面显示。
响应事件的常见机制有两种。
一种是事件处理类EventHandler在 iOS 中叫 Responder。通常我们自定义的窗口类会直接或间接从事件处理类继承。Windows 平台有些特殊为了让窗口类可复用且与语言无关它将事件处理做成了回调函数术语叫窗口过程WindowProc。这只是形式上的不同并无本质差异。
另一种是用委托delegate。顾名思义用委托的意思是事件处理不是收到事件的人自己来做而是把它委托给了别人。这只是一种编程的手法。比如在 Web 编程中我们给一个界面元素UI Element实现 onclick 方法这可以理解为是一种委托delegate
有一个事件比较特殊,它往往被叫做 onPaint 或 onDraw。为什么会有这样的事件我们想象一下当一个窗口在另一个窗口的上面并且我们移动其中一个窗口时部分被遮挡的窗口内容会显露出来。
这个过程我们可能觉得很自然,但实际上,操作系统并不会帮我们保存被遮挡的窗口内容,而是发送 onPaint 事件给对应的窗口让它重新绘制。
另外不只是窗口可以响应事件应用程序Application也可以。因为有一些事件并不是发送给窗口的而是发给应用程序的比如本进程即将被杀死、手机低电量告警等等。
当然如果我们约定一定存在一个主窗口Main Window那么把应用程序级别的事件理解为是发给主窗口的也可以。
事件分派
事件是怎么从全局的事件队列Event Queue到窗口的呢
这就是事件分派Event Dispatch过程它通常由一个事件分派循环Event Dispatch Loop来完成。一些平台把这个过程隐藏起来直接提供一个类似 RunLoop 这样的函数。也有一些平台则让你自己实现。
例如,对于 Windows 平台它把事件叫消息Message事件分派循环的代码看起来是这样的
func RunLoop() {
for {
msg, ok := winapi.GetMessage() // 从事件队列中取出一个消息
if !ok {
break
}
winapi.TranslateMessage(msg)
winapi.DispatchMessage(msg)
}
}
大体来说就是一个简单的取消息GetMessage然后对消息进行分派DispatchMessage的过程。其中 TranslateMessage 函数你可能比较陌生它负责的是将键盘按键事件onKeyDown、onKeyUp转化为字符事件onChar
窗口有了父子和兄弟关系,就有了窗口系统。一旦界面涉及复杂的窗口系统,交互变得更为复杂。事件分派过程怎么知道应该由哪个窗口响应事件呢?
这就是事件处理链EventHandler Chain
不同事件的分派过程并不一样。
对于鼠标或者触摸屏的触摸事件事件的响应方理应是事件发生处所在的窗口。但也会有一些例外的场景比如拖放。为了支持拖放Windows 系统引入了鼠标捕获Mouse Capture的概念一旦鼠标被某个窗口捕获哪怕鼠标已经移出该窗口事件仍然会继续发往该窗口。
对于键盘事件onKeyDown/onKeyUp/onChar则通常焦点窗口先响应如果它不感兴趣再逐层上升直到最顶层的窗口。
键盘从功能上来说,有两个不同的能力:其一是输入文本,其二是触发命令。 从输入文本的角度来说要有一个输入光标在Windows里面叫Caret来指示输入的目的窗口。目的窗口也必然是焦点窗口否则就会显得很不自然。
但是从触发命令的角度来说命令的响应并不一定是在焦点窗口甚至不一定在活跃窗口。比如Windows下就有热键HotKey的概念能够让非活跃窗口Inactive Window也获得响应键盘命令的机会。一个常见的例子是截屏软件它往往需要一个热键来触发截屏。
到了移动时代,键盘不再是交互主体,但是,键盘作为输入文本的能力很难被替代(虽然有语音输入法),于是它便自然而然地保留下来。
不过在移动设备里,不太会有人会基于键盘来触发命令,只有常见的热键需求比如截屏、调大/调小音量、拍照等等,被设计为系统功能(对应的,这些功能的热键也被设计为系统按键)保留下来。
窗口内容绘制
在收到 onPaint 或 onDraw 消息时,我们就要绘制我们的窗口内容了,这时就需要操作系统的 GDI 子系统。
从大分类来说,我们首先要确定要绘制的内容是 2D 还是 3D 的。对于 2D 内容,操作系统 GDI 子系统往往有较好的支持,但是不同平台终究还是会有较大的差异。而对于 3D 内容来说OpenGL 这样的跨平台方案占据了今天的主流市场,而 Vulkan 号称是 NextGL下一代的 OpenGL其潜力同样不容小觑。
从跨平台的难易程度来说,不同平台的 GDI 子系统往往概念上大同小异,相比整个桌面应用程序框架而言,更加容易抽象出跨平台的编程接口。
从另一个角度来说GDI 是操作系统性能要求最高、最耗电的子系统。所以 GDI 优化往往通过硬件加速来完成,真正的关键角色是在硬件厂商这里。由此观之,由硬件厂商来推跨平台的 GDI 硬件加速方案可能会成为趋势。
通用控件
有了以上这些内容,窗口系统本身已经完备,我们就可以实现一个任意复杂的桌面应用程序了。
但是,为了进一步简化开发过程,操作系统往往还提供了一些通用的界面元素,通常我们称之为控件(Control)。常见的控件有如下这些:
静态文本 (Label)
按钮 (Button)
单选框 (RadioBox)
复选框 (CheckBox)
输入框 (Input也叫EditBox/EditText)
进度条 (ProgressBar)
等等。
不同操作系统提供的基础控件大同小异。不过一些处理细节上的差异往往会成为跨平台开发的坑,如果你希望一份代码多平台使用,在这方面就需要谨慎处理。
结语
总结来说,桌面应用程序通常由用户交互所驱动。我们身处在由操作系统约定的编程框架中,这是桌面编程的特点。
在操作系统的所有子系统中,交互相关的子系统是毫无疑问的差异性最大的子系统。我们这里列了一个简单的对比表格:
这还不是差异的全部。要做一个跨平台的桌面应用程序并不容易。我们需要面对的平台太多,简单罗列,如下所示。
PCWindows、MacOS、Linux 等;
PC 浏览器Chrome、Safri、Firefox 等;
手机/平板/手表Android不同手机厂商也会有细节差异、iOS 等;
小程序:微信、支付宝、快应用等。
怎么安排不同平台的优先级?怎么规划未来版本的迭代计划?选择什么样的跨平台方案?这些问题在业务架构之外,但极其考验架构师的决策能力。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “桌面程序的架构建议”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,151 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 桌面程序的架构建议
你好,我是七牛云许式伟。
上一讲我们介绍了图形界面程序的框架。站在操作系统交互子系统的角度来看,我们桌面应用程序的结构是下面这样的。
今天我们换一个角度,站在应用架构的角度,来聊聊如何设计一个桌面应用程序。
从 MVC 说起
关于桌面程序,我想你听得最多的莫过于 MVC 这个架构范式。MVC 全称是 “模型(Model)-视图(View)-控制器(Controller)”。
-
怎么理解 MVC 呢一种理解是Model 是 InputView 是 OutputController 是 Process认为 MVC 与计算机的 Input-Process-Ouput 这个基础模型暗合。
但更准确的解释是Model 是数据View 是数据的显示结果,同时也接受用户的交互动作,也就是事件。从这个意义来说,说 Model 是 Input 并不严谨View 接受的用户交互,也是 Input 的一部分。
Controller 负责 Process处理它接受 “Model + 由 View 转发的事件” 作为 Input处理的结果Output仍然是 Model它更新了 Model 的数据。
View 之所以被理解为 Output是因为 Model 的数据更新后,会发送 DataChanged数据更新事件View 会在监听并收到 DataChanged 事件后,更新 View。所以把 View 理解为 Output 也并不算错,它从数据角度看其实是 Model 的镜像。
对 MVC 模式做些细微的调整就会产生一些变种。比如Model 的数据更新发出 DataChanged 事件后,由 Controller 负责监听并 Update View这样就变成了 MVP 架构。MVP 全称是 “模型(Model)-视图(View)-表现(Presenter)”。
那么,我们究竟应该选择哪一种架构范式比较好?
要想判断我们写的程序架构是否优良,那么我们心中就要有架构优劣的评判标准。比较知名且重要的一些基本原则如下。
最低耦合原则:不同子系统(或模块)之间有最少的交互频率,最简洁且自然的接口。
单一职责原则:不要让一个子系统(或模块)干多件事情,也不要让它不干事情。
如果在我们心中以遵循架构法则为导向,回过头再来看 MVC又会有不同的理解。
理解 Model 层
我们先看 Model。如果你真正理解 Model 层的价值,那么可以认为你的架构水平已经达到了较高层次的水准。因为 Model 层太重要了。
我上面说 Model 层是数据这其实还不是太准确。更准确来说Model 层是承载业务逻辑的 DOM即 “文档对象模型Document Object Model”。直白理解DOM 是 “面向对象” 意义上的数据。它不只是有数据结构,也有访问接口。
为了便于理解,假设我们基于数据库来实现 Model 层。** 这种情况下会有两种常见的架构误区。**
一种是直接让 Controller 层直接操作数据库,也就是拿数据库的读写接口作为 Model 层的接口。
另一种看起来高级一些,用所谓的 ORM 技术来实现 Model 层,让 Controller 直接操作 ORM。
为什么我们说这两种做法都有问题呢?原因就在于对 Model 层的价值不明。Model 层的使用接口最重要的是要自然体现业务的需求。
只有这样Model 层的边界才是稳定的,与你基于的技术无关。是用了 MySQL还是用了 NoSQL是直接裸写 SQL 语句,还是基于 ORM这都没关系未来喜欢了还可以改。
另外从界面编程角度看Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。
我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。
这样来看,直接让 Controller 层直接操作数据库,或者基于 ORM 操作数据库,都是让 Model 层啥事不干,这非常非常浪费,同样也违背了 “单一职责原则”。
我们需要强调,单一职责不只是要求不要让一个子系统(或模块)干多件事情,同时也要求不要让它不干事情。
如果我们用一句话来描述 Model 层的职责,那么应该是 “负责业务需求的内核逻辑”,我们以前经常叫它 “DataCore”。
那么 Model 层为何要发出 DataChanged 事件?
这是从 Model 层的独立性考虑。Model 层作为架构的最底层,它不需要知道其他层的存在,不需要知道到底是 MVC 还是 MVP或者是其他的架构范式。
有了 DataChanged 事件,上层就能够感知到 Model 层的变化,从而作出自己的反应。
如果还记得第一章我们反复强调的稳定点与变化点那么显然DataChanged 事件就是 Model 层面对需求变化点的对策。大部分 Model 层的接口会自然体现业务需求,这是核心价值点,是稳定的。
但是业务的用户交互可能会变化多端,与 PC 还是手机,与屏幕尺寸,甚至可能与地区人文都有关系,是多变的。
用事件回调来解决需求的变化点,这一点 CPU 干过,操作系统也干过,今天你做业务架构也这么干,这就很赞。
理解 View 层
View 层首要的责任,是负责界面呈现。界面呈现只有两个选择,要么自己直接调用 GDI 接口自己画,要么创建子 View 让别人画。
View 层另一个责任是被自然带来的那就是它是响应用户交互事件的入口这是操作系统的界面编程框架决定的。比较理想的情况下View 应该把自己所有的事件都委托delegate出去不要自己干。
但在 View 的设计细节中,也有很多问题需要考虑。
其一View 层不一定会负责生成所有用户看到的 View。 有的 View 是 Controller 在做某个逻辑的过程中临时生成的,那么这样的 View 就应该是 Controller 的一部分,而不应该是 MVC 里面的 View 层的一部分。
其二View 层可能需要非常友好的委托delegate机制的支持。 例如支持一组界面元素的交互事件共同做委托delegate
其三,负责界面呈现,意味着 View 层和 Model 层的关系非常紧密,紧密到需要知道数据结构的细节,这可能会导致 Model 层要为 View 层提供一些专享的只读访问接口。 这合乎情理,只是要确保这些访问接口不要扩散使用。
其四,负责界面呈现,看似只是根据数据绘制界面,似乎很简单,但实则不简单。 原因在于:为了效率,我们往往需要做局部更新的优化。如果我们收到 onPaint 消息,永远是不管三七二十一,直接重新绘制,那么事情就很好办。但是在大部分情况下,只要业务稍微复杂一点,这样的做法都会遇到性能挑战。
在局部更新这个优化足够复杂时,我们往往不得不在 Model 和 View 之间,再额外引入一层 ViewModel 层来做这个事情。
ViewModel 层顾名思义,是为 View 的界面呈现而设计的 Model 层,它的数据组织更接近于 View 的表达,和 View 自身的数据呈一一对应关系Bidi-data-binding。-
-
一个极端但又很典型的例子是 Word。它是数据流式的文档但是界面显示人们用得最多的却是页面视图内容是分页显示的。
这种情况下就需要有一个 ViewModel 层是按分页显示的结构来组织数据。其中负责维持 Model 与 ViewModel 层的数据一致性的模块,我们叫排版引擎。
从理解上来讲,我个人会倾向于认为 ViewModel 是 View 层的一部分,只不过是 View 层太复杂而进行了再次拆分的结果。也就是说,我并不倾向于认为存在所谓的 “Model-View-ViewModel” 这样的模式。
理解 Controller 层
Controller 层是负责用户交互的。可以有很多个 Controller分别负责不同的用户交互需求。
这和 Model 层、View 层不太一样。我们会倾向于认为 Model 层是一个整体。虽然这一个层会有很多类但是它们共同构成了一个完整的逻辑DOM。而 View 层也是如此,它是 DOM 的界面呈现,是 DOM 的镜像,同样是一个整体。
但负责用户交互的 Controller 层,是可以被正交分解的,而且应该作正交分解,彼此完全没有耦合关系。
一个 Controller 模块,可能包含一些属于自己的辅助 View也会接受 View 层委托的一些事件,由事件驱动自己状态,并最终通过调用 Model 层的使用接口来完成一项业务。
Controller 模块的辅助 View 可能是持续可见的,比如菜单和工具条;也可能是一些临时性的,比如 Office 软件中旋转图形的控制点。
对于后者,如果存在 ViewModel 层的话,也有可能会被归到 ViewModel + View 来解决,因为 ViewModel 层可以有 Selection 这样的东西来表示 View 里面被选中的对象。
Controller 层最应该思考的问题是代码的内聚性。哪些代码是相关的,是应该放在一起的,需要一一理清。这也是我上面说的正交分解的含义。
如果我们做得恰当Controller 之间应该是完全无关的。而且要干掉某一个交互特别容易,都不需要删除该 Controller 本身相关的代码,只需要把创建该 Controller 的一行代码注释掉就可以了。
从分层角度,我们会倾向于认为 Model 层在最底层View 层在中间,它持有 Model 层的 DOM 指针Controller 层在最上方,它知道 Model 和 View 层,它通过 DOM 接口操作 Model 层,但它并不操作 View 去改变数据,而只是监听自己感兴趣的事件。
如果 View 层提供了抽象得当的事件绑定接口,你会发现,其实 Controller 层大部分的逻辑都与操作系统提供的界面编程框架无关(除了少量辅助 View是跨平台的。
谁负责把 MVC 各个模块串起来呢当然是应用程序Application了。 在应用开始的时候,它就把 Model 层、View 层,我们感兴趣的若干 Controller 模块都创建好,建立了彼此的关联,一切就如我们期望的那样工作起来了。
兼顾 API 与交互
MVC 是很好的模型来支持用户交互。但这不是桌面程序面临的全部。另一个很重要的需求是提供应用程序的二次开发接口API全称为 Application Programming Interface
提供了 API 的应用程序,意味着它身处一个应用生态之中,可以与其他应用程序完美协作。
通过哪一层提供 API 接口?我个人会倾向于认为最佳的选择是在 ViewModel 层。Model 层也很容易提供 API但是它可能会缺少一些重要的东西比如 Selection。
结语
这一讲我们探讨了一个桌面应用程序的业务架构设计。我们探讨了大家耳熟能详的 MVC 架构范式。一千个人眼中有一千个哈姆雷特,虽然都在谈 MVC但是大家眼中的 MVC 各有不同。
我们站在什么样的架构是好架构的角度,剖析了 MVC 的每一层应该怎样去正确理解与设计,有哪些切实的问题需要去面对。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊基于浏览器的开发。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,183 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Web开发浏览器、小程序与PWA
你好,我是七牛云许式伟。
前面几讲我们聊到桌面软件开发是从原生应用Native App角度来讲的我们的讨论范围还只是单机软件没有涉及网络相关的部分。
虽然介绍 Model 层的时候,我拿基于数据库实现 Model 层来谈常见的两个误区,但这只是因为这种问题经常能够见到,比较典型。实际纯单机软件很少会基于数据库来做,通常是自己设计的内存中的数据结构。
浏览器
今天开始我们聊聊浏览器。从商业价值看,浏览器带来的最为重大的进步是如下这三点。
其一,软件服务化。 当产品交付从单机软件转向云服务后,社会分工就发生了巨大变化。
互联网让 “24 小时不间断服务”成为可能。任何一个环节的力量都得到百倍乃至千倍的放大,都有可能成长出一个超级节点,进而吞噬上下游,让服务链条更短。
其二,随时发布。 这极大改进了软件迭代的效率。人们快速试验自己的想法,不必过度因为顾虑软件质量召回而束手束脚。
其三,跨平台。 浏览器消除了不同操作系统平台的差异性,让一份代码到处运行成为可能。
不过我们今天把重心放到界面开发这个视角。** 从作为界面开发框架的角度看,浏览器带来的最重大变化又是哪些?**
其一,操作系统的窗口系统被颠覆。 一个网页只是一个窗口不再有父子窗口。所有网页中的界面元素都是一个虚拟视图Virtual View无论是大家耳熟能详的通用控件比如 inputimagediv 等等还是自绘窗口canvas都一样。
这一点非常关键。哪些元素是子 View哪些元素是图形Shape 已经完全淡化了更多的是通过一种统一机制来完成事件分派Event Dispatch
其二,窗口的绘制机制变了。 之前是调用操作系统的 GDI 生成界面,现在换成了 HTML+CSS。当然如果我们非要把 HTML+CSS 看作是另一种 GDI 语言,某种程度来看好像也可以。
但是实际上 GDI 与 HTML+CSS 有非常本质的差别。它们一个是在绘制界面一个是在声明界面。这两者的本质差别在视图更新Update View的时候一下子就显现出来。
上一讲我们在介绍 View 层的时候,介绍过 View 层的一大难点是做局部更新的优化。在 View 局部优化比较复杂的时候,我们甚至会引入 ViewModel 层来做视图局部更新的支持。
站在这个角度看 HTML+CSS其实我们不能把它理解为 View 层,它其实是 ViewModel 层。View 层由谁干掉了?浏览器。在我们修改 HTML DOM 时,浏览器自动就更新了 View。怎么做到局部更新优化的你不必关心浏览器已经干完这件事情了。
这事的真正价值超过你的想象。它大幅提升了桌面应用开发的效率。
其三,语言限制。 浏览器的确大幅改善了界面开发的效率,但是从语言支持的角度,大部分操作系统都支持各种语言作为开发工具,而浏览器长期以来只支持 JavaScript 一门语言。
这当然是一个不小的制约。所以有很多人在试图突破这个限制。Google 曾经想要把 Dart 语言打造为下一代的 JavaScript但最终以失败告终。
今天主流的方案还是以代码转换器为主。也就是说,我可以用自己期望的语言(比如 Go 语言)来做开发。但是在发布前通过代码转换器转为 JavaScript。
今天还有一个重要的尝试是 WebAssembly。它的目标是打通各类语言与 Web 之间的桥梁。
其四B/S 架构。 无论是 B/S 还是 C/S本质上还是软件服务化。这对软件架构产生了巨大影响。
一方面,从 Server 端的逻辑看,系统从单用户变成了多用户。另一方面,从 Browser 端(或 Client 端)看,仍然是单用户,但是没有了数据,数据都在 Server 端。这对应用架构提出了新的挑战。
应该怎么设计 Web 程序的架构?我们在下一讲中接着聊这个话题。
小程序
2016年9月微信小程序最初叫“应用号”开始内测。下面是当天七牛云团队的一番内部对话。
许式伟:看下这篇,微信应用号来了。-
Gina这个理念应该不是去构建一个Store它的理念是用完即走是场景通过扫码或者搜索触发的并且应该打的是实体或者服务售卖群体不会针对微信内消费是订阅号的升级展现方式。-
许式伟:关于微信小程序(应用号),我能透露的几个细节,这一篇更详细一些。-
微信没有必要在微信App内放Appstore可以只有Web版本的AppstoreApp不需要安装甚至可能以消息的方式发给别人以服务号的形式存在这是迭代式开发。-
以后终极形态还可以变当前重心应该在runtime的稳定。通过上面的介绍微信实际上升级了浏览器内核的标准符合我之前说的新一代浏览器的定义。-
Gina小程序是一种不需要下载安装即可使用的应用它实现了应用“触手可及”的梦想用户扫一扫或者搜一下即可打开应用。也体现了“用完即走”的理念用户不用关心是否安装太多应用的问题。应用将无处不在随时可用但又无需安装卸载。-
徐倒立WebApp 这个技术和 idea最早来自 Google 浏览器 微信是商业化的最佳实践。Google浏览器在支持开发者开发App时就提出Intents并且和Android是可以互动的。-
Gina没有好的土壤有好的功能也是没意义的。-
许式伟微信小程序在别的App不是做不到是做了意义太小。苹果和腾讯不约而同在IM里面做App是有道理的。-
GinaIM比搜索和浏览器的封闭性更强。用户不容易跳转出去。封闭性强的土壤才能构建App生态。-
许式伟所以移动时代最佳的浏览器是IM不再是以前传统浏览器。-
杜江华我们应该多讨论to B巨头们怎么玩、怎么思考的对我们现阶段才更有意义支付宝、微信等都是to C的。-
许式伟不是这样的。to C 的生态变化,会影响 to B。to C 是根源我们是帮用户做App的如果不知道以后App是怎么玩的怎么可能做好。-
杜江华理解了那应该是客户群之一互联网部分还有不少大B 需要有其他不同的思考方式。-
Gina大B的趋势我挺想听的。这周聚会阿杜能否把最近大项目和大传统客户的一些动作详细聊一下。你这边离业务最近。-
许式伟其实比你想象得还要恐怖不管你是什么大B你都得拥抱微信只有微信和QQ让整个7亿中国网民在里面安家了这就是一个虚拟的国家。所以我的判断是没有大B不开发微信小程序这只是个眼光和时间问题。-
吕桂华:这个微信应用号我们是应该关注的,相当于市场上多了一个操作系统。-
许式伟微信应用号不只是一个新OS而且是下一代OS苹果和谷歌不会坐视不理。当然还有一个痛点是跨平台。-
Gina这个东西可能对营销生态有大的影响。我们也要开发些营销工具。
在这段对话之后的一个月内,我们做出了七牛的第一笔对外投资:“即速应用”,它致力于帮助企业开始快速构建自己的小程序。
为什么微信小程序必然会成功?
因为,有 7 亿人同时使用的操作系统,很少。如果我们把不同 Android 厂商归为不同的主体的话,微信小程序是当时世界上最大的单一来源的操作系统。
随后,支付宝发布了支付宝小程序,国内手机厂商联合发布了 “快应用”,今日头条也发布了自己的小程序。
一下子,小程序变成了一支巨大的新兴力量,成为真正意义上的国产操作系统,对抗着 Android 和 iOS 两大移动操作系统生态。
但是,目前来说,小程序生态仍然存在有诸多问题。
最为关键的,是标准不统一。虽然都叫小程序,但是它们的接口各自都在快速迭代,很难去建立统一的标准,更谈不上让开发者一次开发,到处可用。
这和 Android 不同。虽然 Android 厂商很多,但是不同 Android 的开发接口是一致的,开发工具链是一致的。
小程序的厂商们会好好坐下来谈一谈标准一致的事情吗?应该做,但可能他们现在没空管开发者们的体验,他们的关注点是怎么快速抢地盘。
聊了那么多,我们话题回到技术本身。小程序和传统的 Web 开发有何不同?
其实有很大不同。小程序更像是 Native 程序在线化,而不是 PC Web 移动化。
为什么我们这么说?因为小程序是一个应用,而不是由一个个 Web 页面构成。
我们需要提交应用给微信或支付宝,他们也会像苹果审核 AppStore 上的应用一样,掌控着 App 的生杀大权。
而且理论上可以比苹果更牛的是,他们可以下线一个已经有千万甚至上亿级别用户的 App让他们一无所有。苹果可以掐掉一个 App 的新增,他们可以掐掉一个 App 的全部。
这会带来新的社会问题:操作系统厂商的权限边界究竟在哪里。这不是一个简单的技术问题,而是一个伦理与法律的问题。
正因为这个风险如此之高,所以所有的厂商在拥抱微信的同时,必然时时刻刻想着如何逃离微信。
刀刃,永远是两面的。
这也是我个人非常佩服Facebook扎克伯格的地方。他看到了终局所以在发布 Libra 的时候,他选择的是让一步,放弃 Control。
我还是那句话,他会成功。
让一步,其实就是进一百步。
PWA
国内大厂们纷纷布局小程序的时候Google 也在发力自己的移动浏览器方案,叫 PWA全称 “Progressive Web App”。
其实 Google 想要让浏览器获得 Native 应用同等体验之心是路人皆知的事实。
在 PC 时期Google 就搞了 Google Native Client (NaCl),后来转向了 WebAssembly。移动应用的在线化Google 也同样在探索。
PWA 开始于 2015 年比微信小程序早很多并得到了苹果和微软的支持。从这个角度来说PWA 的潜力不容小觑。
怎么理解 PWA你可以理解为海外版的小程序。
那么它和小程序的差别在哪?
其一演进思路不同。PWA 基本上以兼容并对 Web 进行逐步改造升级为导向。而小程序和 Web 还是有较大程度的差异。
其二关注焦点不同。PWA 更技术化,它很大的精力重心放在如何让 PWA 在断网情况下和本地应用有更一致的体验。而小程序关注点在如何撬动这么庞大的用户市场,小程序之后专门出现小游戏,更加能够证明这一点。
其三PWA 并没有中心化的 AppStore它更像是一项技术标准而不是一个封闭的操作系统。支持 PWA 的厂商们不用担心被人掐脖子,怎么更新你的应用自己说了算。
虽然技术上相似但是如果以操作系统角度看两者有代差。PWA 如果我们看作操作系统的话,相比小程序来说太传统。
为什么这么讲?因为小程序符合我前面介绍现代操作系统的 “账号(Account)-支付(Pay)-应用市场(AppStore)” 的商业闭环,但是 PWA 并没有账号,也没有支付。
怎么看待 PWA 的未来?
最终把 PWA 发扬光大的,很可能是 Facebook当然 Facebook 也非常大概率选择放弃包袱,和小程序一样重新出发)。加上 Libra秒杀微信小程序。
结语
今天我们聊了浏览器,结合浏览器的发展趋势,谈了现在仍然在高速迭代中的移动浏览器之争。有中国特色的小程序,和海外版小程序 PWA。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将聊聊 “跨平台与 Web 开发的建议”。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,131 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 跨平台与 Web 开发的建议
你好,我是七牛云许式伟。
上一讲我们聊了浏览器,以及移动浏览器之争:小程序与 PWA。
当我们思考浏览器从技术上带来了什么的时候,我们可以把它分为两点。
跨平台桌面程序开发;
Web 开发B/S 架构的新型应用)。
今天我们分别就跨平台桌面程序和 Web 开发展开来聊一聊。
跨平台桌面程序开发
跨平台的桌面程序开发是一个超级难题。无数人前仆后继,各种方案层出不穷,但至今为止,仍然没有称得上真正深入人心的解决方案。
原因很简单,因为桌面程序本身的范畴在变。有两个关键的因素会导致桌面开发产生巨大的差异性。
一个因素自然是操作系统。不同的操作系统抽象的界面程序框架并不一致。这些不一致必然导致开发工作量的增加。
放弃某个操作系统,就意味着放弃某个流量入口,也就意味着放弃这些用户。所以虽然很麻烦,我们还是不得不支持着每一个主流的操作系统。
另一个因素是屏幕尺寸。就算相同的操作系统,在不同尺寸的屏幕上,交互的范式也会存在很大的差异性,这也会导致不低的跨平台工作量。
首先我们看下操作系统。
PC 本地WindowsmacOSLinux 等等;
PC WebChromeSafariFireFox 等等;
Mobile 本地AndroidiOS 等等;
Mobile Web小程序PWA 等等。
我们再看下屏幕尺寸。
大屏PC、笔记本Pad 等等;
中屏:手机;
小屏:手表。
如此繁复多样的终端类型,无怪跨平台如此之难。我们来总结一下当前都有哪些跨平台的解决方案。
这个列表只是沧海一粟。之所以没有列那么多,也是因为大部分的跨平台框架都已经不怎么活跃,已经无疾而终了。
目前来说,还很难说哪个方案会胜出。
关于跨平台开发,我觉得有一句话特别深刻:“每一次统一的努力,都最终变成新的分裂”。当然,这样的事情在很多领域都会发生,只是跨平台开发更加如此。
但是无论如何,跨平台的梦还会继续。
Web 开发
聊完了跨平台我们来聊聊浏览器带来的另一面Web 开发。
Web 的 B/S 架构意味着编写软件有了更高的复杂性。这主要表现在以下几个方面。
其一,多用户。 有了 Server 端,意味着用户的数据不再是保存在 ClientBrowser而是存储在 Server 端。
其二,更高的数据可靠性要求。 数据在 Client 端,客户自己对数据的可靠性负责。硬盘坏了,数据丢了,用户会后悔没有对数据进行备份。
但是一旦数据在 Server 端,数据可靠性的责任方就到了软件厂商这边。如果厂商不小心把数据搞丢了,用户就会跳起来。
其三,更多可能的分工安排。 详细来说Web 应用从流派来说,分为两大类:胖前端与胖后端。
所谓胖前端是指把尽可能多的业务逻辑放在前端。极端情况下整个网站就是一个单页的应用。胖前端无论开发体验还是用户体验都更接近于本地应用Native App
所谓胖后端,是指主要逻辑都在后端,包括界面交互的事件响应,也通过网络调用交给了后端来实现。
我们先看客户端Client也就是浏览器端Browser。上一讲我们提到浏览器的界面框架并没有窗口系统它通过 HTML+CSS 来描述界面。
HTML+CSS 与其理解为 View 层,不如理解为 ViewModel 层,因为 HTML DOM 从数据角度完整描述了界面的样子。而 View 层已经被浏览器自己实现了。
这极大简化了界面开发的复杂性,因为界面的局部更新是一个复杂的话题,今天浏览器通过引入 HTML+CSS 这样的 ViewModel 层把它解决了。
这个时候我们重新看 MVC 框架在浏览器下的样子,你会发现它变成了 MVMP 模式,全称为 “Model-ViewModel-Presenter”。
首先,我们看事件响应过程。 浏览器的 View 收到了用户的交互事件它把这些事件委托delegate给了 ViewModel 层,并且通过 HTML DOM 暴露出来。通过修改 HTML 元素的事件响应属性,一般名字叫 onXXX比如 onclick可以获得事件的响应机会。
然后我们看 Model 层的数据变化DataChanged事件。 在标准的 MVC 模式中Model 层的数据变化是通知到 View 层,但是在浏览器下 View 是由浏览器实现的,要想让它接受 DataChanged 事件并且去处理是不太可能了。
所以解决思路自然是让 Controlller 层来做,这样就变成了 MVP 模式。 但是我们又不是标准的 MVP因为 Presenter 层更新界面Update View并不是操作 View而是 ViewModel。
综上,浏览器下的 MVC最终实际上是 MVMPModel-ViewModel-Presenter
聊完了浏览器端我们在来看下服务端Server。虽然这一章我们的重点不是聊服务端但是为了有个完整的认识我们还是要概要地梳理一下 Server 端的架构。
前面我们在 “[22 | 桌面程序的架构建议]” 中曾提到桌面软件除了要支持用户交互外另一个很重要的需求是提供应用程序的二次开发接口API
到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用以支持自动化Automation方面的需求。
所以,对 Server 端来说,最底层的是一个多租户的 Model 层Multi-User Model它实现了自动化Automation所需的 API。
在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同Web 层是基于会话的Session-based因为它负责用户的接入每个用户登录后会形成一个个会话Session
如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分Web 这边的 Model 层我们叫它 Session-based Model。相应地ViewModel 层我们叫它 Session-based ViewModel。
在服务端Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel从而响应用户的交互。
Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。
Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出 View这样整个系统就运转起来了。
结语
今天我们聊了 Web 带来的两个重要改变。一个是跨平台,一个是 Web 开发,即 B/S 架构下的新型应用到底应该怎么实现。
从跨平台来说,这个话题是桌面程序员(也叫“大前端”)永远的痛。计划赶不上变化,用来形容大前端程序员面临的窘境是一点都不过分的。一个玩意还没搞熟悉了,另一个东西又出来了,变化太快,要跟上实属不易。
从 Web 开发来说MVC 变成了 MVMPModel-ViewModel-Presenter。我们和单机的桌面软件一样的建议认真对待 Model 层,认真思考它的使用接口是什么样的,把 Model 层做厚。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web到底是什么样的。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,153 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 桌面开发的未来
你好,我是七牛云许式伟。
今天,我原本想结合一个实战例子,来回顾一下 “桌面软件开发” 一章到今天为止的内容,但是由于要准备的内容比较多,会延后一下。
所以,今天我还是会按原来大纲的内容,谈谈我个人对桌面开发未来趋势的判断。
桌面平台的演进与未来
谈未来,我们要先看过去。
在 PC 时期,本地桌面操作系统主流的有 Windows、MacOS、Linux。为了消除不同平台的差异于是就出现了 QT、wxWidgets 这样的跨平台解决方案。
但是它们都败了败给了一个它们并没有意想得到的对手PC 浏览器。
浏览器并不是为跨平台而来,但是除了干成了软件服务化外,也干成了跨平台这件事情。
虽然浏览器厂商很多,但是它们遵循相同的规范。** 这意味着支持了浏览器,就支持了所有的 PC 用户。**
这太诱人了。
于是在软件服务化和跨平台开发的双重优势下软件厂商们趋之若鹜QT、wxWidgets 这些方案就变成小众需求。
QT 有机会反抗么?其实是有的。关于这一点我们后面再说。
然后,移动大潮来了。我记得 2006 年有一次我和雷军雷总吃饭,聊起对移动操作系统未来趋势的判断,我们俩各持己见。
雷总认为 WinCE 会赢,因为 Windows 已经培育了最大的开发者群体。而我认为 Symbian 会赢,因为它占据了最大的终端用户群。
结局大家已经知道了最后赢的是谁都没有预料到的玩家Android 和 iOS。
如果我们从事后看实际上这个事情并不是完全没有迹象可循。iOS诞生于2007年固然当时还没有诞生但是 Android 诞生于 2003 年,并于 2005 年被 Google 收购。作为搜索引擎厂商Google 收购一个手机操作系统,显然不是随意为之的,而是公司发展战略上的考量。
Android 和 iOS 的诞生,一下子让操作系统的生态变得更为复杂。
操作系统不同,输入方式不同(鼠标 vs 触摸屏),屏幕大小不同,想要一套代码横跨 PC 和移动多个平台?太难。
这还不算。虽然还不像手机那么普遍,但是今天手表、电视机、汽车,以及各式各样的 IoT 传感设备,都需要操作系统的支持。
从操作系统发展来说,我个人会倾向于按交互方式来分。 未来桌面操作系统和服务端操作系统会渐行渐远,差异越来越大。** 从交互来说,服务端会维持简约,经典的命令行交互会长期占据主流。**
而桌面操作系统,笔记本市场,鼠标+键盘仍然会占据主流。虽然鼠标形态已经变了,变成了触控板,但是鼠标指针这种基于精确位置交互的方式会得到保留。多点触摸的交互,也会得到部分机型的支持。
移动市场,多点触摸+键盘会占据主流。但是语音助手也会得到一定程度的渗透。
IoT 市场,语音助手会占据交互的主流。 但也会有一些设备引入多点触摸这种交互方式来补充。在这个市场,目前看技术上的 Amazon 和 Google 占据了领先地位。虽然苹果入场较早,但是 Siri 的表现还是和前两者有较大的差距。
IoT 设备会两极分化。** 一类 IoT 设备是专用设备,它的应用场景非常固定,它对操作系统最大诉求是裁剪能力:最好不要给我太多的东西,匹配场景就好。** 能不能提供AppStore不是重点有也只有很少的一些应用其实直接找合作伙伴就好。
一类 IoT 设备则有较大的通用性。 但受限于语音助手技术的限制IoT 操作系统的开放性要比移动系统差很多。所以在有任何可能的时候,这些设备就会带上触摸屏变成一台由移动系统支持的设备。
长远来说,要看智能语音技术的发展。关于这一点,我个人抱谨慎乐观的态度。但显然,在很长一段时间里,我们面对的还是移动操作系统。
这么多操作系统怎么搞呢?
于是 React-Native 出现了。理论上React-Native 可以横跨 PC 和移动。因为 React 本身基于 Web 技术,可以支持 PC 浏览器,而 React-Native 又支持 iOS 和 Android从而做到 “Learn oncewrite anywhere”。
平台差异不能完全消除,只能尽可能地减少。
手机操作系统这场仗刚有了眉目,移动浏览器之争又起来了。
国内涌现了大量的小程序厂商,国外 Google 也在推 PWA。还有 Facebook 意见不明,不知道会去支持 PWA还是基于自己的 React-Native 技术搞一套新的移动浏览器标准。
这下好了,统一的 Web 分裂成多个技术阵营。
移动浏览器,国内外不统一已经是既成事实。海外巨头们除了 Facebook已经用明确的行动支持 PWA。小程序在海外要想有市场要看头条腾讯阿里们的海外市场占有率。
移动 WebApp 技术的分裂是否会最终得到纠正?这仍然是未知之数。
但由此观之,终端操作系统的多元化已经是既成现实。这对开发者生态将产生重要的影响。
我们可能有人留意到QT 今天基本上支持了所有的桌面操作系统,不管是 PC 还是移动。但是这还不太够,因为还差 Web、小程序和 PWA。
今天的跨平台,重点是要跨 Android、iOS、Web、小程序和 PWA。如果精力顾不上PC 桌面操作系统的优先级反而可以缓一缓,毕竟 Web 也能够顶一下。
QT 的机会在这里。但是很明显它并没有意识到兼容 Web 开发对于一个跨平台工具的重要性。
就算在 PC 时期,一个同时支持 Web 和本地操作系统的跨平台工具也能够受到欢迎。今天随着桌面平台的多元化,跨平台工具的需求达到了历史最高点。
当然还有一种跨平台的思路,是垂直发展,比如专做游戏开发的跨平台。不过单就游戏开发这个领域而言,已经有强大的玩家,比如 Unity 在里面。
那么,通用的跨平台怎么做到?
Google Flutter 给了一条路,它把对操作系统的要求最小化,整个界面系统完全自己在用户态构建。
这个思路和 Go 语言有点像。Go 语言其实是在用户态完全重写了操作系统的进程管理和 IO 子系统。
那么 Flutter 会像 Go 语言一样成功么?
我个人持谨慎态度。不同操作系统的用户是有自己独特的交互范式的。比如 Android 和 iOS 用户的习惯就有一定的差异。而这可能恰恰是跨平台更难的一点。
另一个是软件体积问题。Android 是 Google 自己的,可以通过让 Android 预装基础库来减少体积。但是更多的系统有可能需要一个体积不小的跨平台层。
这会制约 Flutter 的发展。客户端软件的尺寸,对新用户的转化率有着至关重要的影响。何况像微信小程序这样的平台,还限制了小程序的尺寸,最早限制为 4M后来放宽到 8M。
这和 Go 语言面临的环境不太一样。Go 语言因为面向的是服务端,用户对软件的尺寸不敏感,反倒是部署的便捷性更敏感。
我个人更倾向于尺寸更轻盈的跨平台工具。
其次是编程手法上的问题。大趋势是要用 Web 这种声明式的界面描述方式。至于是否需要在语法上进行一次重新梳理我个人觉得是有必要的。React-Native 在这个方向的探索是个不错的尝试。
在这一点上,苹果的 SwiftUI 或许更值得关注。苹果以极简体验著称SwiftUI 某种程度上来说代表了关于跨平台开发的可能方向。
儿童编程教育
在我们谈论桌面开发的时候,我认为其实还有一个重要但又很容易被忽视的趋势,是儿童编程教育的走向。
说到儿童编程教育,我们大多数人可能都知道 Scratch 语言。但是要说儿童编程的鼻祖,毫无疑问应该算 Logo 语言,海龟作图。
Scratch 语言由美国麻省理工大学MIT于 2007 年发布,到现在已经发展到了 3.0 版本,项目正变得越来越活跃。
在 Scratch 之后Google 也曾经发布了 Blockly 语言进军儿童编程教育。但是由于缺乏社区基础Blockly 语言一直不温不火。
但有两件有趣的事情。
其一Scratch 3.0 是基于 Blockly 的源代码改造而成的,为此据说 Google 也投入了大量的技术人员进行协助,双方协同开发。
其二Google 基于 Blockly 语言搞出了一个 App Inventor用于教育儿童学习 Android 开发。
无独有偶的是,苹果推出的 Swift 语言启蒙教程也是针对儿童的,在 AppStore 上可以下载到,叫 “Swift Playgrounds”。
这意味着,我们原本以为两件风马牛不相及的事情,其实是密切相关的。
桌面开发的未来是什么?
从终局的视角来看,桌面开发的终极目标,是让儿童可以轻松编写出生产级的应用。
这不是痴人说梦。
在 iOS 出来之前,如果有人说他要开发一个让三岁小孩都会使用的电脑,可能会有很多人觉得绝无可能。
但是苹果的确做到了。虽然可能还不能完全识别电脑上常见的文字,但是一个三岁的儿童使用起 iPhone 或者 iPad 却毫不困难。
那么,让一个八岁刚刚上学没多久的小学生去做生产级的应用,这事也不是遥不可及的梦想。
桌面开发技术的演进,和儿童编程教育相向而行,有一天必然汇聚于一点上。
结语
今天我们结合桌面开发和儿童编程教育,聊了个人对桌面的未来演进趋势的判断。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将结合一个实际的案例,来讲解一下桌面开发(含单机软件和 Web到底是什么样的。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,256 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 实战(一):怎么设计一个“画图”程序?
你好,我是七牛云许式伟。
到上一讲为止,桌面程序架构设计的基本结构就讲完了。直到现在为止,我们没有讨论任何与具体的应用业务逻辑本身相关的内容。这是因为探讨的内容是普适有效的设计理念,整个讨论会显得很抽象。
今天我们结合一个实际的应用案例,来回顾一下前面我们介绍的内容。
我们选择了做一个 “画图” 程序。选它主要的原因是画图程序比较常见,需求上不需要花费过多的时间来陈述。
我们前面说过,一个 B/S 结构的 Web 程序,基本上分下面几块内容。
Model 层一个多用户Multi-User的 Model 层,和单租户的 Session-based Model。从服务端来说Session-based Model 是一个很简单的转译层。但是从浏览器端来说Session-based Model 是一个完整的单租户 DOM 模型。
View 层:实际是 ViewModel 层,真正的 View 层被浏览器实现了。ViewModel 只有 View 层的数据和可被委托的事件。
Controller 层:由多个相互解耦的 Controller 构成。切记不要让 Controller 之间相互知道对方,更不要让 View 知道某个具体的 Controller 存在。
画图程序的源代码可以在 Github 上下载,地址如下:
https://github.com/qiniu/qpaint
今天我们讨论浏览器端的 ModelView 和 Controller。
Model 层
我们先看 Model 层。浏览器端的 Model 层,代码就是一个 dom.js 文件。它是一棵 DOM 树,根节点为 QPaintDoc 类。整个 DOM 树的规格如下:
class QLineStyle {
properties:
width: number
color: string
methods:
constructor(width: number, color: string)
}
class QLine {
properties:
pt1, pt2: Points
lineStyle: QLineStyle
methods:
constructor(pt1, pt2: Point, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QRect {
properties:
x, y, width, height: number
lineStyle: QLineStyle
methods:
constructor(r: Rect, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QEllipse {
properties:
x, y, radiusX, radiusY: number
lineStyle: QLineStyle
methods:
constructor(x, y, radiusX, radiusY: number, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPath {
properties:
points: []Point
close: bool
lineStyle: QLineStyle
methods:
constructor(points: []Point, close: bool, lineStyle: QLineStyle)
onpaint(ctx: CanvasRenderingContext2D): void
}
interface Shape {
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPaintDoc {
methods:
addShape(shape: Shape): void
onpaint(ctx: CanvasRenderingContext2D): void
}
目前这个 DOM 还是单机版本的,没有和服务端的 Session-based Model 连起来。关于怎么连,我们下一讲再讨论。
这个 Model 层的使用是非常容易理解的,也非常直观体现了业务。主要支持的能力有以下两个方面。
其一添加图形Shape可以是 QLineQRectQEllipseQPath 等等。
其二绘制onpaint。前面我们介绍 MVC 的时候,我曾提到为了 View 层能够绘制,需要让 DOM 层把自己的数据暴露给 View 层。
但是从简洁的方式来说,是让 Model 层自己来绘制,这样就避免暴露 DOM 层的实现细节。虽然这样让 Model 层变得有那么一点点不纯粹,因为和 GDI 耦合了。但是我个人认为耦合 GDI 比暴露 DOM 的数据细节要好,因为 GDI 的接口通常来说更稳定。
依赖选择是考虑耦合的一个关键因素。在依赖选择上,我们会更倾向于依赖接口更为稳定的组件,因为这意味着我们的接口也更稳定。
ViewModel 层
我们再看 ViewModel 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。index.htm 是总控文件,主要包含两个东西:
界面布局Layout
应用初始化InitApplication比如加载哪些 Controllers。
而 view.js 是我们 ViewModel 层的核心,实现了 QPaintView 类。它的规格如下:
interface Controller {
stop(): void
onpaint(ctx: CanvasRenderingContext2D): void
}
class QPaintView {
properties:
doc: QPaintDoc
properties: {
lineWidth: number
lineColor: string
}
drawing: DOMElement
controllers: map[string]Controller
methods:
get currentKey: string
get lineStyle: QLineStyle
onpaint(ctx: CanvasRenderingContext2D): void
invalidateRect(rect: Rect): void
registerController(name: string, controller: Controller): void
invokeController(name: string): void
stopController(): void
getMousePos(event: DOMEvent): Point
events:
onmousedown: (event: DOMEvent):void
onmousemove: (event: DOMEvent):void
onmouseup: (event: DOMEvent):void
ondblclick: (event: DOMEvent):void
onkeydown: (event: DOMEvent):void
}
var qview = new QPaintView()
看起来 QPaintView 的内容有点多,我们归类一下:
和 Model 层相关的,就只有 doc: QPaintDoc 这个成员。有了它就可以操作 Model 层了。
属于 ViewModel 层自身的,数据上只有 properties 和 drawing。 其中 properties 是典型的 ViewModel 数据,用来表示当前用户选择的 lineWidth 和 lineColor 等。drawing 则是浏览器对 HTML 元素的抽象,通过它以及 JavaScript 全局的 document 对象就可以操作 HTML DOM 了。
当然 ViewModel 层一个很重要的责任是绘制。onpaint 和 invalidRect 都是绘制相关。invalidRect 是让界面的某个区域重新绘制。当前为了实现简单,我们总是整个 View 全部重新绘制。
前面我说过, Web 开发一个很重要的优势是不用自己处理局部更新问题,为什么这里我们却又要自己处理呢?原因是我们没有用浏览器的 Virtual View整个 DOM 的数据组织完全自己管理,这样我们面临的问题就和传统桌面开发完全一致。
剩下来的就是 Controller 相关的了。主要功能有:
registerController登记一个 ControllerinvokeController激活一个 Controller 成为当前 ControllerstopController停止当前 ControllerView 层并不关心具体的 Controller 都有些什么,但是会对它们的行为规则进行定义;
事件委托delegate允许 Controller 选择自己感兴趣的事件进行响应;
getMousePos 只是一个辅助方法,用来获取鼠标事件中的鼠标位置。
View 层在 MVC 里面是承上启下的桥梁作用。所以 View 层的边界设定非常关键。
如果我们把实际绘制onpaint的工作交给 Model 层,那么 View 基本上就只是胶水层了。但是就算如此View 层仍然承担了一些极其重要的责任。
屏蔽平台的差异。Model 层很容易做到平台无关,除了 GDI 会略微费劲一点Controller 层除了有少量的界面需要处理平台差异外,大部分代码都是响应事件处理业务逻辑,只要 View 对事件的抽象得当,也是跨平台的。
定义界面布局。不同尺寸的设备,界面交互也会不太一样,在 View 层来控制不同设备的整体界面布局比较妥当。
Controller 层
最后我们看下 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件。详细信息如下。
Menu, PropSelectors, MousePosTracker accel/menu.js
Create Pathcreator/path.js
Create FreePathcreator/freepath.js
Create Line, Rect, Ellipse, Circle creator/rect.js
其中menu.js 主要涉及各种命令菜单和状态显示用途的界面元素。用于创建各类图形Shape选择当前 lineWidth、lineColor以及显示鼠标当前位置。
在创建图形这些菜单项上,有两点需要注意。
其一,菜单并不直接和各类创建图形的 Controller 打交道,而是调用 qview.invokeController 来激活对应的 Controller这就避免了两类 Controller 相互耦合。
其二,虽然前面 Model 层支持的图形只有 QLine、QRect、QEllipse、QPath 等四种但是界面表现有六种Line、Rect、Ellipse、Circle、Path、FreePath 等等。这是非常正常的现象。同一个 DOM API 在 Controller 层往往会有多条实现路径。
选择当前 lineWidth、lineColor 操作的对象是 ViewModel 的数据,不是 Model。这一点前面几讲我们也有过交代。我们当时举的例子是 Selection。其实你把当前 lineWith、lineColor 看作是某种意义上的 Selection ,也是完全正确的认知。
鼠标位置跟踪MousePosTracker是一个极其简单但也是一个很特殊的 Controller它并不操作任何正统意义的数据Model 或 ViewModel而是操作输入的事件。
剩下来的几个 JavaScript 文件都是创建某种图形。它们的工作机理非常相似,我们可以随意选一个看一下。比如 QRectCreator 类,它的规格如下:
class QRectCreator {
methods:
constructor(shapeType: string)
stop(): void
onpaint(ctx: CanvasRenderingContext2D): void
onmousedown: (event: DOMEvent):void
onmousemove: (event: DOMEvent):void
onmouseup: (event: DOMEvent):void
onkeydown: (event: DOMEvent):void
}
在初始化构造QRectCreator 要求传入一个 shapeType。这是因为 QRectCreator 实际上并不只是用于创建 Rect 图形,还支持 Line、Ellipse、Circle。只要通过选择两个 points 来构建的图形,都可以用 QRectCreator 这个 Controlller 来做。
QRectCreator 接管了 View 委托的 mousedown、mousemove、mouseup、keydown 事件。
其中mousedown 事件记录下第一个 point并由此开启了图形所需数据的收集过程mouseup 收集第二个 point随后后创建相应的 Shape 并加入到 DOM 中。keydown 做什么?它用来支持按 ESC 放弃创建图形的过程。
架构思维上我们学习到什么?
通过分析这个 “画图” 程序,你对此最大的收获是什么?欢迎留言就此问题进行交流。这里我也说说我自己想强调的点。
首先,这个程序没有依赖任何第三方库,是裸写的 JavaScript 代码。关于这一点,我想强调的是:
第一,这并不是去鼓励裸写 JavaScript 代码,这只是为了消除不同人的喜好差异,避免因为不熟悉某个库而导致难以理解代码的逻辑;
第二,大家写代码的时候,不要被框架绑架,框架不应该增加代码的耦合,否则这样的框架就应该丢了;更真实的情况是,你很可能是在用一个好框架,但是是不是真用好了,还是取决于你自己的思维。
从架构设计角度来说,在完成需求分析之后,我们就进入了架构的第二步:概要设计(或者也可以叫系统设计)。这个阶段的核心话题是分解子系统,我们关心的问题是下面这些。
每个子系统负责什么事情?
它依赖哪些子系统?它能够少知道一些子系统的存在么?
它们是通过什么接口耦合的?这个接口是否自然体现了两者的业务关系?它们之间的接口是否足够稳定?
MVC 是一个分解子系统的基本框架,它对于桌面程序尤为适用。通过今天对 “画图” 程序的解剖,我们基本能够建立桌面程序框架上非常一致的套路:
Model 层接口要自然体现业务逻辑;
View 层连接 Model 与 Controller它提供事件委托delegate方便 Controller 接收感兴趣的事件,但它不应该知道任何具体的 Controller
Controller 层中,每个 Controller 都彼此独立,一个 Controller 的职责基本上就是响应事件,然后调用 Model 或 ViewModel 的接口修改数据。
当然,这里没有讨论特定应用领域本身相关的架构问题。对于桌面程序而言,这件事通常发生在 Model 层。但对于我们今天的例子 “画图” 程序而言Model 层比较简单,基本上还不太需要讨论。在后面,我们也可能会尝试把这个 “画图” 程序需求变复杂,看架构上应该怎么进行应对。
结语
今天我们结合一个大家非常熟悉的例子 “画图” 程序来介绍 MVC 架构。虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,232 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 实战(二):怎么设计一个“画图”程序?
你好,我是七牛云许式伟。
上一讲开始,我们进入了实战模式。从目前看到的反馈看,我的预期目标并没有达到。
我复盘了一下,虽然这个程序看起来比较简单,但是实际上仍然有很多需要交代而没有交代清楚的东西。
我个人对这个例子的期望是比较高的。因为我认为 “画图” 程序非常适合作为架构实战的第一课。“画图” 程序需求的可伸缩性非常大,完完全全是一个迷你小 Office 程序,很适合由浅及深去谈架构的演进。
所以我今天微调了一下计划,把服务端对接往后延后一讲,增加一篇 “实战(中)” 篇。这个“中”篇一方面把前面 “实战(上)” 篇没有交代清楚的补一下,另一方面对 “画图” 程序做一次需求的迭代。
MVP 版画图程序
先回到 “实战(上)” 篇。这个版本对画图程序来说,基本上是一个 MVP 版本:只能增加新图形,没法删除,也没法修改。
怎么做?我们先看 Model 层,它的代码就是一个 dom.js 文件。从数据结构来说,它是一棵以 QPaintDoc 为根的 DOM 树。这个 DOM 树只有三级Document -> Shape -> LineStyle。具体细节可以参阅下表
这个表列出的是 Model 和 View、Controllers 的耦合关系Model 都为它们提供了什么可以看出View 层当前对 Model 层除了绘制onpaint没有其他任何需求。而各个 Controller对 Model 的需求看起来似乎方法数量不少但是实质上目的也只有一个那就是创建图形addShape
我们再看 View 层。它的代码主要是一个 index.htm 文件和一个 view.js 文件。View 层只依赖 Model 层,并且只依赖一个 doc.onpaint 函数。所以我们把关注点放在 View 自身的功能。
View 层只有一个 QPaintView 类。我们将其功能分为了三类:属于 Model 层职责相关的,属于 View 自身职责相关的,以及为 Controller 层服务的,得到下表。
最后,我们来看 Controller 层。Controller 层的文件有很多,这还是一些 Controller 因为实现相近被合并到一个文件,如下所示。
Menu, PropSelectors, MousePosTracker accel/menu.js
Create Pathcreator/path.js
Create FreePathcreator/freepath.js
Create Line, Rect, Ellipse, Circle creator/rect.js
Controller 位于 MVC 的最上层,我们对它的关注点就不再是它的规格本身,也没人去调用它的方法。所以我们把关注点放在了每个 Controller 都怎么用 Model 和 View 的。
我们列了个表,如下。注意 Controller 对事件Event的使用从 View 中单独列出来了。
通过以上三张表对照着看,可以清晰看出 Model、View、Controllers 是怎么关联起来的。
改进版的画图程序
MVP 版本的画图程序,用着就会发现不好用,毕竟图形创建完就没法改了。所以我们打算做一个新版本出来,功能上有这样一些改进。
选择一个图形,允许删除、移动或者对其样式进行修改。
图形样式增加 fillColor填充色
更加现代的交互范式:默认处于 ShapeSelector 状态,创建完图形后自动回到此状态。
选择图形后,界面上的当前样式自动更新为被选图形的样式。
怎么改我们的程序?
完整的差异对比,请参见:
https://github.com/qiniu/qpaint/compare/v26…v27
下面,我们将详细讲解这些修改背后的思考。
我们先看 Model 层,新的规格见下表。
dom.js
为了方便大家理解,我们做了一个 Model 的 ChangeNotes 表格,如下:
大部分是新功能的增加不提。我们重点关注一个点QLineStyle 改名为 QShapeStyle且其属性 width、color 被改名为 lineWidth、lineColor。这些属于不兼容修改相当于做了一次小重构。
重构关键是要及时处理,把控质量。尤其对 JavaScript 这种弱类型语言,重构的心智负担较大。为了保证质量仍然可控,最好辅以足够多的单元测试。
这也是我个人会更喜欢静态类型语言的原因,重构有任何遗漏,编译器会告诉你哪里漏改了。当然,这并不意味着单元测试可以省略,对每一门语言来说,自动化的测试永远是质量保障的重要手段。
话题回到图形样式。最初我们 new QLine、QRect、QEllipse、QPath 的时候,传入的最后一个参数是 QLineStyle从设计上这是一次失误这意味着后面这些构造还是都需要增加更多参数如 QFillStyle 之类。
把最后一个参数改为 QShapeStyle这从设计上就完备了。后面图形样式就算有更多的演进也会集中到 QShapeStyle 这一个类上。
当前 QShapeStyle 的数据结构是这样的:
class QShapeStyle {
lineWidth: number
lineColor: string
fillColor: string
}
那么,这是合理的么?未来潜在的演进是什么?
对需求演进的推演,关键是眼光看多远。当前各类 GDI 对 LineStyle、FillStyle 支持都非常丰富。所以如果作为一个实实在在要去迭代的画图程序来说,上面这个 QShapeStyle 必然还会面临一次重构。变成如下这个样子:
class QLineStyle {
width: number
color: string
}
class QFillStyle {
color: string
}
class QShapeStyle {
line: any
fill: any
}
为什么 QShapeStyle 里面的 line 不是 QLineStylefill 不是 QFillStyle而是 any 类型?因为它们都只是简单版本的线型样式和填充样式。
举个例子,在 GDI 系统中FillStyle 往往还可以是一张图片平铺,也可以是多个颜色渐变填充,这些都无法用 QFillStyle 来表示。所以这里的 QFillStyle 更好的叫法也许是 QSimpleFillStyle。
聊完了 Model 层,我们再来看 View 层。
view.js
View 层的变化不大。为了给大家更直观的感觉,我这里也列了一个 ChangeNotes 表格,如下:
其中properties 改名为 style以及删除了 get lineStyle(),和 properties 统一为 style。这个和我上面说的 Model 层的小重构相关,并不是本次新版本的功能引起的。
所以 View 层真正的变化是两个:
引入了 selection当前只能单选一个 shape在 selection 变化时会发出 onSelectionChanged 事件;
引入了 onControllerReset 事件,它在 Controller 完成或放弃图形的创建时发出。
引入 selection 比较常规。View 变复杂了通常都会有 selection唯一需要考虑的是 selection 会有什么样的变化,对于 Office 类程序,如果 selection 只允许是单 shape 这不太合理,但我们这里略过,不进行展开。
我们重点谈 onControllerReset 事件。
onControllerReset 事件是创建图形的 Controller例如 QPathCreator、QRectCreator 等)发出,并由 Menu 这个 Controller 接收。
这就涉及了一个问题:类似情况还会有多少?以后是不是还会有更多的事件需要在 Controller 之间传递,需要 View 来中转的?
这个问题就涉及了 View 层事件机制的设计问题。和这个问题相关的有:
要不要支持任意的事件;
监听事件是支持单播还是多播?
从最通用的角度,肯定是支持任意事件、支持多播。比如我们定义一个 QEventManager 类,规格如下。
class QEventManager {
fire(eventName: string, params: ...any): void
addListener(eventName: string, handler: Handler): void
removeListener(eventName: string, handler: Handler): void
}
但是View 的事件机制设定,需要在通用性与架构的可控性之平衡。一旦 View 聚合了这个 QEventManager通用是通用了但是 Controller 之间会有什么样的事件飞来飞去,就比较难去从机制上把控了。
代码即文档。如果能够用代码约束的事情,最好不要在文档中来约束。
所以,就算是我们底层实现 QEventManager 类,我个人也不倾向于在 View 的接口中直接将它暴露出去,而是定义更具体的 fireControllerReset、 onControllerReset/offControllerReset 方法,让架构的依赖直观化。
具体代码看起来是这样的:
class QPaintView {
constructor() {
this._eventManager = new QEventManager()
}
onControllerReset(handler) {
this._eventManager.addListener("onControllerReset", handler)
}
offControllerReset(handler) {
this._eventManager.removeListener("onControllerReset", handler)
}
fireControllerReset() {
this._eventManager.fire("onControllerReset")
}
}
聊完了 View 层,我们接着聊 Controller 层。我们也把每个 Controller 怎么用 Model 和 View 列了个表,如下。
Menu, PropSelectors, MousePosTracker accel/menu.js
ShapeSelectoraccel/select.js
Create Pathcreator/path.js
Create FreePathcreator/freepath.js
Create Line, Rect, Ellipse, Circle creator/rect.js
内容有点多。为了更清楚地看到差异,我们做了 ChangeNotes 表格,如下:
首先Menu、QPathCreator、QFreePathCreator、QRectCreator 的变更,主要因为引入了新的交互范式导致,我们为此引入了 onControllerReset 事件。还有一个变化是 QLineStyle 变 QShapeStyle这一点前面已经详细讨论不提。
所以 Controller 层的变化其实主要是两个。
其一PropSelectors。这个 Controller 要比上一版本的复杂很多:之前只是修改 View 的 properties (现在是 style) 属性,以便于创建图形时引用。现在是改变它时还会作用于 selection (被选中的图形),改变它的样式;而且,在 selection 改变时,会自动更新界面以反映被选图形的样式。
其二QShapSelector。这是新增加的 Controller支持选择图形支持删除、移动被选择的图形。
通过这次的需求迭代我们可以看出,目前 Model、View、Controller 的分工,可以使需求的分解非常正交。
Model 只需要考虑需求导致的数据结构演进并抽象出足够自然的业务接口。View 层非常稳定主要起到各类角色之间的桥接作用。Controller 层每个 Controller 各司其职,彼此之间不会受到对方需求的干扰。
结语
今天我们结合“画图” 程序重新梳理了一遍 MVC 架构。并且我们更进一步,通过对画图程序进行一次需求演进,来观察 MVC 架构各个角色对需求变更的敏感性。需要再次强调的是,虽然我们基于 Web 开发,但是我们当前给出的画图程序本质上还是单机版的。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,154 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 实战(三):怎么设计一个“画图”程序?
你好,我是七牛云许式伟。
前面的两节课结束后,我们的画图程序已经基本实用。它有如下功能:
可以选择全局的图形样式lineWidth、lineColor、fillColor
可以以全局的图形样式来创建各类图形Path、FreePath、Line、Rect、Ellipse、Circle
可以选择已经创建的图形,并修改其图形样式;
可以删除选择的图形;
可以移动选择的图形。
前面有一些同学的反馈,我这里想回答一下。
有一个反馈是对 JavaScript 的使用,我为什么会用 class 关键字。
这是因为我不太希望这是一篇某个语言的教程,我选择的是如何用最接近大家思维的表达方式来表达程序逻辑,你就算没有系统学过 JavaScript也应该能够理解这段程序想要做什么。
另外有一个反馈,是希望我不要一上来就从 MVC 这种模式讲起,而是如果没有 MVC我们用最基础的裸写代码会写出一个什么样的程序来里面有哪些弊端从而引入 MVC 来让程序架构变得更加清晰,功能之间解耦。
这个意见我觉得是比较中肯的,后面我们会补充一讲来裸写 MVP 版本的画图程序。
今天我们开始进入“实战:怎么设计一个‘画图’程序”的第三讲,怎么和服务端连接。
考虑到大家普遍反馈内容有点深,我们把服务端连接分为两节课去聊。今天这一讲我们谈的是在浏览器端进行持久化。
为什么需要在浏览器端进行持久化?
因为我们需要有更好的用户体验。在用户断网的情况下,这个画图程序还可以正常编辑,并且在恢复联网的情况下,需要能够把所有离线编辑的内容自动同步到服务端。
结合前面几讲的介绍,你可能立刻想到 Google 推的 PWA它非常关注浏览器应用的离线体验。
但是当我们做一个技术选型的时候,显然首先要考虑的是这个技术的兼容性如何。我们今天并不基于 PWA 来干这件事情,而是基于更传统的 localStorage 技术来干。
具体我们改的代码如下:
https://github.com/qiniu/qpaint/compare/v27…v28
最核心的变化是 Model 层。完整的离线支持的 Model 层代码如下:
dom.js
对象 ID
为了支持持久化,我们给每一个 Model 层 DOM 树的根 —— QPaintDoc 类引入了两个 ID如下
localID: string
displayID: string
其中 displayID 顾名思义是用户可见的ID。我们的画图程序之前本地调试的行为是打开 http://localhost:8888/ 来编辑一篇文档QPaintDoc但是现在会自动跳转到 http://localhost:8888/#t10001 或类似的 URL。这里 t10001 就是文档的 displayID。
其中displayID 前面带 t 开头,表示这篇文档从它被创建开始,从未与服务器同步过,是一篇临时的文档。一旦它完成与服务端的同步后,就会改用服务端返回的文档 ID。
那么localID 是什么?顾名思义,是这篇文档的本地 ID。在文档还没有和服务端同步时它和 displayID 是有关系的,如果 displayID 是 t10001那么 localID 就是 10001。但是文档第一次保存到服务端后它的 displayID 会变化,而 localID 则并不改变。
这有什么好处?
好处在于,我们在 localStorage 存储 DOM 树的时候,并不是把整篇文档 JSON 化后保存而是分层的QPaintDoc 里面的 shapes 数组保存的只是 shapeID。
是的,每个 Shape图形也引入了一个 ID。这样当 Shape 发生变化,比如修改图形样式、移动,我们修改 shapeID => shapeJsonData。
请注意,在浏览器的 localStorage 里面shapeID 是要全局唯一的,我们实际存储的是 QPaintDoc.localID + “:” + shape.id。
看到这里我们回过头来看,为什么 QPaintDoc 有 displayID 和 localID 就可以理解了。如果只有一个 ID 并且这个 ID 是会发生变化的,那么在 ID 变化时,所有保存在 localStorage 中的这篇文档的图形对象 shapeID => shapeJsonData 数据都需要跟着变化。
引入 localID 就是让 QPaintDoc 一旦初始化QPaintDoc.init 方法)后 ID 就固定下来了,只需要保证在同一个浏览器下是唯一就行。
所以,我们第一次访问 http://localhost:8888/ 自动跳转的是 http://localhost:8888/#t10001 ,第二次访问自动跳转的就是 http://localhost:8888/#t10002 了。这是因为在同一个浏览器下,我们不会让两个 QPaintDoc.localID 相同。
数据变更
我们把数据变更分为了两级:
shapeChanged
documentChanged
什么情况下叫 shapeChanged有这样三种
增加一个图形addShape这个新增的 shape 发生了 shapeChanged
修改一个 shape 的图形样式setProp这个被修改的 shape 发生了 shapeChanged
移动一个 shape 的位置move这个位置改变的 shape 发生了 shapeChanged。
什么情况下发生 documentChanged有这样两种
增加一个图形addShape它会导致文档的图形数量增加一个发生 documentChanged
删除一个图形deleteShape它会导致文档的图形数量减少一个发生 documentChanged。
当然,可以预见的未来,我们支持不同 shape 交换次序(改变 Z-Order这时文档虽然图形的数目不变但是 shapes 数组的内容还是发生了改变,发生 documentChanged。
发生数据变更做什么?
在 shapeChanged 时,更新 localStorage 中的 shapeID => shapeJsonData 数据。在 documentChanged 时,更新 localID => documentJsonData 数据。
从未来的预期来说,数据变更不只是发生在用户交互。考虑多人同时编辑一篇文档的场景。数据变更消息,也会来自其他浏览器端的变更。具体的过程是:
Client B 操作 => Client B 的 DOM 变更 => 服务端数据变更 => Client A 收到数据变更 => Client A 的 DOM 变更 => Client A 的 View 更新
在前面 26 讲、27 讲中,我们并没有引入数据变更事件,而是 Controller 变更完数据后,就自己主动调用 qview.invalidateRect 来通知 View 层重新绘制。这样做比较简单,虽然它并不符合标准的 MVC 架构。因为从 MVC 架构来说,界面更新并不是由 Controller 触发,而应该由 Model 层的数据变更DataChanged事件触发。
存储的容量限制与安全
localStorage 的存储容量是有限制的,不同的浏览器并不一样,大部分在 5-10M 这个级别。在同一个浏览器下,会有多个 QPaintDoc 的数据同时被保存在 localStorage 中。
这意味着随着时间的推移localStorage 的存储空间占用会越来越大,所以我们需要考虑数据清理的机制。
目前,我们通过 localStorage_setItem 函数来统一接管 localStorage.setItem 调用,一旦 setItem 发生 QuotaExceededError 异常,说明 localStorage 空间满,我们就淘汰掉最远创建的一篇文档。
这样,我们就不会因为 localStorage 太满而没法保存。只要我们及时联网同步文档,数据也就不会丢失了。
最后一个话题是安全。
既然我们把数据保存在了 localStorage 中,只要用户打开浏览器,就能够去通过特定手段来查看 localStorage 的数据。
这意味着如果文档中存在敏感数据的话,是可以被人感知的。尤其是我们画图程序如果未来支持多租户的话,在同一个浏览器下多个用户帐号登录登出时,就会发生多个用户的文档都在同一个 localStorage 中可见。
这意味着你登出帐号之后,其他人用这个浏览器,其实还是可以看到你的数据。这样就有隐私泄漏的风险。
解决这个问题最简单的方法是在用户帐号登出的时候,清空所有的 localStorage 中的文档。
结语
今天我们开始考虑 “画图” 程序的服务端连接。今天这一讲我们先做画图程序的本地浏览器存储的持久化,以便拥有更好的离线。
支持离线持久化存储的程序会很不一样。我们今天结合画图程序聊了 DOM 树在 JavaScript 内存和在 localStorage 存储上的差别。为了支持更新数据的粒度不是整个文档每次都保存一遍,存储分成 shape、document 两个级别。相应的,我们数据更新事件也分了 shapeChanged、documentChanged 两个级别。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们将继续实战一个联网版本的画图程序。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,351 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 实战(四):怎么设计一个“画图”程序?
你好,我是七牛云许式伟。
今天继续我们的画图程序。上一讲完成后,我们的画图程序不只是功能实用,并且还支持了离线编辑与存储。
今天我们开始考虑服务端。
我们从哪里开始?
第一步,我们要考虑的是网络协议。
网络协议
为了简化,我们暂时不考虑多租户带授权的场景。后面我们在下一章服务端开发篇会继续实战这个画图程序,将其改造为多租户。
在浏览器中,一个浏览器的页面编辑的是一个文档,不同页面编辑不同的文档。所以在我们的浏览器端的 dom.js 里面,大家可以看到,我们的 DOM 模型是单文档的设计。
但显然,服务端和浏览器端这一点是不同的,就算没有多租户,但是多文档是跑不了的。我们不妨把 QPaint 的文档叫drawing如此服务端的功能基本上是以下这些
创建新 drawing 文档;
获取 drawing 文档;
删除 drawing 文档;
在 drawing 文档中创建一个新 shape
取 drawing 文档中的一个 shape
修改 drawing 文档中的一个 shape包括移动位置、修改图形样式
修改 drawing 文档中的一个 shape 的 zorder 次序(浏览器端未实现);
删除 drawing 文档的一个 shape。
完整的网络协议见下表:
其中<Shape>是这样的:
"path": {
"points": [
{"x": <X>, "y": <Y>},
...
],
"close": <Boolean>,
"style": <ShapeStyle>
}
或:
"line": {
"pt1": {"x": <X>, "y": <Y>},
"pt2": {"x": <X>, "y": <Y>},
"style": <ShapeStyle>
}
或:
"rect": {
"x": <X>,
"y": <Y>,
"width": <Width>,
"height": <Height>,
"style": <ShapeStyle>
}
或:
"ellipse": {
"x": <X>,
"y": <Y>,
"radiusX": <RadiusX>,
"radiusY": <RadiusY>,
"style": <ShapeStyle>
}
其中<ShapeStyle>是这样的:
{
"lineWidth": <Width>, // 线宽
"lineColor": <Color>, // 线型颜色
"fillColor": <Color> // 填充色
}
其中<ZorderOperation>可能的值为:
“top”: 到最顶
“bottom”: 到最底
“front”: 往前一层
“back”: 往后一层
整体来说,这套网络协议比较直白体现了其对应的功能含义。我们遵循这样一套网络协议定义的范式:
创建对象POST /objects
修改对象POST /objects/<ObjectID>
删除对象DELETE /objects/<ObjectID>
查询对象GET /objects/<ObjectID>
其实还有一个列出对象,只不过我们这里没有用到:
列出所有对象GET /objects
列出符合条件的对象GET /objects?key=value
另外,有一个在网络设计时需要特别注意的点是:对重试的友好性。
为什么我们必须要充分考虑重试的友好性?因为网络是不稳定的。这意味着,在发生一次网络请求失败时,在一些场景下你不一定能确定请求的真实状态。
在小概率的情况下,有可能服务端已经执行了预期的操作,只不过返还给客户端的时候网络出现了问题。在重试时你以为只是重试,但实际上是同一个操作执行了两遍。
所谓重试的友好性,是指同一个操作执行两遍,其执行结果和只执行一遍一致。
只读操作,比如查询对象或列出对象,毫无疑问显然是重试友好的。
创建对象POST /objects往往容易被实现为重试不友好的执行两遍会创建出两个对象来。我们对比一下这里创建新drawing和创建新shape的差别
POST /drawings
POST /drawings/<DrawingID>/shapes
Content-Type: application/json
{
"id": <ShapeID>,
<Shape>
}
可以看到,创建新 shape 时传入了 ShapeID也就是说是由客户浏览器端分配 ShapeID。这样做的好处是如果上一次服务端已经执行过该对象的创建可以返回对象已经存在的错误我们用 status = 409 冲突来表示)。
而创建新 drawing 并没有传入什么参数,所以不会发生什么冲突,重复调用就会创建两个新 drawing 出来。
通过以上分析,我们可以认为:创建新 shape 是重试友好的,而创建 drawing 不是重试友好的。那么怎么解决这个问题?有这么几种可能:
客户端传 id和上面创建新 shape 一样);
客户端传 name
客户端传 uuid。
当然这三种方式本质上的差别并不大。比如客户端传 name如果后面其他操作引用时用的也是 name那么本质上这个 name 就是 id。
传 uuid 可以认为是一种常规重试友好的改造手法。这里 uuid 并没有实际含义,你可以理解为它是 drawing 的唯一序列号,也可以理解为网络请求的唯一序列号。当然这两种不同理解的网络协议表现上会略有不同,如下:
POST /drawings
Content-Type: application/json
{
"uuid": <DrawingUUID>
}
POST /drawings
Content-Type: application/json
X-Req-Uuid: <RequestUUID>
修改对象和删除对象,往往是比较容易做到重试友好。但这并不绝对,比如我们这个例子中 “修改shape的顺序”它的网络协议是这样的
POST /drawings/<DrawingID>/shapes/<ShapeID>
Content-Type: application/json
{
"zorder": <ZorderOperation>
}
其中<ZorderOperation>可能的值为:
“top”: 到最顶
“bottom”: 到最底
“front”: 往前一层
“back”: 往后一层
在 ZorderOperation 为 “front” 或 “back” 时,重复执行两遍就会导致 shape 往前(或往后)移动 2 层。
怎么调整?
有两个办法。一个方法是把修改操作用绝对值表示,而不是相对值。比如 ZorderOperation 为 “front” 或 “back” 是相对值,但是 Zorder = 5 是绝对值。
另一个方法是通用的就是用请求的序列号RequestUUID这个方法在上面创建新 drawing 已经用过了,这里还可以用:
POST /drawings/<DrawingID>/shapes/<ShapeID>
Content-Type: application/json
X-Req-Uuid: <RequestUUID>
{
"zorder": <ZorderOperation>
}
当然用请求序列号是有额外代价的因为这意味着服务端要把最近执行成功的所有的请求序列号RequestUUID记录下来在收到带请求序列号的请求时检查该序列号的请求是否已经成功执行已经执行过就报冲突。
在网络协议的设计上,还有一个业务相关的细节值得一提。
细心的你可能留意到,我们 Shape 的 json 表示,在网络协议和 localStorage 存储的格式并不同。在网络协议中是:
{
"id": <ShapeID>,
"path": {
"points": [
{"x": <X>, "y": <Y>},
...
],
"close": <Boolean>,
"style": <ShapeStyle>
}
}
而在 localStorage 中的是:
{
"type": "path",
"id": <ShapeID>,
"points": [
{"x": <X>, "y": <Y>},
...
],
"close": <Boolean>,
"style": <ShapeStyle>
}
从结构化数据的 Schema 设计角度localStorage 中的实现是无 Schema 模式,过于随意。这是因为 localStorage 只是本地自己用的缓存,影响范围比较小,故而我们选择了怎么方便怎么来的模式。而网络协议未来有可能作为业务的开放 API ,需要严谨对待。
版本升级
另外,这个画图程序毕竟只是一个 DEMO 程序,所以还有一些常见网络协议的问题并没有在考虑范围之内。
比如从更长远的角度,网络协议往往还涉及协议的版本管理问题。网络协议是一组开放 API 接口,一旦放出去了就很难收回,需要考虑协议的兼容。
为了便于未来协议升级的边界,很多网络协议都会带上版本号。比如:
POST /v1/objects
POST /v1/objects/<ObjectID>
DELETE /v1/objects/<ObjectID>
GET /v1/objects/<ObjectID>
GET /v1/objects?key=value
在协议发生了不兼容的变更时,我们会倾向于升级版本,比如升为 v2 版本:
POST /v2/objects
POST /v2/objects/<ObjectID>
DELETE /v2/objects/<ObjectID>
GET /v2/objects/<ObjectID>
GET /v2/objects?key=value
这样做有这么一些好处:
可以逐步下线旧版本的流量,一段时间内让两个版本的协议并存;
可以新老版本的业务服务器相互独立,前端由 nginx 或其他的应用网关来分派。
第一个实现版本
聊完了网络协议,我们就要开始考虑服务端的实现。在选择第一个实现版本怎么做时,有这样几种可能性。
第一种,当然是常规的憋大招模式。直接做业务架构设计、架构评审、编码、测试,并最后上线。
第二种,是做一个 Mock 版本的服务端程序。
两者有什么区别?
区别在于,服务端程序从架构设计角度,就算是非业务相关的通用型问题也是很多的,比如高可靠和高可用。
高可靠是指数据不能丢。就算服务器的硬盘坏了,数据也不能丢。这还没什么,很多服务甚至要求,在机房层面出现大面积事故比如地震,也不能出现数据丢失。
高可用是指服务不能存在单点故障。任何一台甚至几台服务器停机了,用户还要能够正常访问。一些服务比如支付宝,甚至要求做到跨机房的异地双活。在一个机房故障时,整个业务不能出现中断。
在没有好的基础设施下,要做好一个好的服务端程序并不那么容易。所以另一个选择是先做一个 Mock 版本的服务端程序。
这不是增加了工作量?有什么意义?
其一,是让团队工作并行。不同团队协作的基础就是网络协议。一个快速被打造的 Mock 的最小化版本服务端,可以让前端不用等待后端。而后端则可以非常便捷地自主针对网络协议进行单元测试,做很高的测试覆盖率以保证质量,进度不受前端影响。
其二 ,是让业务逻辑最快被串联,快速验证网络协议的有效性。中途如果发现网络协议不满足业务需求,可以及时调整过来。
所以我们第一版的服务端程序,是 Mock 的版本。Mock 版本不必考虑太多服务端领域的问题,它的核心价值就是串联业务。所以 Mock 版本的服务器甚至不需要依赖数据库,直接所有的业务逻辑基于内存中的数据结构就行。
代码如下:
https://github.com/qiniu/qpaint/tree/v29/paintdom
正式版画图程序的服务端,我们会在后面服务端开发一章的实战中继续去完成。
从架构角度来说,这个 paintdom 程序分为两层Model 层和 Controller 层。
我们首先看一下 Model 层。它的源代码是:
paintdom/shape.go
paintdom/drawing.go
Model 层与网络无关,有的只是纯纯粹粹的业务核心逻辑。它实现了一个多文档版本的画图程序,逻辑结构也是一棵 DOM 树,只不过比浏览器端多了一层:
Document => Drawing => Shape => ShapeStyle
浏览器端的 QPaintDoc对应的是这里的 Drawing而不是这里的 Document。
我们再来看一下 Controller 层。它的源代码是:
paintdom/service.go
Controller 层实现的是网络协议。你可能觉得奇怪,我为什么会把网络协议层看作 Controller 层,那么 MVC 中 View 层去了哪里。
首先服务端程序大部分情况下并不需要显示模块,所以不存在 View 层。网络协议层为什么可以看作 Controller 层是因为它负责接受用户输入。只不过用户输入不是我们日常理解的用户交互而是来自某个自动化控制Automation程序的 API 请求。
虽然这个 paintdom 程序的实现,有一些 Go 语言相关的知识点是挺值得讲的,尤其是网络协议实现相关的部分。不过我这里就不做展开了,感兴趣的同学可以自行学习一下 Go 语言。
总体来说,业务逻辑相关的部分理解起来相对容易,我们这里不再赘述。
结语
今天我们重点讨论了 “画图” 程序的网络协议,给出了常规网络协议设计上的一些考量点。网络协议的地位非常关键,它是一个 B/S 或 C/S 程序前后端耦合的使用界面,因而也是影响团队开发效率的关键点。
如何及早稳定网络协议?如何及早让前端程序员可以与服务端联调?这些都是我们应该重点关注的地方。
定义清楚网络协议后,我们给出了满足我们定义的网络协议的第一个服务端实现版本 paintdom 程序,用于串联业务逻辑。这个实现版本是 Mock 程序,它只关注业务逻辑,不关心服务端程序的固有的高可靠、高可用等需求。后续在下一章服务端开发中,我们会继续迭代它。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。下一讲我们会把这个 paintdom 服务端程序,和我们的 paintweb 画图程序串联起来。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

View File

@ -0,0 +1,363 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 实战(五):怎么设计一个“画图”程序?
30 实战(五):怎么设计一个“画图”程序?你好,我是七牛云许式伟。
我们继续聊我们的话题。这是画图程序的最后一讲了。当然我们后续还会结合这个实战程序展开讨论有关于架构的方方面面。
宏观的系统架构
上一讲开始,我们的画图程序有了跨团队协作:因为我们开始有了 paintdom 和 paintweb 两大软件。paintdom 监听的地址是 localhost:9999而 paintweb 监听的地址是 localhost:8888。
应当注意,在实际业务中它们是不同的软件,事实上我们 paintweb 程序也完全是以进程间协作的方式,通过反向代理机制来调用 paintdom 的功能。但是在我们这个画图 DEMO 程序中它们同属一个进程paintdom 作为 paintweb 的一个 goroutine 在跑。这纯粹是因为我们想让这两个程序 “同生共死”,方便调试的时候起停进程。
paintdom 和 paintweb 之间相互协作的基础,是它们之间所采用的网络协议。
当我们说起网络协议,它其实通常包含两个层面的意思:其一是我们网络协议的载体,也就是协议栈(我们这里采纳的是 HTTP 协议,而 HTTP 协议又基于 TCP/IP 协议);其二是我们网络协议承载的业务逻辑。
当我们谈架构的时候,也会同时聊这两个层面,只是它们在不同的维度。我们会关心网络协议的协议栈选择什么,是基于 HTTP 还是基于自定义的二进制协议,这个是属于基础架构的维度。我们也会关心网络协议的业务逻辑,判断它是否自然体现业务需求,这是属于应用架构的维度。
明确了网络协议后,我们实现了 Mock 版本的服务端程序 paintdom。在实际项目中Mock 程序往往会大幅提速团队的开发效率。这是因为它能够达到如下两个大的核心目标:
让团队的研发迭代并行,彼此可以独立演进。
及早验证网络协议的合理性,在实战中达到用最短时间稳定协议的目的。
上一讲我们的 paintdom 和 paintweb 之间虽然定义了网络协议,并且实现了第一版,但是并没有去做两者的对接。
今天我们就来对接它们。
虽然 paintweb 没有对接服务端,但从文档编辑的角度来说,它的功能是非常完整的。我们对接 paintdom 和 paintweb 的目的不是加编辑功能,而是让文档可以存储到服务端,以便于人们在世界任何可以联网的角落都可以打开它。
当然严谨来说,说 paintweb 没有服务端是不正确的paintweb 本身是一个 B/S 结构,它有它自己的服务端。如下:
var wwwServer = http.FileServer(http.Dir("www"))
func handleDefault(w http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
http.ServeFile(w, req, "www/index.htm")
return
}
req.URL.RawQuery = "" // skip "?params"
wwwServer.ServeHTTP(w, req)
}
func main() {
http.HandleFunc("/", handleDefault)
http.ListenAndServe(":8888", nil)
}
可以看出paintweb 自己的服务端基本上没干什么事情,就是一个非常普通的静态文件下载服务器,提供给浏览器端下载 HTML + CSS + JavaScript 等内容。
所以 paintweb 的服务端完全是“平庸”的,与业务无关。具体的业务,都是通过 www 目录里面的文件来做到的。这些文件都是前端的浏览器端所依赖的,只不过被 “托管” 到 paintweb 服务端而已。
那么 paintweb 怎么对接 paintdom 呢?
物理上的对接比较简单,只是个反向代理服务器而已,代码如下:
func newReverseProxy(baseURL string) *httputil.ReverseProxy {
rpURL, _ := url.Parse(baseURL)
return httputil.NewSingleHostReverseProxy(rpURL)
}
var apiReverseProxy = newReverseProxy("http://localhost:9999")
func main() {
http.Handle("/api/", http.StripPrefix("/api/", apiReverseProxy))
}
可以看出paintweb 的服务端干的事情仍然是 “平庸” 的,只是把发往 http://localhost:8888/api/xxx 的请求,原封不动地发往 http://localhost:9999/xxx 而已。
在现实中paintweb 的服务端干的事情稍微复杂一些。它背后不只是有业务服务器 paintdom还有必不可少的帐号服务器Account Service用来支持用户登录/登出。
帐号服务器是一个基础架构类的服务,与业务无关。公司很可能不只有 QPaint 这样一个业务,还会有别的,但这些业务可以共享相同的帐号服务。更准确地说,是必须共享相同的帐号服务,否则一个公司弄出好多套独立的帐号体系来,用户也会有所诟病。
在需要对接帐号服务器的情况下,实际上 paintweb 的服务端并不是原封不动地转发业务请求,而是会对协议进行转义。
在 “24 | 跨平台与 Web 开发的建议”这一讲中我们提到过:
到了 Web 开发,我们同样需要二次开发接口,只不过这个二次开发接口不再是在 Client 端完成的,而是在 Server 端完成。Server 端支持直接的 API 调用以支持自动化Automation方面的需求。
所以,对 Server 端来说,最底层的是一个多租户的 Model 层Multi-User Model它实现了自动化Automation所需的 API。
在 Multi-User Model 层之上,有一个 Web 层。Web 层和 Model 层的假设不同Web 层是基于会话的Session-based因为它负责用户的接入每个用户登录后会形成一个个会话Session
如果我们对Web 层细究的话,又分为 Model 层和 ViewModel 层。为了区分Web 这边的 Model 层我们叫它 Session-based Model。相应地ViewModel 层我们叫它 Session-based ViewModel。
在服务端Session-based Model 和 Session-based ViewModel 并不发生直接关联,它们通过自己网络遥控浏览器这一侧的 Model 和 ViewModel从而响应用户的交互。
Session-based Model 是什么样的呢?它其实是 Multi-User Model 层的转译。把多租户的 API 转译成单租户的场景。所以这一层并不需要太多的代码,甚至理论上自动实现也是有可能的。
Session-based ViewModel 是一些 HTML+JavaScript+CSS 文件。它是真正的 Web 业务入口。它通过互联网把自己的数据返回给浏览器,浏览器基于 ViewModel 渲染出View这样整个系统就运转起来了。
这段话说的比较抽象,但结合 QPaint 这个实际的例子,就非常明朗了:
paintdom 就是这里说的 Multi-User Model 层,负责多租户的业务服务器。
paintweb 服务端实现 Session-based Model 层,负责 Session-based 到 Multi-User 的转译。由于我们当前这个例子还不支持多租户,转译就变成了简单的转发。后面我们在 “服务端开发” 一节中会给大家看实际的转译层是怎么做的。
所以你可以看到,其实 paintweb 自身的服务端是业务无关的。它做这样一些事情:
Web 前端文件的托管(作为静态文件下载服务器);
支持帐号服务,实现 Web 的用户登录;
做业务协议的转译,将 Session-based 的 API 请求转为 Multi-User 的 API 请求。
当然,我们这里假设 Web 自身的业务逻辑都是通过 JavaScript 来实现的。这意味着我们是基于 “胖前端” 模式的。
但这并不一定符合事实,有些公司会基于 “胖后端” 模式。这意味着大部分的前端用户行为,都是由后端支持的,比如我们用 PHP 来实现 Web 后端的业务代码。
胖后端模式的好处是 Web 代码比较安全。这里的 “安全” 是指 IT 资产保全方面的安全,不是指业务存在安全问题,因为别人看不到完整的 Web 业务逻辑代码。
但是胖后端模式的缺点是没办法支持离线。大部分的用户交互都需要 Web 后端来响应,一旦断了网就什么都干不了了。
在 “胖后端” 模式下,我个人会倾向于基于类似 PHP 这种胶水语言来实现 Web 后端的业务代码。而一旦我们这么做paintweb 的业务逻辑就被剥离了paintweb 自身的后端仍然是业务无关的,只是多了一个职责:支持 PHP 脚本语言。
真正 Web 后端业务逻辑,还是放在了 www 目录中,以 PHP 文件存在,这些文件就不是简单的静态资源,而是 “胖后端” 的业务代码。
既然 paintweb 后端是 “平庸” 的,与业务无关,那么整个业务逻辑的串联,靠的就是 www 里面的 js 文件,和 paintdom 提供的 API 接口。
上面我们说过,在连接 paintdom 之前paintweb 程序独立看是完整的,它支持离线创建、编辑以及存储文档到浏览器本地的 localStorage 上。
对接 paintdom 与 paintweb 后我们并不会放弃离线编辑的能力,而是要能够做到:
在断网情况下,表现为上一讲我们达到的效果,可以继续离线编辑和保存;
一旦联网,所有离线编辑的内容可以自动保存到 paintdom 服务器。
计算变更
听起来挺简单一件事情?
其实很复杂。第一件要做的事情是:怎么知道断网后离线编辑过的内容有哪些?
思路一是不管三七二十一,每次都完整保存整篇文档。这很浪费,因为不单单刚恢复联网的时候我们需要保存文档,平常每一次编辑操作我们也都会自动保存修改的内容。
思路二是记录完整的编辑操作历史,每做一个编辑操作都将其记录到 localStorage。这个思路看似会更节约但是实际上在很多情况下会更浪费。原因在于
一个对象编辑多次,会有很多条编辑操作的指令要保存;
断网久了,编辑操作累计下来,其存储空间甚至可能超过文档大小。
所以这种方案缺乏很好的鲁棒性,在 badcase 情况下让人难以接受。
思路三是给对象增加版本号。通过对比整个文档的基版本baseVer即上一次同步完成时的版本与某个对象的版本 ver。如果 ver > baseVer说明上一次同步完成后该对象发生了变更。完整的变更信息的计算逻辑如下
prepareSync(baseVer) {
let shapeIDs = []
let changes = []
let shapes = this._shapes
for (let i in shapes) {
let shape = shapes[i]
if (shape.ver > baseVer) {
changes.push(shape)
}
shapeIDs.push(shape.id)
}
let result = {
shapes: shapeIDs,
changes: changes,
ver: this.ver
}
this.ver++
return result
}
同步变更
有了变更的信息,怎么同步给服务端?
一个可能的思路是把变更还原为一条条编辑操作发给服务端。但是,这样做问题会很复杂,因为这些编辑操作一部分发送成功,一部分发送失败怎么办?
这种部分成功的中间态是最挑战我们程序员的编程水平的,很烧脑。
我个人一贯坚持的架构准则是不要烧脑。尤其对大部分非性能敏感的业务代码,简单易于实施为第一原则。
所以我们选择了修改网络协议。增加了同步接口:
这很有趣。在我们讨论相互配合的接口时,我们非常尊重业务逻辑,按照我们对业务的理解,定义了一系列的编辑操作。但是,到最后我们却发现,它们统统不管用,我们要的是一个同步协议。
是最初我们错了吗?
也不能这么说。最初我们定义协议的逻辑并没有错,只是没有考虑到支持离线编辑这样的需求而已。
复盘这件事情,我们可以这么说:
需求的预见性非常重要。如果我们没有充分预见到需求,大部分情况下就会因为我们缺乏市场洞察而买单;
进一步说明,及早推出 Mock让前端可以快速迭代进而及早去发现原先定义网络协议的不足是很有必要的。越晚做出协议调整事情就越难也越低效。
有了同步协议,我们就可以把变更信息同步给服务端了。这个事情我们交给了 QSynchronizer 类来完成(详细请看 dom.js#L204)。
加载文档
把变更详细推送给服务端后,理论上我们就可以在世界各地看到这篇文档。
怎么做到?
我们接下来就谈谈怎么来加载文档。这个过程的难点在于怎么根据服务端返回的 json 数据重建整个文档。
上一讲我们已经说过我们图形Shape的网络协议中的数据格式和 localStorage 中是不同的。这意味着我们需要做两套图形数据的加载工作。
这挺没有必要。
而且从预测变更的角度我们很容易预期的一个变化就是画图程序支持的图形Shape的种类会越来越多。
这两个事情我们一起看。为此我们做了一次重构。重构目标是:
统一 localStorage 和网络协议中的图形表示;
增加新的图形种类要很容易,代码非常内聚,不必到处修改代码。
为此我们增加 qshapes: QSerializer 全局变量允许各种图形类型注册自己的创建方法creator进去。示意代码如下
qshapes.register("rect", function(json) {
return new QRect(json)
})
为了支持 QSerializer 类(代码参见 dom.js#L89),每个图形需要增加两个方法:
interface Shape {
constructor(json: Object)
toJSON(): Object
}
这样我们就可以调用 qshapes.create(json) 来创建一个图形实例。
有了这个能力,我们加载文档就水到渠成了,具体代码请参考 QPaintDoc 类的 _loadRemote(displayID) 方法(参见 dom.js#L690)。
完整来说,加载文档的场景分为这样三类:
_loadBlank即加载新文档。在联网情况下会在服务端创建一个新 drawing。在非联网情况下会本地创建一个临时文档displayID 以 t 开头)。
_loadTempDoc即加载一个临时文档。即该文档从创建之初到目前一直都处于离线编辑的状态。同样它也分两个情况如果当前处于联网状态下会在服务端创建一个新 drawing并把当前的离线编辑的数据同步过去。如果在非联网的情况下会加载离线编辑的数据并可继续离线编辑。
_loadRemote即加载一个远程文档。该文档在本地有可能编辑过那么会先加载本地缓存的离线编辑的数据。如果当前处于联网状态会异步加载远程文档成功后本地离线编辑的内容会被放弃。
另外加载文档结束后QPaintDoc 会发出 onload 消息。这个消息当前会被 QPaintView 响应,用来刷新界面,代码如下:
class QPaintView {
constructor() {
...
let view = this
this.doc.onload = function() {
view.invalidateRect(null)
}
...
}
}
之所以会有 onload 消息,是因为向服务器的 ajax 请求,什么时候完成是比较难预期的,我们加载文档是在异步 ajax 完成之后。这样来看,完成文档加载后发出 onload 事件,就可以避免 Model 层需要去理解 View 层的业务逻辑。
Model 层的厚度
介绍到这里,我们基本上把本次迭代的主体内容介绍清楚了。其他有些小细节的变动,我们不再赘述。详细的代码变更请参阅:
https://github.com/qiniu/qpaint/compare/v29…v30
下面我想聊的话题是关于 Model 层的厚度问题。我们在 “22 | 桌面程序的架构建议” 中提到:
从界面编程角度看Model 层越厚越好。为什么这么说?因为这是和操作系统的界面程序框架最为无关的部分,是最容易测试的部分,也同时是跨平台最容易的部分。我们把逻辑更多向 Model 层倾斜,那么 Controller 层就简洁很多,这对跨平台开发将极其有利。
我们秉承的理念是 Model 层越厚越好。事实上在这次 “画图” 程序实战中,我们在一直坚持这一点。让我们来观测两组数据。
其一不同版本v26..v30的 Model 层dom.js对比
MVP 版本v26 版)的 dom.js ,约 120 行。
最新版本v30 版)的 dom.js ,约 860 行。
Model 层的代码行翻了多少倍7.x 倍。
其二不同版本v26..v30的变更历史
v27https://github.com/qiniu/qpaint/compare/v26…v27
v28https://github.com/qiniu/qpaint/compare/v27…v28
v29https://github.com/qiniu/qpaint/compare/v28…v29
v30https://github.com/qiniu/qpaint/compare/v29…v30
不知道你看出什么来了吗?
一个有趣的事实是,多个版本的迭代,基本上都是以变更 Model 层为多。v29 版本的变更看似比较例外,没有修改 dom.js。但是实际上 v29 整个变更都是 Model 层的变更,因为是增加了服务端的 Model我们前面把它叫做 Multi-User Model
我们深刻思考这个问题的话,我们会有这样一个推论:
如果我们不是让 Model 层代码以内聚的方式放在一起,而是让它自由的散落于各处,那么我们的代码变更质量会非常不受控。
为什么Model 层总体来说是最容易测试的,因为它的环境依赖最小。如果这些代码被分散到 View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。
通过几轮的功能迭代,我们对 Model 层的认知在不断的加深。我们总结一下它的职责,如下:
业务逻辑,对外暴露业务接口。它也是 Model 的最本职的工作。
实现 View 层委托的 onpaint 事件,完成绘制功能。
实现 Controller 层的 hitTest 接口,用来实现 selection 支持。
实现与服务端 Multi-User Model 层的通讯View、Controllers 组件都不需要感知服务端。
实现离线编辑 localStorage 的存取。
除了少量 Viewonpaint、ControllershitTest的需求大部分都是 Model 层的正常业务范畴。
这些职责已经很多,所以 Model 层自然会胖。
结语
今天我们完成了画图程序前后端 paintdom、paintweb 的对接。由于考虑支持离线编辑,对接工作有较大的复杂性,你如果不能理解,建议仔细对代码进行研读。当然后面我们还会掰开来细谈这个案例。
这是最新版本的源代码:
https://github.com/qiniu/qpaint/tree/v30
到这里我们的实战过程就先告一段落了。
如果你对今天的内容有什么思考与解读,欢迎给我留言,我们一起讨论。到现在为止,我们探讨的都是一个完整的桌面应用程序(可能是单机的,也可能是 B/S 结构的)的业务架构。
下一讲我们会谈谈辅助界面元素(自定义控件)的架构设计,它和应用程序的业务架构考虑的问题颇有不同。
话外:按照大纲,当前进度还只有 13 的内容。看起来我们最终会比原计划的 58 讲超出不少,可能要往 90 讲去了。关于这一点,我总体还是以说清楚事情为目标,在聊的过程会根据反馈作出适当的调整。
如果你觉得有所收获,也欢迎把文章分享给你的朋友。感谢你的收听,我们下期再见。

Some files were not shown because too many files have changed in this diff Show More