一个程序的出生与死亡

目录

1.预编译

2.编译

3.汇编

4.生成目标文件

5.链接

6.生成可执行文件

7.装载

8.运行

9.死亡

 


编译又分为 预处理(Preprocessing)编译(Compilation)汇编(Assembly).

1.预编译

预编译过程主要处理源代码文件那些#开头的预编译指令

 

1

2

3

4

5

6

7

8

    1. 删除所有#define, 展开所有宏定义

    2. 处理所有预编译指令, 如#if, #ifdef, #elif, #else, #endif

    3. 递归处理#include

    4. 删除所有注释, // 和 /**/

    5. 添加行号和文件名标识

    6. 保留所有#pragma编译器指令

 

因此如果我们无法判断宏定义是否正确, 头文件包含是否正确时 -> 查看预编译后的文件来确定问题

2.编译

编译过程可分为6部 : 扫描, 语法分析, 语义分析, 源代码优化, 代码生成和目标代码优化.

 

1

2

3

4

5

6

7

8

9

10

11

12

1. 扫描 : 扫描器运用一种类似于有限状态机的算法把源代码分割成一些列的记号(Token)

 

2. 语法分析 : 语法分析器采用上下文无关语法(Context-free Grammar)将Token进行语法分析, 生成语法树(Syntax Tree).该语法树就是以表达式为节点的树

 

3. 语义分析 : 语法分析只是对表达式的语法进行层面的分析, 它并不知道该语句是否真正有意义. 在这里, 语义分析器能够进行静态语义分析, 分析过后整个语法树的表达式都被标识了类型

    静态语义 : 在编译器可以确定的语义, 通常包括声明和类型的匹配, 转换.

    动态语义 : 在运行时才能确定的语义, 比如0作为除数则在这里报错

 

4. 源代码优化 : 源码级优化器(Source Code Optimizer)在源代码级别进行优化, 把一些类似于(2+6)这些在编译器就能确定的表达式优化成值, 从而把整个语法树转换成中间代码(Intermediate Code)

    中间代码使得编译器可以被分为前端和后端, 前端负责产生机器无关的中间代码, 后端将中间代码转换成目标机器代码

 

5. 代码生成与优化 : 代码生成器(Code Generator)将中间代码转换成目标机器代码(该过程十分依赖于目标机器), 最后目标代码优化器(Target Code Optimizer)将上述的代码进行优化, 例如选择合适的寻址方式, 使用位移来代替乘法运算, 删除多余的指令等.

3.汇编

汇编器将汇编代码转换成机器可以执行的指令, 输出目标文件. 该过程比较简单, 就是翻译代码.

经过上述多个步骤, 源代码终于被编译成了目标文件. 这个目标文件肚子里又卖的是什么药呢? 我们接着看~

4.生成目标文件

由于不同的操作系统下, 目标文件, 可执行文件等都有些出入. 本文是用Linux系统下的ELF文件作为例子

编译之后生成的目标文件内容肯定少不了机器指令代码, 数据等. 不过除了这些之外, 目标文件还包括了链接时所需的一些信息, 而目标文件将这些信息按照不同的属性, 以段(Section)来存储.

 

1

2

程序源代码编译后的机器指令 -> 代码段(Code Section), ".code"或".text"

全局变量和局部静态变量数据 -> 数据段(Data Section), ".data"或".bss"

Question :
为什么要把数据和指令分开呢? 经典的冯诺依曼体系不是不分指令还是数据的吗?

Answer :
1 :
当程序被装载后, 数据和指令被映射到两个虚存区域. 数据区域对于进程而言, 是可读写的, 而指令区域则只可读. 这样方便分别设置他们的权限, 防止程序指令被恶意修改
2 : 把指令和数据分开有利于提高程序的局部性, 对于提高CPU缓存命中率有帮助
3 : 最重要的原因, 当系统中运行着多个该程序的副本时, 他们的指令都是一样的, 所以进程之间能共享指令和其他只读数据, 而数据区域则为进程私有. 如果系统中运行了数百个进程, 可以想象共享为我们节省了多少空间

这里插一句 : 其实不是可执行文件才才按照执行文件的格式存储. 什么意思呢? 除了可执行文件之外, 目标对象, 动态链接库, 静态链接库也按照可执行文件的格式存储. 某种程度上他们也是可执行文件. 所以我们可以把他们视为同一类文件

目标文件有什么

ELF文件头(ELF Header)

包含了整个文件的基本属性

段表(Section Header Table)

描述了ELF文件包含的所有段的信息

重定位表

链接器在处理目标文件时, 要对目标文件中某些符号进行重定位, 即代码段和数据段那些对绝对地址引用的符号. 这些重定位信息就记录在重定位表中.

符号表

在链接中, 我们将函数和变量统称为符号(Symbol), 函数名和变量名称为符号名(Symbol Name). 符号表记录着该目标文件所用到的所有符号, 每个符号都有一个对应的值, 符号值(Symbol Value), 对于函数和变量来说, 符号值就是他们的地址.

强符号与弱符号, 强引用与弱引用

如果在目标文件A和目标文件B都定义了一个全局变量global, 并将他们都初始化. 那么链接的时候就会报multiple definition of 'global'的错误. 这种符号就是强符号. 默认所有符号都是强符号, 可以使用GCC的__attribute__ ((weak))定义一个弱符号.

强符号与弱符号的规则 :

 

1

2

3

1. 不允许多次定义强符号

2. 如果一个符号在某个目标文件中是强符号, 在其他目标文件中是弱符号, 那么链接时选择强符号

3. 如果一个符号在全部目标文件中都是弱符号, 那么选择占用空间最大的一个.

符号引用被最终链接的时候必须要被正确决议, 如果没有找到该符号的定义, 就会报符号未定义错误undefined symbol of xxx, 这种称为强引用(Strong Reference). 而弱引用(Weak Reference)则被处理的时候如果未定义, 不报错, 链接器会默认其为0或者是一个特殊值. 默认都是强引用, 可以使用GCC的__attribute__ ((weakref))定义一个弱引用.

弱符号和弱引用的作用 :

 

1

2

3

4

对于库来说十分有用, 库中定义的弱符号可以被用户定义的强符号覆盖, 程序则可以使用自定义的库函数

或者程序可以对某些扩展功能模块的引用定义为弱引用,

当我们将扩展模块与程序链接在一起时, 功能模块可以正常使用;

如果我们去掉了功能模块, 程序也可以正常链接, 只是扩展模块的功能将不起作用.

符号修饰和函数签名

很久之前, 编译器编译源代码产生目标文件时, 符号名与相应的变量和函数的名字是一样的, 例如函数foo, 经过编译后对应的符号名也是foo, 那么久会产生冲突, 例如要使用Fortran语言编写的目标文件, 一链接就会报错. 为了解决这种冲突, 规定C语言的全局变量和函数经编译后, 符号名前加上_, 此时foo编译后符号名为_foo. 但是还是不能完全解决C语言源文件之间链接产生的问题, 因为大家都有下划线啊! 于是C++开始设计的时候就考虑到了这个问题, 衍生出了命名空间(Name Space).

在C++中, int func()int func(int)int func(float)是三个不一样的函数, 这里我们引用一个术语函数签名(Function Signature), 函数签名包括一个函数的信息, 包括函数名, 参数类型, 所在的类和命名空间等其他信息. 于是, 以上三个函数编译后各自的符号名均不一样但是有规律可循.

 

1

2

3

4

5

例如 :

int func() -编译后-> _int_func_

int func(int) -编译后-> _int_func_int_

int func(float) -编译后-> _int_func_float_

// 这里只是举个栗子, 告诉大家他们的符号名不一致, 至于会变成什么样, 需要看是什么编译器

5.链接

很久很久以前, 人们把所有代码写在一个文件中, 到后来, 人类已经没有能力维护这个程序了. 于是人们把代码根据功能或性质划分为不同的模块. 于是, 将这些模块拼接起来的过程就叫 : 链接

不知道大家看完上述的编译过程有没有这么一个疑问 : 如果编译的时候编译器不知道一个外部符号的地址, 怎么办? 答案就是不管, 先放一边, 等到链接的时候再把地址修正, 这就是重定位该做的事.

链接过程包括 : 地址和空间分配(Address and Storage Allocation)符号决议(Symbol Resolution) 和 重定位(Relocation).

静态链接

最基本的静态链接过程 : 把各个目标文件(.o文件)和库(Library)一起链接形成可执行文件.

那么他们每个文件中的段是怎么合并起来呢?

ELF用的就是相似段合并 : a的.text和b的.text合并, a的.data与b的.data合并, 其他段类似.

符号决议和重定位

符号地址的确定

 

符号地址的确定.png

重定位表

每个需要被重定位的段都有一个与之相对应的重定位表, 如.text段对应.rel.text

根据重定位表中每个符号的信息, 找到每个符号对应的目标对象文件, 再根据偏移(offset)确定其绝对地址(或相对地址).

动态链接

为什么有了静态链接还需要动态链接?

 

1

2

3

4

5

1. 为了节省内存和磁盘空间

    例如 : 由于我们操作系统中总是多进程并发的, 如果程序A和B都用到了lib.o, 那么由于是静态链接, 每个程序都有lib.o的一份副本, 当我们同时运行A和B时, lib.o在磁盘和内存中便存在两个副本. 这多耗费内存空间.

 

2. 静态链接对程序的更新, 部署和发布带来许多麻烦

    例如 : 程序A用到了一个第三方厂商提供的lib.o, 当厂商更新了lib.o, 例如修复了其中一个bug. 那么程序A就必须先拿到最新的lib, 再将其链接, 发布. 缺点非常明显, 只要有任何一个模块更新, 整个程序就必须重头链接, 发布给用户.

动态链接怎么解决以上静态链接的短板?

 

1

2

3

1. 假设我们要运行程序A, 系统会首先加载programA.o, 当系统发现其用到了lib.o, 就会接着加载lib.o, 如果还依赖其他文件, 就会继续按照这种方法逐个加载进内存. 当我们接着运行程序B的时候, 就只加载programB.o而不需加载lib.o, 因为此时系统内存中已经有一份lib.o的副本了, 系统只需要把他们两链接起来即可.

 

2. 当有新的模块更新时, 只需要将旧的目标文件更新覆盖掉, 不需要将所有程序重新链接一遍. 当程序下次运行时, 新版本的目标文件会被自动装载到内存并且链接起来, 程序就完成了升级了.

程序可扩展性和兼容性

 

1

2

3

4

5

程序可扩展性 : 动态链接还有一个特点就是程序可以在运行的时候选择加载各种程序模块, 这就是我们熟知的`插件`.

 

兼容性 : 一个程序在不同的平台运行时可以动态链接该操作系统的动态链接库, 这就消除了程序对操作系统的依赖性.

 

例如 : 操作系统A和操作系统B对于printf的实现机制不同, 程序A如果是采用静态链接, 那么就必须分别针对操作系统A和B分布两个不同的版本, 如果是采取动态链接就减少了这种麻烦.

动态链接是否完美无缺

 

1

答案肯定是否. 否则早就把静态链接淘汰掉了. 由于程序所依赖的某个模块更新后有可能与旧模块之间`接口不兼容`, 导致程序无法运行, 崩溃, 这种问题成为`DLL Hell`

动态链接的基本实现

动态链接是不是直接使用目标文件(.o文件)进行链接呢? 理论上可行, 但实际有区别. 由于动态链接的情况下, 进程的虚拟地址空间的分布会比静态链接更为复杂, 还有一些存储管理啊, 内存共享, 进程线程等机制也会有变化.

Linux系统下, ELF动态链接文件为动态共享对象(DSO, Dynamic Shared Objects), 简称共享对象, 一般以.so为后缀

Windows系统下, 称为动态链接库(Dynamical Linking Library), 就是我们常见的.dll为后缀的文件.

也就是说, 动态链接在这个阶段, 实际上是把目标文件和.so文件(或.dll文件)进行链接.

What? 为什么不是把全部.o文件链接起来? 实际上动态链接主要工作并不是在链接这个阶段做的, 否则跟静态链接有什么区别, 何来的动态? 对吧. 其主要工作是在程序被装载进内存的时候. 那么这个阶段的链接有啥用?

还记得链接要完成的三件事情吗? 地址和空间分配, 符号决议和重定位.

对的没错说的就是你 -> 符号决议. 这个.so动态库的作用就在于此. 它是用来告诉链接器 : “哥们, 这个符号采取的是动态链接, 在这里你就别管它地址是多少了, 等程序被装载进内存的时候自然有人负责的啦.”

于是, 链接就这么结束了, 可执行文件就这么被生成了咯. 剩下的动态链接工作在下面装载的时候由动态链接器完成.

6.生成可执行文件

我们前面说过, 可执行文件也就是目标文件, 其实没什么不一样. 略过

7.装载

程序想要运行起来, 就必须被装载进内存中才能被CPU调度到.

 

1

2

3

4

5

6

7

8

9

早期程序装载 : 把整个程序一次性加载到内存中, 然后执行.

 

// 现在的游戏动不动机会几十G的, 哪来那么多内存资源啊

 

覆盖装入 : 在覆盖管理器(Overlay Manager)的辅助下, 进程使用到什么模块就把该模块载入到内存中替换掉不需要使用的模块

 

// 随着虚拟存储机制的发明而诞生出一种技术 -> 页映射

 

页映射 : 将内存和磁盘中的数据和指令按照"页(Page)"为单位划分, 以后所有的装载, 操作的单位就是页. x86下页的大小为4096字节

 

页映射和页装载.png

事实上, 可执行文件并不是直接与物理内存直接映射的, 否则也没有虚拟内存什么事了对吧. 而且程序直接访问物理内存有几个坏处 : 地址空间不隔离, 内存使用效率低, 程序运行的地址不确定等.. 实际上, CPU发出的Virtual Address经过MMU(Memory Management Unit)转换成physical Address之后才能访问物理内存

从操作系统的角度看可执行文件的装载

创建一个独立的虚拟地址空间

这一步所做的是虚拟空间与物理内存的映射关系. 分配一个页目录(Page Directory), 页映射关系可以等到后面程序发生页错误再设置

读取可执行文件, 建立可执行文件与虚拟空间的映射关系

这一步做的是虚拟空间与可执行文件的映射关系. 当发生页错误时, 操作系统从物理内存中分配一个物理页, 然后将该”缺页”从磁盘中读到内存中, 再设置虚拟页和物理页的内存映射关系. 操作系统捕捉到页错误时, 它应该知道程序当前所需要的页在可执行文件中的哪一个位置, 这就是可执行文件和虚拟空间的映射关系.

将CPU的指令寄存器设置成可执行文件的入口地址, 启动运行!

这一步操作系统执行一条跳转指令跳到可执行文件的入口地址( 不是main函数, 不是main函数, 不是main函数, 重要的事情说三遍!!! ). 实际上并没有那么简单, 到程序能成功运行还差许多步骤. 这里只是将这些过程屏蔽了.

VMA

虚拟内存中分为许多个段(Segment), 每一个段就成为VMA(Virtual Memory Area). 还记得目标文件我们说过的段(Section)吗, 此段非彼段, 这里我们就说英文吧. 目标文件中的多个操作权限相同的Section在这里要被合并成一个个Segment, 再装载进程序的虚拟内存中. 如图所示 :

 

ELF可执行文件与进程虚拟空间映射关系.png

进程运行在内存中的的VMA布局就如图所示 :

 

常见进程的虚拟空间.png

页错误

执行完上面那些步骤, 实际上可执行文件的真正指令和数据都还没被装入到内存中. 可执行文件只是与虚拟内存建立了映射关系. 当真正执行指令的时候, 会发现虚拟内存中的页面为空, 这时候就产生页错误(Page Fault). 进程将控制权交给操作系统, 操作系统由上面所说Page Directory, 找到空页面所在的VMA, 计算出相应的页面在可执行文件中的偏移(Offset), 然后在物理内存中分配一个物理页面, 将进程中该虚拟页与物理页之间建立映射关系, 再把控制权交回给进程, 进程从刚才页错误的位置重新开始执行.

动态链接器

还记得之前链接的时候如果是动态链接的话, 那么我们会把符号决议和重定位推迟到加载时进行吗? 如果该程序采取的是动态链接, 那么可执行文件装载完之后, 动态链接器就要闪亮登场了!!!

启动动态链接器本身 -> 装载所有需要的动态库 -> 重定位和初始化

启动动态链接器本身

动态链接器本身也是一个动态库, 其他普通动态库的重定位工作由动态链接器来完成, 那么动态链接器的重定位又由谁来完成? 它可否依赖于其他动态库?

这是一个先有鸡还是先有蛋的问题, 为了解决该问题, 动态链接器必须有些特殊 :

 

1

2

1. 动态链接器本身不能依赖于其他任何动态库

2. 动态链接器本身所需要的全局和静态变量的重定位工作由它本身完成

这样, 动态链接器必须在启动时有一段精巧的代码完成这项工作而又不能用到全局和静态变量, 这就是自举(Bootstrap).

装载所有需要的动态库

完成bootstrap后, 动态链接器把ELF文件和链接器本身的符号表都合并到一个表中, 称为全局符号表(Global Symbol Table)中. 然后链接器开始寻找ELF文件所依赖的动态库, 一个个遍历下去, 直到所有动态库都被加载进来.

重定位和初始化

装载完所有需要的动态库后, 动态链接器开始重新遍历ELF文件和每个动态库的重定位表, 把每个需要被重定位的位置进行修正.

完成这些工作后, 链接器就可以松一口气, 把进程的控制权交还给程序的入口并且开始运行了.

特殊的动态链接

延迟绑定

我们知道, 有一些函数或者一些用户比较少用的功能模块, 也许到程序结束运行都不会用到, 那么如果程序运行的时候也把这些一并链接的话, 这实际是一种浪费, 无用功. 所以才有了这个延迟绑定 : 当函数第一次被用到时才进行绑定(符号查找, 重定位等), 没用到则不绑定.

显式运行时链接(Explicit Run-time Linking)

也叫运行时加载. 也就是让程序自己在运行时控制加载指定的模块, 并且可以在不需要该模块的时候将其卸载. 这种动态库往往被叫做动态装载库(Dynamic Loading Library).

这种加载方式对于需要长期运行的程序来说具有很大的优势, 最常见的便是Web服务器程序.

8.运行

上面我们说到, 动态链接器的任务完成之后就会把控制权交回给程序的入口, 那么这个所谓的程序入口, 是一个什么家伙呢? 我们创建一个命令行项目看看

 

1

2

3

4

5

6

7

#include

 

int main(int argc, const char * argv[]) {

    // insert code here...

    printf("Hello, World!\n");

    return 0;

}

首先, 这个程序入口肯定不是main函数, 你看他还有参数啊哥们!! 那就是别的函数传给他的玩意!

 

1

2

argc : 保存的是命令行参数数量

argv : 保存的是命令行参数字符串数组

在执行main函数以前, 程序需要初始化运行环境, 初始化堆栈, I/O, 线程等等. 这通通在一个我们称之为入口函数或入口点(Entry Point)的地方完成. 等初始化之后, 才轮到main函数出场. main函数结束后, 回到入口函数, 进行清理工作, 然后进行系统调用结束进程.

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

// 伪代码如下

Enrty Point()

{

    if (程序执行){

        init run-time environment; // 存在于系统中的一些公用数据, 任何程序都可以访问, 如系统搜索路径, 当前OS版本等.

        init heap, stack;

        init I/O;

        init thread;

        ...

 

        main(argc, argv); // 初始化完毕, 执行main函数

 

    } else if (程序退出)

        /* 做一些清理工作 */

 

        exit(); // 调用系统接口结束进程

    }

}

内存

终于讲到内存了, 关于内存那就是程序永恒的话题, 各种内存管理, 泄露问题让程序员头疼不已啊…

每个进程内存空间内都有以下默认的区域 :

 

1

2

3

4

5

6

7

栈 : 用于维护函数调用的上下文, 离开了栈函数调用就无法实现. 栈通常在用户空间的最高地址处分配.

 

堆 : malloc或new分配的内存就在这里. 堆通常在栈的下方(低地址方向).

 

可执行文件映像 : 存储可执行文件在内存的映像.

 

保留区 : 对内存中受到保护而禁止访问的内存区域的总称.

于是有了以下这个经典的进程内存布局图 :

 

栈是程序中最重要的概念之一, 没有栈就没有函数, 没有局部变量, 栈遵循FILO(First In Last Out)规则.

栈保存了一个函数调用所需要的维护信息, 称为堆栈帧(Stack Frame). 堆栈帧一般包括如下几个方面的内容 :

 

1

2

3

1. 函数的返回地址和参数

2. 临时变量 : 包括函数的非静态局部变量以及编译器自动生成的其他临时变量

3. 保存的上下文 : 包括在函数调用前后需要保持不变的寄存器

一个堆栈帧用两个寄存器划定范围 : ebpesp.

esp寄存器 : 始终指向栈的顶部

ebp寄存器 : 指向堆栈帧的一个固定位置, 又称帧指针(Frame Pointer).

 

函数的返回地址 : ebp-4

压入栈中的参数地址 : 分别是 ebp-8, ebp-12等示参数的数量和大小而定.

ebp所直接指向的数据是调用该函数前ebp的值, 这样在函数返回的时候, ebp可以通过读取这个值恢复到调用前的值.

i386下的函数调用流程

  • 把所有或一部分参数压入栈中, 如果有其他参数没有入栈, 那么使用某些特定的寄存器传递
  • 把当前指令的下一条指令的地址压入栈中
  • 跳转到函数体执行

其中第2, 3步由指令call一起执行. 伪代码如下

 

1

2

3

4

push ebp; // 把ebp压入栈中(称为old ebp)

mov ebp, esp; // ebp = esp(这时ebp指向栈顶, 此时栈顶就是old ebp)

[可选] sub esp, XXX; // 在栈上分配XXX字节的临时空间

[可选] push XXX; // 如有必要, 保存名为XXX寄存器(可重复多个)

把ebp压入栈中, 是为了在函数返回的时候便于恢复以前的ebp值. 那为什么保存一些寄存器呢, 有一些编译器可能要求某些寄存器在调用前后保持不变. 于是在函数返回时, 代码就恰好相反

 

1

2

3

4

[可选] pop XXX; // 如有必要, 恢复保存过的寄存器(可重复多个)

mov esp, ebp; // 恢复esp同时回收局部变量空间

pop ebp; // 从栈中恢复保存的ebp的值

ret; // 从栈中取得返回地址, 并跳转到该位置.

调用惯例

毫无疑问, 函数的调用方与函数被调用方对函数如何调用必须有着相同的理解, 否则将会出现错乱. 如

 

1

2

Mike : hello, john!

二狗蛋 : 黑龙江?

这种对函数的约定称为调用惯例(Calling Convention) , 内容如下 :

 

1

2

3

4

5

6

7

8

9

- 函数参数的传递顺序和方式

    1. 通过栈传输, 压栈顺序是从左往右还是从右往左?

    2. 通过寄存器传输, 提高性能.

 

- 栈的维护方式

    函数参数pop是由函数调用方来完成还是函数本身来完成?

 

- 名字修饰(Name-mangling)的策略

    对函数名进行修饰(链接的时候曾讲到这个问题, 如foo() -> _foo() )

在C语言中默认的调用惯例是cdecl

 

1

2

3

参数传递 : 从右往左的顺序压参数入栈

出栈方 : 函数调用方

名字修饰 : 直接在函数名称前加1个下划线

 

 

下面我们用一个例子来形容这个调用惯例.

代码 :

 

1

2

3

4

5

6

7

8

9

10

11

void func(int x, int y)

{

    ...

    return;

}

 

int main()

{

    func(1, 3);

    return 0;

}

流程如下 :

 

调用惯例实例.png

相对于栈而言, 堆更加复杂, 程序随时可能发出申请内存和释放内存的指令, 而申请的内存的大小也大小不一. 下面介绍堆的工作原理.

为什么需要堆? 什么是堆?

如果只有栈, 那么函数返回的时候栈上的数据就会全部被pop掉, 无法将数据传给函数外部. 这样的话全局变量则无法动态地产生与销毁

相对于栈, 堆是一块巨大的空间, 占用了程序大多数的虚拟空间. 在这里, 程序可以自由地申请和释放内存空间. 如 :

 

1

2

3

char *p = (char *)malloc(1000); // 申请1000个字节的内存空间

...

free(p); // 释放1000个字节的内存空间

既然是申请内存空间, 那么这个过程完全可以丢给操作系统去做. “喂! 操作系统, 我这里需要xxx字节的内存, 快给我分配一下..”, 想象下我们操作系统多进程并发的情况, 这显然非常低效. 所以应该一次性向操作系统申请一块适当大小的堆空间. 就像你爸一次性给你一个月的零花钱而不用你每天张手跟他要零花钱一样.

堆管理

怎么向堆申请空间呢? 我们知道用malloc函数, 却不知道其背后做了什么.

程序的确是通过malloc向堆申请空间, 而我们清楚的知道, 如果每次malloc都向操作系统申请的话很影响效率, Linux下通过mmap()函数向操作系统申请一块堆空间(Windows下为VitualAlloc() ), 以后malloc时就会从这里索取需要的空间, 只有这里的堆空间又不足了, 堆才会向操作系统再申请多一块堆空间.

内存泄漏

假设程序员总是向堆申请内存空间, 使用完后又不及时释放(free)掉, 这就会造成内存泄漏. 操作系统不会自动回收堆空间, 因为它不知道这一块内存到底是不是有人在用啊. 于是久而久之, 操作系统能用的内存空间就会越来越少, 我们就会感到越来越卡. 这个时候往往重启一下电脑(手机), 这种情况就会改善. 就是这个原因啦.

多线程

线程相对于进程而言, 其访问权限就没那么多约束, 一般来说线程与线程共享整个进程内存的所有数据, 线程甚至可以访问其他线程的堆栈(比较少见)

线程私有

 

1

2

3

局部变量

函数的参数

线程局部存储(TLS, Thread Local Storage)数据

线程之间共享(进程所有)

 

1

2

3

4

5

全局变量

堆上的数据

函数里的静态变量

程序代码, 任何线程都有权利读取并执行任何代码

打开文件, A线程打开的文件可以有B线程读写

用户线程和内核线程之间的关系 :

 

1

2

3

4

5

6

7

一对一 :

    好处 : 线程之间真正的并发, 一个线程阻塞不会影响到其他线程

    坏处 : 由于操作系统限制了内核线程的数量, 所以用户线程的数量也会受到影响, 内核线程调度时, 上下文切换开销大导致用户线程执行效率低

多对一 :

    好处 : 高效的上下文切换和几乎无限制的用户线程数量

    坏处 : 由于多个用户线程对应一个内核线程, 所以其中一个用户线程阻塞会导致其他线程也随之阻塞

多对多 : 结合了一对一和多对一的优缺点, 折中

9.死亡

进程的生命终究走到了尽头, 从main函数return之后就回到入口函数处, 回到梦开始的地方, 把所有资源一一释放掉, 然后转身走开, 不带走一片云彩… 有缘再见~


.小知识

API与ABI

 

1

2

3

4

5

6

7

相同点 : 都是应用程序接口

 

不同点 :

    > API是源代码层面的接口, 而ABI是二进制层面的接口

    > API相同不等于ABI相同 (例如, 同样一个printf函数, 在不同的系统中的底层实现有可能不一样)

 

ps : 二进制兼容(ABI兼容)实现相当困难

编译器优化 和 CPU的动态调度换序

编译器优化

即便是短短的一条x++;的代码, 翻译成汇编语言之后也是需要几条来执行. 那么编译器为了优化代码, 有时候会将一个变量缓存到寄存器而不立即写回(时间局部性原理), 又或者调整这些指令的顺序… 所以多线程下没有绝对的安全(上锁也不例外)

CPU的动态调度换序

CPU的为了优化有时也会乱序执行代码, 也就是说不是一行接着一行执行代码, 那么在一些情况下也会有安全漏洞, 例如单例模式下, 有可能取到的是一个未初始化的对象.

解决办法 :

 

1

2

3

编译器优化 : 使用volatile关键字

 

CPU的动态调度换序 : 使用barrier

变长参数

我们最熟悉不过的带变长参数的函数就是int printf(const char *format, ...); 我们知道, 除了第一个参数外, 还可以追加任意数量, 任意类型的参数.

我们用一个简单的函数来说明这种变长参数的实现原理. 如 : int sum(int num, ...);

当我们调用 int n = sum(1, 3, 5, 7);时, 按道理我们只能用num来访问1这个参数, 其他参数访问不了. 但是多亏了C语言默认的cdecl调用惯例的自右向左压栈的传递方式. 此时函数内部的堆栈如下 :

 

变长参数.png

由于其他的几个参数在num的高地址方向, 所以我们可以间接利用num来访问其他的那几个参数. 而printf函数接收的参数变量类型不一致, 所以比这个要复杂得多得多

格式化

对于一些人来说, 对硬盘格式化就是硬盘的数据全部都没啦..

这种观点完全错误!!! 硬盘上装的是什么? 0和1的序列.. 实际上操作系统是根据一张表, 在表中找到你要找的文件在硬盘中的具体位置, 然后再到硬盘中访问.

 

格式化的本质就是把这张表的内容全擦除掉, 这样操作系统就忘记了你的文件放在哪里了. 尽管文件还是在原来的地方, 他也找不到了.

现在有种方法就是格式化就把这些区域全部用0或者用1填充.

所以大家的SD卡啊, 硬盘啊, 宁愿破坏再扔掉也不要轻易交给别人啊!!!

 

 


 



 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值