第一部分 一般背景
第一章 简介
研究一个大型项目的源码就像进入一个陌生的新大陆,有它自己的风俗和不能用语言表达的期望。预先学会一些主要的习俗并试着与居民交流而不是站在后边看是有用的。
本章大量篇幅专注于向你介绍几种你在网络代码中常见的编程模式和技巧。
我鼓励你在可能的时候尝试通过用户空间工具与一个给定的内核网络代码交互。因此在本章,我会给你一些关于这些工具的线索,当它们没有被安装在你喜欢的Linux发布上,或者当你只是想更新到这些工具到最新版时你知道在哪里下载。
我也会描述一些工具可以让你优雅的找到穿过庞大的内核代码的路径。最后,我会简要的解释为什么一个内核特性不会被整合进官方内核发布中,即使它在Linux社团内被广泛应用。
1.1 基本术语
在本节,我会我会介绍一些本书中要广泛使用的术语和缩写。
在网络文献中八位数通常被称为octets。然而在本书中,我用更常见的术语byte。毕竟,本书描述内核的行为而不是一些网络抽象,并且内核开发者习惯按照字节来思考。
术语vector和array会被交替使用。
当我们提到TCP/IP网络堆栈的层时,我会使用缩写L2,L3,和L4分别来引用链路,网络,和传输层。其中数字是基于著名的(即使不是非常流行)七层OSI模型。在很多场合,L2是Ethernet的同义字,L3指IP的第4版和第6版,L4指UDP,TCP,或ICMP。当我需要引用特定的协议时,我会使用它的名字(例如,TCP)而不是通用的协议术语Ln。
在不同的章节,我们会看到数据单元是怎样被网络栈中不同层的协议接收和发送的。在那些情况下,术语ingress和input会被交替使用。egress和output也是这样。接收和发送一个数据单元的动作会通过缩写RX和TX来分别表示。
数据单元根据它被使用的层被赋予不同的名字,如帧(frame),包(packet),段(segment),消息(message)(更多细节参考13章)。表1-1总结了你在本书中会看到的主要缩写。
表1-1 本书常用的缩写
缩写 | 含义 |
L2 | 链路层(例如,Ethernet) |
L3 | 网络层(例如,IP) |
L4 | 传输层(例如,UDP/TCP/ICMP) |
BH | 后半部(Bottom Half) |
IRQ | 中断 |
RX | 接收 |
TX | 发送 |
1.2 常见代码模式
每个网络特性,像任何其它内核特性一样,只是内核中的一个公民。因此,它必须正确的公平的使用内存,CPU,以及所有其它共享资源。大部分特性不是独立的内核代码,而是在很大程度上根据特性与其它内核组件进行或多或少的交互。因此,它们尽可能的尝试追随相似的机制来实现类似的功能(没有必要每次都重新发明轮子)。
有些东西对一些内核组件是公用的,如都需要为同样的数据结构类型分配几个实例,都需要记录数据结构实例的引用来防止不安全的内存释放,等等。在下面的小小节,我们会看到在Linux中处理这种需求的常用方法。我也会谈到在浏览内核代码时你会遇到的常用的编码技巧。
本书使用子系统(subsystem)作为一个不精确的术语来描述一堆文件的集合,这些文件实现了如IP或路由以及要被同样的人维护和改变的一些主要特性。在本章的剩余部分,我们也会用术语内核组件(kernel component)来引用这些子系统,因为这里讨论的约定在内核的很多部分都有应用,不止是网络部分。
1.2.1 内存高速缓存
内核使用kmalloc和kfree函数分别来分配和释放内存块。这两个函数的语法和libc用户空间库的两个姐妹调用malloc和free很相似。更多kmalloc和kfree的细节,请参考Linux设备驱动(Linux Device Driver)(O’Reilly)。
一个内核组件为同样的数据结构类型分配几个实例是很常见的。当预计的分配和释放会频繁的发生时,相关的内核组件初始化程序(例如:路由表的初始化函数fib_hash_init)通常为内存分配准备一个特殊的内存高速缓存。当一个内存块被释放时,它实际上只是返还给分配它的高速缓存。
一些内核维护了专用高速缓存的网络数据结构的例子包括:
Socket缓冲描述符(Sockets buffer descriptors)
这个高速缓存,被net/core/sk_buff.c中的skb_init分配,用来分配sk_buff缓冲描述符。sk_buff可能是网络子系统中记录了最多的分配和释放的结构体。
邻居协议映射(Neighboring protocol mappings)
每个邻居的协议使用内存高速缓存来分配存储了L3到L2地址映射的数据结构。参看27章。
路由表(Routing tables)
路由代码为定义路由的两个数据结构使用两个内存高速缓存。参看32章。
这里是处理内存高速缓存的关键内核函数:
kmem_cache_create
kmem_cache_destroy
创建和销毁高速缓存
kmem_cache_alloc
kmem_cache_free
分配和归还缓冲到高速缓存。他们常常通过一个包装被调用,该包装管理高层的分配和释放请求。例如,使用kfree_skb释放一个sk_buff缓冲实例的请求只有在所有对该缓冲的应用都被释放并且所有必要的清理动作都被感兴趣的子系统(例如:防火墙)完成时才会以调用kmem_cache_free结束。
能够从一个高速缓存(已经存在)分配出的实例的数量限制通常由kmem_cache_alloc的包装来强制,并且有时是可以通过/proc里的一个参数进行配置的。
1.2.2 高速缓存与哈希表
使用高速缓存来提高性能非常常见。在网络代码中,有为L3到L2映射的高速缓存(如为IPv4使用的ARP高速缓存),为路由表的高速缓存,等等。
高速缓存查找例程常常使用一个输入参数,该参数说明当高速缓存没找到时是否应该创建一个新的元素并加到高速缓存里。其它查找例程总是简单的将缺少的元素添加进去。
高速缓存常常用哈希表实现。内核提供了一些数据结构,如单向和双向链表,可以为简单的哈希表用来做内存块。
处理hash到同样值的输入的标准方法是把它们放到一个链表里。遍历这个链表会比使用hash键来查找花费更长的事件。因此,最小化hash到同样值的输入的数量总是重要的。
当hash表(使用或没使用高速缓存)的查找时间成为所在的子系统的关键参数时,可以实现一个机制来增加hash表的尺寸,这样冲突链表的平均长度会下降,平均查找时间会改善。参看34章的“per-netmask哈希表的动态调整大小”小节的例子。
你也会发现子系统如邻居层(neighboring layer)添加一个随机组件(有规则变化的)到那个用来在高速缓存区的筒子里分布元素的键值上。这用来减少旨在将hash表的元素几种到单一的筒子的拒绝服务攻击(Denial of Service,DoS)的破坏。参看27章“高速缓存”小节的例子。
1.2.3 引用计数
当一块代码要访问已经被释放的数据节哦股时,内核不会很高兴,用户也很少会为内核的反应而高兴。为了避免这些低级问题,并是垃圾收集机制更简单有效(参看本章后便的“垃圾收集”一节),大部分数据结构保持一个引用计数。好的内核公民每次保存和释放对每个数据结构的引用时,相应的增加和减少该数据结构的这个引用计数。因为任何数据类型都需要一个应用计数,因此拥有该结构的内核组件通常导出两个函数,可以被用来增加和减少引用技术。这样的函数通常相应的叫做xxx_hold和xxx_release。有时释放函数用xxx_put来代替(例如:net_device结构的dev_put)。
然而我们只是喜欢假设内核里没有坏公民,开发者是人,因此不总会编写没有bug的代码。引用计数是一个避免释放仍被应用的数据结构的简单有效的机制。然而,它不总会完全解决问题。这是忘记对称的增加和减少的后果:
l 如果你释放了一个数据结构的引用但是忘记调用xxx_release函数,内核永远不会允许该数据结构被释放(除非另外一个有bug的代码恰巧错误的多调用了一次释放函数)。这导致逐渐的内存耗尽。
l 如果你获取一个数据就够的引用而忘记了调用xxx_hold,然后在稍后的某个时候你正好成为唯一引用者,该结构会因为你没有提前说明而被过早的释放。这个案例比上一个导致更大灾难;你下一次尝试访问该结构会破坏其它数据或者导致内核panic并立刻击垮系统。
当一个数据结构因为某些原因要被移除时,引用者会被显示通知该数据要移除以便他们可以优雅的释放他们的引用。这通过通知链来完成。参看第8章的“引用计数”小节的有意思的例子。
一个数据结构上的引用计数可以在以下典型时候增加:
l 在两个数据结构类型间有紧密的关系。在这种情况下,其中的一个常常维护一个初始化为第二个的地址的指针。
l 一个计时器(timer)被启动,该计时器的处理函数要访问该数据结构。当计时器触发时,在该结构上的引用计数被增加,因为你想做的最后一件事是在计时器期满前释放该数据结构。
l 链表或者哈希表的一个成功的查找会返回匹配的元素的指针。在很多情况下,返回值会被调用者使用来执行一些任务。因此,一个查找例程常常增加匹配元素的引用计数,并让调用者在必要的时候释放它。
当一个数据结构的最后的引用被释放时,它会因为不再需要而被释放, 但不是必须的。
新的sysfs文件系统的引入帮助使大部分内核代码更了解引用计数并在使用中保持一致。
1.2.4 垃圾收集
内存是一种共享的有限的资源,不能被浪费,特别是在内核中,因为内核不使用虚拟内存。大部分内核子系统都实现了某种垃圾收集来回收被不使用或失效的数据结构实例占有的内存。根据某种特性的需要,你会发现两种主要的垃圾收集类型:
异步的
这种类型的垃圾收集与特定的事件不相关。一个到期的计时器通常调用一个例程,该例程扫描一些数据结构并释放那些符合删除条件的。决定一个数据结构符合删除的条件依赖于子系统的特性和逻辑,但是一个常见的标准是空的引用计数的出现。
同步的
有这样的情况,当内存缺少时,不能等待异步垃圾收集计时器来立即触发垃圾收集。用来选择数据结构符合删除条件的标准没有必要和异步清理的一样(例如,他们可以更有侵略性)。参看33章的例子。
在第7章,你会看到内核管理者怎样回收被初始化例程使用并在他们执行完后不再需要的内存的。
1.2.5 函数指针和虚函数表(VFTs)
为获得一些面向对象语言的好处,同时编写清晰的C代码的方便方法是使用函数指针。在一个数据结构(对象)的定义里,你包含一系列函数指针(方法)。然后一些或所有对该结构的操作通过嵌入的函数来完成。C语言数据结构里的函数指针看起来像这样子:
struct sock {
...
void (*sk_state_change)(struct sock *sk);
void (*sk_data_ready)(struct sock *sk, int bytes);
...
};
使用函数指针的一个关键优势是他们可以根据不同的标准和对象扮演的不同角色初始化成不同的值。因此,调用sk_state_change对不同的sock对象会实际调用不同的函数。
函数指针在网络代码里被广泛应用。以下只是几个例子:
l 当一个进入的包或一个输出的包被路由子系统处理时,它初始化缓存数据结构中的两个例程。你会在35章看到这一点。参考第2章的sk_buff数据结构里的一个函数指针的完全列表。
l 当一个包在网络硬件上准备好被传输的时候,它被传递给net_device数据结构的hard_start_xmit函数指针。该例程被该设备关联的设备驱动程序初始化。
l 当一个L3协议想发送一个包时,它调用一系列函数指针中的一个。这些指针被与该L3协议相关联的地址转换协议初始化成一系列例程。根据函数指针被初始化的实际例程,会发生一个L3到L2的透明的地址转换(例如,IPv4包通过ARP转换)。当地址转换不必须时,一个不同的例程被使用。关于本接口的更详细的讨论参看第六部分。
我们在前面的例子中看到函数指针怎样被用来作为内核组件间的接口,或者根据不同的子系统所做的事情的不同,作为在合适的时候调用合适的函数处理器的通用机制。在一些时候,函数指针也可以作为一种简单的方法来使协议,设备驱动,或者任何其它特性来个性化一个动作。
让我们来看一个例子。当一个设备驱动向内核注册一个网络设备时,不论什么设备类型,它都要经过一系列需要的步骤。在某个时候,它会调用net_device数据结构上的一个函数指针来让设备驱动在需要的时候做些额外的事情。设备驱动可以初始化该函数指针为自己的一个函数,也可以让该指针为空,因为内核缺省步骤的行为已经足够了。
在执行之前检查函数指针的值来避免解引用NULL指针永远是必要的,就像在这个register_netdevice快照中显示的:
if (dev->init && dev->init(dev) != 0) {
...
}
函数指针有一个主要的缺点:它们使浏览代码变得有些困难。当浏览一个指定代码路径时,你会最终聚焦在一个函数指针的调用上。在这种情况下,在继续进行该代码路径前,你需要找出该函数指针是怎样被初始化的。这可以依赖于不同的因素:
l 当赋值给函数指针的例程的选择是基于一块特殊的数据,如处理数据的协议或者一个包来自的设备驱动,找到该例程是容易的。例如,如果一个给定设备被drivers/net/ 3c 59x.c设备驱动管理,你可以通过阅读设备驱动的设备初始化例程,看net_device数据结构的给定函数指针被初始化成哪个例程来来找到。
l 相反,当例程的选择是基于更复杂的逻辑时,例如L3到L2地址映射转换的状态,什么时候用哪个例程依赖于不能被预测的外部事件。
组织进数据结构里的一系列函数指针常常用虚函数表(VFT)来称呼。当一个VFT用来作为两个主要子系统如L3和L4协议层的接口时,或者当VFT作为通用内核组件(对象集合)的接口简单导出时,里边函数指针的数量会增大以包括很多不同的指针来适应各种的协议或其它特性。每种特性最终只会使用提供的众多函数中的少数几个。你会在第六部分看到一个例子。当然,如果VFT的这种用法过于极端(译注:指函数太多,但每次只用一小部分),它会变得很笨重以至于需要重新设计。
1.2.6 goto语句
个别C程序员喜欢goto语句。不用翻看goto的历史(那是一个计算机编程领域的长久的非常著名的论战),我会概括一些goto通常不建议使用,但在Linux内核中到处使用的原因。
任何使用goto的代码都可以不用goto而重写。使用goto语句会降低代码的可读性,并使调试更困难,因为在跟随goto到达的任何位置,你都不能明确的得出导致运行到该点的条件。
让我做个比喻:给定树上的任何节点,你知道从树根到该节点的路径是什么。但是如果你增加了藤蔓,随机的盘绕着树枝,在树根和其它节点之间,不会总是有唯一的路径。
然而,以为C语言不支持显式异常(并且它们在其它语言中也常常被避免,因为性能和编码复杂性),仔细的放置goto语句可以容易的跳转到处理不希望的或者特殊事件的代码。在内核编程时,特别是网络编程,这样的事件很常见,因此goto成为一个方便的工具。
我必须通过指出开发者从不滥用goto来为内核对goto的使用辩护。尽管有超过30,000次的使用,他们主要用在函数中处理不同的返回码,或者跳出超过一层的嵌套。
1.2.7 数组(vector)定义
在某些情况下,一个数据结构的定义在最后包含一个可选的块,这是一个例子:
struct abc {
int age;
char *name[20];
...
char placeholder[0];(译注:标准写法应该是placeholder[])
}
可选块由placeholder开始。注意placeholder被定义成一个0大小的数组。这意味着当abc被分配了可选块的时候,placeholder指向该块的开始。当不需要可选块时,placeholder只是一个指向该结构体结尾的指针;它不消耗任何空间(译注:sizeof(abc)==offsetof(abc, placeholder))。
因此,如果abc被几块代码使用,每块代码都可以在需要的时候为了个性化用不同的方法扩展abc,而且可以使用同样的基本定义(避免使用只有轻微不同的方法做同样的事情而带来混淆)。
我们在本书中会看到几次这种类型的数据定义。其中一个例子在第19章。
1.2.8 条件指示符(#ifdef和它的家族)
编译期的条件指示符有时是必要的。对它们的过度使用会降低代码可读性,但是我可以声明Linux没有滥用它们。它们有不同的原因出现,但是我们感兴趣的是那些用来检查内核是否支持某个特性的情况。配置工具如make,xconfig决定了是否该特性被编译进去,完全不支持,或者作为模块可加载。
使用#ifdef或#if defined C预处理器指示符进行特性检查的例子是:
在一个数据结构的定义中是否包含一个字段:
struct sk_buff {
...
#ifdef CONFIG_NETFILTER_DEBUG
unsigned int nf_debug;
#endif
...
}
在这个例子里,Netfilter调试特性需要sk_buff结构中的一个nf_debug字段。当内核对Netfilter调试(只被少数开发者需要)没有支持时,没有必要包含该字段,它只能在每个网络包中占用更多的内存。
在一个函数里是否包含一段代码:
int ip_route_input(...)
{
...
if (rth->fl.fl4_dst == daddr &&
rth->fl.fl4_src == saddr &&
rth->fl.iif == iif &&
rth->fl.oif == 0 &&
#ifndef CONFIG_IP_ROUTE_FWMARK
rth->fl.fl4_fwmark == skb->nfmark &&
#endif
rth->fl.fl4_tos == tos) {
...
}
}
路由高速缓存查找路由ip_route_input,在33章描述,只有当内核被编译成支持“IP:使用netfilter MASK值作为路由键值”特性时检查被防火墙设置的标记的值。
用来选择一个合适的函数原型
#ifdef CONFIG_IP_MULTIPLE_TABLES
struct fib_table * fib_hash_init(int id)
#else
struct fib_table * _ _init fib_hash_init(int id)
{
...
}
在这个例子里,指示符用来在内核对策略路由(Policy Routing)没有支持的时候给原型添加__init【注1】标签。
【注1】该宏的描述参看第7章
用来选择一个合适的函数定义
#ifndef CONFIG_IP_MULTIPLE_TABLES
...
static inline struct fib_table *fib_get_table(int id)
{
if (id != RT_TABLE_LOCAL)
return ip_fib_main_table;
return ip_fib_local_table
}
...
#else
...
static inline struct fib_table *fib_get_table(int id)
{
if (id == 0)
id = RT_TABLE_MAIN;
return fib_tables[id];
}
...
#endif
注意这个例子不同于前面的。在前面的例子里,函数体位于#ifdef/#endif块的外边,然而在这个例子里,每个块都包含了函数的全部定义。
变量和宏的定义或初始化也可以用条件指示符。
了解某些函数或宏有多个定义的存在是重要的,它们在编译期的选择像前面的例子那样基于预处理器宏。否则,当你查找一个函数,变量或宏定义时,你可能正在查看错误的那个。
参看第7章的一个讨论,关于怎样减少特殊宏的引入,在某些情况下,使用条件编译指示符。
1.2.9 条件检查的编译期优化
大部分时候,当内核将一个变量和一些外部值进行比较来看是否满足一个给定条件时,结果是很有可能是可预知的。这是很常见的,例如,使用强制完备性检查(enforce sanity check)的代码。内核使用likely和unlikely宏来分别封装更可能返回true(1)或false(0)结果的比较。这些宏利用gcc编译期的一个特性,该特性可以基于这些信息优化该代码的编译。
这里有一个例子。让我们假设你需要调用do_something函数,在失败的时候,你必须使用handle_error函数处理它:
err = do_something(x,y,z);
if (err)
handle_error(err);
在假设do_something很少失败的情况下,你可以像下面这样重写代码:
err = do_something(x,y,z);
if (unlikely(err))
handle_error(err);
一个可能使用likely和unlikely宏优化的例子在处理IP头选项那里。IP选项的使用被限制在非常特别的情况,内核可以安全的假设大部分IP包没有IP选项。当内核转寄一个IP包的时候,它需要依照18章描述的规则处理选项。转寄一个IP包的最后阶段是被ip_forward_finish处理。这个函数使用unlikely宏来封装检测是否有IP选项要被处理的条件判断。参看第20章的“ip_forwad_finish函数”节。
1.2.10 互斥
加锁在网络代码中被广泛的使用,并且你很可能在本书的每一章都看到它作为一个问题出现。互斥,加锁机制,以及同步在很多类型的编程中是常见的,也是非常有意思并复杂的话题,特别是内核编程。经过多年,Linux已经懂得引入和优化几种互斥的方法。因此,本节只是概述在网络代码中可以看到的加锁机制;我建议你参考O’Reilly的《深入理解Linux内核》和《Linux设备驱动》里的高质量,详细的讨论。
每种互斥机制都是在特定环境下的最好选择。这里有一个关于你在网络代码中会经常看到的可选的互斥方法的简单概括:
旋转锁(Spin locks)
这是一个在某一时刻只能被一个运行线程拥有的锁。另外一个运行线程请求该锁的尝试会导致一个循环,直到锁被释放。因为循环导致的浪费,旋转锁只在多处理器系统里使用,并且通常只在开发者期望该锁被短时间拥有时。也因为对其它线程导致的浪费,一个执行线程绝对不能在拥有一个旋转锁的时候睡眠。
读-写旋转锁(Read-Write spin locks)
当一个给定锁的使用可以被清晰的分为只读和读写时,使用读写旋转锁成为首选。旋转锁和读写旋转锁间的不同是,在后者中,很多读者可以同时拥有该锁。然而,在一个时间只能有一个写者可以拥有该锁,并且当它已经被一个写者拥有时,没有读者可以请求它。因为读者被赋予比写者更高的优先级,这种类型的锁在读者的数量(或者说只读锁请求的数量)远远大于写者的数量(或者说读写锁请求的数量)时工作良好。
当该锁被请求为只读模式,它不能被直接提升为读写模式:该锁必须被释放并重新请求成读写模式。
读拷贝更新(Read-Copy-Update,RCU)
RCU是Linux引入的一种提供互斥的最新的机制。它在一下特定条件下工作良好:
l 读写锁请求相对于只读锁请求来说非常少
l 拥有锁的代码被自动运行并不会睡眠
l 被该锁保护的数据结构通过指针访问。
第一中条件关系到性能,另外的两个是RCU工作原理的基础。
注意第一种条件会建议读写旋转锁的使用作为RCU的替代方法。为了理解为什么有RCU,什么使用它合适,什么时候比读写旋转锁工作更好,你需要考虑其它方面,如在SMP系统处理器高速缓存的效果。
RCU设计背后的原理简单强大。为了获得一个关于RCU的优势的清晰描述和它的实现的简单描述,参考它的作者,Paul McKenney,在Linux Journal上发表的一片文章(http://linuxjournal.com/article/6993)【注】。你也可以参考《深入理解Linux内核》和《Linux设备驱动》。
【注】更多的文档,你可以参考作者维护的以下URL:
http://www.rdrop.com/users/paulmck/rclock.
在网络代码中RCU被使用的例子是路由子系统。对高速缓存的查找比更新更频繁,并且实现路由高速缓存查找的例程在搜索中间不会阻塞。参看33章。
内核提供了信号量,但是它们在本书覆盖的网络代码中很少被使用。然而,一个例子是用来串行化配置改变的代码,我们会在第8章实际去看。
1.2.11 主机和网络字节序转换
多于一个字节的数据结构可以以两种不同的格式存储在内存中:小头和大头。第一种格式将最低有效字节放在最低内存地址,而第二种正好相反。像Linux这样的操作系统使用的格式依赖于使用的处理器。例如,Inter处理器遵照小头模型,而Motorola处理器使用大头模型。
设想我们的Linux机器从远程主机收到一个IP包。因为它不知道远程主机使用哪种格式,小头还是大头,被用来来初始化协议头部,它要怎样读取该头部呢?由于这个原因,每个协议族必须定义它使用什么“头”。例如,TCP/IP栈遵循大头模型。
但是这仍然给内核开发者留下一个问题:她必须编写能够运行在很多支持不同的格式的不同的处理器上的代码。一些处理器可能与接收包里的格式匹配,不需要转换成处理器使用的格式。
因此,每当内核需要读取,保存,或者比较IP头部的一个多于一个字节的字段时,必须首先将它从网络字节序转换成主机字节序,反之亦然。这同样被应用到TCP/IP栈的其它协议。当协议和本机都是大头时,转换例程简单的无动作,因为不需要任何转换。他们经常出现在使代码可移植的代码里;只有转换例程本身是平台相关的。表1-2列出了用来转换两字节和四字节字段的主要宏。
表1-2 字节序转换例程
宏 | 含义(short是2字节,long是4字节) |
htons | 主机到网络字节序(short) |
htonl | 主机到网络字节序(long) |
ntohs | 网络到主机字节序(short) |
ntohl | 网络到主机字节序(long) |
这些宏在通用头文件include/linux/byteorder/generic.h中定义。这里是每种结构体系怎样基于他们的格式制作这些宏:
l 每个结构体系在每个结构体系的目录/include/asm-XXX/下有一个byteorder.h文件
l 该文件根据处理器的格式包含include/linux/byteorder/big_endian.h或者include/linux/byteorder/little_endian.h。
l little_endian.h和big_endian.h都包含了通用文件include/linux/byteorder/generic.h。表1-2中的宏的定义基于不同的定义在little_endian.h和big_endian.h中的其它宏,这就是结构体系的格式怎样影响表1-2中的宏的定义的。
为每个表1-2中的宏xxx,有一个姐妹宏,__constant_xxx,当输入字段是一个常量时使用,例如是一个枚举列表的元素时(参看28章“ARP协议初始化”小节的例子)。
我们在本节前边说过当一个数据字段超过一个字节时格式是很重要的。当一个一个或多个字节的字段作为一个位字段的集合使用时格式同样重要。例如,看看在第18章的图18-2中IPv4头部看起来的样子,以及内核在include/linux/ip.h中怎样定义iphdr结构。内核分别在前边提到的little_endian.h和big_endian.h文件里定义__LITTLE_ENDIAN_BITFIELD和__BIG_ENDIAN_BITFIELD。
1.2.12 捕获Bug
一些函数被假定为在某种情况下会被调用,或者在一些情况下不会被调用。内核使用BUG_ON和BUG_TRAP宏来捕获这些情况不满足的情况。当输入给BUG_TRAP的条件是false时,内核打印一个警告消息。相反的BUG_ON打印一个错误消息并panics。
1.2.13 统计
一个特性收集某个特别条件发生的统计信息是一个好习惯,例如高速缓存查找成功和失败,内存分配成功和失败,等等。每个收集统计信息的网络特性,本书会列出并描述每个计数器。
1.2.14 测量时间
内核常常需要测量从一个给定的时刻开始过去了多少时间。例如一个进行CPU密集型任务的例程常常在一段给定的时间后释放CPU。当它被调度运行后会继续它的工作。即使内核支持抢占,这在内核代码里仍然特别重要。网络代码中一个常见的例子是由实现垃圾收集的例程提供的。本书中我们会看到很多。
内核空间过去的时间用ticks来测量。一个tick是两个连续计时器中断到期之间的时间。计时器处理不同的任务(我们现在对它不感兴趣)并且美每秒有规律是HZ到期。HZ是一个被结构体系相关代码初始化的变量。例如,它在i386上被初始化成1000。这意味着当Linux运行在i386系统上时计时器中断每秒到期1000次,也就是在两个连续到期之间有1毫秒。
每次计时器到期它会增加一个叫做jiffies的全局变量。这表示在任何时候,jiffies表示从系统启动到现在的tick数量,并且通用值n*HZ表示n秒时间。
如果一个函数的所有需要只是测量过去的时间,它可以将jiffies保存在一个局部变量里,稍后将jiffies与该时间戳的距离与一个时间段(表示成ticks)进行比较来查看自从开始测量过去了多少时间。
下面的例子演示了一个函数,它要做某种工作但是不像占用超过一tick的CPU时间。当do_something通过将job_done设置成一个非0值来说明工作已经完成时,函数返回:
unsigned long start_time = jiffies;
int job_done = 0;
do {
do_something(&job_done);
If (job_done)
return;
while (jiffies - start_time < 1);
使用jiffies的真正的内核代码的几个例子,参看第10章的“”小节,或者第27章的“”小节
1.3 用户空间工具
不同的工具可以被用来配置Linux上的很多网络特性。像本章开头提到的,你可以为了学习的目的并且为了发现这些变化的影响仔细的使用这些工具来操作内核。
以下工具是在本书中我常常提到的:
iputils
除了常用的ping命令,iputils包括arping(用来产生ARP请求),网络路由器发现daemon(Network Router Discovery daemon)rdisc,以及其它命令。
net-tools
这是一个网络工具套件,你会在里边找到著名的ifconfig,route,netstat,以及arp,还有ipmaddr,iptunnel,ether-wake,netplugd,等等。
IPROUTE2
这是新一代的网络配置套件(尽管它已经出现几年了)。通过一个综合性的叫做ip的命令,该套件可被用来配置IP地址和路由及所有高级特性,邻居协议,等等。
IPROUTE2的源码可以从http://linux-net.osdl.org/index.php/Iproute2下载。其它包可以从大部分Linux发布的下载服务器下载。
这些包被缺省包含进大部分(如果不是所有的话)Linux发布中。任何你不能理解内核代码怎样处理一个来自用户空间的命令时,我鼓励你查看用户空间工具的源码,看看来自用户的命令怎样被打包并发送给内核。
在下面的URLs,你可以找到关于怎样使用上述工具的好文档,包括活动的邮件列表:【注】
【注】我在本书中不会涉及防火墙基础设施设计,但是我常常在分析各种网络协议和层时展示防火墙钩子的位置。
· http://www.policyrouting.org
如果你想追随网络代码的最新修改,关注下面的邮件列表:
The Linux Network Development List Archives (http://oss.sgi.com/projects/netdev/archive)
其它更多特别的URL会在相关章节提供。
1.4 浏览源码
Linux内核已经变得非常庞大,使用我们的老朋友grep浏览代码不再是好主意。现在你可以依靠不同的软件来使你的内核代码之旅有更好的体验。
我要推荐的一个工具是cscope,给那些还不知道它的人,你可以从http://cscope.sourceforge.net下载它。它是一个简单而强大的工具,可以搜索如函数或变量的定义,它们在哪被调用等等。安装该工具是简单的,你可以在网站上找到所有必要的指导。
我们每个人都有他喜欢的编辑器,并且可能我们的大多数是某种形式的Emacs或vi的fans。两种编辑器都可以使用一种叫做“tags”的特殊文件,来让用户在源码间跳转。(cscope也使用一个类似的数据库文件)。通过使用内核代码树根下的makefile里的同名目标,你可以容易的创建这些文件。这三个数据库:TAGS,tags,和cscope.out,可以分别的用make TAGS,make tags,和make cscope【注】创建。
【注】tags和TAGS文件在ctags工具的帮助下创建
注意这些文件非常大,特别是被cscope使用的那个。因此,确保在创建文件前你的硬盘有足够的剩余空间。
如果你已经使用了其它源码浏览工具,很好。但是如果你还没有使用,并且足够懒,那么是该跟grep说再见的时候了,并且花费15分钟来学习怎样使用上述的工具,很值得。
1.4.1 废弃代码
内核想任何其它大型的有很多变动部分的软件一样,包含多块不再使用的代码。不幸的是,你很少在该代码中看到有注释告诉你这一点。有时你会发现你想理解一个函数是怎样使用的或者一个变量是怎样初始化的很难,只是因为你在看废弃代码。如果你幸运,该代码啊没有编译并且你可以猜测它出于废弃状态。有时你没有那么幸运。
每个内核子系统按说都指派了一个或多个维护者。然而,一些维护者有太多的代码要看,没有足够的时间来做这件事。有时他们可能已经对维护他们的子系统失去了兴趣,但是找不到人接替他们的工作。因此当看到一些看起来做些奇怪事情的代码或者不符合通常的编程规则的代码时,记住这些是有好处的。
在本书中,我尽量在任何有意义的时候来提醒你一些不用的函数,变量,以及数据结构的字段,可能因为他们是在移除某个特性时遗留下来的,或者是因为是为某个新的没有完成编码的特性而引入的。
1.5 当一个特性以补丁方式提供时
内核网络代码在持续发展。它不但整合进新的特性,已经存在的组件有时也会经历获得更好模块化和更高性能的设计变化。这点明显的使Linux作为网络应用产品(路由器,交换机,防火墙,负载均衡器,等等)的嵌入式操作系统很有吸引力。
因为每个人都可以为Linux内核开发新特性,或者扩展或重新实现一个存在的特性,任何“开源”开发者的最兴奋的事是看到她的工作被放到内核官方发布中。然而有时这是不可能的或需要很长事件的,即使当一个项目拥有有价值的特性并且被很好的实现。常见的原因包括:
l 代码没有遵照文档Documentation/CodeSytle中的指导方针去写
l 另外一个提供了同样功能的重点项目已经存在多时,并且已经从Linux社团和维护相关内核领域的关键内核开发者那里获得准许。
l 与其它内核组件有太多的重叠。对于这样的例子,最好的方法是移除冗余的功能并尽量使用现有的功能,或者扩展后者以便它能在新的环境中使用。这些条件强调了模块化的重要性。
l 项目的大小以及在一个频繁变化的内核里维护它所需要的工作量使得新项目的开发者使它保持为一个独立的补丁,并只在有时候发布一个新的版本。
l 该特性只会在特殊的场景下使用,被认为在通用目的操作系统中是不必要的。在这种情况下,一个独立的补丁常常是最好的方案。
l 整个设计没能使一些关键内核开发者满意。这些专家脑子里通常有一幅大图,关于内核的情况和以后的发展。通常,他们要求设计变化带来的特性妥当的适合内核。
有时,特性间的重叠很难完全移除,或许,例如,因为一个特性过于灵活,以至于它的不同的使用只有在一段时间后才能体现出来。例如,防火墙在网络栈的几个地方都有钩子。这使得其它特性没有必要实现任何过滤或标记任何方向的数据包:他们可以仅依赖防火墙完成。当然,这导致了依赖(例如,如果路由子系统想标记符合某种标准的通信量,内核必须包括对防火墙的支持)。而且,防火墙维护者必须准备接受合理的请求,如果这些请求被认为是其它内核特性需要的。然而,妥协常常是值得的:更少的冗余代码意味着更少的bug,更容易的代码维护,更简化的代码路径,以及其它好处。
最近的一个清理特性重叠的例子是移除在 2.6 内核中路由代码支持的无状态网络地址转换( Network Address Translation , NAT )。开发者意识到防火墙支持的有状态 NAT 更灵活,因此不再值得维护无状态 NAT 代码(尽管它更高快并占用更少内存)。注意新的模块可以在任何有必要提供无状态 NAT 支持的时候为了 Netfilter 而被编写。