CPU TechTalk:x86/x64架构概述

    

    大家好,这里是第五位面壁者,最近我在参照Intel发布的软件开发手册学习x86/x64架构,为了检验学习成果,将所学习内容总结成PPT,以视频录播的形式和大家分享,视频原版会放在b站,PPT和视频语音文字版本将会在微信公众号/CSDN/知乎同步更新,欢迎各位同行和大佬参观指导。

    在开始正式内容之前,我们先来科普和辨析一些名词。首先是我们常说的x86/arm/mips,都指代的是这个代号所代表的指令集架构,英文对应的就是ISA,Instruction Set Architecture。按照维基百科的定义,所谓的ISA定义了,软件在某个特定处理器上的运行环境和执行策略,这些定义包含但是不限于数据类型,指令,寄存器,寻址方式,内存架构,中断和异常处理以及外部IO。另一个容易和ISA混淆的概念就是微架构microarchitecture,所谓微架构,是某个ISA在硬件上的某个具体实现。比如说Intel第二代酷睿处理器i3/i5/i7,宣称使用的是SandyBridge微架构,这里的SandyBridge就是Intel对x86 ISA的一个具体实现;其次要辨析一下Core/CPU/处理器和SoC,很多场合下包含一些科技新闻里这几个词都被混起来用,但其实他们实际上指代的不同层级的实体。在最一开始,CPU和处理器是等价的,IO部分由另外单独的芯片负责,比如在PC里NB负责高速IO,SB负责低速IO;后来为了提高性能,尝试增加处理器个数提高性能,这些处理器共享一套IO,就是上一期我们讲过的对称多处理器系统,后来制程改进使得芯片集成度大幅度提高,CPU/NB/SB都可以集成到一颗单独的SoC上,我们倾向于把这颗SoC叫做某某处理器,每个SoC的CPU部分里有搭载了若干个CPU内核,也就是Core;将上面的这些都联动起来我们举个例子,比如说咱们中国大陆芯片设计公司兆芯出品的ZX-E,这是一颗兼容x86 ISA的SoC,CPU部分搭载了4-8颗采用LuJiaZui微架构的Core;所以这几个概念起始分界并不是那么清晰,在大部分场合下可以混用,但是在需要区分的场合还是最好能做到区分。

最后我们再回归到我们今天的主题x86,与这相关也有几个容易让人混淆的名词;IA32是Intel Architecture 32bit的简称,在英特尔公司1985年推出的80386处理器中首先采用,通常也被称为x86,i386;后来Intel联合惠普推出IA64架构,也就是所谓的安腾但是这个新的64位架构不兼容之前的IA32体系下的软件,导致市场特别惨淡。AMD乘机推出了可以兼容IA32体系的64位ISA,并称其为AMD64,Intel随后无奈也推出了兼容IA32的64位架构,在自家文档里叫做Intel 64,业界也有叫EMT64的;所以除了IA64以外,其他的几个64其实是一码事。

    然后还有两个微字辈儿的名词:微操作和微码。

    这要从CISC和RISC说起。二者的特点大致就是,CISC采用不等长指令,单个指令可以做很多事情,对于编译器友好,但是解码器的硬件实现就会变得特别复杂;RISC采用等长指令, 每条指令只做最基本和简单的动作,然后通过这些基本简单的指令的集和完成复杂任务,这对编译器提出了不小的挑战,但是在硬件上很容易实现。

    为了做到既可以保证兼容,又能够降低硬件的实现难度,Intel在80年代后期就开始尝试使用微码指令解码器代替原始的硬件连线解码器。复杂指令集CISC指令繁多,而且长度不一,如果都用硬件连线实现,成本和难度无法估量,所以Intel就想到了一个办法,对外,CPU还是使用兼容CISC的不等长指令,用以保证兼容性,但是在内部,解码器会把不等长的复杂指令分解成若干个类似于RISC的精简微操作,Micro-Op。比如说这条pop ebx是一条复杂指令,可以被分解为如右图所示的三个微操作。稍微解释一下,pop括号ebx,指的是把盏顶指针对应的数据弹出到以ebx里内容为地址的内存位置上。换成微操作就是,先把esp指向的数据放到临时内存地址temp里,然后在把temp里的数据放到ebx指向的内存地址里,然后再把栈顶指针esp加4,表示栈顶向上移动4个字节,这样就完成了一次数据出栈。

    CISC指令变成微操作的结果叫做微码,放在CPU里面一颗叫做MicroCode的ROM里。一开始,这颗ROM一旦出厂就不能更改了,后来在1994年的奔腾处理器上出现了除零指令错误,于是在后面的处理器里,新设计了一小块比较小的RAM,BIOS和OS可以通过特殊的指令将CPU厂商发布的微码补丁打到这块SRAM上对已经有的微码修修补补,打补丁的过程需要CPU的解码器支持,具体实现方式Intel和AMD都没说,毕竟涉及商业机密。但是在Intel手册里有提供进行微码更新的软件接口和汇编示例程序,感兴趣的朋友可以研究一下。回头再来看,我们发现微码和微操作的引入让x86 CPU在内部变成了一台RISC处理器;其实现代的RISC指令集也不断的在增加指令,新增加的指令也不像之前那么简洁,二者变得不再那么的泾渭分明了。这可能就是所谓的天下大势,分久必合合久必分吧。

    

    然后我们来看一下,x86架构整个的一个知识体系,包括但是不仅限于图中的这些,而且这里面出现每一个名词几乎都由一本单独的spec才能说清楚,可以看到学习之路漫漫啊。处理器这边,先要搞清楚x86处理器的四种基本工作模式和三种子模式的区别以及它们之间是怎么切换的,然后需要搞清楚不同模式下使用的指令集和寄存器。

    这才是开胃菜,往后的才是硬骨头,内存管理/中断异常处理/任务管理算是整个处理器篇的重中之重了,这三座大山不征服了,基本上根本无法理解操作系统的运行原理

后面的缓存控制就是高阶课程了,说实话高性能处理器的重点难点就是缓存一致性和数据完整性,缓存的原理和缓存一致性协议就算是不精通也起码要做到了解吧。

    硬件控制是我自己想出来的一个标签,包含了初始化/电源管理和多处理器管理三个部分,如果你的工作需要fine tune平台功耗或者是会用到NUMA平台,这三个是必须掌握的

    虚拟化,这个不用我多说了,如今各种各样的云服务离不开虚拟化,如果你是相关从业者,还是有必要了解一下底层运行原理的

剩下的MCA是搞硬件纠错的,PMC是进行性能统计的,TSC是时间戳counter,debug指的是Intel的debug框架,这几个东西太x86,资料比较少,也比较小众,各家的实现方式也不一样,我们只要了解用法,底层原理没必要也没渠道让我们研究。

    IO篇里,其他都可以不看,PCI/PCIE一定要看一下,如果x86 CPU是大脑,那PCI/PCIE就是躯干,现代的x86 CPU依赖PCI/PCIE框架管理外部IO,事实上大部分的x86从业者入职后看的第一本Spec就是PCI3.0。

    后面的固件和软件就看个人所需了,其实这些内容之间都是交叉的,比如处理器篇里的电源管理里P State/C State的概念,最完整的概念其实是在ACPI Spec里,硬件实现里还依赖CPU通过SVIDBUS向VRM不断的发送命令调整CPU核心电压。

    好了,基本框架我们已经搭好了,往后的实际我们就一点一点的填充细节吧。

    我们先来看一下,x86处理器的工作模式,上电或者reset后默认都是工作在实模式下,这也是兼容16位8086处理器的工作模式,随后我们可以通过写Control Register 0的bit0,也就是Protect mode enable进入保护模式。在保护模式下,我们可以清掉CR0 bit0再回到实模式,也可以把EFLAGS寄存器的VM位置1,然后切换到8086软件所述的TSS,也就是task-state structure,后面再任务管理我们会讲到,就可以在保护模式下运行8086兼容的软件了。

    在上述三种模式的任意时刻,当发生了SMI,处理器就会进入到System Management Mode,进入SMM后,处理器会先保存当前运行程序的上下文,然后切换到一个独立的地址空间,去运行SMI handler,随后再回到SMI发生时候的处理器模式。上述的一切对于OS和应用程序来说都是透明的,一般来说,SMM用来进行处理器电源管理和OEM特定的一些设定。

    在支持Intel64架构的处理器里,除了刚才的这些处理器模式以外,又增加了一个IA-32e Mode,这个模式下面又由两个子模式:兼容模式和64bit模式。处理器在上电或者重启之后,还是先进入实模式,然后去写CR0的bit0 PE进入保护模式,如果想要进入IA-32e Mode的话,软件需要先关掉Paging,然后建立IA-32e mode需要的各种数据结构,然后再去置CR0的bit31,打开Paging机制,然后去写IA32_EFFR这个MSR的bit6LME,这里的LME全程应该是long mode enable,之前AMD把64bit 处理器模式叫做long mode,MSR也是按照这个名称定义的,Intel就直接拿来用了。当bit10LMA为1的时候,就代表整个处理器已经工作在IA-32e Mode下了。

    在IA32e Mode下,如果处理器发现即将调用的Code Segment是64bit的,那么就会自动切到64bit mode,如果是16bit/32bit Code Segment就会自动调到兼容模式   我们会在内存管理和任务管理阐述上述过程的底层原理,在这个阶段,大家只要粗略有个概念即可。IA-32e Mode有两点需要强调一下,第一就是从保护模式需要经过兼容模式才能进入64bit mode,不能直接从保护模式跳到64bit mode;第二,SMM依旧不受限制,在上述任何情况下发生SMI,都会导致处理器进入SMM。

    然后我们看一下x86的寄存器,本着从简入繁的原则,我们还是从8086开始。

    如果本科时候学过微机原理,相信大家对于图里的这些东西都不陌生,简单过一遍,先是通用寄存器组共有八个,AX也叫累加器,是很多加法和乘法的缺省寄存器,也可以用来存放函数的返回值。BX是基址寄存器,主要用于内存寻址时候存放基地址;CX是计数寄存器,在一些需要进行循环操作的指令里存放counter数值;DX是数据寄存器,在乘除运算里面做默认操作数;SI/DI分别是源和目的索引寄存器,BP/SP是和堆栈指针寄存器,BP指向栈低,SP指向栈顶,这四个寄存器一两句说不清楚,一会我们通过反汇编研究C语言函数调用过程举例说明。

    然后就是Flag状态寄存器,主要用于提供指令程序执行的状态和相应的控制。段寄存器是用于x86的分段内存管理模式,结合程序计数器IP和SP可以使用两个16位寄存器寻址1M的地址空间,寻址方法就是把相应段寄存器里面的内容左移四位然后加上IP或者SP,CS是默认代码段,DS/ES/FS/FS都是数据段,SS是堆栈段,每个段大小位64K。这一段的的内容很基础,我刚才讲的也很简略,如果有不清楚个中细节的同学,我建议大家关掉我这个视频,先找本8086微机原理然后再去看后面的内容效果会好一些。

    然后我们就到了286时代,286引入了保护模式,实模式时代只能运行单任务操作系统例如DOS,保护模式加入了对多任务的支持,所以寄存器也做相应的改变。首先增加了一个叫做MSW的寄存器用于模式切换,然后定义了一堆链表结构数据,x86称之为descriptor描述符来协助OS进行任务管理和中断管理,这些链表的起始位置就放在这四个寄存器里,后面讲内存管理时候我们再细说,这时候的段寄存器存放的也不是段基地址了,还是GDT/IDT里面的index,Intel管其位段选择符,Segment Seclector,同时包含模式还引入了特权等级的概念,不同的任务或者程序可以处于不同的特权等级之下,为了应付这些变化,Flag寄存器也做了一些扩展,要注意286时代还是16bit的,此时的保护模式只能算是个中间过渡产物,还不是完全体。

    然后从386开始,就进入到了32位时代,然后不断的演化成了一个庞然大物。之前的存在的绝大多数十六位寄存器都扩展成了32位,段寄存器除外,所以大家可以看到AX这些都变成EAX,但是CS还是CS,毕竟保护模式下的段寄存器也就是当作个Table Index,16位也够用了。386往后还有一个变化就是从单处理器系统开始向多处理器系统的转变,所以之前的8259A变得不够用了,引入了APIC体系,所以先多了一个Local APIC以及后续的x2APIC0000000000,同时x86架构还不断在实现新的指令,于是就引入XMM和FPU相关的一堆寄存器,同时引入了MSR的概念,可用用作控制CPU运行,功能开关,调试,监控等,这些寄存器只能透过RDMSR/WRMSR两个指令访问。

    然后到了64位时代,说实话64位架构和32位架构相比变化还是挺大的。显而易见的是,很多之前是32位的都升级到了64位,向EAX变成了RAX,而且还增加了R8-R15这八个通用寄存器。段寄存器还是没变,也不需要改变了,因为整个分段模式基本在64bit架构下是disable的,还有其他的诸多变化,后续我们在讲相关章节的时候再细说,大家在这里能做到对每个模式下的寄存器有个大概印象就好。

    然后我们再稍微讲一下x86指令格式,如果不是搞底层开发或者搞逆向工程的一般来说用不到,但是不妨碍我们稍微了解一下。

    X86使用的是变长指令集,相当于一条机器码的长度和格式不固定的,长度可以从1到15,而且指令和寄存器又多,指令编码复杂度就自然而然的上去了。

    但如果我们能抓住重点,也是比较容易理解的,首先看右上角,是x86指令整体的一个组成格式,分为Instruction Prefix,opcode,ModRM,SIB,dispalcement和immediate六个部分。

    注意x86是小端模式,高位数据放在高地址,低位数据放在低地址,这里从左到右的六个部分在内存里的存放顺序如中间这张图所示。六个部分中,只有opcode是必须存在的,其他都是可选的,所以我们先从opcode入手。

    Opcode也分为单字节/双字节/三字节三种情况,如何区分这三种情况呢,左边的图给了我们提示,先不管prefix,如果opcode开头不是0F打头,那么直接对应单字节的,如果是0F打头但是后面跟着的不是38或者3A,那就是双字节opcode,如果是0F 38或者0F 3A打头的,那么就是三字节opcode。这一招,在刚开始还好用,但是慢慢随着指令和寄存器以及寄存器位数的增多,这种编码方式开始不够用了,为了在扩展opcode编码范围的同时还可以保持兼容,于是又增加了prefix,老的opcode如果加上了prefix就有了新的含义,这样的话我们解码器的判断就多了一层,先是看看第一个指令字节是不是左边这些,如果是的话,就需要按照prefix+opcode后的bitmap却确认具体对应哪个指令了。

    在确定了opcode之后,问题又来了,那就是x86指令的操作数也是不一定,可能是一个也可能是两个或者多个,操作数类型可能是寄存器也可能是内存位置还可能是立即数,寄存器可能是八位的AL,可能是十六位的AX,还可能是三十二位的EAX,内存位置和立即数的位宽也不一定,想到这些我只能借用ZS的一句话就是“我TM真是烦死了!”。那我们确认opcode后,该怎么确认操作数的情况呢,首先我们先根据opcode或者prefix+opcode确认需要执行的指令,然后先看一下指令需要什么类型的操作数,如果指令默认一个操作数都不需要,比如说NOP或者RET,一个是空操作一个是函数返回,那么这条机器码的解码就结束,如果需要一个以上的寄存器,那么opcode后面跟着的这个字节就是ModRM。

    ModRM占用1个字节,分成三个区域,借助ModRM我们可以指导当前指令所使用的寄存器是多少位宽多少以及寻址方式如何,如果是寄存器间接寻址,还需要用到SIB Byte,通过ModRM+SIB Byte我们就可以知道这条指令所需的全部操作数信息,如果需要用到offset和立即数,就会放在后面的address displacement和immediate data里,位宽由指令本身配合ModRM和SIB Byte共同确定。之前我们说过,在64bit mode下,增加寄存器位宽和个数,那这样在64bit mode下,ModRM的编码有些不够用,所以就是又增加了一个REX prefix,用于扩展ModRM的编码范围,这样ModRM可以表示的寄存器和操作数范围就足够了。即使这样,指令编码慢慢的又又又不够用了,于是又加入了VEX和XOP prefix,之前的prefix被称为了legacy prefix。可以相信,x86的指令解码器在拿到机器码以后需要做比较复杂的判断工作才能知道具体需要执行哪些指令,虽然说使用微码技术可以简化执行单元的逻辑,但是解码单元的复杂性还是没办法降低。大家有兴趣的话可以参考intel和AMD的指令集手册仔细深入的研究,这里我只是抛砖引玉。

    刚才,我们说了很多的概念,下面我们通过反汇编的方式深入理解一下C语言的函数调用,同时理解x86一些主要寄存器的使用方法和堆栈对于程序的重要性。

    我们先看一下图中的例程,main函数调用Add子函数完成三个数字的相加,C语言里有两种参数传递方式,值传递和地址传递,我们这里显然是值传递,传递媒介是系统堆栈,读书时候老师应该都讲过,但是可能没讲过具体原理,或者讲过但是当时理解不深,今天我们通过这个例程把基于堆栈的函数参数传递和大家一起探讨一下。

    首先来讲一下我们的工具和环境,我所使用的IDE是Visual Studio,在程序里设置了两个断电1和2,右边是用于记录系统堆栈内容的一个简图,最左边用来表示EBP/ESP的所处阶段,比如现在的意思是,目前的堆栈是进入main函数之前的形态,EBP指向栈底,ESP指向栈顶,主要栈是向下生长的,   所以现在是从下往上是从low addres到high address,EBP和ESP中间的这段堆栈内存被称为当前运行函数的栈帧,里面的每个单元是存放的数值,注意VS默认加载的32位编译器,所以这里的栈指针都是32位,所以每次压栈和出栈都是4个字节。好然后我们在VS里面电debug,然后程序会停在第一个断电,调用Add子函数之前,然后我们看一下对应的反汇编代码。

    这里就是反汇编代码,首先我们要清楚,C语言中的main,它自己也是一个函数,需要操作系统的某个函数调用mian,我们称其为before main,我们可以看反汇编代码,是在19A4这里调用的_main, 然后在_main里jmp到main,在beforemain使用call调用_main的时候,会把_main之后的下一条指令的EIP存放的系统堆栈里,然后进入到main以后,首先是把当前EBP压栈,然后1421和1423就开始建立自己的栈帧,随后把beforemain时代的EBX/ESI/EDI都压栈,从142C到143C这几句是做了一个循环,相当于把栈帧初始化,除了刚才压入的那三个寄存器以外,其他的地方初始化成CC,还记不记得源代码,我们定义了两个局部变量,在栈底的两个位置做了初始化,然后需要调用Add子函数了,大家发现没,这里编译器把a和b分别赋值为ECX和EAX,然后再压栈,这里就是课本上经常说的形参实例化,大家可以看一下,下面的memory1就是在左上1454前的堆栈内存,大家对照看一下是不是符合我刚才的描述,我们可以看到_main的下一条指令地址是00A019A9,然后我们顺着mainEBP往后看,我们看到对应位置也是00A019A9,然后我们再看栈顶这边,之前算上传递的两个形参实例,我们起始一共在main的栈帧里压了5个dw,就是20byte,数一数,正好对的上,然后我们继续。

    往后的过程起始大同小异,先是用call调用一个叫做_Add的函数,然后在_Add里面jmp到Add,那在执行call指令的过程中会讲call往后的指令地址压栈,就是这里的00A01459,然后又是把main时代的EBP压栈,然后开始建立Add函数的栈帧,具体过程我就不赘述了,在栈帧建立完以后就进行add运算,可以看到Add函数里从参数x和y的地址,正好就是main函数在调用Add后压入栈的那两个形参实例化的地址,通过这种方式,main和add就完成了函数参数的值传递,然后就是加法运算,运算结果z是Add的局部变量,所以在Add的栈帧里面存放,但是大家发现没,13FE这里返回z的本质,其实是把z的值交给EAX传递的,我们在将8086寄存器的时候是不是说过,AX还会被用作传递函数返回值,就是这个意思。

    然会就到了Add函数返回的环节,先是把之前存在Add栈帧里面的main时代EBX/ESI/EDI弹出到相应的寄存器里,然会到1404,这里把EBP赋值给ESP,相当于什么,相当于把Add栈帧清空了,然会再把这里的mainEBP弹出给EBP,这样是不是就回到了main函数堆栈了,然后ret会再把EIP After call _Add弹出到EIP里,这样就跳转到1459执行去了,1459干什么呢,大家还记不记得之前的两个形参实例会压入八个byte到堆栈里,既然都执行完成了,main会在这里再把ESP恢复,注意堆栈是向下生长,所以加了8,随后把存放再EAX里的函数返回值再交给ret,main函数往后也没有什么需要执行的语句了,也进入到了弹出EXX,销毁栈帧的过程,往后和C语言的实现有关系,但是过程是类似的,感兴趣的话大家可以自行去研究。

    感谢各位坚持到这里,这期我们只是对x86架构做了一个最基本的介绍,更多的内容还请关注后续的视频。篇幅所限,今天的很多内容都介绍的很粗浅,如果有不清楚或者发现我有讲错的地方欢迎各位发私信一起探讨

    这一期内容介绍了,我们下期再见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值