0%

C 代码是如何跑起来的

上一篇「CPU 提供了什么」中,我们了解了物理的层面的 CPU,为我们提供了什么。

本篇,我们介绍下高级语言「C 语言」是如何在物理 CPU 上面跑起来的。

C 语言提供了什么

C 语言作为高级语言,为程序员提供了更友好的表达方式。在我看来,主要是提供了以下抽象能力:

  1. 变量,以及延伸出来的复杂结构体
    我们可以基于变量来描述复杂的状态。
  2. 函数
    我们可以基于函数,把复杂的行为逻辑,拆分到不同的函数里,以简化复杂的逻辑以。以及,我们可以复用相同目的的函数,现实世界里大量的基础库,简化了程序员的编码工作。

示例代码

构建一个良好的示例代码,可以很好帮助我们去理解。
下面的示例里,我们可以看到 变量函数 都用上了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "stdio.h"

int add (int a, int b) {
return a + b;
}

int main () {
int a = 1;
int b = 2;
int c = add(a, b);

printf("a + b = %d\n", c);

return 0;
}

编译执行

毫无意外,我们得到了期望的 3

1
2
3
$ gcc -O0 -g3 -Wall -o simple simple.c
$ ./simple
a + b = 3

汇编代码

我们还是用 objdump 来看看,编译器生成了什么代码:

  1. 变量
    局部变量,包括函数参数,全部被压入了 里。
  2. 函数
    函数本身,被单独编译为了一段机器指令
    函数调用,被编译为了 call 指令,参数则是函数对应那一段机器指令的第一个指令地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
$ objdump -M intel -j .text -d simple

# 截取其中最重要的部分

000000000040052d <add>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 89 7d fc mov DWORD PTR [rbp-0x4],edi
400534: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
400537: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
40053a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
40053d: 01 d0 add eax,edx
40053f: 5d pop rbp
400540: c3 ret

0000000000400541 <main>:
400541: 55 push rbp
400542: 48 89 e5 mov rbp,rsp
400545: 48 83 ec 10 sub rsp,0x10
400549: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
400550: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
400557: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
40055a: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40055d: 89 d6 mov esi,edx
40055f: 89 c7 mov edi,eax
400561: e8 c7 ff ff ff call 40052d <add>
400566: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
400569: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
40056c: 89 c6 mov esi,eax
40056e: bf 20 06 40 00 mov edi,0x400620
400573: b8 00 00 00 00 mov eax,0x0
400578: e8 93 fe ff ff call 400410 <printf@plt>
40057d: b8 00 00 00 00 mov eax,0x0
400582: c9 leave
400583: c3 ret
400584: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40058b: 00 00 00
40058e: 66 90 xchg ax,ax

函数内的局部变量,为什么会放入栈空间呢?

这个刚好和局部变量的作用域关联起来了:

  1. 函数执行结束,返回的时候,局部变量也应该失效了
  2. 函数返回的时候,刚好要恢复栈高度到上一个调用者函数。

这样的话,只需要栈高度恢复,也就意味着被调用函数的所有的临时变量,全部失效了。

函数内的局部变量,一定会放入栈空间吗?

答案是,不一定。
上面我们是通过 -O0 编译的,接下来,我们看下 -O1 编译生成的机器码。

此时的局部变量直接放在寄存器里了,不需要写入到栈空间了。
不过,此时 main 都已经不再调用 add 函数了,因为已经被 gcc 内联优化了。
好吧,构建个合适的用例也不容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 03 00 00 00 mov esi,0x3
40053a: bf f0 05 40 00 mov edi,0x4005f0
40053f: b8 00 00 00 00 mov eax,0x0
400544: e8 c7 fe ff ff call 400410 <printf@plt>
400549: b8 00 00 00 00 mov eax,0x0
40054e: 48 83 c4 08 add rsp,0x8
400552: c3 ret
400553: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
40055a: 00 00 00
40055d: 0f 1f 00 nop DWORD PTR [rax]

禁止内联优化

我们用如下命令,关闭 gcc 的内联优化:

1
gcc -fno-inline -O1 -g3 -Wall -o simple simple.c

再来看下汇编代码,此时的机器码就符合理想的验证结果了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
000000000040052d <add>:
40052d: 8d 04 37 lea eax,[rdi+rsi*1]
400530: c3 ret

0000000000400531 <main>:
400531: 48 83 ec 08 sub rsp,0x8
400535: be 02 00 00 00 mov esi,0x2
40053a: bf 01 00 00 00 mov edi,0x1
40053f: e8 e9 ff ff ff call 40052d <add>
400544: 89 c6 mov esi,eax
400546: bf f0 05 40 00 mov edi,0x4005f0
40054b: b8 00 00 00 00 mov eax,0x0
400550: e8 bb fe ff ff call 400410 <printf@plt>
400555: b8 00 00 00 00 mov eax,0x0
40055a: 48 83 c4 08 add rsp,0x8
40055e: c3 ret
40055f: 90 nop

总结

  1. 对于 C 语言的变量,编译器会为其分配一段内存空间来存储
    函数内的局部变量,放入栈空间是理想的映射方式。不过编译的优化模式下,则会尽量使用寄存器来存储,寄存器不够用了,才会使用栈空间。
    全局变量,则有对应的内存段来存储,这个以后可以再聊。
  2. 对于 C 语言的函数,编译器会编译为独立的一段机器指令
    调用该函数,则是执行 call 指令,意思是接下来跳转到执行这一段机器指令。