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,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语言正是用了这些跳转条件分支指令才实现了如ifforwhilegotoreturn等程序流程控制逻辑
C语言函数
我们再来看看C语言函数了解一下C语言是怎么把函数转换成汇编语言的
通过前一节课的学习我们知道了函数是C语言中非常重要的组成部分我们要用C语言完成一个实际的功能就需要至少写一个函数可见函数就是C语言中对一段功能代码的抽象一个函数就是一个执行过程有输入参数也有返回结果根据需要可有可无可以调用其它函数也被其它函数调用
让我们去写函数验证一下如下所示
//定义funcB
void funcB()
{
return;
}
//定义funcA
void funcA()
{
//调用funcB
funcB();
return;
}
上述代码中定义了funcAfuncB两个函数函数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_inssltiu_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
有无符号寄存器版本sltsltu指令
接着我们再来看看sltsltu指令这是寄存器与寄存器的有无符号比较指令它们的形式如下所示
slt rdrs1rs2
#slt 有符号比较指令
#rd 目标寄存器
#rs1 源寄存器1有符号数据
#rs2 源寄存器2有符号数据
sltu rdrs1rs2
#sltu 无符号比较指令
#rd 目标寄存器
#rs1 源寄存器1无符号数据
#rs2 源寄存器2无符号数据
上述代码中rdrs1rs2可以是任何通用寄存器无符号同样代表rs1rs2寄存器中的数据
我们先看看sltsltu这两个指令完成的操作用伪代码怎么描述
if(rs1 < rs2)
rd = 1;
else
rd = 0;
我们依然在slti.S文件中用汇编写上slt_inssltu_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_inssltiu_insslt_inssltu_ins函数的二进制数据打开工程目录下slti.bin文件如下所示
以上8个32位数据是八条指令其中四个0x00008067数据是四个函数的返回指令即jr ra0x80052513为slti a0a0-20480x7ff53513为sltiu a0a020470x00b52533为slt a0a0a10x00b53533为sltu a0a0a1
同样地我们也来拆分一下sltisltiusltsltu指令的各位段的数据看看它们是如何编码的
从上图可以发现立即数版本和寄存器版本的指令格式不一样操作码也不一样而它们之间的有无符号是靠功能位段来区分的而立即数位段和源寄存器与目标寄存器位段和之前的指令是相同的
到这里四条比较指令我们就全部讲完了建议你自己课后跟着课程练练手加深印象
重点回顾
这节课我们一起学习了加减指令和比较指令让我们一起来回顾一下
加减指令是CPU里最基本的指令addiaddsub这三条指令能对数据和寄存器进行加减运算可以先把数据装入寄存器中然后对寄存器与寄存器执行加减操作也可以寄存器和立即数进行加减操作
接着我们还学习了比较指令比较指令能对数据进行比较操作一共包括四条指令按照有无符号立即数版本和有无符号寄存器版本划分分别是sltisltiusltsltu这个有无符号是对应操作数中的寄存器的数据立即数永远是有符号数据
加减指令主要用于加减法运算比较指令用于对数据比较判断数值大小再结合后面要学的跳转指令就可以实现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<<a1a0、a1是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所以结果正确无误
逻辑右移指令srlisrl
有逻辑左移就有逻辑右移逻辑右移指令srlisrl分别对应着立即数和寄存器版本它们的形式如下
srli rdrs1imm
#srli 立即数逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数rs1右移的位数0~31
srl rdrs1rs2
#srl 寄存器逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2rs1右移的位数
上述代码中rdrs1rs2可以是任何通用寄存器imm是立即数为了和之前的形式保持一致我们还是沿用imm而非官方文档中的shamt
srlisrl完成的操作可以用后面的伪代码来描述
//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是个只读寄存器始终返回0imm_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 立即数
上述代码中rdrs1可以是任何通用寄存器立即数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函数的地址这个结果是正确的代码流程也符合预期
通过调试我们已经了解了jaljalr指令的功能细节
下面我们来看看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
你可以这样理解这两个指令在rs1rs2寄存器的数据相等时beq指令就会跳转到标号为imm的地方运行而rs1rs2寄存器的数据不相等时bne指令就会跳转到imm标号处运行
下面我们一起写代码来验证在工程目录下我们需要建立一个beq.S文件在文件里用汇编写上beq_insbne_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指令后的状态由于a0a1寄存器内容不相等所以没有跳转到imm_l1处运行而是继续顺序执行beq后面的下一条指令最后返回到main函数中
函数返回结果如下图所示
从图里我们能看到首先会由main函数调用beq_ins函数然后调用printf输出返回的结果在终端中的输出为0这个结果在我们的预料之中也验证了beq指令的效果和我们之前描述的一致
下面我们继续调试就会进入bne_ins函数中如下所示
上图中是执行bne a0a1imm_l2指令之后的状态同样因为a0a1寄存器内容不相等而bne指令是不相等就跳转这时程序会直接跳转到imm_l2处运行执行addi a0zero1指令将a0寄存器置为1后返回到main函数中如下所示
上图中第二个printf函数打印出bne_ins函数返回的结果输出为1bne指令会因为数据相等而跳转将a0寄存器置为1导致返回值为1这个结果是正确的
经过上面的调试验证我们不难发现其实bne是beq的相反操作作为一对指令搭配使用完成相等和不相等的流程控制
小于则跳转blt和bltu指令
有了bqebne有条件跳转指令后就能实现C语言 ==和 != 的比较运算符的功能但这还不够除了比较数据的相等和不等我们还希望实现比较数据的大小这个功能
这就要说到小于则跳转的指令即blt指令与bltu指令bltu指令是blt的无符号数版本它们的汇编代码书写形式如下
blt rs1rs2imm
#blt 条件小于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
bltu rs1rs2imm
#bltu 无符号数条件小于跳转指令
#rs1 源寄存器1
#rs2 源寄存器2
#imm 立即数
和bqebne指令一样上述代码中rs1rs2可以是任何通用寄存器imm是立即数也可称为偏移量占用13位二进制编码它们同样没有目标寄存器不会回写结果
blt指令和bltu指令所完成的操作可以用后面的伪代码描述
//blt
if(rs1 < rs2) pc = pc + 符号扩展imm << 1
//bltu
if((无符号)rs1 < (无符号)rs2) pc = pc + 符号扩展imm << 1
你可以这样理解这两个指令当rs1小于rs2时且rs1rs2中为有符号数据blt指令就会跳转到imm标号处运行而当rs1小于rs2时且rs1rs2中为无符号数据bltu指令就会跳转到imm标号处运行
我们同样通过写代码验证一下加深理解在beq.S文件中我们用汇编写上blt_insbltu_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把a0a1中的数据当成无符号数所以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且rs1rs2中为有符号数据时bge指令就会跳转到imm标号处运行而当rs1大于等于rs2时且rs1rs2中为无符号数据bgeu指令就会跳转到imm标号处运行
我们继续在beq.S文件中用汇编写上bge_insbgeu_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函数负责处理数据的符号
我们对比学习这几条指令理解起来更容易上面的amomaxamomaxu指令都是把rs1中数据当成地址把该地址内存单元中的数据读取到rd中然后与rs2进行比较最后把两者之间大的那个数值写入该地址的内存单元中区别是比较时的数据有无符号
而amominamominu指令则是把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_insamomaxu_insamomin_insamominu_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类指令它们是原子交换指令原子加法指令原子逻辑指令原子取大小指令这些指令相比LRSC指令使用起来更加方便
思考题
请你尝试用LRSC指令实现自旋锁
期待你在留言区记录自己的收获或者向我提问如果觉得这节课还不错别忘了推荐给身边更多朋友跟他一起学习进步

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对虚拟地址空间和物理地址空间进行分页处理一个页大小可以是4KB16KB2MB4MB1GB不等这是为了增加地址的粒度避免采用每个字节一个地址现在一页一个地址地址数量就会大大减少从而减少转换表的大小
其次MMU采用的转换表也称为页表其中只会对应物理页地址不会储存虚拟地址而是将虚拟地址作为页表索引这进一步缩小了页表的大小
最后MMU对页表本身进行了拆分变成了多级页表假如不分级4GB内存空间 按照4KB大小分页有1M个页表项每个页表项只占用4个字节也需要4MB空间如果页表分级在建立页表时就可以按需建立页表而不是一次建立4MB大小的页表
我们一起来画一幅图来描述一下这个过程如下所示
对照图片我们可以看到虚拟内存页和物理内存页是同等大小的都为4KB各级页表占用的空间也是一个页即为4KBMMU把虚拟地址分为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函数的底层原理就是对请求调页的扩展这种方式在处理超大文件的随机读写过程中性能相当不错当只有文件中一部分被读写的时候就不必读取整个文件占用大量内存了
对内存资源精打细算的操作系统通过文件映射的机制让物理内存页的分配管理更加精细了等到应用实际要用到文件的哪一部分系统才会去分配真正的物理内存文件映射的内容到这里就告一段落了其实在WindowsMac 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中的三个基本对象GMP
它们到底是什么我给你列了一个表格如下所示-
知道了这三个基本对象的意思我们还得聊一聊Go运行时是怎样工作的开始Go运行时会建立一个系统上的线程M每一个运行的M会绑定一个P线程M有了P之后会去检查并执行G对象即协程)。然后每一个P中都保存着一个协程G的队列除了每个P自身保存的G的队列外还有一个全局的G队列最后M通过P从它的本地队列或者全局队列中获取G并执行
GMP三者的关系如下图所示
逻辑处理器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 分配器该分配器会根据tinytinyoffset 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会给每个网络接口分配一个唯一的名字比如eth0eth1等方便其它软件访问这些接口但这个名字在文件系统中并没有对应的文件名
然后我们来看看块设备块设备这种设备类型也是Linux下的一个大类块设备的特点是能按一块一块的方式传输数据而且能随机访问设备中的任一地址具体是通过/dev目录下的文件系统节点来访问常见的块设备包括硬盘flashssdU盘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 提供了一系列数据结构如filessuperblockdentryinode结构还规定了具体文件系统应该实现生成这些数据结构的回调函数这样一个文件系统模块就可以被安装到 VFS 中了操作具体文件时VFS 会根据需要调用具体文件系统的函数
从此文件系统的细节就被 VFS 屏蔽了应用程序只需要调用标准的接口就行了也正因如此Linux 可以支持 EXTXFSBTRFSFATNTFS 等多达十几种不同的文件系统但不管在什么储存设备上使用什么文件系统也不管访问什么文件都可以统一地使用一套类似 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其实也都大量使用了异步化思想
异步化思想在后端中架构中也有很多应用比如为了提高后端服务的吞吐能力我们可以使用AIOepoll做IO消息处理的异步化当我们有较多写入请求为了避免击穿下游系统我们也可以用下图中的队列思路来进行异步的削峰填谷
再比如一个A系统原本通过直接调用耦合了下游BCD子系统需要等下游处理完毕才能返回的时候我们也可以基于队列进行异步处理从而降低耦合提升响应时间你可以对照后面的流程图理解一下这段话
掌握了异步化思想之后你就可以基于相同的思路举一反三来设计出分布式事务分布式计算框架之类等更多有用的中间件啦
内功心法之调度
现实世界里我们手里的资源往往是有限的但需求却往往趋近于无限怎么平衡这种矛盾呢没错为了更好地利用资源就出现了调度这个概念调度思想的核心就是通过各种调度手段让有限的资源尽可能得到更高效的利用
操作系统内核中调度无处不在比如为了更好地抽象CPU资源OS内核抽象出了进程/线程面对CPU资源有限有CPU资源需求的进程/线程可能有无限多的情况OS内核设计出了各种调度算法
就拿CFS调度器来说它在调度上非常公平它记录了每个进程的执行时间哪个进程运行时间最少就让那个进程运行更多细节我在第一季操作系统实战45讲[第二十七课]详细分享过感兴趣的话你可以去看看
再比如为了更好地使用物理内存OS内核抽象出了虚拟内存那如何调度这些内存呢内核又设计出了页面调度算法还有就是为了管理磁盘中的数据OS抽象出了文件概念这还没完如何提读写升效率呢OS又设计出了各种磁盘调度算法
调度思想在后端架构中其实也很常见以GolangJava编程语言为例在语言内的运行时库中也会包含对进程/线程/协程内存的调度管理策略
在业务层面我们很多时候也会开发很多后台作业为了提升这些作业的性能和作业的可用性我们也会基于分布式任务调度框架进行后台作业的分布式调度我给你举个具体点的例子带你看看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等函数想操作网络你立马就能想到套接字接口。这一切是基于你长期运用这些函数的结果。
这一点感悟来自我的亲身体会。在自学计算机的道路上,我同样磕磕绊绊,但因为好奇心和兴趣的驱动,脚步虽慢,却未曾停止。
在看过了很多书籍资料之后,我开始尝试把自己的想法用代码实现出来,在真正的计算机上验证一下。在动手实验的过程里,我才遇到了许多先前只看理论时根本无法想到的问题,技术水平也迅速提升。
纸上得来终觉浅,绝知此事要躬行。你学会了一种知识,然后将其运用在工作中,感受是不一样的。在这个过程中你会不断对各种知识进行强化,从而加深理解,深入思考背后的理论原理,到一定的程度你就会“悟”,会有一种豁然开朗的感觉,那就是了解真理的感觉,那就是认知升级的时刻。
一旦认知升级,你脑中的机制,自然就会对已经清楚的知识进行打包,思维体系会继续向上一层,追求新的高度。当你日复一日,年复一年的坚持学而用之,认知体系就会自动的一次又一次的迭代升级,随着每次认知体系的升级,都会增强我们的自信心,提升我们的工作能力。
当我们知识越来越多,也就越来越接近山顶了。这时,你会真正地体会到“会当凌绝顶,一览众山小”的感觉,悄无声息地步入了高手行列。
到这里,真的要和你说再见了。虽然课程结束了,但是这些内容会持续存在,你可以时不时地复习一下。如果你遇见了什么问题,也欢迎继续给我留言。
学习是一辈子的事情,千万别停止学习。道阻且长,行则将至。知行合一,未来可期!
最后,我很想知道你学习这个专栏的感受。这里我为你准备了一份毕业问卷,题目不多,希望你可以花两分钟填一下。