Unix系统调用hook函数以及使用dl库实现

参考链接:http://www.it165.net/os/html/201407/8847.html

系统调用属于一种软中断机制(内中断陷阱),它有操作系统提供的功能入口(sys_call)以及CPU提供的硬件支持(int 3 trap)共同完成。
hook系统调用:为当用户代码调用系统调用的时候,我们通过某种手段入侵该系统调用,使得系统调用中除了执行原有的操作,还可以完成我们需要其完成的一些自定义特性,也可以理解为我们为这个函数做了一个钩子。这样我们就可以实现如当一个文件发生写操作的时候,通知所有关注此文件的进程或线程等等。

通过动态链接库挟持系统调用

在linux操作系统的动态链接库的世界中,LD_PRELOAD就是这样一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。loader在进行动态链接的时候,会将有相同符号名的符号覆盖成LD_PRELOAD指定的so文件中的符号。换句话说,可以用我们自己的so库中的函数替换原来库里有的函数,从而达到hook的目的。

上述hook系统调用的方法可以使用dlfcn.h库中的一系列函数实现。
example,此函数hook了fcntl系统调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <stdarg.h>
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <fcntl.h>
typedef int (*fcntl_pfn_t)(int fildes, int cmd, ...);
int fcntl(int fildes, int cmd, ...)
{
fcntl_pfn_t g_sys_fcntl_func = (fcntl_pfn_t)dlsym(RTLD_NEXT,"fcntl");
va_list arg_list;
va_start( arg_list,cmd );
int ret = -1;
switch( cmd )
{
case F_DUPFD:
{
int param = va_arg(arg_list,int);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
case F_GETFD:
{
ret = g_sys_fcntl_func( fildes,cmd );
break;
}
case F_SETFD:
{
int param = va_arg(arg_list,int);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
case F_GETFL:
{
ret = g_sys_fcntl_func( fildes,cmd );
break;
}
case F_SETFL:
{
int param = va_arg(arg_list,int);
int flag = param;
flag |= O_NONBLOCK;
ret = g_sys_fcntl_func( fildes,cmd,flag );
break;
}
case F_GETOWN:
{
ret = g_sys_fcntl_func( fildes,cmd );
break;
}
case F_SETOWN:
{
int param = va_arg(arg_list,int);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
case F_GETLK:
{
struct flock *param = va_arg(arg_list,struct flock *);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
case F_SETLK:
{
struct flock *param = va_arg(arg_list,struct flock *);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
case F_SETLKW:
{
struct flock *param = va_arg(arg_list,struct flock *);
ret = g_sys_fcntl_func( fildes,cmd,param );
break;
}
}
va_end( arg_list );
return ret;
}
int main() {
int old = fcntl(STDIN_FILENO, F_GETFL);
printf ("old:%d\n", old);
fcntl(STDIN_FILENO, F_SETFL, old);
int _new = fcntl(STDIN_FILENO, F_GETFL);
printf ("new:%d\n", _new);
}

Comment and share

Linux中dlfcn库相关学习

在linux中静态链接库和动态链接库是进程之间代码共享的两种方式。Linux在库中提供了加载和处理动态连接库的系统调用,使用非常方便。具体用法如下:

dlfcn库中函数说明

dlfcn库中主要包括四个函数:

1
2
3
4
5
6
7
8
9
#include <dlfcn.h>
void* dlopen(const char*, int flag);
char* dlerror();
void* dlsym(void* handler, char* symbol);
int dlclose(void* handler);

  • dlopen : 打开一个动态连接库,并返回一个类型为void*的handler,flag为打开模式,可选的模式有两种
    • RTLD_LAZY 暂缓决定,等有需要时再解出符号
    • RTLD_NOW 立即决定,返回前解除所有未决定的符号。
  • dlerror : 返回dl操作的错误,若没有出现错误,则返回NUlL,否则打印错误信息
  • dlsym : 查找动态链接库中的符号symbol,并返回该符号所在的地址
  • dlclose : 关闭动态链接库句柄

使用实例

动态链接库cal.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//cal.cpp
extern "C" {
int add(int a, int b) {
return a + b;
}
int sub(int a, int b) {
return a - b;
}
int mul(int a, int b) {
return a * b;
}
int div(int a, int b) {
return a / b;
}
}

生成动态链接库libcal.so

1
g++ -shared -fPIC cal.cpp libcal.so

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
//main.cpp
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
#define LIB_LIBRARY_PATH_1 "./libcal.so"
typedef int (*CAC_FUNC)(int ,int);
int main() {
void* handler = NULL;
char* error = NULL;
CAC_FUNC cac_func = NULL;
handler = dlopen(LIB_LIBRARY_PATH_1, RTLD_LAZY);
if (!handler) {
fprintf(stderr, "err:%s\n", dlerror());
exit(1);
}
dlerror();
//此处取对应函数地址,
*(void **) (&cac_func) = dlsym(handler, "add");
if ((error = dlerror()) != NULL) {
fprintf(stderr, "err:%s", error);
exit(1);
}
printf("add:%d\n", cac_func(1,2));
cac_func = (CAC_FUNC)dlsym(handler, "sub");
printf("sub:%d\n", cac_func(1,2));
cac_func = (CAC_FUNC)dlsym(handler, "mul");
printf("mul:%d\n", cac_func(1,2));
cac_func = (CAC_FUNC)dlsym(handler, "div");
printf("div:%d\n", cac_func(1,2));
dlclose(handler);
return 0;
}

编译函数main:

1
g++ main.cpp -rdynamic -ldl

执行结果:

1
2
3
4
add:3
sub:-1
mul:2
div:0

注意问题

特别注意,若使用c++编译动态链接库,一定要在需要使用的符号处添加extern “C”,否则会出现符号找不到的问题。即undefined symbol

Comment and share

(no title)

AT&T汇编学习

汇编格式主要包括Intel和AT&T汇编格式两种,两种汇编语言的格式在使用上有较大的差别,我们上一章讲解的汇编格式是以intel的风格为例的,但是在Unix和Linux系统中,大部分的汇编语言采用的是AT&T格式。

注:本节中的实例均以i386处理器中汇编指令为例

AT&T汇编与intel汇编格式对比

AT&T intel 说明
pushl %eax push eax 在AT&T格式中,需要在寄存器前面加上%
pushl $1 push 1 在AT&T中使用$作为前缀表示立即数操作
addl $1 %eax add eax 1 AT&T格式的目的操作数和源操作数的顺序与intel格式相反
movl val,%al mov al, byte ptr val AT&T格式操作数的长度由操作符的最后一个字母标识,分别是(movw[word],movl(long)movb[byte])
section:disp(base, index, scale) section:[base + index*scale + disp] 内存寻址方式不同

简单汇编代码分析

下面一段代码的作用是在屏幕上打印helloworld。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.data # 数据段,该段的数据为初始化数据
msg: .string "helloworld" #定义string类型变量msg,并赋初始值helloworld
len = . - msg #使用段的起始地址和msg地址相减的方式计算msg的长度
.text #代码段初始位置
.global _start #使得连接程序看得到该symbol,这样就可以在其他文件中引用
_start:
movl $len,%edx #edx中存储要打印的数据的长度
movl $msg,%ecx #ecx中存储打印为数据内容
movl $1,%ebx #ebx中为文件描述符,其中1为标准输出流的文件描述符
movl $4,%eax #eax中为系统调用号,4表示sys_write
int $0x80 #调用内核功能执行,此段代码执行后屏幕上打印出helloworld
movl $0,%ebx #0标识退出代码
movl $1,%eax #1为系统调用号(sys_exit)
int $0x80 #调用内核功能运行代码

Comment and share

Unix中select,poll,epoll详解

网络应用需要处理的问题无非两类,网络I/O和数据计算问题。
在处理计算密集型任务的时候,期间会有一些网络IO操作(如写数据库的操作,非本机),此时若使用同步IO,则会造成大量的IO等待,造成CPU使用率较低。所以此时考虑其他IO模型如异步模型。

Unix下网络I/O模型包括五类:

  • 阻塞式IO
  • 非阻塞式IO
  • 多路复用IO
  • 信号驱动IO(边缘触发)
  • 异步IO

其中多路复用I/O机制是通过select,poll以及epoll进行监视。这里暂时只介绍多路复用IO,若想了解其他IO模型,参考《Unix网络编程》第六章

多路复用I/O模型

网络I/O的本质是socket的读取,socket在linux系统中被抽象为流,所以I/O操作可以理解为对流的操作。这个操作包括两个阶段:

  • 等待流数据准备就绪(wait for data be ready)
  • 从内核相进程复制数据

由于非阻塞调用的过程中,轮训占据了大部分的过程,所以轮训会占据大量的CPU时间。如果轮训不是进程的用户态,而是有人帮忙就好了。多路复用正好处理这样的问题。

多路复用的过程:多路复用有两个特别的系统调用select和poll。select调用是内核级别的,select轮训相对于非阻塞的轮训区别在于:前者可以等待多个socket,当其中一个socket数据准备好了,就能返回进行可读,然后进程再进行recvform系统调用,将数据由内核拷贝到进程中,拷贝的过程是阻塞的。

多路复用有两种阻塞,select或poll调用之后,会阻塞进程,与第一种阻塞不同在于,此时的select不是等到socket数据全部到达再处理, 而是有了一部分数据就会调用用户进程来处理。如何知道有一部分数据到达了呢?监视的事情交给了内核,内核负责数据到达的处理。也可以理解为”非阻塞”吧。

类比钓鱼过程:在钓鱼的时候,我们雇了一个帮手,他可以同时抛下多个鱼竿,任何一个鱼竿的鱼一上钩,他就会拉杆。他只负责帮我们钓鱼,并不处理,所以我们在一旁等着,等他收杆之后,我们再进行处理。

多路复用既然可以处理多个IO,也就带来了新的问题:多个IO的顺序不能保证

多路复用的特点多路复用通过一种机制一个进程能同时等待多个IO文件描述符,内核监视这些文件描述符(socket描述符),其中任意一个进入读就绪状态时,select,poll.epoll函数就可以返回。对于监视的方式,有可以分为select,poll,和epoll三种方式。

select函数详解

### 函数原型

1
2
3
#include <select.h>
#include <time.h>
int select(int maxfdp1, fd_set* readset, fd_set* writeset, fd_set* except_set, const struct timeval* timeout);

函数功能

该函数允许进程指示内核等待多个事件中的其中一个发生,并只在有一个或多个事件发生或者经历了一段时间之后才唤醒它。

其中等待的事件类型包括三种:指定集合中的描述符处于可读状态,执行集合中的描述符处于可写状态,指定集合中的描述符有异常未处理。

描述符就绪的条件如下:

可读就绪

当描述符满足下列四个条件中的其中一个,表示该描述符已经准备好读

  • 该套接字接收缓冲区的字节数大于等于套接字接收缓冲区的低水位标记的大小。一般对于TCP和UDP该值默认为1,我们也可以通过SO_RCVLOWAT套接字选项设置该套接字的低水位标记。
  • 该连接读半部关闭(接受了FIN的TCP连接),此时函数返回0。
  • 该套接字是一个监听套接字且完成的连接数不为0。对于这种套接字,accept通常不会阻塞。
  • 其上有一个套接字错误待处理.对这种套接字的读操作将不阻塞病返回-1。

可写就绪

当描述符满足下列四个条件中的其中一个,表示该描述符已经准备好写

  • 该套接字的发送缓冲区的可用空间字节大于等于套接字发送缓冲区的低水位标记大小。TCP和UDP的默认大小一般为2048。可以使用SO_SNDLOWAT套接字选项来设置该套接字的低水位标记
  • 该链接的写半部关闭
  • 使用非阻塞式connect的套接字建立连接,或者connect以失败告终
  • 其上有一个套接字错误未处理

函数参数

  • maxfdp1 : fd_set中最大的描述符+1(特别注意不要忘了+1),如readset中有{1,2,4},writeset中有{5,7,9},except_set中有{2,3,6,10},则此时的maxfdp1为11
  • readset : 需要监听的满足可读条件的描述符集合
  • writeset : 需要监听的满足可写条件的描述符集合
  • except_set : 需要监听的满足异常的描述符集合
  • timeout : 等待的时间,若超过此时间,函数返回

timeout的三种情况

  • timeout=NULL,等待时间无限长,即不限等待时间
  • timeout->sec=0,timeout->usec=0。此时不等待,函数立即返回
  • timeout->sec!=0 || timeout->usec != 0。此时为等待时间

函数返回值

  • 当监视的相应的文件描述符集合中存在满足条件的描述符时,比如说读文件描述符集中有数据到来时,内核IO根据状态修改文件描述符集,并返回一个大于0的数
  • 当没有满足条件的描述符且设置的timeval超时时,select函数返回0
  • 出错返回负数

若存在满足条件的描述符时,内核会将满足条件的描述符置位,并将其他描述符清0.这时,我们可以通过FD_ISSET来判断当前描述符是否满足条件.
如:
假设set为8位表示,起始为0000 0000。此时将{1,2,5}设置到读文件描述符集合中,即:

1
2
3
FD_SET(1, &readset);
FD_SET(2, &readset);
FD_SET(3, &readset);

置位以后set的位为:0000 0111

当调用select函数,并文件描述符2准备就绪时,此时select函数返回大于0的值,set的值变为:0000 0010。此时使用FD_ISSET可以检测到文件描述符2已经就绪。

fd_set相关操作

1
2
3
4
void FD_ZERO(fd_set* set); //将fd_set清空,一般声明fd_set第一步现先将其清空
void FD_SET(int fd, fd_set* set); //将某个fd置位
void FD_CLR(int fd, fd_set* set); //清空某个fd
int FD_ISSET(int fd, fd_set* set); //判断fd是否在set中

select函数底层实现原理

select底层实现的大致原理是,通过轮训文件描述符集中的文件描述符,检查描述符是否达到条件,若达到符合的相关条件则返回,否则轮训,但是当轮训的机制虽然是死循环,但是不是一直轮训,当内核轮训一遍文件描述符之后,会调用schedule_timeout函数挂起,等待fd设备或定时器来唤醒自己,然后再继续循环体看看哪些fd可用,以此提高效率。

若要了解详细的select实现原理参考如下博客:
http://janfan.cn/chinese/2015/01/05/select-poll-impl-inside-the-kernel.html
http://zhangyafeikimi.iteye.com/blog/248815

select函数的特点

select和poll为水平触发,epoll即支持水平触发也支持边缘触发。

缺点:

  • 最大并发数限制:从上面可以看出,被监听的描述符集合的大小受fe_set大小的限制,所以select监听的描述符的个数是有限制的,一般默认个数为1024或4096个等。
  • 效率问题:从select的底层实现可以看出,select每次调用都会线性扫描全部的fd集合,这样效率会出现线性下降,当把FD_SETSIZE增大可能会出现超时.
  • 内核用户空间拷贝问题:从select实现源码中不难看出,描述符集合以及timeout参数都是通过内存拷贝的方式从用户空间拷贝到了内核空间,也是会影响函数的性能。

poll函数详解

函数原型

1
2
3
4
5
6
7
8
9
10
#include <poll.h>
#include <time.h>
struct pollfd{
int fd; //file descriptor
short events; //被监听的事件状态(即select中监听当前描述符是否可写或者可读或者异常等等)
shor revents; //函数返回时该文件描述符的状态
};
int poll(struct pollfd* fds, unsigned long nfds, int timeout);

函数功能

poll的函数功能与select功能基本类似。但是poll函数可监听的文件描述符的个数基本没有限制,poll管理多个文件描述符的方式与select一致,都是轮训,并且都是讲文件描述符数组从用户空间复制到内核空间。

函数参数

  • fds : 被监听的描述符的数组
  • nfds : 数组中描述符的个数,这个大小足以监听linux所有的文件描述的符
  • timeout : 等待的时间

events的合法事件:

1
2
3
4
5
6
7
8
POLLIN --- 有数据可读(普通或优先级带数据)等价于POLLRDNORM||POLLRDBAND
POLLRDNORM --- 有普通数据可读
POLLRDBAND --- 有优先级带数据可读
POLLPRI --- 有高优先级数据可读
POLLOUT --- 有数据可写(普通数据)等价于POLLWRNORM
POLLWRNORM --- 有普通数据可写
POLLWRBBAND --- 有优先级带数据可写

初此之外,revents返回的事件还有:

1
2
3
POLLER --- 发生错误
POLLHUP --- 发生挂起
POLLNVAL --- 指定的描述符非法,没有打开

POLLIN | POLLPRI等价于select的读事件

POLLOUT | POLLWRBBAND 等价于select的写事件

函数返回值

  • 若监听的描述符满足条件,返回revents域不为0的文件描述符的个数
  • 若没有描述符满足条件且已过超时时间,poll返回0
  • 出错返回-1,并设置errno为以下值:
1
2
3
4
5
EBADF --- 一个或多个结构体中的描述符无效
EFAULTfds --- 指针指向的地址超出进程的地址空间
EINTR --- 请求的时间之前产生一个信号,调用可以重新发起
EINVALnfds --- 参数超出PLIMIT_NOFILE的值
ENOMEM --- 可用内存不足,无法完成请求

poll函数优缺点

优点

  • poll函数不需要计算最大的文件描述符加1.
  • poll函数监听的文件描述符的个数不受限制
  • poll相对于select函数应付大数目的描述符的效率较高。

缺点:

  • poll函数没有解决select轮训所有文件描述符的问题
  • poll函数和select相同都是将文件描述符信息从用户空间拷贝到内核空间。

epoll函数详解

函数原型

epoll相关数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//epoll_data保存触发事件相关的数据。(数据类型与具体使用方式有关)
typedef union epoll_data{
void* ptr;
int fd;
_uint32_t u32;
_uint64_t u64;
} epoll_data_t;
//保存感兴趣的事件和被触发的事件
struct epoll_event{
_uint32_t events;
epoll_data_t data;
};

其中events是一个美剧类型的集合,我们可以使用”|”来增加感兴趣的事件。枚举类型的值包括下面:

  • EPOLLIN : 表示关联的fd可以进行读操作
  • EPOLLOUT :表示关联的fd可以进行写操作
  • EPOLLRDHUP(2.6.17之后):表示套接字关闭了连接,或关闭了正写的一半的连接
  • EPOLLPRI : 表示关联的fd有紧急优先事件可以进行读操作。
  • EPOLLERR : 表示关联的fd发生了错误,epoll_wait会一直等待这个事件,所以一般没有必要设置这个属性
  • EPOLLHUP : 表示关联的fd被挂起,epoll_wait会一直等待这个事件,所以一般没有必要设置这个属性
  • EPOLLET : 设置关联的fd为ET的工作方式,即边缘触发
  • EPOLLONESHOT : 设置关联的fd为one-shot工作方式,表示只监听一次事件,如果要再次监听,需要把socket放入到epoll队列中。

epoll相关的函数有三个。

1
2
3
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);
int epoll_wait(int epfd, struct epoll_event* events, int maxevents, int timeout);
  • epoll_create : 创建一个epoll句柄,注意创建epoll句柄会占用一个文件描述符,在使用完之后需要关闭。否则可能会导致文件描述符耗尽。
    • size : size为最大的监听文件描述符数,监听的文件描述符的个数不能超过size可以手动指定,但是这个数值可以达到系统可以开的最大的文件描述符数。
  • epoll_ctl : epoll的事件注册函数,它不同于select的是,它不是在监听事件的时候告诉内核要监听什么类型的时间,而是先注册要监听的事件类型。
    • epfd : epoll文件描述符,即epoll_ create的返回值,表示该epoll描述符注册事件
    • op : 注册事件的类型包括以下三类。
      • EPOLL_CTL_ADD : 注册行的fd到epfd中
      • EPOLL_CTL_MOD : 修改已经注册的fd的事件类型
      • EPOLL_CTL_DEL : 删除已经注册的fd
    • fd : 注册的文件描述符
    • event : 注册的时间的类型,告诉内核需要监听什么事件,类型包括上面几种。
  • epoll_wait : 收集epoll监控的时间中已经就绪的事件,若调用成功,返回就绪的文件描述符的个数,返回0表示超时。
    • epfd : epoll的文件描述符
    • events : 已经就绪的事件集合.内核不分配内存,需要程序自己分配内存传给内核,内核只负责将书复制到这里
    • maxevents : events数组的大小。
    • timeout : 超时时间。

水平触发(LT)与边缘触发(ET)

epoll的默认工作模式是水平触发(LT)。NGINX使用的epoll的ET工作模式

水平触发(level_triggered):当被监控的文件描述符上有可读可写事件的时,epoll_wait()会通知处理程序去读写。如果程序没有一次性把缓冲区的数据读完或者写完,那么下次调用epoll_wait的时候,他还会通知你该文件描述符仍可读写,如果你一直不去读写,它会一直通知你。如果系统中有大量你不需要读写的文件描述符,而他们每次都返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率。

边缘触发(edge-triggered):当被监控的文件描述符上有读写事件发生时,epoll_wait会通知处理程序去读写,如果数据没有一次性读写完,那么下次你再调用epoll_wait的时候,它不会通知你,只有等到下一次发生读写事件的时候,它才会通知你。这种模式比水平触发的效率高,系统不会充斥大量你不关心的文件描述符。

注意:epoll工作在ET模式的时候,必须使用非阻塞的套接字,以避免由于一个文件句柄的阻塞读/阻塞写把多个文件描述符的任务饿死。最好以下面两种方式调用epoll接口

  • 基于非阻塞文件句柄
  • 只有当read/write返回值为EAGAIN时才需要挂起。但这不是说每次都需要循环读,直到读到产生EAGAIN才结束,只要读取到的长度小于预期的长度就说明缓冲区的数据我们已经读完了。

epoll族函数底层实现

epoll的使用方法上面已经有详细的描述,借口也简单易用。首先我们通过epoll_create创建一个epoll文件描述符,然后在epoll文件描述符上注册需要监听的事件,最后使用epoll_wait等待准备就绪的文件描述符。然而在每一步的过程中,内核都做了哪些操作?底层的实现方式是怎么样的?

内核使用了slab机制,为epoll提供了高效快速的数据结构。在内核中,epoll向内核注册了一个文件系统,用于存储被监控的文件描述符的信息。epoll在被内核初始化的时候(操作系统启动),同时会开辟出epoll自己的告诉cache区,用于安置我们需要监控的fd信息,这些fd信息会以红黑树的结构保存在内核cache中,以支持快速的查找,插入删除操作。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

epoll fd在内核中对应的数据够如下:

1
2
3
4
5
6
7
8
9
struct eventpoll {
spin_lock_t lock; //对本数据结构的访问
struct mutex mtx; //防止使用时被删除
wait_queue_head_t wq; //sys_epoll_wait() 使用的等待队列
wait_queue_head_t poll_wait; //file->poll()使用的等待队列
struct list_head rdllist; //事件满足条件的链表
struct rb_root rbr; //用于管理所有fd的红黑树(树根)
struct epitem *ovflist; //将事件到达的fd进行链接起来发送至用户空间
}

当向系统中添加一个fd时,就创建一个epitem结构体,这是内核管理epoll的基本数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct epitem {
struct rb_node rbn; //用于主结构管理的红黑树
struct list_head rdllink; //事件就绪队列
struct epitem *next; //用于主结构体中的链表
struct epoll_filefd ffd; //这个结构体对应的被监听的文件描述符信息
int nwait; //poll操作中事件的个数
struct list_head pwqlist; //双向链表,保存着被监视文件的等待队列,功能类似于select/poll中的poll_table
struct eventpoll *ep; //该项属于哪个主结构体(多个epitm从属于一个eventpoll)
struct list_head fllink; //双向链表,用来链接被监视的文件描述符对应的struct file。因为file里有f_ep_link,用来保存所有监视这个文件的epoll节点
struct epoll_event event; //注册的感兴趣的事件,也就是用户空间的epoll_event
}
  1. 当调用epoll_create的时候,会首先在epoll内存中为分配一个eventpoll的内存大小,以保存当前的epoll描述符(epfd)结构,然后在这块内存上打开一个epoll文件。
  2. 当调用epoll_ctl的时候,如果增加fd(socket),则检查在红黑树中是否存在,存在立即返回,不存在则添加到红黑树上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪list链表中插入数据。当我们调用epoll_ctl往里塞入百万个fd时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的fd给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的fd外,还会再建立一个list链表,用于存储准备就绪的事件。
  3. 调用epoll_wait的时候立即返回准备就绪链表中的数据即可。

如此,一颗红黑树,一张准备就绪fd链表,少量的内核cache,就帮我们解决了大并发下的fd(socket)处理问题。

如果需要了解更详细的epoll底层实现,参考一下链接:
http://www.cnblogs.com/apprentice89/p/3234677.html

epoll特点

  • 没有文件描述符个数限制
  • 使用注册监听时间的方式,无需每次wait时都将时间从用户空间拷贝到内核空间,节省了内存拷贝的时间。
  • 使用回调机制,无需轮训所有的文件描述符检查状态。
  • 返回值只有准备就绪的文件描述符,检查准备就绪的文件描述符也不需要轮训。

Comment and share

汇编基础之寄存器及汇编指令

参考链接:
http://www.cnblogs.com/technology/archive/2010/05/16/1736782.html
http://anonymalias.github.io/2017/01/09/ucontext-theory/

最近在学习协程方面的知识,在协程的一种实现方式中有一种是用汇编实现的,所以这里再把汇编知识复习一下。

寄存器

寄存器按照其用途可分为以下4类:

  • 数据寄存器
  • 指针及变址寄存器
  • 段寄存器
  • 控制寄存器

数据寄存器

数据寄存器包括4个16位的寄存器(AX,BX,CX,DX)或者8个8位的寄存器(AH,AL,BH,BL,CH,CL,DH,DL),这些寄存器都是用来暂时存放操作数,运算结果或者其他信息,但同时又具有某些专门的用途。

  • AX(累加寄存器):算术运算中的主要寄存器,在乘除运算中用来指定除数和被除数,也是乘除运算后积和商的默认存储单元。,另外IO指令均使用该寄存器与IO设备传送信息
  • BX(基址寄存器):指令寻址时常用作基址寄存器,存入偏移量或者偏移量的构成部分。
  • CX(计数寄存器):在循环指令操作或者串处理指令中隐含计数
  • DX(数据寄存器):在双字节运算中,与AX构成32位操作数,DX为高16位。在某些IO指令中,DX被用来存放端口地址

指针及变址寄存器

这些寄存器都是16位的寄存器,用来存放16为的操作数或者中间结果,但是更常见的是存放偏移量或者位移量

  • SP(堆栈指针寄存器):指向栈顶的位置,与SS寄存器一起组成栈顶数据的物理地址
  • BP(基址指针寄存器):系统默认其指向堆栈中的某一单元,即提供栈中该单元偏移量,加段前缀后,BP可作为非堆栈段的地址指针。一般用于识别栈帧的起始位置。
  • SI(源变址寄存器):与DS联用, 指示数据段中某操作的偏移量. 在做串处理时, SI指示源操作数地址, 并有自动增量或自动减量的功能. 变址寻址时, SI与某一位移量共同构成操作数的偏移量
  • DI(目的变址寄存器):与DS联用, 指示数据段中某操作数的偏移量, 或与某一位移量共同构成操作数的偏移量. 串处理操作时, DI指示附加段中目的地址, 并有自动增量或减量的功能

段寄存器

  • CS(代码段):存放当前程序的指令代码
  • DS(数据段):存放程序所涉及的源数据以及结果
  • SS(堆栈段):以先进后出原则的数据区
  • ES(附加段):辅助数据区,存放串或者其他数据

控制寄存器

  • IP(指令寄存器): 存放下一条要执行的指令的偏移量
  • FR(控制标志位):
    • CF(进位标识位):进行加减运算时, 如果最高二进制位产生进位或错位, CF则为1, 否则为0. 程序设计中, 常用条件转移指令JC, JNC指令据此标志位实现转移
    • PF(奇偶标志位):操作结果中二进制位1的个数为偶数是, PF为1, 某则为0
    • AF(辅助进位标志位):运算时半字节产生进位或借位时, AF为1, 某则为0. 主要用于BCD码的调整
    • ZF(零标志位):运算结果为0时, ZF为1, 否则为0
    • SF(符号标志位):当运算结果的最高位为1时, SF为1, 否则为0. 最高位表示符号数的正和负
    • TF(跟踪标志位):用于调试程序时进入单步方式工作. TF=1时, 每条指令执行完后产生一个内部中断, 让用户检查指令运行后寄存器, 存储器和各标志位的内容. TF=0时, CPU工作正常, 不产生内部中断
    • IF(中断允许标志位):IF=1同时中断屏蔽寄存器的相应位为0, 允许系统响应可屏蔽中断, 反之, 不接收外部发出的中断请求
    • DF(方向位标志位):用于控制串操作时地址指针位移方向. 当DF=1时, 指针向高地址方向移动
    • OF(溢出标志位):算术运算时结果超出系统所能表示的数的范围. 溢出时, OF=1

注意:上述为16位处理其器中的寄存器的名字,在32和64位系统中,寄存器中名字有相应的变化,具体看下面(32位在前面加E,64在前面加R,64位系统的中含有16个64位的通用寄存器):

i386常用的16个寄存器

  • EAX、EBX、ECX、EDX这四个寄存器,主要就是用来暂时存放计算过程中所用的操作数、结果或其它信息。
  • ESP为堆栈指针寄存,它和堆栈段寄存器SS共同决定了当前的栈指针,每当执行push,pull操作时,或者因为某些原因(如中断),CPU自动将数据入栈时,就会使用该指针来找到堆栈栈顶(或栈底)的位置,然后执行压栈、出栈的操作。系统管理软件,如操作系统会根据其分配的栈空间地址来设定这两个寄存器的值。
  • EBP称为基址指针寄存器,它和ESP都可以与堆栈段寄存器SS联用来确定堆栈中的某一存储单元的地址,ESP用来指示段顶的偏移地址,而EBP可作为堆栈区中的一个基地址以便访问堆栈中的信息。
  • ESI(源变址寄存器)和EDI(目的变址寄存器)一般与数据段寄存器DS联用,用来确定数据段中某一存储单元的地址。这两个变址寄存器有自动增量和自动减量的功能,可以很方便地用于变址。在串处理指令中,ESI和EDI作为隐含的源变址和目的变址寄存器时,ESI和DS联用,EDI和附加段ES联用,分别达到在数据段和附加段中寻址的目的。
  • EIP指令指针寄存器,它用来存放代码段中的偏移地址。在程序运行的过程中,它始终指向下一条指令的首地址。它与段寄存器CS联用确定下一条指令的物理地址。当这一地址送到存储器后,控制器可以取得下一条要执行的指令,而控制器一旦取得这条指令就马上修改EIP的内容,使它始终指向下一条指令的首地址。那些跳转指令,就是通过修改EIP的值来达到相应的目的的
  • FLAGS标志寄存器,又称PSW(program status word),即程序状态寄存器。这一个是存放条件标志码、控制标志和系统标志的寄存器。
  • 段寄存器:一共六个,分别是CS代码段,DS数据段,ES附加段,SS堆栈段,FS以及GS这两个还是附加段。
    EFLAGS寄存器中的IF位表示是否允许中断,为1允许,否则不允许。
  • TR寄存器:用来指向当前任务的TSS段
  • IDTR寄存器:用来指向当前IDT(中断表述符表或者说是中断向量表),因为在保护模式下,IDT的起始地址可以在任何位置,而不仅限于地址0。
  • GDT和LDT : 前者是全局描述符表,位置由GDTR寄存器确定,后者是局部描述符表,位置由LDTR寄存器确定,具体使用哪一个,取决于段选择码中的TI位。

汇编指令

汇编指令格式

汇编指令的格式如下:

1
[标号:] 指令助记符[[目的操作数][,源操作数]][;注释]

  • 指令助记符:如MOV,ADD之类标识传送,加法。不区分大小写
  • 目的操作数:作用有两个,1.参与指令操作2,暂时存储操作结果
  • 源操作数:主要提供原始数据或操作对象。面向所有寻址方式
  • 注释:用分号隔开

汇编指令中常见的符号:

  • imme:立即数
  • DST:目的操作数
  • SRC:源操作数
  • mem:存储器操作数
  • OPR:操作数
  • reg:通用寄存器
  • EA:偏移地址
  • Sreg:段寄存器
  • Port:端口地址
  • Lable:标号

汇编指令可以分成六类:

  • 数据传送指令
  • 算数运算指
  • 逻辑运算与移位指令
  • 串操作指令
  • 程序控制指令
  • 处理器控制指令

    数据传送指令

    数据传送指令

  • MOV DST,SRC(传送指令):把源操作数的内容送入目的操作数
    • 立即数做源操作数时,立即数的长度必须小于等于目的操作数的长度
    • 操作数分别为reg,reg或reg,sreg或sreg,sreg或reg,sreg时,两者的长度必须保持一致
    • 立即数不能作为目的操作数
    • CS和IP寄存器不能做目的操作数,不允许用立即数为段寄存器赋值
    • 不能将一个段寄存器的内容直接送到另一个段寄存器中, 可借助通用寄存器或PUSH, POP指令实现这一要求
  • PUSH SRC(压栈指令): 将一个字数据压入当前栈顶, 位移量disp=-2的地址单元. 数据进栈时, 栈指针SP首先向低地址方向移动两个字节位置, 接着数据进栈, 形成新的栈顶
  • POP DST(出栈指令):弹出栈顶元素, 后将栈顶指针向栈底方向移动一个字
  • XCHG OPR1, OPR2(交换指令):交换指令: 将这两个操作数交换

地址传送指令

  • LEA DST, SRC(装载有效指令):该指令将源操作数的偏移量OA装载到目的操作数中
  • LDS DST, SRC(装载数据段指针指令):将当前数据段中的一个双字数据装入到一个通用寄存器SI(双字数据的低字)和数据段寄存器DS(双字数据的高字)中
  • LES DST,SRC(装载附加段指针指令):将附加数据段中的一个32位地址数据指针(附加段指针)送到DI(低字)和ES(高字)寄存器中

标志传送指令

  • LAHF(标志寄存器送AH指令): 将标志寄存器的低字节送入AH中
  • SAHF(AH送标志寄存器指令): 将AH寄存器内容送标志寄存器FR的低字节
  • PUSHF(标志进栈指令): 标志寄存器进栈
  • POPF (标志出栈指令): 标志寄存器出栈

累加器专用传送指令

  • IN AL, Port:从端口读入数据, 存放在AL中
  • OUT PORT,AL:传送AL中的数据到端口
  • XLAT OPR或XLAT:用于将AL中当前内容转换为一种代码

算术运算指令

加法指令

  • ADD DST, SRC:DST+SRC的和存放到DST中去
  • ADC DST, SRC:带进位加法指令, DST+SRC+CF
  • INC DST:增1指令

减法指令

  • SUB DST, RSC:DST-SRC, 存放到DST中
  • SBB DST, SRC:带借位减法指令, DST-SRC-CF
  • DEC DST :减1指令
  • NEG DST:求补指令, 求补码
  • CMP OPR1, OPR2:比较指令

乘法指令

  • MUL SRC:无符号数乘指令, AL*SRC, 结果放入AX中
  • IMUL SRC:有符号数乘指令, AL*SRC, 结果放入AX中

除法指令

  • DIV SRC :无符号数除指令, AX/SRC, 商放入AL中, 余数放在AH中
  • IDIV SRC:符号数除指令, AX/SRC, 上放入AL中, 余数放在AH中
  • CBW, CWD:都是符号扩展指令. 将AL的符号扩到AX中; 将AX的符号扩到DX

逻辑运算与移位指令

逻辑运算指令

  • NOT OPR:逻辑非指令
  • AND OPR:逻辑与指令
  • OR OPR:逻辑或指令
  • XOR OPR :逻辑异或指令

移位指令:

  • SHL DST, CNT:逻辑左移
  • SHR DST, CNT:逻辑右移
  • SAL DST, CNT:算术左移
  • SAR DST, CNT:算术右移

循环移位指令

  • ROL DST, CNT:循环左移
  • ROR DST, CNT:循环右移
  • RCL DST, CNT:带进位循环左移
  • RCR DST, CNT:带进位循环右移

串操作指令

  • MOVS:串传送指令
  • CMPS:串比较指令
  • SCAS:串扫描指令
  • LODS:装入串指令
  • STOS:存储串指令

控制转移指令

转移指令:

  • JMP:无条件转移指令
  • JX:条件转移指令(JC/JNC, JZ/JNZ, JE/JNE, JS/JNS, JO/JNO, JP/JNP…)

循环指令

  • LOOP 标号:该指令执行时, 技术寄存器CXX首先减1, 然后判断CX, 若为0, 跳出循环

条件循环指令

  • LOOPZ/LOOPE, LOOPNZ/LOOPNE:前者用于找到第一个不为0的事件, 后者用于找到第一个为0的事件

子程序调用指令

  • CALL 子程序名:段内直接调用
  • RET

中断指令

  • INT N(中断类型号):软中断指令
  • IRET:中断返回指令

处理器控制指令

标志处理指令:

  • CLC:进位标志CF置0
  • CMC:进位标志CF求反
  • STC:进位标志值1
  • CLD:方向标志置0
  • STD:方向标志置1
  • CLI:中断允许标志置0
  • STI:中断允许标志置1

其他处理器控制指令:

  • NOP:空操作
  • HLT:停机
  • WAIT:等待
  • ESC:换码
  • LOCK:封锁

Comment and share

C++协程及其原理

协程的几种实现方式及原理

协程又可以称为用户线程,微线程,可以将其理解为单个进程或线程中的多个用户态线程,这些微线程在用户态进程控制和调度.协程的实现方式有很多种,包括

  1. 使用glibc中的ucontext库实现
  2. 利用汇编代码切换上下文
  3. 利用C语言语法中的switch-case的奇淫技巧实现(protothreads)
  4. 利用C语言的setjmp和longjmp实现

实际上,无论是上述那种方式实现协程,其原理是相同的,都是通过保存和恢复寄存器的状态,来进行各协程上下文的保存和切换。

协程较于函数和线程的优点

  • 相比于函数:协程避免了传统的函数调用栈,几乎可以无限地递归
  • 相比与线程:协程没有内核态的上下文切换,近乎可以无限并发。协程在用户态进程显式的调度,可以把异步操作转换为同步操作,也意味着不需要加锁,避免了加锁过程中不必要的开销。

进程,线程以及协程的设计都是为了并发任务可以更好的利用CPU资源,他们之间最大的区别在于CPU资源的使用上:

  • 进程和线程的任务调度是由内核控制的,是抢占式的;
  • 协程的任务调度是在用户态完成,需要代码里显式地将CPU交给其他协程,是协作式的

由于我们可以在用户态调度协程任务,所以我们可以把一组相互依赖的任务设计为协程。这样,当一个协程任务完成之后,可以手动的进行任务切换,把当前任务挂起(yield),切换到另一个协程区工作.由于我们可以控制程序主动让出资源,很多情况下将不需要对资源进行加锁。

Comment and share

ucontext族函数详解

the ucontext_t type is a structure type suitable for holding the context for the user thread of execution. A thread’s context include stack,saved registersm a list of block signals

上述为ncontext_t结构体的定义,ucontext机制是GNU C库提供的一组创建,保存,切换用户态执行上下文的API,从上面的描述可以看出ucontext_t结构体使得用户在程序中保存当前的上下文成为可能。我们也可以利用此实现用户级线程,即协程。

ucontext_t以及ucontext族函数

ucontext_t结构体

ucontext_t结构体定义,一个ucontext_t至少包括以下四个成员,可能依据不同系统包括其他不同的成员。

1
2
3
4
5
6
7
8
#include <ucontext.h>
typedef struct ucontext_t {
struct ucontext_t* uc_link;
sigset_t uc_sigmask;
stack_t uc_stack;
mcontext_t uc_mcontext;
...
};

类成员解释:

  • uc_link:为当前context执行结束之后要执行的下一个context,若uc_link为空,执行完当前context之后退出程序。
  • uc_sigmask : 执行当前上下文过程中需要屏蔽的信号列表,即信号掩码
  • uc_stack : 为当前context运行的栈信息。
  • uc_mcontext : 保存具体的程序执行上下文,如PC值,堆栈指针以及寄存器值等信息。它的实现依赖于底层,是平台硬件相关的。此实现不透明。

ucontext族函数

ucontext族函数主要包括以下四个:

1
2
3
4
5
#include <ucontext.h>
void makecontext(ucontext_t* ucp, void (*func)(), int argc, ...);
int swapcontext(ucontext_t* olducp, ucontext_t* newucp);
int getcontext(ucontext_t* ucp);
int setcontext(const ucontext_t* ucp);

  • makecontext:初始化一个ucontext_t,func参数指明了该context的入口函数,argc为入口参数的个数,每个参数的类型必须是int类型。另外在makecontext之前,一般需要显示的初始化栈信息以及信号掩码集同时也需要初始化uc_link,以便程序退出上下文后继续执行。
  • swapcontext:原子操作,该函数的工作是保存当前上下文并将上下文切换到新的上下文运行。
  • getcontext:将当前的执行上下文保存在ucp中,以便后续恢复上下文
  • setcontext : 将当前程序切换到新的context,在执行正确的情况下该函数直接切换到新的执行状态,不会返回。

注意:setcontext执行成功不返回,getcontext执行成功返回0,若执行失败都返回-1。若uc_link为NULL,执行完新的上下文之后程序结束。

简单实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <ucontext.h>
#include <iostream>
#include <unistd.h>
void newContextFun() {
std::cout << "this is the new context" << std::endl;
}
int main() {
char stack[10*1204];
//get current context
ucontext_t curContext;
getcontext(&curContext);
//modify the current context
ucontext_t newContext = curContext;
newContext.uc_stack.ss_sp = stack;
newContext.uc_stack.ss_size = sizeof(stack);
newContext.uc_stack.ss_flags = 0;
newContext.uc_link = &curContext;
//register the new context
makecontext(&newContext, (void(*)(void))newContextFun, 0);
swapcontext(&curContext, &newContext);
printf("main\n");
return 0;
}

Comment and share

C/C++语言内存对齐

内存对齐:在计算机中,内存空间按照字节划分,理论上可以从任何起始地址访问任何类型的变量。但实际上在访问特定类型的变量的时候需要从特定的地址开始,这就需要各种类型的数据按照一定的规则在空间上排列,而不是顺序的一个接一个的存放,这就是内存对齐,也叫字节对齐。

内存对齐的作用:

  • 可移植性:因为不同平台对数据的在内存中的访问规则不同,不是所有的硬件都可以访问任意地址上的数据,某些硬件平台只能在特定的地址开始访问数据。所以需要内存对齐。
  • 性能原因:一般使用内存对齐可以提高CPU访问内存的效率。如32位的intel处理器通过总线访问内存数据,每个总线周期从偶地址开始访问32位的内存数据,内存数据以字节为单位存放。如果32为的数据没有存放在4字节整除的内存地址处,那么处理器需要两个总线周期对数据进行访问,显然效率下降很多;另外合理的利用字节对齐可以有效的节省存储空间。

默认内存对齐影响因素:与平台架构(位数)和编译器的默认设置有关。

总线周期:CPU通过总线和存储器或者IO设备进行一次数据传输需要的时间,通常为四个或者多个时钟周期组成。

内存对齐规则

  1. 整体类型的对齐规则:若设置了内存对齐为m个字节,类中的最大成员的对齐字节为n,则该数据类型的对齐字节为p=min(m,n)。(一般32位机器的默认pack为4位;64位机器的默认pack为8位,程序中可以显式设置pack的大小)
  2. 类型中成员的对齐规则:类中的第一个成员放在offset为0的位置;对于其他成员,若设置了内存对齐为m个字节,假设该数据成员的对齐字节数(即当前成员所占的字节数)为k,则该数据成员的起始位置是min(m,k)的整数倍。
  3. 整体对齐规则:最后整个类型的大小为p=min(m,n)的整数倍。
  4. 当设置对齐字节数大于类中最大成员的对齐字节数的时候,这个设置实际不产生任何效果;当设置对齐字节数为1时,类的大小就是简单的把所有成员大小相加。

实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <stddef.h>
using namespace std;
//前提条件:32位机器,当前编译器默认pack为4
struct T {
int a;
short b;
int c;
double d;
};
int main() {
cout << offsetof(T, a) << endl
<< offsetof(T, b) << endl
<< offsetof(T, c) << endl
<< offsetof(T, d) << endl;
return 0;
}

根据上面的分析:

  • a为第一个成员,offset为0。
  • b为short,对齐字节数为2,所以其对齐字节为min(2,4)=2,故offset为4
  • c为int,对齐字节数为4,所以其对齐字节数为min(4,4)=4,故offset为8
  • d为double,对齐字节数为8,故对齐字节数为min(4,8)=8,故offset为12
  • 总的大小为20,是,min(8,4)的倍数.

使用pragma pack修改系统默认pack

修改系统的默认pack可以使用系统函数pragma的pack参数,但是修改之后的pack一定是2的n次幂

1
2
3
#pragma pack(16) //修改pack修改为16
#pragma pack() //恢复系统的默认pack
#pragma pack(show) //返回系统当前的pack,由警告信息显示,注意gcc不支持。只有VS支持

此外pack还有push,pop其他参数可选,但是不同的编译器对这些参数的实现有不同的含义,如果需要了解可以参考对应的资料。

Comment and share

  • page 1 of 1

魏传柳(2824759538@qq.com)

author.bio


Tencent


ShenZhen,China