奔跑吧Linux内核(第2版)卷1:基础架构

978-7-115-54999-0
作者: 笨叔
译者:
编辑: 谢晓芳

图书目录:

详情

本书基于Linux 5.0内核的源代码讲述Linux内核中核心模块的实现。本书共9章,主要内容包括处理器架构、ARM64在Linux内核中的实现、内存管理之预备知识、物理内存与虚拟内存、内存管理之高级主题、内存管理之实战案例、进程管理之基本概念、进程管理之调度和负载均衡、进程管理之调试与案例分析。 本书适合Linux系统开发人员、嵌入式系统开发人员及Android开发人员阅读,也可供计算机相关专业的师生阅读。

图书摘要

版权信息

书名:奔跑吧Linux内核(第2版)卷1:基础架构

ISBN:978-7-115-54999-0

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

著    笨 叔

责任编辑 谢晓芳

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


本书基于Linux 5.0内核的源代码讲述Linux内核中核心模块的实现。本书共9章,主要内容包括处理器架构、ARM64在Linux内核中的实现、内存管理之预备知识、物理内存与虚拟内存、内存管理之高级主题、内存管理之实战案例、进程管理之基本概念、进程管理之调度和负载均衡、进程管理之调试与案例分析。

本书适合Linux系统开发人员、嵌入式系统开发人员及Android开发人员阅读,也可供计算机相关专业的师生阅读。


在阅读本书之前,请读者先完成关于Linux内核的模拟面试题,以检验自己对Linux内核的了解程度。

下面一共有20道题,每道题10分,一共200分。读者可以边阅读Linux内核源代码边做题,请在两小时内完成。如没有特殊说明,以下题目基于Linux 5.0内核和ARM64/x86_64架构。

1.请简述数值0x1234 5678在大小端字节序处理器的存储器中的存储方式。

2.假设系统中有4个CPU,每个CPU都有L1高速缓存,处理器内部实现的是MESI协议,它们都想访问相同地址的数据A(大小等于L1高速缓存行大小),这4个CPU的高速缓存在初始状态下都没有缓存数据A。在T0时刻,CPU0访问数据A。在T1时刻,CPU1访问数据A。在T2时刻,CPU2访问数据A。在T3时刻,CPU3想更新数据A的内容。请依次说明T0~T3时刻4个CPU中高速缓存行的变化情况。

3.什么是高速缓存重名问题和同名问题?虚拟索引物理标签(Virtual Index Physical Tag,VIPT)类型的高速缓存在什么情况下会出现高速缓存重名问题?

4.请回答关于页表的几个问题。

5.为用户进程分配物理内存时,分配掩码应该选用GFPKERNEL,还是GFP_HIGHUSER MOVABLE呢?为什么?

6.假设使用printf()输出时指针bufA和bufB指向的地址是一样的,那么在内核中这两块虚拟内存是否冲突?

7.请回答关于缺页异常的几个问题。

8.page 数据结构中的_refcount 和_mapcount有什么区别?请列举 page 数据结构中关于_refcount和_mapcount的案例。

9.在页面分配器中,分配掩码ALLOC_HIGH、ALLOC_HARDER、ALLOC_OOM以及ALLOC_NO_WATERMARKS之间有什么区别?它们各自能访问系统预留内存的百分比是多少?思考为什么页面分配器需要做这样的安排。

10.假设有这样的场景——请求分配order为4的一块内存,迁移类型是不可迁移
(MIGRATE_UNMOVABLE),但是order大于或等于4的不可迁移类型的空闲链表中没有空闲页块,那么页面分配器会怎么办?

11.把/proc/meminfo节点中SwapTotal减去SwapFree等于系统中已经使用的交换内存大小,我们称之为S_swap。写一个小程序来遍历系统中所有的进程,并把进程中/proc/PID/status节点的VmSwap值都累加起来,我们把它称为P_swap,为什么这两个值不相等?

12.请简述fork()、vfork()和clone()之间的区别。在ARM64的Linux内核中如何获取当前进程的task_struct数据结构?

13.请回答关于负载计算的几个问题。

14.假设进程A和进程B都是在用户空间运行的两个进程,它们不主动陷入内核态。

15.在一个双核处理器的系统中,在Shell界面下运行test程序。CPU0的就绪队列中有4个进程,而CPU1的就绪队列中有1个进程。test程序和这5个进程的nice值都为0。

16.假设CPU0先持有了自旋锁,接着CPU1、CPU2、CPU3都加入该锁的争用中,请阐述这几个CPU如何获取锁,并画出它们申请锁的流程图。

17.假设CPU0~CPU3同时争用一个互斥锁,CPU0率先申请了互斥锁,然后CPU1也加入锁的申请队列中。CPU1在持有锁期间会进入睡眠状态。然后CPU2和CPU3陆续加入该锁的争用中。请画出这几个CPU争用锁的时序图。

18.为什么中断上下文不能运行睡眠操作?软中断的回调函数运行过程中是否允许响应本地中断?是否允许同一个tasklet在多个CPU上并行运行?

19.请回答关于链接的几个问题。

20.假设函数调用关系为main()→func1()→func2(),请画出x84_64架构或者ARM64架构中函数栈的布局。

以上题目的答案都分布在本书的各章中。

如果您答对了90%以上的题目,那么恭喜您,您是深入了解Linux内核的高手,本书可能不适合您,不过您可以把本书分享给身边需要的读者。

如果您答对了30%以上的题目,那么您对Linux内核有一定的了解,当然,本书也可以帮助您继续深入学习Linux内核。

如果您答对的题目少于30%,那么您还不是十分了解Linux。现在就开始阅读本书,与笨叔一起快乐奔跑吧!当然,您也可以先阅读《奔跑吧Linux内核入门篇》,再阅读本书。


2017年本书第1版出版后,得到了广大Linux开发人员和开源工程师的喜爱。2019年3月3日,Linux内核创始人Linus Torvalds在社区里正式宣布了Linux 5.0内核的发布。虽然Linus在邮件列表里提到,Linux 5.0并不是一个大幅修改和新增很多特性的版本,只不过是因为Linux 4.20内核的次版本号太大了,所以才发布了Linux 5.0内核。但是Linux内核的开发并没有因此而暂停或变慢,依然每两个月左右就发布一个新版本,将很多新特性加入内核。从本书第1版采用的Linux 4.0内核到Linux 5.0内核,其间发布了20个版本,出现了很多新特性并且内核的实现已经发生了很大的变化。

最近两年,国内研究操作系统和开源软件的氛围越来越浓厚,很多大公司在基于Linux内核打造自己的操作系统,包括手机操作系统、服务器操作系统、IoT嵌入式系统等。另外,国内很多公司在探索使用ARM64架构来构建自己的硬件生态系统,包括手机芯片、服务器芯片等(例如华为鲲鹏服务器芯片)。

出于上述原因,作者觉得很有必要基于Linux 5.0内核这个有历史意义的版本修订《奔跑吧Linux内核》。第2版的修订工作非常艰辛,工作量巨大,修订工作持续整整一年。作者对第1版做了大幅度的修订,删除了部分内容,新增了很多内容。由于书稿篇幅较长,因此第2版分成了卷1和卷2两本书。

卷1包括处理器架构、Linux内核的内存管理、进程管理等,卷2包括Linux内核调试和性能优化、如何解决宕机难题以及安全漏洞分析等。

第2版的新特性如下。

第2版完全基于Linux 5.0内核来讲解。相对于Linux 4.0内核,Linux 5.0内核中不少重要模块的实现已经发生了天翻地覆的变化,如绿色节能调度器的实现、自旋锁的实现等。同时,Linux 5.0内核修复了Linux 4.x内核的很多故障,如KSM导致的虚拟机宕机故障等。

在手机芯片和嵌入式芯片领域,ARM64架构的处理器占了80%以上的市场份额;而在个人计算机和服务器领域,x86_64架构的处理器则占了90%以上的市场份额。因此,ARM64架构和x86_64架构是目前市场上的主流处理器架构。本书主要基于ARM64/x86_64架构来讲解Linux 5.0内核的实现,很多内核模块的实现和架构的相关性很低,因此本书也非常适合使用其他架构的读者阅读。在服务器领域,目前大部分厂商依然使用x86_64架构加上Red Hat Linux或者Ubuntu Linux企业发行版的方案,因此卷2的第4章会介绍x86_64架构服务器的宕机修复案例。

第2版新增了很多实战案例,如内存管理方面新增了4个实战案例,这些案例都是从实际项目中提取出来的,对读者提升实战能力有非常大的帮助。另外,第2版还新增了解决宕机难题的实战案例。在实际项目开发中,我们常常会遇到操作系统宕机,如手机宕机、服务器宕机等,本书总结了多个宕机案例,利用Kdump+Crash工具来详细分析如何解决宕机难题。考虑到有部分读者使用ARM64处理器做产品开发,也有不少读者在x86_64架构的服务器上做运维或性能调优等工作,本书分别讲解了针于这两种架构的处理器如何快速解决宕机难题。

2019年出现的CPU熔断和“幽灵”漏洞牵动了全球软件开发人员的心,了解这两个漏洞对读者熟悉计算机架构和Linux内核的实现非常有帮助,因此,卷2的第6章详细分析了这两个漏洞的产生原理和攻击方法以及Linux内核修复方案。

第2版新增了很多内核调试和优化(简称调优)的技巧(见卷2)。Linux内核通过proc和sysfs文件系统给我们提供了很多有用的日志信息。在内存管理调优过程中,可通过内核提供的日志信息来快速了解和分析系统内存并进行调优,如查看和分析meminfo、zone信息、伙伴系统等。卷2的第3章里新增了与性能优化相关的内容,如使用perf、eBPF以及BCC来进行性能分析等。

在第1版出版后,部分读者反馈书中粘贴的代码太多。在第2版中,作者尽可能在书中不粘贴代码或者只列出少量核心代码,这样可以用更多的篇幅来扩充新内容。第2版比第1版新增了大量插图和表格。

卷1的第1章和第2章里介绍了ARM64架构及其在Linux内核中的实现,其中包括ARM64指令集、ARM64寄存器、页表、内存管理、TLB、内存屏障等方面的知识。

为了体现问题导向式的内核源代码分析,每章列举了一些高频面试题,以激发读者探索未知的兴趣。

本书使用基于GCC的“O0”选项编译的Linux 5.0内核实验平台。读者可以使用GCC来调试内核,它支持ARM64、x86_64以及RISC-V架构,对深入理解Linux内核的实现有很大帮助。

本书相比第1版删减了部分内容,同时也新增和扩充了很多新内容。

删减的主要内容如下。

新增的主要内容如下。

本书主要介绍ARM64架构、Linux内核内存管理以及进程管理和调度。本书重点介绍Linux内核中基础架构的实现原理。本书基于Linux内核的话题或者技术点展开讨论,本书共9章。

第1章简单介绍ARM64架构、ARMv8寄存器、A64指令集等。

第2章介绍ARM64内存管理、高速缓存管理、TLB管理、内存屏障并分析Linux内核的汇编代码等。

第3章讲述如何从硬件角度看内存管理、从软件角度看内存管理以及物理内存管理之预备知识等内容。

第4章讨论页面分配之快速路径、slab分配器、vmalloc()、虚拟内存管理之进程地址空间、malloc()、mmap以及缺页异常处理等内容。

第5章探讨page、RMAP、页面回收、匿名页面生命周期、页面迁移、内存规整、KSM、页面分配之慢速路径以及内存碎片化管理等内容。

第6章探讨内存管理日志信息和调试信息、内存管理调优参数、内存管理实战案例等内容。

第7章讲述进程的基本概念、进程的创建和终止、进程调度原语等内容。

第8章讲述CFS、负载计算、SMP负载均衡、绿色节能调度器、实时调度等内容。

第9章介绍进程管理中的调试、综合案例等内容。

由于作者知识水平有限,书中难免存在纰漏,敬请各位读者批评指正。作者邮箱是runninglinuxkernel@126.com。在新浪微博中搜索“奔跑吧Linux内核”即可查看作者发布的文章。欢迎读者扫描下方的二维码,在“奔跑吧Linux内核”微信公众号中提问并与作者交流。


在本书编写过程中,我得到了许多人的帮助,其中王龙、彭东林、龙小昆、张毅峰、郑琦等人审阅了大部分书稿,提出了很多有帮助的修改意见。另外,陈宝剑、周明瑞、刘新朋、周明华、席贵峰、张文博、时洋、藏春鑫、艾强、胡茂留、郭述帆、陈启武、陈国龙、陈胡冠申、马福元、郭健、蔡琛、梅赵云、倪晓、刘新鹏、梁嘉荣、何花、陈渝、沈友进等人审阅了部分书稿,感谢这些人的热心帮助和付出。没有他们的支持和帮忙就不会有本书的顺利出版。

感谢西安邮电大学的陈莉君老师,她在本书的修订方面提供了很多帮助,同时感谢陈老师指导的几位研究生,他们放弃寒假休息时间,帮忙审阅全部书稿,提出了很多有建设性的修改意见。他们是戴君宜、梁金荣、贺东升、张孝家、白嘉庆、薛晓雯、马明慧以及崔鹏程。

感谢南京大学的夏耐老师在教学方面提供的建议。

同时感谢人民邮电出版社的各位编辑的辛勤付出,才让本书顺利出版。最后感谢家人对我的支持和鼓励,虽然周末我都忙于写作本书,但是他们总是给我无限的温暖。

笨叔


为了帮助读者更好地阅读本书,我们针对本书做一些约定。

1.内核版本

本书主要讲解Linux内核核心模块的实现,因此以Linux 5.0内核为研究对象。读者可以从Linux内核官网上下载Linux 5.0内核的源代码。在Linux主机中通过如下命令来下载。

$ wget https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.0.tar.xz

$ tar -Jxf linux-5.0.tar.xz

读者可以使用Source Insight或者Vim工具来阅读源代码。Source Insight是收费软件,需自行购买。Vim是开源软件,可以在Linux发行版中安装。关于如何使用Vim来阅读Linux内核源代码,请参考《奔跑吧Linux内核入门篇》第2章相关内容。

2.代码示例和讲解方式

为了避免本书篇幅过长、内容过多,本书尽量不展示源代码,尽可能只展示关键代码片段,甚至不展示相关代码。我们根据不同情况采用如下两种方式来讲解代码。

1)不展示代码

本书讲解的代码绝大部分是Linux 5.0内核的源代码,因此我们根据源代码实际的行号来讲解。如本书介绍__alloc_pages_nodemask()函数的实现时,会采用如下方式来显示。

<mm/page_alloc.c>

struct page *
__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order, int preferred_nid, 
nodemask_t *nodemask)

<mm/page_alloc.c>表示该函数在mm/page_alloc.c文件中实现,接下来列出了该函数的定义。

这种方式需要读者在计算机上打开源代码文件,如__alloc_pages_nodemask()函数的定义见第4516~4517行(见图0.1)。

图0.1 __alloc_pages_nodemask()函数的定义

这种不展示代码的讲解方式主要针对Linux内核的C代码。

2)展示关键代码

这种方式是指给出关键代码并且给出行号,行号是从1开始的,而非源代码中的实际行号。如在讲解el2_setup汇编函数时本书展示了代码的路径、关键代码以及行号。

<arch/arm64/kernel/head.S>
1     ENTRY(el2_setup)
2         msr    SPsel, #1
3         mrs    x0, CurrentEL
4         cmp    x0, #CurrentEL_EL2
5         b.eq   1f
6         mov_q  x0, (SCTLR_EL1_RES1 | ENDIAN_SET_EL1)
7         msr sctlr_el1, x0
8         mov w0, #BOOT_CPU_MODE_EL1        // 该CPU在EL1启动
9         isb
10        ret
11
12    1:  mov_q   x0, (SCTLR_EL2_RES1 | ENDIAN_SET_EL2)
13        msr sctlr_el2, x0
14    ...

这种方式主要用于讲解汇编代码和一些不在Linux内核中的示例代码,如第7章的示例代码。

3.实验平台

本书主要基于ARM64架构来讲解,但是会涉及x86_64架构方面的一部分内容。本书展示了一个基于QEMU虚拟机+Debian根文件系统的实验平台,它有如下新特性。

可以通过https://benshushu.coding.net/public/runninglinuxkernel_5.0/runninglinuxkernel_5.0/git/ files或者https://github.com/figozhang/runninglinuxkernel_5.0下载本书配套的源代码。

本书推荐使用的实验环境如下。

4.补丁说明

本书在讲解实际代码时会在脚注里列举一些关键的补丁,阅读这些补丁的代码有助于读者理解代码。建议读者下载官方Linux内核的代码树。下载命令如下。

$ git clone https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git
$ cd linux
$ git reset v5.0 ––hard

列举的补丁格式如下。

Linux 5.0 patch, commit: 679db70,“arm64: entry: Place an SB sequence following an ERET instruction”.

以上代码表示该补丁是在Linux 5.0内核中加入的补丁,可以通过“git show 679db70”命令来查看该补丁,该补丁的标题是“arm64: entry: Place an SB sequence following an ERET instruction”。

5.关于指令集的书写

ARM64指令集允许使用大写形式或者小写形式来书写汇编代码,在ARM官方的芯片手册中默认采用大写形式,而GNU汇编器默认使用小写形式,如Linux内核的汇编代码。本书中不区分汇编代码的大小写。


本书由异步社区出品,社区(https://www.epubit.com/)为您提供后续服务与支持。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮(见下图)即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线投稿(直接访问www.epubit.com/contribute即可)。

如果您所在学校、培训机构或企业想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接通过邮件发送给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、人工智能、测试、前端、网络技术等。

异步社区

微信服务号


本章的高频面试题

1.请简述精简指令集RISC和复杂指令集CISC的区别。

2.请简述数值0x1234 5678在大小端字节序处理器的存储器中的存储方式。

3.请简述在你所熟悉的处理器(如双核Cortex-A9)中一条存储读写指令的执行全过程。

4.请简述内存屏障(memory barrier)产生的原因。

5.ARM有几条内存屏障指令?它们之间有什么区别?

6.请简述高速缓存(cache)的工作方式。

7.高速缓存的映射方式有全关联(full-associative)、直接映射(direct-mapping)和组相联(set-associative)3种方式,请简述它们之间的区别。为什么现代的处理器都使用组相联的高速缓存映射方式?

8.在一个32KB的4路组相联的高速缓存中,其中高速缓存行为32字节,请画出这个高速缓存的高速缓存行(line)、路(way)和组(set)的示意图。

9.高速缓存重名问题和同名问题是什么?

10.ARM9处理器的数据高速缓存组织方式使用虚拟索引虚拟标签(Virtual Index Virtual Tag,VIVT)方式,而在Cortex-A7处理器中使用物理索引物理标签(Physical Index Physical Tag,PIPT),请简述PIPT与VIVT相比的优势。

11.VIVT类型的高速缓存有什么缺点?请简述操作系统需要做什么事情来克服这些缺点。

12.虚拟索引物理标签(Virtual Index Physical Tag,VIPT)类型的高速缓存在什么情况下会出现高速缓存重名问题?

13.请画出在二级页表架构中虚拟地址到物理地址查询页表的过程。

14.在多核处理器中,高速缓存的一致性是如何实现的?请简述MESI协议的含义。

15.高速缓存在Linux内核中有哪些应用?

16.请简述ARM big.LITTLE架构,包括总线连接和高速缓存管理等。

17.高速缓存一致性(cache coherency)和一致性内存模型(memory consistency)有什么区别?

18.请简述高速缓存的回写策略。

19.请简述高速缓存行的替换策略。

20.多进程间频繁切换对转换旁视缓冲(Translation Look-aside Buffer,TLB)有什么影响?现代的处理器是如何解决这个问题的?

21.请简述NUMA架构的特点。

22.ARM从Cortex系列开始性能有了质的飞跃,如Cortex-A8/A15/A53/A72,请指出Cortex系列在芯片设计方面的重大改进。

23.若对非对齐的内存进行读写,处理器会如何操作?

24.若两个不同进程都能让处理器的使用率达到100%,它们对处理器的功耗影响是否一样?

25.为什么页表存放在主内存中而不是存放在芯片内部的寄存器中?

26.为什么页表要设计成多级页表?直接使用一级页表是否可行?多级页表又引入了什么问题?

27.内存管理单元(Memory Management Unit,MMU)查询页表的目的是找到虚拟地址对应的物理地址,页表项中有指向下一级页表基地址的指针,那它指向的是下一级页表基地址的物理地址还是虚拟地址?

28.假设系统中有4个CPU,每个CPU都有各自的一级高速缓存,处理器内部实现的是MESI协议,它们都想访问相同地址的数据A,大小为64字节,这4个CPU的高速缓存在初始状态下都没有缓存数据A。在T0时刻,CPU0访问数据A。在T1时刻,CPU1访问数据A。在T2时刻,CPU2访问数据A。在T3时刻,CPU3想更新数据A的内容。请依次说明,T0T3时刻,4个CPU中高速缓存行的变化情况。

29.什么是高速缓存伪共享?请阐述高速缓存伪共享发生时高速缓存行状态变化情况,以及软件应该如何避免高速缓存伪共享。

30.CPU和高速缓存之间,高速缓存和主存之间,主存和辅存之间数据交换的单位分别是什么?

31.操作系统选择大粒度的页面有什么好处?选择小粒度页面有什么好处?

32.引入分页机制的虚拟内存是为了解决什么问题?

33.缺页异常相比一般的中断存在哪些区别?

34.高速缓存设计中,如何实现更高的性能?

Linux 5.x内核已经支持几十种处理器(CPU)架构,目前市面上较流行的两种架构是x86_64和ARM64。x86_64架构主要用于PC和服务器,ARM64架构主要用于移动设备等。本书重点讲述Linux内核的设计与实现,离开了处理器体系结构,操作系统就犹如空中楼阁。目前大部分的Linux内核图书是基于x86架构讲解的,但是国内还是有相当多的开发者采用ARM处理器来进行手机、物联网(Internet of Things,IoT)设备、嵌入式设备等产品的开发。因此本书基于x86_64和ARM64架构来讲述Linux内核的设计与实现。

关于x86_64架构,请参考Intel公司的官方文档“Intel 64 and IA-32 Architectures Software Developer’s Manual”。

关于ARM架构,ARM公司的官方文档已经有很多详细资料,其中描述ARMv8-A架构的官方文档有“ARM Architecture Reference Manual, ARMv8, for ARMv8-A architecture profile, v8.4”。另外,《ARM Cortex-A Series Programmer’s Guide for ARMv8-A, version 1.0》讲述了ARM Cortex处理器的编程技巧。

读者可以从Intel官网和ARM官网中下载上述资料。本书的重点是Linux内核本身,不会用过多的篇幅来介绍x86_64和ARM64架构的细节。

可能有些读者对ARM处理器的命名感到疑惑。ARM公司除了提供处理器IP和配套工具以外,还定义了一系列的ARM兼容指令集来构建整个ARM的软件生态系统。从ARMv4指令集开始被人熟知,兼容ARMv4指令集的处理器架构有ARM7-TDMI,典型处理器是三星的S3C44B0X。兼容ARMv4T指令集的处理器架构有ARM920T,典型处理器是三星的S3C2440,有些读者还买过基于S3C2440的开发板。兼容ARMv5指令集的处理器架构有ARM926EJ-S,典型处理器有NXP的i.MX2 Series。兼容ARMv6指令集的处理器架构有ARM11 MPCore。而ARMv7指令集对应的处理器系列以Cortex命名,又分成A、R和M系列,通常A系列针对大型嵌入式系统(如手机),R系列针对实时性系统,M系列针对单片机市场。Cortex-A系列处理器上市后,由于处理性能的大幅提高和较好的功耗控制,使得手机和平板电脑市场迅猛发展。另外,一些新的应用需求正在“酝酿”,如大内存、虚拟化、安全特性(Trustzone[1]),以及更高的能效比(大小核)等。虚拟化和安全特性在ARMv7架构中已经实现,但是对大内存的支持显得有点“捉襟见肘”,虽然可以通过大物理地址扩展(Large Physical Address Extensions,LPAE)技术支持40位的物理地址空间,但是由于32位的处理器最多支持4GB的虚拟地址空间,因此不适合虚拟内存需求巨大的应用。于是ARM公司设计了一套全新的指令集,即ARMv8-A指令集,它可支持64位指令集,并且向前兼容ARMv7-A指令集。因此定义AArch64和AArch32两套执行环境分别来执行64位和32位指令集,软件可以动态切换执行环境。为了行文方便,在本书中AArch64也称为ARM64,AArch32也称为ARM32。

20世纪70年代,IBM的John Cocke研究发现,处理器提供的大量指令集和复杂寻址方式并不会被编译器生成的代码用到:20%的简单指令经常被用到,占程序总指令数的80%;而指令集里其余80%的复杂指令很少被用到,只占程序总指令数的20%。基于这种情况,他将指令集和处理器重新进行了设计,在新的设计中只保留了常用的简单指令,这样处理器不需要浪费太多的晶体管去完成那些很复杂又很少使用的复杂指令。通常,大部分简单指令能在一个周期内完成,符合这种情况的指令集叫作精简指令集计算机(Reduced Instruction Set Computer,RISC)指令集,以前的指令集叫作复杂指令集计算机(Complex Instruction Set Computer,CISC)指令集。

IBM、加州大学伯克利分校的David Patterson以及斯坦福大学的John Hennessy是研究RISC的先驱。Power处理器来自IBM,ARM/SPARC处理器受到加州大学伯克利分校的RISC的影响,MIPS来自斯坦福大学。当前还在使用的最出名的CISC指令集是Intel/AMD的x86指令集。

RISC处理器通过更合理的微架构在性能上超越了当时传统的CISC处理器。在最初的较量中,Intel处理器败下阵来,服务器处理器的市场大部分被RISC阵营占据。Intel的David Papworth和他的同事一起设计了Pentium Pro处理器,x86指令集被译码成类似于RISC指令的微操作(micro-operations,μops)指令,以后的执行过程采用RISC内核的方式。CISC这个“古老”的架构通过巧妙的设计,又一次焕发生机,Intel的x86处理器的性能逐渐超过同期的RISC处理器。

RISC和CISC都是时代的产物,RISC在思想上更先进。

计算机操作系统是以字节为单位存储信息的,每个地址单元都对应1字节,1字节为8位。但在32位处理器中,C语言中除了8位的char类型之外,还有16位的short类型、32位的int类型。另外,对于16位、32位等位数更高的处理器,由于寄存器宽度大于1字节,必然存在如何安排多字节的问题,因此导致了大端存储模式(big-endian)和小端存储模式(little-endian)的产生。如一个16位的short类型变量X在内存中的地址为0x0010,X的值为0x1122,其中,0x11为高字节,0x22为低字节。对于大端模式,就将0x11放在低地址中,将0x22放在高地址中。小端模式则刚好相反。很多的ARM处理器默认使用小端模式,有些ARM处理器还可以由硬件来选择是大端模式还是小端模式。Cortex-A系列的处理器可以通过软件来配置大/小端模式。大/小端模式在处理器访问内存时用于描述寄存器的字节顺序和内存中的字节顺序之间的关系。

大端模式指数据的高字节保存在内存的低地址中,而数据的低字节保存在内存的高地址中。

在大端模式下,应该这样读取0x1234 5678。

0000430: 1234 5678 0000 0000 0000 0000 0000 0000
0000440: 0000 0000 0000 0000 0000 0000 0100 0000

因此,大端模式下地址的增长顺序与值的增长顺序相同。

小端模式指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中。

在小端模式下,应该这样读取0x1234 5678。

0000430: 7856 3412 0000 0000 0000 0000 0000 0000
0000440: 0000 0000 0000 0000 0000 0000 0000 0000

因此,小端模式下地址的增长顺序与值的增长顺序相反。

从上面大/小端模式的内存视图可知,同样是读取0x1234 5678,但是该值在内存中的布局不一样。

如何判断处理器是大端模式还是小端模式?联合体(union)的存放顺序是所有成员都从低地址开始存放,利用该特性可以轻松判断CPU对内存采用大端模式还是小端模式读写。

如果以下代码的输出结果是true,则为小端模式;否则,为大端模式。

int checkCPU(void)
{ 
    union w 
    {
        int  a; 
        char b; 
    } c; 
    c.a = 1; 
    return (c.b == 1); 
}

经典处理器架构的流水线是5级流水线,分别是取指、译码、执行、数据内存访问和写回。

现代处理器在设计上都采用了超标量架构(superscalar architecture)和乱序(Out-of-Order,OoO)执行技术,极大地提高了处理器计算能力。超标量技术能够在一个时钟周期内执行多条指令,实现指令级的并行,有效提高指令级的并行效率(Instruction Level Parallelism,ILP),同时增加整个高速缓存和内存层次结构的实现难度。

一条存储-读-写指令的执行全过程很难用一句话来描述。在一个支持超标量和乱序执行技术的处理器当中,把一条存储-读-写指令的执行过程分解为若干步骤。指令首先进入流水线(pipeline)的前端(front-end),包括预取(fetch)和译码(decode),经过分发(dispatch)和调度(schedule)后进入执行单元,最后提交执行结果。所有的指令采用顺序方式通过前端,并采用乱序的方式进行发射,然后乱序执行,最后用顺序方式提交执行结果,并将最终结果更新到加载-存储队列(Load-Store Queue,LSQ)单元。LSQ单元是指令流水线的一个执行部件,可以理解为存储子系统的最高层,它接收来自CPU的存储器指令,并连接着存储器子系统。其主要功能是将来自CPU的存储器请求发送到存储器子系统,并处理其下存储器子系统的应答数据和消息。

很多程序员对乱序执行的理解有误差。对于一串给定的指令序列,为了提高效率,处理器会找出非真正数据依赖和地址依赖的指令,让它们并行执行。但是在提交执行结果时,是按照指令次序提交的。总的来说,顺序提交指令,然后乱序执行,最后顺序提交执行结果。如果有两条没有数据依赖的数据指令,那么后面那条指令读的数据先返回,它的结果也不能先写回最终寄存器,而必须等到前一条指令完成之后才可以。

对于读指令,当处理器在等待数据从缓存或者内存返回时,它处于什么状态呢?是停顿,还是继续执行别的指令?对于乱序执行的处理器,可以执行后面的指令;对于顺序执行的处理器,会使流水线停顿,直到读取的数据返回。

如图1.1所示,在x86微处理器的经典架构中,指令预取单元会从L1指令高速缓存中加载指令,并做指令的预译码。在取指令阶段,不仅需要从指令高速缓存中取出多条指令,还需要决定下一个周期取指令的地址。当遇到条件跳转指令时,它不能确定是否需要跳转。处理器会使用分支预测单元试图猜测每条跳转指令是否会执行。当它猜测的准确率很高时,流水线充满

▲图1.1 x86微处理器的经典架构

了指令,这样可以实现高的性能。接着,在指令译码单元,把指令译码成微操作(macro-ops)指令,并由分发部件分发到整数单元(integer unit)或者浮点数单元(float point unit)。整数单元由整数调度器、执行单元以及整数重命名单元组成。整数单元的执行单元包含算术逻辑单元(Arithmetic-Logic Unit,ALU)、地址生成单元(Address Generation Unit,AGU)、乘法单元(MUL)以及高级位运算(Advanced Bit Manipulation,ABM)单元。在ALU计算完成之后,进入AGU。计算有效地址后,将结果发送到LSQ单元。浮点数单元的执行单元包括浮点数加法(FADD)运算单元、浮点数乘法(FMUL)运算单元和浮点数存储(FSTOR)单元等。LSQ单元根据处理器系统要求的内存一致性(memory consistency)模型确定访问时序。另外,LSQ单元还需要处理存储器指令间的依赖关系。最后,LSQ单元需要准备一级缓存使用的地址,包括有效地址的计算和虚实地址转换,将地址发送到L1数据高速缓存中。如果L1数据高速缓存未命中,则访问L2高速缓存以及L3高速缓存。如果高速缓存都没有命中,则需要通过内存控制器来访问物理内存。

如图1.2所示,在Cortex-A9处理器中,存储指令首先通过主存储器或者L2高速缓存加

▲图1.2 Cortex-A9处理器的内部架构[2]

载到L1指令高速缓存中,通过总线接口单元(BIU)中的主接口连接到主存储器。在指令预取阶段(instruction prefetch stage),主要做指令预取和分支预测,然后指令通过指令队列和预测队列送到译码器,进行指令的译码工作。译码器(decoder)支持两路译码,可以同时译码两条指令。在寄存器重命名阶段(register rename stage)会做寄存器重命名,避免指令进行不必要的顺序化操作,提高处理器的指令级并行能力。在指令分发阶段(dispatch stage),这里支持4路猜测发射和乱序执行(Out-of-Order Multi-Issue with Speculation),因此它支持基于推测的乱序的发射功能。然后在执行单元(ALU/MUL/FPU/NEON)中乱序执行指令,最终的计算结果会在乱序写回阶段写入寄存器中。存储指令会计算有效地址并将其发送到内存系统中的加载存储单元(Load Store Unit,LSU),最终LSU会访问L1数据高速缓存。在ARM中,只有可缓存的内存地址才需要访问高速缓存。

在多处理器环境下,还需要考虑高速缓存的一致性问题。L1和L2高速缓存控制器需要保证高速缓存的一致性,在Cortex-A9中,高速缓存的一致性是由MESI协议来实现的。Cortex-A9处理器内置了一级缓存模块,由窥探控制单元(Snoop Control Unit,SCU)来实现高速缓存的一致性管理。L2高速缓存需要外接芯片(如PL310)。在最糟糕的情况下需要访问主存储器,并将数据重新传递给LSQ,完成一次存储器读写的全过程。

涉及计算机体系结构中的众多术语比较晦涩难懂,现在对部分术语做简单解释。

若程序在执行时的实际内存访问顺序和程序代码编写的访问顺序不一致,会导致内存乱序访问。内存乱序访问的出现是为了提高程序执行时的效率。内存乱序访问主要发生在如下两个阶段。

(1)编译时,编译器优化导致内存乱序访问。

(2)执行时,多个CPU间交互引起的内存乱序访问。

编译器会把符合人类思维逻辑的代码(如C语言的代码)翻译成符合CPU运算规则的汇编指令,编译器了解底层CPU的思维逻辑,因此它会在翻译汇编指令时对其进行优化。如内存访问指令的重新排序可以提高指令级并行效率。然而,这些优化可能会与程序员原始的代码逻辑不符,导致一些错误发生。编译时的乱序访问可以通过barrier()函数来规避。

#define barrier() __asm__ __volatile__ ("" ::: "memory")

barrier()函数告诉编译器,不要为了性能优化而将这些代码重排。

由于现代处理器普遍采用超标量架构、乱序发射以及乱序执行等技术来提高指令级并行效率,因此指令的执行序列在处理器流水线中可能被打乱,与程序代码编写时序列不一致。另外,现代处理器采用多级存储结构,如何保证处理器对存储子系统访问的正确性也是一大挑战。

例如,在一个系统中含有n个处理器P1~Pn,假设每个处理器包含Si个存储器操作,那么从全局来看,可能的存储器访问序列有多种组合。为了保证内存访问的一致性,需要按照某种规则来选出合适的组合,这个规则叫作内存一致性模型(memory consistency model)。这个规则需要在保证正确性的前提下,同时保证多个处理器访问时有较高的并行度。

在一个单核处理器系统中,保证访问内存的正确性比较简单。每次存储器读操作所获得的结果是最近写入的结果,但是在多个处理器并发访问存储器的情况下就很难保证其正确性了。我们很容易想到使用一个全局时间比例(global time scale)部件来决定存储器访问时序,从而判断最近访问的数据。这种访问的内存一致性模型是严格一致性(strict consistency)内存模型,称为原子一致性(atomic consistency)内存模型。实现全局时间比例部件的代价比较大,因此退而求其次。采用每一个处理器的局部时间比例(local time scale)部件来确定最新数据的内存模型称为顺序一致性(sequential consistency)内存模型。处理器一致性(processor consistency)内存模型是顺序一致性内存模型的进一步弱化,仅要求来自同一个处理器的写操作具有一致性的访问即可。

以上这些内存一致性模型是针对存储器的读写指令展开的,还有一类目前广泛使用的模型,这类模型使用内存同步指令(也称为内存屏障指令)。在这种模型下,存储器访问指令被分成数据指令和同步指令两大类,弱一致性(weak consistency)内存模型就是基于内存屏障指令的。

1986年,Dubois等发表的论文描述了弱一致性内存模型的定义,在这个定义中使用全局同步变量(global synchronizing variable)来描述一个同步访问。在一个多处理器系统中,满足如下3个条件的内存访问称为弱一致性的内存访问。

弱一致性内存模型要求同步访问(访问全局同步变量)是顺序一致的,在一个同步访问可以执行之前,之前的所有数据访问必须完成。在一个正常的数据访问可以执行之前,所有之前的同步访问必须完成。这实质上把一致性问题留给了程序员来解决。在ARM处理器中使用内存屏障指令的方式来实现同步访问。内存屏障指令的基本原则如下。

当然,处理器会根据内存屏障的作用范围进行细分,例如,ARM64处理器把内存屏障指令细分为数据存储屏障指令、数据同步屏障指令以及指令同步屏障指令。

关于内存屏障指令的例子如下。

例1-1:假设有两个CPU内核A和B,同时访问Addr1和Addr2。

Core A:
   STR R0, [Addr1]
   LDR R1, [Addr2]

Core B:
   STR R2, [Addr2]
   LDR R3, [Addr1]

上面的代码片段中,没有任何的同步措施。对于Core A、寄存器R1、Core B和寄存器R3,可能得到如下4种不同的结果。

例1-2:假设Core A把新数据写入Msg地址,Core B需要判断Flag置位后才读取新数据。

Core A:
       STR R0, [Msg] @ 写新数据到Msg地址
       STR R1, [Flag] @ Flag标志表示新数据可以读

Core B:
   Poll_loop:
       LDR R1, [Flag]
       CMP R1,#0       @ 判断Flag有没有置位
       BEQ Poll_loop
       LDR R0, [Msg]   @ 读取新数据

在上面的代码片段中,Core B可能读不到最新的数据,Core B可能出于乱序执行的原因先读取Msg,然后读取Flag。在弱一致性内存模型中,处理器不知道Msg和Flag存在数据依赖性,所以程序员必须使用内存屏障指令来显式地告诉处理器这两个变量有数据依赖关系。Core A需要在两个存储指令之间插入DMB指令来保证两个存储指令的执行顺序。Core B需要在“LDR R0, [Msg]”之前插入DMB指令来保证直到Flag置位才读取Msg。

例1-3:在一个设备驱动中,写一个命令到一个外设寄存器中,然后等待状态的变化。

STR R0, [Addr]        @ 写一个命令到外设寄存器
DSB
Poll_loop:
    LDR R1, [Flag]
    CMP R1,#0         @ 等待状态寄存器的变化
    BEQ Poll_loop

在STR存储指令之后插入DSB指令,强制让写命令完成,然后执行读取Flag的判断循环。

处理器访问主存储器使用地址编码方式。高速缓存也使用类似的地址编码方式,因此处理器使用这些编码地址可以访问各级高速缓存。图1.3所示为一个经典的高速缓存架构。

▲图1.3 经典的高速缓存架构

处理器在访问存储器时,会把虚拟地址同时传递给TLB和高速缓存。TLB是一个用于存储虚拟地址到物理地址转换的小缓存,处理器先使用有效页帧号(Effective Page Number,EPN)在TLB中查找最终的实际页帧号(Real Page Number,RPN)。如果其间发生TLB未命中(TLB miss),将会带来一系列严重的系统惩罚,处理器需要查询页表。假设发生TLB命中(TLB hit),就会很快获得合适的RPN,并得到相应的物理地址(Physical Address,PA)。

同时,处理器通过高速缓存编码地址的索引(index)域可以很快找到相应的高速缓存行对应的组。但是这里的高速缓存行的数据不一定是处理器所需要的,因此有必要进行一些检查,将高速缓存行中存放的标记域和通过MMU转换得到的物理地址的标记域进行比较。如果相同并且状态位匹配,就会发生高速缓存命中(cache hit),处理器通过字节选择与对齐(byte select and align)部件,就可以获取所需要的数据。如果发生高速缓存未命中(cache miss),处理器需要用物理地址进一步访问主存储器来获得最终数据,数据也会填充到相应的高速缓存行中。上述为VIPT类型的高速缓存组织方式,这将在1.1.8节中详细介绍。

图1.4所示为高速缓存的基本结构。

▲图1.4 高速缓存的基本结构

根据每组的高速缓存行数,高速缓存可以分为不同的类。

1.直接映射

当每组只有一个高速缓存行时,高速缓存称为直接映射高速缓存。

下面用一个简单的高速缓存来说明。如图1.5所示,这个高速缓存只有4个高速缓存行,每行有4个字(Word),1个字是4字节,共64字节。高速缓存控制器可以使用Bit[3:2]来选择高速缓存行中的字,使用Bit[5:4]作为索引,来选择4个高速缓存行中的1个,其余的位用于存储标记值。

在这个高速缓存中查询,当索引域和标记域的值与查询的地址相等并且有效位显示这个高速缓存行包含有效数据时,则发生高速缓存命中,可以使用偏移量域来寻址高速缓存行中的数据。如果高速缓存行包含有效数据,但是标记域是其他地址的值,那么这个高速缓存行需要被替换。因此,在这个高速缓存中,主存储器中所有Bit[5:4]相同值的地址都会映射到同一个高速缓存行中,并且同一时刻只有1个高速缓存行。若高速缓存行被频繁换入、换出,会导致严重的高速缓存颠簸(cache thrashing)。

▲图1.5 直接映射的高速缓存和地址

假设在下面的代码片段中,result、data1和data2分别指向0x00、0x40和0x80地址,它们都会使用同一个高速缓存行。

void add_array(int *data1, int *data2, int *result, int size)
{
    int i;
    for (i=0 ; i<size ; i++) {
         result[i] = data1[i] + data2[i];
    }
}

当第一次读data1(即0x40地址)的数据时,因为数据不在高速缓存行中,所以把从0x40到0x4F地址的数据填充到高速缓存行中。

当读data2(即0x80地址)的数据时,数据不在高速缓存行中,需要把从0x80到0x8F地址的数据填充到高速缓存行中。因为0x80和0x40地址映射到同一个高速缓存行,所以高速缓存行发生替换操作。

当把result写入0x00地址时,同样发生了高速缓存行替换操作。

因此上面的代码片段会发生严重的高速缓存颠簸,性能会很低。

2.组相联

为了解决直接映射高速缓存中的高速缓存颠簸问题,组相联的高速缓存结构在现代处理器中得到广泛应用。

如图1.6所示,以一个2路组相联的高速缓存为例,每一路包括4个高速缓存行,因此每个组有两个高速缓存行,可以提供高速缓存行替换。

地址0x00、0x40或者0x80的数据可以映射到同一个组的任意一个高速缓存行。当高速缓存行要进行替换操作时,有50%的概率可以不被替换,从而解决了高速缓存颠簸问题。

▲图1.6 2路组相联的高速缓存

在Cortex-A7和Cortex-A9的处理器上可以看到32KB大小的4路组相联高速缓存。下面来分析这个高速缓存的结构。

高速缓存的总大小为32KB,并且是4路的,所以每一路的大小为8KB。

way_size = 32KB/ 4 = 8KB

高速缓存行的大小为32字节,所以每一路包含的高速缓存行数量如下。

num_cache_line = 8KB/32B = 256

所以在高速缓存编码的地址中,Bit[4:0]用于选择高速缓存行中的数据,其中Bit[4:2]可以用于寻址8个字,Bit[1:0]可以用于寻址每个字中的字节。Bit[12:5]用于在索引域中选择每一路上的高速缓存行,Bit[31:13]用作标记域,如图1.7所示。这里,V表示有效位,D表示脏位。

▲图1.7 32KB大小的4路组相联高速缓存结构

处理器在访问存储器时,访问的地址是虚拟地址(Virtual Address,VA),经过TLB和MMU的映射后变成了物理地址(Physical Address,PA)。TLB只用于加速虚拟地址到物理地址的转换。得到物理地址之后,若每次都直接从物理内存中读取数据,显然会很慢。实际上,处理器都配置了多级的高速缓存来加快数据的访问速度,那么查询高速缓存时使用虚拟地址还是物理地址呢?

1.物理高速缓存

当处理器查询MMU和TLB并得到物理地址之后,使用物理地址查询高速缓存,这种高速缓存称为物理高速缓存。使用物理高速缓存的缺点就是处理器在查询MMU和TLB后才能访问高速缓存,增加了流水线的延迟时间。物理高速缓存的工作流程如图1.8所示。

▲图1.8 物理高速缓存的工作流程

2.虚拟高速缓存

若处理器使用虚拟地址来寻址高速缓存,这种高速缓存称为虚拟高速缓存。处理器在寻址时,首先把虚拟地址发送到高速缓存,若在高速缓存里找到需要的数据,就不再需要访问TLB和物理内存。虚拟高速缓存的工作流程如图1.9所示。

▲图1.9 虚拟高速缓存的工作流程

虚拟高速缓存会引入以下问题。

▲图1.10 重名问题

综上所述,重名问题是多个虚拟地址映射到同一个物理地址引发的问题,而同名问题是一个虚拟地址可能出于进程切换等原因映射到不同的物理地址而引发的问题。

3.高速缓存的分类

在查询高速缓存时使用了索引域和标记域,那么查询高速缓存组时,我们是用虚拟地址还是物理地址的索引域呢?当找到高速缓存组时,我们是用虚拟地址还是物理地址的标记域来匹配高速缓存行呢?

高速缓存可以设计成通过虚拟地址或者物理地址来访问,这在处理器设计时就确定下来了,并且对高速缓存的管理有很大的影响。高速缓存可以分成如下3类。

早期的ARM处理器(如ARM9处理器)采用VIVT的方式,不用经过MMU的翻译,直接使用虚拟地址的索引域和标记域来查找高速缓存行,这种方式会导致高速缓存重名问题。例如,一个物理地址的内容可以出现在多个高速缓存行中,当系统改变了虚拟地址到物理地址的映射时,需要清洗(clean)这些高速缓存并使它们无效,这会导致系统性能降低。

ARM11系列处理器采用VIPT方式,即处理器输出的虚拟地址会同时发送到TLB/MMU进行地址翻译,以及在高速缓存中进行索引和查询高速缓存。在TLB/MMU里,会把VPN翻译成PFN,同时用虚拟地址的索引域和偏移量来查询高速缓存。高速缓存和TLB/MMU可以同时工作,当TLB/MMU完成地址翻译后,再用物理标记域来匹配高速缓存行,如图1.11所示。采用VIPT方式的好处之一是在多任务操作系统中,修改了虚拟地址到物理地址映射关系,不需要对相应的高速缓存进行无效操作。

▲图1.11 VIPT的高速缓存工作方式

采用VIPT方式也可能导致高速缓存重名问题。在VIPT中,若使用虚拟地址的索引域来查找高速缓存组,可能导致多个高速缓存组映射到同一个物理地址。以Linux 内核为例,它是以4KB为一个页面大小进行管理的,那么对于一个页面来说,虚拟地址和物理地址的低12位(Bit [11:0])是一样的。因此,不同的虚拟地址会映射到同一个物理地址,这些虚拟页面的低12位是一样的。

如果索引域位于Bit[11:0],就不会发生高速缓存重名问题,因为该范围相当于一个页面内的地址。那什么情况下索引域会在Bit[11:0]内呢?索引域是用于在一个高速缓存的路中查找高速缓存行的,当一个高速缓存路的大小在4KB范围内,索引域必然在Bit[11:0]范围内。例如,如果高速缓存行大小是32字节,那么偏移量域占5位,有128个高速缓存组,索引域占7位,这种情况下刚好不会发生重名。

如图1.12所示,假设高速缓存的路大小是8KB,并且两个虚拟页面Page1和Page2同时映射到同一个物理页面,我们研究其中的虚拟地址VA1和VA2,这两个虚拟地址的第12位可能是0,也可能是1。当VA1的第12位为0、VA2的第12位为1时,在高速缓存中会在两个不同的地方存储了同一个PA的值,这样就导致了重名问题。当修改虚拟地址VA1的内容后,访问虚拟地址VA2会得到一个旧值,导致错误发生。

▲图1.12 VIPT导致重名问题

Cortex-A系列处理器的数据高速缓存开始采用PIPT方式。对于PIPT方式,索引域和标记域都采用物理地址,高速缓存中只有一个高速缓存组与之对应,不会产生高速缓存重名问题。PIPT方式在芯片设计里的逻辑比VIPT要复杂得多。

另外,对于Cortex-A系列处理器来说,高速缓存总大小是可以在芯片集成中配置的。Cortex-A系列处理器的高速缓存配置情况如表1.1所示。

表1.1 Cortex-A系统处理器的高速缓存配置情况

Cortex-A7

Cortex-A9

Cortex-A15

Cortex-A53

数据缓存实现方式

PIPT

PIPT

PIPT

PIPT

指令缓存实现方式

VIPT

VIPT

PIPT

VIPT

L1数据缓存大小

8KB~64KB

16KB/32KB/64KB

32KB

8KB~64KB

L1数据缓存结构

4路组相联

4路组相联

2路组相联

4路组相联

L2数据缓存大小

128KB~1MB

External

512KB~4MB

128KB~2MB

L2数据缓存结构

8路组相联

External

16路组相联

16路组相联

程序执行所需要的内存往往大于实际物理内存,采用传统的动态分区方法会把整个程序交换到交换分区,这样费时费力,而且效率很低。后来出现了分页机制,分页机制引入了虚拟存储器(virtual memory)的概念,它的核心思想是让程序中一部分不使用的内存可以交换到交换分区中,而程序正在使用的内存继续保留在物理内存中。因此,一个程序执行在虚拟存储器空间中,它的大小由处理器的位宽决定,如32位处理器的位宽是32位,它的地址范围是0x0000~0xFFFF FFFF,64位处理器的虚拟地址位宽是48位,因此它可以访问0x0000 0000 0000 0000到0x0000 FFFF FFFF FFFF以及0xFFFF 0000 0000 0000到0xFFFF FFFF FFFF FFFF这两段空间。在使能了分页机制的处理器中,我们通常把处理器能寻址的地址空间称为虚拟地址(virtual address)空间。和虚拟存储器对应的是物理存储器(physical memory),它对应系统中使用的物理存储设备的地址空间,如DDR内存颗粒等。在没有使能分页机制的系统中,处理器直接寻址物理地址,把物理地址发送到内存控制器中;而在使能了分页机制的系统中,处理器直接寻址虚拟地址,这个地址不会直接发给内存控制器,而是先发送给MMU的硬件单元。MMU负责虚拟地址到物理地址的转换和翻译工作。在虚拟地址空间里按照固定大小来分页,典型页面的粒度为4KB,现代处理器都支持大粒度的页面,如16KB、64KB甚至2MB的巨页(huge page)。而在物理内存中也会分成和虚拟地址空间中大小相同的块,这称为页帧(page frame)。程序可以在虚拟地址空间里任意分配虚拟内存,但只有当程序需要访问或修改虚拟内存时操作系统才会为其分配物理页面,这个过程叫作请求调页(demand page)或者缺页异常(page fault)。

虚拟地址VA[31:0]可以分成两部分:一部分是虚拟页面内的偏移量,以4KB页为例,VA[11:0]是虚拟页面内的偏移量;另一部分用来确定属于哪个页,我们称其为虚拟页帧号(Virtual Page Frame Number,VPN)。对于物理地址,也是类似的,PA[11:0]表示物理页帧的偏移量,剩余部分表示物理页帧号(Physical Frame Number,PFN)。MMU的工作内容就是把VPN转换成PFN。处理器通常使用一张表来存储VPN到PFN的映射关系,这个表称为页表(Page Table,PT)。页表中每一个表项称为页表项(Page Table Entry,PTE)。若将整张页表存放在寄存器中,则会占用很多硬件资源,因此通常的做法是把页表放在主内存里,通过页表基地址寄存器(Translation Table Base Register,TTBR)来指向这种页表的起始地址。页表查询过程如图1.13所示。处理器发出的地址是虚拟地址,通过MMU来查询页表,处理器得到了物理地址,最后把物理地址发送给内存控制器,从而访问物理页面。

▲图1.13 页表查询过程

下面以最简单的一级页表为例,如图1.14所示,处理器采用一级页表,虚拟地址空间的位宽是32位,寻址空间的大小是4GB,物理地址空间的位宽也是32位,最多支持4GB物理内存,页面的大小是4KB。为了能映射到4GB地址空间,需要4GB/4KB=1048576个页表项,每个页表项占用4字节,因此需要4MB大小的物理内存来存放这张页表。VA[11:0]是页面偏移量,VA[31:12]这20位是VPN,作为索引值在页表中查询页表项。页表类似于数组,VPN类似于数组的下标,用于查找数组中对应的成员。页表项中包含两部分。一部分是PFN,它代表页面在物理内存中的帧号,即页帧号,页帧号与页内偏移量就组成最终的PA。另一部分是页表项的属性,图1.14中的V表示有效位。若有效位为1,表示这个页表项对应的物理页面在物理内存中,处理器可以访问这个物理页面的内容;若有效位为0,表示这个物理页面不在内存中,可能在交换分区中。如果访问该物理页面,那么操作系统会触发缺页异常,在缺页异常中处理这种情况。当然,实际的处理器中还有很多其他的属性位,如描述物理页面是否为脏、是否可读可写等的属性位。

▲图1.14 一级页表

通常操作系统支持多进程,进程调度器会在合适的时间切换进程A到进程B来执行,如进程A使用完时间片时。另外,分页机制让每个进程都“感觉”自己拥有了全部的虚拟地址空间。为此,每个进程拥有了一套属于自己的页表,在进程切换时需要切换页表基地址。对于上述的一级页表,每个进程需要为其分配4MB的连续物理内存来存储页表,这是不能接受的,因为这样太浪费内存了。多级页表可减少页表所占用的内存空间。如图1.15所示,二级页表分成一级页表和二级页表,页表基地址寄存器指向一级页表的基地址,一级页表的页表项里存放了一个指针,指向二级页表的基地址。当处理器执行程序时它只需要把一级页表加载到内存中,并不需要把所有的二级页表都装载到内存中,而根据物理内存分配和映射情况逐步创建与分配二级页表。这样做有两个原因,第一,程序不会马上使用完所有的物理内存;第二,对于32位操作系统来说,通常操作系统配置的物理内存小于4GB,如512MB内存等。

图1.15所示为ARMv7-A架构二级页表的查询过程。VA[31:20]用作一级页表的索引值,一共有12位,最多可以索引4096个页表项;VA[19:12]用作二级页表的索引值,一共有8位,最多可以索引256个页表项。当操作系统复制一个新进程时,首先会创建一级页表,分配16KB页面。本场景中,一级页表有4096个页表项,每个页表项占4字节,因此一级页表大小是16KB。当操作系统准备让该进程执行时,设置一级页表在物理内存中的起始地址到页表基地址寄存器中。进程执行过程中需要访问物理内存,因为一级页表的页表项是空的,这会触发缺页异常。在缺页异常里分配一个二级页表,并且把二级页表的起始地址填充到一级页表的相应页表项中。接着,分配一个物理页面,并把这个物理页面的帧号填充到二级页表的对应页表项中,从而完成页表的填充。随着进程的执行,它需要访问越来越多的物理内存,操作系统会逐步地把页表填充和建立起来。

▲图1.15 ARMv7-A架构二级页表的查询过程

当TLB未命中时,处理器的MMU查询页表的过程如下。

图1.16所示的4KB映射的一级页表的项中,Bit[1:0]表示页表映射的项,Bit[31:10]指向二级页表的物理基地址。

▲图1.16 4KB映射的一级页表的项

图1.17所示的4KB映射的二级页表的项中,Bit[31:12]指向4KB大小的页面的物理基地址。

▲图1.17 4KB映射的二级页表的项

在现代处理器中,软件使用虚拟地址访问内存,而处理器的MMU负责把虚拟地址转换成物理地址。为了完成这个映射过程,软件和硬件要共同维护一个多级映射的页表。当处理器发现页表项无法映射到对应的物理地址时,会触发一个缺页异常,挂起出错的进程,操作系统需要处理这个缺页异常。前面提到过二级页表的查询过程,为了完成虚拟地址到物理地址的转换,查询页表需要访问两次内存,因为一级页表和二级页表都是存放在内存中的。

TLB专门用于缓存已经翻译好的页表项,一般在MMU内部。TLB是一个很小的高速缓存,TLB表项(TLB entry)数量比较少,每个TLB表项包含一个页面的相关信息,如有效位、VPN、修改位、PFN等。当处理器要访问一个虚拟地址时,首先会在TLB中查询。如果TLB中没有相应的表项(称为TLB未命中),那么需要访问页表来计算出相应的物理地址;如果TLB中有相应的表项(称为TLB命中),那么直接从TLB表项中获取物理地址,如图1.18所示。

▲图1.18 TLB的查询过程

TLB内部存放的基本单位是TLB表项,TLB容量越大,所能存放的TLB表项就越多,TLB命中率就越高,但是TLB的容量是有限的。目前Linux内核默认采用4KB大小的小页面,如果一个程序使用512个小页面,即2MB大小,那么至少需要512个TLB表项才能保证不会出现TLB 未命中的情况。但是如果使用2MB大小的巨页,那么只需要一个TLB表项就可以保证不会出现TLB 未命中的情况。对于消耗的内存以吉字节为单位的大型应用程序,还可以使用以吉字节为单位的大页,从而减少TLB未命中情况的出现次数。

在一个处理器系统中不同CPU内核上的高速缓存和内存可能具有同一个数据的多个副本,在仅有一个CPU内核的处理器系统中不存在一致性问题。维护高速缓存一致性的关键是跟踪每一个高速缓存行的状态,并根据处理器的读写操作和总线上相应的传输内容来更新高速缓存行在不同CPU内核上的高速缓存中的状态,从而维护高速缓存一致性。维护高速缓存一致性有软件和硬件两种方式。有的处理器架构提供显式操作高速缓存的指令,如PowerPC,不过现在大多数处理器架构采用硬件方式来维护它。在处理器中通过高速缓存一致性协议来实现,这些协议维护一个有限状态机(Finite State Machine,FSM),根据存储器读写的指令或总线上的传输内容,进行状态迁移和相应的高速缓存操作来维护高速缓存一致性,不需要软件介入。

高速缓存一致性协议主要有两大类别:一类是监听协议(snooping protocol),每个高速缓存都要被监听或者监听其他高速缓存的总线活动;另一类是目录协议(directory protocol),用于全局统一管理高速缓存状态。

1983年,James Goodman提出Write-Once总线监听协议,后来演变成目前很流行的MESI协议。Write-Once总线监听协议依赖于这样的事实,即所有的总线传输事务对于处理器系统内的其他单元是可见的。总线是一个基于广播通信的介质,因而可以由每个处理器的高速缓存来进行监听。这些年来人们已经提出了数十种协议,这些协议基本上都是Write-Once总线监听协议的变种。不同的协议需要不同的通信量,通信量要求太多会浪费总线带宽,因为它使总线争用情况变多,留给其他部件使用的带宽减少。因此,芯片设计人员尝试将保持一致性协议所需要的总线通信量减少到最小,或者尝试优化某些频繁执行的操作。

目前,ARM或x86等处理器广泛使用MESI协议来维护高速缓存一致性。MESI协议的名字源于该协议使用的修改(Modified,M)、独占(Exclusive,E)、共享(Shared,S)和失效(Invalid,I)这4个状态。高速缓存行中的状态必须是上述4个状态中的1个。MESI协议还有一些变种,如MOESI协议等,部分ARMv7-A和ARMv8-A处理器使用该变种协议。

高速缓存行中有两个标志——脏(dirty)和有效(valid)。它们很好地描述了高速缓存和内存之间的数据关系,如数据是否有效、数据是否被修改过。在MESI协议中,每个高速缓存行有4个状态,可以使用高速缓存行中的两位来表示这些状态。

表1.2所示为MESI协议中4个状态的说明。

表1.2 MESI协议中4个状态的说明

状  态

说  明

M

这行数据有效,数据已被修改,和内存中的数据不一致,数据只存在于该高速缓存中

E

这行数据有效,数据和内存中数据一致,数据只存在于该高速缓存中

S

这行数据有效,数据和内存中数据一致,多个高速缓存有这行数据的副本

I

这行数据无效

 

MESI协议在总线上的操作分成本地读写和总线操作,如表1.3所示。初始状态下,当缓存行中没有加载任何数据时,状态为I。本地读写指的是本地CPU读写自己私有的高速缓存行,这是一个私有操作。总线读写指的是有总线的事务(bus transaction),因为实现的是总线监听协议,所以CPU可以发送请求到总线上,所有的CPU都可以收到这个请求。总之,总线读写的目标对象是远端CPU的高速缓存行,而本地读写的目标对象是本地CPU的高速缓存行。

表1.3 本地读写和总线操作

操作类型

描  述

本地读(Local Read/PrRd)

本地CPU读取缓存行数据

本地写(Local Write/PrWr)

本地CPU更新缓存行数据

总线读(Bus Read/BusRd)

总线监听到一个来自其他CPU的读缓存请求。收到信号的CPU先检查自己的高速缓存中是否缓存了该数据,然后广播应答信号

总线写(Bus Write/BusRdX)

总线监听到一个来自其他CPU的写缓存请求。收到信号的CPU先检查自己的高速缓存中是否缓存了该数据,然后广播应答信号

总线更新(BusUpgr)

总线监听到更新请求,请求其他CPU做一些额外事情。其他CPU收到请求后,若CPU上有缓存副本,则需要做额外的一些更新操作,如使本地的高速缓存行无效等

刷新(Flush)

总线监听到刷新请求。收到请求的CPU把自己的高速缓存行的内容写回主内存中

刷新到总线(FlushOpt)

收到该请求的CPU会把高速缓存行内容发送到总线上,这样发送请求的CPU就可以获取到这个高速缓存行的内容

表1.4所示为MESI协议中各个状态的转换关系。

表1.4 MESI协议中各个状态的转换关系

高速缓存行当前状态 操作 响  应 迁移
状态
M 总线读[4] 数据在本地CPU(假设是CPU0)上的高速缓存行有副本并且状态为M,而在其他CPU上没有这个数据的副本。当其他CPU(如CPU1)想读这份数据时,CPU1会发起一次总线读操作。
(1)CPU1发出Flushopt信号。若CPU0上有这个数据的副本,那么CPU0收到信号后把高速缓存行的内容发送到总线上,然后CPU1就获取这个高速缓存行的内容。另外,会把相关内容发送到主内存中,把高速缓存行的内容写入主内存中。
(2)更改CPU0上的高速缓存行状态为S
S
总线写 数据在本地CPU(假设是CPU0)上有副本并且状态为M,而其他CPU上没有这个数据的副本。若某个CPU(假设CPU1)想更新(写)这份数据,CPU1就会发起一个总线写操作。
(1)CPU1发出Flushopt信号。若CPU0上有这个数据的副本,CPU0收到信号后把自己的高速缓存行的内容发送到内存控制器,并把该高速缓存行的内容写入主内存中,然后CPU1修改自己本地高速缓存行的内容。
(2)CPU0上的高速缓存行状态变成I
I
本地读 本地处理器读该高速缓存行,状态不变 M
本地写 本地处理器写该高速缓存行,状态不变 M
E 总线读 独占状态的高速缓存行是干净的,因此状态变成S。
(1)高速缓存行的状态变成S。
(2)发送FlushOpt信号,把高速缓存行的内容发送到总线上
S
总线写 数据被修改,该高速缓存行不能再使用了,状态变成I。
(1)高速缓存行的状态变成I。
(2)发送Flushopt信号,把高速缓存行的内容发送到总线上
I
本地读 从该高速缓存行中取数据,状态不变 E
本地写 修改该高速缓存行的数据,状态变成M M
S 总线读 状态不变 S
总线写 数据被修改,该高速缓存行不能再使用了,状态变成I I
本地读 状态不变 S
S 本地写 (1)发送BusUpgr信号到总线上。
(2)本地CPU修改本地高速缓存行的内容,状态变成M。
(3)发送BusUpgr信号到总线上。
(4)其他CPU收到BusUpgr信号后,检查自己的高速缓存中是否有副本,若有,将其状态改成I
M
I 总线读 状态不变,忽略总线上的信号 I
总线写 状态不变,忽略总线上的信号 I
本地读 (1)向总线发送BusRd信号。
(2)其他CPU收到BusRd信号,先检查自己的高速缓存中是否有副本,广播应答信号。
a)若其他CPU的高速缓存有副本并且状态是S或E,把高速缓存行的内容发送到总线上,那么本地CPU就获取了该高速缓存行的内容,然后状态变成S。
b)若其他CPU中有副本并且状态为M,将数据更新到内存,这个高速缓存再从内存中读数据,两个高速缓存行的状态都为S。
c)若其他CPU中没有缓存副本,则从内存中读数据,状态变成E
E/S
本地写 (1)发送BusRdX信号到总线上。
(2)其他CPU收到BusRdX信号,先检查自己的高速缓存中是否有缓存副本,广播应答信号。
a)若其他CPU上有这份数据的副本,且状态为M,则要先将数据更新到内存,更改高速缓存行状态为I,然后广播应答信号。
b)若其他CPU上有这份数据的副本,且状态为S或E,则使这些高速缓存行无效,这些高速缓存行的状态变成I,然后广播应答信号。
c)若其他CPU上也没有这份数据的副本,广播应答信号。
(3)CPU会接收其他CPU的应答信号,确认其他CPU上没有这个数据的缓存副本后,才修改数据,并且本地高速缓存行的状态变成M
M

读者需要注意的是,当操作类型为本地读写时,高速缓存行的状态指的是本地CPU的高速缓存行的状态。当操作类型为总线读写时,高速缓存行的状态指的是远端CPU上高速缓存行的状态。因为请求会被发送到总线上,所有CPU的高速缓存行都会接收到请求,监听到请求的高速缓存行会做相应的处理,并且设置相应的状态转换。

如图1.19所示,实线表示处理器请求响应,虚线表示总线监听响应。那如何解读这个图呢?如当本地CPU的缓存行状态为I时,若CPU发出读PrRd请求,本地缓存未命中,则在总线上产生一个BusRd信号。其他CPU会监听到该请求并且检查它们的缓存来判断是否拥有了该副本,下面分两种情况来考虑。

▲图1.19 MESI协议的状态转换

下面我们以一个例子来说明MESI协议的状态转换。假设系统中有4个CPU,每个CPU都有各自的一级缓存,它们都想访问相同地址的数据A,其大小为64字节。

T0时刻,假设初始状态下数据A还没有缓存到高速缓存中,4个CPU的高速缓存行的默认状态是I。

T1时刻,CPU0率先发起访问数据A的操作。对于CPU0来说,这是一次本地读。由于CPU0本地的高速缓存并没有缓存数据A,因此,CPU0首先发送一个BusRd信号到总线上。它想询问一下其他3个CPU:“小伙伴们,你们有缓存数据A吗?有的话,麻烦发一份给我。”其他3个CPU收到BusRd信号后,马上查询本地高速缓存,然后给CPU0回应一个应答信号。若CPU1在本地查询到缓存副本,则它把高速缓存行的内容发送到总线上并回应CPU0道:“CPU0,我这里缓存了一份副本,我发你一份。”若CPU1在本地没有缓存副本,则回应:“CPU0,我没有缓存数据A。”假设CPU1上有缓存副本,那么CPU1把缓存副本发送到总线上,CPU0的本地缓存就有了数据A,并且把这个高速缓存行的状态设置为S。同时,提供数据的缓存副本的CPU1也知道一个事实,数据的缓存副本已经分享给CPU0了,因此CPU1的高速缓存行的状态也设置为S。在本场景中,如果其他3个CPU都没有数据的缓存副本,那么CPU0只能老老实实地从主内存中读取数据A并将其缓存到CPU0的高速缓存行中,把高速缓存行的状态设置为E。

T2时刻,CPU1也发起读数据操作。这时,整个系统里只有CPU0中有缓存副本,CPU0会把缓存的数据发送到总线上并且应答CPU1,最后CPU0和CPU1都有缓存副本,状态都设置为S。

T3时刻,CPU2的程序想修改数据A中的数据。这时CPU2的本地高速缓存并没有缓存数据A,高速缓存行的状态为I,因此,这是一次本地写操作。首先CPU2会发送BusRdX信号到总线上,其他CPU收到BusRdX信号后,检查自己的高速缓存中是否有该数据。若CPU0和CPU1发现自己都缓存了数据A,那么会使这些高速缓存行无效,然后发送应答信号。虽然CPU3没有缓存数据A,但是它回复了一条应答信号,表明自己没有缓存数据A。CPU2收集完所有的应答信号之后,把CPU2本地的高速缓存行状态改成M,M状态表明这个高速缓存行已经被自己修改了,而且已经使其他CPU上相应的高速缓存行无效。

上述就是4个CPU访问数据A时对应的高速缓存状态转换过程。

MOESI协议增加了一个拥有(Owned,O)状态,并在MESI协议的基础上重新定义了S状态,而E、M和I状态与MESI协议中的对应状态相同。

高速缓存是以高速缓存行为单位来从内存中读取数据并且缓存数据的,通常一个高速缓存行的大小为64字节(以实际处理器的一级缓存为准)。C语言定义的数据类型中,int类型数据大小为4字节,long类型数据大小为8字节(在64位处理器中)。当访问long类型数组中某一个成员时,处理器会把相邻的数组成员都加载到一个高速缓存行里,这样可以加快数据的访问。但是,若多个处理器同时访问一个高速缓存行中不同的数据,反而带来了性能上的问题,这就是高速缓存伪共享(false sharing)。

如图1.20所示,假设CPU0上的线程0想访问和更新data数据结构中的x成员,同理CPU1上的线程1想访问和更新data数据结构中的y成员,其中xy成员都缓存到同一个高速缓存行里。

▲图1.20 高速缓存伪共享

根据MESI协议,我们可以分析出CPU0和CPU1之间对高速缓存行的争用情况。

(1)CPU0第一次访问x成员时,因为x成员还没有缓存到高速缓存,所以高速缓存行的状态为I。CPU0把整个data数据结构都缓存到CPU0的一级缓存里,并且把高速缓存行的状态设置为E。

(2)CPU1第一次访问y成员时,因为y成员已经缓存到高速缓存中,而且该高速缓存行的状态是E,所以CPU1先发送一个总线读的请求。CPU0收到请求后,先查询本地高速缓存中是否有这个数据的副本,若有,则把这个数据发送到总线上。CPU1获取了数据后,把本地的高速缓存行的状态设置为S,并且把CPU0上的本地高速缓存行的状态也设置为S,因此所有CPU上对应的高速缓存行状态都设置为S。

(3)CPU0想更新x成员的值时,CPU0和CPU1上的高速缓存行的状态为S。CPU0发送BusUpgr信号到总线上,然后修改本地高速缓存行的数据,将其状态变成M;其他CPU收到BusUpgr信号后,检查自己的高速缓存行中是否有副本,若有,则将其状态改成I。

(4)CPU1想更新y成员的值时,CPU1上的高速缓存行的状态为I,而CPU0上的高速缓存行缓存了旧数据,并且状态为M。这时,CPU1发起本地写的请求,根据MESI协议,CPU1会发送BusRdX信号到总线上。其他CPU收到BusRdX信号后,先检查自己的高速缓存中是否有该数据的副本,广播应答信号。这时CPU0上有该数据的缓存副本,并且状态为M。CPU0先将数据更新到内存,更改其高速缓存行状态为I,然后发送应答信号到总线上。CPU1收到所有CPU的应答信号后,才能修改CPU1上高速缓存行的内容。最后,CPU1上高速缓存行的状态变成M。

(5)若CPU0想更新x成员的值,这和步骤(4)类似,发送本地写请求后,根据MESI协议,CPU0会发送BusRdX信号到总线上。CPU1接收该信号后,把高速缓存行数据写回内存,然后使该高速缓存行无效,即把CPU1上的高速缓存行状态变成I,然后广播应答信号。CPU0收到所有CPU的应答信号后才能修改CPU0上的高速缓存行内容。最后,CPU0上的高速缓存行的状态变成M。

综上所述,如果CPU0和CPU1反复修改,就会不断地重复步骤(4)和步骤(5),两个CPU都在不断地争夺对高速缓存行的控制权,不断地使对方的高速缓存行无效,不断地把数据写回内存,导致系统性能下降,这种现象叫作高速缓存伪共享。

高速缓存伪共享的解决办法就是让多线程操作的数据处在不同的高速缓存行,通常可以采用高速缓存行填充(padding)技术或者高速缓存行对齐(align)技术,即让数据结构按照高速缓存行对齐,并且尽可能填充满一个高速缓存行大小。下面的代码定义一个counter_s数据结构,它的起始地址按照高速缓存行的大小对齐,数据结构的成员通过pad[4]来填充。

typedef struct counter_s
{
    uint64_t packets;
    uint64_t bytes;
    uint64_t failed_packets;
    uint64_t failed_bytes;
    uint64_t pad[4];
}counter_t __attribute__(__aligned__((64)));

高速缓存行的大小都很小,一般为32字节。CPU的高速缓存是线性排列的,也就是说,一个32字节的高速缓存行与32字节的地址对齐。接下来32字节的数据会缓存到下一组的高速缓存行中。

高速缓存在Linux内核中有很多巧妙的应用,读者可以在阅读本书中类似的情况时细细体会,暂时先总结归纳如下。

(1)内核中常用的数据结构通常是和一级缓存对齐的。如mm_struct、fs_cache等数据结构使用“SLAB_HWCACHE_ALIGN”标志位来创建slab缓存描述符,见proc_caches_init()函数。

(2)一些常用的数据结构在定义时就约定数据结构以一级缓存对齐,使用“____cacheline_internodealigned_in_smp”和“____cacheline_aligned_in_smp”等宏来定义数据结构,如zone、irqaction、softirq_vec[ ]、irq_stat[ ]、worker_pool等。高速缓存和内存交换的最小单位是高速缓存行,若结构体没有和高速缓存行对齐,那么一个结构体可能占用多个高速缓存行。

高速缓存伪共享的现象在SMP中会对系统性能有不小的影响。解决这个问题的一个方法是让结构体按照高速缓存行对齐。include/linux/cache.h文件定义了有关高速缓存相关的操作,其中____cacheline_aligned_in_smp也定义在这个文件中,它和L1_CACHE_BYTES对齐。

<include/linux/cache.h>

#define SMP_CACHE_BYTES L1_CACHE_BYTES

#define ____cacheline_aligned __attribute__ ((__aligned__ (SMP_CACHE_BYTES)))
#define ____cacheline_aligned_in_smp ____cacheline_aligned

#ifndef __cacheline_aligned
#define __cacheline_aligned   \
  __attribute__ ((__aligned__ (SMP_CACHE_BYTES),  \
          __section__ (".data..cacheline_aligned")))
#endif /* __cacheline_aligned */

#define __cacheline_aligned_in_smp __cacheline_aligned

#define ____cacheline_internodealigned_in_smp \
    __attribute__ ((__aligned__ (1 << (INTERNODE_CACHE_SHIFT))))

(3)数据结构中频繁访问的成员可以单独占用一个高速缓存行,或者相关的成员在高速缓存行中彼此错开,以提高访问效率。如对于zone数据结构中zone->lock和zone-> lru_lock这两个频繁被访问的锁,可以让它们各自使用不同的高速缓存行,以提高获取锁的效率。

再如worker_pool数据结构中的nr_running成员就独占了一个高速缓存行,避免多CPU同时读写该成员时引发其他临近成员“颠簸”的现象。

关于slab分配器的着色区,见4.2节。

另外,在多CPU系统中,自旋锁的激烈争用过程导致严重的高速缓存行颠簸现象,见卷2 1.4节。

ARM提出大/小核架构,即big.LITTLE架构。针对性能优化过的处理器内核称为大核,针对低功耗待机优化过的处理器内核称为小核。

如图1.21所示,在典型的big.LITTLE架构不仅包括CCI-400、GIC-400和IO一致性主接口,还包含一个由大核(Cortex-A57)组成的簇(Cluster)和一个由小核(Cortex-A53)组成的簇。每个簇都属于传统的同步频率架构,工作在相同的频率和电压下。大核为高性能核心,工作在较高的电压和频率下,功耗更高,适合计算繁重的任务。常见的大核处理器有Cortex-A15、Cortex-A57、Cortex-A72和Cortex-A73。小核的性能虽然较低,但功耗也比较低,在一些计算负载不大的任务中,不用开启大核,直接用小核即可。常见的小核处理器有Cortex-A7和Cortex-A53。

▲图1.21 典型的big.LITTLE架构

图1.22所示为4核Cortex-A15和4核Cortex-A7的系统总线框。

▲图1.22 4核Cortex-A15和4核Cortex-A7的系统总线框

ARM CoreLink CCI-400模块用于维护大/小核簇的数据互联和高速缓存一致性。大/小核簇作为主设备(master),通过支持ACE协议的从设备(slave)接口连接到CCI-400上,它可以维护大/小核簇中的高速缓存一致性并实现处理器间的数据共享。此外,它还支持3个ACE-Lite从设备接口(ACE-Lite Slave Interface),可以支持一些I/O主设备,如GPU Mali-T604。通过ACE协议,GPU可以监听处理器的高速缓存。CCI-400还支持3个ACE-Lite主设备接口,如通过DMC-400来连接LP-DDR2/3或DDR内存设备,以及通过NIC-400总线来连接一些外设,如DMA设备和LCD等。

ACE(AMBA AXI Coherency Extension)协议是AXI4协议的扩展协议,它增加了很多特性来支持系统级硬件一致性。模块之间共享内存不需要软件干预,硬件直接管理和维护各个高速缓存之间的一致性,这可以大大减小软件的负载,最大效率地使用高速缓存,减少对内存的访问,进而降低系统功耗。

高速缓存一致性关注的是同一个数据在多个高速缓存和内存中的一致性问题,解决高速缓存一致性的方法主要是总线监听协议,如MESI协议等。而一致性内存模型关注的是处理器系统对多个地址进行存储器访问序列时的正确性,学术上提出了很多内存访问模型,如严格一致性内存模型、处理器一致性内存模型,以及弱一致性内存模型等。弱一致性内存模型在现代处理器中得到了广泛应用,因此内存屏障指令也得到了广泛应用。

在处理器内核中,一条存储器读写指令经过取指、译码、发射和执行等一系列操作之后,首先到达LSU。LSU包括加载队列(load queue)和存储队列(store queue),它是指令流水线的一个执行部件,是处理器存储子系统的顶层,是连接指令流水线和高速缓存的一个支点。存储器读写指令通过LSU之后,会到达一级缓存控制器。一级缓存控制器首先发起探测(probe)操作,对于读操作发起高速缓存读探测操作并带回数据,对于写操作发起高速缓存写探测操作。发起写探测操作之前需要准备好待写的高速缓存行,探测操作返回时将会带回数据。当存储器写指令获得最终数据并进行提交操作之后才会将数据写入,这个写入可以采用直写(write through)模式或者回写(write back)模式。

在上述的探测过程中,对于写操作,如果没有找到相应的高速缓存行,就是写未命中(write miss);否则,就是写命中(write hit)。对于写未命中的处理策略是写分配(write-allocate),即一级缓存控制器将分配一个新的高速缓存行,之后和获取的数据进行合并,然后写入一级缓存中。

如果探测的过程是写命中的,那么在真正写入时有如下两种模式。

由于高速缓存的容量远小于主存储器,当高速缓存未命中发生时,意味着处理器不仅需要从主存储器中获取数据,而且需要将高速缓存的某个高速缓存行替换出去。在高速缓存的标记阵列中,除了具有地址信息之外,还有高速缓存行的状态信息。不同的高速缓存一致性策略使用的高速缓存状态信息并不相同。在MESI协议中,一个高速缓存行通常包括M、E、S和I这4种状态。

高速缓存的替换策略有随机法(Random policy)、先进先出(First in First out,FIFO)法和最近最少使用(Least Recently USED,LRU)法。

在Cortex-A57处理器中,一级缓存采用LRU算法,而L2高速缓存采用随机法。在最新的Cortex-A72处理器中,L2高速缓存采用伪随机法(Pseudo-Random Policy)或伪LRU法(Pseudo-Least-Recently-Used Policy)。

现在绝大多数ARM系统会采用统一内存访问(Uniform Memory Access,UMA)的内存架构,即内存是统一结构和统一寻址的。对称多处理器(Symmetric Multiple Processing,SMP)系统大部分采用UMA内存架构。因此在采用UMA架构的系统中有如下特点。

如图1.23所示,SMP系统相对比较简洁,但是缺点很明显。因为所有对等的处理器都通过一条总线连接在一起,随着处理器数量的增多,系统总线成为系统的最大瓶颈。

▲图1.23 SMP系统

非统一内存访问(Non-Unirform Memory Access,NUMA)系统是从SMP系统演化过来的。如图1.24所示,NUMA系统由多个内存节点组成,整个内存体系可以作为一个整体,任何处理器都可以访问,只是处理器访问本地内存节点时拥有更小的延迟和更大的带宽,处理器访问远端内存节点速度要慢一些。每个处理器除了拥有本地的内存之外,还可以拥有本地总线,如PCIE、SATA等。

▲图1.24 NUMA系统

现在的“x86阵营”的服务器芯片早已支持NUMA架构了,如Intel的至强服务器芯片。对于“ARM阵营”,2016年Cavium公司发布的基于ARMv8-A架构的服务器芯片ThunderX2也开始支持NUMA架构。

ARM从Cortex系列开始性能有了质的飞跃,如Cortex-A8/A15/A53/A72,本节介绍Cortex系列在芯片设计方面的重大改进。

计算机体系结构是一种权衡艺术的体现,“尺有所短,寸有所长”。在处理器领域经历多年的优胜劣汰,市面上流行的处理器内核在技术上日渐趋同。

ARM处理器在Cortex系列之后,加入了很多现代处理器的一些新技术和特性,已经具备了和Intel“一较高下”的能力,如2016年发布的Cortex-A73处理器。

2005年发布的Cortex-A8内核是第一个引入超标量技术的ARM处理器,它在每个时钟周期内可以并行发射两条指令,但依然使用静态调度的流水线和顺序执行方式。Cortex-A8内核采用13级整型指令流水线和10级NEON指令流水线,其分支目标缓冲器(Branch Target Buffer,BTB)使用的条目数增加到512,它同时设置了全局历史缓冲器(Global History Buffer,GHB)和返回栈缓冲器(Return Stack Buffer,RSB)等,这些措施极大地提高了指令分支预测的成功率。另外,它还加入了路预测(way-prediction)部件。

2007年Cortex-A9发布了,引入了乱序执行和猜测执行机制,并扩大了L2高速缓存的容量。

2010年Cortex-A15发布了,其最高主频可以到2.5GHz,它最多可支持8个处理器内核,单个簇最多支持4个处理器内核。它采用超标量流水线技术,具有1TB物理地址空间,支持虚拟化技术等新技术。其指令预取总线宽度为128位,它一次可以预取4~8条指令,比Cortex-A9提高了一倍。其译码部件一次可以译码3条指令。Cortex-A15引入了微操作指令。微操作指令和x86的微操作指令较类似。在x86处理器中,指令译码单元把复杂的CISC指令转换成等长的微操作指令,再进入指令流水线中;在Cortex-A15中,指令译码单元把RISC指令进一步细化为微操作指令,以充分利用指令流水线中的多个并发执行单元。指令译码单元为3路指令译码,在一个时钟周期内可以同时译码3条指令。

2012年64位的Cortex-A53和Cortex-A57发布了,ARM开始进入服务器领域。Cortex-A57是首款支持64位的ARM处理器内核,采用三发射乱序执行流水线(Out-of-Order Execution Pipeline),并且增加数据预取功能。

2015年发布的Cortex-A57的升级版本Cortex-A72如图1.25所示。A72在A57架构的基础上做了大量优化工作,包括新的分支预测单元,改善译码流水线设计等。在指令分发单元上也做了很大优化,由原来A57架构的3发射变成了5发射,即同时发射5条指令,并且还支持并行执行8条微操作指令,从而提高译码器的吞吐量。

▲图1.25 Cortex-A72处理器的架构[9]

最近几年,x86和ARM阵营都在各自领域中不断创新。异构计算是一个很热门的技术方向,如Intel公司发布了集成FPGA的至强服务器芯片。FPGA不仅可以在客户的关键算法中提供编程、高性能的加速能力,还提供了灵活性和关键算法的更新优化,不需要购买大量新硬件。在数据中心领域,从事海量数据处理的应用中有不少关键算法需要优化,如密钥加速、图像识别、语音转换、文本搜索等。在安防监控领域,FPGA可以实现对大量车牌的并行分析。强大的至强处理器加上灵活高效的FPGA会给客户在云计算、人工智能等新兴领域带来技术创新。而在ARM阵营中,ARM公司在2020年发布了Cortex-A78处理器内核和处理器架构DynamIQ等新技术。DynmaIQ技术新增了针对机器学习和人工智能的全新处理器指令集,并增加了多核配置的灵活性。另外,ARM公司发布了一个用于数据中心应用的指令集—— Scalable Vector
Extensions,最高支持2048 位可伸缩的矢量计算。

除了x86和ARM两大阵营的创新外,最近几年开源指令集架构(Instruction Set Architecture,ISA)是很火热的发展方向。开源指令集架构的代表作是OpenRISC,并且OpenRISC已经被Linux内核接受,成为官方Linux内核支持的一种架构。但是由于OpenRISC是由爱好者维护的,因此更新缓慢。最近几年,加州大学伯克利分校正在尝试重新设计一个全新的开源指令集,并且不受专利的约束和限制,这就是RISC-V,其中“V”表示变化(Variation)和向量(Vector)。RISC-V包含一个非常小的基础指令集和一系列可选的扩展指令集,基础的指令集只包含40条指令,通过扩展可以支持64位和128位运算,以及变长指令。

加州大学伯克利分校对RISC-V指令集不断改进,迅速得到工业界和学术界的关注。2016年,RISC-V基金会成立,成员包括惠普、甲骨文、西部数据、华为等公司,未来这些公司极可能会将RISC-V运用到云计算或者IoT等的相关产品中。RISC-V指令集类似于Linux内核,是一个开源的、现代的、没有专利问题的全新指令集,并且根据BSD许可证发布。

目前RISC-V已经进入了GCC/Binutils的主线,并且在Linux 4.15内核中合并到主线。另外,目前已经有多款开源和闭源的RISC-V CPU的实现,很多第三方工具和软件厂商开始支持RISC-V。RISC-V是否会变成开源硬件或是开源芯片领域的Linux呢?让我们拭目以待吧!

总之,计算机体系结构是计算机科学的一门基础课程,除了阅读ARM的芯片手册以外,还可以阅读一些经典的图书。

ARMv8-A是ARM公司发布的第一代支持64位处理器的指令集和架构。它在扩充64位寄存器的同时提供了对上一代架构指令集的兼容,因此它提供了运行32位和64位应用程序的环境。

ARMv8-A架构除了提高了处理能力,还引入了很多吸引人的新特性。

下面介绍市面上常见的采用ARMv8架构的处理器(简称ARMv8处理器)内核。

ARM处理器实现的是精简指令集架构。在ARMv8-A架构中有如下一些基本概念和定义。

<register_name>_Elx  //最后一个字母 x 可以表示0、1、2、3

如SP_EL0表示在EL0下的SP寄存器,SP_EL1表示在EL1下的SP寄存器。

ARMv8处理器支持两种执行状态——AArch64状态和AArch32状态。AArch64状态是ARMv8新增的64位执行状态,而AArch32是为了兼容ARMv7架构的32位执行状态。当处理器运行在AArch64状态下时运行A64指令集;而当运行在AArch32状态下时,可以运行A32指令集或者T32指令集。

如图1.26所示,AArch64状态的异常等级(exception level)确定了处理器当前运行的特权级别,类似于ARMv7架构中的特权等级。

▲图1.26 AArch64状态的异常等级

在ARMv8架构里允许切换应用程序的运行模式。如在一个运行64位操作系统的ARMv8处理器中,我们可以同时运行A64指令集的应用程序和A32指令集的应用程序。但是在一个运行32位操作系统的ARMv8处理器中就不能运行A64指令集的应用程序了。当需要运行A32指令集的应用程序时,需要通过一条管理员调用(Supervisor Call,SVC)指令切换到EL1,操作系统会做任务的切换并且返回AArch32的EL0中,这时操作系统就为这个应用程序准备好了AArch32的运行环境。

ARMv8支持如下几种数据宽度。

不对齐访问有两种情况,一种是指令不对齐访问,另外一种是数据不对齐访问。A64指令集要求指令存放的位置必须以字(word,32位宽)为单位对齐。访问一条存储位置不是以字为单位对齐的指令会导致PC对齐异常(PC alignment fault)。

对于数据访问,需要区分不同的内存类型。内存类型是设备内存的不对齐访问会触发一个对齐异常(alignment fault)。

对于访问普通内存,除了独占加载/独占存储(load-exclusive/store-exclusive)指令或者加载-获取/存储-释放(load-acquire/store-release)指令外,对于其他加载或者存储单个或多个寄存器的所有指令,如果访问地址和要访问数据不对齐,那么按照以下两种情况进行处理。

当然,处理器对不对齐访问也有一些限制。

AArch64运行状态支持31个64位的通用寄存器,分别是X0~X30寄存器,而AArch32状态支持16个32位的通用寄存器。

通用寄存器除了用于数据运算和存储之外,还可以在函数调用过程中起到特殊作用,ARM64架构的函数调用标准和规范对此有所约定,如图1.27所示。

▲图1.27 AArch64状态的31个通用寄存器

在AArch64状态下,使用X来表示64位通用寄存器,如X0、X30等。另外,还可以使用W来表示低32位的数据,如w0表示X0寄存器的低32位数据,w1表示X1寄存器的低32位数据,如图1.28所示。

▲图1.28 64位通用寄存器和低32位数据

在ARMv7架构中使用程序状态寄存器(Current Program Status Register,CPSR)来表示当前的处理器状态(processor state),而在AArch64里使用PSTATE寄存器来表示,如表1.5所示。

表1.5 PSTATE寄存器

分类 字段 描  述
条件标志位 N 负数标志位。
在结果是有符号的二进制补码的情况下,如果结果为负数,则N=1;如果结果为非负数,则N=0
Z 0标志位。
如果结果为0,则Z=1;如果结果为非0,则Z=0
C 进位标志位。
当发生无符号数溢出时,C=1。
其他情况下,C=0
V 有符号数溢出标志位。
● 对于加/减法指令,在操作数和结果是有符号的整数时,如果发生溢出,则V=1;如果未发生溢出,则V=0。
● 对于其他指令,V通常不发生变化
运行状态控制 SS 软件单步。该位为1,说明在异常处理中使能了软件单步功能
IL 不合法的异常状态
nRW 当前执行模式。
● 0:处于AArch64状态。
● 1:处于AArch32状态
EL 当前异常等级。
● 0:表示EL0。
● 1:表示EL1。
● 2:表示EL2。
● 3:表示EL3
SP 选择SP寄存器。当运行在EL0时,处理器选择EL0的SP寄存器,即SP_EL0;当处理器运行在其他异常等级时,处理器可以选择使用SP_EL0或者对应的SP_ELn寄存器
异常掩码标志位 D 调试位。使能该位可以在异常处理过程中打开调试断点和软件单步等功能
A 用来屏蔽系统错误(SError)
I 用来屏蔽IRQ
F 用来屏蔽FIQ
访问权限 PAN 特权不访问(Privileged Access Never)位是ARMv8.1的扩展特性。
● 1:在EL1或者EL2访问属于EL0的虚拟地址时会触发一个访问权限错误。
● 0:不支持该功能,需要软件来模拟
UAO 用户特权访问覆盖标志位,是ARMv8.2的扩展特性。
● 1:当运行在EL1或者EL2时,没有特权的加载存储指令可以和有特权的加载存储指令一样访问内存,如LDTR指令。
● 0:不支持该功能

ARMv8架构除了支持31个通用寄存器之外,还提供多个特殊的寄存器,如图1.29所示。

▲图1.29 特殊寄存器

1.零寄存器

ARMv8架构提供两个零寄存器(zero register),这些寄存器的内容全是0,可以用作源寄存器,也可以用作目标寄存器。WZR寄存器是32位的零寄存器,XZR是64位的零寄存器。

2.PC寄存器

PC寄存器通常用来指向当前运行指令的下一条指令的地址,用于控制程序中指令的运行顺序,但是编程人员不能通过指令来直接访问它。

3.SP寄存器

ARMv8架构支持4个异常等级,每一个异常等级都有一个专门的SP寄存器SP_ELn,如处理器运行在EL1时选择SP_EL1寄存器作为SP寄存器。

当处理器运行在比EL0高的异常等级时,处理器可以访问如下寄存器。

当处理器运行在EL0时,它只能访问SP_EL0,而不能访问其他高级的SP寄存器。

4.保存处理状态寄存器

当我们运行一个异常处理器时,处理器的处理状态会保存到保存处理状态寄存器(Saved Process Status Register,SPSR)里,这个寄存器非常类似于ARMv7架构中的CPSR。当异常将要发生时,处理器会把PSTATE寄存器的值暂时保存到SPSR里;当异常处理完成并返回时,再把SPSR的值恢复到PSTATE寄存器。SPSR的格式如图1.30所示,SPSR的重要字段如表1.6所示。

▲图1.30 SPSR的格式

表1.6 SPSR的重要字段

字段

描  述

N

负数标志位

Z

零标志位

C

进位标志位

V

有符号数溢出标志位

DIT

与数据无关的指令时序(Data Independent Timing),ARMv8.4的扩展特性

UAO

用户特权访问覆盖标志位,ARMv8.2的扩展特性

PAN

特权模式禁止访问(Privileged Access Never)位,ARMv8.1的扩展特性

SS

表示是否使能软件单步功能。若该位为1,说明在异常处理中使能了软件单步功能

IL

不合法的异常状态

D

调试位。使能该位可以在异常处理过程中打开调试断点和软件单步等功能

A

用来屏蔽系统错误

I

用来屏蔽IRQ

F

用来屏蔽FIQ

M[4]

用来表示异常处理过程中处于哪个执行状态,若为0,表示AArch64状态

M[3:0]

异常模式

5.ELR

ELR存放了异常返回地址。

6.CurrentEL寄存器[10]

该寄存器表示PSTATE寄存器中的EL字段,其中保存了当前异常等级。使用MRS指令可以读取当前异常等级。

7.DAIF寄存器

该寄存器表示PSTATE寄存器中的{D, A, I, F}字段。

8.SPSel寄存器

该寄存器表示PSTATE寄存器中的SP字段,用于在SP_EL0和SP_ELn中选择SP寄存器。

9.PAN寄存器

该寄存器表示PSTATE寄存器中的PAN(Privileged Access Never,特权禁止访问)字段。可以通过MSR和MRS指令来设置PAN寄存器。

10.UAO寄存器

该寄存器表示PSTATE寄存器中的UAO(User Access Override,用户访问覆盖)字段。可以通过MSR和MRS指令来设置UAO寄存器。

11.NZCV寄存器

该寄存器表示PSTATE寄存器中的{N,Z,C,V}字段。

除了上面介绍的通用寄存器和特殊寄存器之外,ARMv8架构还定义了很多的系统寄存器,通过访问和设置这些系统寄存器来完成对处理器不同的功能配置。在ARMv7架构里,我们需要通过访问CP15协处理器来间接访问这些系统寄存器,而在ARMv8架构中没有协处理器,可直接访问系统寄存器。ARMv8架构支持如下7类系统寄存器。

系统寄存器支持不同的异常等级的访问,通常系统寄存器会使用“Reg_ELn”的方式来表示。

程序可以通过MSR和MRS指令访问系统寄存器。

mrs X0, TTBR0_EL1    //把TTBR0_EL1的值复制到X0寄存器
msr TTBR0_EL1, X0    //把X0寄存器的值复制到TTBR0_EL1

指令集是处理器架构设计的重点之一。ARM公司定义和实现的指令集一直在变化和发展中。ARMv8架构最大的改变是增加了一个新的64位的指令集,这是早前ARM指令集的有益补充和增强。它可以处理64位宽的寄存器和数据并且使用64位的指针来访问内存。这个新的指令集称为A64指令集,运行在AArch64状态。ARMv8兼容旧的32位指令集——A32指令集,它运行在AArch32状态。

A64指令集和A32指令集是不兼容的,它们是两套完全不一样的指令集,它们的指令编码是不一样。需要注意的是,A64指令集的指令宽度是32位,而不是64位。

指令的格式如下。

另外,不同供应商的汇编工具(如ARM汇编器(armasm)、GNU编译器等)具有不同的语法。通常助记符和汇编指令是相同的,但汇编伪指令、定义、标号和注释语法则可能有差异。本章以GNU编译器为例,部分例子来自Linux内核的汇编代码片段。

常用的算术指令(包括加法指令、减法指令等)和常用的搬移指令包括数据搬移指令等,如表1.7所示。

表1.7 常用的算术和搬移指令

指令分类 指令 描  述
算术指令 ADD 加法指令。
● 使用寄存器的加法。
● 使用立即数的加法。指令的格式如下。
ADD Xd|SP, Xn|SP, #imm{, shift} ;
指令的执行结果如下。
Xd = Xn + shift(imm)
● 使用移位操作的加法。指令的格式如下。
ADD Xd, Xn, Xm{, shift #amount} ;
指令的执行结果如下。
Xd = Xn + shift(Xm, amount)
SUB 减法指令。
● 使用寄存器的减法。指令的格式如下。
SUB Xd|SP, Xn|SP, Rm{, extend {#amount}} ;
指令的执行结果如下。
Xd = Xn − LSL(extend(Rm), amount)
● 使用立即数的减法。指令的格式如下。
SUB Xd|SP, Xn|SP, #imm{, shift} ;
指令的执行结果如下。
Xd = Xn − shift(imm)
● 使用移位操作的减法。指令的格式如下。
SUB Xd, Xn, Xm{, shift #amount} ;
指令的执行结果如下。
Xd = Xn − shift(Xm, amount)
ADC 带进位的加法指令。指令的格式如下。
ADC Xd, Xn, Xm ;
指令的执行结果如下。
Xd = Xn + Xm + C,其中C为PSTATE寄存器的C标志位
SBC 带进位的减法。指令的格式如下。
SBC Xd, Xn, Xm ;
注意,指令的执行结果如下。
Xd = Xn − Xm – 1 + C,其中C为处理器状态寄存器的C标志位
NGC 负数减法。指令的格式如下。
NGC Xd, Xm ;
指令的执行结果如下。
Xd = 0 − Xm – 1 + C; 其中C为PSTATE寄存器的C标志位
搬移指令 MOV 数据搬移指令。
● 加载立即数。指令的格式如下。
MOV Xd, #imm ;
● 加载寄存器的值。指令的格式如下。
MOV Xd, Xm ;
MVN 加载一个数的NOT值(取到逻辑反的值)

常见的乘法和除法指令如表1.8所示。

表1.8 乘法和除法指令

指令分类 指令 描  述
乘法指令 MADD 超级乘加指令。指令的格式如下。
MADD Xd, Xn, Xm, Xa ;
指令的执行结果如下。
Xd = Xa + Xn * Xm
MNEG 先乘然后取负数。指令的格式如下。
MNEG Xd, Xn, Xm ;
指令的执行结果如下。
Xd = −(Xn * Xm)
MSUB 乘减运算。指令格式如下。
MSUB Xd, Xn, Xm, Xa ;
指令的执行结果如下。
Xd = Xa − Xn * Xm
MUL 乘法运算。指令的格式如下。
MUL Xd, Xn, Xm ;
指令的执行结果如下。
Xd = Xn * Xm
SMADDL 有符号的乘加运算。指令的格式如下。
SMADDL Xd, Wn, Wm, Xa;
指令的执行结果如下。
Xd = Xa + Wn * Wm
SMNEGL 有符号的乘负运算,先乘后取负数。指令的格式如下。
SMNEGL Xd, Wn, Wm;
指令的执行结果如下。
Xd = − (Wn * Wm)
SMSUBL 有符号的乘减运算。指令的格式如下。
SMSUBL Xd, Wn, Wm, Xa;
指令的执行结果如下。
Xd = Xa − Wn * Wm
SMULH 有符号的乘法运算,但是只取高64位。指令的格式如下。
SMULH Xd, Xn, Xm;
指令的执行结果如下。
Xd = Xn * Xm中的Bit[127:64]
SMULL 有符号的乘法运算。指令的格式如下。
SMULL Xd, Wn, Wm;
指令的执行结果如下。
Xd = Wn * Wm
UMADDL 无符号的乘加运算。指令的格式如下。
UMADDL Xd, Wn, Wm, Xa;
指令的执行结果如下。
Xd = Xa + Wn * Wm
UMNEGL 无符号的乘负运算。指令的格式如下。
UMNEGL Xd, Wn, Wm;
指令的执行结果如下。
Xd = − (Wn * Wm)
UMULH 无符号的乘法运算,但是只取高64位。指令的格式如下。
UMULH Xd, Xn, Xm;
指令的执行结果如下
Xd = Xn * Xm中的Bit[127:64]
乘法指令 UMULL 无符号的乘法运算。指令的格式如下。
UMULL Xd, Wn, Wm
指令的执行结果如下。
Xd = Wn * Wm
除法指令 SDIV 有符号的除法运算。指令的格式如下。
SDIV Xd, Xn, Xm ;
指令的执行结果如下。
Xd = Xn / Xm
UDIV 无符号的除法运算。指令的格式如下。
UDIV Xd, Xn, Xm ;
指令的执行结果如下。
Xd = Xn / Xm

常见的移位操作指令如表1.9所示。

表1.9 常见的移位操作指令

指  令

描  述

LSL

逻辑左移指令。指令的格式如下。
LSL Xd, Xn, Xm ;
指令的执行结果如下。
Xd = LSL(Xn, Xm)

LSR

逻辑右移指令。指令的格式如下。
LSR Xd, Xn, Xm ;
指令的执行结果如下。
Xd = LSR(Xn, Xm)

ASR

算术右移指令。指令的格式如下。
ASR Xd, Xn, Xm ;
指令的执行结果如下。
Xd = ASR(Xn, Xm)

ROR

循环右移指令。指令的格式如下。
ROR Xd, Xs, #shift ;
指令的执行结果如下。
Xd = ROR(Xs, shift)

常见的位操作指令如表1.10所示。

表1.10 位操作指令

指  令

描  述

BFI

位段插入指令。指令的格式如下。
BFI Xd, Xn, #lsb, #width ;
指令的执行结果如下。
用Xn中的Bit[0: width − 1] 替换 Xd中从lsb开始的width位,Xd的其他位不变

BFC

位段清零指令。指令的格式如下。
BFC Xd, #lsb, #width ;
指令的执行结果如下。
从lsb开始清零Xd中的width位

BIC

位清零指令。指令的格式如下。
BIC Xd, Xn, Xm{, shift #amount} ;
指令的执行结果如下。
Xd = Xn &~ Xm

SBFX

有符号的位段提取指令。指令的格式如下。
SBFX Xd, Xn, #lsb, #width ;
指令的执行结果如下。
从Xn寄存器提取位段,位段从lsb开始,位宽为width,结果被写入Xd寄存器的最低位中

UBFX

无符号的位段提取指令。
UBFX Xd, Xn, #lsb, #width ;
指令的执行结果如下。
从Xn寄存器提取位段,位段从第lsb位开始,位宽为width,然后把结果写入Xd寄存器的最低位中

AND

按位与操作。指令的格式如下。
AND Xd, Xn
指令的执行结果如下。
Xd = Xd & Xn

ORR

按位或操作。指令的格式如下。
ORR Xd, Xn
指令的执行结果如下。
Xd = Xd | Xn

EOR

按位异或操作。指令的格式如下。
EOR Xd, Xn
指令的执行结果如下。
Xd = Xd ^ Xn

CLZ

前导零计数指令
用来计算最高位的1前面有几个0。
指令的格式如下。
CLZ Xd, Xn
指令的执行结果如下。
Xd = CLZ(Xn)

相关例子如下。

BFI X0,X1,#8,#4   //把X1寄存器的Bit[3:0]位段插入X0寄存器的第8位中
BFC X0,#8,#4 //从X0寄存器中的第8位开始清零,宽度为4
BIC W0, W0 , #0xF0000000 //将W0寄存器的高4位清零
BIC W1, W1, #0x0F  //将W1寄存器的低4位清零
UBFX X8, X4, #8, #4  //该指令等同于X8 = (X4 & 0xF00)>>8

BFC指令用来清除寄存器中任意相邻的位。

LDR, X0, =0x1234FFFF
BFC, X0, #4, #8

上述指令表示从第4位开始清除X0中的8位,因此上述指令的执行结果为X0 = 0x1234 F00F。

UBFX与SBFX分别为无符号和有符号位段提取指令。二者是有区别的。

UBFX从寄存器(Xn)中任意位置(由lsb指定)开始提取任意宽度(由width指定)的位段,将高位填充零后的值放入目的寄存器(Xd)。

LDR, X0, =0x5678ABCD
UBFX, X1, X0, #4, #8

上述指令的执行结果为X1 = 0x00000000 000000BC。

和UBFX类似,SBFX提取出位段后对目的寄存器进行有符号的展开。

LDR, X0, =0x5678ABCD
SBFX, X1, X0, #4, #8

上述指令的执行结果为X1 = 0xFFFFFFFF FFFFFFBC。

A64指令集沿用了A32指令集中的条件操作,在PSTATE寄存器中条件标志域描述了4种条件标志位,即N、Z、C、V,如表1.11所示。

表1.11 条件标志位

条件标志位

描  述

N

负数标志(上一次运算结果为负值)

Z

零结果标志(上一次运算结果为零)

C

进位标志(上一次的运算结果发生了无符号溢出)

V

溢出标志(上一次的运算结果发生了有符号溢出)

常见的条件操作后缀如表1.12所示。

表1.12 常见的条件操作后缀

后缀

含义(整数运算)

标  志

条件码

EQ

相等

Z=1

0b0000

NE

不相等

Z=0

0b0001

CS/HS

发生了无符号溢出,即C=1

C=1

0b0010

CC/LO

没有发生无符号溢出,即C=0

C=0

0b0011

MI

负数

N=1

0b0100

PL

正数或零

N=0

0b0101

VS

溢出

V=1

0b0110

VC

未溢出

V=0

0b0111

HI

无符号数大于

(C=1) && (Z=0)

0b1000

LS

无符号数小于或等于

(C=0) || (Z=1)

0b1001

GE

有符号数大于或等于

N == V

0b1010

LT

有符号数小于

N!=V

0b1011

GT

有符号数大于

(Z==0) && (N==V)

0b1100

LE

有符号数小于或等于

(Z==1) || (n!=V)

0b1101

AL

无条件执行

0b1110

NV

无条件执行

0b1111

大部分的ARM数据处理指令可以根据执行结果来选择是否更新条件标志位。常见的条件指令如表1.13所示。

表1.13 条件指令

指  令

说  明

CSEL

条件选择指令。指令的格式如下。
CSEL Xd, Xn, Xm, cond ;
指令的执行结果如下。
如果cond为真,返回Xn;否则,返回Xm

CSET

条件置位指令。指令的格式如下。
CSET Xd, cond ;
指令的执行结果如下。
如果cond为真,返回1;否则,返回0

CSINC

条件选择并增加指令。指令的格式如下。
CSINC Xd, Xn, Xm, cond ;
指令的执行结果如下。
如果cond为真,返回Xn;否则,返回Xm+1

下面是一段C语言代码。

if (i == 0)
    r = r +2;
else
    r = r – 1;

可以使用如下汇编代码来表示上述代码。

CMP w0, #0 // if (i == 0)
SUB w2, w1, #1 // r = r - 1
ADD w1, w1, #2 // r = r + 2
CSEL w1, w1, w2, EQ //根据执行结果来选择

和早期的ARM架构一样,ARMv8架构也是基于指令加载和存储的架构。在这种架构下,所有的数据处理都需要在寄存器中完成,而不能直接在内存中完成。因此,首先把待处理数据从内存加载到通用寄存器,然后进行数据处理,最后把结果写入内存中。

常见的内存加载指令是LDR指令,存储指令是STR指令。

LDR 目标寄存器, <存储器地址>  //把存储器地址中的数据加载到目标寄存器中
STR 源寄存器, <存储器地址>    //把源寄存器的数据存储到存储器中

LDR和STR指令根据不同的数据位宽有多种变种,如表1.14所示。

表1.14 加载和存储指令

指  令

说  明

LDR

数据加载指令

LDRSW

有符号的数据加载指令,单位为字

LDRB

数据加载指令,单位为字节

LDRSB

有符号的加载指令,单位为字节

LDRH

数据加载指令,单位为半字

LDRSH

有符号的数据加载指令,单位为半字

STRB

数据存储指令,单位为字节

STRH

数据存储指令,单位为半字

LDR和STR指令有如下几个常用模式。

1.地址偏移量模式

地址偏移量模式常常使用寄存器的值来表示一个地址,或者基于寄存器的值得出偏移量,从而计算内存地址,并且把这个内存地址的值加载到通用寄存器中。偏移量可以是正数,也可以是负数。常见的指令格式如下。

LDR Xd, [Xn, $offset]

首先在Xn寄存器的内容中加一个偏移量并将结果作为内存地址,加载此内存地址的内容到Xd寄存器。

示例如下。

LDR X0, [X1]  //内存地址为X1寄存器的值,加载此内存地址的值到X0寄存器
LDR X0, [X1, #8] //内存地址为X1寄存器的值+8,加载此内存地址的值到X0寄存器

LDR X0, [X1, X2] //内存地址为X1寄存器的值+X2寄存器的值,加载此内存地址的值到X0寄存器

LDR X0,[X1, X2, LSL #3] //内存地址为X1寄存器的值+(X2寄存器的值<<3), 加载此内存地址的值到X0
//寄存器

LDR X0, [X1, W2, SXTW] //先对W2的值做有符号的扩展,和X1寄存器的值相加后,将结果作为内存地址,加载//此内存地址的值到X0寄存器

LDR X0, [X1, W2, SXTW #3] //先对W2的值做有符号的扩展,然后左移3位,和X1寄存器的值相加后,将结果//作为内存地址,加载此内存地址的值到X0寄存器

2.变基模式

变基模式主要有如下两种。

示例如下。

LDR X0,  [X1, #8]! //前变基模式。先更新X1寄存器的值为X1寄存器的值+8,然后以新的X1寄存器的值为
//内存地址,加载该内存地址的值到X0寄存器

LDR X0, [X1], #8  //后变基模式。以X1寄存器的值为内存地址,加载该内存地址的值到X0寄存器,然后更新
//X1寄存器的值为X1寄存器的值+8

STP X0, X1, [SP, #-16]!  //把X0和X1寄存器的值压回栈中

LDP X0, X1, [SP], #16  //把X0和X1寄存器的值弹出栈

3.PC相对地址模式

汇编代码里常常会使用标签(label)来标记代码逻辑片段。我们可以使用PC相对地址模式来访问这些标签。在ARM架构中,我们不能直接访问PC地址,但是通过PC相对地址模式来访问一个和PC相关的地址。

示例如下。

LDR X0,=<label> //从label标记的地址处加载8字节到X0寄存器

利用这个特性可以实现地址重定位,如在Linux内核的文件head.S中,启动MMU之后,使用该特性来实现从运行地址定位到链接地址。

<arch/arm64/kernel/head.S>

1    __primary_switch:
2        adrp  x1, init_pg_dir
3        bl  __enable_mmu
4        
5        ldr  x8, =__primary_switched
6        adrp  x0, __PHYS_OFFSET
7        br  x8
8    ENDPROC(__primary_switch)

第3行的__enable_mmu函数打开MMU,第5行和第7行用于跳转到__primary_switched函数,其中,__primary_switched函数的地址是链接地址,即内核空间的虚拟地址;而在启动MMU之前,处理器运行在实际的物理地址(即运行地址)上。上述指令实现了地址重定位功能,详细介绍可以参考卷2中3.1.5节。

读者容易对下面3条指令产生困扰。

LDR X0, [X1, #8] //内存地址为X1寄存器的值+8,加载此内存地址的值到X0寄存器
LDR X0,  [X1, #8]! //前变基模式。先更新X1寄存器的值为X1寄存器的值+8,然后以新的值为内存地址,加载#该内存地址的值到X0寄存器
LDR X0, [X1], #8  //后变基模式。以X1寄存器的值为内存地址,加载该内存地址的值到X0寄存器,然后更新#X1寄存器的值为X1寄存器的值+8

方括号([ ])表示从该内存地址中读取或者存储数据,而指令中的感叹号(!)表示是否更新存放内存地址的寄存器,即写回和更新寄存器。

在A32指令集中提供LDM和STM指令来实现多字节内存加载与存储,到了A64指令集,不再提供LDM和STM指令,而是提供LDP和STP指令。

示例如下。

LDP X3, X7, [X0]  //以X0寄存器的值为内存地址,加载此内存地址的值到X3寄存器;然后以X0寄存器的值+8  #为内存地址,加载此内存地址的值到X7寄存器

LDP X1, X2, [X0, #0x10]!  //前变基模式。先计算X0 = X0 + 0x10,然后以X0寄存器的值为内存地址,  
#加载此内存地址的值到X1;然后以X0寄存器的值+8为内存地址,加载此内存地址的值到X2

STP X1, X2, [X4] //存储X1寄存器的值到地址为X4寄存器的值的内存中,然后存储X2寄存器的值到地址为X4  
#寄存器的值+8的内存中

ARMv8架构中实现了一组非特权访问级别的加载和存储指令,它适用于在EL0进行的访问,如表1.15所示。

表1.15 非特权访问级别的加载和存储指令

指  令

描  述

LDTR

非特权加载指令

LDTRB

非特权加载指令,加载1字节

LDTRSB

非特权加载指令,加载有符号的1字节

LDTRH

非特权加载指令,加载2字节

LDTRSH

非特权加载指令,加载有符号的2字节

LDTRSW

非特权加载指令,加载有符号的4字节

STTR

非特权存储指令,存储8字节

STTRB

非特权存储指令,存储1字节

STTRH

非特权存储指令,存储2字节

当PSTATE寄存器中的UAO字段为1时,在EL1和EL2执行这些非特权指令的效果和特权指令是一样的,这个特性是在ARMv8.2的扩展特性中加入的。

ARMv8架构实现了一个弱一致性内存模型,内存访问的次序可能和程序预期的次序不一样。A64和A32指令集中提供了内存屏障指令,如表1.16所示。

表1.16 内存屏障指令

指  令

描  述

DMB

数据存储屏障(Data Memory Barrier,DMB)确保在执行新的存储器访问前所有的存储器访问都已经完成

DSB

数据同步屏障(Data Synchronization Barrier,DSB)确保在下一个指令执行前所有存储器访问都已经完成

ISB

指令同步屏障(Instruction Synchronization Barrier,ISB)清空流水线,确保在执行新的指令前,之前所有的指令都已完成

除此之外,ARMv8架构还提供一组新的加载和存储指令,显式包含了内存屏障功能,如表1.17所示,详细介绍见2.5.2节。

表1.17 新的加载和存储指令

指  令

描  述

LDAR

加载-获取(load-acquire)指令。
LDAR指令后面的读写内存指令必须在LDAR指令之后才能执行

STLR

存储-释放(store-release)指令。
所有的加载和存储指令必须在STLR指令之前完成

ARMv7和ARMv8架构都提供独占内存访问(exclusive memory access)的指令。在A64指令集中,LDXR指令尝试在内存总线中申请一个独占访问的锁,然后访问一个内存地址。STXR指令会往刚才LDXR指令已经申请独占访问的内存地址里写入新内容。LDXR和STXR指令通常组合使用来完成一些同步操作,如Linux内核的自旋锁。

另外,ARMv7和ARMv8还提供多字节独占访问的指令,即LDXP和STXP指令,如表1.18所示。

表1.18 独占内存访问指令

指  令

描  述

LDXR

独占内存访问指令。指令的格式如下。
LDXR Xt, [Xn|SP{,#0}] ;

STXR

独占内存访问指令。指令的格式如下。
STXR Ws, Xt, [Xn|SP{,#0}] ;

LDXP

多字节独占内存访问指令。指令的格式如下。
LDXP Xt1, Xt2, [Xn|SP{,#0}] ;

STXP

多字节独占内存访问指令。指令的格式如下。
STXP Ws, Xt1, Xt2, [Xn|SP{,#0}] ;

编写汇编代码常常会使用跳转指令,A64指令集提供了多种不同功能的跳转指令,如表1.19所示。

表1.19 跳转指令

指  令

描  述

B

跳转指令。指令的格式如下。
B label
该跳转指令可以在当前PC偏移量±128MB的范围内无条件地跳转到lable处

B.cond

有条件的跳转指令。指令的格式如下。
B.cond label
如B.EQ,该跳转指令可以在当前PC偏移量±1MB的范围内有条件地跳转到label处

BL

带返回地址的跳转指令。指令的格式如下。
BL label
和B指令类似,不同的地方是BL指令将返回地址设置到X30寄存器中

BR

跳转到寄存器指定的地址。指令的格式如下。
BR Xn

BLR

跳转到寄存器指定的地址。指令的格式如下。
BLR Xn
和BR指令类似,不同的地方是BLR指令将返回地址设置到X30寄存器中

RET

从子函数返回。指令的格式如下。
RET {Xn}
从子函数返回Xn寄存器指定的地址。若没有指定Xn,那么默认会跳转到X30寄存器指定的地址

CBZ

比较并跳转指令。指令的格式如下。
CBZ Xt, label ;
判断Xt寄存器是否为0,若为0,则跳转到label处,跳转范围是当前PC相对偏移量±1MB

CBNZ

比较并跳转指令。指令的格式如下。
CBNZ Xt, label ;
判断Xt寄存器是否不为0,若不为0,则跳转到label处,跳转范围是当前PC相对偏移量±1MB

TBZ

测试位并跳转指令。指令的格式如下。
TBZ R<t>, #imm, label
判断Rt寄存器中第imm位是否为0,若为0,则跳转到label处,跳转范围是当前PC相对偏移量±32KB

TBNZ

测试位并跳转指令。指令的格式如下。
TBNZ R<t>, #imm, label
判断Rt寄存器中第imm位是否不为0,若不为0,则跳转到label处,跳转范围是当前PC相对偏移量±32KB

CMP

比较指令(比较两个数并且更新标志位)。指令的格式如下。
CMP Xn|SP, #imm{, shift} ;
该指令主要做减法运算Xn − #imm,执行结果是用于影响处理器状态寄存器的标志位

CMN

负向比较,把一个数与另外一个数的二进制补码相比较

TST

测试,执行按位与操作,并根据结果更新处理器状态寄存器的z位

在Linux内核代码中的ret_from_fork函数里多次使用跳转指令。

< arch/arm64/kernel/entry.S >

ENTRY(ret_from_fork)
   bl      schedule_tail            //跳转到schedule_tail函数
   cbz     x19, 1f                  //判断x19是否为0,若为0,说明当前线程不是一个内核线程
   mov     x0, x20
   blr     x19                      //若是内核线程,则跳转到x19指定的地址
1:      get_thread_info tsk
   b       ret_to_user              //跳转到ret_to_user函数
ENDPROC(ret_from_fork)

另外,cmp指令常常用来比较两个数的大小,它内部使用SUBS指令来完成,最终结果会影响PSTATE寄存器中的C标志位。例如,下面这条CMP指令中,通过x1+NOT(x2)+1来进行计算,其中NOT(x2)表示对x2取反。如果计算结果发生了无符号数溢出,那么C标志位置1,否则C标志位为0。

cmp x1, x2

在汇编代码中,cmp指令常常和跳转指令搭配使用。另外,cmp指令也可以和带进位的加法指令(adc指令)或者带进位的减法指令(sbc指令)一起使用,此时需要考虑C标志位。下面给出一段示例代码。

.global compare_and_return
compare_and_return:
    cmp x0, x1
    sbc x0, xzr, xzr
    ret

compare_and_return汇编函数通过X0和X1寄存器传递两个参数,最终结果会通过X0寄存器来返回。上述代码示例可以分成如下两种情况来考虑。

A64指令集支持多个异常处理指令,如表1.20所示。

表1.20 异常处理指令

指  令

描  述

SVC

系统调用指令。指令的格式如下。
SVC #imm
允许应用程序通过SVC指令自陷到操作系统中,通常会进入EL1

HVC

虚拟化系统调用指令。指令的格式如下。
HVC #imm
允许主机操作系统通过HVC指令自陷到虚拟机管理程序(hypervisor)中,通常会进入EL2

SMC

安全监控系统调用指令。指令的格式如下。
SMC #imm
允许主机操作系统或者虚拟机管理程序通过SMC指令自陷到安全监管程序(secure monitor)中,通常会进入EL3

在ARMv7架构中,通过访问CP15协处理器来访问系统寄存器,而在ARMv8架构中访问方式进行了大幅改进和优化。通过MRS和MSR两条指令可以直接访问系统寄存器,如表1.21所示。

表1.21 系统寄存器访问指令

指  令

描  述

MRS

读取系统寄存器的值到通用寄存器

MSR

更新系统寄存器的值

要访问系统特殊寄存器,指令如下。

MRS X4, ELR_EL1   //读取ELR_EL1寄存器的值到X4寄存器
MSR SPSR_EL1, X0  //把X0寄存器的值更新到SPSR_EL1寄存器

ARMv8架构支持7类系统寄存器,下面以系统控制寄存器(System Control Register,SCTLR)为例。要访问系统寄存器,指令如下。

mrs  x20, sctlr_el1   //读取SCTLR_EL1
msr  sctlr_el1, x20   //设置SCTLR_EL1

[11]

SCTLR_EL1可以用来设置很多系统属性,如系统大/小端等。我们可以使用MRS和MSR指令来访问系统寄存器。

除了访问系统寄存器之外,还能通过MSR和MRS指令来访问与PSTATE寄存器相关的字段,这些字段可以看作特殊用途的系统寄存器[12],如表1.22所示。

表1.22 特殊用途的系统寄存器

寄 存 器

说  明

CurrentEL

获取当前系统的异常等级

DAIF

获取和设置PSTATE寄存器中的DAIF掩码

NZCV

获取和设置PSTATE寄存器中的条件掩码

PAN

获取和设置PSTATE寄存器中的PAN字段

SPSel

获取和设置当前的SP寄存器

UAO

获取和设置PSTATE寄存器中的UAO字段

在Linux内核代码中使用如下指令来关闭本地处理器的中断。

<arch/arm64/include/asm/assembler.h>

.macro disable_daif
    msr     daifset, #0xf
.endm

.macro enable_daif
     msr     daifclr, #0xf
.endm

disable_daif宏用来关闭本地处理器中PSTATE寄存器中的DAIF功能,也就是关闭处理器调试、系统错误、IRQ以及FIQ。而enable_daif宏用来打开上述功能。

下面是一个设置SP寄存器和获取当前异常等级的例子,代码实现在arch/arm64/kernel/ head.S汇编文件中。

<arch/arm64/kernel/head.S>

ENTRY(el2_setup)
    Msr SPsel, #1            //设置SP寄存器,使用SP_EL1
    mrs x0, CurrentEL        //获取当前异常等级
    cmp x0, #CurrentEL_EL2
    b.eq    1f

在Linux内核代码中常常会使用到GCC内联汇编,GCC内联汇编的格式如下。

__asm__ __volatile__(指令部: 输出部: 输入部: 损坏部)

GCC内联汇编在处理变量和寄存器的问题上提供了一个模板和一些约束条件。

下面先看一个简单的例子,即arch_local_irq_save()函数的实现。

<arch/arm64/include/asm/irqflags.h>

static inline unsigned long arch_local_irq_save(void)
{
    unsigned long flags;
    asm volatile(
        "mrs    %0, daif         //读取PSTAT寄存器中的DAIF域到flags变量
        "msr    daifset, #2"     //关闭IRQ
        : "=r" (flags)
        :
        : "memory");
    return flags;
}

先看输出部,%0操作数对应"=r" (flags),即flags变量,其中“=”表示被修饰的操作数的属性是只写,“r”表示使用一个通用寄存器。

接着看输入部,在上述例子中,输入部为空,没有指定参数。

最后看损坏部,以“memory”结束。

该函数主要用于把PSTATE寄存器中的DAIF域保存到临时变量flags中,然后关闭IRQ。

在输出部和输入部使用%来表示参数的序号,如%0表示第1个参数,%1表示第2个参数。为了增强代码可读性,可以使用汇编符号名字来替代以%表示的操作数,如下面的add()函数。

int add(int i, int j)
{
    int res = 0;

    asm volatile (
    "add %w[result], %w[input_i], %w[input_j]"
    : [result] "=r" (res)
    : [input_i] "r" (i), [input_j] "r" (j)
    );

    return res;
}

上述是一个很简单的GCC内联汇编的例子,主要功能是把参数i的值和参数j的值相加,最后返回结果。

先看输出部,其中只定义了一个操作数。“[result]”表示定义了一个汇编符号操作数,符号名字为result,它对应"=r" (res),使用了函数中定义的res变量。在汇编代码中对应%w[result],其中w表示ARM64中的32位通用寄存器。

再看输入部,其中定义了两个操作数。同样使用汇编符号操作数的方式来定义。第一个汇编符号操作数是input_i,对应的是函数形参i;第二个汇编符号操作数是input_ j,对应的是函数形参j

GCC内联汇编操作符和修饰符如表1.23所示。

表1.23 GCC内联汇编操作符和修饰符

操作符/修饰符

说  明

=

被修饰的操作数只写

+

被修饰的操作数具有可读、可写属性

&

被修饰的操作数只能作为输出

ARM64架构中特有的操作符和修饰符如表1.24所示。

表1.24 ARM64架构中特有的操作符和修饰符[13]

操作符/修饰符

说  明

k

SP寄存器

w

浮点寄存器、SIMD、SVE寄存器

Upl

使用P0到P7中任意一个SVE寄存器

Upa

使用P0到P15中任意一个SVE寄存器

I

整数,常常用于ADD指令

J

整数,常常用于SUB指令

K

整数,常常用于32位逻辑指令

L

整数,常常用于64位逻辑指令

M

整数,常常用于32位的MOV指令

N

整数,常常用于64位的MOV指令

S

绝对符号地址或者标签引用

Y

浮点数,其值为0

Z

整数,其值为0

Ush

表示一个符号(symbol)的PC相对偏移量的高位部分(包括第12位以及高于第12位的部分),这个PC相对偏移量介于0~4GB

Q

表示没有使用偏移量的单一寄存器的内存地址

Ump

一个适用于SI、DI、SF和DF模式下的加载-存储指令的内存地址

函数调用标准(Procedure Call Standard,PCS)用来描述父/子函数是如何编译、链接的,特别是父函数和子函数之间调用关系的约定,如栈的布局、参数的传递等。每个处理器架构都有不同的函数调用标准,本章重点介绍ARM64的函数调用标准。

ARM公司有一份描述ARM64架构函数调用的标准和规范文档,这份文档是《Procedure Call Standard for ARM 64-Bit Architecture》。

ARM64架构的通用寄存器如表1.25所示。

表1.25 ARM64架构的通用寄存器

寄 存 器

描  述

SP寄存器

SP寄存器

X30(LR)

链接寄存器

X29(FP寄存器)

栈帧指针(Frame Pointer)寄存器

X19~X28

被调用函数保存的寄存器。在子函数中使用时需要保存到栈中

X18

平台寄存器

X17

临时寄存器或者第二个IPC(Intra-Procedure-Call)临时寄存器

X16

临时寄存器或者第一个IPC临时寄存器

X9~X15

临时寄存器

X8

间接结果位置寄存器,用于保存子程序的返回地址

X0~X7

用于传递子程序参数和结果,若参数个数大于8,就采用栈来传递。64位的返回结果采用X0寄存器,128位的返回结果采用X0和X1两个寄存器

在ARM64架构中,栈从高地址往低地址生长。栈的起始地址称为栈底。栈从高地址往低地址延伸到某个地址,这个地址称为栈顶。栈在函数调用过程中起到非常重要的作用,包括存储函数使用的局部变量、传递参数等。在函数调用过程中,栈是逐步生成的。为单个函数分配的栈空间,即从该函数栈底(高地址)到栈顶(低地址)这段空间称为栈帧(stack frame)。例如,如果父函数main()调用子函数func1(),那么在准备执行子函数func1()时,栈指针会向低地址延伸一段(从父函数的栈框的最低地址往下延伸),为func1()创建一个栈帧。func1()使用的一些局部变量会存储在这个栈帧里。当从func1()返回时,栈指针会调整回父函数的栈顶,于是func1()的栈空间就被释放了。

假设函数调用关系是main()→func1()→func2(),图1.31所示为栈的布局。

▲图1.31 栈的布局

ARM64架构的函数栈布局的关键点如下。

在ARM64架构里,中断属于异常的一种。中断是外部设备通知处理器的一种方式,它会打断处理器正在执行的指令流。

本节介绍异常的类型。

1.中断

在ARM处理器中,中断请求分成中断请求(Interrupt Request,IRQ)和快速中断请求(Fast Interrupt Request,FIQ)两种,其中FIQ的优先级要高于IRQ。在芯片内部,分别有连接到处理器内部的IRQ和FIQ两根中断线。通常系统级芯片内部会有一个中断控制器,众多的外部设备的中断引脚会连接到中断控制器,由中断控制器来负责中断优先级调度,然后发送中断信号给ARM处理器,中断模型如图1.32所示。

▲图1.32 中断模型

外设中发生了重要的事情之后,需要通知处理器,中断发生的时刻和当前正在执行的指令无关,因此中断的发生时间点是异步的。对于处理器来说,这常常是猝不及防的,但是又不得不停止当前执行的代码来处理中断。在ARMv8架构中,中断属于异步模式的异常。

2.中止[14]

中止主要有指令中止(instruction abort)和数据中止(data abort)两种,它们通常是指访问外部存储单元时候发生了错误,处理器内部的MMU捕获这些错误并且报告给处理器。

指令中止是指当处理器尝试执行某条指令时发生的错误。而数据中止是指使用加载或者存储指令读写外部存储单元时发生的错误。

3.复位

复位(reset)操作是优先级最高的一种异常处理。复位操作通常用于让CPU复位引脚产生复位信号,让CPU进入复位状态,并重新启动。

4.软件产生的异常

ARMv8架构中提供了3种软件产生的异常。这些异常通常是指软件想尝试进入更高的异常等级而造成的错误。

在ARMv8架构里把异常分成同步异常和异步异常两种。同步异常是指处理器需要等待异常处理的结果,然后继续执行后面的指令,如数据中止时我们知道发生数据异常的地址,并且在异常处理函数中修复这个地址。

常见的同步异常如下。

而中断发生时,处理器正在处理的指令和中断是完全没有关系的,它们之间没有依赖关系。因此,指令异常和数据异常称为同步异常,而中断称为异步异常。

常见的异步异常包括物理中断和虚拟中断。

物理中断分为3种,分别是系统错误、IRQ、FIQ。

虚拟中断分为3种,分别是vSError、vIRQ、vFIQ。

当一个异常发生时,CPU内核能感知异常发生,而且会对应生成一个目标异常等级(target exception level)。CPU会自动做如下一些事情[15]

上述是ARMv8处理器检测到异常发生后自动做的事情。操作系统需要做的事情是从中断向量表开始,根据异常发生的类型,跳转到合适的异常向量表。异常向量表的每个项会保存一个异常处理的跳转函数,然后跳转到恰当的异常处理函数并处理异常。

当操作系统的异常处理完成后,执行一条eret指令即可从异常返回。这条指令会自动完成如下工作。

读者常常有这样的疑问,中断处理过程是关闭中断进行的,那中断处理完成后什么时候把中断打开呢?

当中断发生时,CPU会把PSTATE寄存器的值保存到对应目标异常等级的SPSR_ELx寄存器中,并且把PSTATE寄存器里的DAIF域都设置为1,这相当于把本地CPU的中断关闭了。

当中断处理完成后,操作系统调用eret指令返回中断现场,那么会把SPSR_ELx寄存器恢复到PSTATE寄存器中,这就相当于把中断打开了。

[1] Trustzone技术在ARMv6架构中已实现,在ARMv7-A架构的Cortex-A系列处理器中才开始大规模使用。

[2] 该图源自Watch Impress网站。虽然该图出自非ARM官方资料,但是对理解Cortex-A系列处理器内部架构很有帮助。

[3] 有的书上也称其为高速缓存别名问题。

[4] 这里说的总线读写是指该CPU监听到总线读写的信号,而这个信号是其他CPU发出的。

[5] 详见《ARM CoreLink CCI-400 Cache Coherent Interconnect Technical Reference Manual》。

[6] 详见《ARM CoreLink DMC-400 Dynamic Memory Controller Technical Reference》。

[7] 详见《ARM CoreLink NIC-400 Network Interconnect Technical Reference》。

[8] 详见《ARM CoreLink MMU-400 System Memory Management Technical Reference》。

[9] 图片源自Watch Impress网站。

[10] 详见《ARM Architecture Reference Manual, for ARMv8-A architecture profile, v8.4》C5.2节。

[11] 详见《ARM Architecture Reference Manual, for ARMv8-A architecture profile, v8.4》D12.2.100节。

[12] 详见《ARM Architecture Reference Manual, for ARMv8-A architecture profile, v8.4》C5.2节。

[13] 详见GCC官方文档《Using the GNU Compiler Collection, For GCC version 9.3.0》6.47.3节。

[14] 有的教科书称为异常。

[15] 见《ARM Architecture Reference Manual, ARMv8, for ARMv8-A architecture profile》v8.4版本的D.1.10节。


相关图书

Linux常用命令自学手册
Linux常用命令自学手册
庖丁解牛Linux操作系统分析
庖丁解牛Linux操作系统分析
Linux后端开发工程实践
Linux后端开发工程实践
轻松学Linux:从Manjaro到Arch Linux
轻松学Linux:从Manjaro到Arch Linux
Linux高性能网络详解:从DPDK、RDMA到XDP
Linux高性能网络详解:从DPDK、RDMA到XDP
跟老韩学Linux架构(基础篇)
跟老韩学Linux架构(基础篇)

相关文章

相关课程