程序员的自我修养。
#@author: gr
#@date: 2014-03-01
#@email: [email protected]
使用南桥处理低速设备:鼠标、键盘、磁盘
使用北桥处理高速设备:CPU、内存、PCI总线
分层解决,提供接口:
Application => Runtime Library => Operating System Kernel => Hardware
操作系统除了提供抽象的接口,另一个功能是管理硬件资源:CPU、存储器、I/O设备。
存在的问题:
虚拟地址通过增加中间层,通过映射,将这个虚拟地址转换成实际的物理地址。
分段:
将程序所需的内存空间大小的虚拟空间映射到物理空间。可以解决问题一、三。分段对内存区域的映射还是按照程序为单位,如果内存不足,整个程序将被换出。
分页:
分页大小默认是4KB。换入换出以做为单位,大大降低换入换出的数据。
虚拟存储通过一个叫MMU(Memory Management Unit)的部件来进行页映射。程序中的地址是虚拟地址,经过MMU转换为物理地址。一般MMU集成在CPU内部。
Windows对进程和线程区分得很清楚。Linux内核中并不存在真正意义上的线程概念。Linux的执行实体都称为任务,每个任务类型于一个单线程的进程,但Linux不同的任务之间可以选择共享内存空间,所以实际意义上,共享了同一个内存空间的多个任务构成了一个进程。可以使用fork
, exec
, clone
等创建新的任务。
四种进程或线程同步互斥的控制方法
1、临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问。
2、互斥量:为协调共同对一个共享资源的单独访问而设计的。
3、信号量:为控制一个具有有限数量用户资源而设计。
4、事 件:用来通知线程有一些事件已发生,从而启动后继任务的开始。
预编译、编译、汇编、链接
扫描 => 词法分析 => 语法分析 => 语义分析 => 中间语言生成 => 目标代码生成与优化
地址和空间分配, 符号决议(Symbol Resolution),重定位
objdump
vs readelf
:
objdump可以进行反汇编。
const
变量会放到.rodata段。
C中未初始化全局变量放在bss段中,且并没有分配空间,只是记录大小。初始化的变量放在data段中。
bss:
#include <stdlib.h>
#include <stdio.h>
int a[10000];
int main(){
int c;
getchar();
return 0;
}
readelf -S bss
readelf -s bss
data:
#include <stdlib.h>
int a[10000]={1};
int main(){
getchar();
return 0;
}
readelf -S data
readelf -s data
C++的所有全局对象都被以“初始化过的数据”来对待,都是作为强符号来使用的,而C中的未初始化的全局变量(包括初始为0)则只记录到BSS段中,不占用空间,是弱符号,所以可以在两个文件中声明相同的全局变量。
ELF Header -> .text -> .data -> .bss -> other sections -> Section header table -> String Tables, Symbol Tables
文件头位于文件的最前部,它包括了描述整个文件的基本属性,比如ELF文件版本、目标机器型号,程序入口地址等。紧接着是各个段,之后是描述段属性的段表,比如段的大小,偏移,段名,读写权限。
ELF文件头中定义了ELF魔数、文件机器字节长度,数据存储方式、版本、运行平台、ABI版本、入口地址、段表的位置、段的数量、目标平台等。
从文件头中得到如下信息:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
前面的16个字节,正好对应“Elf32_Ehdr”的e_indent
这个成员,这16个字节被ELF标准规定用来标识ELF文件的平台属性。
DEL控制符
, E
, L
, F
。这4个字节被称为ELF文件的魔数。a.out格式最开始的两个字节是0x01, 0x07,PE文件是0x4d, 0x5a。:-)
。每一个段的信息放到一个“Elf32_Shdr”类型的结构里,多个段组成一个“Elf32_Shdr”的数组。
链接器在处理目标文件,须要对目标文件中某些部位进行重定位。有一个".rel.text"段,它的类型为"SHT_REL",它就是一个重定位表。它是针对".text"段的重定位表。
ELF文件用到了许多字符串,比如段名、变量名等。把字符串集中起来放到一个表,用字符串在表中的偏移来引用字符串。
字符串表一般以段的形式保存,常见的段名为“。字符串表".strtab”存储一般的字符串,段表字符串表“.shstrtab”存储段表中用的字符串,比如段名。
在链接中,将函数和变量统称为符号,函数名或变量名就是符号名。
符号表往往也存储在文件中的一个段,叫".symtab"。其中,每个Elf32_Sym
结构对应一个符号,它也是一个数组。
typedef struct{
Elf32_Word st_name; //符号名,利用字符串表的下标
Elf32_Addr st_value; //符号相应的值,地址
Elf32_Word st_size; //符号大小,如double占8个字节,类类型也有大小
unsigned char st_info; //符号类型和绑定信息
unsigned char st_other; //目前为0, 没用
Elf32_Half st_shndx; //符号所在的段
}Elf32_Sym;
Problems:
全局静态变量肯定会保存在符号表中,但是对于局部静态变量或者一个类中的静态成员变量,会保存在符号表中吗?为什么呢?
符号表的作用主要在于用来进行链接,局部静态变量或者一个类中的静态成员变量如果不进行debug的话, 是没有必要保存在符号表中的。
如果全局静态变量是个比较复杂的class,那么符号表在编译时就能确定class的大小吗?如果不能确定,怎么能够把这个class放到符号表中呢?
如果在程序中能使用某一类型(包括类)定义一个此类型的变量,那它一定是一个完整类型,即类型的大小已知。
C语言会在相对应的符号名前加上""。现在LInux下的GCC已经去掉了"",而Windows下还保存这种习惯。
C++语言更强大而复杂,为了避免冲突,需要进行“Name Mangling”。Visual C++的名称修饰规则没有对外公开。GCC的C++修饰方法如下:所有符号都以"_Z"开头,对于嵌套的名字,后面紧跟N,然后是各个名称空间和类的名字,每个名字前是字符串的长度,再以E结尾。对于函数来说,还要加上参数列表在E后面,对于“int"类型就是字母”i“。
可以使用c++filt
来解析被修饰过的名字,如下:
[linux]$ c++filt _ZN1N2C24hellEid
N::C2::hell(int, double)
extern "C"
C++会将在extern "C"
的大括号里的代码当作C语言代码处理。
强符号不允许被多次定义。
函数和初始化的全局变量(包括初始化为0)是强符号,未初始化的全局变量是弱符号。
全局未初始化变量会被初始为0,而局部变量的值是不确定的。
#include <iostream>
int global; // global variable 初始化为0
using namespace std;
class Test{
public:
Test(){
cout << _a << endl; // member data 不确定
int b; // local variable 不确定
cout << b << endl;
}
int a(){return _a;}
private:
int _a;
};
int main(){
Test t;
cout << t.a() << endl; // member data不确定
int local; // local variable 不确定
cout << "global: " << global << endl;
cout << "local: " << local << endl;
}
将多个模块相同性质的段合并到一起。
两步链接:
一、空间与地址分配
二、符号解析与重定位
Linux下一般程序的入口是”_start“,这个函数是Linux系统库(Glibc)的一部分。在main函数之前,可能还有一些操作要被执行,比如全局对象的构造。
.init 该段保存的指令在main被调用之前,Glibc的初始化部分执行这个段中的代码。
.fini 同样,当一个程序的main函数正常退出时,Glibc会执行这个段中的代码。
# 静态库
ar -cr demo.a a.o b.o
# 动态共享库
gcc -fPIC -shared -o test.so test.c
操作系统占据高地址的1GB空间。剩下的是用户空间。
创建一个进程,装载相应的可执行文件并且执行。需要做三件事情:
创建虚拟地址空间。并不是真正创建空间,实际上是创建映射函数所需的数据结构,在LInux上分配一个页目录就可以了,记录虚拟页与物理页帧间的对应关系。
建立映射关系。上一步是虚拟空间到物理内存的映射,这一步是虚拟空间和可执行文件的映射。当发生页错误,知道从哪里读取数据进入内存。
将指令寄存器设置成可执行文件入口,启动运行。
将单个段(Section)装入内存由于页对齐的原因,会造成大量的空间浪费。这样,可以将相同权限的段(Section)合并装入,既可以达到权限管理,又可以节省空间。
这里引入“Segment”的概念,如果将".text"段(Section),".init"段(Section)合并在一起看作是一个"Segment",那么装载的时候就可以看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的VMA,而不是两个。
如下,多个Section组成一个Segment,这些Section被映射到同一个VMA:
$ readelf -l main
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame .gcc_except_table
03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss
04 .dynamic
05 .note.ABI-tag .note.gnu.build-id
06 .eh_frame_hdr
07
08 .init_array .fini_array .jcr .dynamic .got
一般进程按权限可以分为如下几种区:
代码VMA、数据VMA、堆VMA、栈VMA。当讨论进程虚拟空间的”Segment“时,基本上就是这几种VMA。
页是物理内存调度的基本单位,页的大小一般为4096个字节。这样,由于Segment大小不一定对齐,也会造成空间的浪费。
UNIX系统让各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次。
有些函数直到程序运行结束也没有用到,把所有函数都链接好是一种浪费。延迟绑定的基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位),没有用到就不进行绑定。
Linux有一套机制可以保证库兼容问题。
libname.so.x.y.z
如上,x表示主版本号,y表示次版本号,z表示发行版本号。主版本号是软件是重大升级,不同主版本号之间是不兼容的。
次版本号表示增量升级,会增加一些新的接口符号,且保持原来的符号不变。可以和相同主版本号的兼容。
发行版本号表示库的一些错误的修正、性能的改进。
在依赖动态库的软件中dynamic
段会有DT_NEED的字段,字段的值就是需要的动态链接库名。
SO-NAME是共享库文件名去掉次版本号和发布版本号,保留主版本号。比如一个共享库叫做libfoo.so.2.6.1
,那么它的SO-NAME就是libfoo.so.2
,它会链接到libfoo.so.2.6.1
(一般会链接到最新版本)。
linux采用虚拟内存管理技术,每一个进程都有一个3G大小的独立的进程地址空间,这个地址空间就是用户空间。内核空间占据了每个虚拟空间中的最高1GB字节,但映射到物理内存却总是从最低地址(0x00000000)开始。
栈从高地址向下生长。
堆从低地址向上生长。(不一定)
函数调用维护的信息:
调用方与被调用方共同遵守的约定。
如下的函数:
int _cdecl foo(int m, float n);
按照VC中的cdecl标准,具体的栈操作如下:
调用_foo执行,分为两步:
对于小于8字节的返回值,使用eax和edx联合返回的方式进行。如果大于8字节的类型,往往采用如下步骤:
malloc的实现:
有种做法,就是将进程的内存管理交给操作系统,每次申请内存,就一次系统调用,但系统调用开销很大,严重影响性能。比较好的做法是,进程向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,具体来讲,管理堆空间分配的往往是程序的运行库。
运行库需要一个堆分配算法来管理申请的内存,不能把同一块地址分配两次。
对于小于128KB的请求,它会在现有堆空间里面,按照堆分配算法为它分配一块空间并返回。对于大于128KB的请求来说,它会使用mmap()函数为它分配一块匿名空间,然后在匿名空间中为用户分配空间。
空闲链表
把空闲空间按照链表的方式连接起来,当用户请求空间时,遍历整个列表,直到找到合适大小的块并将它拆分;释放空间时,要将它加到空闲链表中。存在的问题:结构很脆弱,一旦链表被越界修改时,整个堆都无法工作。
位图
将整个堆划分为大量的块,每个块大小相同。第一块称为头,其余是主体,可以使用一个数组记录块的使用情况,有头/主体/空闲三种状态。分配内存的时候容易产生碎片。
对象池
每次分配的空间大小都一样,可以按照这个每次请求的分配的大小作为一个单位,把整个空间划分为大量的小块,每次请求的时候只需要找到一个小块就可以了。
glibc采用多种算法复合。