购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

2.2 从socket编程开始

提起网络通信,人们立刻会联想到socket编程,这是了解网络工作原理的最佳入手点。下面从UDP socket编程开始,逐步摸清Linux网络处理的全过程。

本节创建一个UDP服务端,该服务端监听8125端口,服务端收到报文后,将报文中的数据显示到控制台界面上。

2.2.1 UDP服务端源码

代码主体流程包括三个部分:创建socket;初始化监听地址和端口,绑定端口;等待接收外部报文,收到报文后将其内容输出到控制台上。

udp_server.c完整代码如下:

编译源码并运行,看到“Server is ready...”则表示UDP服务端启动成功:

接下来使用其他主机测试udp_server接收报文功能,直接在本机向127.0.0.1:8125发送UDP报文也能达到同样的效果。下面直接使用本机进行验证。

使用socat命令向127.0.0.1:8125发送2条UDP报文:

回到udp_server程序界面查看输出,程序显示收到两个报文,内容是socat命令发送的数据:

上述代码实现比较简单,接下来看程序的实现原理。

2.2.2 UDP服务端源码分析

分析源码之前,先介绍一个概念: 系统调用(system call)

在Linux操作系统中,应用程序和内核在不同的特权模式下工作。应用程序仅有最低权限,而内核拥有最高权限,应用程序受内核管理,这样就能保证个别应用的意外事故不会对全部系统造成致命的破坏。如果应用程序需要使用系统功能,如访问硬件设备,则可以向内核发起服务请求,由内核实现相关功能,我们称这种调用方式为 系统调用

在x86体系架构下,系统调用是通过软件中断实现的。应用程序触发系统调用后,CPU进入软件中断处理程序,同时CPU的运行级别也从用户级别ring3切换到内核级别ring0,此过程将导致一系列的上下文切换。与普通的函数调用相比,系统调用消耗更多的资源,这也就是为什么追求高性能的应用尽量避免使用系统调用。

前面的应用程序调用的socket()、bind()、recvfrom()函数均会产生系统调用。接下来以socket()函数实现为例,介绍Linux操作系统是如何实现系统调用的。

应用程序首先调用socket()函数获得socket文件描述符,应用的代码如下:

程序中调用的socket()函数由glibc提供(与内核无关),编译器在执行连接时,将应用程序的目标文件和glibc的库文件连接在一起,最终形成完整的可执行文件。

glibc的socket()函数定义如下:

㊀ glibc源码:https://elixir.bootlin.com/glibc/glibc-2.27/source/sysdeps/unix/sysv/linux。

继续跟踪宏定义,函数最终调用了“int$0x80”汇编指令(代码位于sysdeps/unix/sysv/linux/i386/sysdep.h文件中),产生了0x80编号的软件中断。在Linux内核中,该中断编号对应了系统调用中断。接下来进入内核代码,继续处理此系统调用。

1.创建socket

内核代码net/socket.c文件定义了一系列的套接字系统调用,socket系统调用定义如下:

SYSCALL_DEFINE3宏定义在include/linux/syscalls.h文件中,该宏的第一个参数表示系统调用的名称,后面跟着三个用户参数。

跟踪__sys_socket()函数实现,调用栈如下:

这里的调用均为通用实现,与具体的协议族无关(Linux内核支持多种协议族)。调用栈的底层是__sock_create()函数,该函数调用报文归属协议族的create()接口创建socket对象:

协议族从哪里来?下面以IPv4协议族为例来说明。IPv4协议族定义在net/ipv4/af_inet.c文件中,初始化函数为inet_init()。

内核初始化时调用inet_init()执行IPv4模块初始化,inet_init()调用sock_register()注册PF_INET协议族对象,该对象的create接口指向inet_create()函数:

回到应用中,用户调用socket接口时,第一个参数指定了当前协议族为AF_INET,所以本次调用的协议族函数为inet_create()。

inet_create()关键代码如下:

函数首先找到用户指定的协议类型对应的操作接口,本次应用程序调用socket接口时传递的协议类型为SOCK_DGRAM,该协议的操作对象位于inetsw_array数组中,UDP的操作对象定义如下:

inet_create()在inetsw_array数组中找到UDP信息后,将socket对象的sock->ops指针设置为inet_dgram_ops对象,将socket对象的sock->prot指针设置为udp_prot对象(执行bind操作时使用)。

inet_create()继续调用sk_alloc()申请内核套接字对象,并执行相关的初始化操作。该对象由内核使用,与应用程序的socket描述符一一对应。函数执行完成后此系统调用执行完成,内核将socket对象的文件描述符(File descriptor)返回给应用程序。

总结socket系统调用流程,如图2-5所示。

图2-5 socket系统调用完整过程

关于文件描述符

在Linux系统中,每种资源都可以用文件来描述,用户进程通过文件描述符访问资源。由于内存限制,每个进程能够使用的文件描述符数量有限(默认为1024),用户可以通过下面的命令查询进程支持的最大文件描述符数量:

同时,整个系统对文件描述符总数也有限制,通过proc文件系统可以获取:

这个最大数是如何计算的呢?在内核中,用户每创建一个文件描述符,就要占用1KB的内存。默认情况下,Linux最多允许系统10%的内存用于存储文件描述符,内核使用files_maxfiles_init()函数计算系统支持的最大文件描述符数量:

对于4GB内存的机器来说,最多允许有400MB内存用于存储文件描述符,对应的数量大概为40万个,与proc文件系统中保存的最大值接近。

2.绑定bind

应用程序创建socket后继续调用bind接口执行端口绑定,一个“地址+端口”的组合只能被一个应用程序绑定,只有这样内核才能精准地把接收的报文转发给应用程序。

应用程序源码中的server_addr对象描述了本程序绑定的地址和端口:

绑定的IP地址为INADDR_ANY,监听端口号为8125,应用程序可以从本机的任意地址接收报文。

程序中使用的bind接口同样由glibc提供。与创建socket的过程类似,内核提供了bind系统调用:

__sys_bind()调用网络协议族的bind接口,调用栈:

对于UDP来说,sock->ops指向的是inet_dgram_ops对象,该对象指定了每种操作对应的回调函数,对象定义如下:

所以,__sys_bind()最终调用的是inet_bind()函数。inet_bind()首先检查当前的协议类型(本例为UDP)是否定义了bind接口,如果定义了则直接调用,否则调用默认的__inet_bind()函数。UDP对应的协议对象是udp_prot,该对象没有定义bind接口:

内核源码中,TCP同样没有定义bind接口,所以这两种协议均通过__inet_bind()函数实现地址端口的绑定操作。inet_bind()函数的调用栈:

__inet_bind()调用协议的get_port接口,UDP对应的函数是udp_v4_get_port(),该函数将当前sock对象插入设备的ip+port哈希表中。

后续内核收到报文后,根据ip+port信息索引到sock对象上,并找到此sock对象关联的应用程序,最后将报文转交给用户进程处理。

总结bind系统调用流程,如图2-6所示。

3.接收recvfrom/发送sendto

应用程序调用recvfrom()从socket中接收数据:

图2-6 bind系统调用执行过程

recvfrom()函数同样触发系统调用,该系统调用将本进程绑定到socket接收队列上,如果该socket上没有收到任何报文,则进程会发生阻塞,当有报文到达后,内核唤醒进程执行收包操作。sendto用于发送报文,同样是系统调用,此接口不会阻塞进程。

后面对这两个系统调用会进行详细描述,这里不展开讲解。 LYmmHjlkkeXKuK+1O9qeWpabGiRguqkQYLIzsPcpAb+DqHOvFywOIXKVD20h1fuY

点击中间区域
呼出菜单
上一章
目录
下一章
×