1 Star 0 Fork 7

yuqian.wang/archbase

forked from loongsonlab/archbase 
加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
12-isa.Rmd 53.99 KB
一键复制 编辑 原始数据 按行查看 历史
# (PART) 指令系统结构 {-} 第二部分介绍计算机软件与硬件之间的界面(或者说接口):指令系统。该部分的内容组织如下:首先介绍指令系统的设计原则和发展历史;随后介绍软硬件之间的关键界面——指令集,以及C语言与指令之间的对应关系;然后介绍异常处理、存储管理两个重要机制;最后介绍软硬件协同工作的一些相关话题。希望通过该部分的介绍能帮助读者拨开计算机软硬件交互的迷雾。 # 指令系统 {#sec-ISA} ## 指令系统简介 随着技术的进步,计算机的形态产生了巨大的变化,从巨型机到小型机到个人电脑(Personal Computer,简称PC)再到智能手机,其基础元件从电子管到晶体管再到超大规模集成电路。虽然计算机的形态和应用场合千变万化,但从用户感知的应用软件到最底层的物理载体,计算机系统均呈现出层次化的结构,图\@ref(fig:hierarchy)直观地展示了这些层次。 ```{r hierarchy, echo=FALSE, fig.align='center', fig.cap="计算机系统的层次", out.width='50%'} knitr::include_graphics("images/chapter2/hierarchy.png") ``` 从上到下,计算机系统可分为四个层次,分别为应用软件、基础软件、硬件电路和物理载体。软件以指令形式运行在CPU硬件上,而指令系统介于软件和硬件之间,是软硬件交互的界面,有着非常关键的作用。软硬件本身的更新迭代速度很快,而指令系统则可以保持较长时间的稳定。有了稳定不变的指令系统界面,软件与硬件得到有效的隔离,并行发展。遵循同一指令系统的硬件可以运行为该指令系统设计的各种软件,比如X86计算机既可运行最新软件,也可运行30年前的软件;反之,为一个指令系统设计的软件可以运行在兼容这一指令系统的不同的硬件实现上,例如同样的操作系统和应用软件在AMD与Intel的CPU上都可以运行。 指令系统包括对指令功能、运行时环境(如存储管理机制和运行级别控制)等内容的定义,涉及软硬件交互的各个方面内容,这些内容将在后续章节一一展开介绍。 ## 指令系统设计原则 指令系统是软硬件的接口,程序员根据指令系统设计软件,硬件设计人员根据指令系统实现硬件。指令系统稍微变化,一系列软硬件都会受到影响,所以指令系统的设计应遵循如下基本原则: * 兼容性。这是指令系统的关键特性。最好能在较长时间内保持指令系统不变并保持向前兼容,例如X86指令系统,虽然背了很多历史包袱,要支持过时的指令,但其兼容性使得Intel在市场上获得了巨大的成功。很多其他指令系统进行过结构上的革命,导致新处理器与旧有软件无法兼容,反而造成了用户群体的流失。因此,保持指令系统的兼容性非常重要。 * 通用性。为了适应各种应用需求,如网络应用、科学计算、视频解码、商业应用等,通用CPU指令系统的功能必须完备。而针对特定应用的专用处理器则不需要强调通用性。指令系统的设计还应满足操作系统管理的需求并方便编译器和程序员的使用。 * 高效性。指令系统还要便于CPU硬件的设计和优化。对同一指令系统,不同的微结构实现可以得到不同的性能,既可以使用先进、复杂的技术得到较高的性能,也可以用成熟、简单的技术得到一般的性能。 * 安全性。当今计算机系统的安全性非常重要,指令系统的设计应当为各种安全性提供支持,如提供保护模式等。 影响指令系统的因素有很多,某些因素的变化会显著影响指令系统的设计,因此有必要了解各方面的影响因素。 * 工艺技术。在计算机发展的早期阶段,计算机硬件非常昂贵,简化硬件实现成为指令系统的主要任务。到了20世纪八九十年代,随着工艺技术的发展,片内可集成晶体管的数量显著增加,CPU可集成更多的功能,功能集成度提高带来的更多可能性支持指令系统的快速发展,例如从32位结构上升至64位结构以及增加多媒体指令等。随着CPU主频的快速提升,CPU速度和存储器速度的差距逐渐变大,为了弥补这个差距,指令系统中增加预取指令将数据预取到高速缓存(Cache)甚至寄存器中。当工艺能力和功耗密度导致CPU主频达到一定极限时,多核结构成为主流,这又导致指令系统的变化,增加访存一致性和核间同步的支持。一方面,工艺技术的发展为指令系统的发展提供了物质基础;另一方面,工艺技术的发展也对指令系统的发展施加影响。 * 计算机体系结构。指令系统本身就是计算机体系结构的一部分,系统结构的变化对指令系统的影响最为直接。诸如单指令多数据(Single Instruction Multiple Data,简称SIMD)、多核结构等新的体系结构特性必然会对指令系统产生影响。事实上,体系结构的发展与指令系统兼容性的基本原则要求是矛盾的,为了兼容性总会背上历史的包袱。X86指令系统和硬件实现就是因为这些历史包袱而变得比较复杂,而诸如PowerPC等精简指令系统都经历过彻底抛弃过时指令系统的过程。 * 操作系统。现代操作系统都支持多进程和虚拟地址空间。虚拟地址空间使得应用程序无须考虑物理内存的分配,在计算机系统发展中具有里程碑意义。为了实现虚拟地址空间,需要设计专门的地址翻译模块以及与其配套的寄存器和指令。操作系统所使用的异常和中断也需要专门的支持。操作系统通常具有核心态、用户态等权限等级,核心态比用户态具有更高的等级和权限,需要设计专门的核心态指令。核心态指令对指令系统有较大的影响,X86指令系统一直在对核心态指令进行规范,MIPS指令系统直到MIPS32和MIPS64才对核心态进行了明确的定义,而Alpha指令系统则通过PALcode定义了抽象的操作系统与硬件的界面。 * 编译技术。编译技术对指令系统的影响也比较大。RISC在某种意义上就是编译技术推动的结果。为使编译器有效地调度指令,至少需要16个通用寄存器。指令功能对编译器更加重要,例如一个指令系统没有乘法指令,编译器就只能将其拆成许多个加法进行运算。 * 应用程序。计算机中的各种应用程序都实现一定的算法,指令是从各种算法中抽象出来的“公共算子”,算法就是由算子序列组成的。指令为应用而设计,因而指令系统随着应用的需求而发展。例如从早期的8位、16位到现在的32位、64位,从早期的只支持定点到支持浮点,从只支持通用指令到支持SIMD指令。此外,应用程序对指令系统的要求还包括前述的兼容性。 总之,指令系统需遵循的设计原则和影响因素很多,指令系统的设计需要综合考虑多方因素并小心谨慎。 ## 指令系统发展历程 指令系统的发展经历了从简单到复杂,再从复杂到简单的演变过程。现代指令系统在指令内容、存储管理和运行级别控制等方面都产生了一系列变化,这些变化体现了人类对计算机体系结构这个学科认知的提升。 ### 指令内容的演变 依据指令长度的不同,指令系统可分为复杂指令系统(Complex Instruction Set Computer,简称CISC)、精简指令系统(Reduced Instruction Set Computer,简称RISC)和超长指令字(Very Long Instruction Word,简称VLIW)指令集三种。CISC中的指令长度可变;RISC中的指令长度比较固定;VLIW本质上来讲是多条同时执行的指令的组合,其“同时执行”的特征由编译器指定,无须硬件进行判断。 早期的CPU都采用CISC结构,如IBM的System360、Intel的8080和8086系列、Motorola的68000系列等。这与当时的时代特点有关,早期处理器设备昂贵且处理速度慢,设计者不得不加入越来越多的复杂指令来提高执行效率,部分复杂指令甚至可与高级语言中的操作直接对应。这种设计简化了软件和编译器的设计,但也显著提高了硬件的复杂性。 当硬件复杂度逐渐提高时,CISC结构出现了一系列问题。大量复杂指令在实际中很少用到,典型程序所使用的80%的指令只占指令集总指令数的20%,消耗大量精力的复杂设计只有很少的回报。同时,复杂的微代码翻译也会增加流水线设计难度,并降低频繁使用的简单指令的执行效率。 针对CISC结构的缺点,RISC遵循简化的核心思路。RISC简化了指令功能,单个指令执行周期短;简化了指令编码,使得译码简单;简化了访存类型,访存只能通过load/store指令实现。RISC指令的设计精髓是简化了指令间的关系,有利于实现高效的流水线、多发射等技术,从而提高主频和效率。 最早的RISC处理器可追溯到CDC公司和其1964年推出的世界上第一台超级计算机CDC6600,现代RISC结构的一些关键特性——如只通过load/store指令访存的load/store结构——都在CDC6600上显现雏形,但简化结构提高效率的思想并未受到小型机和微处理器设计者的重视。1975年,John Cocke在IBM公司位于约克镇的Thomas J. Watson研究中心组织研究指令系统的合理性并研制现代RISC计算机的鼻祖IBM 801,现在IBM PowerPC的主要思想就源于IBM 801。参与IBM 801项目的David Patterson和John Hennessy,分别回到加州大学伯克利分校和斯坦福大学,开始从事RISC-1/RISC-2项目和MIPS项目,它们分别成为SPARC处理器和MIPS处理器的前身。IBM 801的项目经理Joel Birnbaum在HP创立了PA-RISC,DEC公司在MIPS的基础上设计了Alpha处理器。广泛使用的ARM处理器也是RISC处理器的代表之一。David Patterson教授在加州大学伯克利分校推出的开源指令系统RISC-V,是加州大学伯克利分校推出的继RISC-I(1981年推出)、RISC-II(1982年推出)、SOAR(1984年推出,也称为RISC-III)、SPUR(1988年推出,也称为RISC-IV)之后的第五代指令系统。 RISC指令系统的最本质特征是通过load/store结构简化了指令间关系,即所有运算指令都是对寄存器运算,所有访存都通过专用的访存指令(load/store)进行。这样,CPU只要通过寄存器号的比较就能判断运算指令之间以及运算指令和访存指令之间有没有数据相关性,而较复杂的访存指令相关判断(需要对访存的物理地址进行比较)则只在执行load/store指令的访存部件上进行,从而大大简化了指令间相关性判断的复杂度,有利于CPU采用指令流水线、多发射、乱序执行等提高性能。因此,RISC不仅是一种指令系统类型,同时也是一种提高CPU性能的技术。X86处理器中将CISC指令译码为类RISC的内部操作,然后对这些内部操作使用诸如超流水、乱序执行、多发射等高效实现手段。而以PowerPC为例的RISC处理器则包含了许多功能强大的指令。 VLIW结构的最初思想是最大限度利用指令级并行(Instruction Level Parallelism,简称ILP),VLIW的一个超长指令字由多个互相不存在相关性(控制相关、数据相关等)的指令组成,可并行进行处理。VLIW可显著简化硬件实现,但增加了编译器的设计难度。 VLIW的思想最初由Josh Fisher于20世纪80年代初在耶鲁大学提出,Fisher随后离开耶鲁创立了Multiflow公司,并研制了TRACE系列VLIW处理器。后来Fisher和同样经历创业失败的Bob Rau加入了HP公司,并主导了HP在20世纪90年代的计算机结构研究。 同时,Intel在i860中实现了VLIW,这也奠定了随后两家公司在Itanium处理器上的合作关系,Itanium(IA-64)采用的EPIC结构的思想即来源于VLIW。 ```{r inst-coding, echo = FALSE, fig.cap = "RISC、CISC、VLIW指令编码特点", fig.align = 'center', out.width='80%' } knitr::include_graphics("images/chapter2/inst_coding.png") ``` 图\@ref(fig:inst-coding)直观地给出了RISC、CISC、VLIW三种结构的指令编码。MIPS三种类型的指令内部位域分配不同,但总长度均为32位;X86则不同指令的长度都可能不同;IA-64则将三条41位定长指令合并为一条128位的“束”。 ### 存储管理的演变 存储器是冯·诺依曼结构计算机的核心部件,存储管理的演变是指令系统演变的重要组成部分。 存储管理的演变经历了连续实地址、段式、页式虚拟存储等阶段。 连续实地址的管理方式是最早期也是最朴素的方式,各程序所需的内存空间必须连续存放并保证不与其他程序产生冲突。这种方式不但会带来大量的内存碎片,而且难以管理多个程序的空间分配。 段式存储管理将内存分为多个段和节,地址组织为相对于段地址的偏移。段式存储主要应用于早期处理器中,Burroughs公司的B5000是最早使用段式存储的计算机之一。Intel从8086处理器开始使用段式存储管理,在80286之后兼容段页式,但在最新的X86-64位架构中放弃了对段式管理的支持。 页式虚拟存储管理将各进程的虚拟内存空间划分成若干长度相同的页,将虚拟地址和物理地址的对应关系组织为页表,并通过硬件来实现快速的地址转换。现代通用处理器的存储管理单元都基于页式虚拟管理,并通过TLB进行地址转换加速。 页式虚拟存储可使各进程运行在各自独立的虚拟地址空间中,并提供内存映射、公平的物理内存分配和共享虚拟内存等功能,是计算机系统发展过程中具有里程碑意义的一项技术。 下面分别介绍上述几种存储管理方式的基本方法。 段式存储管理的地址转换过程如图\@ref(fig:segment)所示。虚拟地址分为段号和段内偏移两部分,地址转换时根据段号检索段表,得到对应段的起始物理地址(由段长度和基址可得),再加上段内偏移,得到最终的物理地址。需要注意的是,段表中存有每个段的长度,若段内偏移超过该段长度,将被视为不合法地址。 ```{r segment, echo = FALSE, fig.cap = "段式存储管理的地址转换过程", fig.align = 'center', out.width='60%' } knitr::include_graphics("images/chapter2/segment.png") ``` 段式存储中每段可配置不同的起始地址,但段内地址仍需要连续,当程序段占用空间较大时,仍然存在内存碎片等问题。 页式存储管理的地址转换过程如图\@ref(fig:page)所示。虚拟地址分为虚拟页号和页内偏移两部分,地址转换时根据虚拟页号检索页表,得到对应的物理页号,与页内偏移组合得到最终的物理地址。 ```{r page, echo = FALSE, fig.cap = "页式存储管理的地址转换过程", fig.align = 'center', out.width='60%'} knitr::include_graphics("images/chapter2/page.png") ``` 段页式管理结合了段式和页式的特点,其地址转换过程如图\@ref(fig:seg-page)所示,虚拟地址分为段号、虚拟页号和页内偏移三部分,地址转换时首先根据段号查询段表得到对应段的页表起始地址,再根据虚拟页号查询页表得到物理页号,与页内偏移组合得到最终的物理地址。段页式同样需要检查段地址的合法性。 ```{r seg-page, echo = FALSE, fig.cap = "段页式存储管理的地址转换过程", fig.align = 'center', out.width='60%'} knitr::include_graphics("images/chapter2/seg-page.png") ``` ### 运行级别的演变 作为软件指令的执行者,处理器中有各种级别的资源,比如通用寄存器、控制寄存器等。为了对软件所能访问的资源加以限制,计算机引入了运行级别的概念。运行级别经历了无管理、增加保护模式、增加调试模式、增加虚拟化支持等阶段。 早期的处理器和当今的嵌入式单片机中不包含运行级别控制,所有程序都可控制所有资源。无管理的方式在安全方面毫无保障,软件必须小心设计,确保不会相互干扰。这通常只在规模有限、封闭可控的系统如微控制器(Micro Control Unit,简称MCU)中使用。 现代操作系统(如Linux)包含保护模式,将程序分为两个权限等级:用户态和核心态。核心态具有最高权限,可以执行所有指令、访问任意空间。在用户态下,程序只能访问受限的内存空间,不允许访问外围设备。用户态程序需要使用外围设备时,通过系统调用提出申请,由操作系统在核心态下完成访问。保护模式需要硬件支持,如X86指令系统中定义了Ring0~Ring3四个权限等级,MIPS指令系统中定义了user、supervisor和kernel三个权限等级。LoongArch指令系统中定义了PLV0~PLV3四个权限等级,由当前模式信息控制状态寄存器(CSR.CRMD)的PLV域的值确定。在LoongArch处理器上运行的Linux操作系统,其核心态程序运行在PLV0级,用户态程序通常运行在PLV3级。 为了方便软硬件调试,许多指令系统中还定义了调试模式和相应的调试接口,如ARM的JTAG、MIPS的EJTAG。LoongArch指令系统定义了专门的调试模式、调试指令和配套的状态控制寄存器。在调试模式下,处理器所执行的程序将获得最高的权限等级,不过此时处理器所执行的指令是从外部调试接口中获得的,并且利用专用的控制状态寄存器使得被调试程序的上下文可以无缝切换。 虚拟化技术在服务器领域特别有用,一台物理主机可以支撑多台虚拟机,运行各自的系统。虚拟机不绑定底层硬件,可看作一个软件进程,因而部署起来非常灵活。虚拟机中同样要支持不同的运行级别,为了提高效率,硬件辅助虚拟化成为虚拟化发展的必然趋势。IBM System/370早在1970年就增加了硬件虚拟化支持;2005年以来,Intel和AMD也分别提出了硬件辅助虚拟化的扩展VT和SVM。ARM的AArch64架构也定义了硬件虚拟化支持方面的内容。这些指令系统在硬件虚拟化支持中引入了新的运行级别,用于运行虚拟机操作系统的核心态和用户态程序。 以LoongArch指令系统为例,其运行级别主要包括调试模式(Debug Mode)、主机模式(Host Mode)和客户机模式(Guest Mode)。主机模式和客户机模式又各自包含PLV0~PLV3四个权限等级,即具有Host-PLV0~Host-PLV3和Guest-PLV0~Guest-PLV3这8个运行级别。所有运行级别互相独立,即处理器在某一时刻只能存在于某一种运行级别中。处理器上电复位后处于Host-PLV0级,随后根据需要在不同运行级别之间转换。 不同运行级别可访问并控制的处理器资源不同,图\@ref(fig:csr)给出了这种对应关系的示意。其中调试模式下具有最高的优先级,可以访问并控制处理器中所有的资源;Host-PLV0模式下可以访问并控制处理器中除了用于调试功能外的所有其他资源;Guest-PLV0模式下只能访问部分处理器资源,如客户机控制状态寄存器;Host-PLV1/2/3和Guest-PLV1/2/3则只能访问更少的处理器资源。 ```{r csr, echo = FALSE, fig.cap = "LoongArch各运行级别可访问控制处理器资源示意", fig.align = 'center', out.width='60%'} knitr::include_graphics("images/chapter2/csr.png") ``` ## 指令系统组成 指令系统由若干条指令及其操作对象组成。每条指令都是对一个操作的描述,主要包括操作码和操作数。操作码规定指令功能,例如加减法;操作数指示操作对象,包含数据类型、访存地址、寻址方式等内容的定义。 ### 地址空间 处理器可访问的地址空间包括寄存器空间和系统内存空间。寄存器空间包括通用寄存器、专用寄存器和控制寄存器。寄存器空间通过编码于指令中的寄存器号寻址,系统内存空间通过访存指令中的访存地址寻址。 通用寄存器是处理器中最常用的存储单元,一个处理器周期可以同时读取多条指令需要的多个寄存器值。现代指令系统都定义了一定数量的通用寄存器供编译器进行充分的指令调度。针对浮点运算,通常还定义了浮点通用寄存器。表\@ref(tab:regnum)给出了部分常见指令集中整数通用寄存器的数量。 ```{r regnum, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="不同指令集的整数通用寄存器数量", autonum = autonum) %>% theme_box() %>% autofit() ``` LoongArch指令系统中定义了32个整数通用寄存器和32个浮点通用寄存器,其编号分别表示为\$r0~\$r31和\$f0~\$f31,其中\$r0总是返回全0。 除了通用寄存器外,有的指令系统还会定义一些专用寄存器,仅用于某些专用指令或专用功能。如MIPS指令系统中定义的HI、LO寄存器就仅用于存放乘除法指令的运算结果。 控制寄存器用于控制指令执行的环境,比如是核心态还是用户态。其数量、功能和访问方式依据指令系统的定义各不相同。LoongArch指令系统中定义了一系列控制状态寄存器(Control Status Register,简称CSR),将在第3章介绍。 广义的系统内存空间包括IO空间和内存空间,不同指令集对系统内存空间的定义各不相同。X86指令集包含独立的IO空间和内存空间,对这两部分空间的访问需要使用不同的指令:内存空间使用一般的访存指令,IO空间使用专门的in/out指令。而MIPS、ARM、LoongArch等RISC指令集则通常不区分IO空间和内存空间,把它们都映射到同一个系统内存空间进行访问,使用相同的load/store指令。处理器对IO空间的访问不能经过Cache,因此在使用相同的load/store指令既访问IO空间又访问内存空间的情况下,就需要定义load/store指令访问地址的存储访问类型,用来决定该访问能否经过Cache。如MIPS指令集定义缓存一致性属性(Cache Coherency Attribute,简称CCA)Uncached和Cached分别用于IO空间和内存空间的访问,ARM AArch64指令定义内存属性(Memory Attribute)Device和Normal分别对应IO空间和内存空间的访问,LoongArch指令集定义存储访问类型(Memory Access Type,简称MAT)强序非缓存(Strongly-ordered UnCached,简称SUC)和一致可缓存(Coherent Cached,简称CC)分别用于IO空间和内存空间的访问。存储访问类型通常根据访存地址范围来确定。如果采用页式地址映射方式,那么同一页内的地址定义为相同的存储访问类型,通常作为该页的一个属性信息记录在页表项中,如MIPS指令集中的页表项含有CCA域,LoongArch指令集中的页表项含有MAT域。如果采用段式地址映射方式,那么同一段内的地址定义为相同的存储访问类型。如MIPS32中规定虚地址空间的kseg1段(地址范围0xa0000000\~0xbfffffff)的存储访问类型固定为Uncached,操作系统可以使用这段地址来访问IO空间。LoongArch指令集可以把直接地址映射窗口的存储访问类型配置为SUC,那么落在该地址窗口就可以访问IO空间。(有关LoongArch指令集中直接地址映射窗口的详细介绍请看第3章。) 根据指令使用数据的方式,指令系统可分为堆栈型、累加器型和寄存器型。寄存器型又可以进一步分为寄存器-寄存器型(Register-Register)和寄存器-存储器型(Register-Memory)。下面分别介绍各类型的特点。 * 堆栈型。堆栈型指令又称零地址指令,其操作数都在栈顶,在运算指令中不需要指定操作数,默认对栈顶数据进行运算并将结果压回栈顶。 * 累加器型。累加器型指令又称单地址指令,包含一个隐含操作数——累加器,另一个操作数在指令中指定,结果写回累加器中。 * 寄存器-存储器型。在这种类型的指令系统中,每个操作数都由指令显式指定,操作数为寄存器和内存单元。 * 寄存器-寄存器型。在这种类型的指令系统中,每个操作数也由指令显式指定,但除了访存指令外的其他指令的操作数都只能是寄存器。 表\@ref(tab:isatype)给出了四种类型的指令系统中执行C=A+B的指令序列,其中A、B、C为不同的内存地址,R1、R2等为通用寄存器。 ```{r isatype, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="四类指令系统的C=A+B指令序列", autonum = autonum) %>% theme_box() %>% autofit() ``` 寄存器-寄存器型指令系统中的运算指令的操作数只能来自寄存器,不能来自存储器,所有的访存都必须显式通过load和store指令来完成,所以寄存器-寄存器型又被称为load-store型。 早期的计算机经常使用堆栈型和累加器型指令系统,主要目的是降低硬件实现的复杂度。除了X86还保留堆栈型和累加器型指令系统外,当今的指令系统主要是寄存器型,并且是寄存器-寄存器型。使用寄存器的优势在于,寄存器的访问速度快,便于编译器的调度优化,并可以充分利用局部性原理,大量的操作可以在寄存器中完成。此外,寄存器-寄存器型的另一个优势是寄存器之间的相关性容易判断,容易实现流水线、多发射和乱序执行等方法。 ### 操作数 #### 数据类型 计算机中常见的数据类型包括整数、实数、字符,数据长度包括1字节、2字节、4字节和8字节。X86指令集中还包括专门的十进制类型BCD。表\@ref(tab:int-type)给出C语言整数类型与不同指令集中定义的名称和数据长度(以字节为单位)的关系。 ```{r int-type, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="不同指令集整数类型的名称和数据长度", autonum = autonum) %>% footnote(j=2:3, value = as_paragraph(c("LA32和LA64分别是32位和64位LoongArch指令集")), ref_symbols = NULL, part='header') %>% theme_box() %>% autofit() ``` 实数类型在计算机中表示为浮点类型,包括单精度浮点数和双精度浮点数,单精度浮点数据长度为4字节,双精度浮点数据长度为8字节。 在指令中表达数据类型有两种方法。一种是由指令操作码来区分不同类型,例如加法指令包括定点加法指令、单精度浮点加法指令、双精度浮点加法指令。另一种是将不同类型的标记附在数据上,例如加法使用统一的操作码,用专门的标记来标明加法操作的数据类型。 #### 访存地址 在执行访存指令时,必须考虑的问题是访存地址是否对齐和指令系统是否支持不对齐访问。所谓对齐访问是指对该数据的访问起始地址是其数据长度的整数倍,例如访问一个4字节数,其访存地址的低两位都应为0。对齐访问的硬件实现较为简单,若支持不对齐访问,硬件需要完成数据的拆分和拼合。但若只支持对齐访问,又会使指令系统丧失一些灵活性,例如串操作经常需要进行不对齐访问,只支持对齐访问会让串操作的软件实现变得较为复杂。以X86为代表的CISC指令集通常支持不对齐访问,RISC类指令集在早期发展过程中为了简化硬件设计只支持对齐访问,不对齐的地址访问将产生异常。近些年来伴随着工艺和设计水平的提升,越来越多的RISC类指令也开始支持不对齐访问以减轻软件优化的负担。 另一个与访存地址相关的问题是尾端(Endian)问题。不同的机器可能使用大尾端或小尾端,这带来了严重的数据兼容性问题。最高有效字节的地址较小的是大尾端,最低有效字节的地址较小的是小尾端。Motorola的68000系列和IBM的System系列指令系统采用大尾端,X86、VAX和LoongArch等指令系统采用小尾端,ARM、SPARC和MIPS等指令系统同时支持大小尾端。 #### 寻址方式 寻址方式指如何在指令中表示要访问的内存地址。表\@ref(tab:addressing)列出了计算机中常用的寻址方式,其中数组mem表示存储器,数组regs表示寄存器,mem[regs[Rn]]表示由寄存器Rn的值作为存储器地址所访问的存储器值。 ```{r addressing, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="常用寻址方式", autonum = autonum) %>% width(j=1:2, width=1.2) %>% width(j=3, width=3.6) %>% theme_box() ``` 除表\@ref(tab:addressing)之外还可以列出很多其他寻址方式,但常用的寻址方式并不多。John L.Hennessy在其经典名著《计算机系统结构:量化研究方法(第二版)》中给出了如表\@ref(tab:vax-addressing)所示的数据,他在VAX计算机(VAX机的寻址方式比较丰富)上对SPEC CPU 1989中tex、spice和gcc这三个应用的寻址方式进行了统计。 ```{r vax-addressing, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="VAX计算机寻址方式统计", autonum = autonum) %>% theme_box() %>% autofit() ``` 从表\@ref(tab:vax-addressing)可以看出,偏移量寻址、立即数寻址和寄存器间接寻址是最常用的寻址方式,而寄存器间接寻址相当于偏移量为0的偏移量寻址。因此,一个指令系统至少应支持寄存器寻址、立即数寻址和偏移量寻址。经典的RISC指令集,如MIPS和Alpha,主要支持上述三种寻址方式以兼顾硬件设计的简洁和寻址计算的高效。不过随着工艺和设计水平的提升,现代商用RISC类指令集也逐步增加所支持的寻址方式以进一步提升代码密度,如64位的LoongArch指令集(简称LA64)就在寄存器寻址、立即数寻址和偏移量寻址基础之上还支持变址寻址方式。 ### 指令操作和编码 现代指令系统中,指令的功能由指令的操作码决定。从功能上来看,指令可分为四大类:第一类为运算指令,包括加减乘除、移位、逻辑运算等;第二类为访存指令,负责对存储器的读写;第三类是转移指令,用于控制程序的流向;第四类是特殊指令,用于操作系统的特定用途。 在四类指令中,转移指令的行为较为特殊,值得详细介绍。转移指令包括条件转移、无条件转移、过程调用和过程返回等类型。转移条件和转移目标地址是转移指令的两个要素,两者的组合构成了不同的转移指令:条件转移要判断条件再决定是否转移,无条件转移则无须判断条件;相对转移是程序计数器(PC)加上一个偏移量作为转移目标地址,绝对转移则直接给出转移目标地址;直接转移的转移目标地址可直接由指令得到,间接转移的转移目标地址则需要由寄存器的内容得到。程序中的switch语句、函数指针、虚函数调用和过程返回都属于间接转移。由于取指译码时不知道目标地址,因此硬件结构设计时处理间接跳转比较麻烦。 转移指令有几个特点:第一,条件转移在转移指令中最常用;第二,条件转移通常只在转移指令附近进行跳转,偏移量一般不超过16位;第三,转移条件判定比较简单,通常只是两个数的比较。条件转移指令的条件判断通常有两种实现方式:采用专用标志位和直接比较寄存器。采用专用标志位方式的,通过比较指令或其他运算指令将条件判断结果写入专用标志寄存器中,条件转移指令仅根据专用标志寄存器中的判断结果决定是否跳转。采用直接比较寄存器方式的,条件转移指令直接对来自寄存器的数值进行比较,并根据比较结果决定是否进行跳转。X86和ARM等指令集采用专用标志位方式,RISC-V指令集则采用直接比较寄存器方式,MIPS和LoongArch指令集中的整数条件转移指令采用直接比较寄存器方式,而浮点条件转移指令则采用专用标志位方式。 指令编码就是操作数和操作码在整个指令码中的摆放方式。CISC指令系统的指令码长度可变,其编码也比较自由,可依据类似于赫夫曼(Huffman)编码的方式将操作码平均长度缩小。RISC指令系统的指令码长度固定,因此需要合理定义来保证各指令码能存放所需的操作码、寄存器号、立即数等元素。图\@ref(fig:loongarch-coding)给出了LoongArch指令集的编码格式。 ```{r loongarch-coding, echo = FALSE, fig.cap = "LoongArch指令集的编码格式", fig.align = 'center', out.width='100%'} knitr::include_graphics("images/chapter2/loongarch-coding.png") ``` 如图\@ref(fig:loongarch-coding)所示,32位的指令编码被划分为若干个区域,按照划分方式的不同共包含9种典型的编码格式,即3种不含立即数的格式2R、3R、4R和6种包含立即数的格式2RI8、2RI12、2RI14、2RI16、1RI21和I26。编码中的opcode域用于存放指令的操作码;rd、rj、rk和ra域用于存放寄存器号,通常rd表示目的操作数寄存器,而rj、rk、ra表示源操作数寄存器;Ixx域用于存放指令立即数,即立即数寻址方式下指令中给出的数。指令中的立即数不仅作为运算型指令的源操作数,也作为load/store指令中相对于基地址的地址偏移以及转移指令中转移目标的偏移量。 ## RISC指令集比较 本节以MIPS、PA-RISC、PowerPC、SPARC v9和LoongArch为例,比较不同RISC指令系统的指令格式、寻址模式和指令功能,以加深对RISC的了解。 ### 指令格式比较 五种RISC指令集的指令格式如图\@ref(fig:isa-compare)所示。在寄存器类指令中,操作码都由操作码(OP)和辅助操作码(OPX)组成,操作数都包括两个源操作数(RS)和一个目标操作数(RD);立即数类指令都由操作码、源操作数、目标操作数和立即数(Const)组成,立即数的位数各有不同;跳转类指令大同小异,PA-RISC与其他四种差别较大。总的来说,五种RISC指令集的指令编码主要组成元素基本相同,只是在具体摆放位置上存在差别。 ```{r isa-compare, echo = FALSE, fig.cap = "五种RISC指令集的指令编码格式", fig.align = 'center', out.width='100%'} knitr::include_graphics("images/chapter2/isa-compare.png") ``` ### 寻址方式比较 五种指令集的寻址方式如表\@ref(tab:addr-compare)所示。MIPS、SPARC和LoongArch只支持四种常用的寻址方式,PowerPC和PA-RISC支持的寻址方式较多。 ```{r addr-compare, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="五种指令集的寻址方式比较", autonum = autonum) %>% theme_box() %>% autofit() ``` 注:表\@ref(tab:addr-compare)中Y表示支持该寻址方式。 ### 公共指令功能 RISC指令集都有一些公共指令,如load-store、算术运算、逻辑运算和控制流指令。不同指令集在比较和转移指令上区别较大。 1)load-store指令。load指令将内存中的数据取入通用寄存器,store指令将通用寄存器中的数据存至内存中。表\@ref(tab:mem-inst)给出了LoongArch指令集的load-store指令实例。当从内存中取回的数据位宽小于通用寄存器位宽时,后缀没有U的指令进行有符号扩展,即用取回数据的最高位(符号位)填充目标寄存器的高位,否则进行无符号扩展,即用数0填充目标寄存器的高位。 ```{r mem-inst, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="LoongArch指令集的load-store指令", autonum = autonum) %>% theme_box() %>% autofit() ``` 2)ALU指令。ALU指令都是寄存器型的,常见的ALU指令包括加、减、乘、除、与、或、异或、移位和比较等。表\@ref(tab:alu-inst)为LoongArch指令集的ALU指令实例。其中带有“.W”后缀的指令操作的数据位宽为32位(字),带有“.D”后缀的指令操作的数据位宽为64位(双字)。 ```{r alu-inst, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="LoongArch指令集的ALU指令", autonum = autonum) %>% theme_box() %>% autofit() ``` 3)控制流指令。控制流指令分为绝对转移指令和相对转移指令。相对转移的目标地址是当前的PC值加上指令中的偏移量立即数;绝对转移的目标地址由寄存器或指令中的立即数给出。表\@ref(tab:control-inst)为LoongArch指令集中控制流指令的实例。 ```{r control-inst, echo = FALSE, message=FALSE} autonum % flextable() %>% set_caption(caption="LoongArch指令集的控制流指令", autonum = autonum) %>% theme_box() %>% autofit() ``` 在条件转移指令中,转移条件的确定有两种方式:判断条件码和比较寄存器的值。SPARC采用条件码的方式,整数运算指令置条件码,条件转移指令使用条件码进行判断。MIPS和LoongArch的定点转移指令使用寄存器比较的方式进行条件判断,而浮点转移指令使用条件码。PowerPC中包含一个条件寄存器,条件转移指令指定条件寄存器中的特定位作为跳转条件。PA-RISC有多种选择,通常通过比较两个寄存器的值来决定是否跳转。 RISC指令集中很多条件转移采用了转移延迟槽(Delay Slot)技术,程序中条件转移指令的后一条指令为转移延迟槽指令。在早期的静态流水线中,条件转移指令在译码时,后一条指令即进入取指流水级。为避免流水线效率的浪费,有些指令集规定转移延迟槽指令无论是否跳转都要执行。MIPS、SPARC和PA-RISC都实现了延迟槽,但对延迟槽指令是否一定执行有不同的规定。对于当今常用的动态流水线和多发射技术而言,延迟槽技术则没有使用的必要,反而成为指令流水线实现时需要特殊考虑的负担。Alpha、PowerPC和LoongArch均没有采用转移延迟槽技术。 ### 不同指令系统的特色 除了上述公共功能外,不同的RISC指令集经过多年的发展形成了各自的特色,下面举例介绍其各自的主要特色。 1)MIPS部分指令特色。前面介绍过访存地址的对齐问题,当确实需要使用不对齐数据时,采用对齐访存指令就需要复杂的地址计算、移位和拼接等操作,这会给大量使用不对齐访存的程序带来明显的代价。MIPS指令集实现了不对齐访存指令LWL/LWR。LWL指令读取访存地址所在的字并将访存地址到该字中最低位的字节拼接到目标寄存器的高位,LWR指令读取访存地址所在的字并将访存地址到该字中最高位的字节拼接到目标寄存器的低位。上述字中的最低位和最高位字节会根据系统采用的尾端而变化,不同尾端下,LWL和LWR的作用相反。例如,要加载地址1至4的内容到R1寄存器,不同尾端的指令和效果如图\@ref(fig:lwl)所示。 ```{r lwl, echo = FALSE, fig.cap = "不同尾端下的LWL/LWR指令效果", fig.align = 'center', out.width='80%'} knitr::include_graphics("images/chapter2/lwl.png") ``` LWL和LWR指令设计巧妙,兼顾了使用的便利性和硬件实现的简单性,是MIPS指令集中比较有特色的指令。 2)SPARC部分指令特色。SPARC指令系统有很多特色,这里挑选寄存器窗口进行介绍。在SPARC指令系统中,一组寄存器(SPARC v9中规定为8\~31号寄存器)可用于构成窗口,窗口可有多个,0\~7号寄存器作为全局寄存器。寄存器窗口的好处在于函数调用时可不用保存现场,只需切换寄存器组。 3)PA-RISC部分指令特色。PA-RISC指令集最大的特色就是Nullification指令,除了条件转移指令,其他指令也可以根据执行结果确定下一条指令是否执行。例如ADDBF(add and branch if false)指令在完成加法后,检查加法结果是否满足条件,如果不满足就进行转移。一些简单的条件判断可以用Nullification指令实现。 4)PowerPC部分指令特色。在RISC结构中,PowerPC的寻址方式、指令格式和转移指令都是最多的,甚至支持十进制运算,因此又被称为“RISC中的CISC”。表\@ref(tab:ppc-vs-alpha)给出了分别用PowerPC指令和Alpha指令实现的简单程序示例。实现同样的循环程序,PowerPC只需要6条指令,Alpha则需要10条指令,原因就在于PowerPC的指令功能较强。例如其中的LFU(load with update)和STFU(store with update)指令,除了访存外还能自动修改基址寄存器的值;FMADD可以在一条指令中完成乘法和加法;转移指令BC可同时完成计数值减1和条件转移。 ```{r ppc-vs-alpha, echo = FALSE, message=FALSE, tab.cap='PowerPC和Alpha汇编对比', tab.id='ppc-vs-alpha'} dt % set_caption(caption="PowerPC和Alpha汇编对比", autonum = autonum) %>% width(j=c(1,2), width=3) %>% add_header_row(values = "源代码:for(k=0;k<512;k++) x[k]=r*x[k]+t*y[k];", colwidths = 2) %>% valign(i = NULL, j = NULL, valign = "top", part = "body") %>% theme_box() ``` 5)LoongArch部分指令特色。LoongArch指令集的一个特色是其二进制翻译扩展^[二进制翻译是一种跨指令软件兼容的技术,它把二进制软件代码从一种指令集翻译到另一种指令集以实现跨平台运行。新指令集发展过程中经常会利用这个技术来运行现有的软件,为新生态的建设实现平稳过渡。]。LoongArch的二进制翻译扩展提供了百余条指令和一些系统资源来支持软件实现高效的二进制翻译。例如,把X86指令翻译为RISC类的指令集有个影响翻译效率的因素:eflags标志位处理。因为X86指令集中,一个运算指令除了产生运算结果,还会同时产生是否进位、是否溢出等多>个标志位。完全模拟这样的一条指令的语义一般需要30条以上常规RISC指令。LoongArch提供了一系列专门指令用于产生和使用相应的标志位,在保持RISC指令风格的同时消除了这个瓶颈。目前业界最先进的二进制翻译系统可以实现80%左右的翻译运行效率,LoongArch致力于通过深度的软硬件协同进一步提升效率,实现多个主流指令集到龙芯指令集几乎无损的翻译,最终达到“消灭指令集”或者说软件定义指令集的目的。 ## C语言的机器表示 C语言等高级语言编写的程序必须经过编译器转换为汇编语言,再由汇编器转换为指令码才能在CPU上执行。本节简要介绍高级语言转换为指令码涉及的一些问题,为方便起见,选择C语言和LoongArch汇编码进行介绍。 ### 过程调用 过程调用是高级语言程序中的一个关键特性,它可以让特定程序段的内容与其他程序和数据分离。过程接受参数输入,并通过参数返回执行结果。C语言中过程和函数的概念相同,本节后面也不进行区分。过程调用中,调用者和被调用者必须遵循同样的接口约定,包括寄存器使用、栈的使用和参数传递的约定等。这部分涉及内容较多,将在第4章中进行详细的介绍。本节中,主要介绍过程调用的流程和其中与指令集相关的内容。 在LoongArch指令集中,负责函数调用的指令是BL,这是一条相对转移指令。该指令在跳转的同时还将其下一条指令的地址放入1号通用寄存器(记为\$ra)中,作为函数返回地址。负责函数返回的指令是JR^[在LoongArch指令集中,JR指令是JIRL指令rd=0且offs16=0时的别称。],属于间接跳转指令,该指令的操作数为寄存器,因此LoongArch汇编中最常见的函数返回指令是jr \$ra。 除了调用和返回的指令外,函数调用和执行过程中还需要执行一系列操作: * 调用者(S)将参数(实参)放入寄存器或栈中; * 使用BL指令调用被调用者(R); * R在栈中分配自己所需要的局部变量空间; * 执行R过程; * R释放局部变量空间(将栈指针还原); * R使用JR指令返回调用者S。 默认情况下,通用寄存器\$r4\~\$r11(记为\$a0\~\$a7)作为参数输入,其中\$r4和\$r5同时也作为返回值,通用寄存器\$r12\~\$r20(记为\$t0\~\$t8)作为子程序的暂存器无须存储和恢复。LoongArch中没有专门的栈结构和栈指针,通用寄存器\$r3(记为\$sp)通常作为栈指针寄存器,指向栈顶。 一个简单的C语言过程调用程序及其LoongArch汇编码如表\@ref(tab:c-vs-as)所示。 ```{r c-vs-as, echo = FALSE, message=FALSE, tab.cap='过程调用及其LoongArch机器表示', tab.id='c-vs-as'} autonum % # colformat_md(md_extensions = "+hard_line_breaks") %>% set_caption(caption="过程调用及其LoongArch机器表示", autonum = autonum) %>% theme_box() %>% autofit() ``` add程序是被调用的子程序,由于程序功能很简单,因此无须使用栈来存储任何信息,其输入参数存放在\$a0、\$a1两个寄存器中,计算的结果存放在\$a0寄存器中。 ref程序是add程序的调用者,通过BL指令进行调用,BL指令会修改\$ra寄存器的值,因此在ref中需要将\$ra寄存器的值保存到栈中,栈顶指针和RA值存放的位置遵循LoongArch函数调用规范,这部分内容将在4.1节中进行介绍。add程序的返回值放在\$a0寄存器中,这同时也是ref程序的返回值,因此无须进行更多搬运。 ### 流程控制语句 C语言中的控制流语句共有9种,可分为三类:辅助控制语句、选择语句、循环语句,如表\@ref(tab:c-control)所示。 ```{r c-control, echo = FALSE, message=FALSE, tab.cap='C语言控制流语句', tab.id='c-control'} autonum % set_caption(caption="C语言控制流语句", autonum = autonum) %>% delete_part(part='header') %>% merge_v() %>% theme_box() %>% autofit() ``` (1)辅助控制语句 goto语句无条件地跳转到程序中某标号处,其作用与无条件相对跳转指令相同,在LoongArch指令集中表示为B指令跳转到一个标号。break、continue语句的作用与goto类似,只是跳转的标号位置不同。return语句将过程中的变量作为返回值并直接返回,在编译器中对应于返回值写入和返回操作。 (2)选择语句 if\~else语句及其对应的LoongArch汇编码如表\@ref(tab:if-else)所示。 ```{r if-else, echo = FALSE, message=FALSE, tab.cap='if~else语句及其LoongArch汇编表示', tab.id='if-else'} autonum % # colformat_md(md_extensions = "+hard_line_breaks") %>% set_caption(caption="if~else语句及其LoongArch汇编表示", autonum = autonum) %>% theme_box() %>% autofit() ``` 这里的if \~ else实现采用了BEQZ指令,当\$t0寄存器的值等于0时进行跳转,跳转到标号.L1执行“else”分支中的操作,当\$t0寄存器的值不等于0时,则顺序执行“then”分支中的操作并在完成后无条件跳转到标号.L2处绕开“else”分支。 switch \~ case语句的结构更为复杂,由于可能的分支数较多,通常会被映射为跳转表的形式,如表\@ref(tab:switch-case)所示。如果在编译选项中加入-fno-jump-tables的选项,那么switch \~ case语句还可以被映射为跳转级联的形式,如表\@ref(tab:switch-case-chain)所示。表中"alsl.d rd, rj, rk, sa"所进行的操作是:GR[rd] = (GR[rj] << sa) + GR[rk]。即将rj号通用寄存器中的值先左移sa位再与rk号通用寄存器中的值相加,结果写入rd号通用寄存器中。 ```{r switch-case, echo = FALSE, message=FALSE, tab.cap='switch~case语句及其跳转表形式的LoongArch机器表示', tab.id='switch-case'} autonum % # colformat_md(md_extensions = "+hard_line_breaks") %>% set_caption(caption="switch~case语句及其跳转表形式的LoongArch机器表示", autonum = autonum) %>% theme_box() %>% autofit() ``` ```{r switch-case-chain, echo = FALSE, message=FALSE, tab.cap='switch~case语句及其跳转级联形式的LoongArch机器表示', tab.id='switch-case-chain'} autonum % # colformat_md(md_extensions = "+hard_line_breaks") %>% set_caption(caption="switch-case语句及其跳转级联形式的LoongArch机器表示", autonum = autonum) %>% theme_box() %>% autofit() ``` 在这个例子中,\$t0寄存器存放各case分支的值并依次与第一个参数a(存放在\$a0寄存器中)进行比较,根据比较的结果分别跳转到指定标号。读者可自行分析各case分支的执行流。通过比较表\@ref(tab:switch-case)和\@ref(tab:switch-case-chain)中的汇编代码可以看到,在case分支较多时,采用跳转表实现有助于减少级联的转移指令。 ### 循环语句 循环语句均可映射为条件跳转指令,与选择语句的区别就在于跳转的目标标号在程序段已执行过的位置(backward)。三种循环语句的C语言及其对应的LoongArch汇编码如表\@ref(tab:loop)所示。 ```{r loop, echo = FALSE, message=FALSE, tab.cap='循环语句及其LoongArch机器表示', tab.id='loop'} autonum % # colformat_md(md_extensions = "+hard_line_breaks") %>% set_caption(caption="循环语句及其LoongArch机器表示", autonum = autonum) %>% theme_box() %>% autofit() ``` ## 本章小结 本章介绍了指令系统在整个计算机系统中位于软硬件界面的位置,讨论了指令系统设计的原则和影响因素,并从指令内容、存储管理、运行级别三个角度介绍指令系统的发展历程。 本章首先介绍了指令集的关键要素——地址空间定义、指令操作数、指令操作码,随后对几种不同的RISC指令集进行了比较,最后以LoongArch指令集为例给出了C语言和指令汇编码之间的对应关系。 ## 习题 1. 请以某一种指令系统为例,说明其定义了哪些运行级别,以及这些运行级别之间的区别与联系。 2. 请用C语言伪代码形式描述一个采用段页式存储管理机制的计算机系统进行虚实地址转换的过程。(说明:不用描述微结构相关的内容,如TLB;段描述符或页表中的各种属性域均视作有效。) 3. 请简述桌面电脑PPT翻页过程中用户态和核心态的转换过程。 4. 给定下列程序片段: ``` A = B + C B = A + C C = B + A ``` * 写出上述程序片段在四种指令系统类型(堆栈型、累加器型、寄存器-存储器型、寄存器-寄存器型)中的指令序列。 * 假设四种指令系统类型都属于CISC型,令指令码宽度为x位,寄存器操作数宽度为y位,内存地址操作数宽度为z位,数据宽度为w位。 分析指令的总位数和所有内存访问的总位数。 * 微处理器由32位时代进入了64位时代,上述四种指令系统类型哪种更好? 5. 写出0xDEADBEEF在大尾端和小尾端下在内存中的排列(由地址0开始)。 6. 在你的机器上编写C程序来得到不同数据类型占用的字节数,给出程序和结果。 7. 根据LoongArch指令集的编码格式计算2RI16、1RI21和I26三种编码格式的直接转移指令各自的跳转范围。 8. 仅使用对齐访存指令写出如图\@ref(fig:lwl)的不对齐加载(小尾端)。 \newpage
马建仓 AI 助手
尝试更多
代码解读
代码找茬
代码优化
R
1
https://gitee.com/yuqianwang/archbase.git
git@gitee.com:yuqianwang/archbase.git
yuqianwang
archbase
archbase
main

搜索帮助