- 论坛徽章:
- 0
|
第 10 章 合理使用数据类型
在继续讨论更高级的主题之前,我们需要首先讨论一下可移植性问题。现代版本的 Linux 内核,能够非常容易地移植到若干具有很大差异的体系结构上运行。因为 Linux 的多平台特点,所以任何一个重要的驱动程序都应该是可移植的。
但是与内核代码相关的核心问题是,这些代码应该能够同时访问已知长度(例如,文件系统的数据结构或者设备板上的寄存器)的数据项,并充分利用不同处理器(32 位和 64 位体系结构,或者也可能是 16 位的)的能力。
在把 x86 上的代码移植到新的体系结构上时,内核开发人员遇到的若干问题都和不正确的数据类型有关。坚持使用严格的数据类型,并且使用 -Wall –Wstrict-prototypes选项编译可以防止大多数的 bug。
内核使用的数据类型主要被分成三大类:类似 int 这样的标准 C 语言类型,类似 u32 这样的有确定大小的类型,以及象 pid_t 这样的用于特定内核对象的类型。我们将讨论应该在什么情况下使用这三种典型类型,以及如何使用。当从 x86 平台向其它平台移植驱动程序代码时,读者可能遇到其它一些典型的问题,这些问题将在本章的最后一节讨论,还将介绍对新内核头文件所提供的对链表的通用支持。
如果读者遵循我们提供的指导方针,读者的驱动程序甚至可能在那些未经测试的平台上编译和运行。
10.1 使用标准 C 语言类型
尽管大多数程序员习惯于自由使用象 int 和 long 这样的标准类型,而编写设备驱动程序需要小心地避免类型冲突和潜在的 bug。
问题是,当我们需要“两个字节的填充单位”或者“用四个字节字符串表示的某个东西”时,我们不能使用标准类型,因为在不同的体系结构上,一般的 C 语言的数据类型所占空间大小并不相同。在 O’Reilly ftp 站点上的 misc-procs 目录下,提供的样例文件已经包含了 datasize 程序,它可以显示各种 C 语言数据类型的大小,这是 PC 上程序的运行样例(其中最后四个类型将在下节介绍):
morgana% misc-progs/datasize
arch Size: char shor int long ptr long-long u8 u16 u32 u64
i686 1 2 4 4 4 8 1 2 4 8
这个程序也可以在 64 位平台上运行,其结果表明 long 整型和指针的大小和 32 位系统不同。下面的结果说明了该程序在不同平台上的运行结果:
arch Size: char shor int long ptr long-long u8 u16 u32 u64
i386 1 2 4 4 4 8 1 2 4 8
alpha 1 2 4 8 8 8 1 2 4 8
armv4l 1 2 4 4 4 8 1 2 4 8
ia64 1 2 4 8 8 8 1 2 4 8
m68k 1 2 4 4 4 8 1 2 4 8
mips 1 2 4 4 4 8 1 2 4 8
ppc 1 2 4 4 4 8 1 2 4 8
sparc 1 2 4 4 4 8 1 2 4 8
sparc64 1 2 4 4 4 8 1 2 4 8
值得注意的是,Linux-sparc64 的用户空间可以运行 32 位代码,所以在用户空间指针是 32 位宽的,而它们在内核空间是 64 位的,这可以通过装载 kdatasize 模块(可从 misc-proc目录下的样例文件中得到)来验证。模块在装载时使用 printk 汇报大小信息并返回一个错误(所以不需要卸载这个模块):
kernel: arch Size: char short int long ptr long-long u8 u16 u32 u64
kernel: sparc64 1 2 4 8 8 8 1 2 4 8
尽管在混合使用不同数据类型时,我们必须小心谨慎,但有时还有一些其它的理由需要我们这样做。这样的一种情况是内存地址,一涉及到内核,内存地址就变得很特殊。虽然概念上地址是指针,但是通过使用无符号整数类型可以更好的实现内存管理。内核把物理内存看作一个巨型数组,一个内存地址就是该数组的索引。此外,我们可以很方便地使用指针指向的内容(译者注:使用 C 语言的“*”运算符。在 C 语言术语中,称为“反引用,dereference”,和“&”运算符相反,后者称“引用”),但在直接处理内存地址时,我们几乎从来不会以这种方式使用以整数表示的内存地址。使用一个整数类型可以防止类指针的使用方式,因而可避免出现 bug。因此,内核中的地址是 unsigned long 型数据,至少在当前 Linux 支持的所有平台上,指针和 long 整型的大小总是相同的。
C99 标准定义了 intptr_t 和 uintptr_t 类型,它们是能够保存指针值的整型变量。这些类型在 2.4 的内核中几乎没有用到,但是在将来的开发工作中,也许会经常用到。
10.2 为数据项分配确定的空间大小
有时内核代码需要指定大小的数据项,或者用来匹配预定义的二进制结构*,或者通过在结构中插入“filler”成员(关于对齐的问题,请查阅本章后面的“数据对齐”一节)来对齐数据。
注:这种情况一般发生在读分区表、执行二进制文件或者对网络数据包解码的时候。
在读者需要知道自己的数据大小时,内核提供下列数据类型。所有类型在头文件 中声明,这个文件又被头文件 所包含:
u8; /* unsigned byte (8 bits) */
u16; /* unsigned word (16 bits) */
u32; /* unsigned 32-bit value */
u64; /* unsigned 64-bit value */
这些数据类型只有内核代码(也就是说,必须在包含头文件之前定义 _ _KERNEL_ _)可以使用。相应的有符号类型也存在,但是很少使用,如果需要它们的话,只需要将名字中的 u 用 s 替换就可以了。
如果一个用户空间程序需要使用这些类型,它可以在名字前加上两个下划线作为前缀:_ _u8和其它类型是独立于 _ _KERNEL_ _ 定义的。例如,如果一个驱动程序需要通过 ioctl 系统调用与一个运行在用户空间的程序交换二进制结构的话,头文件应该用 _ _u32 来声明结构中的 32 位的成员。
重要的是要记住这些类型是 Linux 特有的,如果使用它们将阻碍软件向其它 Unix 变体的移植。使用新编译器的系统将支持 C99 标准类型,例如 uint8_t 和 uint32_t,可能的情况下,应使用这些类型以支持 Linux 相关变种。但是,如果代码用于 2.0 内核,就无法使用这些类型(因为 2.0 内核只能利用老的编译器来编译)。
读者可能也注意到有时内核使用传统的类型,例如unsigned int,这通常用于大小独立于体系结构的数据项。这种做法通常是为了保持向后兼容性。当 u32 及其相关类型在版本1.1.67中引入时,开发者没有办法将现存的数据结构改变为新的类型,因为当结构成员和赋予的值之间类型不匹配时,编译器将发出警告*。
注:事实上,既使在两个类型只是同一对象的不同名字时,编译器还是会发出类型不一致的警告,就象 PC 上的 unsigned long 和 u32。
Linus 没有想到他自己编写的操作系统会用在多平台上,结果,旧的结构有时定义的不是很严格。
10.3 接口特有的类型
内核中最常用的数据类型由它们自己的 typedef 声明,这样防止出现任何移植性问题。例如,一个进程的标识符(pid)通常使用 pid_t 类型,而不是 int,使用 pid_t 屏蔽了在实际的数据类型中任何可能的差异。我们使用“接口特有”这一表达方式,是指由某个库定义的一种数据类型,以便为某个特定的数据结构提供接口。
既使没有定义接口特有的类型,也应该使用适当的数据类型,以便和内核其余部分保持一致。比如,一个 jiffy 计数总是属于 unsigned long 类型,而不管它的实际大小如何,因此,在使用 jiffies 的时候应该一直使用 unsigned long 类型。本节中我们主要讨论“_t”类型的用法。
完整的 _t 类型清单在头文件 中定义,但是这个清单很少有用。在需要某个特定类型时,可在需要调用的函数原型,或者所使用的数据结构中找到这个类型。
只要驱动程序使用了需要这种“定制”类型的函数,又不遵守约定的时候,编译器会产生警告。如果使用 -Wall 编译选项并且细心地去除了所有警告,就可以自信代码是可移植的了。
_t数据项的主要问题是在我们需要打印它们的时候,不太容易选择正确的 printk 或者 printf 的输出格式,并且在一种体系结构上排除了警告,而在另一种体系结构上可能还会出现警告。例如,当 size_t 在一些平台上是 unsigned long,而在另一种平台上是 int 类型时,我们应该如何打印它呢?
在我们需要打印一些接口特定的数据类型时,最行之有效的方法,就是将其强制转换成最可能的类型(通常是 long 或者 unsigned long),然后用相应的格式打印出来。这种作法不会产生错误或者警告,因为格式和类型相匹配,而且也不会丢失数据位,因为强制类型转换要么是一个空操作,要么是将该数据项向更宽的数据类型扩展。
实际上,通常我们并不需要打印我们讨论的这些数据项,因此,只有在调试信息中才会出现这些问题。更经常的,除了将接口特有的数据类型作为参数传递给库函数或者内核函数之外,代码只须对它们进行储存和比较操作。
尽管 _t 类型在大多数情况下是正确的解决方案,但有时正确的类型并不存在。这发生在一些还没有被整理的旧接口上。
在内核头文件中我们已经发现一处疑点,I/O 函数的数据类型不是很严格(请看第 8 章的“平台相关性”一节),这种不严格的类型定义主要是出于历史原因,但是却可能在编写代码时产生问题。例如,经常在把参数交换给象 outb 这样的函数时遇到麻烦;如果有一种 port_t 类型,编译器就会发现这种错误类型。
10.4 其它有关移植性的问题
除了数据类型定义的问题之外,在编写一个能在不同的 Linux 平台间移植的驱动程序时,还必须注意其它一些软件上的问题。
一个通用的原则是对显式常量值持怀疑态度。通常,代码通过使用预处理的宏使之参数化。这一节列出了最重要的移植性问题,在遇到其它已经被参数化的值时,可以在头文件和正式内核发布的设备驱动程序中找到一些线索。
10.4.1 时间间隔
在处理时间周期时,不要假定每秒一定有 100 个 jiffy。尽管对于当前的 Linux-x86 这是正确的,但并不是每一种 Linux 平台都是以 100HZ(就象2.4,你会发现这个值的范围是从 20 到 1200,尽管 20 只是用在IA-64模拟器里面)运行。既使在 x86 上这种假设也可能是错误的,因为 HZ 值可能已被改变,何况没有人知道未来的内核将发生什么改变。使用jiffy 计算时间间隔的时候,应该用 HZ(每秒定时器中断的次数)来衡量。例如,为了检测半秒的超时,可以将消逝的时间与 HZ/2 作比较。更常见的,与 msec 毫秒对应的 jiffy数目总是 msec*HZ/1000。很多网络驱动程序在移植到 Alpha 平台时上都必须修正该细节。它们中的一部分在 Alpha 平台上没有正常工作,就是因为它们假定了 HZ 是 100。
10.4.2 页大小
使用内存时,要记住内存页的大小为 PAGE_SIZE 字节,而不是 4KB。假定页大小就是 4KB,并且硬编码该数值是 PC 程序员常犯的错误――相反,在已支持的平台上,页大小范围从 4KB到 64KB,有时候它们在相同平台上的不同实现也是不一致的。这一问题涉及到的宏是 PAGE_SIZE 和 PAGE_SHIFT。后者是得到一个地址所在页的页号时,需要对该地址右移的位数。对于当前 4KB 和更大的页,这个数值通常是 12 或者更大。这些宏在头文件 中定义。如果用户空间程序需要这些信息,则可以使用 getpagesize 来获得。让我们看看特殊情形,如果一个驱动程序需要 16KB 空间来储存临时数据,我们不应该指定传递给 get_free_pages 的参数为2的幂,而需要一个可移植的方案。使用大量的 #ifdef 条件编译可以很好地工作,但是它只能解决我们所知道的平台,而在其它的体系结构上可能出错,例如在某个未来支持的体系结构上。所以,我们建议使用下面的代码替代:
int order = (14 - PAGE_SHIFT > 0) ? 14 - PAGE_SHIFT : 0;
buf = get_free_pages(GFP_KERNEL, order);
解决方法利用了 16KB 等于 1 并且应该检查头文件定义了 _ _BIG_EBDIAN 还是 _ _LITTLE_ENDIAN。
我们可以编写一组 #ifdef __LITTLE_ENDIAN 条件,但是有一个更好的方法。Linux 内核定义了一组宏,它可以在处理器字节序和特殊字节序数据之间进行转换。例如:
u32 _ _cpu_to_le32 (u32);
u32 _ _le32_to_cpu (u32);
这两个宏可以将一个 CPU 使用的值转换成一个无符号值的 32位 little-endina 数,或者相反。它们可以正常工作而不管 CPU 是 big-endian 或 little-endian,也不管它是否是一个 32 位处理器。如果没有转换工作需要做,它们就返回未经修改的参数。使用这些宏可以使编写可移植代码的工作变得更加容易,从而无需使用很多条件编译。
类似例程有十几个之多,读者可以在头文件 和 中看到完整的列表。稍后能看到,这种模式很容易遵循。__be64_to_cpu 将一个无符号的 64 位 big-endian 的数值转换成 CPU 的内部表达。相应的,__le16_to_cpus 处理一个有符号的 16 位 little-endian 数值。在处理指针时,也可以使用类似 __cpu_to_le32p 这样的函数,它们使用指向数值的指针而不是数值本身。其它函数可参阅头文件。
并不是所有的 Linux 版本都定义了所有处理字节序的宏。特别要指出的是,linux/byteorder 目录出现在版本 2.1.72,用来重新整理各个 文件,并删除重复的定义。如果读者使用我们的 sysdep.h,在为 2.0 或者 2.2 内核编译代码时,则可以使用 Linux 2.4所定义的所有宏。
10.4.4 数据对齐
最后值得关注的问题是如何访问未对齐的数据――例如,怎样读取一个存储在非四字节倍数的地址中的四字节值。PC 的用户常常访问未对齐的数据,但是只有很少的体系结构允许这样做,大部分现代的体系结构在每次程序试图除数未对齐的数据时,都会产生一个异常;这时,数据传输会被异常处理程序处理,因此会带来大量性能损失。如果需要访问未对齐的数据,则应该使用下面的宏:
#include
get_unaligned(ptr);
put_unaligned(val, ptr);
这些宏是与类型无关的,对各种数据项,不管它是 1 字节、2 字节、4 字节还是 8 字节,这些宏都有效。所有版本的内核都定义了这些宏。
另一个关于数据对齐的问题是数据结构的跨平台可移植性。同样的数据结构(在 C 语言源文件中定义的)在不同的平台上可能会被编译成不同的样子,编译器排列数据结构的成员时,将根据平台的不同而进行不同的对齐。至少理论上,为了优化内存的使用,编译器甚至会重新排列数据结构的成员*。
注:在当前已支持的体系结构上,不会发生成员的重新排列,因为这会破坏与已有代码的协同工作能力,但是由于对齐的限制,新的体系结构可能为带有空洞的结构定义成员的重新排列规则。
为了编写含有可以在平台之间移动的数据项的数据结构,除了标准化特定的字节序,还应该始终坚持数据项的自然对齐。“自然对齐”意味着在数据项大小的整数倍(例如,8字节数据项存入8的整数倍的地址)的地址处存储数据项。强制自然对齐可以防止编译器移动数据结构的成员,读者应该使用填充符(filler)成员以避免在数据结构中留下空洞。
为说明编译器是怎样强制对齐的,源代码的 misc-progs 目录中有个 dataalign 程序,对应模块是 kdataalign(在misc-modules目录中)。下面 dataalign 程序在若干平台上的输出,以及 kdataalign 模块在 SPARC64 体系结构上的输出:
arch Align: char short int long ptr long-long u8 u16 u32 u64
i386 1 2 4 4 4 4 1 2 4 4
i686 1 2 4 4 4 4 1 2 4 4
alpha 1 2 4 8 8 8 1 2 4 8
armv4l 1 2 4 4 4 4 1 2 4 4
ia64 1 2 4 8 8 8 1 2 4 8
mips 1 2 4 4 4 8 1 2 4 8
ppc 1 2 4 4 4 8 1 2 4 8
sparc 1 2 4 4 4 8 1 2 4 8
sparc64 1 2 4 4 4 8 1 2 4 8
kernel: arch Align: char short int long ptr long-long u8 u16 u32 u64
kernel: sparc64 1 2 4 8 8 8 1 2 4 8
值得注意的是,不是所有平台都在 64 位边界对齐 64 位数值,所以需要填充符成员来强制对齐并确保可移植性。
10.5 链表
就象其它很多程序一样,操作系统内核经常需要维护数据结构的列表。有时,Linux 内核中同时存在多个链表的实现代码。为了减少重复代码的数量,内核开发者已经建立了一套标准的循环链表、双向链表的实现。这套实现在版本 2.1.45 中引入,如果需要操作链表,则鼓励使用这一内核设施。
为了使用这个列表机制,驱动程序必须包含头文件 。该文件定义了一个简单的 list_head 类型的结构。
struct list_head {
struct list_head *next, *prev;
};
用在实际代码中的链表几乎总是由某种结构类型构成,每个结构描述链表中的一个入口。为了在代码中使用 Linux 链表设施,只需要在构成链表的结构里面嵌入一个 list_head。如果驱动程序维护一个链表,则可声明如下:
struct todo_struct {
struct list_head list;
int priority; /* driver specific */
/* ... add other driver-specific fields */
};
链表头必须是一个独立的 list_head 结构。在使用之前,必须用 INIT_LIST_HEAD 宏来初始化链表头。一个实际的链表头可如下声明并初始化:
struct list_head todo_list;
INIT_LIST_HEAD(&todo_list);
另外,可在编译阶段象下面这样初始化链表:
LIST_HEAD(todo_list);
头文件 中声明了下面这些操作链表的函数:
list_add(struct list_head *new, struct list_head *head);
这个函数会立即在链表头后面添加新入口――通常是在链表的头部。这样,它可以用来建立栈。但需要注意的是,head 并不一定非得是链表的第一项,如果传递了一个恰巧位于链表中间某处的 list_head 结构,新入口会立即排在它的后面。因为 Linux 链表是循环的,链表头通常与其它的入口没有本质上的区别。
list_add_tail(struct list_head *new, struct list_head *head);
在给定链表头的前面增加一个新的入口,即在链表的末尾添加。因此,可使用 list_add_tail建立先入先出队列。
list_del(struct list_head *entry);
将给定的入口从链表中删除。
list_empty(struct list_head *head);
如果给定的链表是空的,就返回一个非零值。
list_splice(struct list_head *list, struct list_head *head);
这个函数通过在 head 的后面插入 list 来合并两个链表。
list_head 结构有利于实现具有相似结构的链表,但调用程序通常对建立链表的大结构更感兴趣。因此,可利用“list_entry”宏将一个 list_head结构指针映射回一个指向大结构的指针。可如下调用这个宏:
list_entry(struct list_head *ptr, type_of_struct, field_name);
其中,ptr 是指向 struct list_head 结构的指针,type_of_struct 是包含 ptr 的结构类型,field_name 是结构中链表成员的名字。在我们之前的 todo_struct 结构中,链表成员只是简单地称为 list。这样,利用类似下面的代码行,我们可以将一个链表入口转换成包含它的结构:
struct todo_struct *todo_ptr =
list_entry(listptr, struct todo_struct, list);
宏“list_entry”需要稍微习惯一下,但还不是很难使用。
遍历链表很容易:只须跟随 prev 和 next 指针。作为例子,假设我们想让 todo_struct链表中的项按照优先级(即 priority 成员)降序排列,则增加新入口的函数如下所示:
void todo_add_entry(struct todo_struct *new)
{
struct list_head *ptr;
struct todo_struct *entry;
for (ptr = todo_list.next; ptr != &todo_list; ptr = ptr->next) {
entry = list_entry(ptr, struct todo_struct, list);
if (entry->priority priority) {
list_add_tail(&new->list, ptr);
return;
}
}
list_add_tail(&new->list, &todo_struct)
}
头文件 也定义了宏“list_for_each”,在代码中它扩展为 for 循环使用。正如读者所怀疑的,我们在通过它修改链表时必须十分小心。
图 10-1 显示了怎样使用简单的 struct list_head 来维护数据结构链表。
老版本内核中缺少一些出现在 2.4 内核头文件“list.h”中的功能,但我们的头文件“sysdep.h”声明了所有可用于老版本内核中的宏和函数。
![]()
图 10-1:list_head 数据结构
10.6 快速索引
本章引入了下列符号:
#include
typedef u8;
typedef u16;
typedef u32;
typedef u64;
这些类型保证是8位、16位、32位和64位的无符号整数值,对应的有符号类型同样存在。在用户空间,读者可以通过 _ _u8、_ _u16 等来使用这些类型。
#include
PAGE_SIZE
PAGE_SHIFT
这些符号定义了当前体系结构下每页包含的字节数和页偏移量的位数(12对应4KB的页而13对应8KB的页)。
#include
_ _LITTLE_ENDIAN
_ _BIG_ENDIAN
两个符号中只有一个被定义,这依赖于体系结构。
#include
u32 _ _cpu_to_le32 (u32);
u32 _ _le32_to_cpu (u32);
在已知字节序和处理器字节序之间完成转换的函数。有多于60个这样的函数,完整的列表和它们的定义方式,可参阅目录 include/byteorder/ 下面的不同文件。
#include
get_unaligned(ptr);
put_unaligned(val, ptr);
某些体系结构须使用这些宏来保护对未对齐数据的访问。在那些允许访问未对齐数据的体系结构上,这些宏扩展为取指针内容的通常操作。
#include
list_add(struct list_head *new, struct list_head *head);
list_add_tail(struct list_head *new, struct list_head *head);
list_del(struct list_head *entry);
list_empty(struct list_head *head);
list_entry(entry, type, member);
list_splice(struct list_head *list, struct list_head *head);
操作循环链表和双向链表的函数。
本文来自ChinaUnix博客,如果查看原文请点:http://blog.chinaunix.net/u/23470/showart_171405.html |
|