Socket与系统调用深度分析
一.socket的相关系统调用
1.socket() 创建套接字
int socket(int af, int type, int protocol);
af 为地址族(Address Family),也就是 IP 地址类型
type 为数据传输方式/套接字类型,常用的有 SOCKET_STREAM(面向连接套接字)和SOCKET_DGRAM(无连接套接字)
protocol 表示传输协议,常用的有IPRPTO_TCP和IPPTOTO_UDP
2.bind() 将本机套接字地址绑定到监听套接字上
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小
3.connect() 用来建立连接
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);
3.listen() 监听本端口
int listen(int sock, int backlog);
4.accept() 接受连接
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
5.read() 数据接收
ssize_t read(int fd, void *buf, size_t nbytes);
6.write() 数据发送
ssize_t write(int fd, const void *buf, size_t nbytes);
二.系统调用机制研究
我们已经知道socket接口在用户态通过系统调用机制进入内核,下面系统调用的具体机制是什么呢?
用一张图来概括的话就是这样的:

一般情况下,用户进程是不能访问内核的。它既不能访问内核所在的内存空间,也不能调用内核中的函数。系
统调用是一个例外。其原理是:
(1)进程先用适当的值(系统调用号)填充寄存器
(2)然后调用一个特殊的指令
(3)这个指令会让用户程序跳转到一个事先定义好的内核中的一个位置
(4)进程可以跳转到的固定的内核位置。这个过程检查系统调用号,这个号码告诉内核进程请求哪种服务。
(5)查看系统调用表(sys_call_table)找到所调用的内核函数入口地址。接着,就调用函数,等返回后,做一些系统检查,最后返回到进程。
进一步的详细的解释如下:
(1)适当的值
所有适当的值我们都可以在include/asm/unistd.h中找到,在这个文件中为每一个系统调用规定了唯一的编号,叫做系统调用号。
在~/kernel/linux-5.0.1/arch/sh/include/uapi/asm/unistd_64.h文件中为每个系统调用规定了唯一的编号。
执行下面这条命令就能看到文件对应的系统调用号

/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#ifndef __ASM_SH_UNISTD_64_H
#define __ASM_SH_UNISTD_64_H
/*
* include/asm-sh/unistd_64.h
*
* This file contains the system call numbers.
*
* Copyright (C) 2000, 2001 Paolo Alberelli
* Copyright (C) 2003 - 2007 Paul Mundt
* Copyright (C) 2004 Sean McGoogan
*
* This file is subject to the terms and conditions of the GNU General Public
* License. See the file "COPYING" in the main directory of this archive
* for more details.
*/
#define __NR_restart_syscall 0
#define __NR_exit 1
#define __NR_fork 2
#define __NR_read 3
#define __NR_write 4
#define __NR_open 5
#define __NR_close 6
#define __NR_waitpid 7
#define __NR_creat 8
#define __NR_link 9
#define __NR_unlink 10
#define __NR_execve 11
#define __NR_chdir 12
#define __NR_time 13
#define __NR_mknod 14
#define __NR_chmod 15
#define __NR_lchown 16
/* 17 was sys_break */
#define __NR_oldstat 18
#define __NR_lseek 19
#define __NR_getpid 20
这是其中的前20个
这里面每一个宏就是一个系统调用号
(2)特殊的指令
在Intel CPU中,这个指令由中断0x80实现
在ARM中,这个指令是SWI(softwhere interrupt:软中断指令),现在重新命名为SVC
(3)固定的位置
每个CPU固定的位置是不一样的,在ARM体系中这个固定的内核位置是ENTRY(vector_swi)(在arch\sh\kernel\entry-common.S),也就是PC指针会跳转到这个位置
(4)相应的函数
内核根据应用程序传递来的系统调用号,从系统调用表sys_call_table找到相应的内核函数
系统调用表记录了各个系统调用处理函数的入口地址,以系统调用号为偏移量很容易的能够在该表中找到对应处理函数地址。
它的定义是这样的:
ENTRY(sys_call_table)
.longSYMBOL_NAME(sys_ni_syscall)
.longSYMBOL_NAME(sys_exit)
.longSYMBOL_NAME(sys_fork)
.longSYMBOL_NAME(sys_read)
.longSYMBOL_NAME(sys_write)
......
对上面的内容做一个总结就是:
1 在系统启动时,对INT0x80进行初始化:
1.1、调用汇编子程序setup_idt初始化中断描述符表,这时所有中断入口函数偏移地址都被设为ignore_int;
1.2、调用Start_kernel()(linux/init/main.c)时,Start_kernel()会调用trap_init()(linux/arch/i386/kernel/trap.c)函数设置中断描述符表。
在该函数里,实际上是通过调用函数set_system_gate(SYSCALL_VECTOR,&system_call)来完成该项的设置的。其中的SYSCALL_VECTOR就是0x80,而system_call则是一个汇编子函数,它即是中断0x80的处理函数。
2、线程调用“系统调用函数”时,产生1个0x80的软中断。
3、中断执行函数system_call()中,实际完成了以下几条操作:
3.1、执行SAVE_ALL,首先获取当前线程的内核栈空间地址;把当前用户栈空间地址保存在到内核栈中;设置栈指针寄存器内容为内核栈空间地址;
3.2、然后可以执行系统调用的处理函数(系统调用表中的系统调用处理函数,eg:sys_fork(),sys_read()等),并访问内核空间的内存地址了。
3.3、执行RESTORE_ALL,来弹出所有被SAVE_ALL压入核心栈的内容,并恢复用户态
三.socket相关系统调用分析
在这样部分我们将在上一篇博文上构建的调试LINUX内核网络代码环境的menuos系统上跟踪分析socket相关的系统调用的内核处理函数
先以调试模式运行MenuOS系统
qemu -kernel ../linux-5.0.1/arch/x86/boot/bzImage -initrd ../rootfs.img -append nokaslr -s -S
打开另一终端开启gdb调试 ,设置与前面系统调用有关的四个进程断点
依次在gdb中输入一下命令:
gdb
qemu -kernel ../linux-5.0.1/arch/x86/boot/bzImage -initrd ../rootfs.img -append nokaslr -s -S
target remote:1234
b start_kernel
b trap_init
b cpu_init
b syscall_init

这也验证了前面的系统调用机制的正确性
下面我们以前面讲过的bind()函数为例用gdb分析追踪socket接口的内核处理函数:
用和上面相同的方法开启menuos,用打开另一终端进入gdb调试
为bind()和listen()添加断点

输入c继续运行,在menuOS中依次输入replyhi和hello,观察gdb中显示调用的函数为
可看到服务器在运行到第二个断点__sys_bind函数出停止,在gdb中输入list可以看到函数源代码

我们还可以在socket.h文件中找到对应的函数定义

对应的socket.c文件中的__sys_bind()在linux源码包的net目录下:

核心的处理语句为sock->ops->bind,此操作实际是调用了inet_stream_ops.bind。
inet_stream_ops.bind结构:
const struct proto_ops inet_stream_ops = { .family = PF_INET, .owner = THIS_MODULE, .release = inet_release, .bind = inet_bind, .connect = inet_stream_connect, .socketpair = sock_no_socketpair, .accept = inet_accept, .getname = inet_getname, .poll = tcp_poll, .ioctl = inet_ioctl, .listen = inet_listen, .shutdown = inet_shutdown, .setsockopt = sock_common_setsockopt, .getsockopt = sock_common_getsockopt, .sendmsg = inet_sendmsg, .recvmsg = inet_recvmsg, .mmap = sock_no_mmap, .sendpage = inet_sendpage, .splice_read = tcp_splice_read, #ifdef CONFIG_COMPAT .compat_setsockopt = compat_sock_common_setsockopt, .compat_getsockopt = compat_sock_common_getsockopt, .compat_ioctl = inet_compat_ioctl, #endif
其中bind调用了inet_bind()函数,bind系统调用通过套接口层Inet_bind(),然后便会调用传输接口层的函数,TCP中的传输层接口函数为inet_csk_get_port函数,
该函数主要实现bind的作用,如果用户系统调用使用的端口号为0,系统会自动选择一个可用的端口号,这里选择可用端口号思路是:先在绑定表中选择可用的端口号,
如果在绑定表中没有可用的端口号,再选择空闲的端口号。
bind()的分析就到这里了,其他socket相关系统调用的分析榆次类似,大家有时间可以仿照上面的步骤试一下。

浙公网安备 33010602011771号