本篇是《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. 在用户空间
像accept、listen等等函数,都是在用户空间实现,即库函数。它们都通过系统调用实现功能。
以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);
}
接下来,控制流就进入网络栈进行处理了。更多内容请移步网络栈专题(一)——应用层和套接字层。