文件描述符
创建文件描述符,文件描述符其实就是一个数字代表的数据结构
普通文件的文件描述符
exec 6< 1.txt # 创建一个文件描述符6,用6这个数字代表对1.txt的读操作
exec 7> 1.txt # 创建一个文件描述符7,用7 这个数字代表对1.txt的写操作
exec 8<> 1.txt
lsof -op $$
lsof -p $BASHPID # 两个命令都可以查看当前进程正在使用的文件的描述符
#以及进到 当前进程的 fd文件夹也可以看到,$$代表当前进程的ID号
cd /proc/$$/fd
echo "hhh" >& 7 # 往1.txt里边写数据
read a 0<& 6 # 就可以读取到第一行数
据
另外每个进程一旦创建都有三个自己默认的文件描述符 0u(标准输入) 1u(标准输出) 2u(报错信息输出),u代表读写都可以,其实我们自己创建的读入文件描述符6是[6r]以及[7w],所以此时当前bash进程已经有了四个文件描述符。
每个文件描述符代表的数据结构中都有自己的偏移量,表示它可以从当前文件的那个位置进行操作(读写)。
每个进程都有自己的文件描述符,因为进程的隔离,所以不同进程维护的各自的文件描述符可以是重复的,也就是说不同进程的相同的文件描述符可以指向不同的文件。 假如不同进程的相同的文件描述符指向了同一个文件,他们仍然各自维护了自己的偏移量指针,也就是每个进程可以各自访问自己区域
socket文件描述符
# 创建socket文件描述符
exec 8<> /dev/tcp/www.baidu.com
lsof -op $$
socket类型的文件描述符也会有自己的缓存数据的区域,但是这个数据不是要刷盘的,是要通过网卡发走的,中间经历了网络各种层间的协议,包装成数据包发往目标IP地址。
进程描述符
在linux中,每一个进程都有一个进程描述符,这个”进程描述符”是一个结构体名字叫做task_struct,在task_struct里面保存了许多关于进程控制的信息。 task_struct是Linux内核的一种数据结构,它会被装载到RAM里并包含进程的信息。每个进程都把它的信息放在task_struct这个数据结构里面。
task_struct内容 标示符:描述本进程的唯一标示符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下一条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据:进程执行时处理器的寄存器中的数据。
I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和正在被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟总数,时间限制,记账号等。
内核态和用户态
概念
- 内核态:
系统中既有操作系统的程序,也有普通用户程序。为了安全性和稳定性,操作系统的程序不能随便访问,这就是内核态。即需要执行操作系统的程序就必须转换到内核态才能执行,内核态可以使用计算机所有的硬件资源。
- 用户态:
不能直接使用系统资源,也不能改变CPU的工作状态,并且只能访问这个用户程序自己的存储空间。
内核态和用户态的区别
当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核状态。此时处理器处于特权级最高的(0级)内核代码。**当进程处于内核态时,执行的内核代码会使用当前的内核栈。每个进程都有自己的内核栈。当进程在执行用户自己的代码时,则称其处于用户态。**即此时处理器在特权级最低的用户代码中运行。当正在执行用户程序而突然中断时,此时用户程序也可以象征性地处于进程的内核态。因为中断处理程序将使用当前进程的内核态。
内核态和用户态的转换

a.系统调用
这是用户进程主动要求切换到内核态的一种方式,用户进程通过系统调用申请操作系统提供的服务程序完成工作。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的ine 80h中断。
b.异常
当CPU在执行运行在用户态的程序时,发现了某些事件不可知的异常,这是会触发由当前运行进程切换到处理此异常的内核相关程序中,也就到了内核态,比如缺页异常。
c.外围设备的中断
当外围设备完成用户请求的操作之后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条将要执行的指令转而去执行中断信号的处理程序,如果先执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了有用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
中断
什么是中断
中断是能够打断CPU指令序列的事件,它是在CPU内外,由硬件产生的电信号。CPU接收到中断后,就会向OS反映这个信号,从而由OS就会对新到来的数据进行处理。不同的事件,其对应的中断不同,而OS则是通过中断号(也即IRQ线)来找到对应的处理方法。不同体系中,中断可能是固定好的,也可能是动态分配的。
中断产生后,首先会告诉中断控制器。中断控制器负责收集所有中断源的中断,它能够控制中断源的优先级、中断的类型,指定中断发给哪一个CPU处理。
中断控制器通知CPU后,对于一个中断,会有一个CPU来响应这个中断请求。CPU会暂停正在执行的程序,转而去执行相应的处理程序,也即OS当中的中断处理程序。这里,中断处理程序是和特定的中断相关联的。
中断描述符表
那么CPU是如何找到中断服务程序的呢?为了让CPU由中断号去查找到对应的中断程序入口,就需要在内存中建立一张查询表,也即中断描述符(IDT)。在CPU当中,有专门的寄存器IDTR来保存IDT在内存中的位置。这里需要注意的是,常说的中断向量表,是在实模式下的,中断向量是直接指出处理过程的入口,而中断描述符表除了入口地址还有别的信息。
更多内容: 深入理解中断
权限设置
以Linux举例,硬件中有两个段寄存器:DPL和CPL两个段寄存器,系统加载时DPL段寄存器被设置为0(指代内核内存空间的权限级别),系统加载完毕启动一个shell执行用户态应用程序时,CPL指代的用户态地址空间的权限级别为3
此时 DPL = 0,CPL=3;
DPL >= CPL才能访问内核段的内存空间
所有的指令中,只有 int 0x80 指令 可以把CPL设置为0、DPL设置为3,然后才可以访问内核空间
int 0x80的执行逻辑
假设执行fmt.Println("hello world"),这段代码最终调用write()系统调用把输出打印到屏幕的时候,需要最终通过系统调用完成最后的打印操作。
- 把 DPL 从 0 置为 3,让用户态可以进入内核,(此时才可以查询 IDT中断向量表)
- 查到 0x80 号中断是啥,是一个内核函数入口:systemcall
- CPL 置为 0,然后就可以执行内核函数(内核函数应该就是系统调用吧,我是这么理解的)
- systemcall找到 %eax寄存器中的值,开始调用write,然后获取其他相关寄存器中的值,拿到文件描述符和需要打印的字符和长度 int write(int fd, const char *buf, off_t count)中已经把write的地址和fd等存在了其他寄存器或者内存
- 执行完之后返回用户态, CPL置为3,DPL置为0,返回用户态,该干啥干啥

select
select函数详解
#include <sys/select.h>
int select(int maxfpd1, fd_set *read_fds, fdset *write_fds, fdset *exception_fds, struct timeval *restrict tvpr);
//返回值为可以操作的文件描述符的数量。
使用select函数实现io端口的复用,传递给select函数的参数会告诉内核:
- 我们所关心的文件描述符
- 对每个描述符,我们所关心的状态
- 我们要等待多长时间
从select函数返回后,内核告诉我们一些信息:
- 对我们的要求已经做好准备的描述符的个数
- 对于三种条件哪些描述符已经做好准备(读、写、异常)
fd_set是bitmap结构,是一个二进制,长度是1024(32位机器),其实就是位图
read_fds, write_fds, exception_fds

maxfdp1:最大的文件描述符编号+1,最大为1024。通过指定我们关注的最大的描述符,内核只需要在此范围内搜索打开的位。
相关操作函数:
int FD_ZERO(fd_set *fdset) // 将一个fd_set类型变量的所有位都设为0
int FD_CLR(int fd, fd_set *fdset) // 清除某个位
int FD_SET(int fd, fd_set *fdset) // 将制定位置的bit设置为1
int FD_ISSET(int fd, fd_set *fdset) // 测试某位是否被置为1
注意:先把readset和writeset的文件描述符对应的位置位1,然后交给select()系统调用去判断哪个文件描述符打开,如果某个fd不可读或者写,该位置位0, 如果可读,在数组中仍然为1. 系统调用完成后,返回可读可写的数量之和 服务程序,循环遍历所有文件描述符用 FD_ISSET() 判断是否进行读写操作
图解
客户端向服务器发起请求

设置socket文件描述符,构建readset

select函数进行系统调用,会把readset从用户态拷贝到内核态

客户端1,3发送数据,通过网卡的DMA将数据放到内存,称为网卡缓冲区

发起网卡硬件中断


读取网卡缓冲区的数据



进程A回到运行队列

优势
通过一次系统调用把所有的fds传递给内核,内核进行遍历,这种遍历减少了多次系统调用的开销
弊端:
-
因为select直接在**&readset,&writeset**上做出修改,导致两个数组不可重用,必须每次重新赋值
-
每次select都要重新遍历全量的fds
poll
# include <poll.h>
int poll(struct pollfd fdarry[], nfds_t nfds, int timeout);
struct pollfd{
int fd;
short events;
short revents;
}
pollfd
poll不是构建一个描述符集,而是构造一个pollfd的数组,每个数组的元素制定一个描述符编号(结构体中的fd)以及我们对该描述符感兴趣的条件(short events)。
当poll系统调用完成后,同样返回可以操作的文件描述符数量,服务程序检查pollfd中的revents字段
int a = poll(*fdarray, nfds, 0);
if(a > 0){ //服务程序操作可以读写的文件描述符
for(i=0; i<4; ++i){
if(pollfd->revents){ //☆☆☆☆☆☆☆
//操作
}
}
}
优势
- 内核操作为文件描述符创建的结构体中的revents字段,没有破坏其他结构体中其他的字段,所有不用每次重新构造类似于readset那样的bitmap
- 没有了select最大支持1024个文件描述符的限制
弊端:
-
每次poll都仍要重新遍历全量的fds
-
服务程序也要遍历全量的fds,查看每个文件描述符的revents字段是否需要读写操作
epoll

函数详解
epoll是Linux特有的I/O复用函数,epoll使用一组函数来完成任务,而不是单个函数。epoll把用户关心的文件描述符放到内核的一个事件表中,所以epoll需要使用一个额外的文件描述符来表示内核中的事件表。
#include <sys/epoll.h>
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() 返回一个文件描述符,该文件描述符“描述”的是内核中的一块内存区域,size现在不起任何作用。
-
epoll_ctl()用来操作内核事件表,
-
int epfd表示epoll_create() 返回的事件表
-
int fd:新创建的socket文件描述符
-
int op
- EPOLL_CTL_ADD: 事件表中添加一个文件描述符,内核应该关注的socket的事件在epoll_event结构体中,添加到事件表中的文件描述符以红黑树的形式存在,防止重复添加
- EPOLL_CTL_MOD:修改fd上注册的事件
- EPOLL_CTL_DEL:删除fd上注册的事件
-
struct epoll_event *event
-
struct epoll_event{ _uint32_t events; //epoll事件,读、写、异常三种 epoll_data_t data; //用户数据 } struct epoll_data{ void* prt; int fd; _uint32_t u32; _uint64_t u64; }epoll_data_t;
-
-
-
epoll_wait()该函数返回就绪文件描述符的个数
工作模式(LT模式、ET模式)
LT模式(水平触发)
- fd可读之后,如果服务程序读走一部分就结束此次读取,LT模式下该文件描述符仍然可读
- fd可写之后,如果服务程序写了一部分就结束此次写入,LT模式下该文件描述符也仍然可写
ET模式(边缘触发)
- fd可读之后,如果服务程序读走一部分就结束此次读取,ET模式下该文件描述符是不可读,需要等到下次有数据到达时才可变为可读,所有我们要保证循环读取数据,以确保把所有数据读出
- fd可写之后,如果服务程序写了一部分就结束此次写入,ET模式下该文件描述符是不可写的,我们要保证写入数据,确保把数据写满
ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高
图解
客户端建立socket连接

执行epoll_create()函数返回事件列表

执行epoll_ctl()函数


执行epoll_wait()函数

客户端发送数据

中断





