文章目录
- 前言
- 简介
- 基础术语
- 常见的编码模式
- 内存缓存
- 缓存和hash表
- 引用计数
- 垃圾收集
- 函数指针和虚拟函数表(VFT)
- goto语句
- 向量(数组)定义
- 条件指示指令(#ifdef及其系列指令)
- 条件检查的编译期间最优化
- 互斥
- 主机和网络之间的字节次序转换
- 捕获BUG
- 统计数据
- 测量时间
- 用户空间工具
- 浏览代码
- 死代码
- 当功能以补丁的形式提供时
- 链接
前言
Hello,各位小伙伴们大家好。
出于工作的需要以及对Linux的浓厚兴趣,决定从今天开始对《深入理解Linux网络技术内幕》
这本数进行学习。
写这个博客的目的也是记录自己的学习笔记和自己的学习心得,如果大各位小伙伴也有兴趣,欢迎一起学习和讨论。
关于本书的前言以及介绍这里就不再赘述,直接进入本书的第一章。
简介
对于大型项目源码的研究,就像进入一个陌生新奇的领域,有其习惯和不能言表的期待。事先学习一些主要的约定,并尝试和几种组件互动而不是仅站在旁边观察,肯定会对你有所帮助。
本章主要介绍一些在网络代码中经常遇到的通用编程模式和技巧。
可能的话,希望你利用一些用户空间工具(user-space tool)尝试与内核网络代码中的特定的部分进行交互。如果你首选的Linux发行套件中没有安装这样的工具,或者你只是像将其更新成最新的版本,本章可以提示你取哪里下载这些工具。
这里也会介绍一些工具,让你以优雅的方式探索庞大的内核代码。最后也会简要的说明为什么某些Linux社区中广为使用的内核特性并没有集成到正式内核代码版本中的原因。
基础术语
在网络文献中,八个位的量通常称为八位组(octet)
。然而,本书中使用更为常见的术语字节(byte)
。毕竟,本书是描述内核行为,而不是某些网络抽象概念,并且内核开发人员都习惯用字节来思考。
术语向量(vector)和数组(array)是交替使用的
。
当涉及TCP/IP网络协议栈的分层时,使用L2、L3和L4分别表示链路层(link layer)、网络层(network layer)以及传输层(transport layer)
。这些数基于著名的(现在已经不确切)七层OSI模型。多数情况下,L2为Ethernet的同义词,L3指IPv4或IPv6,L4是指UDP、TCP或ICMP。当需要专指特定协议时,会使用其名称如(TCP),而不用通称的Ln协议的术语。
数据单元的接收或传输的行为分别用缩写为RX和TX表示。
数据的单元的名称根据其所在层的不同而不同,如帧(frame)、封包(packet)、段(sagment)以及消息(message)等。
主要的缩写如下
常见的编码模式
与其他内核功能类似,每种网络功能只是内核各种之一。因此,也必须适当而公平地使用内存、CPU以及其他各种共享资源。多数功能在内核中并非独立地代码片段,而是根据该功能而定,或多或少会和其他内核组件交互。因此,类似地功能都会尽可能采用类似的机制实现。
有些内核组件有一些共同的需求,如必须分配同一种数据结构类型的几个实例,必须记录某种数据结构实例的引用(reference),以避免不安全的内存回收(deallocation),诸如此类。后面几小节中,我们将说明Linux处理这类需求的常见做法,还将介绍在浏览内核代码时可能碰到的常见编码技巧。
内存缓存
内核分别使用kmalloc
和kfree
函数分配和释放一个内存块。这两个函数的语法,类似于另外两个来自于libc
用户空间库的姊妹函数malloc和free
的调用。有关于kamlloc和kfree的详细细节,可以参考《Linux设备驱动程序》
一书。
内核组件为同一种数据结构类型分配几个实例(instance)时很常见的事。当分配和回收经常发生时,相关联的内核组件初始化函数(例如,路由表的fib_hash_init)通常会分配一块特殊的内存缓存,以作分配之用。当一个内存块被释放时,实际上时返回到当初被分配的同一个缓存区。
内核维护的其专属内存缓存的一些网络数据结构的例子包括:
套接字缓冲区描述符
这个缓存是由net/core/sk_buff.c的skb_init分配的,用于分配sk_buff缓冲区描述符。sk_buff结构可能是网络子系统种分配和回收注册次数最高的。
邻居协议映射
每个邻居协议都使用一个缓存区,以分配存储L3层到L2层地址映射的数据结构。(参见第二十七章)
路由表
路由代码使用两块缓存,用于定义路径的两个数据结构
用于处理内存缓存的关键内核函数:
kmem_cache_create
kmem_cache_destroy
创建和销毁一个缓存
kmem_cache_alloc
kmem_cache_free
为缓存分配及回收一个缓冲。通常由调用包裹函数(wrapper)
实现分配和回收一个缓存缓冲区,包裹函数在较高层中管理用于处理分配和回收的请求。例如,用kfree_skb要求释放一个sk_buff缓冲区的一个实例时,只要当所有对缓冲区的引用都已经释放,而且相关的子系统(例如防火墙)也把所有必要的清理工作都做完后,才会调用kmem_cache_free。
给定缓存区(存在时)所能分配的实例数目的限制,通常由内含kmem_cache_alloc的包裹函数决定,但是有些时候可以通过/proc
里的参数进行配置。
关于内存缓存的实现以及其和厚片分配器(slab allocator)之间的接口细节,可以参考《深入理解Linux内核》一书。
缓存和hash表
使用缓存以提高性能时很常见的事。在网络代码中,L3层到L2层之间的映射(如IPv4所使用的ARP缓存)以及路由表等都使用缓存。
缓存查询函数通常有一个输入参数,指出发生缓存不命中(cache miss)时,是否应该在缓存中创建并添加一个新的元素。而没有这个参数时,查询函数则随时会把不命中的元素新增进来。
缓存通常采用散列表(即hash表)实现
。内核提供一组数据类型,如单向和双向列表,用来构建简单的hash表。有些输入经hash后得到相同的值,其标准处理方式就是将其放入一个列表中。查询这个列表所化的时间,比采用hash关键字(key)查询所花的时间长的多。因此,把hash值相同的输入值数目缩小到最小一直都是很重要的事。
当hash表的查询时间(无论是否使用缓存)是拥有者子系统(owner subsystem)的关键参数时,可以实现一种机制,以增加hash表的大小,使得冲突列表(collision list)的平均长度得以下降,而平均查询时间得以改善。
引用计数
当一段代码试图访问一个已被释放的数据结构时,内核并不欢迎,而大多数用户对于内核的这种反应也会感到不愉快。为了避免这类讨厌的问题,同时让垃圾收集机制更为容易而且有效率多数数据都会保存引用计数(reference count)
。对每个数据结构的每一次存储和释放,良好的内核组件都会分别递增和递减该数据结构的引用记数。任何数据结构若需要引用计数,拥有该结构的内核组件通常会提供两个函数,用以递增和递减引用计数值。这类函数通常分别称为xxx_hold和xxx_release。有时候,释放函数被称为xxx_put(例如,net_device结构的释放函数就是dev_put)。
虽然我们都希望内核中没有不良组件,到那时开发人员也是人,因此不可能永远写出无缺陷的代码。引用计数的运用是一种简单但有效率的机制,可以避免释放仍在被引用的数据结构。然而,因其忽略了平衡递增量和递减量,因此并不能总是完全解决问题:
- 如果你释放了一个对数据结构的引用,但却忘记了调用xxx_release函数,内核将永远不会让该数据结构被释放掉(除非另一段出错的代码碰巧又错误地调用多调用了一次释放函数!)。这样会导致内存逐渐耗尽。
- 如果你引用了一个数据结构,但却忘记了调用xxx_hold,后来你碰巧又成为唯一的引用持有者,结果该结构就会提早被释放(因为你没有说明)。这种情况肯定比前面一种更具灾难性:下次你试图访问该结构时,可能会破坏其他数据,或引起
内核恐慌(panic)
,使得整个系统瞬间崩溃。
当某个数据结构因某种原因被删除时,可以明确通知引用持有者该数据结构即将消失,使其能按规则释放其引用,而这是通过通知链实现的。(后面会有例子)
- 两种数据结构类型之间有很密切的关系。在这种情况下,其中的一个结构通常会维护一个被初始化为第二个结构的地址的指针。
- 一个定时器启动,而定时器的处理程序将访问该数据结构。当定时器启动时,该结构的引用计数值就会递增,因为你最不愿意看到的是,在定时器到期前该数据结构就被释放了。
- 对列表或hash表的成功查询会返回一个指向匹配元素的指针。多数情况下,返回的结果被调用者用来执行某种任务。因此,常见的做法是,查询函数递增匹配元素引用计数值,然后在必要时再让调用者予以释放。
当一个数据结构的最后一个引用也被释放时,该数据结构就可被释放,因为再也用不到该数据结构了,不过不一定非要这么做不可。
新引入的sysfs文件系统
有助于产生内核的一部分良好代码,运用它可以更关注引用计数和一致性。
垃圾收集
内存是有限的共享资源,不应该浪费,特别是在内核中,因为内核不是使用虚拟内存。多数内核子系统会实现某种垃圾收集,以回收由未使用或无效的数据结构实例化所持有的内存。根据特定功能所需而定,你会发现有两种主要的垃圾收集:
异步
这种垃圾收集类型和特定的事件无关。一个定时器会定期启动一个函数,以扫描一组数据结构,然后把那些适合删除的数据结构释放掉。数据结构符合删除条件依赖于该子系统的功能和逻辑,但是,一个常见的准则为是否存在null(空)引用计数。
同步
在内存不足时的情况下会立即触发垃圾收集,而不能等待定时器触发的异步垃圾收集。用于选取符合删除的数据结构的准则,不一定与异步清理机制所用的准则相同(例如,准则更为积极)。
函数指针和虚拟函数表(VFT)
函数指针是一种方便的方式,可以写出简洁的C代码,又能利用面向对象语言的某些优点。在数据结构类型(对象)的定义中,你可以包含一组函数指针(方法)。于是,该结构的某些或全部的操作都可通过嵌入的函数完成。C语言的函数指针在数据结构中类似如下所示
struct sock{
...
void (*sk_state_change)(struct sock *sk);
void (*sk_data_ready)(struct sock *sk, int bytes);
...
}
使用函数指针
最主要的优点就是,可以根据不同准则以及该对象所扮演的角色进行初始化。因此,调用sk_state_change时,实际上可能会为不同的sock套接字而调用不同的函数。
函数指针在网络代码中广为使用。下面只是很少的例子:
- 当入口数据封包或出口数据封包由路由子系统处理时,会对缓冲区数据结构中的两个函数做初始化。
- 当数据包已准备好在网络硬件上传输时,就会交给net_device数据结构的hard_start_xmit函数指针。该函数由该设备所关联的设备驱动程序进行初始化。
- 当L3协议想传输数据封包时,会调用一组函数指针中的一个。这些函数指针由该L3协议相关联的地址解析协议(ARP)负责初始化为一组函数。根据函数指针初始化的实际函数,可以产生透明化的L3层到L2层的地址解析。当不需要地址解析时,会调用另一个函数。后面进行分析。
通过上面的例子可知,函数指针可作为内核组件之间的接口,或者作为一种通用的机制,根据不同子系统所做的某事的结果,在适当的时机调用适当的函数处理程序。在其他情况下,函数指针也可作为一种简单方式、使协议、设备驱动程序或任何其他功能得以让某种动作个别化。
下面分析一个例子
当设备驱动程序在内核注册网络设备是,无论是何种类型的设备,都需要经过一系列步骤。到了某一时刻,就会对net_device数据结构调用一个函数指针,使设备驱动程序在必要时做些其他事情。设备驱动程序既可以把该函数初始化成其拥有的一个函数,也可以让该指针保持为NULL,因为内核默认的执行步骤已经足够了。
执行一个函数指针之前,必须始终检查其值,以避免提取NULL指针所指向单元的值的事情发生,例如,register_netdevice
中的片段:
if(dev->init && dev->init(dev) !=0)
{
....
}
函数指针有一个主要的缺点
:使阅读代码稍显困难。
沿着给定的代码路径读下去,最后的关注焦点可能就是函数指针调用。在这种情况下,继续沿着代码路径读下去之前必须搞清楚该函数指针是如何初始化的。这可能取决于一些不同的因素:
- 当选择函数分派给函数指针是基于特定数据片段时(如处理数据的协议,或者接收的给定数据封包所来自的设备驱动程序),就比较容易推导出该函数。例如,如果给定设备是由drivers/net/3c59x.c设备驱动程序管理,就可以阅读该设备驱动程序提供的设备初始化函数,从而导致分派给net_device数据结构的特定函数指针的函数。
- 当函数的选择是基于更为复杂的逻辑时,如L3层到L2层地址映射解析的状态,任何时刻所用的函数都会依赖于无法预测的外在事件。
指向一个数据结构的一组函数指针通常就称为虚拟函数表(virtual function table,VFT)
。当一VFT作为两个主要子系统之间的接口时,如L3与L4协议层之间,或者当VFT只是作为输出成为通用地某个内核组件(一组对象)的接口时,数据结构中的函数指针数目可能会膨胀,以包含许多不同指针,从而适应各种各样的协议或其他功能。每种功能最后可能只会用到所提供的众多函数中的一小部分而已。
goto语句
很少有C程序员喜欢goto语句。不谈goto的历史(计算机程序设计领域历史最久,最有名的争论)这里将总结一些goto通常被废弃不用,而Linux内核仍然使用它的原因。
任何使用goto语句的代码片段都能被改写成不使用该语句。使用goto语句会使代码的可读性降低,调试困难。因为对于任何goto之后的语句,你都无法明确推导出其执行的条件。
做一个类比:一棵树无论那个节点,你都知道从根节点到该节点的路径。但是如果随机地把藤蔓缠绕到一些分枝上,根节点与其他节点之间就一定只有唯一的路径了。
然而C语言没有提供显示异常事件(其他语言因为性能损失和编码复杂度的缘故,通常也不提供显示异常事件)谨慎使用goto语句可以轻松跳到处理未预料或特殊事件的代码。在内核程序设计中,特别是网络程序设计中,这类事件很常见,因此,goto语句变成了一个方便的功能。
在为内核使用goto语句辩解时,我必须指出一点,开发人员绝不是用到无法无天的地步。尽管有3000个以上的实例,但主要都是用于处理函数内的不同返回代码,或者用于跳出一层以上的嵌套。
向量(数组)定义
在某些情况下,数据结构定义的末端会包括一个可选的区块。以下是一个实例。
struct abc{
iint age;
char *name[20];
...
char placeholder[0];
}
可选区块从placeholder开始。注意,placeholder定义为大小0的向量。也就是说,当abc被分配为带有可选区块时,placeholder就指向此区块的起始处。不需要可选区块时,placeholder就指向此区块的起始处。不需要可选区块时,placeholder就只是一个指向此结构尾端的指针而已,不耗任何空间。
因此,如果abc被好几段代码所用到,每段代码都可以使用相同的基本定义(避免各自以略微不同的方式做同一件事而造成混淆),但是又可以根据其需求不同方式将abc的定义予以个别化。
(我也没咋明白其作用,后面会有例子)
条件指示指令(#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只有在内核编译支持 “IP:use netfilter MARK value as routing key”功能时,才会检查由防火墙所设的标记值。
为函数选择适当的原型
#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)时,这些指示用于把__int标志加至原型。
为函数选择适当定义
#idndef 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 ifb_table *fib_get_table(int id)
{
if(id == 0)
id = RT_TABLE_MAIN
return fib_tables[id];
}
...
#endif
注意这种情况与前一种情况的不同之处。在前一种情况中,函数位于#ifdef/#endif块之外,而现在这种情况中,每个块都包含一个完成的函数定义。
变量和宏的定义或初始化也可以使用条件编译。
知道某些函数或宏存在许多定义是很重要的。与前面的例子类似,这些函数或宏定义,由预处理宏程序在编译期间决定。否则,当你查看函数,变量或宏定义时,可能会看到错误的定义。
条件检查的编译期间最优化
多数情况下,当内核用某些外部值比较一个变量以了解是否满足给定条件时,其结果极有可能是可预测的。这是很常见的事,例如,那些强制健康检查(sanity check)的代码。内核分别使用likely和unlikely宏,进行返回真(1)或假(0)的包裹比较。这些宏会利用gcc编译器的一项功能,这个功能就是可根据该项信息使代码编译最优化
.
以下是一个实例。假设你需要调用do_something函数,调用失败时,你必须用handle_errror函数进行处理:
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报头(header)里的选项。IP选项的使用只限于一些特定情况,因此,内核可以安全地假设多数IP封包不会携带IP选项。当内核转发IP封包时,需要根据一些规则处理那些选项。转发IP封包的最后一个阶段由ip_forwad_finish负载。此函数使用unlikely宏把检查是否需要处理IP选项的条件包裹起来。
互斥
网络代码中广泛使用上锁(locking),在本书中每项主题之下,你可能都会发现上锁这一议题。对众多程序设计类型而言,尤其是针对内核的程序设计,互斥(mutual exclusion)、上锁机制以及同步都是一般性主题,而且相当有趣而复杂。近年来,Linux不但引入互斥,而且对一些方法做了优化。因此,本节只总结在网络代码中所见到的上锁机制。可以参考阅读《深入理解Linux内核》、《Linux设备驱动程序》
每种互斥机制都是特定环境下的最佳选择。以下是网络代码中常见的可选互斥方法的简洁摘要:
回转锁
(Spin lock)
这是一种在某一时刻只能由一个执行的线程(thread)所持有的锁。若试图获得由另一个正在执行的线程拥有的锁,则要进入循环等待,直到该锁被释放为止。由于进入循环将造成浪费,回转锁只用于多处理系统中,并且通常只用于开发人员预期该锁只会被短期持有的时候。此外,由于会引起其他执行线程的浪费,执行线程在持有回转锁时不能休眠。
读-写回转锁
当给定锁的使用可以明确分为只读和读-写时,应该先使用读-写回转锁。读回转锁和读-写回转锁的差别在于,对读-写回转锁而言,多个读取者可以同时持有该锁。然而,在同一时刻该锁的持有者只能有一个人可以写入,同时,该锁被一个写入者持有时,读取者都不能取得该锁。由于读取者的优先级高于写入者,因此该读取者的书面(或者取得只读锁的数目)远远超过写入者的数目(或者取得读-写锁的数目)时,这种类型的锁能很好地工作。
当该锁是在只读模式下取得时,不能直接提升成读-写模式:该锁必须被释放,再以读-写模式取得。
读取(拷贝)更新(Read-Copy-Update,RCU)
RCU是Linux提供互斥的最新机制,在下列特定条件下工作的非常好:
- 与只读锁的请求相比,读-写锁的请求很少见。
- 持有该锁的代码以原子形式执行,而且不能休眠。
- 由改锁保护的数据结构是通过指针访问的。
第一个条件涉及性能,而另外两个条件则是RCU工作原理的基础。
注意,第一个条件似乎意味着应该使用读-写回转锁来代替RCU。为了了解恰当。
当使用时,为什么RCU比读-写回转锁工作的更好,你需要考虑其他方面,如SMP(symmetric multiprocessing,对称式多重处理)系统上处理器缓存地影响。
RCU在网络代码中的使用实例就是路由子系统。在缓存中查询比更新更要频繁,因此,实现路由缓存查询的函数不会在搜索期间被堵塞。
内核也提供信号量(semaphore)但是本书所涉及的网络代码中很少使用。然而,用于把配置变更序列化(serialze)的代码就是使用信号量的例子。
主机和网络之间的字节次序转换
超过一个字节以上的数据结构可以采用两种不同格式存储于内存中:
小端
:低地址存放数据的低位
大端
:低地址存放数据的高位
像Linux这类操作系统所用的格式依赖于所用的处理器。例如,Intel处理器遵循的是小端模式,而Motorola处理器支持的是大端模式。
假设我们的Linux机器从一台远程主机接收到一个IP封包。由于Linux不知道远程主机初始化协议报头时所用的格式是小端还是大端,那么Linux如何读取报头?出于这个原因,每个协议族都必须定义其所用的字节序列。例如TCP/IP协议栈采用大端模式。
但是,这样仍然会给内核开发人员留下一个问题:他所写的代码必须能在众多支持不同字节序列模式的处理器上工作。有些处理器可能与入口封包是同一字节序列模式,但是,如果字节序列模式不同,就必须要转换成一致的。
因此,每次内核需要读取、保存或比较超过一个字节的IP报头字段时,首先就必须将网络字节序转换成主机字节序,或者相反。TCP/IP协议栈的其他协议也是如此。当协议与本地主机都是大端时,转换函数就是空操作,因为没必要做任何转换。但是,转换函数还是会始终出现在代码中,为的是让代码可移植;只有转换函数本身是与平台相关的。
下表列出了用于两个字节和四个字节的字节序转换的主要宏。
在这里插入图片描述
捕获BUG
有一些函数假定在特定条件下被调用,或者假定在特定条件下不被调用。内核使用BUG_ON和BUG_TRAP宏捕获引起这类条件不满足的地方
。当传给BUG_TRAP的输入条件为假时,内核会打印出警告信息(warning message)。对于BUG_ON则打印出错误消息,然后内核panic。
统计数据
收集特定条件下发生的统计数据的功能是一种良好的习惯,如缓存查询成功和失败次数、内存分配成功和失败次数等等。对于每种收集统计数据的网络功能,本书会列出每个计数器,并给出描述。
测量时间
内核经常需要测量自某一时刻其已经过了多少时间。例如,一个进行CPU密集运算(CPU-intensive)任务的函数,通常会在特定时间之后释放CPU。当其被重新调度而执行时,就会继续其工作。即使内核支持内核抢占(kernel preemption),在内核代码中,这一点也特别重要。网络代码中常见的例子就是实现垃圾收集函数。本书中会看到很多类似的函数。
内核空间中时间的流逝采用滴答(tick)
来计算。一个滴答就是两个连续到期的定时器中断之间的时间长度。定时器会负责各种不同任务(对此我们没兴趣),而且每秒会固定到期HZ次。HZ是一个变量,由体系依赖代码进行初始化。例如,在i386机器上,它被初始化每秒1000次。也就是说,当Linux在i386系统上运行时,定时器每秒中断1000次。因此,两次连续到期中断之间的时间长度就是一毫秒。
每次定时器到期时,就会使一个名为jiffies的全局变量递增。也就是说在任何时刻,jiffies代表的是从系统引导后所经过的滴答次数,而通过值n*HZ代表的就是n秒时间。
如果一个函数所需做的工作仅是测量经过的时间,就可以把jiffies的值保存在一个局部变量,稍后再拿jiffies和当前时间戳做对比,得到一段时间间隔(以滴答数表示),以得知从测量时刻起经过了多少时间。
下面的例子给出了一个函数,该函数必须做某种工作,但又不想让其持有CPU的时间超过一个滴答。当do_something把job_done设为非0值时,表示工作已经完成,则此函数可返回。
extern unsigned long jiffies;
unsigned long start_time = jiffies;
int job_done = 0;
do{
do_something(&job_done);
if(job_done)
return ;
}while(jiffies - start_time <1)
内核代码使用jiffies的几个实例参见第十章“积压处理:process_backlog轮询虚拟函数”一节,第二十七章 “异步清理”:neigh_periodic_timer函数一节。
用户空间工具
有多种不同的工具可用于配置Linux众多可用的网络功能。如本章开头所述,你可以通过使用这些工具对内核巧妙地处理,以便于学习以及发现做这样的修改后的影响。
下面是本书中将会经常涉及的工具:
iputils
除了经常使用ping命令外,iputils还包括arping(用于产生ARP请求)、网络路由器发现守护函数radisc以及其他程序。
net-tools
这是一组网络工具,其中最著名的ifconfig、route、netstat以及arp,还有ipmaddr、iptunnel、ether-wake和netpluged等。
IPROUTE2
这是新一代网络配置套件(尽管已经存在好多年了)。通过一个多用途的命令ip,这个套件可以配置IP地址以及路由,还有其他各种高级功能,如邻居协议等等。
IPROTE2的源代码可以从官网进行下载,而其他组件可以从大多数Linux发行版的下载服务器上下载。
大多数Linux发行版本中都默认包含这些组件。每当你不了解内核代码如何处理来自用户空间的命令时,鼓励你看一看那个用户空间的源代码,以了解用户所下达的命令是如何打包并传给内核的。
下面的URL之中,你可以发现很好的文档,说明如何使用上述工具,包括邮件列表。(mailing list)
Linux Advanced Routing & Traffic Control
策略路由
Netflter
如果你想追踪网络代码的最新修改,要关注下列邮件列表:
Linux网络开发论坛档案库
浏览代码
Linux内核已经变得很大,用“老朋友”grep浏览代码,肯定不在是好主意。现在,你可以依靠各种软件,让你的内核代码之旅更为顺心。
还不知道cscope
的人,建议使用cscope,可以进行下载源码下载。
这是一个简单而功能强大的工具,例如,可以搜索函数或变量定义之处和调用之处等等。安装这个工具的过程也非常的简单,并且把你可以在网站上找到所有必需的指导。
死代码
内核像其他大型而动态演变的软件一样,也会包括一些不再被调用的代码片段。不幸的是,代码中的注释很少会告诉你这一点。有时候,只是因为你在看的是死代码,害的你无法理解给定函数的用法或者给定变量的初始化是怎么回事。如果运气好的话,那些代码没有编译,并且你也猜出那些程序代码已经太陈旧了。其他时候,你可能就没有那么幸运了。
每个内核子系统都假定指派了一位或多个维护人员。然而,有些维护人员有太多代码要看,根本没有足够的空闲时间去做这件事。其他时候,他们已不在有兴趣继续维护他们的子系统,但是又找不到可以替换他们的角色。因此,看到似乎在做很奇怪的事的代码,或者代码没有遵循通用而常识性的程序设计规则时,把这件事谨记在心是很有意义的。
本书中,只要有意义可言,我都尽量提醒你那些没在使用的函数、变量以及数据结构。没在使用的原因可能是因为删除一项功能时被留了下来,或者是因为一项新功能而被引入,但编码尚未完成。
当功能以补丁的形式提供时
内核网络代码一直在不断进化。不但会整合新功能,而且有时也会对现存的组件进行设计变更,以达到更为模块化和更高的性能。显然,这使得Linux非常有吸引力,以作为网络应用产品(路由器、交换机、防火墙以及负载平衡器等等)所需的嵌入式操作系统。
因为任何人都可以为Linux内核开发一项新的功能,或者拓展或重新实现一项现存的功能。对任何“开放”的开发人员而言,当看到他的作品成为正式内核版本的一部分时,都会是一件最振奋人心的事。然而,有时候即使一个项目的功能很有价值而且实现的很好也不可能整合进来,或者需要花很长一段时间。常见原因包括如下:
- 代码没有按照Documentation/CodingStyle的原则来写。
- 另一个重要项目提供相同机能已经有一段时间了,而且已经获得linux社区和维护相关内核领域的重要内核开发人员的认同。
- 与另一个内核组件有太多重叠之处。在这种情况下,最佳做法就是把冗余功能删除掉,可能的地方使用现存的功能,或者拓展现有组件使其能用于新的环境中,这种情况强调了模块化的重要性。
- 项目的大小以及在快速变动的内核中进行维护所需的工作量,可能导致新项目的开发人员将它保持为独立的补丁,并且仅仅偶尔才会发布新版本。
- 这种功能只会用在特定场合下,在通用操作系统中并非必须的。这种情况下采用独立的补丁通常是最佳的解决方案。
- 全面的设计可能无法满足某些重要内核开发人员。这些专家通常在心中都有大蓝图,关心内核现在的发展以及未来的发展方向。通常他们要求设计上进行修改,使得该项功能能适应内核的需求
某些时候,功能间的重叠很难完全消除。例如,也许因为一项功能太过灵活,其不同的用法只有在日后才会一目了然。例如,防火墙在网络协议栈中的几个地方都有挂钩。因此,就没必要实现过滤或者为数据包标明传输方向等的其他功能,只要简答地依赖防火墙就行了。当然这会产生相关性(例如,如果路由子系统想使得流量符合特定准则,内核就必须包括对防火墙的支持)。此外,只要其他内核功能有合理的增强请求,防火墙的维护者必须随时接受。然而,这种折中通常是有益的:减少冗余代码意味着有更少的bug,代码维护就越简单,代码路径也会简化,诸如此类。
最近的功能重叠清理的实例就是在内核2.6版中,把路由代码所支持的无态NAT(stateless Network Address Translation,无态网络地址翻译)给删掉。开发人员认识到把有态NAT的支持放在防火墙中更为灵活,因此继续维护无态NAT程序就没有价值了(尽管无态NAT比较快,耗用地内存比较少)。注意,Netfilter所需的一个新模块随时会被写,必要时会提供无态NAT的支持。
链接
百度网盘