前言
上接:
介绍
-
ESP:(extended stack pointer,栈指针寄存器);EBP:(extended base pointer,基址指针寄存器) -
glibc:(GNU C library,是GNU发布的libc库,即c运行库。glibc是linux系统中最底层的api,几乎其它任何运行库都会依赖于glibc。 -
MSVC:微软(MS)的VC运行库。 -
MSVC CRT:Microsoft Visual C++ C Runtime Library,是Windows下的C运行库。 -
TLS:(Thread Local Storage,线程局部存储) -
SDK:(software developmet kit,软件开发包) -
堆空间并不是向上增长的,这是源于类unix系统使用类似brk() 的方法分配堆空间,brk() 是向上分配的。但是windows的HeapCreate() 就不是。 -
大小端:
- 大端:高字节存低地址、低字节存高地址;高低低高;用于MAC、TCP/IP
- 小端:高字节存高地址、低字节存低地址;高高低低;用于X86 PC
内存
内存布局
- 内存的用户空间主要包含了栈、堆、可执行文件映像、保留区、动态链接库映射区。
segment fault 和 非法操作,该内存地址不能read/write 是典型的非法指针解引用造成的错误。
栈与调用惯例
-
栈:
- 通常的OS中,栈向下增长。栈顶由esp寄存器定位。
- 栈通常保存了一个函数调用所需维护的信息,常称为栈帧。
- 栈帧:保存了函数的返回地址和参数、临时变量(非静态局部变量和临时变量)、上下文(函数调用前后的寄存器)。
-
- 函数调用过程:
- 参数压栈。(到时候使用类似
ebp-4 相关地址访问参数) - 下一条指令压栈。(用于函数返回时知道返回地址)
- 跳转到函数体。(进行函数调用)
ebp 压栈。(保存上一次的ebp,以供函数返回时修正栈帧)- mov ebp, esp; 将栈顶指针赋给栈底指针。(开始新函数的栈帧)
- 分配临时空间【可选】。
- 寄存器入栈【可选】(以供函数返回时恢复寄存器(上下文))
- 函数返回过程:
- pop 寄存器; 【可选】。恢复寄存器(上下文)
- mov esp,ebp; 恢复
esp 同时回收函数局部空间 。 - pop ebp; 恢复ebp,回到调用函数之前的栈帧。
- ret 从栈中取返回地址,跳转到该地址。(pop 和这一步
esp 都将发生相应变化 ) - 钩子(HOOK)技术:
- 很多指令例如
mov edi edi 、nop 这种占位符的存在。用于函数在运行时可以被其他函数替换掉, 通过将其替换成对其他函数的jmp 。 - 这种替换机制可以用来实现 钩子(HOOK)技术,允许用户在某情况下截获特定函数的调用。
-
调用惯例:
- 函数调用方和被调用方需要有个调用惯例,保证函数正确调用。
- 主要包括:参数传递顺序和方式(栈传递还是寄存器传递、压栈顺序)、栈的维护方式(由调用者弹栈还是由被调用者弹栈)、名字修饰策略。
- C语言中默认
cdecl 调用惯例。(参数从右到左压栈、由调用方出栈、名字修饰在函数前加_ ) - 不同的编译器支持的调用惯例不一定相同。
- C++的名字修饰策略更加复杂, 因为重载、命名空间、成员函数等,使得C++函数名可以对应多个函数定义。
- VC对C++的
this 指针存放与ecx 寄存器传递 。而gcc、thiscall、cedcl只是将this 看作函数的第一个参数传递。 -
函数返回值的传递:
- 通常的返回值,4字节由
eax 寄存器传递,5~8字节由eax 、edx 寄存器组合传递。 - 当返回值类型尺寸太大,C语言会在函数返回时,使用一个临时的栈上空间作为中转,这样使得返回值对象会被拷贝两次。
- 其中临时的栈上空间由进入函数之前分配好,并将其地址作为隐含参数传递进函数。
- 最终返回值被拷贝到临时空间后,还是由
eax 将这个空间的地址作为返回值传递出去,函数返回后,调用者将eax 指向的临时对象再拷贝给需要的地方。造成返回值对象会被拷贝两次。 - VC和gcc 平台下的C/C++ 思路大同小异,基本都是这样处理。
堆与内存管理
- 堆:
- 由于栈上数据在函数返回后被释放,无法将数据传递至函数外部。全局变量又没法动态的产生,于是堆成了唯一选择。
- 堆空间管理:
- 通常由运行库向OS申请一块适当堆空间 ,然后由运行库管理这块空间。(因为如果由OS管理,那么每次的堆空间申请与释放都需要进行系统调用,开销较大。)
- Linux进程的堆管理:
- 进程地址空间中,除了可执行文件映射区域、共享库、栈,其余空间都可以作为堆空间,Linux提供了两个堆空间分配方式,分别的系统调用为:
brk() 、 mmap() 。
- glic 的malloc:小于128K会在现有堆空间按照堆分配算法分配一块空间并返回;大于128K将会使用
mmap() 分配一块匿名空间,在匿名空间中为用户分配空间。 brk() :用于设置进程的数据段介结束地址。 Linux将数据段和BSS合并为数据段,将数据段结束地址扩大,数据段中扩大的部分就可以作为堆空间之一。 int brk(void* end_data_segment) mmap() :向OS申请一段虚拟地址空间,这个空间可以映射到某个文件(这个系统调用最初的作用),将这个空间不映射文件时,就是匿名空间,用于作为堆空间。void *mmap(void* start,size_t length, int prot,int falgs,int fd,off_t offset) (需要设置起始、长度、权限、映射类型(文件/匿名空间)、文件描述符、文件偏移) - Windows进程的堆管理:
- windows进程虚拟地址空间分布:
- 每个线程的栈都是独立的,所以一个进程有多少线程,就有多少对应的栈。
- Windows默认的栈大小为1MB,线程启动时,OS分配相应空间作为栈。
- windows进程空间支离破碎,OS提供了
VirtualAlloc() API 申请空间,要求申请的空间需是页的整数倍。 - Windows提供了堆管理器(Heap Manager)实现了分配算法。提供了 创建
HeapCreate 、分配HeapAlloc 、释放HeapFree 和销毁HeapDestroy 堆空间的API。
- 在
NTDLL.DLL 和Ntoskrnl.exe 都有一份堆管理器,前者负责Windows子系统DLL与windows内核之间的接口,所有应用程序、运行时库、子系统的堆分配都是使用这部分代码;后者负责内核中堆空间的分配。 - malloc 分配的是连续的虚拟空间,但是不一定是连续的物理页面。
- 堆分配算法: 管理一大块连续内存空间,能够按照需求分配、释放其中的空间。
- 空闲链表:链表头记录pre、next、大小,链接着每块未被使用的内存块, 分配完后将这块断掉。释放后再将其加入链表。
- 链表头被破坏整个堆就无法工作,因为容易越界读写到链表头。
- 位图(堆策略):将堆分配为大量的的块,使用数组记录使用情况,2bits即可记录头、主体、空闲状态。
- 优点在于:空闲信息存储集中,cache容易命中;稳定性好,备份位图即可;不需要额外信息,便于管理。
- 缺点在于:易产生碎片;对很大或者块很小容易造成位图很大,使得浪费空间和降低cache命中率。(可以使用多级位图)
- 对象池(池策略):内存分配对象基本都是固定的一些值,按这些值分配小块,每次只取一个块使用。
- 易于管理、无需查找很大的空间。
- 堆策略会产生外部碎片,而池策略会产生内部碎片。(堆策略使用多个块,造成外部块很多碎片;池策略使用比自己申请空间稍大的一个块,造成块内部碎片。
运行库
入口函数和程序初始化
-
入口函数:
- 入口函数入则准备好main函数执行需要的环境,并调用main;在main返回之后,会记录main返回值,调用
atexit 注册的函数,然后结束进程。(入口函数通常给运行库的一部分)
- OS创建进程,控制权交给程序的入口,往往是运行库中的某个入口函数。
- 入口函数对运行库和程序运行环境初始化。(堆栈、IO、线程、全局变量构造等)
- 调用main。
- mian执行完毕后,返回入口函数,入口函数开始清理工作(全局变量析构、堆销毁、关闭IO等),然后进行系统调用结束进程。
-
入口函数实现: glibc & MSVC
- glibc 入口函数: 以静态glibc用于可执行文件为例
- glibc 程序入口为
_start ,由ld 链接器默认的连接脚本指定。
- 此时栈中存储了环境变量
env 、argv 、argc 。(环境变量包括系统搜索路径、OS版本等。) _start 最终调用_libc_start_main ,并传入main 、argv 、argc 、init (初始化函数)、fini (收尾工作)、rtld_fini (动态链接收尾工作)、栈底指针。_libc_start_main :保存env 的栈中地址、栈底地址。
_libc_start_main 首先调用了一系列初始化函数和atexit 注册main 之后的函数。_libc_start_main 的末尾调用了 main 函数 ,在main 后面跟着 exit 函数 。
exit 函数while 循环执行atexit 和__cxa_atexit 注册的函数链表。 - 程序正常结束有两种情况:一是main函数正常返回、二是程序中调用
exit 函数退出。
- 即使
main 函数正常退出,exit 函数也会被调用。 exit 是进程正常退出的必经之路,因此将atexit 注册的函数任务交给exit 函数执行则万无一失。 - MSVC CRT入口函数:
- MSVC 的CRT默认入口函数名为
mianCRTStartup 。
- 先堆一些预定义的全局变量赋值。(包括OS版本、主版本号等)
- 这里因为没有初始化heap,所以没法使用
malloc 分配内存,则使用alloc 动态的分配栈上内存。(最终还是调整esp释放这些栈上内存) - 随后初始化堆。
- 在try-except块中初始化I/O、初始化
main 函数argv 、设置环境变量、配置其他C库。 - 最后调用
main 函数,然后except 负责最后的清理,最终返回main 的返回值。 -
运行库与I/O:
- IO初始化首先在用户空间建立
stdin 、stdout 、stderr 及其对应的FILE 结构,使得main 之后可以直接使用printf 、scanf 等函数。 - 对于程序来说IO指代:程序与外界的交互。(包括了文件、管道、网络、命令行、信号等)
- 对于任意文件,OS提供一组函数,包括打开、读写、移动文件指针等。
-
在OS层面上,文件操作也有类似FILE的概念,Linux中的fd(file descriptor,文件描述符)、windows中的句柄(Handle) -
用户通过函数打开文件获得句柄,随后通过句柄操作文件。 -
句柄:用于防止用户随意读写OS内核的文件对象,文件句柄总和内核的文件对象相关联。 -
Linux中的fd,0、1、2代表了:标准输入、标准输入、标准错误输出。
- 内核中,每个进程都有一个私有的打开文件表, 这个表存是个指针数组,每个元素指向一个内核的打开文件对象,fd就是这个表的下标。
- 当用户打开一个文件,内核在内部生成一个打开文件对象,并在表中找到一个空项,指向生成的打开文件对象,并返回这项的下标作为fd。
- 打开文件表存在于内核,用户只能通过OS提供的函数进行操作。
-
- 内核中有个指针
p 指向该表,只要有fd ,就可以通过p+fd 访问文件表中的一个项。 -
MSVC CRT的入口函数初始化:
- MSVC的入口函数初始化主要包括:堆初始化、IO初始化。
- MSVC的堆初始化:
- 32为编译环境下,MSVC的堆初始化只是调用了
HeapCreate API创建了一个系统堆。 - 即MSVC的malloc函数是调用了
HeapAlloc API,将堆的管理交给了OS。(不是由运行库进行堆的管理?) - MSVC的I/O初始化主要做了:
- 建立打开文件表
- 如果能够继承自父进程,就从父进程获取继承的句柄。
- 初始化标准输入输出。
- FILE结构中重要的一个字段是
_file ,是一个整数,通过_file 访问内部文件句柄表的项。
- windows中,用户态使用句柄访问内核文件对象,句柄本身是个32位数据类型, 不同场合有用int、指针来表示。
- MSVC的CRT中,已经打开的文件句柄的信息使用
ioinfo 数据结构来表示。(包含打开文件的句柄、文件打开属性、管道的单字符缓冲) - 在
crt\src\ioinit.c 中有个二维数组__pioinfo 表示用户态的打开文件表。
- 通过FILE结构中的
_file 字段中的0~ 4bits和5 ~ 10bits来表示:用户态打开文件表的二维坐标。
- 入口函数只是庞大代码集合的一部分,这个代码集合就是运行库。
C/C++运行库
-
C语言运行库:C语言的运行时库
- 包括了:
- 入口函数以及所依赖的其他函数
- 标准函数:C语言标准库的函数实现
- IO封装和实现
- 堆的封装和实现
- 一些语言的特殊功能实现
- 调试功能的代码
- 其中标准库占据了主要地位,标准库由1983年建立ANSI C的完整标准C89,一直到C99。
-
C语言标准库:
- c语言标准库十分轻量,仅包含了数学函数、字符/字符串处理、IO等基本方面。
- 还有些特殊库,如变长参数
stdarg.h 、非局部跳转setjmp.h
stdarg.h 里多个宏访问各个额外的参数。(用lastarg 记录函数最后一个具名参数,根据参数数或最后一个参数一次指向可变参数)- 变长参数的实现:
- 参数从右向左压栈,
printf 函数一个%d 就从栈里取一个整数,再把指针向下即可。 - 变长参数的实现得益于C语言默认的
cdecl 调用惯例,即自右向左压栈传递参数。
- cdecl是调用方负责清楚堆栈,所以知道传递参数的多少,可以完整清除。
-
glibc与MSVC CRT:
-
c语言运行库像是C语言程序与不同OS之间的抽象层,将不同OS 的API抽象成相同库函数。
- 运行库功能有限,如用户权限控制、OS线程创建等不属于标准的C运行库。
- glibc与MSVC CRT是标准c运行库的超集,各自对c标准库进行了扩展。(如glibc 有可选pthread库中的
pthread_create 、MSVCRT有_beginthread 来创建线程。 -
glibc:
- glibc的发布版本包括头文件和库二进制文件,二进制部分主要是C语言标准库,有静态
libc.a 、动态libc.so.6 两个版本。 - 同时除了C标准库意外,还有些辅助程序运行的运行库,
ctr1.o 、ctri.o 、ctrn.o 。
ctr1.o 中包含了_start 程序入口函数,它负责调用__libc_start_main() 初始化libc并调用main 进入真正程序主体。 ctr1.o 同时向libc 启动函数__libc_start_main() 传递了两个函数指针:_libc_csu_init 、_libc_csu_fini ,这来给你个函数负责调用_init() 、_finit() 函数。ctri.o 、ctrn.o 用于帮助实现初始化函数,这两个目标文件中包含的代码实际上是_init() 、_finit() 函数的开始和结尾部分,这两个文件和其他目标文件按照顺序链接起来以后,形成了完整的_init() 、_finit() 函数。- 最终的
._init 段如图所示, .init 段、.fini 段还包含了录入C++全局对象的构造/析构函数的调用函数、用户监控程序性能、调试工具等。我们也可以用__attribute((section(".init"))) 将函数放入._init 段。(但是要用汇编,否则编译器产生ret 指令,使得._init 段提前返回了) -
GCC 平台相关目标文件
- 还有
crtbeginT.o 、crtend.o 、libgcc.a 、libgcc_eh.a 。这些并不属于glibc,是GCC的一部分。
- glibc 只是一个c语言运行库,对C++实现并不了解,GCC是C++的真正实现者,对C++全局构造和析构了如指掌。
crtbeginT.o 、crtend.o 用于配合glibc实现C++的全局构造与析构
ctri.o 、ctrn.o 中的.init 段、.fini 段只是提供了一个在main() 之前/之后运行代码的机制,真正的全局构造/析构由crtbeginT.o 、crtend.o 来实现。(即上图中的中间部分) - GCC还需要处理不同平台之间的差异性。
libgcc.a 包含了很多函数的运算(不同CPU对浮点数等运算方法不同);libgcc_eh.a 则包含了C++的异常处理的平台相关函数。 -
MSVC CRT:
-
C++CRT:
- C++程序需要额外链接相应的C++标准库。这些C++标准库里包含的仅仅是C++的内容,不含C的标准库,如
iostream 、string 、map 。 -
-
当程序里包含了某个C++标准库头文件,MSVC编译器认为该源代码文件是一个C++源代码程序,在编译时根据选项在目标文件的.drectve 段添加相应C++标准库链接信息。链接时会加上相应的.lib 。 -
使用不同版本的CRT:
- 静态链接:目标文件对静态库的引用只是在目标文件的符号表中保留一个记号, 并不进行实际的链接,也没有静态库的版本信息,所以没关系。
- 动态链接:动态链接不版本的CRT可能引起符号重定义报错。
- 程序依赖的DLL 使用了不同的CRT:则导致程序运行时有多分CRT的副本,一般能正常运行。但是当两个DLL A和B,分别使用不同DLL:
- A中申请的内存不能在B中释放,因为属于不同的CRT,拥有不同的堆。(CRT堆的管理一般是向OS申请一块空间作为堆空间来管理,两个CRT则会有两个不同的堆空间)
- A中打开的文件不能用于B,FILE*等类型依赖于CRT的文件操作。
- 当我们使用第三方的
.lib 或DLL文件但是又没有源代码时就很难办。 -
用一个版本的编译器编译的程序无法在别的机器上运行?
- 因为一般编译程序使用了manifest 机制,需要依赖相对应版本的运行库。
- 可以使用静态链接,不需要依赖于CRT的DLL了。
- 或者将相应版本的运行库和程序一起发布给用户。
运行库与多线程
- CRT的多线程困扰:
- 线程的访问权限:
- 多线程运行库:
- 对于C/C++标准库来说,线程相关部分不属于标准库的内容,同网络、图形图像一样属于标准库之外的系统相关库。
- 对于多线程的操作接口,windows 的MSVC CRT提供了
_beginthread() 、_endthread() ,Linux 的glibc 提供了可选的线程库pthread(POSIX thread) 包括了pthread_create() 、pthread_exit() 。这些都不属于标准的运行库,他们都是平台相关的。 - 对于C/C++运行库支持多线程环境,:有很多函数是不可重入的,如errno(全局变量作为错误代码 )、
strtok() (分解字符串函数)(局部静态变量在多线程中混乱)、malloc/new、free/delete、printf、信号等都是线程不安全的。
- 但也有些是可重入的:如字符/串处理、数学函数、获取环境变量等。
- 为了解决标准库多线程问题,许多编译器附带了多线程版本的运行库。MSVC中使用
/MT 或/MTd 等参数指定使用多线程运行库。 - CRT的改进:
- TLS: 例如errno等问题,使用宏定义,在单线程版本中返回全局变量errno的地址,在多线程中返回得到地址不同,即使用TLS使其成为了线程的私有成员。
- 加锁:多线程运行库中,线程不安全的函数内部都会自动加锁,包括malloc、printf等。
- 改进函数调用方式: 例如将局部静态变量存储的东西,放入参数列表,空间由调用方申请好,那么就是线程私有的了。
- 线程局部存储实现 TLS:
- 经常有线程私有数据的需求,但是属于每个线程的私有数据包括了线程栈、当前寄存器。
- GCC
__thread int number 与MSVC__declspec(thread) int number 都提供了 TLS机制。 - 一旦定义一个全局变量为TLS类型,那么每个线程都会拥有这个变量的一个副本,互不影响。
- Windows TLS 实现:
- Windows将全局变量和静态变量存在
.data 、.bss 段中, 当使用__declspec(thread) 定义一个线程私有变量时,编译器将其放在.tls 段中。
- 当OS启动新的线程,会从进程堆中分配一块空间将
.tls 段中内容复制进去,使得每个线程都有独立的.tls 副本。 - 对于C++的TLS对象,还需要每次创建/销毁线程后,对其进行构造/析构。
- PE文件中有个数据目录的结构,有个元素中保存了TLS表的地址与长度。
- TLS表中存了所有TLS元素的构造/析构函数的地址,OS根据TLS表在创建/销毁线程时,对TLS变量进行构造/析构。
- TLS表存在PE的
.rdata 段中。 - OS会有一个TEB(thread environment block,线程环境块),用于保存线程的:堆栈地址、线程ID等信息。其中有一个地方保存了TLS数组。
- 访问TLS变量地址: 首先通过寄存器找到TLS数组地址、根据TLS数组地址得到
.tls 段副本第hi、然后加上变量偏移得到TLS变量在线程中的地址。 - 显式TLS:
- 通过关键字定义全局变量为TLS变量的方法为隐式TLS,程序员无需关心TLS变量的申请、分配赋值和释放,由编译器、运行库和OS处理了。
- 显式TLS需要程序员手动申请、查询地址、设置与释放。
- Windows 提供了
TlsAlloc() 、TlsGetValue() 、TlsSetValue() 、TlsFree() ;Linux提供了pthread_key_create() 、pthread_getspecific() 、pthread_setspecific() 、pthread_key_delete() 。 - 线程创建尽量使用包装好的。(用OS API创建删除线程,又用CRT提供的库函数,可能造成CRT以为是自己库生成的线程,有些特殊的内存被申请,但是OS的退出线程又不会释放这个内存,就会产生内存泄漏)
- CRT的这个接口也是对 windows API的封装。
- 即:当使用CRT时(基本所有程序都使用CRT),尽量使用CRT提供的函数创建和销毁线程。
- 同样在MFC中,也尽量使用MFC提供的线程包装函数以保证程序运行正确,因为MFC层面包装线程函数,会维护线程与MFC相关结构。
- Windows提供的接口:
CreateThread() 、ExitThread() ; - CRT提供的接口:
_beginthread() 、_beginthreadex() 、_endthread() 、_endthreadex() (推荐使用带ex的); - MFC提供的接口:
AfxBeginThread() 、AfxEndThread() ;
C++全局构造与析构
fread 实现
-
运行库中真正复杂的是软件与外部通信部分,即IO部分。 -
fread() 是对Windows API ReadFile() 的封装。 fread() 的参数有 buffer、count、size、stream。 -
缓冲机制:用于避免大量实际文件的访问。减少了系统调用的开销。
- flush机制(将缓冲的数据写入文件) 常在写缓冲中使用,因为写传冲使得文件处于一种不同步状态,有些写入文件了但是还存在缓冲当中,用于防止异常时缓冲里的数据还有机会写入文件。
- C语言支持全缓冲和行缓冲。
-
fread_s:
fread 调用了fread_s (S ->safe)fread_s 多了一个buffersize 的参数。fread_s 首先检查参数,然后使用_lock_str 对文件加锁,然后调用_fread_nolock_s -
_fread_nolock_s: 进行实际工作的函数;(利用缓冲的读取,减少了系统调用的开销)
- 首先进行了缓冲模式判断、
streambuffersize 设置 - 然后循环拷贝读数据+减少count
memcpy_s 将文件stream 里_ptr 所指向的缓冲内容复制到data 指向的位置。- 同时还要修复FILE 结构和局部变量的各种数据。
- 当缓冲为空:
- 要读的数据大于缓冲大小:
- 使用
_read 函数真正的从文件中读取数据,读取尽可能多的证书个缓冲的数据直接进入输出位置。 - 要读的数据不大于缓冲大小:
- 仅需要重新填充缓冲即可,使用
_filbuf 函数负责填充缓冲,_filbuf 最终也调用了_read 函数
_read 函数主要:从文本中读取数据、对文本模式打开的文件,转换回车符。 -
_read`:
- 源码位于
crt/src/read.c ,调用关系: fread -> fread_s -> _fread_nolock_s -> _read 。 - 首先处理一个单字节缓冲,仅对设备和管道文件有效,检查pipech ,以免漏掉一个字节
- 然后进行实际的文件读取部分,调用了
ReadFlie() 函数。
ReadFlie() 函数是Windows API。 - 最后将Windows 返回值翻译为CRT所使用的版本。
-
文本换行:
_read 后续还要为以文本模式打开的文件 转换回车符。
- windows 中的回车符存储的是:
0X0D 0X0A 用CR LF表示;用C语言中的字符串表示为:\r\n
- linux是
\n ;macos是\r ;windows 是\r\n ; - C语言中的回车只是
\n ,所以遇到除了linux之外的os,都需要转换。
-
fread 回顾:
系统调用与API
系统调用介绍
- 系统调用:
- 是用户层与内核层的界限,系统调用是应用程序(运行库也是应用程序)与OS内核之间的接口。
- windows 完全基于DLL机制,通过DLL对系统调用进行包装,形成了windows API。
- linux使用
0x08 号中断作为系统调用的入口,windows使用0x2E 号中断作为系统调用入口。 - 系统调用覆盖了程序运行必须的支持(创建/推出进程/线程、进程内存管理、资源访问(文件、网络、进程间通信、硬件设备访问)、图形界面的操作支持等。
- linux 系统调用:
- x86下,系统调用由
0x08 中断完成,各个通用寄存器用于传递参数。
- EXA寄存器用于表示系统调用的接口号,系统调用返回时,EAX作为调用结果的返回值。
- 每个系统调用都对应于内核源代码中的一个函数。
-
- 系统调用都可以在程序中直接使用,他的c语言形式被定义在
usr/include/unistd.h 。(可以绕过glibc 的fopen 、fread 、fclose ,直接使用open 、read 、close 实现文件操作,使用write 向屏幕输出字符串,句柄为0) - 系统调用的弊端:
- 系统调用完成了应用程序与内核的交流。
- 系统调用的两个特点:
- 使用不便。系统调用接口过于原始,没有很好的包装并不方便。
- 各OS之间不兼容。windows 、linux、unix的系统调用基本完全不一致,即使内容一致,定义与实现也不一致。
- 这时候运行库作为:系统调用与程序之间的一个抽象层。特点:
- 使用简便,运行库本身就是语言级别,设计友好。
- 形式统一,运行库的标准为标准库,这个标准的运行库相互兼容,不随着OS、编译器的变化而变化。
- 这样使得无论什么平台,都可以使用c语言运行库的
fread 来读取文件。 - 这样使得不同OS下可以直接编译与运行,即源代码上的可移植性。(和跨平台不同,跨平台更复杂)
- 但是运行库是保证了多个平台通用,于是只有各个平台功能的交集,使得程序使用了CRT之外的接口,就很难保持平台间的兼容了。(这里才涉及到了跨平台,因为要利用不同平台间的特性和使用接口)
系统调用原理
- 系统调用也是个中断,中断还包括了很多溢出、缺页等。
- 系统调用函数有很多,如
fork 、read 、write 等。 - 一般通过中断号进入系统调用中断,在寄存器传参决定系统调用函数。
特权级与中断:
- 现代OS通常有两个特权级别:用户态、内核态
- 系统调用运行在内核态,应用程序运行在用户态,OS一般通过中断将用户态切换到内核态。
-
中断是 一个硬件或软件 发出的请求。 -
中断通常有两个属性:中断号、中断处理程序(ISR,intertupt service routine)
- 不同中断有不同的终端号,一个中断处理程序一一对应一个中断号。
- 内核中有中断向量表,存着中断处理函数的地址,中断号指向中断向量表的表项。
-
-
软件中断带一个参数标记中断号。使用该指令,用户可以手动触发某个中断并执行其中中断处理程序。 -
中断号有限,所以通常所有系统调用占用一个中断号。linux使用0x08 号,windows使用0x2E 号。
- 和中断一致,系统调用也有一个系统调用号,标明是哪一个系统调用。
- 系统调用号也指向系统调用表中的表项,其中存着对应系统调用函数的指针。
基于int 的linux 经典系统调用实现:
-
- 触发中断:
- 通过内嵌汇编,cpu读到
int 0x80 ,会保存现场,将特权状态切换到内核态,然后查询中断向量表中0x80 号元素。
__asm__ 是gcc关键字,表示下面要嵌入汇编代码。volatile 关键字表示GCC对这段代码不进行任何优化。int $0x80 为调用0x80 号中断。 - 切换堆栈:
- 进入中断函数之前,CPU还需要进行栈的切换。
- 用户态和内核态使用不同的栈,每个进程都有自己的内核栈。
int 中断发生时,CPU切入内核态,还需要找到当前进程的内核栈,并在内核栈中压入用户态的SS 、ESP 、EFLAGS 、CS 、EIP 。(都是int 做的)
- 当系统调用返回时,调用
iret 回到用户态,并将内核栈中弹出寄存器的值,恢复用户态的栈。(都是iret 做的) - 中断处理程序:
-
- i386的中断向量表在linux源代码的
Linux/arch/i386/kernel/traps.c 中看到一部分,包括了算数异常(处0、溢出)、页缺失、无效指令、系统调用等中断。 - linux 的i386 的系统调用表在
Linux/arch/i386/kernel/syscall_table.S 里,每个元素都是记录着系统调用函数地址。 -
- int 中断后,通过
SAVE_ALL 汇编将相关寄存器压入栈中,这里就包括了EAX~EDI。
- 这也就是调用系统调用时,用这些寄存器传递参数的原因。
- 参数被压入栈后,系统调用函数使用的是内核栈的参数。
linux 新型系统调用机制:
- Linux 2.5版本后开始使用新型的系统调用机制,有了新的专门针对系统调用的指令:
sysenter 、sysexit 。
- 调用
sysenter 后,系统直接跳转到由某个寄存器(eax)指定的函数执行,并自动完成特权级别转换、堆栈切换。
Windows API
运行库实现
c语言运行库
- 实现一个mini CRT的功能起码需要:入口函数、初始化(堆、IO)、堆管理、基本IO。还有一些C++的new/delete、stream、string 的支持
- 为了考虑对windows、linux 的跨平台,常采用条件编译
#ifdef WIN32 来根据编译平台确定代码部分。
- 通常将CRT的各函数声明放在不同的头文件中。如IO:
stdio.h 、字符串与堆:stdlib.h 。 - 运行库的入口函数主要负责:
- 准备程序运行环境及初始化运行库、调用main函数主体、清理程序运行后的各种资源。
- 运行库为所有程序提供的入口函数应该相同,在链接程序时需要指定入口函数名。
- 对于启动进程的命令行参数:
- linux进程启动时,栈中保存着环境变量和传递给main函数的参数。
- windows提供相应API获取命令行参数字符串:
GetCommandLine - 对于进程的结束:
- linux通过调用1号系统调用实现进程结束,ebx表示进程退出码。
- windows通过
ExitProcess API 结束进程。
- 通常在结束进程前,需要调用由
atexit() 注册的退出回调函数。
堆的实现:
- 实现
malloc 和free 。
- linux 下使用
brk() 系统调用,将数据段结束地址向后调整*MB,作为堆空间。 - windows 下使用
virtualAlloc API,向系统申请*MB空间作为堆空间。
- 并自己使用双链表实现动态分配的
malloc ,不使用windows 的HeapAlloc 等API来分配空间。
IO与文件操作:
- IO对于任何软件来说都是复杂的。
- 对于windows,由文件的基本操作API可以使用:
CreateFile 、ReadFile 、WriteFile 、CloseHandle 、SetFilePointer 。
- windows下,标准输入输出并不是文件描述符0、1、2,需要通过
GetStdHandle 的API获得。 - Linux 则没有API,需要使用内嵌汇编实现
open 、read 、write 、close 、seek 等系统调用。
FILE* 类型在windows中实际是内核句柄 ,在linux中是文件描述符,并不是FILE 的指针。
字符串相关操作:
- 字符串相关操作无需涉及和内核交互,纯粹的用户态计算,相对简单。
- 包括了字符串长度计算、字符串比较、字符串与整数转换等。
格式化字符串:
- 有了基本的:堆管理、文件操作、基本字符串操作等,就要有
printf 的实现了。
printf 是个典型的变长参数函数,参数数量不确定。
- 可变参数,利用了c语言中参数从右向左压栈的特性,将参数从栈中取出。
- 通过几个宏来实现可变参数的:参数起始地址查询、下一个参数查询等。
CRT 库的使用:
-
首先是头文件:
-
编译库文件:(采用静态库的形式,动态库更加复杂)
-
-
linux下的编译:
- 需要
-fno-bulitin :关闭GCC内置函数功能,否则GCC默认将strlen 等常用函数展开成GCC内部实现。 - 需要
-nostdlib :不使用来着glibc、GCC的库文件和启动文件。 - 需要
-fno-stack-protector :关闭堆栈保护功能。 -
windows 下的编译:
/DWIN32 :表示定义WIN32这个宏。用于区分平台/GS- :表示关闭堆栈保护功能。 -
使用:
-
- Linux 下
-e mini_crt_entry 和windows 下的/entry:mini_crt_entry 都是:用于指定入口函数。 -
保证了整个程序只依赖OS内核。绕过了运行库。
- windows 下仅依赖
Kernel32.DLL ,绕过了MSVC CRT的运行库msvcr90.dll 。
c++ 运行库实现
总结
参考
|