【Linux】内核学习笔记(八)——网络栈简介

本篇是《Linux内核学习笔记》系列的第八篇。

在看了一点点内核代码之后,我觉得自己有向正在探索的领域——网络迈出更前一步的能力了。本篇会结合《Linux网络栈源代码情景分析》第0章简单学习,介绍网络栈的发展,以及从应用层到网络栈顶部的路径。

关于网络栈的更多内容请移步Linux网络专题。

1. 网络栈俯瞰

1.1. 七层模型和四层模型

网络栈从定义上还比较抽象。不过从功能上看,它是为了让主机之间交换数据而生的。

要实现网络栈,最原始的一个方式就是:将想发送的报文封装成帧,然后发出去就完事了。接收主机会按约定好的方式解析。不过这种存在一个问题,如果要加密怎么办?要支持QoS怎么办?要支持路由怎么办?这可坏了,所有的主机都得修改解析方式。所以,网络栈有个最基本的要求,就是可扩展。随之而来,数据包的格式也得可扩展。这就有了协议栈这种东西,各种协议报头是一层层叠加在数据之上的。对某个协议做修改,可以尽量不影响到其他的协议。

工程师说要有分层,于是就有了分层。早先的分层有应用层、传输层、网络层和链路层,其中应用层数据格式完全由用户定制。后来,工程师说还要有数据保密和完整性检验。那不能让用户发送数据前都手动加密一下吧?这多麻烦,内核可以帮助完成这一操作,但它其实不属于传输层的职责。这也催生了经典的OSI协议栈模型,它在应用层和传输层间加了表示层和会话层,其中

  • 表示层可以负责数据自动加密;
  • 会话层可以维护一次session中的一些参数。

此外还加了个物理层,用于定义物理硬件的一些特性。这样OSI协议栈模型就有七层。不过,新加的两层其实还是比较像应用层,和下面的层次联系不紧密,所以往往在实现时还是把这两层划进应用层,不单独实现。然而,它们相应的功能还是由内核完成。

1.2. 各层次到底是什么?

接下来说说各层次到底对应于系统中的哪些函数或代码。

应用层对应于socket的一系列接口函数,比如socket、bind、listen等等,它们通常是库函数提供的,封装了系统调用,仍在内核之外。

表示层和会话层未单独实现,但是Linux中的BSD Socket层(net/socket.c)和INET Socket层(net/ipv4/af_inet.c)有实现这些功能。

传输层可就太经典了。TCP、UDP自不必多说,而ICMP、IGMP等网络层协议出于实现的方便考虑,也做在了这一层。

网络层包含路由功能,也是网络栈的重要组成部分。它还包括RAW套接字、SOCK_PACKET套接字等。没有包含ARP协议,不过都是怎么方便怎么来,理论上的定义不必深究。

链路层是网络栈和底层驱动的接口层,通常更关心数据帧是如何封装的。

再往下就是驱动层次,它已经离开了网络栈,来到drivers子目录下的设备驱动程序。

所以,Linux网络栈就包含BSD和INET Socket层、传输层、网络层和链路层。

2. 从系统调用到BSD Socket层

2.1. 在用户空间

acceptlisten等等函数,都是在用户空间实现,即库函数。它们都通过系统调用实现功能。

accept为例,它会阻塞在一个正在监听的套接字上,等待返回对方的信息。它在glibc 2.33中的实现如下:

int
__libc_accept (int fd, __SOCKADDR_ARG addr, socklen_t *len)
{

  return SYSCALL_CANCEL (accept, fd, addr.__sockaddr__, len);

}
weak_alias (__libc_accept, accept)

SYSCALL_CANCEL这个宏包装了系统调用,将其展开会得到一大坨克苏鲁语。不过问题不大,它最终靠这个宏完成系统调用:

#undef internal_syscall3
#define internal_syscall3(number, arg1, arg2, arg3)			\
({									\
    unsigned long int resultvar;					\
    TYPEFY (arg3, __arg3) = ARGIFY (arg3);			 	\
    TYPEFY (arg2, __arg2) = ARGIFY (arg2);			 	\
    TYPEFY (arg1, __arg1) = ARGIFY (arg1);			 	\
    register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;			\
    register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;			\
    register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;			\
    asm volatile (							\
    "syscall\n\t"							\
    : "=a" (resultvar)							\
    : "0" (number), "r" (_a1), "r" (_a2), "r" (_a3)			\
    : "memory", REGISTERS_CLOBBERED_BY_SYSCALL);			\
    (long int) resultvar;						\
})

我们可以看出accept是体系结构无关的代码,而系统调用显然是体系结构相关的。glibc提供一个脚本configure,在编译前运行它会自动生成配置,可以识别出当前的体系结构,编译器会自动筛选出符合要求的体系结构相关代码。这点和Linux很像,不过Linux代码的可读性远远比它好。

与此同时,可以看出用户态的accept函数没做什么事情,仅仅是将参数存入寄存器然后直接进行系统调用而已。

2.2. 进入系统调用处理程序

控制流通过int 0x80进入内核的完整过程已经在保护模式下Linux对异常和中断的响应中讲述清楚。通过syscall进入的是另外一个函数,其原型如下:

void do_syscall_64(unsigned long nr, struct pt_regs *regs);

在一系列检查和环境准备后,内核调用相应的处理函数。

regs->ax = sys_call_table[nr](regs);

对于accept来说,nr=43,我们去对应的表项。在它附近我们可以看到好多它的兄弟姐妹:

__SYSCALL_COMMON(41, sys_socket)
__SYSCALL_COMMON(42, sys_connect)
__SYSCALL_COMMON(43, sys_accept)
__SYSCALL_COMMON(44, sys_sendto)
__SYSCALL_64(45, sys_recvfrom)
__SYSCALL_64(46, sys_sendmsg)
__SYSCALL_64(47, sys_recvmsg)

最终定位到sys_accept的入口:

SYSCALL_DEFINE3(accept, int, fd, struct sockaddr __user *, upeer_sockaddr,
		int __user *, upeer_addrlen)
{
	return __sys_accept4(fd, upeer_sockaddr, upeer_addrlen, 0);
}

接下来,控制流就进入网络栈进行处理了。更多内容请移步网络栈专题(一)——应用层和套接字层

发表评论

您的电子邮箱地址不会被公开。