(一)我们编写的代码如何在计算机上运行

目录

1、计算机指令

2、代码执行过程

3、函数执行的原理

4、我们写的代码如何编译链接,被加载到内存并执行

4.1、背景

4.2、问题

4.3、解决方案

4.4、编译 -> 链接 -> 装载

4.5、程序如何加载到内存

4.6、静态/动态链接


1、计算机指令

计算机的指令即cpu能理解的操作,也就是我们所说的机器语言。不同的cpu能理解的语言不一样,如intel的cpu,苹果使用的ARM的cpu。不同的语言即不同的计算机指令集。所以电脑上写好的程序COPY到手机运行不了,因为二者cpu指令集不相同

高级语言,汇编语言,计算机指令的关系

  • 一条高级语言 可翻译成 多条汇编指令(一对多)
  • 一条汇编指令 可翻译成 一条计算机指令 (一对一)
  • 一条条的计算机指令 即 一条条机器码(由0和1组成)
  • 高级语言也可以直接翻译成机器码,也可以先翻译成汇编语言再由汇编器翻译成机器码
  • 通常我们会把高级语言翻译成汇编,来看计算机执行的每个步骤

2、代码执行过程

寄存器是cpu内部的组成部分,特殊的有三类:PC寄存器(存储下一条指令的内存地址,也叫作程序计数器)、指令寄存器(存储正在执行的指令码)、条件码寄存器(用里面的一个一个标记位(Flag),存放 CPU 进行算术或者逻辑计算的结果)。除了这些特殊的寄存器,CPU里面还有更多用来存储数据和内存地址的寄存器。这样的寄存器通常一类里面不止一个。我们通常根据存放的数据内容来给它们取名字,比如整数寄存器、浮点数寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放数据,又能存放地址,我们就叫它通用寄存器。

查看高级语言对应的汇编及机器码,以linux下c语言为例

int main()
{
    int a = 0;
    for (int i = 0; i < 3; i++)
    {
        a += i;
    }
}

 通过gcc 和 objdump命令查看代码对应的汇编指令

gcc -g -c test.c
objdump -d -M intel -S test.o 

 下面为代码对应的汇编指令,可以看到for循环是通过 逻辑判断+指令跳转 来实现

    for (int i = 0; i < 3; i++)
   b:   c7 45 f8 00 00 00 00    mov    DWORD PTR [rbp-0x8],0x0
  12:   eb 0a                   jmp    1e <main+0x1e>
    {
        a += i;
  14:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  17:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
    for (int i = 0; i < 3; i++)
  1a:   83 45 f8 01             add    DWORD PTR [rbp-0x8],0x1
  1e:   83 7d f8 02             cmp    DWORD PTR [rbp-0x8],0x2
  22:   7e f0                   jle    14 <main+0x14>
  24:   b8 00 00 00 00          mov    eax,0x0
    }

对于cpu而言,我们写好的代码变成计算机指令后,是一条一条顺序执行的。CPU 会根据 PC 寄存器里的地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度地址自增,开始顺序读取下一条指令。可以看到,一个程序的一条条指令,在内存(虚拟内存上是连续的,虚拟内存映射物理内存即可找到物理内存上的指令数据)里面是连续保存的,也会一条条顺序加载。对于有些指令,则会修改pc寄存器的里的值,达到程序跳转的目的。

3、函数执行的原理

https://time.geekbang.org/column/article/93014

https://time.geekbang.org/column/article/94427

  • 每个线程都有一个自己的栈(内存中开辟的空间),线程执行中,所有方法公用这个栈。栈底到栈顶内存地址不变变小,这样只用判断地址是否小于0即可知道栈内是否还有空间。
  • 每个方法在栈里保存着各自的信息。每个方法占用的栈的空间叫做一个栈帧(逻辑层面的)。
  • RSP是栈顶指针寄存器,一直指向栈顶位置,入栈操作 Push 和出栈操作 Pop 指令,会自动调整 RSP 的值。
  • RBP是栈基地址指针寄存器,指向当前栈帧的最底部。 rsp到rbp 表示当前栈帧范围。
  • RBP和RSP在函数调用的过程中,以及调用完返回的过程中。只需要记住,RSP会一直指向栈顶,RBP会一直指向当前最新栈帧的的栈底即可
  • rdi、rsi、rdx、rcx、r8、r9 这 6 个寄存器,传递的参数会存储在寄存器中。如果超过 6 的时候,还是需要放到调用者自身的栈帧里,其入栈的顺序也是有讲究的,为了方便被调用者访问。

分析代码如下,可以看汇编中的注释:

// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
    return a+b;
}


int main()
{
    int x = 5;
    int y = 10;
    int u = add(x, y);
}
int static add(int a, int b)
{
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   4:   89 7d fc                mov    DWORD PTR [rbp-0x4],edi //获取传入参数
   7:   89 75 f8                mov    DWORD PTR [rbp-0x8],esi //获取传入参数
    return a+b;
   a:   8b 55 fc                mov    edx,DWORD PTR [rbp-0x4]
   d:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  10:   01 d0                   add    eax,edx //函数结果放入eax
}
  12:   5d                      pop    rbp
  13:   c3                      ret // 主函数返回地址出栈且更新到pc寄存器
0000000000000014 <main>:
int main()
{
  14:   55                      push   rbp
  15:   48 89 e5                mov    rbp,rsp
  18:   48 83 ec 10             sub    rsp,0x10
    int x = 5;
  1c:   c7 45 fc 05 00 00 00    mov    DWORD PTR [rbp-0x4],0x5
    int y = 10;
  23:   c7 45 f8 0a 00 00 00    mov    DWORD PTR [rbp-0x8],0xa
    int u = add(x, y);
  2a:   8b 55 f8                mov    edx,DWORD PTR [rbp-0x8]
  2d:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  30:   89 d6                   mov    esi,edx //传递参数,放到寄存器
  32:   89 c7                   mov    edi,eax //传递参数,放到寄存器
  34:   e8 c7 ff ff ff          call   0 <add> // 返回地址入栈
  39:   89 45 f4                mov    DWORD PTR [rbp-0xc],eax // 获取add函数结果
  3c:   b8 00 00 00 00          mov    eax,0x0
}
  41:   c9                      leave  
  42:   c3                      ret    

1.参数如何传递到另一个函数?

  • 参数不超过六个时,直接通过特定的寄存器传递参数。被调用者直接从寄存器中获取参数即可。
  • 超过六个的参数,则会入栈。被调用者自身知道参数个数,当前RBP栈基地址值,只要越过【前面栈帧的栈基地址】【返回地址】固定大小,则可以拿到第七个参数以及后续参数。

2.返回值如何返回?

  • 看汇编代码可以看到,add函数的结果存储在EAX寄存器中;在主函数中只需要去EAX寄存器中获取返回结果即可。

3.被调用的函数执行完后如何回到调用的地方继续往下执行呢?

  • 在主函数中,调用 call 的时候,会把pc寄存器的地址入栈(下一条指令的地址)。
  • 在add函数中,最后调用 ret 的时候,会把入栈的地址出栈且更新到pc寄存器中。

4.方法为何是线程安全的呢?

  • 一个方法在不同线程并行执行,首先不同线程有各自的栈,每个方法在各自线程的栈里也会有各自的栈帧,里面有方法自己的局部变量。所以是线程安全的。

5.函数内联有什么利弊?

  • 缺点:我们把可以复用的程序指令在调用它的地方完全展开了。如果一个函数在很多地方都被调用了,那么就会展开很多次,整个程序占用的空间就会变大了。
  • 优点:内联可以提升代码执行效率,不用像调用函数一样有多余的操作(调用,入栈,出栈等),执行的指令变少了
  • 限制:如果a调用b,b里面又调用a。如果直接内联,这样代码会无穷无尽。

6. 参数过多的坏处?

  • 不利于理解。
  • 参数超过六个,会入栈,比单纯的通过寄存器传递和获取参数,效率更低。

总结:看汇编代码可以得出,其操作其实都是建立在一个非常严谨的数据结构上。每一步操作会影响指针,会影响下一步操作的数据。数据结构跟代码执行是完全同步的。

4、我们写的代码如何编译链接,被加载到内存并执行

4.1、背景

  • 我们自己编写的源代码文件可以有多个,源文件中涉及到变量和函数的引用,可能是单个文件内的引用,也可能是文件之间的引用
  • 除了自己编写的,还有共享代码库。例如glibc共享库。源文件中可能有对glibc库中变量和函数的引用
  • 某个共享库,也会对其他共享库中的变量及函数有引用

4.2、问题

  • 对于cpu而言,执行指令,其实就是通过获取不同地址的指令数据,译码并执行。所以代码中对变量和函数的引用,最终都应该变成有效的地址。
  • 所以最终要解决的问题就是,源代码变成机器码后,被cpu执行时,我的代码和数据(int b = &a)中涉及到的引用全部都要变成有效的地址。

4.3、解决方案

根本问题就是要把对数据和函数的引用替换为有效地址,这个行为叫做重定位

  • 方案一:生成的机器码,在加载到内存前,进行重定位处理

实现思路:这种方案叫做静态链接,把整个程序用到的所有代码,全部合并成一个大文件,然后把大文件内的所有针对变量和函数的引用全部替换为有效地址

缺点:对于glibc这种共享库,如果每个程序都要把它合并到文件里,在磁盘存储和内存占用上,都是极大的浪费。

改良:针对每个程序都会用到的代码(例如glibc共享库),我们就不能采用静态链接这种方式,所以我们有了方案二。

  • 方案二:程序对共享库的引用,共享库和共享库之间的引用,在加载到内存后进行重定位

实现思路:这种方案叫做动态链接,由于不能把共享库合并到大文件,所以无法提前预知共享库的地址。只能在装载程序到内存时,把依赖的共享库也装载进内存。此时共享库的在物理内存的位置已经确定。而每个程序对应的进程都可以建立‘虚拟地址’到‘物理地址’的映射,来实现不同程序引用同一个共享库代码。此时地址都已经确定,可以进行重定位。

缺点:每次重定位,都会对代码进行更改

改良:就像我们写代码,能否把变化转移到数据上,而代码不用变,所以有了方案三

  • 方案三:代码中对其他共享库的引用(地址值),使用自定义的变量,数组的某一项表示

实现思路:创建一个全局变量数组 GOT[], 代码中针对其他共享库的引用,会使用数组的值来表示。例如: call printf 表示未 call GOT[index],index的值是编译器决定的。这样我们针对printf 重定位的时候,代码是通用的,只需要去更改数据GOT[index]为printf的实际虚拟地址。而GOT在虚拟内存中的位置相对于整个程序而言偏移量是固定的,可以预知。

缺点:装载程序时,需要把所有共享库的引用全部重定位,启动速度慢

改良:能否把重定位工作放到运行时去处理呢,所以有了方案四

ps:共享库数据部分每个程序各自都会有一份副本,各自修改互不干扰。针对数据部分的重定位(int a=&b),使用方案二的方式即可

  • 方案四:cpu执行到该代码时,才去进行重定位

实现思路:让cpu执行到该代码时,能有如下的逻辑判断,if(已重定位){跳转正确的地址} else{进行重定位}

这种实现方式叫做延迟绑定(PLT),PLT其实就是一串代码,大致实现如下:

printf@plt
// 1. 如果未重定位, GOT[index]其实就是 push n 这条指令的地址(加载的时候初始化)继续执行后续的指令;如果重定位了,则正确跳转到printf的位置,不会继续往下执行
jmp GOT[index] 
// 2. 参数传递:哪个函数需要重定位,在哪个模块下
push n
push moduleId 
// 3. 跳转到重定位程序,获取模块和函数,进行重定位,修改GOT[index]的值
jump 重定位程序 

4.4、编译 -> 链接 -> 装载

静态链接:针对某一个程序,程序在链接的时候,把不同文件的代码段,合并到一起,成为最后的可执行文件。这样我们写一个方法,在这个程序每个地方都只需要链接就可以使用,达到复用的目的。

动态链接:针对很多程序,当他门之间很多功能代码都是重复时。把他们都生成可执行文件并都装载到内存里,会浪费很多的磁盘和内存空间。这时我们可以把这部分功能代码提炼出来,加载到内存,让所有程序在运行时可以通过动态链接来使用。这样可以充分节省磁盘和内存空间。(动态链接库,例如dll文件)既可以在开发阶段复用,也可以在运行阶段复用。

每个源文件编译得到对应的目标文件,多个目标文件通过链接器链接得到可执行文件(包含静态链接)。可执行文件装载时,也会加载它依赖的动态共享文件,并在加载期间把数据部分进行重定位。在运行期间把代码部分对应的GOT表进行重定位(动态链接)。

 

可执行文件在linux下是ELF格式,在windows下是PE格式。Linux 下的装载器只能解析 ELF 格式而不能解析 PE 格式。所以在windows下生成的可执行文件在linux下无法执行。

ELF格式的文件:目标文件,可执行文件,动态共享文件

ELF格式文件重要结构大致如下:

  • 文件头,表示这个文件的基本属性,比如是否是可执行文件,对应的 CPU、操作系统等等。
  • 代码段,用来保存程序的代码和指令。
  • 数据段,保存程序里面设置好的初始化数据信息。
  • 重定位表,保留的是当前的文件里面,哪些跳转地址其实是我们不知道的。比如调用函数 printf 在链接发生之前,我们并不知道该跳转到哪里,这些信息就会存储在重定位表里。
  • 符号表,保留了当前文件里面定义的函数名称和对应地址的地址簿。
  • 字符串表

静态链接的过程:链接器会扫描所有输入的目标文件,然后把所有符号表里的信息收集起来,构成一个全局的符号表。然后再根据重定位表,把所有不确定要跳转地址的代码,根据符号表里面存储的地址,进行一次修正。最后,把所有的目标文件的对应段进行一次合并,形成可执行文件。理解下图三个表之间的关系,从而理解静态目标文件的大致结构,并理解静态链接的大致过程。

动态链接的过程这里就不列举了,有兴趣可以看书《程序员的自我修养 链接装载库》

4.5、程序如何加载到内存

虚拟内存:指令里用到的内存地址叫虚拟地址(比如可执行文件中地址)。

物理内存:指令加载到内存中,实际的硬件的地址。

虚拟内存与物理内存映射表:指令还是按照虚拟内存中的地址来进行跳转及运行,所以需要一个映射表,通过虚拟地址找到物理地址。

内存交换:有多个程序需要加载到内存并运行,若内存不够时,则需要把其他程序占用的内存先转移到硬盘,等需要使用时再从硬盘恢复到内存。

内存分页:由于程序执行的时候,程序计数器是顺序往下读的,所以需要程序在内存中的地址是连续的。但是若整个程序都要求连续,这样多个程序运行内存不足时,需要把整个程序都进行一次内存交换,延迟太高。若把程序拆分成多个固定大小(一般为4kb),这样我们运行程序时可以只加载用到的页进入内存,进行内存交换的时候也可以只针对某些页,不会产生很大的延迟。

程序运行到某页,若该页未加载到内存,触发cpu缺页异常,操作系统捕捉到异常则把该页加载到内存,再由cpu读取执行。

 

  • 15
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值