中断与系统调用深度分析(以网络编程接口SocketAPI为例)

1.从计算机CPU与I/O设备的交互方式谈起

计算机CPU与I/O设备的交互方式有最早的程序查询(也叫轮询)方式,发展到后来的程序中断方式,DMA方式等。简单来说,最早的程序查询方式的机制是,CPU若想和I/O设备交互,首先向I/O设备发出命令,查询并读取设备的状态,如果此时设备可用,则设备开始进行准备工作;CPU每隔一段时间便向设备发出命令,以查询并读取设备的当前状态;当设备准备好后,开始进行数据的传输,在传输过程中CPU同样要每隔一段时间就查询设备发送数据的情况,以防止存储I/O交互数据的寄存器(也叫数据端口)溢出导致传输失败。程序查询方式最明显的特点在于:I/O设备无任何自主性,I/O设备的状态转换和数据传输的全过程均由CPU全程干预,CPU必须抽出大量的时间用于定期轮询I/O设备的情况,大大降低了CPU的运算效率。

 

 

上图是程序查询方式和程序中断方式的执行示意图。

为什么要让CPU和I/O设备的交互如此频繁呢?换句话说,为什么要让I/O设备毫无任何自主性呢?进一步讲,如果让I/O设备有着初步的自主性,允许I/O设备在准备好以及数据传输完毕后主动通知CPU,从而打断了CPU的执行,令CPU转而服务I该/O设备,这样做就可以大大提高CPU的执行效率,这就是中断方式。在著名计算机入门教程《穿越计算机的迷雾》中,作者是这样讲述中断机制的:“中断的意思是在做一件事情的时候临时打个岔,中途去做另外一件事情,然后再回来。这好比拍一下中央处理器的肩膀,告诉它这里有一件事情需要它过来帮个小忙。在有些计算机原理书上,他们把中断看成你在吃饭,突然电话铃响了,于是你放下碗筷去接电话,然后再坐下来接着吃。”

中断机制的执行具体过程如下:

①关中断,目的是防止其他中断源前来破坏现场;

②保存断点,这是为了保证中断服务程序执行完毕后能正确返回原处;

③引出中断服务程序,将其于内存中的地址送入CPU的程序计数器PC,这本质上就是一个CPU指令系统的特殊寻址过程;寻址中断服务程序的入口内存地址有两种策略:硬件向量法(硬件产生中断向量,中断向量由中断号决定,中断号的概念在下一段有具体解释)和软件查询法(利用软件编程的方式事先规定好);

④保存现场状态;

⑤开中断,这是为了响应更高级的中断请求,实现中断嵌套;

⑥执行中断服务程序;

⑦关中断,这是为了保证恢复现场时不被外界打扰;

⑧恢复原来的现场和屏蔽字;

⑨开中断,中断返回。(中断服务程序的最后一条指令)

 其中,①~③由硬件自动完成,该过程也被抽象描述为“中断隐指令”(这只是一个抽象过程,不是真正的指令);其余步骤由中断服务程序完成。

如上所述,中断机制有两个好处:第一个好处,也是最明显的好处——通过赋予I/O设备一定的独立性从而增大CPU执行效率。中断机制的第二个好处是,不同的外设有不同的中断信号,因此它们都被CPU分配了各自不同的中断号,这就意味着计算机内存里可以防止多个不同的程序,而不是像以前那样每次只能有一个,这也意味着中断的种类可以有多种多样,中断机制不仅可以用在CPU与I/O设备交互上,还可用于软件应用程序与操作系统的交互上——系统调用。

 

2.从中断到系统调用

如上所述,中断分为繁多的类型,因此中断也有不同的分类方法。

最常用的分类方法是“外中断”和“内中断”。该方法可以涵盖所有的中断。

外中断(Interruption,有时直接被称为“中断”)指来自CPU和内存以外的部件引起的中断,如上文所述的I/O设备中断,如用户在键盘上输入命令等。外中断有时直接被称为“中断”。

内中断,又叫“异常”(Exception,这个概念在高级语言编程中经常被提到),则指在CPU和内存内部产生的中断,最简单的例子,如“拔电源”,系统突然断电,CPU确实失去了电能因此无法工作,这是典型的内中断。此外,如地址非法,除数为0,算数操作溢出,内存页面失效,用户程序执行了特权指令等均为内中断。显然,系统调用属于内中断。

此外,还有“硬件中断”和“软件中断”的分类。硬件中断是指外部硬件产生的中断,这显然属于外中断;软件中断指的是,通过编程实现的,通过某条指令产生的中断,显然系统调用属于软件中断,软件中断又属于内中断。

 

3.从程序接口到系统调用

操作系统为用户和应用程序均提供了对计算机硬件系统的接口。前者为命令接口,后者为程序接口,命令接口,如SHELL,脚本等。程序接口,由一组系统调用命令(也叫广义指令)组成,用户通过在程序中使用这些系统调用命令来请求操作系统为其提供服务。用户在程序中可以直接使用这组系统调用命令向系统提出各种服务要求。如当前流行的图形用户界面GUI,其本质就是利用系统调用。

 

4.系统调用

系统调用,就是用户在程序中调用操作系统所提供的一些子功能,系统调用可以被看做特殊的公共子程序。系统中的各种共享资源都由操作系统统一掌管,因此在用户程序中,凡是与资源有关的操作,如存储分配,进行I/O传输,及管理文件等,都必须通过系统调用方式向操作系统提出夫区请求,并由操作系统代为完成。同城,一个操作系统提供的系统调用命令有数百条。这些系统调用按功能大致可分为如下几类:

设备管理——完成设备的请求与释放,以及设备启动禁用等功能;如多个进程同时争夺一个声卡;

文件管理——完成文件的创建,读写等;如下载器在下载之前需要用户设定文件存储地址;

进程管理——完成对进程的创建,撤销,阻塞与唤醒等;

进程通信——完成进程之间的消息传递或信号传递等功能;

内存管理——完成内存的分配,回收以及获取作业占用内存区大小和初始地址等;

显然,系统调用运行在系统的核心态,通过系统调用的方式来使用系统功能,可以摆正系统的稳定性和安全性,防止用户随意更改或访问系统的数据或命令。系统调用命令是由操作系统提供的一个或多个子程序模块实现的。系统调用的运行机制为:用户通过操作系统运行上层程序,如系统提供的命令解释程序或用户自编程序,而上层程序的运行依赖于操作系统的底层管理程序提供服务支持,当需要管理程序服务时,系统则通过硬件中断机制进入和心态,运行管理程序;也可能是程序运行出现异常情况,被动地需要管理程序的服务,这时就通过异常处理来进入核心态。当管理程序运行结束时,用户程序需要继续运行,则通过相应的保存的程序现场退出中断处理程序或异常处理程序,返回断点处继续执行。

操作系统从用户态转向核心态的情况有:系统调用——用户程序要求操作系统的服务,发生一次中断,用户程序中产生一个错误状态,用户程序企图执行一条特权指令等。如果程序的运行由用户态转向核心态,会用到访管指令,这是一条在用户态使用的,因此不是特权指令。

 

 上图是系统调用的执行过程。

 

 上图显示了操作系统用户态和内核态之间的关系。系统调用是二者间重要的桥梁,SocketAPI正是这一点的体现。

 

5.利用gdb跟踪系统调用

上次实验已经跟踪到了Linux内核的start_kernel函数,本次实验继续利用gdb跟踪集成了replyhi的MenuOS系统的Socket相关系统调用。

简述gdb的使用:

首先启动一终端,输入gdb

然后键入file 带路径的你要测试的文件名(因此建议在该文件所处的目录下打开用于gdb的终端)

gdb常见命令:

我们在这里对Socket两个非常常用的系统调用bind()和listen()进行跟踪,具体步骤如下:

打开Ubuntu虚拟机后,进入上次实验的装有MenuOS内核的文件夹,在实验之前,首先要修改上次的~/MenuOS/menu/Makefile文件:

cd ~/MenuOS/menu
sudo su
# 切换至root用户以修改Makefile文件
gedit Makefile
# 去掉-S

 

 

 然后,和上次一样,打开MenuOS,效果如下:

make rootfs

 

 

 此时切不可关闭该终端和QEMU,返回到目录../linux-5.0.1下,打开另一个终端,输入如下命令:

gdb
file ./vmlinux
target remote:1234
break __sys_bind
break __sys_listen

此时,gdb已经给__sys_bind和__sys_listen两个Socket系统调用设定了断点,gdb响应如下:

 

 

 说明gdb已找到这两条系统调用的函数定义所在。

然后开始对MenuOS的运行:

c #在gdb终端输入,接下来两条命令在QEMU中输入
replyhi
hello

 

 效果如上图,gdb已经成功跟踪到系统调用。

而__sys_bind()系统调用究竟做了哪些事情呢?我们根据gdb给我们函数定义地址的信息,在~/linux-5.0.1/net/socket.c中找到了相应的函数定义,如下图:

 

 该函数的讲解如下:

/*
 *    Bind a name to a socket. Nothing much to do here since it's
 *    the protocol's responsibility to handle the local address.
 *
 *    We move the socket address to kernel space before we call
 *    the protocol layer (having also checked the address is ok).
 */

/*
 *上面一段话的大概意思是,bind()系统调用仅负责将进程的名字与socket绑定;
 *此外,bind()也负责将该socket转入内核处理;
 *至于处理本地地址,这是网络协议所需要做的事情。
 */
int __sys_bind(int fd, struct sockaddr __user *umyaddr, int addrlen)
{
    struct socket *sock;
    struct sockaddr_storage address;
    int err, fput_needed;
        /*
         *以fd为索引从当前进程的文件描述符表中,找到对应的file实例,
         *然后从file实例的private_data中,获取socket实例
         */

    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock) {
        /*
         * 将用户空间的地址拷贝到内核空间的缓冲区中
         */
        err = move_addr_to_kernel(umyaddr, addrlen, &address);
        if (!err) {
            /*
             * SELinux相关,不需要关心。
             */
            err = security_socket_bind(sock,
                           (struct sockaddr *)&address,
                           addrlen);
            /*
             * 如果是TCP套接字,sock->ops指向的是inet_stream_ops,
             * sock->ops是在inet_create()函数中初始化,所以bind接口
             * 调用的是inet_bind()函数。
             */

            if (!err)
                err = sock->ops->bind(sock,
                              (struct sockaddr *)
                              &address, addrlen);
        }
        fput_light(sock->file, fput_needed);
    }
    return err;
}

SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)
{
    return __sys_bind(fd, umyaddr, addrlen);
}    

__sys_listen()的代码如下图所示:

 

 

 该函数的讲解如下:

/*
 *    Perform a listen. Basically, we allow the protocol to do anything
 *    necessary for a listen, and if that works, we mark the socket as
 *    ready for listening.
 */

/*
 *上述解释的大致含义是,该系统调用的作用是将端口置为监听状态;
 *具体操作视协议而定
*/

int __sys_listen(int fd, int backlog)
{
    struct socket *sock;
    int err, fput_needed;
    int somaxconn;

    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    if (sock) {
        /*
         * sysctl_somaxconn存储的是服务器监听时,允许每个套接字连接队列长度 
         * 的最大值,默认值是128
         */
        somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
        /*
         * 如果指定的最大连接数超过系统限制,则使用系统当前允许的连接队列
         * 中连接的最大数。
         */
        if ((unsigned int)backlog > somaxconn)
            backlog = somaxconn;

        err = security_socket_listen(sock, backlog);
        if (!err)
        /*
         * 从这里开始,socket以后所用的函数将根据TCP/UDP而视协议而定
         */
            err = sock->ops->listen(sock, backlog);

        fput_light(sock->file, fput_needed);
    }
    return err;
}

SYSCALL_DEFINE2(listen, int, fd, int, backlog)
{
    return __sys_listen(fd, backlog);
}

而实际上,在进行Linux系统维护与测试时,一般不需要掌握用gdb跟踪系统调用的所有深层代码实现,更重要的是,利用strace命令跟踪一个进程在运行过程中发生了哪些系统调用,结果如何。

strace命令的常用格式为:

strace +带路径的你要检测的文件名

其常用参数为:

-t 在每行输出的前面,显示秒级别的时间
-T 显示每次系统调用所花费的时间
-v 对于某些相关调用,把完整的环境变量,文件stat结构等打出来。
-f 跟踪目标进程,以及目标进程创建的所有子进程
-e 控制要跟踪的事件和跟踪行为,比如指定要跟踪的系统调用名称
-o 把strace的输出单独写到指定的文件
-s 当系统调用的某个参数是字符串时,最多输出指定长度的内容,默认是32个字节
-p 指定要跟踪的进程pid, 要同时跟踪多个pid, 重复多次-p选项即可。

因为很多情况下不知道你想要跟踪的进程的路径,因此需要得知其进程PID号,需要使用pidof命令,如查询火狐浏览器的PID:

firefox
pidof firefox

结果如下:

 

 

 然后可以输入如下命令,开始跟踪其系统调用:

strace -tt -T -v -f -e trace=network -o ./firefoxlog.txt -p 30589 -p 30544 -p 30494 -p 30443

然后就可以跟踪到如下内容:(下述内容是我之前跟踪的结果,因此PID号与上文并不一致)

 

可见,firefox浏览器最常调用的三个系统调用分别为:recvmsg(),sendmsg(),socketpair(),它们分别负责发送/接收套接字的创建,数据包的发送和接收。

 对recvmsg(),sendmsg()的深度解析,详见文末的参考链接:

 

 

参考链接:

https://github.com/mengning/net/blob/master/doc/systemcall.md

https://blog.csdn.net/weixin_40039738/article/details/81095013

https://blog.csdn.net/u014209688/article/details/71311973

posted @ 2019-12-17 21:02  Noble~小仙女(何昳遥)  阅读(786)  评论(0编辑  收藏  举报