第 2 章 环境搭建及基础知识

第 2 章 环境搭建及基础知识

本章介绍编写本书操作系统所需的基础知识、系统环境及环境搭建方法,大家不必在这方面耗费太多精力,本着够用就好的原则即可。

本书操作系统开发使用的系统环境是Windows 7系统,编译环境是Linux的开源发行版CentOS 6。因此,作者将借助VMware虚拟机软件在Windows 7系统下创建了一个虚拟平台,再由此平台搭载CentOS 6操作系统。虽然我们的意图是在物理平台上运行操作系统,但如果在操作系统开发初期就使用物理平台的话,代码调试工作将变得十分艰难。故此,在开发初期使用Bochs虚拟机来调试我们的操作系统是个不错的选择。

在基础知识方面,不管你是精通C语言和汇编语言、能够写出高效且晦涩的代码的大神,还是初学编程语言抱着谭浩强的《C语言程序设计》乱啃的菜鸟,都请你们静下心来读完这一章再上路。本章可以作为复习章,亦可作为提高自己知识技能的学习章,其中涉及的知识点都很重要,如果不了解这些知识,往后的内容你会学得很吃力。不经一番寒彻骨,怎得梅花扑鼻香。我们今天的止步不前,是为了明天大踏步的前进。

2.1 虚拟机及开发系统平台介绍

研发任何一款软件都需要有完整的开发环境,研发操作系统也不例外。

开发操作系统主要使用汇编语言和C语言,再加上些许灵活多变的设计思想即可。开发应用程序可以借助丰富的调试工具和系统开发库的支持,而开发操作系统一切皆需要从零做起。

随着开源免费软件大军逐渐壮大,为了避免版权问题和收费软件的麻烦,Linux家族的操作系统已成为开发环境的首选。VMware虚拟机软件以稳定、方便、灵活、功能强大等特点深受开发者们的喜爱。Windows、Linux或Mac OS系统平台都能创建出一个表现出众的虚拟平台。Linux开源操作系统与VMware虚拟机软件经常会组合在一起使用。

开源的轻量级虚拟机Bochs,不仅可以运行虚拟平台,还能够在平台运行期间对平台进行调试,从而帮助我们度过一个个难关。当然,如果你手头有其他的可调试虚拟机,只要它具有设置断点、查看内存、查看寄存器状态、反汇编内存代码等基本功能,也可以使用。希望读者能够根据自己的喜好,搭建出一个顺手的开发环境。

2.1.1 VMware的安装

VMware这款虚拟机软件想必大家并不陌生,它基本上属于开发必备软件之一。如果你正在使用Linux的某个发行版,可以选择跳过这部分内容,直接从2.1.3节看起。这部分内容主要针对Windows用户介绍虚拟机软件和编译环境。

作者使用的操作系统是Windows 7 SP1,编译环境选定为Linux的某个发行版。因此,使用VMware软件来为编译环境虚拟硬件平台是个理想的选择。VMware旗下的VMwareWorkstation和VMwarePlayer均可满足本书开发需求。对于软件版本也无过多要求,只要能顺利安装一款Linux发行版操作系统,并支持动态挂载USB设备就可以了。

注意事项

  • VMware安装完毕后,读者很可能会使用优化软件对电脑进行清理和优化,此时要特别注意,在优化过程中,优化软件可能会关闭VMware的某些自动开启的系统服务,以至于虚拟机软件有时无法连接网络和挂载USB设备。解决办法是,在运行栏内输入services.msc开启服务管理窗口,开启相关服务。如果不知道该开启哪个服务的话,就索性开启VMware软件的全部服务。
  • 在Windows 7操作系统下运行VMware软件时,尽量以管理员权限运行,否则容易报错。

2.1.2 编译环境CentOS 6

VMware软件安装后,我们将使用该软件建立虚拟硬件平台,并在虚拟平台上安装操作系统。CentOS 6是本书编译环境选用的操作系统。

  1. 系统安装

    对于操作系统,可根据个人习惯自由选定,只要是Linux的发行版皆可。作者选择CentOS操作系统,主要是由于长期的使用习惯所使。虽然CentOS系统的大部分软件不是最新的,但是对于企业来说,系统稳定更重要。而且CentOS是Red Hat的免费版,提供的维护和更新时间更长,操作界面相对简单、易使用。

  2. 开发过程中涉及的一些命令

    操作Linux类系统主要依靠终端命令实现,这点与Windows操作系统有所不同。这也是Linux类操作系统的精髓所在,不同功能的命令可以组合使用,进而实现更强大的功能。以下命令及工具大致涵盖了开发本操作系统所需。

    • 编译器和编译工具
      • gcc:GUN C语言编译器,支持C99标准并拥有独特的扩展。
      • as:GAS汇编语言编译器,用于编译AT&T格式的汇编语言。
      • ld:链接器,用于将编译文件链接成可执行文件。
      • nasm:NASM汇编语言编译器,用于编译Intel格式的汇编语言。
      • make:编译工具,根据编译脚本文件记录的内容编译程序。
    • 系统工具与命令
      • dd:复制指定大小的数据块,并在复制过程中转换数据格式。
      • mount:挂载命令,用于将U盘、光驱、软盘等存储设备挂载到指定路径上。
      • umount:卸载命令,与mount命令功能相反。
      • cp:复制命令,复制指定文件或目录。
      • sync:数据同步命令,将已缓存的数据回写到存储设备上。
      • rm:删除命令,删除指定文件或目录。
      • objdump:反汇编命令,负责将可执行文件反编译成汇编语言。
      • objcopy:文件提取命令,将源文件中的内容提取出来,再转存到目标文件中。

    以上命令和工具通常会默认安装到Linux发行版系统中。如果操作系统里没有相关命令,也无需担心,使用操作系统自带的软件更新工具(yumapt-get等),就能安装(或更新)最新版本的命令到系统中。

    注意事项

    • 在使用VMware软件创建虚拟平台时,不必为内存和硬盘分配过大的存储空间,而且硬盘可以配置成动态增长型,这样可以节省虚拟机的磁盘存储空间。
    • 由于本次开发不会使用到swap分区,那么系统就没有必要创建该分区。如果读者还打算在本系统中进行其他开发,还是创建swap分区为妙。

2.1.3 Bochs虚拟机

Bochs是一款开源的可调试虚拟机软件,在开发操作系统的初期阶段,通过它的调试功能可以为系统内核的正常运行保驾护航。

  1. Bochs环境安装

    由于这款软件仍处于完善中,新版本将会解决不少bug,对于开发操作系统的内核级软件来说,这点会比较重要。因此,在选择Bochs的软件版本时,还是相对新一些比较好,作者选择的是最新的bochs-2.6.8。请读者自行下载和安装Bochs虚拟机,这里分享一下configure工具的配置信息,仅供参考:

    ./configure --with-x11 --with-wx --enable-debugger --enable-disasm
    --enable-all-optimizations --enable-readline --enable-long-phy-address
    --enable-ltdl-install --enable-idle-hack --enable-plugins --enable-a20-pin
    --enable-x86-64 --enable-smp --enable-cpu-level=6 --enable-large-ramfile
    --enable-repeat-speedups --enable-fast-function-calls  --enable-handlers-chaining
    --enable-trace-linking --enable-configurable-msrs --enable-show-ips --enable-cpp
    --enable-debugger-gui --enable-iodebug --enable-logging --enable-assert-checks
    --enable-fpu --enable-vmx=2 --enable-svm --enable-3dnow --enable-alignment-check
    --enable-monitor-mwait --enable-avx  --enable-evex --enable-x86-debugger
    --enable-pci --enable-usb --enable-voodoo

    因为不清楚调试内核到底会使用多少功能,索性就将它们全部添加上去。在编译时可能会出现“文件不存在”错误,这时只需将后缀名为.cpp的文件克隆出一个后缀名为.cc的副本即可通过编译,请参考以下几行复制命令:

    cp misc/bximage.cpp  misc/bximage.cc
    cp iodev/hdimage/hdimage.cpp iodev/hdimage/hdimage.cc
    cp iodev/hdimage/vmware3.cpp iodev/hdimage/vmware3.cc
    cp iodev/hdimage/vmware4.cpp iodev/hdimage/vmware4.cc
    cp iodev/hdimage/vpc-img.cpp iodev/hdimage/vpc-img.cc
    cp iodev/hdimage/vbox.cpp iodev/hdimage/vbox.cc
  2. Bochs运行环境配置

    编译安装Bochs虚拟机软件后,还需要为即将实现的操作系统创建虚拟硬件环境。这个环境是通过配置文件描述的,在Bochs文件夹内已为用户准备了一个默认的系统环境配置文件.bochsrc,里面有配置选项的说明和实例可供用户参考使用。读者可以在.bochsrc文件的基础上稍作修改,配置出一个自己的虚拟平台环境。以下内容是本系统虚拟平台环境的配置信息:

    # configuration file generated by Bochs
    plugin_ctrl: unmapped=1, biosdev=1, speaker=1, extfpuirq=1, parallel=1, serial=1, iodebug=1
    config_interface: textconfig
    display_library: x
    #memory: host=2048, guest=2048
    romimage: file="/usr/local/share/bochs/BIOS-bochs-latest"
    vgaromimage: file="/usr/local/share/bochs/VGABIOS-lgpl-latest"
    boot: floppy
    floppy_bootsig_check: disabled=0
    floppya: type=1_44, 1_44="boot.img", status=inserted, write_protected=0
    # no floppyb
    ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
    ata0-master: type=none
    ata0-slave: type=none
    ata1: enabled=1, ioaddr1=0x170, ioaddr2=0x370, irq=15
    ata1-master: type=none
    ata1-slave: type=none
    ata2: enabled=0
    ata3: enabled=0
    pci: enabled=1, chipset=i440fx
    vga: extension=vbe, update_freq=5
    
    cpu: count=1:1:1, ips=4000000, quantum=16, model=corei7_haswell_4770,reset_on_triple_fault=1, cpuid_limit_winnt=0, ignore_bad_msrs=1, mwait_is_nop=0, msrs="msrs.def"
    
    cpuid: x86_64=1,level=6, mmx=1, sep=1, simd=avx512, aes=1, movbe=1, xsave=1,apic=x2apic,sha=1,movbe=1,adx=1,xsaveopt=1,avx_f16c=1,avx_fma=1,bmi=bmi2,1g_pages=1,pcid=1,fsgsbase=1,smep=1,smap=1,mwait=1,vmx=1
    cpuid: family=6, model=0x1a, stepping=5, vendor_string="GenuineIntel", brand_string="Intel(R) Core(TM) i7-4770 CPU (Haswell)"
    
    print_timestamps: enabled=0
    debugger_log: -
    magic_break: enabled=0
    port_e9_hack: enabled=0
    private_colormap: enabled=0
    clock: sync=none, time0=local, rtc_sync=0
    # no cmosimage
    # no loader
    log: -
    logprefix: %t%e%d
    debug: action=ignore
    info: action=report
    error: action=report
    panic: action=ask
    keyboard: type=mf, serial_delay=250, paste_delay=100000, user_shortcut=none
    mouse: type=ps2, enabled=0, toggle=ctrl+mbutton
    speaker: enabled=1, mode=system
    parport1: enabled=1, file=none
    parport2: enabled=0
    com1: enabled=1, mode=null
    com2: enabled=0
    com3: enabled=0
    com4: enabled=0
    
    megs: 2048

    在这段虚拟平台配置信息中,大部分内容依然使用默认设置信息。需要特殊说明的有以下几项。

    • boot:floppy:相当于设置BIOS的启动项,此处为软盘启动。
    • floppya:type=1_44,1_44="boot.img",status=inserted,write_protected=0:设置插入软盘的类型为容量1.44 MB的软盘,软盘镜像文件的文件名为boot.img,状态是已经插入,写保护开关置于关闭状态。
    • cpucpuid:这两个选项描述了处理器的相关信息,可以根据个人需求自行设定,在.bochsrc文件中也有详细说明可供参考。
    • megs:2048:设置虚拟平台的可用物理内存容量,以MB为单位。目前,Bochs虚拟软件可用的内存上限是2048 MB(2 GB),如果操作系统没有足够内存,Bochs会运行失败,失败时的提示信息大致如下所示:

      terminate called after throwing an instance of 'std::bad_alloc'
      what():  std::bad_alloc
      Aborted (core dumped)

    补充说明 如果把配置项display_library: x修改为display_library: x,options="gui_debug",将开启图形界面的调试窗口。

  3. Bochs相关的调试命令

    Bochs虚拟机软件最大的优点是,在虚拟平台运行时可以通过命令对其进行调试,表2-1罗列出了经常使用的调试命令。

    表2-1 Bochs调试命令

    指令说明举例
    b address在某物理地址上设置断点b 0x7c00
    c继续执行,直到遇到断点c
    s单步执行s
    info cpu查看寄存器信息info cpu
    r查看寄存器信息r
    sreg查看寄存器信息sreg
    creg查看寄存器信息creg
    xp /nuf addr查看内存物理地址内容xp /10bx 0x100000
    x /nuf addr查看线性地址内容x /40wd 0x90000
    u start end反汇编一段内存u 0x100000 0x100010

    n代表显示单元个数;u代表显示单元大小[bBytehWordwDWordgQWord(四字节)];f代表显示格式(x:十六进制、d:十进制、t:二进制、c:字符)。

    以上这些命令都会在今后的系统开发中使用到。如果一开始就让代码运行在物理平台上,一旦出现问题,错误分析工作会变得举步维艰,甚至连查看寄存器状态和内存区数据这类小事,都会变得茫然失措、无从下手,濒临绝望。考虑到这些原因,就先让我们的程序在Bochs虚拟机里运行一段时间,待到时机成熟后再把它移植到物理平台上运行。

2.2 汇编语言

汇编语言的书写格式大体分为两种,一种是AT&T汇编语言格式,另一种是Intel汇编语言格式。这两种书写格式并不会影响汇编指令的功能,而且它们都有相应的编译器支持。

Intel汇编语言格式书写简洁,使用起来会比较舒服,支持它的编译器有MASM编译器、NASM编译器和YASM编译器。而AT&T汇编语言格式相对来说会复杂一些,支持它的编译器是GNU的GAS编译器。

对本书操作系统而言,BootLoader部分将采用Intel格式的汇编语言编写,使用NASM编译器进行编译;操作系统的内核与应用程序将采用AT&T格式的汇编语言编写,使用GNU的GAS编译器进行编译。同时使用这两种汇编语言书写格式是有原因的,可以概括为以下两点。

  • 由于BootLoader全部使用汇编语言编写,代码量大,如果采用Intel格式的汇编语言,可以保证既书写简单又便于阅读。
  • 内核和应用程序只有一小部分关键代码必须使用汇编语言编写,绝大部分代码会使用GNU C语言编写,那么为GNU C语言搭配上AT&T格式的GNU汇编语言,可使两者更加自然流畅地相互调用,进而提高两者的互相兼容性。

C语言和汇编语言经常会出现互相调用的情况,其中汇编语言调用C语言的过程最为复杂。稍后将通过一节篇幅专门对其进行讲解。

2.2.1 AT&T汇编语言格式与Intel汇编语言格式

AT&T汇编语言格式与Intel汇编语言格式在指令的功能上并无太大区别,但在书写格式、赋值方向、前缀等方面却各有各的特点。表2-2对这两种汇编语言格式进行了对比。

表2-2 AT&T汇编语言格式与Intel汇编语言格式对比表

 

Intel汇编语言格式

AT&T汇编语言格式

书写格式

大多数编译器要求关键字必须使用大写字母书写,如:MOV AX,0x10

编译器要求关键字必须使用小写字母书写,如:mov $0x10,%ax

赋值方向

指令通常带有两个操作数,一个是目的操作数,另一个是源操作数,赋值方向从右向左。
ADD汇编指令为例,它的格式为:
ADD 目的操作数,源操作数

与Intel汇编语言格式恰恰相反,第一个是源操作数,第二个是目的操作数,赋值方向从左向右

操作数前缀

使用寄存器和立即数无需额外添加前缀。例如:
MOV CX,12

使用寄存器必须在前面添加指令前缀%,使用立即数必须在前面添加前缀$,例如:
mov $12,%cx
对于标识符变量,可以直接引用,无需添加前缀。例如:
values: .long 0x5a5a5a5a
movl values,%eax
此处的values是一个标识符变量,这条指令的意思是将values变量记录的数值0x5a5a5a5a装入寄存器eax
如果添加标识符前缀$,则说明正在引用该变量的地址。例如:
movl $values,%ebx
这条汇编指令的意思是将values变量的地址装入ebx寄存器

跳转和调用指令

远跳转指令JMP的目标地址由段地址和段内偏移组成。远调用指令CALL的目标地址同样由段地址和段内偏移组成,远返回指令RET无操作数:
CALL FAR SECTION:OFFSET
JMP FAR SECTION:OFFSET
RET

对于远跳转指令和远调用指令必须使用前缀l加以修饰,与lcall指令相对应的是远返回指令lret。例如:
lcall $section:$offset
ljmp $section:$offset
lret

内存间接寻址格式

Intel使用[ ]来表示间接寻址,完整格式为section:[base+index*scale+displacement]
其中scale的默认值为1,可取值是1、2、4、8;section用于指定段寄存器,不同情况的默认段寄存器是不同的

AT&T使用( )来表示间接寻址,格式为section: displacement(base,index,scale)
这里的sectionbaseindexscaledisplacement与Intel的使用规则相同

指令的后缀

使用内存操作数时应该对操作数的位宽加以限定,借助修饰符PTR可以限定操作数的位宽,例如:BYTE PTR代表一个字节、WORD PTR代表一个字、DWORD PTR代表一个双字等。例如:
MOV EAX,DWORD PTR [EBX]

AT&T语法中大部分指令在访问数据时都需要指明操作数的位宽,通常一个字节用b表示、一个字用w表示、一个双字用l表示、一个四字用q表示。例如:
movq %rax,%rbx
此外,跳转指令的地址标识符也可添加后缀以表示跳转方向,f表示向前跳转(forward),b表示向后跳转(back),如:
jmp 1f
1:

2.2.2 NASM编译器

想必,许多读者在学习汇编语言时都是从Intel处理器的i386汇编语言开始,使用的编译器很可能是MASM(Microsoft Macro Assembler)。本节介绍的NASM编译器在语法和书写格式上,与MASM编译器比较相似,值得说明的有以下几点。

  1. 符号[ ]

    在NASM编译器中,如果直接引用变量名或者标识符,则被编译器认为正在引用该变量的地址。如果希望访问变量里的数据,则必须使用符号[]。如果这样不太容易记忆,那么可以把它想象成C语言里的数组,数组名代表数组的起始地址,当为数组名加上符号[ ]后,就表示正在引用数组的元素。

  2. 符号$

    符号$在NASM编译器中代表当前行被编译后的地址。这么说好像不太容易理解,那么请看下面这行代码:

    jmp $

    这条汇编指令的功能是死循环,将它翻译成十六进制机器码是E9 FD FF。其中,机器码E9的意思是跳转,而机器码FD FF用于确定跳转的目标地址,由于x86处理器是以小端模式保存数据的,所以机器码转换为地址偏移值是0xfffd,即十进制数-3。从机器码E9可知,这个JMP指令完成的动作是相对跳转,跳转的目标地址是在当前指令地址减3处,这条指令的长度为3个字节,所以处理器又回到这条指令处重新执行。符号$在上述过程中指的是机器码E9之前的位置。

  3. 符号$$

    明白了符号$,那么,符号$$又是什么意思呢?其实,它代表一个Section(节)起始处被编译后的地址,也就是这个节的起始地址。编写小段的汇编程序时,通常使用一个Section即可,只有在编写复杂程序时,才会用到多个Section。Section既可以是数据段,也可以是代码段。不能把Section比喻成函数,这是不恰当的。

    提示 在编写代码的过程中,时常使用代码$-$$,它表示本行程序距离Section起始处的偏移。如果只有一个节,它便表示本行程序距离程序起始处的距离。在第3章中,我们会把它与关键字times联合使用,来计算将要填充的数据长度,示例代码如下:

    times 512 - ($ - $$) db 0

2.2.3 使用汇编语言调用C语言的函数

在开发操作系统时,常常会从汇编程序跳转至C语言的函数中执行。比如,从系统引导程序(汇编程序)跳转到系统内核主函数中,或者从中断处理入口程序(汇编程序)跳转到中断处理函数(属于中断上半部)中等。这些汇编语言调用C语言的过程都会涉及函数的调用约定、参数的传递方式、函数的调用方式等技术细节,下面就来逐一讲解这些知识点。

  1. 函数的调用方式

    汇编语言调用函数的方式并没有想象中的那么复杂,通过汇编指令JMPCALLRET及其变种指令就可实现。为了更好理解整个调用过程,请先看下面这段代码:

    int test()
    {
        int i = 0;
        i = 1 + 2;
        return i;
    }
    int main()
    {
        test();
        return 0;
    }

    这段程序非常简单,唯一要注意的地方是主函数main的返回值,此处建议主函数的返回值使用int类型,而不要使用void或者其他类型。虽然主函数执行到return 0以后就跟我们没有关系了,但在回收进程的过程中可能要求主函数要有返回值,或者某些场合会用到主函数的返回值。考虑到上述原因,请读者尽量使用int类型,如果处于某种特殊的、可预测的环境,则无需遵照此条建议。

    接下来,反汇编这段代码编译出的程序,让我们从汇编语言的角度去看看函数test的调用过程。使用objdump命令可以把目标程序反编译成汇编语言,该命令提供了诸多参数,通过这些参数可以从目标程序中反编译出各类想要的数据信息。读者可以参考以下命令对test程序进行反汇编:

    objdump -d test

    经过objdump命令的反编译工作后,程序代码段内的数据将会以汇编语言形式显示出来。过滤掉多余的代码后,以下是test函数和main函数的反汇编代码片段:

    0000000000400474 <test>:
      400474:    55                       push   %rbp
      400475:    48 89 e5                 mov    %rsp,%rbp
      400478:    c7 45 fc 00 00 00 00     movl   $0x0,-0x4(%rbp)
      40047f:    c7 45 fc 03 00 00 00     movl   $0x3,-0x4(%rbp)
      400486:    8b 45 fc                 mov    -0x4(%rbp),%eax
      400489:    c9                       leaveq
      40048a:    c3                       retq
    000000000040048b <main>:
      40048b:    55                       push   %rbp
      40048c:    48 89 e5                 mov    %rsp,%rbp
      40048f:    b8 00 00 00 00           mov    $0x0,%eax
      400494:    e8 db ff ff ff           callq  400474<test>
      400499:    b8 00 00 00 00           mov    $0x0,%eax
      40049e:    c9                       leaveq
      40049f:    c3                       retq

    这段代码中的000000000040048b<main> :是程序的主函数main,函数名前面的十六进制数000000000040048b是函数的起始地址,每个数字占4位宽度共16个数字,这也间接说明该程序运行在16×4 = 64位地址宽度下。

    乍一看,有好多个%符号。还记得2.2.1节里讲的AT&T汇编语法格式吗?这就是引用寄存器时必须在前面添加的符号前缀。还有一些汇编指令加入了后缀字母lq,字母l表示操作数的位宽是32位(一个双字),字母q表示操作数的位宽是64位(一个四字)。

    此段中的代码leaveq等效于movq %rbp, %rsp; popq %rbp;,其中的rsp表示64位寄存器,它是32位寄存器ESP的扩展,其他通用寄存器同理。代码callq 400474的意思是跳转到test函数里执行,由此看来,汇编语言调用C语言的函数还是非常简单的。如果使用JMP汇编指令替换CALL指令,依然可以获得同样的效果。从它们的区别来看,CALL指令会把其后的那条指令的地址压入栈中,作为调用的返回地址,也就是代码中的0000000000400499地址处,随后再跳转至test函数里执行,而JMP指令却不会把返回地址0000000000400499压入栈中。一旦test函数执行完毕,便会执行代码retq把栈中的返回地址弹出到RIP寄存器中,进而返回到主函数main中继续执行。由于JMP指令没有返回地址入栈的操作,通过以下伪代码即可替代CALL指令:

    pushq    $0x0000000000400499
    jmpq     400474 <test>

    CALL指令还可以被RET指令所取代,在执行RET指令时,该指令会弹出栈中保存的返回地址,并从返回地址处继续执行。根据RET指令的执行动作,可先将返回地址0000000000400499压入栈中,再把test函数的入口地址0000000000400474压入栈中,此时跳转地址和返回地址均已存入栈中,紧接着执行RET指令,以调用返回的形式从主函数main“返回”到test函数。以下是RET指令取代CALL指令的伪代码:

    pushq $0x0000000000400499
    pushq $0x0000000000400474
    retq

    整个实现过程是不是没有想象中的那么困难?当掌握了汇编指令的原理后,任何指令皆可灵活运用,希望本节内容可以启发读者的设计灵感!

  2. 函数的调用约定

    函数的调用约定描述了执行函数时返回地址和参数的出入栈规律。不同公司开发的C语言编译器都有各自的函数调用约定,而且这些调用约定的差异性很大。随着IBM兼容机对市场进行洗牌后,微软操作系统和编程工具占据了统治地位。除微软之外,仍有零星的几家公司和开源项目GNU C在维护自己的调用约定。下面将介绍几款比较流行的函数调用约定。

    • stdcall调用约定

      • 在调用函数时,参数将按照从右向左的顺序依次压入栈中,例如下面的function函数,其参数入栈顺序依次是secondfirst

        int function(int first,int second)
      • 函数的栈平衡操作(参数出栈操作)是由被调用函数完成的。通过代码retn x可在函数返回时从栈中弹出x字节的数据。当CPU执行RET指令时,处理器会自动将栈指针寄存器ESP向上移动x个字节,来模拟栈的弹出操作。例如上面的function函数,当function函数返回时,它会执行该指令把参数secondfirst从栈中弹出来,再到返回地址处继续执行。

      • 在函数的编译过程中,编译器会在函数名前用下划线修饰,其后用符号@修饰,并加上入栈的字节数,因此函数function最终会被编译为_function@8
    • cdecl调用约定

      • cdecl调用约定的参数压栈顺序与stdcall相同,皆是按照从右向左的顺序将参数压入栈中。
      • 函数的栈平衡操作是由调用函数完成的,这点与stdcall恰恰相反。stdcall调用约定使用代码retn x平衡栈,而cdecl调用约定则通常会借助代码leavepop或向上移动栈指针等方法来平衡栈。
      • 每个函数调用者都含有平衡栈的代码,因此编译生成的可执行文件会较stdcall调用约定生成的文件大。

        cdecl是GNU C编译器的默认调用约定。但GNU C在64位系统环境下,却使用寄存器作为函数参数的传递方式。函数调用者按照从左向右的顺序依次将前6个整型参数放在通用寄存器RDI、RSI、RDX、RCX、R8和R9中;同时,寄存器XMM0~XMM7用来保存浮点变量,而RAX寄存器则用于保存函数的返回值,函数调用者负责平衡栈。

    • fastcall调用约定
      • fastcall调用约定要求函数参数尽可能使用通用寄存器ECX和EDX来传递参数,通常是前两个int类型的参数或较小的参数,剩余参数再按照从右向左的顺序逐个压入栈中。
      • 函数的栈平衡操作由被调用函数负责完成。

    除此之外,还有很多调用约定,如thiscall、nakedcall、pascal等,有兴趣的读者可以自行研究。

  3. 参数传递方式

    在知晓函数的调用约定后不难发现,参数的传递方式无外乎两种,一种是寄存器传递方式,另一种是内存传递方式。由于这两种参数传递方式在通常情况下都可以满足开发要求,所以参数的传递方式并不会被特殊关注。但在编写操作系统的过程中存在许多要求苛刻的场景,使得我们不得不掌握这两种参数传递方式的特点。

    • 寄存器传递方式。寄存器传递方式就是通过寄存器来传递函数的参数。此种传递方式的优点是执行速度快,只有少数调用约定默认使用寄存器来传递参数的,而绝大部分编译器需要特殊指定传递参数的寄存器 。

      在基于x86体系结构的Linux内核中,系统调用API一般会使用寄存器传递方式。因为,应用层空间与内核层空间是相隔离的,若想从应用层把参数传递至内核层,最便捷的方法是通过寄存器来携带参数,否则就只能大费周折地在两个层之间搬运数据。更详细的解释会在第4章中给出。

    • 内存传递方式。在大多数情况下,函数参数都是以压栈方式传递到目标函数中的。

      同在x86体系结构的Linux内核中,中断处理过程和异常处理过程都会使用内存传参方式。(从Linux 2.6开始逐渐改为寄存器传递方式。)因为从中断/异常产生到调用相应的处理,这期间的过渡代码全部由汇编语言编写。在汇编语言跳转至C语言函数的过程中,C语言函数使用栈来传递参数,为了保证两种开发语言的无缝衔接,在汇编代码中必须把参数压入栈中,然后再跳转到C语言实现的中断处理函数中执行。

    以上内容均是基于x86体系结构的参数传递方式。而在x64体系结构下,大多数编译器选择寄存器传参方式。

2.3 C语言

我想绝大部分读者对C语言并不陌生,但由于它的灵活性仅次于变幻莫测的汇编语言,即使作者本人也不敢说熟练掌握或精通C语言。由于个人能力有限,下面仅对本书操作系统的主要开发语言(GNU C语言)进行讲解,整个讲解过程侧重于内嵌汇编语言和标准C语言扩展两个方面。

2.3.1 GNU C内嵌汇编语言

在很多操作系统开发场景中,C语言依然无法完全代替汇编语言。例如,操作某些特殊的CPU寄存器、操作主板上的某些IO端口或者对性能要求极为苛刻的场景等,此时我们必须在C语言内嵌入汇编语言来满足上述要求。

GNU C语言提供了关键字asm来声明代码是内嵌的汇编语句,如下面这行代码:

#define nop()    __asm__ __volatile__ ("nop    \n\t")

这条内嵌汇编语句的作用可从函数名中知晓,它正是nop函数(空操作函数)的实现,同时该函数也是本书系统内核支持的一个库函数。那就让我们从nop函数入手,开启GNU C内嵌汇编语言的学习之旅。

nop函数中可知,C语言使用关键字__asm____volatile__对汇编语句加以修饰,这两个关键字在C语言内嵌汇编语句时经常使用。

  • __asm__关键字:用于声明这行代码是一个内嵌汇编表达式,它是关键字asm的宏定义(#define __asm__ asm)。故此,它是内嵌汇编语言必不可少的关键字,任何内嵌的汇编表达式都以此关键字作为开头;如果希望编写符合ANSI C标准的代码(即与ANSI C标准相兼容),那么建议使用关键字__asm__
  • __volatile__关键字:其作用是告诉编译器此行代码不能被编译器优化,编译时保持代码原状。由此看来,它也是内嵌汇编语言不可或缺的关键字,否则经过编译器优化后,汇编语句很可能被修改以至于无法达到预期的执行效果。如果期望编写处符合ANSI C标准的程序(即与ANSI C标准兼容),那么建议使用关键字__volatile__

GNU C语言的内嵌汇编表达式并非像nop函数一般简单,它有着极为复杂的书写格式。接下来将书写格式分为内嵌汇编表达式、操作约束和修饰符、序号占位符三部分进行讲解。

  1. 内嵌汇编表达式

    尽管C语言经过汇编阶段后会被解释成汇编语言,但两者毕竟是不同的开发语言,为了在C语言内融入一段汇编代码片段,那就必须在每次嵌入汇编代码前做一番准备工作,因此在C语言里嵌入汇编代码要比纯粹使用汇编代码复杂得多。嵌入前的准备工作主要负责确定寄存器的分配情况、与C程序的融合情况等细节,这些内容大部分需要在内嵌的汇编表达式中显式标明出来。

    GNU C语言的内嵌汇编表达式由4部分构成,它们之间使用“:”号分隔,其完整格式为:

    指令部分:输出部分:输入部分:损坏部分

    如果将内嵌汇编表达式当作函数,指令部分是函数中的代码,输入部分用于向函数传入参数,而输出部分则可以理解为函数的返回值。以下是这4部分功能的详细解释。

    • 指令部分是汇编代码本身,其书写格式与AT&T汇编语言程序的书写格式基本相同,但也存在些许不同之处。指令部分是内嵌汇编表达式的必填项,而其他部分视具体情况而定,如果不需要的话则可以直接忽略。在最简单的情况下,指令部分与常规汇编语句基本相同,如nop函数。

      指令部分的编写规则要求是:当指令表达式中存在多条汇编代码时,可全部书写在一对双引号中;亦可将汇编代码放在多对双引号中。如果将所有指令编写在同一双引号中,那么相邻两条指令间必须使用分号(;)或换行符(\n)分隔。如果使用换行符,通常在其后还会紧跟一个制表符(\t)。当汇编代码引用寄存器时,必须在寄存器名前再添加一个 % 符,以表示对寄存器的引用,例如代码"movl $0x10,%%eax"

    • 输出部分紧接在指令部分之后,这部分记录着指令部分的输出信息,其格式为:“输出操作约束”(输出表达式),“输出操作约束”(输出表达式),……。格式中的输出操作约束和输出表达式成对出现,整个输出部分可包含多条输出信息,每条信息之间必须使用逗号“,”分隔开。

      • 括号内的输出表达式部分主要负责保存指令部分的执行结果。通常情况下,输出表达式是一个变量。
      • 双引号内的部分,被称为“输出操作约束”,也可简称为“输出约束”。输出约束部分必须使用等号“=”或加号“+”进行修饰。这两个符号的区别是,等号“=”意味着输出表达式是一个纯粹的输出操作,加号“+”意味着输出表达式既用于输出操作,又用于输入操作。不论是等号“=”还是加号“+”,它们只能用在输出部分,不能出现在输入部分,而且是可读写的。关于输出约束的更多内容,将在“操作约束和修饰符”中进行补充。
    • 输入部分记录着指令部分的输入信息,其格式为:“输入操作约束”(输入表达式),“输入操作约束”(输入表达式),……。格式中的输入操作约束与输入表达式同样要求成对出现,整个输入部分亦可包含多条输入信息,并用逗号“,”分隔开。在输入操作约束中不允许使用等号“=”和加号“+”,因此输入部分是只读的。
    • 损坏部分描述了在指令部分执行的过程中,将被修改的寄存器、内存空间或标志寄存器,并且这些修改部分并未在输出部分和输入部分出现过,格式为:“损坏描述”,“损坏描述”,……。如果需要声明多个寄存器,则必须使用逗号“,”将它们分隔开,这点与输入/输出部分一致。

      • 寄存器修改通知。这种情况一般发生在寄存器出现于指令部分,又不是输入/输出操作表达式指定的寄存器,更不是编译器为 rg 约束选择的寄存器。如果该寄存器被指令部分所修改,那么就应该在损坏部分加以描述,比如下面这行代码:

        __asm__ __volatile__ ("movl %0,%%ecx"::"a"(__tmp):"cx");

        这段汇编表达式的指令部分修改了寄存器ECX的值,却未被任何输入/输出部分所记录,那么必须在损坏部分加以描述,一旦编译器发现后续代码还要使用它,便会在内嵌汇编语句的过程中做好数据保存与恢复工作。如果未在损坏部分描述,则很可能会影响后续程序的执行结果。

        注意,已在损坏部分声明的寄存器,不能作为输入/输出操作表达式的寄存器约束,也不会被指派为 qrg 约束的寄存器。如果在输入/输出操作表达式中已明确选定寄存器,或者使用 qrg 约束让编译器指派寄存器时,编译器对这些寄存器的状态非常清楚,它知道哪些寄存器将会被修改。除此之外,编译器对指令部分修改的寄存器却一无所知。

      • 内存修改通知。除了寄存器的内容会被篡改外,内存中的数据同样会被修改。如果一个内嵌汇编语句的指令部分修改了内存数据,或者在内嵌汇编表达式出现的地方,内存数据可能发生改变,并且被修改的内存未使用 m 约束。此时,应该在损坏部分使用字符串 memory ,向编译器声明内存会发生改变。

        如果损坏部分已经使用 memory 对内存加以约束,那么编译器会保证在执行汇编表达式之后,重新向寄存器装载已引用过的内存空间,而非使用寄存器中的副本,以防止内存与副本中的数据不一致。

      • 标志寄存器修改通知。当内嵌汇编表达式中包含影响标志寄存器R|EFLAGS的指令时,必须在损坏部分使用 cc 来向编译器声明这一点。

  2. 操作约束和修饰符

    每个输入/输出表达式都必须指定自身的操作约束。操作约束的类型可以细分为寄存器约束、内存约束和立即数约束。在输出表达式中,还有限定寄存器操作的修饰符。

    • 寄存器约束限定了表达式的载体是一个寄存器,这个寄存器可以明确指派,亦可模糊指派再由编译器自行分配。寄存器约束可使用寄存器的全名,也可以使用寄存器的缩写名称,如下所示:

      __asm__ __volatile__("movl %0,%%cr0"::"eax"(cr0));
      __asm__ __volatile__("movl %0,%%cr0"::"a"(cr0));

      如果使用寄存器的缩写名称,那么编译器会根据指令部分的汇编代码来确定寄存器的实际位宽。表2-3记录了常用的约束缩写名称。

      表2-3 常用约束缩写名称表

      缩写描述
      r任何输入/输出型的寄存器
      q从EAX/EBX/ECX/EDX中指派一个寄存器
      g寄存器或内存空间
      m内存空间-f-选用浮点寄存器
      a使用RAX/EAX/AX/AL寄存器
      b使用RBX/EBX/BX/BL寄存器
      c使用RCX/ECX/CX/CL寄存器
      d使用RDX/EDX/DX/DL寄存器
      D使用RDI/EDI/DI寄存器
      S使用RSI/ESI/SI寄存器
      i一个整数类型的立即数
      F一个浮点类型的立即数
    • 内存约束限定了表达式的载体是一个内存空间,使用约束名 m 表示。例如以下内嵌汇编表达式:

      __asm__ __volatile__ ("sgdt %0":"=m"(__gdt_addr)::);
      __asm__ __volatile__ ("lgdt %0"::"m"(__gdt_addr));
    • 立即数约束只能用于输入部分,它限定了表达式的载体是一个数值,如果不想借助任何寄存器或内存,那么可以使用立即数约束,比如下面这行代码:

      __asm__ __volatile__("movl %0,%%ebx"::"i"(50));

      使用约束名 i 限定输入表达式是一个整数类型的立即数,如果希望限定输入表达式是一个浮点数类型的立即数,则使用约束名 F 。立即数约束只能使用在输入部分。

    • 修饰符只可用在输出部分,除了等号 = 和加号 + 外,还有 & 符。符号 & 只能写在输出约束部分的第二个字符位置上,即只能位于=+ 之后,它告诉编译器不得为任何输入操作表达式分配该寄存器。因为编译器会在输入部分赋值前,先对 & 符号修饰的寄存器进行赋值,一旦后面的输入操作表达式向该寄存器赋值,将会造成输入和输出数据混乱。

    补充说明 只有在输入约束中使用过模糊约束(使用qrg等约束缩写)时,在输出约束中使用符号&修饰才有意义!如果所有输入操作表达式都明确指派了寄存器,那么输出约束再使用符号 & 就没有任何意义。如果没有使用修饰符 &,那就意味着编译器将先对输入部分进行赋值,当指令部分执行结束后,再对输出部分进行操作。

  3. 序号占位符

    序号占位符是输入/输出操作约束的数值映射,每个内嵌汇编表达式最多只有10条输入/输出约束,这些约束按照书写顺序依次被映射为序号0~9。如果指令部分想引用序号占位符,必须使用百分号%前缀加以修饰,例如序号占位符%0对应第1个操作约束,序号占位符%1对应第2个操作约束,依次类推。指令部分为了区分序号占位符和寄存器,特使用两个百分号(%%)对寄存器加以修饰。在编译时,编译器会将每个占位符代表的表达式替换到相应的寄存器或内存中。

    指令部分在引用序号占位符时,可以根据需要指定操作位宽是字节或者字,也可以指定操作的字节位置,即在%与序号占位符之间插入字母b表示操作最低字节,或插入字母h表示操作次低字节。

2.3.2 GNU C语言对标准C语言的扩展

为了提高C语言的易用性和开发效率,GNU C语言在标准C语言的基础上引入了诸多人性化的扩展。下面主要讲解今后开发操作系统将会涉及的技巧,和平时研发过程中使用频率比较高的内容。

  1. 柔性数组成员(或称零长数组、变长数组)

    GNU C 语言允许使用长度为0的数组来增强结构体的灵活性,其在动态创建结构体时有着非常明显的优势,例如下面这几行代码:

    struct s {int n;long d[0];};
    int m = 数值;
    struct s *p = malloc(sizeof (struct s) + sizeof (long [m]));

    struct s结构体中的数组成员变量d在作用上与指针极为相似,但是在为指针p开辟存储空间时却仅需执行一次malloc函数。由此可见,柔性数组成员不仅能够减少内存空间的分配次数提高程序执行效率,还能有效保持结构体空间的连续性。

  2. case关键字支持范围匹配

    GNU C语言允许case关键字匹配一个数值范围,由此可以取代多级的if条件检测语句。以下这段代码的执行条件是待匹配字符为小写字母:

    case 'a'...'z':  /*from 'a' to 'z'*/
    break;
  3. typeof关键字获取变量类型

    借助关键字typeof(x)可以取得变量x的数据类型,在编写宏定义时,关键字typeof经常会派上用场。

  4. 可变参数宏

    在GNU C语言中宏函数允许使用可变参数类型,例如:

    #define pr_debug(fmt,arg...) \
    printk(fmt,##arg)

    在这段代码中,当可变参数arg被忽略或为空时,printk函数中的##操作将迫使预处理器去掉它前面的那个逗号。如果在调用宏函数时,确实提供了若干个可变参数,那么GNU C会把这些可变参数放到逗号后面,使其能够正常工作。

  5. 元素编号

    标准C语言规定数组和结构体必须按照固定顺序对成员(或元素)进行初始化赋值。GNU C语言为使数组和结构体初始化更加自由,特意放宽了此限制,使得数组可以在初始化期间借助下标对某些元素(元素可以是连续的或者不连续的)进行赋值,并在结构体初始化过程中允许使用成员名直接对成员进行赋值。与此同时,GNU C语言还允许数组和结构体按照任意顺序对成员(或元素)进行初始化赋值。以下是两者的初始化实例:

    unsigned char data[MAX] =
    {
        [0]=10,
        [10 ... 50]=100,
        [55]=55,
    };
    struct file_operations ext2_file_operations=
    {
        open:ext2_open,
        close:ext2_close,
    };

    Linux 2.6以后的内核源码已经开始使用上述初始化扩展。读者在编写Linux驱动时,推荐采用以下初始化方式:

    struct file_operations ext2_file_operations=
    {
        .read=ext2_read,
        .write=ext2_write,
    };
  6. 当前函数名

    GNU C语言为当前函数的名字准备了两个标识符,它们分别是__PRETTY__FUNCTION____FUNCTION__,其中__FUNCTION__标识符保存着函数在源码中的名字,__PRETTY__FUNCTION__标识符则保存着带有语言特色的名字。在C函数中,这两个标识符代表的函数名字相同,参考代码如下所示:

    void func_example()
    {
        printf("the function name is %s",__FUNCTION__);
    }

    在C99标准中,只规定标识符__func__能够代表函数的名字,而__FUNCTION__虽被各类编译器广泛支持,但只是__func__标识符的宏别名。

  7. 特殊属性声明

    GNU C语言还允许使用特殊属性对函数、变量和类型加以修饰,以便对它们进行手工代码优化和定制。在声明处加入关键字__attribute__((ATTRIBUTE))即可指定特殊属性,关键字中的ATTRIBUTE是属性说明,如果存在多个属性,必须使用逗号隔开。目前GNU C语言支持的属性说明有noreturnnoinlinealways_inlinepureconstnothrowformatformat_argno_instrument_functionsectionconstructordestructorusedunuseddeprecatedweakmallocaliaswarn_unused_result nonnull等。

    noreturn属性用来修饰函数,表示该函数从不返回。这会使编译器在优化代码时剔除不必要的警告信息。例如:

    #define ATTRIB_NORET __attribute__((noreturn)) ....
    asmlinkage NORET_TYPE void do_exit(long error_code) ATTRIB_NORET;

    packed属性的作用是取消结构在编译时的对齐优化,使其按照实际占用字节数对齐。这个属性经常出现在协议包的定义中,如果在定义协议包结构体时加入了packed属性,那么编译器会取消各个成员变量间的对齐填充,按照实际占用字节数进行对齐。例如下面这个结构体,它的实际内存占用量为1 B+4 B+8 B=13 B:

    struct example_struct
    {
        char a;
        int b;
        long c;
    } __attribute__((packed));

    regparm(n)属性用于以指定寄存器传递参数的个数,该属性只能用在函数定义和声明里,寄存器参数的上限值为3(使用顺序为EAX、EDX、ECX)。如果函数的参数个数超过3,那么剩余参数将使用内存传递方式。

    值得注意的一点是,regparm属性只在x86处理器体系结构下有效,而在x64体系结构下,GUN C语言使用寄存器传参方式作为函数的默认调用约定。无论是否采用regparm属性加以修饰,函数都会使用寄存器来传递参数,即使参数个数超过3,依然使用寄存器来传递参数,具体细节遵照cdecl调用约定。请看下面这个例子:

    int q = 0x5a;
    int t1 = 1;
    int t2 = 2;
    int t3 = 3;
    int t4 = 4;
    #define REGPARM3 __attribute((regparm(3)))
    #define REGPARM0 __attribute((regparm(0)))
    void REGPARM0 p1(int a)
    {
        q = a + 1;
    }
    void REGPARM3 p2(int a, int b, int c, int d)
    {
        q = a + b + c + d + 1;
    }
    int main()
    {
        p1(t1);
        p2(t1,t2,t3,t4);
        return 0;
    }

    使用下面这条objdump命令将这段程序反汇编,让我们从汇编级来看看regparm属性对函数调用约定的影响:

    objdump -D 可执行程序

    此条命令中的选择-D用于反汇编程序中的所有段,包括代码段、数据段、只读数据段以及其他辅助段等。而此前使用过的选项-d只能反汇编出程序的代码段。以下是反汇编出的部分程序片段:

    Disassembly of section .text:
    0000000000400474 <p1>:
      400474:    55                      push   %rbp
      400475:    48 89 e5                mov    %rsp,%rbp
      400478:    89 7d fc                mov    %edi,-0x4(%rbp)
      40047b:    8b 45 fc                mov    -0x4(%rbp),%eax
      40047e:    83 c0 01                add    $0x1,%eax
      400481:    89 05 3d 04 20 00       mov    %eax,0x20043d(%rip)   #6008c4 <q>
      400487:    c9                      leaveq
      400488:    c3                      retq
    0000000000400489 <p2>:
      400489:    55                      push   %rbp
      40048a:    48 89 e5                mov    %rsp,%rbp
      40048d:    89 7d fc                mov    %edi,-0x4(%rbp)
      400490:    89 75 f8                mov    %esi,-0x8(%rbp)
      400493:    89 55 f4                mov    %edx,-0xc(%rbp)
      400496:    89 4d f0                mov    %ecx,-0x10(%rbp)
      400499:    8b 45 f8                mov    -0x8(%rbp),%eax
      40049c:    8b 55 fc                mov    -0x4(%rbp),%edx
      40049f:    8d 04 02                lea    (%rdx,%rax,1),%eax
      4004a2:    03 45 f4                add    -0xc(%rbp),%eax
      4004a5:    03 45 f0                add    -0x10(%rbp),%eax
      4004a8:    83 c0 01                add    $0x1,%eax
      4004ab:    89 05 13 04 20 00       mov    %eax,0x200413(%rip)  # 6008c4 <q>
      4004b1:    c9                      leaveq
      4004b2:    c3                      retq
    00000000004004b3 <main>:
      4004b3:    55                      push   %rbp
      4004b4:    48 89 e5                mov    %rsp,%rbp
      4004b7:    53                      push   %rbx
      4004b8:    8b 05 0a 04 20 00       mov    0x20040a(%rip),%eax #6008c8 <t1>
      4004be:    89 c7                   mov    %eax,%edi
      4004c0:    e8 af ff ff ff          callq  400474<p1>
      4004c5:    8b 0d 09 04 20 00       mov    0x200409(%rip),%ecx  #6008d4 <t4>
      4004cb:    8b 15 ff 03 20 00       mov    0x2003ff(%rip),%edx  #6008d0 <t3>
      4004d1:    8b 1d f5 03 20 00       mov    0x2003f5(%rip),%ebx  #6008cc <t2>
      4004d7:    8b 05 eb 03 20 00       mov    0x2003eb(%rip),%eax  #6008c8 <t1>
      4004dd:    89 de                   mov    %ebx,%esi
      4004df:    89 c7                   mov    %eax,%edi
      4004e1:    e8 a3 ff ff ff          callq  400489<p2>
      4004e6:    b8 00 00 00 00          mov    $0x0,%eax
      4004eb:    5b                      pop    %rbx
      4004ec:    c9                      leaveq
      4004ed:    c3                      retq
      4004ee:    90                      nop
      4004ef:    90                      nop
    Disassembly of section .data:
    00000000006008c0 <__data_start>:
      6008c0:    00 00                   add    %al,(%rax)
        ...
    00000000006008c4 <q>:
      6008c4:    5a                      pop    %rdx
      6008c5:    00 00                   add    %al,(%rax)
        ...
    00000000006008c8 <t1>:
      6008c8:    01 00                   add    %eax,(%rax)
        ...
    00000000006008cc <t2>:
      6008cc:    02 00                   add    (%rax),%al
        ...
    00000000006008d0 <t3>:
      6008d0:    03 00                   add    (%rax),%eax
        ...
    00000000006008d4 <t4>:
      6008d4:    04 00                   add    $0x0,%al
        ...

    如果读者参照2.2.3节中描述的cdecl调用约定可知,在x64体系结构下,函数采用寄存器传参方式。而此段代码也确实通过寄存器向函数p1p2传递参数,按照从左至右的顺序依次使用RDI、RSI、RDX、RCX这4个寄存器,这却与regparm属性的规定完全不一致。由此看来,在基于x64体系结构的GNU C语言环境中,属性regparm已经不再起作用了。

目录