您的位置:首页 > 职场人生

读《程序员的自我修养 —— 库与运行库》乱摘

2016-06-12 10:45 260 查看
2016.06.07 - 06.30

读《程序员的自我修养 —— 链接、装载与库》“库与运行库”的个人理解笔记。

06.07

一个程序典型的运行环境:程序(内存),运行库,API,内核。

1 内存

1.1 程序的内存布局



Linux进程虚拟地址空间分布

1.2 栈与调用惯例

(1) 栈

。栈是一个特殊的容器(抽象的描述)。将数据放入栈中叫作入栈;将数据从栈中取出来称为出栈。出栈和入栈需要遵循的规则:先入栈的数据后出栈。

在程序运行过程中,栈保存了一个函数调用所需要的维护信息(堆栈帧)。

06.08

一般,可以用esp寄存器指向栈顶,用ebp指向栈底。

在i386中,一个函数的活动记录用ebp和esp这两个寄存器划定范围。esp寄存器始终指向栈的顶部,同时也指向了当前函数的活动记录的顶部。而相对的,ebp寄存器指向了函数活动记录的一个固定位置,ebp寄存器又被称为帧指针。一个常见的活动记录如下图所示:



esp始终指向栈顶(随着函数的执行,esp值会发生变化)。ebp固定在图中所示的位置,不随函数的执行而发生变化。

例 – 调用一个函数的活动记录。

06.13

call_fun_activity_record.c

/* call_fun_activity_record.c
* 通过反汇编一个子函数了解函数调用的活动记录
* 2016.06.13
*
*/

int foo()
{
int  i, k;
char ch;

i   = 1;
ch  = 1;
k   = i + ch;

return k;
}

// 用作gcc的编译通过
int main()
{
int v;

v   = foo();
}


经过编译形成可执行文件(或直接看foo目标文件的反汇编)





经过该反汇编可分析出foo函数被调用的活动记录(堆栈帧)如下图。



(2) 调用惯例

即对“函数参数的传递顺序和方式”、“栈的维护方式”、“名字修饰的策略”等的规定。

(3) 函数返回值传递

对于4个字节来说,函数将返回值存储在eax中,返回后函数的调用方再读取eax。对于5 ~ 8字节对象的情况,几乎所有的调用惯例都是采用eax和edx联合返回的方式进行的。对于超过8字节的情况,这些内容会被存储到内存中,然后将该内存的地址存到eax中,返回后函数的调用方再读取eax。

例 – 多字节(超过8字节)返回值的传递过程。

多字节(超过8字节)返回值的传递过程(参考)

如下C语言程序例子。

return_big_thing.c

/* return_big_thing.c
* 理解在子函数中返回超过8字节内容的方式
* 2016.06.13
*/

typedef struct big_thing
{
char buf[128];
}big_thing;

big_thing return_big_thing_test()
{
big_thing b;
b.buf[0]   = 0;
return b;
}

int main()
{
big_thing   bt;
bt  = return_big_thing_test();
}


06.16

return_big_thing_test函数可以通过以下方式返回给main函数128字节内容。



上图可以被简要解释为:

[1] 首先main函数在栈上额外开辟了一片空间,并将这块空间的一部分作为传递返回值的临时对象(temp)。

[2] 将temp对象的地址作为隐藏参数传递给return_big_thing_test函数。

[3] return_big_thing_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出。

[4] return_big_thing_test返回之后,main函数将eax指向的temp对象的内容拷贝给bt。这样就实现了多字节的返回。

这不一定就是实现多字节返回的唯一方式(如将bt的地址直接作为隐藏参数传递给return_big_thing_test函数)。

1.3 堆与内存管理

在进程的虚拟地址空间中,除了可执行文件、共享库和栈之外,剩余的未分配的空间都可以被用来作为堆空间。

Linux下的进程管理堆提供了两种堆空间分配的方式:brk()和mmap()系统调用。

2 运行库

2.1 入口函数和程序初始化

整个过程

操作系统加载程序之后,首先运行的代码并不是main的第一行,而是某些别的代码,这些代码复杂准备好main函数执行所需要的环境,并且负责调用main函数。在main返回之后,它会记录main函数的返回值,调用(atexit)注册的函数,然后结束进程。运行(包含)这些代码的函数称为入口函数或入口点,视平台的不同而有不同的名字。程序的入口点实际上是一个程序的初始化和结束部分,它往往是运行库的一部分。一个典型的程序运行步骤大致如下:

-[1] 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。

-[2] 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等。

-[3] 入口函数在完成初始化之后,调用main函数,正式开始执行程序主体部分。

-[4] main函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。

06.09

glibc启动代码(静态glibc用于可执行文件的例子)。

glibc源代码的子目录libc/csu中有关于程序启动的代码。

glibc的程序入口为ld链接器默认指定的(可修改)_start。_start由汇编实现,跟平台相关(可以用函数的活动记录知识查看_start汇编代码)。

环境变量。环境变量是存在于系统中的一些公用数据,任何程序都可以访问。通常来说,环境变量存储的都是一些系统的公共信息,例如系统搜索路径,当前OS版本等。环境变量的格式为key=value的字符串,C语言里可以使用getenv这个函数来获取环境变量信息。

06.23

入口函数和程序初始化

[1] (Glibc)入口函数如何实现



调用exit系统调用后,进程会直接结束。程序正常结束有两种情况,一种是main函数的正常返回,一种是程序中用exit退出。(从__libc_start_main中可以看出)即使main返回了,exit也会被调用。exit是进程正常退出的必经之路。

运行库与I/O

在了解glibc入口函数(启动代码)的基本思路后,是了解各个部分的具体实现的时候了。

程序角度的I/O。一个程序的I/O指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。(I/O指代任何操作系统理解为“文件”的事物)

句柄。Linux下的文件描述符在Windows下叫作句柄。[By the way]

I/O初始化。首先I/O初始化函数需要在用户空间中建立stdin、stdout、stderr及其对应的FILE结构,使得程序进入main之后可以直接用printf、scanf等函数。

06.28

fd。在内核中,每个进程都有一个私有的“打开文件表”,这个表是一个指针数组,每一个元素都指向一个内核的打开文件对象。而fd,就是这个表的下标。(当用户打开一个文件时,内核会在内部生成一个打开文件对象,并在这个表里找到一个空项,让这一项指向生成的打开文件对象,并返回这一项的下标作为fd)



2.2 C语言运行库

C运行库(CRT)。包含入口函数、所依赖的函数所构成的函数集合及其包括各种标准库函数的实现。由它支持一个C程序的正常运行。一个C语言库大致包含了如下功能:

-[1] 启动与退出:包括入口函数以及入口函数所依赖的其它函数。

-[2] 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现。

-[3] I/O:I/O功能的封装和实现。

-[4] 堆:堆的封装和实现。

-[5] 语言实现:语言中的一些特殊功能实现。

-[6] 调试:语言中一些特殊功能的代码。

C标准库

C语言标准库占据了C运行库的主要地位。

变长参数

当我们调用int n=sum(3, 16, 38, 53);时,参数在栈上会形成如下图所示的布局。



glibc。glibc主要由两部分组成,一部分是头文件,比如stdio、stdlib等,它们往往位于/usr/include;另外一部分则是库的二进制文件。二进制部分主要的就是C语言标准库,它有静态和动态两个版本。(glibc除了C标准库外,还有几个辅助程序运行的运行库,这几个文件可以称得上真正的“运行库”。它们就是/usr/lib/crtl.o、/usr/lib/crti.o。)[By the way]

glibc启动文件。crtl.o里面包含的就是程序的入口函数_start,由它负责调用__libc_start_main初始化libc并且调用main函数进入真正的程序主体。其包含了基本的启动、退出代码。

06.28

由于C++的出现和ELF文件的改进,出现了必须在main()函数之前执行的全局/静态对象构造和必须在main()函数之后执行的全局/静态对象析构。为了满足类似的需求,运行库在每个目标文件中引入两个与初始化相关的段”.init”和”.finit”。运行库会保证所有位于这两个段中的代码会先于/后于main()函数执行。链接器在进行链接时,会把所有输入目标文件中的”.init”和”.finit”按照顺序收集起来,然后将它们合并输出文件中的”.init”和”.finit”。但这两个输出的段中所包含的指令还需要一些辅助的代码来帮助它们启动,于是引入了两个目标文件分别用来帮助实现初始化函数的crti.o和crtn.o。[用objdump –dr /usr/lib/crti.o(crtn.o)可查看其中的_init()和_finit()函数的开始(crti.o)和结尾(ctrn.o)部分(多个文件的.init和.finit段还要被链接器合并,各个文件的链接顺序)]



.init段的组成

2.3 运行库与多线程

线程访问权限。线程的访问能力非常自由,它可以访问线程内存里的所有数据,甚至包括其他线程的堆栈(如果它知道其他线程的堆栈地址,然而这是很少见的情况),但实际运用中线程也拥有自己的私有存储空间,包括:

-[1] 栈(尽管并非完全无法被其他线程访问,但一般你看下仍然可以认为是私有的数据)。

-[2] 线程局部存储(Thread Local Storage,TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的尺寸。

-[3] 寄存器是执行流的基本数据,因此为线程私有。

多线程运行库。对于C/C++标准库来说,线程相关的部分是不属于标准库的内容的,它跟网络、图形图像等一样,属于标准库之外的系统相关库。(多线程操作接口?运行库本身支持多线程环境 —— 如C标准库中的errno:大多数错误代码是在函数返回之前赋值在名为errno的全局变量里的。多线程并发的时候,有可能A线程的errno的值在获取之前就被B线程给覆盖掉,从而获得错误的出错信息)

3 系统调用与API

3.1 系统调用介绍

由于系统有限的资源有可能被多个不同的应用程序同时访问,因此,如果不加以保护,那么各个应用程序难免产生冲突。所以现代操作系统都将可能产生冲突的系统资源给保护起来,阻止应用程序直接访问。这些系统资源包括文件、网络、IO、各种设备等。

为了让应用程序有能力访问系统资源,也为了让程序借助操作系统做一些必须由操作系统支持的行为,每个操作系统都提供一套接口,以供应用程序使用。这些接口往往通过中断来实现(Linux使用0x80号中断作为该套接口的入口,Windows采用0x2E)。

Linux系统调用。在x86下,系统调用由0x80中断完成,各个通用寄存器用于传递参数,EAX寄存器用于表示系统调用的接口号,比如EAX=1表示退出进程(exit);EAX=2表示创建进程(fork);EAX=3表示读取文件或IO(read);EAX=4表示写文件或IO(write)等,每个系统调用都对应于内核资源代码中的一个函数,它们都是以“sys_”开头的,比如exit调用对应内核中sys_exit函数。当系统调用返回时,EAX有座位调用结果的返回值。[见函数返回值传递]。这些系统调用都可以在程序里直接使用,它的C语言形式被定义在“usr/include/unistd.h”中[比如glibc的fopen、fread、fclose打开读取和关闭文件,而直接使用open()、read()和close()来实现文件的读取,使用write向屏幕输出字符串(标准输出的文件句柄为0)]。

3.2 系统调用原理

中断触发(int 80h) –> 堆栈切换 –> 中断处理程序。

特权级与中断。现代的CPU常常可以在多种截然不同的特权级别下执行指令,在现代操作系统中,通常也据此由两种特权级别,分别为用户模式内核模式,也被称为用户态和内核态。由于有多种特权模式的存在,操作系统就可以让不同的代码运行在不同的模式上,以限制它们的权力,提高稳定性和安全性。普通应用程序运行在用户态的模式下,诸多操作将受到限制,这些操作包括访问硬件设备、开关中断、改变特权模式等。操作系统一般是通过中断来从用户态切换到内核态。

系统调用原理见《30天自制操作系统》更详细(有源码样例)。

堆栈切换。在实际执行中断向量表中的第0x80号元素所对应的函数之前,CPU首先还要进行栈切换。在Linux中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。在应用程序调用0x80中断时,程序的执行流程从用户态切换到内核态,这时程序的当前栈必须也相应地从用户栈切换到内核栈。从中断处理函数中返回时,程序的当前栈(esp的值所在的栈空间,ss值为当前栈所在的页)还要从内核栈切换回用户栈。

将当前栈由用户栈切换到内核栈的实际行为就是:

-[1] 保存当前的ESP、SS的值。

-[2] 将ESP、SS的值设置为内核栈的相应值。

反过来,将当前栈由内核栈切换为用户栈的实际行为则是:

-[1] 恢复原来ESP、SS的值。

-[2] 用户态的ESP和SS的值保存在内核栈上。

当0x80号中断发生的时候,CPU除了切入内核态之外,还会自动完成下列事:

-[3] 找到当前进程的内核栈(每个进程都有自己的内核栈)。

-[4] 在内核栈中依次压如用户态的寄存器SS、ESP、EFLAGS、CS、EIP。

而当内核从系统调用中返回的时候,需要调用iret指令来回到用户态,iret指令则会从内核栈里弹出寄存器SS、ESP、EFLAGS、CS、EIP的值,使得栈恢复到用户态的状态。



中断处理程序



Linux新型系统调用机制

由于int指令的系统调用在奔腾4代处理器上性能不佳,Linux在2.5版本起开始支持一种新型的系统调用机制。这种新机制使用Intel在奔腾2代处理器就开始支持的一种专门针对系统调用的指令 —— sysenter和sysexit。

4 运行库实现

06.11

4.1 迷你C运行库

描述一个迷你CRT,具备入口函数、初始化、堆管理、基本IO。

[调用系统调用函数,黑体函数名为编写函数]

[1] 入口函数

程序运行最初入口点是由运行库为其提供的入口函数。它主要负责三部分工作:准备好程序运行环境及初始化运行库,调用main函数执行程序主体,清理程序运行后的各种资源。运行库为所有程序提供的入口函数应该相同,在链接程序时需要制定该入口函数名。

入口函数框架

06.28

void mini_crt_start(void)
{
// 初始化部分

int ret = main();

// 结束部分

exit(ret);
}


这里的初始化主要负责准备好程序运行的环境,包括准备main函数的参数、初始化运行库,包括堆、IO等,结束部分主要负责清理程序运行资源。

main的参数



初始化运行库

如初始化堆(让程序能使用malloc/free)mini_crt_heap_init();IO部分的初始化mini_crt_io_init()

结束部分

[1] 调用由atexit()注册的退出回调函数;

[2] 结束进程。

这两项任务都有exit()函数(先调用由atexit()注册的退出回调函数,由mini_crt_exit_routine()实现;再调用1号系统调用实现进程结束)完成。

入口函数的代码清单

[敲一遍]

mini_crt_start.c

#include “minicrt.h” // 以概念来理解该包含

#ifdef WIN32
#include <Windows.h>
#endif

extern int main(int argc, char *argv[]);
void exit(int);

static void crt_fatal_error(const char *msg)
{
exit(1);
}

void mini_crt_entry(void)
{
int ret;

#ifdef WIN32
int flag = 0;
int argc = 0;
char *argv[16]; //最多16个参数
char *cl = GetCommandLineA();

// 解析命令行
argv[0] = cl;
argc++;
while (*cl) {
if (*cl == ‘\”’)
if (flag == 0) flag = 1;
else flag = 0;
else if (*cl == ‘ ‘ && flag == 0) {
if (*(cl + 1)) {
argv[argc] = cl + 1;
argc++;
}
*cl = ‘\0’;
}
cl++;
}
#else
int argc;
char **argv;

char *ebp_reg = 0;
asm(“movl %%ebp, %0 \n” : “=r”(ebp_reg));

argc = *(int *)(ebp_reg + 4);
argv = (char *)(ebp_reg + 8);
#endif

if (!mini_crt_heap_init())
crt_fatal_error(“heap initialize failed”);

if (!mini_crt_io_init())
crt_fatal_error(“IO initialize failed”);

ret = main(argc, argv);
exit(ret);
}

void exit(int exitCode)
{
#ifdef WIN32
ExitProcess(exitCode);
#else
asm( “movl %0, %%ebx \n\t”
“movl $1, %%eax \n\t”
“int $0x80 \n\t”
“hlt  \n\t” :: “m”(exitCode));
#endif
}


[2] 堆的实现

06.29

实现堆的操作,即实现malloc()函数和free()函数[《TCPL》、《30天自制操作系统》、《本书》 —— 算法 + 系统调用]。

[3] IO与文件操作

IO部分在任何软件中都是最为复杂的。在传统的C语言和UNIX里面,IO和文件是同一个概念,所有的IO都是通过对文件的操作来实现的,也就是实现基本的文件操作fopen、fread、fwrite、fclose和fseek(《TCPL》、《30天自制操作系统》、《本书》 —— 算法 + 系统调用)。

[4] 字符串相关操作

字符串相关的操作包括计算字符串长度、比较两个字符串、整数与字符串之间的转换等。这部分功能无须涉及任何与内核交互,是纯粹的用户态的计算。

[5] 格式化字符串

如实现printf函数(典型的变长参数函数 —— 《汇编语言》、《本书》 —— 参数在栈中的分布)

4.2 使用迷你C运行库

一般一个CRT提供给最终用户时往往有两部分,一部分是CRT的库文件部分,用于与用户程序进行链接,如Glibc提供了两个两个版本的库文件:静态Glibc库libc.a和动态Glibc库libc.so。CRT另外一个部分就是它的头文件,包含了使用该CRT所需要的所有常数定义、宏定义及函数声明。

06.30

[1] 建立包含所有相关常数定义、宏定义以及迷你CRT所实现的函数声明的头文件minicrt.h。当用户程序使用mini CRT时,仅需要包含minicrt.h即可。

[2] 编译得到(静态)库文件

用各平台下的(编译)工具加特殊的参数得到库文件。

[2016.06.12 - 10:46]
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: