本节通过一个简单的C代码在Linux下执行的过程,介绍编译器和OS是如何分工、合作完成代码的执行。
一个简单的C示例如下:
int global_count = 10; int add(int i, int j){ return i + j; } main(){ int i = 3; int j = 5; int result = add(i, j); }
该示例非常简单,不存在动态链接,编译、链接完成后即可在OS中执行。但是程序要在OS上执行,需要符合OS的执行要求,主要包括:
以Linux系统为例,上面的源代码和编译生成的可执行文件(ELF格式)的对应关系如下图1-3所示。
图1-3 代码和ELF格式约定
以Linux/X86-64为例,通过gcc编译器对上述代码进行编译,产生目标文件。文件格式为ELF,可以使用objdump命令(或readelf命令)对编译后的目标文件进行解析。首先可以确认一下数据段的信息,如下所示:
Disassembly of section .data: 0000000000600868 <__data_start>: 600868: 00 00 add %al,(%rax) …… 000000000060086c <global_count>: 60086c: 0a 00 or (%rax),%al
在这个数据段中有两个变量__data_start和global_count,其中global_count是代码中定义的全局变量,可以看到该变量占用的空间为4字节,初始值为10;而__data_start是gcc在链接时创建的一个全局变量,该变量指向数据段开始的位置,该变量的大小也是4字节。
编译器除了满足OS对于可执行文件的约定规范外,其中一个重要的功能就是针对代码进行编译优化(当然也包含了内存数据的布局等)。接下来看一下代码段的内容。代码段非常长,这里只关注add函数的汇编代码,如下所示:
0000000000400474 <add>: 400474: 55 push %rbp 400475: 48 89 e5 mov %rsp,%rbp 400478: 89 7d fc mov %edi,-0x4(%rbp) 40047b: 89 75 f8 mov %esi,-0x8(%rbp) 40047e: 8b 45 f8 mov -0x8(%rbp),%eax 400481: 8b 55 fc mov -0x4(%rbp),%edx 400484: 8d 04 02 lea (%rdx,%rax,1),%eax 400487: c9 leaveq 400488: c3 retq
注意
在gcc编译过程中采用的是默认编译优化级别(默认编译优化级别为O0),如果采用不同的编译优化级别,生成的代码会略有不同。
在C/C++中,编译优化体现在源代码的编译时间长短不同,同时不同的编译代码执行效率也会不同。在JVM的执行过程中也存在同样的问题,并且因为JVM在编译代码执行过程中需要先等待编译代码完成后才能执行,所以编译时长会直接影响应用执行的性能。
OS首先读取ELF文件,按照进程执行时内存的布局把ELF文件的信息加载到内存中。在64位Linux环境下,文件到内存的映射以及加载后内存的布局如图1-4所示。
图1-4 Linux执行代码内存布局
代码的入口地址位于0x00400000处(32位系统位于0x08048000),本程序真正执行的地址开始于0x00400390(可以从objdump中看到该信息,此处对0进行了省略)。
architecture: i386:X86-64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x0000000000400390
该地址对应的代码可以在代码段中找到。汇编代码如下:
0000000000400390 <_start>: 400390: 31 ed xor %ebp,%ebp 400392: 49 89 d1 mov %rdx,%r9 400395: 5e pop %rsi 400396: 48 89 e2 mov %rsp,%rdx 400399: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 40039d: 50 push %rax 40039e: 54 push %rsp 40039f: 49 c7 c0 c0 04 40 00 mov $0x4004c0,%r8 4003a6: 48 c7 c1 d0 04 40 00 mov $0x4004d0,%rcx 4003ad: 48 c7 c7 89 04 40 00 mov $0x400489,%rdi 4003b4: e8 c7 ff ff ff callq 400380 <__libc_start_main@plt> 4003b9: f4 hlt
该代码是gcc生成的,它作为入口地址,从此处开始执行。它将通过glibc的库函数_libc_start_main执行到源代码中的main函数中(具体细节可以参考其他书籍)。
在上面的代码示例中,main函数调用了add函数,这里简单演示一下从main函数到add函数的执行过程,主要关注栈的变化情况。main函数的汇编代码如下:
0000000000400489 <main>: 400489: 55 push %rbp 40048a: 48 89 e5 mov %rsp,%rbp 40048d: 48 83 ec 10 sub $0x10,%rsp 400491: c7 45 f4 03 00 00 00 movl $0x3,-0xc(%rbp) 400498: c7 45 f8 05 00 00 00 movl $0x5,-0x8(%rbp) 40049f: 8b 55 f8 mov -0x8(%rbp),%edx 4004a2: 8b 45 f4 mov -0xc(%rbp),%eax 4004a5: 89 d6 mov %edx,%esi 4004a7: 89 c7 mov %eax,%edi 4004a9: e8 c6 ff ff ff callq 400474 <add> 4004ae: 89 45 fc mov %eax,-0x4(%rbp) 4004b1: c9 leaveq 4004b2: c3 retq
从main函数到执行callq指令之前,栈的情况如图1-5所示。
图1-5 main函数执行函数调用前的栈帧
从图1-5中可以看到,在调用add之前,main函数需要将参数以及add函数后的下一条指令地址入栈(由于此处add函数需要传递的参数比较少,因此直接使用寄存器传递。但是需要注意的是main函数中仍然有局部遍历i和j,它们也在栈中分配),其中传递的参数被add函数使用,返回地址用于add函数执行完成后继续返回main函数执行。当进入add函数中后,栈的情况如图1-6所示。
图1-6 main函数调用add函数后的栈帧
栈帧的变化是OS根据芯片的调用约定组织的,不同的芯片有不同的调用约定。在JVM编译优化中也需要按照调用约定实现相关的代码。