SA12226242 施健 信息安全
导读
在深入理解Linux之前,我们需要了解计算机是如何工作的。使用Example的c代码分别生成.cpp,.s,.o和ELF可执行文件,并加载运行,分析.s汇编代码在CPU上的执行过程。
一、C语言的编译过程
1.1 C语言的编译过程
由于是单文件的程序,因此链接的过程省略。详细参考《程序员的自我修养》第2.1节 被隐藏了的过程[1]
1.2 源文件example.c
1 // example.c 2 3 int g(int x) 4 { 5 return x + 3; 6 } 7 8 int f(int x) 9 { 10 return g(x); 11 } 12 13 int main() 14 { 15 return f(8) + 1; 16 }
1.3 预处理
预处理主要是处理宏指令和#include指令。使用命令 gcc -E -o example.cpp example.c。
1 # 1 "example.c" 2 # 1 "<built-in>" 3 # 1 "<command-line>" 4 # 1 "example.c" 5 6 7 int g(int x) 8 { 9 return x + 3; 10 } 11 12 int f(int x) 13 { 14 return g(x); 15 } 16 17 int main() 18 { 19 return f(8) + 1; 20 }
1.4 编译成汇编代码
编译的过程使用一系列的词法分析,语法分析,语义分析和优化后生成相应的汇编代码。使用命令 gcc -x cpp-output -S -o example.s example.cpp
1 .file "example.c" 2 .text 3 .globl g 4 .type g, @function 5 g: 6 pushl %ebp 7 movl %esp, %ebp 8 movl 8(%ebp), %eax 9 addl $3, %eax 10 popl %ebp 11 ret 12 .size g, .-g 13 .globl f 14 .type f, @function 15 f: 16 pushl %ebp 17 movl %esp, %ebp 18 subl $4, %esp 19 movl 8(%ebp), %eax 20 movl %eax, (%esp) 21 call g 22 leave 23 ret 24 .size f, .-f 25 .globl main 26 .type main, @function 27 main: 28 leal 4(%esp), %ecx 29 andl $-16, %esp 30 pushl -4(%ecx) 31 pushl %ebp 32 movl %esp, %ebp 33 pushl %ecx 34 subl $4, %esp 35 movl $8, (%esp) 36 call f 37 addl $1, %eax 38 addl $4, %esp 39 popl %ecx 40 popl %ebp 41 leal -4(%ecx), %esp 42 ret 43 .size main, .-main 44 .ident "GCC: (Ubuntu 4.3.3-5ubuntu4) 4.3.3" 45 .section .note.GNU-stack,"",@progbits
1.5 汇编成目标代码
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译就可以了,“汇编”这个名字也来源于此。[1]
使用命令 gcc -x assembler -c example.s -o example.o
1.6 链接
详见《程序员的自我修养》2.1.4 [1]
二、C程序的运行
现在我们观察汇编的代码,模拟C语言运行过程中栈的变化情况,来深入了解C程序的运行过程。
首先,C程序从main函数入口进入执行:
28 到30:
27 main: -> 28 leal 4(%esp), %ecx 29 andl $-16, %esp 30 pushl -4(%ecx)
这三条指令是:将esp按照16字节对齐,然后再push esp。
31到32行:
-> 31 pushl %ebp 32 movl %esp, %ebp
31行将ebp压栈,
32行将esp中的地址赋值给ebp,为了区别,我们使用ebp1表示。对应的栈示意图如下:
高地址 +---------------+
| |
+---------------+
ebp1-> | ebp | <- esp
+---------------+
| |
低地址 +---------------+
此时,就搭建好了main函数运行的框架。
33行:
继续看第33行代码
-> 33 pushl %ecx
将ecx压栈,具体原因暂时不详,栈示意图。
高地址 +---------------+
| |
+---------------+
ebp1-> | ebp |
+---------------+
| ecx | <- esp
+---------------+
| |
低地址 +---------------+
34到36行:
-> 34 subl $4, %esp 35 movl $8, (%esp) 36 call f
34 行将esp向下减4个字节的大小,等价与分配了4个字节的空间。
35行将立即数8放入到esp指向的内存中,这其实是在压入参数。栈示意图
高地址 +---------------+ | | +---------------+ ebp1-> | ebp | +---------------+ | ecx | +---------------+ | 8 | <- esp +---------------+ | | 低地址 +---------------+
36是宏指令call,其作用等价于将当前cs : eip的值压入栈顶,cs : eip指向被调用函数的入口地址。[2]
栈示意图:
高地址 +---------------+ | | +---------------+ ebp1-> | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | <- esp +---------------+ | | 低地址 +---------------+
接下来执行的是函数f,我们从15行开始继续看:
15到17行:
-> 15 f: 16 pushl %ebp 17 movl %esp, %ebp
16行将ebp压栈
17行将esp的内容赋值给ebp,为了区别,我们将其命名为ebp2,栈示意图:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp2-> | ebp1 | <- esp +---------------+ | | 低地址 +---------------+
现在,f函数的执行框架搭好了。
18到20行:
-> 18 subl $4, %esp 19 movl 8(%ebp), %eax 20 movl %eax, (%esp)
18行申请了一个4字节大小的栈空间
19行将ebp+8位置处的内容放入到eax寄存器中。ebp+8的位置,就是第一个参数的位置。所以,这句话其实是传参数。
20行将eax寄存器的内容放入到栈顶。对应的栈示意图:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp2-> | ebp1 | +---------------+ | 8 | <- esp +---------------+ | | 低地址 +---------------+
21行:
-> 21 call g
21行调用函数g。call是宏指令,栈示意图:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp2-> | ebp1 | +---------------+ | 8 | +---------------+ | cs:eip | <- esp +---------------+ | | 低地址 +---------------+
现在观察函数g,汇编对应的是5到10行:
5到7行:
-> 5 g: 6 pushl %ebp 7 movl %esp, %ebp
6行,入栈ebp
7行,将esp赋值给ebp,为示区别,我们使用ebp3。
栈示意图:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ | ebp1 | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp3-> | ebp2 | <- esp +---------------+ | | 低地址 +---------------+
函数g的执行环境搭建好了。
8到9行:
-> 8 movl 8(%ebp), %eax 9 addl $3, %eax
8行,从ebp+8的位置出取参数,放入到eax寄存器中。
9行,将eax中的内容增加3。
此时,栈无任何变化。
10-11行:
-> 10 popl %ebp 11 ret
20行,从栈顶取出数据,放入到ebp中。对应的栈变化如下:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp2-> | ebp1 | +---------------+ | 8 | +---------------+ | cs:eip | <- esp +---------------+ | | 低地址 +---------------+
11行ret是个宏指令,其功能是从栈顶弹出原来保存在这里的cs : eip的值,放入cs : eip中。[2]
对应的栈变化示意图:
高地址 +---------------+ | | +---------------+ | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | +---------------+ ebp2-> | ebp1 | +---------------+ | 8 | <- esp +---------------+ | | 低地址 +---------------+
此时,我们回到了f函数中call指令的下一条指令,即第22行。
22行到23行:
-> 22 leave 23 ret
22行,leave是宏指令,其相当于 movl %ebp, %esp和popl %ebp
运行后,栈示意图如下:
高地址 +---------------+ | | +---------------+ ebp1-> | ebp | +---------------+ | ecx | +---------------+ | 8 | +---------------+ | cs:eip | <- esp +---------------+ | | 低地址 +---------------+
23行,ret恢复eip到main函数中call的下一条指令。
栈示意图:
高地址 +---------------+ | | +---------------+ ebp1-> | ebp | +---------------+ | ecx | +---------------+ | 8 | <- esp +---------------+ | | 低地址 +---------------+
此时,我们回到main函数的37行继续执行:
37到39行:
-> 37 addl $1, %eax 38 addl $4, %esp 39 popl %ecx
37行:将eax中的内容加1,注意eax通常用来做为返回值,所以,eax存储的是调用f后的返回值。
38行:将esp增加4,这是销毁了调用f的参数栈。
39行:将ecx寄存器恢复。还是不清楚,这是在做什么。
栈示意图:
高地址 +---------------+
| |
+---------------+
ebp1-> | ebp | <- esp
+---------------+
| |
低地址 +---------------+
40到42行:
-> 40 popl %ebp
41 leal -4(%ecx), %esp
42 ret
40行:将栈顶内容出栈到ebp寄存器。
41行:将ecx-4中的内容出栈到esp,即将之前保存的esp内容出栈。
42行:将eip恢复到某个地方继续执行。结束。
现在,我们对C语言的程序执行调用用了大概的了解了。那么,计算机是怎样工作的呢。
三. 计算机是如何工作的
通过上面的分析,我们知道计算机的工作过程实际上就是取指令->执行指令的工程。其基本模型如下:
for (;;) { read_next_intruction();
execute_intruction(); }
在计算机中,又不单纯的是线性的执行下去。还有跳转指令,跳转指令是通过修改eip寄存器实现的,因为cpu每次是从eip指向的内存位置取下一条指令的。
单任务中,这个模型没什么问题。如果多任务怎么办呢?
多任务中,引入了中断的概念了。中断信号提供了一种特殊的方式,使得处理器转而去运行正常控制流之外的代码。当一个中断信号到达时,CPU必须停止它当前正在做的事情,并且切换到一个新的活动。[3]
这样,上面的模型就可以改成下面这样。
for (;;) { read_next_intruction(); execute_intruction(); detect_interrupt(); }
中断的概念在计算机系统中非常重要,如:IO中断,时间片中断,系统调用中断等。
这样,我们就很基础的理解了计算机是怎样工作的了。
下一次,我会尝试自己编译Linux内核,欢迎持续关注。
参考资料:
[1] 程序员的自我修养
[2] Linux操作系统分析所需的相关基础知识.ppt by 孟宁
[3] 深入理解Linux内核第三版