Understanding Linux Network Internals
这只是刚开始,下面的文字从整体上对linux进行了一个简单描述,接下来的日子我们将开始逐步深入进行学习,同时会穿插一些扩展的内容或自己的理解。
该文大部分内容都是接受一些中网络代码中常见的编程模式和技巧。
内核描述:
内核使用kmalloc和kfree来分配和释放内存。它们的使用方法与用户空间的函数malloc和free相同。若需要了解它们,可参考书籍: Linux Device Drivers (O’Reilly)
如果一个内核组件的分配和释放频繁发生,则相关内核组件的初始化函数(如路由子系统中的fib_hash_init函数)通常会分配一个特殊的内存缓冲以加速内存分配。
下面时一些需要内核维护内存缓冲的网络数据结构:
插口哦缓冲描述符
该缓冲由net/core/sk_buff.c中的skb_init 分配sk_buff结构。Sk_buff结构可能时网络子系统中分配和释放频率最高的数据结构。
邻居协议映射
它使用内存缓冲来分配neighbour结构,该结构保存L3到L2的地址映射关系。
路由表
路由代码使用两个内存缓冲来分配两种数据结构,它们定义了路由表。
下面时使用内存缓冲时会用到的一些函数:
创建或销毁内存缓冲
kmem_cache_create
kmem_cache_destory
从内存缓冲中分配或释放一个对象
kmem_cache_alloc
kmem_cache_free
它们通常会在一个包装函数中被调用,该包装函数中更高的层次上处理分配和释放的请求。如:kfree_skb函数处理释放sk_buff的请求,但只有中所有对此结构的引用释放之后并且相关的子系统已执行了清除操作后,才调用kmem_cache_free释放这个sk_buff。具体的分配限制通常中kmem_cache_alloc的包装函数中知道,但有时也可通过/proc文件系统中的参数来调整。
关于内存缓冲的实现和slab分配器接口的详细信息,请参考Understanding the Linux
缓冲和哈希表
L3到L2映射的缓存(如IPV4中的ARP缓存),路由表缓存。
缓存的查找函数通常由一个输入值来说明中查找没有命中的情况下,是否需要配置一个新元素并把它加入到缓存中。而其他类型的查找只会把没有命中的元素添加进去。
缓存通常使用哈希表实现。 单向和双向的链表
如果哈希表的查找时间时一个子系统的关键,则就需要实现一个机制,通过增加哈希表的大小来减少平均的冲突几率,这样可减少平均的查找时间。也可以中其他子系统中,如neighboring layer,看到通过给键值增加一个随机变量使得哈希值可以均匀的分布。这样可以减少DoS(Denial of Service)的危害,因为该类DoS都是通过特定参数使得哈希表的表项都集中中同一个哈希值上
引用计数
大多数数据结构都会由一个引用计数,每访问一个数据结构,都会相应的增加或减少该数据的引用计数。相应的拥有这个数据结构的内核模块通常会导出两个增加和减少引用计数的函数。这些函数通常被命名为xxx_hold和xxx_release。
注:1.如果你释放了一个数据结构,但忘记调用xxx_release函数,内核就永远不会允许释放该数据结构,导致内存被逐步耗尽。
2.如果你引用来一个数据结构,但忘记调用xxx_hold函数,而刚好你是该数据结构的唯一引用者,这时的危害更大,如果你者后续操作中引用该结构,将可能导致其他数据被破坏或导致内核崩溃。
垃圾收集机制
异步:与具体事件无关。它定时去检查一组数据结构并把可以释放的数据结构释放掉。判断释放可以释放的条件依赖于子系统的功能和内部逻辑,但前提是引用计数为0.
同步:内存短缺时,可立即执行一个垃圾收集函数。
函数指针和虚拟函数表(VFTS)
在定义数据结构时(对象),可以包含一组函数指针(方法)
stuct sock {
……
void (*sk_state_change) (struct sock *sk);
void (*sk_data_ready) (struct sock *sk, int byte);
……
};
使用函数指针的好处是中初始化对象时,可根据对象的不同或对象作用的不同而把函数指针赋为不同的值。这样,同样是调用sk_state_change函数,就可以激活不同sock对象的不同函数。
在网络代码中,函数指针被大量的使用:
路由子系统,处理进入或发出包时,它会初始化sk_buff中两个函数指针。
当一个包准备好往网络设备发送时,它会被传递给net_device结构中的hard_start_xmit函数指针。这个指针中网络设备驱动和网络设备绑定时被赋值。
当L3的协议发送包时,它会调用一组函数指针中的一个。这些指针中L3的地址解析协议处理中被初始化。具体调用哪个函数要看它被初始化成哪个函数。
从上面得知,函数指针可以被用作不同内核组件间的接口;或是一个子系统中不同的条件下调用不同函数的机制;或是被用作允许不同的协议,驱动程序或功能可以使用各自不同的方法的机制。
当一个设备驱动向内核注册一个网络设备后,内核会执行一些与设备类型无关的函数。在某些点上,它会调用net_device结构中的一些函数指针来让设备驱动做一些事情。设备驱动可把这些函数指针初始化为自己的函数,也可把它置为NULL,这表示内核执行缺省函数即可。
中调用函数指针前,要先检查一下函数指针的值,以避免引用空指针。下面是一个简化后的例子:
register_netdevice:
if (dev->init && dev->init(dev) != 0) {
}
函数指针初始化与很多其他的因素相关:
如果函数指针的赋值与特定的数据有关,如协议标识或受到包的时某个特定的驱动程序。这种情况下较容易查找到真正的函数。如/drivers/net/3c59x.c驱动收到一个包,你可以中设备初始化函数中找到net_device结构中的函数指针被赋值为那些值。
如果函数指针的赋值与更复杂的逻辑有关,如L3到L2地址映射中的状态值。这种情况下,函数指针被赋为何值与外部事件相关,因而难以预测。
一组函数指针放到一个数据结构中,通常叫做一个虚函数表(VFT)。当它被用做两个子系统间的接口,如L3和L4间的接口;或被导出作为某个内核组件的接口(一组对象)时,它里面可能包含很多不同的指针,它们中不同的协议或功能中使用。
数组定义
struct abc {
int age;
char *name[20];
……
char placeholder[0];
}
在某些情况下,一个数据结构会中末尾包含一个可选的数据块。placeholder被定义成长度为0的数组。这意味着中给bac分配空间时,同时也分配了一个可选块,placeholder就是块的指针。如果不需要可选块,那它就只是一个指向结构末尾的指针,它不占用任何空间。如果abc中不同的代码中被使用,每个代码都使用相同的基本定义,同时可以根据自己的需求来扩展abc。
互斥操作
互斥,锁机制和同步。
中网络代码中常见的互斥机制:
自旋锁
同一时刻只能由一个线程占有。该锁一般用作多处理器系统中,且开发者都希望线程占有锁时间不会太长,因此占有锁的进程,执行过程不能睡眠。
读写自旋锁
如果一个锁的用户可明确分为只读和读写两类,推荐使用读写自旋锁。它与自旋锁的区别后者可由多个用户同时占有,但如果写者获得来锁,读者就不能获取这个锁。因为读者的优先级比写者高,因此这个锁适用于读者多,而写者少的情况。如果锁被读者得到,写者就不能获得;只有读者释放来锁,写者才能获得它。
读-拷贝-更新(RCU)
RCU是linux最新提供的一种互斥机制。它适合于:
读请求多而读写请求很少
获得锁的代码自动执行,且不会睡眠
被锁包含的数据结构通过指针访问
路由子系统使用RCU。它的查找次数比更新次数要多,且路由查找代码不会中途中被阻塞。
如果需要深入了解锁机制,可参考O’Reilly的Understanding the Linux Kernel 和 Linux Device Driver。
主机字节顺序和网络字节顺序的转换
字节在内存中存储由两种格式:小端格式(把最低字节存储中最低地址中),而大端格式刚好相反。
TCP/IP协议栈使用大端模型。
每次内核读写ip头超过一个字节变量时,它都必须首先将网络字节序转换为主机字节序或是相反。该原则同样适用于TCP/IP协议栈中的其他协议。
字节序转换函数:
htons: 主机序到网络序(short)
hton: 主机序到网络序(long)
ntohs: 网络序到主机序(short)
ntoh: 网络序到主机序(long)
这些宏定义放在include/linux/byteorder/generic.h头文件。
一些备注信息:
在本书描述TCP/IP协议栈时,分别用L2,L3,L4来代表链路层,网络层和传输层。
术语vector和array通常可以互相替换。
BH 底半处理 IRQ 中断
在文中,我们将了解到数据时怎样被协议栈带某一层带某个协议接收和发送的。在这种情况下,ingress和input时可以互相替换的。同样egress和output也适用。接收和发送数据两个动作可分别被简称为RX和TX。