Hao Yu's blog

The program monkey was eaten by the siege lion.

0%

Ext2 文件系统

Ext2(第二扩充文件系统)是一种功能强大、易扩充、性能上进行了全面的优化的文件系统,也是当前Linux文件系统实际上的标准。

Ext2有如下几方面的特点。

  • 它的节点中使用了 15 个数据块指针,这样它最大可支持4TB的磁盘分区。
  • 它使用变长的目录项,这样既可以不浪费磁盘空间,又能支持最长 255 个字符的文件名。
  • 使用位图来管理数据块和节点的使用情况,解决了Ext出现的问题。
  • 最重要的一点是,它在磁盘上的布局做了改进,即使用了块组的概念,从而使数据的读和写更快、更有效,也便系统变得更安全可靠。
  • 易于扩展。

基本概念

具体文件系统管理的是一个逻辑空间,这个逻辑空间就像一个大的数组,数组的每个元素就是文件系统操作的基本单位——逻辑块,逻辑块是从 0 开始编号的,而且,逻辑块是连续的。

与逻辑块相对的是物理块,物理块是数据在磁盘上的存取单位,也就是每进行一次I/O操作,最小传输的数据大小。如果物理块定的比较大,比如一个柱面大小,这时,即使是 1 个字节的文件都要占用整个一个柱面,大的存取单位将带来严重的磁盘空间浪费。另一方面,如果物理块过小,则意味着对一个文件的操作将进行更多次的寻道延迟和旋转延迟。

因此,最优的方法是计算出Linux环境下文件的平均大小,然后将物理块大小定为最接近扇区的整数倍大小。

假设用户要对一个已有文件进行写操作,用户进程必须先打开这个文件,file结构记录了该文件的当前位置。然后用户把一个指向用户内存区的指针和请求写的字节数传送给系统,请求写操作,这时系统要进行两次映射。

  1. 一组字节到逻辑块的映射。这个映射过程就是找到起始字节到结束字节所占用的所有逻辑块号。这是因为在逻辑空间,文件传输的基本单位是逻辑块而不是字节。
  2. 逻辑块到物理块的映射。这个过程必须要用到索引节点结构,该结构中有一个物理块指针数组,以逻辑块号为索引,通过这些指针找到磁盘上的物理块,具体实现将在介绍Ext2索引节点时再进行介绍。

每个文件必然占用整数个逻辑块,除非每个文件大小都恰好是逻辑块的整数倍,否则最后一个逻辑块必然有空间未被使用,实际上,每个文件的最后一个逻辑块平均要浪费一半的空间,显然最终浪费的还是物理块。在一个有很多文件的系统中,这种浪费是很大的。Ext2使用片来解决这个问题。

片也是一个逻辑空间中的概念,其大小在1KB4KB之间,但片的大小总是不大于逻辑块。假设逻辑块大小为 4KB,片大小为 1KB,物理块大小也是 1KB,当你要创建一个3KB大小的文件时,实际上分配给你了 3 个片,而不会给你一个逻辑块,当文件大小增加到4KB时,文件系统则分配一个逻辑块给你,而原来的四个片被清空。如果文件又增加到5KB时,则占用 1 个逻辑块和 1 个片。上述 3 种情况下,所占用的物理块分别是 3 个、4 个、5 个,如果不采用片,则要用到 4 个、4 个、8 个物理块,可见,使用片,减少了磁盘空间的浪费。当然,在物理块和逻辑块大小一样时,片就没有意义了。

由上面分析也可看出:物理块大小<=片大小<=逻辑块大小

Ext2 的磁盘布局和数据结构

Ext2 的磁盘布局

文件系统的逻辑空间最终要通过逻辑块到物理块的映射转化为磁盘等介质上的物理空间,因此,对逻辑空间的组织和管理的好坏必然影响到物理空间的使用情况。一个文件系统,在磁盘上如何布局,要综合考虑以下几个方面的因素。

  • 首先也是最重要的是要保证数据的安全性,也就是说当在向磁盘写数据时发生错误,要能保证文件系统不遭到破坏。
  • 其次,数据结构要能高效地支持所有的操作。Ext2 中,最复杂的操作是硬链接操作。硬链接允许一个文件有多个名称,通过任何一个名称都将访问相同的数据。另一个比较复杂的操作是删除一个已打开的文件。
  • 第三,磁盘布局应使数据查找的时间尽量短,以提高效率。驱动器查找分散的数据要比查找相邻的数据花多得多的时间。一个好的磁盘布局应该让相关的数据尽量连续分布。例如,同一个文件的数据应连续分布,并和包含该文件的目录文件相邻。
  • 最后,磁盘布局应该考虑节省空间。虽然现在节省磁盘空间已不太重要,但也不应该无谓地浪费磁盘空间。

Ext2 的磁盘布局在逻辑空间中的映像由一个引导块和重复的块组构成的,每个块组又由超级块、组描述符表、块位图、索引节点位图、索引节点表、数据区构成。引导块中含有可执行代码,启动计算机时,硬件从引导设备将引导块读入内存,然后执行它的代码。系统启动后,引导块不再使用。因此,引导块不属于文件系统管理。

Ext2 的超级块

Ext2 超级块是用来描述Ext2文件系统整体信息的数据结构,是Ext2的核心所在。它是一个ext2_super_block数据结构(在include/Linux/ext2_fs.h中定义),其各个域及含义如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
struct ext2_super_block
{
__u32 s_inodes_count; /*文件系统中索引节点总数 */
__u32 s_blocks_count; /*文件系统中总块数 */
__u32 s_r_blocks_count; /*为超级用户保留的块数 */
__u32 s_free_blocks_count; /*文件系统中空闲块总数 */
__u32 s_free_inodes_count; /*文件系统中空闲索引节点总数*/
__u32 s_first_data_block; /* 文件系统中第一个数据块 */
__u32 s_log_block_size; /* 用于计算逻辑块大小 */
__s32 s_log_frag_size; /* 用于计算片大小 */
__u32 s_blocks_per_group; /* 每组中块数 */
__u32 s_frags_per_group; /* 每组中片数 */
__u32 s_inodes_per_group; /* 每组中索引节点数 */
__u32 s_mtime; /*最后一次安装操作的时间 */
__u32 s_wtime; /*最后一次对该超级块进行写操作的时间 */
__u16 s_mnt_count; /* 安装计数 */
__s16 s_max_mnt_count; /* 最大可安装计数 */
__u16 s_magic; /* 用于确定文件系统版本的标志 */
__u16 s_state; /* 文件系统的状态*/
__u16 s_errors; /* 当检测到有错误时如何处理 */
__u16 s_minor_rev_level; /* 次版本号 */
__u32 s_lastcheck; /* 最后一次检测文件系统状态的时间 */
__u32 s_checkinterval; /* 两次对文件系统状态进行检测的间隔时间 */
__u32 s_rev_level; /* 版本号 */
__u16 s_def_resuid; /* 保留块的默认用户标识号 */
__u16 s_def_resgid; /* 保留块的默认用户组标识号*/

/*
* These fields are for EXT2_DYNAMIC_REV superblocks only.
*
* Note: the difference between the compatible feature set and
* the incompatible feature set is that if there is a bit set
* in the incompatible feature set that the kernel doesn't
* know about, it should refuse to mount the filesystem.
*
* e2fsck's requirements are more strict; if it doesn't know
* about a feature in either the compatible or incompatible
* feature set, it must abort and not try to meddle with
* things it doesn't understand...
*/
__u32 s_first_ino; /* 第一个非保留的索引节点 */
__u16 s_inode_size; /* 索引节点的大小 */
__u16 s_block_group_nr; /* 该超级块的块组号 */
__u32 s_feature_compat; /* 兼容特点的位图*/
__u32 s_feature_incompat; /* 非兼容特点的位图 */
__u32 s_feature_ro_compat; /* 只读兼容特点的位图*/
__u8 s_uuid[16]; /* 128 位的文件系统标识号*/
char s_volume_name[16]; /* 卷名 */
char s_last_mounted[64]; /* 最后一个安装点的路径名 */
__u32 s_algorithm_usage_bitmap; /* 用于压缩*/

/*
* Performance hints. Directory preallocation should only
* happen if the EXT2_COMPAT_PREALLOC flag is on.
*/
__u8 s_prealloc_blocks; /* 预分配的块数*/
__u8 s_prealloc_dir_blocks; /* 给目录预分配的块数 */
__u16 s_padding1;
__u32 s_reserved[204]; /* 用`NULL`填充块的末尾 */
};

从中我们可以看出,这个数据结构描述了整个文件系统的信息,下面对其中一些域作一些解释。

  • 文件系统中并非所有的块普通用户都可以使用,有一些块是保留给超级用户专用的,这些块的数目就是在s_r_blocks_count中定义的。一旦空闲块总数等于保留块数,普通用户无法再申请到块了。如果保留块也被使用,则系统就可能无法启动了。有了保留块,我们就可以确保一个最小的空间用于引导系统。
  • 逻辑块是从 0 开始编号的,对块大小为1KB的文件系统,s_first_data_block为 1,对其他文件系统,则为 0。
  • s_log_block_size是一个整数,以 2 的幂次方表示块的大小,用 1024 字节作为单位。因此,0 表示 1024 字节的块,1 表示 2048 字节的块,如此等等。同样,片的大小计算方法也是类似的,因为Ext2中还没有实现片,因此,s_log_frag_sizes_log_block_size相等。
  • Ext2 要定期检查自己的状态,它的状态取下面两个值之一。
    • #define EXT2_VALID_FS 0x0001文件系统没有出错。
    • #define EXT2_ERROR_FS 0x0002内核检测到错误。
    • s_lastcheck就是用来记录最近一次检查状态的时间,而s_checkinterval则规定了两次检查状态的最大允许间隔时间。
  • 如果检测到文件系统有错误,则对s_errors赋一个错误值。一个好的系统应该能在错误发生时进行正确处理,有关Ext2如何处理错误将在后面介绍。

超级块被读入内存后,主要用于填写VFS的超级块,此外,它还要用来填写另外一个结构,这就是ext2_super_info结构,这一点我们可以从有关Ext2超级块的操作中看出,比如ext2_read_super()。之所以要用到这个结构,是因为VFS的超级块必须兼容各种文件系统的不同的超级块结构,所以对某个文件系统超级块自己的特性必须用另一个结构保存于内存中,以加快对文件的操作,比如对Ext2 来说,片就是它特有的,所以不能存储在VFS超级块中。

Ext2 中的这个结构是ext2_super_info,它其中的信息多是从磁盘上的索引节点计算得来的 。该结构定义于include/Linux/ext2_fs_sb.h,下面是该结构及各个域含义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct ext2_sb_info
{
unsigned long s_frag_size; /* 片大小(以字节计) */
unsigned long s_frags_per_block; /* 每块中片数 */
unsigned long s_inodes_per_block; /* 每块中节点数*/
unsigned long s_frags_per_group; /* 每组中片数*/
unsigned long s_blocks_per_group; /* 每组中块数 */
unsigned long s_inodes_per_group; /*每组中节点数 */
unsigned long s_itb_per_group; /* 每组中索引节点表所占块数 */
unsigned long s_db_per_group; /* 每组中组描述符所在块数 */
unsigned long s_desc_per_block; /* 每块中组描述符数 */
unsigned long s_groups_count; /* 文件系统中块组数 */
struct buffer_head * s_sbh; /* 指向包含超级块的缓存 */
struct buffer_head ** s_group_desc; /* 指向高速缓存中组描述符表块的指针数组的一个指针 */
unsigned short s_loaded_inode_bitmaps; /* 装入高速缓存中的节点位图块数*/
unsigned short s_loaded_block_bitmaps; /*装入高速缓存中的块位图块数*/
unsigned long s_inode_bitmap_number[Ext2_MAX_GROUP_LOADED];
struct buffer_head * s_inode_bitmap[Ext2_MAX_GROUP_LOADED];
unsigned long s_block_bitmap_number[Ext2_MAX_GROUP_LOADED];
struct buffer_head * s_block_bitmap[Ext2_MAX_GROUP_LOADED];
int s_rename_lock; /*重命名时的锁信号量*/
struct wait_queue * s_rename_wait; /*指向重命名时的等待队列*/
unsigned long s_mount_opt; /*安装选项*/
unsigned short s_resuid; /*默认的用户标识号*/
unsigned short s_resgid; /* 默认的用户组标识号*/
unsigned short s_mount_state; /*专用于管理员的安装选项*/
unsigned short s_pad; /*填充*/
int s_inode_size; /*节点的大小*/
int s_first_ino; /*第一个节点号*/
};

s_block_bitmap_number[]s_block_bitmap[]s_inode_bitmap_number[]s_inode_bitmap[]是用来管理位图块高速缓存的。

另外,由于每个文件系统的组描述符表可能占多个块,这些块进入缓存后,用一个指针数组分别指向它们在缓存中的地址,而s_group_desc则是用来指向这个数组的,用相对于组描述符表首块的块数作索引,就可以找到指定的组描述符表块。


图 9.3 是 3 个与超级块相关的数据结构的关系示意图。

Ext2 的索引节点

Ext2使用索引节点来记录文件信息。每一个普通文件和目录都有唯一的索引节点与之对应,索引节点中含有文件或目录的重要信息。当你要访问一个文件或目录时,通过文件或目录名首先找到与之对应的索引节点,然后通过索引节点得到文件或目录的信息及磁盘上的具体的存储位置。Ext2 的索引节点的数据结构叫ext2_inode,在include/Linux/ext2_fs.h中定义,下面是其结构及各个域的含义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ext2_inode {
__u16 i_mode; /* 文件类型和访问权限 */
__u16 i_uid; /* 文件拥有者标识号*/
__u32 i_size; /* 以字节计的文件大小 */
__u32 i_atime; /* 文件的最后一次访问时间 */
__u32 i_ctime; /* 该节点最后被修改时间 */
__u32 i_mtime; /* 文件内容的最后修改时间 */
__u32 i_dtime; /* 文件删除时间 */
__u16 i_gid; /* 文件的用户组标志符 */
__u16 i_links_count; /* 文件的硬链接计数 */
__u32 i_blocks; /* 文件所占块数(每块以 512 字节计)*/
__u32 i_flags; /* 打开文件的方式 */
union /* 特定操作系统的信息 */
__u32 i_block[Ext2_N_BLOCKS]; /* 指向数据块的指针数组 */
__u32 i_version; /* 文件的版本号(用于`NFS) */
__u32 i_file_acl; /*文件访问控制表(已不再使用) */
__u32 i_dir_acl; /*目录访问控制表(已不再使用)*/
__u8 l_i_frag; /* 每块中的片数 */
__u32 i_faddr; /* 片的地址 */
union /*特定操作系统信息*/
}

从中可以看出,索引节点是用来描述文件或目录信息的。

以下,对其中一些域作一定解释。

  • 前面说过,Ext2 通过索引节点中的数据块指针数组进行逻辑块到物理块的映射。在Ext2 索引节点中,数据块指针数组共有 15 项,前 12 个为直接块指针,后 3 个分别为“一次间接块指针”、“二次间接块指针”、“三次间接块指针”,如图 9.4 所示。

所谓“直接块”,是指该块直接用来存储文件的数据,而“一次间接块”是指该块不存储数据,而是存储直接块的地址。这里所说的块,指的都是物理块。Ext2 默认的物理块大小为 1KB,块地址占 4 个字节(32 位),所以每个物理块可以存储 256 个地址。这样,文件大小最大可达 12KB+256KB+ 64MB+16GB。

系统是以逻辑块号为索引查找物理块的。例如,要找到第 100 个逻辑块对应的物理块,因为 256+12>100+12,所以要用到一次间接块,在一次间接块中查找第 88 项,此项内容就是对应的物理块的地址。而如果要找第 1000 个逻辑块对应的物理块,由于 1000>256+12,所以要用到二次间接块了。

  • 索引节点的标志(flags)取下列几个值的可能组合。

    • EXT2_SECRM_FL 0x00000001:完全删除标志。设置这个标志后,删除文件时,随机数据会填充原来的数据块。
    • EXT2_UNRM_FL 0x00000002:可恢复标志。设置这个标志后,删除文件时,文件系统会保留足够信息,以确保文件仍能恢复(仅在一段时间内)。
    • EXT2_COMR_FL 0x00000004:压缩标志。设置这个标志后,表明该文件被压缩过。当访问该文件时,文件系统必须采用解压缩算法进行解压。
    • EXT2_SYNC_FL 0x00000008:同步更新标志。设置该标志后,则该文件必须和内存中的内容保持一致,对这种文件进行异步输入、输出操作是不允许的。这个标志仅用于节点本身和间接块。数据块总是异步写入磁盘的。
  • 索引节点在磁盘上是经过编号的。其中,有一些节点有特殊用途,用户不能使用。这些特殊节点也在include/Linux/ext2_fs.h中定义。

    • #define EXT2_BAD_INO 1:该节点所对应的文件中包含着该文件系统中坏块的链接表。
    • #define EXT2_ROOT_INO 2:该文件系统的根目录所对应的节点。
    • #define EXT2_IDX_INO 3:ACL(访问控制链表)节点。
    • #define EXT2_DATA_INO 4:ACL节点。
    • #define EXT2_BOOT_LOADER_INO 5:用于引导系统的文件所对应的节点。
    • #define EXT2_UNDEL_DIR_INO 6:文件系统中可恢复的目录对应的节点。
    • #define EXT2_FIRST_INO 11:没有特殊用途的第一个节点号为 11。

与 Ext2 超级块类似,当磁盘上的索引节点调入内存后,除了要填写VFS的索引节点外,系统还要根据它填写另一个数据结构,该结构叫ext2_inode_info,其作用也是为了存储特定文件系统自己的特性,它在include/Linux/ext2_fs_i.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ext2_inode_info
{
__u32 i_data[15]; /*数据块指针数组*/
__u32 i_flags; /*打开文件的方式*/
__u32 i_faddr; /*片的地址*/
__u8 i_frag_no; /*如果用到片,则是第一个片号*/
__u8 i_frag_size; /*片大小*/
__u16 i_osync; /*同步*/
__u32 i_file_acl; /*文件访问控制链表*/
__u32 i_dir_acl; /*目录访问控制链表*/
__u32 i_dtime; /*文件的删除时间*/
__u32 i_block_group; /*索引节点所在的块组号*/
/******以下四个域是用于操作预分配块的*************/
__u32 i_next_alloc_block;
__u32 i_next_alloc_goal;
__u32 i_prealloc_block;
__u32 i_prealloc_count;
__u32 i_dir_start_lookup
int i_new_inode:1 /* Is a freshly allocated inode */
};

VFS索引节点中是没有物理块指针数组的域,这个Ext2特有的域在调入内存后,就必须保存在ext2_inode_info这个结构中。此外,片作为Ext2比较特殊的地方,在ext2_inode_info中也保存了一些相关的域。另外,Ext2在分配一个块时通常还要预分配几个连续的块,因为它判断这些块很可能将要被访问,所以采用预分配的策略可以减少磁头的寻道时间。这些用于预分配操作的域也被保存在ext2_inode_info结构中。

组描述符

块组中,紧跟在超级块后面的是组描述符表,其每一项称为组描述符,是一个叫ext2_group_desc的数据结构,共 32 字节。它是用来描述某个块组的整体信息的。

1
2
3
4
5
6
7
8
9
10
11
struct ext2_group_desc
{
__u32 bg_block_bitmap; /*组中块位图所在的块号 */
__u32 bg_inode_bitmap; /*组中索引节点位图所在块的块号 */
__u32 bg_inode_table; /*组中索引节点表的首块号 */
__u16 bg_free_blocks_count; /*组中空闲块数 */
__u16 bg_free_inodes_count; /* 组中空闲索引节点数 */
__u16 bg_used_dirs_count; /*组中分配给目录的节点数 */
__u16 bg_pad; /*填充,对齐到字*/
__u32[3] bg_reserved; /*用NULL填充 12 个字节*/
}

每个块组都有一个相应的组描述符来描述它,所有的组描述符形成一个组描述符表,组描述符表可能占多个数据块。组描述符就相当于每个块组的超级块,一旦某个组描述符遭到破坏,整个块组将无法使用,所以组描述符表也像超级块那样,在每个块组中进行备份,以防遭到破坏。组描述符表所占的块和普通的数据块一样,在使用时被调入块高速缓存。

位图

在 Ext2 中,是采用位图来描述数据块和索引节点的使用情况的,每个块组中都有两个块,一个用来描述该组中数据块的使用情况,另一个描述该组中索引节点的使用情况。这两个块分别称为数据位图块和索引节点位图块。数据位图块中的每一位表示该组中一个块的使用情况,如果为 0,则表示相应数据块空闲,为 1,则表示已分配,索引节点位图块的使用情况类似。

Ext2 在安装后,用两个高速缓存分别来管理这两种位图块。每个高速缓存最多同时只能装入Ext2_MAX_GROUP_LOADED个位图块或索引节点块,当前该值定义为 8,所以也应该采用一些算法来管理这两个高速缓存,Ext2中采用的算法类似于LRU算法。

前面说过,ext2_sp_info结构中有 4 个域用来管理这两个高速缓存,其中s_block_bitmap_number[]数组中存有进入高速缓存的位图块号(即块组号,因为一个块组中只有一个位图块),而s_block_bitmap[]数组则存储了相应的块在高速缓存中的地址。s_inode_bitmap_number[]s_inode_bitmap[]数组的作用类似上面。

我们通过一个具体的函数来看Ext2如何通过这 4 个域管理位图块管理高速缓存。在Linux/fs/ext2/balloc.c中,有一个函数load__block_bitmap(),它用来调入指定的数据位图块,下面是它的执行过程。

  • 如果指定的块组号大于块组数,出错,结束。
  • 通过搜索s_block_bitmap_number[]数组可知位图块是否已进入高速缓存,如果已进入,则结束,否则,继续;
  • 如果块组数不大于Ext2_MAX_GROUP_LOADED,高速缓存可以同时装入所有块组的数据块位图块,不用采用什么算法,只要找到s_block_bitmap_number[]数组中第一个空闲元素,将块组号写入,然后将位图块调入高速缓存,最后将它在高速缓存中的地址写入s_block_bitmap[]数组中。
  • 如果块组数大于Ext2_MAX_GROUP_LOADED,则需要采用以下算法:
    • 首先通过s_block_bitmap_number[]数组判断高速缓存是否已满,若未满,则操作过程类似上一步,不同之处在于需要将s_block_bitmap_number[]数组各元素依次后移一位,而用空出的第一个元素存储块组号,s_block_bitmap[]也要做相同处理;
    • 如果高速缓存已满,则将s_block_bitmap[]数组最后一项所指的位图块从高速缓存中交换出去,然后调入所指定的位图块,最后对这两个数组做与上面相同的操作。

可以看出,这个算法很简单,就是对两个数组的简单操作,只是在块组数大于Ext2_MAX_GROUP_LOADED时,要求数组的元素按最近访问的先后次序排列,显然,这样也是为了更合理的进行高速缓存的替换操作。

索引节点表及实例分析

在两个位图块后面,就是索引节点表了,每个块组中的索引节点都存储在各自的索引节点表中,并且按索引节点号依次存储。索引节点表通常占好几个数据块,索引节点表所占的块使用时也像普通的数据块一样被调入块高速缓存。

fs/ext2/inode.c中,有一个ext2_read_inode(),用来读取指定的索引节点信息。其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
void ext2_read_inode (struct inode * inode)
{
struct buffer_head * bh;
struct ext2_inode * raw_inode;
unsigned long block_group;
unsigned long group_desc;
unsigned long desc;
unsigned long block;
unsigned long offset;
struct ext2_group_desc * gdp;
if ( ( inode->i_ino != EXT2_ROOT_INO && inode->i_ino != EXT2_ACL_IDX_INO &&
inode->i_ino != EXT2_ACL_DATA_INO && inode->i_ino < EXT2_FIRST_INO(inode->i_sb)) ||
inode->i_ino > le32_to_cpu(inode->i_sb->u.ext2_sb.s_es->s_inodes_count)) {
ext2_error (inode->i_sb, "ext2_read_inode", "bad inode number: %lu", inode->i_ino);
goto bad_inode;
}
block_group = (inode->i_ino – 1) / EXT2_INODES_PER_GROUP(inode->i_sb);
if (block_group >= inode->i_sb->u.ext2_sb.s_groups_count) {
ext2_error (inode->i_sb, "ext2_read_inode", "group >= groups count");
goto bad_inode;
}
group_desc = block_group >> EXT2_DESC_PER_BLOCK_BITS(inode->i_sb);
desc = block_group & (EXT2_DESC_PER_BLOCK(inode->i_sb) - 1);
bh = inode->i_sb->u.ext2_sb.s_group_desc[group_desc];
if (!bh) {
ext2_error (inode->i_sb, "ext2_read_inode", "Descriptor not loaded");
goto bad_inode;
}
gdp = (struct ext2_group_desc *) bh->b_data;
/*
* Figure out the offset within the block group inode table
*/
offset = ((inode->i_ino - 1) % EXT2_INODES_PER_GROUP(inode->i_sb)) *
EXT2_INODE_SIZE(inode->i_sb);
block = le32_to_cpu(gdp[desc].bg_inode_table) +
(offset >> EXT2_BLOCK_SIZE_BITS(inode->i_sb));
if (!(bh = sb_bread(inode->i_sb, block))) {
ext2_error (inode->i_sb, "ext2_read_inode",
"unable to read inode block - "
"inode=%lu, block=%lu", inode->i_ino, block);
goto bad_inode;
}
offset &= (EXT2_BLOCK_SIZE(inode->i_sb) - 1);
raw_inode = (struct ext2_inode *) (bh->b_data + offset);
inode->i_mode = le16_to_cpu(raw_inode->i_mode);
inode->i_uid = (uid_t)le16_to_cpu(raw_inode->i_uid_low);
inode->i_gid = (gid_t)le16_to_cpu(raw_inode->i_gid_low);
if(!(test_opt (inode->i_sb, NO_UID32))) {
inode->i_uid |= le16_to_cpu(raw_inode->i_uid_high) << 16;
inode->i_gid |= le16_to_cpu(raw_inode->i_gid_high) << 16;
}
inode->i_nlink = le16_to_cpu(raw_inode->i_links_count);
inode->i_size = le32_to_cpu(raw_inode->i_size);
inode->i_atime = le32_to_cpu(raw_inode->i_atime);
inode->i_ctime = le32_to_cpu(raw_inode->i_ctime);
inode->i_mtime = le32_to_cpu(raw_inode->i_mtime);
inode->u.ext2_i.i_dtime = le32_to_cpu(raw_inode->i_dtime);
/* We now have enough fields to check if the inode was active or not.
* This is needed because nfsd might try to access dead inodes
* the test is that same one that e2fsck uses
* NeilBrown 1999oct15
*/

if (inode->i_nlink == 0 && (inode->i_mode == 0 || inode->u.ext2_i.i_dtime)) {
/* this inode is deleted */
brelse (bh);
goto bad_inode;
}
inode->i_blksize = PAGE_SIZE; /* This is the optimal IO size (for stat), not the fs block size */
inode->i_blocks = le32_to_cpu(raw_inode->i_blocks);
inode->i_version = ++event;
inode->u.ext2_i.i_flags = le32_to_cpu(raw_inode->i_flags);
inode->u.ext2_i.i_faddr = le32_to_cpu(raw_inode->i_faddr);
inode->u.ext2_i.i_frag_no = raw_inode->i_frag;
inode->u.ext2_i.i_frag_size = raw_inode->i_fsize;
inode->u.ext2_i.i_file_acl = le32_to_cpu(raw_inode->i_file_acl);

if (S_ISREG(inode->i_mode))
inode->i_size |=((__u64)le32_to_cpu(raw_inode->i_size_high))<<32;
else
inode->u.ext2_i.i_dir_acl = le32_to_cpu(raw_inode->i_dir_acl);
inode->i_generation = le32_to_cpu(raw_inode->i_generation);
inode->u.ext2_i.i_prealloc_count = 0;
inode->u.ext2_i.i_block_group = block_group;
/*
* NOTE! The in-memory inode i_data array is in little-endian order
* even on big-endian machines: we do NOT byteswap the block numbers!
*/
for (block = 0; block < EXT2_N_BLOCKS; block++)
inode->u.ext2_i.i_data[block] = raw_inode->i_block[block];
if (inode->i_ino == EXT2_ACL_IDX_INO || inode->i_ino == EXT2_ACL_DATA_INO)
/* Nothing to do */ ;
else if (S_ISREG(inode->i_mode)) {
inode->i_op = &ext2_file_inode_operations;
inode->i_fop = &ext2_file_operations;
inode->i_mapping->a_ops = &ext2_aops;
} else if (S_ISDIR(inode->i_mode)) {
inode->i_op = &ext2_dir_inode_operations;
inode->i_fop = &ext2_dir_operations;
inode->i_mapping->a_ops = &ext2_aops;
} else if (S_ISLNK(inode->i_mode)) {
if (!inode->i_blocks)
inode->i_op = &ext2_fast_symlink_inode_operations;
else {
inode->i_op = &page_symlink_inode_operations;
inode->i_mapping->a_ops = &ext2_aops;
}
} else
init_special_inode(inode, inode->i_mode,

le32_to_cpu(raw_inode->i_block[0]));
brelse (bh);
inode->i_attr_flags = 0;

if (inode->u.ext2_i.i_flags & EXT2_SYNC_FL) {
inode->i_attr_flags |= ATTR_FLAG_SYNCRONOUS;
inode->i_flags |= S_SYNC;
}
if (inode->u.ext2_i.i_flags & EXT2_APPEND_FL) {
inode->i_attr_flags |= ATTR_FLAG_APPEND;
inode->i_flags |= S_APPEND;
}
if (inode->u.ext2_i.i_flags & EXT2_IMMUTABLE_FL) {
inode->i_attr_flags |= ATTR_FLAG_IMMUTABLE;
inode->i_flags |= S_IMMUTABLE;
}
if (inode->u.ext2_i.i_flags & EXT2_NOATIME_FL) {
inode->i_attr_flags |= ATTR_FLAG_NOATIME;
inode->i_flags |= S_NOATIME;
}
return;

bad_inode:
make_bad_inode(inode);
return;
}

这个函数的代码有 200 多行,为了突出重点,下面是对该函数主要内容的描述。

  • 如果指定的索引节点号是一个特殊的节点号(EXT2_ROOT_INOEXT2_ACL_IDX_INOEXT2_ACL_DATA_INO),或者小于第一个非特殊用途的节点号,即EXT2_FIRST_INO(为11),或者大于该文件系统中索引节点总数,则输出错误信息,并返回。
  • 用索引节点号整除每组中索引节点数,计算出该索引节点所在的块组号。即:block_group = (inode->i_ino - 1) / Ext2_INODES_PER_GROUP(inode->i_sb)
  • 找到该组的组描述符在组描述符表中的位置。因为组描述符表可能占多个数据块,所以需要确定组描述符在组描述符表的哪一块以及是该块中第几个组描述符。即:group_desc = block_group >> Ext2_DESC_PER_BLOCK_BITS(inode->i_sb)表示块组号整除每块中组描述符数,计算出该组的组描述符在组描述符表中的哪一块。
  • 块组号与每块中组的描述符数进行“与”运算,得到这个组描述符具体是该块中第几个描述符。即desc = block_group & (Ext2_DESC_PER_BLOCK(inode->i_sb) - 1)
  • 有了group_descdesc,接下来在高速缓存中找这个组描述符就比较容易了。即:bh = inode->i_sb->u.ext2_sb.s_group_desc[group_desc],首先通过s_group_desc[]数组找到这个组描述符所在块在高速缓存中的缓冲区首部;然后通过缓冲区首部找到数据区,即gdp = (struct ext2_group_desc *) bh->b_data
  • 找到组描述符后,就可以通过组描述符结构中的bg_inode_tabl找到索引节点表首块在高速缓存中的地址:
    • offset = ((inode->i_ino - 1) % Ext2_INODES_PER_GROUP(inode->i_sb)) * Ext2_INODE_SIZE(inode->i_sb)/*计算该索引节点在块中的偏移位置*/
    • block = le32_to_cpu(gdp[desc].bg_inode_table) + (offset >> Ext2_BLOCK_SIZE_BITS(inode->i_sb))/*计算索引节点所在块的地址*/
  • 代码中le32_to_cpu()le16_to_cpu()按具体CPU的要求进行数据的排列,在i386 处理器上访问Ext2文件系统时这些函数不做任何事情。因为不同的处理器在存取数据时在字节的排列次序上有所谓“big ending”和“little ending”之分。
  • 计算出索引节点所在块的地址后,就可以调用sb_bread()通过设备驱动程序读入该块。从磁盘读入的索引节点为ext2_Inode数据结构,前面我们已经看到它的定义。磁盘上索引节点中的信息是原始的、未经加工的,所以代码中称之为raw_INOde,即:raw_inode = (struct ext2_inode *) (bh->b_data + offset)
  • 与磁盘索引节点ext2_INOde相对照,内存中VFSinode结构中的信息则分为两部分,一部分是属于VFS层的,适用于所有的文件系统;另一部分则属于具体的文件系统,这就是inode中的那个union,因具体文件系统的不同而赋予不同的解释。对Ext2来说,这部分数据就是前面介绍的ext2_inode_info结构。至于代表着符号链接的节点,则并没有文件内容(数据),所以正好用这块空间来存储链接目标的路径名。ext2_inode_info结构的大小为 60 个字节。虽然节点名最长可达 255 个字节,但一般都不会太长,因此将符号链接目标的路径名限制在 60 个字节不至于引起问题。代码中inode->u.*设置的就是Ext2文件系统的
    特定信息。
  • 接着,根据索引节点所提供的信息设置inode结构中的inode_operations结构指针和file_operations结构指针,完成具体文件系统与虚拟文件系统VFS之间的连接。
  • 目前 2.4 版内核并不支持存取控制表`ACL,因此,代码中只是为之留下了位置,而暂时没做任何处理。
  • 另外,通过检查inode结构中的mode域来确定该索引节点是常规文件(S_ISREG)、目录(S_ISDIR)、符号链接(S_ISLNK)还是其他特殊文件而作不同的设置或处理。例如,对Ext2文件系统的目录节点,就将i_opi_fop分配设置为ext2_dir_inode_operationsext2_dir_operations。而对于Ext2常规文件,则除i_opi_fop以外,还设置了另一个指针a_ops,它指向一个address_apace_operation结构,用于文件到内存空间的映射或缓冲。对特殊文件,则通过init_special_inode()函数加以检查和处理。

Ext2 的目录项及文件的定位

文件系统一个很重要的问题就是文件的定位,如何通过一个路径来找到一个文件的具体位置,就要依靠ext2_dir_entry这个结构。

Ext2 目录项结构

Ext2中,目录是一种特殊的文件,它是由ext2_dir_entry这个结构组成的列表。这个结构是变长的,这样可以减少磁盘空间的浪费,但是,它还是有一定的长度方面的限制,一是文件名最长只能为 255 个字符。二是尽管文件名长度可以不限(在 255 个字符之内),但系统自动将之变成 4 的整数倍,不足的地方用NULL字符(\0)填充。目录中有文件和子目录,每一项对应一个ext2_dir_entry。该结构在include/Linux/ext2_fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
/*
* Structure of a directory entry
*/
#define EXT2_NAME_LEN 255
struct ext2_dir_entry {
__u32 inode; /* Inode number */
__u16 rec_len; /* Directory entry length */
__u16 name_len; /* Name length */
char name[EXT2_NAME_LEN]; /* File name */
};

这是老版本的定义方式,在ext2_fs.h中还有一种新的定义方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* The new version of the directory entry. Since EXT2 structures are
* stored in intel byte order, and the name_len field could never be
* bigger than 255 chars, it's safe to reclaim the extra byte for the
* file_type field.
*/
struct ext2_dir_entry_2 {
__u32 inode; /* Inode number */
__u16 rec_len; /* Directory entry length */
__u8 name_len; /* Name length */
__u8 file_type;
char name[EXT2_NAME_LEN]; /* File name */
};

其二者的差异在于,一是新版中结构名改为ext2_dir_entry_2;二是老版本中ext2_dir_entry中的name_len为无符号短整数,而新版中则改为 8 位的无符号字符,腾出一半用作文件类型。目前已定义的文件类型为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* Ext2 directory file types. Only the low 3 bits are used. The
* other bits are reserved for now.
*/
enum {
EXT2_FT_UNKNOWN, /*未知*/
EXT2_FT_REG_FILE, /*常规文件*/
EXT2_FT_DIR, /*目录文件*/
EXT2_FT_CHRDEV, /*字符设备文件*/
EXT2_FT_BLKDEV, /*块设备文件*/
EXT2_FT_FIFO, /*命名管道文件*/
EXT2_FT_SOCK, /*套接字文件*/
EXT2_FT_SYMLINK, /*符号连文件*/
EXT2_FT_MAX /*文件类型的最大个数*/
};

各种文件类型如何使用数据块

我们说,不管哪种类型的文件,每个文件都对应一个inode结构,在inode结构中有一个指向数据块的指针i_blaock,用来标识分配给文件的数据块。但是Ext2所定义的文件类型以不同的方式使用数据块。有些类型的文件不存放数据,因此,根本不需要数据块,下面对不同文件类型如何使用数据块给予说明。

常规文件是最常用的文件。常规文件在刚创建时是空的,并不需要数据块,只有在开始有数据时才需要数据块;可以用系统调用truncate()清空一个常规文件。

目录文件:Ext2 以一种特殊的方式实现了目录,这种文件的数据块中存放的就是ext2_dir_entry_2结构。如前所述,这个结构的最后一个域是可变长度数组,因此该结构的长度是可变的。在ext2_dir_entry_2结构中,因为rec_len域是目录项的长度,把它与目录项的起始地址相加就得到下一个目录项的起始地址,因此说,rec_len可以被解释为指向下一个有效目录项的指针。为了删除一个目录项,把ext2_dir_entry_2inode域置为 0 并适当增加前一个有效目录项rec_len域的值就可以了。

符号连:如果符号连的路径名小于 60 个字符,就把它存放在索引节点的i_blaock域,该域是由15 个 4 字节整数组成的数组,因此无需数据块。但是,如果路径名大于 60 个字符,就需要一个单独的数据块。

设备文件、管道和套接字:这些类型的文件不需要数据块。所有必要的信息都存放在索引节点中。

文件的定位

如果要找的文件为普通文件,则可通过文件所对应的索引节点找到文件的具体位置,如果是一个目录文件,则也可通过相应的索引节点找到目录文件具体所在,然后再从这个目录文件中进行下一步查找。

现在,我们来分析一下fs/ext2/dir.c中的函数ext2_find_entry(),该函数从磁盘上找到并读入当前节点的目录项,其代码及解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
 /*
* ext2_find_entry()
*
* finds an entry in the specified directory with the wanted name. It
* returns the page in which the entry was found, and the entry itself
* (as a parameter - res_dir). Page is returned mapped and unlocked.
* Entry is guaranteed to be valid.
*/
typedef struct ext2_dir_entry_2 ext2_dirent
struct ext2_dir_entry_2 * ext2_find_entry (struct inode * dir,
struct dentry *dentry, struct page ** res_page)
{
const char *name = dentry->d_name.name; /*目录项名*/
int namelen = dentry->d_name.len; /*目录项名的长度*/
unsigned reclen = EXT2_DIR_REC_LEN(namelen); /*目录项的长度*/
unsigned long start, n;
unsigned long npages = dir_pages(dir); /*把以字节为单位的文件大小转换为物理页面数*/
struct page *page = NULL;
ext2_dirent * de; /*de`为要返回的`Ext2`目录项
/*结构*/
/* OFFSET_CACHE */
*res_page = NULL;
start = dir->u.ext2_i.i_dir_start_lookup; /*目录项在内存的起始位置*/
if (start >= npages)
start = 0;
n = start;
do {
char *kaddr;
page = ext2_get_page(dir, n); /*从页面高速缓存中获得目录项所在的页面*/
if (!IS_ERR(page)) {
kaddr = page_address(page); /*获得page所对应的内核虚拟地址*/
de = (ext2_dirent *) kaddr; /*获得该目录项结构的起始地址*/
kaddr += PAGE_CACHE_SIZE - reclen; /* PAGE_CACHE_SIZE的大小为 1 个页面的大小,假定所有的目录项结构都存放在一个页面内*/
while ((char *) de <= kaddr) { /*循环查找,直到找到匹配的目录项*/
if (ext2_match (namelen, name, de))
goto found;
de = ext2_next_entry(de);
}
ext2_put_page(page); /*释放目录项所在的页面*/
}
if (++n >= npages)
n = 0;
} while (n != start);
return NULL;

found:
*res_page = page;
dir->u.ext2_i.i_dir_start_lookup = n;
return de;
}

链接文件

目录项中,每一对文件名和索引节点号的一个一一对应称为一个链接,这就使同一个索引节点号出现在多个链接中成为可能,也就是说,同一个索引节点号可以对应多个不同的文件名。这种链接称为硬链接,可以用ln命令为一个已存在的文件建立一个新的硬链接:

1
ln /home/user1/file1 /home/user1/file2

建立了一个文件file2,链接到file1上。file2file1有相同的索引节点号,也就是和file1共享同一个索引节点。在建立了一个新的硬链接后,这个索引节点中的i_links_count值将加 1,i_links_count的值反映了链接到这个索引节点上的文件数。

使用硬链接的好处如下所示。

  1. 由于在删除文件时,实际上先对i_links_count作减 1,如果i_links_count不为0,则结束,即仅仅删除了一个硬链接,具体文件的数据并没有被删除。只有在i_links_count为 0 时,才真正将文件从磁盘上删除。这样,你可以对重要的文件作多个链接,防止文件被误删除。
  2. 允许用户在不进入某个目录的情况下对该目录下面的文件进行处理。

由于同一个文件系统中,索引节点号是系统用来辨认文件的唯一标志,而两个不同的文件系统中,可能有索引节点号一样的文件,所以硬链接仅允许在同一个文件系统上进行,要在多个文件系统之间建立链接,必须用到符号链接。

符号链接与硬链接最大的不同就在于它并不与索引节点建立链接,也就是说当为一个文件建立一个符号链接时,索引节点的链接计数并不变化。当你删除一个文件时,它的符号链接文件也就失去了作用,而当你删去一个文件的符号链接文件,对该文件本身并无影响。

因为内核为符号链接文件也创建一个索引节点,但它跟普通文件的索引节点所有不同。关于符号链接的操作也就比较简单。对Ext2 文件系统来说,只有ext2_readlink()ext2_follow_link()函数,这是在fs/ext2/symlink.c中定义的:

1
2
3
4
struct inode_operations ext2_fast_symlink_inode_operations = {
readlink: ext2_readlink,
follow_link: ext2_follow_link,
};

ext2_readlink()函数的代码如下:

1
2
3
4
5
static int ext2_readlink(struct dentry *dentry, char *buffer, int buflen)
{
char *s = (char *)dentry->d_inode->u.ext2_i.i_data;
return vfs_readlink(dentry, buffer, buflen, s);
}

如前所述,对于Ext2文件系统,连接目标的路径在ext2_INOde_info结构(即inode结构的union域)的i_data域中存放,因此字符串s就存放有连接目标的路径名。

vfs_readlink()的代码在fs/namei.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int vfs_readlink(struct dentry *dentry, char *buffer, int buflen, const char *link)
{
int len;
len = PTR_ERR(link);
if (IS_ERR(link))
goto out;
len = strlen(link);
if (len > (unsigned) buflen)
len = buflen;
if (copy_to_user(buffer, link, len))
len = -EFAULT;
out:
return len;
}

从代码可以看出,该函数比较简单,即把连接目标的路径名拷贝到用户空间的缓冲区中,并返回路径名的长度。

ext2_follow_link()函数用于搜索符号连接所在的目标文件,其代码如下:

1
2
3
4
5
static int ext2_follow_link(struct dentry *dentry, struct nameidata *nd)
{
char *s = (char *)dentry->d_inode->u.ext2_i.i_data;
return vfs_follow_link(nd, s);
}

这个函数与ext2_readlink()类似,值得注意的是,从ext2_readlink()中对vfs_readlink()的调用意味着从较低的层次(Ext2 文件系统)回到更高的VFS层次。为什么呢?这是因为符号链接的目标有可能在另一个不同的文件系统中,因此,必须通过VFS来中转,在vfs_follow_link()中必须要调用路径搜索函数link_path_walk()来找到代表着连接对象的dentry结构,函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static inline int vfs_follow_link(struct nameidata *nd, const char *link)
{
int res = 0;
char *name;
if (IS_ERR(link))
goto fail;
if (*link == '/') {
path_release(nd);
if (!walk_init_root(link, nd))
/* weird __emul_prefix() stuff did it */
goto out;
}
res = link_path_walk(link, nd);
out:
if (current->link_count || res || nd->last_type!=LAST_NORM)
return res;
/*
* If it is an iterative symlinks resolution in open_namei() we
* have to copy the last component. And all that crap because of
* bloody create() on broken symlinks. Furrfu...
*/
name = __getname();
if (!name)
return -ENOMEM;
strcpy(name, nd->last.name);
nd->last.name = name;
return 0;
fail:
path_release(nd);
return PTR_ERR(link);
}

其中nameidata结构为:

1
2
3
4
5
6
7
struct nameidata { 
struct dentry *dentry;
struct vfsmount *mnt;
struct qstr last;
unsigned int flags;
int last_type;
};

last_type域的可能取值定义于fs.h中:

1
2
3
4
/*
* Type of the last component on LOOKUP_PARENT
*/
enum {LAST_NORM, LAST_ROOT, LAST_DOT, LAST_DOTDOT, LAST_BIND};

在路径的搜索过程中,这个域的值会随着路径名当前的搜索结果而变。例如,如果成功地找到了目标文件,那么这个域的值就变成了LAST_NORM;而如果最后停留在一个“.”上,则变成LAST_DOT

Qstr结构用来存放路径名中当前节点的名字、长度及哈希值,其定义于include/linux/dcache.h中:

1
2
3
4
5
6
7
8
9
/*
* "quick string" -- eases parameter passing, but more importantly
* saves "metadata" about the string (ie length and the hash).
*/
struct qstr {
const unsigned char * name;
unsigned int len;
unsigned int hash;
};

下面来对vfs_follow_link()函数的代码给予说明。

  • 如果符号链接的路径名是以“/”开头的绝对路径,那就要通过walk_init_root()从根节点开始查找。
  • 调用link_path_walk()函数查找符号链接所在目标文件对应的信息。从link_path_walk()返回时,返回值为 0 表示搜索成功,此时,nameidata结构中的指针dentry指向目标节点的dentry结构,指针mnt指向目标节点所在设备的安装结构,同时,这个结构中的last_type表示最后一个节点的类型,节点名则在类型为qstr结构的last中。该函数失败时,则函数返回值为一负的出错码,而nameidata结构中则提供失败的节点名等信息。
  • vfs_follow_link()返回值的含义与ink_path_walk()函数完全相同。

分配策略

一个好的分配物理块的策略,将导致文件系统性能的提高。一个好的思路是将相关的数据尽量存储在磁盘上相邻的区域,以减少磁头的寻道时间。Ext2 使用块组的优越性就体现出来了,因为,同一个组中的逻辑块所对应的物理块通常是相邻存储的。Ext2 企图将每一个新的目录分到它的父目录所在的组。所以,将父目录和子目录放在同一个组是有必要的。它还企图将文件和它的目录项分在同一个组,因为目录访问常常导致文件访问。当然如果组已满,则文件或目录可能分在某一个未满的组中。

分配新块的算法如下所述。

  1. 文件的数据块尽量和它的索引节点在同一个组中。
  2. 每个文件的数据块尽量连续分配。
  3. 父目录和子目录尽量在一个块组中。
  4. 文件和它的目录项尽量在同一个块组中。

数据块寻址

每个非空的普通文件都是由一组数据块组成。这些块或者由文件内的相对位置(文件块号)来表示,或者由磁盘分区内的位置(它们的逻辑块号)来表示。从文件内的偏移量f导出相应数据块的逻辑块号需要以下两个步骤。

  • 从偏移量f导出文件的块号,即偏移量f处的字符所在的块索引。
  • 把文件的块号转化为相应的逻辑块号。

只用关心文件的块号确实不错。但是,由于Ext2文件的数据块在磁盘上并不是相邻的,因此把文件的块号转化为相应的逻辑块号可不是那么直接了当。

因此,Ext2文件系统必须提供一种方法,用这种方法可以在磁盘上建立每个文件块号与相应逻辑块号之间的关系。在索引节点内部部分实现了这种映射,这种映射也包括一些专门的数据块,可以把这些数据块看成是用来处理大型文件的索引节点的扩展。磁盘索引节点的i_block域是一个有EXT2_N_BLOCKS个元素且包含逻辑块号的数组。在下面的讨论中,我们假定EXT2_N_BLOCKS的默认值为 15,如图 9.4 所示,这个数组表示一个大型数据结构的初始化部分。正如你从图中所看到的,数组的 15 个元素有 4 种不同的类型。

  • 最初的 12 个元素产生的逻辑块号与文件最初的 12 个块对应,即对应的文件块号从 0到 11。
  • 索引 12 中的元素包含一个块的逻辑块号,这个块代表逻辑块号的一个二级数组。这个数组对应的文件块号从 12 到b/4+11,这里b是文件系统的块大小(每个逻辑块号占 4 个字节,因此我们在式子中用 4 做除数)。因此,内核必须先用指向一个块的指针访问这个元素,然后,用另一个指向包含文件最终内容的块的指针访问那个块。
  • 索引 13 中的元素包含一个块的逻辑块号,这个块包含逻辑块号的一个二级数组;这个二级数组的数组项依次指向三级数组,这个三级数组存放的才是逻辑块号对应的文件块号,范围从b/4+12(b/4)^2+(b/4)+11
  • 最后,索引 14 中的元素利用了三级间接索引:第四级数组中存放的才是逻辑块号对应的文件块号,范围从(b/4)^2+(b/4)+12(b/4)^3+(b/4)^2+(b/4)+11

注意这种机制是如何支持小文件的。如果文件需要的数据块小于 12,那么两次访问磁盘就可以检索到任何数据:一次是读磁盘索引节点i_block数组的一个元素,另一次是读所需要的数据块。对于大文件来说,可能需要 3~4 次的磁盘访问才能找到需要的块。实际上,这是一种最坏的估计,因为目录项、缓冲区及页高速缓存都有助于极大地减少实际访问磁盘的次数。也要注意文件系统的块大小是如何影响寻址机制的,因为大的块大小允许Ext2把更多的逻辑块号存放在一个单独的块中。例如,如果块的大小是 1024 字节,并且文件包含的数据最多为 268KB,那么,通过直接映射可以访问文件最初的12KB数据,通过简单的间接映射可以访问剩余的13KB268KB的数据。对于 4096 字节的块,两次间接就完全满足了对2GB文件的寻址。

文件的洞

文件的洞是普通文件的一部分,它是一些空字符但没有存放在磁盘的任何数据块中。洞是UNIX文件一直存在的一个特点。例如,下列的Linux命令创建了第一个字节是洞的文件。

1
$ echo -n "X" | dd of=/tmp/hole bs=1024 seek=6

现在,/tmp/hole有 6145 个字符(6144 个NULL字符加一个X字符),然而,这个文件只占磁盘上一个数据块。

文件洞在Ext2的实现是基于动态数据块的分配:只有当进程需要向一个块写数据时,才真正把这个块分配给文件。每个索引节点的i_size域定义程序所看到的文件大小,包括洞,而i_blocks域存放分配给文件有效的数据块数(以 512 字节为单位)。

在前面dd命令的例子中,假定/tmp/hole文件被创建在块大小为 4096 的Ext2分区上。其相应磁盘索引节点的i_size域存放的数为 6145,而i_blocks域存放的数为 8(因为每 4096字节的块包含 8 个 512 字节的块)。i_block数组的第 2 个元素(对应块的文件块号为 1)存放已分配块的逻辑块号,而数组中的其他元素都为空。

分配一个数据块

当内核要分配一个新的数据块来保存Ext2普通文件的数据时,就调用ext2_get_block()函数。这个函数依次处理在“数据块寻址”部分所描述的那些数据结构,并在必要时调用ext2_alloc_block()函数在Ext2分区实际搜索一个空闲的块。

为了减少文件的碎片,Ext2文件系统尽力在已分配给文件的最后一个块附近找一个新块分配给该文件。如果失败,Ext2文件系统又在包含这个文件索引节点的块组中搜寻一个新的块。作为最后一个办法,可以从其他一个块组中获得空闲块。

Ext2 文件系统使用数据块的预分配策略。文件并不仅仅获得所需要的块,而是获得一组多达 8 个邻接的块。ext2_inode_info结构的i_prealloc_count域存放预分配给某一文件但还没有使用的数据块数,而i_prealloc_block域存放下一次要使用的预分配块的逻辑块号。

当下列情况发生时,即文件被关闭时,文件被删除时,或关于引发块预分配的写操作而言,有一个写操作不是顺序的时候,就释放预分配但一直没有使用的块。

下面我们来看一下ext2_get_block()函数,其代码在fs/ext2/inode.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
 /*
* Allocation strategy is simple: if we have to allocate something, we will
* have to go the whole way to leaf. So let's do it before attaching anything
* to tree, set linkage between the newborn blocks, write them if sync is
* required, recheck the path, free and repeat if check fails, otherwise
* set the last missing link (that will protect us from any truncate-generated
* removals - all blocks on the path are immune now) and possibly force the
* write on the parent block.
* That has a nice additional property: no special recovery from the failed
* allocations is needed - we simply release blocks and do not touch anything
* reachable from inode.
*/
static int ext2_get_block(struct inode *inode, long iblock, struct buffer_head *bh_result, int create)
{
int err = -EIO;
int offsets[4];
Indirect chain[4];
Indirect *partial;
unsigned long goal;
int left;
int depth = ext2_block_to_path(inode, iblock, offsets);
if (depth == 0)
goto out;
lock_kernel();
reread:
partial = ext2_get_branch(inode, depth, offsets, chain, &err);
/* Simplest case - block found, no allocation needed */
if (!partial) {
got_it:
bh_result->b_dev = inode->i_dev;
bh_result->b_blocknr = le32_to_cpu(chain[depth-1].key);
bh_result->b_state |= (1UL << BH_Mapped);
/* Clean up and exit */
partial = chain+depth-1; /* the whole chain */
goto cleanup;
}
/* Next simple case - plain lookup or failed read of indirect block */
if (!create || err == -EIO) {
cleanup:
while (partial > chain) {
brelse(partial->bh);
partial--;
}
unlock_kernel();
out:
return err;
}

/*
* Indirect block might be removed by truncate while we were
* reading it. Handling of that case (forget what we've got and
* reread) is taken out of the main path.
*/
if (err == -EAGAIN)
goto changed;
if (ext2_find_goal(inode, iblock, chain, partial, &goal) < 0)
goto changed;
left = (chain + depth) - partial;
err = ext2_alloc_branch(inode, left, goal,
offsets+(partial-chain), partial);
if (err)
goto cleanup;
if (ext2_splice_branch(inode, iblock, chain, partial, left) < 0)
goto changed;
bh_result->b_state |= (1UL << BH_New);
goto got_it;
changed:
while (partial > chain) {
brelse(partial->bh);
partial--;
}
goto reread;
}

函数的参数inode指向文件的inode结构;参数iblock表示文件中的逻辑块号;参数bh_result为指向缓冲区首部的指针,buffer_head结构已在上一章做了介绍;参数create表示是否需要创建。其中Indirect结构在同一文件中定义如下:

1
2
3
4
5
typedef struct {
u32 *p;
u32 key;
struct buffer_head *bh;
} Indirect

用数组chain[4]描述 4 种不同的索引,即直接索引、一级间接索引、二级间接索引、三级间接索引。举例说明这个结构各个域的含义。如果文件内的块号为 8,则不需要间接索引,所以只用chain[0]一个Indirect结构,p指向直接索引表下标为 8 处,即&inode->u.ext2_i.i_data[8];而key则持有该表项的内容,即文件块号所对应的设备上的块号;bhNULL,因为没有用于间接索引的块。如果文件内的块号为 20,则需要一次间接索引,索引要用chian[0]chain[1]两个表项。

第一个表项chian[0]中,指针bh仍为NULL,因为这一层没有用于间接索引的数据块;指针p指向&inode->u.ext2_i.i_data[12],即间接索引的表项;而key持有该项的内容,即对应设备的块号。chain[1]中的指针bh则指向进行间接索引的块所在的缓冲区,这个缓冲区的内容就是用作间接索引的一个整数数组,而p指向这个数组中下标为 8 处,而key则持有该项的内容。这样,根据具体索引的深度depth,数组chain[]中的最后一个元素,即chain[depth-1].key,总是持有目标数据块的物理块号。而从chain[]中第 1 个元素chain[0]到具体索引的最后一个元素chain[depth-1],则提供了具体索引的整个路径,构成了一条索引链,这也是数据名chain的由来。

了解了以上基本内容后,我们来看ext2_get_block()函数的具体实现代码。

  • 首先调用ext2_block_to_path()函数,根据文件内的逻辑块号iblock计算出这个数据块落在哪个索引区间,要采用几重索引(1 表示直接)。如果返回值为 0,表示出错,因为文件内块号与设备上块号之间至少也得有一次索引。出错的原因可能是文件内块号太大或为负值。
  • ext2_get_branch()函数深化从ext2_block_to_path()所取得的结果,而这合在一起基本上完成了从文件内块号到设备上块号的映射。从ext2_get_branch()返回的值有两种可能。一是,如果顺利完成了映射则返回值为NULL。二是,如果在某一索引级发现索引表内的相应表项为 0,则说明这个数据块原来并不存在,现在因为写操作而需要扩充文件的大小。此时,返回指向Indirect结构的指针,表示映射在此断裂。此外,如果映射的过程中出错,例如,读数据块失败,则通过err返回一个出错代码。
  • 如果顺利完成了映射,就把所得结果填入缓冲区结构bh_result中,然后把映射过程中读入的缓冲区(用于间接索引)全部释放。
  • 可是,如果ext2_get_branch()返回一个非 0 指针,那就说明映射在某一索引级上断裂了。根据映射的深度和断裂的位置,这个数据块也许是个用于间接索引的数据块,也许是最终的数据块。不管怎样,此时都应该为相应的数据块分配空间。
  • 要分配空间,首先应该确定从物理设备上何处读取目标块。根据分配算法,所分配的数据块应该与上一次已分配的数据块在设备上连续存放。为此目的,在ext2_inode_info结构中设置了两个域i_next_alloc_blocki_next_alloc_goal。前者用来记录下一次要分配的文件内块号,而后者则用来记录希望下一次能分配的设备上的块号。在正常情况下,对文件的扩充是顺序的,因此,每次所分配的文件内块号都与前一次的连续,而理想上来说,设备上的块号也同样连续,二者平行地向前推进。这种理想的“建议块号”就是由ext2_find_goal()函数来找的。
  • 设备上具体物理块的分配,以及文件内数据块与物理块之间映射的建立,都是调用ext2_alloc_branch()函数完成的。调用之前,先要算出还有几级索引需要建立。
  • ext2_alloc_branch()返回以后,我们已经从设备上分配了所需的数据块,包括用于间接索引的中间数据块。但是,原先映射开始断开的最高层上所分配的数据块号只是记录了其Indirect结构中的key域,却并没有写入相应的索引表中。现在,就要把断开的“树枝”接到整个索引树上,同时,还需要对文件所属inode结构中的有关内容做一些调整。这些操作都是由ext2_splice_branch()函数完成。

到此为止,万事具备,则转到标号got_it处,把映射后的数据块连同设备号置入bh_result所指的缓冲区结构中,这就完成了数据块的分配。

模块机制

Linux的整体式结构决定了要给内核增加新的成分也是非常困难,因此Linux提供了一种全新的机制—可装入模块(Loadable Modules,以下简称模块),用户可以根据自己的需要,在不需要对内核进行重新编译的条件下,模块能被动态地插入到内核或从内核中移走。

概述

什么是模块

模块是内核的一部分(通常是设备驱动程序),但是并没有被编译到内核里面去。它们被分别编译并连接成一组目标文件,这些文件能被插入到正在运行的内核,或者从正在运行的内核中移走,进行这些操作可以使用insmod(插入模块)或rmmod(移走模块)命令,或者,在必要的时候,内核本身能请求内核守护进程(kerned)装入或卸下模块。这里列出在Linux内核源程序中所包括的一些模块。

  • 文件系统: minix, xiafs, msdos, umsdos, sysv, isofs, hpfs, smbfs, ext3, nfs, proc等。
  • 大多数SCSI驱动程序: (如:aha1542, in2000)。
  • 所有的SCSI高级驱动程序: disk, tape, cdrom, generic。
  • 大多数以太网驱动程序。
  • 大多数CD-ROM驱动程序:

一旦一个Linux内核模块被装入,那么它就像任何标准的内核代码一样成为内核的一部分,它和任何内核代码一样具有相同的权限和职责。像所有的内核代码或驱动程序一样,Linux内核模块也能使内核崩溃。

Linux内核模块的优缺点

利用内核模块的动态装载性具有如下优点:

  • 将内核映像的尺寸保持在最小,并具有最大的灵活性;
  • 便于检验新的内核代码,而不需重新编译内核并重新引导。

但是,内核模块的引入也带来了如下问题:

  • 对系统性能和内存利用有负面影响;
  • 装入的内核模块和其他内核部分一样,具有相同的访问权限,因此,差的内核模块会导致系统崩溃;
  • 为了使内核模块访问所有内核资源,内核必须维护符号表,并在装入和卸载模块时修改这些符号表;
  • 有些模块要求利用其他模块的功能,因此,内核要维护模块之间的依赖性;
  • 内核必须能够在卸载模块时通知模块,并且要释放分配给模块的内存和中断等资源;
  • 内核版本和模块版本的不兼容,也可能导致系统崩溃,因此,严格的版本检查是必需的。

实现机制

数据结构

模块符号

为了方便起见,Linux把内核也看作一个模块。那么模块与模块之间如何进行交互呢,一种常用的方法就是共享变量和函数。但并不是模块中的每个变量和函数都能被共享,内核只把各个模块中主要的变量和函数放在一个特定的区段,这些变量和函数就统称为符号。对于内核模块,在kernel/ksyms.c中定义了从中可以“移出”的符号,例如进程管理子系统可以“移出”的符号定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* process memory management */
EXPORT_SYMBOL(do_mmap_pgoff);
EXPORT_SYMBOL(do_munmap);
EXPORT_SYMBOL(do_brk);
EXPORT_SYMBOL(exit_mm);
EXPORT_SYMBOL(exit_files);
EXPORT_SYMBOL(exit_fs);
EXPORT_SYMBOL(exit_sighand);
EXPORT_SYMBOL(complete_and_exit);
EXPORT_SYMBOL(__wake_up);
EXPORT_SYMBOL(__wake_up_sync);
EXPORT_SYMBOL(wake_up_process);
EXPORT_SYMBOL(sleep_on);
EXPORT_SYMBOL(sleep_on_timeout);
EXPORT_SYMBOL(interruptible_sleep_on);
EXPORT_SYMBOL(interruptible_sleep_on_timeout);
EXPORT_SYMBOL(schedule);
EXPORT_SYMBOL(schedule_timeout);
EXPORT_SYMBOL(jiffies);
EXPORT_SYMBOL(xtime);
EXPORT_SYMBOL(do_gettimeofday);
EXPORT_SYMBOL(do_settimeofday);

实际上,仅仅知道这些符号的名字是不够的,还得知道它们在内核映像中的地址才有意义。因此,内核中定义了如下结构来描述模块的符号:

1
2
3
4
5
struct module_symbol
{
unsigned long value; /*符号在内核映像中的地址*/
const char *name; /*指向符号名的指针*/
};

从后面对EXPORT_SYMBOL宏的定义可以看出,连接程序(ld)在连接内核映像时将这个结构存放在一个叫做__ksymtab的区段中,而这个区段中所有的符号就组成了模块对外“移出”的符号表,这些符号可供内核及已安装的模块来引用。而其他“对内”的符号则由连接程序自行生成,并仅供内部使用。

EXPORT_SYMBOL相关的定义在include/linux/module.h中:

1
2
3
4
5
6
7
8
9
10
11
12
#define __MODULE_STRING_1(x) #x
#define __MODULE_STRING(x) __MODULE_STRING_1(x)

#define __EXPORT_SYMBOL(sym, str) \
const char __kstrtab_##sym[] \
__attribute__((section(".kstrtab"))) = str; \
const struct module_symbol __ksymtab_##sym \
__attribute__((section("__ksymtab"))) = \
{ (unsigned long)&sym, __kstrtab_##sym }

#if defined(MODVERSIONS) || !defined(CONFIG_MODVERSIONS)
#define EXPORT_SYMBOL(var) __EXPORT_SYMBOL(var, __MODULE_STRING(var))

下面我们以EXPORT_SYMBOL(schedule)为例,来看一下这个宏的结果是什么。

首先EXPORT_SYMBOL(schedule)的定义成了__EXPORT_SYMBOL(schedule, "schedule")。而__EXPORT_SYMBOL()定义了两个语句,第 1 个语句定义了一个名为__kstrtab_schedule的字符串,将字符串的内容初始化为“schedule”,并将其置于内核映像中的.kstrtab区段,注意这是一个专门存放符号名字符串的区段。第 2 个语句则定义了一个名为__kstrtab_schedulemodule_symbol结构,将其初始化为{&schedule, __kstrtab_schedule}结构,并将其置于内核映像中的__ksymtab区段。这样,module_symbol结构中的域value的值就为schedule在内核映像中的地址,而指针name则指向字符串“schedule”。

模块引用(Module Reference)

模块引用是一个不太好理解的概念。 有些装入内核的模块必须依赖其他模块,例如,因为VFAT文件系统是FAT文件系统或多或少的扩充集,那么,VFAT文件系统依赖(depend)于FAT文件系统,或者说,FAT模块被VFAT模块引用,或换句话说,VFAT为“父”模块,`FAT为“子”模块。其结构如下:

1
2
3
4
5
6
struct module_ref
{
struct module *dep; /* “父”模块指针*/
struct module *ref; /* “子”模块指针*/
struct module_ref *next_ref;/*指向下一个子模块的指针*/
};

在这里“dep”指的是依赖,也就是引用,而“ref”指的是被引用。因为模块引用的关系可能延续下去,例如A引用BB有引用C,因此,模块的引用形成一个链表。

模块

模块的结构为module,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct module_persist; /* 待决定 */

struct module
{
unsigned long size_of_struct; /* 模块结构的大小,即`sizeof(module) */
struct module *next; /*指向下一个模块 */
const char *name; /*模块名,最长为 64 个字符*/
unsigned long size; /*以页为单位的模块大小*/
union
{
atomic_t usecount; /*使用计数,对其增减是原子操作*/
long pad;
} uc; /* Needs to keep its size - so says rth */
unsigned long flags; /* 模块的标志 */
unsigned nsyms; /* 模块中符号的个数 */
unsigned ndeps; /* 模块依赖的个数 */
struct module_symbol *syms; /* 指向模块的符号表,表的大小为`nsyms */
struct module_ref deps; /*指向模块引用的数组,大小为`ndeps */
struct module_ref *refs;
int (*init)(void); /* 指向模块的`init_module()`函数 */
void (*cleanup)(void); /* 指向模块的`cleanup_module()`函数 */
const struct exception_table_entry *ex_table_start;
const struct exception_table_entry *ex_table_end;
/* 以下域是在以上基本域的基础上的一种扩展,因此是可选的。可以调用`mod_member_present()函数来检查以下域的存在与否。*/
const struct module_persist *persist_start;/*尚未定义*/
const struct module_persist *persist_end;
int (*can_unload)(void);
int runsize /*尚未使用*/
const char *kallsyms_start; /*用于内核调试的所有符号 */
const char *kallsyms_end;
const char *archdata_start; /* 与体系结构相关的特定数据*/
const char *archdata_end;
const char *kernel_data; /*保留 */
};

其中,moudle中的状态,即flags的取值定义如下:

1
2
3
4
5
6
7
8
9
 /* Bits of module.flags. */
#define MOD_UNINITIALIZED 0 /*模块还未初始化*/
#define MOD_RUNNING 1 /*模块正在运行*/
#define MOD_DELETED 2 /*卸载模块的过程已经启动*/
#define MOD_AUTOCLEAN 4 /*安装模块时带有此标记,表示允许自动卸载模块*/
#define MOD_VISITED 8 /*模块被访问过*/
#define MOD_USED_ONCE 16 /*模块已经使用过一次*/
#define MOD_JUST_FREED 32 /*模块刚刚被释放*/
#define MOD_INITIALIZING 64 /*正在进行模块的初始化*/

如前所述,虽然内核不是可安装模块,但它也有符号表,实际上这些符号表受到其他模块的频繁引用,将内核看作可安装模块大大简化了模块设计。因此,内核也有一个module结构,叫做kernel_module,与kernel_module相关的定义在kernel/module_c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#if defined(CONFIG_MODULES) || defined(CONFIG_KALLSYMS)
extern struct module_symbol __start___ksymtab[];
extern struct module_symbol __stop___ksymtab[];
extern const struct exception_table_entry __start___ex_table[];
extern const struct exception_table_entry __stop___ex_table[];
extern const char __start___kallsyms[] __attribute__ ((weak));
extern const char __stop___kallsyms[] __attribute__ ((weak));
struct module kernel_module =
{
size_of_struct: sizeof(struct module),
name: "",
uc: {ATOMIC_INIT(1)},
flags: MOD_RUNNING,
syms: __start___ksymtab,
ex_table_start: __start___ex_table,
ex_table_end: __stop___ex_table,
kallsyms_start: __start___kallsyms,
kallsyms_end: __stop___kallsyms,
};

首先要说明的是,内核对可安装模块的的支持是可选的。如果在编译内核代码之前的系统配置阶段选择了可安装模块,就定义了编译提示CONFIG_MODULES,使支持可安装模块的代码受到编译。同理,对用于内核调试的符号的支持也是可选的。

凡是在以上初始值未出现的域,其值均为 0 或NULL。显然,内核没有init_module()cleanup_module()函数,因为内核不是一个真正的可安装模块。同时,内核没有deps数组,开始时也没有refs链。可是,这个结构的指针syms指向__start___ksymtab,这就是内核符号表的起始地址。符号表的大小nsyms为 0,但是在系统能初始化时会在init_module()函数中将其设置成正确的值。

在模块映像中也可以包含对异常的处理。发生于一些特殊地址上的异常,可以通过一种描述结构exception_table_entry规定对异常的反映和处理,这些结构在可执行映像连接时都被集中在一个数组中,内核的exception_table_entry结构数组就为__start___ex_table[]。当异常发生时,内核的异常响应处理程序就会先搜索这个数组,看看是否对所发生的异常规定了特殊的处理。

另外,从kernel_module开始,所有已安装模块的module结构都链在一起成为一条链,内核中的全局变量module_list就指向这条链:

1
struct module *module_list = &kernel_module;

实现机制的分析

当你新建立了最小内核,并且重新启动后,你可以利用实用程序insmodrmmod,随意地给内核插入或从内核中移走模块。如果kerneld守护进程启动,则由kerneld自动完成模块的插拔。有关模块实现的源代码在/kernel/module.c中,以下是对源代码中主要函数的分析。

启动时内核模块的初始化函数init_modules()

当内核启动时,要进行很多初始化工作,其中,对模块的初始化是在main.c中调用init_modules()函数完成的。实际上,当内核启动时唯一的模块就为内核本身,因此,初始化要做的唯一工作就是求出内核符号表中符号的个数:

1
2
3
4
5
6
7
8
/*
* Called at boot time
*/
void __init init_modules(void)
{
kernel_module.nsyms = __stop___ksymtab - __start___ksymtab;
arch_init_modules(&kernel_module);
}

因为内核代码被编译以后,连接程序进行连接时内核符号的符号结构就“移出”到了ksymtab区段,__start___ksymtab为第 1 个内核符号结构的地址,__stop___ksymtab为最后一个内核符号结构的地址,因此二者之差为内核符号的个数。其中,arch_init_modules是与体系结构相关的函数,对i386来说,arch_init_modulesinclude/i386/module.h中定义为:

1
#define arch_init_modules(x) do { } while (0)

可见,对i386来说,这个函数为空。

创建一个新模块

当用insmod给内核中插入一个模块时,意味着系统要创建一个新的模块,即为一个新的模块分配空间,函数sys_create_module()完成此功能,该函数也是系统调用screate_module()在内核的实现函数,其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/*
* Allocate space for a module.
*/
asmlinkage unsigned long
sys_create_module(const char *name_user, size_t size)
{
char *name;
long namelen, error;
struct module *mod;
unsigned long flags;
if (!capable(CAP_SYS_MODULE))
return -EPERM;
lock_kernel();
if ((namelen = get_mod_name(name_user, &name)) < 0) {
error = namelen;
goto err0;
}
if (size < sizeof(struct module)+namelen) {
error = -EINVAL;
goto err1;
}
if (find_module(name) != NULL) {
error = -EEXIST;
goto err1;
}
if ((mod = (struct module *)module_map(size)) == NULL) {
error = -ENOMEM;
goto err1;
}
memset(mod, 0, sizeof(*mod));
mod->size_of_struct = sizeof(*mod);
mod->name = (char *)(mod + 1);
mod->size = size;
memcpy((char*)(mod+1), name, namelen+1);
put_mod_name(name);
spin_lock_irqsave(&modlist_lock, flags);

mod->next = module_list;
module_list = mod; /* link it in */
spin_unlock_irqrestore(&modlist_lock, flags);
error = (long) mod;
goto err0;
err1:
put_mod_name(name);
err0:
unlock_kernel();
return error;
}

下面对该函数中的主要语句给予解释。

  • capable(CAP_SYS_MODULE)检查当前进程是否有创建模块的特权。
  • 参数size表示模块的大小,它等于module结构的大小加上模块名的大小,再加上模块映像的大小,显然,size不能小于后两项之和。
  • get_mod_name()函数获得模块名的长度。
  • find_module()函数检查是否存在同名的模块,因为模块名是模块的唯一标识。
  • 调用module_map()分配空间,对i386来说,就是调用vmalloc()函数从内核空间的非连续区分配空间。
  • memset()将分配给module结构的空间全部填充为 0,也就是说,把通过module_map()所分配空间的开头部分给了module结构;然后(module+1)表示从mod所指的地址加上一个module结构的大小,在此处放上模块的名字;最后,剩余的空间给模块映像。
  • 新建moudle结构只填充了三个值,其余值有待于从用户空间传递过来。
  • put_mod_name()释放局部变量name所占的空间。
  • 将新创建的模块结构链入module_list链表的首部。

初始化一个模块

从上面可以看出,sys_create_module()函数仅仅在内核为模块开辟了一块空间,但是模块的代码根本没有拷贝过来。实际上,模块的真正安装工作及其他的一些初始化工作由sys_init_module()函数完成,该函数就是系统调用init_module()在内核的实现代码。

该函数的原型为:

1
asmlinkage long sys_init_module(const char *name_user, struct module *mod_user)

其中参数name_user为用户空间的模块名,mod_user为指向用户空间欲安装模块的module结构。

该函数的主要操作描述如下。

  • sys_create_module()在内核空间创建了目标模块的module结构,但是这个结构还基本为空,其内容只能来自用户空间。因此,初始化函数就要把用户空间的module结构拷贝到内核中对应的module结构中。但是,由于内核版本在不断变化,因此用户空间module结构可能与内核中的module结构不完全一样。为了防止二者的module结构在大小上的不一致而造成麻烦,因此,首先要把用户空间的module结构中的size_of_struct域复制过来加以检查。
  • 通过了对结构大小的检查以后,先把内核中的module结构保存在堆栈中作为后备,然后就从用户空间拷贝其module结构。复制时是以内核中module结构的大小为准的,以免破坏内核中的内存空间。
  • 复制过来以后,还要检查module结构中各个域的合理性。
  • 最后,还要对模块名进行进一步的检查。虽然已经根据参数name_user从用户空间拷贝过来了模块名,但是这个模块名可能与用户空间module结构中所指示的模块名不一致,因此还要根据module结构的内容把模块映像中的模块名也复制过来,再与原来使用的模块名进行比较。
  • 经过以上检查以后,可以从用户空间把模块的映像复制过来了。
  • 模块之间的依赖关系还得进行修正,因为正在安装的模块可能要引用其他模块中的符号。虽然在用户空间已经完成了对这些符号的连接,但现在必须验证所依赖的模块在内核中还未被卸载。如果所依赖的模块已经不在内核中了,则对目标模块的安装就失败了。在这种情况下,应用程序(例如insmod)有责任通过系统调用delete_module()将已经创建的module结构从moudle_list中删除。
  • 至此,模块的安装已经基本完成,但还有一件事要做,那就是启动待执行模块的init_moudle()函数,每个模块块必须有一个这样的函数,module结构中的函数指针init就指向这个函数,内核可以通过这个函数访问模块中的变量和函数,或者说,init_moudle()是模块的入口,就好像每个可执行程序的入口都是main()一样。

卸载模块的函数sys_delete_module()

卸载模块的系统调用为delete_module(),其内核的实现函数为sys_delete_module(),该函数的原型为:

1
asmlinkage long sys_delete_module(const char *name_user)

与前面几个系统调用一样,只有特权用户才允许卸载模块。卸载模块的方式有两种,这取决于参数name_username_user是用户空间中的模块名。如果name_user非空,表示卸载一个指定的模块;如果为空,则卸载所有可以卸载的模块。

(1)卸载指定的模块:一个模块能否卸载,首先要看内核中是否还有其他模块依赖该模块,也就是该模块中的符号是否被引用,更具体地说,就是检查该模块的refs指针是否为空。此外,还要判断该模块是否在使用中,即__MOD_IN_USE()宏的值是否为 0。只有未被依赖且未被使用的模块才可以卸载。

卸载模块时主要要调用目标模块的cleanup_module()函数,该函数撤销模块在内核中的注册,使系统不再能引用该模块。

一个模块的拆除有可能使它所依赖的模块获得自由,也就是说,它所依赖的模块其refs队列变为空,一个refs队列为空的模块就是一个自由模块,它不再被任何模块所依赖。

(2)卸载所有可以卸载的模块:如果参数name_user为空,则卸载同时满足以下条件的所有模块。

  • 不再被任何模块所依赖。
  • 允许自动卸载,即安装时带有MOD_AUTOCLEAN标志位。
  • 已经安装但尚未被卸载,即处于运行状态。
  • 尚未被开始卸载。
  • 安装以后被引用过。
  • 已不再使用。

以上介绍了init_module()create_module()delete_module()三个系统调用在内核的实现机制,还有一个查询模块名的系统调用query_module()。这几个系统调用是在实现insmodrmmod实用程序的过程中被调用的。

装入内核模块request_module()函数

在用户通过insmod安装模块的过程中,内核是被动地接受用户发出的安装请求。但是,在很多情况下,内核需要主动地启动某个模块的安装。例如,当内核从网络中接收到一个特殊的packet或报文时,而支持相应规程的模块尚未安装;又如,当内核检测到某种硬件时,而支持这种硬件的模块尚未安装等等,类似情况还有很。在这种情况下,内核就调用request_module()主动地启动模块的安装。

request_module()函数在kernel/kmod.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
 /**
* request_module - try to load a kernel module
* @module_name: Name of module
*
* Load a module using the user mode module loader. The function returns
* zero on success or a negative errno code on failure. Note that a
* successful module load does not mean the module did not then unload
* and exit on an error of its own. Callers must check that the service
* they requested is now available not blindly invoke it.
*
* If module auto-loading support is disabled then this function
* becomes a no-operation.
*/
int request_module(const char * module_name)
{
pid_t pid;
int waitpid_result;
sigset_t tmpsig;
int i;
static atomic_t kmod_concurrent = ATOMIC_INIT(0);
#define MAX_KMOD_CONCURRENT 50 /* Completely arbitrary value - KAO */
static int kmod_loop_msg;

/* Don't allow request_module() before the root fs is mounted! */
if ( ! current->fs->root ) {
printk(KERN_ERR "request_module[%s]: Root fs not mounted\n", module_name);
return -EPERM;
}

/* If modprobe needs a service that is in a module, we get a recursive
* loop. Limit the number of running kmod threads to max_threads/2 or
* MAX_KMOD_CONCURRENT, whichever is the smaller. A cleaner method
* would be to run the parents of this process, counting how many times
* kmod was invoked. That would mean accessing the internals of the
* process tables to get the command line, proc_pid_cmdline is static
* and it is not worth changing the proc code just to handle this case.
* KAO.
*/
i = max_threads/2;
if (i > MAX_KMOD_CONCURRENT)
i = MAX_KMOD_CONCURRENT;
atomic_inc(&kmod_concurrent);
if (atomic_read(&kmod_concurrent) > i) {
if (kmod_loop_msg++ < 5)
printk(KERN_ERR
"kmod: runaway modprobe loop assumed and stopped\n");
atomic_dec(&kmod_concurrent);
return -ENOMEM;
}

pid = kernel_thread(exec_modprobe, (void*) module_name, 0);
if (pid < 0) {
printk(KERN_ERR "request_module[%s]: fork failed, errno %d\n", module_name, -pid);
atomic_dec(&kmod_concurrent);
return pid;
}
/* Block everything but SIGKILL/SIGSTOP */
spin_lock_irq(&current->sigmask_lock);
tmpsig = current->blocked;
siginitsetinv(&current->blocked, sigmask(SIGKILL) | sigmask(SIGS- TOP));
recalc_sigpending(current);
spin_unlock_irq(&current->sigmask_lock);

waitpid_result = waitpid(pid, NULL, __WCLONE);
atomic_dec(&kmod_concurrent);

/* Allow signals again.. */
spin_lock_irq(&current->sigmask_lock);
current->blocked = tmpsig;
recalc_sigpending(current);
spin_unlock_irq(&current->sigmask_lock);

if (waitpid_result != pid) {
printk(KERN_ERR "request_module[%s]: waitpid(%d,...) failed, errno %d\n",
module_name, pid, -waitpid_result);
}
return 0;
}

对该函数的解释如下。

  • 因为request_module()是在当前进程的上下文中执行的,因此首先检查当前进程所在的根文件系统是否已经安装。
  • request_module()的调用有可能嵌套,因为在安装过程中可能会发现必须先安装另一个模块。因此,就要对嵌套深度加以限制,程序中设置了一个静态变量kmod_concurrent,作为嵌套深度的计数器,并且还规定了嵌套深度的上限为MAX_KMOD_CONCURRENT。不过,对嵌套深度的控制还要考虑到系统中对进程数量的限制,即max_therads,因为在安装的过程中要创建临时的进程。
  • 通过了这些检查以后,就调用kernel_thread()创建一个内核线程exec_modprobe()exec_modprobe()接受要安装的模块名作为参数,调用execve()系统调用执行外部程序/sbin/modprobe,然后,modprobe程序真正地安装要安装的模块以及所依赖的任何模块。
  • 创建内核线程成功以后,先把当前进程信号中除SIGKILLSIGSTOP以外的所有信号都屏蔽掉,免得当前进程在等待模块安装的过程中受到干扰,然后就通过waitpid()使当前进程睡眠等待,直到exec_modprobe()内核线程完成模块安装后退出。当前进程被唤醒而从waitpid()返回时,又要恢复当前进程原有信号的设置。根据waitpid()的返回值可以判断exec_modprobe()操作的成功与否。如果失败,就通过prink()在系统的运行日志/var/log/message中记录一条出错信息。

模块的装入和卸载

实现机制

有两种装入模块的方法,第 1 种是用insmod命令人工把模块插入到内核,第 2 种是一种更灵活的方法,当需要时装入模块,这就是所谓的请求装入。

当内核发现需要一个模块时,例如,用户安装一个不在内核的文件系统时,内核将请求内核守护进程(kerneld)装入一个合适的模块。内核守护进程(kerneld)是一个标准的用户进程,但它具有超级用户权限。kerneld
常是在系统启动时就开始执行,它打开IPC(Inter-Process Communication)到内核的通道,内核通过给kerneld发送消息请求执行各种任务。

kerneld的主要功能是装入和卸载内核模块,但它也具有承担其他任务的能力,kerneld并不执行这些任务,它通过运行诸如insmod这样的程序来做这些工作,kerneld仅仅是内核的一个代理。insmod实用程序必须找到请求装入的内核模块,请求装入的内核模块通常保存在/lib/modules/kernel-version/目录下。内核模块被连接成目标文件,与系统中其他程序不同的是,这种目标文件是可重定位的(它们是a.outELF格式的目标文件)。insmods实用程序位于/sbin目录下,该程序执行以下操作。

  1. 从命令行中读取要装入的模块名。
  2. 确定模块代码所在的文件在系统目录树中的位置,即/lib/modules/kernel-version/目录。
  3. 计算存放模块代码、模块名和module结构所需要的内存区大小。调用create_module()系统调用,向它传递新模块的模块名和大小。
  4. QM_MODULES子命令反复调用query_module()系统调用来获得所有已安装模块的模块名。
  5. QM_SYMBOL子命令反复调用query_module()系统调用来获得内核符号表和所有已经安装到内核的模块的符号表。
  6. 使用内核符号表、模块符号表以及create_module()系统调用所返回的地址重新定位该模块文件中所包含的文件的代码。这就意味着用相应的逻辑地址偏移量来替换所有出现的外部符号和全局符号。
  7. 在用户态地址空间中分配一个内存区,并把module结构、模块名以及为正在运行的内核所重定位的模块代码的一个拷贝装载到这个内存区中。如果该模块定义了init_module()函数,那么module结构的init域就被设置成该模块的init_module()函数重新分配的地址。同理,如果模块定义了cleanup_module()函数,那么cleanup域就被设置成模块的cleanup_module()函数所重新分配的地址。
  8. 调用init_module()系统调用,向它传递上一步中所创建的用户态的内存区地址。
  9. 释放用户态内存区并结束。

为了取消模块的安装,用户需要调用/sbin/rmmod实用程序,它执行以下操作:

  1. 从命令行中读取要卸载的模块的模块名。
  2. 使用QM_MODULES子命令调用query_module()系统调用来取得已经安装的模块的链表。
  3. 使用QM_REFS子命令多次调用query_module()系统调用来检索已安装的模块间的依赖关系。如果一个要卸载的模块上面还安装有某一模块,就结束。
  4. 调用delete_module()系统调用,向其传递模块名。

内核版本

内核版本与模块版本的兼容性

内核版本的变化直接影响着曾经编写的模块是否能被新的内核认可。

例如,mydriver.o是基于Linux 2.2.1 内核编写和编译的,但是有人想把它装入到Linux2.2.2 的内核中,如果mydriver.o所调用的内核函数在 2.2.2 中有所变化,那么内核怎么知道内核版本与模块所调用函数的版本不一致呢?

为了解决这个问题,可装入模块的开发者就决定给模块也编以内核的版本号。在上面的例子中,mydriver.o目标文件的.modinfo特殊区段就含有“2.2.1”,因为mydriver.o的编译使用了来自Linux 2.2.1 的头文件,因此,当把该驱动程序装入到 2.2.2 内核时,insmod就会发现不匹配而失败,从而告诉你内核版本不匹配。

当以符号编码来编译内核或模块时,我们前面介绍的EXPORT_SYMBOL()宏定义的形式就有所不同,例如模块最常调用的内核函数register_chrdev(),其函数名的宏定义的在C中为:

1
#define register_chrdev register_chrdev_Rc8dc8350

把符号register_chrdev定义为register_chrdev加上一个后缀,这个后缀就是register_chrdev()函数实际源代码的校验和,只要函数的源代码改动一个字符,这个校验和也会发生变化。因此,尽管你在源代码中读到的函数名为register_chrdev,但C的预处理程序知道真正调用的是register_chrdev_Rc8dc8350

从版本 2.0 到 2.2 内核API的变化

用户空间与内核空间之间数据的拷贝

我们知道,内核空间与用户空间之间数据的拷贝要通过一个缓冲区,在以前的内核中,对这个缓冲区有效性的检查是通过verify_area()函数的,如果这个缓冲区有效,则调用memcpy_tofs()把数据从内核空间拷贝到用户空间。但是,verify_area()函数是低效的,因为它必须检查每一个页面,看其是否是一个有效的映射。

在 2.1.x(以及后来的版本)中,取消了对用户空间缓冲区每个页面的检查,取而代之的是用异常来处理非法的缓冲区。这就避免了在SMP上的竞争条件及有效性检查。verify_area()函数现在仅仅用来检查缓冲区的范围是否合法,这是一个快速的操作。因此,如果你要把数据拷贝到用户空间,就使用copy_to_user()函数,其用法如下:

1
if ( copy_to_user (ubuff, kbuff, length) ) return -EFAULT;

这里,ubuff是用户空间的缓冲区,kbuff是内核空间的缓冲区,而length是要拷贝的字节数。如果copy_to_user()函数返回一个非 0 值,就意味着某些数据没有被拷贝(由于无效的缓冲区)。在这种情况下,返回-EFAULT以表示缓冲区是无效的。类似地,从用户空间拷贝到内核空间的用法如下:

1
if ( copy_from_user (kbuff, ubuff, length) ) return -EFAULT;

注意,这两个函数都自动调用verify_area()函数,你没必要自己调用它。

文件操作的方法

在内核 2.1.42 版本以后,增加了一个目录高速缓存(dcache)层,这个层加速了目录搜索操作(大约能提高 4 倍),但同时也需要改变文件操作接口。对驱动程序的编写者,这个变化相对比较简单:原来传递给file_operations某些方法的参数为struct inode *,现在改为struct dentry *。如果你的驱动程序要引用inode,下面代码就足够了:

1
struct inode *inode = dentry->d_inode;

假定dentry是目录项的变量名。实际上,有些驱动程序就不涉及inode,因此可忽略这一步。然而,你必须改变的是,重新声明file_operations中的函数。注意,某些方法还是把inode而不是dentry作为参数来传递。

有些方法甚至没有提供dentry,仅仅提供了struct file *,在这种情况下,你可以用下面的代码提取出dentry

1
struct dentry *dentry = file->f_dentry;

假定file是指向file指针的变量名。

下面是内核 2.2.x文件操作的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
loff_t llseek (struct file *, loff_t, int); 
ssize_t read (struct file *, char *, size_t, loff_t *);
ssize_t write (struct file *, const char *, size_t, loff_t *);
int readdir (struct file *, void *, filldir_t);
unsigned int poll (struct file *, struct poll_table_struct *);
int ioctl (struct inode *, struct file *, unsigned int, unsigned long);
int mmap (struct file *, struct vm_area_struct *);
int open (struct inode *, struct file *);
int flush (struct file *);
int release (struct inode *, struct file *);
int fsync (struct file *, struct dentry *);
int fasync (int, struct file *, int);
int check_media_change (kdev_t dev);
int revalidate (kdev_t dev);
int lock (struct file *, int, struct file_lock *);

在你声明自己的file_operations结构时,应当确保把自己的方法放置在与上面一致的位置。不过,还有另外一种我们提到过的方法,其形式如下:

1
2
3
4
5
6
static struct file_operations mydev_fops = {
open: mydev_open,
release: mydev_close,
read: mydev_read,
write: mydev_write,
};

gcc编译程序能够把这些方法放在正确的位置,并把未定义的方法置为NULL

另外还值得注意的是,Linux 2.2中引入了pread()pwrite()系统调用,这就允许进程可以从一个文件的指定位置进行读和写,这与另一个lseek()系统调用类似但不完全相同。其不同之处是,pread()pwrite()系统调用能对一个文件进行并发访问。为了对这些新的系统调用进行支持,在read()write()方法中增加了第 4 个(或最后一个)参数,这个参数是指向offset的一个指针。

信号的处理

新增加的signal_pending()函数时的信号的处理更加容易和健壮。2.0 版处理方式是:

1
if (current->signal & ~current->blocked)

2.2 版是:

1
if ( signal_pending (current) ) 

IO事件的多路技术

select()poll()系统调用可以让一个进程同时处理多个文件描述符,也就是说可以使进程检测同时等待的多个I/O设备,当没有设备准备好时,select()阻塞,其中任一设备准备好时,select()就返回。在Linux 2.0中,驱动程序通过在file_operations结构中提供select()方法来支持这种技术,而在Linux 2.2中,驱动程序必须提供的是poll()方法,这种方法具有更大的灵活性。

丢弃初始化函数和数据

当内核初始化全部完成以后,就可以丢弃以后不再需要的函数和数据,这意味着存放这些函数和数据的内存可以重新得到使用。但这仅仅应用在编译进内核的驱动程序,而不适合于可安装模块。

定义一个以后要丢弃的变量的形式为:

1
static int mydata __initdata = 0;

定义一个以后要丢弃的函数的形式为:

1
2
3
__initfunc(void myfunc (void))
{
}

__initdata__initfunc关键字把代码和数据放在一个特殊的“初始化”区段。较理想的做法应当是,尽可能地把更多的代码和数据放在初始化区段,当然,这里的代码和数据指的是初始化以后(当init进程启动时)不再使用的。

定时的设定

新增加了一些定时设定函数。Linux 2.0 设定定时是这样的:

1
2
current->timeout = jiffies + timeout;
schedule ();

Linux 2.2 是:

1
timeout = schedule_timeout (timeout);

同理,如果你需要在一个等待队列上睡眠,但需要定时,Linux 2.0 操作是:

1
2
current->timeout = jiffies + timeout;
interruptible_sleep_on (&wait);

Linux 2.2 是:

1
timeout = interruptible_sleep_on_timeout (&wait, timeout);

注意,这些新函数返回的是剩余时间的多少。在某些情况下,这些函数在定时时间还没到就返回。

向后兼容的宏

你可以把下面的代码包含进自己编写的代码中,这样就不必费神维护是为Linux 2.2.x还是为Linux 2.0.x所编译的驱动程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <linux/version.h>
#ifndef KERNEL_VERSION
# define KERNEL_VERSION(a,b,c) (((a) << 16) + ((b) << 8) + (c)
#endif
#if (LINUX_VERSION_CODE < KERNEL_VERSION(2,1,0))
# include <linux/mm.h>
static inline unsigned long copy_to_user (void *to, const void *from,
unsigned long n)
{
if ( !verify_area (VERIFY_WRITE, to, n) ) return n;
memcpy_tofs (to, from, n);
return 0;
}
static inline unsigned long copy_from_user (void *to, const void *from,
unsigned long n)
{
if ( !verify_area (VERIFY_READ, from, n) ) return n;
memcpy_fromfs (to, from, n);
return 0;
}
# define __initdata
# define __initfunc(func) func
#else
# include <asm/uaccess.h>
#endif
#ifndef signal_pending
# define signal_pending(p) ( (p)->signal & ~(p)->blocked )
#endif

把内核 2.2 移植到内核 2.4

使用设备文件系统(DevFS)

DevFS设备文件系统是Linux 2.4一个全新的功能,它主要为了有效地管理/dev目录而开发的。UNIX/Linux中所有的目录都是层次结构,唯独/dev目录是一维结构(没有子目录),这就直接影响着访问的效率及管理的方便与否。另外,/dev目录下的节点并不是按实际需要创建的,因此,该目录下存在大量实际不用的节点,但一般也不能轻易删除。

理想的/dev目录应该是层次的、其规模是可伸缩的。DevFS就是为达到此目的而设计的。它在底层改写了用户与设备交互的方式和途径。它会给用户在两方面带来影响。

  • 首先,几乎所有的设备名称都做了改变,例如:/dev/hda是用户的硬盘,现在可能被定位于/dev/ide0/...。这一修改方案增大了设备可用的名字空间,且容许USB类和类似设备的系统集成。
  • 其次,不再需要用户自己创建设备节点。DevFS/dev目录最初是空的,里面特定的文件是在系统启动时、或是加载模块后驱动程序装入时建立的。当模块和驱动程序卸载时,文件就消失了。

字符设备的注册和注销调用register_chrdev()unregister_chrdev()函数。注册了设备驱动程序以后,驱动程序应该调用devfs_register()登记设备的入口点,所谓设备的入口点就是设备所在的路径名;在注销设备驱动程序之前,应该调用devfs_unregister()取消注册。

devfs_register()devfs_unregister()函数原型为:

1
2
3
4
5
devfs_handle_t devfs_register(devfs_handle_t dir, const char *name,
unsigned int flags,
unsigned int major, unsigned int minor,
umode_t mode, void *ops, void *info);
void devfs_unregister(devfs_handle_t de);

其中devfs_handle_t表示DevFS的句柄(一个结构类型),每个参数的含义如下。

  • dir:我们要创建的文件所在的DevFS的句柄。NULL意味着这是DevFS的根,即/dev
  • flags:设备文件系统的标志,缺省值为DEVFS_FL_DEFAULT
  • major:主设备号,普通文件不需要这一参数。
  • minor:次设备号,普通文件也不需要这一参数。
  • mode:缺省的文件模式(包括属性和许可权)。
  • ops:指向file_operationsblock_device_operations结构的指针。
  • info:任意一个指针,这个指针将被写到file结构的private_data域。

例如,如果我们要注册的设备驱动程序叫做DEVICE_NAME,其主设备号为MAJOR_NR,次设备号为MINOR_NR,缺省的文件操作为device_fops,则该设备驱动程序的init_module()函数和cleanup_module()函数如下:

1
2
3
4
5
6
7
8
9
10
int init_module(void)
{
int ret;
if((ret = register_chrdev(MAJOR_NR, DEVICE_NAME, &device_fops))==0)
return ret;
}
void cleanup_module(void)
{
unregister_chrdev(MAJOR_NR, DEVICE_NAME);
}

对以上代码进行改写以支持设备文件系统(假定设备入口点的名字为DEVICE_ENTRY)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <linux/devfs_fs_kernel.h>
devfs_handle_t devfs_handle;
int init_module(void)
{
int ret;
if ((ret = devfs_register_chrdev(MAJOR_NR, DEVICE_NAME, &device_fops)) == 0)
return ret;
devfs_handle = devfs_register(NULL, DEVICE_ENTRY, DEVFS_FL_DEFAULT,
MAJOR_NR, MINOR_NR, S_IFCHR | S_IRUGO | S_IWUSR,
&device_fops, NULL);
}
void cleanup_module(void)
{
devfs_unregister_chrdev(MAJOR_NR, DEVICE_NAME);
devfs_unregister(devfs_handle);
}

devfs_mk_dir()用来创建一个目录,这个函数返回DevFS的句柄,这个句柄用作devfs_register的参数dir

例如,为了在/dev/mydevice目录下创建一个设备设备入口点,则进行如下操作:

1
2
3
4
devfs_handle = devfs_mk_dir(NULL, "mydevice", NULL);
devfs_register(devfs_handle, DEVICE_ENTRY, DEVFS_FL_DEFAULT,
MAJOR_NR, MINOR_NR, S_IFCHR | S_IRUGO | S_IWUSR,
&device_fops, NULL);

注册和注销块设备的函数为:

1
2
devfs_register_blkdev()
devfs_unregister_blkdev ()

使用/proc文件系统

/proc是一个特殊的文件系统,其安装点一般都固定为/proc。这个文件系统中所有的文件都是特殊文件,其内容不存在于任何设备上。每当创建一个进程时,系统就以其pid为文件名在这个目录下建立起一个特殊文件,使得通过这个文件就可以读/写相应进程的用户空间,而当进程退出时则将此文件删除。

/proc文件系统中的目录项结构dentry,在磁盘上没有对应结构,而以内存中的proc_dir_entry结构来代替,在include/linux/proc_fs.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct proc_dir_entry {
unsigned short low_ino;
unsigned short namelen;
const char *name;
mode_t mode;
nlink_t nlink;
uid_t uid;
gid_t gid;
unsigned long size;
struct inode_operations * proc_iops;
struct file_operations * proc_fops;
get_info_t *get_info;
struct module *owner;
struct proc_dir_entry *next, *parent, *subdir;
void *data;
read_proc_t *read_proc;
write_proc_t *write_proc;
atomic_t count; /* use count */
int deleted; /* delete flag */
kdev_t rdev;
};

注册和注销/proc文件系统的机制已经发生了变化。在Linux 2.2中,proc_dir_entry结构是静态定义和初始化的,而在Linux 2.4中,这个数据结构被动态地创建。

当传送的数据小于一个页面大小时,/proc文件系统的实现可以通过proc_dir_entry中的read_procwrite_proc方法来实现。假定我们要注册的/proc文件系统名为foo,在Linux 2.2中的代码如下。

foo_proc_entry结构的初始化:

1
2
3
4
5
6
7
struct proc_dir_entry foo_proc_entry = {
namelen: 3,
name : "foo",
mode : S_IRUGO | S_IWUSR,
read_proc : foo_read_proc,
write_proc : foo_write_proc,
};

proc文件系统根节点,即目录项proc_root的初始化为:
struct proc_dir_entry proc_root = {
low_ino: PROC_ROOT_INO,
namelen: 5,
name: “/proc”,
mode: S_IFDIR | S_IRUGO | S_IXUGO,
nlink: 2,
proc_iops: &proc_root_inode_operations,
proc_fops: &proc_root_operations,
parent: &proc_root,
};

1
2
3
4

注册:
```C
proc_register(&proc_root, &foo_proc_entry);

注销:

1
proc_unreigster(&proc_root, foo_proc_entry.low_ino);

Linux 2.4中注册:

1
2
3
4
5
struct proc_dir_entry *ent;
if ((ent = create_proc_entry("foo", S_IRUGO | S_IWUSR, NULL)) != NULL) {
ent->read_proc = foo_read_proc;
ent->write_proc = foo_write_proc;
}

注销:

1
remove_proc_entry("foo", NULL);

当传送数据大于一个页面大小时,/proc文件系统的实现应当通过完整的file结构来实现,在Linux 2.2中相关数据结构为:

1
2
3
4
5
6
7
8
9
10
11
12
struct file_operations foo_file_ops = {
......
};
struct inode_operations foo_inode_ops = {
default_file_ops : &foo_file_ops;
};
struct proc_dir_entry foo_proc_entry = {
namelen: 3,
name : "foo",
mode : S_IRUGO | S_IWUSR,
ops : &foo_inode_ops,
};

注册为:

1
proc_register(&proc_root, &foo_proc_entry);

注销为:

1
proc_unreigster(&proc_root, foo_proc_entry.low_ino);

Linux 2.4中相关数据结构为:

1
2
3
4
5
6
struct file_operations foo_file_ops = { 
......
};
struct inode_operations foo_inode_ops = {
......
};

注册为:

1
2
3
4
5
struct proc_dir_entry *ent;
if ((ent = create_proc_entry("foo", S_IRUGO | S_IWUSR, NULL)) != NULL) {
ent->proc_iops = &foo_inode_ops;
ent->proc_fops = &foo_file_ops;
}

注销为:

1
remove_proc_entry("foo", NULL);

块设备驱动程序

块设备驱动程序的界面有了很大的变化,新引入了block_device_operations结构,缓冲区高速缓存的接口也发生了变化。

Linux 2.2中,块设备与字符设备驱动程序的注册基本相同,都是通过file_operations结构进行的。在Linux 2.4中,引入了新结构block_device_operations

例如,块设备的名字为DEVICE_NAME,主设备号为MAJOR_NR,则在Linux 2.2中如下所述。

数据结构为:

1
2
3
4
5
6
7
8
struct file_operations device_fops = {
open : device_open,
release : device_release,
read : block_read,
write : block_write,
ioctl : device_ioctl,
fsync : block_fsync,
};

注册为:

1
register_blkdev(MAJOR_NR, DEVICE_NAME, &device_fops);

Linux 2.4中数据结构为:

1
2
3
4
5
6
#include <linux/blkpg.h>
struct block_device_operations device_fops = {
open : device_open,
release : device_release,
ioctl : device_ioctl,
};

注册为:

1
register_blkdev(MAJOR_NR, DEVICE_NAME, &device_fops);

在块设备驱动程序中,有一个“请求函数”来处理缓冲区高速缓存的请求。在Linux 2.2中,请求函数的注册和定义如下。函数原型为:

1
void device_request(void);

注册为:

1
blk_dev[MAJOR_NR].request_fn = &device_request;

请求函数的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void device_request(void)
{
while (1) {
INIT_REQUEST;
......
switch (CURRENT->cmd) {
case READ :
// read
break;
case WRITE :
// write
break;
default :
end_request(0);
continue;
}
end_request(1);
}
}

Linux 2.4中函数原型为:

1
int device_make_request(request_queue_t *q, int rw, struct buffer_head *sbh);

注册:

1
blk_queue_make_request(BLK_DEFAULT_QUEUE(MAJOR_NR),&device_make_request);

请求函数的定义为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int device_make_request(request_queue_t *q, int rw, struct buffer_head *sbh)
{
char *bdata;
int ret = 0;
......
bdata = bh_kmap(sbh);
switch (rw) {
case READ :
// read
break;
case READA :
// read ahead
break;
case WRITE :
// write
break;
default :
goto fail;
}
ret = 1;
fail:
sbh->b_end_io(sbh, ret);
return 0;
}

编写内核模块

简单内核模块的编写

一个内核模块应当至少有两个函数,第 1 个为init_moudle,当模块被插入到内核时调用它;第 2 个为cleanup_module,当模块从内核移走时调用它。init_module的主要功能是在内核中注册一个处理某些事的处理程序。cleanup_module函数的功能是取消init_module所做的事情。

下面看一个例子“Hello,world!”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* hello.c
* "Hello,world" */
/*下面是必要的头文件*/
#include <linux/kernel.h> /* 内核模块共享这个头文件 */
#include <linux/module.h> /* 这是一个模块 */
/* 处理CONFIG_MODVERSIONS */
#if CONFIG_MODVERSIONS==1
#define MODVERSIONS
#include <linux/modversions.h>
#endif
/*初始化模块 */
int init_module()
{
printk("Hello, world - this is a simple module\n");
/* 如果返回一个非 0,那就意味着init_module失败,不能装载该内核模块*/
return 0;
}
/* 取消init_module所作的工作*/
void cleanup_module()
{
printk("the module exits the kernel\n");
}

内核模块的Makefiles文件

内核模块不是独立的可执行文件,但在运行时其目标文件被连接到内核中,因此,编译内核模块时必须加-c标志,另外, 还得加确定的预定义符号。

  • __KERNEL__ — 相当于告诉头文件,这个代码必须运行在内核模式下,而不是用户进程的一部分。
  • MODULE — 这个标志告诉头文件,要给出适当的内核模块的定义。
  • LINUX — ,从技术上讲,这个标志不是必要的。但是,如果你希望写一个比较正规的内核模块,在多个操作系统上能进行编译,这个标志将会使你感到方便。它可以允许你在独立于操作系统的部分进行常规的编译。

还有其他的一些标志是否被包含进去,这取决于编译模块时的选项。如果你不能明确内核怎样被编译,可以在in/usr/include/linux/config.h中查到。

  • __SMP__ — ,对称多处理机。如果内核被编译成支持对称多处理机,这必须被定义。如果你要用对称多处理机,还有一些其他的事情必须做,
    在此不进行详细的讨论。
  • CONFIG_MODVERSIONS — ,如果CONFIG_MODVERSIONS被激活,当编译内核模块时,你必须定义它,并且包含进usr/include/linux/modversions.h中,这也可以由代码本身来做。

Makefile举例

1
2
3
4
5
6
7
CC=gcc
MODCFLAGS := -Wall -DMODULE -D__KERNEL__ -DLINUX
hello.o: hello.c /usr/include/linux/version.h
$(CC) $(MODCFLAGS) -c hello.c
echo insmod hello.o to turn it on
echo rmmod hello to turn it off
echo

现在,你以root的身份对这个内核模块进行编译并连接后,形成一个目标文件hello.o,然后用insmodhello插入到内核,也可以用rmmod命令把hello从内核移走。如果你想知道结果如何,你可以查看/proc/modules文件,从中会找到一个新加入的模块。

内核模块的多个文件

有时,可以从逻辑上把内核模块分成几个源文件,在这种情况下,需要做以下事情。

(1)除了一个源文件外,在其他所有的源文件中都要增加一行#define __NO_VERSION__,这是比较重要的,因为module.h通常包括了对kernel_version的定义,kernel_version是一个具有内核版本信息的全局变量,并且编译模块时要用到它。如果你需要version.h
你就必须自己包含它,但如果你定义了__NO_VERSION__module.h就不会被包含进去。

(2)像通常那样编译所有的源文件。

(3)把所有的目标文件结合到一个单独文件中。在x86下,这样连接:

1
ld -m elf_i386 -r -o <name of module>.o <第 1 个源文件>.o <第 2 个源文件>.o

请看下面例子start.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* start.c
*
* "Hello, world"
* 这个文件包含了启动例程
*/
/*下面是必要的头文件 */
/* 内核模块的标准形式*/
#include <linux/kernel.h>
#include <linux/module.h>
/* 处理`CONFIG_MODVERSIONS */
#if CONFIG_MODVERSIONS==1
#define MODVERSIONS
#include <linux/modversions.h>
#endif
/* 初始化模块 */
int init_module()
{
printk("Hello, world - this is the kernel speaking\n");
return 0;
}

另一个例子stop.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* stop.c */
/* 这个文件仅仅包含`stop`例程。*/
/* 必要的头文件*/
#include <linux/kernel.h>
#define __NO_VERSION__
#include <linux/module.h>
#include <linux/version.h>
#if CONFIG_MODVERSIONS==1
#define MODVERSIONS
#include <linux/modversions.h>
#endif
void cleanup_module()
{
printk("Short is the life of a kernel module\n");
}

下面是多个文件的Makefile

1
2
3
4
5
6
7
8
CC=gcc
MODCFLAGS := -Wall -DMODULE -D__KERNEL__ -DLINUX
hello.o: start.o stop.o
ld -m elf_i386 -r -o hello.o start.o stop.o
start.o: start.c /usr/include/linux/version.h
$(CC) $(MODCFLAGS) -c start.c
stop.o: stop.c /usr/include/linux/version.h
$(CC) $(MODCFLAGS) -c stop.c

hello是模块名,它占用了一页(4KB)的内存,此时,没有其他内核模块依赖它。

要从内核移走这个模块,敲入rmmod hello,注意,rmmod命令需要的是模块名而不是文件名。

设备驱动程序

概述

Linux中输入/输出设备被分为 3 类:块设备,字符设备和网络设备。

I/O软件

I/O软件的总体目标就是将软件组织成一种层次结构,低层软件用来屏蔽具体设备细节,高层软件则为用户提供一个简洁规范的界面。这种层次结构很好地体现了I/O设计的一个关键的概念:设备无关性,其含义就是程序员写的软件无需须修改就能读出软盘,硬盘以及CD-ROM等不同设备上的文件。

输入/输出系统的层次结构及各层次的功能如图 11.1 所示。

从图可以看出,用户进程的下层是设备无关的软件,在Linux中,设备无关软件的功能大部分由文件系统去完成,其基本功能就是执行适用于所有设备的常用的输入/输出功能,向用户软件提供一个一致的接口。其结构如图 11.2 所示。

设备无关的软件具有以下特点。

  • 文件和设备采用统一命名。设备无关软件负责将设备名映射到相应的驱动程序,一个设备名唯一地确定一个索引节点,索引节点中包含了主设备号和从设备号,通过主设备号可以找到相应的设备驱动程序,通过从设备号确定具体的物理设备。
  • 对设备提供的保护机制同文件系统一样都采用rwx权限。
  • 数据块的大小可能对于不同的设备其大小不一样,但操作系统屏蔽这一事实,向高层软件提供了统一的逻辑块的大小。
  • 为了解决数据交换速度的匹配问题,采用了缓冲技术,对于缓冲区的管理由文件系统去完成。
  • 块设备的存储分配也是由文件系统去处理。
  • 对于独占设备的分配和释放属于对临界资源的管理。

设备驱动程序

设备管理的一个基本特征是设备处理的抽象性,即所有硬件设备都被看成普通文件,可以通过用操纵普通文件相同的系统调用来打开、关闭、读取和写入设备。系统中每个设备都用一种设备特殊文件来表示,例如系统中第一个IDE硬盘被表示成/dev/hda

首先当用户进程发出输入输出时,系统把请求处理的权限放在文件系统,文件系统通过驱动程序提供的接口将任务下放到驱动程序,驱动程序根据需要对设备控制器进行操作,设备控制器再去控制设备本身。

Linux设备驱动程序的主要功能有:

  • 对设备进行初始化;
  • 使设备投入运行和退出服务;
  • 从设备接收数据并将它们送回内核;
  • 将数据从内核送到设备;
  • 检测和处理设备出现的错误。

Linux中,设备驱动程序是一组相关函数的集合。它包含设备服务子程序和中断处理程序。设备服务子程序包含了所有与设备相关的代码,每个设备服务子程序只处理一种设备或者紧密相关的设备。其功能就是从与设备无关的软件中接受抽象的命令并执行之。当执行一条请求时,具体操作是根据控制器对驱动程序提供的接口(指的是控制器中的各种寄存器),并利用中断机制去调用中断服务子程序配合设备来完成这个请求。设备驱动程序利用结构file_operations与文件系统联系起来,即设备的各种操作的入口函数存在file_operation中。对于特定的设备来说有一些操作是不必要的,其入口置为NULL

Linux内核中虽存在许多不同的设备驱动程序但它们具有一些共同的特性,如下所述。

  1. 驱动程序属于内核代码:设备驱动程序是内核的一部分,它像内核中其他代码一样运行在内核模式,驱动程序如果出错将会使操作系统受到严重破坏,甚至能使系统崩溃并导致文件系统的破坏和数据丢失。
  2. 为内核提供统一的接口:设备驱动程序必须为Linux内核或其他子系统提供一个标准的接口。例如终端驱动程序为Linux内核提供了一个文件I/O接口。
  3. 驱动程序的执行属于内核机制并且使用内核服务:设备驱动可以使用标准的内核服务如内存分配、中断发送和等待队列等。
  4. 动态可加载:多数Linux设备驱动程序可以在内核模块发出加载请求时加载,而不再使用时将其卸载。这样内核能有效地利用系统资源。
  5. 可配置:Linux设备驱动程序可以连接到内核中。当内核被编译时,被连入内核的设备驱动程序是可配置的。

设备驱动基础

I/O端口

每个连接到I/O总线上的设备都有自己的I/O地址集,即所谓的I/O端口(I/O port)。在IBM PC体系结构中,I/O地址空间一共提供了 65,536 个 8 位的I/O端口。可以把两个连续的 8 位端口看成一个 16 位端口,但是这必须是从偶数地址开始。同理,也可以把两个连续的 16 位端口看成一个 32 位端口,但是这必须是从 4 的整数倍地址开始。有 4 条专用的汇编语言指令可以允许CPUI/O端口进行读写:它们分别是ininsoutouts。在执行其中的一条指令时,CPU使用地址总线选择所请求的I/O端口,使用数据总线在CPU寄存器和端口之间传送数据。

I/O端口还可以被映射到物理地址空间,因此,处理器和I/O设备之间的通信就可以直接使用对内存进行操作的汇编语言指令(例如,movandor等等)。现代的硬件设备更倾向于映射I/O,因为这样处理的速度较快,并可以和DMA结合起来使用。系统设计者的主要目的是提供对I/O编程的统一方法,但又不牺牲性能。为了达到这个目的,每个设备的I/O端口都被组织成如图 11.4 所示的一组专用寄存器。

CPU把要发给设备的命令写入控制寄存器(Control Register),并从状态寄存器(Status Register)中读出表示设备内部状态的值。CPU`还可以通过读取输入寄存器(Input Register)的内容从设备
取得数据,也可以通过向输出寄存器(Output Register)中写入字节而把数据输出到设备。

那么如何访问I/O端口?inoutinsouts汇编语言指令都可以访问I/O端口。Linux内核中定义了以下辅助函数来简化这种访问。

  1. inb()inw()inl()函数:分别从I/O端口读取 1、2 或 4 个连续字节。后缀bwl分别代表一个字节(8位)、一个字(16 位)以及一个长整型(32 位)。
  2. inb_p()inw_p()inl_p():分别从I/O端口读取 1、2 或 4 个连续字节,然后执行一条“哑元(dummy,即空指令)”指令使CPU暂停。
  3. outb()outw()outl():分别向一个I/O端口写入 1、2 或 4 个连续字节。
  4. outb_p()outw_p()outl_p():分别向一个I/O端口写入 1、2 或 4 个连续字节,然后执行一条“哑元”指令使CPU暂停。
  5. insb()、insw()insl():分别从I/O端口读入以 1、2 或 4 个字节为一组的连续字节序列。字节序列的长度由该函数的参数给出。
  6. outsb()outsw()outsl():分别向I/O端口写入以 1、2 或 4 个字节为一组的连续字节序列。

虽然访问I/O端口非常简单,但是检测哪些I/O端口已经分配给I/O设备可能就不这么简单,特别是对基于ISA总线的系统来说更是如此。通常,I/O设备驱动程序为了侦探硬件设备,需要盲目地向某一I/O端口写入数据;但是,如果其他硬件设备已经使用这个端口,那么系统就会崩溃。为了防止这种情况的发生,内核必须使用iotable表来记录分配给每个硬件设备的I/O端口。任何设备驱动程序都可以使用下面 3 个函数。

  • request_region():把一个给定区间的I/O端口分配给一个I/O设备。
  • check_region():检查一个给定区间的I/O端口是否空闲,或者其中一些是否已经分配给某个I/O设备。
  • release_region():释放以前分配给一个I/O设备的给定区间的I/O端口。

当前分配给I/O设备的I/O地址可以从/proc/ioports文件中获得。

I/O接口及设备控制器

I/O接口是处于一组I/O端口和对应的设备控制器之间的一种硬件电路。它起翻译器的作用,即把I/O端口中的值转换成设备所需要的命令和数据。从另一个角度来看,它检测设备状态的变化,并对起状态寄存器作用的I/O端口进行相应地更新。还可以通过一条IRQ线把这种电路连接到可编程中断控制器上,以使它代表相应的设备发出中断请求。

有两类类型的接口,如下所述。

专用I/O接口

专门用于一个特定的硬件设备。在一些情况下,设备控制器与这种I/O接口处于同一块卡中,连接到专用I/O接口上的设备可以是内部设备(位于PC机箱内部的设备),也可以是外部设备(位于PC机箱外部的设备)。例如键盘接口、图形接口、磁盘接口、总线鼠标接口及网络接口都属于专用I/O接口。

通用I/O接口

用来连接多个不同的硬件设备。连接到通用I/O接口上的设备通常都是外部设备。例如并口、串口、通用串行总线(USB)、PCMCIA接口及SCSI接口都属于通用I/O接口。复杂的设备可能需要一个设备控制器来驱动。控制器具有两方面的作用,一是对从I/O接口接收到的高级命令进行解释,并通过向设备发送适当的电信号序列强制设备执行特定的操作;二是对从设备接收到的电信号进行转换和解释,并通过I/O接口修改状态寄存器的值。

设备文件

设备文件是用来表示Linux所支持的大多数设备的,每个设备文件除了设备名,还有 3个属性:即类型、主设备号、从设备号。

设备文件是通过mknod系统调用创建的。其原型为:

1
mknod(const char * filename, int mode, dev_t dev)

其参数有设备文件名、操作模式、主设备号及从设备号。最后两个参数合并成一个 16位的dev_t无符号短整数,高 8 位用于主设备号,低 8 位用于从设备号。内核中定义了 3 个宏来处理主、从设备号:MAJORMINOR宏可以从 16 位数中提取出主、从设备号,而MKDEV宏可以把主、从号合并为一个 16 位数。实际上,dev_t是专用于应用程序的一个数据类型;在内核中使用kdev_t数据类型。

分配给设备号的正式注册信息及/dev目录索引节点存放在documentation/devices.txt文件中。也可以在include/linux/major.h文件中找到所支持的主设备号。设备文件通常位于/dev目录下。表 11.1 显示了一些设备文件的属性。注意同一主设备号既可以标识字符设备,也可以标识块设备。

设备名 类型 主设备号 从号 说明
/dev/fd0 块设备 2 0 软盘
/dev/hda 块设备 3 0 第 1 个IDE磁盘
/dev/hda2 块设备 3 2 第 1 个IDE磁盘上的第 2 个主分区
/dev/hdb 块设备 3 64 第 2 个IDE磁盘
/dev/hdb3 块设备 3 67 第 2 个IDE磁盘上的第 3 个主分区
/dev/ttyp0 字符设备 3 0 终端
/dev/console 字符设备 5 1 控制台
/dev/lp1 字符设备 6 1 并口打印机
/dev/ttyS0 字符设备 4 64 第 1 个串口
/dev/rtc 字符设备 10 135 实时时钟
/dev/null 字符设备 1 3 空设备(黑洞)

块设备和字符设备的比较

块设备具有以下特点。

  • 可以在一次I/O操作中传送固定大小的数据块。
  • 可以随机访问设备中所存放的块:传送数据块所需要的时间独立于块在设备中的位置,也独立于当前设备的状态。

字符设备具有以下特点。

  • 可以在一次I/O操作中传送任意大小的数据。实际上,诸如打印机之类的字符设备可以一次传送一个字节,而诸如磁带之类的设备可以一次传送可变大小的数据块。
  • 通常访问连续的字符。

网卡

有些I/O设备没有对应的设备文件。最明显的一个例子是网卡。实际上,网卡把向外发送的数据放入通往远程计算机系统的一条线上,把从远程系统中接收到的报文装入内核内存。从BSD开始,所有的UNIX类系统为计算机中的每个网卡都分配一个不同的符号名。

由于没有使用文件系统,所以系统管理员必须建立设备名和网络地址之间的联系。因此,应用程序和网络接口之间的数据通信不是基于标准的有关文件的系统调用的,而是基于socket()bind()listen()accept()connect()系统调用的,这些系统调用对网络地址进行操作。这组系统调用是在UNIX BSD中首先引入的,现在已经成为网络设备的标准编程模型。

VFS对设备文件的处理

虽然设备文件也在系统的目录树中,但是它们和普通文件以及目录有根本的不同。当进程访问普通文件(即磁盘文件)时,它会通过文件系统访问磁盘分区中的一些数据块。而在进程访问设备文件时,它只要驱动硬件设备就可以了。例如,进程可以访问一个设备文件以从连接到计算机的温度计读取房间的温度。VFS的责任是为应用程序隐藏设备文件与普通文件之间的差异。

为了做到这点,VFS改变打开的设备文件的缺省文件操作。因此,可以把对设备文件的任一系统调用转换成对设备相关的函数的调用,而不是对主文件系统相应函数的调用。设备相关的函数对硬件设备进行操作以完成进程所请求的操作。

控制I/O设备的一组设备相关的函数称为设备驱动程序。由于每个设备都有一个唯一的I/O控制器,因此也就有唯一的命令和唯一的状态信息,所以大部分I/O设备类型都有自己的驱动程序。

中断处理

基于中断的设备驱动程序,指的是在硬件设备需要服务时向CPU发一个中断信号,引发中断服务子程序执行 。这样就大大地提高了系统资源的利用率,使内核不必一直等到设备执行完任务后才开始有事可干,而是在设备工作期间内核就可以转去处理其他的事务,收到中断请求信号时再回头响应设备。

Linux对中断的管理

Linux内核为了将来自硬件设备的中断传递到相应的设备驱动程序,在驱动程序初始化的时候就将其对应的中断程序进行了登记,即通过调用函数request_irq ()将其中断信息添加到结构为irqaction的数组中,从而使中断号和中断服务程序联系起来。

request_irq ()函数原形如下:

1
2
3
4
5
int request_irq(unsigned int irq, /* 中断请求号 */
void (*handler)(int, void *, struct pt_regs *), /* 指向中断服务子程序 */
unsigned long irqflags, /* 中断类型 */
const char * devname, /* 设备的名字 */
void *dev_id);

另外,irqaction的数据结构如下,其图示如图 11.5 所示。

1
2
3
4
5
6
7
8
9
struct irqaction {
void (*handler)(int, void *, struct pt_regs *);
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id;
struct irqaction *next;
};
static struct irqaction *irq_action[NR_IRQS+1]

根据设备的中断号可以在数组irq_action检索到设备的中断信息。对中断资源的请求在驱动程序初始化时就已经完成。

Linux对中断的处理

Linux中断处理子系统的一个基本任务是将中断正确联系到中断处理代码中的正确位置。这些代码必须了解系统的中断拓扑结构。例如在中断控制器上引脚 6 上发生的软盘控制器中断必须被辨认出的确来自软盘并同系统的软盘设备驱动的中断服务子程序联系起来。

中断发生时,Linux首先读取系统可编程中断控制器中中断状态寄存器,判断出中断源,将其转换成irq_action数组中偏移值,然后调用其相应的中断处理程序。当Linux内核调用设备驱动程序的中断服务子程序时,必须找出中断产生的原因以及相应的解决办法,这是通过读取设备上的状态寄存器的内容来完成的。

下面我们结合输入/输出系统的层次结构来看一下中断在驱动程序工作的过程中的作用。

  1. 用户发出某种输入/输出请求。
  2. 调用驱动程序的read()函数或request()函数,将完成的输入/输出的指令送给设备控制器,现在设备驱动程序等待操作的发生。
  3. 一小段时间以后,硬设备准备好完成指令的操作,并产生中断信号标志事件的发生。
  4. 中断信号导致调用驱动程序的中断服务子程序,它将所要的数据从硬设备复制到设备驱动程序的缓冲区中,并通知正在等待的read()函数和request()函数,现在数据可供使用。
  5. 在数据可供使用时,read()request()函数现在可将数据提供给用户进程。

上述过程是经过了简化了的,但却反映了中断的主要过程的主要方面。

驱动DMA工作

所有的PC都包含一个称为直接内存访问控制器或DMAC的辅助处理器,它可以用来控制在RAMI/O设备之间数据的传送。DMAC一旦被CPU激活,就可以自行传送数据;当数据传送完成之后,DMAC发出一个中断请求。当CPUDMAC同时访问同一内存单元时,所产生的冲突由一个称为内存仲裁器的硬件电路来解决。

使用DMAC最多的是磁盘驱动器和其他需要一次传送大量字节的慢速设备。因为DMAC的设置时间相当长,所以在传送数量很少的数据时直接使用CPU效率更高。

到现在为止,我们已区分了 3 类内存地址:逻辑地址、线性地址以及物理地址,前两个在CPU内部使用,最后一个是CPU从物理上驱动数据总线所用的内存地址。但是,还有第 4种内存地址,称为总线地址:它是除CPU之外的硬件设备驱动数据总线所用的内存地址。

从根本上说,内核为什么应该关心总线地址呢?这是因为在DMA操作中数据传送不用CPU的参与:I/O设备和DMAC直接驱动数据总线。因此,在内核开始DMA操作时,必须把所涉及的内存缓冲区总线地址或写入DMAC适当的I/O端口、或写入I/O设备适当的I/O端口。

很多I/O驱动程序都使用直接内存访问控制器(DMAC)来加快操作的速度。DMAC与设备的I/O控制器相互作用共同实现数据传送。后文中我们还会看到,内核中包含一组易用的例程来对DMAC进行编程。当数据传送完成时,I/O控制器通过IRQCPU发出信号。

当设备驱动程序为某个I/O设备建立DMA操作时,必须使用总线地址指定所用的内存缓冲区。内核提供两个宏virt_to_busbus_to_virt,分别把虚拟地址转换成总线地址或把总线地址转换成虚拟地址。

IRQ一样,DMAC也是一种资源,必须把这种资源动态地分配给需要它的设备驱动程序。驱动程序开始和结束DMA操作的方法依赖于总线的类型。

ISA总线的DMA

每个ISA DMAC只能控制有限个通道。每个通道都包括一组独立的内部寄存器,所以,DMAC就可以同时控制几个数据的传送。

设备驱动程序通常使用下面的方式来申请和释放ISA DMAC。设备驱动程序照样要靠一个引用计数器来检测什么时候任何进程都不再访问设备文件。驱动程序执行以下操作。

  • 在设备文件的open()方法中把设备的引用计数器加 1。如果原来的值是 0,那么,驱动程序执行以下操作:
    • 调用request_irq()来分配ISA DMAC所使用的IRQ中断号;
    • 调用request_dma()来分配DMA通道;
    • 通知硬件设备应该使用DMA并产生中断。
    • 如果需要,为DMA缓冲区分配一个存储区域
  • 当必须启动DMA操作时,在设备文件的read()write()方法中执行以下操作:
    • 调用set_dma_mode()把通道设置成读/写模式;
    • 调用set_dma_addr()来设置DMA缓冲区的总线地址。(因为只有最低的 24 位地址会发给DMAC,所以缓冲区必须在RAM的前16MB中);
    • 调用set_dma_count()来设置要发送的字节数;
    • 调用set_dma_dma()来启用DMA通道;
    • 把当前进程加入该设备的等待队列,并把它挂起,当DMAC完成数据传送操作时,设备的I/O控制器就发出一个中断,相应的中断处理程序会唤醒正在睡眠的进程;
    • 进程一旦被唤醒,就立即调用disable_dma()来禁用这个DMA通道;
    • 调用get_dma_residue()来检查是否所有的数据都已被传送。
  • 在设备文件的release方法中,减少设备的引用计数器。如果该值变成 0,就执行以下操作:
    • 禁用DMA和对这个硬件设备上的相应中断;
    • 调用free_dma()来释放DMA通道;
    • 调用free_irq()来释放DMA所使用的IRQ线。

PCI总线的DMA

PCI总线对于DMA的使用要简单得多,因为DMAC是集成到I/O接口内部的。在open()方法中,设备驱动程序照样必须分配一条IRQ线来通知DMA操作的完成。但是,并没有必要分配一个DMA通道,因为每个硬件设备都直接控制PCI总线的电信号。要启动DMA操作,设备驱动程序在硬件设备的某个I/O端口中简单地写入DMA缓冲区的总线地址、传送方向以及数据大小,然后驱动程序就挂起当前进程。在最后一个进程关闭这个文件对象时,release方法负责释放这条IRQ线。

I/O空间的映射

很多硬件设备都有自己的内存,通常称之为I/O空间。

地址映射

根据设备和总线类型的不同,PC体系结构中的I/O空间可以在 3 个不同的物理地址范围之间进行映射。

  1. 对于连接到ISA总线上的大多数设备,I/O空间通常被映射到从0xa00000xfffff的物理地址范围,这就在640K1MB之间留出了一段空间,这就是所谓的“洞”。
  2. 对于使用VESA本地总线(VLB)的一些老设备这主要是由图形卡使用的一条专用总线:I/O空间被映射到从0xe000000xffffff的地址范围中,也就是14MB16MB之间。因为这些设备使页表的初始化更加复杂,因此已经不生产这种设备了。
  3. 对于连接到PCI总线的设备:I/O空间被映射到很大的物理地址区间,位于RAM物理地址的顶端。这种设备的处理比较简单。

访问I/O空间

内核驱动程序必须把I/O空间单元的物理地址转换成内核空间的虚拟地址。在PC体系结构中,这可以简单地把 32 位的物理地址和 0xc0000000 常量进行或运算得到。例如,假设内核需要把物理地址为 0x000b0fe4 的I/O单元的值存放在t1中,把物理地址为 0xfc000000的I/O单元的值存放在`t2 中,就可以使用下面的表达式来完成这项功能:

  • t1 = *((unsigned char *)(0xc00b0fe4));
  • t2 = *((unsigned char *)(0xfc000000));

在第六章我们已经介绍过,在初始化阶段,内核已经把可用的RAM物理地址映射到虚拟地址空间第4GB的最初部分。因此,分页机制把出现在第 1 个语句中的虚拟地址 0xc00b0fe4映射回到原来的I/O物理地址 0x000b0fe4,正好落在从640K1MB的这段“ISA`洞”中。这正是我们所期望的。

但是,对于第 2 个语句来说,这里有一个问题,因为其I/O物理地址超过了系统RAM的最大物理地址。因此,虚拟地址 0xfc000000 就不需要与物理地址 0xfc000000 相对应。在这种情况下,为了在内核页表中包括对这个I/O物理地址进行映射的虚拟地址,必须对页表进行修改:这可以通过调用ioremap()函数来实现。ioremap()vmalloc()函数类似,都调用get_vm_area()建立一个新的vm_struct描述符,其描述的虚拟地址区间为所请求I/O空间区的大小。然后,ioremap()函数适当地更新所有进程的对应页表项。

因此,第 2 个语句的正确形式应该为:

1
2
io_mem = ioremap(0xfb000000, 0x200000);
t2 = *((unsigned char *)(io_mem + 0x100000));

第 1 条语句建立一个2MB的虚拟地址区间,从 0xfb000000 开始;第 2 条语句读取地址0xfc000000 的内存单元。驱动程序以后要取消这种映射,就必须使用iounmap()函数。

设备驱动程序框架

Linux的设备驱动程序可以分为以下 3 部分。

  1. 驱动程序与内核的接口,这是通过数据结构file_operations来完成的。
  2. 驱动程序与系统引导的接口,这部分利用驱动程序对设备进行初始化。
  3. 驱动程序与设备的接口,这部分描述了驱动程序如何与设备进行交互,这与具体设备密切相关。

根据功能,驱动程序的代码可以分为如下几个部分。

  1. 驱动程序的注册和注销。
  2. 设备的打开与释放。
  3. 设备的读和写操作。
  4. 设备的控制操作。
  5. 设备的中断和查询处理。

与读写操作不同,ioctl()的用法与具体设备密切相关,例如,对于软驱的控制可以使用floppy_ioctl(),其调用形式为:

1
2
static int floppy_ioctl(struct inode *inode, struct file *filp,
unsigned int cmd, unsigned long param)

其中cmd的取值及含义与软驱有关,例如,FDEJECT表示弹出软盘。

除了ioctl(),设备驱动程序还可能有其他控制函数,如lseek()等。

块设备驱动程序

对于块设备来说,读写操作是以数据块为单位进行的,为了使高速的CPU同低速块设备能够协调工作,提高读写效率,操作系统设置了缓冲机制。当进行读写的时候,首先对缓冲区读写,只有缓冲区中没有需要读的数据或是需要写的数据没有地方写时,才真正地启动设备控制器去控制设备本身进行数据交换,而对于设备本身的数据交换同样也是同缓冲区打交道。

块设备驱动程序的注册

对于块设备来说,驱动程序的注册不仅在其初始化的时候进行而且在编译的时候也要进行注册。在初始化时通过register_blkdev()函数将相应的块设备添加到数组blkdevs中,该数组在fs/block_dev.c中定义如下:

1
2
3
4
static struct {
const char *name;
struct block_device_operations *bdops;
} blkdevs[MAX_BLKDEV];

Linux 2.4开始,块设备表的定义与下一节要介绍的字符设备表的定义有所不同。因为每种具体的块设备都有一套具体的操作,因而各自有一个类似于file_operations那样的数据结构,称为block_device_operations结构,其定义为:

1
2
3
4
5
6
7
8
struct block_device_operations {
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
int (*ioctl) (struct inode *, struct file *, unsigned, unsigned long);
int (*check_media_change) (kdev_t);
int (*revalidate) (kdev_t);
struct module *owner;
};

如果说file_operation结构是连接虚拟的VFS文件的操作与具体文件系统的文件操作之间的枢纽,那么block_device_operations就是连接抽象的块设备操作与具体块设备操作之间的枢纽。

具体的块设备是由主设备号唯一确定的,因此,主设备号唯一地确定了一个具体的block_device_operations数据结构。

下面我们来看register_blkdev()函数的具体实现,其代码在fs/block_dev.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int register_blkdev(unsigned int major, const char * name, struct block_device_operations
*bdops)
{
if (major == 0) {
for (major = MAX_BLKDEV-1; major > 0; major--) {
if (blkdevs[major].bdops == NULL) {
blkdevs[major].name = name;
blkdevs[major].bdops = bdops;
return major;
}
}
return -EBUSY;
}
if (major >= MAX_BLKDEV)
return -EINVAL;
if (blkdevs[major].bdops && blkdevs[major].bdops != bdops)
return -EBUSY;
blkdevs[major].name = name;
blkdevs[major].bdops = bdops;
return 0;
}

这个函数的第 1 个参数是主设备号,第 2 个参数是设备名称的字符串,第 3 个参数是指向具体设备操作的指针。如果一切顺利则返回 0,否则返回负值。如果指定的主设备号为 0,此函数将会搜索空闲的主设备号分配给该设备驱动程序并将其作为返回值。

那么,块设备注册到系统以后,怎样与文件系统联系起来呢,也就是说,文件系统怎么调用已注册的块设备,这还得从file_operations结构说起。

我们先来看一下块设备的file_operations结构的定义,其位于fs/block_dev.c中:

1
2
3
4
5
6
7
8
9
10
struct file_operations def_blk_fops = {
open: blkdev_open,
release: blkdev_close,
llseek: block_llseek,
read: generic_file_read,
write: generic_file_write,
mmap: generic_file_mmap,
fsync: block_fsync,
ioctl: blkdev_ioctl,
};

下面以open()系统调用为例,说明用户进程中的一个系统调用如何最终与物理块设备的操作联系起来。在此,我们仅仅给出几个open()函数的调用关系,如图 11.6 所示。

当调用open()系统调用时,其最终会调用到def_blk_fopsblkdev_open()函数。blkdev_open()函数的任务就是根据主设备号找到对应的block_device_operations结构,然后再调用block_device_operations结构中的函数指针open所指向的函数,如果open所指向的函数非空,就调用该函数打开最终的物理块设备。

这就简单地说明了块设备注册以后,从最上层的系统调用到具体地打开一个设备的过程。另外要说明的是 , 如果选择了通过设备文件系统DevFS进行注册,则调用devfs_register_blkdev()函数,该函数的说明及代码在fs/devfs/base.c中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* devfs_register_blkdev - Optionally register a conventional block driver.
* @major: The major number for the driver.
* @name: The name of the driver (as seen in /proc/devices).
* @bdops: The &block_device_operations structure pointer.
*
* This function will register a block driver provided the "devfs=only"
* option was not provided at boot time.
* Returns 0 on success, else a negative error code on failure.
*/
int devfs_register_blkdev (unsigned int major, const char *name,
struct block_device_operations *bdops)
{
if (boot_options & OPTION_ONLY) return 0;
return register_blkdev (major, name, bdops);
} /* End Function devfs_register_blkdev */

块设备基于缓冲区的数据交换

关于块缓冲区的管理在中已有所描述,在这里我们从交换数据的角度来看一下基于缓冲区的数据交换的实现。

扇区及块缓冲区

块设备的每次数据传送操作都作用于一组相邻字节,我们称之为扇区。在大部分磁盘设备中,扇区的大小是 512 字节,但是现在新出现的一些设备使用更大的扇区(1024 和 2014字节)。注意,应该把扇区作为数据传送的基本单元:不允许传送少于一个扇区的数据,而大部分磁盘设备都可以同时传送几个相邻的扇区。

Linux中,块大小必须是 2 的幂,而且不能超过一个页面。此外,它必须是扇区大小的整数倍,因为每个块必须包含整数个扇区。因此,在PC体系结构中,允许块的大小为 512、1024、2048 和 4096 字节。同一个块设备驱动程序可以作用于多个块大小,因为它必须处理共享同一主设备号的一组设备文件,而每个块设备文件都有自己预定义的块大小。

内核在一个名为blksize_size的表中存放块的大小;表中每个元素的索引就是相应块设备文件的主设备号和从设备号。如果blksize_size[M]为NULL,那么共享主设备号M的所有块设备都使用标准的块大小,即 1024 字节。

每个块都需要自己的缓冲区,它是内核用来存放块内容的RAM内存区。当设备驱动程序从磁盘读出一个块时,就用从硬件设备中所获得的值来填充相应的缓冲区;同样,当设备驱动程序向磁盘中写入一个块时,就用相关缓冲区的实际值来更新硬件设备上相应的一组相邻字节。缓冲区的大小一定要与块的大小相匹配。

块驱动程序的体系结构

下面我们说明通用块驱动程序的体系结构,以及在为缓冲区I/O操作时所涉及的主要成分。

块设备驱动程序通常分为两部分,即高级驱动程序和低级驱动程序,前者处理VFS层,后者处理硬件设备,如图 11.7 所示。

假设进程对一个设备文件发出read()write()系统调用。VFS执行对应文件对象的readwrite方法,由此就调用高级块设备处理程序中的一个过程。这个过程执行的所有操作都与对这个硬件设备的具体读写请求有关。内核提供两个名为generic_file_read ()generic_file_write ()通用函数来留意所有事件的发生。因此,在大部分情况下,高级硬件设备驱动程序不必做什么,而设备文件的readwrite方法分别指向generic_file_read()generic_file_write ()方法。

即使高级设备驱动程序有自己的readwrite方法,但是这两个方法通常最终还会调用generic_file_read ()generic_file_write ()函数。这些函数把对I/O设备文件的访问请求转换成对相应硬件设备的块请求。所请求的块可能已在主存,因此generic_file_read ()generic_file_write ()函数调用getblk()函数来检查缓冲区中是否已经预取了块,还是从上次访问以来缓冲区一直都没有改变。如果块不在缓冲区中,getblk()就必须调用ll_rw_block()继续从磁盘中读取这个块,后面这个函数激活操纵设备控制器的低级驱动程序,以执行对块设备所请求的操作。

VFS直接访问某一块设备上的特定块时,也会触发缓冲区I/O操作。例如,如果内核必须从磁盘文件系统中读取一个索引节点,那么它必须从相应磁盘分区的块中传送数据 。对于特定块的直接访问是由bread()breada()函数来执行的,这两个函数又会调用前面提到过的getblk()ll_rw_block()函数。

块设备请求

虽然块设备驱动程序可以一次传送一个单独的数据块,但是内核并不会为磁盘上每个被访问的数据块都单独执行一次I/O操作:这会导致磁盘性能的下降,因为确定磁盘表面块的物理位置是相当费时的。取而代之的是,只要可能,内核就试图把几个块合并在一起,并作为一个整体来处理,这样就减少了磁头的平均移动时间。

当进程、VFS层或者任何其他的内核部分要读写一个磁盘块时,就真正引起一个块设备请求。从本质上说,这个请求描述的是所请求的块以及要对它执行的操作类型(读还是写)。然而,并不是请求一发出,内核就满足它,实际上,块请求发出时I/O操作仅仅被调度,稍后才会被执行。这种人为的延迟有悖于提高块设备性能的关键机制。当请求传送一个新的数据块时,内核检查能否通过稍微扩大前一个一直处于等待状态的请求而满足这个新请求。由于磁盘的访问大都是顺序的,因此这种简单机制就非常高效。

每个块设备驱动程序都维护自己的请求队列;每个物理块设备都应该有一个请求队列,以提高磁盘性能的方式对请求进行排序。因此策略程序就可以顺序扫描这种队列,并以最少地移动磁头而为所有的请求提供服务。

每个块设备请求都是由一个request结构来描述的,其定义于include/linux/blkdev.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* Ok, this is an expanded form so that we can use the same
* request for paging requests.
*/
struct request {
struct list_head queue;
int elevator_sequence;
volatile int rq_status; /* should split this into a few status bits */
#define RQ_INACTIVE (-1)
#define RQ_ACTIVE 1
#define RQ_SCSI_BUSY 0xffff
#define RQ_SCSI_DONE 0xfffe
#define RQ_SCSI_DISCONNECTING 0xffe0
kdev_t rq_dev;
int cmd; /* READ or WRITE */
int errors;
unsigned long sector;
unsigned long nr_sectors;
unsigned long hard_sector, hard_nr_sectors;
unsigned int nr_segments;
unsigned int nr_hw_segments;
unsigned long current_nr_sectors;
void * special;
char * buffer;
struct completion * waiting;
struct buffer_head * bh;
struct buffer_head * bhtail;
request_queue_t *q;
};

我们把struct request叫做请求描述符。

数据传送的方向存放在cmd域中:该值可能是READ(把数据从块设备读到RAM中)或者WRITE(把数据从RAM写到块设备中)。rq_status域用来定义请求的状态:对于大部分块设备来说,这个域的值可能为RQ_INACTIVE(请求描述符还没有使用)或者RQ_ACTIVE(有效的请求,低级设备驱动程序要对其服务或正在对其服务)。

一次请求可能包括同一设备中的很多相邻块。rq_dev域指定块设备,而sector域说明请求中第一个块对应的第一个扇区的编号。nr_sectorcurrent_nr_sector给出要传送数据的扇区数。sectornr_sectorcurrent_nr_sector域都可以在请求得到服务的过程中而被动态修改。

请求块的所有缓冲区首部都被集中在一个简单链表中。每个缓冲区首部的b_reqnext域指向链表中的下一个元素,而请求描述符的bhbhtail域分别指向链表的第一个元素和最后一个元素。

请求描述符的buffer域指向实际数据传送所使用的内存区。如果只请求一个单独的块,那么缓冲区只是缓冲区首部的b_data域的一个拷贝。然而,如果请求了多个块,而这些块的缓冲区在内存中又不是连续的,那么就使用缓冲区首部的b_reqnext域把这些缓冲区链接在一起。对于读操作来说,低级设备驱动程序可以选择先分配一个大的内存区来立即读取请求的所有扇区,然后再把这些数据拷贝到各个缓冲区。同样,对于写操作来说。

另外,在严重负载和磁盘操作频繁的情况下,固定数目的请求描述符就可能成为一个瓶颈。空闲描述符的缺乏可能会强制进程等待直到正在执行的数据传送结束。因此,request_queue_t类型(见下面)中的wait_for_request等待队列就用来对正在等待空闲请求描述符的进程进行排队。get_request_wait()试图获取一个空闲的请求描述符,如果没有找到,就让当前进程在等待队列中睡眠;get_request()函数与之类似,但是如果没有可用的空闲请求描述符,它只是简单地返回NULL

请求队列

请求队列只是一个简单的链表,其元素是请求描述符。每个请求描述符中的next域都指向请求队列的下一个元素,最后一个元素为空。这个链表的排序通常是:首先根据设备标识符,其次根据最初的扇区号。

如前所述,对于所服务的每个硬盘,设备驱动程序通常都有一个请求队列。然而,一些设备驱动程序只有一个请求队列,其中包括了由这个驱动器处理的所有物理设备的请求。这种方法简化了驱动程序的设计,但是损失了系统的整体性能,因为不能对队列强制使用简单排序的策略。请求队列定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
struct request_queue
{
/*
* the queue request freelist, one for reads and one for writes
*/
struct request_list rq[2];
/*
* Together with queue_head for cacheline sharing
*/
struct list_head queue_head;
elevator_t elevator;
request_fn_proc * request_fn;
merge_request_fn * back_merge_fn;
merge_request_fn * front_merge_fn;
merge_requests_fn * merge_requests_fn;
make_request_fn * make_request_fn;
plug_device_fn * plug_device_fn;
/*
* The queue owner gets to use this for whatever they like.
* ll_rw_blk doesn't touch it.
*/
void * queuedata;
/*
* This is used to remove the plug when tq_disk runs.
*/
struct tq_struct plug_tq;
/*
* Boolean that indicates whether this queue is plugged or not.
*/
char plugged;
/*
* Boolean that indicates whether current_request is active or
* not.
*/
char head_active;
/*
* Is meant to protect the queue in the future instead of
* io_request_lock
*/
spinlock_t queue_lock;
/*
* Tasks wait here for free request
*/
wait_queue_head_t wait_for_request;
};
typedef struct request_queue request_queue_t;

其中,request_list为请求描述符组成的空闲链表,其定义如下:

1
2
3
4
struct request_list {
unsigned int count;
struct list_head free;
};

有两个这样的链表,一个用于读,一个用于写。

elevator_t结构描述的是为磁盘的电梯调度算法而设的数据结构。从request_fn_procplug_device_fn都是一些函数指针。例如request_fn是一个指针,指向类型为request_fn_proc的对象。而request_fn_proc则通过#typedef定义为一种函数:

1
typedef void (request_fn_proc) (request_queue_t *q)

其余的函数也与此类似,这些指针(连同其他域)都是在相应设备初始化时设置好的。需要对一个块设备进行操作时,就为之设置好一个数据结构request_queue。并将其挂入相应的请求队列中。

这里要说明的是,request_fn()域包含驱动程序的策略程序的地址,策略程序是低级块设备驱动程序的关键函数,为了开始传送队列中的一个请求所指定的数据,它与物理块设备(通常是磁盘控制器)真正打交道。

块设备驱动程序描述符

驱动程序描述符是一个blk_dev_struct类型的数据结构,其定义如下:

1
2
3
4
5
6
7
8
struct blk_dev_struct {
/*
* queue_proc has to be atomic
*/
request_queue_t request_queue;
queue_proc *queue;
void *data;
};

在这个结构中,其主体是请求队列request_queue;此外,还有一个函数指针queue,当这个指针为非 0 时,就调用这个函数来找到具体设备的请求队列,这是为考虑具有同一主设备号的多种同类设备而设的一个域。这个指针也在设备初始化时就设置好,另一个指针data是辅助queue函数找到特定设备的请求队列。

所有块设备的描述符都存放在blk_dev表中:

1
struct blk_dev_struct blk_dev[MAX_BLKDEV];

每个块设备都对应着数组中的一项,可以用主设备号进行检索。每当用户进程对一个块设备发出一个读写请求时,首先调用块设备所公用的函数generic_file_read ()generic_file_write(),如果数据存在缓冲区中或缓冲区还可以存放数据,就同缓冲区进行数据交换。否则,系统会将相应的请求队列结构添加到其对应项的blk_dev_struct中,如图 11.8 所示。如果在加入请求队列结构的时候该设备没有请求,则马上响应该请求,否则将其追加到请求任务队列尾顺序执行。

图 11.8 表示每个请求有指向一个或多个buffer_hear结构的指针,每个请求读写一块数据。如果系统对buffer_head结构上锁, 则进程会等待到对此缓冲区的块操作完成。一旦设备驱动程序完成了请求则它必须将每个buffer_heard结构从request结构中清除,将它们标记成已更新状态并对它们解锁。对buffer_head的解锁将唤醒所有等待此块操作完成的睡眠进程,然后request数据结构被标记成空闲以便被其他块请求使用。

块设备驱动程序的几个函数

所有对块设备的读写都是调用generic_file_read ()generic_file_write ()函数,这两个函数的原型如下:

1
2
ssize_t generic_file_read(struct file * filp, char * buf, size_t count, loff_t *ppos)
ssize_t generic_file_write(struct file *file,const char *buf,size_t count, loff_t *ppos)

其参数的含义如下。

  • filp:和这个设备文件相对应的文件对象的地址。
  • buf:用户态地址空间中的缓冲区的地址。generic_file_read()把从块设备中读出的数据写入这个缓冲区;反之,generic_file_write()从这个缓冲区中读取要写入块设备的数据。
  • count:要传送的字节数。
  • ppos:设备文件中的偏移变量的地址;通常,这个参数指向filp->f_pos,也就是说,指向设备文件的文件指针。

只要进程对设备文件发出读写操作,高级设备驱动程序就调用这两个函数。例如,superformat程序通过把块写入/dev/fd0设备文件来格式化磁盘,相应文件对象的write方法就调用generic_file_write()函数。这两个函数所做的就是对缓冲区进行读写,如果缓冲区不能满足操作要求则返回负值,否则返回实际读写的字节数。每个块设备在需要读写时都调用这两个函数。

下面介绍几个低层被频繁调用的函数。
bread()breada()函数:bread()函数检查缓冲区中是否已经包含了一个特定的块;如果还没有,该函数就从块设备中读取这个块。文件系统广泛使用bread()从磁盘位图、索引节点以及其他基于块的数据结构中读取数据(注意当进程要读块设备文件时是使用generic_file_read()函数,而不是使用bread()函数)。该函数接收设备标志符、块号和块大小作为参数,其代码在fs/buffer.c`中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* bread() - reads a specified block and returns the bh
* @block: number of block
* @size: size (in bytes) to read
*
* Reads a specified block, and returns buffer head that
* contains it. It returns NULL if the block was unreadable.
*/
struct buffer_head * bread(kdev_t dev, int block, int size)
{
struct buffer_head * bh;
bh = getblk(dev, block, size);
touch_buffer(bh);
if (buffer_uptodate(bh))
return bh;
ll_rw_block(READ, 1, &bh);
wait_on_buffer(bh);
if (buffer_uptodate(bh))
return bh;
brelse(bh);
return NULL;
}

对该函数解释如下。

  • 调用getblk()函数来查找缓冲区中的一个块;如果这个块不在缓冲区中,那么getblk()就为它分配一个新的缓冲区。
  • 调用buffer_uptodate()宏来判断这个缓冲区是否已经包含最新数据,如果是,则getblk()结束。
  • 如果缓冲区中没有包含最新数据,就调用ll_rw_block()函数启动读操作。
  • 等待,直到数据传送完成为止。这是通过调用一个名为wait_on_buffer()的函数来实现的,该函数把当前进程插入b_wait等待队列中,并挂起当前进程直到这个缓冲区被开锁为止。

breada()bread()十分类似,但是它除了读取所请求的块之外,还要另外预读一些其他块。注意不存在把块直接写入磁盘的函数。写操作永远都不会成为系统性能的瓶颈,因为写操作通常都会延时。

ll_rw_block()函数

ll_rw_block()函数产生块设备请求;内核和设备驱动程序的很多地方都会调用这个函数。该函数的原型如下:

1
void ll_rw_block(int rw, int nr, struct buffer_head * bhs[])

其参数的含义如下。

  • 操作类型rw,其值可以是READWRITEREADA或者WRITEA。最后两种操作类型和前两种操作类型之间的区别在于,当没有可用的请求描述符时后两个函数不会阻塞。
  • 要传送的块数nr
  • 一个bhs数组,有nr个指针,指向说明块的缓冲区首部(这些块的大小必须相同,而且必须处于同一个块设备)。

该函数的代码在block/ll_rw_blk.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
void ll_rw_block(int rw, int nr, struct buffer_head * bhs[])
{
unsigned int major;
int correct_size;
int i;
if (!nr)
return;
major = MAJOR(bhs[0]->b_dev);
/* Determine correct block size for this device. */
correct_size = get_hardsect_size(bhs[0]->b_dev);
/* Verify requested block sizes. */
for (i = 0; i < nr; i++) {
struct buffer_head *bh = bhs[i];
if (bh->b_size % correct_size) {
printk(KERN_NOTICE "ll_rw_block: device %s: "
"only %d-char blocks implemented (%u)\n",
kdevname(bhs[0]->b_dev),
correct_size, bh->b_size);
goto sorry;
}
}
if ((rw & WRITE) && is_read_only(bhs[0]->b_dev)) {
printk(KERN_NOTICE "Can't write to read-only device %s\n",
kdevname(bhs[0]->b_dev));
goto sorry;
}
for (i = 0; i < nr; i++) {
struct buffer_head *bh = bhs[i];
/* Only one thread can actually submit the I/O. */
if (test_and_set_bit(BH_Lock, &bh->b_state))
continue;
/* We have the buffer lock */
atomic_inc(&bh->b_count);
bh->b_end_io = end_buffer_io_sync;
switch(rw) {
case WRITE:
if (!atomic_set_buffer_clean(bh))
/* Hmmph! Nothing to write */
goto end_io;
__mark_buffer_clean(bh);
break;
case READA:
case READ:
if (buffer_uptodate(bh))
/* Hmmph! Already have it */
goto end_io;
break;
default:
BUG();
end_io:
bh->b_end_io(bh, test_bit(BH_Uptodate, &bh->b_state));
continue;
}
submit_bh(rw, bh);
}
return;
sorry:
/* Make sure we don't get infinite dirty retries.. */
for (i = 0; i < nr; i++)
mark_buffer_clean(bhs[i]);
}

下面对该函数给予解释。
进入ll_rw_block()以后,先对块大小作一些检查;如果是写访问,则还要检查目标设备是否可写。内核中有个二维数组ro_bits,定义于drivers/block/ll_rw_blk.c中:

1
static long ro_bits[MAX_BLKDEV][8];

每个设备在这个数组中都有个标志,通过系统调用ioctl()可以将一个标志位设置成 1或 0,表示相应设备为只读或可写,而is_read_only()就是检查这个数组中的标志位是否为 1。

接下来,就通过第 2 个for循环依次处理对各个缓冲区的读写请求了。对于要读写的每个块,首先将其缓冲区加上锁,还要将其buffer_head结构中的函数指针b_end_io设置成指向end_buffer_io_sync,当完成对给定块的读写时,就调用该函数。此外,对于待写的缓冲区,其BH_Dirty标志位应该为 1,否则就不需要写了,而既然写了,就要把它清 0,并通过__mark_buffer_clean(bh)将缓冲区转移到干净页面的LRU队列中。反之,对于待读的缓冲区,其buffer_uptodate()标志位为 0,否则就不需要读了。每个具体的设备就好像是个服务器,所以最后具体的读写是通过submit_bh()将读写请求提交各“服务器”完成的,每次读写一个块,该函数的代码也在同一文件中,读者可以自己去读。

RAM盘驱动程序的实现

RAM盘的硬件

利用RAM盘的驱动程序可以访问内存的任何部分,它的主要用途是保留一部分内存并象普通磁盘一样来使用它。

RAM盘的思想很简单,块设备是有两个操作的命令的存储介质:即写数据块和读数据块。通常这些数据存储于旋转存储设备上如软盘和硬盘,RAM盘则简单得多,它利用预先分配的主存来存储数据块。因此不存在像磁盘那样的寻道操作,其读写操作只是在内存间进行的。RAM盘具有快速存取的优点(没有寻道和旋转延迟的时间),适合于存储需要频繁存取的数据。

Linux中RAM盘的驱动程序

RAM盘的驱动程序同其他所有的驱动程序一样都是由一组函数组成,对RAM盘的操作实际上是对内存的操作,它不需要中断机制,故RAM盘的驱动程序不包括中断服务子程序.。一般我们对于一个驱动程序的分析是在了解硬件的基础上从该设备所提供的操作入手的,相应的写驱动程序也应该是这样的。

下面是RAM盘操作的结构:

1
2
3
4
5
static struct block_device_operations rd_bd_op = {
owner: THIS_MODULE,
open: rd_open,
ioctl: rd_ioctl,
};

Linux中,RAM盘的主设备号是 1。在rd_open()函数中,它首先检测设备号INITRD_MINOR,由于INITRD是在系统一启动的时候就已经创建,其中映像的是操作系统从偏移地址 0 开始的内容,即内核空间,如果是内核空间,其接口需要相应的发生变换即:

1
2
3
4
5
filp->f_op = &initrd_fops。
static struct file_operations initrd_fops = {
read: initrd_read,
release: initrd_release,
};

对于INITRD盘的操作用户只有读和释放的权限而无写的权限。initrd_read()函数执行的是从内核区进行的读操作,故而是利用memcpy_tofs (buf,(char *)initrd_start+file->f_pos, count)去完成的。

initrd_release()函数在判断没有用户操作这个设备之后,以页的方式把INITRD盘所占的内存释放掉。

在普通RAM盘接口中的另一个函数为rd_ioctl(),同其他设备驱动程序一样是执行一些输入/输出的控制操作。

硬盘驱动程序的实现

Linux中硬盘驱动程序的实现

将要讨论的驱动程序在drivers/ide/hd.c中,在文件为include/linux/hdreg.h中,定义了控制器寄存器、状态位和命令、数据结构和原形。这些宏定义可以根据其名字并结合上面所说的硬件内容去理解。

Linux中,硬盘被认为是计算机的最基本的配置,所以在装载内核的时候,硬盘驱动程序必须就被编译进内核,不能作为模块编译。硬盘驱动程序提供内核的接口为:

1
2
3
4
5
static struct block_device_operations hd_fops = {
open: hd_open,
release: hd_release,
ioctl: hd_ioctl,
};

对硬盘的操作只有 3 个函数。我们来看一下hd_open ()hd_release ()函数,打开操作首先检测了设备的有效性,接着测试了它的忙标志,最后对请求硬盘的总数加 1,来标识对硬盘的请求个数,hd_release()函数则将请求的总数减 1。

前面说过,对于块设备的读写操作是先对缓冲区操作,但是当需要真正同硬盘交换数据的时候,驱动程序又干了些什么?在hd.c中有一个函数hd_out(),可以说它在实际的数据交换中起着主要的作用。它的原形是:

1
2
static void hd_out(unsigned int drive,unsigned int nsect,unsigned int sect,
unsigned int head,unsigned int cyl,unsigned int cmd, void (*intr_addr)(void));

其中参数drive是进行操作的设备号;nsect是每次读写的扇区数;sect是读写的开始扇区号;head是读写的磁头号;cmd是操作命令控制命令字。

通过这个函数向硬盘控制器的寄存器中写入数据,启动硬盘进行实际的操作。同时这个函数也配合完成cmd命令相应的中断服务子程序,通过SET_INIT(intr_addr)宏定义将其地址赋给DEVICE_INTR

hd_request()函数就是通过这个函数进行实际的数据交换,同其他驱动程序不同的是该函数还要根据每个命令的不同来确定一些参数,最基本的是读写方式的确定,关于硬盘的读写方式有两种,一种是单扇区的读写,另一种是多扇区的读写,单扇区的读写是指每次操作只对一个扇区操作,而多扇区则指每次对多个扇区进行操作,不同的方式其中断服务子程序不同,其相应的地址就作为参数传给hd_out(),由它设置DEVICE_INIThd_request()函数确定的其他参数也就是hd_out()所需要的参数。

我们知道块设备的实际数据交换需要中断服务子程序的配合,在本驱动程序中的中断服务子程序有以下几个主要函数。

  • void unexpected_hd_interrupt(void)
    • 功能:对不期望的中断进行处理(设置SET_TIMER)。
  • static void bad_rw_intr(void)
    • 功能:当硬盘的读写操作出现错误时进行处理。
      • 每重复 4 次磁头复位;
      • 每重复 8 次控制器复位;
      • 每重复 16 次放弃操作。
  • static void recal_intr(void)
    • 功能:重新进行硬盘的本次操作。
  • static void read_intr(void)
    • 功能:从硬盘读数据到缓冲区。
  • static void write_intr(void)
    • 功能:从缓冲区读数据到硬盘。
  • static void hd_interupt(void)
    • 功能:决定硬盘中断所要调用的中断程序。

在注册的时候,同硬盘中断联系的是hd_interupt(),也就是说当硬盘中断到来的时候,执行的函数是hd_interupt(),在此函数中调用DEVICE_INTR所指向的中断函数,如果DEVICE_INTR为空,则执行unexpected_hd_interrupt()函数。

对硬盘的操作离不开控制寄存器,为了控制磁盘要经常去检测磁盘的运行状态,在本驱动程序中有一系列的函数是完成这项工作的,check_status()检测硬盘的运行状态,如果出现错误则进行处理。contorller_ready()检测控制器是否准备好。drive_busy()检测硬盘设备是否处于忙态。当出现错误的时候,由dump_status()函数去检测出错的原因。wait_DRQ()对数据请求位进行测试。

当硬盘的操作出现错误的时候,硬盘驱动程序会把它尽量在接近硬件的地方解决掉,其方法是进行重复操作,这些在bad_rw_intr()中进行,与其相关的函数有reset_controller()reset_hd()

函数hd_init()是对硬盘进行初始化的,这个函数的过程同其他块设备基本一致。

字符设备驱动程序

简单字符设备驱动程序

我们来看一个最简单的字符设备,即“空设备”/dev/null,这个设备的主设备号为 1。如前所述,主设备号为 1 的设备其实不是“设备”,而都是与内存有关,或是在内存中(不必通过外设)就可以提供的功能,所以其主设备号标识符为MEM_MAJOR,其定义于include/linux/major.h中:

1
#define MEM_MAJOR 1

file_operatins结构为memory_fops,定义于dreivers/char/mem.c中:

1
2
3
static struct file_operations memory_fops = {
open: memory_open, /* just a selector for the real open */
};

因为主设备号为 1 的字符设备并不能唯一地确定具体的设备驱动程序,因此需要根据从设备号来进行进一步的区分,所以memory_fops还不是最终的file_operations结构,还需要由memory_open()进一步加以确定和设置,其代码在同一文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int memory_open(struct inode * inode, struct file * filp)
{
switch (MINOR(inode->i_rdev)) {
case 1:
filp->f_op = &mem_fops;
break;
case 2:
filp->f_op = &kmem_fops;
break;
case 3:
filp->f_op = &null_fops;
break;

}
if (filp->f_op && filp->f_op->open)
return filp->f_op->open(inode,filp);
return 0;
}

因为/dev/null的从设备号为 3,所以其file_operations结构为null_fops

1
2
3
4
5
static struct file_operations null_fops = {
llseek: null_lseek,
read: read_null,
write: write_null,
};

由于这个结构中函数指针openNULL,因此在打开这个文件时没有任何附加操作。当通过write()系统调用写这个文件时,相应的驱动函数为write_null(),其代码为:

1
2
3
4
5
static ssize_t write_null(struct file * file, const char * buf,
size_t count, loff_t *ppos)
{
return count;
}

从中可以看出,这个函数什么也没做,仅仅返回count,假装要求写入的字节已经写好了,而实际把写的内容丢弃了。

再来看一下读操作又做了些什么,read_null()的代码为:

1
2
3
4
5
static ssize_t read_null(struct file * file, char * buf,
size_t count, loff_t *ppos)
{
return 0;
}

返回 0 表示从这个文件读了 0 个字节,但是并没有到达(永远也不会到达)文件的末尾。当然,字符设备的驱动程序不会都这么简单,但是总的框架是一样的。

字符设备驱动程序的注册

具有相同主设备号和类型的每类设备文件都是由device_struct数据结构来描述的,该结构定义于fs/devices.c

1
2
3
4
struct device_struct {
const char * name;
struct file_operations * fops;
};

其中,name是某类设备的名字,fops是指向文件操作表的一个指针。所有字符设备文件的device_struct描述符都包含在chrdevs表中:

1
static struct device_struct chrdevs[MAX_CHRDEV];

该表包含有 255 个元素,每个元素对应一个可能的主设备号,其中主设备号 255 为将来的扩展而保留的。表的第一项为空,因为没有一个设备文件的主设备号是 0。

chrdevs表最初为空。register_chrdev()函数用来向其中的一个表中插入一个新项,而unregister_chrdev()函数用来从表中删除一个项。我们来看一下register_chrdev()的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
int register_chrdev(unsigned int major, const char * name, struct file_operations *fops)
{
if (major == 0) {
write_lock(&chrdevs_lock);
for (major = MAX_CHRDEV-1; major > 0; major--) {
if (chrdevs[major].fops == NULL) {
chrdevs[major].name = name;
chrdevs[major].fops = fops;
write_unlock(&chrdevs_lock);
return major;
}
}
write_unlock(&chrdevs_lock);
return -EBUSY;
}
if (major >= MAX_CHRDEV)
return -EINVAL;
write_lock(&chrdevs_lock);
if (chrdevs[major].fops && chrdevs[major].fops != fops) {
write_unlock(&chrdevs_lock);
return -EBUSY;
}
chrdevs[major].name = name;
chrdevs[major].fops = fops;
write_unlock(&chrdevs_lock);
return 0;
}

从代码可以看出,如果参数major为 0,则由系统自动分配第 1 个空闲的主设备号,并把设备名和文件操作表的指针置于chrdevs表的相应位置。

例如,可以按如下方式把并口打印机驱动程序的相应结构插入到chrdevs表中:

1
register_chrdev(6, "lp", &lp_fops); 

该函数的第 1 个参数表示主设备号,第 2 个参数表示设备类名,最后一个参数是指向文件操作表的一个指针。

如果设备驱动程序被静态地加入内核,那么,在系统初始化期间就注册相应的设备文件类。但是,如果设备驱动程序作为模块被动态装入内核,那么,对应的设备文件在装载模块时被注册,在卸载模块时被注销。

字符设备被注册以后,它所提供的接口,即file_operations结构在fs/devices.c中定义如下:

1
2
3
4
5
6
7
8
/*
* Dummy default file-operations: the only thing this does
* is contain the open that then fills in the correct operations
* depending on the special file...
*/
static struct file_operations def_chr_fops = {
open: chrdev_open,
};

由于字符设备的多样性,因此,这个缺省的file_operations仅仅提供了打开操作,具体字符设备文件的file_operationschrdev_open()函数决定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 /*
* Called every time a character special file is opened
*/
int chrdev_open(struct inode * inode, struct file * filp)
{
int ret = -ENODEV;
filp->f_op=get_chrfops(MAJOR(inode->i_rdev), MINOR(inode->i_rdev));
if (filp->f_op) {
ret = 0;
if (filp->f_op->open != NULL) {
lock_kernel();
ret = filp->f_op->open(inode,filp);
unlock_kernel();
}
}
return ret;
}

首先调用MAJOR()MINOR()宏从索引节点对象的i_rdev域中取得设备驱动程序的主设备号和从设备号,然后调用get_chrfops()函数为具体设备文件安装合适的文件操作。如果文件操作表中定义了open方法,就调用它。

注意,最后一次调用的open()方法就是对实际设备操作,这个函数的工作是设置设备。通常,open()函数执行如下操作。

  • 如果设备驱动程序被包含在一个内核模块中,那么把引用计数器的值加 1,以便只有把设备文件关闭之后才能卸载这个模块。
  • 如果设备驱动程序要处理多个同类型的设备,那么,就使用从设备号来选择合适的驱动程序,如果需要,还要使用专门的文件操作表选择驱动程序。
  • 检查该设备是否真正存在,现在是否正在工作。
  • 如果必要,向硬件设备发送一个初始化命令序列。
  • 初始化设备驱动程序的数据结构。

一个字符设备驱动程序的实例

Linux中, 驱动程序一般用C语言编写,有时也支持一些汇编和`C++语言。

头文件、宏定义和全局变量

一个典型的设备驱动程序一般都包含有一个专用头文件,这个头文件中包含一些系统函数的声明、设备寄存器的地址、寄存器状态位和控制位的定义以及用于此设备驱动程序的全局变量的定义,另外大多数驱动程序还使用以下一些标准的头文件。

  • param.h包含一些内核参数
  • dir.h包含一些目录参数
  • user.h用户区域的定义
  • tty.h终端和命令列表的定义
  • fs.h其中包括Buffer header信息

下面是一些必要的头文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <linux/kernel.h>
#include <linux/module.h>
#if CONFIG_MODVERSIONS==1 /* 处理`CONFIG_MODVERSIONS */
#define MODVERSIONS
#include <linux/modversions.h>
#endif
/* 下面是针对字符设备的头文件 */
#include <linux/fs.h>
#include <linux/wrapper.h>
/* 对于不同的版本我们需要做一些必要的事情*/
#ifndef KERNEL_VERSION
#define KERNEL_VERSION(a,b,c) ((a)*65536+(b)*256+(c))
#endif
#if LINUX_VERSION_CODE > KERNEL_VERSION(2,4,0)
#include <asm/uaccess.h> /* for copy_to_user */
#endif

#define SUCCESS 0
/* 声明设备 */
/* 这是本设备的名字,它将会出现在 /proc/devices */

#define DEVICE_NAME "char_dev"
/* 定义此设备消息缓冲的最大长度 */
#define BUF_LEN 100
/* 为了防止不同的进程在同一个时间使用此设备,定义此静态变量跟踪其状态 */
static int Device_Open = 0
/* 当提出请求的时候,设备将读写的内容放在下面的数组中 */
static char Message[BUF_LEN];
/* 在进程读取这个内容的时候,这个指针是指向读取的位置*/
static char *Message_Ptr ;
/* 在这个文件中,主设备号作为全局变量以便于这个设备在注册和释放的时候使用*/
static int Major;

open()函数

功能:无论一个进程何时试图去打开这个设备都会调用这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int device_open(struct inode *inode,
struct file *file)
{
static int counter = 0;
#ifdef DEBUG
printk ("device_open(%p,%p)\n", inode, file);
#endif
printk("Device: %d.%d\n",
inode->i_rdev >> 8, inode->i_rdev & 0xFF);
/* 这个设备是一个独占设备,为了避免同时有两个进程使用这一个设备我们需要采取一定的措施*/
if (Device_Open)
return -EBUSY;
Device_Open++;
/* 下面是初始化消息,注意不要使读写内容的长度超出缓冲区的长度,特别是运行在内核模式时,否
则如果出现缓冲上溢则可能导致系统的崩溃*/
sprintf(Message, "If I told you once, I told you %d times - %s", counter++, "Hello, world\n");
Message_Ptr = Message;
/*当这个文件被打开的时候,我们必须确认该模块还没有被移走并且增加此模块的用户数目(在移走
一个模块的时候会根据这个数字去决定可否移去,如果不是 0 则表明还有进程正在使用这个模块,不能移
走)*/
MOD_INC_USE_COUNT;
return SUCCESS;
}

release()函数

功能:当一个进程试图关闭这个设备特殊文件的时候调用这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2,4,0)
static int device_release(struct inode *inode,
struct file *file)
#else
static void device_release(struct inode *inode,
struct file *file)
#endif
{
#ifdef DEBUG
printk ("device_release(%p,%p)\n", inode, file);
#endif

/* 为下一个使用这个设备的进程做准备*/
Device_Open --;
/* 减少这个模块使用者的数目,否则一旦你打开这个模块以后,你永远都不能释放掉它*/
MOD_DEC_USE_COUNT;
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2,4,0)
return 0;
#endif
}

read()函数

功能:当一个进程已经打开此设备文件以后并且试图去读它的时候调用这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2,4,0)
static ssize_t device_read(struct file *file,
char *buffer, /* 把读出的数据放到这个缓冲区*/
size_t length, /* 缓冲区的长度*/
loff_t *offset) /* 文件中的偏移 */
#else
static int device_read(struct inode *inode,
struct file *file,
char *buffer, int length)
#endif
{
/* 实际上读出的字节数 */
int bytes_read = 0;
/* 如果读到缓冲区的末尾,则返回 0 ,类似文件的结束*/
if (*Message_Ptr == 0)
return 0;
/* 将数据放入缓冲区中*/
while (length && *Message_Ptr) {
/* 由于缓冲区是在用户空间而不是内核空间,所以我们必须使用`copu_to_user()`函数将内核空间中的数据拷贝到用户空间*/
copy_to_user(buffer++,*(Message_Ptr++), length--);
bytes_read ++;
}
#ifdef DEBUG
printk ("Read %d bytes, %d left\n",
bytes_read, length);
#endif
/* Read函数返回一个真正读出的字节数*/
return bytes_read;
}

write()函数

功能:当试图将数据写入这个设备文件的时侯,这个函数被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2,4,0)
static ssize_t device_write(struct file *file,
const char *buffer,
size_t length,
loff_t *offset)
#else
static int device_write(struct inode *inode,
struct file *file,
const char *buffer,
int length)
#endif
{
int i;
#ifdef DEBUG
printk ("device_write(%p,%s,%d)", file, buffer, length);
#endif
#if LINUX_VERSION_CODE >= KERNEL_VERSION(2,4,0)
copy_from_user(Message, buffer,`length);
Message_Ptr = Message;
/* 返回写入的字节数 */
return i;
}

这个设备驱动程序提供给文件系统的接口

当一个进程试图对我们生成的设备进行操作的时候就利用下面这个结构,这个结构就是我们提供给操作系统的接口,它的指针保存在设备表中,在init_module()中被传递给操作系统。

1
2
3
4
5
6
struct file_operations Fops = {
read: device_read,
write: device_write,
open: device_open,
release: device_release
};

模块的初始化和模块的卸载

init_module函数用来初始化这个模块—注册该字符设备。init_module ()函数调用module_register_chrdev,把设备驱动程序添加到内核的字符设备驱动程序表中,它返回这个驱动程序所使用的主设备号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int init_module()
{
/* 试图注册设备*/
Major = module_register_chrdev(0,
DEVICE_NAME,
&Fops);
/* 失败的时候返回负值*/
if (Major < 0) {
printk ("%s device failed with %d\n",
"Sorry, registering the character",
Major);
return Major;
}
printk ("%s The major device number is %d.\n", "Registeration is a success.", Major);
printk ("If you want to talk to the device driver,\n");
printk ("you'll have to create a device file. \n");
printk ("We suggest you use:\n");
printk ("mknod <name> c %d <minor>\n", Major);
printk ("You can try different minor numbers %s", "and see what happens.\n");
return 0;
}

以下这个函数的功能是卸载模块,主要是从/proc中取消注册的设备特殊文件。

1
2
3
4
5
6
7
8
9
void cleanup_module()
{
int ret;
/* 取消注册的设备*/
ret = module_unregister_chrdev(Major, DEVICE_NAME);
/* 如果出错则显示出错信息 */
if (ret < 0)
printk("Error in unregister_chrdev: %d\n", ret);
}

驱动程序的编译与装载

Linux里,除了直接修改系统内核的源代码,把设备驱动程序加进内核外,还可以把设备驱动程序作为可加载的模块,由系统管理员动态地加载它,使之成为内核的一部分。也可以由系统管理员把已加载的模块动态地卸载下来。Linux中,模块可以用C语言编写,用gcc编译成目标文件(不进行链接,作为*.o文件存盘),为此需要在gcc命令行里加上-c的参数。在编译时,还应该在gcc的命令行里加上这样的参数:

1
-D__KERNEL__ -DMODULE。

由于在不链接时,gcc只允许一个输入文件,因此一个模块的所有部分都必须在一个文件里实现。编译好的模块*.o放在/lib/modules/xxxx/misc下(xxxx表示内核版本),然后用depmod -a使此模块成为可加载模块。模块用insmod命令加载,用rmmod命令来卸载,并可以用lsmod命令来查看所有已加载的模块的状态。

编写模块程序的时候,必须提供两个函数,一个是int init_module(void),供insmod在加载此模块的时候自动调用,负责进行设备驱动程序的初始化工作。init_module返回 0以表示初始化成功,返回负数表示失败。另一个函数是void cleanup_module (void),在模块被卸载时调用,负责进行设备驱动程序的清除工作。

在成功地向系统注册了设备驱动程序后(调用register_chrdev成功后),就可以用mknod命令来把设备映射为一个特别文件,其他程序使用这个设备的时候,只要对此特别文件进行操作就行了。

网络

网络协议

网络参考模型

OSI参考模型和TCP/IP参考模型如表所示。

TCP/IP工作原理及数据流

TCP/IP不是一个单独的协议,它是由一组协议组成的协议集,在TCP/IP参考模型中各层对应的协议如表所示。

IP不仅是TCP/IP的一个重要组成部分,而且也是OSI模型的一个基本协议。IP定义了一个协议,而不是一个连接,因此与网络连接无关。IP主要负责数据报在计算机之间的寻址问题,并管理这些数据报的分段过程。该协议在信息数据报格式和由数据报信息组成的报头方面有规范的定义。IP负责数据报的路由,决定数据报发送到哪里以及在出现问题时更换路由。

IP数据报的传输具有“不可靠性”,数据报的传输不能受到保障,因为数据报可能会遇到延迟或路由错误,或在数据报分解和重组时遭到破坏。IP没有能力证实发送的报文是否能被正确的接收,IP把验证和流量控制的任务交给了分层模型中的其他部件完成。IP是无连接的,它不管数据报沿途经过那些节点。它的这些特点都在IP报体现。如图 12.3 所示,数据经过IP层时,都会被加上IP的协议头,其输入/输出是从用户的角度来看的。

IP的协议头,也可叫做IP数据报或IP报头,是IP的基本传输单元。IP`协议头的结构如图 12.4 所示。

TCP

TCP是传输层中使用最为广泛的一协议,它可以向上层提供面向连接的协议,使上层启动应用程序,以确保网络上所发送的数据报被完整接收。就这种作用而言,TCP的作用是提供可靠通信的有效报文协议。一旦数据报被破坏或丢失,通常是TCP将其重新传输,而不是应用程序或IP

TCP必须与低层的IP(使用IP定义好的方法)和高层的应用程序(使用TCP-ULP元语)进行通信。TCP还必须通过网络与其他TCP软件进行通信。为此,它使用了协议数据单元(PDU),在TCP用语中称为分段。TCP PDU(通常称为TCP报头)的分布如图 12.5 所示。

部分域含义如下。

  • 本机端口:标识本机TCP用户(通常为上层应用程序)的 16 位域。
  • 远端端口:标识远程计算机TCP用户的 16 位域。
  • 序号:指明当前时钟在全文中位置的序号。也可用在两个TCP之间以提供初始发送序号(ISS)。
  • 确认号:指明下一个预计序列的序号。反过来,它还可以表示最后接收数据的序号,表示最后接收的序号加 1。
  • 数据偏移:用于标识数据段的开始。
  • URG:如果打开(值为 1),则指明紧急指针域有效。
  • ACK:如果打开,则指明确认域有效。
  • RST:如果打开,则指明要重复连接。
  • SYN:如果打开,则指明要同步的序号。
  • FIN:如果打开,则指明发送双方不再发送数据。这与传输结束标志是相同的。

这些域在TCP连接和传输数据时会用到。

TCP对如何通信有许多规则。这些规则以及TCP连接、传输要遵循的过程,通常都体现在状态数据报中(因为TCP是一个状态驱动协议,其行为取决于状态标志或类似结构)。要完全避免复杂的状态数据报是很困难的,所以流程图对理解TCP是一种很有效的方法。下面我们就以TCP连接的流程图为例,介绍TCP的工作原理。如图 12.6 所示。此过程以计算机ATCP开始,TCP可从它的ULP接收连接请求,通过它向计算机B发送一个主动打开原语,所构成的分段应设置SYN标志(值为 1),并分配一个序列号M。图12.6 用SYN 50表示,SYN标志打开,序号M用 50 表示,可任意选择。

计算机B上的应用程序将向它的TCP发送一个被动打开指令,当接收到SYN M分段时,计算机B上的TCP将序号M+1发回一个确认给计算机A,图 12.6 用ACK 51表示。计算机B也为自己设置一个初始发送序号N,图 12.6 用SYN 200表示。

计算机A根据接收到的内容,通过将序号设置为N+1,发回他自己的确认报文,图 12.6 用ACK 201表示。然后,打开并确认此次连接,计算机A和计算机B通过ULP将连接打开报文发送到请求的应用程序。至此两台计算机建立了连接,可以在TCP层传输数据。

套接字(socket)

套接字在网络中的地位和作用

socket在所有的网络操作系统中都是必不可少的,而且在所有的网络应用程序中也是必不可少的。它是网络通信中应用程序对应的进程和网络协议之间的接口,如图 12.7 所示。

socket在网络系统中的作用如下。

  1. socket位于协议之上,屏蔽了不同网络协议之间的差异。
  2. socket是网络编程的入口,它提供了大量的系统调用,构成了网络程序的主体。
  3. Linux系统中,socket属于文件系统的一部分,网络通信可以被看作是对文件的读取,使得我们对网络的控制和对文件的控制一样方便。

套接字接口的种类

Linux支持多种套接字种类,不同的套接字种类称为“地址族”,这是因为每种套接字种类拥有自己的通信寻址方法。

套接字的工作原理

INET套接字就是支持Internet地址族的套接字,它位于TCP之上,BSD套接字之下。INETBSD套接字之间的接口通过Internet地址族套接字操作集实现,这些操作集实际是一组协议的操作例程,在include/linux/net.h中定义为proto_ops

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct proto_ops {
int family;
int (*release) (struct socket *sock);
int (*bind) (struct socket *sock, struct sockaddr *umyaddr,
int sockaddr_len);
int (*connect) (struct socket *sock, struct sockaddr *uservaddr,
int sockaddr_len, int flags);
int (*socketpair) (struct socket *sock1, struct socket *sock2);
int (*accept) (struct socket *sock, struct socket *newsock,
int flags);
int (*getname) (struct socket *sock, struct sockaddr *uaddr,
int *usockaddr_len, int peer);
unsigned int (*poll) (struct file *file, struct socket *sock, struct poll_table_struct *wait);
int (*ioctl) (struct socket *sock, unsigned int cmd,
unsigned long arg);
int (*listen) (struct socket *sock, int len);
int (*shutdown) (struct socket *sock, int flags);
int (*setsockopt) (struct socket *sock, int level, int optname,
char *optval, int optlen);
int (*getsockopt) (struct socket *sock, int level, int optname,
char *optval, int *optlen);
int (*sendmsg) (struct socket *sock, struct msghdr *m, int total_len, struct scm_cookie *scm);
int (*recvmsg) (struct socket *sock, struct msghdr *m, int total_len, int flags, struct scm_cookie *scm);
int (*mmap) (struct file *file, struct socket *sock, struct vm_area_struct * vma);
ssize_t (*sendpage) (struct socket *sock, struct page *page, int offset, size_t size, int flags);
};

这个操作集类似于文件系统中的file_operations结构。BSD套接字层通过调用proto_ops结构中的相应函数执行任务。BSD套接字层向INET套接字层传递socket数据结构来代表一个BSD套接字,socket结构在include/linux/net.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct socket
{
socket_state state;
unsigned long flags;
struct proto_ops *ops;
struct inode *inode;
struct fasync_struct *fasync_list; /* Asynchronous wake up list */
struct file *file; /* File back pointer for gc */
struct sock *sk;
wait_queue_head_t wait;
short type;
unsigned char passcred;
};

但在INET套接字层中,它利用自己的sock数据结构来代表该套接字,因此,这两个结构之间存在着链接关系,sock结构定义于include/net/sock.h。在BSDsocket数据结构中存在一个指向sock的指针sk,而在sock中又有一个指向socket的指针,这两个指针将BSD socket数据结构和sock数据结构链接了起来。通过这种链接关系,套接字调用就可以方便地检索到sock数据结构。实际上,sock数据结构可适用于不同的地址族,它也定义有自己的协议操作集proto。在建立套接字时,sock数据结构的协议操作集指针指向所请求的协议操作集。如果请求TCP,则sock数据结构的协议操作集指针将指向TCP的协议操作集。

进程在利用套接字进行通信时,采用客户/服务器模型。服务器首先创建一个套接字,并将某个名称绑定到该套接字上,套接字的名称依赖于套接字的底层地址族,但通常是服务器的本地地址。套接字的名称或地址通过sockaddr数据结构指定,该结构定义于include/linux/socket.h中:

1
2
3
4
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};

对于INET套接字来说,服务器的地址由两部分组成,一个是服务器的IP地址,另一个是服务器的端口地址。已注册的标准端口可查看/etc/services文件。将地址绑定到套接字之后,服务器就可以监听请求链接该绑定地址的传入连接。连接请求由客户生成,它首先建立一个套接字,并指定服务器的目标地址以请求建立连接。传入的连接请求通过不同的协议层最终到达服务器的监听套接字。服务器接收到传入的请求后,如果能够接受该请求,服务器必须创建一个新的套接字来接受该请求并建立通信连接(用于监听的套接字不能用来建立通信连接),这时,服务器和客户就可以利用建立好的通信连接传输数据。

内核负责在BSD套接字和底层的地址族之间建立联系。这种联系通过交叉链接数据结构以及地址族专有的支持例程表建立。

在内核中,地址族和协议信息保存在inet_protos向量中,其定义于include/net/protocol.h

1
2
3
4
5
6
7
8
9
10
11
12
struct inet_protocol *inet_protos[MAX_INET_PROTOS];
/* This is used to register protocols. */
struct inet_protocol
{
int (*handler)(struct sk_buff *skb);
void (*err_handler)(struct sk_buff *skb, u32 info);
struct inet_protocol *next;
unsigned char protocol;
unsigned char copy:1;
void *data;
const char *name;
};

每个地址族由其名称以及相应的初始化例程地址代表。在引导阶段初始化套接字接口时,内核调用每个地址族的初始化例程,这时,每个地址族注册自己的协议操作集。协议操作集实际是一个例程集合,其中每个例程执行一个特定的操作。

socket的通信过程

请先看如图 12.9 所示的socket通信过程。

建立套接字

Linux在利用socket()系统调用建立新的套接字时,需要传递套接字的地址族标识符、套接字类型以及协议,其函数定义于net/socket.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
asmlinkage long sys_socket(int family, int type, int protocol)
{
int retval;
struct socket *sock;

retval = sock_create(family, type, protocol, &sock);
if (retval < 0)
goto out;

retval = sock_map_fd(sock);
if (retval < 0)
goto out_release;
out:
/* It may be already another descriptor 8) Not kernel problem. */
return retval;
out_release:
sock_release(sock);
return retval;
}

实际上,套接字对于用户程序而言就是特殊的已打开的文件。内核中为套接字定义了一种特殊的文件类型,形成一种特殊的文件系统sockfs,其定义于net/socket.c

1
2
static struct vfsmount *sock_mnt;
static DECLARE_FSTYPE(sock_fs_type, "sockfs",sockfs_read_super, FS_NOMOUNT);

在系统初始化时,要通过kern_mount()安装这个文件系统。安装时有个作为连接件的vfsmount数据结构,这个结构的地址就保存在一个全局的指针sock_mnt中。所谓创建一个套接字,就是在sockfs文件系统中创建一个特殊文件,或者说一个节点,并建立起为实现套接字功能所需的一整套数据结构。所以,函数sock_create()首先是建立一个socket数据结构,然后将其“映射”到一个已打开的文件中,进行socket结构和sock结构的分配和初始化。

新创建的BSD socket数据结构包含有指向地址族专有的套接字例程的指针,这一指针实际就是proto_ops数据结构的地址。BSD套接字的套接字类型设置为所请求的SOCK_STREAMSOCK_DGRAM等。然后,内核利用proto_ops数据结构中的信息调用地址族专有的创建例程。

之后,内核从当前进程的fd向量中分配空闲的文件描述符,该描述符指向的file数据结构被初始化。初始化过程包括将文件操作集指针指向由BSD套接字接口支持的BSD文件操作集。所有随后的套接字(文件)操作都将定向到该套接字接口,而套接字接口则会进一步调用地址族的操作例程,从而将操作传递到底层地址族,如图 12.10 所示。

实际上,socket结构与sock结构是同一事物的两个方面。如果说socket结构是面向进程和系统调用界面的,那么sock结构就是面向底层驱动程序的。可是,为什么不把这两个数据结构合并成一个呢?

我们说套接字是一种特殊的文件系统,因此,inode结构内部的union的一个成分就用作socket结构,其定义如下:

1
2
3
4
5
6
7
struct inode {

union {

struct socket socket_i;
}
}

由于套接字操作的特殊性,这个结构中需要大量的结构成分。可是,如果把这些结构成分全都放在socket结构中,则inode结构中的这个union就会变得很大,从而inode结构也会变得很大,而对于其他文件系统,这个union成分并不需要那么庞大。因此,就把套接字所需的这些结构成分拆成两部分,把与文件系统关系比较密切的那一部分放在socket结构中,把与通信关系比较密切的那一部分则单独组成一个数据结构,即sock结构。由于这两部分数据在逻辑上本来就是一体的,所以要通过指针互相指向对方,形成一对一的关系。

在INET BSD套接字上绑定(bind)地址

为了监听传入的Internet连接请求,每个服务器都需要建立一个INET BSD套接字,并且将自己的地址绑定到该套接字。绑定操作主要在INET套接字层中进行,还需要底层TCP层和IP层的某些支持。将地址绑定到某个套接字上之后,该套接字就不能用来进行任何其他的通信,因此,该socket数据结构的状态必须为TCP_CLOSE。传递到绑定操作的sockaddr数据结构中包含要绑定的IP地址,以及一个可选的端口地址。通常而言,要绑定的地址应该是赋予某个网络设备的IP地址,而该网络设备应该支持INET地址族,并且该设备是可用的。利用ifconfig命令可查看当前活动的网络接口。被绑定的IP地址保存在sock数据结构的rcv_saddrsaddr域中,这两个域分别用于哈希查找和发送用的IP地址。端口地址是可选的,如果没有指定,底层的支持网络会选择一个空闲的端口。

当底层网络设备接收到数据包时,它必须将数据传递到正确的INETBSD套接字以便进行处理,因此,TCP维护多个哈希表,用来查找传入IP消息的地址,并将它们定向到正确的socket/sock对。TCP并不在绑定过程中将绑定的sock数据结构添加到哈希表中,在这一过程中,它仅仅判断所请求的端口号当前是否正在使用。在监听操作中,该sock结构才被添加到TCP的哈希表中。

监听(listen)INET BSD套接字

当某个套接字被绑定了地址之后,该套接字就可以用来监听专属于该绑定地址的传入连接。网络应用程序也可以在未绑定地址之前监听套接字,这时,INET套接字层将利用空闲的端口编号并自动绑定到该套接字。套接字的监听函数将socket的状态改变为TCP_LISTEN。当接收到某个传入的TCP连接请求时,TCP建立一个新的sock数据结构来描述该连接。当该连接最终被接受时,新的sock数据结构将变成该TCP连接的内核bottom_half部分,这时,它要克隆包含连接请求的传入sk_buff中的信息,并在监听sock数据结构的receive_queue队列中将克隆的信息排队。克隆的sk_buff中包含有指向新sock数据结构的指针。

接受连接请求(accept)

接受操作在监听套接字上进行,从监听socket中克隆一个新的socket数据结构。其过程如下:接受操作首先传递到支持协议层,即INET中,以便接受任何传入的连接请求。相反,接受操作进一步传递到实际的协议,例如TCP上。接受操作可以是阻塞的,也可以是非阻塞的。接受操作为非阻塞的情况下,如果没有可接受的传入连接,则接受操作将失败,而新建立的socket数据结构被抛弃。接受操作为阻塞的情况下,执行阻塞操作的网络应用程序将添加到等待队列中,并保持挂起直到接收到一个TCP连接请求为至。当连接请求到达之后,包含连接请求的sk_buff被丢弃,而由TCP建立的新sock数据结构返回到INET套接字层,在这里,sock数据结构和先前建立的新socket数据结构建立链接。而新socket的文件描述符被返回到网络应用程序,此后,应用程序就可以利用该文件描述符在新建立的INET BSD套接字上进行套接字操作。

socket为用户提供的系统调用

socket系统调用是socket最有价值的一部分,也是用户唯一能够接触到的一部分,它是我们进行网络编程的接口。如表所示。

系统调用 说明
Accept 接收套接字上连接请求
Bind 在套接字绑定地址信息
Connet 连接两个套接字
Getpeername 获取已连接端套接字的地址
Getsockname 获取套接字的地址
Getsockopt 获取套接字上的设置选项
Listen 监听套接字连接
Recv 从已连接套接字上接收消息
Recvfrom 从套接字上接收消息
Send 向已连接的套接字发送消息
Sendto 向套接字发送消息
Setdomainname 设置系统的域名
Sethostid 设置唯一的主机标识符
Sethostname 设置系统的主机名称
Setsockopt 修改套接字选项
Shutdown 关闭套接字
Socket 建立套接字通信的端点
Socketcall 套接字调用多路复用转换器
Socketpair 建立两个连接套接字

套接字缓冲区(sk_buff)

套接字缓冲区是网络部分一个重要的数据结构,它描述了内存中的一块数据区域,该数据区域存放着网络传输的数据包。

套接字缓冲区的特点

当套接字缓冲区在协议层流动过程中,每个协议都需要对数据区的内容进行修改,也就是每个协议都需要在发送数据时向缓冲区添加自己的协议头和协议尾,而在接收数据时去掉这些协议头和协议尾,这样就存在一个问题,当缓冲区在不同的协议之间传递时,每层协议都要寻找自己特定的协议头和协议尾,从而导致数据缓冲区的传递非常困难。我们设置sk_buff数据结构的主要目的就是为网络部分提供一种统一有效的缓冲区操作方法,从而可让协议层以标准的函数或方法对缓冲区数据进行处理,这是Linux系统网络高效运行的关键。

套接字缓冲区操作基本原理

在传输过程中,存在着多个套接字缓冲区,这些缓冲区组成一个链表,每个链表都有一个链表头sk_buff_head,链表中每个节点分别对应内存中一块数据区。因此对它的操作有两种基本方式:第 1 种是对缓冲区链表进行操作;第 2 种是对缓冲区对应的数据区进行控制。

当我们向物理接口发送数据时或当我们从物理接口接收数据时,我们就利用链表操作;当我们要对数据区的内容进行处理时,我们就利用内存操作例程。这种操作机制对网络传输是非常有效的。

前面我们讲过,每个协议都要在发送数据时向缓冲区添加自己的协议头和协议尾,而在接收数据时去掉协议头和协议尾,那么具体的操作是怎样进行的呢?我们先看看对缓冲区操作的两个基本的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void append_frame(char *buf, int len){
struct sk_buff *skb=alloc_skb(len, GFP_ATOMIC); /*创建一个缓冲区*/
if(skb==NULL)
my_dropped++;
else {
kb_put(skb,len);
memcpy(skb->data,data,len); /*向缓冲区添加数据*/
skb_append(&my_list, skb); /*将该缓冲区加入缓冲区队列*/
}
}
void process_frame(void){
struct sk_buff *skb;
while((skb=skb_dequeue(&my_list))!=NULL)
{
process_data(skb); /*将缓冲区的数据传递给协议层*/
kfree_skb(skb, FREE_READ); /*释放缓冲区,缓冲区从此消失*/
}
}

这两个非常简单的程序片段,虽然它们不是源程序,但是它们恰当地描述了处理数据包的工作原理,append_frame()描述了分配缓冲区。创建数据包过程process_frame()描述了传递数据包,释放缓冲区的的过程。关于它们的源程序,可以去参见net/core/dev.cnetif_rx()函数和net_bh()函数。你可以看出它们和上面我们提到的两个函数非常相似。

这两个函数非常复杂,因为他们必须保证数据能够被正确的协议接收并且要负责流程的控制,但是他们最基本的操作是相同的。

让我们再看看上面提到的函数append_frame()。当alloc_skb()函数获得一个长度为len字节的缓冲区(如图 12.12 (a)所示)后,该缓冲区包含以下内容:

  • 缓冲区的头部有零字节的头部空间;
  • 零字节的数据空间;
  • 缓冲区的尾部有零字节的尾部空间。

再看skb_put()函数(如图 12.12 (d)所示),它的作用是从数据区的尾部向缓冲区尾部不断扩大数据区大小,为后面的memcpy()函数分配空间。

当一个缓冲区创建以后,所有的可用空间都在缓冲区的尾部。在没有向其中添加数据之前,首先被执行的函数调用是skb_reserve()(如图 12.12 (b)所示),它使你在缓冲区头部指定一定的空闲空间,因此许多发送数据的例程都是这样开头的:

1
2
3
4
5
skb=alloc_skb(len+headspace, GFP_KERNEL);
skb_reserve(skb, headspace);
skb_put(skb,len);
memcpy_fromfs(skb->data,data,len);
pass_to_m_protocol(skb);

sk_buff数据结构的核心内容

sk_buff数据结构中包含了一些指针和长度信息,从而可让协议层以标准的函数或方法对应用程序的数据进行处理,其定义于include/linux/skbuff.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
struct sk_buff {
/* These two members must be first. */
struct sk_buff * next; /* Next buffer in list*/
struct sk_buff * prev; /* Previous buffer in list*/
struct sk_buff_head * list; /* List we are on */
struct sock *sk; /* Socket we are owned by */
struct timeval stamp; /* Time we arrived */
struct net_device *dev; /* Device we arrived on/are leaving by */
/* Transport layer header */
union
{
struct tcphdr *th;
struct udphdr *uh;
struct icmphdr *icmph;
struct igmphdr *igmph;
struct iphdr *ipiph;
struct spxhdr *spxh;
unsigned char *raw;
} h;
/* Network layer header */
union
{
struct iphdr *iph;
struct ipv6hdr *ipv6h;
struct arphdr *arph;
struct ipxhdr *ipxh;
unsigned char *raw;
} nh;

/* Link layer header */
union
{
struct ethhdr *ethernet;
unsigned char *raw;
} mac;
struct dst_entry *dst;
/*
* This is the control buffer. It is free to use for every
* layer. Please put your private variables there. If you
* want to keep them across layers you have to do a skb_clone()
* first. This is owned by whoever has the skb queued ATM.
*/
char cb[48];
unsigned int len; /* Length of actual data*/
unsigned int data_len;
unsigned int csum; /* Checksum */
unsigned char __unused, /* Dead field, may be reused */
cloned, /* head may be cloned (check refcnt to be sure). */
pkt_type, /* Packet class */
ip_summed; /* Driver fed us an IP checksum */
__u32 priority; /* Packet queueing priority */
atomic_t users; /* User count - see datagram.c,tcp.c */
unsigned short protocol; /* Packet protocol from driver. */
unsigned short security; /* Security level of packet */
unsigned int truesize; /* Buffer size */
unsigned char *head; /* Head of buffer */
unsigned char *data; /* Data head pointer
unsigned char *tail; /* Tail pointer


unsigned char *end; /* End pointer */
void (*destructor)(struct sk_buff *); /* Destruct function */

}

该结构的示意图如图 12.13 所示。

每个sk_buff均包含一个数据块、4 个数据指针以及两个长度字段。利用 4 个数据指针,各协议层可操纵和管理套接字缓冲区的数据,这 4 个指针的用途如下所述。

  • head:指向内存中数据区的起始地址。sk_buff`和相关数据块在分配之后,该指针的值是固定的。
  • data:指向协议数据的当前起始地址。该指针的值随当前拥有sk_buff的协议层的变化而变化。
  • tail:指向协议数据的当前结尾地址。和data指针一样,该指针的值也随当前拥有sk_buff的协议层的变化而变化。
  • end:指向内存中数据区的结尾。和head指针一样,sk_buff被分配之后,该指针的值也固定不变。

sk_buff有两个非常重要长度字段,lentruesize,分别描述当前协议数据包的长度和数据缓冲区的实际长度。

套接字缓冲区提供的函数

操纵sk_buff链表的函数

sk_buff链表是一个双向链表,它包括一个链表头而且每一个缓冲区都有一个prevnext指针,指向链表中前一个和后一个缓冲区节点。

1
struct sk_buff *skb_dequeue(struct skb_buff_head *list) 

这个函数作用是把第 1 个缓冲区从链表中移走。返回取出的sk_buff,如果队列为空,就返回空指针。添加缓冲区用到skb_queue_headskb_queue_tail两个例程。

1
int skb_peek(struct sk_buff_head *list)

返回指向缓冲区链表第 1 个节点的指针。

1
int skb_queue_empty(struct sk_buff_head *list)

如果链表为空,返回true 。

1
void skb_queue_head(struct sk_buff *skb)

这个函数在链表头部添加一个缓冲区。

1
void skb_queue_head_init(struct sk_buff_head *list)

初始化sk_buff_head结构 。该函数必须在所有的链表操作之前调用,而且它不能被重复执行。

1
__u32 skb_queue_len(struct sk_buff_head *list)

返回队列中排队的缓冲区的数目。

1
void skb_queue_tail(struct sk_buff *skb)

这个函数在链表的尾部添加一个缓冲区,这是在缓冲区操作函数中最常用的一个函数。

1
void skb_unlink(struct sk_buff *skb)

这个函数从链表中移去一个缓冲区。它只是将缓冲区从链表中移去,但并不释放它。

许多更复杂的协议,如TCP协议,当它接收到数据时,需要保持链表中数据帧的顺序或对数据帧进行重新排序。有两个函数完成这些工作:

1
2
void skb_append(struct sk_buff *entry, struct sk_buff *new_entry)
void skb_insert(struct sk_buff *entry, struct sk_buff *new_entry)

它们可以使用户把一个缓冲区放在链表中任何一个位置。

创建或取消一个缓冲区结构的函数

这些操作用到内存处理方法,它们的正确使用对管理内存非常重要。sk_buff结构的数量和它们占用内存大小会对机器产生很大的影响,因为网络缓冲区的内存组合是最主要一种的系统内存组合。

1
struct sk_buff *alloc_skb(int size, int priority)

创建一个新的sk_buff结构并将它初始化。

1
void kfree_skb(struct sk_buff *skb, int rw)

释放一个skb_buff

1
struct sk_buff *skb_clone(struct sk_buff *old, int priority)

复制一个sk_buff,但不复制数据部分。

1
struct sk_buff *skb_copy(struct sk_buff *skb)

完全复制一个sk_buff

对sk_buff结构数据区进行操作的操作

这些函数用到了套接字结构体中两个域:缓冲区长度skb->len和缓冲区中数据包的实际起始地址skb->data。这些两个域对用户来说是可见的,而且它们具有只读属性。

1
unsigned char *skb_headroom(struct sk_buff *skb)

返回sk_buff结构头部空闲空间的字节数大小。

1
unsigned char *skb_pull(struct sk_buff *skb, int len) 

该函数将data指针向数据区的末尾移动,减少了len字段的长度。该函数可用于从接收到的数据头上移去数据或协议头。

1
unsigned char *skb_push(struct sk_buff *skb, int len)

该函数将data指针向数据区的前端移动,增加了len字段的长度。在发送数据的过程中,利用该函数可在数据的前端添加数据或协议头。

1
unsigned char *skb_put(struct sk_buff *skb, int len)

该函数将tail指针向数据区的末尾移动,增加了len字段的长度。在发送数据的过程中,利用该函数可在数据的末端添加数据或协议尾。

1
unsigned char *skb_reserve(struct sk_buff *skb, int len)

该函数在缓冲区头部创建一块额外的空间,这块空间在skb_push添加数据时使用。因为套接字建立时并没有为skb_push预留空间。它也可以用于在缓冲区的头部增加一块空白区域,从而调整缓冲区的大小,使缓冲区的长度统一。这个函数只对一个空的缓冲区才能使用。

1
unsigned char *skb_tailroom(struct sk_buff *skb)

返回sk_buff尾部空闲空间的字节数大小。

1
unsigned char *skb_trim(struct sk_buff *skb, int len)

该函数和put函数的功能相反,它将tail指针向数据区的前端移动,减小了len字段的长度。该函数可用于从接收到的数据尾上移去数据或协议尾。如果缓冲区的长度比len还长,那么它就通过移去缓冲区尾部若干字节,把缓冲区的大小缩减到len长度。

套接字缓冲区的上层支持例程

我们上面讲了套接字缓冲区基本的操作方法,利用它们就可以完成数据包的发送和接收工作。为了保证网络传输的高效和稳定,我们需要对整个过程进行流程控制,因此,我们又引进了两个支持例程。它们是利用信号的交互来完成任务的。
sock_queue_rcv_skb()函数用来对数据的接收进行控制,通常调用它的的形式为:

1
2
3
4
5
6
7
sk=my_find_socket(whatever);
if(sock_queue_rcv_skb(sk,skb)==-1)
{
myproto_stats.dropped++;
kfree_skb(skb,FREE_READ);
return;
}

它利用套接字的读队列的计数器,从而避免了大量的数据包堆积在套接字层。一旦到达这个极限,其余的数据包就会被丢弃。这样做是为了保障高层的应用协议有足够快的读取速度,比如TCP,包含对该流程的控制,当接收端不能再接收数据时,TCP就告诉发送端的机器停止传输。

在数据传输方面,sock_alloc_send_skb()可以对发送队列进行控制, 我们不能把所有的缓冲区都填充数据,使得发送队列总有空余, 避免了数据堵塞。这个函数在具体应用时有很多微妙之处,所以推荐编写网络协议的作者尽可能使用它。

许多发送例程利用这个函数几乎可以做所有的工作:

1
2
3
4
5
6
7
8
skb=sock_alloc_send_skb(sk,....)
if(skb==NULL)
return -err;
skb->sk=sk;
skb_reserve(skb, headroom);
skb_put(skb,len);
memcpy(skb->data, data, len);
protocol_do_something(skb);

上面大部分代码我们前面已经见过。其中最重要的一句是skb->sk=sksock_alloc_send_skb()负责把缓冲区送到套接字层。通过设置skb->sk,告诉内核无论哪个例程对缓冲区进行kfree_skb()处理,都必须保证缓冲区已经成功地送到套接字层。因此一旦网络设备驱动程序发送一个缓冲区,并将之释放,我们就认为数据已经发送成功,这样我们就可以继续发送数据了 。 在源代码中我们看到kfree_skb操作一执行就会触发sock_alloc_send_skb()

网络设备接口

文件drivers/net/skeleton.c包含了网络设备驱动程序的基本骨架。

基本结构

如图 12.14 是网络设备驱动程序的结构,从中我们可以看出,网络设备驱动程序的功能分为两部分:发送数据和接受数据。在发送数据时,设备驱动程序全权负责把来自协议层的网络缓冲区发送到物理介质,并且接收硬件产生的应答信号;在接收数据时,设备驱动程序接收来自网络介质上的数据帧,并把它转换成能被网络协议识别的网络缓冲区,然后把它传递给netif_rx ()函数。这个函数的功能是把数据帧传递到网络协议层进行进一步的处理。

命名规则

所有的Linux网络设备都有唯一的名字,这个名字和文件系统所规定的设备的名字没有任何联系。事实上,网络设备并没有使用文件系统的表示方法。 传统上名字只表示设备类型而不代表生产厂商,如果同一类型的网络设备有多个,它们的名字就用从 0 开始的数字加以区别

设备注册

每一个设备的建立都需要在设备数据结构类型中添加一个设备对象,并将它传递给register_netdev(struct device *)函数。这样就把你的设备数据结构和内核中的网络设备表联系起来。如果你要传递的数据结构正被内核使用,就不能释放它们,直到你卸载该设备,卸载设备用到unregister_netdev(struct device *)函数。这些函数调用通常在系统启动时或网络模块安装或卸载时执行。

内核不允许用同一个名字安装多个设备。因此,如果你的设备是可安装的模块,就应该利用struct device *dev_get(const char *name)函数来确保名字没有被使用。如果名字已经被使用,那么就必须另选一个,否则新的设备将安装失败。如果发现有设备冲突,就可以使用unregister_netdev()注销一个使用该名字的设备。

下面是一个典型的设备注册的源代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int register_my_device(void)
{
int i=0;
for(i=0;i<100;i++)
{
sprintf(mydevice.name,"mydev%d",i);
if(dev_get(mydevice.name)==NULL)
{
if(register_netdev(&mydevice)!=0)
return -EIO;
return 0;
}
}
printk("100 mydevs loaded. Unable to load more.<\\>n");
return -ENFILE;
}

网络设备数据结构

网络设备数据结构device,是网络驱动程序的最重要的部分,也是理解Linux网络接口的关键,它的源代码保存在include/linux/netdevice.h中。

名称

name域指网络设备的名称,我们应该按上面讨论的命名方式为设备起名。该域也可以为空,这种情况下系统自动地分配一个ethn名字。在Linux 2.0版本以后,我们可以用dev_make_name("eth")函数来为设备命名。

总线接口参数

总线接口参数用来设置设备在设备地址空间的位置。

  • irq:指设备使用的中断请求号(IRQ),它通常在启动时或被初始化函数时设置。如果设备没有分配中断请求号,该域可以置 0。中断请求号也可以设置为变量,由系统自动搜索一个空闲的中断请求号分配给该设备。 网络设备驱动程序通常使用一个全局整型变量irq表示中断号,因此用户可以使用insmod mydevice irq=5这样的命令装载一个网络设备。最后,IRQ域也可以利用ifconfig命令很方便地进行设置。
  • base_addr(基地址):指设备占用的基本输入输出(I/O)地址空间。如果设备没有被分配I/O地址或该设备运在一个没有I/O空间概念的系统上,该域就置 0。当该地址由用户设置时,它通常用一个全局变量io来表示。I/O接口地址也可以由ifconfig设置。
  • 网络设备存在着两个硬件共享内存空间的情况,例如ISA总线和以太网卡共享内存空间。在网络设备的device数据结构中有 4 个相关的域。在共享内存时,rmem_startrmem_end域就被舍弃,并且置 0;mem_startmem_end两个域标识设备共享内存块的起始地址和结束地址。如果没有共享内存的情况,上面两个域就置 0。有一些设备允许用户设置内存地址,我们通常用一个全局变量mem表示。
  • dma:标志设备正在使用的DMA通道。Linux允许DMA(像中断一样)被系统自动探测。如果没有使用DMA通道或DMA通道没有设置,该域就置 0。如果由用户设置DMA通道,通常使用一个全局变量dma来表示。
  • if_port:标识一些多功能网络设备的类型,例如combo Ethernet boards

协议层参数

  • mtu:指网络接口的最大负荷,也就是网络可以传输的最大的数据包尺寸,它不包括设备自身提供的低层数据头的大小,该值常被协议层(如`IP)使用,用来选择大小合适数据包进行发送。
  • family:指该设备支持的地址族。常用的地址族是AF_INETLinux允许一个设备同时使用多个地址族。
  • interface hardware type:指设备所连接的物理介质的硬件接口类型,它的值来自物理介质类型表。支持ARP的物理介质,它们的接口类型被ARP使用;其他的接口类型是为其他物理层定义的。新的接口类型,只有当它对内核和net-tools都是必需时才会添加。包含像ifconfig这样的工具包可以对该域进行解码。该域的定义形式为:
1
2
3
4
5
6
7
8
ARPHRD_NETROMARPHRD_ETHER 10mbit/s`和 100mbit/s`以太网卡
ARPHRD_EETHER`实验用网卡 (没有使用)
ARPHRD_AX25 AX.25 2 级接口
ARPHRD_PRONET PROnet token ring (没有使用)
ARPHRD_CHAOS ChaosNET (没有使用)
ARPHRD_IEE802 802.2 networks notably token ring
ARPHRD_ARCNET ARCnet`接口
ARPHRD_DLCI Frame Relay DLCI

Linux定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
ARPHRD_SLIP Serial Line IP protocol
ARPHRD_CSLIP SLIP with VJ header compression
ARPHRD_SLIP6 6bit encoded SLIP
ARPHRD_CSLIP6 6bit encoded header compressed SLIP
ARPHRD_ADAPT SLIP interface in adaptive mode
ARPHRD_PPP PPP interfaces (async and sync)
ARPHRD_TUNNEL IPIP tunnels
ARPHRD_TUNNEL6 IPv6 over IP tunnels
ARPHRD_FRAD Frame Relay Access Device
ARPHRD_SKIP SKIP encryption tunnel
ARPHRD_LOOPBACK Loopback device
ARPHRD_LOCALTLK Localtalk apple networking device
ARPHRD_METRICOM Metricom Radio Network

上面标注“没有使用”的接口,是因为它们虽然被定义了类型,但是目前还没有支持它们的net-tools。Linux内核为以太网和令环网提供了额外的支持例程。

  • pa_addr:用来保持IP地址。
  • pa_brdaddr:网络广播地址。
  • pa_dstaddr:点对点连接中的目标地址。
  • pa_mask:网络掩码。
  • 上面所有域都被初始化为 0。
  • pa_alen:保存一个地址的长度,就IP地址而言,应该初始化为 4。

支持函数

初始化设置(init)

init函数在设备初始化和注册时被调用,它执行的是底层的确认和检查工作。在初始化程序里可以完成对硬件资源的配置。如果设备没有就绪或设备不能注册或其他任何原因而导致初始化工作不能正常进行,该函数就返回出错信息。一旦初始化函数返回出错信息,register_netdev()也返回出错信息,这样该设备就不能安装。

打开(open)

open这个函数在网络设备驱动程序里是网络设备被激活的时候被调用(即设备状态由down—>up)。所以实际上很多在init中的工作可以放到这里来做。比如资源的申请,硬件的激活。如果dev->open返回非零(error),则硬件的状态还是downopen函数另一个作用是如果驱动程序作为一个模块被装入,则要防止模块卸载时设备处于打开状态。在open方法里要调用MOD_INC_USE_COUNT宏。

关闭(stop)

close函数做和open函数相反的工作。可以释放某些资源以减少系统负担。close是在设备状态由up转为down时被调用的。另外如果是作为模块装入的驱动程序,close里应调用MOD_DEC_USE_COUNT,减少设备被引用的次数,以使驱动程序可以被卸载。另外close方法必须返回成功(0==success)。

数据帧传输例程

所有的设备驱动程序都必须提供传输例程,如果一个设备不能传输,也就没有存在的必要性。事实上,设备的所谓的传输仅仅是释放传送给它的缓冲区,而真正实现传输功能是虚拟设备。

dev->hard_start_xmit():该函数的功能是将网络缓冲区,也就是sk_buff发送到硬件设备。如果设备不能接受缓冲区,它就会返回 1,并置dev->tbusy为非零值。这样缓冲区就排成队列,等待着dev->tbusy置零以后会再次发送。如果协议层决定释放被设备抛弃的缓冲区,那么缓冲区就不会再被送回设备;如果设备知道缓冲区短时间内不被能传送,例如设备严重堵塞,那么它就调用dev_kfree_skb()函数丢掉缓冲区,该函数返回零值标明缓冲区已经被处理完毕。

当缓冲区被传送到硬件以后,硬件应答信号标识传输已经完毕,驱动程序必须调用dev_kfree_skb(skb, FREE_WRITE)函数释放缓冲区,一旦该调用结束,缓冲区就会很自然地消失,这样,驱动程序就不能再涉及缓冲区了。该函数传送下来的sk_buff中的数据已经包含硬件需要的帧头。所以在发送方法里不需要再填充硬件帧头,数据可以直接提交给硬件发送。sk_buff是被锁住的(ocked)确保其他程序不会存取它。

硬件帧头

网络设备驱动程序提供了一个dev->hard_header()例程,来完成添加硬件帧头的工作。协议层在发送数据之前会在缓冲区的开始留下至少dev->hard_header_len长度字节的空闲空间。这样dev->hard_header()程序只要调用skb_push(),然后正确填入硬件帧头就可以了。

调用这个例程需要给出和缓冲区相关的信息:设备指针、协议类型、指向源地址和目标地址(指硬件地址)的指针、数据包的长度。源地址可以为“NULL”,这意味着“使用默认地址”;目标地址也可以为“NULL”,这意味着“目标未知”。如果目标地址“未知”,数据帧头的操作就不能完成,本来为硬件帧头预留的空间全部被其他信息占用,那么函数就返回填充硬件帧头空间的字节数的相反数(一定为负数)。当硬件帧头完全建立以后,函数返回所添加的数据帧头的字节数。

如果一个硬件帧头不能够完全建立,协议层必须试图解决地址问题,因为硬件地址对于数据的发送是必需的。一旦这种情况发生,dev->rebuild_header()函数就会被调用,通常是利用ARP(地址解析协议)来完成。如果硬件帧头还不能被解决,该函数就返回零,并且会再次尝试,协议层总是相信硬件帧头的解决是可能的。

数据接收

网络设备驱动程序没有关于接收的处理,当数据到来时,总是驱动程序通知系统。对一个典型的网络设备,当它收到数据后都会产生一个中断,中断处理程序调用dev_alloc_skb(),申请一个大小合适的缓冲区sk_buff,把从硬件传来的数据放入缓冲区。接着,设备驱动程序分析数据包的类型,把skb->dev设置为接收数据的设备类型,把skb->protocol设置为数据帧描述的协议类型,这样,数据帧就可以被发送到正确的协议层。

硬件帧头指针保存在skb->mac.raw中,并且硬件帧头通过调用skb_pull()被去掉,因此网络协议就不涉及硬件的信息。最后还要设置skb->pkt_type,标明链路层数据类型,设备驱动程序必须按以下类型设置skb->pkt_type

  • PACKET_BROADCAST链接层广播地址
  • PACKET_MULTICAST链接层多路地址
  • PACKET_SELF发给自己的数据帧
  • PACKET_OTHERHOST发向另一个主机的数据帧(监听模式时会收到)

最后,设备驱动程序调用netif_rx(),把缓冲区向上传递给协议层。缓冲区首先排成一个队列,然后发出中断请求,中断请求响应后,缓冲区队列才被协议层进行处理。这种处理机制,延长了缓冲区等待处理的时间,但是减少了请求中断的次数,从而整体上提高了数据传输效率。一旦netif_rx()被调用,缓冲区就不在属设备驱动程序所有,它不能被修改,而且设备驱动程序也不能再涉及它了。

在协议层,接收数据包的流程控制分两个层次:首先,netif_rx()函数限制了从物理层到协议层的数据帧的数量。第二,每一个套接字都有一个队列,限制从协议层到套接字层的数据帧的数量。在传输方面,驱动程序的dev->tx_queue_len参数用来限制队列的长度。

队列的长度通常是 100 帧,在进行大量数据传输的高速连接中,它足以容纳下所有等待传输的缓冲区,不会出现大量缓冲区阻塞的情况。在低速连接中,例如slip连接,队列的长度长设为 10 帧左右,因为传输 10 帧的数据就要花费数秒的时间排列数据。

Linux系统的启动

初始化流程

每一个操作系统都要有自己的初始化程序,Linux`也不例外。那么,怎样初始化?我们
首先看一下初始化的流程。

1
加电或复位 -> BIOS`的启动 -> Boot Loader -> 操作系统 -> 初始化

加电或复位这一项代表操作者按下电源开关或复位按钮那一瞬间计算机完成的工作。BIOS的启动是紧跟其后的基于硬件的操作,它的主要作用就是完成硬件的初始化。BIOS启动完成后,Boot Loader`将读操作系统代码,然后由操作系统来完成初始化剩下的所有工作。

系统加电或复位

当一台装有Intel 386 CPU的计算机系统的电源开关或复位按钮被按下时,通常所说的冷启动过程就开始了。中央处理器进入复位状态,它将内存中所有的数据清零,并对内存进行校验,如果没有错误,CS寄存器中将置入FFFF[0]IP寄存器中将置入 0000[0],其实,这个CS:IP组合指向的是BIOS的入口,它将作为处理器运行的第一条指令。系统就是通过这个方法进入BIOS启动过程的。

BIOS`启动

BIOS的全名是基本输入输出系统(Basic Input Output System)。它的主要任务是提供CPU所需的启动指令。刚才提到了,计算机进入复位状态后,内存被自动清零,CPU此时是无法获得指令的。计算机的设计者们当然考虑到了这一点,因此,他们预先编好了供系统启动使用的启动程序,把它们存放在ROM中,并安排它到一个固定的位置,即FFFF:0000CPU就从BIOS中获得了启动所需的指令集。该指令集除了完成硬件的启动过程以外,还要将软盘或硬盘上的有关启动的系统软件调入内存。

首先是上电自检(POST Power-On Self Test),然后是对系统内的硬件设备进行监测和连接,并把测试所得的数据存放到BIOS数据区,以便操作系统在启动时或启动后使用,最后,BIOS将从软盘或硬盘上读入Boot Loader,到底是从软盘还是从硬盘启动要看BIOS的设置,如果是从硬盘启动,BIOS将读入该盘的零柱面零磁道上的 1 扇区(MBR),这个扇区上就存放着Boot Loader,该扇区的最后一个字存放着系统标志,如果该标志的值为AA55BIOS在完成硬件监测后会把控制权交给Boot Loader

除了启动程序以外,BIOS还提供一组中断以便对硬件设备的访问。我们知道,当键盘上的某一键被按下时,CPU就会产生一个中断并把这个键的信息读入,在操作系统没有被装入以前(如LinuxBootsect.S还没有被读入)或操作系统没有专门提供另外的中断响应程序的情况下,中断的响应程序就是由BIOS提供的。

这里介绍一个具体的BIOS系统,它的上电自检(POST)程序包含 14 个项目,具体内容如表所示,执行过POST后,该系统将调入硬盘上的Boot Loader

序号 相应内容 序号 相应内容
1 CPU处理器内部寄存器测试 8 键盘复位和测试
2 32K RAM存储器测试 9 键盘复位和测试
3 DMA控制器测试 10 附加RAM存储器测试
4 32K RAM存储器测试 11 其他包含在系统中的BIOS测试
5 CRT视频接口测试 12 软盘设备测试
6 8259中断控制器测试 13 硬盘设备测试
7 8253 定时器测试 14 打印机接口和串行接口测试

Boot Loader

Boot Loader通常是一段汇编代码,存放在MBR中,它的主要作用就是将系统启动代码读入内存。

操作系统的初始化

这部分实际上是初始化的关键。Boot Loader将控制权交给操作系统的初始化代码后,操作系统所要完成的存储管理、设备管理、文件管理、进程管理等任务的初始化必须马上进行,以便进入用户态。其实不管是单任务的DOS操作系统还是这里介绍的多任务Linux操作系统,当启动过程完成以后,系统都进入用户态,等待用户的操作命令。

Linux的Boot Loader

Boot Loader

实际上Boot Loader的来源有多种,最常见的一种是你的操作系统就是DOS,而Boot LoaderDOS系统提供的MS-Boot Loader。这种情况下比较简单:如果是软盘启动,Boot Loader会检查盘上是否存在两个隐含的系统文件(IBMBIO.COM、IBMDOS.COM),若有,读出并送至内存中指定的区域,把控制权转移给IBMBIO这个模块,否则显示出错信息。如果是硬盘启动,Boot Loader将查找主分区表中标记为活动分区的表项,把该表项对应的分区的引导扇区读入,然后把控制权交给该扇区内的引导程序,这段程序也可以被看作是Boot Loader的一部分,它完成的工作与软盘的Boot Loader大致相同。

LILO

LILO是一个在Linux环境编写的Boot Loader程序(所以安装和配置它都要在Linux下)。它不但可以作为Linux分区的引导扇区内的启动程序,还可以放入MRB中完全控制Boot Loadr的全过程。

LILO的功能实际上是由几个程序共同实现的,它们是:

  • Map Installer:这是LILO用于管理启动文件的程序。它可以将LILO启动时所需的文件放置到合适的位置(这些文件的位置由LILO本身决定)并且记录下这些位置,以便LILO访问。其实,当运行/sbin/lilo这个程序时,Map installer就已经工作了,它将Boot Loader写入引导分区(原来的Boot Loader将被备份),创建记录文件map file以映射内核的启动文件。每当内核发生变化时(比如说内核升级了),你必须运行/sbin/lilo来保证系统的正常运行。
  • Boot Loader:这就是由BIOS读入内存的那部分LILO的程序,它负责把Linux的内核或其他操作系统的引导分区读入内存。另外,LinuxBoot Loader`还提供一个命令行接口,可以让用户选择从哪个操作系统启动和加入启动参数。
  • 其他文件:这些文件主要包括用于存放Map installer记录的map文件(/boot/map)和存放LILO配置信息的配置文件(/etc/lilo.conf),这些文件都是LILO启动时必需的,它们一般存放在/boot目录下。

LILO的运行分析

从软盘启动

Linux内核可以存入一张1.44MB的软盘中,这样做的前提是对“Linux`内核映像”进行压缩,压缩是在编译内核时进行的,而解压是由装入程序在引导时进行的。

当从软盘引导Linux时,Boot Loader比较简单,其代码在arch/i386/boot/bootsect.S汇编语言文件中。当编译Linux内核源码时,就获得一个新的内核映像,这个汇编语言文件所产生的可执行代码就放在内核映像文件的开始处。因此,制作一个包含Linux内核的软磁盘并不是一件困难的事。

把内核映像的开始处拷贝到软盘的第 1 个扇区就创建了一张启动软盘。当BIOS装入软盘的第 1 个扇区时,实际上就是拷贝Boot Loader的代码。BIOSBoot Loader读入至内存中物理地址 0x07c00 处,控制权转给Boot LoaderBoot Loader执行如下操作。

  • 把自己从地址 0x07c00 移到 0x90000。
  • 利用地址 0x03ff,建立“实模式”栈。
  • 建立磁盘参数表,这个表由BIOS用来处理软盘设备驱动程序。
  • 通过调用BIOS的一个过程显示“Loading”信息。
  • 然后,调用BIOS的一个过程从软盘装入内核映像的setup()代码,并把这段代码放入从地址 0x90200 开始的地方。
  • 最后再调用BIOS的一个过程。这个过程从软盘装入内核映像的其余部分,并把映像放在内存中从地址 0x10000 开始的地方,或者从地址 0x100000 开始的地方,前者叫做“低地址”的小内核映像(以“make zImage”进行的编译),后者叫做“高地址”的大内核映像(以“make bzImage”)进行的编译。

从硬盘启动

一般情况下,Linux内核都是从硬盘装入的。BIOS照样将引导扇区读入至内存中的0x00007c00处,控制权转给Boot LoaderBoot Loader把自身移动至 0x90000处,并在 0x9B000处建立堆栈(从 0x9B000 处向 0x9A200 增长),将第 2 级的引导扇区读入至内存的 0x9B000处,把控制权交给它。在引导扇区移动之后,将显示一个大写的L字符,而在启动第 2 级的引导扇区之前,将显示一个大写的I字符。如果读入第 2 级的引导扇区的过程有错误,屏幕上的LI之后会显示一个十六进制的错误号。

二级引导扇区内的代码将把描述符表读入至内存中的 0x9D200 处,把包含有命令行解释程序的扇区读入至内存的 0x9D600 处。接着,二级引导扇区将等待用户的输入,不管这时用户输入了一个选择还是使用缺省配置,都将把对应的扇区读入至内存的 0x9D600(覆盖命令行解释程序的空间),把生成的启动参数保存在 0x9D800 处。

如果用户定义了用于启动的RAM盘的话,这部分文件将被读入到物理内存的末尾。如果你的内存大于16MB的话,它会被读入至16MB内存的结尾,这是因为BIOS程序不支持对 16MB以上内存的访问(它用于寻址的指令中只有 24 位的地址描述位)。并且它开始于一个新的页,以便于启动后系统把它所占的内存回收到内存池。

接下来,操作系统的初始化代码将被读入到内存的 0x90200 处。而系统的内核将被读入到 0x10000 处。如果该内核是以make bzImage方式编译的,它将被读入到内存的 0x100000处。在读入的过程中,存放map文件的扇区被读入至内存的 0x9D000 处。如果读入的imageLinux的内核,控制权将交给处于 0x90200 的Setup.S。如果读入的是另外的操作系统,过程要稍微麻烦一点:chain loader被读入到内存的 0x90200 处。该系统用于启动的扇区被读入到 0x90400。chain loader将把它所包含的分区表移到 0x00600处,把引导扇区读入到 0x07c00。做完这一切,它把控制权交给引导扇区。

第 2 级引导扇区在得到控制权以后马上显示一个大写的L字符。读入命令行解释程序后显示一个大写的O字符。

图 13.11 是LILO运行完后,内存的分布情况。

进入操作系统

Boot Loader作了这么多工作,一言以蔽之,只是把操作系统的代码调入内存,所以,当它执行完后,自然该把控制权交给操作系统,由操作系统的启动程序来完成剩下的工作。上面已经提到了,LILO此时把控制权交给了Setup.S这段程序。该程序是用汇编语言编写的16 位启动程序,它作了些什么呢?

Setup.S

首先,Setup.S对已经调入内存的操作系统代码进行检查,如果没有错误(所有的代码都已经被调入,并放至合适的位置), 它会通过BIOS中断获取内存容量信息,设置键盘的响应速度,设置显示器的基本模式,获取硬盘信息,检测是否有PS/2鼠标,这些操作,都是在386 的实模式下进行的,这时,操作系统就准备让CPU进入保护模式了。当然,要先屏蔽中断信号,否则,系统可能会因为一个中断信号的干扰而陷入不可知状态,然后再次设置 32位启动代码的位置,这是因为虽然预先对 32 位启动程序的存储位置有规定,但是Boot Loader(通常是LILO)有可能把 32 位的启动代码读入一个与预先定义的位置不同的内存区域,为了保证下一个启动过程能顺利进行,这一步是必不可少的。

完成上面的工作后,操作系统指令lidtlgdt被调用了,中断向量表(idt)和全局描述符表(gdt)终于浮出水面了,此时的中断描述符表放置的就是开机时由BIOS设定的那张表,gdt虽不完善,但它也有了 4 项确定的内容,也就是说,这里已经定义了下面 4 个保护模式下的段。

(1) .word 0,0,0,0 ! 系统所定义的NULL

(2) .word 0,0,0,0 ! 空段,未使用

(3)

1
2
3
4
.word 0xFFFF ! 4Gb (0x100000*0x1000 = 4Gb)大小的系统代码段
.word 0x0000 !base address=0
.word 0x9A00 ! 可执行代码段
.word 0x00CF !粒度=4096

(4)

1
2
3
4
.word 0xFFFF ! 4Gb(0x100000*0x1000 = 4Gb)大小的系统数据段
.word 0x0000 ! base address=0
.word 0x9200 !可读写段
.word 0x00CF !粒度=4096

此外,协处理器也需要重新复位。这几件事做完以后,Setup.S设置保护模式的标志位,重新取指令以后,再用一条跳转指令:

1
jmpi 0x100000,KERNEL_CS

进入保护模式下的启动阶段,同时把控制权交给Head.S这段纯 32 位汇编代码。

main.c中的初始化

head.s在最后部分调用main.c中的start_kernel()函数,从而把控制权交给了它。所以启动程序从start_kernel()函数继续执行。这个函数是main.c乃至整个操作系统初始化的最重要的函数,一旦它执行完了,整个操作系统的初始化也就完成了。

如前所述,计算机在执行start_kernel()前处已经进入了 386 的保护模式,设立了中断向量表并部分初始化了其中的几项,建立了段和页机制,设立了 9 个段,把线性空间中用于存放系统数据和代码的地址映射到了物理空间的头 4MB,可以说我们已经使 386 处理器完全进入了全面执行操作系统代码的状态。

start_kernel()执行后,你就可以以一个用户的身份登录和使用Linux了。让我们来看看start_kernel到底做了些什么。start_kernel()这个函数是在/init/main.c中,这里也只是将main.c中较为重要的函数列举出来。

1
2
3
4
5
start_kernel()/*定义于`init/main.c */
{
……
setup_arch();
}

它主要用于对处理器、内存等最基本的硬件相关部分的初始化,初始化RAM盘所占用的空间等。其中,setup_arch()给系统分配了intel系列芯片统一使用的几个I/O端口的地址。

1
paging_init(); /*该函数定义于arch/i386/mm/init.c */

它的具体作用是把线性地址中尚未映射到物理地址上的部分通过页机制进行映射。当paging_init()函数调用完后,页的初始化就整个完成了。

1
trap_init(); /*该函数在arch/i386/kernel/traps.c中定义*/

这个初始化程序是对中断向量表进行初始化,详见第四章。它通过调用set_trap_gate(或set_system_gate等)宏对中断向量表的各个表项填写相应的中断响应程序的偏移地址。事实上,Linux操作系统仅仅在运行trap_init()函数前使用BIOS的中断响应程序。一旦真正进入了Linux操作系统,BIOS的中断向量将不再使用。

另外,在trap_init()函数里,还要初始化第一个任务的LDTTSS,把它们填入Gdt相应的表项中。第一个任务就是init_task这个进程,填写完后,还要把init_taskTSSLDT描述符分别读入系统的TSSLDT寄存器。

1
init_IRQ()/* 在arch/i386/kernel/irq.c中定义*/

这个函数也是与中断有关的初始化函数。不过这个函数与硬件设备的中断关系更密切一些。

我们知道intel的 80386 系列采用两片 8259 作为它的中断控制器。这两片级连的芯片一共可以提供 16 个引脚,其中 15 个与外部设备相连,一个用于级连。可是,从操作系统的角度来看,怎么知道这些引脚是否已经使用;如果一个引脚已被使用,Linux操作系统又怎么知道这个引脚上连的是什么设备呢?在内核中,同样是一个数组(静态链表)来纪录这些信息的。这个数组的结构在irq.h中定义:

1
2
3
4
5
6
7
8
struct irqaction {
void (*handler)(intvoid *, struct pt_regs *);
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id;
struct irqaction *next;
}

我们来看一个例子:

1
2
3
4
5
6
7
8
static void math_error_irq(int cpl, void *dev_id, struct pt_regs *regs)
{
outb(00xF0);
if (ignore_irq13 || !hard_math)
return;
math_error();
}
static struct irqaction irq13 = { math_error_irq, 0, 0, "math error", NULL, NULL };

该例子就是这个数组结构的一个应用,这个中断是用于协处理器的。在init_irq()这个函数中,除了协处理器所占用的引脚,只初始化另外一个引脚,即用于级连的 2 引脚。不过,这个函数并不仅仅做这些,它还为两片 8259 分配了I/O地址,对应于连接在管脚上的硬中断,它初始化了从 0x20 开始的中断向量表的 15 个表项(386 中断门),不过,这时的中断响应程序由于中断控制器的引脚还未被占用,自然是空程序了。当我们确切地知道了一个引脚到底连接了什么设备,并知道了该设备的驱动程序后,使用setup_x86_irq这个函数填写该引脚对应的 386 的中断门时,中断响应程序的偏移地址才被填写进中断向量表。

1
sched_init()/*在/kernel/sched.c`中定义*/

这个程序是名副其实的初始化程序:仅仅为进程调度程序的执行做准备。它所做的具体工作是调用init_bh函数(在kernel/softirq.c中)把timertqueueimmediate三个任务队列加入下半部分的数组。

1
time_init()/*在`arch/i386/kernel/time.c`中定义*/

时间在操作系统中是个非常重要的概念。特别是在LinuxUNIX这些多任务的操作系统中它更是作为主线索贯穿始终,之所以这样说,是因为无论进程调度(特别是时间片轮转算法)还是各种守护进程(也可以称为系统线程,如页表刷新的守护进程)都是根据时间运作的。

1
parse_options()/*在main.c中定义*/

这个函数把启动时得到的参数如debuginit等从命令行的字符串中分离出来,并把这些参数赋给相应的变量。这其实是一个简单的词法分析程序。

1
console_init()/*在linux/drivers/char/tty_io.c中定义*/

这个函数用于对终端的初始化。在这里定义的终端并不是一个完整意义上的TTY设备,它只是一个用于打印各种系统信息和有可能发生错误的出错信息的终端。真正的TTY设备以后还会进一步定义。

1
kmalloc_init()/*在linux/mm/kmalloc.c中定义*/

kmalloc代表的是kernel_malloc的意思,它是用于内核的内存分配函数。而这个针对kmalloc的初始化函数用来对内存中可用内存的大小进行检查,以确定kmalloc所能分配的内存的大小。所以,这种检查只是检测当前在系统段内可分配的内存块的大小。

下面的几个函数是用来对Linux的文件系统进行初始化的。

1
inode_init()/*在Linux/fs/inode.c中定义*/

这个函数是对VFS的索引节点管理机制进行初始化。这个函数非常简单:把用于索引节点查找的哈希表置入内存,再把指向第一个索引节点的全局变量置为空。

1
name_cache_init()/*在linux/fs/dcache.c中定义*/

这个函数用来对VFS的目录缓存机制进行初始化。先初始化LRU1链表,再初始化LRU2链表。

1
buffer_init()/*在linux/fs/buffer.c中定义*/

这个函数用来对用于指示块缓存的buffer free list初始化。

1
mem_init()/* 在arch/i386/mm/init.c中定义*/

启动到了目前这种状态,只剩下运行/etc下的启动配置文件。这些文件一旦运行,启动的全过程就结束了,系统也最终将进入我们所期待的用户态。

建立init进程

在完成了上面所有的初始化工作后,Linux的运行环境已经基本上完备了。此时,Linux开始逐步建立进程了。

init进程的建立

Linux将要建立的第一个进程是init进程,建立该进程是以调用kernel_thread(init, NULL,0)这个函数的形式进行的。initLinux的第 1 个进程,也是其他所有进程的父进程。让我们来看一下它是怎样产生的。

在调用kernel_thread(init, NULL, 0)函数时,会调用main.c中的另外一个函数——init()。请注意init()函数和init进程是不同的概念。通过执行inin()函数,系统完成了下述工作。

  • 建立dbflushkswapd两个新的内核线程。
  • 初始化tty1设备。该设备对应了多个终端(concole),用户登录时,就是登录在这些终端上的。
  • 启动init进程。Linux首先寻找/etc/init文件,如果找不到,就接着找/bin/init文件,若仍找不到,再去找/sbin/init。如果仍无法找到,启动将无法进行下去。否则,便执行init文件,从而建立init进程。

etc/init(假定它存在)执行时,建立好的init进程将根据启动脚本文件的内容创建其它必要的进程去完成一些重要的操作。

  1. 文件系统检查。
  2. 启动系统的守护进程。
  3. 对每个联机终端建立一个getty进程。
  4. 执行/etc/rc下的命令文件。

此后,getty会在每个终端上显示login提示符,以等待用户的登录。此时getty会调用exec执行login程序,login将核对用户帐户和密码,如果密码正确,login调用exec执行shell的命令行解释程序。shell接着去执行用户默认的系统环境配置脚本文件(通常是用户的home目录下的profile文件)。

init还有另外一个任务,当某个终端或虚拟控制台上的用户注销之后,init进程要为该终端或虚拟控制台重新启动一个getty,以便能够让其他用户登录。你应该发现,当用户登录时,getty用的是exec而不是fork系统调用来执行login,这样,login在执行的时候会覆盖getty的执行环境(同理,用户注册成功后,login的执行环境也会被shell占用)。所以,如果想再次使用同一终端,必须再启动一个getty

此外,init进程还负责管理系统中的“孤儿”进程。如果某个进程创建子进程之后,在子进程终止之前终止,则子进程成为孤儿进程。init进程负责“收养”该进程,即孤儿进程会立即成为init进程的子进程。这是为了保持进程树的完整性。

启动所需的Shell脚本文件

在启动的过程中,多次用到了Shell的脚本文件——Shell Script,如或是其他什么任意你喜欢的字符,可以设所用的Shellbashksh,还是zsh`。显然,这部分不是我们的重点,我们要重点描述的是前一部分——系统启动所必需的脚本。

系统启动所必需的脚本存放在系统默认的配置文件目录/etc下。用一条ls指令你可以看到所用的配置文件。不过,/etc下面还有一些子目录,比如说,rc.d就是启动中非常重要的一部分。我们主要介绍的是/etc/inittabrc.d下的一些文件,我们还是按启动时init进程调用它们的顺序来一一介绍。

首先调用的是/etc/inittabinit进程将会读取它并依据其中所记载的内容进入不同的启动级别,从而启动不同的进程。所谓运行级别就是系统中定义了许多不同的级别,根据这些级别,系统在启动时给用户分配资源。比如说,以系统管理员级别登录的用户,就拥有使用几乎所有系统资源的权力,而一般用户显然不会被赋予如此大的特权。

下面是系统的 7 个启动级别。

  • 0 系统停止。如果在启动时选择该级别,系统每次运行到inittab就会自动停止,无法启动。
  • 1 单用户模式。该模式只允许一个用户从本地计算机上登录,该模式主要用于系统管理员检查和修复系统错误。
  • 2 多用户模式。与 3 级别的区别在于用于网络的时候,该模式不支持NFS(网络文件系统)。
  • 3 完全多用户模式。可以支持Linux的所有功能,是Linux安装的默认选项。
  • 4 未使用的模式。
  • 5 启动后自动进入X Windwos
  • 6 重新启动模式。如果在启动时选择该级别,系统每次运行到inittab就会自动重新启动,无法进入系统。

让我们看一个inittab文件的实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
id:3:initdefault:                  系统默认模式为 3。
#System initialization.
si::sysinit:/etc/rc.d/rc.sysinit 无论从哪个级别启动,都执行/etc/rc.d/rd.sysinit。
10:0:wait:/etc/rc.d/rc.0 从 0 级别启动,将运行rc.0。
11:1:wait:/etc/rc.d/rc.1 从 1 级别启动,将运行rc.1。
12:2:wait:/etc/rc.d/rc.2 从 2 级别启动,将运行rc.2。
13:3:wait:/etc/rc.d/rc.3 从 3 级别启动,将运行rc.3。
14:4:wait:/etc/rc.d/rc.4 从 4 级别启动,将运行rc.4。
15:5:wait:/etc/rc.d/rc.5 从 5 级别启动,将运行rc.5。
16:6:wait:/etc/rc.d/rc.6 从 6 级别启动,将运行rc.6。
#Things to run in every runlevel 任何级别都执行的配置文件。
ud::once:/sbin/update
#Run gettys in standard runlevels 对虚拟终端的初始化。
1:12345:respqwn:/sbin/mingetty tty1 tty1 运行于 1、2、3、4、5 五个级别。
2:2345:respqwn:/sbin/mingetty tty2 tty2 运行于 2、3、4、5 四个级别。
3:2345:respqwn:/sbin/mingetty tty3 tty3 运行于 2、3、4、5 四个级别。
4:2345:respqwn:/sbin/mingetty tty4 tty4 运行于 2、3、4、5 四个级别。
5:2345:respqwn:/sbin/mingetty tty5 tty5 运行于 2、3、4、5 四个级别。
6:2345:respqwn:/sbin/mingetty tty6 tty6 运行于 2、3、4、5 四个级别。
#Run xdm in runlevel 5 在级别 5 启动X Window。
x:5:respawn:/usr/bin/X11/xdm -nodaemon

现在详细解释一些inittab的内容。
从上面的文件可以看出,inittab的每一行分成 4 个部分,这 4 个部分的格式如下:

1
id:runleveld:action:process

它们代表的意义分别如下。

  • id:代表有几个字符所组成的标识符。在inittab中任意两行的标识符不能相同。
  • runlevels:指出本行中第 3 部分的action以及第 4 部分的进程会在哪些runlevel中被执行,这一栏的合法值有 0、1、2……6,s以及S。
  • action:这个部分记录init进程在启动过程中调用进程时,对进程所采取的应答方式,合法的应答方式有下面几项。
  • initdefault:指出系统在启动时预设的运行级别。上例中的第 1 行就用了这个方式。
    • 所以系统将在启动时,进入runlevel为 3 的模式。当然,可以把 3 改为 5,那将会执行/etc/rc.d/rc.5,也就是X Window。
  • sysinit:在系统启动时,这个进程肯定会被执行。而所有的inittab的行中,如果它的action中有bootbootwait,则该行必须等到这些actionsysinit的进程执行完之后才能够执行。
  • wait:在启动一个进程之后,若要再启动另一个进程,则必须等到这个进程结束之后才能继续。
  • respawn:代表这个process即使在结束之后,也可能会重新被启动,最典型的例子就是getty

明白了inittab的意思,让我们回过头来看看启动过程。

  • 首先,执行的是/etc/rd.c/rc.sysinit。这里不再给出它的程序清单,只给出它的主要功能:
    • 检查文件系统:包括启用系统交换分区,检查根文件系统的情况,使用磁盘定额程序quato(可选项),安装内核映像文件系统proc,安装其他文件系统。
    • 设置硬件设备:设定主机名,检查并设置PNP设备,初始化串行接口,初始化其他设备。
    • 检查并载入模块
  • 执行完rc.sysinit并返回inittab后,init进程会根据inittab所设定的运行级别去执行/etc/rc.d目录下的相应的rc文件。比如说运行级别为 3,相应的rc文件即为rc.3。这些文件将运行不同的启动程序去初始化各个运行级别下的系统环境,这部分启动程序最重要的作用之一是启动系统的守护进程,如在rc.3 中,就要启动cronsendmial等守护进程。
  • 做完这一步,init进程将执行getty进程从而等待用户的登录,也就是说,Linux的启动全过程已经结束了,剩下的部分,就是整个系统等待用户需求,并为用户提供服务了。

原文:https://zhuanlan.zhihu.com/p/400616130

我们先从最简单的讲起

我们先来看一个很简单的 Register File (寄存器组):

这个寄存器将每个数据储存在了触发电路(Flip-Flop)中,

  • 如果要读取数据,Read MUX 会根据 读取的地址 (Read Address)来选择其中一个数据输出
  • 如果要写入数据,底部的解码器(Decoder)会根据写的地址(Write Address)发出信号,来打开对应的触发电路的使能信号,顶部的 Write Line 就可以写入数据了

那么为什么在现代处理器设计中,不使用这种设计呢?原因如下:

  1. 如果我们需要更大容量的寄存器,那么这个设计中的触发器的数量需要增加,也就需要更大的 Read MUX
  2. 从 传播延迟(Propagation Delay) 角度来说,信号传递的距离会随着容量的增加而增加,整个系统会有很大的延迟
  3. 从面积角度来说,不方便做成芯片,因为每增加容量,长度就需要增加

那怎么解决呢?这就是为什么在现代系统内,工程师使用了一种正方形的储存器设计 —— Memory Array (储存器阵列)

Memory Array —— Register File

左侧所展示的,是存储器阵列设计内部图,每一个小的正方形都代表着一个 位单元 (Bit-Cell);右侧展示的是每个 位单元 的内部图。

从 CPU 发出的地址会被输入进 行解码器(Row Decoder) 和 列解码器(Col Decoder);行解码器会根据指令的读和写来选择 写字线(Write Word Line, WWL) 或者 读字线(Read Word Line, RWL),列解码器也会根据地址来选择 写位线(Write Bit Line) 和 读位线(Read Bit Line)。好比坐标系,我们知道了Y-轴坐标和X-轴坐标,我们就能定位到对应的 Bit-Cell,从而来近些读写操作。

对比之前讨论的设计,用了 Memory Array 技术的存储器有以下几个有点:

  1. 正方形的设计更好的优化了芯片面积
  2. 正方形的设计更好的优化了传播延迟(Propagation Delay)

解码器也不再是一个单独的多路复用器(MUX)了,可以分解成多个小的 MUX

Memory Array —— SRAM and DRAM

下面我们来看看通常用于制造 Cache (缓存器)的一种存储器技术:SRAM - Static Random Access Memory 静态随机存储器

图中的SRAM也采用了存储器阵列的设计方法,可以看出结构上和上面讨论的 Register File 别无差异,但每个 Bit-Cell 里面的设计就不一样了。

SRAM 的每个单元由六个晶体管构成,垂直的 位线(Bit Line)其实是由一根 位线 构成了,另一根是位线的反逻辑(Bit Line Bar),内部设有一个放大器(Sensing Amplifier)用来分辨 位线 和 反位线 上微小的差异。

SRAM 只要一直通着电,数据就会一直存在里面,所以名字中带有“静态”二字;但如果电没有了,数据就会消失,所以 SRAM 属于 易失性存储器(Volatile Memory) 中的一种。

由于每个单元里塞了六个晶体管,所以 SRAM 的造价并不便宜,而且面积功耗都很大。因此SRAM 并不是制造 Main Memory (内存)的最佳选择。 制造容量更大的内存需要另一种存储器技术:DRAM - Dynamic Random Access Memory 动态随机存储器。

展示了 DRAM 的内部结构,可以看出,DRAM 也可以用 Memory Array 的技术设计。

相比于 SRAM,DRAM 中的每个单元就简单了许多,由一个晶体管和一个电容构成。这也赋予了 DRAM 一个相当大的有点:对比 SRAM 和 Register File,在相同的面积下,DRAM 可以储存更多的数据,因为每个单元需要的晶体管非常的少,这也使得 DRAM 可以做成大容量的相对便宜的存储器。

因为内部存在了电容,所以 DRAM 不是纯逻辑电路,一般的 CMOS 制程不能用于制造 DRAM,所以 DRAM 的制程和普通的都是区分开来了的。DRAM 的另一个特点就是需要经常刷新(Refresh),因为电容会持续漏电荷从而损失数据状态,所以每过几毫秒,都会重新刷新电荷,不过刷新也需要时间,这也导致了 DRAM 的速度比 SRAM 稍慢一些。

三家对比

三个角度来对比这三种存储器技术:

  1. 延迟 —— 读写数据的速度,通常比处理器的周期时间(Cycle Time)要大很多
    Register << SRAM << DRAM,Register 延迟最小,DRAM 延迟最大

  2. 容量 —— 储存数据的多少
    Register << SRAM << DRAM,相同面积下,Register 容量最小,DRAM 容量最大

  3. 带宽 —— 单位时间内,数据的传输量(吞吐量)
    Register >> SRAM >> DRAM,Register 带宽最大,DRAM 带宽最小

为什么我们需要 Cache 缓存器

现代计算机的性能瓶颈大多集中处理器和内存之间的数据传输中。当由 DRAM 制造的内存变得越来越大时,导致了以下两个不好的结果:

  • 信号需要传递的更远,延迟增加
  • 因为逻辑门的增多,信号的 扇出(Fan Out) 会变大,从而延迟增加

延迟的增大,导致了处理器和内存通信时间的增长,影响了处理器的性能。所以,我们需要在处理器和内存中间加一层更快的结构,来缓存我们从内存中拿取的数据。这个中间的结构就叫做 Cache 缓存器。

Register —-> Cache 缓存器 (SRAM) —-> Main Memory 内存 (DRAM)

根据上面讨论的三种存储器的技术,我们可以利用 SRAM 的特点,来设计制造这个缓存器:

  • 缓存器的容量 - Register File 寄存器 << Cache 缓存器 (SRAM) << Main Memory 内存 (DRAM)
  • 缓存器的延迟 - Register File 寄存器 << Cache 缓存器 (SRAM) << Main Memory 内存 (DRAM)
  • 缓存器的带宽 - 一般来说,缓存器和处理器是在同一块片上系统的(On-Chip),然后内存和处理器不在,所以从带宽角度来说:Cache 缓存器 (SRAM) >> Main Memory 内存 (DRAM)

所以简单来说,缓存器就是内存的一个小副本,里面记录了处理器可能需要的数据,这样处理器就能更快的从缓存器里面拿到数据了,大大减少了延迟,从而提升了整体性能。

那么问题来了……

怎样让 Cache 缓存器 变得有用?

怎样让缓存器里面尽可能多的存储处理器需要的数据呢?

根据上个世纪 IBM 公司所做的研究发现,计算机执行一个程序的时候,有以下的特点:

程序的运行,有三个要素:取指令(Instruction Fetches)、栈的访问(Stack Accesses)、取数据(Data Accesses)

  • 一个程序的指令在内存中通常是连续的(Program Counter + 4),图4中可以看出,每个原点都是处理器访问内存的位置,这些点是图上是连续的,也就是说每一次访问的位置都是连续的,这次访问在0x0000,下次大概率访问在0x0004的位置
  • 程序通常在调用一个子方程的时候,会传递若干个参数进去,这些参数可能在这个子方程的运算中要重复使用,所以同一个位置会被经常的访问
  • 在获取数据时,有很大概率程序会碰到数列(Array)的情况,数列在内存中都是连续的存在,所以这次访问了数列的第一个数据 array[0],下次通常就会访问 array[1]。当然也会有重复拿取同一个数据的情况。

所以,要想让缓存器里存有处理器可能需要的数据,我们可以利用上述的两个特性,来增加命中(Hit)的可能性:空间局部性(Spatial Locality)和 时间局部性(Temporal Locality)。

  • 空间局部性(Spatial Locality)—— 当一个内存位置被访问后,在下一次访问中,处理器有很大的概率会访问他相邻的位置
  • 时间局部性(Temporal Locality)—— 当一个内存位置被访问后,在一下次访问中,处理器有很大的概率会访问同一个位置

有了这两点,我们可以重新回过头了看取指令、栈的访问、取数据这三个要素:

  • 取指运用了空间局部性(Spatial Locality)和 时间局部性(Temporal Locality)的特点
  • 栈的访问运用了时间局部性(Temporal Locality)的特点
  • 取数据也运用了空间局部性(Spatial Locality)和 时间局部性(Temporal Locality)的特点

缓存器的结构

图向大家展示了缓存器在系统中的位置,它介于处理器(Processor)和主存(Main Memory)之间,内部结构可以在想象成一个查找表,最左边一列叫地址标签(Address Tag),是用来和处理器发来的地址比较,如果相同的话,那么代表缓存器中有处理器所需的数据,反之则没有。

跟在地址标签后面的那几列都是用来存数据的,名叫数据块(Data Block)。每个块是一个字节(1 Byte),但不同的缓存器设计可能会有不同的数据块大小。缓存器的一整行,地址标签再加上数据块,被统称为线(Line)。

接下来就是组(Sets)的概念。在缓存器里,一个线(Line)可以成为一个组,两根线放在一起也可以成为一个组,四根线放在一起,照样也能成为一个组,八根线也是。一个组里面有多少线,或者说有几行,可以根据实际设计需求来制定。

比如说,一个简易的缓存器有八个线(可以理解为八行),如果一个线为一个组,那么一共有八个组,每个组里面只有一个线。如果设计成两个线为一组,那么一共有 [公式] 组,每个组有两个线,我们有时也叫他两个路(Two-ways),所以这种缓存器就叫做双向关联缓存(Two-way Associative Cache)。同理,如果四个线为一组,那就有两组,每个组有四路,这就叫四路关联缓存(Four-way Associative Cache)。关于组和路的介绍,下面还会讲到。

好啦,这就是最基础的一个缓存器,就这么简单。数据块存在缓存器里,打上标签,每当处理器访问的时候,我们查看标签;标签一致,缓存器命中(Hit),可以直接返还处理器需要的数据;如果没有这个标签,缓存器不命中(Miss),这时候就需要缓存器去访问主存,再把数据拿上来。

地址的组成

要了解缓存器如何运作,不光要了解缓存器的构成,也要知道它是如何和处理器进行沟通的。处理器通过发送地址(Address)来告诉缓存器它想要的数据的信息。下图是地址的组成示意图:

一条地址由两大块组成:块偏移量(Block Offset)+ 块地址(Block Address)

块地址呢又由两个部分组成:标签(Tag)+ 索引(Index)

所以一条地址可以看成是有三个重要部分组成:标签(Tag)+ 索引(Index)+ 块偏移量(Block Offset)

  • 标签(Tag): 用于比较,查看是否命中所需的数据
  • 索引(Index): 用于选择组(Set)
  • 块偏移量(Block Offset): 用于选择数据块(Data Block)中的字节

缓存器四大设计问题(一)—— 如何放置数据?

缓存器设计中有四个重要的特性,确定了这四个特性,一个基本的缓存器也就慢慢成型了,这四个特性也反应出了设计缓存器之初的四个问题:

  • 如何在缓存器里放置数据?(Cache Placement)
  • 如何在缓存器里搜寻数据?(Cache Searching)
  • 如何在缓存器里替换数据?(Cache Replacement)
  • 如何通过缓存器向主存写数据?(Cache Write Policy)

下面是缓存器设计的第一个问题:如何放置数据?

重点概念:Direct Mapped 直接映射,Fully Associative 全关联,Set-Associative 组关联

比如上图中的 Memory 中有 32 个数据块,而我们的缓存器只有 8 条数据块能存放,肯定存不下所有内容,那怎么办呢?架构师提供了三种解决方案:

比如第 12 号数据块(Memory 的灰色部分),只能放在缓存器对应的一个地方,那就是缓存器的四号位置(12 % 8 = 4)。这种运用了一一对应的映射方法的缓存器,我们叫它 Direct Mapped Cache 直接映射缓存器
如果我们把同样的缓存器分成四组,每个组里面有两条数据块,就像上图中右边第一个缓存器。这时,内存中的第 12 号数据块需要放在缓存器的第 0 号组里(12 % 4 = 0)。但置于放在这个组里的哪一条数据块中,其实都可以(缓存器四大设计问题之三会讲到在组里放置的策略。)这种缓存器,我们叫它 Set-Associative Cache 组相联缓存器

最后一种,就是没有限制的缓存器,内存中的数据,可以放在缓存器中的任何位置;也可以想象成整个缓存器变成了一个组,且只有这个组。这样随意的缓存器就叫做 FullyAssociative Cache 全关联缓存器

缓存器四大设计问题(二)—— 如何搜寻数据?

重点概念:Tag 标签、Address 地址

其实在第六节:介绍地址组成部分的时候已经概述到了,缓存器利用地址中的标签位(Tag Bits)来和缓存器中的标签进行比较,如果一样,就表示命中(Hit),反之则不命中(Miss)。

在这里肯定有很多童鞋会问:为什么只要比较标签(Tag),而不需要比较索引(Index)和块偏移量(Block Offset)呢?

答案在这里:

  • 为什么不比较块偏移量(Block Offset)呢?
    • 因为不管这个数据块(Block Offset)里的哪个数据,其实都代表着已经命中了(Hit)。不管处理器需要第一个字节,还是第二个字节,亦或是第三个,其实本质上都已命中,所以不需要比较块偏移量
  • 那为什么不比较索引(Index)呢?
    • 索引的作用是用于选择组(Set),索引一出,缓存器就只会在相应的组(Set)里找数据,并不会看其他组了,这个组里没有数据就不命中,有的话就命中,不会受到索引的影响。
  • 那么剩下的,就只有标签(Tag)了。标签也之所以叫标签,也正是因为它是唯一用于比较的量。

缓存器四大设计问题(三)—— 如何替换数据?

重点概念:Random 随机替换、Least Recent Used (LRU)替换最不常用、FIFO (First-In First-Out)

什么适合需要考虑替换数据这个问题呢?在全关联缓存器(Direct Mapped Cache),每个数据都有自己定好的位置,没有选择的余地,所以全关联缓存器不需要考虑这个问题。但在有组(Sets)的缓存器中,不能忽略这个问题。

先讲第一个,随机替换。顾名思义,这个策略是最佛系的一种,就是随机替换组里一个数据。这个方法的好处就是实现起来非常简单,没有太多复杂的硬件需要添加。

第二个方法是替换组里不常用的(LRU)数据。这个方法的好处就是最大程度利用了 时间局部性(Temporal Locality),因为系统保留了最经常用的那个数据。缺点呢,就是大大增加了硬件的复杂性。而且甚至提升芯片的功耗和面积,并且造成 关键路径(Critical Path),给时序带来压力。所以,LRU 策略经常用在 二路关联缓存器(Two-way Associative Path)中,因为这样一来,我们只需要多添加一位比特(Bit)来记录就可以了。

第三个方法是 FIFO 先入先出。这个方法在硬件复杂度上没有 LRU 策略那么的复杂,只需要记录刚刚最先进入缓存器的数据即可,所以这个方法大多运用在 多路关联缓存器 上(二路以上)。

缓存器四大设计问题(四)—— 如何向主存写数据?

重点概念:Write Through 写穿、Write Back 写回、Write Allocate 写分配、No-Write Allocate 写不分配

当发生写命中时(Write Hit)

当我们向缓存器发出写入指令(Store)并且要写的数据恰好在缓存器中,我们面临着两种选择:

同时更新 缓存器 和 主存。这样做的好处是设计起来很简单,并且可以保持主存里面的数据是最新的,这一点可以在 缓存一致性(Cache Coherency) 里面很重要。这种方法叫做 写穿(Write Through)。
只更新 缓存器,但在这个数据块要被替换的时候,才写回主存。这样做的好处是可以减少从 缓存器 写数据进 主存 的频率,并且可以减少使用主存的带宽,这一点在 多核处理器(Multicore Processor)里面很收欢迎。这样的方法叫做 写回(Write Back)。

当发生写不命中时(Write Miss)

当我们向缓存器发出写入指令(Store)并且要写的数据不在缓存器中,我们面临着两种选择:

写进主存,并把写的数据存放在 缓存器 中,这样就下次就有几率命中这个数据。这样的方法叫做 写分配 (Write Allocate)。
写进主存,但不把这个数据写进 缓存器。这种方法叫 写不分配 (No-Write Allocate)。
一般缓存的搭配是这样的:

  • 写穿 & 写不分配(Write Through & No-Write Allocate)
  • 写回 & 写分配 (Write Back & Write Allocate)

缓存设计的性能指标

在设计缓存器的时候,有一个性能指标是需要设计者一直考虑权衡的,那就是 平均内存访问时间 (Average Memory Access Time,AMAT)。

AMAT 的公式AMAT = HitTime + MissRate * MissPenalty

可以看出,平均内存访问时间 是由三个要素组成的:

  • 命中时间(Hit Time)—— 命中时,需要花多少的时间,主要花在了查找和比较 标签(Tag)上。
  • 不命中率(Miss Rate)—— 描述了不命中的比例(The ratio that the Access to the Cache is missed)。这个是缓存设计中一个很重要的指标。一般来说,不命中率(Miss Rate) 需要区分 读不命中率(Read Miss Rate) 和 写不命中率(Write Miss Rate),因为他们不一样;但在计算时,可以取他们的平均数。
  • 不命中损失(Miss Penalty)—— 一次不命中缓存时,需要访问主存的时间,也就是不命中时,造成的损失是多少。和不命中率一样,不命中损失(Miss Penalty)也需要区分读和写,但在计算时,也可以去他们的平均数。值得一提的是,这个指标不是一个常数,它随时都会变,因为可能存在其他的设备,比如 总线(Bus)延迟了内存的访问时间,或者内存在专心干别的事情,需要等待(比如完成上一个数据请求,或者在刷新自身)。
    以上这三个指标非常重要,后面的优化缓存设计其实也是围绕着这三点来运作的。

缓存器中的三种不命中情况

在缓存器中,存在了三种不同类型的不命中(Miss)。

  • 必定发生的不命中(Compulsory Miss)- 发生在系统刚刚启动,初始化缓存的时候,即使一个无限容量的缓存,也必定会发生有这种情况
  • 由容量所限导致的不命中(Capacity Miss)- 缓存其实是内存的映射,但缓存的大小几乎肯定远远小于内存的容量,所以因为容量有限导致的不命中,就被成为 Capacity Miss
  • 由冲突导致的不命中(Conflict Miss)- 有些缓存被设计成两路相关联(Two-way Associ)或是四路相关联(Four-way Associ),在这些组(Sets)里经常会发生数据放满了,放不下其他相关的数据,这种因为组里的冲突而发生的不命中,就叫做 Conflict Miss

这三种不同类型的错失(Miss),深深影响了缓存器的错失率(Miss Rate);

基础优化一:增大缓存总容量(Cache Capacity)来减少错失率

缓存的总容量越大,也就意味着能放的数据越多,内存中的数据尽可能被多的映射进缓存,这样一来,有容量所限导致的不命中(Capacity Miss)的数量就会下降。但这个方法也有缺点,那就是增加了命中时的任务量:硬件需要再更多的数据中比较标签(Tag),命中时间(Hit Time)就会有所下降。所以这是一个权衡(Trade-off),如果增大了总容量,错失率大大降低,但命中时间只增长一点,那么总体来说 AMAT 的值就会下降,达到优化的目的。

通常这一优化方法会用在片外缓存(Off-chip Cache)上,因为片上的面积很昂贵。并且通过长时间的实验,工程师得出一个心得:如果缓存总容量提高一倍,那么错失率会降低sqrt(2)倍。

基础优化二:增大块长度(Block Size)来减少错失率

上图展示了块的长度(Block Size)和错失率(Miss Rate)的关系,整个图假设当我们变化块长度的时候,缓存总容量不变(控制变量法)。

上图是缓存概念里面的一张经典的图,被戏称为浴缸图。先说结论:对于4K、16K、64K、和256K的缓存来说,取适中的块长度,能够有效的减少错失率。如果块长度过小(16)或者过大(256),错失率又会增长反弹。

当块长度很小时(16,图的最左侧)并在缓存容量不变的情况下,块的数量会增加,进而导致一开始由于系统初始化必定发生的不命中(Compulsory Miss)增加。所以块长度很小时,错失率是很高的。

当块长度变大时(64,图的中间部位),在缓存容量不变的情况下,错失率会有所降低,因为此时相比于之前,一次性从内存带上来的相连数据变多,很好的利用了空间局部性(Spatial Locality)。

当块长度变得更大时(256,图的最右侧),在缓存容量和组的数量不变的情况下,每个组中的块数量变少,会导致由冲突导致的不命中(Conflict Miss)上升,进而导致总体错失率提高。不过有失必有得,此时附带了一个好处,那就是因为块变大,每个块存的数据变多,所需要的 位移(Offset) 位数增多,但地址的总体位数不能变,所以 标签(Tag) 所需要的位数就会下降(被 Offset 拿走了),所以硬件中的比较器(Comparator)会得到优化。

所以比较了这么多,到底在设计中,应该给块选取多长的长度才合适呢?其实这个问题没有正确答案,只有最适合的答案。在设计中,我们需要结合两点来确定块的长度:内存和缓存之间的 带宽(Bandwidth)和 延迟(Latency):

  • 如果带宽很大,但延迟很大,那么适合长度更大的块,因为这样可以很好的利用他们之间的带宽和总线(Bus);当然啦,如果总线是片上的,那就更好了
  • 如果带宽很小,但延迟很小,那么就是和长度更小的块

基础优化三:增加相关联性(Higher Associative)来减少错失率

在保持缓存总容量的情况下,增加相关联性,意味着组里的块个数增加,组的个数减少。这样一来,由冲突导致的不命中(Conflict Miss)会大大减少,这时,由容量导致的不命中(Capacity Miss)占据了 Miss 总个数的主导地位。

那么既然增加相关联性能减少错失的几率,那么为什么不一直使用四路、八路、甚至全相关(Fully Associative)的缓存呢?原因有两个:

  • 全相联缓存对硬件要求特别高,会大大增加复杂度。比如,如果使用 LRU 替换策略,那么需要更多的位数来记录每个块的使用情况。
  • 由于组(Sets)的个数减少,我们所需要的 索引(Index) 位数也变少,随之而来的就是 标签(Tag)位数增加,这也增加了缓存的命中时间(Hit Time);如果增加太多,可能会成为电路中的关键路径(Critical Path),从而降低了处理器的时钟周期时间(Cycle Time)。

这就是三大基础优化方法,分别从缓存容量、块长度、相关联性入手。但这三部分都是缓存的基本特性,我们还可以从整个系统的角度来优化缓存。下一章我们就开始介绍缓存的高级优化,在这里先预告一下:

  • 多级缓存系统(Multilevel Cache)
  • 写入缓冲器(Write Buffer)
  • 牺牲高速缓存(Victim Cache)
  • 缓存预取(Prefetching)

高级优化方法一:缓存流水线化(Pipelined Cache)

为什么需要带有流水线的缓存呢?

通常来说,将数据写入缓存有两步:

  • 使用索引(Index)来定位 Tag Array (我们假设 Tag Array 和 Data Array 是分开的)
  • 检查有效位 Valid Bit 和标签 Tag;如果 Tag 相同,则写入缓存

上述两个步骤可以同时做,但对时钟周期(Clock Cycle)不是那么的友好,因为一个周期里面硬件需要做的事情更多了,可能会违反 Timing。

如果把这两步用时序分开,那么在 Memory Stage 会需要两个时钟周期:

  • 一个周期用来比较标签(Tag)
  • 一个周期用来写入数据(如果命中)

所以,为了让效率最大化,我们就可以使用流水线缓存(Pipelined Cache)来对这两个时序进行 Pipelining。

流水线缓存如何工作?

为了拆分这两个步骤,设计人员在缓存旁边新添加了一个模块,叫做(缓存延迟存储缓冲区 Delayed Cache Store Buffer)。没错,你一定看晕了,为了便于理解,我们可以认为它就是块缓冲区(Buffer),数据将在里面持续等待,直到被写入缓存。

那么数据要等到什么时候呢?答案是当遇到下一个 Store 指令的时候,在其比较标签 Tag 的时候,来自上一个 Store 指令的数据就可以完成第二步:从缓冲区写入缓存。下面的时序图能更直观的展示流水线缓存的工作流程:

1
2
3
4
5
6
7
8
        0   1   2   3   4   5   6   7   8  
SW1 CHK WC
SW2 CHK - - - - WC
INTR1 F D E M W
SW3 CHK WC

WC = Write Cache
CHK = Check Tag

来自 SW2 指令的数据一直停留在缓冲区中,直到第六个周期,下一条指令 SW3 开始了 CHK,这个时候这块 Buffer 检测到了,并把 SW2 的数据写入进了缓存。

流水线缓存的得与失

看似 Pipelined Cache 这种优化思路能够拆分写入缓存的步骤,减少写命中时间,但从硬件实现的角度来说,这一点还需要斟酌。假设有一个情况,一个 Load 指令紧接着 Store 指令,那么这个 Load 指令就需要去 Delayed Stored Buffer 里面寻找有没有相同的地址。如果有,就需要使用 Bypass 把数据送还给 CPU。图2 中的黄色标注路线就是 Bypass 路径。可以看出,为了实现这一点,一些新的硬件模块比如多路复用器(MUX)和比较器(Comparator)被加入到了缓存系统,而新的硬件则会为 Timing 和命中时间(Hit Time)带来新的挑战。

高级优化方法二:内存写入缓冲区(Write Buffer)

同样,我们先抛出相同的问题:为什么需要这种优化?

  • 如果有连续的 Store 指令(其实很普遍),我们不希望让 Cache 和 Memory 之间每次都通信,这样耗费的时间太多
  • 所以可以加一个Write Buffer,类似一个 Queue,可以 Queue-in 要存入内存的数据
  • 好处是,如果有连续的 Store 指令,他们可以在 Write Buffer 里形成一个完整的 Block,然后再存入 Memory 中,而不是每次都要访问内存,这种效果叫做 Write Merging
  • 可以减少 Cache 和 Memory 之间的 Bandwidth 的压力,也能减少 Miss Penalty
  • 当 Store 指令把数存入 Write Buffer 之后,从处理器的角度来说,整个写入内存的过程已经完成

在计算机的世界里,有一种现象很普遍,那就是多个连续的 Store 指令连续出现。如果每次 Store 指令都需要让缓存单独把数据写入内存,那效率就太低了。所以,设计人员就希望在缓存和内存之间,添加一个新的缓冲区叫做(内存)写入缓冲区(Write Buffer),作用类似于一个队列(Queue),可以 Queue-in 将要存入内存的数据。

在经历过连续的 Store 指令后,多个数据可以在 Write Buffer 里面形成一个完整的 Block,然后再将整个 Block 存入内存中,而不是每次都要访问内存。这种效果叫做写入合并(Write Merging)。从处理器的角度来说,处理器不需要担心 Write Buffer的存在,当 Store 指令把数据存进 Write Buffer 之后,整个写入内存的过程已经完成了。

Write Buffer 对于写穿和写回缓存有区别吗?

对于写穿(Write Through)的缓存来说,Write Buffer 里面存了要写入内存的数据,而对于写回(Write Back)的缓存来说,里面存了被驱逐的脏线(Dirty Line),它们正在等待被写入。但对于以下两种情况,上述的两种缓存都有着相同的策略:

  • 如果 Write Buffer 满了,那么 CPU 和缓存必须阻塞(Stall),等待 Write Buffer 清空
  • 如果出现读不命中(Read Miss),那么可以比较 Read Miss 的地址和存在 Write Buffer 里面的地址;如果没有相同,可以允许 Read Miss 比之前的 Store 指令提前去内存拿数;如果有相同的地址,那么就把这个在 Write Buffer 里面的数据送还给 CPU

高级优化方法三:多级缓存系统(Multilevel Cache System)

为什么需要多级缓存?

存储器技术一直有一个限制 —— 一块内存不能做到运行速度快,同时存储容量又大,正所谓鱼和熊掌不可兼得;如果需要存储器延迟小,那么容量就不能太大,反之亦然。

然而工程师说只有小孩才做选择,我全都要。所以为了解决这个痛点,早期的芯片设计师们就提出,用多个不同性质的缓存来组成并且模拟一个速度又快同时容量又大的缓存系统。

多级缓存通常由两到三个缓存构成,每个缓存都有不同的性质和名称,最靠近处理器的叫做 L1 缓存,下一级的就叫做 L2 缓存,有的系统里还会有第三级缓存,就叫做 L3 缓存,以此类推。图1 中的多级缓存系统就没有第三级,取而代之的是内存(DRAM)。

第一级缓存(L1 Cache)通常是一个写穿缓存(Write Through),容量非常小。因为 L1 缓存非常靠近处理器,所以我们需要它的运行频率非常贴合处理器的工作频率,而小容量就不会增加每个时钟周期的负担,可以保证运行的速度,也能够更好的融合进 CPU 的流水线(Pipeline)。而设计成写穿模式,通常是为了考虑到二级缓存(L2 Cache)和系统整体的简易性,这样每次 L1 缓存都能够更新 L2 缓存,使 L2 缓存里的数据保持最新。

第二级缓存(L2 Cache)通常使用的是写回(Write Back)并且比 L1 Cache 的容量大很多,这样一来,L2 Cache 就可以储存更多的数据,减少缓存的不命中成本(Miss Penalty),并且写回(Write Back)缓存可以降低 L2 缓存与内存之间的通信次数,因为缓存基本都是片上(On-chip),但内存都是 Off-Chip,每次的通信成本很高。

L2 缓存的出现,不仅仅提供了更大的容量,同时也能简化 L1 缓存的设计。比如说,缓存上的错误恢复(Error Recovery)功能可以交给 L2 缓存来完成。如果芯片被高能射线照射,高能粒子使 L1 Cache 的数据被修改,L1 Cache 可以用奇偶校验位(Parity Bits)来告诉 L2 Cache,自己的数据出错了;而 L2 Cache 有很强的错误保护和恢复能力,它能够根据 L1 Cache 送来的 Parity Bits 随时使 L1 Cache 中的错误数据无效。这样一来,L1 缓存不必再担心自身的数据出错,也不需要额外的模块来恢复,所以 L1 Cache 的设计更简单了。

多级缓存的性能(AMAT)

AMAT 的公式如下:

1
AMAT = HitTime + MissRate * MissPenalty

所以,根据这个 AMAT 的公式,我们可以写出 L1 缓存在多级缓存系统中的 AMAT:

1
AMAT L1 = HitTime L1 + MissRate L1 * MissPenalty L1

并且,L1 Cache 的不命中成本(Miss Penalty)又和 L2 Cache 的 AMAT 有关,所以我们可以写出 L1 Miss Penalty 的公式:

1
MissPenalty L1 = AMAT L2 = HitTime L2 + MissRate L2 * MissPenalty L2

从处理器的角度来说,处理器只能看到 L1 缓存,并且认为 L1 缓存就是整个缓存系统。所以,从 CPU 角度来看,整个多级缓存系统的 AMAT,就是 L1 Cache 的 AMAT。我们可以把第二个公式和第三个公式结合在一起,得到了整个 Multilevel Cache System 的 AMAT 公式:

1
AMAT total = HitTime L1 + MissRate L1 * (HitTime L2 + MissRate L2 * MissPenalty L2)

所以,对于 L1 和 L2 缓存,这里就引申出了两个概念:

  • 本地未命中率(Local Miss Rate)
  • 全局未命中率(Global Miss Rate)

本地未命中率就不用多说了,就是 L1 和 L2 缓存自身的 Miss Rate。全局未命中率是一个针对多级缓存系统的新概念。它的定义为缓存系统的未命中次数与 CPU 访问缓存次数的比值(The ratio of the number of misses in cache and the number of CPU memory accesses)。

对于 L1 Cache 来说,Global Miss Rate 就是它自身的 Miss Rate

对于 L2 Cache 来说,Global Miss Rate 是 L1 的 Miss Rate 和 自身的 Miss Rate 的乘积

1
2
Global Miss Rate L1 = Local Miss Rate L1 = Miss Rate L1
Global Miss Rate L2 = Miss Rate L1 * Miss Rate L2

多级缓存的包含策略(Inclusion Policies)

在多级缓存系统中,通常有两种包含策略(Inclusion Policies),它们描述了这些缓存互相之间如何储存数据。

  • 数据共享 —— Inclusive Multilevel Cache
    • 任何存在 L1 Cache 的数据都在 L2 Cache 里
    • 优点:在更大的 SoC 中,外部模块的数据访问只需要访问 L2 Cache 即可,因为在 L1 中的数据必定在 L2 缓存中
    • 如果有数据在 L1 或者 L2 Cache 被驱逐,那么数据就会被放回内存,同时更新 L1 Cache 和 L2 Cache
  • 数据独占 —— Exclusive Multilevel Cache
    • L2 Cache 可能会有 L1 Cache 中没有的数据
    • 好处:可以让整个多级缓存系统(Multilevel Cache System)的总容量变大,并且可以最大程度利用缓存的时间局部性(Temporal Locality),因为被 L1 缓存驱逐的数据还会存在 L2 Cache,如果再次用到,不用去内存里再拿数据了
    • 用这种 Policy 需要一种互换操作(Swap Operation),可以在 L1 Cache 和 L2 Cache 之间交换数据

高级优化方法四:高速牺牲缓存(Victim Cache)

为什么需要 Victim Cache?

首先我们来看一个程序:

1
2
3
4
5
6
7
int a[1M]  // 1 million
int b[1M]
int c[1M]

for(i=0; i=1M; i++) {
c[i] = a[i] + b[i];
}

这是一个非常简单的程序,三个数组(Array),其中两个数组每个元素相加,并把输出放入相应的第三个数组里面。假设数组 c[], a[], b[] 都被索引在一个相同的 Cache Line 上,并且这个 Cache 是双向关联的(Two-Way Associative),那么这三个数组在缓存里会相互冲突,造成由冲突导致的不命中(Conflict Miss)。这种情况下,空间局部性(Spatial Locality)和 时间局部性(Temporal Locality)都失去了作用。

那如何在不改变原有缓存的性质的情况下,解决这种类似的问题呢?答案就是今天的主角之一:牺牲缓存(Victim Cache)。牺牲缓存可以想象成一块放在缓存边上的额外的缓冲区(Buffer),可以变相的增大缓存的相联性(Associativity)。这样一来,在处理上述例子里的三个数组的时候,就不需要因为冲突而大老远的去内存里面拿数了。牺牲缓存的出现可以减少缓存系统的错失率(Miss Rate)和错失成本(Miss Penalty)

高速牺牲缓存(Victim Cache)的特点

虽然名字怪怪的(有的地方翻译成“受害者缓存”),但它的特性和原理其实和简单。Victim Cache 就是另一块小的 Cache,是缓存的缓存(Cache of Cache)。里面的数据都是从原本的 Cache 驱逐出来的(Evicted),所以它们被称为受害者,或者被牺牲者(Victim)。

在传统的缓存系统中,被驱逐的脏线(Evicted Cache Line)会失效,或者被新的缓存线(Cache Line)代替。而有了牺牲缓存,这些被驱逐出去的脏线就有了去除,他们会被存在这个小缓存中,等待着下一次被召唤(也可能没被召唤,下场就是被踢回 Memory 中了)。

这块牺牲缓存通常是全相联(Fully Associative),并且有着非常少的输入口(Entries)(通常 4 ~ 16 个)。在多级缓存系统中,牺牲缓存通常放置在 L1 缓存旁,两个缓存可以进行并行访问(Parallel Checking),或者按序访问(Series Checking)。

牺牲缓存虽然可以减少错失率和错失成本,但会给硬件带了一些新的挑战。如果我们把它和 L1 缓存设计成并行访问,那么硬件复杂度和功耗都会上升;如果设计出按序访问,那么缓存系统整体的延迟就会增加,毕竟多了一个访问步骤。

牺牲缓存的工作流程

那么 Victim Cache 是怎么工作的呢?图1 展示了一个比较简单的 Victim Cache 系统,整个工作逻辑如下:

  1. 检查 L1 Cache;如果命中,那么返回数据
  2. 如果没有命中,那么检查 Victim Cache(假设是按序检查)。如果 Victim Cache 里面有这个数据,那么该数据将会返回给 L1 Cache 然后再送还给处理器。在 L1 Cache 里被驱逐的数据,会存放进 Victim Cache 里面
  3. 如果 L1 Cache 和 Victim Cache 都没有该数据,那么就会从 Memory/L2 Cache 取出该数据,并把它放在 L1 Cache 里面。如果在该次操作里面,L1 Cache 需要驱逐一个数据,那么这个被驱逐的数据会被存进 Victim Cache 里面。任何被 Victim Cache 驱逐的未修改过的数据,都会写进 Memory 里面或者被丢弃

高级优化方法五:多端口缓存(Multiporting Cache)和分区缓存(Banked Cache)

为什么缓存需要多端口或者分区?

  • 如果处理器是超标量,比如上图两个 pipe,那么 single-port Cache 只能支持一个 pipe 来运行 Mem 相关的指令(Load & Store),造成 One memory instruction per cycle 的吞吐量瓶颈
  • 如何让两个 pipe 都能同时访问内存呢?给 Cache 增加 ports

如果是咱们使用的是超标量处理器(SuperScalar),比如上图的两级流水线,那么拥有着单一端口的缓存(Single-port Cache)只能支持其中一个流水线来运行和 Memory 相关的指令(比如 Load 和 Store),那么整个系统在每个时钟周期只能最多运行一条内存相关指令,这就卡了系统吞吐量的脖子了。

那如何让两个流水线能够同时访问缓存呢?

多端口缓存(Multiporting Cache)—— Bad Idea!

工程师们首先想到的是给缓存多加一组读写端口,从而变成多端口缓存。

上图的缓存拥有两组 ports,虽然支持多端口的 SRAM 真实存在,但问题随之而来:

  • 虽然支持两组端口,但缓存的面积增大了两倍,导致访问速度变慢,命中时间(Hit Time)增加
  • 如果处理器新增了一条流水线,那么这个 Cache 需要三组接口,更难设计;换句话说,这种方案的可扩展性不高
  • 如果两个 Store 指令在相同的地址存数,或者在相同 Clock Cycle 里面的一个 Load 指令和一个 Store 指令拥有相同的地址,那么会造成冲突,需要额外的硬件和时序来解决

那怎么办呢?既然不能多加端口,那就把几个缓存拼在一起,组成一个大缓存,于是乎就有了 —— 分区缓存。

分区缓存(Banked Cache)

虽然整体上我们可以看成一块缓存,但在地址空间(Address Space)看来,这些缓存是独立分区的,每一块都有自己的地址空间。系统会利用地址的一部分来选择访问或者写入那一块分区(Bank)。

现代缓存系统中,地址的高低位都可以用来选择缓存分区,原因如下:

  • 数组(Array)拥有着相同的高位地址,如果此时利用高位地址来选择分区,那么数组中连续的元素都会放在同一个分区,从而损伤了性能
  • 结构数组(Array of Struct)中相同的元素拥有着一样的低位地址,如果此时利用低位地址来选择分区,那么结构数组中相同的元素都会放在同一个分区,连续访问时也会造成性能的下降

所以,在现代的处理器设计中,分区缓存(Banked Cache)都会有一个路由装置( Router/Crossbar),交替着选择是用地址的高位还是低位,来选取缓存分区,并且确保了每一条流水线都可以访问所有的分区。

但分区缓存也不是万能的,它为硬件带来了新的挑战:

  • 因为需要 Router/Crossbar,所以在设计上会增加难度,系统的功耗和延迟都会增加
  • 分区冲突(Bank Conflict)和使用不均匀(Uneven Utilization)的现象依旧存在

高级优化方法五:缓存预取(Prefetching)

为什么需要缓存预取?

在我们之前的笔记里,提到了很多方法可以来减少容量错失(Capacity Miss)和冲突错失(Conflict Miss),比如通过更多的相关联性(Associative)来降低 Conflict Miss,也可以通过使用更大的缓存来降低 Capacity Miss。

然后,目前还没有一种方法能够降低强制性错失(Compulsory Miss),这种情况是由于程序第一次运行导致缓存里面还未加载和这个程序有关的数据,而造成的百分百的不命中。不过,借助类似处理器的预测执行(Speculative Execution),我们可以在缓存里添加类似的机制从而在一开始就提前放入程序需要的数据。

缓存预取的风险

缓存预取这种新机制可能会对 Capacity Miss 和 Conflict Miss 造成负面影响,因为它可能提前拿取的数据没有用上,反而污染了(Pollute)整个 Cache Line

所以缓存预取功能有两个要点:

  • Usefulness 实用性 - 带上来的数据应该被命中
  • Timeliness 及时性 - 应该在恰到好处的时间点带上来正确的数据;如果太早,会被提早驱逐出去,对性能、功耗、带宽上造成影响,这在 Multi-core 和 Off-Chip 系统里很严重;如果太晚,则预测的数据没有用上,污染了缓存

一般来说,Prefetching 在指令缓存(Instruction Cache)里面很好用,因为通常情况下执行的指令都是按照顺序的,方便预测。并且可以用于一级指令缓存(Instruction L1 Cache) 和二级缓存之间,因为他们是片上系统,可以充分利用带宽。反之,这个机制不用于二级缓存和内存之间,因为一般情况内存都不在片上(Off-Chip),它们中间的带宽十分昂贵。

预取器 Prefetcher

在设计中,通常有三种缓存预取的设计:

  • 硬件指令预取器 Hardware Instruction Prefetcher
  • 硬件数据预取器 Hardware Data Prefetcher
  • 软件预取 Software Prefetching

硬件指令预取器 Hardware Instruction Prefetcher

从 图2 可以看到,一个新的模块 —— Stream Buffer 加入了指令缓存系统,这块 Buffer 可以读取下一条很有可能被执行的指令。整个操作流程如下:

  • 假设 CPU 请求了 block i,但未命中
    • 这种情况下,缓存系统会拿取两个 Block
      • block i放在指令缓存(Instruction Cache)里面
      • block i+1放在 Stream Buffer 里面
    • 执行完 block i后,CPU 会请求 block i+1 的指令
      • 一级指令缓存中未命中,但 Stream Buffer 命中
      • block i+1 会从 Stream Buffer 里面移到一级指令缓存,接着 Stream Buffer 会读取下一条 block i+2 指令
  • (以上假设这段代码中没有 branch 分支)

硬件数据预取器 Hardware Data Prefetcher

在预测数据方面,通常由三种方案:

  1. Prefetch-on-miss
    和上面讲的指令预取器很像,如果 block i 未命中,那么同时拿取 block i+1

  2. One Block Lookahead (OBL)
    不管 block i 有没有命中,都拿取 block i+1。那么有很多聪明的小伙伴要问了,这不就是把缓存的块长度(Block Size)翻倍嘛。区别还是有的!如果块长度翻倍,那么在同一时钟周期里面需要拿两个 block,工作量更大,容易引起 Timing Violation;但这个预取器是分步拿取的:拿上来 block i 先用,然后预取器自己再去拿 block i+1, block i+2, ……, block i+n,这样可以将拿取这些块的延迟隐藏起来(hide latency)

  3. Strided Prefetch (现代 Intel 处理器使用的方法)
    以上两种方法都不能解决一个问题:在它们的眼里,数据是连续的,比如 array;但有时数据不是连续的,比如 array of struct,这时我们想连续提取每个 struct 里面相同的元素,就是以固定的 offset 去 array of struct 里面拿数,这时内存的访问就不是连续的了。

所以,我们需要一个更聪明的预取器,以一定的步长(Stride)来拿数。

这个预取器需要能发现一定的规律,比如,如果发现连续两次取数都有 offset N 的偏移量,block i, block i+n, block i+2n,那么,预取器就会自动以这个规律去拿取 block i+3n, block i+4n, …

Strided Prefetch 还用在了 IBM Power 5 上面,这款处理器有八个独立的 Strided Prefetcher,每一个可以提前拿取 12 个 Block Line。

软件预取 Software Prefetchingz

最后一种,软件预取,顾名思义,就是利用软件来提前取数。

1
2
3
4
5
6
for (i = 0; i < N; i++)
{
prefetch(&a[i+1]); // hint for HW to prefetch
prefetch(&b[i+1]);
sum = sum + a[i] * b[i];
}

比如上面的代码中,利用 prefetch 关键词来提示软件提前拿到 a[i+1] 和 b[i+1] 的数据。

然而,软件预取存在一个很大问题 —— Timing,软件需要在合适的时间拿取合适的数据。但是软件很难知道底层的情况:

  • Programmer 和 Software 不知道真实的底层 Memory load 是多大
  • Programmer 和 Software 不知道 Memory Controller 的阻塞情况
  • Programmer 和 Software 不知道数据 a[i] 和 b[i] 有没有命中
  • 上面这些都是处理器的动态信息,很难把握,所以 Timing 就成为了 Software Prefetching 的一大难题,软件有可能过早或者过晚的拿到了数据,从而污染或者错过了需要的数据。

总结

总的来说,预取机制是一种类似处理器的预测执行的功能,可以预测未来可能会用到的数据或者指令,并且提前加载进缓存系统。这种机制可以有效降低 Compulsory Miss 和错失成本(Miss Penalty)。但是,错误的预测会污染缓存,反而造成危害,并且也会增加缓存和内存间带宽的压力。

软件 / 编译器优化 SW Compiler Optimization

之前一直在讲硬件和架构上的优化,软件和编译器的优化也能够帮助提升缓存的性能,比如,编译器可以更好的优化和整理代码,使一些 Load 或者 Store 的指令靠在一起,充分利用空间局部性(Spatial Locality)和时间局部性(Temporal Locality)。

再比如,编译器优化可以防止一些非必要的数据进入缓存。如果一个数据只用一次,那就应该防止它进入 Cache。软件或者编译器应该提示硬件,不要为这个给只用一次的数据分配空间,设计人员也可以设计一个新的 load 指令,比如 load.nc (nc 代表 not cache,意味着不要分配缓存空间)

软件或者编译器也可以提前从缓存中驱逐一个之后不会再用到的数据,就像是预取(Prefetch)的反向操作。一些数据是因为空间局部性(Spatial Locality)而存进缓存,如果这些数据在后面不会用到,那么可以提早将他们逐出去,避免污染缓存,造成 miss

非阻塞缓存 Non-Blocking Caches

非阻塞缓存 Non-blocking Cache, 别名 Out-of-order Cache,Lookup Free Cache。它的核心思想是如果处理器遇到 Cache Miss,那么后续的 Mem 指令依然可以执行。所以,non-blocking cache 会出现两种情况:

  • Miss —> Miss (Concurrent Miss,也叫做 Miss-under-Miss)
  • Miss —> Hit (Hit-under-Miss)

Memory 乱序带来的挑战

这些乱序的改动,会给缓存系统带来不小的挑战:

  • 需要添加额外的机制来确保乱序的 Memory Miss 能够按照正确顺序返回数据
  • 如果 Load 或者 Store 发生在已经 miss 的地址,Cache 系统很有可能出错

解决方案

聪明的工程师和设计人员为 Non-blocking Cache 系统添加两块新的模块:

  • 新模块一 —— Miss Status Handling Register (MSHR) / Miss Address File (MAF)
    • Block Addr —— 缓存块(Cache Block)的地址
    • Issue —— 有没有发行出去(Get issued)? 设立这个是因为 Memory 并不是在 miss 之后马上去寻找数据,比如系统的 Bus 正在忙碌其他事情
  • 新模块二 —— Load/Store Entry Table (简称 L/S ET)
    • MSHR Entry —— 是一个指针,指向 MSHR/MAF
    • Offset —— 缓存线(Cacheline) 中的偏移量
    • Type —— Memory 指令的种类
    • Destination —— 当前的指令要写到哪个 Register,或者是哪个 Memory Location / Store Buffer

假设当前有两个 Load Misses,一个的地址是 0,另一个的地址是 10,假设 Cache Line 的长度是 16B(32位,每个 Cache line 能放四个数据,所以是 32 * 4 / 8 = 16),所以,这两个地址是在同一个 Cache Line 上的。

这两个 Miss 会在 L/S ET 里面填写上各自的两行信息,其中 MSHR Entry 都指向同一行 MSHR,因为它们都在同一个 Cacheline 上面,但 Offset 会有所不同。

非阻塞缓存的操作流程

当出现 Cache Miss 的时候:

  • 检查 MSHR 有没有对应的地址
    • 如果有,那就在 L/S ET 里面分配一个 entry,然后指向 MSHR
    • 如果没有,那么分配一个新的 MSHR 和 L/S ET
    • 如果 MSHR 或者 L/S ET 满了,那就 stall 整个系统

当数据从 Memory 返回时

  • 找到正在等待的对应的 Load 或者 Store 指令
  • 将数据传送给处理器或者存在 Cache 里面
  • 完成之后,解除 MSHR entry 和 L/S ET 的分配空间

关键词优先和提前重启 Critical Word First & Early Restart

Critical Word First

重要的先行 — 优先拿取重要的字(word),这样处理器就可以提前开始工作。例如上图,数据 3 更重要,所以 3 的 miss 最先被处理,CPU 下一次的工作时间提早了,所以这个方法可以减少 Miss Penalty。

Early Restart

在通常情况下,CPU 需要等到整个 Cache line 都上来之后才重新工作。所以 Early Restart 的核心思想是 —— 当想要的数据一上来之后,CPU 立马开始工作。

如上图的例子,数据 3 是处理器想要的数据,但正常处理器需要等到 0~7 全部写入 Cache,才重新开始工作。如果有 Early Restart 功能,0~7 拿取的顺序还是一样的,但是当数据 3 写入缓存之后,CPU 不必等接下来的数据,直接可以重新工作。这个方法也可以减少 Miss Penalty。

背景介绍

传统TCP/IP通信模式

传统的TCP/IP网络通信,数据需要通过用户空间发送到远程机器的用户空间。数据发送方需要讲数据从用户应用空间Buffer复制到内核空间的Socket Buffer中。然后Kernel空间中添加数据包头,进行数据封装。通过一系列多层网络协议的数据包处理工作,这些协议包括传输控制协议(TCP)、用户数据报协议(UDP)、互联网协议(IP)以及互联网控制消息协议(ICMP)等。数据才被Push到NIC网卡中的Buffer进行网络传输。消息接受方接受从远程机器发送的数据包后,要将数据包从NIC buffer中复制数据到Socket Buffer。然后经过一些列的多层网络协议进行数据包的解析工作。解析后的数据被复制到相应位置的用户应用空间Buffer。这个时候再进行系统上下文切换,用户应用程序才被调用。以上就是传统的TCP/IP协议层的工作。

通信网络定义

计算机网络通信中最重要两个衡量指标主要是指高带宽和低延迟。通信延迟主要是指:处理延迟和网络传输延迟。处理延迟开销指的就是消息在发送和接收阶段的处理时间。网络传输延迟指的就是消息在发送和接收方的网络传输时延。如果网络通信状况很好的情况下,网络基本上可以 达到高带宽和低延迟。

当今网络现状

当今随着计算机网络的发展。消息通信主要分为两类消息,一类是Large messages,在这类消息通信中,网络传输延迟占整个通信中的主导位置。还有一类消息是Small messages,在这类消息通信中,消息发送端和接受端的处理开销占整个通信的主导地位。然而在现实计算机网络中的通信场景中,主要是以发送小消息为主。所有说发送消息和接受消息的处理开销占整个通信的主导的地位。具体来说,处理开销指的是buffer管理、在不同内存空间中消息复制、以及消息发送完成后的系统中断。

传统TCP/IP存在的问题

传统的TPC/IP存在的问题主要是指I/O bottleneck瓶颈问题。在高速网络条件下与网络I/O相关的主机处理的高开销限制了可以在机器之间发送的带宽。这里感兴趣的高额开销是数据移动操作和复制操作。具体来讲,主要是传统的TCP/IP网络通信是通过内核发送消息。Messaging passing through kernel这种方式会导致很低的性能和很低的灵活性。性能低下的原因主要是由于网络通信通过内核传递,这种通信方式存在的很高的数据移动和数据复制的开销。并且现如今内存带宽性相较如CPU带宽和网络带宽有着很大的差异。很低的灵活性的原因主要是所有网络通信协议通过内核传递,这种方式很难去支持新的网络协议和新的消息通信协议以及发送和接收接口。

相关工作

高性能网络通信历史发展主要有以下四个方面:TCP Offloading Engine(TOE)、User-Net Networking(U-Net)、Virtual interface Architecture(VIA)、Remote Direct Memroy Access(RDMA)。U-Net是第一个跨过内核网络通信的模式之一。VIA首次提出了标准化user-level的网络通信模式,其次它组合了U-Net接口和远程DMA设备。RDMA就是现代化高性能网络通信技术。

TCP Offloading Engine

在主机通过网络进行通信的过程中,主机处理器需要耗费大量资源进行多层网络协议的数据包处理工作,这些协议包括传输控制协议(TCP)、用户数据报协议(UDP)、互联网协议(IP)以及互联网控制消息协议(ICMP)等。由于CPU需要进行繁重的封装网络数据包协议,为了将占用的这部分主机处理器资源解放出来专注于其他应用,人们发明了TOE(TCP/IP Offloading Engine)技术,将上述主机处理器的工作转移到网卡上。

这种技术需要特定网络接口-网卡支持这种Offloading操作。这种特定网卡能够支持封装多层网络协议的数据包,这个功能常见于高速以太网接口上,如吉比特以太网(GbE)或10吉比特以太网(10GbE)。

User-Net Networking(U-Net)

U-Net的设计目标是将协议处理部分移动到用户空间去处理。这种方式避免了用户空间将数据移动和复制到内核空间的开销。它的设计宗旨就是移动整个协议栈到用户空间中去,并且从数据通信路径中彻底删除内核。这种设计带来了高性能的提升和高灵活性的提升。

U-Net的virtual NI 为每个进程提供了一种拥有网络接口的错觉,内核接口只涉及到连接步骤。传统上的网络,内核控制整个网络通信,所有的通信都需要通过内核来传递。U-Net应用程序可以通过MUX直接访问网络,应用程序通过MUX直接访问内核,而不需要将数据移动和复制到内核空间中去。

DMA

DMA(直接内存访问)是一种能力,允许在计算机主板上的设备直接把数据发送到内存中去,数据搬运不需要CPU的参与。

传统内存访问需要通过CPU进行数据copy来移动数据,通过CPU将内存中的Buffer1移动到Buffer2中。DMA模式:可以同DMA Engine之间通过硬件将数据从Buffer1移动到Buffer2,而不需要操作系统CPU的参与,大大降低了CPU Copy的开销。

RDMA

RDMA是一种概念,在两个或者多个计算机进行通讯的时候使用DMA, 从一个主机的内存直接访问另一个主机的内存。

RDMA(Remote Direct Memory Access)技术全称远程直接内存访问,就是为了解决网络传输中服务器端数据处理的延迟而产生的。RDMA通过网络把资料直接传入计算机的存储区,将数据从一个系统快速移动到远程系统存储器中,而不对操作系统造成任何影响,这样就不需要用到多少计算机的处理功能。它消除了外部存储器复制和上下文切换的开销,因而能解放内存带宽和CPU周期用于改进应用系统性能。

RDMA主要有以下三个特性:1.Low-Latency 2.Low CPU overhead 3. high bandwidth

RDMA是一种host-offload, host-bypass技术,允许应用程序(包括存储)在它们的内存空间之间直接做数据传输。具有RDMA引擎的以太网卡(RNIC)—而不是host—负责管理源和目标之间的可靠连接。使用RNIC的应用程序之间使用专注的QP和CQ进行通讯:

  • 每一个应用程序可以有很多QP和CQ
  • 每一个QP包括一个SQ和RQ
  • 每一个CQ可以跟多个SQ或者RQ相关联

RDMA的优势

传统的TCP/IP技术在数据包处理过程中,要经过操作系统及其他软件层,需要占用大量的服务器资源和内存总线带宽,数据在系统内存、处理器缓存和网络控制器缓存之间来回进行复制移动,给服务器的CPU和内存造成了沉重负担。尤其是网络带宽、处理器速度与内存带宽三者的严重”不匹配性”,更加剧了网络延迟效应。

RDMA是一种新的直接内存访问技术,RDMA让计算机可以直接存取其他计算机的内存,而不需要经过处理器的处理。RDMA将数据从一个系统快速移动到远程系统的内存中,而不对操作系统造成任何影响。

在实现上,RDMA实际上是一种智能网卡与软件架构充分优化的远端内存直接高速访问技术,通过将RDMA协议固化于硬件(即网卡)上,以及支持Zero-copy和Kernel bypass这两种途径来达到其高性能的远程直接数据存取的目标。 使用RDMA的优势如下:

  • 零拷贝(Zero-copy) - 应用程序能够直接执行数据传输,在不涉及到网络软件栈的情况下。数据能够被直接发送到缓冲区或者能够直接从缓冲区里接收,而不需要被复制到网络层。
  • 内核旁路(Kernel bypass) - 应用程序可以直接在用户态执行数据传输,不需要在内核态与用户态之间做上下文切换。
  • 不需要CPU干预(No CPU involvement) - 应用程序可以访问远程主机内存而不消耗远程主机中的任何CPU。远程主机内存能够被读取而不需要远程主机上的进程(或CPU)参与。远程主机的CPU的缓存(cache)不会被访问的内存内容所填充。
  • 消息基于事务(Message based transactions) - 数据被处理为离散消息而不是流,消除了应用程序将流切割为不同消息/事务的需求。
  • 支持分散/聚合条目(Scatter/gather entries support) - RDMA原生态支持分散/聚合。也就是说,读取多个内存缓冲区然后作为一个流发出去或者接收一个流然后写入到多个内存缓冲区里去。

在具体的远程内存读写中,RDMA操作用于读写操作的远程虚拟内存地址包含在RDMA消息中传送,远程应用程序要做的只是在其本地网卡中注册相应的内存缓冲区。远程节点的CPU除在连接建立、注册调用等之外,在整个RDMA数据传输过程中并不提供服务,因此没有带来任何负载。

RDMA 三种不同的硬件实现

RDMA作为一种host-offload, host-bypass技术,使低延迟、高带宽的直接的内存到内存的数据通信成为了可能。目前支持RDMA的网络协议有:

  1. InfiniBand(IB): 从一开始就支持RDMA的新一代网络协议。由于这是一种新的网络技术,因此需要支持该技术的网卡和交换机。
  2. RDMA过融合以太网(RoCE): 即RDMA over Ethernet, 允许通过以太网执行RDMA的网络协议。这允许在标准以太网基础架构(交换机)上使用RDMA,只不过网卡必须是支持RoCE的特殊的NIC。
  3. 互联网广域RDMA协议(iWARP): 即RDMA over TCP, 允许通过TCP执行RDMA的网络协议。这允许在标准以太网基础架构(交换机)上使用RDMA,只不过网卡要求是支持iWARP(如果使用CPU offload的话)的NIC。否则,所有iWARP栈都可以在软件中实现,但是失去了大部分的RDMA性能优势。

在三种主流的RDMA技术中,可以划分为两大阵营。一个是IB技术, 另一个是支持RDMA的以太网技术(RoCE和iWARP)。其中, IBTA力挺的技术自然是IB和RoCE, Mellanox公司(一个以色列人搞的小公司)是这方面的急先锋。而iWARP则是IEEE/IETF力挺的技术,主要是Chelsio公司在推进。RoCE和iWARP的争论,请参考Mellanox和Chelsio这两家公司发布的白皮书。

在存储领域,支持RDMA的技术早就存在,比如SRP(SCSI RDMA Protocol)和iSER(iSCSI Extensions for RDMA)。 如今兴起的NVMe over Fabrics如果使用的不是FC网络的话,本质上就是NVMe over RDMA。 换句话说,NVMe over InfiniBand, NVMe over RoCE和NVMe over iWARP都是NVMe over RDMA。

RDMA基本术语

Remote:数据通过网络与远程机器间进行数据传输

Direct:没有内核的参与,有关发送传输的所有内容都卸载到网卡上

Memory:在用户空间虚拟内存与RNIC网卡直接进行数据传输不涉及到系统内核,没有额外的数据移动和复制

Access:send、receive、read、write、atomic操作

RDMA基本概念

RDMA有两种基本操作。

  1. Memory verbs: 包括RDMA read、write和atomic操作。这些操作指定远程地址进行操作并且绕过接收者的CPU。
  2. Messaging verbs:包括RDMA send、receive操作。这些动作涉及响应者的CPU,发送的数据被写入由响应者的CPU先前发布的接受所指定的地址。

RDMA传输分为可靠和不可靠的,并且可以连接和不连接的(数据报)。凭借可靠的传输,NIC使用确认来保证消息的按序传送。不可靠的传输不提供这样的保证。然而,像InfiniBand这样的现代RDMA实现使用了一个无损链路层,它可以防止使用链路层流量控制的基于拥塞的损失,以及使用链路层重传的基于位错误的损失。因此,不可靠的传输很少会丢弃数据包。

目前的RDMA硬件提供一种数据报传输:不可靠的数据报(UD),并且不支持memory verbs。

RDMA三种不同的硬件实现

目前RDMA有三种不同的硬件实现。分别是InfiniBand、iWarp(internet Wide Area RDMA Protocol)、RoCE(RDMA over Converged Ethernet)。

目前,大致有三类RDMA网络,分别是Infiniband、RoCE、iWARP。其中,Infiniband是一种专为RDMA设计的网络,从硬件级别保证可靠传输 , 而RoCE 和 iWARP都是基于以太网的RDMA技术,支持相应的verbs接口,如图1所示。从图中不难发现,RoCE协议存在RoCEv1和RoCEv2两个版本,主要区别RoCEv1是基于以太网链路层实现的RDMA协议(交换机需要支持PFC等流控技术,在物理层保证可靠传输),而RoCEv2是以太网TCP/IP协议中UDP层实现。从性能上,很明显Infiniband网络最好,但网卡和交换机是价格也很高,然而RoCEv2和iWARP仅需使用特殊的网卡就可以了,价格也相对便宜很多。

  1. Infiniband,支持RDMA的新一代网络协议。 由于这是一种新的网络技术,因此需要支持该技术的NIC和交换机。
  2. RoCE,一个允许在以太网上执行RDMA的网络协议。 其较低的网络标头是以太网标头,其较高的网络标头(包括数据)是InfiniBand标头。 这支持在标准以太网基础设施(交换机)上使用RDMA。 只有网卡应该是特殊的,支持RoCE。
  3. iWARP,一个允许在TCP上执行RDMA的网络协议。 IB和RoCE中存在的功能在iWARP中不受支持。 这支持在标准以太网基础设施(交换机)上使用RDMA。 只有网卡应该是特殊的,并且支持iWARP(如果使用CPU卸载),否则所有iWARP堆栈都可以在SW中实现,并且丧失了大部分RDMA性能优势。

Fabric

A local-area RDMA network is usually referred to as a fabric.所谓Fabric,就是支持RDMA的局域网(LAN)。

CA(Channel Adapter)

A channel adapter is the hardware component that connects a system to the fabric.

CA是Channel Adapter(通道适配器)的缩写。那么,CA就是将系统连接到Fabric的硬件组件。 在IBTA中,一个CA就是IB子网中的一个终端结点(End Node)。分为两种类型,一种是HCA, 另一种叫做TCA, 它们合称为xCA。其中, HCA(Host Channel Adapter)是支持”verbs”接口的CA, TCA(Target Channel Adapter)可以理解为”weak CA”, 不需要像HCA一样支持很多功能。 而在IEEE/IETF中,CA的概念被实体化为RNIC(RDMA Network Interface Card), iWARP就把一个CA称之为一个RNIC。

简言之,在IBTA阵营中,CA即HCA或TCA; 而在iWARP阵营中,CA就是RNIC。 总之,无论是HCA、 TCA还是RNIC,它们都是CA, 它们的基本功能本质上都是生产或消费数据包(packet)

Verbs

在RDMA的持续演进中,有一个组织叫做OpenFabric Alliance所做的贡献可谓功不可没。 Verbs这个词不好翻译,大致可以理解为访问RDMA硬件的“一组标准动作”。 每一个Verb可以理解为一个Function。

核心概念

RDMA技术

传统上的RDMA技术设计内核封装多层网络协议并且涉及内核数据传输。RDMA通过专有的RDMA网卡RNIC,绕过内核直接从用户空间访问RDMA enabled NIC网卡。RDMA提供一个专有的verbs interface而不是传统的TCP/IP Socket interface。要使用RDMA首先要建立从RDMA到应用程序内存的数据路径 ,可以通过RDMA专有的verbs interface接口来建立这些数据路径,一旦数据路径建立后,就可以直接访问用户空间buffer。

RDMA技术详解

RDMA 的工作过程如下:

  1. 当一个应用执行RDMA 读或写请求时,不执行任何数据复制.在不需要任何内核内存参与的条件下,RDMA 请求从运行在用户空间中的应用中发送到本地NIC( 网卡)。

  2. NIC 读取缓冲的内容,并通过网络传送到远程NIC。

  3. 在网络上传输的RDMA 信息包含目标虚拟地址、内存钥匙和数据本身.请求既可以完全在用户空间中处理(通过轮询用户级完成排列) ,又或者在应用一直睡眠到请求完成时的情况下通过系统中断处理.RDMA 操作使应用可以从一个远程应用的内存中读数据或向这个内存写数据。

  4. 目标NIC 确认内存钥匙,直接将数据写人应用缓存中.用于操作的远程虚拟内存地址包含在RDMA 信息中。

RDMA整体系统架构图

上诉介绍的是RDMA整体框架架构图。从图中可以看出,RDMA在应用程序用户空间,提供了一系列verbs interface接口操作RDMA硬件。RDMA绕过内核直接从用户空间访问RDMA 网卡(RNIC)。RNIC网卡中包括Cached Page Table Entry,页表就是用来将虚拟页面映射到相应的物理页面。

Memory Registration(MR) | 内存注册

RDMA 就是用来对内存进行数据传输。那么怎样才能对内存进行传输,很简单,注册。 因为RDMA硬件对用来做数据传输的内存是有特殊要求的。

  • 在数据传输过程中,应用程序不能修改数据所在的内存。
  • 操作系统不能对数据所在的内存进行page out操作 — 物理地址和虚拟地址的映射必须是固定不变的。

注意无论是DMA或者RDMA都要求物理地址连续,这是由DMA引擎所决定的。 那么怎么进行内存注册呢?

  • 创建两个key (local和remote)指向需要操作的内存区域
  • 注册的keys是数据传输请求的一部分

注册一个Memory Region之后,这个时候这个Memory Region也就有了它自己的属性:

  • context : RDMA操作上下文
  • addr : MR被注册的Buffer地址
  • length : MR被注册的Buffer长度
  • lkey:MR被注册的本地key
  • rkey:MR被注册的远程key

对Memrory Registration:Memory Registration只是RDMA中对内存保护的一种措施,只有将要操作的内存注册到RDMA Memory Region中,这快操作的内存就交给RDMA 保护域来操作了。这个时候我们就可以对这快内存进行操作,至于操作的起始地址、操作Buffer的长度,可以根据程序的具体需求进行操作。我们只要保证接受方的Buffer 接受的长度大于等于发送的Buffer长度。

Queues | 队列

RDMA一共支持三种队列,发送队列(SQ)和接收队列(RQ),完成队列(CQ)。其中,SQ和RQ通常成对创建,被称为Queue Pairs(QP)。

RDMA是基于消息的传输协议,数据传输都是异步操作。 RDMA操作其实很简单,可以理解为:

  • Host提交工作请求(WR)到工作队列(WQ): 工作队列包括发送队列(SQ)和接收队列(RQ)。工作队列的每一个元素叫做WQE, 也就是WR。
  • Host从完成队列(CQ)中获取工作完成(WC): 完成队列里的每一个叫做CQE, 也就是WC。
  • 具有RDMA引擎的硬件(hardware)就是一个队列元素处理器。 RDMA硬件不断地从工作队列(WQ)中去取工作请求(WR)来执行,执行完了就给完成队列(CQ)中放置工作完成(WC)。从生产者-消费者的角度理解就是:
  • Host生产WR, 把WR放到WQ中去
  • RDMA硬件消费WR
  • RDMA硬件生产WC, 把WC放到CQ中去
  • Host消费WC

RDMA操作细节

RDMA提供了基于消息队列的点对点通信,每个应用都可以直接获取自己的消息,无需操作系统和协议栈的介入。

消息服务建立在通信双方本端和远端应用之间创建的Channel-IO连接之上。当应用需要通信时,就会创建一条Channel连接,每条Channel的首尾端点是两对Queue Pairs(QP)。每对QP由Send Queue(SQ)和Receive Queue(RQ)构成,这些队列中管理着各种类型的消息。QP会被映射到应用的虚拟地址空间,使得应用直接通过它访问RNIC网卡。除了QP描述的两种基本队列之外,RDMA还提供一种队列Complete Queue(CQ),CQ用来知会用户WQ上的消息已经被处理完。

RDMA提供了一套软件传输接口,方便用户创建传输请求Work Request(WR),WR中描述了应用希望传输到Channel对端的消息内容,WR通知QP中的某个队列Work Queue(WQ)。在WQ中,用户的WR被转化为Work Queue Element(WQE)的格式,等待RNIC的异步调度解析,并从WQE指向的Buffer中拿到真正的消息发送到Channel对端。

RDAM单边操作 (RDMA READ)

READ和WRITE是单边操作,只需要本端明确信息的源和目的地址,远端应用不必感知此次通信,数据的读或写都通过RDMA在RNIC与应用Buffer之间完成,再由远端RNIC封装成消息返回到本端。

对于单边操作,以存储网络环境下的存储为例,数据的流程如下:

  1. 首先A、B建立连接,QP已经创建并且初始化。
  2. 数据被存档在B的buffer地址VB,注意VB应该提前注册到B的RNIC (并且它是一个Memory Region) ,并拿到返回的local key,相当于RDMA操作这块buffer的权限。
  3. B把数据地址VB,key封装到专用的报文传送到A,这相当于B把数据buffer的操作权交给了A。同时B在它的WQ中注册进一个WR,以用于接收数据传输的A返回的状态。
  4. A在收到B的送过来的数据VB和R_key后,RNIC会把它们连同自身存储地址VA到封装RDMA READ请求,将这个消息请求发送给B,这个过程A、B两端不需要任何软件参与,就可以将B的数据存储到A的VA虚拟地址。
  5. A在存储完成后,会向B返回整个数据传输的状态信息。

单边操作传输方式是RDMA与传统网络传输的最大不同,只需提供直接访问远程的虚拟地址,无须远程应用的参与其中,这种方式适用于批量数据传输。

RDMA 单边操作 (RDMA WRITE)

对于单边操作,以存储网络环境下的存储为例,数据的流程如下:

  1. 首先A、B建立连接,QP已经创建并且初始化。
  2. 数据remote目标存储buffer地址VB,注意VB应该提前注册到B的RNIC(并且它是一个Memory Region),并拿到返回的local key,相当于RDMA操作这块buffer的权限。
  3. B把数据地址VB,key封装到专用的报文传送到A,这相当于B把数据buffer的操作权交给了A。同时B在它的WQ中注册进一个WR,以用于接收数据传输的A返回的状态。
  4. A在收到B的送过来的数据VB和R_key后,RNIC会把它们连同自身发送地址VA到封装RDMA WRITE请求,这个过程A、B两端不需要任何软件参与,就可以将A的数据发送到B的VB虚拟地址。
  5. A在发送数据完成后,会向B返回整个数据传输的状态信息。

单边操作传输方式是RDMA与传统网络传输的最大不同,只需提供直接访问远程的虚拟地址,无须远程应用的参与其中,这种方式适用于批量数据传输。

RDMA 双边操作 (RDMA SEND/RECEIVE)

RDMA中SEND/RECEIVE是双边操作,即必须要远端的应用感知参与才能完成收发。在实际中,SEND/RECEIVE多用于连接控制类报文,而数据报文多是通过READ/WRITE来完成的。
对于双边操作为例,主机A向主机B(下面简称A、B)发送数据的流程如下:

  1. 首先,A和B都要创建并初始化好各自的QP,CQ
  2. A和B分别向自己的WQ中注册WQE,对于A,WQ=SQ,WQE描述指向一个等到被发送的数据;对于B,WQ=RQ,WQE描述指向一块用于存储数据的Buffer。
  3. A的RNIC异步调度轮到A的WQE,解析到这是一个SEND消息,从Buffer中直接向B发出数据。数据流到达B的RNIC后,B的WQE被消耗,并把数据直接存储到WQE指向的存储位置。
  4. AB通信完成后,A的CQ中会产生一个完成消息CQE表示发送完成。与此同时,B的CQ中也会产生一个完成消息表示接收完成。每个WQ中WQE的处理完成都会产生一个CQE。

双边操作与传统网络的底层Buffer Pool类似,收发双方的参与过程并无差别,区别在零拷贝、Kernel Bypass,实际上对于RDMA,这是一种复杂的消息传输模式,多用于传输短的控制消息。

RDMA数据传输

RDMA Send | RDMA发送(/接收)操作 (Send/Recv)

跟TCP/IP的send/recv是类似的,不同的是RDMA是基于消息的数据传输协议(而不是基于字节流的传输协议),所有数据包的组装都在RDMA硬件上完成的,也就是说OSI模型中的下面4层(传输层,网络层,数据链路层,物理层)都在RDMA硬件上完成。

RDMA Read | RDMA读操作 (Pull)

RDMA读操作本质上就是Pull操作, 把远程系统内存里的数据拉回到本地系统的内存里。

RDMA Write | RDMA写操作 (Push)

RDMA写操作本质上就是Push操作,把本地系统内存里的数据推送到远程系统的内存里。

RDMA Write with Immediate Data | 支持立即数的RDMA写操作

支持立即数的RDMA写操作本质上就是给远程系统Push(推送)带外(OOB)数据, 这跟TCP里的带外数据是类似的。

可选地,immediate 4字节值可以与数据缓冲器一起发送。 该值作为接收通知的一部分呈现给接收者,并且不包含在数据缓冲器中。

RDMA Send Receive操作

前言

RDMA指的是远程直接内存访问,这是一种通过网络在两个应用程序之间搬运缓冲区里的数据的方法。RDMA与传统的网络接口不同,因为它绕过了操作系统。这允许实现了RDMA的程序具有如下特点:

  • 绝对的最低时延
  • 最高的吞吐量
  • 最小的CPU足迹 (也就是说,需要CPU参与的地方被最小化)

RDMA Verbs操作

使用RDMA, 我们需要有一张实现了RDMA引擎的网卡。我们把这种卡称之为HCA(主机通道适配器)。 适配器创建一个贯穿PCIe总线的从RDMA引擎到应用程序内存的通道。一个好的HCA将在导线上执行的RDMA协议所需要的全部逻辑都在硬件上予以实现。这包括分组,重组以及流量控制和可靠性保证。因此,从应用程序的角度看,只负责处理所有缓冲区即可。

在RDMA中我们使用内核态驱动建立一个数据通道。我们称之为命令通道(Command Channel)。使用命令通道,我们能够建立一个数据通道(Data Channel),该通道允许我们在搬运数据的时候完全绕过内核。一旦建立了这种数据通道,我们就能直接读写数据缓冲区。

建立数据通道的API是一种称之为”verbs”的API。”verbs” API是由一个叫做OFED的Linux开源项目维护的。在站点http://www.openfabrics.org上,为Windows WinOF提供了一个等价的项目。”verbs” API跟你用过的socket编程API是不一样的。但是,一旦你掌握了一些概念后,就会变得非常容易,而且在设计你的程序的时候更简单。

Queue Pairs

RDMA操作开始于“搞”内存。当你在对内存进行操作的时候,就是告诉内核这段内存名花有主了,主人就是你的应用程序。于是,你告诉HCA,就在这段内存上寻址,赶紧准备开辟一条从HCA卡到这段内存的通道。我们将这一动作称之为注册一个内存区域(MR)。一旦MR注册完毕,我们就可以使用这段内存来做任何RDMA操作。在下面的图中,我们可以看到注册的内存区域(MR)和被通信队列所使用的位于内存区域之内的缓冲区(buffer)。

RDMA Memory Registration

1
2
3
4
5
6
7
8
9
struct ibv_mr {
struct ibv_context *context;
struct ibv_pd *pd;
void *addr;
size_t length;
uint32_t handle;
uint32_t lkey;
uint32_t rkey;
};

RDMA硬件不断地从工作队列(WQ)中去取工作请求(WR)来执行,执行完了就给完成队列(CQ)中放置工作完成通知(WC)。这个WC意思就是Work Completion。表示这个WR RDMA请求已经被处理完成,可以从这个Completion Queue从取出来,表示这个RDMA请求已经被处理完毕。

RDMA通信基于三条队列(SQ, RQ和CQ)组成的集合。 其中, 发送队列(SQ)和接收队列(RQ)负责调度工作,他们总是成对被创建,称之为队列对(QP)。当放置在工作队列上的指令被完成的时候,完成队列(CQ)用来发送通知。

当用户把指令放置到工作队列的时候,就意味着告诉HCA那些缓冲区需要被发送或者用来接受数据。这些指令是一些小的结构体,称之为工作请求(WR)或者工作队列元素(WQE)。 WQE的发音为”WOOKIE”,就像星球大战里的猛兽。一个WQE主要包含一个指向某个缓冲区的指针。一个放置在发送队列(SQ)里的WQE中包含一个指向待发送的消息的指针。一个放置在接受队列里的WQE里的指针指向一段缓冲区,该缓冲区用来存放待接受的消息。

下面我们来看一下RDMA中的Work Request(SendWR和ReceWR)

RDMA Send Work Request请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ibv_send_wr {
uint64_t wr_id;
struct ibv_send_wr *next;
struct ibv_sge *sg_list;
int num_sge;
enum ibv_wr_opcode opcode;
int send_flags;
uint32_t imm_data; /* in network byte order */
union {
struct {
uint64_t remote_addr;
uint32_t rkey;
} rdma;
struct {
uint64_t remote_addr;
uint64_t compare_add;
uint64_t swap;
uint32_t rkey;
} atomic;
struct {
struct ibv_ah *ah;
uint32_t remote_qpn;
uint32_t remote_qkey;
} ud;
} wr;
};

RDMA Receive Work Request请求

1
2
3
4
5
6
struct ibv_recv_wr {
uint64_t wr_id;
struct ibv_recv_wr *next;
struct ibv_sge *sg_list;
int num_sge;
};

RDMA是一种异步传输机制。因此我们可以一次性在工作队列里放置好多个发送或接收WQE。HCA将尽可能快地按顺序处理这些WQE。当一个WQE被处理了,那么数据就被搬运了。 一旦传输完成,HCA就创建一个完成队列元素(CQE)并放置到完成队列(CQ)中去。 相应地,CQE的发音为”COOKIE”。

RDMA Complete Queue Element

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct ibv_wc {
uint64_t wr_id;
enum ibv_wc_status status;
enum ibv_wc_opcode opcode;
uint32_t vendor_err;
uint32_t byte_len;
uint32_t imm_data; /* in network byte order */
uint32_t qp_num;
uint32_t src_qp;
int wc_flags;
uint16_t pkey_index;
uint16_t slid;
uint8_t sl;
uint8_t dlid_path_bits;
};

RDMA Send/Receive

让我们看个简单的例子。在这个例子中,我们将把一个缓冲区里的数据从系统A的内存中搬到系统B的内存中去。这就是我们所说的消息传递语义学。接下来我们要讲的一种操作为SEND,是RDMA中最基础的操作类型。

第一步

第1步:系统A和B都创建了他们各自的QP的完成队列(CQ), 并为即将进行的RDMA传输注册了相应的内存区域(MR)。 系统A识别了一段缓冲区,该缓冲区的数据将被搬运到系统B上。系统B分配了一段空的缓冲区,用来存放来自系统A发送的数据。

第二步

第二步:系统B创建一个WQE并放置到它的接收队列(RQ)中。这个WQE包含了一个指针,该指针指向的内存缓冲区用来存放接收到的数据。系统A也创建一个WQE并放置到它的发送队列(SQ)中去,该WQE中的指针执行一段内存缓冲区,该缓冲区的数据将要被传送。

第三步

第三步:系统A上的HCA总是在硬件上干活,看看发送队列里有没有WQE。HCA将消费掉来自系统A的WQE, 然后将内存区域里的数据变成数据流发送给系统B。当数据流开始到达系统B的时候,系统B上的HCA就消费来自系统B的WQE,然后将数据放到该放的缓冲区上去。在高速通道上传输的数据流完全绕过了操作系统内核。

第四步

第四步:当数据搬运完成的时候,HCA会创建一个CQE。 这个CQE被放置到完成队列(CQ)中,表明数据传输已经完成。HCA每消费掉一个WQE, 都会生成一个CQE。因此,在系统A的完成队列中放置一个CQE,意味着对应的WQE的发送操作已经完成。同理,在系统B的完成队列中也会放置一个CQE,表明对应的WQE的接收操作已经完成。如果发生错误,HCA依然会创建一个CQE。在CQE中,包含了一个用来记录传输状态的字段。

我们刚刚举例说明的是一个RDMA Send操作。在IB或RoCE中,传送一个小缓冲区里的数据耗费的总时间大约在1.3µs。通过同时创建很多WQE, 就能在1秒内传输存放在数百万个缓冲区里的数据。

RDMA单边通信

在 RDMA 传输中,SEND/RECEIVE 是双边操作,即需要通信双方的参与,并且 RECEIVE 要先于 SEND 执行,这样对方才能发送数据,当然如果对方不需要发送数据,可以不执行 RECEIVE 操作,因此该过程和传统通信相似,区别在于 RDMA 的零拷贝网络技术和内核旁路,延迟低,多用于传输短的控制消息。

WRITE/READ 是单边操作,顾名思义,读/写操作是一方在执行,在实际的通信过程中,WRITE/READ 操作是由客户端来执行的,而服务器端不需要执行任何操作。RDMA WRITE 操作中,由客户端把数据从本地 buffer 中直接 push 到远程 QP 的虚拟空间的连续内存块中(物理内存不一定连续),因此需要知道目的地址(remote addr)和访问权限(remote key)。RDMA READ 操作中,是客户端直接到远程的 QP 的虚拟空间的连续内存块中获取数据 pull 到本地目的 buffer 中,因此需要远程 QP 的内存地址和访问权限。单边操作多用于批量数据传输。

可以看出,在单边操作过程中,客户端需要知道远程 QP 的 remote addr 和 remote key,而这两个信息是可以通过 SEND/REVEIVE 操作来交换的。

RDMA 单边操作(RDMA READ)

READ 和 WRITE 是单边操作,只需要本端明确信息的源和目的地址,远端应用不必感知此次通信,数据的读或写都通过 RDMA 在网卡与应用 Buffer 之间完成,再由远端网卡封装成消息返回到本端。

对于单边操作,以存储网络环境下的存储为例,数据的流程如下:

  1. 首先 A、B 建立连接,QP 已经创建并且初始化。
  2. 数据被存档在 B 的 buffer 地址 VB,注意 VB 应该提前注册到 B 的网卡(并且它是一个 memory region),并拿到返回的 remote key,相当于 RDMA 操作这块 buffer 的权限。
  3. B 把数据地址 VB,key 封装到专用的报文传送到 A,这相当于 B 把数据 buffer 的操作权交给了 A。同时 B 在它的 WQ 中注册进一个 WR,以用于接收数据传输的 A 返回的状态。
  4. A 在收到 B 的送过来的数据 VB 和 remote key 后,网卡会把它们连同自身存储地址 VA 到封装 RDMA READ 请求,将这个消息请求发送给 B,这个过程 A、B 两端不需要任何软件参与,就可以将 B 的数据存储到 A 的 VA 虚拟地址。
  5. A 在存储完成后,会向 B 返回整个数据传输的状态信息。

单边操作传输方式是 RDMA 与传统网络传输的最大不同,只需提供直接访问远程的虚拟地址,无须远程应用参与其中,这种方式适用于批量数据传输。

RDMA 单边操作(RDMA WRITE)

对于单边操作,以存储网络环境下的存储为例,数据的流程如下:

  1. 首先 A、B 建立连接,QP 已经创建并且初始化。
  2. 数据 remote 目标存储 buffer 地址 VB,注意 VB 应该提前注册到 B 的网卡(并且它是一个 memory region),并拿到返回的 remote key,相当于 RDMA 操作这块 buffer 的权限。
  3. B 把数据地址 VB,key 封装到专用的报文传送到 A,这相当于 B 把数据 buffer 的操作权交给了 A。同时 B 在它的 WQ 中注册进一个 WR,以用于接收数据传输的 A 返回的状态。
  4. A 在收到 B 的送过来的数据 VB 和 remote key 后,网卡会把它们连同自身发送地址 VA 到封装 RDMA WRITE 请求,这个过程 A、B 两端不需要任何软件参与,就可以将 A 的数据发送到 B 的 VB 虚拟地址。
  5. A 在发送数据完成后,会向 B 返回整个数据传输的状态信息。

单边操作传输方式是 RDMA 与传统网络传输的最大不同,只需提供直接访问远程的虚拟地址,无须远程应用的参与其中,这种方式适用于批量数据传输。

RDMA 双边操作(RDMA SEND/RECEIVE)

RDMA 中 SEND/RECEIVE 是双边操作,即必须要远端的应用感知参与才能完成收发。在实际中,SEND/RECEIVE 多用于连接控制类报文,而数据报文多是通过 READ/WRITE 来完成的。

对于双边操作为例,主机 A 向主机 B(下面简称 A、B)发送数据的流程如下:

  1. 首先,A 和 B 都要创建并初始化好各自的 QP,CQ。
  2. A 和 B 分别向自己的 WQ 中注册 WQE,对于 A,WQ = SQ,WQE 描述指向一个等到被发送的数据;对于 B,WQ = RQ,WQE 描述指向一块用于存储数据的 Buffer。
  3. A 的网卡异步调度轮到 A 的 WQE,解析到这是一个 SEND 消息,从 buffer 中直接向 B 发出数据。数据流到达 B 的网卡后,B 的 WQE 被消耗,并把数据直接存储到 WQE 指向的存储位置。
  4. AB 通信完成后,A 的 CQ 中会产生一个完成消息 CQE 表示发送完成。与此同时,B 的 CQ 中也会产生一个完成消息表示接收完成。每个 WQ 中 WQE 的处理完成都会产生一个 CQE。

双边操作与传统网络的底层 Buffer Pool 类似,收发双方的参与过程并无差别,区别在零拷贝、kernel bypass,实际上对于 RDMA,这是一种复杂的消息传输模式,多用于传输短的控制消息。

总结

在这博客中,我们学习了如何使用RDMA verbs API。同时也介绍了队列的概念,而队列概念是RDMA编程的基础。最后,我们演示了RDMA send操作,展现了缓冲区的数据是如何在从一个系统搬运到另一个系统上去的。

理解RDMA SGL

前言

在使用RDMA操作之前,我们需要了解一些RDMA API中的一些需要的值。其中在ibv_send_wr我们需要一个sg_list的数组,sg_list是用来存放ibv_sge元素,那么什么是SGL以及什么是sge呢?对于一个使用RDMA进行开发的程序员来说,我们需要了解这一系列细节。

SGE简介

在NVMe over PCIe中,I/O命令支持SGL(Scatter Gather List 分散聚合表)和PRP(Physical Region Page 物理(内存)区域页), 而管理命令只支持PRP;而在NVMe over Fabrics中,无论是管理命令还是I/O命令都只支持SGL。

RDMA编程中,SGL(Scatter/Gather List)是最基本的数据组织形式。 SGL是一个数组,该数组中的元素被称之为SGE(Scatter/Gather Element),每一个SGE就是一个Data Segment(数据段)。RDMA支持Scatter/Gather操作,具体来讲就是RDMA可以支持一个连续的Buffer空间,进行Scatter分散到多个目的主机的不连续的Buffer空间。Gather指的就是多个不连续的Buffer空间,可以Gather到目的主机的一段连续的Buffer空间。

下面我们就来看一下ibv_sge的定义:

1
2
3
4
5
struct ibv_sge {
uint64_t addr;
uint32_t length;
uint32_t lkey;
};

  • addr: 数据段所在的虚拟内存的起始地址 (Virtual Address of the Data Segment (i.e. Buffer))
  • length: 数据段长度(Length of the Data Segment)
  • lkey: 该数据段对应的L_Key (Key of the local Memory Region)

ivc_post_send接口

而在数据传输中,发送/接收使用的Verbs API为:

ibv_post_send() - post a list of work requests (WRs) to a send queue 将一个WR列表放置到发送队列中 ibv_post_recv() - post a list of work requests (WRs) to a receive queue 将一个WR列表放置到接收队列中
下面以ibv_post_send()为例,说明SGL是如何被放置到RDMA硬件的线缆(Wire)上的。

ibv_post_send()的函数原型

1
2
3
4
5
#include <infiniband/verbs.h>

int ibv_post_send(struct ibv_qp *qp,
struct ibv_send_wr *wr,
struct ibv_send_wr **bad_wr);

ibv_post_send()将以send_wr开头的工作请求(WR)的列表发布到Queue Pair的Send Queue。 它会在第一次失败时停止处理此列表中的WR(可以在发布请求时立即检测到),并通过bad_wr返回此失败的WR。

参数wr是一个ibv_send_wr结构,如中所定义。

ibv_send_wr结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
struct ibv_send_wr {
uint64_t wr_id; /* User defined WR ID */
struct ibv_send_wr *next; /* Pointer to next WR in list, NULL if last WR */
struct ibv_sge *sg_list; /* Pointer to the s/g array */
int num_sge; /* Size of the s/g array */
enum ibv_wr_opcode opcode; /* Operation type */
int send_flags; /* Flags of the WR properties */
uint32_t imm_data; /* Immediate data (in network byte order) */
union {
struct {
uint64_t remote_addr; /* Start address of remote memory buffer */
uint32_t rkey; /* Key of the remote Memory Region */
} rdma;
struct {
uint64_t remote_addr; /* Start address of remote memory buffer */
uint64_t compare_add; /* Compare operand */
uint64_t swap; /* Swap operand */
uint32_t rkey; /* Key of the remote Memory Region */
} atomic;
struct {
struct ibv_ah *ah; /* Address handle (AH) for the remote node address */
uint32_t remote_qpn; /* QP number of the destination QP */
uint32_t remote_qkey; /* Q_Key number of the destination QP */
} ud;
} wr;
};

在调用ibv_post_send()之前,必须填充好数据结构wr。 wr是一个链表,每一个结点包含了一个sg_list(i.e. SGL: 由一个或多个SGE构成的数组), sg_list的长度为num_sge。

RDMA 提交WR流程

下面图解一下SGL和WR链表的对应关系,并说明一个SGL (struct ibv_sge *sg_list)里包含的多个数据段是如何被RDMA硬件聚合成一个连续的数据段的。

第一步:创建SGL

从上图中,我们可以看到wr链表中的每一个结点都包含了一个SGL,SGL是一个数组,包含一个或多个SGE。通过ibv_post_send提交一个RDMA SEND 请求。这个WR请求中,包括一个sg_list的元素。它是一个SGE链表,SGE指向具体需要发送数据的Buffer。

第二步:使用PD进行内存保护

我们在发送一段内存地址的时候,我们需要将这段内存地址通过Memory Registration注册到RDMA中。也就是说注册到PD内存保护域当中。一个SGL至少被一个MR保护, 多个MR存在同一个PD中。如图所示一段内存MR可以保护多个SGE元素。

调用ibv_post_send()将SGL发送到wire上去

在上图中,一个SGL数组包含了3个SGE, 长度分别为N1, N2, N3字节。我们可以看到,这3个buffer并不连续,它们Scatter(分散)在内存中的各个地方。RDMA硬件读取到SGL后,进行Gather(聚合)操作,于是在RDMA硬件的Wire上看到的就是N3+N2+N1个连续的字节。换句话说,通过使用SGL, 我们可以把分散(Scatter)在内存中的多个数据段(不连续)交给RDMA硬件去聚合(Gather)成连续的数据段。

RDMA服务器的代码流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
main()
{
1. rdma_create_event_channel
// 这一步是创建一个event channel,event channel是RDMA设备在操作完成后,或者有连接请求等事件发生时,用来通知应用程序的通道。其内部就是一个file descriptor, 因此可以进行poll等操作。

2. rdma_create_id(channel, **id,……)
// 这一步创建一个rdma_cm_id, 概念上等价与socket编程时的listen socket。

3. rdma_bind_addr(id,addr)
// 和socket编程一样,也要先绑定一个本地的地址和端口,以进行listen操作。

4. rdma_listen(id,block)
// 开始侦听客户端的连接请求

5. rdma_get_cm_event
// 这个调用就是作用在第一步创建的event channel上面,要从event channel中获取一个事件。这是个阻塞调用,只有有事件时才会返回。在一切正常的情况下,函数返回时会得到一个 RDMA_CM_EVENT_CONNECT_REQUEST事件,也就是说,有客户端发起连接了。
//在事件的参数里面,会有一个新的rdma_cm_id传入。这点和socket是不同的,socket只有在accept后才有新的socket fd创建。

on_event()
{
on_connect_request()//RDMA_CM_EVENT_CONNECT_REQUEST
{
build_context()
{
6.ibv_alloc_pd
// 创建一个protection domain。protection domain可以看作是一个内存保护单位,在内存区域和队列直接建立一个关联关系,防止未授权的访问。

7.ibv_create_comp_channel
// 和之前创建的event channel类似,这也是一个event channel,但只用来报告完成队列里面的事件。当完成队列里有新的任务完成时,就通过这个channel向应用程序报告。

8.ibv_create_cq
// 创建完成队列,创建时就指定使用第6步的channel。

}//--end build_context()

9.rdma_create_qp
// 创建一个queue pair, 一个queue pair包括一个发送queue和一个接收queue. 指定使用前面创建的cq作为完成队列。该qp创建时就指定关联到第6步创建的pd上。

10.ibv_reg_mr
// 注册内存区域。RDMA使用的内存,必须事先进行注册。这个是可以理解的,DMA的内存在边界对齐,能否被swap等方面,都有要求。

11.rdma_accept
// 至此,做好了全部的准备工作,可以调用accept接受客户端的这个请求了。
} //--end on_connect_request()

12.rdma_ack_cm_event
// 对于每个从event channel得到的事件,都要调用ack函数,否则会产生内存泄漏。这一步的ack是对应第5步的get。每一次get调用,都要有对应的ack调用。

13.rdma_get_cm_event
// 继续调用rdma_get_cm_event, 一切正常的话我们此时应该得到 RDMA_CM_EVENT_ESTABLISHED 事件,表示连接已经建立起来。不需要做额外的处理,直接rdma_ack_cm_event就行了
}//--end on_event()

// 当rdma_get_cm_event返回RDMA_CM_EVENT_DISCONNECTED事件时,表示客户端断开了连接,server端要进行对应的清理。此时可以调用rdma_ack_cm_event释放事件资源。然后依次调用下面的函数,释放连接资源,内存资源,队列资源。

rdma_disconnect

rdma_destroy_qp

ibv_dereg_mr

rdma_destroy_id
//释放同客户端连接的rdma_cm_id

rdma_destroy_id
// 释放用于侦听的rdma_cm_id

rdma_destroy_event_channel
// 释放 event channel

} // end main

服务端server.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <rdma/rdma_cma.h>

#define TEST_NZ(x) do { if ( (x)) die("error: " #x " failed (returned non-zero)." ); } while (0)
#define TEST_Z(x) do { if (!(x)) die("error: " #x " failed (returned zero/null)."); } while (0)

const int BUFFER_SIZE = 1024;

struct context {
struct ibv_context *ctx;
struct ibv_pd *pd;
struct ibv_cq *cq;
struct ibv_comp_channel *comp_channel;

pthread_t cq_poller_thread;
};

struct connection {
struct ibv_qp *qp;

struct ibv_mr *recv_mr;
struct ibv_mr *send_mr;

char *recv_region;
char *send_region;
};

static void die(const char *reason);

static void build_context(struct ibv_context *verbs);
static void build_qp_attr(struct ibv_qp_init_attr *qp_attr);
static void * poll_cq(void *);
static void post_receives(struct connection *conn);
static void register_memory(struct connection *conn);

static void on_completion(struct ibv_wc *wc);
static int on_connect_request(struct rdma_cm_id *id);
static int on_connection(void *context);
static int on_disconnect(struct rdma_cm_id *id);
static int on_event(struct rdma_cm_event *event);

static struct context *s_ctx = NULL;

int main(int argc, char **argv)
{
#if _USE_IPV6
struct sockaddr_in6 addr;
#else
struct sockaddr_in addr;
#endif
struct rdma_cm_event *event = NULL;
struct rdma_cm_id *listener = NULL;
struct rdma_event_channel *ec = NULL;
uint16_t port = 0;

memset(&addr, 0, sizeof(addr));
#if _USE_IPV6
addr.sin6_family = AF_INET6;
#else
addr.sin_family = AF_INET;
#endif

TEST_Z(ec = rdma_create_event_channel());
TEST_NZ(rdma_create_id(ec, &listener, NULL, RDMA_PS_TCP));
TEST_NZ(rdma_bind_addr(listener, (struct sockaddr *)&addr));
TEST_NZ(rdma_listen(listener, 10)); /* backlog=10 is arbitrary */

port = ntohs(rdma_get_src_port(listener)); //rdma_get_src_port 返回listener对应的tcp 端口

printf("listening on port %d.\n", port);

while (rdma_get_cm_event(ec, &event) == 0) {
struct rdma_cm_event event_copy;

memcpy(&event_copy, event, sizeof(*event));
rdma_ack_cm_event(event);

if (on_event(&event_copy))
break;
}

rdma_destroy_id(listener);
rdma_destroy_event_channel(ec);

return 0;
}

void die(const char *reason)
{
fprintf(stderr, "%s\n", reason);
exit(EXIT_FAILURE);
}

void build_context(struct ibv_context *verbs)
{
if (s_ctx) {
if (s_ctx->ctx != verbs)
die("cannot handle events in more than one context.");

return;
}

s_ctx = (struct context *)malloc(sizeof(struct context));

s_ctx->ctx = verbs;

TEST_Z(s_ctx->pd = ibv_alloc_pd(s_ctx->ctx));
TEST_Z(s_ctx->comp_channel = ibv_create_comp_channel(s_ctx->ctx));
TEST_Z(s_ctx->cq = ibv_create_cq(s_ctx->ctx, 10, NULL, s_ctx->comp_channel, 0)); /* cqe=10 is arbitrary */
TEST_NZ(ibv_req_notify_cq(s_ctx->cq, 0)); #完成完成队列与完成通道的关联

TEST_NZ(pthread_create(&s_ctx->cq_poller_thread, NULL, poll_cq, NULL));
}

void build_qp_attr(struct ibv_qp_init_attr *qp_attr)
{
memset(qp_attr, 0, sizeof(*qp_attr));

qp_attr->send_cq = s_ctx->cq;
qp_attr->recv_cq = s_ctx->cq;
qp_attr->qp_type = IBV_QPT_RC;

qp_attr->cap.max_send_wr = 10;
qp_attr->cap.max_recv_wr = 10;
qp_attr->cap.max_send_sge = 1;
qp_attr->cap.max_recv_sge = 1;
}

void * poll_cq(void *ctx)
{
struct ibv_cq *cq;
struct ibv_wc wc;

while (1) {
TEST_NZ(ibv_get_cq_event(s_ctx->comp_channel, &cq, &ctx));
ibv_ack_cq_events(cq, 1);
TEST_NZ(ibv_req_notify_cq(cq, 0));

while (ibv_poll_cq(cq, 1, &wc))
on_completion(&wc);
}

return NULL;
}

void post_receives(struct connection *conn)
{
struct ibv_recv_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

wr.wr_id = (uintptr_t)conn;
wr.next = NULL;
wr.sg_list = &sge;
wr.num_sge = 1;

sge.addr = (uintptr_t)conn->recv_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->recv_mr->lkey;

TEST_NZ(ibv_post_recv(conn->qp, &wr, &bad_wr));
}

void register_memory(struct connection *conn)
{
conn->send_region = malloc(BUFFER_SIZE);
conn->recv_region = malloc(BUFFER_SIZE);

TEST_Z(conn->send_mr = ibv_reg_mr(
s_ctx->pd,
conn->send_region,
BUFFER_SIZE,
0));

TEST_Z(conn->recv_mr = ibv_reg_mr(
s_ctx->pd,
conn->recv_region,
BUFFER_SIZE,
IBV_ACCESS_LOCAL_WRITE));
}

void on_completion(struct ibv_wc *wc)
{
if (wc->status != IBV_WC_SUCCESS)
die("on_completion: status is not IBV_WC_SUCCESS.");

if (wc->opcode & IBV_WC_RECV) {
struct connection *conn = (struct connection *)(uintptr_t)wc->wr_id;

printf("received message: %s\n", conn->recv_region);

} else if (wc->opcode == IBV_WC_SEND) {
printf("send completed successfully.\n");
}
}

int on_connect_request(struct rdma_cm_id *id)
{
struct ibv_qp_init_attr qp_attr;
struct rdma_conn_param cm_params;
struct connection *conn;

printf("received connection request.\n");

build_context(id->verbs);
build_qp_attr(&qp_attr);

TEST_NZ(rdma_create_qp(id, s_ctx->pd, &qp_attr));

id->context = conn = (struct connection *)malloc(sizeof(struct connection));
conn->qp = id->qp;

register_memory(conn);
post_receives(conn);

memset(&cm_params, 0, sizeof(cm_params));
TEST_NZ(rdma_accept(id, &cm_params));

return 0;
}

int on_connection(void *context)
{
struct connection *conn = (struct connection *)context;
struct ibv_send_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

snprintf(conn->send_region, BUFFER_SIZE, "message from passive/server side with pid %d", getpid());

printf("connected. posting send...\n");

memset(&wr, 0, sizeof(wr));

wr.opcode = IBV_WR_SEND;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.send_flags = IBV_SEND_SIGNALED;

sge.addr = (uintptr_t)conn->send_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->send_mr->lkey;

TEST_NZ(ibv_post_send(conn->qp, &wr, &bad_wr));

return 0;
}

int on_disconnect(struct rdma_cm_id *id)
{
struct connection *conn = (struct connection *)id->context;

printf("peer disconnected.\n");

rdma_destroy_qp(id);

ibv_dereg_mr(conn->send_mr);
ibv_dereg_mr(conn->recv_mr);

free(conn->send_region);
free(conn->recv_region);

free(conn);

rdma_destroy_id(id);

return 0;
}

int on_event(struct rdma_cm_event *event)
{
int r = 0;

if (event->event == RDMA_CM_EVENT_CONNECT_REQUEST)
r = on_connect_request(event->id);
else if (event->event == RDMA_CM_EVENT_ESTABLISHED)
r = on_connection(event->id->context);
else if (event->event == RDMA_CM_EVENT_DISCONNECTED)
r = on_disconnect(event->id);
else
die("on_event: unknown event.");

return r;
}

客户端client.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
#include <netdb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <rdma/rdma_cma.h>

#define TEST_NZ(x) do { if ( (x)) die("error: " #x " failed (returned non-zero)." ); } while (0)
#define TEST_Z(x) do { if (!(x)) die("error: " #x " failed (returned zero/null)."); } while (0)

const int BUFFER_SIZE = 1024;
const int TIMEOUT_IN_MS = 500; /* ms */

struct context {
struct ibv_context *ctx;
struct ibv_pd *pd;
struct ibv_cq *cq;
struct ibv_comp_channel *comp_channel;

pthread_t cq_poller_thread;
};

struct connection {
struct rdma_cm_id *id;
struct ibv_qp *qp;

struct ibv_mr *recv_mr;
struct ibv_mr *send_mr;

char *recv_region;
char *send_region;

int num_completions;
};

static void die(const char *reason);

static void build_context(struct ibv_context *verbs);
static void build_qp_attr(struct ibv_qp_init_attr *qp_attr);
static void * poll_cq(void *);
static void post_receives(struct connection *conn);
static void register_memory(struct connection *conn);

static int on_addr_resolved(struct rdma_cm_id *id);
static void on_completion(struct ibv_wc *wc);
static int on_connection(void *context);
static int on_disconnect(struct rdma_cm_id *id);
static int on_event(struct rdma_cm_event *event);
static int on_route_resolved(struct rdma_cm_id *id);

static struct context *s_ctx = NULL;

int main(int argc, char **argv)
{
struct addrinfo *addr;
struct rdma_cm_event *event = NULL;
struct rdma_cm_id *conn= NULL;
struct rdma_event_channel *ec = NULL;

if (argc != 3)
die("usage: client <server-address> <server-port>");

TEST_NZ(getaddrinfo(argv[1], argv[2], NULL, &addr));

TEST_Z(ec = rdma_create_event_channel());
TEST_NZ(rdma_create_id(ec, &conn, NULL, RDMA_PS_TCP));
TEST_NZ(rdma_resolve_addr(conn, NULL, addr->ai_addr, TIMEOUT_IN_MS));

freeaddrinfo(addr);

while (rdma_get_cm_event(ec, &event) == 0) {
struct rdma_cm_event event_copy;

memcpy(&event_copy, event, sizeof(*event));
rdma_ack_cm_event(event);

if (on_event(&event_copy))
break;
}

rdma_destroy_event_channel(ec);

return 0;
}

void die(const char *reason)
{
fprintf(stderr, "%s\n", reason);
exit(EXIT_FAILURE);
}

void build_context(struct ibv_context *verbs)
{
if (s_ctx) {
if (s_ctx->ctx != verbs)
die("cannot handle events in more than one context.");

return;
}

s_ctx = (struct context *)malloc(sizeof(struct context));

s_ctx->ctx = verbs;

TEST_Z(s_ctx->pd = ibv_alloc_pd(s_ctx->ctx));
TEST_Z(s_ctx->comp_channel = ibv_create_comp_channel(s_ctx->ctx));
TEST_Z(s_ctx->cq = ibv_create_cq(s_ctx->ctx, 10, NULL, s_ctx->comp_channel, 0)); /* cqe=10 is arbitrary */
TEST_NZ(ibv_req_notify_cq(s_ctx->cq, 0));

TEST_NZ(pthread_create(&s_ctx->cq_poller_thread, NULL, poll_cq, NULL));
}

void build_qp_attr(struct ibv_qp_init_attr *qp_attr)
{
memset(qp_attr, 0, sizeof(*qp_attr));

qp_attr->send_cq = s_ctx->cq;
qp_attr->recv_cq = s_ctx->cq;
qp_attr->qp_type = IBV_QPT_RC;

qp_attr->cap.max_send_wr = 10;
qp_attr->cap.max_recv_wr = 10;
qp_attr->cap.max_send_sge = 1;
qp_attr->cap.max_recv_sge = 1;
}

void * poll_cq(void *ctx)
{
struct ibv_cq *cq;
struct ibv_wc wc;

while (1) {
TEST_NZ(ibv_get_cq_event(s_ctx->comp_channel, &cq, &ctx));
ibv_ack_cq_events(cq, 1);
TEST_NZ(ibv_req_notify_cq(cq, 0));

while (ibv_poll_cq(cq, 1, &wc))
on_completion(&wc);
}

return NULL;
}

void post_receives(struct connection *conn)
{
struct ibv_recv_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

wr.wr_id = (uintptr_t)conn;
wr.next = NULL;
wr.sg_list = &sge;
wr.num_sge = 1;

sge.addr = (uintptr_t)conn->recv_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->recv_mr->lkey;

TEST_NZ(ibv_post_recv(conn->qp, &wr, &bad_wr));
}

void register_memory(struct connection *conn)
{
conn->send_region = malloc(BUFFER_SIZE);
conn->recv_region = malloc(BUFFER_SIZE);

TEST_Z(conn->send_mr = ibv_reg_mr(
s_ctx->pd,
conn->send_region,
BUFFER_SIZE,
0));

TEST_Z(conn->recv_mr = ibv_reg_mr(
s_ctx->pd,
conn->recv_region,
BUFFER_SIZE,
IBV_ACCESS_LOCAL_WRITE));
}

int on_addr_resolved(struct rdma_cm_id *id)
{
struct ibv_qp_init_attr qp_attr;
struct connection *conn;

printf("address resolved.\n");

build_context(id->verbs);
build_qp_attr(&qp_attr);

TEST_NZ(rdma_create_qp(id, s_ctx->pd, &qp_attr));

id->context = conn = (struct connection *)malloc(sizeof(struct connection));

conn->id = id;
conn->qp = id->qp;
conn->num_completions = 0;

register_memory(conn);
post_receives(conn);

TEST_NZ(rdma_resolve_route(id, TIMEOUT_IN_MS));

return 0;
}

void on_completion(struct ibv_wc *wc)
{
struct connection *conn = (struct connection *)(uintptr_t)wc->wr_id;

if (wc->status != IBV_WC_SUCCESS)
die("on_completion: status is not IBV_WC_SUCCESS.");

if (wc->opcode & IBV_WC_RECV)
printf("received message: %s\n", conn->recv_region);
else if (wc->opcode == IBV_WC_SEND)
printf("send completed successfully.\n");
else
die("on_completion: completion isn't a send or a receive.");

if (++conn->num_completions == 2)
rdma_disconnect(conn->id);
}

int on_connection(void *context)
{
struct connection *conn = (struct connection *)context;
struct ibv_send_wr wr, *bad_wr = NULL;
struct ibv_sge sge;

snprintf(conn->send_region, BUFFER_SIZE, "message from active/client side with pid %d", getpid());

printf("connected. posting send...\n");

memset(&wr, 0, sizeof(wr));

wr.wr_id = (uintptr_t)conn;
wr.opcode = IBV_WR_SEND;
wr.sg_list = &sge;
wr.num_sge = 1;
wr.send_flags = IBV_SEND_SIGNALED;

sge.addr = (uintptr_t)conn->send_region;
sge.length = BUFFER_SIZE;
sge.lkey = conn->send_mr->lkey;

TEST_NZ(ibv_post_send(conn->qp, &wr, &bad_wr));

return 0;
}

int on_disconnect(struct rdma_cm_id *id)
{
struct connection *conn = (struct connection *)id->context;

printf("disconnected.\n");

rdma_destroy_qp(id);

ibv_dereg_mr(conn->send_mr);
ibv_dereg_mr(conn->recv_mr);

free(conn->send_region);
free(conn->recv_region);

free(conn);

rdma_destroy_id(id);

return 1; /* exit event loop */
}

int on_event(struct rdma_cm_event *event)
{
int r = 0;

if (event->event == RDMA_CM_EVENT_ADDR_RESOLVED)
r = on_addr_resolved(event->id);
else if (event->event == RDMA_CM_EVENT_ROUTE_RESOLVED)
r = on_route_resolved(event->id);
else if (event->event == RDMA_CM_EVENT_ESTABLISHED)
r = on_connection(event->id->context);
else if (event->event == RDMA_CM_EVENT_DISCONNECTED)
r = on_disconnect(event->id);
else
die("on_event: unknown event.");

return r;
}

int on_route_resolved(struct rdma_cm_id *id)
{
struct rdma_conn_param cm_params;

printf("route resolved.\n");

memset(&cm_params, 0, sizeof(cm_params));
TEST_NZ(rdma_connect(id, &cm_params));

return 0;
}

走进Linux

走进Linux内核

Linux的内核包含五大部分内容:进程调度、内存管理、进程间通信、虚拟文件系统及网络接口这五部分,我们也称为五个子系统。

Linux内核的特征

Linux内核具有下列基本特征。

  1. Linux内核的组织形式为整体式结构。也就是说整个Linux内核由很多过程组成,每个过程可以独立编译,然后用连接程序将其连接在一起成为一个单独的目标程序。
  2. Linux的进程调度方式简单而有效。对于用户进程,Linux采用简单的动态优先级调度方式;对于内核中的例程则采用了一种独特的机制——软中断机制,这种机制保证了内核例程的高效运行。
  3. Linux支持内核线程(或称守护进程)。内核线程是在后台运行而又无终端或登录shell和它结合在一起的进程。内核线程可以说是用户进程,但和一般的用户进程又有不同,它像内核一样不被换出,因此运行效率较高。
  4. Linux支持多种平台的虚拟内存管理。为了支持不同的硬件平台而又保证虚拟存储管理技术的通用性,Linux的虚拟内存管理为不同的硬件平台提供了统一的接口。
  5. Linux内核另一个独具特色的部分是虚拟文件系统(VFS)。
  6. Linux的模块机制使得内核保持独立而又易于扩充。模块机制可以使内核很容易地增加一个新的模块(如一个新的设备驱动程序),而无需重新编译内核
  7. 增加系统调用以满足特殊的需求。Linux开放的源代码也允许你设计自己的系统调用,然后把它加入到内核。
  8. 网络部分面向对象的设计思想使得Linux内核支持多种协议、多种网卡驱动程序变得容易。

Linux内核源代码

Linux内核源代码的结构

Linux内核源代码位于/usr/src/linux目录下,每一个目录或子目录可以看作一个模块,下面是对每一个目录的简单描述。

  • include/目录包含了建立内核代码时所需的大部分包含文件,这个模块利用其他模块重建内核。
  • init/子目录包含了内核的初始化代码,这是内核开始工作的起点。
  • arch/子目录包含了所有硬件结构特定的内核代码,arch/子目录下有i386和 alpha模块等。
  • drivers/目录包含了内核中所有的设备驱动程序,如块设备,scsi设备驱动程序等。
  • fs/目录包含了所有文件系统的代码,如:ext2,vfat模块的代码等。
  • net/目录包含了内核的连网代码。
  • mm/目录包含了所有的内存管理代码。
  • ipc/目录包含了进程间通信的代码。
  • kernel/目录包含了主内核代码。

图1.3显示了8 个目录,即initkernelmmipcdriversfsarchnet的包含文件都在include/目录下。在Linux内核中包含了driversfsarchnet模块,这就使得Linux内核既不是一个层次式结构,也不是一个微内核结构,而是一个“整体式” 结构。因为系统调用可以直接调用内核层,因此,该结构使得整个系统具有较高的性能,其缺点是内核修改起来比较困难,除非遵循严格的规则和编码标准。

Linux运行的硬件基础

i386的寄存器

80386作为80X86系列中的一员,必须保证向后兼容,也就是说,既要支持16位的处理器,又要支持32位的处理器。在8086中,所有的寄存器都是16位的,下面我们来看一下780386中寄存器有何变化。

  • 把16位的通用寄存器、标志寄存器以及指令指针寄存器扩充为32位的寄存器
  • 段寄存器仍然为16位。
  • 增加4 个32位的控制寄存器。
  • 增加4 个系统地址寄存器。
  • 增加8 个调式寄存器。
  • 增加2 个测试寄存器。

通用寄存器

8个通用寄存器是8086寄存器的超集,它们的名称和用途分别为:

  • EAX:一般用作累加器。
  • EBX:一般用作基址寄存器(Base)。
  • ECX:一般用来计数(Count)。
  • EDX:一般用来存放数据(Data)。
  • EBP:一般用作堆栈指针(StackPointer)。
  • EBP:一般用作基址指针(BasePointer)。
  • ESI:一般用作源变址(SourceIndex)。
  • EDI:一般用作目标变址(DestinatinIndex)。

8个通用寄存器中通常保存32位数据,但为了进行16位的操作并与16位机保持兼容,它们的低位部分被当成8 个16位的寄存器,即AX、BX⋯⋯DI。为了支持8 位的操作,还进一步把EAX、EBX、ECX、EDX这 4个寄存器低位部分的16位,再分为8 位一组的高位字节和低位字节两部分,作为8 个8 位寄存器。这8 个寄存器分别被命名为AH、BH、CH、DH和 AL、BL、CL、DL。对8 位或16位寄存器的操作只影响相应的寄存器。例如,在做8 位加法运算时, 位7 的进位并不传给目的寄存器的位9,而是把标志寄存器中的进位标志(CF)置位。因此, 这8 个通用寄存器既可以支持1 位、8位、16位和32位数据运算,也支持16位和32位存储器寻址。

段寄存器

8086中有4 个16位的段寄存器:CS、DS、SS、ES,分别用于存放可执行代码的代码段、 数据段、堆栈段和其他段的基地址。在80386中,有6 个16位的段寄存器,但是,这些段寄存器中存放的不再是某个段的基地址,而是某个段的选择符(Selector)。因为16位的寄存器无法存放32位的段基地址,段基地址只好存放在一个叫做描述符表(Descriptor)的表中。 因此,在80386中,我们把段寄存器叫做选择符。下面给出6 个段寄存器的名称和用途。

  • CS:代码段寄存器。
  • DS:数据段寄存器。
  • SS:堆栈段寄存器。
  • ES、FS及 GS:附加数据段寄存器。

状态和控制寄存器

状态和控制寄存器是由标志寄存器(EFLAGS)、指令指针(EIP)和4 个控制寄存器组成。

指令指针寄存器和标志寄存器:指令指针寄存器(EIP)中存放下一条将要执行指令的偏移量(offset),这个偏移量是相对于目前正在运行的代码段寄存器(CS)而言的。偏移量加上当前代码段的基地址,就形成了下一条指令的地址。EIP中的低16位可以分开来进行访问,给它起名叫指令指针IP寄存器,用于16位寻址。

标志寄存器(EFLAGS)存放有关处理器的控制标志,如图所示。标志寄存器中的第1、3、5、15位及18到31位都没有定义。

第8 位TF(Trap Flag)是自陷标志,当将其置1 时则可以进行单步执行。当指令执行完后,就可能产生异常1 的自陷。也就是说,在程序的执行过程中,每执行完一条指令,都要由异常1 处理程序进行检验。当将第8 位清0 后,且将断点地址装入调试寄存器DR0~DR3时,才会产生异常1 的自陷。

第12、13位IOPL是输入输出特权级位,这是保护模式下要使用的两个标志位。由于输入输出特权级标志共两位,它的取值范围只可能是0、1、2和3共4 个值,恰好与输入输出特权级0~3级相对应。但Linux内核只使用了两个级别,即0 和3 级,0表示内核级,3表示用户级。在当前任务的特权级CPL(Current Privilege Level)高于或等于输入输出特权级时,就可以执行像IN、OUT、INS、OUTS、STI、CLI和 LOCK等指令而不会产生异常13(即保护异常)。在当前任务特权级CPL为 0时,POPF(从栈中弹出至标志位)指令和中断返回指令IRET可以改变IOPL字段的值。

第9 位IF(Interrupt Flag)是中断标志位,是用来表示允许或者禁止外部中断。若第9位IF被置为1,则允许CPU接收外部中断请求信号;若将IF位清0,则表示禁止外部中断。在保护模式下,只有当第12、13位指出当前CPL为最高特权级时,才允许将新值置入标志寄存器(EFLAGS)以改变IF位的值。

第10位DF(Direction Flag)是定向标志。DF位规定了在执行串操作的过程中,对源变址寄存器ESI或目标变址寄存器EDI是增值还是减值。如果DF为 1,则寄存器减值;若DF为 0,则寄存器值增加。

第14位 NT是嵌套任务标志位。在保护模式下常使用这个标志。当80386在发生中断和执行CALL指令时就有可能引起任务切换。若是由于中断或由于执行CALL指令而出现了任务切换,则将NT置为1。若没有任务切换,则将NT位清0。

第17位 VM(Virtual 8086Mode Flag)是虚拟8086方式标志,是80386新设置的一个标志位。表示80386 CPU是在虚拟8086环境中运行。如果80386 CPU是在保护模式下运行, 而VM为又被置成1,这时80386就转换成虚拟8086操作方式,使全部段操作就像是在8086 CPU上运行一样。VM位只能由两种方式中的一种方式给予设置,即或者是在保护模式下,由最高特权级(0)级代码段的中断返回指令IRET设置,或者是由任务转换进行设置。

控制寄存器

状态和控制寄存器组除了EFLAGS、EIP,还有4 个32位的控制寄存器,它们是CR0,CR1、CR2和 CR3。

这几个寄存器中保存全局性和任务无关的机器状态。

CR0中包含了6 个预定义标志,

  • 0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
  • 1位是监控协处理位MP(Moniter Coprocessor),它与第3 位一起决定:当TS=1时操作码WAIT是否产生一个“协处理器不能使用”的出错信号。
  • 3位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置1。随着TS=1,就不能使用协处理器。
  • 第2位是模拟协处理器位EM (Emulate Coprocessor),如果EM=1,则不能使用协处理器,如果EM=0,则允许使用协处理器。
  • 第4位是微处理器的扩展类型位ET(Processor Extension Type),其内保存着处理器扩展类型的信息,如果ET=0,则标识系统使用的是287协处理器,如果ET=1,则表示系统使用的是387浮点协处理器。
  • CR0的第31位是分页允许位(Paging Enable), 它表示芯片上的分页部件是否允许工作。

PG位和PE位定义的操作方式如表所示。

PG PE 方式
0 0 实模式
0 1 保护模式,但不允许分页
1 0 出错
1 1 允许分页的保护模式
  • CR1是未定义的控制寄存器,供将来的处理器使用。
  • CR2是页故障线性地址寄存器,保存最后一次出现页故障的全32位线性地址。
  • CR3是页目录基址寄存器,保存页目录表的物理地址。页目录表总是放在以4KB为单位的存储器边界上,因此,它的地址的低12位总为0,不起作用,即使写上内容,也不会被理会。

系统地址寄存器

80386有 4个系统地址寄存器,如图所示,它保存操作系统要保护的信息和地址转换表信息。这4 个专用寄存器用于引用在保护模式下所需要的表和段,它们的名称和作用如下。

  • 全局描述符表寄存器GDTR(Global Descriptor Table Register ),是48位寄存器, 用来保存全局描述符表(GDT)的32位基地址和16位 GDT的界限。
  • 中断描述符表寄存器IDTR(Interrupt Descriptor Table Register),是48位寄存器,用来保存中断描述符表(IDT)的32位基地址和16位 IDT的界限。
  • 局部描述符表寄存器LDTR(Global Descriptor Table Register ),是16位寄存器,保存局部描述符表LDT段的选择符。
  • 任务状态寄存器TR(TaskState Register)是16位寄存器,用于保存任务状态段TSS段的16位选择符。

用以上4 个寄存器给目前正在执行的任务(或进程)定义任务环境、地址空间和中断向量空间。

调试寄存器和测试寄存器

调试寄存器

80386为调试提供了硬件支撑。在80386芯片内有8 个32位的调试寄存器DR0~DR7,如图所示。

这些寄存器可以使系统程序设计人员定义4 个断点,用它们可以规定指令执行和数据读写的任何组合。DR0~DR3是线性断点地址寄存器,其中保存着4 个断点地址。DR4、DR5是两个备用的调试寄存器,目前尚未定义。DR6是断点状态寄存器,其低序位是指示符位,

当允许故障调试并检查出故障而进入异常调试处理程序(debug())时,由硬件把指示符位置1,调试异常处理程序在退出之前必须把这几位清0。DR7是断点控制寄存器,它的高序半个字又被分为4 个字段,用来规定断点字段的长度是1 个字节、2个字节、4个字节及规定将引起断点的访问类型。低序半个字的位字段用于“允许”断点和“允许”所选择的调试条件。

测试寄存器

80386有两个32位的测试寄存器TR6和 TR7。这两个寄存器用于在转换旁路缓冲器 (Translation Lookaside Buffer)中测试随机存储器(RAM)和相联存储器(CAM)。TR6是测试命令寄存器,其内存放测试控制命令。TR7是数据寄存器,其内保存转换旁路缓冲器测试的数据。

内存地址

在任何一台计算机上,都存在一个程序能产生的内存地址的集合。当程序执行这样一条指令时:

1
MOVE REG, ADDR

它把地址为ADDR(假设为10000)的内存单元的内容复制到REG中,地址ADDR可以通过索引、基址寄存器、段寄存器和其他方式产生。

在8086的实模式下,把某一段寄存器左移4 位,然后与地址ADDR相加后被直接送到内存总线上,这个相加后的地址就是内存单元的物理地址,而程序中的这个地址就叫逻辑地址 (或叫虚地址)。在80386的保护模式下,这个逻辑地址不是被直接送到内存总线,而是被送到内存管理单元(MMU)。MMU由一个或一组芯片组成,其功能是把逻辑地址映射为物理地址, 即进行地址转换,如图所示。

当使用80386时,我们必须区分以下3 种不同的地址。

  • 逻辑地址:机器语言指令仍用这种地址指定一个操作数的地址或一条指令的地址。这种寻址方式在Intel的分段结构中表现得尤为具体,它使得MS-DOS或 Windows程序员把程序分为若干段。每个逻辑地址都由一个段和偏移量组成。
  • 线性地址:线性地址是一个32位的无符号整数,可以表达高达232(4GB)的地址。通常用16进制表示线性地址,其取值范围为0x00000000~0xffffffff。
  • 物理地址:物理地址是内存单元的实际地址,用于芯片级内存单元寻址。物理地址也由32位无符号整数表示。

MMU是一种硬件电路,它包含两个部件,一个是分段部件,一个是分页部件,在本书中,我们把它们分别叫做分段机制和分页机制,以利于从逻辑的角度来理解硬件的实现机制。分段机制把一个逻辑地址转换为线性地址;接着,分页机制把一个线性地址转换为物理地址,如图2.8所示。

段机制和描述符

段机制

在80386的段机制中,逻辑地址由两部分组成,即段部分(选择符)及偏移部分。

段是形成逻辑地址到线性地址转换的基础。如果我们把段看成一个对象的话,那么对它的描述如下。

  1. 段的基地址(Base Address):在线性地址空间中段的起始地址。
  2. 段的界限(Limit):表示在逻辑地址中,段内可以使用的最大偏移量。
  3. 段的属性(Attribute):表示段的特性。例如,该段是否可被读出或写入,或者该段是否作为一个程序来执行,以及段的特权级等。

段的界限定义逻辑地址空间中段的大小。段内在偏移量从0 到limit范围内的逻辑地址,对应于从Base到Base+Limit范围内的线性地址。在一个段内,偏移量大于段界限的逻辑地址将没有意义,使用这样的逻辑地址,系统将产生异常。另外,如果要对一个段进行访问,系统会根据段的属性检查访问者是否具有访问权限,如果没有,则产生异常。例如,在80386中,如果要在只读段中进行写入,80386将根据该段的属性检测到这是一种违规操作,则产生异常。

图表示一个段如何从逻辑地址空间,重新定位到线性地址空间。图的左侧表示逻辑地址空间,定义了A、B及 C三个段,段容量分别为LimitA、LimitB及 LimitC。图中虚线把逻辑地址空间中的段A、B及 C与线性地址空间区域连接起来表示了这种转换。

段的基地址、界限及保护属性,存储在段的描述符表中,在逻辑—线性地址转换过程中要对描述符进行访问。段描述符又存储在存储器的段描述符表中,该描述符表是段描述符的一个数组。

描述符的概念

所谓描述符(Descriptor),就是描述段的属性的一个8 字节存储单元。在实模式下,段的属性不外乎是代码段、堆栈段、数据段、段的起始地址、段的长度等,而在保护模式下则复杂一些。80386将它们结合在一起用一个8 字节的数表示,称为描述符。80386的一个通用的段描述符的结构如图所示。从图可以看出,一个段描述符指出了段的32位基地址和20位段界限(即段长)。

第6 个字节的G 位是粒度位,当G=0时,段长表示段格式的字节长度,即一个段最长可达 1M字节。当G=1时,段长表示段的以4K字节为一页的页的数目,即一个段最长可达1M×4K=4G字节。D位表示缺省操作数的大小,如果D=0,操作数为16位,如果D=1,操作数为 32位。第6 个字节的其余两位为0,这是为了与将来的处理器兼容而必须设置为0 的位。

第5 个字节是存取权字节,它的一般格式如下所示。

1
2
7 6 5 4 3 2 1 0
P DPL S 类 型 A

  • 第7 位P 位(Present) 是存在位,表示段描述符描述的这个段是否在内存中,如果在内存中。P=1;如果不在内存中,P=0。
  • DPL(Descriptor Privilege Level),就是描述符特权级,它占两位,其值为0~3, 用来确定这个段的特权级即保护等级。
  • S位(System)表示这个段是系统段还是用户段。如果S=0,则为系统段,如果S=1,则为用户程序的代码段、数据段或堆栈段。系统段与用户段有很大的不同,后面会具体介绍。
  • 类型占3 位,第3 位为E 位,表示段是否可执行。当E=0时,为数据段描述符,这时的第 2位 ED表示扩展方向。当ED=0时,为向地址增大的方向扩展,这时存取数据段中的数据的偏移量必须小于或等于段界限,当ED=1时,表示向地址减少的方向扩展,这时偏移量必须大于界限。当表示数据段时,第1 位(W)是可写位,当W=0时,数据段不能写,W=1时,数据段可写入。在80386中,堆栈段也被看成数据段,因为它本质上就是特殊的数据段。当描述堆栈段时,ED=0,W=1,即堆栈段朝地址增大的方向扩展。

也就是说,当段为数据段时,存取权字节的格式如图所示。

1
2
7 6 5 4 3 2  1 0
P DPL 1 0 ED W A

当段为代码段时,第3 位E=1,这时第2 位为一致位(C)。当C=1时,如果当前特权级低于描述符特权级,并且当前特权级保持不变,那么代码段只能执行。所谓当前特权级 (Current Privilege Level),就是当前正在执行的任务的特权级。第1 位为可读位R,当R=0时,代码段不能读,当R=1时可读。也就是说,当段为代码段时,存取权字节的格式如

1
2
7 6 5 4 3 2 1 0
P DPL 1 1 C R A

存取权字节的第0 位A 位是访问位,用于请求分段不分页的系统中,每当该段被访问时, 将A 置1。对于分页系统,则A 被忽略未用。

系统段描述符

以上介绍了用户段描述符。系统段描述符的一般格式如图所示。

可以看出,系统段描述符的第5 个字节的第4 位为0,说明它是系统段描述符,类型占4 位,没有A 位。第6 个字节的第6 位为0,说明系统段的长度是字节粒度,所以,一个系统段的最大长度为1M字节。

系统段的类型为16种,如图所示。

在这16种类型中,保留类型和有关286的类型不予考虑。 门也是一种描述符,有调用门、任务门、中断门和陷阱门4 种门描述符。

描述符表

各种各样的用户描述符和系统描述符,都放在对应的全局描述符表、局部描述符表和中断描述符表中。

描述符表(即段表)定义了386系统的所有段的情况。所有的描述符表本身都占据一个字节为8 的倍数的存储器空间,空间大小在8 个字节(至少含一个描述符)到64K字节(至多含8K)个描述符之间。

  1. 全局描述符表(GDT):全局描述符表GDT(Global Descriptor Table),除了任务门,中断门和陷阱门描述符外,包含着系统中所有任务都共用的那些段的描述符。它的第一个8 字节位置没有使用。
  2. 中断描述符表(IDT):中断描述符表IDT(Interrupt Descriptor Table),包含256个门描述符。IDT中只能包含任务门、中断门和陷阱门描述符,虽然IDT表最长也可以为64K字节,但只能存取2K字节以内的描述符,即256个描述符,这个数字是为了和8086保持兼容。
  3. 局部描述符表(LDT):局部描述符表LDT(Local Descriptor Table),包含了与一个给定任务有关的描述符, 每个任务各自有一个的LDT。有了LDT,就可以使给定任务的代码、数据与别的任务相隔离。

每一个任务的局部描述符表LDT本身也用一个描述符来表示,称为LDT描述符,它包含了有关局部描述符表的信息,被放在全局描述符表GDT中。

选择符与描述符表寄存器

在实模式下,段寄存器存储的是真实的段地址,在保护模式下,16位的段寄存器无法放下32位的段地址,因此,它们被称为选择符,即段寄存器的作用是用来选择描述符。选择符的结构如图所示。

1
2
15     2  1 0
索引 TI RPL

可以看出,选择符有3 个域:第15~3位这13位是索引域,表示的数据为0~8192,用于指向全局描述符表中相应的描述符。第2 位为选择域,如果TI=1,就从局部描述符表中选择相应的描述符,如果TI=0,就从全局描述符表中选择描述符。第1、0位是特权级,表示选择符的特权级,被称为请求者特权级RPL(Requestor Privilege Level)。只有请求者特权级 RPL高于(数字低于)或等于相应的描述符特权级DPL,描述符才能被存取,这就可以实现一定程度的保护。

我们知道,实模式下是直接在段寄存器中放置段基地址,现在则是通过它来存取相应的描述符来获得段基地址和其他信息,这样以来,存取速度会不会变慢呢?为了解决这个问题, 386的每一个段选择符都有一个程序员不可见(也就是说程序员不能直接操纵)的88位宽的段描述符高速缓冲寄存器与之对应。无论什么时候改变了段寄存器的内容,只要特权级合理, 描述符表中的相应的8 字节描述符就会自动从描述符表中取出来,装入高速缓冲寄存器中(还有 24位其他内容)。一旦装入,以后对那个段的访问就都使用高速缓冲寄存器的描述符信息, 而不会再重新从表中去取,这就大大加快了执行的时间。

由于段描述符高速缓冲寄存器的内容只有在重新设置选择符时才被重新装入,所以,当你修改了选择符所选择的描述符后,必须对相应的选择符重新装入,这样,88位描述符高速缓冲寄存器的内容才会发生变化。无论如何,当选择符的值改变时,处理器自动装载不可见部分。

下面讲一下在没有分页操作时,寻址一个存储器操作数的步骤。

  1. 在段选择符中装入16位数,同时给出32位地址偏移量(比如在ESI、EDI中等)。
  2. 根据段选择符中的索引值、TI及 RPL值,再根据相应描述符表寄存器中的段地址和段界限,进行一系列合法性检查(如特权级检查、界限检查),该段无问题,就取出相应的描述符放入段描述符高速缓冲寄存器中。
  3. 将描述符中的32位段基地址和放在ESI、EDI等中的32位有效地址相加,就形成了 32位物理地址。

描述符投影寄存器

为了避免在每次存储器访问时,都要访问描述符表,读出描述符并对段进行译码以得到描述符本身的各种信息,每个段寄存器都有与之相联系的描述符投影寄存器。在这些寄存器中,容纳有由段寄存器中的选择符确定的段的描述符信息。段寄存器对编程人员是可见的, 而与之相联系的容纳描述符的寄存器,则对编程人员是不可见的,故称之为投影寄存器。图2.19中所示的是6 个寄存器及其投影寄存器。用实线画出的寄存器是段寄存器,用以表示这些寄存器对编程人员可见;用虚线画出的寄存器是投影寄存器,表示对编程人员不可见。

投影寄存器容纳有相应段寄存器寻址的段的基地址、界限及属性。每当用选择符装入段寄存器时,CPU硬件便自动地把描述符的全部内容装入对应的投影寄存器。因此,在多次访问同一段时,就可以用投影寄存器中的基地址来访问存储器。投影寄存器存储在80386的芯片上,因而可以由段基址硬件进行快速访问。因为多数指令访问的数据是在其选择符已经装入到段寄存器之后进行的,所以使用投影寄存器可以得到很好的执行性能。

Linux中的段

Intel微处理器的段机制是从8086开始提出的, 那时引入的段机制解决了从CPU内部16位地址到20位实地址的转换。为了保持这种兼容性,386仍然使用段机制,但比以前复杂得多。因此,Linux内核的设计并没有全部采用Intel所提供的段方案,仅仅有限度地使用了一下分段机制。

从2.2版开始,Linux让所有的进程(或叫任务)都使用相同的逻辑地址空间,因此就没有必要使用局部描述符表LDT。

Linux在启动的过程中设置了段寄存器的值和全局描述符表GDT的内容,段的定义在include/asm-i386/segment.h中:

1
2
3
4
#define __KERNEL_CS 0x10   /* 内核代码段,index=2,TI=0,RPL=0 */
#define __KERNEL_DS 0x18 /* 内核数据段, index=3,TI=0,RPL=0 */
#define __USER_CS 0x23 /* 用户代码段, index=4,TI=0,RPL=3 */
#define __USER_DS 0x2B /* 用户数据段, index=5,TI=0,RPL=3 */

从定义看出,没有定义堆栈段,实际上,Linux内核不区分数据段和堆栈段,这也体现了 Linux内核尽量减少段的使用。因为没有使用LDT,因此,TI=0,并把这4 个段都放在GDT中,index就是某个段在GDT表中的下标。内核代码段和数据段具有最高特权,因此其RPL为 0,而用户代码段和数据段具有最低特权,因此其RPL为 3。可以看出,Linux内核再次简化了特权级的使用,使用了两个特权级而不是4 个。

全局描述符表的定义在arch/i386/kernel/head.S中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ENTRY(gdt_table)
.quad 0x0000000000000000 /* NULL descriptor */
.quad 0x0000000000000000 /* not used */
.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */
.quad 0x0000000000000000 /* notused */
.quad 0x0000000000000000 /* notused */
/*
* TheAPMsegmentshavebytegranularityandtheirbases
* andlimitsaresetatruntime.
*/
.quad 0x0040920000000000 /* 0x40 APM set up for bad BIOS's */
.quad 0x00409a0000000000 /* 0x48 APM CS code */
.quad 0x00009a0000000000 /* 0x50 APM CS 16 code (16bit) */
.quad 0x0040920000000000 /* 0x58 APM DS data */
.fillNR_CPUS*4,8,0 /* space for TSS's and LDT's */

从代码可以看出,GDT放在数组变量gdt_table中。按Intel规定,GDT中的第一项为空,这是为了防止加电后段寄存器未经初始化就进入保护模式而使用GDT的。第二项也没用。 从下标2~5共 4项对应于前面的4 种段描述符值。从描述符的数值可以得出:

  • 段的基地址全部为0x00000000;
  • 段的上限全部为0xffff;
  • 段的粒度G 为1,即段长单位为4KB;
  • 段的D 位为1,即对这4 个段的访问都为32位指令;
  • 段的P 位为1,即4 个段都在内存。

由此可以得出,每个段的逻辑地址空间范围为0~4GB。因为每个段的基地址为0,因此,逻辑地址到线性地址映射保持不变,也就是说,偏移量就是线性地址,我们以后所提到的逻辑地址 (或虚拟地址)和线性地址指的也就是同一地址。看来,Linux巧妙地把段机制给绕过去了,而完全利用了分页机制。

分页机制

分页机制在段机制之后进行,以完成线性—物理地址的转换过程。段机制把逻辑地址转换为线性地址,分页机制进一步把该线性地址再转换为物理地址。

分页机制由CR0中的PG位启用。如PG=1,启用分页机制,把线性地址转换为物理地址。如PG=0,禁用分页机制,直接把段机制产生的线性地址当作物理地址使用。分页机制管理的对象是固定大小的存储块,称之为页(page)。分页机制把整个线性地址空间及整个物理地址空间都看成由页组成,在线性地址空间中的任何一页,可以映射为物理地址空间中的任何一页(我们把物理空间中的一页叫做一个页面或页框(page frame))。

80386使用4K字节大小的页。每一页都有4K字节长,并在4K字节的边界上对齐,即每一页的起始地址都能被4K整除。因此,80386把4G字节的线性地址空间,划分为1G个页面, 每页有4K字节大小。分页机制通过把线性地址空间中的页,重新定位到物理地址空间来进行管理,因为每个页面的整个4K字节作为一个单位进行映射,并且每个页面都对齐4K字节的边界,因此,线性地址的低12位经过分页机制直接地作为物理地址的低12位使用。

线性—物理地址的转换,可将其意义扩展为允许将一个线性地址标记为无效,而不是实际地产生一个物理地址。有两种情况可能使页被标记为无效:其一是线性地址是操作系统不支持的地址;其二是在虚拟存储器系统中,线性地址对应的页存储在磁盘上,而不是存储在物理存储器中。在前一种情况下,程序因产生了无效地址而必须被终止。对于后一种情况,该无效的地址实际上是请求操作系统的虚拟存储管理系统,把存放在磁盘上的页传送到物理存储器中,使该页能被程序所访问。由于无效页通常是与虚拟存储系统相联系的,这样的无效页通常称为未驻留页,并且用页表属性位中叫做存在位的属性位进行标识。未驻留页是程序可访问的页,但它不在主存储器中。对这样的页进行访问,形式上是发生异常,实际上是通过异常进行缺页处理。

分页机构

如前所述,分页是将程序分成若干相同大小的页,每页4K个字节。如果不允许分页(CR0的最高位置0),那么经过段机制转化而来的32位线性地址就是物理地址。但如果允许分页(CR0的最高位置1),就要将32位线性地址通过一个两级表格结构转化成物理地址。

两级页表结构

在80386中页表共含1M个表项,每个表项占4 个字节。如果把所有的页表项存储在一个表中,则该表最大将占4M字节连续的物理存储空间。为避免使页表占有如此巨额的物理存储器资源,故对页表采用了两级表的结构,而且对线性地址的高20位的线性—物理地址转化也分为两部完成,每一步各使用其中的10位。

两级表结构的第一级称为页目录,存储在一个4K字节的页面中。页目录表共有1K个表项,每个表项为4 个字节,并指向第二级表。线性地址的最高10位(即位31~位22)用来产生第一级的索引,由索引得到的表项中,指定并选择了1K个二级表中的一个表。

两级表结构的第二级称为页表,也刚好存储在一个4K字节的页面中,包含1K个字节的表项,每个表项包含一个页的物理基地址。第二级页表由线性地址的中间10位(即位21~位12)进行索引,以获得包含页的物理地址的页表项,这个物理地址的高20位与线性地址的低12位形成了最后的物理地址,也就是页转化过程输出的物理地址,具体转化过程稍后会讲到, 如图2.21为两级页表结构。

页目录项

图2-22所示为页目录表,最多可包含1024个页目录项,每个页目录项为4 个字节,结构如图2.22所示。

  • 第31~12位是20位页表地址,由于页表地址的低12位总为0,所以用高20位指出32位页表地址就可以了。因此,一个页目录最多包含1024个页表地址。
  • 第0 位是存在位,如果P=1,表示页表地址指向的该页在内存中,如果P=0,表示不在内存中。
  • 第1 位是读/写位,第2 位是用户/管理员位,这两位为页目录项提供硬件保护。当特权级为3 的进程要想访问页面时,需要通过页保护检查,而特权级为0 的进程就可以绕过页保护。
  • 第3 位是PWT(PageWrite-Through)位,表示是否采用写透方式,写透方式就是既写内存(RAM)也写高速缓存,该位为1 表示采用写透方式。
  • 第4 位是PCD(PageCacheDisable)位,表示是否启用高速缓存,该位为1 表示启用高速缓存。
  • 第5 位是访问位,当对页目录项进行访问时,A位=1。
  • 第7 位是PageSize标志,只适用于页目录项。如果置为1,页目录项指的是4MB的 页面,请看后面的扩展分页。
  • 第9~11位由操作系统专用,Linux也没有做特殊之用。

页面项

80386的每个页目录项指向一个页表,页表最多含有1024个页面项,每项4 个字节,包含页面的起始地址和有关该页面的信息。页面的起始地址也是4K的整数倍,所以页面的低12位也留作它用,如图2.24所示。

第31~12位是20位物理页面地址,除第6 位外第0~5位及9~11位的用途和页目录项一样,第6 位是页面项独有的,当对涉及的页面进行写操作时,D位被置1。

4GB的存储器只有一个页目录,它最多有1024个页目录项,每个页目录项又含有1024个页面项,因此,存储器一共可以分成1024×1024=1M个页面。由于每个页面为4K个字节, 所以,存储器的大小正好最多为4GB。

线性地址到物理地址的转换

当访问一个操作单元时,如何由分段结构确定的32位线性地址通过分页操作转化成32位物理地址呢?过程如图2.25所示。

  • 第一步,CR3包含着页目录的起始地址,用32位线性地址的最高10位 A31~A22作为页目录的页目录项的索引,将它乘以4,与CR3中的页目录的起始地址相加,形成相应页表的地址。
  • 第二步,从指定的地址中取出32位页目录项,它的低12位为0,这32位是页表的起始地址。用32位线性地址中的A21~A12位作为页表中的页面的索引,将它乘以4,与页表的起始地址相加,形成32位页面地址。
  • 第三步,将A11~A0作为相对于页面地址的偏移量,与32位页面地址相加,形成32位 物理地址。

扩展分页

从奔腾处理器开始,Intel微处理器引进了扩展分页,它允许页的大小为4MB,如图2.26所示。

在扩展分页的情况下,分页机制把32位线性地址分成两个域:最高10位的目录域和其余 22位的偏移量。

页面高速缓存

由于在分页情况下,每次存储器访问都要存取两级页表,这就大大降低了访问速度。 所以,为了提高速度,在386中设置一个最近存取页面的高速缓存硬件机制,它自动保持32项处理器最近使用的页面地址,因此,可以覆盖128K字节的存储器地址。当进行存储器访问时,先检查要访问的页面是否在高速缓存中,如果在,就不必经过两级访问了,如果不在,再进行两级访问。平均来说,页面高速缓存大约有98%的命中率,也就是说每次访问存储器时,只有2%的情况必须访问两级分页机构。这就大大加快了速度,页面高速缓存的作用如图2.27所示。有些书上也把页面高速缓存叫做“联想存储器”或“转换旁路缓冲器(TLB)”。

Linux中的分页机制

如前所述,Linux主要采用分页机制来实现虚拟存储器管理,原因如下。

  • Linux的分段机制使得所有的进程都使用相同的段寄存器值,这就使得内存管理变得简单,也就是说,所有的进程都使用同样的线性地址空间(0~4GB)。
  • Linux设计目标之一就是能够把自己移植到绝大多数流行的处理器平台。但是,许多RISC处理器支持的段功能非常有限。

为了保持可移植性,Linux采用三级分页模式而不是两级,这是因为许多处理器都采用64位结构的处理器,在这种情况下,两级分页就不适合了,必须采用三级分页。如图2.28所示为三级分页模式,为此,Linux定义了3 种类型的页表。

  • 总目录PGD(PageGlobalDirectory)
  • 中间目录PMD(PageMiddleDerectory)
  • 页表PT(PageTable)

与页相关的数据结构及宏的定义

Linux所定义的数据结构分布在include/asm-i386/目录下的page.hpgtable.hpgtable-2level.h三个文件中。

表项的定义

如上所述,PGD、PMD及 PT表的表项都占4 个字节,因此,把它们定义为无符号长整数,分别叫做pgd_tpmd_tpte_t(pte即Page table Entry),在page.h中定义如下:

1
2
3
4
typedef struct { unsigned long pte_low; } pte_t;
typedef struct { unsigned long pmd; } pmd_t;
typedef struct { unsigned long pgd; } pgd_t;
typedef struct { unsigned long pgprot; } pgprot_t;

可以看出,Linux没有把这几个类型直接定义长整数而是定义为一个结构,这是为了让gcc在编译时进行更严格的类型检查。另外,还定义了几个宏来访问这些结构的成分,这也是一种面向对象思想的体现:

1
2
3
#define pte_val(x) ((x).pte_low)
#define pmd_val(x) ((x).pmd)
#define pgd_val(x) ((x).pgd)

从图2.22和图2.24可以看出,对这些表项应该定义成位段,但内核并没有这样定义,而是定义了一个页面保护结构pgprot_t和一些宏:

1
2
typedef struct { unsigned long pgprot; } pgprot_t; 
#define pgprot_val(x) ((x).pgprot)

字段pgprot的值与图2.24页面项的低12位相对应,其中的9 位对应0~9位,在pgtalbe.h中定义了对应的宏:

1
2
3
4
5
6
7
8
9
#define _PAGE_PRESENT 0x001
#define _PAGE_RW 0x002
#define _PAGE_USER 0x004
#define _PAGE_PWT 0x008
#define _PAGE_PCD 0x010
#define _PAGE_ACCESSED 0x020
#define _PAGE_DIRTY 0x040
#define _PAGE_PSE 0x080 /* 4MB (or2MB) page, Pentium+, ifpresent.. */
#define _PAGE_GLOBAL 0x100 /* GlobalTLBentryPPro+ */

另外,页目录表及页表在pgtable.h中定义如下:

1
2
extern pgd_t swapper_pg_dir[1024];
extern unsigned long pg0[1024];

swapper_pg_dir为页目录表,pg0为一临时页表,每个表最多都有1024项。

线性地址域的定义

Intel线性地址的结构如下所示。

1
2
31    22 21    12 11   0
目录 页 偏移量

偏移量的位数

1
2
3
4
#define PAGE_SHIFT 12
#define PAGE_SIZE (1UL << PAGE_SHIFT)
#define PTRS_PER_PTE 1024
#define PAGE_MASK (~(PAGE_SIZE-1))

其中PAGE_SHIFT宏定义了偏移量的位数为12,因此页大小PAGE_SIZE为4096字节;

PTRS_PER_PTE为页表的项数;最后PAGE_MASK值定义为0xfffff000,用以屏蔽掉偏移量域的所有位(12位)。

1
2
3
4
#define PGDIR_SHIFT 22
#define PTRS_PER_PGD 1024
#define PGDIR_SIZE (1UL << PGDIR_SHIFT)
#define PGDIR_MASK (~(PGDIR_SIZE-1))

PGDIR_SHIFT是页表所能映射区域线性地址的位数,它的值为22(12位的偏移量加上10位的页表);PTRS_PER_PGD为页目录目录项数;PGDIR_SIZE为页目录的大小,为222,即4MB;PGDIR_MASK为0xffc00000,用于屏蔽偏移量位与页表域的所有位。

1
2
#define PMD_SHIFT  22
#define PTRS_PER_PMD 1

PMD_SHIFT为中间目录表映射的地址位数,其值也为22,但是因为Linux在 386中只用了两级页表结构,因此,让其目录项个数为1,这就使得中间目录在指针序列中的位置被保存,以便同样的代码在32位系统和64位系统下都能使用。

对页目录及页表的处理

page.hpgtable.hpgtable-2level.h3个文件中还定义有大量的宏,用以对页目录、页表及表项的处理。

表项值的确定

1
2
3
static inline int pgd_none(pgd_t pgd) { return 0; }
static inline int pgd_present(pgd_t pgd) { return 1; }
#define pte_present(x) ((x).pte_low & (_PAGE_PRESENT | _PAGE_PROTNONE))

pgd_none()函数直接返回0,表示尚未为这个页目录建立映射,所以页目录项为空。pgd_present()函数直接返回1,表示映射虽然还没有建立,但页目录所映射的页表肯定存在于内存(即页表必须一直在内存)。

pte_present宏的值为1 或0,表示P 标志位。如果页表项不为0,但标志位为0,则表示映射已经建立,但所映射的物理页面不在内存。

清相应表的表项

1
2
#define pgd_clear(xp) do { } while (0)
#define pte_clear(xp) do { set_pte(xp, __pte(0)); } while (0)

pgd_clear宏实际上什么也不做,定义它可能是为了保持编程风格的一致。pte_clear就是把0 写到页表表项中。

对页表表项标志值进行操作的宏

这些宏的代码在pgtable.h文件中,表2.1给出宏名及其功能。

宏名 功能
Set_pte() 把一个具体的值写入表项
Pte_read() 返回User/Supervisor标志值(由此可以得知是否可以在用户态下访问此页)
Pte_write() 如果Present标志和Read/Write标志都为1,则返回1(此页是否存在并可写)
Pte_exec() 返回User/Supervisor标志值
Pte_dirty() 返回Dirty标志的值(说明此页是否被修改过)
Pte_young() 返回Accessed标志的值(说明此页是否被存取过)
Pte_wrprotect() 清除Read/Write标志
Pte_rdprotect() 清除User/Supervisor标志
Pte_mkwrite() 设置Read/Write标志
Pte_mkread() 设置User/Supervisor标志
Pte_mkdirty() 把Dirty标志置1
Pte_mkclean() 把Dirty标志置0
Pte_mkyoung() 把Accessed标志置1
Pte_mkold() 把Accessed标志置0
Pte_modify(p,v) 把页表表项p 的所有存取权限设置为指定的值v
Mk_pte() 把一个线性地址和一组存取权限合并来创建一个32位的页表表项
Pte_pte_phys() 把一个物理地址与存取权限合并来创建一个页表表项
Pte_page() 从页表表项返回页的线性地址

中断机制

中断基本知识

16 位实地址模式下的中断机制在 32 位的保护模式下依然有效。两种模式之间最本质的差别就是在保护模式引入的中断描述符表。

中断向量

Intel x86 系列微机共支持 256 种向量中断,为使处理器较容易地识别每种中断源,将它们从 0~256 编号,即赋予一个中断类型码n,Intel 把这个 8 位的无符号整数叫做一个向量,因此,也叫中断向量。所有 256 种中断可分为两大类:异常和中断。异常又分为故障(Fault)和陷阱(Trap),它们的共同特点是既不使用中断控制器,又不能被屏蔽。中断又分为外部可屏蔽中断(INTR)和外部非屏蔽中断(NMI),所有 I/O 设备产生的中断请求(IRQ)均引起屏蔽中断,而紧急的事件(如硬件故障)引起的故障产生非屏蔽中断。非屏蔽中断的向量和异常的向量是固定的,而屏蔽中断的向量可以通过对中断控制器的编程来改变。Linux 对 256 个向量的分配如下。

  • 从 0~31 的向量对应于异常和非屏蔽中断。
  • 从 32~47 的向量(即由 I/O 设备引起的中断)分配给屏蔽中断。
  • 剩余的从 48~255 的向量用来标识软中断。Linux 只用了其中的一个(即 128 或 0x80向量)用来实现系统调用。当用户态下的进程执行一条int 0x80汇编指令时,CPU 就切换到内核态,并开始执行system_call()内核函数。

外设可屏蔽中断

Intel x86 通过两片中断控制器 8259A 来响应 15 个外中断源,每个 8259A 可管理 8 个中断源。第 1 级(称主片)的第 2 个中断请求输入端,与第 2 级 8259A(称从片)的中断输出端 INT 相连,如图 3.1 所示。我们把与中断控制器相连的每条线叫做中断线,要使用中断线,就得进行中断线的申请,就是 IRQ(Interrupt ReQuirement ),我们也常把申请一条中断线称为申请一个 IRQ 或者是申请一个中断号。IRQ 线是从 0 开始顺序编号的,因此,第一条 IRQ线通常表示成 IRQ0。IRQn 的缺省向量是 n+32;如前所述,IRQ 和向量之间的映射可以通过中断控制器端口来修改。

中断控制器 8259A 执行如下操作。

  • 监视中断线,检查产生的中断请求(IRQ)信号。
  • 如果在中断线上产生了一个中断请求信号。
    • 把接受到的 IRQ 信号转换成一个对应的向量。
    • 把这个向量存放在中断控制器的一个 I/O 端口,从而允许 CPU 通过数据总线读此向量。
    • 把产生的信号发送到 CPU 的 INTR 引脚——即发出一个中断。
    • 等待,直到 CPU 确认这个中断信号,然后把它写进可编程中断控制器(PIC)的一个 I/O 端口;此时,清 INTR 线。
  • 返回到第一步。

对于外部 I/O 请求的屏蔽可分为两种情况,一种是从 CPU 的角度,也就是清除 eflag 的中断标志位(IF),当 IF=0 时,禁止任何外部 I/O 的中断请求,即关中断;一种是从中断控制器的角度,因为中断控制器中有一个 8 位的中断屏蔽寄存器(IMR),每位对应 8259A 中的一条中断线,如果要禁用某条中断线,则把 IRM 相应的位置 1,要启用,则置 0。

异常及非屏蔽中断

异常就是 CPU 内部出现的中断,也就是说,在 CPU 执行特定指令时出现的非法情况。非屏蔽中断就是计算机内部硬件出错时引起的异常情况。在 CPU 执行一个异常处理程序时,就不再为其他异常或可屏蔽中断请求服务,也就是说,当某个异常被响应后,CPU 清除 eflag 的中 IF 位,禁止任何可屏蔽中断。但如果又有异常产生,则由 CPU 锁存(CPU 具有缓冲异常的能力),待这个异常处理完后,才响应被锁存的异常。

Intel x86 处理器发布了大约 20 种异常(具体数字与处理器模式有关)。Linux 内核必须为每种异常提供一个专门的异常处理程序。这里特别说明的是,在某些异常处理程序开始执行之前,CPU 控制单元会产生一个硬件错误码,内核先把这个错误码压入内核栈中。在表中给出了 Pentium 模型中异常的向量、名字、类型及简单描述。

向量 异常名 类别 描述
0 除法出错 故障 被 0 除
1 调试 故障、陷阱 当对一个程序进行逐步调试时
2 非屏蔽中断(NMI) 为不可屏蔽中断保留
3 断点 陷阱 由 int3(断点指令)指令引起
4 溢出 陷阱 当 into(check for overflow)指令被执行
5 边界检查 故障 当 bound 指令被执行
6 非法操作码 故障 当 CPU 检查到一个无效的操作码
7 设备不可用 故障 随着设置 cr0 的 TS 标志,ESCAPE 或 MMX 指令被执行
8 双重故障 故障 处理器不能串行处理异常而引起的
9 协处理器段越界 故障 因外部的数学协处理器引起的问题(仅用在 80386)
10 无效 TSS 故障 要切换到的进程具有无效的 TSS
11 段不存在 故障 引用一个不存在的内存段
12 栈段异常 故障 试图超过栈段界限,或由 ss 标识的段不在内存
13 通用保护 故障 违反了 Intelx86 保护模式下的一个保护规则
14 页异常 故障 寻址的页不在内存,或违反了一种分页保护机制
15 Intel保留 保留
16 浮点出错 故障 浮点单元用信号通知一个错误情形,如溢出
17 对齐检查 故障 操作数的地址没有被正确地排列

18~31 由 Intel 保留,为将来的扩充用。

另外,如表 3.2 所示,每个异常都由专门的异常处理程序来处理,它们通常把一个 UNIX 信号发送到引起异常的进程。

向量 异常名 出错码 异常处理程序 信号
0 除法出错 divide_error() SIGFPE
1 调试 debug() SIGTRAP
2 非屏蔽中断(NMI) nmi() None
3 断点 int3() SIGTRAP
4 溢出 overflow() SIGSEGV
5 边界检查 bounds() SIGSEGV
6 非法操作码 invalid_op() SIGILL
7 设备不可用 device_not_available() SIGSEGV
8 双重故障 double_fault() SIGSEGV
9 协处理器段越界 coprocessor_segment_overrun() SIGFPE
10 无效TSS invalid_tss() SIGSEGV
11 段不存在 segment_not_present() SIGBUS
12 栈段异常 stack_segment() SIGBUS
13 通用保护 general_protection() SIGSEGV
14 页异常 page_fault() SIGSEGV
15 Intel保留 None None
16 浮点出错 coprocessor_error() SIGFPE
17 对齐检查 alignment_check() SIGSEGV

中断描述符表

在实地址模式中,CPU 把内存中从 0 开始的 1K 字节作为一个中断向量表。表中的每个表项占 4 个字节,由两个字节的段地址和两个字节的偏移量组成,这样构成的地址便是相应中断处理程序的入口地址。但是,在实模式下,由 4 字节的表项构成的中断向量表显然满足不了要求。这是因为:

  • 除了两个字节的段描述符,偏移量必用 4 字节来表示;
  • 要有反映模式切换的信息。

因此,在实模式下,中断向量表中的表项由 8 个字节组成,如图 3.2 所示,中断向量表也改叫做中断描述符表 IDT(Interrupt Descriptor Table)。其中的每个表项叫做一个门描述符(Gate Descriptor),“门”的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。

其中类型占 3 位,表示门描述符的类型,这些描述符如下。

  1. 任务门(Task gate):其类型码为 101,门中包含了一个进程的 TSS 段选择符,但偏移量部分没有使用,因为 TSS本身是作为一个段来对待的,因此,任务门不包含某一个入口函数的地址。TSS 是 Intel 所提供
    的任务切换机制,但是 Linux 并没有采用任务门来进行任务切换。
  2. 中断门(Interrupt gate):其类型码为 110,中断门包含了一个中断或异常处理程序所在段的选择符和段内偏移量。当控制权通过中断门进入中断处理程序时,处理器清 IF 标志,即关中断,以避免嵌套中断的发生。中断门中的 DPL(Descriptor Privilege Level)为 0,因此,用户态的进程不能访问Intel 的中断门。所有的中断处理程序都由中断门激活,并全部限制在内核态。
  3. 陷阱门(Trap gate):其类型码为 111,与中断门类似,其唯一的区别是,控制权通过陷阱门进入处理程序时维持 IF 标志位不变,也就是说,不关中断。
  4. 系统门(System gate):这是 Linux 内核特别设置的,用来让用户态的进程访问 Intel 的陷阱门,因此,门描述符的 DPL 为 3。通过系统门来激活 4 个 Linux 异常处理程序,它们的向量是 3、4、5 及 128,也就是说,在用户态下,可以使用int3intoboundint 0x80四条汇编指令。

最后,在保护模式下,中断描述符表在内存的位置不再限于从地址 0 开始的地方,而是可以放在内存的任何地方。为此,CPU 中增设了一个中断描述符表寄存器 IDTR,用来存放中断描述符表在内存的起始地址。中断描述符表寄存器 IDTR 是一个 48 位的寄存器,其低 16位保存中断描述符表的大小,高 32 位保存 IDT 的基址。

1
2
47         16 15   0
32 位基址值 界限

相关汇编指令

调用过程指令 CALL

指令格式:CALL 过程名

说明:i386 在取出 CALL 指令之后及执行 CALL 指令之前,使指令指针寄存器 EIP 指向紧接 CALL 指令的下一条指令。CALL 指令先将 EIP 值压入栈内,再进行控制转移。当遇到 RET指令时,栈内信息可使控制权直接回到 CALL 的下一条指令

调用中断过程指令 INT

指令格式:INT 中断向量

说明:EFLAG、CS 及 EIP 寄存器被压入栈内。控制权被转移到由中断向量指定的中断处理程序。在中断处理程序结束时,IRET 指令又把控制权送回到刚才执行被中断的地方。

调用溢出处理程序的指令 INTO

指令格式:INTO

说明:在溢出标志为 1 时,INTO 调用中断向量为 4 的异常处理程序。EFLAG、CS 及 EIP寄存器被压入栈内。控制权被转移到由中断向量 4 指定的异常处理程序。在中断处理程序结束时,IRET 指令又把控制权送回到刚才执行被中断的地方。

中断返回指令 IRET

指令格式:IRET

说明:IRET 与中断调用过程相反:它将 EIP、CS 及 EFLAGS 寄存器内容从栈中弹出,并将控制权返回到发生中断的地方。IRET 用在中断处理程序的结束处。

加载中断描述符表的指令 LIDT

格式:LIDT 48 位的伪描述符

说明:LIDT 将指令中给定的 48 位伪描述符装入中断描述符寄存器 IDTR。伪描述符和中断描述符表寄存器的结构相同,都是由两部分组成:在低字(低 16 位)中装的是界限,在高双字(高 32 位)中装的是基址。这条指令只能出现在操作系统的代码中。

中断或异常处理程序执行的最后一条指令是返回指令 IRET。这条指令将使 CPU 进行如下操作后,把控制权转交给被中断的进程。

  • 从中断处理程序的内核栈中恢复相应寄存器的值。如果一个硬件错码被压入堆栈,则先弹出这个值,然后,依次将 EIP、CS 及 EFLSG 从栈中弹出。
  • 检查中断或异常处理程序的 CPL 是否等于 CS 中的最低两位,如果是,这就意味着被中断的进程与中断处理程序都处于内核态,也就是没有更换堆栈,因此,IRET 终止执行,返回到被中断的进程。否则下一步。
  • 从栈中装载 SS 和 ESP 寄存器,返回到用户态堆栈。
  • 检查 DS、ES、FS 和 GS 四个段寄存器的内容,看它们包含的选择符是否是一个段选择符,并且其 DPL 是否小于 CPL。如果是,就清除其内容。这么做的原因是为了禁止用户态的程序(CPL=3)利用内核曾用过的段寄存器(DPL=0)。如果不这么做,怀有恶意的用户就可能利用这些寄存器来访问内核的地址空间。

中断描述符表的初始化

Linux 内核在系统的初始化阶段要进行大量的初始化工作,其与中断相关的工作有:初始化可编程控制器 8259A;将中断向量 IDT 表的起始地址装入 IDTR 寄存器,并初始化表中的每一项。这些操作的完成将在本节进行具体描述。

用户进程可以通过 INT 指令发出一个中断请求,其中断请求向量在 0~255 之间。为了防止用户使用 INT 指令模拟非法的中断和异常,必须对 IDT 表进行谨慎的初始化。其措施之一就是将中断门或陷阱门中的 DPL 域置为 0。如果用户进程确实发出了这样一个中断请求,CPU 会检查出其 CPL(3)与 DPL(0)有冲突,因此产生一个“通用保护”异常。

但是,有时候必须让用户进程能够使用内核所提供的功能(比如系统调用),也就是说从用户空间进入内核空间,这可以通过把中断门或陷阱门的 DPL 域置为 3 来达到。

外部中断向量的设置

前面我们已经提到,Linux 把向量 0~31 分配给异常和非屏蔽中断,而把 32~47 之间的向量分配给可屏蔽中断,可屏蔽中断的向量是通过对中断控制器的编程来设置的。

8259A 通过两个端口来进行数据传送,对于单块的 8259A 或者是级连中的 8259A_1 来说,这两个端口是 0x20 和 0x21。对于 8259A_2 来说,这两个端口是 0xA0 和 0xA1。8259A 有两种编程方式,一是初始化方式,二是工作方式。在操作系统启动时,需要对 8959A 做一些初始化工作,这就是初始化方式编程。

先简单介绍一下 8259A 内部的 4 个中断命令字(ICW)寄存器的功能,它们都是用来启动初始化编程的。

  • ICW1:初始化命令字。
  • ICW2:中断向量寄存器,初始化时写入高 5 位作为中断向量的高五位,然后在中断响应时由 8259 根据中断源(哪个管脚)自动填入形成完整的 8 位中断向量(或叫中断类型号)。
  • ICW3:8259 的级连命令字,用来区分主片和从片。
  • ICW4:指定中断嵌套方式、数据缓冲选择、中断结束方式和 CPU 类型。

8259A 初始化的目的是写入有关命令字,8259A 内部有相应的寄存器来锁存这些命令字,以控制 8259A 工作。只具体把Linux对8259A的初始化讲解一下,代码在/arch/i386/kernel/i8259.c的函数init_8259A()中:

1
2
3
4
5
6
7
8
9
outb(0xff, 0x21); /* 送数据到工作寄存器 OCW1(又称中断屏蔽字),屏蔽所有外部中断, 因为此时系统尚未初始化完毕,不能接收任何外部中断请求 */
outb(0xff, 0xA1);
outb_p(0x11, 0x20); /* 送 0x11 到 ICW1(通过端口 0x20),启动初始化编程。0x11 表示外部中断请求信号为上升沿有效,系统中有多片 8295A 级连,还表示要向 ICW4送数据 */
outb_p(0x20 + 0, 0x21); /* 送 0x20 到 ICW2,写入高 5 位作为中断向量的高 5 位,低 3 位根据中断源(管脚)填入中断号 0~7,因此把 IRQ0-7 映射到向量 0x20-0x27 */
outb_p(0x04, 0x21); /* 送 0x04 到 ICW3,ICW3 是 8259 的级连命令字, 0x04表示 8259A-1 是主片 */
outb_p(0x11, 0xA0); /* 用 ICW1 初始化 8259A-2 */
outb_p(0x20 + 8, 0xA1); /* 用 ICW2 把 8259A-2 的 IRQ0-7 映射到 0x28-0x2f */
outb_p(0x02, 0xA1); /* 送 0x04 到 ICW3。表示 8259A-2 是从片,并连接在 8259A_1 的 2 号管脚上*/
outb_p(0x01, 0xA1); /* 把 0x01 送到 ICW4 */

最后一句有 4 方面含义:

  • 中断嵌套方式为一般嵌套方式。当某个中断正在服务时,本级中断及更低级的中断都被屏蔽,只有更高级的中断才能响应。注意,这对于多片 8259A 级连的中断系统来说,当某从片中一个中断正在服务时,主片即将这个从片的所有中断屏蔽,所以此时即使本片有比正在服务的中断级别更高的中断源发出请求,也不能得到响应,即不能中断嵌套。
  • 8259A 数据线和系统总线之间不加三态缓冲器。一般来说,只有级连片数很多时才用到三态缓冲器;
  • 中断结束方式为正常方式(非自动结束方式)。即在中断服务结束时(中断服务程序末尾),要向 8259A 芯片发送结束命令字 EOI(送到工作寄存器 OCW2 中),于是中断服务寄存器 ISR 中的当前服务位被清 0
  • CPU 类型为 x86 系列。

outb_p()函数就是把第一个操作数拷贝到由第二个操作数指定的 I/O 端口,并通过一个空操作来产生一个暂停。

中断描述符表 IDT 的预初始化

当计算机运行在实模式时,IDT 被初始化并由 BIOS 使用。然而,一旦真正进入了 Linux 内核,IDT 就被移到内存的另一个区域,并进行进入实模式的初步初始化。

中断描述表寄存器 IDTR 的初始化

用汇编指令 LIDT 对中断向量表寄存器 IDTR 进行初始化,其代码在arch/i386/boot/setup.S中:

1
2
3
4
5
lidt idt_48   # load idt with 0,0

idt_48:
.word 0 # idt limit = 0
.word 0, 0 # idt base = 0L

把 IDT 表的起始地址装入 IDTR

用汇编指令 LIDT 装入 IDT 的大小和它的地址(在arch/i386/kernel/head.S中):

1
2
3
4
5
6
7
8
#define IDT_ENTRIES 256
.globl SYMBOL_NAME(idt)
lidt idt_descr

idt_descr:
.word IDT_ENTRIES*8-1 # idt contains 256 entries
SYMBOL_NAME(idt):
.long SYMBOL_NAME(idt_table)

其中 idt 为一个全局变量,内核对这个变量的引用就可以获得 IDT 表的地址。表的长度为 256×8=2048 字节。

setup_idt()函数填充 idt_table 表中的 256 个表项

我们首先要看一下idt_table的定义(在arch/i386/kernel/traps.c中):

1
struct desc_struct idt_table[256] __attribute__((__section__(".data.idt"))) = { {0, 0}, };

desc_struct结构定义为:

1
2
3
struct desc_struct {
unsigned long a, b;
}

idt_table变量还定义了其属性(__attribute__),__section__是汇编中的“节”,指定了idt_table的起始地址存放在数据节的idt变量中。

在对idt_table表进行填充时,使用了一个空的中断处理程序ignore_int()。因为现在处于初始化阶段,还没有任何中断处理程序,因此用这个空的中断处理程序填充每个表项。

ignore_int()是一段汇编程序(在head.S中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ignore_int:
cld #方向标志清 0,表示串指令自动增长它们的索引寄存器(esiedi
pushl %eax
pushl %ecx
pushl %edx
pushl %es
pushl %ds
movl $(__KERNEL_DS), %eax
movl %eax,%ds
movl %eax,%es
pushl $int_msg
call SYMBOL_NAME(printk)
popl %eax
popl %ds
popl %es
popl %edx
popl %ecx
popl %eax
iret
int_msg:
.asciz "Unknown interrupt\n"
ALIGN

该中断处理程序模仿一般的中断处理程序,执行如下操作:

  • 在栈中保存一些寄存器的值;
  • 调用printk()函数打印“Unknown interrupt”系统信息;
  • 从栈中恢复寄存器的内容;
  • 执行 iret 指令以恢复被中断的程序。

实际上,ignore_int()处理程序应该从不执行。如果在控制台或日志文件中出现了“Unknown interrupt”消息,说明要么是出现了一个硬件问题(一个 I/O 设备正在产生没有预料到的中断),要么就是出现了一个内核问题(一个中断或异常未被恰当地处理)。

最后,我们来看setup_idt()函数如何对 IDT 表进行填充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 /*
* setup_idt
*
* sets up a idt with 256 entries pointing to
* ignore_int, interrupt gates. It doesn't actually load
* idt - that can be done only after paging has been enabled
* and the kernel moved to PAGE_OFFSET. Interrupts
* are enabled elsewhere, when we can be relatively
* sure everything is ok.
*/
setup_idt:
lea ignore_int,%edx /*计算 ignore_int 地址的偏移量,并将其装入%edx*/
movl $(__KERNEL_CS << 16), %eax /* selector = 0x0010 = cs */
movw %dx,%ax
movw $0x8E00,%dx /* interrupt gate - dpl=0, present */
lea SYMBOL_NAME(idt_table), %edi
mov $256,%ecx
rp_sidt:
movl %eax,(%edi)
movl %edx,4(%edi)
addl $8,%edi
dec %ecx
jne rp_sidt
ret

这段程序的理解要对照门描述符的格式。8 个字节的门描述符放在两个 32 位寄存器 eax和 edx 中,如图 3.4 所示,从 rp_sidt 开始的那段程序是循环填充 256 个表项。

中断向量表的最终初始化

在对中断描述符表进行预初始化后, 内核将在启用分页功能后对 IDT 进行第二遍初始化,也就是说,用实际的陷阱和中断处理程序替换这个空的处理程序。一旦这个过程完成,对于每个异常,IDT 都由一个专门的陷阱门或系统门,而对每个外部中断,IDT 都包含专门的中断门。

IDT 表项的设置

IDT 表项的设置是通过_set_gate()函数实现的,这与 IDT 表的预初始化比较相似。调用_set_gate()函数来给 IDT 插入门:

1
2
3
4
void set_intr_gate(unsigned int n, void *addr)
{
_set_gate(idt_table+n,14,0,addr);
}

在第 n 个表项中插入一个中断门。这个门的段选择符设置成代码段的选择符(__KERNEL_CS),DPL 域设置成 0,14 表示 D 标志位为 1 而类型码为 110,所以set_intr_gate()设置的是中断门,偏移域设置成中断处理程序的地址 addr。

1
2
3
4
static void __init set_trap_gate(unsigned int n, void *addr)
{
_set_gate(idt_table+n,15,0,addr);
}

在第 n 个表项中插入一个陷阱门。这个门的段选择符设置成代码段的选择符,DPL 域设置成 0,15 表示 D 标志位为 1 而类型码为 111,所以set_trap_gate()设置的是陷阱门,偏移域设置成异常处理程序的地址 addr

1
2
3
4
static void __init set_system_gate(unsigned int n, void *addr) 
{
_set_gate(idt_table+n,15,3,addr);
}

在第 n 个表项中插入一个系统门。这个门的段选择符设置成代码段的选择符,DPL 域设置成 3,15 表示 D 标志位为 1 而类型码为 111,所以set_system_gate()设置的也是陷阱门,但因为 DPL 为 3,因此,系统调用在用户空间可以通过“INT0X80”顺利穿过系统门,从而进入内核空间。

对陷阱门和系统门的初始化

trap_init()函数就是设置中断描述符表开头的 19 个陷阱门,如前所说,这些中断向量都是 CPU 保留用于异常处理的:

  • set_trap_gate(0, &divide_error);
  • set_trap_gate(1, &debug);
  • set_intr_gate(2, &nmi);
  • set_system_gate(3, &int3); /* int3-5 can be called from all */
  • set_system_gate(4, &overflow);
  • set_system_gate(5, &bounds);
  • set_trap_gate(6, &invalid_op);
  • set_trap_gate(7, &device_not_available);
  • set_trap_gate(8, &double_fault);
  • set_trap_gate(9, &coprocessor_segment_overrun);
  • set_trap_gate(10, &invalid_TSS);
  • set_trap_gate(11, &segment_not_present);
  • set_trap_gate(12, &stack_segment);
  • set_trap_gate(13, &general_protection);
  • set_intr_gate(14, &page_fault);
  • set_trap_gate(15, &spurious_interrupt_bug);
  • set_trap_gate(16, &coprocessor_error);
  • set_trap_gate(17, &alignment_check);
  • set_trap_gate(18, &machine_check);
  • set_trap_gate(19, &simd_coprocessor_error);
  • set_system_gate(SYSCALL_VECTOR, &system_call);

在对陷阱门及系统门设置以后,我们来看一下中断门的设置。

中断门的设置

下面介绍的相关代码均在arch/I386/kernel/i8259.c文件中,其中中断门的设置是由init_IRQ()函数中的一段代码完成的:

1
2
3
4
5
for (i = 0; i< NR_IRQS; i++) {
int vector = FIRST_EXTERNAL_VECTOR + i;
if (vector != SYSCALL_VECTOR)
set_intr_gate(vector, interrupt[i]);
}

其含义比较明显:从FIRST_EXTERNAL_VECTOR开始,设置NR_IRQS个 IDT 表项。常数FIRST_EXTERNAL_VECTOR定义为 0x20,而NR_IRQS则为 224,即中断门的个数。注意,必须跳过用于系统调用的向量 0x80,因为这在前面已经设置好了。

这里,中断处理程序的入口地址是一个数组interrupt[],数组中的每个元素是指向中断处理函数的指针。

1
2
3
4
5
6
7
8
9
10
#define IRQ(x,y) \
IRQ##x##y##_interrupt

#define IRQLIST_16(x) \
IRQ(x,0), IRQ(x,1), IRQ(x,2), IRQ(x,3), \
IRQ(x,4), IRQ(x,5), IRQ(x,6), IRQ(x,7), \
IRQ(x,8), IRQ(x,9), IRQ(x,a), IRQ(x,b), \
IRQ(x,c), IRQ(x,d), IRQ(x,e), IRQ(x,f)

void (*interrupt[NR_IRQS])(void) = IRQLIST_16(0x0)

其中,##的作用是把字符串连接在一起。经过 gcc 预处理,IRQLIST_16(0x0)被替换为IRQ0x00_interruptIRQ0x01_interruptIRQ0x02_interrupt……IRQ0x0f_interrupt

异常处理

Linux 利用异常来达到两个截然不同的目的:

  • 给进程发送一个信号以通报一个反常情况;
  • 处理请求分页。

对于第一种情况,例如,如果进程执行了一个被 0 除的操作,CPU 则会产生一个“除法错误”异常,并由相应的异常处理程序向当前进程发送一个 SIGFPE 信号。当前进程接收到这个信号后,就要采取若干必要的步骤,或者从错误中恢复,或者终止执行(如果这个信号没有相应的信号处理程序)。

内核对异常处理程序的调用有一个标准的结构,它由以下 3 部分组成:

  • 在内核栈中保存大多数寄存器的内容(由汇编语言实现);
  • 调用 C 编写的异常处理函数;
  • 通过ret_from_exception()函数从异常退出。

在内核栈中保存寄存器的值

所有异常处理程序被调用的方式比较相似,因此,我们用handler_name来表示一个通用的异常处理程序的名字。进入异常处理程序的汇编指令在arch/I386/kernel/entry.S中:

1
2
3
4
handler_name:
pushl $0 /* only for some exceptions */
pushl $do_handler_name
jmp error_code

例如:

1
2
3
4
overflow:
pushl $0
pushl $ do_overflow
jmp error_code

当异常发生时,如果控制单元没有自动地把一个硬件错误代码插入到栈中,相应的汇编语言片段会包含一条pushl $0指令,在栈中垫上一个空值;如果错误码已经被压入堆栈,则没有这条指令。然后,把异常处理函数的地址压进栈中,函数的名字由异常处理程序名与do_前缀组成。

标号为error_code的汇编语言片段对所有的异常处理程序都是相同的,除了“设备不可用”这一个异常。这段代码实际上是为异常处理程序的调用和返回进行相关的操作,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
error_code:
pushl %ds
pushl %eax
xorl %eax,%eax
pushl %ebp
pushl %edi #把 C 函数可能用到的寄存器都保存在栈中
pushl %esi
pushl %edx
decl %eax #eax = -1
pushl %ecx
pushl %ebx
cld # 清 eflags 的方向标志,以确保 ediesi 寄存器的值自动增加
movl %es,%ecx
movl ORIG_EAX(%esp), %esi # get the error code, ORIG_EAX= 0x24
movl ES(%esp), %edi # get the function address, ES = 0x20
movl %eax, ORIG_EAX(%esp) # 把栈中的这个位置置为-1
movl %ecx, ES(%esp)
movl %esp,%edx
pushl %esi # push the error code
pushl %edx # push the pt_regs pointer
movl $(__KERNEL_DS),%edx
movl %edx,%ds # 把内核数据段选择符装入 ds 寄存器
movl %edx,%es
GET_CURRENT(%ebx) # ebx 中存放当前进程 task_struct 结构的地址
call *%edi # 调用这个异常处理程序
addl $8,%esp
jmp ret_from_exception

图 3.5 给出了从用户进程进入异常处理程序时内核堆栈的变化示意图。

中断请求队列的初始化

由于硬件的限制,很多外部设备不得不共享中断线,例如,一些 PC 配置可以把同一条中断线分配给网卡和图形卡。由此看来,让每个中断源都必须占用一条中断线是不现实的。所以,仅用中断描述符表并不能提供中断产生的所有信息,内核必须对中断线给出进一步的描述。在 Linux 设计中,专门为每个中断请求 IRQ 设置了一个队列,这就是我们所说的中断请求队列。

注意,中断线、中断请求(IRQ)号及中断向量之间的关系为:中断线是中断请求的一种物理描述,逻辑上对应一个中断请求号(或简称中断号),第 n 个中断号(IRQn)的缺省中断向量是 n+32。

中断请求队列的数据结构

如前所述,在 256 个中断向量中,除了 32 个分配给异常外,还有 224 个作为中断向量。对于每个 IRQ,Linux 都用一个irq_desc_t数据结构来描述,我们把它叫做 IRQ 描述符,224个 IRQ 形成一个数组irq_desc[],其定义在/include/linux/irq.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 /*
* This is the "IRQ descriptor", which contains various information
* about the irq, including what kind of hardware handling it has,
* whether it is disabled etc etc.
*
* Pad this out to 32 bytes for cache and indexing reasons.
*/
typedef struct {
unsigned int status; /* IRQ status */
hw_irq_controller *handler;
struct irqaction *action; /* IRQ action list */
unsigned int depth; /* nested irq disables */
spinlock_t lock;
} ____cacheline_aligned irq_desc_t;
extern irq_desc_t irq_desc[NR_IRQS];

  • ____cacheline_aligned表示这个数据结构的存放按 32 字节(高速缓存行的大小)进行对齐,以便于将来存放在高速缓存并容易存取。
  • status:描述 IRQ 中断线状态的一组标志(在irq.h中定义)。
  • handler:指向hw_interrupt_type描述符,这个描述符是对中断控制器的描述。
  • action:指向一个单向链表的指针,这个链表就是对中断服务例程进行描述的irqaction结构。
  • depth:如果启用这条 IRQ 中断线,depth 则为 0,如果禁用这条 IRQ 中断线不止一次,则为一个正数。每当调用一次disable_irq(),该函数就对这个域的值加 1;如果 depth 等于 0,该函数就禁用这条 IRQ 中断线。相反,每当调用enable_irq()函数时,该函数就对这个域的值减 1;如果 depth 变为 0,该函数就启用这条 IRQ 中断线。

IRQ 描述符的初始化

在系统初始化期间,init_ISA_irqs()函数对 IRQ 数据结构(或叫描述符)的域进行初始化(参见i8258.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (i = 0; i < NR_IRQS; i++) {
irq_desc[i].status = IRQ_DISABLED;
irq_desc[i].action = 0;
irq_desc[i].depth = 1;
if (i < 16) {
/*
* 16 old-style INTA-cycle interrupts:
*/
irq_desc[i].handler = &i8259A_irq_type;
} else {
/*
* 'high' PCI IRQs filled in on demand
*/
irq_desc[i].handler = &no_irq_type;
}
}

从这段程序可以看出,初始化时,让所有的中断线都处于禁用状态;每条中断线上还没有任何中断服务例程(action为 0);因为中断线被禁用,因此 depth 为 1;对中断控制器的描述分为两种情况,一种就是通常所说的 8259A,另一种是其他控制器。

然后,更新中断描述符表 IDT,用最终的中断门来代替临时使用的中断门。

中断控制器描述符 hw_interrupt_type

这个描述符包含一组指针,指向与特定中断控制器电路(PIC)打交道的低级 I/O 例程,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* Interrupt controller descriptor. This is all we need
* to describe about the low-level hardware.
*/
struct hw_interrupt_type {
const char * typename;
unsigned int (*startup)(unsigned int irq);
void (*shutdown)(unsigned int irq);
void (*enable)(unsigned int irq);
void (*disable)(unsigned int irq);
void (*ack)(unsigned int irq);
void (*end)(unsigned int irq);
void (*set_affinity)(unsigned int irq, unsigned long mask);
};
typedef struct hw_interrupt_type hw_irq_controller;

中断服务例程描述符 irqaction

在 IRQ 描述符中我们看到指针 action 的结构为 irqaction,它是为多个设备能共享一条中断线而设置的一个数据结构。在include/linux/interrupt.h中定义如下:

1
2
3
4
5
6
7
8
struct irqaction {
void (*handler)(int, void *, struct pt_regs *);
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id;
struct irqaction *next;
};

这个描述符包含下列域。

  • handler:指向一个具体 I/O 设备的中断服务例程。这是允许多个设备共享同一中断线的关键域。
  • flags:用一组标志描述中断线与 I/O 设备之间的关系。
    • SA_INTERRUPT:中断处理程序必须以禁用中断来执行。
    • SA_SHIRQ:该设备允许其中断线与其他设备共享。
    • SA_SAMPLE_RANDOM:可以把这个设备看作是随机事件发生源;因此,内核可以用它做随机数产生器(用户可以从/dev/random/dev/urandom设备文件中取得随机数而访问这种特征)。
    • SA_PROBE:内核在执行硬件设备探测时正在使用这条中断线。
  • name:I/O 设备名(读取/proc/interrupts 文件,可以看到,在列出中断号时也显示设备名)。
  • dev_id:指定 I/O 设备的主设备号和次设备号。
  • next:指向 irqaction 描述符链表的下一个元素。共享同一中断线的每个硬件设备都有其对应的中断服务例程,链表中的每个元素就是对相应设备及中断服务例程的描述。

中断服务例程

我们这里提到的中断服务例程(Interrupt Service Routine)与以前所提到的中断处理程序(Interrupt handler)是不同的概念。具体来说,中断处理程序相当于某个中断向量的总处理程序,例如IRQ0x05_interrupt(),是中断号 5(向量为 37)的总处理程序,如果这个 5 号中断由网卡和图形卡共享,则网卡和图形卡分别有其相应的中断服务例程。每个中断服务例程都有相同的参数:

  • IRQ:中断号;
  • dev_id:设备标识符,其类型为void*
  • regs:指向内核堆栈区的指针,堆栈中存放的是中断发生后所保存的寄存器。

中断请求队列的初始化

在设备驱动程序的初始化阶段,必须通过request_irq()函数将对应的中断服务例程挂入中断请求队列。request_irq()函数的代码在/arch/i386/kernel/irq.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
 /*
* request_irq - allocate an interrupt line
* @irq: Interrupt line to allocate
* @handler: Function to be called when the IRQ occurs
* @irqflags: Interrupt type flags
* @devname: An ascii name for the claiming device
* @dev_id: A cookie passed back to the handler function
*
* This call allocates interrupt resources and enables the
* interrupt line and IRQ handling. From the point this
* call is made your handler function may be invoked. Since
* your handler function must clear any interrupt the board
* raises, you must take care both to initialise your hardware
* and to set up the interrupt handler in the right order.
*
* Dev_id must be globally unique. Normally the address of the
* device data structure is used as the cookie. Since the handler
* receives this value it makes sense to use it.
*
* If your interrupt is shared you must pass a non NULL dev_id
* as this is required when freeing the interrupt.
*
* Flags:
*
* SA_SHIRQ Interrupt is shared
*
* SA_INTERRUPT Disable local interrupts while processing
*
* SA_SAMPLE_RANDOM The interrupt can be used for entropy
*
*/

int request_irq(unsigned int irq,
void (*handler)(int, void *, struct pt_regs *),
unsigned long irqflags,
const char * devname,
void *dev_id)
{
int retval;
struct irqaction * action;

#if 1
/*
* Sanity-check: shared interrupts should REALLY pass in
* a real dev-ID, otherwise we'll have trouble later trying
* to figure out which interrupt is which (messes up the
* interrupt freeing logic etc).
*/
if (irqflags & SA_SHIRQ) {
if (!dev_id)
printk("Bad boy: %s (at 0x%x) called us without a dev_id!\n", devname, (&irq)[-1]);
}
#endif

if (irq >= NR_IRQS)
return -EINVAL;
if (!handler)
return -EINVAL;

action = (struct irqaction *)kmalloc(sizeof(struct irqaction), GFP_KERNEL);
if (!action)
return -ENOMEM;
action->handler = handler;
action->flags = irqflags;
action->mask = 0;
action->name = devname; /*对 action 进行初始化*/
action->next = NULL;
action->dev_id = dev_id;

retval = setup_irq(irq, action);
if (retval)
kfree(action);
return retval;
}

编码作者对此函数给出了比较详细的描述。其中主要语句就是对setup_irq()函数的调用,该函数才是真正对中断请求队列进行初始化的函数(有所简化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int setup_irq(unsigned int irq, struct irqaction * new)
{
int shared = 0;
unsigned long flags;
struct irqaction *old, **p;
irq_desc_t *desc = irq_desc + irq; /* 获得 irq 的描述符 */

/* 对中断请求队列的操作必须在临界区中进行 */
spin_lock_irqsave(&desc->lock,flags); /* 进入临界区 */
p = &desc->action; /* 让 p 指向 irq 描述符的 action 域,即 irqaction 链表的首部 */
if ((old = *p) != NULL) { /*如果这个链表不为空*/
/* Can't share interrupts unless both agree to */
if (!(old->flags & new->flags & SA_SHIRQ)) {
spin_unlock_irqrestore(&desc->lock,flags);
return -EBUSY;
}
/* 把新的中断服务例程加入到 irq 中断请求队列*/
do {
p = &old->next;
old = *p;
} while (old);
shared = 1;
}
*p = new;
if (!shared) { /*如果 irq 不被共享 */
desc->depth = 0; /* 启用这条 irq 线 */
desc->status &= ~(IRQ_DISABLED | IRQ_AUTODETECT | IRQ_WAITING);
desc->handler->startup(irq); /* 即调用 startup_8259A_irq()函数 */
}
spin_unlock_irqrestore(&desc->lock,flags); /* 退出临界区 */
register_irq_proc(irq); /* 在 proc 文件系统中显示 irq 的信息 */
return 0;
}

下面我们举例说明对这两个函数的使用。

对 register_irq()函数的使用

在驱动程序初始化或者在设备第一次打开时,首先要调用该函数,以申请使用该 irq。其中参数 handler 指的是要挂入到中断请求队列中的中断服务例程。假定一个程序要对/dev/fd0/(第一个软盘对应的设备)设备进行访问,有两种方式,一是直接访问/dev/fd0/,另一种是在系统上安装一个文件系统,我们这里假定采用第一种。通常将 IRQ6 分配给软盘控制器,给定这个中断号 6,软盘驱动程序就可以发出下列请求,以将其中断服务例程挂入中断请求队列:

1
request_irq(6, floppy_interrupt, SA_INTERRUPT|SA_SAMPLE_RANDOM, "floppy", NULL);

我们可以看到,floppy_interrupt()中断服务例程运行时必须禁用中断(设置了SA_INTERRUPT标志),并且不允许共享这个 IRQ(清SA_SHIRQ标志)。在关闭设备时,必须通过调用free_irq()函数释放所申请的中断请求号。例如,当软盘操作终止时(或者终止对/dev/fd0/的 I/O 操作,或者卸载这个文件系统),驱动程序就放弃这个中断号:

1
free_irq(6, NULL);

对 setup_ irq()函数的使用

在系统初始化阶段,内核为了初始化时钟中断设备 irq0 描述符,在time_init()函数中使用了下面的语句:

1
2
struct irqaction irq0 = {timer_interrupt, SA_INTERRUPT, 0, "timer", NULL};
setup_irq(0, &irq0);

首先,初始化类型为irqactionirq0变量,把handler域设置成timer_interrupt()函数的地址,flags域设置成SA_INTERRUPTname域设置成”timer”,最后一个域设置成 NULL 以表示没有用dev_id值。接下来,内核调用setup_x86_irq(),把irq0插入到IRQ0的中断请求队列。

类似地,内核初始化与 IRQ2 和 IRQ13 相关的 irqaction 描述符,并把它们插入到相应的请求队列中,在init_IRQ()函数中有下面的语句:

1
2
3
4
struct irqaction irq2 = {no_action, 0, 0, "cascade", NULL};
struct irqaction irq13 = {math_error_irq, 0, 0, "fpu", NULL};
setup_x86_irq(2, &irq2);
setup_x86_irq(13, &irq13);

中断处理

如何执行中断处理程序正是我们本节要关心的主要内容

中断和异常处理的硬件处理

当 CPU 执行了当前指令之后,CS 和 EIP 这对寄存器中所包含的内容就是下一条将要执行指令的逻辑地址。在对下一条指令执行前,CPU 先要判断在执行当前指令的过程中是否发生了中断或异常。如果发生了一个中断或异常,那么 CPU 将做以下事情。

  • 确定所发生中断或异常的向量 i(在 0~255 之间)。
  • 通过 IDTR 寄存器找到 IDT 表,读取 IDT 表第 i 项(或叫第 i 个门)。
  • 分两步进行有效性检查:
    • 首先是“段”级检查,将 CPU 的当前特权级 CPL(存放在 CS寄存器的最低两位)与 IDT 中第 i 项段选择符中的 DPL 相比较,如果 DPL(3)大于 CPL(0),就产生一个“通用保护”异常(中断向量 13),因为中断处理程序的特权级不能低于引起中断的程序的特权级。
    • 然后是“门”级检查,把 CPL 与 IDT 中第 i 个门的 DPL 相比较,如果 CPL 大于DPL,也就是当前特权级(3)小于这个门的特权级(0),CPU 就不能“穿过”这个门,于是产生一个“通用保护”异常,这是为了避免用户应用程序访问特殊的陷阱门或中断门。
  • 检查是否发生了特权级的变化。当中断发生在用户态(特权级为 3),而中断处理程序运行在内核态(特权级为 0),特权级发生了变化,所以会引起堆栈的更换。也就是说,从用户堆栈切换到内核堆栈。而当中断发生在内核态时,即 CPU 在内核中运行时,则不会更换堆栈,如图 3.6 所示。

从图 3.5 中可以看出,当从用户态堆栈切换到内核态堆栈时,先把用户态堆栈的值压入中断程序的内核态堆栈中,同时把 EFLAGS 寄存器自动压栈,然后把被中断进程的返回地址压入堆栈。如果异常产生了一个硬件错误码,则将它也保存在堆栈中。如果特权级没有发生变化,则压入栈中的内容如图 3.6 中。

SS:ESP 的值从当前进程的 TSS 中获得,也就是获得当前进程的内核栈指针,因为此时中断处理程序成为当前进程的一部分,代表当前进程在运行。CS:EIP 的值就是 IDT 表中第 i 项门描述符的段选择符和偏移量的值,此时,CPU 就跳转到了中断或异常处理程序。

Linux 对中断的处理

把所有的操作都放进中断处理程序本身并不合适。需要时间长的、非重要的操作应该推后,因为当一个中断处理程序正在运行时,相应的 IRQ 中断线上再发出的信号就会被忽略。更重要的是,中断处理程序是代表进程执行的,它所代表的进程必须总处于TASK_RUNNING状态,否则,就可能出现系统僵死情形。Linux把一个中断要执行的操作分为下面的 3 类。

  1. 紧急的(Critical):这样的操作诸如:中断到来时中断控制器做出应答,对中断控制器或设备控制器重新编程,或者对设备和处理器同时访问的数据结构进行修改。这些操作都是紧急的,应该被很快地执行,也就是说,紧急操作应该在一个中断处理程序内立即执行,而且是在禁用中断的状态下。
  2. 非紧急的(Noncritical):这样的操作如修改那些只有处理器才会访问的数据结构。这些操作也要很快地完成,因此,它们由中断处理程序立即执行,但在启用中断的状态下。
  3. 非紧急可延迟的(Noncritical deferrable):这样的操作如,把一个缓冲区的内容拷贝到一些进程的地址空间。这些操作可能被延迟较长的时间间隔而不影响内核操作。非紧急可延迟的操作由一些被称为“下半部分”(bottom halves)的函数来执行。

所有的中断处理程序都执行 4 个基本的操作:

  • 在内核栈中保存 IRQ 的值和寄存器的内容;
  • 给与 IRQ 中断线相连的中断控制器发送一个应答,这将允许在这条中断线上进一步发出中断请求;
  • 执行共享这个 IRQ 的所有设备的中断服务例程(ISR);
  • 跳到ret_from_intr()的地址后终止。

与堆栈有关的常量、数据结构及宏

常量定义

下面这些常量定义了进入中断处理程序时,相关寄存器与堆栈指针(ESP)的相对位置,图 3.7 给出了在相应位置上所保存的寄存器内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
EBX = 0x00
ECX= 0x04
EDX= 0x08
ESI= 0x0C
EDI= 0x10
EB = 0x14
EAX= 0x18
DS= 0x1C
ES = 0x20
ORIG_EAX = 0x24
EIP = 0x28
CS = 0x2C
EFLAGS = 0x30
OLDESP= 0x34
OLDSS = 0x38

存放在栈中的寄存器结构 pt_regs

在内核中,很多函数的参数是pt_regs数据结构,定义在include/i386/ptrace.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct pt_regs {
long ebx;
long ecx;
long edx;
long esi;
long edi;
long ebp;
long eax;
int xds;
int xes;
long orig_eax;
long eip;
int xcs;
long eflags;
long esp;
int xss;
};

把这个结构与内核栈的内容相比较,会发现堆栈的内容是这个数据结构的一个映像。

保存现场的宏 SAVE_ALL

在中断发生前夕,要把所有相关寄存器的内容都保存在堆栈中,这是通过SAVE_ALL宏完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define SAVE_ALL \
cld; \
pushl %es; \
pushl %ds; \
pushl %eax; \
pushl %ebp; \
pushl %edi; \
pushl %esi; \
pushl %edx; \
pushl %ecx; \
pushl %ebx; \
movl $(__KERNEL_DS),%edx; \
movl %edx,%ds; \
movl %edx,%es;

该宏执行以后,堆栈内容如图 3.7 所示。把这个宏与图 3.6 结合起来就很容易理解图 3.7,在此对该宏再给予解释:
• CPU 在进入中断处理程序时自动将用户栈指针(如果更换堆栈)、EFLAGS 寄存器及返回地址一同压入堆栈。
• 段寄存器 DS 和 ES 原来的内容入栈,然后装入内核数据段描述符__KERNEL_DS(定义为 0x18),内核段的 DPL 为 0。

恢复现场的宏 RESTORE_ALL

当从中断返回时,恢复相关寄存器的内容,这是通过RESTORE_ALL宏完成的:

1
2
3
4
5
6
7
8
9
10
11
12
#define RESTORE_ALL \
popl %ebx; \
popl %ecx; \
popl %edx; \
popl %esi; \
popl %edi; \
popl %ebp; \
popl %eax; \
1: popl %ds; \
2: popl %es; \
addl $4,%esp; \
3: iret;

可以看出,RESTORE_ALLSAVE_ALL遥相呼应。当执行到iret指令时,内核栈又恢复到刚进入中断门时的状态,并使 CPU 从中断返回。

将当前进程的 task_struct 结构的地址放在寄存器中

1
2
3
#define GET_CURRENT(reg) \
movl $-8192, reg; \
andl %esp, reg

中断处理程序的执行

CPU 从中断控制器的一个端口取得中断向量 I,然后根据 I 从中断描述符表 IDT 中找到相应的表项,也就是找到相应的中断门。因为这是外部中断,不需要进行“门级”检查,CPU就可以从这个中断门获得中断处理程序的入口地址,假定为IRQ0x05_interrupt。因为这里假定中断发生时 CPU 运行在用户空间(CPL=3),而中断处理程序属于内核(DPL=0),因此,要进行堆栈的切换。也就是说,CPU 从 TSS 中取出内核栈指针,并切换到内核栈(此时栈还为空)。当 CPU 进入IRQ0x05_interrupt时,内核栈中除用户栈指针、EFLAGS的内容以及返回地址外再无其他内容。另外,由于 CPU 进入的是中断门(而不是陷阱门),因此,这条中断线已被禁用,直到重新启用。

我们用IRQn_interrupt来表示从IRQ0x01_interruptIRQ0x0f_interrupt任意一个中断处理程序。这个中断处理程序实际上要调用do_IRQ(),而do_IRQ()要调用handle_IRQ_event()函数;最后这个函数才真正地执行中断服务例程(ISR)。

中断处理程序 IRQn_interrupt

我们首先看一下从IRQ0x01_interruptIRQ0x0f_interrupt的这 16 个函数是如何定义的,在i8259.c中定义了如下宏:

1
2
3
4
5
6
7
8
9
10
#define BI(x,y) \
BUILD_IRQ(x##y)

#define BUILD_16_IRQS(x) \
BI(x,0) BI(x,1) BI(x,2) BI(x,3) \
BI(x,4) BI(x,5) BI(x,6) BI(x,7) \
BI(x,8) BI(x,9) BI(x,a) BI(x,b) \
BI(x,c) BI(x,d) BI(x,e) BI(x,f)

BUILD_16_IRQS(0x0)

经过 gcc 的预处理,宏定义BUILD_16_IRQS(0x0)会被展开成BUILD_IRQ(0x00)BUILD_IRQ(0x0f)BUILD_IRQ宏是一段嵌入式汇编代码(在/include/i386/hw_irq.h中),为了有助于理解,我们把它展开成下面的汇编语言片段:

1
2
3
IRQn_interrupt:
pushl $n-256
jmp common_interrupt

把中断号减 256 的结果保存在栈中,这就是进入中断处理程序后第一个压入堆栈的值,也就是堆栈中ORIG_EAX的值。这是一个负数,正数留给系统调用使用。对于每个中断处理程序,唯一不同的就是压入栈中的这个数。然后,所有的中断处理程序都跳到一段相同的代码common_interrupt。这段代码可以在BUILD_COMMON_IRQ宏中找到,同样,我们略去其嵌入式汇编源代码,而把这个宏展开成下列的汇编语言片段:

1
2
3
4
common_interrupt:
SAVE_ALL
call do_IRQ
jmp ret_from_intr

SAVE_ALL宏已经在前面介绍过,它把中断处理程序会使用的所有 CPU 寄存器都保存在栈中。然后,BUILD_COMMON_IRQ宏调用do_IRQ()函数,因为通过 CALL 调用这个函数,因此,该函数的返回地址被压入栈。当执行完do_IRQ(),就跳转到ret_from_intr()地址。

do_IRQ()函数

do_IRQ()这个函数处理所有外设的中断请求。当这个函数执行时,内核栈从栈顶到栈底包括:

  • do_IRQ()的返回地址;
  • SAVE_ALL推进栈中的一组寄存器的值;
  • ORIG_EAX(即 n-256);
  • CPU 自动保存的寄存器。

该函数的实现用到中断线的状态,下面给予具体说明:

1
2
3
4
5
6
7
8
9
#define IRQ_INPROGRESS 1 /* 正在执行这个 IRQ 的一个处理程序*/
#define IRQ_DISABLED 2 /* 由设备驱动程序已经禁用了这条 IRQ 中断线 */
#define IRQ_PENDING 4 /* 一个 IRQ 已经出现在中断线上,且被应答,但还没有为它提供服务 */
#define IRQ_REPLAY 8 /* 当 Linux 重新发送一个已被删除的 IRQ 时 */
#define IRQ_AUTODETECT 16 /* 当进行硬件设备探测时,内核使用这条 IRQ 中断线 */
#define IRQ_WAITING 32 /*当对硬件设备进行探测时,设置这个状态以标记正在被测试的 irq */
#define IRQ_LEVEL 64 /* IRQ level triggered */
#define IRQ_MASKED 128 /* IRQ masked - shouldn't be seen again */
#define IRQ_PER_CPU 256 /* IRQ is per CPU */

这 9 个状态的前 6 个状态比较常用,因此我们给出了具体解释。另外,我们还看到每个状态的常量是 2 的幂次方。最大值为 256(2^8), 因此可以用一个字节来表示这 9 个状态,其中每一位对应一个状态。

该函数在arch/i386/kernel/irq.c中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
asmlinkage unsigned int do_IRQ(struct pt_regs regs)
{
/* 函数返回 0 则意味着这个 irq 正在由另一个 CPU 进行处理,或这条中断线被禁用 */
int irq = regs.orig_eax & 0xff; /* 还原中断号 */
int cpu = smp_processor_id(); /* 获得 CPU 号 */
irq_desc_t *desc = irq_desc + irq;/* 在 irq_desc[]数组中获得 irq 的描述符 */
struct irqaction * action;
unsigned int status;

kstat.irqs[cpu][irq]++;
spin_lock(&desc->lock); /* 针对多处理机加锁 */
desc->handler->ack(irq); /* CPU 对中断请求给予确认 */

status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING);
status |= IRQ_PENDING; /* we _want_ to handle it */

action = NULL;
if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) {
action = desc->action;
status &= ~IRQ_PENDING; /* we commit to handling */
status |= IRQ_INPROGRESS; /* we are handling it */
}
desc->status = status;
if (!action)
goto out;
for (;;) {
spin_unlock(&desc->lock); /*进入临界区*
handle_IRQ_event(irq, &regs, action);
spin_lock(&desc->lock); /*出临界区*/

if (!(desc->status & IRQ_PENDING))
break;
desc->status &= ~IRQ_PENDING;
}
desc->status &= ~IRQ_INPROGRESS;
out:
/*
* The ->end() handler has to deal with interrupts which got
* disabled while the handler was running.
*/
desc->handler->end(irq);
spin_unlock(&desc->lock);
if (softirq_pending(cpu))
do_softirq(); /* 处理软中断 */
return 1;
}

下面对这个函数进行进一步的讨论。

当执行到for (;;)这个无限循环时,就准备对中断请求队列进行处理,这是由handle_IRQ_event()函数完成的。因为中断请求队列为一临界资源,因此在进入这个函数前要加锁。

handle_IRQ_event()函数的主要代码片段为

1
2
3
4
5
6
7
8
if (!(action->flags & SA_INTERRUPT))
__sti(); /* 关中断 */
do {
status |= action->flags;
action->handler(irq, action->dev_id, regs);
action = action->next;
} while (action);
__cli(); /* 开中断 */

这个循环依次调用请求队列中的每个中断服务例程。

经验表明,应该避免在同一条中断线上的中断嵌套,内核通过IRQ_PENDING标志位的应用保证了这一点。当do_IRQ()执行到for(;;)循环时,desc->status中的IRQ_PENDING的标志位肯定为 0。当 CPU 执行完handle_IRQ_event()函数返回时,如果这个标志位仍然为 0,那么循环就此结束。如果这个标志位变为 1,那就说明这条中断线上又有中断产生(对单 CPU 而言),所以循环又执行一次。通过这种循环方式,就把可能发生在同一中断线上的嵌套循环化解为“串行”。

不同的 CPU 不允许并发地进入同一中断服务例程,否则,那就要求所有的中断服务例程必须是“可重入”的纯代码。可重入代码的设计和实现就复杂多了,因此,Linux 在设计内核时巧妙地“避难就易”,以解决问题为主要目标。

在循环结束后调用desc->handler->end()函数,具体来说,如果没有设置IRQ_DISABLED标志位,就调用低级函数enable_8259A_irq()来启用这条中断线。如果这个中断有后半部分,就调用do_softirq()执行后半部分。

从中断返回

从前面的讨论我们知道,do_IRQ()这个函数处理所有外设的中断请求。这个函数执行的时候,内核栈栈顶包含的就是do_IRQ()的返回地址,这个地址指向ret_from_intr。实际上,ret_from_intr是一段汇编语言的入口点,为了描述简单起见,我们以函数的形式提及它。

虽然我们这里讨论的是中断的返回,但实际上中断、异常及系统调用的返回是放在一起实现的,因此,我们常常以函数的形式提到下面这 3 个入口点。

  • ret_from_intr():终止中断处理程序。
  • ret_from_sys_call():终止系统调用,即由 0x80 引起的异常。
  • ret_from_exception():终止除了 0x80 的所有异常。

在相关的计算机课程中,我们已经知道从中断返回时 CPU 要做的事情,下面我们来看一下 Linux 内核的具体实现代码(在entry.S中):

1
2
3
4
5
6
7
8
ENTRY(ret_from_intr)
GET_CURRENT(%ebx)
ret_from_exception:
movl EFLAGS(%esp),%eax # mix EFLAGS and CS
movb CS(%esp),%al
testl $(VM_MASK | 3),%eax # return to VM86 mode or non-supervisor?
jne ret_from_sys_call
jmp restore_all

这里的GET_CURRENT(%ebx)将当前进程task_struct结构的指针放入寄存器 EBX 中。然后两条mov指令是为了把中断发生前夕 EFALGS寄存器的高 16 位与代码段 CS 寄存器的内容拼揍成 32 位的长整数,其目的是要检验:

  • 中断前夕 CPU 是否够运行于 VM86 模式;
  • 中断前夕 CPU 是运行在用户空间还是内核空间。

VM86 模式是为在 i386 保护模式下模拟运行 DOS 软件而设置的,EFALGS 寄存器高 16 位中有个标志位表示 CPU 是否运行在 VM86 模式,我们在此不予详细讨论。CS 的最低两位表示中断发生时 CPU 的运行级别 CPL,若这两位为 3,说明中断发生于用户空间。如果中断发生在内核空间,则控制权直接转移到restore_all。如果中断发生于用户空间(或 VM86 模式),则转移到ret_from_sys_call

1
2
3
4
5
6
7
8
9
10
11
ENTRY(ret_from_sys_call)
cli # need_resched and signals atomic test
cmpl $0,need_resched(%ebx)
jne reschedule
cmpl $0,sigpending(%ebx)
jne signal_return
restore_all:
RESTORE_ALL
reschedule:
call SYMBOL_NAME(schedule) # test
jmp ret_from_sys_call

进入ret_from_sys_call后,首先关中断,也就是说,执行这段代码时 CPU 不接受任何中断请求。然后,看调度标志是否为非 0,其中常量need_resched定义为 20,need_resched(%ebx)表示当前进程task_struct结构中偏移量need_resched处的内容,如果调度标志为非 0,说明需要进行调度,则去调用schedule()函数进行进程调度。

同样,如果当前进程的task_struct结构中的sigpending标志为非 0,则表示该进程有信号等待处理,要先处理完这些信号后才从中断返回。处理完信号,控制权还是返回到restore_all

中断的后半部分处理机制

为什么把中断分为两部分来处理

中断服务例程一般都是在中断请求关闭的条件下执行的,以避免嵌套而使中断控制复杂化。但是,中断是一个随机事件,它随时会到来,如果关中断的时间太长,CPU 就不能及时响应其他的中断请求,从而造成中断的丢失。因此,内核把中断处理分为两部分:前半部分(top half)和后半部分(bottom half),前半部分内核立即执行,而后半部分留着稍后处理

首先,一个快速的“前半部分”来处理硬件发出的请求,它必须在一个新的中断产生之前终止。通常情况下,除了在设备和一些内存缓冲区之间移动或传送数据,确定硬件是否处于健全的状态之外,这一部分做的工作很少。

然后,就让一些与中断处理相关的有限个函数作为“后半部分”来运行:

  • 允许一个普通的内核函数,而不仅仅是服务于中断的一个函数,能以后半部分的身份来运行;
  • 允许几个内核函数合在一起作为一个后半部分来运行。

后半部分运行时是允许中断请求的,而前半部分运行时是关中断的,这是二者之间的主要区别。

实现机制

Linux 内核为将中断服务分为两部分提供了方便,并设立了相应的机制。在以前的内核中,这个机制就叫 bottom half(简称 bh),但在 2.4 版中有了新的发展和推广,叫做软中断(softirq)机制。

bh 机制

以前内核中的 bh 机制设置了一个函数指针数组bh_base[],它把所有的后半部分都组织起来,其大小为 32,数组中的每一项就是一个后半部分,即一个bh函数。同时,又设置了两个 32 位无符号整数bh_activebh_mask,每个无符号整数中的一位对应着bh_base[]中的一个元素,如图 3.10 所示。

在 2.4 以前的内核中,每次执行完do_IRQ()中的中断服务例程以后,以及每次系统调用结束之前,就在一个叫do_bottom_half()的函数中执行相应的bh函数。

do_bottom_half()中对bh函数的执行是在关中断的情况下进行的,也就是说对bh的执行进行了严格的“串行化”,这种方式简化了bh的设计,这是因为对单 CPU 来说,bh函数的执行可以不嵌套;而对于多 CPU 来说,在同一时间内最多只允许一个 CPU 执行bh函数。

bh函数的串行化是针对所有 CPU 的,根本发挥不出多 CPU 的优势。

软中断机制

软中断机制也是推迟内核函数的执行,然而,与bh函数严格地串行执行相比,软中断却在任何时候都不需要串行化。同一个软中断的两个实例完全有可能在两个 CPU 上同时运行。当然,在这种情况下,软中断必须是可重入的。

Tasklet 机制

另一个类似于bh的机制叫做 tasklet。Tasklet 建立在软中断之上,但与软中断的区别是,同一个 tasklet 只能运行在一个 CPU 上,而不同的 tasklet 可以同时运行在不同的 CPU上。在这种情况下,tasklet 就不需要是可重入的,因此,编写 tasklet 比编写一个软中断要容易。

数据结构的定义

在具体介绍软中断处理机制之前,我们先介绍一下相关的数据结构,这些数据结构大部分都在/include/linux/interrupt.h中。

与软中断相关的数据结构

软中断本身是一种机制,同时也是一种基本框架。在这个框架中,既包含了bh机制,也包含了 tasklet 机制。

1
2
3
4
5
6
enum {
HI_SOFTIRQ=0,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
TASKLET_SOFTIRQ
};

内核中用枚举类型定义了 4 种类型的软中断,其中NET_TX_SOFTIRQNET_RX_SOFTIRQ两个软中断是专为网络操作而设计的,而HI_SOFTIRQTASKLET_SOFTIRQ是针对bh 和tasklet 而设计的软中断。一般情况下,不要再分配新的软中断。

软中断向量

1
2
3
4
5
struct softirq_action {
void (*action)(struct softirq_action *);
void *data;
}
static struct softirq_action softirq_vec[32] __cacheline_aligned;

从定义可以看出,内核定义了 32 个软中断向量,每个向量指向一个函数,但实际上,内核目前只定义了上面的 4 个软中断,而我们后面主要用到的为HI_SOFTIRQTASKLET_SOFTIRQ两个软中断。

软中断控制/状态结构:softirq_vec[]是个全局量,系统中每个 CPU 所看到的是同一个数组。但是,每个 CPU各有其自己的“软中断控制/状态”结构,这些数据结构形成一个以 CPU 编号为下标的数组irq_stat[](定义在include/i386/hardirq.h中)

1
2
3
4
5
6
7
8
9
typedef struct {
unsigned int __softirq_pending;
unsigned int __local_irq_count;
unsigned int __local_bh_count;
unsigned int __syscall_count;
struct task_struct * __ksoftirqd_task; /* waitqueue is too large */
unsigned int __nmi_count; /* arch dependent */
} ____cacheline_aligned irq_cpustat_t;
irq_cpustat_t irq_stat[NR_CPUS];

irq_stat[]数组也是一个全局量,但是各个 CPU 可以按其自身的编号访问相应的域。于是,内核定义了如下宏(在include/linux/irq_cpustat.h中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifdef CONFIG_SMP
#define __IRQ_STAT(cpu, member) (irq_stat[cpu].member)
#else
#define __IRQ_STAT(cpu, member) ((void)(cpu), irq_stat[0].member)
#endif
/* arch independent irq_stat fields */
#define softirq_pending(cpu) __IRQ_STAT((cpu), __softirq_pending)
#define local_irq_count(cpu) __IRQ_STAT((cpu), __local_irq_count)
#define local_bh_count(cpu) __IRQ_STAT((cpu), __local_bh_count)
#define syscall_count(cpu) __IRQ_STAT((cpu), __syscall_count)
#define ksoftirqd_task(cpu) __IRQ_STAT((cpu), __ksoftirqd_task)
/* arch dependent irq_stat fields */
#define nmi_count(cpu) __IRQ_STAT((cpu), __nmi_count) /* i386, ia64
*/

与 tasklet 相关的数据结构

与 bh 函数相比,tasklet 是“多序”的 bh 函数。内核中用tasklet_task来定义一个tasklet:

1
2
3
4
5
6
7
struct tasklet_struct {
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
void (*func)(unsigned long);
unsigned long data;
};

从定义可以看出,tasklet_struct是一个链表结构,结构中的函数指针func指向其服务程序。内核中还定义了一个以 CPU 编号为下标的数组tasklet_vec[]tasklet_hi_vec[]

1
2
3
4
5
6
struct tasklet_head {
struct tasklet_struct *list;
} __attribute__ ((__aligned__(SMP_CACHE_BYTES)));

extern struct tasklet_head tasklet_vec[NR_CPUS];
extern struct tasklet_head tasklet_hi_vec[NR_CPUS];

这两个数组都是tasklet_head结构数组,每个tasklet_head结构就是一个tasklet_struct结构的队列头。

与 bh 相关的数据结构

前面我们提到, bh 建立在 tasklet 之上,更具体地说,对一个 bh 的描述也是tasklet_struct结构,只不过执行机制有所不同。因为在不同的 CPU 上可以同时执行不同的tasklet,而任何时刻,即使在多个 CPU 上,也只能有一个 bh 函数执行。

bh 的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum {
TIMER_BH = 0, /* 定时器 */
TQUEUE_BH, /* 周期性任务队列 */
DIGI_BH, /* DigiBoard PC/Xe */
SERIAL_BH, /* 串行接口 */
RISCOM8_BH, /* RISCom/8 */
SPECIALIX_BH, /* Specialix IO8+ */
AURORA_BH, /* Aurora 多端口卡(SPARC)*/
ESP_BH, /* Hayes ESP 串行卡 */
SCSI_BH, /* SCSI 接口*/
IMMEDIATE_BH, /* 立即任务队列*/
CYCLADES_BH, /* Cyclades Cyclom-Y 串行多端口 */
CM206_BH, /* CD-ROM Philips/LMS cm206 磁盘 */
JS_BH, /* 游戏杆(PC IBM)*/
MACSERIAL_BH, /* Power Macintosh 的串行端口 */
ISICOM_BH /* MultiTech 的 ISI 卡*/
};

bh 的组织结构:在 2.4 以前的版本中,把所有的 bh 用一个bh_base[]数组组织在一起,数组的每个元素指向一个bh函数:

1
static void (*bh_base[32])(void);

2.4 版中保留了上面这种定义形式,但又定义了另外一种形式:

1
struct tasklet_struct bh_task_vec[32];

这也是一个有 32 个元素的数组,但数组的每个元素是一个tasklet_struct结构,数组的下标就是上面定义的枚举类型中的序号。

软中断、bh 及 tasklet 的初始化

Tasklet 的初始化

Tasklet 的初始化是由tasklet_init()函数完成的:

1
2
3
4
5
6
7
void tasklet_init(struct tasklet_struct *t, void (*func)(unsigned long), unsigned long data) {
t->next = NULL;
t->state = 0;
atomic_set(&t->count, 0);
t->func = func;
t->data = data;
}

其中,atomic_set()为原子操作,它把t->count置为 0。

软中断的初始化

首先通过open_softirq()函数打开软中断:

1
2
3
4
void open_softirq(int nr, void (*action)(struct softirq_action*), void *data) {
softirq_vec[nr].data = data;
softirq_vec[nr].action = action;
}

然后,通过softirq_init()函数对软中断进行初始化:

1
2
3
4
5
6
7
8
void __init softirq_init()
{
int i;
for (i=0; i<32; i++)
tasklet_init(bh_task_vec+i, bh_action, i);
open_softirq(TASKLET_SOFTIRQ, tasklet_action, NULL);
open_softirq(HI_SOFTIRQ, tasklet_hi_action, NULL);
}

对于 bh 的 32 个tasklet_struct,调用tasklet_init以后,它们的函数指针func全部指向bh_action()函数,也就是建立了 bh 的执行机制,但具体的 bh 函数还没有与之挂勾,就像具体的中断服务例程还没有挂入中断服务队列一样。同样,调用open_softirq()以后,软中断TASKLET_SOFTIRQ的服务例程为tasklet_action(),而软中断HI_SOFTIRQ的服务例程为tasklet_hi_action()

Bh 的初始化

bh 的初始化是由init_bh()完成的:

1
2
3
4
5
void init_bh(int nr, void (*routine)(void))
{
bh_base[nr] = routine;
mb();
}

这里调用的函数mb()与 CPU 中执行指令的流水线有关。下面看一下几个具体 bh 的初始化(在kernel/sched.c中):

1
2
3
init_bh(TIMER_BH,timer_bh);
init_bh(TUEUE_BH,tqueue_bh);
init_bh(IMMEDIATE_BH,immediate_bh);

初始化以后,bh_base[TIMER_BH]处理定时器队列timer_bh,每个时钟中断都会激活TIMER_BH,这意味着大约每隔 10ms 这个队列运行一次。bh_base[TUEUE_BH]处理周期性的任务队列tqueue_bh,而bh_base[IMMEDIATE_BH]通常被驱动程序所调用,请求某个设备服务的内核函数可以链接到IMMEDIATE_BH所管理的队列immediate_bh中,在该队列中排队等待。

后半部分的执行

Bh 的处理

当需要执行一个特定的 bh 函数(例如bh_base[TIMER_BH]())时,首先要提出请求,这是由mark_bh()函数完成的(在Interrupt.h中):

1
2
3
static inline void mark_bh(int nr) {
tasklet_hi_schedule(bh_task_vec+nr);
}

从上面的介绍我们已经知道,bh_task_vec[]每个元素为tasklet_struct结构,函数的指针func指向bh_action()

进程描述

进程和程序

首先我们对进程作一明确定义:所谓进程是由正文段(Text)、用户数据段(User Segment)以及系统数据段(System Segment)共同组成的一个执行环境。程序是一个静态的实体。这里,对可执行映像做进一步解释,可执行映像就是一个可执行文件的内容。程序装入内存后就可以运行了:在指令指针寄存器的控制下,不断地将指令取至CPU运行。

Linux是一个多任务操作系统,也就是说,可以有多个程序同时装入内存并运行,操作系统为每个程序建立一个运行环境即创建进程,每个进程拥有自己的虚拟地址空间,它们之间互不干扰,即使要相互作用(例如多个进程合作完成某个工作),也要通过内核提供的进程间通信机制(IPC)。Linux内核支持多个进程虚拟地并发执行,这是通过不断地保存和切换程序的运行环境而实现的,选择哪个进程运行是由调度程序决定的。

进程是一个动态实体,由 3 个独立的部分组成。

  1. 正文段(Text):存放被执行的机器指令。这个段是只读的,它允许系统中正在运行的两个或多个进程之间能够共享这一代码。
  2. 用户数据段(User Segment):存放进程在执行时直接进行操作的所有数据,包括进程使用的全部变量在内。显然,这里包含的信息可以被改变。虽然进程之间可以共享正文段,但是每个进程需要有它自己的专用用户数据段。
  3. 系统数据段(System Segment):该段有效地存放程序运行的环境。事实上,这正是程序和进程的区别所在。这一部分存放有进程的控制信息。系统中有许多进程,操作系统要管理它们、调度它们运行,就是通过这些控制信息。Linux为每个进程建立了task_struct数据结构来容纳这些控制信息。

总之,进程是一个程序完整的执行环境。该环境是由正文段、用户数据段、系统数据段的信息交织在一起组成的。

Linux中的进程概述

Linux中的每个进程由一个task_struct数据结构来描述,在Linux中,任务(Task)和进程(Process)是两个相同的术语,task_struct其实就是通常所说的“进程控制块”即PCB。task_struct容纳了一个进程的所有信息,是系统对进程进行控制的唯一手段,也是最有效的手段。

Linux支持多处理机(SMP),所以系统中允许有多个CPU。Linux作为多处理机操作系统时,系统中允许的最大CPU个数为 32。和其他操作系统类似,Linux也支持两种进程:普通进程和实时进程。实时进程具有一定程度上的紧迫性,要求对外部事件做出非常快的响应;而普通进程则没有这种限制。所以,实时进程要比普通进程优先运行。

总之,包含进程所有信息的task_struct数据结构是比较庞大的,但是该数据结构本身并不复杂,我们将它的所有域按其功能可做如下划分:

  • 进程状态(State);
  • 进程调度信息(Scheduling Information);
  • 各种标识符(Identifiers);
  • 进程通信有关信息(IPC,Inter_Process Communication);
  • 时间和定时器信息(Times and Timers);
  • 进程链接信息(Links);
  • 文件系统信息(File System);
  • 虚拟内存信息(Virtual Memory);
  • 页面管理信息(page);
  • 对称多处理器(SMP)信息;
  • 和处理器相关的环境(上下文)信息(Processor Specific Context);
  • 其他信息。

下面我们对task_struct结构进行具体描述。

task_struct`结构描述

进程状态(State)

进程执行时,它会根据具体情况改变状态。进程状态是调度和对换的依据。Linux中的进程主要有如下状态,如表所示。

内核表示 含义
TASK_RUNNING 可运行
TASK_INTERRUPTIBLE 可中断的等待状态
TASK_UNINTERRUPTIBLE 不可中断的等待状态
TASK_ZOMBIE 僵死
TASK_STOPPED 暂停
TASK_SWAPPING 换入/换出

(1)可运行状态:处于这种状态的进程,要么正在运行、要么正准备运行。正在运行的进程就是当前进程(由current所指向的进程),而准备运行的进程只要得到CPU就可以立即投入运行。系统中有一个运行队列(run_queue),用来容纳所有处于可运行状态的进程,调度程序执行时,从中选择一个进程投入运行。当前运行进程一直处于该队列中,也就是说,current`总是指向运行队列中的某个元素,只是具体指向谁由调度程序决定。

(2)等待状态:处于该状态的进程正在等待某个事件(Event)或某个资源,它肯定位于系统中的某个等待队列(wait_queue)中。Linux中处于等待状态的进程分为两种:可中断的等待状态和不可中断的等待状态。处于可中断等待态的进程可以被信号唤醒,如果收到信号,该进程就从等待状态进入可运行状态,并且加入到运行队列中,等待被调度;而处于不可中断等待态的进程是因为硬件环境不能满足而等待,例如等待特定的系统资源,它任何情况下都不能被打断,只能用特定的方式来唤醒它,例如唤醒函数wake_up()等。

(3)暂停状态:此时的进程暂时停止运行来接受某种特殊处理。通常当进程接收到SIGSTOPSIGTSTPSIGTTINSIGTTOU信号后就处于这种状态。

(4)僵死状态:进程虽然已经终止,但由于某种原因,父进程还没有执行wait()系统调用,终止进程的信息也还没有回收。顾名思义,处于该状态的进程就是死进程,这种进程实际上是系统中的垃圾,必须进行相应处理以释放其占用的资源。

进程调度信息

这一部分信息通常包括进程的类别(普通进程还是实时进程)、进程的优先级等,如表所示。

域名 含义
need_resched 调度标志
Nice 静态优先级
Counter 动态优先级
Policy 调度策略
rt_priority 实时优先级

need_resched被设置时,在“下一次的调度机会”就调用调度程序schedule()counter代表进程剩余的时间片,是进程调度的主要依据,也可以说是进程的动态优先级,因为这个值在不断地减少;nice是进程的静态优先级,同时也代表进程的时间片,用于对counter赋值,可以用nice()系统调用改变这个值;policy是适用于该进程的调度策略,实时进程和普通进程的调度策略是不同的;rt_priority只对实时进程有意义,它是实时进程调度的依据。

进程的调度策略有 3 种,如表所示。

名称 解释 适用范围
SCHED_OTHER 其他调度 普通进程
SCHED_FIFO 先来先服务调度 实时进程
SCHED_RR 时间片轮转调度 实时进程

只有root用户能通过sched_setscheduler()系统调用来改变调度策略。

标识符(Identifiers)

每个进程都有一个唯一的进程标识符(PID,process identifier),内核通过这个标识符来识别不同的进程,同时,进程标识符PID也是内核提供给用户程序的接口。PID是 32 位的无符号整数,它被顺序编号:新创建进程的PID通常是前一个进程的PID加 1。

进程通信有关信息

Linux支持多种不同形式的通信机制。它支持典型的UNIX通信机制(IPC Mechanisms):信号(Signals)、管道(Pipes),也支持SystemV通信机制:共享内存(Shared Memory)、信号量和消息队列(Message Queues)。

域名 含义
Spinlock_t sigmask_lock 信号掩码的自旋锁
Long blocked 信号掩码
Struct signal *sig 信号处理函数
Struct sem_undo *semundo 为避免死锁而在信号量上设置的取消操作
Struct sem_queue *semsleeping 与信号量操作相关的等待队列

程序创建的进程具有父/子关系。因为一个进程能创建几个子进程,而子进程之间有兄弟关系,在task_struct结构中有几个域来表示这种关系。

每个进程的task_struct结构有许多指针,通过这些指针,系统中所有进程的task_struct结构就构成了一棵进程树,这棵进程树的根就是初始化进程inittask_struct结构(init进程是Linux内核建立起来后人为创建的一个进程,是所有进程的祖先进程)。

时间和定时器信息(Times and Timers)

一个进程从创建到终止叫做该进程的生存期(lifetime)。进程在其生存期内使用CPU的时间,内核都要进行记录,以便进行统计、计费等有关操作。进程耗费CPU的时间由两部分组成:一是在用户模式(或称为用户态)下耗费的时间、一是在系统模式(或称为系统态)下耗费的时间。每个时钟滴答,也就是每个时钟中断,内核都要更新当前进程耗费CPU的时间信息。

文件系统信息(File System)

进程可以打开或关闭文件,文件属于系统资源,Linux内核要对进程使用文件的情况进行记录。task_struct结构中有两个数据结构用于描述进程与文件相关的信息。其中,fs_struct中描述了两个VFS索引节点(VFS inode),这两个索引节点叫做rootpwd,分别指向进程的可执行映像所对应的根目录(Home Directory)和当前目录或工作目录。

file_struct结构用来记录了进程打开的文件的描述符(Descriptor)。如表所示。

定义形式 解释
struct fs_struct *fs 进程的可执行映像所在的文件系统
struct files_struct *files 进程打开的文件

在文件系统中,每个VFS索引节点唯一描述一个文件或目录,同时该节点也是向更低层的文件系统提供的统一的接口。

虚拟内存信息(Virtual Memory)

除了内核线程(Kernel Thread),每个进程都拥有自己的地址空间(也叫虚拟空间),用mm_struct来描述。另外Linux 2.4 还引入了另外一个域active_mm,这是为内核线程而引入的。因为内核线程没有自己的地址空间,为了让内核线程与普通进程具有统一的上下文切换方式,当内核线程进行上下文切换时,让切换进来的线程的active_mm指向刚被调度出去的进程的active_mm(如果进程的mm域不为空,则其active_mm域与mm域相同)。内存信息如表所示。

定义形式 解释
struct mm_struct *mm 描述进程的地址空间
struct mm_struct *active_mm 内核线程所借用的地址空间

页面管理信息

当物理内存不足时,Linux内存管理子系统需要把内存中的部分页面交换到外存,其交换是以页为单位的。有关页面的描述信息如表。

定义形式 解释
int swappable 进程占用的内存页面是否可换出
unsigned long min_flat, maj_flt, nswap 进程累计的次(minor)缺页次数、主(major)次数及累计换出、换入页面数
unsigned long cmin_flat,cmaj_flt,cnswap 本进程作为祖先进程,其所有层次子进程的累计的次(minor)缺页次数、主(major)次数及累计换出、换入页面数

对称多处理机(SMP)信息

Linux 2.4 对SMP进行了全面的支持,表是与多处理机相关的几个域。

定义形式 解释
int has_cpu 进程当前是否拥有CPU
int processor 进程当前正在使用的CPU
int lock_depth 上下文切换时内核锁的深度

和处理器相关的环境(上下文)信息(Processor Specific Context)

因为不同的处理器对内部寄存器和堆栈的定义不尽相同,所以叫做“和处理器相关的环境”,也叫做“处理机状态”。当进程暂时停止运行时,处理机状态必须保存在进程的task_struct结构中,当进程被调度重新运行时再从中恢复这些环境,也就是恢复这些寄存器和堆栈的值。处理机信息如表所示。

定义形式 解释
struct thread_struct *tss 任务切换状态

其他

struct wait_queue *wait_chldexit

在进程结束时,或发出系统调用wait4时,为了等待子进程的结束,而将自己(父进程)睡眠在该等待队列上,设置状态标志为TASK_INTERRUPTIBLE,并且把控制权转给调度程序。

struct rlimit rlim[RLIM_NLIMITS]

每一个进程可以通过系统调用setlimitgetlimit来限制它资源的使用。

int exit_code exit_signal

程序的返回代码以及程序异常终止产生的信号,这些数据由父进程(子进程完成后)轮流查询。

char comm[16]

这个域存储进程执行的程序的名字,这个名字用在调试中。

unsigned long personality

personality进一步描述进程执行的程序属于何种UNIX平台的“个性”信息。通常有PER_Linux,PER_Linux_32BIT,PER_Linux_EM86,PER_SVR4,PER_SVR3,PER_SCOSVR3,PER_WYSEV386,PER_ISCR4,PER_BSD,PER_XENIXPER_MASK等。

int did_exec:1

POSIX要求设计的布尔量,区分进程正在执行老程序代码,还是用系统调用execve()装入一个新的程序。

struct linux_binfmt *binfmt

指向进程所属的全局执行文件格式结构,共有a.out、script、elf、java等 4 种。

task_struct`结构在内存中的存放

task_struct结构在内存的存放与内核栈是分不开的,因此,首先讨论内核栈。

进程内核栈

每个进程都有自己的内核栈。当进程从用户态进入内核态时,CPU就自动地设置该进程的内核栈,也就是说,CPU从任务状态段TSS中装入内核栈指针esp

X86 内核栈的分布如图 4.2 所示。

Intel系统中,栈起始于末端,并朝这个内存区开始的方向增长。从用户态刚切换到内核态以后,进程的内核栈总是空的,因此,esp寄存器直接指向这个内存区的顶端。在图 4.2中,从用户态切换到内核态后,esp寄存器包含的地址为 0x018fc00。进程描述符存放在从0x015fa00 开始的地址。只要把数据写进栈中,esp`的值就递减。

/include/linux/sched.h中定义了如下一个联合结构:

1
2
3
4
union task_union {
struct task_struct task;
unsigned long stack[2408];
};

从这个结构可以看出,内核栈占 8KB 的内存区。实际上,进程的task_struct结构所占的内存是由内核动态分配的,更确切地说,内核根本不给task_struct分配内存,而仅仅给内核栈分配 8KB 的内存,并把其中的一部分给task_struct使用。

task_struct结构大约占 1K 字节左右,其具体数字与内核版本有关,因为不同的版本其域稍有不同。因此,内核栈的大小不能超过 7KB,否则,内核栈会覆盖task_struct结构,从而导致内核崩溃。不过,7KB`大小对内核栈已足够。

task_struct结构与内核栈放在一起具有以下好处:

  • 内核可以方便而快速地找到这个结构,用伪代码描述如下:task_struct = (struct task_struct *) STACK_POINTER & 0xffffe000
  • 避免在创建进程时动态分配额外的内存。
  • task_struct结构的起始地址总是开始于页大小(PAGE_SIZE)的边界。

当前进程(current`宏)

在Linux/include/i386/current.h中定义了current`宏,这是一段与体系结构相关的代码:

1
2
3
4
5
6
static inline struct task_struct * get_current(void)
{
struct task_struct *current;
__asm__("andl %%esp,%0; ":"=r" (current) : "0" (~8191UL));
return current;
}

实际上,这段代码相当于如下一组汇编指令(设p是指向当前进程task_struct结构的指针):

1
2
3
movl $0xffffe000, %ecx
andl %esp, %ecx
movl %ecx, p

换句话说,仅仅只需检查栈指针的值,而根本无需存取内存,内核就可以导出task_struct结构的地址。

进程组织方式

为了对系统中的很多进程及处于不同状态的进程进行管理,Linux采用了如下几种组织方式。

哈希表

哈希表是进行快速查找的一种有效的组织方式。Linux在进程中引入的哈希表叫做pidhash,在include/linux/sched.h中定义如下:

1
2
3
4
#define PIDHASH_SZ (4096 >> 2)
extern struct task_struct *pidhash[PIDHASH_SZ];

#define pid_hashfn(x) ((((x) >> 8) ^ (x)) & (PIDHASH_SZ - 1))

其中,PIDHASH_SZ为表中元素的个数,表中的元素是指向task_struct结构的指针。pid_hashfn为哈希函数,把进程的PID转换为表的索引。通过这个函数,可以把进程的PID均匀地散列在它们的域(0 到PID_MAX-1)中。

Linux利用链地址法来处理冲突的PID:也就是说,每一表项是由冲突的PID组成的双向链表,这种链表是由task_struct结构中的pidhash_nextpidhash_pprev域实现的,同一链表中pid的大小由小到大排列。

哈希表pidhash中插入和删除一个进程时可以调用hash_pid()unhash_pid()函数。对于一个给定的pid,可以通过find_task_by_pid()函数快速地找到对应的进程:

1
2
3
4
5
6
7
static inline struct task_struct *find_task_by_pid(int pid)
{
struct task_struct *p, **htable = &pidhash[pid_hashfn(pid)];
for(p = *htable; p && p->pid != pid; p = p->pidhash_next)
;
return p;
}

双向循环链表

哈希表的主要作用是根据进程的pid可以快速地找到对应的进程,但它没有反映进程创建的顺序,也无法反映进程之间的亲属关系,因此引入双向循环链表。每个进程task_struct结构中的prev_tasknext_task域用来实现这种链表。

SET_LINK用来在该链表中插入一个元素:

1
2
3
4
5
6
7
8
9
10
#define SET_LINKS(p) do { \
(p)->next_task = &init_task; \
(p)->prev_task = init_task.prev_task; \
init_task.prev_task->next_task = (p); \
init_task.prev_task = (p); \
(p)->p_ysptr = NULL; \
if (((p)->p_osptr = (p)->p_pptr->p_cptr) != NULL) \
(p)->p_osptr->p_ysptr = p; \
(p)->p_pptr->p_cptr = p; \
} while (0)

从这段代码可以看出,链表的头和尾都为init_task,它对应的是进程 0(pid为 0),也就是所谓的空进程,它是所有进程的祖先。这个宏把进程之间的亲属关系也链接起来。另外,还有一个宏for_each_task()

1
2
#define for_each_task(p) \
for (p = &init_task ; (p = p->next_task) != &init_task ; )

这个宏是循环控制语句。注意init_task的作用,因为空进程是一个永远不存在的进程,因此用它做链表的头和尾是安全的。

因为进程的双向循环链表是一个临界资源,因此在使用这个宏时一定要加锁,使用完后开锁。

运行队列

当内核要寻找一个新的进程在CPU上运行时,必须只考虑处于可运行状态的进程(即在TASK_RUNNING状态的进程),因为扫描整个进程链表是相当低效的,所以引入了可运行状态进程的双向循环链表,也叫运行队列(runqueue)。

运行队列容纳了系统中所有可以运行的进程,它是一个双向循环队列。

进程的运行队列链表

该队列通过task_struct结构中的两个指针run_list链表来维持。队列的标志有两个:一个是“空进程”idle_task,一个是队列的长度。有两个特殊的进程永远在运行队列中待着:当前进程和空进程。前面我们讨论过,当前进程就是由cureent指针所指向的进程,也就是当前运行着的进程,直到调度程序选定某个进程投入运行后,current才真正指向了当前运行进程;空进程是个比较特殊的进程,只有系统中没有进程可运行时它才会被执行,Linux将它看作运行队列的头,当调度程序遍历运行队列,是从idle_task开始、至idle_task结束的,在调度程序运行过程中,允许队列中加入新出现的可运行进程,新出现的可运行进程插入到队尾,这样的好处是不会影响到调度程序所要遍历的队列成员,可见,idle_task是运行队列很重要的标志。

另一个重要标志是队列长度,也就是系统中处于可运行状态(TASK_RUNNING)的进程数目,用全局整型变量nr_running表示,在/kernel/fork.c中定义如下:

1
int nr_running=1

nr_running为 0,就表示队列中只有空进程。在这里要说明一下:若nr_running为0,则系统中的当前进程和空进程就是同一个进程。但是Linux会充分利用CPU而尽量避免出现这种情况。

等待队列

在 2.4 版本中,引入了一种特殊的链表—通用双向链表,它是内核中实现其他链表的基础,也是面向对象的思想在C语言中的应用。在等待队列的实现中多次涉及与此链表相关的内容。

通用双向链表

include/linux/list.h中定义了这种链表:

1
2
3
struct list_head {
struct list_head *next, *prev;
};

这是双向链表的一个基本框架,在其他使用链表的地方就可以使用它来定义任意一个双向链表,例如:

1
2
3
4
struct foo_list {
int data;
struct list_head list;
};

对于list_head类型的链表,Linux定义了 5 个宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define LIST_HEAD_INIT(name) { &(name), &(name) }

#define LIST_HEAD(name) \
struct list_head name = LIST_HEAD_INIT(name)

#define INIT_LIST_HEAD(ptr) do { \
(ptr)->next = (ptr); (ptr)->prev = (ptr); \
} while (0)

#define list_entry(ptr, type, member) \
((type *)((char *)(ptr)-(unsigned long)(&((type *)0)->member)))

#define list_for_each(pos, head) \
for (pos = (head)->next; pos != (head); pos = pos->next)

前 3 个宏都是初始化一个空的链表,但用法不同,LIST_HEAD_INIT()在声明时使用,用来初始化结构元素,第 2 个宏用在静态变量初始化的声明中,而第 3 个宏用在函数内部。其中,最难理解的宏为list_entry(),在内核代码的很多处都用到这个宏,例如,在调度程序中,从运行队列中选择一个最值得运行的进程,部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
static LIST_HEAD(runqueue_head);
struct list_head *tmp;
struct task_struct *p;
list_for_each(tmp, &runqueue_head) {
p = list_entry(tmp, struct task_struct, run_list);
if (can_schedule(p)) {
int weight = goodness(p, this_cpu, prev->active_mm);
if (weight > c)
c = weight, next = p;
}
}

从这段代码可以分析出list_entry(ptr, type, member)宏及参数的含义:ptr是指向list_head类型链表的指针,type为一个结构,而member为结构type中的一个域,类型为list_head,这个宏返回指向type结构的指针。在内核代码中大量引用了这个宏,因此,搞清楚这个宏的含义和用法非常重要。

另外,对list_head类型的链表进行删除和插入(头或尾)的宏为list_del()/list_add()/list_add_tail(),在内核的其他函数中可以调用这些宏。例如,从运行队列中删除、增加及移动一个任务的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static inline void del_from_runqueue(struct task_struct * p)
{
nr_running--;
list_del(&p->run_list);
p->run_list.next = NULL;
}
static inline void add_to_runqueue(struct task_struct * p)
{
list_add(&p->run_list, &runqueue_head);
nr_running++;
}
static inline void move_last_runqueue(struct task_struct * p)
{
list_del(&p->run_list);
list_add_tail(&p->run_list, &runqueue_head);
}
static inline void move_first_runqueue(struct task_struct * p)
{
list_del(&p->run_list);
list_add(&p->run_list, &runqueue_head);
}

等待队列

运行队列链表把处于TASK_RUNNING状态的所有进程组织在一起。当要把其他状态的进程分组时,不同的状态要求不同的处理,Linux选择了下列方式之一。

  • TASK_STOPPEDTASK_ZOMBIE状态的进程不链接在专门的链表中,也没必要把它们分组,因为父进程可以通过进程的PID或进程间的亲属关系检索到子进程。
  • TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE状态的进程再分成很多类,其每一类对应一个特定的事件。在这种情况下,进程状态提供的信息满足不了快速检索进程,因此,有必要引入另外的进程链表。这些链表叫等待队列。

等待队列表示一组睡眠的进程,当某一条件变为真时,由内核唤醒它们。等待队列由循环链表实现。

1
2
3
4
5
6
struct __wait_queue {
unsigned int flags;
struct task_struct * task;
struct list_head task_list;
};
typedef struct __wait_queue wait_queue_t ;

另外,关于等待队列另一个重要的数据结构—等待队列首部的描述如下:

1
2
3
4
5
struct __wait_queue_head {
wq_lock_t lock;
struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;

下面给出 2.4 版中的一些主要函数及其功能:

  • init_waitqueue_head()——对等待队列首部进行初始化
  • init_waitqueue_entry()——对要加入等待队列的元素进行初始化
  • waitqueue_active()——判断等待队列中已经没有等待的进程
  • add_wait_queue()——给等待队列中增加一个元素
  • remove_wait_queue()——从等待队列中删除一个元素

注意,在以上函数的实现中,都调用了对list_head类型链表的操作函数(list_del()/list_add()/list_add_tail()),因此可以说,list_head类型相当于`C++中的基类型。

希望等待一个特定事件的进程能调用下列函数中的任一个:

sleep_on()函数对当前的进程起作用,我们把当前进程叫做`P:

1
2
3
4
5
6
7
8
sleep_on(wait_queue_head_t *q)
{
SLEEP_ON_VAR /*宏定义,用来初始化要插入到等待队列中的元素*/
current->state = TASK_UNINTERRUPTIBLE;
SLEEP_ON_HEAD /*宏定义,把`P`插入到等待队列 */
schedule();
SLEEP_ON_TAIL /*宏定义把`P`从等待队列中删除 */
}

这个函数把P的状态设置为TASK_UNINTERRUPTIBLE,并把P插入等待队列。然后,它调用调度程序恢复另一个程序的执行。当P被唤醒时,调度程序恢复sleep_on()函数的执行,把P从等待队列中删除。

interruptible_sleep_on()sleep_on()函数是一样的,但稍有不同,前者把进程P的状态设置为TASK_INTERRUPTIBLE而不是TASK_UNINTERRUPTIBLE,因此,通过接受一个信号可以唤醒`P。

sleep_on_timeout()interruptible_sleep_on_timeout()与前面情况类似,但它们允许调用者定义一个时间间隔,过了这个间隔以后,内核唤醒进程。为了做到这点,它们调用schedule_timeout()函数而不是schedule()函数。

利用wake_up或者wake_up_interruptible宏,让插入等待队列中的进程进入TASK_RUNNING状态,这两个宏最终都调用了try_to_wake_up()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static inline int try_to_wake_up(struct task_struct * p, int synchronous)
{
unsigned long flags;
int success = 0;
spin_lock_irqsave(&runqueue_lock, flags); /*加锁*/
p->state = TASK_RUNNING;
if (task_on_runqueue(p)) /*判断`p`是否已经在运行队列*/

add_to_runqueue(p); /*不在,则把`p`插入到运行队列*/
if (!synchronous || !(p->cpus_allowed & (1 << smp_processor_id())))
reschedule_idle(p);
success = 1;
out:
spin_unlock_irqrestore(&runqueue_lock, flags); /*开锁*/
return success;
}

在这个函数中,p为要唤醒的进程。如果p不在运行队列中,则把它放入运行队列。如果重新调度正在进行的过程中,则调用reschedule_idle()函数,这个函数决定进程p是否应该抢占某一CPU上的当前进程。

实际上,在内核的其他部分,最常用的还是wake_up或者wake_up_interruptible宏,也就是说,如果你要在内核级进行编程,只需调用其中的一个宏。例如一个简单的实时时钟(RTC)中断程序如下:

1
2
3
4
5
6
7
8
static DECLARE_WAIT_QUEUE_HEAD(rtc_wait); /*初始化等待队列首部*/
void rtc_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
spin_lock(&rtc_lock);
rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
spin_unlock(&rtc_lock);
wake_up_interruptible(&rtc_wait);
}

这个中断处理程序通过从实时时钟的I/O端口(CMOS_READ宏产生一对outb/inb)读取数据,然后唤醒在rtc_wait等待队列上睡眠的任务。

内核线程

内核线程(thread)或叫守护进程(daemon),在操作系统中占据相当大的比例,当Linux操作系统启动以后,尤其是Xwindow也启动以后,你可以用“ps”命令查看系统中的进程,这时会发现很多以“d”结尾的进程名,这些进程就是内核线程。

内核线程也可以叫内核任务,它们周期性地执行,例如,磁盘高速缓存的刷新,网络连接的维护,页面的换入换出等。在Linux中,内核线程与普通进程有一些本质的区别,从以下几个方面可以看出二者之间的差异。

  • 内核线程执行的是内核中的函数,而普通进程只有通过系统调用才能执行内核中的函数。
  • 内核线程只运行在内核态,而普通进程既可以运行在用户态,也可以运行在内核态。
  • 因为内核线程指只运行在内核态,因此,它只能使用大于PAGE_OFFSET(3G)的地址空间。另一方面,不管在用户态还是内核态,普通进程可以使用 4GB`的地址空间。

内核线程是由kernel_thread()函数在内核态下创建的,这个函数所包含的代码大部分是内联式汇编语言,但在某种程度上等价于下面的代码:

1
2
3
4
5
6
7
8
9
10
11
int kernel_thread(int (*fn)(void *), void * arg, unsigned long flags)
{
pid_t p ;
p = clone( 0, flags | CLONE_VM );
if ( p ) /* parent */
return p;
else { /* child */
fn(arg);
exit();
}
}

进程的权能

Linux用“权能(capability)”表示一进程所具有的权力。一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作。这个模型不同于传统的“超级用户对普通用户”模型,在后一种模型中,一个进程要么能做任何事情,要么什么也不能做,这取决于它的有效`UID。也就是说,超级用户与普通用户的划分过于笼统。如表给出了在Linux内核中已定义的权能。

名字 描述
CAP_CHOWN 忽略对文件和组的拥有者进行改变的限制
CAP_DAC_OVERRIDE 忽略文件的访问许可权
CAP_DAC_READ_SEARCH 忽略文件/目录读和搜索的许可权
CAP_FOWNER 忽略对文件拥有者的限制
CAP_FSETID 忽略对setidsetgid标志的限制
CAP_KILL 忽略对信号挂起的限制
CAP_SETGID 允许setgid标志的操作
CAP_SETUID 允许setuid标志的操作
CAP_SETPCAP 转移/删除对其他进程所许可的权能
CAP_LINUX_IMMUTABLE 允许对仅追加和不可变文件的修改
CAP_NET_BIND_SERVICE 允许捆绑到低于 1024TCP/UDP`的套节字
CAP_NET_BROADCAST 允许网络广播和监听多点传送
CAP_NET_ADMIN 允许一般的网络管理。
CAP_NET_RAW 允许使用RAWPACKET套节字
CAP_IPC_LOCK 允许页和共享内存的加锁
CAP_IPC_OWNER 跳过IPC拥有者的检查
CAP_SYS_MODULE 允许内核模块的插入和删除
CAP_SYS_RAWIO 允许通过ioperm()iopl()访问I/O端口
CAP_SYS_CHROOT 允许使用chroot()
CAP_SYS_PTRACE 允许在任何进程上使用ptrace()
CAP_SYS_PACCT 允许配置进程的计账
CAP_SYS_ADMIN 允许一般的系统管理
CAP_SYS_BOOT 允许使用reboot()
CAP_SYS_NICE 忽略对nice()的限制
CAP_SYS_RESOURCE 忽略对几个资源使用的限制
CAP_SYS_TIME 允许系统时钟和实时时钟的操作
CAP_SYS_TTY_CONFIG 允许配置tty设备

任何时候,每个进程只需要有限种权能,这是其主要优势。因此,即使一位有恶意的用户使用有潜在错误程序,他也只能非法地执行有限个操作类型。

内核同步

信号量

进程间对共享资源的互斥访问是通过“信号量”机制来实现的。信号量机制是操作系统教科书中比较重要的内容之一。Linux内核中提供了两个函数down()up(),分别对应于操作系统教科书中的P、V操作。

信号量在内核中定义为semaphore数据结构,位于include/i386/semaphore.h

1
2
3
4
5
6
7
8
struct semaphore {
atomic_t count;
int sleepers;
wait_queue_head_t wait;
#if WAITQUEUE_DEBUG
long __magic;
#endif
};

其中的count域就是“信号量”中的那个“量”,它代表着可用资源的数量。如果该值大于 0,那么资源就是空闲的,也就是说,该资源可以使用。相反,如果count小于 0,那么这个信号量就是繁忙的,也就是说,这个受保护的资源现在不能使用。在后一种情况下,count`的绝对值表示了正在等待这个资源的进程数。该值为 0 表示有一个进程正在使用这个资源,但没有其他进程在等待这个资源。

wait域存放等待链表的地址,该链表中包含正在等待这个资源的所有睡眠的进程。当然,如果count大于或等于 0,则等待队列为空。为了明确表示等待队列中正在等待的进程数,引入了计数器`sleepers。

down()up()函数主要应用在文件系统和驱动程序中,把要保护的临界区放在这两个函数中间,用法如下:

1
2
3
down();
临界区
up();

这两个函数是用嵌入式汇编实现的。

原子操作

避免干扰的最简单方法就是保证操作的原子性,即操作必须在一条单独的指令内执行。有两种类型的原子操作,即位图操作和数学的加减操作。

位图操作

在内核的很多地方用到位图,例如内存管理中对空闲页的管理,位图还有一个广泛的用途就是简单的加锁,例如提供对打开设备的互斥访问。关于位图的操作函数如下,以下函数的参数中,addr`指向位图。

  • void set_bit(int nr, volatile void *addr):设置位图的第nr位。
  • void clear_bit(int nr, volatile void *addr): 清位图的第nr位。
  • void change_bit(int nr, volatile void *addr): 改变位图的第nr位。
  • int test_and_set_bit(int nr, volatile void *addr): 设置第nr位,并返回该位原来的值,且两个操作是原子操作,不可分割。
  • int test_and_clear_bit(int nr, volatile void *addr): 清第nr为,并返回该位原来的值,且两个操作是原子操作。
  • int test_and_change_bit(int nr, volatile void *addr):改变第nr位,并返回该位原来的值,且这两个操作是原子操作。

这些操作利用了LOCK_PREFIX宏,对于SMP内核,该宏是总线锁指令的前缀,对于单CPU这个宏不起任何作用。这就保证了在SMP环境下访问的原子性。

算术操作

有时候位操作是不方便的,取而代之的是需要执行算术操作,即加、减操作及加 1、减1 操作。典型的例子是很多数据结构中的引用计数域count(如inode结构)。这些操作的原子性是由atomic_t数据类型和表中的函数保证的。atomic_t的类型在include/i386/atomic.h,定义如下:

1
typedef struct { volatile int counter; } atomic_t;

函数 说明
atomic_read(v) 返回*v
atomic_set(v,i) 把*v设置成i
atomic_add(i,v) 给*v增加i
atomic_sub(i,v) 从*v中减去i
atomic_inc(v) 给*v`加 1
atomic_dec(v) 从*v`中减去 1
atomic_dec_and_test(v) 从*v`中减去 1,如果结果非空就返回 1;否则返回 0
atomic_inc_and_test_greater_zero(v) 给*v`加 1,如果结果为正就返回 1;否则就返回 0
atomic_clear_mask(mask,addr) 清除由mask所指定的addr中的所有位
atomic_set_mask(mask,addr) 设置由mask所指定的addr中的所有位

自旋锁、读写自旋锁和大读者自旋锁

在Linux内核中,临界区的代码或者是由进程上下文来执行,或者是由中断上下文来执行。在单CPU上,可以用cli/sti指令来保护临界区的使用,例如:

1
2
3
4
5
unsigned long flags;
save_flags(flags);
cli();
/* critical code */
restore_flags(flags);

但是,在SMP上,这种方法明显是没有用的,因为同一段代码序列可能由另一个进程同时执行,而cli()仅能单独地为每个CPU上的中断上下文提供对竞争资源的保护,它无法对运行在不同CPU上的上下文提供对竞争资源的访问。因此,必须用到自旋锁。

所谓自旋锁,就是当一个进程发现锁被另一个进程锁着时,它就不停地“旋转”,不断执行一个指令的循环直到锁打开。自旋锁只对SMP有用,对单CPU没有意义。有 3 种类型的自旋锁:基本的、读写以及大读者自旋锁。读写自旋锁适用于“多个读者少数写者”的场合,例如,有多个读者仅有一个写者,或者没有读者只有一个写者。大读者自旋锁是读写自旋锁的一种,但更照顾读者。大读者自旋锁现在主要用在`Sparc64 和网络系统中。

进程调度与切换

Linux时间系统

时间系统通常又被简称为时钟,它的主要任务是维持系统时间并且防止某个进程独占CPU及其他资源,也就是驱动进程的调度。

时钟硬件

大部分PC机中有两个时钟源,他们分别叫做RTCOS(操作系统)时钟。RTC(Real Time Clock,实时时钟)也叫做CMOS时钟,它是PC主机板上的一块芯片(或者叫做时钟电路),它靠电池供电,即使系统断电,也可以维持日期和时间。由于它独立于操作系统,所以也被称为硬件时钟,它为整个计算机提供一个计时标准,是最原始最底层的时钟数据。Linux只用RTC来获得时间和日期,同时,通过作用于/dev/rtc设备文件,也允许进程对RTC编程。内核通过0x700x71 I/O端口存取RTC。通过执行/sbin/clock系统程序(它直接作用于这两个I/O端口),系统管理员可以配置时钟。

OS时钟产生于PC主板上的定时/计数芯片,由操作系统控制这个芯片的工作,OS时钟的基本单位就是该芯片的计数周期。在开机时操作系统取得RTC中的时间数据来初始化OS时钟,然后通过计数芯片的向下计数形成了OS时钟,所以OS时钟并不是本质意义上的时钟,它更应该被称为一个计数器。OS时钟只在开机时才有效,而且完全由操作系统控制,所以也被称为软时钟或系统时钟。下面我们重点描述OS时钟的产生。

可编程定时/计数器总体上由两部分组成:计数硬件和通信寄存器。通信寄存器包含有控制寄存器、状态寄存器、计数初始值寄存器(16 位)、计数输出寄存器等。通信寄存器在计数硬件和操作系统之间建立联系,用于二者之间的通信,操作系统通过这些寄存器控制计数硬件的工作方式、读取计数硬件的当前状态和计数值等信息。

在Linux内核初始化时,内核写入控制字和计数初值,这样计数硬件就会按照一定的计数方式对晶振产生的输入脉冲信号(5MHz~100MHz的频率)进行计数操作:计数器从计数初值开始,每收到一次脉冲信号,计数器减 1,当计数器减至 0 时,就会输出高电平或低电平,然后,如果计数为循环方式(通常为循环计数方式),则重新从计数初值进行计数。这个输出脉冲将接到中断控制器上,产生中断信号,触发后面要讲的时钟中断,由时钟中断服务程序维持OS时钟的正常工作,所谓维持,其实就是简单的加 1 及细微的修正操作。这就是OS时钟产生的来源。

Linux的时间系统

系统时间是以“时钟滴答”为单位的,而时钟中断的频率决定了一个时钟滴答的长短,例如每秒有 100 次时钟中断,那么一个时钟滴答的就是 10 毫秒(记为 10ms),相应地,系统时间就会每 10ms`增 1。不同的操作系统对时钟滴答的定义是不同的。

Linux中用全局变量jiffies表示系统自启动以来的时钟滴答数目,在/kernel/time.c中定义如下:

1
unsigned long volatile jiffies

jiffies基础上,Linux提供了如下适合人们习惯的时间格式,在/include/linux/time.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct timespec { /* 这是精度很高的表示*/
long tv_sec; /* 秒 (second) */
long tv_nsec; /* 纳秒:十亿分之一秒( nanosecond)*/
};
struct timeval { /* 普通精度 */
int tv_sec; /* 秒 */
int tv_usec; /* 微秒:百万分之一秒(microsecond)*/
};
struct timezone { /* 时区 */
int tz_minuteswest; /* 格林尼治时间往西方的时差 */
int tz_dsttime; /* 时间修正方式 */
};

tv_sec表示秒(second),tv_usec表示微秒(microsecond,百万分之一秒即 10-6秒),tv_nsec表示纳秒(nanosecond,十亿分之一秒即 10-9秒)。定义tb_usectv_nsec的目的是为了适用不同的使用要求,不同的场合根据对时间精度的要求选用这两种表示。

时钟中断

时钟中断的产生

操作系统对可编程定时/计数器进行有关初始化,然后定时/计数器就对输入脉冲进行计数(分频),脉冲信号接到中断控制器 8259A_1的 0 号管脚,触发一个周期性的中断,我们就把这个中断叫做时钟中断,时钟中断的周期,也就是脉冲信号的周期,我们叫做“滴答”或“时标”(tick)。从本质上说,时钟中断只是一个周期性的信号,完全是硬件行为,该信号触发CPU去执行一个中断服务程序,但是为了方便,我们就把这个服务程序叫做时钟中断

Linux实现时钟中断的全过程

可编程定时/计数器的初始化

IBM PC中使用的是 8253 或 8254 芯片。Linux对 8253的初始化程序段如下(在/arch/i386/kernel/i8259.cinit_IRQ()`函数中):

1
2
3
4
5
6
7
8
set_intr_gate(ox20, interrupt[0]);
/* 在`IDT`的第 0x20 个表项中插入一个中断门。这个门中的段选择符设置成内核代码段的选择符,偏移域设置成 0 号中断处理程序的入口地址。*/
outb_p(0x34,0x43);
/* 写计数器 0 的控制字:工作方式 2*/
outb_p(LATCH & 0xff, 0x40);
/* 写计数初值`LSB`计数初值低位字节 */
outb(LATCH >> 8 , 0x40);
/* 写计数初值`MSB`计数初值高位字节*/

LATCH(英文意思为:锁存器,即其中锁存了计数器 0 的初值)为计数器 0 的计数初值,在/include/linux/timex.h中定义如下:

1
2
3
#define CLOCK_TICK_RATE 1193180 /* 图 5.3 中的输入脉冲 */

#define LATCH ((CLOCK_TICK_RATE + HZ/2) / HZ) /* 计数器 0 的计数初值 */

与时钟中断相关的函数

下面我们接着介绍时钟中断触发的服务程序,该程序代码比较复杂,分布在不同的源文件中,主要包括如下函数:

  • 时钟中断程序:timer_interrupt();
  • 中断服务通用例程:do_timer_interrupt();
  • 时钟函数:do_timer();
  • 中断安装程序:setup_irq();
  • 中断返回函数:ret_from_intr();

timer_interrupt()大约每 10ms被调用一次,实际上,timer_interrupt()函数是一个封装例程,它真正做的事情并不多,但是,作为一个中断程序,它必须在关中断的情况下执行。如果只考虑单处理机的情况,该函数主要语句就是调用do_timer_interrupt()函数。

do_timer_interrupt()函数有两个主要任务,一个是调用do_timer(),另一个是维持实时时钟(RTC,每隔一定时间段要回写),其实现代码在/arch/i386/kernel/time.c中,

1
2
3
4
5
6
7
8
static inline void do_timer_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
do_timer(regs); /* 调用时钟函数,将时钟函数等同于时钟中断未尝不可*/

if(xtime.tv_sec > last_rtc_update + 660)
update_RTC();
/* 每隔 11 分钟就更新`RTC`中的时间信息,以使`OS`时钟和`RTC`时钟保持同步,11 分钟即660 秒,`xtime.tv_sec`的单位是秒,`last_rtc_update`记录的是上次`RTC`更新时的值 */
}

其中,xtime是前面所提到的timeval类型,这是一个全局变量。

时钟函数do_timer() (在/kernel/sched.c中)

1
2
3
4
5
6
7
8
9
10
11
12
void do_timer(struct pt_regs * regs)
{
(*(unsigned long *)&jiffies)++;
/*更新系统时间,这种写法保证对`jiffies`操作的原子性*/
update_process_times();
++lost_ticks;
if( ! user_mode ( regs ) )
++lost_ticks_system;
mark_bh(TIMER_BH);
if (tq_timer)
mark_bh(TQUEUE_BH);
}

其中,update_process_times()函数与进程调度有关,从函数的名子可以看出,它处理的是与当前进程与时间有关的变量,例如,要更新当前进程的时间片计数器counter,如果counter<=0,则要调用调度程序,要处理进程的所有定时器:实时、虚拟、概况,另外还要做一些统计工作。

中断安装程序

从上面的介绍可以看出,时钟中断与进程调度密不可分,因此,一旦开始有时钟中断就可能要进行调度,在系统进行初始化时,所做的大量工作之一就是对时钟进行初始化,其函数time_init()的代码在/arch/i386/kernel/time.c中,对其简写如下:

1
2
3
4
5
6
void __init time_init(void)
{
xtime.tv_sec=get_cmos_time();
xtime.tv_usec=0;
setup_irq(0,&irq0);
}

其中的get_cmos_time()函数就是把当时的实际时间从CMOS时钟芯片读入变量xtime中,时间精度为秒。而setup_irq(0, &irq0)就是时钟中断安装函数,irq0指的是一个结构类型irqaction,其定义及初值如下:

1
static struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, 0, "timer", NULL, NULL};

setup_irq(0, &irq0)的代码在/arch/i386/kernel/irq.c中,其主要功能就是将中断程序连入相应的中断请求队列,以等待中断到来时相应的中断程序被执行。

我们将有关函数改写如下,体现时钟中断的大意:

1
2
3
4
5
6
7
8
do_timer_interrupt()` /*这是一个伪函数 */
{
SAVE_ALL /*保存处理机现场 */
intr_count += 1; /* 这段操作不允许被中断 */
timer_interrupt() /* 调用时钟中断程序 */
intr_count -= 1;
jmp ret_from_intr /* 中断返回函数 */
}

其中,jmp ret_from_intr是一段汇编代码,也是一个较为复杂的过程,它最终要调用jmp ret_from_sys_call,即系统调用返回函数,而这个函数与进程的调度又密切相关,因此,我们重点分析jmp ret_from_sys_call

系统调用返回函数

系统调用返回函数的源代码在/arch/i386/kernel/entry.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
ENTRY(ret_from_sys_call)
cli # need_resched and signals atomic test
cmpl $0,need_resched(%ebx)
jne reschedule
cmpl $0,sigpending(%ebx)
jne signal_return
restore_all:
RESTORE_ALL

ALIGN
signal_return:
sti # we can get here from an interrupt handler
testl $(VM_MASK),EFLAGS(%esp)
movl %esp,%eax
jne v86_signal_return
xorl %edx,%edx
call SYMBOL_NAME(do_signal)
jmp restore_all

ALIGN
v86_signal_return:
call SYMBOL_NAME(save_v86_state)
movl %eax,%esp
xorl %edx,%edx
call SYMBOL_NAME(do_signal)
jmp restore_all
….
reschedule:
call SYMBOL_NAME(schedule) # test
jmp ret_from_sys_call

这一段汇编代码就是前面我们所说的“从系统调用返回函数”ret_from_sys_call,它是从中断、异常及系统调用返回时的通用接口。这段代码主体就是ret_from_sys_call函数,其执行过程中要调用其他一些函数(实际上是一段代码,不是真正的函数),在此我们列出相关的几个函数。

  • ret_from_sys_call:主体。
  • reschedule:检测是否需要重新调度。
  • signal_return:处理当前进程接收到的信号。
  • v86_signal_return:处理虚拟 86 模式下当前进程接收到的信号。
  • RESTORE_ALL:我们把这个函数叫做彻底返回函数,因为执行该函数之后,就返回到当前进程的地址空间中去了。

可以看到ret_from_sys_call的主要作用有:检测调度标志need_resched,决定是否要执行调度程序;处理当前进程的信号;恢复当前进程的环境使之继续执行。

Linux的调度程序—Schedule()

进程的合理调度是一个非常复杂的工作,它取决于可执行程序的类型(实时或普通)、调度的策略及操作系统所追求的目标,幸运的是,Linux的调度程序比较简单。

基本原理

系统通过不同的调度算法(Scheduling Algorithm)来实现这种资源的分配。一个好的调度算法应当考虑以下几个方面。
1.公平:保证每个进程得到合理的CPU时间。
2.高效:使CPU保持忙碌状态,即总是有进程在CPU上运行。
3.响应时间:使交互用户的响应时间尽可能短。
4.周转时间:使批处理用户等待输出的时间尽可能短。
5.吞吐量:使单位时间内处理的进程数量尽可能多。

时间片轮转调度算法

时间片(Time Slice)就是分配给进程运行的一段时间。在分时系统中,为了保证人机交互的及时性,系统使每个进程依次地按时间片轮流的方式执行,此时即应采用时间片轮转法进行调度。在通常的轮转法中,系统将所有的可运行(即就绪)进程按先来先服务的原则,排成一个队列,每次调度时把CPU分配给队首进程,并令其执行一个时间片。时间片的大小从几ms到几百ms不等。当执行的时间片用完时,系统发出信号,通知调度程序,调度程序便据此信号来停止该进程的执行,并将它送到运行队列的末尾,等待下一次执行。然后,把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。这样就可以保证运行队列中的所有进程,在一个给定的时间(人所能接受的等待时间)内,均能获得一时间片的处理机执行时间。

优先权调度算法

为了照顾到紧迫型进程在进入系统后便能获得优先处理,引入了最高优先权调度算法。当将该算法用于进程调度时,系统将把处理机分配给运行队列中优先权最高的进程,这时,又可进一步把该算法分成两种方式。

(1)非抢占式优先权算法(又称不可剥夺调度,Nonpreemptive Scheduling),系统一旦将处理机(CPU)分配给运行队列中优先权最高的进程后,该进程便一直执行下去,直至完成;或因发生某事件使该进程放弃处理机时,系统方可将处理机分配给另一个优先权高的进程。这种调度算法主要用于批处理系统中,也可用于某些对实时性要求不严的实时系统中。

(2)抢占式优先权调度算法(又称可剥夺调度,Preemptive Scheduling)该算法的本质就是系统中当前运行的进程永远是可运行进程中优先权最高的那个。在这种方式下,系统同样是把处理机分配给优先权最高的进程,使之执行。但是只要一出现了另一个优先权更高的进程时,调度程序就暂停原最高优先权进程的执行,而将处理机分配给新出现的优先权最高的进程,即剥夺当前进程的运行。因此,在采用这种调度算法时,每当出现一新的可运行进程,就将它和当前运行进程进行优先权比较,如果高于当前进程,将触发进程调度。

这种方式的优先权调度算法,能更好的满足紧迫进程的要求,故而常用于要求比较严格的实时系统中,以及对性能要求较高的批处理和分时系统中。Linux也采用这种调度算法。

多级反馈队列调度

这是时下最时髦的一种调度算法。其本质是:综合了时间片轮转调度和抢占式优先权调度的优点,即:优先权高的进程先运行给定的时间片,相同优先权的进程轮流运行给定的时间片。

Linux进程调度时机

Linux的调度程序是一个叫schedule()的函数,这个函数被调用的频率很高,由它来决定是否要进行进程的切换,如果要切换的话,切换到哪个进程等。我们先来看在什么情况下要执行调度程序,我们把这种情况叫做调度时机。Linux调度时机主要有。

  1. 进程状态转换的时刻:进程终止、进程睡眠;
  2. 当前进程的时间片用完时(current->counter=0);
  3. 设备驱动程序;
  4. 进程从中断、异常及系统调用返回到用户态时。
  • 时机 1,进程要调用sleep()exit()等函数进行状态转换,这些函数会主动调用调度程序进行进程调度。
  • 时机 2,由于进程的时间片是由时钟中断来更新的,因此,这种情况和时机 4 是一样的。
  • 时机 3,当设备驱动程序执行长而重复的任务时,直接调用调度程序。在每次反复循环中,驱动程序都检查need_resched的值,如果必要,则调用调度程序schedule()主动放弃CPU。
  • 时机 4,如前所述,不管是从中断、异常还是系统调用返回,最终都调用ret_from_sys_call(),由这个函数进行调度标志的检测,如果必要,则调用调用调度程序。

每个时钟中断(timer interrupt)发生时,由 3 个函数协同工作,共同完成进程的选择和切换,它们是:schedule()do_timer()ret_form_sys_call()

  • schedule():进程调度函数,由它来完成进程的选择(调度)。
  • do_timer():暂且称之为时钟函数,该函数在时钟中断服务程序中被调用,是时钟中断服务程序的主要组成部分,该函数被调用的频率就是时钟中断的频率即每秒钟 100 次;
  • ret_from_sys_call():系统调用返回函数。当一个系统调用或中断完成时,该函数被调用,用于处理一些收尾工作,例如信号处理、核心任务等。

前面我们讲过,时钟中断是一个中断服务程序,它的主要组成部分就是时钟函数do_timer(),由这个函数完成系统时间的更新、进程时间片的更新等工作,更新后的进程时间片counter作为调度的主要依据。

在时钟中断返回时,要调用函数ret_from_sys_call(),前面我们已经讨论过这个函数,在这个函数中有如下几行:

1
2
3
4
5
6
7
8
9
cmpl $0, _need_resched
jne reschedule
……
restore_all:
RESTORE_ALL

reschedule:
call SYMBOL_NAME(schedule)
jmp ret_from_sys_call

这几行的意思很明显:检测need_resched标志,如果此标志为非 0,那么就转到reschedule处调用调度程序schedule()进行进程的选择。调度程序schedule()会根据具体的标准在运行队列中选择下一个应该运行的进程。当从调度程序返回时,如果发现又有调度标志被设置,则又调用调度程序,直到调度标志为 0,这时,从调度程序返回时由RESTORE_ALL恢复被选定进程的环境,返回到被选定进程的用户空间,使之得到运行。以上就是时钟中断这个最频繁的调度时机。

进程调度的依据

调度程序运行时,要在所有处于可运行状态的进程之中选择最值得运行的进程投入运行。在每个进程的task_struct结构中有如下 5 项:need_reschednicecounterpolicyrt_priority

  • need_resched: 在调度时机到来时,检测这个域的值,如果为 1,则调用`schedule() 。
  • counter: 进程处于运行状态时所剩余的时钟滴答数,每次时钟中断到来时,这个值就减 1。当这个域的值变得越来越小,直至为 0 时,就把need_resched域置 1,因此,也把这个域叫做进程的“动态优先级”。
  • nice: 进程的“静态优先级”,这个域决定counter的初值。只有通过nice()sched_setparam()setpriority()系统调用才能改变进程的静态优先级。
  • rt_priority: 实时进程的优先级
  • policy: 从整体上区分实时进程和普通进程,因为实时进程和普通进程的调度是不同的,它们两者之间,实时进程应该先于普通进程而运行,可以通过系统调用sched_setscheduler()来改变调度的策略。

对于同一类型的不同进程,采用不同的标准来选择进程。对于普通进程,选择进程的主要依据为counternice。对于实时进程,Linux采用了两种调度策略,即FIFO(先来先服务调度)和RR(时间片轮转调度)。因为实时进程具有一定程度的紧迫性,所以衡量一个实时进程是否应该运行,Linux采用了一个比较固定的标准。实时进程的counter只是用来表示该进程的剩余滴答数,并不作为衡量它是否值得运行的标准,这和普通进程是有区别的。

进程可运行程度的衡量

函数goodness()就是用来衡量一个处于可运行状态的进程值得运行的程度。该函数综合使用了上面我们提到的 5 项,给每个处于可运行状态的进程赋予一个权值(weight),调度程序以这个权值作为选择进程的唯一依据。函数主体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static inline int goodness(struct task_struct * p, struct mm_struct *this_mm)
{
int weight; /* 权值,作为衡量进程是否运行的唯一依据 */
weight=-1;
if (p->policy&SCHED_YIELD)
goto out; /*如果该进程愿意“礼让(yield)”,则让其权值为-1 */
switch(p->policy)
{
/* 实时进程*/
case SCHED_FIFO:
case SCHED_RR:
weight = 1000 + p->rt_priority;
/* 普通进程 */
case SCHED_OTHER:
{
weight = p->counter;
if(!weight)
goto out
/* 做细微的调整*/
if (p->mm=this_mm||!p->mm)
weight = weight+1;
weight+=20-p->nice;
}
}
out:
return weight; /*返回权值*/
}

其中,在sched.h中对调度策略定义如下:

1
2
3
4
#define SCHED_OTHER 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_YIELD 0x10

这个函数比较很简单。首先,根据policy区分实时进程和普通进程。实时进程的权值取决于其实时优先级,其至少是 1000,与conternice无关。普通进程的权值需特别说明如下两点。

  1. 为什么进行细微的调整?如果p->mm为空,则意味着该进程无用户空间(例如内核线程),则无需切换到用户空间。如果p->mm=this_mm,则说明该进程的用户空间就是当前进程的用户空间,该进程完全有可能再次得到运行。对于以上两种情况,都给其权值加 1,算是对它们小小的“奖励”。
  2. 进程的优先级nice是从早期UNIX沿用下来的负向优先级,其数值标志“谦让”的程度,其值越大,就表示其越“谦让”,也就是优先级越低,其取值范围为-20~+19,因此,(20-p->nice)的取值范围就是 0~40。可以看出,普通进程的权值不仅考虑了其剩余的时间片,还考虑了其优先级,优先级越高,其权值越大。

进程调度的实现

调度程序在内核中就是一个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
asmlinkage void schedule(void)
{
struct task_struct *prev, *next, *p; /* prev`表示调度之前的进程, next`表示调度之后的进程 */
struct list_head *tmp;
int this_cpu, c;
if (!current->active_mm)
BUG();/*如果当前进程的`active_mm`为空,出错*/
need_resched_back:
prev = current; /*让`prev`成为当前进程 */
this_cpu = prev->processor;
if (in_interrupt()) {
/*如果`schedule`是在中断服务程序内部执行,就说明发生了错误*/
printk("Scheduling in interrupt\n");
BUG();
}
release_kernel_lock(prev, this_cpu); /*释放全局内核锁,并开`this_CPU的中断*/
spin_lock_irq(&runqueue_lock); /*锁住运行队列,并且同时关中断*/
if (prev->policy == SCHED_RR) /*将一个时间片用完的`SCHED_RR`实时
goto move_rr_last; 进程放到队列的末尾 */
move_rr_back:
switch (prev->state) { /*根据`prev`的状态做相应的处理*/
case TASK_INTERRUPTIBLE: /*此状态表明该进程可以被信号中断*/
if (signal_pending(prev)) { /*如果该进程有未处理的信号,则让其变为可运行状态*/
prev->state = TASK_RUNNING;
break;
}
default: /*如果为可中断的等待状态或僵死状态*/
del_from_runqueue(prev); /*从运行队列中删除*/
case TASK_RUNNING:;/*如果为可运行状态,继续处理*/
}
prev->need_resched = 0;
/*下面是调度程序的正文 */
repeat_schedule: /*真正开始选择值得运行的进程*/
next = idle_task(this_cpu); /*缺省选择空闲进程*/
c = -1000;
if (prev->state == TASK_RUNNING)
goto still_running;
still_running_back:
list_for_each(tmp, &runqueue_head) { /*遍历运行队列*/
p = list_entry(tmp, struct task_struct, run_list);
if ( can_schedule ( p, this_cpu ) ) { / * 单CPU中 ,该函数总返回 1* /
int weight = goodness(p, this_cpu, prev->active_mm);
if (weight > c)
c = weight, next = p;
}
}

/* 如果`c`为 0,说明运行队列中所有进程的权值都为 0,也就是分配给各个进程的时间片都已用完,需重新计算各个进程的时间片 */
if (!c) {
struct task_struct *p;
spin_unlock_irq(&runqueue_lock);/*锁住运行队列*/
read_lock(&tasklist_lock); /* 锁住进程的双向链表*/
for_each_task(p) /* 对系统中的每个进程*/
p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
read_unlock(&tasklist_lock);
spin_lock_irq(&runqueue_lock);
goto repeat_schedule;
}
spin_unlock_irq(&runqueue_lock);/*对运行队列解锁,并开中断*/
if (prev == next) { /*如果选中的进程就是原来的进程*/
prev->policy &= ~SCHED_YIELD;
goto same_process;
}
/* 下面开始进行进程切换*/
kstat.context_swtch++; /*统计上下文切换的次数*/
{
struct mm_struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
if (!mm) { /*如果是内核线程,则借用`prev`的地址空间*/
if (next->active_mm) BUG();
next->active_mm = oldmm;
} else { /*如果是一般进程,则切换到`next`的用户空间*/
if (next->active_mm != mm) BUG();
switch_mm(oldmm, mm, next, this_cpu);
}
if (!prev->mm) { /*如果切换出去的是内核线程*/
prev->active_mm = NULL;/*归还它所借用的地址空间*/
mmdrop(oldmm); /*mm_struct`中的共享计数减 1*/
}
}

switch_to(prev, next, prev); /*进程的真正切换,即堆栈的切换*/
__schedule_tail(prev); /*置`prev->policy`的`SCHED_YIELD`为 0 */
same_process:
reacquire_kernel_lock(current);/*针对`SMP*/
if (current->need_resched) /*如果调度标志被置位*/
goto need_resched_back; /*重新开始调度*/
return;
}

  • 如果当前进程既没有自己的地址空间,也没有向别的进程借用地址空间,那肯定出错。另外,如果schedule()在中断服务程序内部执行,那也出错。
  • 对当前进程做相关处理,为选择下一个进程做好准备。当前进程就是正在运行着的进程,可是,当进入schedule()时,其状态却不一定是TASK_RUNNIG,例如,在exit()系统调用中,当前进程的状态可能已被改为TASK_ZOMBE;又例如,在wait4()系统调用中,当前进程的状态可能被置为TASK_INTERRUPTIBLE。因此,如果当前进程处于这些状态中的一种,就要把它从运行队列中删除。
  • 从运行队列中选择最值得运行的进程,也就是权值最大的进程。
  • 如果已经选择的进程其权值为 0,说明运行队列中所有进程的时间片都用完了(队列中肯定没有实时进程,因为其最小权值为 1000),因此,重新计算所有进程的时间片,其中宏操作NICE_TO_TICKS就是把优先级nice转换为时钟滴答。
  • 进程地址空间的切换。如果新进程有自己的用户空间,也就是说,如果next->mmnext->active_mm相同,那么,switch_mm()函数就把该进程从内核空间切换到用户空间,也就是加载next的页目录。如果新进程无用户空间(next->mm为空),也就是说,如果它是一个内核线程,那它就要在内核空间运行,因此,需要借用前一个进程(prev)的地址空间,因为所有进程的内核空间都是共享的,因此,这种借用是有效的。
  • 用宏switch_to()进行真正的进程切换。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换,任务切换,或上下文切换。Inteli386 系统结构的设计中考虑到了进程(任务)的管理和调度,并从硬件上支持任务之间的切换。

硬件支持

Intel i386 体系结构包括了一个特殊的段类型,叫任务状态段(TSS)。

每个任务包含有它自己最小长度为 104 字节的TSS段,在/include/i386/processor.h中定义为tss_struct结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
struct tss_struct {
unsigned short back_link,__blh;
unsigned long esp0;
unsigned short ss0,__ss0h;/*0 级堆栈指针,即Linux中的内核级 */
unsigned long esp1;
unsigned short ss1,__ss1h; /* 1 级堆栈指针,未用*/
unsigned long esp2;
unsigned short ss2,__ss2h; /* 2 级堆栈指针,未用*/
unsigned long __cr3;
unsigned long eip;
unsigned long eflags;
unsigned long eax,ecx,edx,ebx;
unsigned long esp;
unsigned long ebp;
unsigned long esi;
unsigned long edi;
unsigned short es, __esh;
unsigned short cs, __csh;
unsigned short ss, __ssh;
unsigned short ds, __dsh;
unsigned short fs, __fsh;
unsigned short gs, __gsh;
unsigned short ldt, __ldth;
unsigned short trace, bitmap;
unsigned long io_bitmap[IO_BITMAP_SIZE+1];
/*
* pads the TSS to be cacheline-aligned (size is 0x100)
*/
unsigned long __cacheline_filler[5];
};

每个TSS有它自己 8 字节的任务段描述符(Task State Segment Descriptor ,简称TSSD)。这个描述符包括指向TSS起始地址的 32 位基地址域,20 位界限域,界限域值不能小于十进制 104(由TSS段的最小长度决定)。TSS描述符存放在GDT中,它是GDT中的一个表项。

后面将会看到,Linux在进程切换时,只用到TSS中少量的信息,因此Linux内核定义了另外一个数据结构,这就是thread_struct结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct thread_struct {
unsigned long esp0;
unsigned long eip;
unsigned long esp;
unsigned long fs;
unsigned long gs;
/* Hardware debugging registers */
unsigned long debugreg[8]; /* %%db0-7 debug registers */
/* fault info */
unsigned long cr2, trap_no, error_code;
/* floating point info */
union i387_union i387;
/* virtual 86 mode info */
struct vm86_struct * vm86_info;
unsigned long screen_bitmap;
unsigned long v86flags, v86mask, v86mode, saved_esp0;
/* IO permissions */
int ioperm;
unsigned long io_bitmap[IO_BITMAP_SIZE+1];
};

用这个数据结构来保存cr2寄存器、浮点寄存器、调试寄存器及指定给Intel 80x86 处理器的其他各种各样的信息。需要位图是因为ioperm()iopl()系统调用可以允许用户态的进程直接访问特殊的I/O端口。尤其是,如果把eflag寄存器中的IOPL域设置为 3,就允许用户态的进程访问对应的I/O访问权位图位为 0 的任何一个I/O端口。

进程切换

前面所介绍的schedule()中调用了switch_to宏,这个宏实现了进程之间的真正切换,其代码存放于include/i386/system.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define switch_to(prev,next,last) do { \
asm volatile("pushl %%esi\n\t" \
"pushl %%edi\n\t" \
"pushl %%ebp\n\t" \
"movl %%esp,%0\n\t" /* save ESP */ \
"movl %3,%%esp\n\t" /* restore ESP */ \
"movl $1f,%1\n\t" /* save EIP */ \
"pushl %4\n\t" /* restore EIP */ \
"jmp __switch_to\n" \
"1:\t" \
"popl %%ebp\n\t" \
"popl %%edi\n\t" \
"popl %%esi\n\t" \
:"=m" (prev->thread.esp),"=m" (prev->thread.eip), \
"=b" (last) \
:"m" (next->thread.esp),"m" (next->thread.eip), \
"a" (prev), "d" (next), \
"b" (prev)); \
} while (0)

switch_to宏是用嵌入式汇编写成。

  • thread的类型为前面介绍的thread_struct结构。
  • 输出参数有 3 个,表示这段代码执行后有 3 项数据会有变化,它们与变量及寄存器的对应关系如下:
    • 0%与prev->thread.esp对应,1%与prev->thread.eip对应,这两个参数都存放在内存,而 2%与ebx寄存器对应,同时说明last参数存放在ebx寄存器中。
  • 输入参数有 5 个,其对应关系如下:
    • 3%与next->thread.esp对应,4%与next->thread.eip对应,这两个参数都存放在内存,而 5%、6%和 7%分别与eax、edxebx相对应,同时说明prev、next以及prev这 3个参数分别放在这 3 个寄存器中。
  • 第 2~4 行就是在当前进程prev的内核栈中保存esi、ediebp寄存器的内容。
  • 第 5 行将prev的内核堆栈指针ebp存入prev->thread.esp中。
  • 第 6 行把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中。从现在开始,内核对next的内核栈进行操作,因此,这条指令执行从prevnext真正的上下文切换,因为进程描述符的地址与其内核栈的地址紧紧地联系在一起,因此,改变内核栈就意味着改变当前进程。如果此处引用current,那就已经指向nexttask_struct`结构了。从这个意义上说,进程的切换在这一行指令执行完以后就已经完成。但是,构成一个进程的另一个要素是程序的执行,这方面的切换尚未完成。
  • 第 7 行将标号“1”所在的地址,也就是第一条popl指令所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度运行而切入时的“返回”地址。
  • 第 8 行将next->thread.eip压入next的内核栈。那么,next->thread.eip究竟指向那个地址?实际上,它就是next上一次被调离时通过第 7 行保存的地址,也就是第 11 行popl指令的地址。因为,每个进程被调离时都要执行这里的第 7 行,这就决定了每个进程(除了新创建的进程)在受到调度而恢复执行时都从这里的第 11 行开始。
  • 第 9 行通过jump指令(而不是call指令)转入一个函数__switch_to()。这个函数的具体实现将在下面介绍。当CPU执行到__switch_to()函数的ret指令时,最后进入堆栈的next->thread.eip就变成了返回地址,这就是标号“1”的地址。
  • 第 11~13 行恢复next上次被调离时推进堆栈的内容。从现在开始,next`进程就成为当前进程而真正开始执行。

下面我们来讨论__switch_to()函数。在调用__switch_to()函数之前,对其定义了fastcall:

1
extern void FASTCALL(__switch_to(struct task_struct *prev, struct task_struct *next));

fastcall对函数的调用不同于一般函数的调用,因为__switch_to()从寄存器取参数,而不像一般函数那样从堆栈取参数,也就是说,通过寄存器eaxedxprevnext参数传递给__switch_to()函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void __switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread,
*next = &next_p->thread;
struct tss_struct *tss = init_tss + smp_processor_id();
unlazy_fpu(prev_p);/* 如果数学处理器工作,则保存其寄存器的值*/
/* 将`TSS`中的内核级(0 级)堆栈指针换成`next->esp0,这就是`next`进程在内核栈的指针*/

tss->esp0 = next->esp0;
/* 保存`fs`和`gs,但无需保存`es`和`ds,因为当处于内核时,内核段总是保持不变*/
asm volatile("movl %%fs,%0":"=m" (*(int *)&prev->fs));
asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->gs));
/*恢复`next`进程的`fs`和`gs */
loadsegment(fs, next->fs);
loadsegment(gs, next->gs);
/* 如果`next`挂起时使用了调试寄存器,则装载 0~7 个寄存器中的 6 个寄存器,其中第 4、5 个寄存器没有使用 */
if (next->debugreg[7]){
loaddebug(next, 0);
loaddebug(next, 1);
loaddebug(next, 2);
loaddebug(next, 3);
/* no 4 and 5 */
loaddebug(next, 6);
loaddebug(next, 7);
}
if (prev->ioperm || next->ioperm) {
if (next->ioperm) {
/*把`next`进程的`I/O`操作权限位图拷贝到`TSS`中 */
memcpy(tss->io_bitmap, next->io_bitmap,
IO_BITMAP_SIZE*sizeof(unsigned long));
/* 把`io_bitmap`在`tss`中的偏移量赋给`tss->bitmap */
tss->bitmap = IO_BITMAP_OFFSET;
} else

/*如果一个进程要使用`I/O`指令,但是,若位图的偏移量超出`TSS`的范围,
就会产生一个可控制的`SIGSEGV`信号。第一次对`sys_ioperm()的调用会
建立起适当的位图 */

tss->bitmap = INVALID_IO_BITMAP_OFFSET;
}
}

从上面的描述我们看到,尽管Intel本身为操作系统中的进程(任务)切换提供了硬件支持,但是Linux内核的设计者并没有完全采用这种思想,而是用软件实现了进程切换,而且,软件实现比硬件实现的效率更高,灵活性更大。

Linux内存管理

Linux的内存管理概述

Linux的内存管理主要体现在对虚拟内存的管理。我们可以把Linux虚拟内存管理功能概括为以下几点:

  • 大地址空间;
  • 进程保护;
  • 内存映射;
  • 公平的物理内存分配;
  • 共享虚拟内存。

Linux虚拟内存的实现结构

我们先从整体结构上了解Linux对虚拟内存的实现结构。

  1. 内存映射模块(mmap):负责把磁盘文件的逻辑地址映射到虚拟地址,以及把虚拟地址映射到物理地址。
  2. 交换模块(swap):负责控制内存内容的换入和换出,它通过交换机制,使得在物理内存的页面(RAM`页)中保留有效的页 ,即从主存中淘汰最近没被访问的页,保存近来访问过的页。
  3. 核心内存管理模块(core):负责核心内存管理功能,即对页的分配、回收、释放及请页处理等,这些功能将被别的内核子系统(如文件系统)使用。
  4. 结构特定的模块:负责给各种硬件平台提供通用接口,这个模块通过执行命令来改变硬件MMU的虚拟地址映射,并在发生页错误时,提供了公用的方法来通知别的内核子系统。这个模块是实现虚拟内存的物理基础。

内核空间和用户空间

Linux的虚拟地址空间也为 0~4G字节。Linux内核将这 4G 字节的空间分为两部分。将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为“内核空间”。而将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为“用户空间”。因为每个进程可以通过系统调用进入内核,因此,Linux内核由系统内的所有进程共享。于是,从具体进程的角度来看,每个进程可以拥有 4G 字节的虚拟空间。

Linux使用两级保护机制:0 级供内核使用,3 级供用户程序使用。每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的。最高的 1G`字节虚拟内核空间则为所有进程以及内核所共享。

虚拟内核空间到物理空间的映射

内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中。虽然内核空间占据了每个虚拟空间中的最高 1G 字节,但映射到物理内存却总是从最低地址(0x00000000)开始。如图 6.4 所示,对内核空间来说,其地址映射是很简单的线性映射,0xC0000000就是物理地址与线性地址之间的位移量,在Linux代码中就叫做PAGE_OFFSET

我们来看一下在include/asm/i386/page.h中对内核空间中地址映射的说明及定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/*
* This handles the memory map.. We could make this a config
* option, but too many people screw it up, and too few need
* it.
*
* A __PAGE_OFFSET of 0xC0000000 means that the kernel has
* a virtual address space of one gigabyte, which limits the
* amount of physical memory you can use to about 950MB.
*
* If you want more physical memory than this then see the CONFIG_HIGHMEM4G
* and CONFIG_HIGHMEM64G options in the kernel configuration.
*/
#define __PAGE_OFFSET (0xC0000000)
……
#define PAGE_OFFSET ((unsigned long)__PAGE_OFFSET)
#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)
#define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))

源代码的注释中说明,如果你的物理内存大于 950MB,那么在编译内核时就需要加CONFIG_HIGHMEM4GCONFIG_HIGHMEM64G选项,这种情况我们暂不考虑。如果物理内存小于950MB,则对于内核空间而言,给定一个虚地址x,其物理地址为x - PAGE_OFFSET,给定一个物理地址x,其虚地址为x + PAGE_OFFSET。这里再次说明,宏__pa()仅仅把一个内核空间的虚地址映射到物理地址,而决不适用于用户空间,用户空间的地址映射要复杂得多。

内核映像

在下面的描述中,我们把内核的代码和数据就叫内核映像(Kernel Image)。当系统启动时,Linux内核映像被安装在物理地址 0x00100000 开始的地方,即 1MB 开始的区间(第 1M 留作它用)。然而,在正常运行时, 整个内核映像应该在虚拟内核空间中,因此,连接程序在连接内核映像时,在所有的符号地址上加一个偏移量PAGE_OFFSET,这样,内核映像在内核空间的起始地址就为 0xC0100000。

例如,进程的页目录PGD(属于内核数据结构)就处于内核空间中。在进程切换时,要将寄存器CR3设置成指向新进程的页目录PGD,而该目录的起始地址在内核空间中是虚地址,但CR3所需要的是物理地址,这时候就要用__pa()进行地址转换。在mm_context.h中就有这么一行语句:

1
asm volatile("movl %0,%%cr3": :"r" (__pa(next->pgd));

这是一行嵌入式汇编代码,其含义是将下一个进程的页目录起始地址next_pgd,通过__pa()转换成物理地址,存放在某个寄存器中,然后用mov指令将其写入 CR3 寄存器中。经过这行语句的处理,CR3 就指向新进程next的页目录表PGD了。

虚拟内存实现机制间的关系

Linux虚拟内存的实现需要各种机制的支持,因此,本章我们将对内存的初始化进行描述以后,围绕以下几种实现机制进行介绍:

  • 内存分配和回收机制;
  • 地址映射机制;
  • 缓存和刷新机制;
  • 请页机制;
  • 交换机制;
  • 内存共享机制。

  • 首先内存管理程序通过映射机制把用户程序的逻辑地址映射到物理地址,在用户程序运行时如果发现程序中要用的虚地址没有对应的物理内存时,就发出了请页要求①;
  • 如果有空闲的内存可供分配,就请求分配内存②(于是用到了内存的分配和回收),
  • 并把正在使用的物理页记录在页缓存中③(使用了缓存机制)。
  • 如果没有足够的内存可供分配,那么就调用交换机制,腾出一部分内存④⑤。
  • 另外在地址映射中要通过`TLB(翻译后援存储器)来寻找物理页⑧;
  • 交换机制中也要用到交换缓存⑥;
  • 并且把物理页内容交换到交换文件中后也要修改页表来映射文件地址⑦。

Linux内存管理的初始化

启用分页机制

当Linux启动时,首先运行在实模式下,随后就要转到保护模式下运行。Linux内核代码的入口点就是/arch/i386/kernel/head.S中的startup_32

页表的初步初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* The page tables are initialized to only 8MB here - the final page
* tables are set up later depending on memory size.
*/
.org 0x2000
ENTRY(pg0)

.org 0x3000
ENTRY(pg1)
/*
* empty_zero_page must immediately follow the page tables ! (The
* initialization loop counts until empty_zero_page)
*/
.org 0x4000
ENTRY(empty_zero_page)
/*
* Initialize page tables
*/
movl $pg0-__PAGE_OFFSET,%edi /* initialize page tables */
movl $007,%eax /* "007" doesn't mean with right to kill, but PRESENT+RW+USER */
2: stosl
add $0x1000,%eax
cmp $empty_zero_page-__PAGE_OFFSET,%edi
jne 2b

内核的这段代码执行时,因为页机制还没有启用,还没有进入保护模式,因此指令寄存器EIP中的地址还是物理地址,但因为pg0中存放的是虚拟地址(gcc编译内核以后形成的符号地址都是虚拟地址),因此,$pg0-__PAGE_OFFSET获得pg0的物理地址,可见pg0存放在相对于内核代码起点为 0x2000 的地方,即物理地址为 0x00102000,而pg1的物理地址则为 0x00103000。pg0pg1这个两个页表中的表项则依次被设置为 0x007、0x1007、0x2007等。其中最低的 3 位均为 1,表示这两个页为用户页,可写,且页的内容在内存中。所映射的物理页的基地址则为 0x0、0x1000、0x2000 等,也就是物理内存中的页面 0、1、2、3 等等,共映射 2K个页面,即 8MB 的存储空间。由此可以看出,Linux内核对物理内存的最低要求为 8MB。紧接着存放的是empty_zero_page页(即零页),零页存放的是系统启动参数和命令行参数。

启用分页机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/*
* This is initialized to create an identity-mapping at 0-8M (for bootup
* purposes) and another mapping of the 0-8M area at virtual address
* PAGE_OFFSET.
*/
.org 0x1000
ENTRY(swapper_pg_dir)
.long 0x00102007
.long 0x00103007
.fill BOOT_USER_PGD_PTRS-2,4,0
/* default: 766 entries */
.long 0x00102007
.long 0x00103007
/* default: 254 entries */
.fill BOOT_KERNEL_PGD_PTRS-2,4,0
/*
* Enable paging
*/
3:
movl $swapper_pg_dir-__PAGE_OFFSET,%eax
movl %eax,%cr3 /* set the page table pointer.. */
movl %cr0,%eax
orl $0x80000000,%eax
movl %eax,%cr0 /* ..and set paging (PG) bit */
jmp 1f /* flush the prefetch-queue */
1:
movl $1f,%eax
jmp *%eax /* make sure eip is relocated */
1:

我们先来看这段代码的功能。这段代码就是把页目录swapper_pg_dir的物理地址装入控制寄存器cr3,并把cr0 中的最高位置成 1,这就开启了分页机制。但是,启用了分页机制,并不说明Linux内核真正进入了保护模式,因为此时,指令寄存器EIP中的地址还是物理地址,而不是虚地址。jmp 1f指令从逻辑上说不起什么作用,但是,从功能上说它起到丢弃指令流水线中内容的作用,因为这是一个短跳转,EIP中还是物理地址。紧接着的movjmp指令把第 2 个标号为 1 的地址装入EAX寄存器并跳转到那儿。在这两条指令执行的过程中, EIP还是指向物理地址“1MB+某处”。因为编译程序使所有的符号地址都在虚拟内存空间中,因此,第 2 个标号 1 的地址就在虚拟内存空间的某处(PAGE_OFFSET+某处),于是,jmp指令执行以后,EIP`就指向虚拟内核空间的某个地址,这就使CPU转入了内核空间,从而完成了从实模式到保护模式的平稳过渡。

然后再看页目录swapper_pg_dir中的内容。从前面的讨论我们知道pg0 和pg1 这两个页表的起始物理地址分别为 0x00102000 和 0x00103000。页目录项的最低 12位用来描述页表的属性。因此,在swapper_pg_dir中的第 0 和第 1 个目录项 0x00102007、0x00103007,就表示pg0 和pg1 这两个页表是用户页表、可写且页表的内容在内存。接着,把swapper_pg_dir中的第 2~767 共 766 个目录项全部置为 0。因为一个页表的大小为 4KB,每个表项占 4 个字节,即每个页表含有 1024 个表项,每个页的大小也为 4KB,因此这 768 个目录项所映射的虚拟空间为 768×1024×4K=3G,也就是swapper_pg_dir表中的前 768 个目录项映射的是用户空间。

最后,在第 768 和 769 个目录项中又存放pg0 和pg1 这两个页表的地址和属性,而把第770~1023 共 254 个目录项置 0。这 256 个目录项所映射的虚拟地址空间为 256×1024×4K=1G,也就是swapper_pg_dir表中的后 256 个目录项映射的是内核空间。由此可以看出,在初始的页目录swapper_pg_dir中,用户空间和内核空间都只映射了开头的两个目录项,即 8MB`的空间,而且有着相同的映射,如图 6.6 所示。

当CPU进入内核代码的起点startup_32后,是以物理地址来取指令的。在这种情况下,如果页目录只映射内核空间,而不映射用户空间的低区,则一旦开启页映射机制以后就不能继续执行了,这是因为,此时CPU中的指令寄存器EIP仍指向低区,仍会以物理地址取指令,直到以某个符号地址为目标作绝对转移或调用子程序为止。所以,Linux内核就采取了上述的解决办法。

但是,在CPU转入内核空间以后,应该把用户空间低区的映射清除掉。页目录swapper_pg_dir经扩充后就成为所有内核线程的页目录。在内核线程的正常运行中,处于内核态的CPU是不应该通过用户空间的虚拟地址访问内存的。清除了低区的映射以后,如果发生CPU在内核中通过用户空间的虚拟地址访问内存,就可以因为产生页面异常而捕获这个错误。

物理内存的初始分布

经过这个阶段的初始化,初始化阶段页目录及几个页表在物理空间中的位置如图 6.7 所示。

其中empty_zero_page中存放的是在操作系统的引导过程中所收集的一些数据,叫做引导参数。因为这个页面开始的内容全为 0,所以叫做“零页”,代码中常常通过宏定义ZERO_PAGE来引用这个页面。不过,这个页面要到初始化完成,系统转入正常运行时才会用到。这里假定这些参数已被复制到“零页”,在setup.c中定义了引用这些参数的宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 /*
* This is set up by the setup-routine at boot-time
*/
#define PARAM ((unsigned char *)empty_zero_page)
#define SCREEN_INFO (*(struct screen_info *) (PARAM+0))
#define EXT_MEM_K (*(unsigned short *) (PARAM+2))
#define ALT_MEM_K (*(unsigned long *) (PARAM+0x1e0))
#define E820_MAP_NR (*(char*) (PARAM+E820NR))
#define E820_MAP ((struct e820entry *) (PARAM+E820MAP))
#define APM_BIOS_INFO (*(struct apm_bios_info *) (PARAM+0x40))
#define DRIVE_INFO (*(struct drive_info_struct *) (PARAM+0x80))
#define SYS_DESC_TABLE (*(struct sys_desc_table_struct*)(PARAM+0xa0))
#define MOUNT_ROOT_RDONLY (*(unsigned short *) (PARAM+0x1F2))
#define RAMDISK_FLAGS (*(unsigned short *) (PARAM+0x1F8))
#define ORIG_ROOT_DEV (*(unsigned short *) (PARAM+0x1FC))
#define AUX_DEVICE_INFO (*(unsigned char *) (PARAM+0x1FF))
#define LOADER_TYPE (*(unsigned char *) (PARAM+0x210))
#define KERNEL_START (*(unsigned long *) (PARAM+0x214))
#define INITRD_START (*(unsigned long *) (PARAM+0x218))
#define INITRD_SIZE (*(unsigned long *) (PARAM+0x21c))
#define COMMAND_LINE ((char *) (PARAM+2048))
#define COMMAND_LINE_SIZE 256

其中宏PARAM就是empty_zero_page的起始位置。

这里要特别对宏E820_MAP进行说明。E820_MAP是个struct e820entry数据结构的指针,存放在参数块中位移为 0x2d0 的地方。这个数据结构定义在include/i386/e820.h中:

1
2
3
4
5
6
7
8
9
struct e820map {
int nr_map;
struct e820entry {
unsigned long long addr; /* start of memory segment */
unsigned long long size; /* size of memory segment */
unsigned long type; /* type of memory segment */
} map[E820MAX];
};
extern struct e820map e820;

其中,E820MAX被定义为 32。从这个数据结构的定义可以看出,每个e820entry都是对一个物理区间的描述,并且一个物理区间必须是同一类型。如果有一片地址连续的物理内存空间,其一部分是RAM,而另一部分是ROM,那就要分成两个区间。即使同属RAM,如果其中一部分要保留用于特殊目的,那也属于不同的分区。在e820.h`文件中定义了 4 种不同的类型:

1
2
3
4
5
#define E820_RAM 1
#define E820_RESERVED 2
#define E820_ACPI 3 /* usable as RAM once ACPI tables have been read */
#define E820_NVS 4
#define HIGH_MEMORY (1024*1024)

其中E820_NVS表示“Non-Volatile Storage”,即“不挥发”存储器,包括ROM、EPROM、Flash存储器等。

因为历史的原因,把 1MB以上的空间定义为HIGH_MEMORY,这个称呼一直沿用到现在,于是代码中的常数HIGH_MEMORY就定义为“1024×1024”。现在,配备了 128MB 的内存已经是很普遍了。但是,为了保持兼容,就得留出最初 1MB`的空间。这个阶段初始化后,物理内存中内核映像的分布如图 6.8 所示。

符号_text对应物理地址 0x00100000,表示内核代码的第一个字节的地址。内核代码的结束位置用另一个类似的符号_etext表示。内核数据被分为两组:初始化过的数据和未初始化过的数据。初始化过的数据在_etext后开始,在_edata处结束,紧接着是未初始化过的数据,其结束符号为_end,这也是整个内核映像的结束符号。

物理内存的探测

BIOS能引导操作系统,还担负着加电自检和对资源的扫描探测,包括了对物理内存的自检和扫描。对于这个阶段中获得的内存信息可以通过BIOS调用int 0x15加以检查。由于Linux内核不能作BIOS调用,因此内核本身就得代为检查,并根据获得的信息生成一幅物理内存构成图,然后通过上面提到的参数块传给内核,使得内核能知道系统中内存资源的配置。之所以称为e820 图,是因为在通过int 0x15查询内存的构成时要把调
用参数之一设置成0xe820

分页机制启用以后,与内存管理相关的操作就是调用init/main.c中的start_kernel()函数,start_kernel()函数要调用一个叫setup_arch()的函数,setup_arch()位于arch/i386/kernel/setup.c文件中,我们所关注的与物理内存探测相关的内容就在这个函数中。

setup_arch()函数

  • 首先调用setup_memory_region()函数,这个函数处理内存构成图(map),并把内存的分布信息存放在全局变量`e820 中。
  • 调用parse_mem_cmdline(cmdline_p)函数。在特殊的情况下,有的系统可能有特殊的RAM空间结构,此时可以通过引导命令行中的选择项来改变存储空间的逻辑结构,使其正确反映内存的物理结构。此函数的作用就是分析命令行中的选择项,并据此对数据结构e820 中的内容作出修正,其代码也在setup.c中。

宏定义:

1
2
3
#define PFN_UP(x) (((x) + PAGE_SIZE-1) >> PAGE_SHIFT)
#define PFN_DOWN(x) ((x) >> PAGE_SHIFT)
#define PFN_PHYS(x) ((x) << PAGE_SHIFT)

PFN_UP()PFN_DOWN()都是将地址x转换为页面号(PFN即Page Frame Number的缩写),二者之间的区别为:PFN_UP()返回大于x的第 1 个页面号,而PFN_DOWN()返回小于x的第 1 个页面号。宏PFN_PHYS()返回页面号x的物理地址。

宏定义

1
2
3
4
5
6
7
/*
* 128MB for vmalloc and initrd
*/
#define VMALLOC_RESERVE (unsigned long)(128 << 20)
#define MAXMEM (unsigned long)(-PAGE_OFFSET-VMALLOC_RESERVE)
#define MAXMEM_PFN PFN_DOWN(MAXMEM)
#define MAX_NONPAE_PFN (1 << 20)

对这几个宏描述如下:

  • VMALLOC_RESERVE:为vmalloc()函数访问内核空间所保留的内存区,大小为 128MB。
  • MAXMEM:内核能够直接映射的最大RAM容量,为 1GB-128MB=896MB(-PAGE_OFFSET`就等于 1GB)
  • MAXMEM_PFN:返回由内核能直接映射的最大物理页面数。
  • MAX_NONPAE_PFN:给出在 4GB 之上第 1 个页面的页面号。当页面扩充(PAE)功能启用时,才能访问 4GB 以上的内存。

获得内核映像之后的起始页面号:

1
2
3
4
5
/*
* partially used pages are not usable - thus
* we are rounding upwards:
*/
start_pfn = PFN_UP(__pa(&_end));

在上一节已说明,宏__pa()返回给定虚拟地址的物理地址。其中标识符_end表示内核映像在内核空间的结束位置。因此,存放在变量start_pfn中的值就是紧接着内核映像之后的页面号。

找出可用的最高页面号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* Find the highest page frame number we have available
*/
max_pfn = 0;
for (i = 0; i < e820.nr_map; i++) {
unsigned long start, end;
/* RAM? */
if (e820.map[i].type != E820_RAM)
continue;
start = PFN_UP(e820.map[i].addr);
end = PFN_DOWN(e820.map[i].addr + e820.map[i].size);
if (start >= end)
continue;
if (end > max_pfn)
max_pfn = end;
}

上面这段代码循环查找类型为E820_RAM(可用RAM)的内存区,并把最后一个页面的页面号存放在max_pfn中。

确定最高和最低内存范围:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* Determine low and high memory ranges:
*/
max_low_pfn = max_pfn;
if (max_low_pfn > MAXMEM_PFN) {
max_low_pfn = MAXMEM_PFN;
#ifndef CONFIG_HIGHMEM
/* Maximum memory usable is what is directly addressable */
printk(KERN_WARNING "Warning only %ldMB will be used.\n",
MAXMEM>>20);
if (max_pfn > MAX_NONPAE_PFN)
printk(KERN_WARNING "Use a PAE enabled kernel.\n");
else
printk(KERN_WARNING "Use a HIGHMEM enabled kernel.\n");
#else /* !CONFIG_HIGHMEM */
#ifndef CONFIG_X86_PAE
if (max_pfn > MAX_NONPAE_PFN) {
max_pfn = MAX_NONPAE_PFN;
printk(KERN_WARNING "Warning only 4GB will be used.\n");
printk(KERN_WARNING "Use a PAE enabled kernel.\n");
}
#endif /* !CONFIG_X86_PAE */
#endif /* !CONFIG_HIGHMEM */
}

有两种情况:

  • 如果物理内存RAM大于 896MB,而小于 4GB,则选用CONFIG_HIGHMEM选项来进行访问;
  • 如果物理内存RAM大于 4GB,则选用CONFIG_X86_PAE(启用PAE模式)来进行访问。

上面这段代码检查了这两种情况,并显示适当的警告信息。

1
2
3
4
5
6
7
8
#ifdef CONFIG_HIGHMEM
highstart_pfn = highend_pfn = max_pfn;
if (max_pfn > MAXMEM_PFN) {
highstart_pfn = MAXMEM_PFN;
printk(KERN_NOTICE "%ldMB HIGHMEM available.\n",
pages_to_mb(highend_pfn - highstart_pfn));
}
#endif

如果使用了CONFIG_HIGHMEM选项,上面这段代码仅仅打印出大于 896MB`的可用物理内存数量。

初始化引导时的分配器

1
2
/* Initialize the boot-time allocator (with low memory only): */
bootmap_size = init_bootmem(start_pfn, max_low_pfn);

通过调用init_bootmem()函数,为物理内存页面管理机制的建立做初步准备,为整个物理内存建立起一个页面位图。这个位图建立在从start_pfn开始的地方,也就是说,把内核映像终点_end上方的若干页面用作物理页面位图。在前面的代码中已经搞清楚了物理内存顶点所在的页面号为max_low_pfn,所以物理内存的页面号一定在 0~max_low_pfn`之间。建立这个位图的目的就是要搞清楚哪一些物理内存页面可以动态分配的。

bootmem分配器,登记全部低区(0~896MB)的可用RAM页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 /*
* Register fully available low RAM pages with the
* bootmem allocator.
*/
for (i = 0; i < e820.nr_map; i++) {
unsigned long curr_pfn, last_pfn, size;
/*
* Reserve usable low memory
*/
if (e820.map[i].type != E820_RAM)
continue;
/*
* We are rounding up the start address of usable memory:
*/
curr_pfn = PFN_UP(e820.map[i].addr);
if (curr_pfn >= max_low_pfn)
continue;
/*
* ... and at the end of the usable range downwards:
*/
last_pfn = PFN_DOWN(e820.map[i].addr + e820.map[i].size);
if (last_pfn > max_low_pfn)
last_pfn = max_low_pfn;
/*
* .. finally, did all the rounding and playing
* around just make the area go away?
*/
if (last_pfn <= curr_pfn)
continue;
size = last_pfn - curr_pfn;
free_bootmem(PFN_PHYS(curr_pfn), PFN_PHYS(size));
}

这个循环仔细检查所有可以使用的RAM,并调用free_bootmem()函数把这些可用RAM标记为可用。这个函数调用以后,只有类型为 1(可用RAM)的内存被标记为可用的。

保留内存:

1
2
3
4
5
6
7
8
/*
* Reserve the bootmem bitmap itself as well. We do this in two
* steps (first step was init_bootmem()) because this catches
* the (very unlikely) case of us accidentally initializing the
* bootmem allocator with an invalid RAM area.
*/
reserve_bootmem(HIGH_MEMORY, (PFN_PHYS(start_pfn) +
bootmap_size + PAGE_SIZE-1) - (HIGH_MEMORY));

这个函数把内核和bootmem位图所占的内存标记为“保留”。HIGH_MEMORY为 1MB,即内核开始的地方。

setup_memory_region() 函数

这个函数用来处理BIOS的内存构成图,并把这个构成图拷贝到全局变量`e820 中。如果操作失败,就创建一个伪内存构成图。这个函数的主要操作如下所述。

  • 调用sanitize_e820_map()函数,以删除内存构成图中任何重叠的部分,因为BIOS所报告的内存构成图可能有重叠。
  • 调用copy_e820_map()进行实际的拷贝。
  • 如果操作失败,创建一个伪内存构成图,这个伪构成图有两部分:0 到 640K及 1M到最大物理内存。
  • 打印最终的内存构成图。

copy_e820_map() 函数

函数原型为:

1
static int __init sanitize_e820_map(struct e820entry * biosmap, char * pnr_map)

其主要操作如下概述。

  1. 如果物理内存区间小于 2,那肯定出错。因为BIOS至少和RAM属于不同的物理区间。

    1
    2
    if (nr_map < 2)
    return -1;
  2. BIOS构成图中读出一项。

    1
    2
    3
    4
    5
    6
    do {
    unsigned long long start = biosmap->addr;
    unsigned long long size = biosmap->size;
    unsigned long long end = start + size;
    unsigned long type = biosmap->type;
    }
  3. 进行检查。

    1
    2
    3
    /* Overflow in 64 bits? Ignore the memory map. */
    if (start > end)
    return -1;
  4. 一些BIOS把 640KB~1MB 之间的区间作为RAM来用,这是不符合常规的。因为从0xA0000 开始的空间用于图形卡,因此,在内存构成图中要进行修正。如果一个区的起点在0xA0000 以下,而终点在 1MB 之上,就要将这个区间拆开成两个区间,中间跳过从 0xA0000到 1MB边界之间的那一部分。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /*
    * Some BIOSes claim RAM in the 640k - 1M region.
    * Not right. Fix it up.
    */
    if (type == E820_RAM) {
    if (start < 0x100000ULL && end > 0xA0000ULL) {
    if (start < 0xA0000ULL)
    add_memory_region(start, 0xA0000ULL-start, type)
    if (end <= 0x100000ULL)
    continue;
    start = 0x100000ULL;
    size = end - start;
    }
    }

    add_memory_region(start, size, type);
    } while (biosmap++,--nr_map);
    return 0;

add_memory_region() 函数

这个函数的功能就是在`e820 中增加一项,其主要操作如下所述。

  1. 获得已追加在`e820 中的内存区数。

    1
    int x = e820.nr_map;
  2. 如果数目已达到最大(32),则显示一个警告信息并返回。

    1
    2
    3
    4
    if (x == E820MAX) {
    printk(KERN_ERR "Oops! Too many entries in the memory map!\n");
    return;
    }
  3. 在e820 中增加一项,并给nr_map加 1。

    1
    2
    3
    4
    e820.map[x].addr = start;
    e820.map[x].size = size;
    e820.map[x].type = type;
    e820.nr_map++;

这个函数把内存构成图在控制台上输出。例如函数的输出为(BIOS所提供的物理RAM区间):

1
2
3
4
BIOS-e820: 0000000000000000 - 00000000000a0000 (usable)
BIOS-e820: 00000000000f0000 - 0000000000100000 (reserved)
BIOS-e820: 0000000000100000 - 000000000c000000 (usable)
BIOS-e820: 00000000ffff0000 - 0000000100000000 (reserved)

物理内存的描述

一致存储结构(UMA)和非一致存储结构(NUMA)

在传统的计算机结构中,整个物理内存都是均匀一致的,CPU访问这个空间中的任何一个地址所需要的时间都相同,所以把这种内存称为“一致存储结构(Uniform Memory Architecture)”,简称`UMA。

在多CPU结构中,系统中只有一条总线(例如,PCI`总线),每个CPU模块都有本地的物理内存,但是也可以通过系统总线访问其他CPU模块上的内存,所有的CPU模块都可以通过系统总线来访问公用的存储模块。因此,所有这些物理内存的地址可以互相连续而形成一个连续的物理地址空间。

显然,就某个特定的CPU而言,访问其本地的存储器速度是最快的,而穿过系统总线访问公用存储模块或其他CPU模块上的存储器就比较慢,而且还面临因可能的竞争而引起的不确定性。也就是说,在这样的系统中,其物理存储空间虽然地址连续,但因为所处“位置”不同而导致的存取速度不一致,所以称为“非一致存储结构( Non-Uniform Memory Architecture),简称`NUMA。

为了对NUMA进行描述,引入一个新的概念——“存储节点(或叫节点)”,把访问时间相同的存储空间就叫做一个“存储节点”。一般来说,连续的物理页面应该分配在相同的存储节点上。

Linux把物理内存划分为 3个层次来管理:存储节点(Node)、管理区(Zone)和页面(Page),并用 3 个相应的数据结构来描述。

页面(Page)数据结构

对一个物理页面的描述在/include/linux/mm.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/*
* Each physical page in the system has a struct page associated with
* it to keep track of whatever it is we are using the page for at the
* moment. Note that we have no way to track which tasks are using
* a page.
*
* Try to keep the most commonly accessed fields in single cache lines
* here (16 bytes or greater). This ordering should be particularly
* beneficial on 32-bit processors.
*
* The first line is data used in page cache lookup, the second line
* is used for linear searches (eg. clock algorithm scans).
*
* TODO: make this structure smaller, it could be as small as 32 bytes.
*/
typedef struct page {
struct list_head list; /* ->mapping has some page lists. */
struct address_space *mapping; /* The inode (or ...) we belong to. */
unsigned long index; /* Our offset within mapping. */
struct page *next_hash; /* Next page sharing our hash bucket in the pagecache hash table. */
atomic_t count; /* Usage count, see below. */
unsigned long flags; /* atomic flags, some possibly updated asynchronously */
struct list_head lru; /* Pageout list, eg. active_list;
protected by pagemap_lru_lock !! */
wait_queue_head_t wait; /* Page locked? Stand in line... */
struct page **pprev_hash; /* Complement to *next_hash. */
struct buffer_head * buffers; /* Buffer maps us to a disk block. */
void *virtual; /* Kernel virtual address (NULL if not kmapped, ie. highmem) */
struct zone_struct *zone; /* Memory zone we are in. */
} mem_map_t;
extern mem_map_t * mem_map;

内核中用来表示这个数据结构的变量常常是pagemap。当页面的数据来自一个文件时,index代表着该页面中的数据在文件中的偏移量;当页面的内容被换出到交换设备上,则index指明了页面的去向。结构中各个成分的次序是有讲究的,尽量使得联系紧密的若干域存放在一起,这样当这个数据结构被装入到高速缓存中时,联系紧密的域就可以存放在同一缓冲行(Cache Line)中。因为同一缓冲行(其大小为 16字节)中的内容几乎可以同时存取,因此,代码注释中希望这个数据结构尽量地小到用 32个字节可以描述。

系统中的每个物理页面都有一个Page(或mem_map_t)结构。系统在初始化阶段根据内存的大小建立起一个Page结构的数组mem_map,数组的下标就是内存中物理页面的序号。

管理区`Zone

为了对物理页面进行有效的管理,Linux又把物理页面划分为 3 个区:

  • 专供DMA使用的ZONE_DMA区(小于 16MB);
  • 常规的ZONE_NORMAL区(大于 16MB`小于 896MB);
  • 内核不能直接映射的区ZONE_HIGME区(大于 896MB)。

这里进一步说明为什么对DMA要单独设置管理区。

  • 首先,DMA使用的页面是磁盘I/O所需的,如果在页面的分配过程中,所有的页面全被分配完,那么页面及盘区的交换就无法进行了,这是操作系统决不允许出现的现象。
  • 另外,在 i386 CPU中,页式存储管理的硬件支持是在CPU内部实现的,而不像有些CPU那样由一个单独的MMU来提供,所以DMA对内存的访问不经过MMU提供的地址映射。这样,外部设备就要直接访问物理页面的地址。可是,有些外设(特别是插在ISA总线上的外设接口卡)在这方面往往有些限制,要求用于DMA的物理地址不能过高。另一方面,当DMA所需的缓冲区超过一个物理页面的大小时,就要求两个物理页面在物理上是连续的,但因为此时DMA控制器不能依靠CPU内部的MMU将连续的虚存页面映射到物理上也连续的页面上,因此,用于DMA的物理页面必须加以单独管理。

存储节点(Node)的数据结构

存储节点的数据结构为pglist_data,定义于include/linux/mmzone.h中:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct pglist_data {
zone_t node_zones[MAX_NR_ZONES];
zonelist_t node_zonelists[GFP_ZONEMASK+1];
int nr_zones;
struct page *node_mem_map;
unsigned long *valid_addr_bitmap;
struct bootmem_data *bdata;
unsigned long node_start_paddr;
unsigned long node_start_mapnr;
unsigned long node_size;
int node_id;
struct pglist_data *node_next;
} pg_data_t;

显然,若干存储节点的pglist_data数据结构可以通过node_next形成一个单链表队列。每个结构中的node_mem_map指向具体节点的page结构数组,而数组node_zone[]就是该节点的最多 3 个页面管理区。

pglist_data结构里设置了一个node_zonelists数组,其类型定义也在同一文件中:

1
2
3
4
typedef struct zonelist_struct {
zone_t *zone[MAX_NR_ZONE+1]; //NULL delimited
int gfp_mast;
} zonelist_t;

这里的zone[]是个指针数组,各个元素按特定的次序指向具体的页面管理区,表示分配页面时先试zone[0]所指向的管理区,如果不能满足要求就试zone[1]所指向的管理区,等等。

页面管理机制的初步建立

为了对页面管理机制作出初步准备,Linux使用了一种叫bootmem分配器(Bootmem Allocator)的机制,这种机制仅仅用在系统引导时,它为整个物理内存建立起一个页面位图。这个位图建立在从start_pfn开始的地方,也就是说,内核映像终点_end上方的地方。这个位图用来管理低区(例如小于 896MB),因为在 0 到 896MB 的范围内,有些页面可能保留,有些页面可能有空洞,因此,建立这个位图的目的就是要搞清楚哪一些物理页面是可以动态分配的。用来存放位图的数据结构为bootmem_data(在mm/numa.c中) :

1
2
3
4
5
6
7
typedef struct bootmem_data {
unsigned long node_boot_start;
unsigned long node_low_pfn;
void *node_bootmem_map;
unsigned long last_offset;
unsigned long last_pos;
} bootmem_data_t;

  • node_boot_start表示存放bootmem位图的第一个页面(即内核映像结束处的第一个页面)。
  • node_low_pfn表示物理内存的顶点,最高不超过 896MB。
  • node_bootmem_map指向bootmem位图
  • last_offset用来存放在前一次分配中所分配的最后一个字节相对于last_pos的位移量。
  • last_pos用来存放前一次分配的最后一个页面的页面号。这个域用在__alloc_bootmem_core()函数中,通过合并相邻的内存来减少内部碎片。

下面介绍与bootmem相关的几个函数,这些函数位于mm/bootmeme.c中。

init_bootmem()函数

1
2
3
4
5
6
unsigned long __init init_bootmem (unsigned long start, unsigned long pages)
{
max_low_pfn = pages;
min_low_pfn = start;
return(init_bootmem_core(&contig_page_data, start, 0, pages));
}

这个函数仅在初始化时用来建立bootmem分配器。这个函数实际上是init_bootmem_core()函数的封装函数。init_bootmem()函数的参数start表示内核映像结束处的页面号,而pages表示物理内存顶点所在的页面号。而函数init_bootmem_core()就是对contig_page_data变量进行初始化。下面我们来看一下对该变量的定义:

1
2
3
int numnodes = 1; /* Initialized for UMA platforms */
static bootmem_data_t contig_bootmem_data;
pg_data_t contig_page_data = { bdata: &contig_bootmem_data };

变量contig_page_data的类型就是前面介绍过的pg_data_t数据结构。每个pg_data_t数据结构代表着一片均匀的、连续的内存空间。在连续空间UMA结构中,只有一个节点contig_page_data,而在NUMA结构或不连续空间UMA结构中,有多个这样的数据结构。系统中各个节点的pg_data_t数据结构通过node_next连接在一起成为一个链。有一个全局量pgdat_list则指向这个链。从上面的定义可以看出,contig_page_data是链中的第一个节点。

这里假定整个物理空间为均匀的、连续的,以后若发现这个假定不能成立,则将新的pg_data_t结构加入到链中。pg_data_t结构中有个指针bdatacontig_page_data被初始化为指向bootmem_data_t数据结构。下面我们来看init_bootmem_core()函数的具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* Called once to set up the allocator itself.
*/
static unsigned long __init init_bootmem_core (pg_data_t *pgdat, unsigned long mapstart, unsigned long start, unsigned long end)
{
bootmem_data_t *bdata = pgdat->bdata;
unsigned long mapsize = ((end - start)+7)/8;
pgdat->node_next = pgdat_list;
pgdat_list = pgdat;
mapsize = (mapsize + (sizeof(long) - 1UL)) & ~(sizeof(long) - 1UL);
bdata->node_bootmem_map = phys_to_virt(mapstart << PAGE_SHIFT);
bdata->node_boot_start = (start << PAGE_SHIFT);
bdata->node_low_pfn = end;
/*
* Initially all pages are reserved - setup_arch() has to
* register free RAM areas explicitly.
*/
memset(bdata->node_bootmem_map, 0xff, mapsize);
return mapsize;
}

下面对这一函数给予说明。

  • 变量mapsize存放位图的大小。(end - start)给出现有的页面数,再加个 7 是为了向上取整,除以 8 就获得了所需的字节数(因为每个字节映射 8 个页面)。
  • 变量pgdat_list用来指向节点所形成的循环链表首部,因为只有一个节点,因此使pgdat_list指向自己。
  • 接下来的一句使memsize成为下一个 4 的倍数(4 为CPU的字长)。例如,假设有 40 个物理页面,因此,我们可以得出memsize为 5 个字节。所以,上面的操作就变为(5+(4-1))&~(4-1)(00001000&11111100),最低的两位变为 0,其结果为 8。这就有效地使memsize变为 4 的倍数。
  • phys_to_virt(mapstart << PAGE_SHIFT)把给定的物理地址转换为虚地址。
  • 用节点的起始物理地址初始化node_boot_start(这里为 0x00000000)。
  • 用物理内存节点的页面号初始化node_low_pfn
  • 初始化所有被保留的页面,即通过把页面中的所有位都置为 1 来标记保留的页面。
  • 返回位图的大小。

free_bootmem()函数

这个函数把给定范围的页面标记为空闲(即可用),也就是,把位图中某些位清 0,表示相应的物理内存可以投入分配。原函数为:

1
2
3
4
void __init free_bootmem (unsigned long addr, unsigned long size)
{
return (free_bootmem_core(contig_page_data.bdata, addr, size));
}

从上面可以看出,free_bootmem()是个封装函数,实际的工作是由free_bootmem_core()函数完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void __init free_bootmem_core(bootmem_data_t *bdata, unsigned long addr, unsigned long size)
{
unsigned long i;
unsigned long start;
/*
* round down end of usable mem, partially free pages are
* considered reserved.
*/
unsigned long sidx;
unsigned long eidx = (addr + size - bdata->node_boot_start)/PAGE_SIZE;
unsigned long end = (addr + size)/PAGE_SIZE;
if (!size) BUG();
if (end > bdata->node_low_pfn)
BUG();
/*
* Round up the beginning of the address.
*/
start = (addr + PAGE_SIZE-1) / PAGE_SIZE;
sidx = start - (bdata->node_boot_start/PAGE_SIZE);
for (i = sidx; i < eidx; i++) {
if (!test_and_clear_bit(i, bdata->node_bootmem_map))
BUG();
}
}

对此函数的解释如下。

  • 变量eidx被初始化为页面总数。
  • 变量end被初始化为最后一个页面的页面号。
  • 进行两个可能的条件检查。
  • start初始化为第一个页面的页面号(向上取整),而sidx(start index)初始化为相对于node_boot_start的页面号。
  • 清位图中从sidxeidx的所有位,即把这些页面标记为可用。

reserve_bootmem()函数

这个函数用来保留页面。为了保留一个页面,只需要在bootmem位图中把相应的位置为1 即可。原函数为:

1
2
3
4
void __init reserve_bootmem (unsigned long addr, unsigned long size)
{
reserve_bootmem_core(contig_page_data.bdata, addr, size);
}

reserve_bootmem()为封装函数,实际调用的是reserve_bootmem_core()函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static void __init reserve_bootmem_core ( bootmem_data_t *bdata, unsigned long addr, unsigned long size)
{
unsigned long i;
/*
* round up, partially reserved pages are considered
* fully reserved.
*/
unsigned long sidx = (addr - bdata->node_boot_start)/PAGE_SIZE;
unsigned long eidx = (addr + size - bdata->node_boot_start + PAGE_SIZE-1)/PAGE_SIZE;
unsigned long end = (addr + size + PAGE_SIZE-1)/PAGE_SIZE;
if (!size) BUG();
if (sidx < 0)
BUG();
if (eidx < 0)
BUG();
if (sidx >= eidx)
BUG();
if ((addr >> PAGE_SHIFT) >= bdata->node_low_pfn)
BUG();
if (end > bdata->node_low_pfn)
BUG();
for (i = sidx; i < eidx; i++)
if (test_and_set_bit(i, bdata->node_bootmem_map))
printk("hm, page %08lx reserved twice.\n", i*PAGE_SIZE);
}

对此函数的解释如下。

  • sidx (start index)初始化为相对于node_boot_start的页面号。
  • 变量eidx初始化为页面总数(向上取整)。
  • 变量end初始化为最后一个页面的页面号(向上取整)。
  • 进行各种可能的条件检查。
  • 把位图中从sidxeidx的所有位置 1。

__alloc_bootmem()函数

这个函数以循环轮转的方式从不同节点分配页面。因为在i386 上只有一个节点,因此只循环一次。函数原型为:

1
2
3
4
5
6
7
void * __alloc_bootmem (unsigned long size,
unsigned long align,
unsigned long goal);
void * __alloc_bootmem_core (bootmem_data_t *bdata,
unsigned long size,
unsigned long align,
unsigned long goal);

其中__alloc_bootmem()为封装函数,实际调用的函数为__alloc_bootmem_core(),因为__alloc_bootmem_core()函数比较长,下面分片断来进行仔细分析。

1
2
3
4
5
unsigned long i, start = 0;
void *ret;
unsigned long offset, remaining_size;
unsigned long areasize, preferred, incr;
unsigned long eidx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT);

eidx初始化为本节点中现有页面的总数。

1
2
3
if (!size) BUG();
if (align & (align-1))
BUG();

进行条件检查。

1
2
3
4
5
6
7
8
9
/*
* We try to allocate bootmem pages above 'goal'
* first, then we try to allocate lower pages.
*/
if (goal && (goal >= bdata->node_boot_start) && ((goal >> PAGE_SHIFT) < bdata->node_low_pfn)) {
preferred = goal - bdata->node_boot_start;
} else
preferred = 0;
preferred = ((preferred + align - 1) & ~(align - 1)) >> PAGE_SHIFT;

开始分配后首选页的计算分为两步:

  1. 如果goal为非 0 且有效,则给preferred赋初值,否则,其初值为 0。
  2. 根据参数align来对齐preferred的物理地址。
    1
    areasize = (size+PAGE_SIZE-1)/PAGE_SIZE;

获得所需页面的总数(向上取整)

1
incr = align >> PAGE_SHIFT ? : 1;

根据对齐的大小来选择增加值。除非大于 4KB(很少见),否则增加值为 1。

1
2
3
4
5
restart_scan:
for (i = preferred; i < eidx; i += incr) {
unsigned long j;
if (test_bit(i, bdata->node_bootmem_map))
continue;

这个循环用来从首选页面号开始,找到空闲的页面号。test_bit()宏用来测试给定的位,如果给定位为 1,则返回 1。

1
2
3
4
5
6
for (j = i + 1; j < i + areasize; ++j) {
if (j >= eidx)
goto fail_block;
if (test_bit (j, bdata->node_bootmem_map))
goto fail_block;
}

这个循环用来查看在首次满足内存需求以后,是否还有足够的空闲页面。如果没有空闲页,就跳到fail_block

1
2
start = i;
goto found;

如果一直到了这里,则说明从i开始找到了足够的页面,跳过fail_block并继续。

1
2
3
4
5
6
7
fail_block:;
}
if (preferred) {
preferred = 0;
goto restart_scan;
}
return NULL;

如果到了这里,从首选页面中没有找到满足需要的连续页面,就忽略preferred的值,并从 0 开始扫描。如果preferred为 1,但没有找到满足需要的足够页面,则返回NULL。

1
found:

已经找到足够的内存,继续处理请求。

1
2
if (start >= eidx)
BUG();

进行条件检查。

1
2
3
4
5
6
7
8
9
10
/*
* Is the next page of the previous allocation-end the start
* of this allocation's buffer? If yes then we can 'merge'
* the previous partial page with this allocation.
*/
if (align <= PAGE_SIZE && bdata->last_offset && bdata->last_pos+1 == start) {
offset = (bdata->last_offset+align-1) & ~(align-1);
if (offset > PAGE_SIZE)
BUG();
remaining_size = PAGE_SIZE-offset;

if语句检查下列条件:

  1. 所请求对齐的值小于页的大小(4KB)。
  2. 变量last_offset为非 0。如果为 0,则说明前一次分配达到了一个非常好的页面边界,没有内部碎片。
  3. 检查这次请求的内存是否与前一次请求的内存是相临的,如果是,则把两次分配合在一起进行。

如果以上 3 个条件都满足,则用前一次分配中最后一页剩余的空间初始化remaining_size。

1
2
3
4
5
if (size < remaining_size) {
areasize = 0;
// last_pos unchanged
bdata->last_offset = offset+size;
ret = phys_to_virt(bdata->last_pos*PAGE_SIZE + offset + bdata->node_boot_start);

如果请求内存的大小小于前一次分配中最后一页中的可用空间,则没必要分配任何新的页。变量last_offset增加到新的偏移量,而last_pos保持不变,因为没有增加新的页。把这次新分配的起始地址存放在变量ret中。宏phys_to_virt()返回给定物理地址的虚地址。

1
2
3
4
5
6
} else {
remaining_size = size - remaining_size;
areasize = (remaining_size+PAGE_SIZE-1)/PAGE_SIZE;
ret = phys_to_virt(bdata->last_pos*PAGE_SIZE + offset + bdata->node_boot_start);
bdata->last_pos = start+areasize-1;
bdata->last_offset = remaining_size;

所请求的大小大于剩余的大小。首先求出所需的页面数,然后更新变量last_poslast_offset。例如,在前一次分配中,如果分配了 9KB,则占用 3 个页面,内部碎片为 12KB-9KB=3KB。因此,page_offset为 1KB,且剩余大小为 3KB。如果新的请求为 1KB,则第 3 个页面本身就能满足要求,但是,如果请求的大小为 10KB,则需要新分配((10KB- 3KB) + PAGE_SIZE-1)/PAGE_SIZE,即 2 个页面,因此,page_offset为 3KB。

1
2
3
4
5
6
7
    }
bdata->last_offset &= ~PAGE_MASK;
} else {
bdata->last_pos = start + areasize - 1;
bdata->last_offset = size & ~PAGE_MASK;
ret = phys_to_virt(start * PAGE_SIZE + bdata->node_boot_start);
}

如果因为某些条件未满足而导致不能进行合并,则执行这段代码,我们刚刚把last_poslast_offset直接设置为新的值,而未考虑它们原先的值。last_pos的值还要加上所请求的页面数,而新page_offset值的计算就是屏蔽掉除了获得页偏移量位的所有位,即size &PAGE_MASKPAGE_MASK为 0x00000FFF,用PAGE_MASK的求反正好得到页的偏移量。

1
2
3
4
5
6
7
8
9
/*
* Reserve the area now:
*/

for (i = start; i < start+areasize; i++)
if (test_and_set_bit(i, bdata->node_bootmem_map))
BUG();
memset(ret, 0, size);
return ret;

现在,我们有了内存,就需要保留它。宏test_and_set_bit()用来测试并置位,如果某位原先的值为 0,则它返回 0;如果为 1,则返回 1。还有一个条件判断语句,进行条件判断(这种条件出现的可能性非常小,除非RAM坏)。然后,把这块内存初始化为 0,并返回给调用它的函数。

26

free_all_bootmem()函数

这个函数用来在引导时释放页面,并清除bootmem分配器。函数原型为:

1
2
void free_all_bootmem (void);
void free_all_bootmem_core(pg_data_t *pgdat);

同前面的函数调用形式类似,free_all_bootmem()为封装函数,实际调用free_all_bootmem_core()函数。下面,我们对free_all_bootmem_core()函数分片断来介绍。

1
2
3
4
5
6
7
8
struct page *page = pgdat->node_mem_map;
bootmem_data_t *bdata = pgdat->bdata;
unsigned long i, count, total = 0;
unsigned long idx;

if (!bdata->node_bootmem_map) BUG();
count = 0;
idx = bdata->node_low_pfn - (bdata->node_boot_start >> PAGE_SHIFT);

idx初始化为从内核映像结束处到内存顶点处的页面数。

1
2
3
4
5
6
7
8
for (i = 0; i < idx; i++, page++) {
if (!test_bit(i, bdata->node_bootmem_map)) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}
}

搜索bootmem位图,找到空闲页,并把mem_map中对应的项标记为空闲。set_page_count()函数把page结构的count域置 1,而__free_page()真正的释放页面,并修改伙伴(buddy)系统的位图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
total += count;

/*
* Now free the allocator bitmap itself, it's not
* needed anymore:
*/
page = virt_to_page(bdata->node_bootmem_map);
count = 0;
for (i = 0; i < ((bdata->node_low_pfn-(bdata->node_boot_start >> PAGE_SHIFT))/8 + PAGE_SIZE-1)/PAGE_SIZE; i++,page++) {
count++;
ClearPageReserved(page);
set_page_count(page, 1);
__free_page(page);
}

获得bootmem位图的地址,并释放它所在的页面。

1
2
3
total += count;
bdata->node_bootmem_map = NULL;
return total;

把该存储节点的bootmem_map域置为NULL,并返回空闲页面的总数。

页表的建立

前面已经建立了为内存页面管理所需的数据结构,现在是进一步完善页面映射机制,并且建立起内存页面映射管理机制的时候了,与此相关的主要函数有:

1
2
paging_init() 函数
pagetable_init() 函数

paging_init() 函数

这个函数仅被调用一次,即由setup_arch()调用以建立页表,对此函数的具体描述如下:

1
pagetable_init();

这个函数实际上才真正地建立页表,后面会给出详细描述。

1
__asm__( "movl %%ecx,%%cr3\n" ::"c"(__pa(swapper_pg_dir)));

因为pagetable_init()已经建立起页表,因此把swapper_pg_dir(页目录)的地址装入CR3寄存器。

1
2
3
4
5
6
7
8
9
#if CONFIG_X86_PAE
/*
* We will bail out later - printk doesnt work right now so
* the user would just see a hanging kernel.
*/
if (cpu_has_pae)
set_in_cr4(X86_CR4_PAE);
#endif
__flush_tlb_all();

上面这一句是个宏,它使得转换旁路缓冲区(TLB)无效。TLB总是要维持几个最新的虚地址到物理地址的转换。每当页目录改变时,TLB就需要被刷新。

1
2
3
#ifdef CONFIG_HIGHMEM
kmap_init();
#endif

如果使用了CONFIG_HIGHMEM选项,就要对大于 896MB的内存进行初始化。

1
2
3
4
{
unsigned long zones_size[MAX_NR_ZONES] = {0, 0, 0};
unsigned int max_dma, high, low;
max_dma = virt_to_phys((char *)MAX_DMA_ADDRESS) >> PAGE_SHIFT;

低于 16MB的内存只能用于DMA,因此,上面这条语句用于存放 16MB的页面。

1
2
3
4
5
6
7
8
9
10
11
12
low = max_low_pfn;
high = highend_pfn;

if (low < max_dma)
zones_size[ZONE_DMA] = low;
else {
zones_size[ZONE_DMA] = max_dma;
zones_size[ZONE_NORMAL] = low - max_dma;
#ifdef CONFIG_HIGHMEM
zones_size[ZONE_HIGHMEM] = high - low;
#endif
}

计算 3 个管理区的大小,并存放在zones_size数组中。3 个管理区如下所述。

  • ZONE_DMA:从 0~16MB 分配给这个区。
  • ZONE_NORMAL:从 16MB~896MB 分配给这个区。
  • ZONE_DMA:896MB以上分配给这个区。
1
2
3
4
free_area_init(zones_size);
}

return;

这个函数用来初始化内存管理区并创建内存映射表,详细介绍参见后面内容。

pagetable_init()函数

这个函数真正地在页目录swapper_pg_dir中建立页表,描述如下:

1
2
3
4
5
6
7
8
9
10
unsigned long vaddr, end;
pgd_t *pgd, *pgd_base;
int i, j, k;
pmd_t *pmd;
pte_t *pte, *pte_base;
/*
* This can be zero as well - no problem, in that case we exit
* the loops anyway due to the PTRS_PER_* conditions.
*/
end = (unsigned long)__va(max_low_pfn*PAGE_SIZE);

计算max_low_pfn的虚拟地址,并把它存放在end中。

1
pgd_base = swapper_pg_dir;

pgd_base(页目录基地址) 指向swapper_pg_dir

1
2
3
4
#if CONFIG_X86_PAE
for (i = 0; i < PTRS_PER_PGD; i++)
set_pgd(pgd_base + i, __pgd(1 + __pa(empty_zero_page)));
#endif

如果PAE被激活,PTRS_PER_PGD就为 4,且变量swapper_pg_dir用作页目录指针表,宏set_pgd()定义于include/asm-i386/pgtable-3level.h中。

1
2
i = __pgd_offset(PAGE_OFFSET);
pgd = pgd_base + i;

__pgd_offset()在给定地址的页目录中检索相应的下标。因此__pgd_offset(PAGE_OFFSET)返回 0x300(或十进制 768),即内核地址空间开始处的下标。因此,pgd现在指向页目录表的第 768 项。

1
2
3
4
for (; i < PTRS_PER_PGD; pgd++, i++) {
vaddr = i*PGDIR_SIZE;
if (end && (vaddr >= end))
break;

如果使用了CONFIG_X86_PAE选项,PTRS_PER_PGD就为 4,否则,一般情况下它都为1024,即页目录的项数。PGDIR_SIZE给出一个单独的页目录项所能映射的RAM总量,在两级页目录中它为 4MB,当使用CONFIG_X86_PAE选项时,它为 1GB。计算虚地址vaddr,并检查它是否到了虚拟空间的顶部。

1
2
3
4
5
6
#if CONFIG_X86_PAE
pmd = (pmd_t *) alloc_bootmem_low_pages(PAGE_SIZE);
set_pgd(pgd, __pgd(__pa(pmd) + 0x1));
#else
pmd = (pmd_t *)pgd;
#endif

如果使用了CONFIG_X86_PAE选项,则分配一页(4KB)的内存给bootmem分配器用,以保存中间页目录,并在总目录中设置它的地址。否则,没有中间页目录,就把中间页目录直接映射到总目录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (pmd != pmd_offset(pgd, 0))
BUG();
for (j = 0; j < PTRS_PER_PMD; pmd++, j++) {
vaddr = i*PGDIR_SIZE + j*PMD_SIZE;
if (end && (vaddr >= end))
break;
if (cpu_has_pse) {
unsigned long __pe;
set_in_cr4(X86_CR4_PSE);
boot_cpu_data.wp_works_ok = 1;
__pe = _KERNPG_TABLE + _PAGE_PSE + __pa(vaddr);
/* Make it "global" too if supported */
if (cpu_has_pge) {
set_in_cr4(X86_CR4_PGE);
__pe += _PAGE_GLOBAL;
}
set_pmd(pmd, __pmd(__pe));
continue;
}

现在,开始填充页目录(如果有PAE,就是填充中间页目录)。计算表项所映射的虚地址,如果没有激活PAE,PMD_SIZE大小就为 0,因此,vaddr = i * 4MB。例如,表项 0x300 所映射的虚地址为0x300 * 4MB = 3GB。接下来,我们检查PSE(Page Size Extension)是否可用,如果是,就要避免使用页表而直接使用 4MB 的页。宏CPU_has_pse()用来检查处理器是否具有扩展页,如果有,则宏set_in_cr4()就启用它。

从Pentium II处理器开始,就可以有附加属性PGE (Page Global Enable)。当一个页被标记为全局的,且设置了PGE,那么,在任务切换发生或 CR3 被装入时,就不能使该页所在的页表(或页目录项)无效。这将提高系统性能,也是让内核处于 3GB以上的原因之一。选择了所有属性后,设置中间页目录项。

1
2
pte_base = pte = (pte_t *)
alloc_bootmem_low_pages(PAGE_SIZE);

如果PSE不可用,就执行这一句,它为一个页表(4KB)分配空间。

1
2
3
4
for (k = 0; k < PTRS_PER_PTE; pte++, k++) {
vaddr = i*PGDIR_SIZE + j*PMD_SIZE + k*PAGE_SIZE;
if (end && (vaddr >= end))
break;

在一个页表中有 1024 个表项(如果启用PAE,就是 512 个),每个表项映射 4KB(1 页)。

1
2
    *pte = mk_pte_phys(__pa(vaddr), PAGE_KERNEL);
}

mk_pte_phys()创建一个页表项,这个页表项的物理地址为__pa(vaddr)。属性PAGE_KERNEL表示只有在内核态才能访问这一页表项。

1
2
3
4
5
        set_pmd(pmd, __pmd(_KERNPG_TABLE + __pa(pte_base)));
if (pte_base != pte_offset(pmd, 0))
BUG();
}
}

通过调用set_pmd()把该页表追加到中间页目录中。这个过程一直继续,直到把所有的物理内存都映射到从PAGE_OFFSET开始的虚拟地址空间。

1
2
3
4
5
6
/*
* Fixed mappings, only the page table structure has to be
* created - mappings will be set by set_fixmap():
*/
vaddr = __fix_to_virt(__end_of_fixed_addresses - 1) & PMD_MASK;
fixrange_init(vaddr, 0, pgd_base);

在内存的最高端(4GB~128MB),有些虚地址直接用在内核资源的某些部分中,这些地址的映射定义在/include/asm/fixmap.h中,枚举类型__end_of_fixed_addresses用作索引,宏__fix_to_virt()返回给定索引的虚地址。函数fixrange_init()为这些虚地址创建合适的页表项。注意,这里仅仅创建了页表项,而没有进行映射。这些地址的映射是由set_fixmap()函数完成的。

1
2
3
4
5
6
7
8
9
10
11
#if CONFIG_HIGHMEM
/*
* Permanent kmaps:
*/
vaddr = PKMAP_BASE;
fixrange_init(vaddr, vaddr + PAGE_SIZE*LAST_PKMAP, pgd_base);
pgd = swapper_pg_dir + __pgd_offset(vaddr);
pmd = pmd_offset(pgd, vaddr);
pte = pte_offset(pmd, vaddr);
pkmap_page_table = pte;
#endif

如果使用了CONFIG_HIGHMEM选项,我们就可以访问 896MB以上的物理内存,这些内存的地址被暂时映射到为此目的而保留的虚地址上。PKMAP_BASE的值为 0xFE000000(即4064MB),LAST_PKMAP的值为 1024。因此,从 4064MB 开始,由fixrange_init()在页表中创建的表项能覆盖 4MB 的空间。接下来,把覆盖 4MB 内存的页表项赋给pkmap_page_table

1
2
3
4
5
6
7
8
9
10
#if CONFIG_X86_PAE
/*
* Add low memory identity-mappings - SMP needs it when
* starting up on an AP from real-mode. In the non-PAE
* case we already have these mappings through head.S.
* All user-space mappings are explicitly cleared after
* SMP startup.
*/
pgd_base[0] = pgd_base[USER_PTRS_PER_PGD];
#endif

内存管理区

前面已经提到,物理内存被划分为 3 个区来管理,它们是ZONE_DMAZONE_NORMALZONE_HIGHMEM。每个区都用struct zone_struct结构来表示,定义于include/linux/mmzone.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
typedef struct zone_struct {
/*
* Commonly accessed fields:
*/
spinlock_t lock;
unsigned long free_pages;
unsigned long pages_min, pages_low, pages_high;
int need_balance;

/*
* free areas of different sizes
*/
free_area_t free_area[MAX_ORDER];

/*
* Discontig memory support fields.
*/
struct pglist_data *zone_pgdat;
struct page *zone_mem_map;
unsigned long zone_start_paddr;
unsigned long zone_start_mapnr;

/*
* rarely used fields:
*/
char *name;
unsigned long size;
} zone_t;

#define ZONE_DMA 0

#define ZONE_NORMAL 1

#define ZONE_HIGHMEM 2

#define MAX_NR_ZONES 3

struct zone_struct结构中每个域的描述如下。

  • lock:用来保证对该结构中其他域的串行访问。
  • free_pages:在这个区中现有空闲页的个数。
  • pages_min、pages_lowpages_high是对这个区最少、次少及最多页面个数的描述。
  • need_balance:与kswapd合在一起使用。
  • free_area:在伙伴分配系统中的位图数组和页面链表。
  • zone_pgdat:本管理区所在的存储节点。
  • zone_mem_map:该管理区的内存映射表。
  • zone_start_paddr:该管理区的起始物理地址。
  • zone_start_mapnr:在mem_map中的索引(或下标)。
  • name:该管理区的名字。
  • size:该管理区物理内存总的大小。

其中,free_area_t定义为:

1
2
3
4
5
#difine MAX_ORDER 10
type struct free_area_struct {
struct list_head free_list
unsigned int *map
} free_area_t

因此,zone_struct结构中的free_area[MAX_ORDER]是一组“空闲区间”链表。为什么要定义一组而不是一个空闲队列呢?这是因为常常需要成块地在物理空间分配连续的多个页面,所以要按块的大小分别加以管理。因此,在管理区数据结构中既要有一个队列来保持一些离散(连续长度为 1)的物理页面,还要有一个队列来保持一些连续长度为 2 的页面块以及连续长度为 4、8、16、……、直至 2 MAX_ORDER(即 4M 字节)的队列。

如前所述,内存中每个物理页面都有一个struct page结构,位于include/linux/mm.h,该结构包含了对物理页面进行管理的所有信息,下面给出具体描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct page {
struct list_head list;
struct address_space *mapping;
unsigned long index;
struct page *next_hash;
atomic_t count;
unsigned long flags;
struct list_head lru;
wait_queue_head_t wait;
struct page **pprev_hash;
struct buffer_head * buffers;
void *virtual;
struct zone_struct *zone;
} mem_map_t;

对每个域的描述如下。

  • list:指向链表中的下一页。
  • mapping:用来指定我们正在映射的索引节点(inode)。
  • index:在映射表中的偏移。
  • next_hash:指向页高速缓存哈希表中下一个共享的页。
  • count:引用这个页的个数。
  • flags:页面各种不同的属性。
  • lru:用在active_list中。
  • wait:等待这一页的页队列。
  • pprev_hash:与next_hash相对应。
  • buffers:把缓冲区映射到一个磁盘块。
  • zone:页所在的内存管理区。

与内存管理区相关的 3 个主要函数为:

  • free_area_init()函数;
  • build_zonelists()函数;
  • mem_init()函数。

free_area_init() 函数

这个函数用来初始化内存管理区并创建内存映射表,定义于mm/page_alloc.c中。函数原型为:

1
2
3
4
5
6
7
void free_area_init(unsigned long *zones_size);
void free_area_init_core(int nid, pg_data_t *pgdat,
struct page **gmap,
unsigned long *zones_size,
unsigned long zone_start_paddr,
unsigned long *zholes_size,
struct page *lmem_map);

free_area_init()为封装函数,而free_area_init_core()为真正实现的函数,对该函数详细描述如下:

1
2
3
4
5
6
7
struct page *p;
unsigned long i, j;
unsigned long map_size;
unsigned long totalpages, offset, realtotalpages;
const unsigned long zone_required_alignment = 1UL << (MAX_ORDER-1);
if (zone_start_paddr & ~PAGE_MASK)
BUG();

检查该管理区的起始地址是否是一个页的边界。

1
2
3
4
5
totalpages = 0;
for (i = 0; i < MAX_NR_ZONES; i++) {
unsigned long size = zones_size[i];
totalpages += size;
}

计算本存储节点中页面的个数。

1
2
3
4
5
realtotalpages = totalpages;
if (zholes_size)
for (i = 0; i < MAX_NR_ZONES; i++)
realtotalpages -= zholes_size[i];
printk("On node %d totalpages: %lu\n", nid, realtotalpages);

打印除空洞以外的实际页面数。

1
2
INIT_LIST_HEAD(&active_list);
INIT_LIST_HEAD(&inactive_list);

初始化循环链表。

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* Some architectures (with lots of mem and discontinous memory
* maps) have to search for a good mem_map area:
* For discontigmem, the conceptual mem map array starts from
* PAGE_OFFSET, we need to align the actual array onto a mem map
* boundary, so that MAP_NR works.
*/
map_size = (totalpages + 1)*sizeof(struct page);
if (lmem_map == (struct page *)0) {
lmem_map = (struct page *)
alloc_bootmem_node(pgdat, map_size);
lmem_map = (struct page *)(PAGE_OFFSET + MAP_ALIGN((unsigned long)lmem_map - PAGE_OFFSET));
}

给局部内存(即本节点中的内存)映射分配空间,并在sizeof(mem_map_t)边界上对齐它。

1
2
3
4
5
*gmap = pgdat->node_mem_map = lmem_map;
pgdat->node_size = totalpages;
pgdat->node_start_paddr = zone_start_paddr;
pgdat->node_start_mapnr = (lmem_map - mem_map);
pgdat->nr_zones = 0;

初始化本节点中的域。

1
2
3
4
5
6
7
8
9
10
11
/*
* Initially all pages are reserved - free ones are freed
* up by free_all_bootmem() once the early boot process is
* done.
*/
for (p = lmem_map; p < lmem_map + totalpages; p++) {
set_page_count(p, 0);
SetPageReserved(p);
init_waitqueue_head(&p->wait);
memlist_init(&p->list);
}

仔细检查所有的页,并进行如下操作。

  1. 把页的使用计数(count域)置为 0。
  2. 把页标记为保留。
  3. 初始化该页的等待队列。
  4. 初始化链表指针。
1
offset = lmem_map - mem_map;

变量mem_map是类型为struct pages的全局稀疏矩阵。mem_map下标的起始值取决于第一个节点的第一个管理区。如果第一个管理区的起始地址为 0,则下标就从 0 开始,并且与物理页面号相对应,也就是说,页面号就是mem_map的下标。每一个管理区都有自己的映射表,存放在zone_mem_map中,每个管理区又被映射到它所在的节点node_mem_map中,而每个节点又被映射到管理全局内存的mem_map中。

在上面的这行代码中,offset表示该节点放的内存映射表在全局mem_map中的入口点(下标)。在这里,offset为 0,因为在 i386 上,只有一个节点。

1
for (j = 0; j < MAX_NR_ZONES; j++) {

这个循环对zone的域进行初始化。

1
2
3
4
zone_t *zone = pgdat->node_zones + j;
unsigned long mask;
unsigned long size, realsize;
realsize = size = zones_size[j];

管理区的实际数据是存放在节点中的,因此,让指针指向正确的管理区,并获得该管理区的大小。

1
2
3
if (zholes_size)
realsize -= zholes_size[j];
printk("zone(%lu): %lu pages.\n", j, size);

计算各个区的实际大小,并进行打印。例如,在具有 256MB 的内存上,上面的输出为:

1
2
3
zone(0): 4096 pages.
zone(1): 61440 pages.
zone(2): 0 pages.

这里,管理区 2 为 0,因为只有 256MB 的RAM。

1
2
3
4
5
6
zone->size = size;
zone->name = zone_names[j];
zone->lock = SPIN_LOCK_UNLOCKED;
zone->zone_pgdat = pgdat;
zone->free_pages = 0;
zone->need_balance = 0;

初始化管理区中的各个域。

1
2
if (!size)
continue;

如果一个管理区的大小为 0,就没必要进一步的初始化。

1
2
3
4
5
6
pgdat->nr_zones = j+1;
mask = (realsize / zone_balance_ratio[j]);
if (mask < zone_balance_min[j])
mask = zone_balance_min[j];
else if (mask > zone_balance_max[j])
mask = zone_balance_max[j];

计算合适的平衡比率。

1
2
3
4
5
6
zone->pages_min = mask;
zone->pages_low = mask*2;
zone->pages_high = mask*3;
zone->zone_mem_map = mem_map + offset;
zone->zone_start_mapnr = offset;
zone->zone_start_paddr = zone_start_paddr;

设置该管理区中页面数量的几个界限,并把在全局变量mem_map中的入口点作为zone_mem_map的初值。用全局变量mem_map的下标初始化变量zone_start_mapnr

1
2
3
4
5
6
7
8
9
10
if ((zone_start_paddr >> PAGE_SHIFT) & (zone_required_alignment-1))
printk("BUG: wrong zone alignment, it will crash\n");

for (i = 0; i < size; i++) {
struct page *page = mem_map + offset + i;
page->zone = zone;
if (j != ZONE_HIGHMEM)
page->virtual = __va(zone_start_paddr);
zone_start_paddr += PAGE_SIZE;
}

对该管理区中的每一页进行处理。首先,把struct page结构中的zone域初始化为指向该管理区(zone),如果这个管理区不是ZONE_HIGHMEM,则设置这一页的虚地址(即物理地址 + PAGE_OFFSET)。也就是说,建立起每一页物理地址到虚地址的映射。

1
offset += size;

offset增加size,使它指向mem_map中下一个管理区的起始位置。

1
2
3
4
5
6
7
for (i = 0; ; i++) {
unsigned long bitmap_size;
memlist_init(&zone->free_area[i].free_list);
if (i == MAX_ORDER-1) {
zone->free_area[i].map = NULL;
break;
}

初始化free_area[]链表,把free_area[]中最后一个序号的位图置为NULL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/*
* Page buddy system uses "index >> (i+1)",
* where "index" is at most "size-1".
*
* The extra "+3" is to round down to byte
* size (8 bits per byte assumption). Thus
* we get "(size-1) >> (i+4)" as the last byte
* we can access.
*
* The "+1" is because we want to round the
* byte allocation up rather than down. So
* we should have had a "+7" before we shifted
* down by three. Also, we have to add one as
* we actually _use_ the last bit (it's [0,n]
* inclusive, not [0,n[).
*
* So we actually had +7+1 before we shift
* down by 3. But (n+8) >> 3 == (n >> 3) + 1
* (modulo overflows, which we do not have).
*
* Finally, we LONG_ALIGN because all bitmap
* operations are on longs.
*/
bitmap_size = (size-1) >> (i+4);
bitmap_size = LONG_ALIGN(bitmap_size+1);
zone->free_area[i].map = (unsigned long *)
alloc_bootmem_node(pgdat, bitmap_size);
}

计算位图的大小,然后调用alloc_bootmem_node给位图分配空间。

1
2
}
build_zonelists(pgdat);

在节点中为不同的管理区创建链表。

build_zonelists()函数

函数原型:

1
static inline void build_zonelists(pg_data_t *pgdat)

代码如下:

1
2
3
4
5
6
int i, j, k;
for (i = 0; i <= GFP_ZONEMASK; i++) {
zonelist_t *zonelist;
zone_t *zone;
zonelist = pgdat->node_zonelists + i;
memset(zonelist, 0, sizeof(*zonelist));

获得节点中指向管理区链表的域,并把它初始化为空。

1
2
3
4
5
6
j = 0;
k = ZONE_NORMAL;
if (i & __GFP_HIGHMEM)
k = ZONE_HIGHMEM;
if (i & __GFP_DMA)
k = ZONE_DMA;

把当前管理区掩码与 3 个可用管理区掩码相“与”,获得一个管理区标识,把它用在下面的switch语句中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
switch (k) {
default:
BUG();
/*
* fallthrough:
*/
case ZONE_HIGHMEM:
zone = pgdat->node_zones + ZONE_HIGHMEM;
if (zone->size) {
#ifndef CONFIG_HIGHMEM
BUG();
#endif
zonelist->zones[j++] = zone;
}
case ZONE_NORMAL:
zone = pgdat->node_zones + ZONE_NORMAL;
if (zone->size)
zonelist->zones[j++] = zone;
case ZONE_DMA:
zone = pgdat->node_zones + ZONE_DMA;
if (zone->size)
zonelist->zones[j++] = zone;
}

给定的管理区掩码指定了优先顺序,我们可以用它找到在switch语句中的入口点。如果掩码为__GFP_DMA,管理区链表zonelist将仅仅包含DMA管理区,如果为__GFP_HIGHMEM,则管理区链表中就会依次有ZONE_HIGHMEMZONE_NORMALZONE_DMA

1
2
    zonelist->zones[j++] = NULL;
}

NULL结束链表。

mem_init() 函数

这个函数由start_kernel()调用,以对管理区的分配算法进行进一步的初始化,定义于arch/i386/mm/init.c中,具体解释如下:

1
2
3
4
5
6
7
8
9
int codesize, reservedpages, datasize, initsize;
int tmp;
int bad_ppro;
if (!mem_map)
BUG();

#ifdef CONFIG_HIGHMEM
highmem_start_page = mem_map + highstart_pfn;
max_mapnr = num_physpages = highend_pfn;

如果HIGHMEM被激活,就要获得HIGHMEM的起始地址和总的页面数。

1
2
3
#else
max_mapnr = num_physpages = max_low_pfn;
#endif

否则,页面数就是常规内存的页面数。

1
high_memory = (void *) __va(max_low_pfn * PAGE_SIZE);

获得低区内存中最后一个页面的虚地址。

1
2
3
4
5
/* clear the zero-page */
memset(empty_zero_page, 0, PAGE_SIZE);
/* this will put all low memory onto the freelists */
totalram_pages += free_all_bootmem();
reservedpages = 0;

free_all_bootmem()函数本质上释放所有的低区内存,从此以后,bootmem不再使用。

1
2
3
4
5
6
/*
* Only count reserved RAM pages
*/
for (tmp = 0; tmp < max_low_pfn; tmp++)
if (page_is_ram(tmp) && PageReserved(mem_map+tmp))
reservedpages++;

mem_map查找一遍,并统计所保留的页面数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifdef CONFIG_HIGHMEM
for (tmp = highstart_pfn; tmp < highend_pfn; tmp++) {
struct page *page = mem_map + tmp;
if (!page_is_ram(tmp)) {
SetPageReserved(page);
continue;
}
if (bad_ppro && page_kills_ppro(tmp)) {
SetPageReserved(page);
continue;
}
ClearPageReserved(page);
set_bit(PG_highmem, &page->flags);
atomic_set(&page->count, 1);
__free_page(page);
totalhigh_pages++;
}
totalram_pages += totalhigh_pages;
#endif

把高区内存查找一遍,并把保留但不能使用的页面标记为PG_highmem,并调用__free_page()释放它,还要修改伙伴系统的位图。

1
2
3
4
5
6
7
codesize = (unsigned long) &_etext - (unsigned long) &_text;
datasize = (unsigned long) &_edata - (unsigned long) &_etext;
initsize = (unsigned long) &__init_end - (unsigned long) &__init_begin;
printk("Memory: %luk/%luk available (%dk kernel code, %dk reserved, %dk data, %dk init, %ldk highmem)\n",
(unsigned long) nr_free_pages() << (PAGE_SHIFT-10), max_mapnr << (PAGE_SHIFT-10), codesize >> 10,
reservedpages << (PAGE_SHIFT-10), datasize >> 10, initsize >> 10,
(unsigned long) (totalhigh_pages << (PAGE_SHIFT-10)));

计算内核各个部分的大小,并打印统计信息。

从以上的介绍可以看出,在初始化阶段,对内存的初始化要做许多工作。但这里要说明的是,尽管在这个阶段建立起了初步的虚拟内存管理机制,但仅仅考虑了内核虚拟空间(3GB以上),还根本没有涉及用户空间的管理。因此,在这个阶段,虚拟存储空间到物理存储空间的映射非常简单,仅仅通过一种简单的线性关系就可以达到虚地址到物理地址之间的相互转换。但是,了解这个初始化阶段又非常重要,它是后面进一步进行内存管理分析的基础。

内存的分配和回收

Linux采用著名的伙伴(Buddy)系统算法来解决外碎片问题。对于内存页面的管理,通常是先在虚存空间中分配一个虚存区间,然后才根据需要为此区间分配相应的物理页面并建立起映射,也就是说,虚存区间的分配在前,而物理页面的分配在后。

伙伴算法

原理

Linux的伙伴算法把所有的空闲页面分为 10 个块组,每组中块的大小是 2 的幂次方个页面,例如,第 0 组中块的大小都为 20 (1 个页面),第 1 组中块的大小都为 21(2 个页面),第 9 组中块的大小都为 29(512 个页面)。也就是说,每一组中块的大小是相同的,且这同样大小的块形成一个链表。

假设要求分配的块的大小为 128 个页面(由多个页面组成的块我们就叫做页面块)。该算法先在块大小为 128 个页面的链表中查找,看是否有这样一个空闲块。如果有,就直接分配;如果没有,该算法会查找下一个更大的块,具体地说,就是在块大小 256 个页面的链表中查找一个空闲块。如果存在这样的空闲块,内核就把这 256 个页面分为两等份,一份分配出去,另一份插入到块大小为 128 个页面的链表中。如果在块大小为 256 个页面的链表中也没有找到空闲页块,就继续找更大的块,即 512 个页面的块。如果存在这样的块,内核就从512 个页面的块中分出 128 个页面满足请求,然后从 384 个页面中取出 256 个页面插入到块大小为 256 个页面的链表中。然后把剩余的 128 个页面插入到块大小为 128 个页面的链表中。如果 512 个页面的链表中还没有空闲块,该算法就放弃分配,并发出出错信号。

以上过程的逆过程就是块的释放过程,这也是该算法名字的来由。满足以下条件的两个块称为伙伴:

  1. 两个块的大小相同;
  2. 两个块的物理地址连续。

伙伴算法把满足以上条件的两个块合并为一个块,该算法是迭代算法,如果合并后的块还可以跟相邻的块进行合并,那么该算法就继续合并。

数据结构

在 6.2.6 节中所介绍的管理区数据结构struct zone_struct中,涉及到空闲区数据结构:

1
free_area_t free_area[MAX_ORDER];

我们再次对free_area_t给予较详细的描述。

1
2
3
4
5
#define MAX_ORDER 10
type struct free_area_struct {
struct list_head free_list
unsigned int *map
} free_area_t

其中list_head域是一个通用的双向链表结构,链表中元素的类型将为mem_map_t(即struct page结构)。Map域指向一个位图,其大小取决于现有的页面数。free_areak项位图的每一位,描述的就是大小为 2k 个页面的两个伙伴块的状态。如果位图的某位为 0,表示一对兄弟块中或者两个都空闲,或者两个都被分配,如果为 1,肯定有一块已被分配。当兄弟块都空闲时,内核把它们当作一个大小为 2k+1的单独块来处理。如图 6.9 给出该数据结构的示意图。

图 6.9 中,free_aea数组的元素 0 包含了一个空闲页(页面编号为 0);而元素 2 则包含了两个以 4 个页面为大小的空闲页面块,第一个页面块的起始编号为 4,而第二个页面块的起始编号为 56。

我们曾提到,当需要分配若干个内存页面时,用于DMA的内存页面必须是连续的。其实为了便于管理,从伙伴算法可以看出,只要请求分配的块大小不超过 512 个页面(2KB),内核就尽量分配连续的页面。

物理页面的分配和释放

当一个进程请求分配连续的物理页面时,可以通过调用alloc_pages()来完成。Linux 2.4版本中有两个alloc_pages(),一个在mm/numa.c中,另一个在mm/page_alloc,c中,编译时根据所定义的条件选项CONFIG_DISCONTIGMEM来进行取舍。

非一致存储结构(NUMA)中页面的分配

CONFIG_DISCONTIGMEM条件编译的含义是“不连续的存储空间”,Linux把不连续的存储空间也归类为非一致存储结构(NUMA)。这是因为,不连续的存储空间本质上是一种广义的NUMA,因为那说明在最低物理地址和最高物理地址之间存在着空洞,而有空洞的空间当然是“不一致”的。所以,在地址不连续的物理空间也要像结构不一样的物理空间那样划分出若干连续且均匀的“节点”。因此,在存储结构不连续的系统中,每个模块都有若干个节点,因而都有个pg_data_t数据结构队列。我们先来看mm/numa.c中的alloc_page()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* This can be refined. Currently, tries to do round robin, instead
* should do concentratic circle search, starting from current node.
*/
struct page * _alloc_pages(unsigned int gfp_mask, unsigned int order)
{
struct page *ret = 0;
pg_data_t *start, *temp;
#ifndef CONFIG_NUMA
unsigned long flags;
static pg_data_t *next = 0;
#endif
if (order >= MAX_ORDER)
return NULL;
#ifdef CONFIG_NUMA
temp = NODE_DATA(numa_node_id());
#else
spin_lock_irqsave(&node_lock, flags);
if (!next) next = pgdat_list;
temp = next;
next = next->node_next;
spin_unlock_irqrestore(&node_lock, flags);
#endif
start = temp;
while (temp) {
if ((ret = alloc_pages_pgdat(temp, gfp_mask, order)))
return(ret);
temp = temp->node_next;
}
temp = pgdat_list;
while (temp != start) {
if ((ret = alloc_pages_pgdat(temp, gfp_mask, order)))
return(ret);
temp = temp->node_next;
}
return(0);
}

对该函数的说明如下。

该函数有两个参数。gfp_mask表示采用哪种分配策略。参数order表示所需物理块的大小,可以是 1、2、3 直到2MAX_ORDER-1。如果定义了CONFIG_NUMA,也就是在NUMA结构的系统中,可以通过NUMA_DATA()宏找到CPU所在节点的pg_data_t数据结构队列,并存放在临时变量temp中。

如果在不连续的UMA结构中,则有个pg_data_t数据结构的队列pgdat_listpgdat_list就是该队列的首部。因为队列一般都是临界资源,因此,在对该队列进行两个以上的操作时要加锁。

分配时轮流从各个节点开始,以求各节点负荷的平衡。函数中有两个循环,其形式基本相同,也就是,对节点队列基本进行两遍扫描,直至在某个节点内分配成功,则跳出循环,否则,则彻底失败,从而返回 0。对于每个节点,调用alloc_pages_pgdat()函数试图分配所需的页面。

一致存储结构(UMA)中页面的分配

连续空间UMA结构的alloc_page()是在include/linux/mm.h中定义的:

1
2
3
4
5
6
7
8
9
10
11
12
#ifndef CONFIG_DISCONTIGMEM
static inline struct page * alloc_pages(unsigned int gfp_mask, unsigned int order)
{
/*
* Gets optimized away by the compiler.
*/
if (order >= MAX_ORDER)
return NULL;
return __alloc_pages(gfp_mask, order,
contig_page_data.node_zonelists+(gfp_mask & GFP_ZONEMASK));
}
#endif

从这个函数的定义可以看出,alloc_page()_alloc_pages()的封装函数,而_alloc_pages()才是伙伴算法的核心。这个函数定义于mm/page_alloc.c中,我们先对此函数给予概要描述。

_alloc_pages()在管理区链表zonelist中依次查找每个区,从中找到满足要求的区,然后用伙伴算法从这个区中分配给定大小(2^order个)的页面块。如果所有的区都没有足够的空闲页面,则调用swapperbdflush内核线程,把脏页写到磁盘以释放一些页面。在__alloc_pages()和虚拟内存(简称VM)的代码之间有一些复杂的接口。每个区都要对刚刚被映射到某个进程VM的页面进行跟踪,被映射的页面也许仅仅做了标记,而并没有真正地分配出去。因为根据虚拟存储的分配原理,对物理页面的分配要尽量推迟到不能再推迟为止,也就是说,当进程的代码或数据必须装入到内存时,才给它真正分配物理页面。

搞清楚页面分配的基本原则后,我们对其代码具体分析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* This is the 'heart' of the zoned buddy allocator:
*/
struct page * __alloc_pages(unsigned int gfp_mask, unsigned int order, zonelist_t *zonelist)
{
unsigned long min;
zone_t **zone, * classzone;
struct page * page;
int freed;
zone = zonelist->zones;
classzone = *zone;
min = 1UL << order;
for (;;) {
zone_t *z = *(zone++);
if (!z)
break;
min += z->pages_low;
if (z->free_pages > min) {
page = rmqueue(z, order);
if (page)
return page;
}
}

这是对一个分配策略中所规定的所有页面管理区的循环。循环中依次考察各个区中空闲页面的总量,如果总量尚大于“最低水位线”与所请求页面数之和,就调用rmqueue()试图从该区中进行分配。如果分配成功,则返回一个page结构指针,指向页面块中第一个页面的起始地址。
1
2
3
4
classzone->need_balance = 1;
mb();
if (waitqueue_active(&kswapd_wait))
wake_up_interruptible(&kswapd_wait);

如果发现管理区中的空闲页面总量已经降到最低点,则把zone_t结构中需要重新平衡的标志(need_balance)置 1,而且如果内核线程kswapd在一个等待队列中睡眠,就唤醒它,让它收回一些页面以备使用(可以看出,need_balance是和kswapd配合使用的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
zone = zonelist->zones;
min = 1UL << order;
for (;;) {
unsigned long local_min;
zone_t *z = *(zone++);
if (!z)
break;
local_min = z->pages_min;
if (!(gfp_mask & __GFP_WAIT))
local_min >>= 2;
min += local_min;
if (z->free_pages > min) {
page = rmqueue(z, order);
if (page)
return page;
}
}

如果给定分配策略中所有的页面管理区都分配失败,那只好把原来的“最低水位”再向下调(除以 4),然后看是否满足要求(z->free_pages > min),如果能满足要求,则调用rmqueue()进行分配。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    /* here we're in the low on memory slow path */
rebalance:
if (current->flags & (PF_MEMALLOC | PF_MEMDIE)) {
zone = zonelist->zones;
for (;;) {
zone_t *z = *(zone++);
if (!z)
break;
page = rmqueue(z, order);
if (page)
return page;
}
return NULL;
}

如果分配还不成功,这时候就要看是哪类进程在请求分配内存页面。其中PF_MEMALLOCPF_MEMDIE是进程的task_struct结构中flags域的值,对于正在分配页面的进程(如kswapd内核线程),则其PF_MEMALLOC的值为 1(一般进程的这个标志为 0),而对于使内存溢出而被杀死的进程,则其PF_MEMDIE为 1。不管哪种情况,都说明必须给该进程分配页面。因此,继续进行分配。

1
2
3
/* Atomic allocations - we can't balance anything */
if (!(gfp_mask & __GFP_WAIT))
return NULL;

如果请求分配页面的进程不能等待,也不能被重新调度,只好在没有分配到页面的情况下“空手”返回。

1
2
3
page = balance_classzone(classzone, gfp_mask, order, &freed);
if (page)
return page;

如果经过几番努力,必须得到页面的进程(如kswapd)还没有分配到页面,就要调用balance_classzone()函数把当前进程所占有的局部页面释放出来。如果释放成功,则返回一个page结构指针,指向页面块中第一个页面的起始地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
zone = zonelist->zones;
min = 1UL << order;
for (;;) {
zone_t *z = *(zone++);
if (!z)
break;
min += z->pages_min;
if (z->free_pages > min) {
page = rmqueue(z, order);
if (page)
return page;
}
}

继续进行分配。

1
2
3
4
5
6
7
8
9
    /* Don't let big-order allocations loop */
if (order > 3)
return NULL;
/* Yield for kswapd, and try again */
current->policy |= SCHED_YIELD;
__set_current_state(TASK_RUNNING);
schedule();
goto rebalance;
}

在这个函数中,频繁调用了rmqueue()函数,下面我们具体来看一下这个函数内容。

rmqueue()函数试图从一个页面管理区分配若干连续的内存页面。这是最基本的分配操作,其具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static struct page * rmqueue(zone_t *zone, unsigned int order)
{
free_area_t * area = zone->free_area + order;
unsigned int curr_order = order;
struct list_head *head, *curr;
unsigned long flags;
struct page *page;
spin_lock_irqsave(&zone->lock, flags);
do {
head = &area->free_list;
curr = memlist_next(head);
if (curr != head) {
unsigned int index;
page = memlist_entry(curr, struct page, list);
if (BAD_RANGE(zone,page))
BUG();
memlist_del(curr);
index = page - zone->zone_mem_map;
if (curr_order != MAX_ORDER-1)
MARK_USED(index, curr_order, area);
zone->free_pages -= 1UL << order;
page = expand(zone, page, index, order, curr_order, area);
spin_unlock_irqrestore(&zone->lock, flags);
set_page_count(page, 1);
if (BAD_RANGE(zone,page))
BUG();
if (PageLRU(page))
BUG();
if (PageActive(page))
BUG();
return page;
}
curr_order++;
area++;
} while (curr_order < MAX_ORDER);
spin_unlock_irqrestore(&zone->lock, flags);
return NULL;
}

对该函数的解释如下。
参数zone指向要分配页面的管理区,order表示要求分配的页面数为2^order

do循环从free_area数组的第order个元素开始,扫描每个元素中由page结构组成的双向循环空闲队列。如果找到合适的页块,就把它从队列中删除,删除的过程是不允许其他进程、其他处理器来打扰的。所以要用spin_lock_irqsave()将这个循环加上锁。

首先在恰好满足大小要求的队列里进行分配。其中memlist_entry(curr, struct page, list)获得空闲块的第 1 个页面的地址,如果这个地址是个无效的地址,就陷入BUG()。如果有效,memlist_del(curr)从队列中摘除分配出去的页面块。如果某个页面块被分配出去,就要在frea_area的位图中进行标记,这是通过调用MARK_USED()宏来完成的。

如果分配出去后还有剩余块,就通过expand()获得所分配的页块,而把剩余块链入适当的空闲队列中。

如果当前空闲队列没有空闲块,就从更大的空闲块队列中找。

expand()函数源代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static inline struct page * expand (zone_t *zone, struct page *page,
unsigned long index, int low, int high, free_area_t * area)
{
unsigned long size = 1 << high;
while (high > low) {
if (BAD_RANGE(zone,page))
BUG();
area--;
high--;
size >>= 1;
memlist_add_head(&(page)->list, &(area)->free_list);
MARK_USED(index, high, area);
index += size;
page += size;
}
if (BAD_RANGE(zone,page))
BUG();
return page;
}

参数zone指向已分配页块所在的管理区;page指向已分配的页块;index是已分配的页面在mem_map中的下标;low表示所需页面块大小为2^low,而high表示从空闲队列中实际进行分配的页面块大小为2^highareafree_area_struct结构,指向实际要分配的页块。

通过上面介绍可以知道,返回给请求者的块大小为2^low个页面,并把剩余的页面放入合适的空闲队列,且对伙伴系统的位图进行相应的修改。例如,假定我们需要一个 2 页面的块,但是,我们不得不从order为 3(8 个页面)的空闲队列中进行分配,又假定我们碰巧选择物理页面 800 作为该页面块的底部。在我们这个例子中,这几个参数值为:

1
2
3
4
5
page == mem_map+800
index == 800
low == 1
high == 3
area == zone->free_area+high ( 也就是`frea_area`数组中下标为 3 的元素)

首先把size初始化为分配块的页面数(例如,size = 1<<3 == 8) `while`循环进行循环查找。每次循环都把`size`减半。如果我们从空闲队列中分配的一个块与所要求的大小匹配,那么`low = high`,就彻底从循环中跳出,返回所分配的页块。如果分配到的物理块所在的空闲块大于所需块的大小(即`2^high > 2^low),那就将该空闲块分为两半(即area—;high—; size >>= 1),然后调用memlist_add_head()把刚分配出去的页面块又加入到低一档(物理块减半)的空闲队列中,准备从剩下的一半空闲块中重新进行分配,并调用MARK_USED()`设置位图。

在上面的例子中,第 1 次循环,我们从页面 800 开始,把页面大小为 4的块其首地址插入到frea_area[2]中的空闲队列;因为low<high,又开始第 2 次循环,这次从页面 804 开始,把页面大小为 2 的块插入到frea_area[1]中的空闲队列,此时,page=806,high=low=1,退出循环,我们给调用者返回从 806 页面开始的一个 2 页面块。从这个例子可以看出,这是一种巧妙的分配算法。

释放页面

从上面的介绍可以看出,页面块的分配必然导致内存的碎片化,而页面块的释放则可以将页面块重新组合成大的页面块。页面的释放函数为__free_pages(page struct *page, unsigned long order),该函数从给定的页面开始,释放的页面块大小为2^order。原函数为:

1
2
3
4
5
void __free_pages(page struct *page, unsigned long order)
{
if (!PageReserved(page) && put_page_testzero(page))
__free_pages_ok(page, order);
}

其中比较巧妙的部分就是调用put_page_testzero()宏,该函数把页面的引用计数减 1,如果减 1 后引用计数为 0,则该函数返回 1。因此,如果调用者不是该页面的最后一个用户,那么,这个页面实际上就不会被释放。另外要说明的是不可释放保留页PageReserved,这是通过PageReserved()宏进行检查的。

如果调用者是该页面的最后一个用户,则__free_pages()再调用__free_pages_ok()__free_pages_ok()才是对页面块进行释放的实际函数,该函数把释放的页面块链入空闲链表,并对伙伴系统的位图进行管理,必要时合并伙伴块。这实际上是expand()函数的反操作。

Slab分配机制

采用伙伴算法分配内存时,每次至少分配一个页面。但当请求分配的内存大小为几十个字节或几百个字节时应该如何处理?如何在一个页面中分配小的内存区,小内存区的分配所产生的内碎片又如何解决?

Linux 2.0 采用的解决办法是建立了 13 个空闲区链表,它们的大小从 32 字节到 132056字节。从Linux 2.2 开始,MM的开发者采用了一种叫做Slab的分配模式,主要是基于以下考虑。

  • 内核对内存区的分配取决于所存放数据的类型。例如,当给用户态进程分配页面时,内核调用get_free_page()函数,并用 0 填充这个页面。而给内核的数据结构分配页面时,事情没有这么简单,例如,要对数据结构所在的内存进行初始化、在不用时要收回它们所占用的内存。因此,Slab中引入了对象这个概念,所谓对象就是存放一组数据结构的内存区,其方法就是构造或析构函数,构造函数用于初始化数据结构所在的内存区,而析构函数收回相应的内存区。为了避免重复初始化对象,Slab分配模式并不丢弃已分配的对象,而是释放但把它们依然保留在内存中。当以后又要请求分配同一对象时,就可以从内存获取而不用进行初始化,这是在Solaris中引入Slab的基本思想。

出于效率的考虑,Linux并不调用对象的构造或析构函数,而是把指向这两个函数的指针都置为空。Linux中引入Slab的主要目的是为了减少对伙伴算法的调用次数。

实际上,内核经常反复使用某一内存区。例如,只要内核创建一个新的进程,就要为该进程相关的数据结构(task_struct、打开文件对象等)分配内存区。当进程结束时,收回这些内存区。因为进程的创建和撤销非常频繁,因此,Linux的早期版本把大量的时间花费在反复分配或回收这些内存区上。从Linux 2.2 开始,把那些频繁使用的页面保存在高速缓存中并重新使用。

可以根据对内存区的使用频率来对它分类。对于预期频繁使用的内存区,可以创建一组特定大小的专用缓冲区进行处理,以避免内碎片的产生。对于较少使用的内存区,可以创建一组通用缓冲区(如Linux 2.0 中所使用的 2 的幂次方)来处理,即使这种处理模式产生碎片,也对整个系统的性能影响不大。

硬件高速缓存的使用,又为尽量减少对伙伴算法的调用提供了另一个理由,因为对伙伴算法的每次调用都会“弄脏”硬件高速缓存,因此,这就增加了对内存的平均访问次数。Slab分配模式把对象分组放进缓冲区。因为缓冲区的组织和管理与硬件高速缓存的命中率密切相关,因此,Slab缓冲区并非由各个对象直接构成,而是由一连串的“大块(Slab)”构成,而每个大块中则包含了若干个同种类型的对象,这些对象或已被分配,或空闲,如图6.10 所示。一般而言,对象分两种,一种是大对象,一种是小对象。

所谓小对象,是指在一个页面中可以容纳下好几个对象的那种。例如,一个inode结构大约占 300 多个字节,因此,一个页面中可以容纳 8 个以上的inode结构,因此,inode`结构就为小对象。Linux内核中把小于 512 字节的对象叫做小对象。

Slab的数据结构

Slab分配模式有两个主要的数据结构,一个是描述缓冲区的结构kmem_cache_t,一个是描述Slab的结构kmem_slab_t,下面对这两个结构给予简要讨论。

SlabSlab管理模式中最基本的结构。它由一组连续的物理页面组成,对象就被顺序放在这些页面中。其数据结构在mm/slab.c中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* slab_t
*
* Manages the objs in a slab. Placed either at the beginning of mem allocated
* for a slab, or allocated from an general cache.
* Slabs are chained into three list: fully used, partial, fully free slabs.
*/
typedef struct slab_s {
struct list_head list;
unsigned long colouroff;
void *s_mem; /* including colour offset */
unsigned int inuse; /* num of objs active in slab */
kmem_bufctl_t free;
} slab_t;

这里的链表用来将前一个Slab和后一个Slab链接起来形成一个双向链表,colouroff为该Slab上着色区的大小,指针s_mem指向对象区的起点,inuseSlab中所分配对象的个数。最后,free的值指明了空闲对象链中的第一个对象,kmem_bufctl_t其实是一个整数。

Slab结构的示意图如图 6.11 所示。

对于小对象,就把Slab的描述结构slab_t放在该Slab中;对于大对象,则把Slab结构游离出来,集中存放。关于Slab中的着色区再给予具体描述。每个Slab的首部都有一个小小的区域是不用的,称为“着色区(Coloring Area)”。着色区的大小使Slab中的每个对象的起始地址都按高速缓存中的“缓存行(Cache Line)”大小进行对齐(80386 的一级高速缓存行大小为 16 字节,Pentium为 32 字节)。因为Slab是由 1 个页面或多个页面(最多为 32)组成,因此,每个Slab都是从一个页面边界开始的,它自然按高速缓存的缓冲行对齐。

但是,Slab中的对象大小不确定,设置着色区的目的就是将Slab中第一个对象的起始地址往后推到与缓冲行对齐的位置。因为一个缓冲区中有多个Slab,因此,应该把每个缓冲区中的各个Slab着色区的大小尽量安排成不同的大小,这样可以使得在不同的Slab中,处于同一相对位置的对象,让它们在高速缓存中的起始地址相互错开,这样就可以改善高速缓存的存取效率。

每个Slab上最后一个对象以后也有个小小的废料区是不用的,这是对着色区大小的补偿,其大小取决于着色区的大小,以及Slab与其每个对象的相对大小。但该区域与着色区的总和对于同一种对象的各个Slab是个常数。每个对象的大小基本上是所需数据结构的大小。只有当数据结构的大小不与高速缓存中的缓冲行对齐时,才增加若干字节使其对齐。所以,一个Slab上的所有对象的起始地址都必然是按高速缓存中的缓冲行对齐的。

每个缓冲区管理着一个Slab链表,Slab按序分为 3 组。第 1 组是全满的Slab(没有空闲的对象),第 2 组Slab中只有部分对象被分配,部分对象还空闲,最后一组Slab中的对象全部空闲。只所以这样分组,是为了对Slab进行有效的管理。每个缓冲区还有一个轮转锁(Spinlock),在对链表进行修改时用这个轮转锁进行同步。类型kmem_cache_smm/slab.c中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
struct kmem_cache_s {
/* 1) each alloc & free */
/* full, partial first, then free */
struct list_head slabs_full;
struct list_head slabs_partial;
struct list_head slabs_free;
unsigned int objsize;
unsigned int flags; /* constant flags */
unsigned int num; /* # of objs per slab */
spinlock_t spinlock;
#ifdef CONFIG_SMP
unsigned int batchcount;
#endif
/* 2) slab additions /removals */
/* order of pgs per slab (2^n) */
unsigned int gfporder;
/* force GFP flags, e.g. GFP_DMA */
unsigned int gfpflags;
size_t colour; /* cache colouring range */
unsigned int colour_off; /* colour offset */
unsigned int colour_next; /* cache colouring */
kmem_cache_t *slabp_cache;
unsigned int growing;
unsigned int dflags; /* dynamic flags */
/* constructor func */
void (*ctor)(void *, kmem_cache_t *, unsigned long);
/* de-constructor func */
void (*dtor)(void *, kmem_cache_t *, unsigned long);
unsigned long failures;
/* 3) cache creation/removal */
char name[CACHE_NAMELEN];
struct list_head next;
#ifdef CONFIG_SMP
/* 4) per-cpu data */
cpucache_t *cpudata[NR_CPUS];
#endif
…..
};

然后定义了kmem_cache_t,并给部分域赋予了初值:

1
2
3
4
5
6
7
8
9
10
static kmem_cache_t cache_cache = {
slabs_full: LIST_HEAD_INIT(cache_cache.slabs_full),
slabs_partial: LIST_HEAD_INIT(cache_cache.slabs_partial),
slabs_free: LIST_HEAD_INIT(cache_cache.slabs_free),
objsize: sizeof(kmem_cache_t),
flags: SLAB_NO_REAP,
spinlock: SPIN_LOCK_UNLOCKED,
colour_off: L1_CACHE_BYTES,
name: "kmem_cache",
};

对该结构说明如下。
该结构中有 3 个队列slabs_fullslabs_partial以及slabs_free,分别指向满Slab、半满Slab和空闲Slab,另一个队列next则把所有的专用缓冲区链成一个链表。除了这些队列和指针外,该结构中还有一些重要的域:objsize是原始的数据结构的大小,这里初始化为kmem_cache_t的大小;num表示每个Slab上有几个缓冲区;gfporder则表示每个Slab大小的对数,即每个Slab2^gfporder个页面构成。

如前所述,着色区的使用是为了使同一缓冲区中不同Slab上的对象区的起始地址相互错开,这样有利于改善高速缓存的效率。colour_off表示颜色的偏移量,colour表示颜色的数量;一个缓冲区中颜色的数量取决于Slab中对象的个数、剩余空间以及高速缓存行的大小。所以,对每个缓冲区都要计算它的颜色数量,这个数量就保存在colour中,而下一个Slab将要使用的颜色则保存在colour_next中。当colour_next达到最大值时,就又从 0 开始。

着色区的大小可以根据(colour_off×colour)算得。例如,如果colour为 5,colour_off为 8,则第一个Slab的颜色将为 0,Slab中第一个对象区的起始地址(相对)为 0,下一个Slab中第一个对象区的起始地址为 8,再下一个为 16,24,32,0……等。cache_cache变量实际上就是缓冲区结构的头指针。

由此可以看出,缓冲区结构kmem_cache_t相当于Slab的总控结构,缓冲区结构与Slab结构之间的关系如图 6.12 所示。

在图 6.12 中,深灰色表示全满的Slab,浅灰色表示含有空闲对象的Slab,而无色表示空的Slab。缓冲区结构之间形成一个单向链表,Slab结构之间形成一个双向链表。另外,缓冲区结构还有分别指向满、半满、空闲Slab结构的指针。

专用缓冲区的建立和撤销

专用缓冲区是通过kmem_cache_create()函数建立的,函数原型为:

1
2
3
4
kmem_cache_t *kmem_cache_create(const char *name, size_t size, size_t offset,
unsigned long c_flags,
void (*ctor) (void *objp, kmem_cache_t *cachep, unsigned long flags),
void (*dtor) (void *objp, kmem_cache_t *cachep, unsigned long flags))

对其参数说明如下。

  • name:缓冲区名 ( 19 个字符)。
  • size:对象大小。
  • offset:所请求的着色偏移量。
  • c_flags:对缓冲区的设置标志。
    • SLAB_HWCACHE_ALIGN:表示与第一个高速缓存中的缓冲行边界(16 或 32 字节)对齐。
    • SLAB_NO_REAP:不允许系统回收内存。
    • SLAB_CACHE_DMA:表示Slab使用的是DMA内存。
  • ctor:构造函数(一般都为NULL)。
  • dtor:析构函数(一般都为NULL)。
  • objp:指向对象的指针。
  • cachep:指向缓冲区。

kmem_cache_create()函数要进行一系列的计算,以确定最佳的Slab构成。包括:每个Slab由几个页面组成,划分为多少个对象;Slab的描述结构slab_t应该放在Slab的外面还是放在Slab的尾部;还有“颜色”的数量等等。并根据调用参数和计算结果设置kmem_cache_t结构中的各个域,包括两个函数指针ctordtor。最后,将kmem_cache_t结构插入到cache_cachenext队列中。

但请注意,函数kmem_cache_create()所创建的缓冲区中还没有包含任何Slab,因此,也没有空闲的对象。只有以下两个条件都为真时,才给缓冲区分配Slab

  1. 已发出一个分配新对象的请求;
  2. 缓冲区不包含任何空闲对象。

当这两个条件都成立时,Slab分配模式就调用kmem_cache_grow()函数给缓冲区分配一个新的Slab。其中,该函数调用kmem_gatepages()从伙伴系统获得一组页面;然后又调用kmem_cache_slabgmt()获得一个新的Slab结构;还要调用kmem_cache_init_objs()为新Slab中的所有对象申请构造方法(如果定义的话);最后,调用kmem_slab_link_end()把这个Slab结构插入到缓冲区中Slab链表的末尾。

Slab分配模式的最大好处就是给频繁使用的数据结构建立专用缓冲区。但到目前的版本为止,Linux内核中多数专用缓冲区的建立都用NULL作为构造函数的指针,例如,为虚存区间结构vm_area_struct建立的专用缓冲区vm_area_cachep

1
2
3
vm_area_cachep = kmem_cache_create("vm_area_struct",
sizeof(struct vm_area_struct), 0,
SLAB_HWCACHE_ALIGN, NULL, NULL);

就把构造和析构函数的指针置为NULL,也就是说,内核并没有充分利用Slab管理机制所提供的好处。为了说明如何利用专用缓冲区,我们从内核代码中选取一个构造函数不为空的简单例子,这个例子与网络子系统有关,在net/core/buff.c中定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
void __init skb_init(void)
{
int i;
skbuff_head_cache = kmem_cache_create("skbuff_head_cache",
sizeof(struct sk_buff),
0,
SLAB_HWCACHE_ALIGN,
skb_headerinit, NULL);
if (!skbuff_head_cache)
panic("cannot create skbuff cache");
for (i=0; i<NR_CPUS; i++)
skb_queue_head_init(&skb_head_pool[i].list);
}

从代码中可以看出,skb_init()调用kmem_cache_create()为网络子系统建立一个sk_buff数据结构的专用缓冲区,其名称为skbuff_head_cache(你可以通过读取/proc/slabinfo/文件得到所有缓冲区的名字)。调用参数offset为 0,表示第一个对象在Slab中的位移并无特殊要求。但是参数flagsSLAB_HWCACHE_ALIGN,表示Slab中的对象要与高速缓存中的缓冲行边界对齐。对象的构造函数为skb_headerinit(),而析构函数为空,也就是说,在释放一个Slab时无需对各个缓冲区进行特殊的处理。

当从内核卸载一个模块时,同时应当撤销为这个模块中的数据结构所建立的缓冲区,这是通过调用kmem_cache_destroy()函数来完成的。

通用缓冲区

在内核中初始化开销不大的数据结构可以合用一个通用的缓冲区。通用缓冲区非常类似于物理页面分配中的大小分区,最小的为 32,然后依次为 64、128、……直至 128KB(即 32 个页面),但是,对通用缓冲区的管理又采用的是Slab方式。从通用缓冲区中分配和释放缓冲区的函数为:

1
2
void *kmalloc(size_t size, int flags);
Void kree(const void *objp);

因此,当一个数据结构的使用根本不频繁时,或其大小不足一个页面时,就没有必要给其分配专用缓冲区,而应该调用kmalloc()进行分配。如果数据结构的大小接近一个页面,则干脆通过alloc_page()为之分配一个页面。事实上,在内核中,尤其是驱动程序中,有大量的数据结构仅仅是一次性使用,而且所占内存只有几十个字节,因此,一般情况下调用kmalloc()给内核数据结构分配内存就足够了。另外,因为,在Linux 2.0 以前的版本一般都调用kmalloc()给内核数据结构分配内存,因此,调用该函数的一个优点是(让你开发的驱动程序)能保持向后兼容。

内核空间非连续内存区的管理

首先,非连续内存处于3GB4GB之间,也就是处于内核空间,如图 6.13 所示。

图 6.13 中,PAGE_OFFSET为 3GB,high_memory为保存物理地址最高值的变量,VMALLOC_START为非连续区的的起始地址,定义于include/i386/pgtable.h中:

1
2
3
#define VMALLOC_OFFSET (8*1024*1024)

#define VMALLOC_START (((unsigned long) high_memory + 2*VMALLOC_OFFSET-1) & ~(VMALLOC_OFFSET-1))

在物理地址的末尾与第一个内存区之间插入了一个 8MB(VMALLOC_OFFSET)的区间,这是一个安全区,目的是为了“捕获”对非连续区的非法访问。出于同样的理由,在其他非连续的内存区之间也插入了 4KB 大小的安全区。每个非连续内存区的大小都是 4096 的倍数。

非连续区的数据结构

描述非连续区的数据结构为struct vm_struct,定义于include/linux/vmalloc.h中:

1
2
3
4
5
6
7
struct vm_struct {
unsigned long flags;
void * addr;
unsigned long size;
struct vm_struct * next;
};
struct vm_struct * vmlist;

非连续区组成一个单链表,链表第一个元素的地址存放在变量vmlist中。addr域是内存区的起始地址;size是内存区的大小加 4096(安全区的大小)。

创建一个非连续区的结构

函数get_vm_area()创建一个新的非连续区结构,其代码在mm/vmalloc.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct vm_struct * get_vm_area(unsigned long size, unsigned long flags)
{
unsigned long addr;
struct vm_struct **p, *tmp, *area;
area = (struct vm_struct *) kmalloc(sizeof(*area), GFP_KERNEL);
if (!area)
return NULL;
size += PAGE_SIZE;
addr = VMALLOC_START;
write_lock(&vmlist_lock);
for (p = &vmlist; (tmp = *p) ; p = &tmp->next) {
if ((size + addr) < addr)
goto out;
if (size + addr <= (unsigned long) tmp->addr)
break;
addr = tmp->size + (unsigned long) tmp->addr;
if (addr > VMALLOC_END-size)
goto out;
}
area->flags = flags;
area->addr = (void *)addr;
area->size = size;
area->next = *p;
*p = area;
write_unlock(&vmlist_lock);
return area;
out:
write_unlock(&vmlist_lock);
kfree(area);
return NULL;
}

这个函数比较简单,就是在单链表中插入一个元素。其中调用了kmalloc()kfree()函数,分别用来为vm_struct结构分配内存和释放所分配的内存。

分配非连续内存区

vmalloc()函数给内核分配一个非连续的内存区,在/include/linux/vmalloc.h中定义如下:

1
2
3
4
static inline void * vmalloc (unsigned long size)
{
return __vmalloc(size, GFP_KERNEL | __GFP_HIGHMEM, PAGE_KERNEL);
}

vmalloc()最终调用的是__vmalloc()函数,该函数的代码在mm/vmalloc.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void * __vmalloc (unsigned long size, int gfp_mask, pgprot_t prot)
{
void * addr;
struct vm_struct *area;
size = PAGE_ALIGN(size);
if (!size || (size >> PAGE_SHIFT) > num_physpages) {
BUG();
return NULL;
}
area = get_vm_area(size, VM_ALLOC);
if (!area)
return NULL;
addr = area->addr;
if (vmalloc_area_pages(VMALLOC_VMADDR(addr), size, gfp_mask, prot)) {
vfree(addr);
return NULL;
}
return addr;
}

函数首先把size参数取整为页面大小(4096)的一个倍数,也就是按页的大小进行对齐,然后进行有效性检查,如果有大小合适的可用内存,就调用get_vm_area()获得一个内存区的结构。但真正的内存区还没有获得,函数vmalloc_area_pages()真正进行非连续内存区的分配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
inline int vmalloc_area_pages (unsigned long address, unsigned long size, int gfp_mask, pgprot_t prot)
{
pgd_t * dir;
unsigned long end = address + size;
int ret;
dir = pgd_offset_k(address);
spin_lock(&init_mm.page_table_lock);
do {
pmd_t *pmd;
pmd = pmd_alloc(&init_mm, dir, address);
ret = -ENOMEM;
if (!pmd)
break;
ret = -ENOMEM;
if (alloc_area_pmd(pmd, address, end - address, gfp_mask, prot))
break;
address = (address + PGDIR_SIZE) & PGDIR_MASK;
dir++;
ret = 0;
} while (address && (address < end));
spin_unlock(&init_mm.page_table_lock);
return ret;
}

该函数有两个主要的参数,address表示内存区的起始地址,size表示内存区的大小。内存区的末尾地址赋给了局部变量end。其中还调用了几个主要的函数或宏。

  1. pgd_offset_k()宏导出这个内存区起始地址在页目录中的目录项。
  2. pmd_alloc()为新的内存区创建一个中间页目录。
  3. alloc_area_pmd()为新的中间页目录分配所有相关的页表,并更新页的总目录;该函数调用pte_alloc_kernel()函数来分配一个新的页表,之后再调用alloc_area_pte()为页表项分配具体的物理页面。
  4. vmalloc_area_pages()函数可以看出,该函数实际建立起了非连续内存区到物理页面的映射。

kmalloc()与vmalloc()的区别

从前面的介绍已经看出,这两个函数所分配的内存都处于内核空间,即从 3GB~4GB;但位置不同,kmalloc()分配的内存处于3GB~high_memory之间,而vmalloc()分配的内存在VMALLOC_START~4GB之间,也就是非连续内存区。一般情况下在驱动程序中都是调用kmalloc()来给数据结构分配内存,而vmalloc()用在为活动的交换区分配数据结构,为某些I/O驱动程序分配缓冲区,例如在include/asm-i386/module.h中定义了如下语句:

1
#define module_map(x) vmalloc(x)

其含义就是把模块映射到非连续的内存区。

kmalloc()和vmalloc()相对应,两个释放内存的函数为kfree()vfree()`。

地址映射机制

顾名思义地址映射就是建立几种存储媒介(内存,辅存,虚存)间的关联,完成地址间的相互转换,它既包括磁盘文件到虚拟内存的映射,也包括虚拟内存到物理内存的映射,如图 6.14 所示。

描述虚拟空间的数据结构

一个进程的虚拟地址空间主要由两个数据结构来描述。一个是最高层次的:mm_struct,一个是较高层次的:vm_area_structs。最高层次的mm_struct结构描述了一个进程的整个虚拟地址空间。较高层次的结构vm_area_truct描述了虚拟地址空间的一个区间(简称虚拟区)。

MM_STRUCT结构

mm_strcut用来描述一个进程的虚拟地址空间,在/include/linux/sched.h中描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct mm_struct {
struct vm_area_struct * mmap; /* 指向虚拟区间(VMA)链表 */
rb_root_t mm_rb; /*指向red_black树*/
struct vm_area_struct * mmap_cache; /* 指向最近找到的虚拟区间*/
pgd_t * pgd; /*指向进程的页目录*/
atomic_t mm_users; /* 用户空间中的有多少用户*/
atomic_t mm_count; /* 对"struct mm_struct"有多少引用*/
int map_count; /* 虚拟区间的个数*/
struct rw_semaphore mmap_sem;
spinlock_t page_table_lock; /* 保护任务页表和`mm->rss */
struct list_head mmlist; /*所有活动(active)mm`的链表 */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long rss, total_vm, locked_vm;
unsigned long def_flags;
unsigned long cpu_vm_mask;
unsigned long swap_address;
unsigned dumpable:1;
/* Architecture-specific MM context */
mm_context_t context;
};

对该结构进一步说明如下。
在内核代码中,指向这个数据结构的变量常常是mm。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程的结构。可以说,mm_struct结构是对整个用户空间的描述。

一个进程的虚拟空间中可能有多个虚拟区间(参见下面对vm_area_struct描述),对这些虚拟区间的组织方式有两种,当虚拟区间较少时采用单链表,由mmap指针指向这个链表,当虚拟区间多时采用“红黑树(red_black tree)”结构,由mm_rb指向这颗树。把最近用到的虚拟区间结构应当放入高速缓存,这个虚拟区间就由mmap_cache指向。

指针pgd指向该进程的页目录(每个进程都有自己的页目录,注意同内核页目录的区别),当调度程序调度一个程序运行时,就将这个地址转成物理地址,并写入控制寄存器(CR3)。由于进程的虚拟空间及其下属的虚拟区间有可能在不同的上下文中受到访问,而这些访问又必须互斥,所以在该结构中设置了信号量mmap_sem

此外,page_table_lock也是为类似的目的而设置的。虽然每个进程只有一个虚拟地址空间,但这个地址空间可以被别的进程来共享,如,子进程共享父进程的地址空间(也即共享mm_struct结构)。所以,用mm_usermm_count进行计数。类型atomic_t实际上就是整数,但对这种整数的操作必须是“原子”的。

另外,还描述了代码段、数据段、堆栈段、参数段以及环境段的起始地址和结束地址。

这里的段是对程序的逻辑划分,与我们前面所描述的段机制是不同的。

VM_AREA_STRUCT结构

vm_area_struct描述进程的一个虚拟地址区间,在/include/linux/mm.h中描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    struct vm_area_struct
struct mm_struct * vm_mm; /* 虚拟区间所在的地址空间*/
unsigned long vm_start; /* 在vm_mm中的起始地址*/
unsigned long vm_end; /*在vm_mm中的结束地址 */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot; /* 对这个虚拟区间的存取权限 */
unsigned long vm_flags; /* 虚拟区间的标志 */

rb_node_t vm_rb;
/*
* For areas with an address space and backing store,
* one of the address_space->i_mmap{,shared} lists,
* for shm areas, the list of attaches, otherwise unused.
*/
struct vm_area_struct *vm_next_share;
struct vm_area_struct **vm_pprev_share;

/*对这个区间进行操作的函数 */
struct vm_operations_struct * vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
unsigned long vm_raend; /* XXX: put full readahead info here. */
void * vm_private_data; /* was vm_pte (shared mem) */
};

vm_flag是描述对虚拟区间的操作的标志,其定义和描述如表所示。

标志名 描述
VM_DENYWRITE在这个区间映射一个打开后不能用来写的文件
VM_EXEC页可以被执行
VM_EXECUTABLE页含有可执行代码
VM_GROWSDOWN这个区间可以向低地址扩展
VM_GROWSUP这个区间可以向高地址扩展
VM_IO这个区间映射一个设备的I/O地址空间
VM_LOCKED页被锁住不能被交换出去
VM_MAYEXEC VM_EXEC标志可以被设置
VM_MAYREAD VM_READ标志可以被设置
VM_MAYSHARE VM_SHARE标志可以被设置
VM_MAYWRITE VM_WRITE标志可以被设置
VM_READ页是可读的
VM_SHARED页可以被多个进程共享
VM_SHM页用于IPC共享内存
VM_WRITE页是可写的

较高层次的结构vm_area_struct是由双向链表连接起来的,它们是按虚地址的降顺序来排列的,每个这样的结构都对应描述一个相邻的地址空间范围。之所以这样分割,是因为每个虚拟区间可能来源不同,有的可能来自可执行映像,有的可能来自共享库,而有的则可能是动态分配的内存区,所以对每一个由vm_area_struct结构所描述的区间的处理操作和它前后范围的处理操作不同。因此Linux把虚拟内存分割管理,并利用了虚拟内存处理例程(vm_ops)来抽象对不同来源虚拟内存的处理方法。不同的虚拟区间其处理操作可能不同,Linux在这里利用了面向对象的思想,即把一个虚拟区间看成一个对象,用vm_area_struct描述了这个对象的属性,其中的vm_operation s-stract结构描述了在这个对象上的操作,其定义在/include/linux/mm.h中:

1
2
3
4
5
6
7
8
9
10
 /*
* These are the virtual MM functions - opening of an area, closing and
* unmapping it (needed to keep files on disk up-to-date etc), pointer
* to the functions called when a no-page or a wp-page exception occurs.
*/
struct vm_operations_struct {
void (*open)(struct vm_area_struct * area);
void (*close)(struct vm_area_struct * area);
struct page * (*nopage)(struct vm_area_struct * area, unsigned long address, int unused);
};

vm_operations结构中包含的是函数指针;其中,openclose分别用于虚拟区间的打开、关闭,而nopage用于当虚存页面不在物理内存而引起的“缺页异常”时所应该调用的函数。图 6.15 给出了虚拟区间的操作集。

红黑树结构

一颗红黑树是具有以下特点的二叉树:

  • 每个节点着有颜色,或者为红,或者为黑;
  • 根节点为黑色;
  • 如果一个节点为红色,那么它的子节点必须为黑色;
  • 从一个节点到叶子节点上的所有路径都包含有相同的黑色节点数;

红黑树的结构在include/linux/rbtree.h中定义如下:

1
2
3
4
5
6
7
8
9
typedef struct rb_node_s
{
struct rb_node_s * rb_parent;
int rb_color;
#define RB_RED 0
#define RB_BLACK 1
struct rb_node_s * rb_right;
struct rb_node_s * rb_left;
} rb_node_t;

进程的虚拟空间

用户进程经过编译、链接后形成的映象文件有一个代码段和数据段(包括data段和bss段),其中代码段在下,数据段在上。数据段中包括了所有静态分配的数据空间,即全局变量和所有申明为static的局部变量,这些空间是进程所必需的基本要求,这些空间是在建立一个进程的运行映像时就分配好的。除此之外,堆栈使用的空间也属于基本要求,所以也是在建立进程时就分配好的,如图 6.17 所示。

由图 6.17 可以看出,堆栈空间安排在虚存空间的顶部,运行时由顶向下延伸;代码段和数据段则在低部,运行时并不向上延伸。从数据段的顶部到堆栈段地址的下沿这个区间是一个巨大的空洞,这就是进程在运行时可以动态分配的空间(也叫动态内存)。

进程在运行过程中,可能会通过系统调用mmap动态申请虚拟内存或释放已分配的内存,新分配的虚拟内存必须和进程已有的虚拟地址链接起来才能使用;Linux进程可以使用共享的程序库代码或数据,这样,共享库的代码和数据也需要链接到进程已有的虚拟地址中。在后面我们还会看到,系统利用了请页机制来避免对物理内存的过分使用。因为进程可能会访问当前不在物理内存中的虚拟内存,这时,操作系统通过请页机制把数据从磁盘装入到物理内存。为此,系统需要修改进程的页表,以便标志虚拟页已经装入到物理内存中,同时,Linux还需要知道进程虚拟空间中任何一个虚拟地址区间的来源和当前所在位置,以便能够装入物理内存。

由于上面这些原因,Linux采用了比较复杂的数据结构跟踪进程的虚拟地址。在进程的task_struct结构中包含一个指向mm_struct结构的指针。进程的mm_struct则包含装入的可执行映像信息以及进程的页目录指针pgd。该结构还包含有指向vm_area_struct结构的几个指针,每个vm_area_struct代表进程的一个虚拟地址区间。

图 6.18 是某个进程的虚拟内存简化布局以及相应的几个数据结构之间的关系。从图中以看出,系统以虚拟内存地址的降序排列vm_area_struct。除链表结构外,Linux还利用红黑(Red_black)树来组织vm_area_struct。通过这种树结构,Linux可以快速定位某个虚拟内存地址。

当进程利用系统调用动态分配内存时,Linux首先分配一个vm_area_struct结构,并链接到进程的虚拟内存链表中,当后续的指令访问这一内存区间时,因为Linux尚未分配相应的物理内存,因此处理器在进行虚拟地址到物理地址的映射时会产生缺页异常,当Linux处理这一缺页异常时,就可以为新的虚拟内存区分配实际的物理内存。

在内核中,经常会用到这样的操作:给定一个属于某个进程的虚拟地址,要求找到其所属的区间以及vma_area_struct结构,这是由find_vma()来实现的,其实现代码在mm/mmap.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Look up the first VMA which satisfies addr < vm_end, NULL if none. */
struct vm_area_struct * find_vma(struct mm_struct * mm, unsigned long addr)
{
struct vm_area_struct *vma = NULL;

if (mm) {
/* Check the cache first. */
/* (Cache hit rate is typically around 35%.) */
vma = mm->mmap_cache;
if (!(vma && vma->vm_end > addr && vma->vm_start <= addr)) {
rb_node_t * rb_node;
rb_node = mm->mm_rb.rb_node;
vma = NULL;
while (rb_node) {
struct vm_area_struct * vma_tmp;
vma_tmp = rb_entry(rb_node, struct vm_area_struct, vm_rb);
if (vma_tmp->vm_end > addr) {
vma = vma_tmp;
if (vma_tmp->vm_start <= addr)
break;
rb_node = rb_node->rb_left;
} else
rb_node = rb_node->rb_right;
}
if (vma)
mm->mmap_cache = vma;
}
}
return vma;
}

这个函数比较简单,我们对其主要点给予解释。

  • 参数的含义:函数有两个参数,一个是指向mm_struct结构的指针,这表示一个进程的虚拟地址空间;一个是地址,表示该进程虚拟地址空间中的一个地址。
  • 条件检查:首先检查这个地址是否恰好落在上一次(最近一次)所访问的区间中。如果没有命中,那就要在红黑树中进行搜索,红黑树与AVL树类似。
  • 查找节点:如果已经建立了红黑树结构(rb_rode不为空),就在红黑树中搜索。
    • 如果找到指定地址所在的区间 , 就把mmap_cache指针设置成指向所找到的vm_area_struct结构。
    • 如果没有找到,说明该地址所在的区间还没有建立,此时,就得建立一个新的虚拟区间,
  • 再调用insert_vm_struct()函数将新建立的区间插入到vm_struct中的线性队列或红黑树中。

内存映射

当某个程序的映像开始执行时,可执行映像必须装入到进程的虚拟地址空间。如果该进程用到了任何一个共享库,则共享库也必须装入到进程的虚拟地址空间。由此可看出,Linux并不将映像装入到物理内存,相反,可执行文件只是被连接到进程的虚拟地址空间中。随着程序的运行,被引用的程序部分会由操作系统装入到物理内存,这种将映像链接到进程地址空间的方法被称为“内存映射”。

当可执行映像映射到进程的虚拟地址空间时,将产生一组vm_area_struct结构来描述虚拟内存区间的起始点和终止点,每个vm_area_struct结构代表可执行映像的一部分,可能是可执行代码,也可能是初始化的变量或未初始化的数据,这些都是在函数do_mmap()中来实现的。随着vm_area_struct结构的生成,这些结构所描述的虚拟内存区间上的标准操作函数也由Linux初始化。但要明确在这一步还没有建立从虚拟内存到物理内存的影射,也就是说还没有建立页表页目录。

为了对上面的原理进行具体的说明,我们来看一下do_mmap()的实现机制。函数do_mmap()为当前进程创建并初始化一个新的虚拟区,如果分配成功,就把这个新的虚拟区与进程已有的其他虚拟区进行合并,do_mmap()include/linux/mm.h中定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
static inline unsigned long do_mmap(struct file *file, unsigned long addr, 
unsigned long len, unsigned long prot,
unsigned long flag, unsigned long offset)
{
unsigned long ret = -EINVAL;
if ((offset + PAGE_ALIGN(len)) < offset)
goto out;
if (!(offset & ~PAGE_MASK))
ret = do_mmap_pgoff(file, addr, len, prot, flag, offset >> PAGE_SHIFT);
out:
return ret;
}

函数中参数的含义如下。

  • file:表示要映射的文件,file`结构将在第八章文件系统中进行介绍。
  • offset:文件内的偏移量,因为我们并不是一下子全部映射一个文件,可能只是映射文件的一部分,off`就表示那部分的起始位置。
  • len:要映射的文件部分的长度。
  • addr:虚拟空间中的一个地址,表示从这个地址开始查找一个空闲的虚拟区。
  • prot: 这个参数指定对这个虚拟区所包含页的存取权限。可能的标志有PROT_READPROT_WRITEPROT_EXECPROT_NONE。前 3 个标志与标志VM_READVM_WRITEVM_EXEC的意义一样。PROT_NONE表示进程没有以上 3 个存取权限中的任意一个。
  • flag:这个参数指定虚拟区的其他标志:
    • MAP_GROWSDOWNMAP_LOCKEDMAP_DENYWRITEMAP_EXECUTABLE
      • 它们的含义与表 6.1 中所列出标志的含义相同。
    • MAP_SHAREDMAP_PRIVATE
      • 前一个标志指定虚拟区中的页可以被许多进程共享;后一个标志作用相反。这两个标志都涉及vm_area_struct中的VM_SHARED标志。
    • MAP_ANONYMOUS
      • 表示这个虚拟区是匿名的,与任何文件无关。
    • MAP_FIXED
      • 这个区间的起始地址必须是由参数addr所指定的。
    • MAP_NORESERVE
      • 函数不必预先检查空闲页面的数目。

do_mmap()函数对参数offset的合法性检查后,就调用do_mmap_pgoff()函数,该函数才是内存映射的主要函数,do_mmap_pgoff()的代码在mm/mmap.c中,代码比较长,我们分段来介绍:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
unsigned long do_mmap_pgoff(struct file * file, unsigned long addr, unsigned long len,
unsigned long prot, unsigned long flags, unsigned long pgoff)
{
struct mm_struct * mm = current->mm;
struct vm_area_struct * vma, * prev;
unsigned int vm_flags;
int correct_wcount = 0;
int error;
rb_node_t ** rb_link, * rb_parent;

if (file && (!file->f_op || !file->f_op->mmap))
return -ENODEV;

if ((len = PAGE_ALIGN(len)) == 0)
return addr;
if (len > TASK_SIZE)
return -EINVAL;
/* offset overflow? */
if ((pgoff + (len >> PAGE_SHIFT)) < pgoff)
return -EINVAL;
/* Too many mappings? */
if (mm->map_count > MAX_MAP_COUNT)
return -ENOMEM;

函数首先检查参数的值是否正确,所提的请求是否能够被满足,如果发生以上情况中的任何一种,do_mmap()函数都终止并返回一个负值。

1
2
3
4
5
6
/* Obtain the address to map to. we verify (or select) it and ensure
* that it represents a valid section of the address space.
*/
addr = get_unmapped_area(file, addr, len, pgoff, flags);
if (addr & ~PAGE_MASK)
return addr;

调用get_unmapped_area()函数在当前进程的用户空间中获得一个未映射区间的起始地址。PAGE_MASK的值为 0xFFFFF000,因此,如果addr & ~PAGE_MASK为非 0,说明addr最低 12 位非 0,addr就不是一个有效的地址,就以这个地址作为返回值;否则,addr就是一个有效的地址(最低 12 位为 0),继续向下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Do simple checking here so the lower-level routines won't have
* to. we assume access permissions have been handled by the open
* of the memory object, so we don't do any here.
*/
vm_flags = calc_vm_flags(prot,flags) | mm->def_flags | VM_MAYREAD | VM_MAYWRITE | VM_MAYEXEC;

/* mlock MCL_FUTURE? */
if (vm_flags & VM_LOCKED) {
unsigned long locked = mm->locked_vm << PAGE_SHIFT;
locked += len;
if (locked > current->rlim[RLIMIT_MEMLOCK].rlim_cur)
return -EAGAIN;
}

如果flag参数指定的新虚拟区中的页必须锁在内存,且进程加锁页的总数超过了保存在进程的task_struct结构rlim[RLIMIT_MEMLOCK].rlim_cur域中的上限值,则返回一个负值。继续:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
if (file) {
switch (flags & MAP_TYPE) {
case MAP_SHARED:
if ((prot & PROT_WRITE) && !(file->f_mode & FMODE_WRITE))
return -EACCES;
/* Make sure we don't allow writing to an append-only file.. */
if (IS_APPEND(file->f_dentry->d_inode) && (file->f_mode & FMODE_WRITE))
return -EACCES;
/* make sure there are no mandatory locks on the file. */
if (locks_verify_locked(file->f_dentry->d_inode))
return -EAGAIN;
vm_flags |= VM_SHARED | VM_MAYSHARE;
if (!(file->f_mode & FMODE_WRITE))
vm_flags &= ~(VM_MAYWRITE | VM_SHARED);
/* fall through */
case MAP_PRIVATE:
if (!(file->f_mode & FMODE_READ))
return -EACCES;
break;
default:
return -EINVAL;
}
} else {
vm_flags |= VM_SHARED | VM_MAYSHARE;
switch (flags & MAP_TYPE) {
default:
return -EINVAL;
case MAP_PRIVATE:
vm_flags &= ~(VM_SHARED | VM_MAYSHARE);
/* fall through */
case MAP_SHARED:
break;
}
}

  • 如果file结构指针为 0,则目的仅在于创建虚拟区间,或者说,并没有真正的映射发生;
  • 如果file结构指针不为 0,则目的在于建立从文件到虚拟区间的映射,那就要根据标志指定的映射种类,把为文件设置的访问权考虑进去。
  • 如果所请求的内存映射是共享可写的,就要检查要映射的文件是为写入而打开的,而不是以追加模式打开的,还要检查文件上没有上强制锁。
  • 对于任何种类的内存映射,都要检查文件是否为读操作而打开的。
  • 如果以上条件都不满足,就返回一个错误码。
1
2
3
4
5
6
7
8
9
/* Clear old maps */
error = -ENOMEM;
munmap_back:
vma = find_vma_prepare(mm, addr, &prev, &rb_link, &rb_parent);
if (vma && vma->vm_start < addr + len) {
if (do_munmap(mm, addr, len))
return -ENOMEM;
goto munmap_back;
}

函数find_vma_prepare()find_vma()基本相同,它扫描当前进程地址空间的vm_area_struct结构所形成的红黑树,试图找到结束地址高于addr的第 1 个区间;如果找到了一个虚拟区,说明addr所在的虚拟区已经在使用,也就是已经有映射存在,因此要调用do_munmap()把这个老的虚拟区从进程地址空间中撤销,如果撤销不成功,就返回一个负数;如果撤销成功,就继续查找,直到在红黑树中找不到addr所在的虚拟区,并继续下面的检查:

1
2
3
/* Check against address space limit. */
if ((mm->total_vm << PAGE_SHIFT) + len > current->rlim[RLIMIT_AS].rlim_cur)
return -ENOMEM;

total_vm是表示进程地址空间的页面数,如果把文件映射到进程地址空间后,其长度超过了保存在当前进程rlim[RLIMIT_AS].rlim_cur中的上限值,则返回一个负数。

1
2
3
/* Private writable mapping? Check memory availability.. */
if ((vm_flags & (VM_SHARED | VM_WRITE)) == VM_WRITE && !(flags & MAP_NORESERVE) &&!vm_enough_memory(len >> PAGE_SHIFT))
return -ENOMEM;

如果flags参数中没有设置MAP_NORESERVE标志,新的虚拟区含有私有的可写页,空闲页面数小于要映射的虚拟区的大小;则函数终止并返回一个负数;其中函数vm_enough_memory()用来检查一个进程的地址空间中是否有足够的内存来进行一个新的映射。

1
2
3
4
/* Can we just expand an old anonymous mapping? */
if (!file && !(vm_flags & VM_SHARED) && rb_parent)
if (vma_merge(mm, prev, rb_parent, addr, addr + len, vm_flags))
goto out;

如果是匿名映射(file为空),并且这个虚拟区是非共享的,则可以把这个虚拟区和与它紧挨的前一个虚拟区进行合并;虚拟区的合并是由vma_merge()函数实现的。如果合并成功,则转out处,请看后面out处的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* Determine the object being mapped and call the appropriate
* specific mapper. the address has already been validated, but
* not unmapped, but the maps are removed from the list.
*/
vma = kmem_cache_alloc(vm_area_cachep, SLAB_KERNEL);
if (!vma)
return -ENOMEM;
vma->vm_mm = mm;
vma->vm_start = addr;
vma->vm_end = addr + len;
vma->vm_flags = vm_flags;
vma->vm_page_prot = protection_map[vm_flags & 0x0f];
vma->vm_ops = NULL;
vma->vm_pgoff = pgoff;
vma->vm_file = NULL;
vma->vm_private_data = NULL;
vma->vm_raend = 0;

经过以上各种检查后,现在必须为新的虚拟区分配一个vm_area_struct结构。这是通过调用Slab分配函数kmem_cache_alloc()来实现的,然后就对这个结构的各个域进行了初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    if (file) {
error = -EINVAL;
if (vm_flags & (VM_GROWSDOWN|VM_GROWSUP))
goto free_vma;
if (vm_flags & VM_DENYWRITE) {
error = deny_write_access(file);
if (error)
goto free_vma;
correct_wcount = 1;
}
vma->vm_file = file;
get_file(file);
error = file->f_op->mmap(file, vma);
if (error)
goto unmap_and_free_vma;
} else if (flags & MAP_SHARED) {
error = shmem_zero_setup(vma);
if (error)
goto free_vma;
}
free_vma:
kmem_cache_free(vm_area_cachep, vma);
return error;
}

如果建立的是从文件到虚存区间的映射,则情况下。

  • 当参数flags中的VM_GROWSDOWNVM_GROWSUP标志位为 1 时,说明这个区间可以向低地址或高地址扩展,但从文件映射的区间不能进行扩展,因此转到free_vma,释放给vm_area_struct分配的Slab,并返回一个错误。
  • flags中的VM_DENYWRITE标志位为 1 时,就表示不允许通过常规的文件操作访问该文件,所以要调用deny_write_access()排斥常规的文件操作。

get_file()函数的主要作用是递增file结构中的共享计数。

每个文件系统都有个fiel_operation数据结构,其中的函数指针mmap提供了用来建立从该类文件到虚存区间进行映射的操作,这是最具有实质意义的函数;对于大部分文件系统,这个函数为generic_file_mmap()函数实现的,该函数执行以下操作。

  • 初始化vm_area_struct结构中的vm_ops域。如果VM_SHARED标志为 1,就把该域设置成file_shared_mmap,否则就把该域设置成file_private_mmap。从某种意义上说,这个步骤所做的事情类似于打开一个文件并初始化文件对象的方法。
  • 从索引节点的i_mode域检查要映射的文件是否是一个常规文件。如果是其他类型的文件(例如目录或套接字),就返回一个错误代码。
  • 从索引节点的i_op域中检查是否定义了readpage()的索引节点操作。如果没有定义,就返回一个错误代码。
  • 调用update_atime()函数把当前时间存放在该文件索引节点的i_atime域中,并将这个索引节点标记成脏。
  • 如果flags参数中的MAP_SHARED标志位为 1,则调用shmem_zero_setup()进行共享内存的映射。

继续看do_mmap()中的代码。

1
2
3
4
5
6
/* Can addr have changed??
*
* Answer: Yes, several device drivers can do it in their
* f_op->mmap method. -DaveM
*/
addr = vma->vm_start;

源码作者给出了解释,意思是说,addr有可能已被驱动程序改变,因此,把新虚拟区的起始地址赋给addr

1
2
3
vma_link(mm, vma, prev, rb_link, rb_parent);
if (correct_wcount)
atomic_inc(&file->f_dentry->d_inode->i_writecount);

此时,应该把新建的虚拟区插入到进程的地址空间,这是由函数vma_link()完成的,该函数具有 3 方面的功能:

  1. vma插入到虚拟区链表中;
  2. vma插入到虚拟区形成的红黑树中;
  3. vam插入到索引节点(inode)共享链表中。

函数atomic_inc(x)*x加 1,这是一个原子操作。在内核代码中,有很多地方调用了以atomic为前缀的函数。所谓原子操作,就是在操作过程中不会被中断。

1
2
3
4
5
6
7
out:
mm->total_vm += len >> PAGE_SHIFT;
if (vm_flags & VM_LOCKED) {
mm->locked_vm += len >> PAGE_SHIFT;
make_pages_present(addr, addr + len);
}
return addr;

do_mmap()函数准备从这里退出,首先增加进程地址空间的长度,然后看一下对这个区间是否加锁,如果加锁,说明准备访问这个区间,就要调用make_pages_present()函数,建立虚拟页面到物理页面的映射,也就是完成文件到物理内存的真正调入。返回一个正数,说明这次映射成功。

1
2
3
4
5
6
7
unmap_and_free_vma:
if (correct_wcount)
atomic_inc(&file->f_dentry->d_inode->i_writecount);
vma->vm_file = NULL;
fput(file);
/* Undo any partial mapping done by a device driver. */
zap_page_range(mm, vma->vm_start, vma->vm_end - vma->vm_start);

如果对文件的操作不成功,则解除对该虚拟区间的页面映射,这是由zap_page_range()函数完成的。

这里要说明的是,文件到虚存的映射仅仅是建立了一种映射关系,也就是说,虚存页面到物理页面之间的映射还没有建立。当某个可执行映象映射到进程虚拟内存中并开始执行时,因为只有很少一部分虚拟内存区间装入到了物理内存,可能会遇到所访问的数据不在物理内存。这时,处理器将向Linux报告一个页故障及其对应的故障原因,于是就用到了请页机制。

请页机制

Linux采用请页机制来节约内存,它仅仅把当前正在执行的程序要使用的虚拟页(少量一部分)装入内存。当需要访问尚未装入物理内存的虚拟内存区域时,处理器将向Linux报告一个页故障及其对应的故障原因。本节将主要介绍arch/i386/mm/fault.c中的页故障处理函数do_page_fault,为了突出主题,我们将分析代码中的主要部分。

页故障的产生

页故障的产生有 3 种原因。

  1. 一是程序出现错误,例如向随机物理内存中写入数据,或页错误发生在TASK_SIZE(3G)的范围外,这些情况下,虚拟地址无效,Linux将向进程发送SIGSEGV信号并终止进程的运行。
  2. 另一种情况是,虚拟地址有效,但其所对应的页当前不在物理内存中,即缺页错误,这时,操作系统必须从磁盘映像或交换文件(此页被换出)中将其装入物理内存。
  3. 最后一种情况是,要访问的虚地址被写保护,即保护错误,这时,操作系统必须判断:如果是用户进程正在写当前进程的地址空间,则发SIGSEGV信号并终止进程的运行;如果错误发生在一旧的共享页上时,则处理方法有所不同,也就是要对这一共享页进行复制,这就是我们后面要讲的写时复制(Copy On Write简称COW)技术。

有关页错误的发生次数的信息可在目录proc/stat下找到。

页错误的定位

页错误的定位既包含虚拟地址的定位,也包含被调入页在交换文件(swapfile)或在可执行映象中的定位。

具体地说,在一个进程访问一个无效页表项时,处理器产生一个陷入并报告一个页错误,它描述了页错误发生的虚地址和访问类型,这些类型通过页的错误码error_code中的前 3位来判别 ,具体如下:

  • bit 0 == 0 means no page found, 1 means protection fault
  • bit 1 == 0 means read, 1 means write
  • bit 2 == 0 means kernel, 1 means user-mode。

也就是说,如果第 0 位为 0,则错误是由访问一个不存在的页引起的(页表的表项中·present`标志为 0),否则,如果第 0 位为 1,则错误是由无效的访问权所引起的;如果第 1位为 0,则错误是由读访问或执行访问所引起,如果为 1,则错误是由写访问所引起的;如果第 2 位为 0,则错误发生在处理器处于内核态时,否则,错误发生在处理器处于用户态时。

页错误的线性地址被存于CR2寄存器,操作系统必须在vm_area_struct中找到页错误发生时页的虚拟地址,下面通过do_page_fault()中的一部分源代码来说明这个问题:

1
2
3
/* CR2 中包含有最新的页错误发生时的虚拟地址*/
__asm__("movl %%cr2,%0":"=r" (address));
vma = find_vma(current, address);

如果没找到,则说明访问了非法虚地址,Linux会发信号终止进程(如果必要)。否则,检查页错误类型,如果是非法类型(越界错误,段权限错误等)同样会发信号终止进程,部分源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    vma = find_vma(current, address);
if (!vma)
goto bad_area;
if (vma->vm_start <= address)
goto good_area;
if (!(vma->vm_flags & VM_GROWSDOWN))
goto bad_area;
if (error_code & 4) { /*如是用户态进程*/
/* 不可访问堆栈空间*/
if (address + 32 < regs->esp)
goto bad_area;
}
if (expand_stack(vma, address))
goto bad_area;
bad_area: /* 用户态的访问*/
{
if (error_code & 4) {
current->tss.cr2 = address;
current->tss.error_code = error_code;
current->tss.trap_no = 14;
force_sig(SIGSEGV, current); /* 给当前进程发杀死信号*/
return;
}
die_if_kernel("Oops", regs, error_code); /*报告内核 */
do_exit(SIGKILL); /*强行杀死进程*/
}

进程地址空间中的缺页异常处理

对有效的虚拟地址,如果是缺页错误,Linux必须区分页所在的位置,即判断页是在交换文件中,还是在可执行映像中。为此,Linux通过页表项中的信息区分页所在的位置。如果该页的页表项是无效的,但非空,则说明该页处于交换文件中,操作系统要从交换文件装入页。对于有效的虚拟地址addressdo_page_fault()转到good_area标号处的语句执行:

1
2
3
4
5
6
7
8
9
good_area:
write = 0;
if (error_code & 2) { /* 写访问 */
if (!(vma->vm_flags & VM_WRITE))
goto bad_area;
write++;
} else /* 读访问 */
if (error_code & 1 || !(vma->vm_flags & (VM_READ | VM_EXEC)))
goto bad_area;

如果错误由写访问引起,函数检查这个虚拟区是否可写。如果不可写,跳到bad_area代码处;如果可写,把write局部变量置为 1。

如果错误由读或执行访问引起,函数检查这一页是否已经存在于物理内存中。如果在,错误的发生就是由于进程试图访问用户态下的一个有特权的页面(页面的User/Supervisor标志被清除),因此函数跳到bad_area代码处(实际上这种情况从不发生,因为内核根本不会给用户进程分配有特权的页面)。如果不存在物理内存,函数还将检查这个虚拟区是否可读或可执行。

如果这个虚拟区的访问权限与引起错误的访问类型相匹配,则调用handle_mm_fault()函数:

1
2
3
4
5
6
7
8
if (!handle_mm_fault(tsk, vma, address, write)) {
tsk->tss.cr2 = address;
tsk->tss.error_code = error_code;
tsk->tss.trap_no = 14;
force_sig(SIGBUS, tsk);
if (!(error_code & 4)) /* 内核态 */
goto no_context;
}

如果handle_mm_fault()函数成功地给进程分配一个页面,则返回 1;否则返回一个适当的错误码,以便do_page_fault()函数可以给进程发送SIGBUS信号。

handle_mm_fault()函数有 4 个参数:

  • tsk指向错误发生时正在CPU上运行的进程;
  • vma指向引起错误的虚拟地址所在虚拟区;
  • address为引起错误的虚拟地址;
  • write:如果tsk试图向address写,则置为 1,如果tsk试图读或执行address,则置为 0。

handle_mm_fault()函数首先检查用来映射address的页中间目录和页表是否存在。即使address属于进程的地址空间,但相应的页表可能还没有分配,因此,在做别的事情之前首先执行分配页目录和页表的任务:

1
2
3
4
5
6
7
pgd = pgd_offset(vma->vm_mm, address);
pmd = pmd_alloc(pgd, address);
if (!pmd)
return -1;
pte = pte_alloc(pmd, address);
if (!pte)
return -1;

pgd_offset()宏计算出address所在页在页目录中的目录项指针。如果有中间目录(i386不起作用),调用pmd_alloc()函数分配一个新的中间目录。然后,如果需要,调用pte_alloc()函数分配一个新的页表。如果这两步都成功,pte局部变量所指向的页表表项就是引用address的表项。然后调用handle_pte_fault()函数检查address地址所对应的页表表项:

1
return handle_pte_fault(tsk, vma, address, write_access, pte);

handle_pte_fault()函数决定怎样给进程分配一个新的页面。如果被访问的页不存在,也就是说,这个页还没有被存放在任何一个页面中,那么,内核分配一个新的页面并适当地初始化。这种技术称为请求调页。如果被访问的页存在但是被标为只读,也就是说,它已经被存放在一个页面中,那么,内核分配一个新的页面,并把旧页面的数据拷贝到新页面来初始化它的内容。这种技术称为写时复制。

请求调页

请求调页指的是一种动态内存分配技术,它把页面的分配推迟到不能再推迟为止,也就是说,一直推迟到进程要访问的页不在物理内存时为止,由此引起一个缺页错误。

对于全局分配(一开始就给进程分配所需要的全部页面,直到程序结束才释放这些页面)来说,请求调页是首选的,因为它增加了系统中的空闲页面的平均数,从而更好地利用空闲内存。从另一个观点来看,在内存总数保持不变的情况下,请求调页从总体上能使系统有更大的吞吐量。

为这一切优点付出的代价是系统额外的开销:由请求调页所引发的每个“缺页”错误必须由内核处理,这将浪费CPU的周期。幸运的是,局部性原理保证了一旦进程开始在一组页上运行,在接下来相当长的一段时间内它会一直停留在这些页上而不去访问其他的页:这样我们就可以认为“缺页”错误是一种稀有事件。

基于以下原因,被寻址的页可以不在主存中。

  1. 进程永远也没有访问到这个页。内核能够识别这种情况,这是因为页表相应的表项被填充为 0,也就是说,pte_none宏返回 1。
  2. 进程已经访问过这个页,但是这个页的内容被临时保存在磁盘上。内核能够识别这种情况,这是因为页表相应表项没被填充为 0(然而,由于页面不存在物理内存中,present为 0)。

handle_pte_fault()函数通过检查与address相关的页表表项来区分这两种情况:

1
2
3
4
5
6
entry = *pte;
if (!pte_present(entry)) {
if (pte_none(entry))
return do_no_page(tsk, vma, address, write_access, pte);
return do_swap_page(tsk, vma, address, pte, entry, write_access);
}

我们将在交换机制一节检查页被保存到磁盘上的这种情况(do_swap_page()函数)。在其他情况下,当页从未被访问时则调用do_no_page()函数。有两种方法装入所缺的页,这取决于这个页是否被映射到磁盘文件。该函数通过检查vma虚拟区描述符的nopage域来确定这一点,如果页与文件建立起了映射关系,则nopage域就指向一个把所缺的页从磁盘装入到RAM的函数。因此,可能的情况如下所述。

  1. vma->vm_ops->nopage域不为NULL。在这种情况下,某个虚拟区映射一个磁盘文件,nopage域指向从磁盘读入的函数。这种情况涉及到磁盘文件的低层操作。
  2. 或者vm_ops域为NULL,或者vma->vm_ops->nopage域为NULL。在这种情况下,虚拟区没有映射磁盘文件,也就是说,它是一个匿名映射。因此,do_no_page()调用do_anonymous_page()函数获得一个新的页面:
1
2
if (!vma->vm_ops || !vma->vm_ops->nopage)
return do_anonymous_page(tsk, vma, page_table, write_access);

do_anonymous_page()函数分别处理写请求和读请求:

1
2
3
4
5
6
7
8
9
    if (write_access) {
page = __get_free_page(GFP_USER);
memset((void *)(page), 0, PAGE_SIZE)
entry = pte_mkwrite(pte_mkdirty(mk_pte(page, vma->vm_page_prot)));
vma->vm_mm->rss++;
tsk->min_flt++;
set_pte(pte, entry);
return 1;
}

当处理写访问时,该函数调用__get_free_page()分配一个新的页面,并利用memset宏把新页面填为 0。然后该函数增加tskmin_flt域以跟踪由进程引起的次级缺页(这些缺页只需要一个新页面)的数目,再增加进程的内存区结构vma->vm_mmrss域以跟踪分配给进程的页面数目。然后页表相应的表项被设为页面的物理地址,并把这个页面标记为可写和脏两个标志。

相反,当处理读访问时,页的内容是无关紧要的,因为进程正在对它进行第一次寻址。给进程一个填充为 0 的页要比给它一个由其他进程填充了信息的旧页更为安全。Linux在请求调页方面做得更深入一些。没有必要立即给进程分配一个填充为零的新页面,由于我们也可以给它一个现有的称为零页的页,这样可以进一步推迟页面的分配。零页在内核初始化期间被静态分配,并存放在empty_zero_page变量中(一个有 1024 个长整数的数组,并用 0填充);它存放在第六个页面中(从物理地址 0x00005000 开始),并且可以通过ZERO_PAGE宏来引用。因此页表表项被设为零页的物理地址:

1
2
3
entry = pte_wrprotect(mk_pte(ZERO_PAGE, vma->vm_page_prot));
set_pte(pte, entry);
return 1;

由于这个页被标记为不可写,如果进程试图写这个页,则写时复制机制被激活。当且仅当在这个时候,进程才获得一个属于自己的页并对它进行写。这种机制在下一部分进行描述。

写时复制

写时复制技术最初产生于UNIX系统,用于实现一种傻瓜式的进程创建:当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要:

  • 为子进程的页表分配页面;
  • 为子进程的页分配页面;
  • 初始化子进程的页表;
  • 把父进程的页复制到子进程相应的页中。

写时复制:父进程和子进程共享页面而不是复制页面。然而,只要页面被共享,它们就不能被修改。无论父进程和子进程何时试图写一个共享的页面,就产生一个错误,这时内核就把这个页复制到一个新的页面中并标记为可写。原来的页面仍然是写保护的:当其他进程试图写入时,内核检查写进程是否是这个页面的唯一属主;如果是,它把这个页面标记为对这个进程是可写的。

Page结构的count域用于跟踪共享相应页面的进程数目。只要进程释放一个页面或者在它上面执行写时复制,它的count域就递减;只有当count变为NULL时,这个页面才被释放。

现在我们讲述Linux怎样实现写时复制(COW)。当handle_pte_fault()确定“缺页”错误是由请求写一个页面所引起的时(这个页面存在于内存中且是写保护的):

1
2
3
4
5
6
7
8
9
10
11
12
13
if (pte_present(pte)) {
entry = pte_mkyoung(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
if (write_access) {
if (!pte_write(entry))
return do_wp_page(tsk, vma, address, pte);
entry = pte_mkdirty(entry);
set_pte(pte, entry);
flush_tlb_page(vma, address);
}
return 1;
}

首先,调用pte_mkyoung()set_pte()函数来设置引起错误的页所对应页表项的访问位。这个设置使页“年轻”并减少它被交换到磁盘上的机会。如果错误由违背写保护而引起的,handle_pte_fault()返回由do_wp_page()函数产生的值;否则,则已检测到某一错误情况(例如,用户态地址空间中的页,其User/Supervisor标志为 0),且函数返回1。

do_wp_page()函数首先把page_table参数所引用的页表表项装入局部变量pte,然后再获得一个新页面:

1
2
pte = *page_table;
new_page = __get_free_page(GFP_USER);

由于页面的分配可能阻塞进程,因此,一旦获得页面,这个函数就在页表表项上执行下面的一致性检查:

  • 当进程等待一个空闲的页面时,这个页是否已经被交换出去(pte*page_table的值不相同);
  • 这个页是否已不在物理内存中(页表表项中页的Present标志为 0);
  • 页现在是否可写(页项中页的Read/Write标志为 1)。

如果以上情况中的任意一个发生,do_wp_page()释放以前所获得的页面,并返回 1。现在,函数更新次级缺页的数目,并把引起错误的页的页描述符指针保存到page_map局部变量中。

1
2
tsk->min_flt++;
page_map = mem_map + MAP_NR(old_page);

接下来,函数必须确定是否必须真的把这个页复制一份。如果仅有一个进程使用这个页,就无须应用写时复制技术,而且进程应该能够自由地写这个页。因此,这个页面被标记为可写,这样当试图写入的时候就不会再次引起“缺页”错误,以前分配的新的页面也被释放,函数结束并返回 1。这种检查是通过读取page结构的count域而进行的:

1
2
3
4
5
6
7
if (page_map->count == 1) {
set_pte(page_table, pte_mkdirty(pte_mkwrite(pte)));
flush_tlb_page(vma, address);
if (new_page)
free_page(new_page);
return 1;
}

相反,如果这个页面由两个或多个进程所共享,函数把旧页面(old_page)的内容复制到新分配的页面(new_page)中:

1
2
3
4
5
6
7
8
9
if (old_page == ZERO_PAGE)
memset((void *) new_page, 0, PAGE_SIZE);
else
memcpy((void *) new_page, (void *) old_page, PAGE_SIZE);
set_pte(page_table, pte_mkwrite(pte_mkdirty(
mk_pte(new_page, vma->vm_page_prot))));
flush_tlb_page(vma, address);
__free_page(page_map);
return 1;

如果旧页面是零页面,就使用memset宏把新的页面填充为 0。否则,使用memcpy宏复制页面的内容。不要求一定要对零页作特殊的处理,但是特殊处理确实能够提高系统的性能,因为它使用很少的地址而保护了微处理器的硬件高速缓存。

然后,用新页面的物理地址更新页表的表项,并把新页面标记为可写和脏。最后,函数调用__free_pages()减小对旧页面的引用计数。

对本节的几点说明

  1. 通过fork()建立进程,开始时只有一个页目录和一页左右的可执行页,于是缺页异常会频繁发生。
  2. 虚拟地址映射到物理地址,只有在请页时才完成,这时要建立页表和更新页表(页表是动态建立的)。页表不可被换出,不记年龄,它们被内核中保留,只有在exit时清除。
  3. 在处理页故障的过程中,因为要涉及到磁盘访问等耗时操作,因此操作系统会选择另外一个进程进入执行状态,即进行新一轮调度。

交换机制

当物理内存出现不足时,Linux内存管理子系统需要释放部分物理内存页面。这一任务由内核的交换守护进程kswapd完成,该内核守护进程实际是一个内核线程,它在内核初始化时启动,并周期地运行。它的任务就是保证系统中具有足够的空闲页面,从而使内存管理子系统能够有效运行。

交换的基本原理

在Linux中,我们把用作交换的磁盘空间叫做交换文件或交换区。在Linux中,交换的单位是页面而不是进程。尽管交换的单位是页面,但交换还是要付出一定的代价,尤其是时间的代价。这里要说明的是,页面交换是不得已而为之,例如在时间要求比较紧急的实时系统中,是不宜采用页面交换机制的,因为它使程序的执行在时间上有了较大的不确定性。

在页面交换中,必须考虑 4 个主要问题:

  • 哪种页面要换出;
  • 如何在交换区中存放页面;
  • 如何选择被交换出的页面;
  • 何时执行页面换出操作。

哪种页面被换出

可以把用户空间中的页面按其内容和性质分为以下几种:

  • 进程映像所占的页面,包括进程的代码段、数据段、堆栈段以及动态分配的“存储堆”;
    • 进程的代码段数据段所占的内存页面可以被换入换出,但堆栈所占的页面一般不被换出,因为这样可以简化内核的设计。
  • 通过系统调用mmap()把文件的内容映射到用户空间;
    • 这些页面所使用的交换区就是被映射的文件本身。
  • 进程间共享内存区。
    • 其页面的换入换出比较复杂。

与此相对照,映射到内核空间中的页面都不会被换出。具体来说,内核代码和内核中的全局量所占的内存页面既不需要分配(启动时被装入),也不会被释放,这部分空间是静态的。除此之外,内核在执行过程中使用的页面要经过动态分配,但永驻内存,此类页面根据其内容和性质可以分为两类。

  1. 内核调用kmalloc()vmalloc()为内核中临时使用的数据结构而分配的页于是立即释放。但是,由于一个页面中存放有多个同种类型的数据结构,所以要到整个页面都空闲时才把该页面释放。
  2. 内核中通过调用alloc_pages(),为某些临时使用和管理目的而分配的页面,例如,每个进程的内核栈所占的两个页面、从内核空间复制参数时所使用的页面等。这些页面也是一旦使用完毕便无保存价值,所以立即释放。

在内核中还有一种页面,虽然使用完毕,但其内容仍有保存价值,因此,并不立即释放。这类页面“释放”之后进入一个LRU队列,经过一段时间的缓冲让其“老化”。如果在此期间又要用到其内容了,就又将其投入使用,否则便继续让其老化,直到条件不再允许时才加以回收。这种用途的内核页面大致有以下这些:

  • 文件系统中用来缓冲存储一些文件目录结构dentry的空间;
  • 文件系统中用来缓冲存储一些索引节点inode的空间;
  • 用于文件系统读/写操作的缓冲区。

如何在交换区中存放页面

交换区也被划分为块,每个块的大小正好等于一页,我们把交换区中的一块叫做一个页插槽(Page Slot),意思是说,把一个物理页面插入到一个插槽中。当进行换出时,内核尽可能把换出的页放在相邻的插槽中,从而减少在访问交换区时磁盘的寻道时间。这是高效的页面置换算法的物质基础。

如果系统使用了多个交换区,事情就变得更加复杂了。快速交换区(也就是存放在快速磁盘中的交换区)可以获得比较高的优先级。当查找一个空闲插槽时,要从优先级最高的交换区中开始搜索。如果优先级最高的交换区不止一个,为了避免超负荷地使用其中一个,应该循环选择相同优先级的交换区。如果在优先级最高的交换区中没有找到空闲插槽,就在优先级次高的交换区中继续进行搜索,依此类推。

如何选择被交换出的页面

页面交换是非常复杂的,其主要内容之一就是如何选择要换出的页面,我们以循序渐进的方式来讨论页面交换策略的选择。

  • 策略一,需要时才交换。每当缺页异常发生时,就给它分配一个物理页面。如果发现没有空闲的页面可供分配,就设法将一个或多个内存页面换出到磁盘上,从而腾出一些内存页面来。
  • 策略二,系统空闲时交换。与策略一相比较,这是一种积极的交换策略,也就是,在系统空闲时,预先换出一些页面而腾出一些内存页面,从而在内存中维持一定的空闲页面供应量,使得在缺页中断发生时总有空闲页面可供使用。至于换出页面的选择,一般都采用LRU(最近最少使用)算法。
  • 策略三,换出但并不立即释放。当系统挑选出若干页面进行换出时,将相应的页面写入磁盘交换区中,并修改相应页表中页表项的内容(把present标志位置为 0),但是并不立即释放,而是将其page结构留在一个缓冲(Cache)队列中,使其从活跃(Active)状态转为不活跃(Inactive)状态。至于这些页面的最后释放,要推迟到必要时才进行。
  • 策略四,把页面换出推迟到不能再推迟为止。实际上,策略三还有值得改进的地方。首先在换出页面时不一定要把它的内容写入磁盘。如果一个页面自从最近一次换入后并没有被写过(如代码),那么这个页面是“干净的”,就没有必要把它写入磁盘。其次,即使“脏”页面,也没有必要立即写出去,可以采用策略三。至于“干净”页面,可以一直缓冲到必要时才加以回收,因为回收一个“干净”页面花费的代价很小。

下面对物理页面的换入/换出给出一个概要描述,这里涉及到前面介绍的page结构和free_area结构。

  1. 释放页面。如果一个页面变为空闲可用,就把该页面的page结构链入某个页面管理区(Zone)的空闲队列free_area,同时页面的使用计数count减 1。
  2. 分配页面。调用__alloc_pages()__get_free_page()从某个空闲队列分配内存页面,并将其页面的使用计数count置为 1。
  3. 活跃状态。已分配的页面处于活跃状态,该页面的数据结构page通过其队列头结构lru链入活跃页面队列active_list,并且在进程地址空间中至少有一个页与该页面之间建立了映射关系。
  4. 不活跃“脏”状态。处于该状态的页面其page结构通过其队列头结构lru链入不活跃“脏”页面队列inactive_dirty_list,并且原则是任何进程的页面表项不再指向该页面,也就是说,断开页面的映射,同时把页面的使用计数count减 1。
  5. 将不活跃“脏”页面的内容写入交换区,并将该页面的page结构从不活跃“脏”页面队列inactive_dirty_list转移到不活跃“干净”页面队列,准备被回收。
  6. 不活跃“干净”状态。页面page结构通过其队列头结构lru链入某个不活跃“干净”页面队列,每个页面管理区都有个不活跃“干净”页面队列inactive_clean_list
  7. 如果在转入不活跃状态以后的一段时间内,页面又受到访问,则又转入活跃状态并恢复映射。
  8. 当需要时,就从“干净”页面队列中回收页面,也就是说或者把页面链入到空闲队列,或者直接进行分配。

以上是页面换入/换出及回收的基本思想,实际的实现代码还要更复杂一些。

何时执行页面换出操作

Linux内核定期地检查系统内的空闲页面数是否小于预定义的极限,一旦发现空闲页面数太少,就预先将若干页面换出,以减轻缺页异常发生时系统所承受的负担。为此,Linux内核设置了一个专伺定期将页面换出的守护进程kswapd

页面交换守护进程kswapd

从原理上说,kswapd相当于一个进程,它有自己的进程控制块task_struct结构。与普通进程相比,kswapd有其特殊性。首先,它没有自己独立的地址空间,所以在近代操作系统理论中把它称为“线程”以与进程相区别。那么,kswapd的地址空间实际上就是内核空间。其次,它的代码是静态地链接在内核中的,因此,可以直接调用内核中的各种子程序和函数。

kswapd的源代码基本上都在mm/vmscan.c中,图 6.19 给出了kswapd中与交换有关的主要函数调用关系。

kswapd()

在Linux 2.4.10 以后的版本中对kswapd()的实现代码进行了模块化组织,可读性大大加强,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int kswapd(void *unused)
{
struct task_struct *tsk = current;
DECLARE_WAITQUEUE(wait, tsk);
daemonize(); /*内核线程的初始化*/
strcpy(tsk->comm, "kswapd");
sigfillset(&tsk->blocked); /*把进程PCB中的阻塞标志位全部置为 1*/

/*
* Tell the memory management that we're a "memory allocator",
* and that if we need more memory we should get access to it
* regardless (see "__alloc_pages()"). "kswapd" should
* never get caught in the normal page freeing logic.
*
* (Kswapd normally doesn't need memory anyway, but sometimes
* you need a small amount of memory in order to be able to
* page out something else, and this flag essentially protects
* us from recursively trying to free more memory as we're
* trying to free the first piece of memory in the first place).
*/
tsk->flags |= PF_MEMALLOC; /*这个标志表示给`kswapd`要留一定的内存*/
/*
* Kswapd main loop.
*/
for (;;) {
__set_current_state(TASK_INTERRUPTIBLE);
add_wait_queue(&kswapd_wait, &wait); /*把kswapd加入等待队列*/
mb(); /*增加一条汇编指令*/
if (kswapd_can_sleep()) /*检查调度标志是否置位*/
schedule(); /*调用调度程序*/
_set_current_state(TASK_RUNNING); /*让`kswapd`处于就绪状态*/
remove_wait_queue(&kswapd_wait, &wait); /*把`kswapd`从等待队列删除*/
/*
* If we actually get into a low-memory situation,
* the processes needing more memory will wake us
* up on a more timely basis.
*/
kswapd_balance(); /* kswapd的核心函数,请看后面内容*/
run_task_queue(&tq_disk); /*运行tq_disk队列中的例程*/
}
}

kswapd是内存管理中唯一的一个线程,其创建如下:

1
2
3
4
5
6
7
static int __init kswapd_init(void)
{
printk("Starting kswapd\n");
swap_setup();
kernel_thread(kswapd, NULL, CLONE_FS | CLONE_FILES | CLONE_SIGNAL);
return 0;
}

然后,在内核启动时由模块的初始化例程调用kswapd_init

1
module_init(kswapd_init)

从上面的介绍可以看出,kswapd成为内核的一个线程,其主循环是一个无限循环。循环一开始,把它加入等待队列,但如果调度标志为 1,就执行调度程序,紧接着就又把它从等待队列删除,将其状态变为就绪。只要调度程序再次执行,它就会得到执行,如此周而复始进行下去。

kswapd_balance()函数

在本章的初始化一节中,我们介绍了物理内存的 3 个层次,即存储节点、管理区和页面。所谓平衡就是对页面的释放要均衡地在各个存储节点、管理区中进行,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
static void kswapd_balance(void)
{
int need_more_balance;
pg_data_t * pgdat;
do {
need_more_balance = 0;
pgdat = pgdat_list;
do {
need_more_balance |= kswapd_balance_pgdat(pgdat);
} while ((pgdat = pgdat->node_next));
} while (need_more_balance);
}

这个函数比较简单,主要是对每个存储节点进行扫描。然后又调用kswapd_balance_pgdat()对每个管理区进行扫描:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int kswapd_balance_pgdat(pg_data_t * pgdat)
{
int need_more_balance = 0, i;
zone_t * zone;
for (i = pgdat->nr_zones-1; i >= 0; i--) {
zone = pgdat->node_zones + i;
if (unlikely(current->need_resched))
schedule();
if (!zone->need_balance)
continue;
if (!try_to_free_pages(zone, GFP_KSWAPD, 0)) {
zone->need_balance = 0;
__set_current_state(TASK_INTERRUPTIBLE);
schedule_timeout(HZ);
continue;
}
if (check_classzone_need_balance(zone))
need_more_balance = 1;
else
zone->need_balance = 0;
}

其中,最主要的函数是try_to_free_pages(),能否调用这个函数取决于平衡标志need_balance是否为 1,也就是说看某个管理区的空闲页面数是否小于最高警戒线,这是由check_classzone_need_balance()函数决定的。当某个管理区的空闲页面数小于其最高警戒线时就调用try_to_free_pages()

try_to_free_pages()

该函数代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int try_to_free_pages(zone_t *classzone, unsigned int gfp_mask, unsigned int order)
{
int priority = DEF_PRIORITY;
int nr_pages = SWAP_CLUSTER_MAX;
gfp_mask = pf_gfp_mask(gfp_mask);
do {
nr_pages = shrink_caches(classzone, priority, gfp_mask, nr_pages);
if (nr_pages <= 0)
return 1;
} while (--priority);
/*
* Hmm.. Cache shrink failed - time to kill something?
* Mhwahahhaha! This is the part I really like. Giggle.
*/
out_of_memory();
return 0;
}

其中的优先级表示对队列进行扫描的长度,缺省的优先级DEF_PRIORITY为 6(最低优先级)。假定队列长度为L,优先级 6 就表示要扫描的队列长度为L/26,所以这个循环至少循环 6 次。nr_pages为要换出的页面数,其最大值SWAP_CLUSTER_MAX为 32。其中主要调用的函数为shrink_caches()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static int shrink_caches(zone_t * classzone, int priority, unsigned int gfp_mask, int nr_pages)
{
int chunk_size = nr_pages;
unsigned long ratio;
nr_pages -= kmem_cache_reap(gfp_mask);
if (nr_pages <= 0)
return 0;
nr_pages = chunk_size;
/* try to keep the active list 2/3 of the size of the cache */
ratio = (unsigned long) nr_pages * nr_active_pages / ((nr_inactive_pages + 1) * 2);
refill_inactive(ratio);
nr_pages = shrink_cache(nr_pages, classzone, gfp_mask, priority);
if (nr_pages <= 0)
return 0;
shrink_dcache_memory(priority, gfp_mask);
shrink_icache_memory(priority, gfp_mask);
#ifdef CONFIG_QUOTA
shrink_dqcache_memory(DEF_PRIORITY, gfp_mask);
#endif
return nr_pages;
}

其中kmem_cache_reap()函数“收割(reap)”由slab机制管理的空闲页面。如果从slap回收的页面数已经达到要换出的页面数nr_pages,就不用从其他地方进行换出。refill_inactive()函数把活跃队列中的页面移到非活跃队列。shrink_cache()函数把一个“洗净”且未加锁的页面移到非活跃队列,以便该页能被尽快释放。

此外,除了从各个进程的用户空间所映射的物理页面中回收页面外,还调用shrink_dcache_memory()shrink_icache_memory()shrink_dqcache_memory()回收内核数据结构所占用的空间。

页面置换

到底哪些页面会被作为后选页以备换出,这是由swap_out()shrink_cache()一起完成的。这个过程比较复杂,这里我们抛开源代码,以理清思路为目标。

shrink_cache()要做很多换出的准备工作。它关注两个队列:“活跃的” LRU队列和“非活跃的” FIFO 队列,每个队列都是struct page形成的链表。该函数的代码比较长,我们把它所做的工作概述如下:

  • 把引用过的页面从活跃队列的队尾移到该队列的队头(实现LRU策略);
  • 把未引用过的页面从活跃队列的队尾移到非活跃队列的队头(为准备换出而排队);
  • 把脏页面安排在非活跃队列的队尾准备写到磁盘;
  • 从非活跃队列的队尾恢复干净页面(写出的页面就成为干净的)。

交换空间的数据结构

Linux支持多个交换文件或设备,它们将被swaponswapoff系统调用来打开或关闭。每个交换文件或设备都可用swap_info_struct结构来描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct swap_info_struct {
unsigned int flags;
kdev_t swap_device;
spinlock_t sdev_lock;
struct dentry * swap_file;
struct vfsmount *swap_vfsmnt;
unsigned short * swap_map;
unsigned int lowest_bit;
unsigned int highest_bit;
unsigned int cluster_next;
unsigned int cluster_nr;
int prio; /* swap priority */
int pages;
unsigned long max;
int next; /* next entry on swap list */
};
extern int nr_swap_pages;

flags域(SWP_USEDSWP_WRITEOK)用作控制访问交换文件。当swapoff被调用(为了取消一个文件)时,SWP_WRITEOK置成off,使在文件中无法分配空间。如果swapon加入一个新的交换文件时,SWP_USED被置位。这里还有一静态变量(nr_swapfiles)来记录当前活动的交换文件数。

lowest_bithighest_bit表明在交换文件中空闲范围的边界,这是为了快速寻址。当用户程序mkswap初始化交换文件或设备时,在文件的第一个页插槽的前 10 个字节,有一个包含有位图的标志,在位图里初始化为 0,代表坏的页插槽,1 代表相关页插槽是空闲的。

当用户程序调用swapon()时,有一页被分配给swap_mapswap_map为在交换文件中每一个页插槽保留了一个字节,0 代表可用页插槽,128 代表不可用页插槽。它被用于记下交换文件中每一页插槽上的swap请求。内存中的一页被换出时,调用get_swap_page()会得到一个一个记录换出位置的索引,然后在页表项中回填( 1~ 31 位)此索引。这是为了在发生在缺页异常时进行处理(do_no_page)。索引的高 7 位给定交换文件,后 24 位给定设备中的页插槽号。

另外函数swap_duplicate()copy_page_tables()调用来实现子进程在fork()时继承被换出的页面,这里要增加域swap_map中此页面的count值,任何进程访问此页面时,会换入它的独立的拷贝。

swap_free()减少域swap_map中的count值,如果count减到 0 时,则这页面又可再次分配(get_swap_page),在把一个换出页面调入(swap_in)内存时或放弃一个页面时(free_one_table)调用swap_free()。相关函数在文件filemap.c中。

交换空间的应用

建立交换空间

作为交换空间的交换文件实际就是通常的文件,但文件的扇区必须是连续的,即文件中必须没有“洞”,另外,交换文件必须保存在本地硬盘上。

由于内核要利用交换空间进行快速的内存页面交换,因此,它不进行任何文件扇区的检查,而认为扇区是连续的。由于这一原因,交换文件不能包含洞。可用下面的命令建立无洞的交换文件:

1
2
3
$ dd if=/dev/zero of=/extra-swap bs=1024 count=2048
2048+0 records in
2048+0 records out

上面的命令建立了一个名称为extra-swap,大小为2048KB的交换文件。

交换分区和其他分区也没有什么不同,可像建立其他分区一样建立交换分区。但该分区不包含任何文件系统。

建立交换文件或交换分区之后,需要在文件或分区的开头写入签名,写入的签名实际是由内核使用的一些管理信息。写入签名的命令为mkswap,如下所示:

1
2
$ mkswap /extra-swp 2048
Setting up swapspace, size = 2088960 bytes

这时,新建立的交换空间尚未开始使用。使用mkswap命令时必须小心,因为该命令不会检查文件或分区内容,因此极有可能覆盖有用的信息,或破坏分区上的有效文件系统信息。

Linux内存管理子系统将每个交换空间的大小限制在 127MB (实际为 (4096.10)84096 = 133890048 Byte = 127.6875MB)。可以在系统中同时使用 16 个交换空间,从而使交换空间总量达到 2GB。

使用交换空间

利用swapon命令可将经过初始化的交换空间投入使用。如下所示:

1
$ swapon /extra-swap

如果在/etc/fstab文件中列出交换空间,则可自动将交换空间投入使用:

1
2
/dev/hda5 none swap sw 0 0
/extra-swap none swap sw 0 0

实际上,启动脚本会运行swapon –a命令,从而将所有出现在/etc/fstab文件中的交换空间投入使用。

利用free命令,可查看交换空间的使用。如下所示:

1
2
3
4
5
$ free
total used free shared buffers
Mem: 15152 14896 256 12404 2528
-/+ buffers: 12368 2784
Swap: 32452 6684 25768

该命令输出的第一行Mem显示了系统中物理内存的使用情况。total列显示的是系统中的物理内存总量;used列显示正在使用的内存数量;free列显示空闲的内存量;shared列显示由多个进程共享的内存量,该内存量越多越好;buffers显示了当前的缓冲区高速缓存的大小。

输出的最后一行Swap显示了有关交换空间的类似信息。如果该行的内容均为 0,表明当前没有活动的交换空间。

利用top命令或查看/proc文件系统中的/proc/meminfo文件可获得相同的信息。利用swapoff命令可移去使用中的交换空间。但该命令应只用于临时交换空间,否则有可能造成系统崩溃。

swapoff –a命令按照/etc/fstab文件中的内容移去所有的交换空间,但任何手工投入使用的交换空间保留不变。

分配交换空间

大多数人认为,交换空间的总量应该是系统物理内存量的两倍,实际上这一规则是不正确的,正确的交换空间大小应按如下规则确定。

  1. 估计需要的内存总量。运行想同时运行的所有程序,并利用freeps程序估计所需的内存总量,只需大概估计。
  2. 增加一些安全性余量。
  3. 减去已有的物理内存数量,然后将所得数据取整为`MB,这就是应当的交换空间大小。
  4. 如果得到的交换空间大小远远大于物理内存量,则说明需要增加物理内存数量,否则系统性能会因为过分的页面交换而下降。
  5. 当计算的结果说明不需要任何交换空间时,也有必要使用交换空间。Linux从性能的角度出发,会在磁盘空闲时将某些页面交换到交换空间中,以便减少必要时的交换时间。

另外,如果在不同的磁盘上建立多个交换空间,有可能提高页面交换的速度,这是因为某些硬盘驱动器可同时在不同的磁盘上进行读写操作。

缓存和刷新机制

Linux使用的缓存

不管在硬件设计还是软件设计中,高速缓存是获得高性能的常用手段。Linux使用了多种和内存管理相关的高速缓存。

缓冲区高速缓存

缓冲区高速缓存中包含了由块设备使用的数据缓冲区。这些缓冲区中包含了从设备中读取的数据块或写入设备的数据块。缓冲区高速缓存由设备标识号和块标号索引,因此可以快速找出数据块。如果数据能够在缓冲区高速缓存中找到,则系统就没有必要在物理块设备上进行实际的读操作。

内核为每个缓冲区维护很多信息以有助于缓和写操作,这些信息包括一个“脏(dirty)”位,表示内存中的缓冲区已被修改,必须写到磁盘;还包括一个时间标志,表示缓冲区被刷新到磁盘之前已经在内存中停留了多长时间。因为缓冲区的有关信息被保存在缓冲区首部,所以,这些数据结构连同用户数据本身的缓冲区都需要维护。

页面高速缓存

页面高速缓存是页面I/O操作访问数据所使用的磁盘高速缓存。页面高速缓存中一个页面的标识是通过文件的索引节点和文件中的偏移量达到的。与页面高速缓存有关的操作主要有 3 种:

  • 当访问的文件部分不在高速缓存中时增加一页面;
  • 当高速缓存变得太大时删除一页面;
  • 查找一个给定文件偏移量所在的页面。

交换高速缓存

只有修改后的(脏)页面才保存在交换文件中。修改后的页面写入交换文件后,如果该页面再次被交换但未被修改时,就没有必要写入交换文件,相反,只需丢弃该页面。交换高速缓存实际包含了一个页面表项链表,系统的每个物理页面对应一个页面表项。对交换出的页面,该页面表项包含保存该页面的交换文件信息,以及该页面在交换文件中的位置信息。

如果某个交换页面表项非零,则表明保存在交换文件中的对应物理页面没有被修改。如果这一页面在后续的操作中被修改,则处于交换缓存中的页面表项被清零。Linux需要从物理内存中交换出某个页面时,它首先分析交换缓存中的信息,如果缓存中包含该物理页面的一个非零页面表项,则说明该页面交换出内存后还没有被修改过,这时,系统只需丢弃该页面。

这里给出有关交换缓存的部分函数及功能:位于/linux/mm/swap_state.c中。初始化交换缓冲,设定大小,位置的函数:

1
extern unsigned long init_swap_cache(unsigned long, unsigned long);

显示交换缓冲信息的函数:

1
extern void show_swap_cache_info(void);

加入交换缓冲的函数:

1
int add_to_swap_cache(unsigned long index, unsigned long entry)

参数index是进入缓冲区的索引(index是索引表中的某一项),entry是‘页面表项’。

复制被换出的页面:

1
extern void swap_duplicate(unsigned long);

从缓冲区中移去某页面

1
delete_from_swap_cache(page_nr);

缓冲区高速缓存

Linux采用了缓冲区高速缓存机制,而不同于其他操作系统的“写透”方式,也就是说,当把一个数据写入文件时,内核将把数据写入内存缓冲区,而不是直接写入磁盘。

在这里要用到一个数据结构buffer_head,它是用来描述缓冲区的数据结构,缓冲区的大小一般要比页面尺寸小,所以一页面中可以包含数个缓冲区,同一页面中的缓冲区用链表连接。回忆一下页面结构page,其中有一个域buffer_head buffer就是用来指向缓冲区的。

由于使用了缓冲技术,因此有可能出现这种情况:写磁盘的命令已经返回,但实际的写入磁盘的操作还未执行。

在Linux系统中,除了传统的update守护进程之外,还有一个额外的守护进程dbflush,这一进程可频繁运行不完整的sync从而可避免有时由于sync命令的超负荷磁盘操作而造成的磁盘冻结,一般情况下,它们在系统引导时自动执行,且每隔 30s 执行一次任务。sync命令使用基本的系统调用sync()来实现。dbflush在Linux系统中由update启动。如果由于某种原因该进程僵死了,则内核会发送警告信息,这时需要手工启动该进程(/sbin/update)。

页面缓存的详细描述

经内存映射的文件每次只读取一页面内容,读取后的页面保存在页面缓存中,利用页面缓存,可提高文件的访问速度。


如图 6.20 所示,页面缓存由page_hash_table组成,它是一个mem_map_t(即struct page数据结构)的指针向量。页面缓存的结构是Linux内核中典型的哈希表结构。哈希线性表中的指针代表一个链表,该链表所包含的所有节点均具有相同的哈希值,在该链表中查找可访问到指定的数据。

在Linux页面缓存中,访问page_hash_table的索引由文件的VFS(虚拟文件系统)索引节点inode和内存页面在文件中的偏移量生成。

当系统要从内存映射文件中读取某一未加锁的页面时,就首先要用到函数:

1
find_page (struct inode * inode, unsigned long offset)

它完成如下工作。

首先是在“页面缓存”中查找,如果发现该页面保存在缓存中,则可以免除实际的文件读取,而只需从页面缓存中读取,这时,指向mm_map_t数据结构的指针被返回到页面故障的处理代码。部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/*函数page_hash()是从哈希表中找页面*/
for (page = page_hash(inode, offset); page ; page = page->next_hash)
{
if (page->inode != inode)
continue;
if (page->offset != offset)
continue;
/* 找到了特定页面 */
atomic_inc(&page->count);
set_bit(PG_referenced, &page->flags);/*设访问位*/
break;
}
return page;

如果该页面不在缓存中,则必须从实际的文件系统映像中读取页面,这时Linux内核首先分配物理页面然后从磁盘读取页面内容。

如果可能,Linux还会预先读取文件中下一页面内容到页面缓存中,而不等页面错误发生才去“请页面”,这样做是为了提高装入代码的速度(有关代码在filemap.c中,如generic_file_readahead()等函数)。这样,如果进程要连续访问页面,则下一页面的内容不必再次从文件中读取了,而只需从页面缓存中读取。

随着映像的读取和执行,页面缓存中的内容可能会增多,这时,Linux可移走不再需要的页面。当系统中可用的物理内存量变小时,Linux也会通过缩小页面缓存的大小而释放更多的物理内存页面。

有关页面缓存的函数

先看把读入的页面如何存于缓存,这要用到函数add_to_page_cache(),它完成把指定的“文件页面”记入页面缓存中。

1
2
3
4
5
6
7
8
9
10
static inline void add_to_page_cache(struct page * page,
struct inode * inode, unsigned long offset)
{
/*设置有关页面域,引用数,页面使用方式,页面在文件中的偏移 */
page->count++;
page->flags &= ~((1 << PG_uptodate) | (1 << PG_error));
page->offset = offset;
add_page_to_inode_queue(inode, page);/* 把页面加入inode节点队列*/
add_page_to_hash_queue(inode, page);/* 把页面加入哈唏表`page_hash_table[]*/
}

哈希表page_hash_table[]的定义:

1
extern struct page * page_hash_table[PAGE_HASH_SIZE];

下面是有关对哈希表操作的部分代码:

1
2
3
4
5
6
7
8
9
10
static inline void add_page_to_inode_queue(struct inode * inode, struct page * page)
{
struct page **p = &inode->i_pages;/*指向物理页面*/
inode->i_nrpages++;/*节点中调入内存的页面数目增 1*/
page->inode = inode; /*指向该页面来自的文件节点结构,相互连成链*/
page->prev = NULL;
if ((page->next = *p) != NULL)
page->next->prev = page;
*p = page;
}

把页面加入哈希表:

1
2
3
4
5
6
7
8
9
10
11
static inline void add_page_to_hash_queue(struct inode * inode, struct page * page)
{
struct page **p = &page_hash(inode,page->offset);
page_cache_size++; /*哈希表中记录的页面数目加 1*/
set_bit(PG_referenced, &page->flags);/*设置访问位*/
page->age = PAGE_AGE_VALUE; /*设缓存中的页面“年龄”为定值,为淘汰做准备*/
page->prev_hash = NULL;
if ((page->next_hash = *p) != NULL)
page->next_hash->prev_hash = page;
*p = page;
}

有关页面的刷新函数:

1
2
remove_page_from_hash_queue(page); /*从哈希表中去掉页面*/
remove_page_from_inode_queue(page); /*从 inode节点中去掉页面*/

翻译后援存储器(TLB)

页表的实现对虚拟内存系统效率是极为关键的。例如把一个寄存器的内容复制到另一个寄存器中的一条指令,在不使用分页时,只需访问内存一次取指令,而在使用分页时需要额外的内存访问去读取页表。而系统的运行速度一般是被CPU从内存中取得指令和数据的速率限制的,如果在每次访问内存时都要访问两次内存会使系统性能降低三分之二。

对这个问题的解决,有人提出了一个解决方案,这个方案基于这样的观察:大部分程序倾向于对较少的页面进行大量的访问。因此,只有一小部分页表项经常被用到,其他的很少被使用。

采取的解决办法是为计算机装备一个不需要经过页表就能把虚拟地址映射成物理地址的小的硬件设备,这个设备叫做TLB(翻译后援存储器,Translation Lookside Buffer),有时也叫做相联存储器(Associative Memory),如图 6.21 所示。它通常在MMU内部,条目的数量较少,在这个例子中是 6 个,80386 有 32 个。

每一个TLB寄存器的每个条目包含一个页面的信息:有效位、虚页面号、修改位、保护码和页面所在的物理页面号,它们和页面表中的表项一一对应,如表所示。

段号 虚页面号 页面框 保护 年龄 有效位
4 1 7 RW 5 1
8 7 16 RW 1 1
2 0 33 RX 4 1
4 4 72 RX 13 0
5 8 17 RW 2 1
2 7 34 RX 2 1

当一个虚地址被送到MMU翻译时,硬件首先把它和TLB中的所有条目同时(并行地)进行比较。如果它的虚页面号在TLB中,并且访问没有违反保护位,它的页面会直接从TLB中取出而不去访问页表;如果虚页面号在TLB中,但当前指令试图写一个只读的页面,这时将产生一个缺页异常,与直接访问页表时相同。

MMU发现在TLB中没有命中,它将随即进行一次常规的页表查找,然后从TLB中淘汰一个条目并把它替换为刚刚找到的页表项。因此如果这个页面很快再被用到的话,第 2 次访问时它就能在TLB中直接找到。在一个TLB条目被淘汰时,被修改的位被复制回在内存中的页表项,其他的值则已经在那里了。当TLB从页表装入时,所有的域都从内存中取得。必须明确在分页机制中,TLB中的数据和页表中的数据的相关性,不是由处理器进行维护,而是必须由操作系统来维护,高速缓存的刷新是通过装入处理器(80386)中的寄存器 CR3 来完成的。

这里提到的命中率,指一个页面在TBL中找到的概率。一般来说TLB的尺寸大可增加命中率,但会增加成本和软件的管理。所以一般都采用 8~64 个条目的数量。假如命中率是 0.85,访问内存时间是 120 纳秒,访TLB时间是 15 纳秒。那么访问时间是:0.85×(15+120)+(1-0.85)×(15+120+120)=153 纳秒。

刷新机制

软件管理TLB

在现代的一些RISC机中,几乎全部的这种页面管理工作都是由软件完成的。在这些机器中,TLB条目是由操作系统显式地装入,在TLB没有命中时,MMU不是到页表中找到并装入需要的页面信息,而是产生一个TLB故障把问题交给操作系统。操作系统必须找到页面,从TLB中淘汰一个条目,装入一个新的条目,然后重新启动产生异常(或故障)的指令。当然,所有这些都必须用很少指令完成,因为TLB不命中的频率远比页面异常大得多。

令人惊奇的是,如果TLB的尺寸取一个合理的较大值(比如 64 个条目)以减少不命中的频率,那么软件管理的TLB效率可能相当高。这里主要的收益是一个简单得多的MMU,它在CPU芯片上为高速缓存和其他能提高性能的部件让出了相当大的面积。

为了减少TLB的不命中率,操作系统有时可以用它的直觉来指出那些页面可能将被使用并把他们预装入TLB中。例如,当一个客户进程向位于同一台机器的服务器进程发出一个RPC请求时,服务器很可能即将运行。知道了这一点,在客户进程因执行RPC陷入时,系统就可以找到服务器的代码、数据、堆栈的页面,并在TLB中提前为他们建立映射,以避免TLB故障的发生。无论是硬件还是软件,处理TLB不命中的一般方法是对页表执行索引操作找出所引用的页面。用软件执行这个搜索的一个问题是保存页表的页面本身可能就不在TLB中,这将在处理过程中再一次引发一个TLB异常,这种异常可以通过保持一个大的(比如 4KB)TLB条目的软件高速缓存而得到减少,这个高速缓存保持在固定位置,它的页面总是保持在TLB中,操作系统通过首先检查软件高速缓存可以大大减少TLB不命中的次数。

刷新机制

用软件来管理TLB和其他缓存的一个重要的要求就是保持TLB和其他缓存中的内容的同步性,这样必须考虑在一定条件下刷新内容。

在Linux中刷新机制(包括TLB的刷新,缓存的刷新等等)主要要用来完成以下几个工作:

  1. 保证在任何时刻内存管理硬件所看到的进程的内核映射和内核页表一致;
  2. 如果负责内存管理的内核代码对用户进程页面进行了修改,那么用户的进程在被允许继续执行前,要求必须在缓存中看到正确的数据。

例如当正在执行write()系统调用时,要保证页面缓存中的页面为新页,也就是要使缓存中的页面内容和写入文件的一致,就需要更新缓存中的页面。

通常当地址空间的状态改变时,调用适当的刷新机制来描述状态的改变

在Linux中刷新机制的实现是通过一系列函数(或宏)来完成的,例如常用的两个刷新函数的一般形式为:

1
2
flush_cache_foo();
flush_tlb_foo();

这两个函数的调用是有一定顺序的,它们的逻辑意义如下所述。

在地址空间改变前必须刷新缓存,防止缓存中存在非法的空映射。函数flush_cache_*()会把缓存中的映射变成无效( 这里的缓存指的是MMU中的缓存,它负责虚地址到物理地址的当前映射关系。在刷新地址后,由于页表的改变,必须刷新TBL以便硬件可以把新的页表信息装入TLB

下面介绍一些刷新函数的作用和使用情况:

1
2
void flush_cache_all(void);
void flush_tlb_all(void);

这两个例程是用来通知相应机制,内核地址空间的映射已被改变,它意味着所有的进程都被改变了;

1
2
void flush_cache_mm(struct mm_struct *mm);
void flush_tlb_mm(struct mm_struct *mm);

它们用来通知系统被mm_struct结构所描述的地址空间正在改变,它们仅发生在用户空间的地址改变时;

1
2
flush_cache_range(struct mm_struct *mm,unsigned long start, unsigned long end);
flush_tlb_range(struct mm_struct *mm,unsigned long start, unsigned long end);

它们刷新用户空间中的指定范围;

1
2
void flush_cache_page(struct vm_area_struct *vma,unsigned long address);
void flush_tlb_page(struct vm_area_struct *vma,unsigned long address);

刷新一页面。

1
void flush_page_to_ram(unsigned long page);/*如果使用`i386 处理器,此函数为空,相应的刷新功能由硬件内部自动完成*/

这个函数一般用在写时复制,它会使虚拟缓存中的对应项无效,这是因为如果虚拟缓存不可以自动地回写,于是会造成虚拟缓存中页面和主存中的内容不一致。

例如,虚拟内存 0x2000 对任务 1、任务 2、任务 3 共享,但对任务 2 只是可读,它映射物理内存 0x1000,那么如果任务 2 要对虚拟内存 0x2000 执行写操作时,会产生页面错误。内存管理系统要给它重新分配一个物理页面如 0x2600,此页面的内容是物理内存 0x1000 的拷贝,这时虚拟索引缓存中就有两项内核别名项 0x2000 分别对应两个物理地址0x1000 和 0x2600,在任务 2 对物理页面 0x2600 的内容进行了修改后,这样内核别名即虚地址 0x2000 映射的物理页面内容不一致,任务 3 在来访问虚地址 0x2000 时就会产生不一致错误。为了避免不一致错误,使用flush_page_to_ram使得缓存中的内核别名无效。

一般刷新函数的使用顺序如下:

1
2
3
4
5
6
7
copy_cow_page(old_page,new_page,address);
flush_page_to_ram(old_page);
flush_page_to_ram(new_page);
flush_cache_page(vam,address);
….
free_page(old_page);
flush_tlb_page(vma,address);

函数代码简介

大部分刷新函数都在include/asm/pttable.h中定义,这里就 i386 中__flush_tlb()的定义给予说明:

1
2
3
4
5
6
7
8
9
#define __flush_tlb()
do {
unsigned int tmpreg;
__asm__ __volatile__(
"movl %%cr3, %0; # flush TLB \n"
"movl %0, %%cr3; \n"
: "=r" (tmpreg)
:: "memory");
} while (0)

这个函数比较简单,通过对CR3寄存的重新装入,完成对TLB的刷新。

进程的创建和执行

进程的创建

新的进程通过克隆旧的程序(当前进程)而建立。fork()clone()(对于线程)系统调用可用来建立新的进程。这两个系统调用结束时,内核在系统的物理内存中为新的进程分配新的task_struct结构,同时为新进程要使用的堆栈分配物理页。Linux还会为新的进程分配新的进程标识符。然后,新task_struct结构的地址保存在链表中,而旧进程的task_struct结构内容被复制到新进程的task_struct结构中。

在克隆进程时,Linux允许两个进程共享相同的资源。可共享的资源包括文件、信号处理程序和虚拟内存等(通过继承)。当某个资源被共享时,该资源的引用计数值会增加 1,从而只有两个进程均终止时,内核才会释放这些资源。图 6.24 说明了父进程和子进程共享打开的文件。

系统对进程虚拟内存的克隆过程则更加巧妙些。新的vm_area_struct结构、新进程自己的mm_struct结构以及新进程的页表必须在一开始就准备好,但这时并不复制任何虚拟内存,只有当两个进程中的任意一个向虚拟内存中写入数据时才复制相应的虚拟内存;而没有写入的任何内存页均可以在两个进程之间共享。代码页实际总是可以共享的。

内核线程是调用kernel_thread()函数创建的,而kernel_thread()在内核态调用了clone()系统调用。内核线程通常没有用户地址空间,即p->mm = NULL,它总是直接访问内核地址空间。

不管是fork()还是clone()系统调用,最终都调用了内核中的do_fork(),其源代码在kernel/fork.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
/*
* Ok, this is the main fork-routine. It copies the system process
* information (task[nr]) and sets up the necessary registers. It also
* copies the data segment in its entirety. The "stack_start" and
* "stack_top" arguments are simply passed along to the platform
* specific copy_thread() routine. Most platforms ignore stack_top.
* For an example that's using stack_top, see
* arch/ia64/kernel/process.c.
*/
int do_fork(unsigned long clone_flags, unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size)
{
int retval;
struct task_struct *p;
struct completion vfork;
retval = -EPERM;
/*
* CLONE_PID is only allowed for the initial SMP swapper
* calls
*/
if (clone_flags & CLONE_PID) {
if (current->pid)
goto fork_out;
}
retval = -ENOMEM;
p = alloc_task_struct();
if (!p)
goto fork_out;
*p = *current;
retval = -EAGAIN;
/*
* Check if we are over our maximum process limit, but be sure to
* exclude root. This is needed to make it possible for login and
* friends to set the per-user process limit to something lower
* than the amount of processes root is running. -- Rik
*/
if (atomic_read(&p->user->processes) >= p->rlim[RLIMIT_NPROC].rlim_cur && !capable(CAP_SYS_ADMIN) !capable(CAP_SYS_RESOURCE))
goto bad_fork_free;

atomic_inc(&p->user->__count);
atomic_inc(&p->user->processes);
/*
* Counter increases are protected by
* the kernel lock so nr_threads can't
* increase under us (but it may decrease).
*/
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;

get_exec_domain(p->exec_domain);
if (p->binfmt && p->binfmt->module)
__MOD_INC_USE_COUNT(p->binfmt->module);
p->did_exec = 0;
p->swappable = 0;
p->state = TASK_UNINTERRUPTIBLE;
copy_flags(clone_flags, p);
p->pid = get_pid(clone_flags);
p->run_list.next = NULL;
p->run_list.prev = NULL;
p->p_cptr = NULL;
init_waitqueue_head(&p->wait_chldexit);
p->vfork_done = NULL;
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
}

spin_lock_init(&p->alloc_lock);
p->sigpending = 0;
init_sigpending(&p->pending);
p->it_real_value = p->it_virt_value = p->it_prof_value = 0;
p->it_real_incr = p->it_virt_incr = p->it_prof_incr = 0;
init_timer(&p->real_timer);
p->real_timer.data = (unsigned long) p;
p->leader = 0; /* session leadership doesn't inherit */
p->tty_old_pgrp = 0;
p->times.tms_utime = p->times.tms_stime = 0;
p->times.tms_cutime = p->times.tms_cstime = 0;
#ifdef CONFIG_SMP
{
int i;
p->cpus_runnable = ~0UL;
p->processor = current->processor;
/* ?? should we just memset this ?? */
for(i = 0; i < smp_num_cpus; i++)
p->per_cpu_utime[i] = p->per_cpu_stime[i] = 0;
spin_lock_init(&p->sigmask_lock);
}
#endif

p->lock_depth = -1; /* -1 = no lock */
p->start_time = jiffies;

INIT_LIST_HEAD(&p->local_pages);

retval = -ENOMEM;
/* copy all the process information */
if (copy_files(clone_flags, p))
goto bad_fork_cleanup;
if (copy_fs(clone_flags, p))
goto bad_fork_cleanup_files;
if (copy_sighand(clone_flags, p))
goto bad_fork_cleanup_fs;
if (copy_mm(clone_flags, p))
goto bad_fork_cleanup_sighand;
retval = copy_thread(0, clone_flags, stack_start, stack_size, p, regs);
if (retval)
goto bad_fork_cleanup_mm;
p->semundo = NULL;

/* Our parent execution domain becomes current domain
These must match for thread signalling to apply */

p->parent_exec_id = p->self_exec_id;

/* ok, now we should be set up.. */
p->swappable = 1;
p->exit_signal = clone_flags & CSIGNAL;
p->pdeath_signal = 0;

/*
* "share" dynamic priority between parent and child, thus the
* total amount of dynamic priorities in the system doesnt change,
* more scheduling fairness. This is only important in the first
* timeslice, on the long run the scheduling behaviour is unchanged.
*/
p->counter = (current->counter + 1) >> 1;
current->counter >>= 1;
if (!current->counter)
current->need_resched = 1;

/*
* Ok, add it to the run-queues and make it
* visible to the rest of the system.
*
* Let it rip!
*/
retval = p->pid;
p->tgid = retval;
INIT_LIST_HEAD(&p->thread_group);

/* Need tasklist lock for parent etc handling! */
write_lock_irq(&tasklist_lock);

/* CLONE_PARENT and CLONE_THREAD re-use the old parent */
p->p_opptr = current->p_opptr;
p->p_pptr = current->p_pptr;
if (!(clone_flags & (CLONE_PARENT | CLONE_THREAD))) {
p->p_opptr = current;
if (!(p->ptrace & PT_PTRACED))
p->p_pptr = current;
}

if (clone_flags & CLONE_THREAD) {
p->tgid = current->tgid;
list_add(&p->thread_group, &current->thread_group);
}

SET_LINKS(p);
hash_pid(p);
nr_threads++;
write_unlock_irq(&tasklist_lock);

if (p->ptrace & PT_PTRACED)
send_sig(SIGSTOP, p, 1);

wake_up_process(p); /* do this last */
++total_forks;
if (clone_flags & CLONE_VFORK)
wait_for_completion(&vfork);

fork_out:
return retval;
bad_fork_cleanup_mm:
exit_mm(p);
bad_fork_cleanup_sighand:
exit_sighand(p);
bad_fork_cleanup_fs:
exit_fs(p); /* blocking */
bad_fork_cleanup_files:
exit_files(p); /* blocking */
bad_fork_cleanup:
put_exec_domain(p->exec_domain);
if (p->binfmt && p->binfmt->module)
__MOD_DEC_USE_COUNT(p->binfmt->module);
bad_fork_cleanup_count:
atomic_dec(&p->user->processes);
free_uid(p->user);
bad_fork_free:
free_task_struct(p);
goto fork_out;
}

尽管fork()系统调用因为传递用户堆栈和寄存器参数而与特定的平台相关,但实际上do_fork()所做的工作还是可移植的。下面给出对以上代码的解释。

给局部变量赋初值-ENOMEM,当分配一个新的task_struc结构失败时就返回这个错误值。如果在clone_flags中设置了CLONE_PID标志,就返回一个错误(-EPERM)。因为CLONE_PID有特殊的作用,当这个标志为 1 时,父、子进程(线程)共用一个进程号,也就是说,子进程虽然有自己的task_struct结构,却使用父进程的pid。但是,只有 0 号进程(即系统中的空线程)才允许使用这个标志。

调用alloc_task_struct()为子进程分配两个连续的物理页面,低端用来存放子进程的task_struct结构,高端用作其内核空间的堆栈。用结构赋值语句*p = *current把当前进程task_struct结构中的所有内容都拷贝到新进程中。稍后,子进程不该继承的域会被设置成正确的值。

task_struct结构中有个指针user,用来指向一个user_struct结构。一个用户常常有多个进程,所以有关用户的信息并不专属于某一个进程。这样,属于同一用户的进程就可以通过指针user共享这些信息。显然,每个用户有且只有一个user_struct结构。该结构中有一个引用计数器count,对属于该用户的进程数量进行计数。可想而知,内核线程并不属于某个用户,所以其task_struct中的user指针为 0。每个进程task_struct结构中有个数组rlim,对该进程占用各种资源的数量作出限制,而rlim[RLIMIT_NPROC]就规定了该进程所属用户可以拥有的进程数量。所以,如果当前进程是一个用户进程,并且该用户拥有的进程
数量已经达到了规定的界限值,就不允许它fork()了。

除了检查每个用户拥有的进程数量外,接着要检查系统中的任务总数(所有用户的进程数加系统的内核线程数)是否超过了最大值max_threads,如果是,也不允许再创建子进程。

task_struct有一个指针exec_doman,指向一个exec_doman结构。在exec_doman结构中有一个域是module,这是指向某个module结构的指针。在Linux中,一个文件系统或驱动程序都可以作为一个单独的模块进行编译,并动态地链接到内核中。

module结构中有一个计数器count,用来统计几个进程需要使用这个模块。因此,get_exec_domain(p->exec_domain)递增模块结构module中的计数器。

另外,每个进程所执行的程序属于某种可执行映像格式,如a.out格式、elf格式,甚至Java虚拟机格式。对于不同格式的支持通常是通过动态安装的模块来实现的。所以,task_struct中有一个执行Linux_binfmt结构的指针binfmt,而__MOD_INC_USE_COUNT()就是对有关模块的使用计数进行递增。

紧接着为什么要把进程的状态设置成为TASK_UNINTERRUPTIBLE?这是因为后面get_pid()的操作必须独占,子进程可能因为一时进不了临界区而只好暂时进入睡眠状态。

copy_flags()函数将clone_flags参数中的标志位略加补充和变换,然后写入p->flags

get_pid()函数根据clone_flags中标志位ClONE_PID的值,或返回父进程(当前进程)的pid,或返回一个新的pid

前面在复制父进程的task_struct结构时把父进程的所有域都照抄过来,但实际上很多域的值必须重新赋初值,因此,后面的赋值语句就是对子进程task_struct结构的初始化。其中start_time表示进程创建的时间,而全局变量jiffies就是从系统初始化开始至当前的是时钟滴答数。local_pages表示属于该进程的局部页面形成一个双向链表,在此进行了初始化。

copy_files()有条件地复制已打开文件的控制结构,也就是说,这种复制只有在clone_flags中的CLONE_FILES标志为 0 时才真正进行,否则只是共享父进程的已打开文件。当一个进程有已打开文件时,task_struct结构中的指针files指向一个file_struct结构,否则为 0。所有与终端设备tty相联系的用户进程的头 3 个标准文件stdinstdoutstderr都是预先打开的,所以指针一般不为空。

copy_fs()也是只有在clone_flags中的CLONE_FS标志为 0 时才加以复制。在task_struct中有一个指向fs_struct结构的指针,fs_struct结构中存放的是进程的根目录root、当前工作目录pwd、一个用于文件操作权限的umask,还有一个计数器。类似地,copy_sighand()也是只有在CLONE_SIGHAND为 0 时才真正复制父进程的信号结构,否则就共享父进程。

信号是进程间通信的一种手段,信号随时都可以发向一个进程,就像中断随时都可以发向一个处理器一样。进程可以为各种信号设置相应的信号处理程序,一旦进程设置了信号处理程序,其task_struct结构中的指针sig就指向signal_struct结构(定义于include/linux/sched.h)。

用户空间的继承是通过copy_mm()函数完成的。进程的task_struct结构中有一个指针mm,就指向代表着进程地址空间的mm_struct结构。对mm_struct的复制也是在clone_flags中的CLONE_VM标志为 0 时才真正进行,否则,就只是通过已经复制的指针共享父进程的用户空间。对mm_struct的复制不只限于这个数据结构本身,还包括了对更深层次数据结构的复制,其中最主要的是vm_area_struct结构和页表的复制,这是由同一文件中的dum_mmap()函数完成的。

到此为止,task_struct结构中的域基本复制好了,但是用于内核堆栈的内容还没有复制,这就是copy_thread()的责任了。copy_thread()函数与平台相关,定义于arch/i386/kernel/process.c中。copy_thread()实际上只复制父进程的内核空间堆栈。

堆栈中的内容记录了父进程通过系统调用fork()进入内核空间、然后又进入copy_thread()函数的整个历程,子进程将要循相同的路线返回,所以要把它复制给子进程。但是,如果父子进程的内核空间堆栈完全相同,那返回用户空间后就无法区分哪个是子进程了,所以,复制以后还要略作调整。

parent_exec_id表示父进程的执行域,p->self_exec_id是本进程(子进程)的执行域,swappable表示本进程的页面可以被换出。exit_signal为本进程执行exit()系统调用时向父进程发出的信号,death_signal为要求父进程在执行exit()时向本进程发出的信号。

另外,counter域的值是进程的时间片(以时钟滴达为单位),代码中将父进程的时间片分成两半,让父、子进程各有原值的一半。

进程创建后必须处于某一组中,这是通过task_struct结构中的队列头thread_group与父进程链接起来,形成一个进程组(注意,thread并不单指线程,内核代码中经常用thread通指所有的进程)。

建立进程的家族关系。先建立起子进程的祖先和双亲(当然还没有兄弟和孩子),然后通过SET_LINKS()宏将子进程的task_struct结构插入到内核中其他进程组成的双向链表中。通过hash_pid()将其链入按其pid计算得的哈希表中。

最后,通过wake_up_process()将子进程唤醒,也就是将其挂入可执行队列等待被调度。

但是,还有一种特殊情况必须考虑。当参数clone_flagsCLONE_VFORK标志位为 1 时,一定要保证子进程先运行,一直到子进程通过系统调用execve()执行一个新的可执行程序或通过系统调用exit()退出系统时,才可以恢复父进程的执行,这是通过wait_for_completion()函数实现的。为什么要这样做呢?这是因为当CLONE_VFORK标志位为 1 时,就说明父、子进程通过指针共享用户空间(指向相同的mm_struct结构),那也说明父进程写入用户空间的内容同时也写入了子进程的用户空间,反之亦然。在这种情况下,父子进程对堆栈区的写入是致命的了。

到此为止,子进程的创建已经完成,该是从内核态返回用户态的时候了。实际上,fork()系统调用执行之后,父子进程返回到用户空间中相同的的地址,用户进程根据fork()的返回值分别安排父子进程执行不同的代码。

程序执行

ELF可执行文件

ELF是“可执行可连接格式”的英文缩写,ELF在装入内存时多一些系统开支,但是更为灵活。ELF可执行文件包含了可执行代码和数据,通常也称为正文和数据。这种文件中包含一些表,根据这些表中的信息,内核可组织进程的虚拟内存。另外,文件中还包含有对内存布局的定义以及起始执行的指令位置。

下面我们分析一个简单程序在利用编译器编译并连接之后的ELF文件格式:

1
2
3
4
5
#include <stdio.h>
main ()
{
printf(“Hello world!\n”);
}

从图可以看出,ELF可执行映象文件的开头是 3 个字符‘E’、‘L’和‘F’,作为这类文件的标识符。e_entry定义了程序装入之后起始执行指令的虚拟地址。这个简单的ELF映像利用两个“物理头”结构分别定义代码和数据,e_phnum是该文件中所包含的物理头信息个数,本例为 2。e_phyoff是第一个物理头结构在文件中的偏移量,而e_phentsize则是物理头结构的大小,这两个偏移量均从文件头开始算起。根据上述两个信息,内核可正确读取两个物理头结构中的信息。

物理头结构的p_flags字段定义了对应代码或数据的访问属性。图中第 1 个p_flags字段的值为FP_XFP_R,表明该结构定义的是程序的代码;类似地,第 2 个物理头定义程序数据,并且是可读可写的。p_offset定义对应的代码或数据在物理头之后的偏移量。

p_vaddr定义代码或数据的起始虚拟地址。p_fileszp_memsz分别定义代码或数据在文件中的大小以及在内存中的大小。

对我们的简单例子,程序代码开始于两个物理头之后,而程序数据则开始于物理头之后的第 0x68533 字节处,显然,程序数据紧跟在程序代码之后。程序的代码大小为 0x68532,显得比较大,这是因为连接程序将C函数printf的代码连接到了ELF文件的原因。程序代码的文件大小和内存大小是一样的,而程序数据的文件大小和内存大小不一样,这是因为内存数据中,起始的 2200 字节是预先初始化的数据,初始化值来自ELF映象,而其后的 2048 字节则由执行代码初始化。

如前面所描述的,Linux利用请页技术装入程序映像。当shell进程利用fork()系统调用建立了子进程之后,子进程会调用exec()系统调用(实际有多种exec调用),exec()系统调用将利用ELF二进制格式装载器装载ELF映像,当装载器检验映像是有效的ELF文件之后,就会将当前进程(实际就是父进程或旧进程)的可执行映像从虚拟内存中清除,同时清除任何信号处理程序并关闭所有打开的文件(把相应file结构中的f_count引用计数减 1,如果这一计数为 0,内核负责释放这一文件对象),然后重置进程页表。

完成上述过程之后,只需根据ELF文件中的信息将映象代码和数据的起始和终止地址分配并设置相应的虚拟地址区域,修改进程页表。这时,当前进程就可以开始执行对应的ELF映像中的指令了。

执行函数

在执行fork()之后,同一进程有两个拷贝都在运行,也就是说,子进程具有与父进程相同的可执行程序和数据(简称映像)。但是,子进程肯定不满足于仅仅成为父进程的“影子”,因此,父进程就要调用execve()装入并执行子进程自己的映像。execve()函数必须定位可执行文件的映像,然后装入并运行它。当然开始装入的并不是实际二进制映像的完全拷贝,拷贝的完全装入是用请页装入机制(Demand Pageing Loading)逐步完成的。开始时只需要把要执行的二进制映像头装入内存,可执行代码的inode节点被装入当前进程的执行域中就可以执行了。

由于Linux文件系统采用了Linux_binfmt数据结构(在/include/linux/binfmt.h中)来支持各种文件系统,所以Linux中的exec()函数执行时,使用已注册的linux_binfmt结构就可以支持不同的二进制格式。需要指出的是binux_binfmt结构中嵌入了两个指向函数的指针,一个指针指向可执行代码,另一个指向了库函数;使用这两个指针是为了装入可执行代码和要使用的库。linux_binfmt结构描述如下。

1
2
3
4
5
6
7
struct linux_binfmt {
struct linux_binfmt * next;
long *use_count;
int (*load_binary)(struct linux_binprm *, struct pt_regs * regs);/*装入二进制代码*/
int (*load_shlib)(int fd); /*装入公用库*/
int (*core_dump)(long signr, struct pt_regs * regs);
}

在使用这种数据结构前必须调用vod binfmt_setup()函数进行初始化;这个函数分别初始化了一些可执行的文件格式,如:init_elf_binfmt()init_aout_binfmt()init_java_binfmt()init_script_binfmt()

其实初始化就是用register_binfmt(struct linux_binfmt * fmt)函数把文件格式注册到系统中,即加入*formats所指的链中,*formats的定义如下:

1
static struct linux_binfmt *formats = (struct linux_binfmt *) NULL

在使用装入函数的指针时,如果可执行文件是ELF格式的,则指针指向的装入函数分别是:

1
2
load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs);
static int load_elf_library(int fd);

所以elf_format文件格式说明将被定义成:

1
2
3
4
5
6
static struct linux_binfmt elf_format = {
#ifndef MODULE
NULL, NULL, load_elf_binary, load_elf_library, elf_core_dump
#else
NULL, &mod_use_count_, load_elf_binary, load_elf_library, elf_core_dump#endif
}

其他格式文件处理很类似,相关代码请看本节后面介绍的search_binary_handler()函数。

另外还要提的是在装入二进制时还需要用到结构Linux_binprm,这个结构保存着一些在装入代码时需要的信息:

1
2
3
4
5
6
7
8
9
10
11
12
struct linux_binprm{
char buf[128];/*读入文件时用的缓冲区*/
unsigned long page[MAX_ARG_PAGES];
unsigned long p;
int sh_bang;
struct inode * inode;/*映像来自的节点*/
int e_uid, e_gid;
int argc, envc; /*参数数目,环境数目*/
char * filename; /* 二进制映像的名字,也就是要执行的文件名 */
unsigned long loader, exec;
int dont_iput; /* binfmt handler has put inode */
};

其他域的含义在后面的do_exec()代码中做进一步解释。

Linux所提供的系统调用名为execve(),可是,C语言的程序库在此系统调用的基础上向应用程序提供了一整套的库函数,包括execve()execlp()execle()execv()execvp(),它们之间的差异仅仅是参数的不同。下面来介绍execve()的实现。

系统调用execve()在内核的入口为sys_execve(),其代码在arch/i386/kernel/process.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
* sys_execve() executes a new program.
*/
asmlinkage int sys_execve(struct pt_regs regs)
{
int error;
char * filename;
filename = getname((char *) regs.ebx);
error = PTR_ERR(filename);
if (IS_ERR(filename))
goto out;
error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, &regs);
if (error == 0)
current->ptrace &= ~PT_DTRACE;
putname(filename);
out:
return error;
}

系统调用进入内核时,regs.ebx中的内容为应用程序中调用相应的库函数时的第 1 个参数,这个参数就是可执行文件的路径名。但是此时文件名实际上存放在用户空间中,所以getname()要把这个文件名拷贝到内核空间,在内核空间中建立起一个副本。然后,调用do_execve()来完成该系统调用的主体工作。do_execve()的代码在fs/exec.c中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/*
* sys_execve() executes a new program.
*/
int do_execve(char * filename, char ** argv, char ** envp, struct pt_regs * regs)
{
struct linux_binprm bprm;
struct file *file;
int retval;
int i;
file = open_exec(filename);
retval = PTR_ERR(file);
if (IS_ERR(file))
return retval;
bprm.p = PAGE_SIZE*MAX_ARG_PAGES-sizeof(void *);
memset(bprm.page, 0, MAX_ARG_PAGES*sizeof(bprm.page[0]));
bprm.file = file;
bprm.filename = filename;
bprm.sh_bang = 0;
bprm.loader = 0;
bprm.exec = 0;
if ((bprm.argc = count(argv, bprm.p / sizeof(void *))) < 0) {
allow_write_access(file);
fput(file);
return bprm.argc;
}
if ((bprm.envc = count(envp, bprm.p / sizeof(void *))) < 0) {
allow_write_access(file);
fput(file);
return bprm.envc;
}
retval = prepare_binprm(&bprm);
if (retval < 0)
goto out;
retval = copy_strings_kernel(1, &bprm.filename, &bprm);
if (retval < 0)
goto out;
bprm.exec = bprm.p;
retval = copy_strings(bprm.envc, envp, &bprm);

if (retval < 0)
goto out;
retval = copy_strings(bprm.argc, argv, &bprm);
if (retval < 0)
goto out;
retval = search_binary_handler(&bprm,regs);
if (retval >= 0)
/* execve success */
return retval;
out:
/* Something went wrong, return the inode and free the argument pages*/
allow_write_access(bprm.file);
if (bprm.file)
fput(bprm.file);
for (i = 0 ; i < MAX_ARG_PAGES ; i++) {
struct page * page = bprm.page[i];
if (page)
__free_page(page);
}
return retval;
}

参数filenameargvenvp分别代表要执行文件的文件名、命令行参数及环境串。下面对以上代码给予解释。

首先,将给定可执行程序的文件找到并打开,这是由open_exec()函数完成的。open_exec()返回一个file结构指针,代表着所读入的可执行文件的映像。

所有Linux_binprm结构中有一个页面指针数组,数组的大小为系统所允许的最大参数个数MAX_ARG_PAGES(定义为 32)。memset()函数将这个指针数组初始化为全 0。

对局部变量bprm的各个域进行初始化。其中bprm.p几乎等于最大参数个数所占用的空间;bprm.sh_bang表示可执行文件的性质,当可执行文件是一个Shell脚本(Shell Sript)时置为 1,此时还没有可执行Shell脚本,因此给其赋初值 0,还有其他两个域也赋初值 0。

函数count()对字符串数组argv[]中参数的个数进行计数。bprm.p / sizeof(void *)表示所允许参数的最大值。同样,对环境变量也要统计其个数。

如果count()小于 0,说明统计失败,则调用fput()把该可执行文件写回磁盘,在写之前,调用allow_write_access()来防止其他进程通过内存映射改变该可执行文件的内容。

完成了对参数和环境变量的计数之后,又调用prepare_binprm()bprm变量做进一步的准备工作。更具体地说,就是从可执行文件中读入开头的 128 个字节到Linux_binprm结构的缓冲区buf,这是为什么呢?因为不管目标文件是ELF格式还是a.out格式,或者其他格式,在其可执行文件的开头 128 个字节中都包括了可执行文件属性的信息。

然后,就调用copy_strings把参数以及执行的环境从用户空间拷贝到内核空间的bprm变量中,而调用copy_strings_kernel()从内核空间中拷贝文件名,因为前面介绍的get_name()已经把文件名拷贝到内核空间了。

所有的准备工作已经完成,关键是调用search_binary_handler()函数了,请看下面对这个函数的详细介绍。

search_binary_handler()函数也在exec.c中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
/*
* cycle the list of binary formats handler, until one recognizes the image
*/
int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs)
{
int try,retval=0;
struct linux_binfmt *fmt;

/* kernel module loader fixup */
/* so we don't try to load run modprobe in kernel space. */
set_fs(USER_DS);
for (try=0; try<2; try++) {
read_lock(&binfmt_lock);
for (fmt = formats ; fmt ; fmt = fmt->next) {
int ( *fn ) ( struct linux_binprm *, struct pt_regs * ) = fmt->load_binary;
if (!fn)
continue;
if (!try_inc_mod_count(fmt->module))
continue;
read_unlock(&binfmt_lock);
retval = fn(bprm, regs);
if (retval >= 0) {
put_binfmt(fmt);
allow_write_access(bprm->file);
if (bprm->file)
fput(bprm->file);
bprm->file = NULL;
current->did_exec = 1;
return retval;
}
read_lock(&binfmt_lock);
put_binfmt(fmt);
if (retval != -ENOEXEC)
break;
if (!bprm->file) {
read_unlock(&binfmt_lock);
return retval;
}
}

read_unlock(&binfmt_lock);
if (retval != -ENOEXEC) {
break;
#ifdef CONFIG_KMOD
}else{
#define printable(c) (((c)=='\t') || ((c)=='\n') || (0x20<=(c) && (c)<=0x7e))
char modname[20];
if (printable(bprm->buf[0]) &&
printable(bprm->buf[1]) &&
printable(bprm->buf[2]) &&
printable(bprm->buf[3]))
break; /* -ENOEXEC */
sprintf ( modname, "binfmt-%04x", * ( unsigned short * ) (&bprm->buf[2]));
request_module(modname);
#endif
}
}
return retval;
}

exec.c中定义了一个静态变量formats:

1
static struct linux_binfmt *formats

因此,formats就指向链表队列的头,挂在这个队列中的成员代表着各种可执行文件格式。在do_exec()函数的准备阶段,已经从可执行文件头部读入 128 字节存放在bprm的缓冲区中,而且运行所需的参数和环境变量也已收集在bprm中。search_binary_handler()函数就是逐个扫描formats队列,直到找到一个匹配的可执行文件格式,运行的事就交给它。如果在这个队列中没有找到相应的可执行文件格式,就要根据文件头部的信息来查找是否有为此种格式设计的可动态安装的模块,如果有,就把这个模块安装进内核,并挂入formats队列,然后再重新扫描。下面对具体程序给予解释。

程序中有两层嵌套for循环。内层是针对formats队列的每个成员,让每一个成员都去执行一下load_binary()函数,如果执行成功,load_binary()就把目标文件装入并投入运行,并返回一个正数或 0。当CPU从系统调用execve()返回到用户程序时,该目标文件的执行就真正开始了,也就是,子进程新的主体真正开始执行了。如果load_binary()返回一个负数,就说明或者在处理的过程中出错,或者没有找到相应的可执行文件格式,在后一种情况下,返回-ENOEXEC

内层循环结束后,如果load_binary()执行失败后的返回值为-ENOEXEC,就说明队列中所有成员都不认识目标文件的格式。这时,如果内核支持动态安装模块(取决于编译选项CONFIG_KMOD),就根据目标文件的第 2 和第 3 个字节生成一个binfmt模块,通过request_module()试着将相应的模块装入内核。外层的for循环有两次,就是为了在安装了模块以后再来试一次。

Linux_binfmt数据结构中,有 3 个函数指针:load_binaryload_shlib以及core_dump,其中load_binary就是具体的装载程序。不同的可执行文件其装载函数也不同,如a.out格式的装载函数为load_aout_binary()ELF格式的装载函数为load_elf_binary(),其源代码分别在fs/binfmt_aout.c中和fs/binfmt_elf中。

新标准的诞生

C++11语言变化的领域

C++11相对于C++98/03有哪些显著的增强呢?事实上,这包括以下几点:

  • 通过内存模型、线程、原子操作等来支持本地并行编程( Native Concurrency )。
  • 通过统一.初始化表达式、auto、declytype、移动语义等来统一对泛型编程的支持。
  • 通过constexpr、POD (概念)等更好地支持系统编程。
  • 通过内联命名空间、继承构造函数和右值引用等,以更好地支持库的构建。

表列出了C++11批准通过的,且本书将要涉及的语言特性。

与硬件紧密合作

在C++11中,常量表达式以及原子操作都是可以用于支持嵌人式编程的重要特性。这些特性对于提高性能、降低存储空间都大有好处,比如ROM。C++98/03中也具备const类型,不过它对只读内存(ROM)支持得不够好。这是因为在C++中const类型只在初始化后才意味着它的值应该是常量表达式,从而在运行时不能被改变。不过由于初始化依旧是动态的,这对ROM设备来说并不适用。这就要求在动态初始化前就将常量计算出来。为此标准增加了constexpr,它让函数和变量可以被编译时的常量取代。

C++11 通过引入内存模型,为开发者和系统建立了一个高效的同步机制。作为开发者,通常需要保证线程程序能够正确同步,在程序中不会产生竞争。而相对地,系统(可能是编译器、内存系统,或是缓存一致性机制)则会保证程序员编写的程序(使用原子类型)不会引入数据竞争。而且为了同步,系统会自行禁止某些优化,又保证其他的一些优化有效。除非编写非常底层的并行程序,否则系统的优化对程序员来讲,基本上是透明的。这可能是C++11中最大、最华丽的进步。

就算程序员不乐意使用原子类型,而要使用线程,那么使用标准的互斥变量mutex来进行临界区的加锁和开锁也就够了。而如果读者还想要疯狂地挖掘并行的速度,或试图完全操控底层,或想找点麻烦,那么无锁( lock-free)的原子类型也可以满足你的各种“野心”。内存模型的机制会保证你不会犯错。只有在使用与系统内存单位不同的位域的时候,内存模型才无法成功地保证同步。比如说下面这个位域的例子,这样的位域常常会引发竞争(跨了一个内存单元),因为这破坏了内存模型的假定,编译器不能保证这是没有竞争的。

1
struct {int a:9; int b:7;}

不过如果使用下面的字符位域则不会引发竞争,因为字符位域可以被视为是独立内存位置。而在C++98/03 中,多线程程序中该写法却通常会引发竞争。这是因为编译器可能将a和b连续存放,那么对b进行赋值(互斥地)的时候就有可能在a没有被上锁的情况下一起写掉了。原因是在单线程情况下常被视为普通的安全的优化,却没有考虑到多线程情况下的复杂性。C++11 则在这方面做出了较好的修正。

1
struct {char a; char b;}

融入编程现实

如今GNU的属性( attribute)几乎无所不在,所有的编译器都在尝试支持它,以用于修饰类型、变量和函数等。不过__attribute__((attribute-name))这样的写法,除了不怎么好看外,每一个编译器可能还都有它自己的变体,比如微软的属性就是以__declspec打头的。因此在C++11中,我们看到了通用属性的出现。

不过C++11引入通用属性更大的原因在于,属性可以在不引入额外的关键字的情况下,为编译提供额外的信息。因此,一些可以实现为关键字的特性,也可以用属性来实现。C++11标准最终选择创建很少的几个通用属性,noreturncarrier_dependency(其实final、override也一度是热门“人选” )。

保证稳定性和兼容性

作为C语言的嫡亲,C++有一个众所周知的特性——对C语言的高度兼容。

保持与C99兼容

虽然C语言发展中的大多数改进都被引入了C++语言标准中,但还是存在着一些属于C99标准的“漏网之鱼”。所以C++11将对以下C99特性的支持也都纳入了新标准中:

  • C99中的预定义宏
  • __func__预定义标识符
  • _Pragma操作符
  • 不定参数宏定义以及__VA_ARGS__
  • 宽窄字符串连接

预定义宏

相较于C89标准,C99语言标准增加一些预定义宏。C++11 同样增加了对这些宏的支持。

宏名称 功能描述
__STDC_HOSTED__ 如果编译器的目标系统环境中包含完整的标准C库,那么这个宏就定义为1,否则宏的值为0
__STDC__ C编译器通常用这个宏的值来表示编译器的实现是否和C标准一致。 C++11标准中这个宏是否定义以及定成什么值由编译器来决定
__STDC_VERSION__ C编译器通常用这个宏来表示所支持的C标准的版本,比如1999mmL。C++11 标准中这个宏是否定义以及定成什么值将由编译器来决定
__STDC_ISO_10646__ 这个宏通常定义为一个yyymmL格式的整数常量,例如199712L,用来表示C++编译环境符合某个版本的ISO/IEC 10646标准

使用这些宏,我们可以查验机器环境对C标准和C库的支持状况,如代码所示。

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
int main() {
cout << "Standard Clib:" << __STDC_HOSTED__<< endl;
// Standard Clib: 1
cout << "ISO/IEC" << __STDC_ISO_10646__<< endl ;
// ISO/IEC 200009
//编译选项:g++ -std=c++11 2-1-1.cpp
}

预定义宏对于多目标平台代码的编写通常具有重大意义。通过以上的宏,程序员通过使用#ifdef/#endif等预处理指令,就可使得平台相关代码只在适合于当前平台的代码上编译,从而在同一套代码中完成对多平台的支持。从这个意义上讲,平台信息相关的宏越丰富,代码的多平台支持越准确。不过值得注意的是,与所有预定义宏相同的,如果用户重定义(#define)或#undef了预定义的宏,那么后果是“未定义”的。因此在代码编写中,程序员应该注意避免自定义宏与预定义宏同名的情况。

func预定义标识符

很多现实的编译器都支持C99标准中的__func__预定义标识符功能,其基本功能就是返回所在函数的名字。

1
2
3
4
5
6
7
8
9
#include <string>
#include <iostream>
using namespace std;
const char* hello() { return __func__; }
const char* world() { return __func__; }
int main(){
cout << hello() << ", " << world() << endl; // hello, world
}
//编译选项:g++ -std=c++11 2-1-2.ccpp

按照标准定义,编译器会隐式地在函数的定义之后定义__func__标识符。比如上述例子中的hello函数,其实际的定义等同于如下代码:

1
2
3
4
const char* hello() {
static const char* __func__= "hello";
return __func__;
}

__func__预定义标识符对于轻量级的调试代码具有十分重要的作用。而在C++11中,标准甚至允许其使用在类或者结构体中。我们可以看看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;
struct TestStruct {
TestStruct () : name(__func__) {}
const char *name;
};

int main() {
Teststruct ts;
cout << ts.name << endl;
}
// TestStruct
//编译选项:g++ -std=c++11 2-1-3.ccpp

在结构体的构造函数中,初始化成员列表使用__func__预定义标识符是可行的,其效果跟在函数中使用一样。不过将__fun__标识符作为函数参数的默认值,如下所示:

1
void FuncFail(string func_name = __func__) { }; // 无法通过编译

_Pragma 操作符

在C/C++标准中,#pragma是预处理的指令(preprocessor directive)。简单地说,#pragma是用来向编译器传达语言标准以外的一些信息。举个简单的例子,如果我们在代码的头文件中定义了以下语句:

1
#pragma once

那么该指令会指示编译器(如果编译器支持),该头文件应该只被编译一次。这与使用如下代码来定义头文件所达到的效果是一样的。

1
2
3
4
#ifndef THIS_HEADER
#define THIS_HEADER
//一些头文件的定义
#endif

在C++11中,标准定义了与预处理指令#pragma功能相同的操作符_Pragma_Pragma操作符的格式如下所示:

1
_Pragma ( 字符串字面量)

其使用方法跟sizeof等操作符一样,将字符串字面量作为参数写在括号内即可。那么要达到与上例#pragma类似的效果,则只需要如下代码即可。

1
_Pragma ("once");

而相比预处理指令#pragma,由于_Pragma是一个操作符,因此可以用在一些宏中。我们可以看看下面这个例子:

1
2
3
#define CONCAT(x) PRAGMA (concat on #x)
#define PRAGMA(x) _Pragma (#x)
CONCAT( ..\concat.dir )

这里,CONCAT( ..\concat.dir )最终会产生_Pragma(concat on "..\concat.dir")这样的效果(这里只是显示语法效果,应该没有编译器支持这样的_Pragma语法)。而#pragma则不能在宏中展开,因此从灵活性上来讲,C++11的_Pragma具有更大的灵活性。

变长参数的宏定义以及 VA_ARGS

在C99标准中,程序员可以使用变长参数的宏定义。变长参数的宏定义是指在宏定义中参数列表的最后一个参数为省略号,而预定义宏__VA_ARGS__则可以在宏定义的实现部分替换省略号代表的字符串。

1
#define PR(...) printf(__VA_ARGS__)

就可以定义一个printf的别名PR。事实上,变长参数宏与printf是好搭档:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#define LOG(...) {\
fprintf (stderr, "%s: Line %d:\t",__FILE__, __LINE__); \
fprintf (stderr, __VA_ARGS__); \
fprintf (stderr, "\n"); \

int main() {
int x = 3;
//一些代码
LOG("x = &d", x); // 2-1-5.cpp: Line 12: X=3
}
//编译选项:g++ -std=c++11 2-1-5.cpp

定义LOG宏用于记录代码位置中一些信息。程序员可以根据stderr产生的日志追溯到代码中产生这些记录的位置。这样的特性对于轻量级调试,简单的错误输出都是具有积极意义的。

long long整型

相比于C++98标准,C++11整型的最大改变就是多了long long。long long整型有两种:long long和unsigned long long。在C++11中,标准要求long long整型可以在不同平台上有不同的长度,但至少有64位。我们在写常数字面量时,可以使用LL后缀(或是ll)标识一个long long类型的字面量,而ULL (或ull、Ull、uLL)表示一个unsigned long long类型的字面量。比如:

1
2
long long int lli = -9000000000000000000LL;
unsigned long long int ulli = - 900000000000000000ULL;

就定义了一个有符号的long long变量lli和无符号的unsigned long long变量ulli。事实上,在C++11中,还有很多与long long等价的类型。比如对于有符号的,下面的类型是等价的:long longsigned long longlong long intsigned long long int;而unsigned long longunsigned long long int也是等价的。

同其他的整型一样,要了解平台上long long大小的方法就是查看<climits> (或<limits.h>中的宏)。与long long整型相关的一共有3个:LLONG_MINLLONG_MAXULLONG_MIN,它们分别代表了平台上最小的long long值、最大的long long值,以及最大的unsigned long long值。

扩展的整型

程序员常会在代码中发现一些整型的名字,比如UINT__int16u64int64_t等等。这些类型有的源自编译器的自行扩展,有的则是来自某些编程环境(比如工作在Linux内核代码中),不一而足。而事实上,在C++11中一共只定义了以下5种标准的有符号整型:

  • signed char
  • short int
  • int
  • long int
  • long long int

标准同时规定,每一种有符号整型都有一种对应的无符号整数版本,且有符号整型与其对应的无符号整型具有相同的存储空间大小。比如与signed int对应的无符号版本的整型是unsigned int。

在实际的编程中,由于这5种基本的整型适用性有限,所以有时编译器出于需要,也会自行扩展一些整型。在C++11中,标准对这样的扩展做出了一些规定。具体地讲,除了标准整型( standard integer type)之外,C++11 标准允许编译器扩展自有的所谓扩展整型(extended integer type)。这些扩展整型的长度(占用内存的位数)可以比最长的标准整型(long long int,通常是一个64位长度的数据)还长,也可以介于两个标准整数的位数之间。比如在128位的架构上,编译器可以定义一个扩展整型来对应128位的的整数。

简单地说,C++11 规定,扩展的整型必须和标准类型一样,有符号类型和无符号类型占用同样大小的内存空间。而由于C/C++是一种弱类型语言,当运算、传参等类型不匹配的时候,整型间会发生隐式的转换,这种过程通常被称为整型的提升( Integral promotion)。 比如如下表达式:

1
(int) a + (long long)b

通常就会导致变量(int)a被提升为long long类型后才与(long long)b进行运算。而无论是扩展的整型还是标准的整型,其转化的规则会由它们的“等级”(rank)决定。而通常情况,我们认为有如下原则:

  • 长度越大的整型等级越高,比如long long int的等级会高于int。
  • 长度相同的情况下,标准整型的等级高于扩展类型,比如long long int 和int64如果都是64位长度,则long long int类型的等级更高。
  • 相同大小的有符号类型和无符号类型的等级相同,long long int和unsigned longlong int的等级就相同。

而在进行隐式的整型转换的时候,一般是按照低等级整型转换为高等级整型,有符号的转换为无符号。这种规则其实跟C++98的整型转换规则是一致的。

在一个128位的构架上,编译器可以定义__int128_t为128位的有符号整型(对应的无符号类型为_uint128_t)。于是程序员可以使用_int128_t类型保存形如+92233720368547758070的超长整数(长于64位的自然数)。而不用查看编译器文档我们也会知道,一旦遇到整型提升,按照上面的规则,比如_int128_t a,与任何短于它的类型的数据b进行运算(比如加法)时,都会导致b被隐式地转换为_int128_t的整型,因为扩展的整型必须遵守C++11的规范。

宏__cplusplus

在C与C++混合编写的代码中,我们常常会在头文件里看到如下的声明:

1
2
3
4
5
6
7
#ifdef __cplusplus
extern "C" {
#endif
//一些代码
#ifdef __cplusplus
}
#endif

这种类型的头文件可以被#include到C文件中进行编译,也可以被#include到C++文件中进行编译。由于extern "C"可以抑制C++对函数名、变量名等符号( symbol)进行名称重整( name mangling),因此编译出的C目标文件和C++目标文件中的变量、函数名称等符号都是相同的(否则不相同),链接器可以可靠地对两种类型的目标文件进行链接。这样该做法成为了C与C++混用头文件的典型做法。

鉴于以上的做法,程序员可能认为__cplusplus这个宏只有“被定义了”和“未定义”两种状态。事实上却并非如此,__cplusplus这个宏通常被定义为一个整型值。而且随着标准变化,_cplusplus宏会是一个比以往标准中更大的值。比如在C++03标准中,__cplusplus的值被预定为199711L,而在C++11标准中,宏__cplusplus被预定义为201103L。这点变化可以为代码所用。比如程序员在想确定代码是使用支持C++11编译器进行编译时,那么可以按下面的方法进行检测:

1
2
3
#if __cplusplus < 201103L
#error "should use C++11 implementat ion”
#endif

这里,使用了预处理指令#error,这使得不支持C++11的代码编译立即报错并终止编译。读者可以使用C++98编译器和C++11的编译器分别实验一下其效果。

静态断言

断言:运行时与预处理时

断言(assertion)是一种编程中常用的手段。在通常情况下,断言就是将一个返回值总是需要为真的判别式放在语句中,用于排除在设计的逻辑上不应该产生的情况。比如一个函数总需要输入在一定的范围内的参数,那么程序员就可以对该参数使用断言,以迫使在该参数发生异常的时候程序退出,从而避免程序陷入逻辑的混乱。

从一些意义.上讲,断言并不是正常程序所必需的,不过对于程序调试来说,通常断言能够帮助程序开发者快速定位那些违反了某些前提条件的程序错误。在C++中,标准在<cassert><assert.h>头文件中为程序员提供了assert宏,用于在运行时进行断言。我们可以看看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
#include <cassert>
using namespace std;
//一个简单的堆内存数组分配函数
char *ArrayAlloc(int n) {
assert(n > 0); // 断言,n必须大于0
return new char [n] ;
}
int main () {
char* a = ArrayAlloc(0) ;
}
//编译选项:g++ 2-5-1. cpp

在代码中,我们定义了一个ArrayAlloc函数,该函数的唯一功能就是在堆上分配字节长度为n的数组并返回。为了避免意外发生,函数ArrayAlloc对参数n进行了断言,要求其大于0。而main函数中对ArrayAlloc的使用却没有满足这个条件,那么在运行时,我们可以看到如下结果:

1
2
a.out: 2-5-1.cpp:6: char* ArrayAlloc(int): Assertion : 'n > 0' failed.
Aborted

在C++中,程序员也可以定义宏NDEBUG来禁用assert宏。这对发布程序来说还是必要的。因为程序用户对程序退出总是敏感的,而且部分的程序错误也未必会导致程序全部功能失效。那么通过定义NDEBUG宏发布程序就可以尽量避免程序退出的状况。而当程序有问题时,通过没有定义宏NDEBUG的版本,程序员则可以比较容易地找到出问题的位置。

事实上,assert宏在<cassert>中的实现方式类似于下列形式:

1
2
3
4
5
#ifdef NDEBUG
# define assert (expr) (static_cast<void> (0))
#else
...
#endif

可以看到,一旦定义了NDBUG宏,assert宏将被展开为一条无意义的C语句(通常会被编译器优化掉)。

静态断言与static_assert

断言assert宏只有在程序运行时才能起作用。而#error只在编译器预处理时才能起作用。有的时候,我们希望在编译时能做一些断言。 比如下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <cassert>
using namespace std;
//枚举编译器对各种特性的支持,每个枚举值占一位
enum FeatureSupports {
C99 = 0x0001,
ExtInt = 0x0002,
SAssert = 0x0004,
NoExcept = 0x0008,
SMAX = 0x0010,
};

//一个编译器类型,包括名称、特性支持等
struct Compiler {
const char * name ;
int spp; // 使用FeatureSupports枚举
};

int main() {
//检查枚举值是否完备
assert( (SMAX - 1) == (C99| ExtInt| SAssert| NoExcept)) ;
Compiler a = {"abc", (C99| SAssert) };
// ...
if (a.spp & C99) {
//一些代码...
}
}
//编译选项:g++ 2-5-2. cpp .

在该例中,我们编写了一个枚举类型FeatureSupports,用于列举编译器对各种特性的支持。而结构体Compiler则包含了一个int类型成员spp。由于各种特性都具有“支持”和“不支持”两种状态,所以为了节省存储空间,我们让每个FeatureSupports的枚举值占据一个特定的比特位置,并在使用时通过“或”运算压缩地存储在Compiler的spp成员中( 即bitset的概念)。在使用时,则可以通过检查spp的某位来判断编译器对特性是否支持。

有的时候这样的枚举值会非常多,而且还会在代码维护中不断增加。那么代码编写者必须想出办法来对这些枚举进行校验,比如查验一下 是否有重位等。在本例中程序员的做法是使用一个“最大枚举”SMAX,并通过比较SMAX - 1与所有其他枚举的或运算值来验证是否有枚举值重位。可以想象,如果SAssert被误定义为0x0001,表达式(SMAX- 1) == (C99 | ExtInt | SAssert | NoExcept)将不再成立。

在本例中我们使用了断言assert。但assert是一个运行时的断言,这意味着不运行程序我们将无法得知是否有枚举重位。在一些情况下,这是不可接受的,因为可能单次运行代码并不会调用到assert相关的代码路径。因此这样的校验最好是在编译时期就能完成。在一些C++的模板的编写中,我们可能也会遇到相同的情况,比如下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <cassert>
#include <cstring>
using namespace std;
template <typename T,typename U> int bit_copy(T& a, U& b) {
assert (sizeof(b) == sizeof(a));
memcpy(&a, &b, sizeof (b));
};
int main() {
int a = 0x2468;
double b
bit_copy(a, b) ;
}
//编译选项:g++ 2-5-3. cpp

代码中的assert是要保证a和b两种类型的长度一致,这样bit_copy才能够保证复制操作不会遇到越界等问题。这里我们还是使用assert的这样的运行时断言,但如果bit_copy不被调用,我们将无法触发该断言。实际上,正确产生断言的时机应该在模板实例化时,即编译时期。

利用语言规则实现静态断言的讨论非常多,比较典型的实现是开源库Boost内置的BOOST_STATIC_ASSERT断言机制( 利用sizeof操作符)。我们可以利用“除0”会导致编译器报错这个特性来实现静态断言。

1
2
3
4
#define assert_static(e) \
do { \
enum { assert_static__= 1/(e) }; \
} while (0)

在理解这段代码时,读者可以忽略do while循环以及enum这些语法上的技巧。真正起作用的只是1/(e)这个表达式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cstring>
using namespace std;

template <typename T,typename U> int bit_copy(T& a, U& b){
assert_static(sizeof(b) == sizeof(a)) ;
memcpy(&a, &b, sizeof(b)) ;
};

int main() {
int a = 0x2468;
double b;
bit_copy(a, b) ;
}
//编译选项:g++ -std=c++11 2-5-4. cpp

结果如我们预期的,在模板实例化时我们会得到编译器的错误报告,读者可以实验一下在自己本机运行的结果。在我们的实验机上会输出比较长的错误信息,主要信息是除零错误。当然,读者也可以尝试一下Boost库内置的BOOST_STATIC_ASSERT,输出的主要信息是sizeof错误。但无论是哪种方式的静态断言,其缺陷都是很明显的:诊断信息不够充分,不熟悉该静态断言实现的程序员可能一时无法将错误对应到断言错误上,从而难以准确定位错误的根源。

在C++11标准中,引入了static_assert断言来解决这个问题。static_assert使用起来非常简单,它接收两个参数,一个是断言表达式,这个表达式通常需要返回一个bool值;一个则
是警告信息,它通常也就是一段字符串。我们可以用static_assert替换一下代码中bit_copy的声明。

1
2
3
template <typename t, typename u> int bit_copy(t& a, u& b) {
static_assert (sizeof(b) == sizeof (a) , "the parameters of bit_copy must have same width.") ;
};

那么再次编译代码清单2-9的时候,我们就会得到如下信息:

1
error: static assertion failed: "the parameters of bit_copy should have same width. "

这样的错误信息就非常清楚,也非常有利于程序员排错。而由于static_assert是编译时期的断言,其使用范围不像assert一样受到限制。在通常情况下,static_assert可以用于任何名字空间,如代码所示。

1
2
3
static_assert(sizeof(int) == 8"This 64-bit machine should follow this!") ;
int main() { return 0;}
//编译选项:g++ -std=C++11 2-5-5.ccpp

而在C++中,函数则不可能像代码中的static_assert这样独立于任何调用之外运行。因此将static_assert写在函数体外通常是较好的选择,这让代码阅读者可以较容易发现static_assert为断言而非用户定义的函数。而反过来讲,必须注意的是,static_assert的断言表达式的结果必须是在编译时期可以计算的表达式,即必须是常量表达式。如果读者使用
了变量,则会导致错误,如代码所示。

1
2
3
4
int positive (const int n) {
static_assert(n > 0"value must >0") ;
}
//编译选项:g++ -std=C++11 -c 2-5-6. ccpp

代码使用了参数变量n(虽然是个const参数),因而static_assert无法通过编译。对于此例,如果程序员需要的只是运行时的检查,那么还是应该使用assert宏。

noexcept修饰符与noexcept操作符

相比于断言适用于排除逻辑上不可能存在的状态,异常通常是用于逻辑上可能发生的错误。在C++98中,我们看到了一套完整的不同于C的异常处理系统。通过这套异常处理系统,C++拥有了远比C强大的异常处理功能。

在异常处理的代码中,程序员有可能看到过如下的异常声明表达形式:.

1
void excpt_func() throw(int, double){ ... }

excpt_func函数声明之后,我们定义了一个动态异常声明throw(int, double),该声明指出了excpt_func可能抛出的异常的类型。事实上,该特性很少被使用,因此在C++11中被弃用了,而表示函数不会抛出异常的动态异常声明throw()也被新的noexcept异常声明所取代。

noexcept表示其修饰的函数不会抛出异常。不过与throw()动态异常声明不同的是,在C++11中如果noexcept修饰的函数抛出了异常,编译器可以选择直接调用std:terminate()函数来终止程序的运行,这比基于异常机制的throw()在效率上会高一些。

从语法上讲,noexcept修饰符有两种形式,一种就是简单地在函数声明后加上noexcept关键字。比如:

1
void excpt_func() noexcept ; .

另外一种则可以接受一个常量表达式作为参数,如下所示:

1
void excpt_func() noexcept ( 常量表达式);

常量表达式的结果会被转换成一个bool类型的值。该值为true,表示函数不会抛出异常,反之,则有可能抛出异常。这里,不带常量表达式的noexcept相当于声明了noexcept(true),即不会抛出异常。

在通常情况下,在C++11中使用noexcept可以有效地阻止异常的传播与扩散。我们可以看看下面这个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;
void Throw() { throw 1; }
void NoBlockThrow() { Throw(); }
void BlockThrow() noexcept { Throw() ;}

int main() {
try {
Throw() ;
} catch(...) {
cout << "Found throw." << endl;
// Found throw.
}
try {
NoBlockThrow() ;
} catch(...) {
cout << "Throw is not blocked." << endl ;
// Throw is not blocked .
}
try {
BlockThrow(); // terminate called after throwing an instance of ' int '
} catch(...) {
cout << "Found throw 1." << endl;
}
}
// 编译选项:g++ -std=c++11 2-6-1. ccpp .

在代码中,我们定义了Throw函数,该函数的唯一作用是抛出一个异常。而NoBlockThrow是一个调用Throw的普通函数,BlockThrow则是一个noexcept修饰的函数。从main的运行中我们可以看到,NoBlockThrow会让Throw函数抛出的异常继续抛出,直到main中的catch语句将其捕捉。而BlockThrow则会直接调用std::terminate中断程序的执行,从而阻止了异常的继续传播。从使用效果上看,这与C++98中的throw()是一样的。

noexcept作为一个操作符时,通常可以用于模板。比如:

1
2
template <class T>
void fun() noexcept (noexcept (T())) {}

这里,fun函数是否是一个noexcept的函数,将由T()表达式是否会抛出异常所决定。这里的第二个noexcept就是一个noexcept操作符。当其参数是一个有可能抛出异常的表达式的时候,其返回值为false,反之为true。这样一来,我们就可以使模板函数根据条件实现noexcept修饰的版本或无noexcept修饰的版本。从泛型编程的角度看来,这样的设计保证了关于“ 函数是否抛出异常”这样的问题可以通过表达式进行推导。因此这也可以视作C++11为了更好地支持泛型编程而引入的特性。

虽然noexcept修饰的函数通过std::terminate的调用来结束程序的执行的方式可能会带来很多问题,比如无法保证对象的析构函数的正常调用,无法保证栈的自动释放等,但很多时候,“暴力”地终止整个程序确实是很简单有效的做法。比如在C++98中,存在着使用throw()来声明不抛出异常的函数。

1
2
3
4
5
6
template<class T> class A {
public:
static constexpr T min() throw() { return T() ; }
static constexpr T max() throw(){ return T() ;
static constexpr T lowest() throw(){ return T() ;}
}

而在C++11中,则使用noexcept来替换throw()

1
2
3
4
5
template<class T> class A {
public:
static constexpr T min() noexcept { return T() ; }
static constexpr T max() noexcept { return T() ; }
static constexpr T lowest() noexcept{ return T(); }

又比如,在C++98中,new可能会包含一些抛出的std::bad_alloc 异常。

1
2
void* operator new(std::size_t) throw(std::bad_alloc) ;
void* operator new[] (std::size_t) throw(std::bad_alloc) ;

而在C++11中,则使用noexcept(false)来进行替代。

1
2
void* operator new(std::size_t) noexcept (false) ;
void* operator new[] (std::size_t) noexcept (false) ;

当然,noexcept更大的作用是保证应用程序的安全。比如一个类析构函数不应该抛出异常,那么对于常被析构函数调用的delete函数来说,C++11默认将delete函数设置成noexcept,就可以提高应用程序的安全性。

1
2
void operator delete (void*) noexcept ; 
void operator delete[] (void*) noexcept;

而同样出于安全考虑,C++11 标准中让类的析构函数默认也是noexcept(true)的。当然,如果程序员显式地为析构函数指定了noexcept,或者类的基类或成员有noexcept(false)的析构函数,析构函数就不会再保持默认值。我们可以看看下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
using namespace std;

struct A {
~A() { throw 1; }
};
struct B {
~B() noexcept (false) { throw 2; }
};
struct C {
B b;
};
int funA() { A a; }
int funB() { B b; }
int funC() { C c; }

int main() {
try {
funB() ;
} catch(...){
cout << "caught funB." << endl; // caught funB .
}
try {
funC() ;
} catch(...){
cout << "caught funC." << endl; // caught funC.
}
try {
funA(); // terminate called after throwing an instance of 'int'
} catch(...){
cout << "caught funA." << endl;
}
}
//编译选项:g++ -std=c++11 2-6-2.cpp

在代码中,无论是析构函数声明为noexcept(false)的类B,还是包含了B类型成员的类C,其析构函数都是可以抛出异常的。只有什么都没有声明的类A,其析构函数被默认为noexcept(true),从而阻止了异常的扩散。这在实际的使用中,应该引起程序员的注意。

快速初始化成员变量

在C++98中,支持了在类声明中使用等号“=”加初始值的方式,来初始化类中静态成员常量。这种声明方式我们也称之为“就地”声明。就地声明在代码编写时非常便利,不过C++98对类中就地声明的要求却非常高。如果静态成员不满足常量性,则不可以就地声明,而且即使常量的静态成员也只能是整型或者枚举型才能就地初始化。而非静态成员变量的初始化则必须在构造函数中进行。我们来看看下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Init {
public:
Init(): a(0){}
Init(int d): a(d) {}
private:
int a;
const static int b = 0;
int c = 1;
// 成员,无法通过编译
static int d = 0;
// 成员,无法通过编译
static const double e = 1.3;
// 非整型或者枚举,无法通过编译
static const char * const f = "e"; // 非整型或者枚举,无法通过编译
};
//编译选项:g++ -c 2-7-1.ccpp

在代码中,成员c、静态成员d、静态常量成员e以及静态常量指针f的就地初始化都无法通过编译。在C++11中,标准允许非静态成员变量的初始化有多种形式。具体而言,除了初始化列表外,在C++11中,标准还允许使用等号=或者花括号{}进行就地的非静态成员变量初始化。比如:

1
struct init{ int a = 1; double b {1.2}; };

在这个名叫init的结构体中,我们给了非静态成员a和b分别赋予初值1和1.2。这在C++11中是一个合法的结构体声明。花括号式的集合(列表)初始化已经成为C++11中初始化声明的一种通用形式。不过在C++11中,对于非静态成员进行就地初始化,两者却并非等价的,如代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <string>
using namespace std;
struct C {
C(int i):c(i){};
int c;
};

struct init {
int a = 1;
string b("he1lo"); // 无法通过编译
C c(1);
//无法通过编译
};
//编译选项:g++ -std=c++11 -c 2-7-2. cpp

从代码中可以看到,就地圆括号式的表达式列表初始化非静态成员b和c都会导致编译出错。在C++11标准支持了就地初始化非静态成员的同时,初始化列表这个手段也被保留下来了。如果两者都使用,是否会发生冲突呢?我们来看下面这个例子,如代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <iostream>
using namespace std;
struct Mem {
Mem() { cout << "Mem default, num: " << num << endl; }
Mem(int i): num(i) { cout << "Mem, num: " << num << endl; }
int num = 2; // 使用=初始化非静态成员
};

class Group{
public:
Group() { cout << "Group default. val:" << val << endl; }
Group(int i): val('G'), a(i) { cout << "Group. val: " << val << endl; }
void NumOfA() { cout << "number of A:" << a.num << endl; }
void NumOfB() { cout << "number of B:" << b.num << endl; }
private:
char val{'g'}; // 使用{}初始化非静态成员
Mem a;
Mem b{19};
//使用{}初始化非静态成员
};

int main() {
Mem member;
// Mem default, num: 2
Group group;
// Mem default, num: 2
// Mem, num: 1
// Group default. val: g
group.NumOfA() ;
// number of A: 2
group.NumOfB() ;
// number of B: 19
Group group2(7) ;
// Mem, num: 7
// Mem, num: 1
// Group. val: G
group2.NumOfA() ;
// number of A: 7
group2.NumOfB() ;
// number of B: 19
}
//编译选项:g++ 2-7-3.ccpp -std=C++11 .

在代码中,我们定义了有两个初始化函数的类Mem,此外还定义了包含两个Mem对象的Group类。类Mem中的成员变量num,以及classGroup中的成员变量a、b、val,采用了与C++98完全不同的初始化方式。

相对于传统的初始化列表,在类声明中对非静态成员变量进行就地列表初始化可以降低程序员的工作量。当然,我们只在有多个构造函数,且有多个成员变量的时候可以看到新方式带来的便利。

非静态成员的sizeof

在C++引入类(class) 类型之后, sizeof 的定义也随之进行了拓展。不过在C++98标准中,对非静态成员变量使用sizeof是不能够通过编译的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;
struct People {
public:
int hand;
static People *all;
};

int main() {
People P;
cout << sizeof (p.hand) << endl;
// C++98 中通过,C++11 中通过
cout << sizeof (People::all) << endl;
// C++98中通过,C++11 中通过
cout << sizeof (People: :hand) << endl;
// C++98 中错误,C++11中通过
}
//编译选项:g++ 2-8-1. ccpp

注意最后一个sizeof操作。在C++11中,对非静态成员变量使用sizeof操作是合法的。而在C++98中,只有静态成员,或者对象的实例才能对其成员进行sizeof操作。因此如果读者只有一个支持C++98标准的编译器,在没有定义类实例的时候,要获得类成员的大小,我们通常会采用以下的代码:

1
sizeof (( (People*)0) ->hand) ;

这里我们强制转换0为一个People类的指针,继而通过指针的解引用获得其成员变量,并用sizeof求得该成员变量的大小。而在C++11中,我们无需这样的技巧,因为sizeof可以作用的表达式包括了类成员表达式。

1
sizeof (People::hand) ;

扩展的friend语法

friend 关键字用于声明类的友元,友元可以无视类中成员的属性。C++11对friend关键字进行了一些改进。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Poly;
typedef Poly P;

class LiLei {
friend class Poly; // C++98 通过,C++11 通过
};
class Jim
friend Poly;
// C++98 失败,C++11 通过
};
class HanMeiMei {
friend P;
// C++98失败,C++11 通过
};
//编译选项:g++ -std=C++11 2-9-1.cpp

在代码中,我们声明了3个类型:LiLei、 Jim和HanMeiMei,它们都有一个友元类型Poly。从编译通过与否的状况中我们可以看出,在C++11中,声明一个类为另外一个类的友元时,不再需要使用class关键字。

我们使用Poly的别名P来声明友元,程序员借此可以为类模板声明友元。比如下面这个例子。

1
2
3
4
5
6
7
8
9
class P;

template <typename T> class People {
friend T;
};
People<P> PP;
//类型P在这里是People类型的友元
People<int> Pi; //对于int类型模板参数,友元声明被忽略
//编译选项:g++ -std=c++11 2-9-2.cpp

从代码中我们看到,对于People这个模板类,在使用类P为模板参数时,P是People<P>的一个friend 类。而在使用内置类型int作为模板参数的时候,People<int>会被实例化为一个普通的没有友元定义的类型。这样一来,我们就可以在模板实例化时才确定一个模板类是否有友元,以及谁是这个模板类的友元。

final/override 控制

在通常情况下,一旦在基类A中的成员函数fun被声明为virtual 的,那么对于其派生类B而言,fun总是能够被重载的(除非被重写了)。有的时候我们并不想fun在B类型派生类中被重载,那么,C++98没有方法对此进行限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
using namespace std;
class Mathobject {
public:
virtual double Arith() = 0;
virtual void Print() = 0;
};
class Printable : public Mathobject {
public:
double Arith() = 0
void Print() {//在C++98中我们无法阻止该接口被重写
cout << "Output is: " << Arith() << endl;
}
};

class Add2 : public Printable {
public:
Add2 (double a, double b): x(a), y(b) {}
double Arith() { returnx + y; }
private:
double x,y;
};

class Mu13 : public Printable {
public:
Mul3 (double a, double b, double c): x(a), y(b), z(c) {}
double Arith() { return x*y*z; }
private:
double x,y, z;
};
//编译选项:g++ 2-10-1. cpp

我们的基础类MathObject定义了两个接口:ArithPrint。类Printable则继承于MathObject并实现了Print接口。接下来,Add2Mul3为了使用MathObject的接口和PrintablePrint的实现,于是都继承了Printable

final关键字的作用是使派生类不可覆盖它所修饰的虚函数。C++11 也采用了类似的做法。

1
2
3
4
5
6
7
8
9
10
11
struct object{
virtual void fun() = 0;
};
struct Base : public object {
void fun() final; // 声明为final
};
struct Derived : public Base {
void fun();
//无法通过编译
};.
//编译选项:g++ -c -std=c++11 2-10-2. cpp

派生于ObjectBase类重载了Objectfun接口,并将本类中的fun函数声明为final的。那么派生于BaseDerived类对接口fun的重载则会导致编译时的错误。

基类中的虚函数可以使用final关键字,不过这样将使用该虚函数无法被重载,也就失去了虚函数的意义。如果不想成员函数被重载,程序员可以直接将该成员函数定义为非虚的。而final通常只在继承关系的“中途”终止派生类的重载中有意义。

在C++中重载还有一个特点,就是对于基类声明为virtual的函数,之后的重载版本都不需要再声明该重载函数为virtual。即使在派生类中声明了virtual,该关键字也是编译器可以忽略的。

在C++11中为了帮助程序员写继承结构复杂的类型,引入了虚函数描述符override,如
果派生类在虚函数声明时使用了override描述符,那么该函数必须重载其基类中的同名函数,否则代码将无法通过编译。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Base {
virtual void Turing() = 0;
virtual void Dijkstra() = 0;
virtual void VNeumann(int g) = 0;
virtual void DKnuth() const;
void Print() ;
};
struct DerivedMid: public Base {
// void VNeumann (double g) ;
//接口被隔离了,曾想多一个版本的VNeumann函数
};
struct DerivedTop : public DerivedMid {
void Turing() override ;
void Dikjstral() override;
//无法通过编译,拼写错误,并非重载
void VNeumann (double g) override;
//无法通过编译,参数不一致,并非重载
void DKnuth() override ;
//无法通过编译,常量性不一致,并非重载
void Print() override;
//无法通过编译,非虚函数重载
}
//编译选项:g++ -c -std=C++11 2-10-3. cpp

我们在基类Base中定义了一些virtual的函数(接口)以及一个非virtual的函数Print。其派生类DerivedMid中,基类的Base的接口都没有重载,DerivedTop的作者在重载所有Base类的接口的时候,犯下了3种不同的错误:

  • 函数名拼写错,Djjkstra 误写作了Dikjstra。
  • 函数原型不匹配,VNeumann 函数的参数类型误做了double类型,而DKnuth的常量性在派生类中被取消了。
  • 重写了非虛函数Print。

模板函数的默认模板参数

在C++11中模板和函数一样,可以有默认的参数。这就带来了一定的复杂性。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;
//定义一个函数模板
template <typename T> void TempFun(T a) {
cout << a << endl;
}
int main() {
TempFun(1) ;
// 1,(实例化为TempFun<const int>(1))
TempFun("1") ;
// 1,(实例化为TempFun<const char *>("1"))
}
//编译选项:g++ 2-11-1. cpp

在代码清单2-26中,当编译器解析到函数调用fun(1)的时候,发现fun是一个函数模板。这时候编译器就会根据实参1的类型const int推导实例化出模板函数void TempFun<const int>(int),再进行调用。相应的,对于fun("1")来说也是类似的,不过编译器实例化出的模板函数的参数的类型将是const char *

函数模板在C++98中与类模板一起被引入,不过在模板类声明的时候,标准允许其有默认模板参数。默认的模板参数的作用好比函数的默认形参。

1
2
3
4
5
6
7
void DefParm(int m = 3) {} // C++98 编译通过,C++11编译通过
template <typename T = int>
class DefClass {};
// C++98 编译通过,C++11 编译通过
template <typename T = int>
void DefTemcpparm() {}; // C++98 编译失败,C++11编译通过
//编译选项:g++ -c -std=c++11 2-11-1. ccpp

可以看到,DefTemcpparm函数模板拥有一个默认参数。使用仅支持C++98的编译器
编译,DefTemcpparm的编译会失败,而支持C++11的编译器则毫无问题。不过在语法上,与类模板有些不同的是,在为多个默认模板参数声明指定默认值的时候,程序员必须遵照“从右往左”的规则进行指定。

而这个条件对函数模板来说并不是必须的。

1
2
3
4
5
6
7
template<typename T1, typename T2 = int> class DefClass1 ;
template<typename T1 = int, typename T2> class DefClass2; //无法通过编译
template<typename T,int i = 0> class DefClass3;
template<int i = 0typename T> class DefClass4; //无法通过编译
template<typename T1 = int, typename T2> void DefFunc1(T1 a,T2 b) ;
template<int i = 0typename T> void DefFunc2(T a) ;
//编译选项:g++ -c -std=c++11 2-11-2. cpp

从代码清单2-28中可以看到,不按照从右往左定义默认类模板参数的模板类DefClass2DefClass4都无法通过编译。而对于函数模板来说,默认模板参数的位置则比较随意。可以看到DefFunc1DefFunc2都为第一个模板参数定义了默认参数,而第二个模板参数的默认值并没有定义,C++11 编译器却认为没有问题。

函数模板的参数推导规则也并不复杂。简单地讲,如果能够从函数实参中推导出类型的话,那么默认模板参数就不会被使用,反之,默认模板参数则可能会被使用。

1
2
3
4
5
6
7
8
9
10
template <class T, class U = double>
voidf(T t = 0, U u = 0);

void g() {
f(1, 'c'); // f<int, char>(1, 'c')
f(1); // f<int, double>(1,0),使用了默认模板参数double
f(); // 错误:T无法被推导出来
f<int>(); // f<int , double>(0,0),使用了默认模板参数double
f<int, char>(); // f<int,char>(0,0)
//编译选项:g++ -std=c++11 2-11-3. cpp

我们定义了一个函数模板f,f同时使用了默认模板参数和默认函数参数。可以看到,由于函数的模板参数可以由函数的实参推导而出,所以在f(1)这个函数调用中,我们实例化出了模板函数的调用应该为f<int,double>(1,0),其中,第二个类型参数U使用了默认的模板类型参数double,而函数实参则为默认值0。类似地,f<int>()实例化出的模板函数第二参数类型为double,值为0。而表达式f()由于第一类型参数T的无法推导,从而导致了编译的失败。而通过这个例子我们也可以看到,默认模板参数通常是需要跟默认函数参数一起使用的。

外部模板

为什么需要外部模板

通常情况下,我们在一个文件中a.c中定义了一个变量int i,而在另外一个文件b.c中想使用它,这个时候我们就会在没有定义变量i的b.c文件中做一个外部变量的声明。比如:

1
extern int i;

这样做的好处是,在分别编译了a.c 和b.c之后,其生成的目标文件a.o和b.o中只有i
这个符号的一份定义。如果b.c中我们声明int i的时候不加上extern的话,那么i就会实实在在地既存在于a.o的数据区中,也存在于b.o的数据区中。那么链接器在链接a.o和b.o的时候,就会报告错误,因为无法决定相同的符号是否需要合并。

而对于函数模板来说,现在我们遇到的几乎是一模一样的问题。我们在一个test.h的文件中声明了如下一个模板函数:

1
template <typename T> void fun(T) {}

在第一个test1.cpp文件中,我们定义了以下代码:

1
2
#include "test .h"
void test1() { fun(3); }

而在另一个test2.cpp文件中,我们定义了以下代码:

1
2
#include "test. h"
void test2() { fun(4); }

由于两个源代码使用的模板函数的参数类型一致,所以在编译test1.cpp的时候,编译器实例化出了函数fun<int>(int),而当编译test2.cpp的时候,编译器又再一次实例化出了函数fun<int>(int)。那么可以想象,在test1.o目标文件和test2.o目标文件中,会有两份一模一样的函数fun<int>(int)代码。

在链接的时候,链接器通过一些编译器辅助的手段将重复的模板函数代码fun<int>(int)删除掉,只保留了单个副本。由于编译器会产生大量冗余代码,会极大地增加编译器的编译时间和链接时间。解决这个问题的方法基本跟变量共享的思路是一样的,就是使用“外部的”模板。

显式的实例化与外部模板的声明

外部模板的使用实际依赖于C++98中一个已有的特性,即显式实例化(Explicit Instantiation)。显式实例化的语法很简单,比如对于以下模板:

1
template <typename T> void fun(T) {}

我们只需要声明:

1
template void fun<int> (int) ;

这就可以使编译器在本编译单元中实例化出fun<int>(int)版本的函数。而在C++11标准中,又加入了外部模板( Exterm Template)的声明。语法上,外部模板的声明跟显式的实例化差不多,只是多了一个关键字extern。对于上面的例子,我们可以通过:

1
extern template void fun<int> (int) ;

这样的语法完成一个外部模板的声明。

那么回到一开始我们的例子,来修改一下我们的代码。首先,在test1.cpp做显式地实
例化:

1
2
3
#include "test.h"
template void fun<int>(int); // 显示地实例化
void test1() { fun(3); }

接下来,在test2.cpp中做外部模板的声明:

1
2
3
#include "test.h"
extern template void fun<int> (int); //外部模板的声明
void test1() { fun(3) ;}

这样一来,在test2.o中不会再生成fun<int>(int)的实例代码,编译器也不用每次都产生一份fun<int>(int)的代码,所以可以减少编译时间。这里也可以把外部模板声明放在头文件中,这样所有包含test.h的头文件就可以共享这个外部模板声明了。

在使用外部模板的时候,我们还需要注意以下问题:如果外部模板声明出现于某个编译
单元中,那么与之对应的显示实例化必须出现于另一个编译单元中或者同一个编译单元的后续代码中;外部模板声明不能用于一个静态函数(即文件域函数),但可以用于类静态成员函数。

通用为本,专用为末

继承构造函数

如果派生类要使用基类的构造函数,通常需要在构造函数中显式声明。比如下面的例子:

1
2
struct A { A(int i) {} };
struct B : A { B(inti) : A(i) {} };

B派生于A,B又在构造函数中调用A的构造函数,从而完成构造函数的“传递”。这在C++代码中非常常见。当然,这样的设计有一定的好处,尤其是B中有成员的时候。

1
2
3
4
5
6
struct A { A(int i) {} };
struct B : A {
B(int i) : A(i), d(i) {}
int d;
};
//编译选项:g++ -c 3-1-1. ccpp

倘若基类中有大量的构造函数,而派生类却只有一些成员函数时,那么对于派生类而言,其构造就等同于构造基类。这时候问题就来了,在派生类中我们写的构造函数完完全全就是为了构造基类。那么为了遵从于语法规则,我们还需要写很多的“透传”的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
struct A {
A(int i) {}
A(double d, int i) {}
A(float f, int i, const char* c) {}
// ...
};
struct B : A{
B(int i): A(i) {}
B(double d, int i): A(d, i) {}
B(float f, int i, const char* c): A(f, i, c){}
// ...
virtual void Extrainterface() {}

我们的基类A有很多的构造函数的版本,而继承于A的派生类B实际上只是添加了一个接口Extralnterface。那么如果我们在构造B的时候想要拥有A这样多的构造方法的话,就必须一一“透传”各个接口。这无疑是相当不方便的。事实上,在C++中已经有了一个好用的规则,就是如果派生类要使用基类的成员函数的,可以通过using声明( using-declaration)来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
include <iostream>
using namespace std;

struct Base {
void f (double i) { cout << "Base:" << i << endl; }
};

struct Derived : Base {
using Base::f;
void f(int i) { cout << "Derived:" << i << endl; }
};

int main () {
Base b;
b.f(4.5); // Base: 4.5

Derived d;
d.f(4.5); // Base: 4.5
}

派生类中的f函数接受int类型为参数,而基类中接受double类型的参数。这里我们使用了using声明,声明派生类Derived也使用基类版本的函数f。这样一来,派生类中实际就拥有了两个f函数的版本。可以看到,我们在main函数中分别定义了Base变量b和 Derived变量d,并传入浮点字面常量4.5,结果都会调用到基类的接受double为参数的版本。

在C++11中,这个想法被扩展到了构造函数上。子类可以通过使用 using声明来声明继承基类的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
struct A {
A(int i) {}
A(double d, int i) {}
A(float f, int i, coust char* c) {}
// ...
};

struct B : A {
using A::A;
// ...
virtual void ExtraInterface() {}
};

这里我们通过using A::A的声明,把基类中的构造函数悉数继承到派生类B中。C++11标准继承构造函数被设计为跟派生类中的各种类默认函数(默认构造、析构、拷贝构造等样,是隐式声明的。这意味着如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。这无疑比“透传”方案总是生成派生类的各种构造函数更加节省目标代码空间。

有的时候,基类构造函数的参数会有默认值。对于继承构造函数来讲,参数的默认值是不会被继承的。事实上,默认值会导致基类产生多个构造函数的版本,这些函数版本都会被派生类继承。

1
2
3
4
5
6
7
struct A {
A (int a = 3, double d = 2.4) {}
};

struct B : A {
using A::A;
};

我们的基类的构造函数A(int a=3, double=2.4)有一个接受两个参数的构造函数,且两个参数均有默认值。那么A到底有多少个可能的构造函数的版本呢?事实上,B可能从A中继承来的候选继承构造函数有如下一些:

  • A(int=3, double =2.4);这是使用两个参数的情况。
  • A(int=3);这是减掉一个参数的情况。
  • A(const A&);这是默认的复制构造函数
  • A()这是不使用参数的情况。

相应地,B中的构造函数将会包括以下一些:

  • B(int, double);这是一个继承构造函数
  • B(int);这是减少掉一个参数的继承构造函数。
  • B(const B&);这是复制构造函数,这不是继承来的。
  • B();这是不包含参数的默认构造函数。

有的时候,我们还会遇到继承构造函数“冲突”的情况。这通常发生在派生类拥有多个基类的时候。多个基类中的部分构造函数可能导致派生类中的继承构造函数的函数名、参数(有的时候,我们也称其为函数签名)都相同,那么继承类中的冲突的继承构造函数将导致不合法的派生类代码。

1
2
3
4
5
6
7
struct A { A(int) {} };
struct B { B(int) {} };

struct C: A, B {
using A::A;
using B::B;
};

A和B的构造函数会导致C中重复定义相同类型的继承构造函数这种情况下,可以通过显式定义继承类的冲突的构造函数,阻止隐式生成相应的继承构造函数来解决冲突。比如:

1
2
3
4
5
struct C: A, B{
using A::A;
using B::B;
C(int){}
};

其中的构造函数C(int)就很好地解决了继承构造函数的冲突问题。

另外我们还需要了解的一些规则是,如果基类的构造函数被声明为私有成员函数,或者派生类是从基类中虚继承的,那么就不能够在派生类中声明继承构造函数。此外,如果一旦使用了继承构造函数,编译器就不会再为派生类生成默认构造函数了

委派构造函数

通过委派其他构造函数,多构造函数的类编写将更加容易。我们能够将一个构造函数设定为“基准版本”,而其他构造函数可以通过委派“基准版本”来进行初始化。按照这个想法,我们可能会如下编写构造函数:

1
2
3
Info() { Initrest(); }
Info(int i) { this->Info(); type = i; }
Info(char e) { this->Info(); name = e;}

这里我们通过this指针调用我们的“基准版本”的构造函数。不过可惜的是,一般的编译器都会阻止this->Info()的编译。原则上,编译器不允许在构造函数中调用构造函数,即使参数看起来并不相同。

在C+11中,我们可以使用委派构造函数来达到期望的效果。更具体的,C++11中的委派构造函数是在构造函数的初始化列表位置进行构造的、委派的。

1
2
3
4
5
6
7
8
9
class Info {
public:
Info() { Initrest(); }
Info(int i) : Info() { type = i; }
Info(char e) : Info() { name = e;}
private:
void Initrest() {/* 其他初始化 */ }
int type {1};
char name {'a'};

我们在Info(int)Info(char)的初始化列表的位置,调用了“基准版本”的构造函数Info()。这里我们为了区分被调用者和调用者,称在初始化列表中调用“基准版本”的构造函数为委派构造函数(delegating constructor),而被调用的“基准版本”则为目标构造函数( target constructor)。

在C++11中,所谓委派构造,就是指委派函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式。当然,委派构造函数只能在函数体中为ype、name等成员赋初值。这是由于委派构造函数不能有初始化列表造成的。

在C++中,构造函数不能同时“委派”和使用初始化列表,所以如果委派构造函数要给变量赋初值,初始化代码必须放在函数体中。比如:

1
2
3
4
5
struct Rule1 {
int i;
Rule1(int a); i(a) {}
Rule1(): Rule1(40), i(1) {} //无法通过编译
};

Rule1的委派构造函数Rule1()的写法就是非法的。我们不能在初始化列表中既初始化成员,又委托其他构造函数完成构造。

事实上,在使用委派构造函数的时候,我们也建议程序员抽象出最为“通用”的行为做目标构造函数。这样做一来代码清晰,二来行为也更加正确。在构造函数比较多的时候,我们可能会拥有不止一个委派构造函数,而一些目标构造函数很可能也是委派构造函数,这样一来,我们就可能在委派构造函数中形成链状的委派构造关系。

1
2
3
4
5
6
7
8
9
10
class Info {
public:
Info() : Info(1) { } //委派构造函数
Info(int i) : Info(i,'a') { } //既是目标构造函数,也是委派构造函数
Info(char e): Info(1, e) { }
private:
Info(int i, char e) : type(i), name(e){/* 其他初始化 */ }//目标构造函数
int type;
char name;
};

这里我们使Info()委托Info(int)进行构造,而Info(int)又委托Info(int, char)进行构造。在委托构造的链状关系中,有一点程序员必须注意,就是不能形成委托环( delegation cycle)。比如

1
2
3
4
5
6
struct Rule2 {
int i, c;
Rule2(): Rule2(2) {}
Rule2(int i): Rule2('c') { }
Rule2(char c): Rule2(2) { }
};

Rule2定义中,Rule2()Rule2(int)Rule2(char)都依赖于别的构造函数,形成环委托构造关系。这样的代码通常会导致编译错误。

右值引用:移动语义和完美转发

指针成员与拷贝构造

在类中包含了一个指针成员特别小心拷贝构造函数的编写,因为一不小心,就会出现内存泄露。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
class HasPtrMem{
public:
HasPtrMem(): d(new int(0)){}
~HasPtrMem() {
delete d;
}
int *d; //指针成员d
};
int main(){
HasPtrMem a;
HasPtrMem b(a);
cout<<*a.d<<endl;//0
cout<<*b.d<<endl;//0
}

我们定义了一个HasptrMem的类。这个类包含一个指针成员,该成员在构造时接受一个new操作分配堆内存返回的指针,而在析构的时候则会被delete操作用于释放之前分配的堆内存。在main函数中,我们声明了HasPtrMem类型的变量a,又使用a初始化了变量b。按照C++的语法,这会调用HasptrMem的拷贝构造函数。a.db.d都指向了同一块堆内存。在main作用域结束的时候,a和b的析构函数纷纷被调用,当其中之一完成析构之后(比如b),那么a.d就成了一个“悬挂指针”(dangling pointer),因为其不再指向有效的内存了。

这样的拷贝构造方式,在C++中也常被称为“浅拷贝”(shallow copy)。而在未声明构造函数的情况下,C++也会为类生成一个浅拷贝的构造函数。通常最佳的解决方案是用户自定义拷贝构造函数来实现“深拷贝”(deep copy)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include<iostream>
using namespace std;
class HasptrMem {
public:
HasptrMem(): d(new int (0)) { }
HasptrMem(HasptrMem & h) : d(new int(*h.d)) { } //拷贝构造函数,从堆中分配内存,并用*h.d初始化
~HasptrMem() { delete d; }
int * d;
}
int main(){
HasptrMem a;
HasptrMem b(a);
cout << *a.d << endl;
cout << *b.d << endl;
}

我们为HasptrMem添加了一个拷贝构造函数。拷贝构造函数从堆中分配新内存,将该分配来的内存的指针交还给d,又使用*(h.d)对d进行了初始化。通过这样的方法,就避免了悬挂指针的困扰。

移动语义

拷贝构造函数中为指针成员分配新的内存再进行内容拷贝的做法在C++编程中几乎被视为是不可违背的。不过在一些时候,我们确实不需要这样的拷贝构造语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
using namespace std;
class HasPtrMem{
public:
HasPtrMem(): d(new int(0)){
cout<<"Construct:" << ++n_cstr<<endl;
}
HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){
cout<<"Copy construct:"<< ++n_cptr<<endl;
} //拷贝构造函数,从堆中分配内存,并用*h.d初始化
~HasPtrMem() {
cout<<"Destruct:"<<++n_dstr<<endl;
}
int *d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
};
int HasPtrMem::n_cstr=0;
int HasPtrMem::n_dstr=0;
int HasPtrMem::n_cptr=0;
HasPtrMem GetTemp(){
return HasPtrMem();
}
int main(){
HasPtrMem a=GetTemp();
}

我们声明了一个返回一个HasptrMem变量的函数。为了记录构造函数、拷贝构造函数,以及析构函数调用的次数,我们使用了一些静态变量。在main函数中,我们简单地声明了一个HasptrMem的变量a,要求它使用Gettemp的返回值进行初始化。编译运行该程序,我们可以看到下面的输出

1
2
3
4
5
Construct: 1
Copy construct: 1
Destruct: 1
Copy construct: 2
Destruct: 2

这里构造函数被调用了一次,这是在GetTemp函数中HasptrMem()表达式显式地调用了构造函数而打印出来的。而拷贝构造函数则被调用了两次。这两次一次是从GetTemp函数中HasPtrmeme()生成的变量上拷贝构造出一个临时值,以用作GetTemp的返回值,而另外一次则是由临时值构造出main中变量a调用的。对应地,析构函数也就调用了3次。

如果HasptrMem的指针指向非常大的堆内存数据的话,那么拷贝构造的过程就会非常昂贵。按照C++的语义,临时对象将在语句结束后被析构,会释放它所包含的堆内存资源。而a在拷贝构造的时候,又会被分配堆内存。一种“新”方法是在构造时使得a.d指向临时对象的堆内存资。同时我们保证临时对象不释放所指向的堆内存,那么在构造完成后临时对象被析构,a就从中“偷”到了临时对象所拥有的堆内存资源。

C++11中,这样的“偷走”临时变量中资源的构造函数,就被称为“移动构造函数”而这样的“偷”的行为,则称之为“移动语义”( move semantics)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
using namespace std;
class HasPtrMem{
public:
HasPtrMem(): d(new int(3)){
cout<<"Construct:" << ++n_cstr<<endl;
}
HasPtrMem(const HasPtrMem&h): d(new int(*h.d)){
cout<<"Copy construct:"<< ++n_cptr<<endl;
} //拷贝构造函数,从堆中分配内存,并用*h.d初始化
HasPtrMem(HasPtrMem &&h):d(h.d){
h.d=nullptr;//将临时值得指针成员置空。
cout<<"Move construct:"<<++n_mvtr<<endl;
}
~HasPtrMem() {
delete d;
cout<<"Destruct:"<<++n_dstr<<endl;
}
int *d;
static int n_cstr;
static int n_dstr;
static int n_cptr;
static int n_mvtr;
};
int HasPtrMem::n_cstr=0;
int HasPtrMem::n_dstr=0;
int HasPtrMem::n_cptr=0;
int HasPtrMem::n_mvtr=0;
HasPtrMem GetTemp(){
HasPtrMem h;
cout<<"Resource from"<<__func__<<":"<<hex<<h.d<<endl;
return h;
}
int main(){
//HasPtrMem b;
HasPtrMem a=GetTemp();
cout<<"Resource from"<<__func__<<":"<<hex<<a.d<<endl;
}

HasptrMem类多了一个构造函数HasPtrmem(HasptrMem&&),这个就是我们所谓的移动构造函数。移动构造函数接受一个所谓的“右值引用”的参数。可以看到,移动构造函数使用了参数h的成员d初始化了本对象的成员d,而h的成员d随后被置为指针空值nullptr。这就完成了移动构造的全过程。

这里所谓的“偷”堆内存,就是指将本对象d指向h.d所指的内存这一条语句,相应地,我们还将h的成员d置为指针空值。这其实也是我们“偷”内存时必须做的。这是因为在移动构造完成之后,临时对象会立即被析构。如果不改变h.d(临时对象的指针成员)的话,则临时对象会析构掉本是我们“偷”来的堆内存。这样一来,本对象中的d指针也就成了个悬挂指针,如果我们对指针进行解引用,就会发生严重的运行时错误。

将指针置为nullptr只是让这个指针不再指向任何对象,并没有释放原来这个指针指向的对象的内存。结果:

1
2
3
4
5
6
7
8
9
//理论上的结果:
Construct:1
Resource from GetTemp:0x603010
Move construct:1
Destruct:1
Move construct:2
Destruct:2
Resource from main:0x603010
Destruct:3

可以看到,这里没有调用拷贝构造函数,而是调用了两次移动构造函数,移动构造的结果是,GetTemp中的h的指针成员h.dmain函数中的a的指针成员a.d的值是相同的,即h.da.d都指向了相同的堆地址内存。该堆内存在函数返回的过程中,成功地逃避了被析构的“厄运”,取而代之地,成为了赋值表达式中的变量a的资源。

事实上,移动语义并不是什么新的概念,在C++98/03的语言和库中,它已经存在了,比如:

  • 在某些情况下拷贝构遗函数的省略
  • 智能指针的拷贝
  • 链表拼接
  • 容器内的置换

以上这些操作都包含了从一个对象向另外一个对象的资源转移的过程,唯一欠缺的是统一的语法和语义的支持,来使我们可以使用通用的代码移动任意的对象。如果能够任意地使用对象的移动而不是拷贝,那么标准库中的很多地方的性能都会大大提高。

左值、右值与右值引用

在赋值表达式中,出现在等号左边的就是“左值”,而在等号右边的,则称为“右值”。C++中还有一个被广泛认同的说法,那就是可以取地址的有名字的就是左值,反之,不能取地址的、没有名字的就是右值。

更为细致地,在C++11中,右值是由两个概念构成的个是将亡值( xvalue, expiring Value),另一个则是纯右值( rvalue, Pure Rvalue)。其中纯右值就是C++98标准中右值的概念,讲的是用于辨识临时变量和一些不跟对象关联的值。比如非引用返回的函数返回的临时变量值就是一个纯右值。一些运算表达式,比如1+3产生的临时变量值,也是纯右值。而不跟对象关联的字面量值,比如:2、’c’、true,也是纯右值。此外,类型转换函数的返回值、 lambda表达式等,也都是右值。

而将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象,比如返回右值引用T&&的函数返回值、std:move的返回值,或者转换为T&&的类型转换函数的返回值。而剩余的,可以标识函数、对象的值都属于左值。在C++11的程序中,所有的值必属于左值、将亡值、纯右值三者之一。

通常情况下,我们只能是从右值表达式获得其引用。比如T && a = Returnrvalue();,这个表达式中,假设Returnrvalue返回一个右值,我们就声明了一个名为a的右值引用,其值等于Returnrvalue函数返回的临时变量的值。

为了区别于C++98中的引用类型,我们称C++98中的引用为“左值引用”。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。而其原因可以理解为是引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。

在上面的例子中,Returnrvalue函数返回的右值在表达式语句结束后,其生命也就终结了(通常我们也称其具有表达式生命期),而通过右值引用的声明,该右值又“重获新生”其生命期将与右值引用类型变量a的生命期一样。所以相比于以下语句的声明方式:

1
T b = Returnrvalue()

我们刚才的右值引用变量声明,就会少一次对象的析构及一次对象的构造。因为a是右值引用,直接绑定了Returnrvalue()返回的临时量,而b只是由临时值构造而成的,而临时量在表达式结東后会析构因应就会多一次析构和构造的开销。

能够声明右值引用a的前提是Returnrvalue返回的是一个右值。通常情况下,右值引用是不能够绑定到任何的左值的。比如下面的表达式就是无法通过编译的。

1
2
int c;
int &&d = c;

这样的语句是否能够通过编译呢?

1
2
T &e = ReturnRvalue();
const T & f = ReturnRvalue();

这里的答案是:e的初始化会导致编译时错误,而f则不会。

在常量左值引用在C++98标准中可以接受非常量左值、常量左值、右值对其进行初始化。而且在使用右值对其初始化的时候,常量左值引用还可以像右值引用一样将右值的生命期延长。不过相比于右值引用所引用的右值,常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。

即使在C++98中,我们也常可以使用常量左值引用来减少临时对象的开销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

struct Copyable {
Copyable() {}
Copyable(const Copyable &o) {
cout << "Copied" << endl;
}
};

Copyable ReturnRvalue() { return Copyable(); }
void AcceptVal(Copyable) {}
void AcceptRef(const Copyable & ) {}

int main() {
cout << "Pass by value: " << endl;
AcceptVal(ReturnRvalue()); // 临时值被拷贝传入
cout << "Pass by reference: " << endl;
AcceptRef(ReturnRvalue()); // 临时值被作为引用传递
}

我们声明了结构体Copyable,该结构体唯一作用是在被拷贝构造的时候打印一句话:Copied。两个函数,AcceptVal使用了值传递参数,AcceptRef使用了引用传递。在以ReturnRvalue返回的右值为参数的时候,AcceptRef就可以直接使用产生的临时值,而AcceptVal则不能直接使用临时对象。

编译运行代码,可以得到以下结果:

1
2
3
4
5
Pass by value:
Copied
Copied
Pass by reference:
Copied

可以看到,由于使用了左值引用,临时对象被直接作为函数的参数,而不需要从中拷贝。在C++11中,以右值引用为参数声明如下函数:

1
void AcceptRvalueRef(Copyable &&) { }

也同样可以减少临时变量拷贝的开销。进一步地,还可以在AcceptRvalueRef中修改该临时值。

如果我们这样实现函数

1
2
3
void AcceptRvalueRef(Copyable && s) {
Copyable news = std::move(s);
}

std::move(s)的作用是强制一个左值成为右值。该函数就是使用右值来初始化 Copyable变量news。使用移动语义的前提是Copyable还需要添加一个以右值引用为参数的移动构造函数,比如

1
Copyable(Copyable &&o) { /*实现移动语义*/ }

这样一来,如果Copyable类的临时对象中包含一些大块内存的指针,news就可以将临时值中的内存“窃”为己用,从而从这个以右值引用参数的AcceptRvalueRef函数中获得最大的收益。

如果Copyable没有移动构造函数,下列语句

1
Copyable news = std::move(s);

将调用以常量左值引用为参数的拷贝构造函数。这是一种非常安全的设计,移动不成,至少还可以执行拷贝。

为了语义的完整,C++11中还存在着常量右值引用,比如我们通过以下代码声明一个常量右值引用。

1
const T && crvalueref = ReturnRvalue();

我们列出了在C++11中各种引用类型可以引用的值的类型。值得注意的是只要能够绑定右值的引用类型,都能够延长右值的生命期。

有的时候,我们可能不知道一个类型是否是引用类型,以及是左值引用还是右值引用。标准库在<type_traits>头文件中提供了3个模板类:is_rvalue_referenceis_lvalue_referenceis_reference,可供我们进行判断。比如:

1
cout << is_rvalue_reference<string &&>::value;

std::move:强制转化为右值

在C++11中,标准库在utility中提供了一个有用的函数std::move,这个函数的功能是将一个左值强制转化为右值引用,继而我们可以通过右值引用使用该值,以用于移动语义。从实现上讲std::move基本等同于一个类型转换:

1
static_cast<T&&>(lvalue);

值得一提的是,被转化的左值,其生命期并没有随着左右值的转化而改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

class Moveable{
public:
Moveable():i(new int(3)) {}
~Moveable() { delete i; }
Moveable(const Moveable & m): i(new int(*m.i)) { }
Moveable(Moveable && m):i(m.i) {
m.i = nullptr;
}
int* i;
};

int main() {
Moveable a;

Moveable c(move(a)); // 会调用移动构造函数
cout << *a.i << endl; // 运行时错误
}

我们为类型Moveable定义了移动构造函数。这个函数定义本身没有什么问题,但调用的时候,使用了Moveable c(move(a));这样的语句。这里的a本来是个左值变量,通过std::move将其转换为右值。这样一来,a.i就被c的移动构造函数设置为指针空值。由于a的生命期实际要到main函数结束才结東,那么随后对表达式*a.i进行计算的时候,就会发生严重的运行时错误。这是个典型误用std::move的例子。

我们来看看正确例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
using namespace std;

class HugeMem{
public:
HugeMem(int size): sz(size > 0 ? size : 1) {
c = new int[sz];
}
~HugeMem() { delete [] c; }
HugeMem(HugeMem && hm): sz(hm.sz), c(hm.c) {
hm.c = nullptr;
}
int * c;
int sz;
};
class Moveable{
public:
Moveable():i(new int(3)), h(1024) {}
~Moveable() { delete i; }
Moveable(Moveable && m):
i(m.i), h(move(m.h)) { // 强制转为右值,以调用移动构造函数
m.i = nullptr;
}
int* i;
HugeMem h;
};

Moveable GetTemp() {
Moveable tmp = Moveable();
cout << hex << "Huge Mem from " << __func__
<< " @" << tmp.h.c << endl; // Huge Mem from GetTemp @0x603030
return tmp;
}

int main() {
Moveable a(GetTemp());
cout << hex << "Huge Mem from " << __func__
<< " @" << a.h.c << endl; // Huge Mem from main @0x603030
}

我们定义了两个类型:HugememMoveable,其中Moveable包含了一个HugeMem的对象。在Moveable的移动构造函数中,我们就看到了std::move函数的使用。该函数将m.h强制转化为右值,以迫使Moveable中的h能够实现移动构造。这里可以使用std::move,是因为m.hm的成员,既然m将在表达式结束后被析构,其成员也自然会被析构,因此不存在生存期不对的问题。

另外一个问题可能是std::move使用的必要性。可以接受右值的右值引用本身却是个左值。这里的m.h引用了一个确定的对象,而且m.h也有名字,可以使用&m.h取到地址,因此是个不折不扣的左值。不过这个左值确确实实会很快“灰飞烟灭”,因为拷贝构造函数在Moveable对象a的构造完成后也就结束了。那么这里使用std::move强制其为右值就不会有问题了。

事实上,为了保证移动语义的传递,程序员在编写移动构造函数的时候,应该总是记得使用std::move转换拥有形如堆内存、文件句柄等资源的成员为右值,这样一来,如果成员支持移动构造的话,就可以实现其移动语义。而即使成员没有移动构造函数,那么接受常量左值的构造函数版本也会轻松地实现拷贝构造,因此也不会引起大的问题。

移动语义的一些其他问题

移动语义一定是要修改临时变量的值。程序员在实现移动语义一定要注意排除不必要的const关键字。

在C++11中,拷贝/移动构造函数实际上有以下3个版本:

  • T object(T &)
  • T Object(const T &)
  • T object(T &&)

其中常量左值引用的版本是一个拷贝构造版本,而右值引用版本是一个移动构造版本。默认情况下,编译器会为程序员隐式地生成一个移动构造函数。不过如果程序员声明了自定义的拷贝构造函数、拷贝赋值函数、移动赋值函数、析构函数中的一个或者多个,编译器都不会再为程序员生成默认版本。默认的移动构造函数实际上跟默认的拷贝构造函数一样,只能做一些按位拷贝的工作。这对实现移动语义来说是不够的。

声明了移动构造函数、移动赋值函数、拷贝赋值函数和析构函数中的一个或者多个,编译器也不会再为程序员生成默认的拷贝构造函数。所以在C++11中,拷贝构造/赋值和移动构造/赋值函数必须同时提供,或者同时不提供,程序员才能保证类同时具有拷贝和移动语义。只声明其中一种的话,类都仅能实现一种语义。

只有移动语义构造的类型往往都是“资源型”的类型,比如说智能指针,文件流等,都可以视为“资源型”的类型。一些编译器现在也把ifstream这样的类型实现为仅可移动的。

在标准库的头文件<type_traits>里,我们还可以通过一些辅助的模板类来判断一个类型是否是可以移动的。比如is_move_constructibleis_trivially_move_constructibleis_nothrow_move_constructible,使用方法仍然是使用其成员value。比如

1
cout << is_move_constructible<UnknownType>::value;

就可以打印出UnknowType是否可以移动,这在一些情况下还是非常有用的。

而有了移动语义,还有一个比较典型的应用是可以实现高性能的置换(swap)函数。

1
2
3
4
5
6
template <class T>
void swap(T& a, T& b) {
T tmp(move(a));
a = move(b);
b = move(tmp);
}

如果T是可以移动的,那么移动构造和移动赋值将会被用于这个置换。整个过程,代码都只会按照移动语义进行指针交换,不会有资源的释放与申请。而如果T不可移动却是可拷贝的,那么拷贝语义会被用来进行置换。这就跟普通的置换语句是相同的了。

另外一个关于移动构造的话题是异常。程序员应该尽量编写不抛出异常的移动构造函数,通过为其添加一个noexcept关键字,可以保证移动构造函数中抛出来的异常会直接调用terminate程序终止运行,而不是造成指针悬挂的状态。而标准库中,我们还可以用一个std::move_if_noexcept的模板函数替代move函数。该函数在类的移动构造函数没有noexcept关键字修饰时返回一个左值引用从而使变量可以使用拷贝语义,而在类的移动构造函数有noexcept关键字时,返回一个右值引用,从而使变量可以使用移动语义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <utility>
using namespace std;

struct Maythrow {
Maythrow() {}
Maythrow(const Maythrow&) {
std::cout << "Maythorow copy constructor." << endl;
}
Maythrow(Maythrow&&) {
std::cout << "Maythorow move constructor." << endl;
}
};

struct Nothrow {
Nothrow() {}
Nothrow(Nothrow&&) noexcept {
std::cout << "Nothorow move constructor." << endl;
}
Nothrow(const Nothrow&) {
std::cout << "Nothorow move constructor." << endl;
}
};

int main() {
Maythrow m;
Nothrow n;

Maythrow mt = move_if_noexcept(m); // Maythorow copy constructor.
Nothrow nt = move_if_noexcept(n); // Nothorow move constructor.
return 0;
}

可以清楚地看到move_if_noexcept的效果。事实上,move_if_noexcept是以牺牲性能保证安全的一种做法,而且要求类的开发者对移动构造函数使用noexcept进行描述,否则就会损失更多的性能。

还有一个与移动语义看似无关,但偏偏有些关联的话题是,编译器中被称为RVO/NRVO的优化(RVO, 返回值优化)。事实上,在本节中大量的代码都使用了-fno-elide-constructors选项在g++/clang++中关闭这个优化,这样可以使读者在代码中较为容易地利用函数返回的临时量右值。

但若在编译的时候不使用该选项的话,读者会发现很多构造和移动都被省略了。对于下面这样的代码,一旦打开g++/clang++的RVO/NRVO,从ReturnValue函数中a变量拷贝/移动构造临时变量,以及从临时变量拷贝/移动构造b的二重奏就通通没有了。

1
2
A ReturnRvalue() { A a(); return a; }
A b = ReturnRvalue();

b变量实际就使用了ReturnRvalue函数中a的地址,任何的拷贝和移动都没有了。通俗地说,就是b变量直接“霸占”了a变量。这是编译器中一个效果非常好的一个优化。不过RVO/NRVO并不是对任何情况都有效。比如有些情况下,一些构造是无法省略的。还有一些情况,即使 RVO/NRVO完成了,也不能达到最好的效果

完美转发

所谓完美转发(perfect forwarding),是指在函数模板中,完全依照模板的参数的类型,将参数传递给函数模板中调用的另外一个函数。比如

1
2
template <typename T>
void IamForwording(T t) { IruncodeActually(t); }

这个简单的例子中,IamForwording是一个转发函数模板。而函数IruncodeActually则是真正执行代码的目标函数。对于目标函数IruncodeActually而言,它总是希望转发函数将参数按照传入Iamforwarding时的类型传递,而不产生额外的开销,就好像转发者不存在一样。通常程序员需要的是一个引用类型,引用类型不会有拷贝的开销。其次,则需要考虑转发函数对类型的接受能力。因为目标函数可能需要能够既接受左值引用,又接受右值引用。

以常量左值为参数的转发函数却会遇到一些尷尬,比如

1
2
3
void IrunCodeActually(int t) {}
template <typename T>
void IamForwording(const T& t) { IrunCodeActually(t); }

这里,由于目标函数的参数类型是非常量左值引用类型,因此无法接受常量左值引用作为参数,这样一来,虽然转发函数的接受能力很高,但在目标函数的接受上却出了问题。那么我们可能就需要通过一些常量和非常量的重载来解决目标函数的接受问题。C++11通过引入一条所谓“引用折叠”(reference collapsing)的新语言规则,并结合新的模板推导规则来完成完美转发。

在C++11以前,形如下列语句:

1
2
3
typedef const int T;
typedef T& TR;
TR& v = 1;

其中TR& v = 1这样的表达式会被编译器认为是不合法的表达式,而在C++11中,一旦出现了这样的表达式,就会发生引用折叠,即将复杂的未知表达式折叠为已知的简单表达式,具体如表。

这个规则并不难记忆,因为一旦定义中出现了左值引用,引用折叠总是优先将其折叠为值引用。而模板对类型的推导规则就比较简单,当转发函数的实参是类型X的一个左值引用,那么模板参数被推导为X&类型,而转发函数的实参是类型X的一个右值引用的话,那么模板的参数被推导为X&&类型。结合以上的引用折叠规则,就能确定出参数的实际类型进一步,我们可以把转发函数写成如下形式:

1
2
3
4
template<typename T>
void IamForwording (T && t) {
IrunCodeActually(static_cast<T &&>(t));
}

我们不仅在参数部分使用了T&&这样的标识,在目标函数传参的强制类型转换中也使用了这样的形式。比如我们调用转发函数时传入了一个X类型的左值引用,可以想象,转发函数将被实例化为如下形式:

1
2
3
void IamForwording(X& && t) {
IrunCodeActually(static_cast<X&&&>(t));
}

应用上引用折叠规则,就是:

1
2
3
void IamForwording(X& t) {
IrunCodeActually(static_cast<X&>(t));
}

这样一来,我们的左值传递就毫无问题了。实际使用的时候,IrunCodeActually如果接受左值引用的话,就可以直接调用转发函数。不过读者可能发现,这里调用前的 static_cast没有什么作用。事实上,这里的static_cast是留给传递右值用的。

而如果我们调用转发函数时传入了一个X类型的右值引用的话,我们的转发函数将被实例化为

1
2
3
void IamForwording(X&& && t) {
IrunCodeActually(static_cast<X&&&&>(t));
}

应用上引用折叠规则,就是:

1
2
3
void IamForwording(X&& t) {
IrunCodeActually(static_cast<X&&>(t));
}

这里我们就看到了static_cast的重要性。对于一个右值而言,当它使用右值引用表达式引用的时候,该右值引用却是个不折不扣的左值,那么我们想在函数调用中继续传递右值,就需要使用std::move来进行左右值的转换。而std::move通常就是一个static_cast。不过在C++11中,用于完美转发的函数却不再叫作move,而是另外一个名字:forward。所以我们可以把转发函数写成这样:

1
2
3
template <typename T>void IamForwording(T && t) {
IrunCodeActually(forward(t));
}

我们来看一个完美转发的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;
void RunCode(int && m) { cout << "rvalue ref" << endl; }
void RunCode(int & m) { cout << "lvalue ref" << endl; }
void RunCode(const int && m) { cout << "const rvalue ref" << endl; }
void RunCode(const int & m) { cout << "const lvalue ref" << endl; }

template <typename T>
void PerfectForward(T &&t) {
RunCode(forward<T>(t));
}

int main() {
int a;
int b;
const int c = 1;
const int d = 0;

PerfectForward(a); // lvalue ref
PerfectForward(move(b)); // rvalue ref
PerfectForward(c); // const lvalue ref
PerfectForward(move(d)); // const rvalue ref
}

我们使用了表3-1中的所有4种类型的值对完美转发进行测试,可以看到,所有的转发都被正确地送到了目的地。

完美转发的一个作用就是做包装函数,这是一个很方便的功能。可以用很少的代码记录单参数函数的参数传递状况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template < typename T, typename U>
void PerfectForward(T &&t, U& Func) {
cout << t << "\tforwarded." << endl;
Func(forward<T>(t));
}

void RunCode(double && m) { }
void RunHome(double && ) { }
void RunComp(double && m) { }

int main() {
PerfectForward(1.5, RunComp);
PerfectForward(8, RunCode);
PerfectForward(1.5, RunHome);
}

C++11标准库中我们可以看到大量完美转发的实际应用,一些很小巧好用的函数,比如make_pairmake_unique等都通过完美转发实现了。

显式转换操作符

我们确实应该阻止会产生歧义的隐式转换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include<iostream>
using namespace std;
template <typename T>
class Ptr {
public:
Ptr(T* p) : _p(p) {}
operator bool() const {
if (p != 0)
return true;
else
return false;
}
private:
T* _p;
};

int main() {
int a;
Ptr<int> p(&a); //自动转换为bool型,没有问题
if (p)
cout << "valid pointer. " << endl; // valid pointer
else
cout << "invalid pointer. " << endl;

Ptr<double> pd(0);
cout << p + pd << endl; // 1, 相加没有意义

我们定义了一个指针模板类型Ptr。我们为指针编写了自定义类型转换到bool类型的函数,这样的转换使得Ptr<int>Ptr<double>两个指针的加法运算获得了语法上的允许。

在C++11中,标准将explicit的使用范围扩展到了自定义的类型转换操作符上,以支持所谓的“显式类型转换”。explicit关键字作用于类型转换操作符上,意味着只有在直接构造目标类型或显式类型转换的时候可以使用该类型。所谓显式类型转换并没完全禁止从源类型到目标类型的转换,不过由于此时拷贝构造和非显式类型转换不被允许,那么我们通常就不能通过赋值表达式或者函数参数的方式来产生这样一个目标类型。

列表初始化

初始化列表

如标准程序库中的vector这样的容器,总是需要声明对象-循环初始化这样的重复动作,这对于使用模板的泛型编程无疑是非常不利的。在C++11中,集合(列表)的初始化已经成为C++语言的一个基本功能,在C++11中,这种初始化的方法被称为“初始化列表”。

1
2
3
4
int a[] = {1, 3, 5}; //C++98通过,C++11通过
int b[] {2, 4, 6}; //C++98失败,C++11通过
vector<int> c{1, 3, 5}; //C++98失败,C++11通过
map<int, float> d = {{1, 1.0f}, {2, 2.0f}, {5, 3.2f}}; //C++98失败,C+11通过

我们看到了变量b、c、d在C++98的情况下均无法通过编译,在C++11中,却由于列表初始化的存在而可以通过编译。这里,列表初始化可以在“{}”花括号之前使用等号,其效果与不带使用等号的初始化相同。

这样一来,自动变量和全局变量的初始化在C++11中被丰富了。程序员可以使用以下几种形式完成初始化的工作

  • 等号“=”加上赋值表达式,比如int a = 3+4
  • 等号“=”加上花括号式的初始化列表,比如int a = {3+4}
  • 圆括号式的表达式列表,比如int a (3+4)
  • 花括号式的初始化列表,比如int a {3+4}

而后两种形式也可以用于获取堆内存new操作符中,比如:

1
2
int i = new int(1);
double *d = new double{1.2f};

在C++11中,标准总是倾向于使用更为通用的方式来支持新的特性。标准模板库中容器对初始化列表的支持源自<initializer_list>这个头文件中initialize_list类模板的支持。程序员只要include了<initializer_list>头文件,井且声明一个以initialize_list<T>模板类为参数的构造函数,同样可以使得自定义的类使用列表初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Gender {boy, girl};
class People {
public:
People(initializer_list<pair<string, Gender>> l) {
auto i = l.begin();
for (; i != l.end(); i ++)
data.push_back(*i);
}
private:
vector<pair<string, Gender>> data;
};

People ship2 = {{"Garfield", boy}, {"Hellokitty", girl}};

类和结构体的成员函数也可以使用初始化列表,包括一些操作符的重载函数。而在代码所示的这个例子中,我们利用了初始化列表重载了operator[],并且重载了operator=以及使用辅助的数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Mydata
{
public:
Mydata & operator[] (initializer_list<int> input) {
for (auto i = input.begin(); i != input.end(); ++i)
idx.push_back(*i);
return *this;
}
Mydata & operator = (int v) {
if (idx.empty() != true) {
for (auto i = idx.begin(); i != idx.end(); ++i) {
d.resize((*i > d.size()) ? *i : d.size());
d[*i - 1] = v;
}
idx.clear();
}
return *this;
}
void print() {
for (auto i = d.begin(); i != d.end(); ++i)
cout << *i << " ";
cout << endl;
}

private:
vector<int> idx;//辅助数组,用于记录index
vector<int> d;
};
Mydata mydata;
mydata[{2, 3, 5}] = 7;
mydata[{1, 4, 5, 8}] = 4;
mydata.print();

此外,初始化列表还可以用于函数返回的情况。返回一个初始化列表,通常会导致构造个临时变量,比如

1
vector<int> Func() { return {1, 3}; }

当然,跟声明时采用列表初始化一样,列表初始化构造成什么类型是依据返回类型的比如:

1
deque<int> Func2() { return {3, 5}; }

上面的返回值就是以deque<int>列表初始化构造函数而构造的。而跟普通的字面量相同,如果返回值是一个引用类型的话,则会返回一个临时变量的引用。比如:

1
const vector<int>& Func1() { return {3, 5}; }

这里注意,必须要加const限制符。该规则与返回一个字面常量是一样的。

防止类型收窄

类型收窄一般是指一些可以使得数据变化或者精度丢失的隐式类型转换。可能导致类型收窄的典型情况如下:

  • 从浮点数隐式地转化为整型数。
  • 从高精度的浮点数转为低精度的浮点数
  • 从整型转化为浮点型
  • 从整型转化为较低长度的整型

在C++11中,使用初始化列表进行初始化的数据,编译器是会检查其是否发生类型收窄的。

1
2
3
4
5
6
7
8
9
10
11
12
13
const int x = 1024;
const int y = 10;

char a = x; // 收窄,但可以通过编译
char* b = new char(1024); // 收窄,但可以通过编译
char c = {x}; // 收窄,无法通过编译
char d = {y}; // 可以通过编译

unsigned char e {-1}; // 收窄,无法通过编译
float f {7} // 可以通过编译
int g { 2.0f }; // 收窄,无法通过编译
float *h = new float {1e48}; // 收窄,无法通过编译
float i = 1.2l; // 可以通过编译

在C++11中,列表初始化是唯一一种可以防止类型收窄的初始化方式。这也是列表初始化区别于其他初始化方式的地方。事实上,现有编译器大多数会在发生类型收窄的时候提示用户,因为类型收窄通常是代码可能出现问题的征兆。C++11将列表初始化设定为可以防范收窄,也就是为了加强类型使用的安全性。

POD类型

POD是英文中 Plain Old Data的缩写。C++11将POD划分为两个基本概念的合集,即:平凡的(trivial)和标准布局的(standard layout)。

通常情况下,一个平凡的类或结构体应该符合以下定义:

  1. 拥有平凡的默认构造函数(trivial constructor)和析构函数(trivial destructor)。平凡的默认构造函数就是说构造函数“什么都不干””。通常情况下,不定义类的构造函数,编译器就会为我们生成一个平凡的默认构造函数。而一旦定义了构造函数,即使构造函数不包含参数,函数体里也没有任何的代码,那么该构造函数也不再是“平凡”的。
  2. 拥有平凡的拷贝构造函数(trivial copy constructor)和移动构造函数(trivial move constructor)。平凡的拷贝构造函数基本上等同于使用memcpy进行类型的构造。同平凡的默认构造函数一样,不声明拷贝构造函数的话,编译器会帮程序员自动地生成。同样地,可以显式地使用= default声明默认拷贝构造函数。
  3. 拥有平凡的拷贝赋值运算符(trivial assignment operator)和移动赋值运算符(trivial move operator)。这基本上与平凡的拷贝构造函数和平凡的移动构造运算符类似。
  4. 不能包含虚函数以及虚基类。

C++11中,我们可以通过一些辅助的类模板来帮我们进行以上属性的判断

1
template <typename T> struct std::is_trivial;

类模板is_trivial的成员value可以用于判断T的类型是否是一个平凡的类型。除了类和结构体外,is_trivial还可以对内置的标量类型数据及数组类型进行判断。

POD包含的另外一个概念是标准布局。标准布局的类或结构体应该符合以下定义:

  • 所有非静态成员有相同的访问权限(public,prvate,protected)
  • 在类或者结构体继承时,满足以下两种情况之一
    • 派生类中有非静态成员,且只有一个仅包含静态成员的基类
    • 基类有非静态成员,而派生类没有非静态成员
    • 这样的类或者结构体,也是标准布局的。比如:
      • struct B1 { static int a; };
      • struct D1 : B1 { int d; };
      • struct B2 { int a; };
      • struct D2 : B2 { static int d; }
      • struct D3 : B2, B1 { static int d; };
      • struct D4 : B2 { int d; };
      • struct D5 : B2, D1 { }
    • D1、D2和D3都是标准布局的,而D4和D5则不属于标准布局的。这实际上使得非静态成员只要同时出现在派生类和基类间,其即不属于标准布局的。而多重继承也会导致类型布局的一些变化,所以一且非静态成员出现在多个基类中,派生类也不属于标准布局的
  • 类中第一个非静态成员的类型与其基类不同。
    • 用于形如struct A : B { B b; };这样的情况。这里A类型不是一个标准布局的类型,因为第一个非静态成员变量b的类型跟A所继承的类型B相同。
    • 形如struct C: B { int a; B b; }则是一个标准布局的类型。
    • 该规则实际上是基于C++中允许优化不包含成员的基类而产生的。
  • 没有虚函数和虚基类
  • 所有非静态数据成员均符合标准布局类型,其基类也符合标准布局。

在C++标准中,如果基类没有成员,标准允许派生类的第一个成员与基类共享地址,基类并没有占据任何实际的空间,但此时若该派生类的第一个成员类型仍然是基类,编译器仍会为基类分配1字节的空间,这是因为C++标准要求类型相同的对象必须地址不同,所以C++11标准强制要求POD类型的派生类的第一个非静态成员的类型必须不同于基类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
using namespace std;

class A1 {};
class A2 {};

class B1:public A1 {
public:
A1 a1;
int b1;
};

class B2:public A1 {
public:
A2 a2;
int b2;
};

class B3:public A1 {
public:
int b3;
A1 a1;
};

int main() {
B1 b1;b1.b1=0xb1;
B2 b2;b2.b2=0xb2;
B3 b3;b3.b3=0xb3;

cout<<"sizeof(b1)="<<sizeof(b1)<<endl;
cout<<"&b1 ="<<&b1<<endl;
cout<<"&b1.a1="<<&b1.a1<<endl;
cout<<"&b1.b1="<<&b1.b1<<endl<<endl;
cout<<"sizeof(b2)="<<sizeof(b2)<<endl;
cout<<"&b2 ="<<&b2<<endl;
cout<<"&b2.a2="<<&b2.a2<<endl;
cout<<"&b2.b2="<<&b2.b2<<endl<<endl;
cout<<"sizeof(b3)="<<sizeof(b3)<<endl;
cout<<"&b3 ="<<&b3<<endl;
cout<<"&b3.b3="<<&b3.b3<<endl;
cout<<"&b3.a1="<<&b3.a1<<endl<<endl;

cout<<boolalpha<<is_standard_layout<B1>::value<<endl;
cout<<boolalpha<<is_standard_layout<B2>::value<<endl;
cout<<boolalpha<<is_standard_layout<B3>::value<<endl;
return 0;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
sizeof(b1)=8
&b1 =0x28ff28
&b1.a1=0x28ff29
&b1.b1=0x28ff2c

sizeof(b2)=8
&b2 =0x28ff20
&b2.a2=0x28ff20
&b2.b2=0x28ff24

sizeof(b3)=8
&b3 =0x28ff18
&b3.b3=0x28ff18
&b3.a1=0x28ff1c

false
true
true

在C++11中,我们可以使用模板类来帮助判断类型是否是一个标准布局的类型。

1
template <typename T> struct std::is_standard_layout;

对于POD而言,在C++11中的定义就是平凡的和标准布局的两个方面。同样地,要判定某一类型是否是POD,标准库中的<type_traits>头文件也为程序员提供了如下模板类:

1
template <typename T> struct std::is_pod;

POD最为复杂的地方还是在类或者结构体的判断。使用POD有什么好处呢?

  • 字节赋值,代码中我们可以安全地使用memsetmemcpy对POD类型进行初始化和拷贝等操作。
  • 提供对C内存布局兼容。C++程序可以与C函数进行相互操作,因为POD类型的数据在C与C++间的操作总是安全的
  • 保证了静态初始化的安全有效。静态初始化在很多时候能够提高程序的性能,而POD类型的对象初始化往往更加简单

非受限联合体

联合体(union)是一种构造类型的数据结构。在一个联合体内,我们可以定义多种不同的数据类型,这些数据将会共享相同内存空间,这在一些需要复用内存的情况下,可以达到节省空间的目的。

除了非POD类型之外,C++98标准也不允许联合体拥有静态或引用类型的成员。这样虽然可能在一定程度上保证了和C的兼容性,不过也为联合体的使用带来了很大的限制。而且通过长期的实践应用证明,C++98标准对于联合体的限制是完全没有必要的。在新的C++11标准中,取消了联合体对于数据成员类型的限制。标准规定,任何非引用类型都可以成为联合体的数据成员,这样的联合体即所谓的非受限联合体(Unrestricted Union)。

用户自定义字面量

在C/C++程序中,程序员常常会使用结构体或者类来创造新的类型,以满足实际的需求。C++11标准可以通过确定一个后缀标识的操作符,将声明了该后缀标识的字面量转化为需要的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
using namespace std;

typedef unsigned char uint8;

struct RGBA
{
uint8 r;
uint8 g;
uint8 b;
uint8 a;
RGBA(uint8 R, uint8 G, uint8 B, uint8 A = 0)
: r(R), g(G), b(B), a(A)
{}
};

RGBA operator "" _C(const char* col, size_t n)
{
const char* p = col;
const char* end = col + n;
const char* r, *g, *b, *a;
r = g = b = a = nullptr;
for (; p != end; ++p)
{
if (*p == 'r') r = p;
else if (*p == 'g') g = p;
else if (*p == 'b') b = p;
else if (*p == 'a') a = p;
}
if ((nullptr == r) || (nullptr == g) || (nullptr == b))
{
throw;
}
else if (nullptr == a)
return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1));
else
return RGBA(atoi(r + 1), atoi(g + 1), atoi(b + 1), atoi(a + 1));
}

ostream& operator<<(ostream& out, RGBA& col)
{
return out << "r: " << (int)col.r
<< ", g: " << (int)col.g
<< ", b: " << (int)col.b
<< ", a: " << (int)col.a << endl;
}

void blend(RGBA && col1, RGBA && col2)
{
cout << "blend " << endl << col1 << col2 << endl;
}

int main()
{
blend("r255 g240 b155"_C, "r15 g255 b10 a7"_C);
system("pause");
}

/*运行结果
blend
r: 255, g: 240, b: 155, a: 0
r: 15, g: 255, b: 10, a: 7
*/

这里,我们声明了一个字面量操作符(literal operator)函数:
1
RGBA operator ""_C(const char *col, size_t n)

函数。这个函数会解析以_C为后缀的字符串,并返回一个RGBA的临时变量。有了这样一个用户字面常量的定义,main函数中我们不再需要通过声明RGBA类型的声明变量-传值运算的方式来传递实际意义上的常量。通过声明一个字符串以及一个_C后缀,operator "" _C函数会产生临时变量。blend函数就可以通过右值引用获得这些临时值并进行计算了。这样一来,用户就完成了定义自定义类型的字面常量,main函数中的代码书写显得更加清晰。

在C++11中,标准要求声明字面量操作符有一定的规则该规则跟字面量的“类型”密切相关。C++11中具体规则如下:

  • 如果字面量为整型数,那么字面量操作符函数只可接受unsigned long long或者 const char*为其参数。当unsigned long long无法容纳该字面量的时候,编译器会自动将该字面量转化为以0为结束符的字符串,并调用以const char*为参数的版本进行处理。
  • 如果字面量为浮点型数,则字面量操作符函数只可接受long double或者const char*为参数。const char*版本的调用规则同整型的一样。
  • 如果字面量为字符串,则字面量操作符函数函数只可接受const char*size_t为参数。
  • 如果字面量为字符,则字面量操作符函数只可接受一个char为参数。

应该注意以下几点:

  • 在字面量操作符函数的声明中,operator""与用户自定义后缀之间必须有空格。
  • 后缀建议以下划线开始。不宜使用非下划线后级的用户自定义字符串常量,否则会被编译器警告。

内联名字空间

C++11中,标准引入了一个新特性,叫做“内联的名字空间”。通过关键字inline namespace就可以声明一个内联的名字空间。内联的名字空间允许程序员在父名字空间定义或特化子名字空间的模板。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;
namespace Jim {
inline namespace Basic {
struct Knife { Knife() { cout << " Knife in Basic." << endl; } }
class Corkscrew {};
}
inline namespace Toolkit {
template<typename T> class SwissArmyknife {};
}
// ...
namespace Other {
Knife b;// Knife in Basic
struct Knife { Knife() { cout << "Knife in other" << endl; } };
Knife c; // Knife in other
Basic::Knife k; // Knife in Basic
}
}
namespace Jim {
template<> class SwissArmyKnife<Knife>{}; //编译通过
}

using namespace Jim;
int main() {
SwissArmyKnife<Knife> sknife;
}

我们将名字空间 Basic和 Toolkit都声明为 inline的。此时,Lilei对库中模板的偏特化(SwissArmyKnife<Knife>)则可以通过编译。不过这里我们需要再次注意一下 Other这个名字空间中的状况。可以看到,变量b的声明语句是可以通过编译的,而且其被声明为一个Basic::Knife的类型。

模板的别名

当遇到一些比较长的名字,尤其是在使用模板和域的时候,使用别名的优势会更加明显。比如:typedef std::vector<std::string> strvec;。这里使用strvec作为std::vector<std::string>的别名。在C++11中,定义别名已经不再是typedef的专属能力,使用using同样也可以定义类型的别名,而且从语言能力上看,using丝毫不比typedef逊色。

在使用模板程的时候,using的语法甚至比typedef更加灵活。比如下面这个例子:

1
2
template<typename T> using Mapstring = std::map<T, char*>;
Mapstring<int> numberedstring;

在这里,我们“模板式”地使用了using关键字,将std::map<T, char*>定义为了一个Mapstring类型,之后我们还可以使用类型参数对Mapstring进行类型的实例化,而使用typedef将无法达到这样的效果。

一般化的SFINEA规则

SFINEA- Substitution failure is not an error,即“匹配失败不是错误”。更为确切地说,这条规则表示的是对重载的模板的参数进行展开的时候,如果展开导致了一些类型不匹配,编译器并不会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Test {
typedef int foo;
};
template <typename T>
void f(typename T::foo) {} //第一个模板定义

template <typename T>
void f(T) {} // 第二个模板定义

int main() {
f<Test>(10);
f<int>(10);
}

这里通过typename知道T是一个类,第二个模板定义则接受一个T类型的参数。在main函数中,分别使用f<Test>f<int>对模板进行实例化的时候会发现,对于f<int>来讲,虽然不存在int::foo这样的类型,编译器依然不会报错,相反地编译器会找到第二个模板定义并对其进行实例化。这样一来,就保证了编译的正确性。

基本上,这是一个使得C++模板推导规则符合程序员想象的规则。通过SFINAE我们能够使得模板匹配更为“精确”,即使得一些模板函数、模板类在实例化时使用特殊的模板版本,而另外一些则使用通用的版本,这样就大大增加了模板设计使用上的灵活性。

新手易学,老兵易用

右尖括号>的改进

在C++98中,有一条需要程序员规避的规则:如果在实例化模板的时候出现了连续的两个右尖括号>,那么它们之间需要一个空格来进行分隔,以避免发生编译时的错误。我们定义了两个模板类型XY,并且使用模板定义分别声明了以X<1>为参数的Y<X<1>>类型变量x1,以及以X<2>为参数的Y<X<2>>类型变量x2。不过x2的定义编译器却不能正确解析。在x2的定义中,编译器会把>优先解析为右移符号。

除去嵌套的模板标识,在使用形如static_castdynamic castreinterpret_cast或者const_cast表达式进行转换的时候,我们也常会遇到相同的情况。

1
const vector<int> v = static_cast<vector<int>>(v);

auto类型推导

静态类型、动态类型与类型推导

每个变量使用前必须定义被视为编程语言中的“静态类型”的体现。而变量不需要声明,“拿来就用”则被视为“动态类型”的体现。

不过从技术上严格地讲,静态类型和动态类型的主要区别在于对变量进行类型检査的时间点。静态类型类型检査主要发生在编译阶段;动态类型类型检查主要发生在运行阶段。变量“拿来就用”的特性归功于类型推导。

auto关键字在早期的C/C++标准中有着完全不同的含义。声明时使用auto修饰的变量,按照早期C/C++标准的解释,是具有自动存储期的局部变量。不过现实情况是该关键字几儿乎无人使用,因为一般函数内没有声明为static的变量总是具有自动存储期的局部变量。因此在C++11中,标准委员会决定赋予auto全新的含义,即auto不再是一个存储类型指示符,而是作为一个新的类型指示符来指示编译器,auto声明变量的类型必须由编译器在编译时期推导而得。

1
2
3
4
5
6
int main() {
double foo();
auto x = 1; // x的类型为int
auto y = foo(); // y的类型为double
auto z; // 无法推导,无法通过编译
}

变量x被初始化为1,因为字面常量1的类型为const int,所以编译器推导出x的类型应该为int。同理在变量y的定义中,auto类型的y被推导为double类型;使用auto关键字来“声明”z,但不立即对其进行定义则会报错。auto声明的变量必须被初始化,以使编译器能够从其初始化表达式中推导出其类型。从这个意义上来讲,auto并非一种“类型”声明,而是一个类型声明时的“占位符”,编译器在编译时期会将auto替代为变量实际的类型。

auto的优势

直观地,auto推导的一个最大优势就是在拥有初始化表达式的复杂类型变量声明时简化代码。

1
2
3
4
5
#include <string>
#include <vector>
void loopover(std::vector<std::string> & vs) {
std::vector<std::string>::iterator i = vs.begin();//想要使用iterator,往往需要大量代码
}

当我们想定义一个迭代器i的时侯我们必须写出std::vector<std::string>::iterator这样长的类型声明。这么长的类型声明只需要一个auto即可。

auto的第二个优势则在于可以免除程序员在一些类型声明时的麻烦,或者避免一些在类型声明时的错误。对于不同的平台上的代码维护,auto也会带来一些“泛型”的好处。这里我们以strlen函数为例,在32位的编译环境下,strlen返回的为一个4字节的整型,而在64位的编译环境下,strlen会返回一个8字节的整型。虽然系统库<cstring>为其提供了size_t类型来支持多平台间的代码共享支持,但是使用auto关键字我们同样可以达到代码跨平台的效果:

1
auto var = strlen("hello world");

由于size_t的适用范围往往局限于<cstring>中定义的函数,auto的适用范围明显更为广泛。

auto的使用细则

在C++11中,auto可以与指针和引用结合起来使用,使用的效果基本上会符合C/C++程序员的想象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int x; 
int * y = &x;
double foo();
int & bar();

auto * a = &x; // int*
auto & b = x; // int&
auto c = y; // int*
auto * d = y; // int*

auto * e = &foo(); // 编译失败,指针不能指向一个临时变量
auto & f = foo(); // 编译失败,nonconst的左值引用不能和一个临时变量绑定
auto g = bar(); // int
auto & h = bar(); // int&

本例中,变量a、c、d的类型都是指针类型,且都指向变量x。实际上对于a、c、d变量而言,声明其为auto*auto并没有区别。而如果要使得auto声明的变量是另一个变量的引用,则必须使用auto&,如同本例中的变量b和h一样。

其次,autovolatileconst之间也存在着一些相互的联系。volatileconst代表了变量的两种不同的属性:易失的和常量的。在C++标准中,它们常常被一起叫作cv限制符(cv-qualifier)。鉴于cv限制符的特殊性,C+1标准规定auto可以与cv限制符一起使用。不过声明为auto的变量并不能从其初始化表达式中“带走”cv限制符。

1
2
3
4
5
6
7
8
9
10
11
12
double foo(); 
float * bar();

const auto a = foo(); // a: const double
const auto & b = foo(); // b: const double&
volatile auto * c = bar(); // c: volatile float*

auto d = a; // d: double
auto & e = a; // e: const double &
auto f = c; // f: float *

volatile auto & g = c; // g: volatile float * &

我们可以通过非cv限制的类型初始化一个cv限制的类型,如变量a、b、c所示。不过通过auto声明的变量d、f却无法带走a和f的常量性或者易失性。这里的例外还是引用,可以看出,声明为引用的变量e、g都保持了其引用的对象相同的属性。此外,跟其他的变量指示符一样,同一个赋值语句中,auto可以用来声明多个变量的类型,不过这些变量的类型必须相同。如果这些变量的类型不相同,编译器则会报错。事实上,用auto来声明多个变量类型时,只有第一个变量用于auto的类型推导,然后推导出来的数据类型被作用于其他的变量。所以不允许这些变量的类型不相同。

1
2
3
4
5
6
7
auto x = 1, y = 2; // x和y的类型均为int 

// m是一个指向const int类型变量的指针,n是一个int类型的变量
const auto* m = &x, n = 1;
auto i = 1, j = 3. 14f; // 编译失败

auto o = 1, &p = o, *q = &p; // 从左向右推导

我们使用auto声明了两个类型相同变量x和y,并用逗号进行分隔,这可以通过编译。而在声明变量i和j的时候,按照我们所说的第一变量用于推导类型的规则,那么由于x所推导出的类型是int,那么对于变量j而言,其声明就变成了int j = 3.14f,这无疑会导致精度的损失。而对于变量m和n,就变得非常有趣,这里似乎是auto被替换成了int,所以m是一个int*指针类型,而n只是一个int类型。同样的情况也发生在变量o、p、q上,这里o是一个类型为int的变量,p是o的引用,而q是p的指针。auto的类型推导按照从左往右,且类似于字面替换的方式进行。事实上,标准里称auto是一个将要推导出的类型的“占位符”(placeholder)。这样的规则无疑是直观而让人略感意外的。

受制于语法的二义性,或者是实现的困难性,auo往往也会有使用上的限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include < vector> 
using namespace std;
void fun( auto x = 1){} // 1: auto函数参数,无法通过编译

struct str{
auto var = 10; // 2: auto非静态成员变量,无法通过编译
};

int main() {
char x[ 3];
auto y = x;
auto z[ 3] = x; // 3: auto数组,无法通过编译
// 4: auto模板参数(实例化时),无法通过编译
vector< auto> v = {1};
}

  1. 对于函数fun来说,auto不能是其形参类型。auto是不能做形参的类型的。如果程序员需要泛型的参数,还是需要求助于模板。
  2. 对于结构体来说,非静态成员变量的类型不能是auto的。同样的,由于var定义了初始值,读者可能认为auto可以推导str成员var的类型为int的。但编译器阻止auto对结构体中的非静态成员进行推导,即使成员拥有初始值。
  3. 声明auto数组。我们可以看到,main中的x是一个数组,y的类型是可以推导的。而声明auto z[3]这样的数组同样会被编译器禁止
  4. 在实例化模板的时候使用auto作为模板参数,如main中我们声明的vector<auto>虽然读者可能认为这里一眼而知是int类型,但编译器却阻止了编译。

decltype

typeid与decltype

C++98对动态类型支持就是C++中的运行时类型识别(RTTI)。RTTI的机制是为每个类型产生一个type info类型的数据,程序员可以在程序中使用typeid随时查询一个变量的类型,typeid就会返回变量相应的type_info数据。而type_infoname成员函数可以返回类型的名字。而在C++11中,又增加了hash_code,这个成员函数返回该类型唯一的哈希值,以供程序员对变量的类型随时进行比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream> 
#include <typeinfo>
using namespace std;

class White{};
class Black{};

int main()
{
White a;
Black b;
cout << typeid(a).name() << endl; // 5White
cout << typeid(b).name() << endl; // 5Black
White c;
bool a_b_sametype = (typeid(a).hash_code() == typeid(b).hash_code());
bool a_c_sametype = (typeid(a).hash_code() == typeid(c).hash_code());
cout << "Same type? " << endl;
cout << "A and B? " << (int) a_b_sametype << endl; // 0
cout << "A and C? " << (int) a_c_sametype << endl; // 1
}

在RTTI的支持下,程序员可以在一定程度上了解程序中类型的信息。除了typeid外,RTTI还包括了C++中的dynamic_cast等特性。不过不得不提的是,由于RTTI会带来一些运行时的开销,所以一些编译器会让用户选择性地关闭该特性(比如XLCC++编译器的- qnortti,GCC的选项-fno-rttion,或者微软编译器选项/GR-)。

在decltype产生之前,很多编译器的厂商都开发了自己的C++语言扩展用于类型推导。比如GCC的typeof操作符就是其中的一种。C++11则将这些类型推导手段标准化为auto以及decltype。与auto类似地,decltype也能进行类型推导,不过两者的使用方式却有一定的区别。

1
2
3
4
5
6
7
8
9
10
int main() 
{
int i;
decltype(i) j = 0;
cout << typeid(j).name() << endl; // 打印出" i", g++ 表示 int
float a;
double b;
decltype(a + b) c;
cout << typeid(c).name() << endl; // 打印出" d", g++ 表示 double
}

我们看到变量j的类型由decltype(i)进行声明,表示j的类型跟i相同。而c的类型则跟(a+b)这个表达式返回的类型相同。而由于a+b加法表达式返回的类型为double,所以c的类型被 decltype推导为double。

从这个例子中可以看到,decltype的类型推导并不是像auto一样是从变量声明的初始化表达式获得变量的类型,decltype总是以一个普通的表达式为参数,返回该表达式的类型。而与auto相同的是,作为一个类型指示符,decltype可以将获得的类型来定义另外一个变量。与auto相同,decltype类型推导也是在编译时进行的。

decltype的应用

在C+11中,使用decltype推导类型是非常常见的事情。比较典型的就是decltypetypedef/using的合用。在C++11的头文件中,我们常能看以下这样的代码

1
2
3
using size_ t = decltype(sizeof(0)); 
using ptrdiff_ t = decltype((int*) 0 - (int*) 0);
using nullptr_ t = decltype(nullptr);

这里size_t以及ptrdiff_t还有nullptr_t都是由decltype推导出的类型。这种定义方式非常有意思。在一些常量、基本类型、运算符、操作符等都已经被定义好的情况下,类型可以按照规则被推导出。而使用using,就可以为这些类型取名。这就颠覆了之前类型拓展需要将扩展类型“映射”到基本类型的常规做法。

除此之外,decltype在某些场景下,可以极大地增加代码的可读性。

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
vector<int> vec;
typedef decltype(vec.begin()) vectype;

for (vectype i = vec.begin(); i < vec.end(); i ++) {
// ...
}

for (decltype(vec)::iterator i = vec.begin(); i < vec.end(); i ++) {
// ...
}
}

我们定义了vector的iterator的类型。这个类型还可以在main函数中重用。当我们遇到一些具有复杂类型的变量或表达式时,就可以利用decltypetypedef using的组合来将其转化为一个简单的表达式,这样在以后的代码写作中可以提高可读性和可维护性。此外我们可以看到decltype(vec)::iterator这样的灵活用法,这看起来跟auto非常类似,也类似于是一种“占位符”式的替代。

拥有了decltype这个利器之后,重用匿名类型也并非难事。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class {K1, K2, K3} anon_e; //匿名的强类型枚举

union {
decltype (anon_e) key;
char* name;
} anon_u;//匿名的 union联合体

struct {
int d;
decltype(anon_u) id;
} anon_s[100]; // 匿名的struct数组

int main() {
decltype<anon_s) as;
as[0].id.key = decltype(anon_e)::K1; //引用匿名强类型枚举中的值
}

我们使用了3种不同的匿名类型:匿名的强类型枚举anon_e、匿名的联合体anon_u,以及匿名的结构体数组anon_s。可以看到,只要通过匿名类型的变量名anon_eanon_u,以及anon_s,decltype可以推导其类型并且进行重用。

有了decltype,我们可以适当扩大模板泛型的能力。

1
2
3
4
template<typename T1, typename T2>
void Sum(T1 & t1, T2 & t2, decltype(t1+t2) & s) {
s = t1 + t2;
}

这样一来,Sum的适用范围增加,因为其返回的类型不再是单一的类型,而是根据t1+2推导而来的类型。不过这里还是有一定的限制,我们可以看到返回值的类型必须一开始就被指定,程序员必须清楚Sum运算的结果使用什么样的类型来存储是合适的,这在一些泛型编程中依然不能满足要求。解决的方法是结合decltypeauto关键字,使用追踪返回类型的函数定义来使得编译器对函数返回值进行推导。

我们在实例化一些模板的时候,decltype也可以起到一些作用。

1
2
3
4
int hash(char*);

map<char*, decltype(hash)> dict_key; // 无法通过编译
map<char*, decltype(hash(nullptr))> dict_key1;

我们实例化了标准库中的map模板。因为该map是为了存储字符串以及与其对应哈希值的,因此我们可以通过decltype(hash(nullptr)来确定哈希值的类型。这样的定义非常直观,但是程序员必须要注意的是,decltype只能接受表达式做参数,像函数名做参数的表达式decltype(hash)是无法通过编译的。

一些标准库的实现也会依赖于类型推导。一个典型的例子是基于decltype的模板类result_of,其作用是推导函数的返回类型。

1
2
3
4
5
6
7
8
#include <type_traits>
using namespace std;
typedef double (*func)();

int main() {
result_of<func()>::type f;
//由func()推导其结果类型
}

这里f的类型最终被推导为double,而result_of并没有真正调用func()这个函数,这切都是因为底层的实现使用了decltyperesult_of的一个可能的实现方式如下

1
2
3
4
5
6
7
8
template<class>
struct result_of;
template<class F, class.. ArgTypes>
struct result_of<F(ArgTypes.. )> {
typedef decltype(
std::declval<F>()(std::declval<ArgTypes>()...)
) type;
}

这里标准库将decltype作用于函数调用上,并将函数调用表达式返回的类型typedef为一个名为type的类型。这样一来,result_of<func()>::type就会被decltype推导为double

decltype推导四规则

1
2
3
int i;
decltype(i) a;
decltype((i)) b; // b int&: 必须初始化引用

decltype((i))b这样的语句编译不过。编译器会提示b是一个引用,但没有被赋初值。而decltypet(i) a这一句却能通过编译,因为其类型被如预期地推导为int。这种问题显得非常诡异,单单多了一对圆括号,decltype所推导出的类型居然发生了变化。事实上,C++11中decltype推导返回类型的规则比我们想象的复杂。具体地,当程序员用decltype(e)来获取类型时,编译器将依序判断以下四规则:

  1. 如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么decltype(e)就是e所命名的实体的类型。此外,如果e是一个被重载的函数,则会导致编译时错误。
  2. 否则,假设e的类型是T,如果e是一个将亡值,那么decltype(e)T&&
  3. 否则,假设e的类型是T,如果e是一个左值,则decltype(e)T&
  4. 否则,假设e的类型是T,则decltype(e)T

基本上,所有除去关键字、字面量等编译器需要使用的标记之外的程序员自定义的标记都可以是标记符。而单个标记符对应的表达式就是标记符表达式。比如程序员定义了int arr[4],那么arr是一个标记符表达式,而arr[3]+0arr[3]等,则都不是标记符表达式。

decltype(i) a使用了推导规则1,因为i是一个标记符表达式,所以类型被推导为int;而decltype((i))b中,由于(i)不是一个标记符表达式,但却是一个左值表达式(可以有具名的地址),因此,按照 decltype推导规则3,其类型应该是一个int的引用。上面的规则看起来非常复杂,但事实上,在实际应用中,decltype类型推导规则中最容易引起迷惑的只有规则1和规则3。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int i = 4;
int arr[5] = {5};
int *ptr = arr;

struct S { double d; } s;

void Overloaded(int);
void Overloaded(char); //重载的函数

int && RvalRef();
const bool Func(int);

// 规则1:单个标记符表达式以及访问类成员,推导为本类型
decltype(arr) var1; // int[5],标记符表达式
decltype(ptr) var2; // int*,标记符表达式
decltype(s.d) var4; // doub1e,成员访问表达式
decltype(Overloaded) var5; // 无法通过编译,是个重载的函数

//规则2:将亡值,推导为类型的右值引用
decltype(RvalRef()) var6 = 1; //int&&

// 规则3:左值,推导为类型的引用
decltype(true ? i : i) var7 = i; // int&,三元运算符,这里返回一个i的左值
decltype((i)) var8 = i; // int&,带圆括号的左值
decltype(++i) var9 = i; // int&,++i返回i的左值
decltype(arr[3]) var10 = i // int[]操作返回左值
decltype(*ptr) var11 = i; // int& *操作返回左值
decltype("lval") var12 = "lval"; // const char(&)[9],字符串字面常量为左值

//规则4:以上都不是,推导为本类型
decltype(1) var13; // int,除字符串外字面常量为右值
decltype(i++)var14; // int,i++返回右值
decltype((Func(1))) var15; // const bool,圆括号可以忽略

另外一些时候,C++11标准库中添加的模板类is_lvalue_reference,可以帮助程序员进行一些推导结果的识别。

1
std::cout << std::is_lvalue_reference<decltype(++i)>::value << std::endl;

结果1表示为左值,结果为0为非右值。同样的,也有is_rvalue_reference这样的模板类来判断decltype推断结果是否为右值。

cv限制符的继承与冗余的符号

与auto类型推导时不能“带走”cv限制符不同, decltype是能够“带走”表达式的cv限制符的。不过,如果对象的定义中有 const 或 volatile 限制符,使用 decltype进行推导时其成员不会继承 const或 volatile限制符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int main()
{
const int ic = 0;
volatile int iv;
struct S{ int i; };

const S a = {0};
volatile S b;
volatile S*p = &b;
std::cout << std::is_const<decltype(ic)>::value << std::endl; //1 推导类型为:const int
std::cout << std::is_volatile<decltype(iv)>::value << std::endl; //1 推导类型为:volatile int

std::cout << std::is_const<decltype(a)>::value << std::endl; //1 推导类型为:const S
std::cout << std::is_volatile<decltype(b)>::value << std::endl; //1 推导类型为:volatile S

std::cout << std::is_const<decltype(a.i)>::value << std::endl; //0 推导类型a为const,但是成员不继承const类型
std::cout << std::is_volatile<decltype(p->i)>::value << std::endl; //0 推导类型p为volatile,但是成员不继承volatile类型

return 0;
}

可以看到,结构体变量a、b和结构体指针p的cv限制符并没有出现在其成员的 decltype类型推导结果中。而与auto相同的, decltype从表达式推导出类型后,进行类型定义时,也会允许一些冗余的符号。比如cv限制符以及引用符号&,通常情况下,如果推导出的类型已经有了这些属性,冗余的符号则会被忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main()
{
int i = 1;
int& j = i;
int *p = &i;
const int k = 1;

decltype(i)& var1 = i;
decltype(j)& var2 = i;
std::cout << std::is_lvalue_reference<decltype(var1)>::value << std::endl; //1,是左值引用
std::cout << std::is_rvalue_reference<decltype(var2)>::value << std::endl; //0,不是右值引用
std::cout << std::is_lvalue_reference<decltype(var2)>::value << std::endl; //1,是左值引用

//decltype(p)* var3 = &i; //编译错误
decltype(p)* var4 = &p; //var4的类型为int **

auto* v3 = p; //v3的类型为int *
v3 = & i;
const decltype(k) var5 = 1; //冗余的const,被忽略

return 0;
}

我们定义了类型为decltype(i)&的变量var1,以及类型为decltype(j)&的变量var2。由于i的类型为int,所以这里的引用符号保证var1成为一个int&引用类型。而由于j本来就是一个int&的引用类型,所以decltype之后的&成为了冗余符号,会被编译器忽略,因此j的类型依然是int&。这里特别要注意的是decltype(p)*的情况。可以看到,在定义var3变量的时候,由于p的类型是int*,因此var3被定义为了int**类型。这跟auto声明中,*也可以是冗余的不同。在decltype后的*号,并不会被编译器忽略。

此外我们也可以看到,var4中const可以被冗余的声明,但会被编译器忽略,同样的情况也会发生在volatile限制符上。总的说来,decltype算得上是C++11中类型推导使用方式上最灵活的一种。虽然看起来它的推导规则比较复杂,有的时候跟auto推导结果还略有不同,但大多数时候,我们发现使用decltype还是自然而亲切的。

追踪返回类型

追踪返回类型的引入

1
2
3
4
template<typename T1, typename T2>
decltype(t1+t2) Sum(T1& t1, T2& t2) {
return t1 + t2;
}

编译器在推导decltype(t1+t2)时,表达式中的t1t2都未声明,为了解决这个问题C++11引入新语法——追踪返回类型,来声明和定义这样的函数。

1
2
3
4
template<typename T1, typename T2>
auto Sum(T1 &t1, T2 &t2) -> decltype(t1 + t2) {
return t1 + t2;
}

我们把函数的返回值移至参数声明之后,复合符号decltype(t1 + t2)被称为追踪返回类型。而原本函数返回值的位置由auto关键字占据。这样,我们就可以让编译器来推导Sum函数模板的返回类型了。而auto占位符和-> ereturn type也就是构成追踪返回类型函数的两个基本元素

使用追踪返回类型的函数

追踪返回类型的函数和普通函数的声明最大的区别在于返回类型的后置。在一般情况下,普通函数的声明方式会明显简单于最终返回类型。比如int func (char a, int b)这样的书写会比下面auto func(char*a, int b)-> int少上不少。

如我们刚才提到的,返回类型后置,使模板中的一些类型推导就成为了可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T1, typename T2>
auto Sum(const T1 & t1, const T2 & t2) -> decltype(t1 + t2) {
return t1 + t2;
}
template<typename T1, typename T2>
auto Mul(const T1 & t1, const T2 & t2) -> decltype(t1 * t2) {
return t1 * t2;
}

int main() {
auto a = 3;
auto b = 4L;
auto pi = 3.14;
auto c = Mul(Sum(a, b), pi);
}

我们定义了两个模板函数SumMul,它们的参数的类型和返回值都在实例化时决定。而由于main函数中还使用了auto,整个例子中没有看到一个具体的类型声明。

追踪返回类型的另一个优势是简化函数的定义,提高代码的可读性。这种情况常见于函数指针中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <type_traits>
#include <iostream>
using namespace std;

int (*(*pf())())() {
return nullptr;
}


// auto (*)() -> int(*)() 一个返回函数指针的函数(假设为a函数)
// auto pf1() -> auto (*)() -> int(*)() 一个返回a函数的指针的函数
auto pf1() -> auto(*)()-> int (*)() {
eturn nullptr;
}

int main() {
cout << is_same<decltype(pf), decltype(pf1)>::value << endl;

定义了两个类型完全一样的函数pfpf1其返回的都是一个函数指针。而该函数指针又指向一个返回函数指针的函数。这一点通过is_same的成员value已经能够确定了。而仔细看一看函数类型的声明,可以发现老式的声明法可读性非常差。而追踪返回类型只需要依照从右向左的方式,就可以将嵌套的声明解析出来。这大大提高了嵌套函数这类代码的可读性。

除此之外,追踪返回类型也被广泛地应用在转发函数中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
using namespace std;

double foo(int a) {
return (double)a + 0.1;
}

int foo(double b) {
return (int)b;
}

template<class T>
auto Forward(T t) -> decltype(foo(t)) {
return foo(t);
}

int main() {
cout << Forward(2) << endl;
cout << Forward(0.5) << endl;

由于使用了追踪返回类型,可以实现参数和返回类型不同时的转发。

追踪返回类型还可以用在函数指针中,其声明方式与追踪返回类型的函数比起来,并没有太大的区别。比如

1
auto(*fp)() -> int


1
int (*fp) ();

的函数指针声明是等价的。同样的情况也适用于函数引用,比如:auto (&fr)() -> intint (&fr)();的声明也是等价的。

P 168

基于范围的for循环

C++的标准模板库中,我们可以找到形如for_each的模板函数。如果我们使用for_each,代码看起来会是

1
2
3
4
5
6
7
8
9
10
#include <algorithm>
#include <iostream>

int action1(int &e) { e *= 2; }
int action2(int &e) { cout << e << endl; }

int main() {
int arr[5] = {1,2,3,4,5};
for_each(arr, arr+sizeof(arr)/sizeof(arr[0]), action1);
for_each(arr, arr+sizeof(arr)/sizeof(arr[0]), action2);

上述代码要告诉循环体其界限的范围,即arrarr+sizeof(arr)/sizeof(arr[0])之间,才能按元素执行操作。

C++11也引入了基于范围的for循环,就可以很好地解决了这个问题

1
2
3
4
5
6
7
8
9
10
#include <iostream>
using namespace std;

int main() {
int arr[5] = {1,2,3,4,5};
for (int &e: arr)
e *= 2;
for (int &e: arr)
cout << e << << endl;
}

这是一个基于范围的for循环的实例。for循环后的括号由冒号:分为两部分,第一部分是范围内用于迭代的变量,第二部分则表示将被送代的范围。基于范围的for循环跟普通循环是一样的,可以用continue语句来跳过循环的本次迭代,而用break语句来跳出整个循环。

值得指出的是,是否能够使用基于范围的for循环,必须依赖于一些条件。首先,就是for循环迭代的范围是可确定的。对于类来说,如果该类有beginend函数,那么beginend之间就是for循环迭代的范围。对于数组而言,就是数组的第一个和最后一个元素间的范围。其次,基于范围的for循环还要求迭代的对象实现++==等操作符。对于标准库中的容器,如string、aray、 vector、 deque、list、 queue、map、set等,不会有问题,因为标准库总是保证其容器定义了相关的操作。普通的已知长度的数组也不会有问题。而用户自己写的类,则需要自行提供相关操作。

提高类型安全

强类型枚举

强类型枚举

非强类型作用域,允许隐式转换为整型,占用存储空间及符号性不确定,都是枚举类的缺点。针对这些缺点,新标准C++11引入了一种新的枚举类型,即“枚举类”,又称“强类型枚举”。声明强类型枚举非常简单,只需要在enum后加上关键字class。比如enum class Type { General, Light, Medium, Heavy};就声明了一个强类型的枚举Type

强类型枚举具有以下几点优势

  • 强作用域,强类型枚举成员的名称不会被输出到其父作用域空间。
  • 转换限制,强类型枚举成员的值不可以与整型隐式地相互转换。
  • 可以指定底层类型。强类型枚举默认的底层类型为int,但也可以显式地指定底层类型具体方法为在枚举名称后面加上“:type”,其中type可以是除wchar_t以外的任何整型。比如enum class Type: char { General, Light, Medium, Heavy};就指定了Type是基于char类型的强类型枚举。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

enum class Type { General, Light, Medium, Heavy };
enum class Category { General = 1, Pistol, Machinegun, Cannon};

int main() {
Type t = Type::Light;
t = General; //编译失败,必须使用强类型名称
if (t == Category::General) //编译失败,必须使用Type中的 General
cout << "General Weapon" << endl;
if (t > Type::General) //通过编译
cout << "Not General Weapon" << endl;
if (t > 0) //编译失败,无法转换为int类型
cout << "Not General Weapon" << endl;
if ((int)t > 0) //通过编译
cout << "Not General Weapon" << endl;
cout << is_pod<Type>::value << endl;
cout << is_pod<Category>::value << endl;
}

在代码清单5-5中,我们定义了两个强类型枚举TypeCategory,它们都包含一个称为General的成员。由于强类型枚举成员的名字不会输出到父作用域,因此不会有编译问题。也由于不输出成员名字,所以我们在使用该类型成员的时候必须加上其所属的枚举类型的名字。此外,可以看到,枚举成员间仍然可以进行数值式的比较,但不能够隐式地转为int型。事实上,如果要将强类型枚举转化为其他类型,必须进行显式转换。事实上,强类型制止enum成员和int之间的转换,使得枚举更加符合“枚举”的本来意义,即对同类进行列举的一个集合,而定义其与数值间的关联则使之能够默认拥有种对成员排列的机制。而制止成员名字输出则进一步避免了名字空间冲突的问题。我们可以看到,TypeCategory都是POD类型,不会像 class 封装版本一样被编译器视为结构体。

堆内存管理

显式内存管理

从语言层面来讲,我们可以将不正确处理堆内存的分配与释放归纳为以下一些问题。

  • 野指针:一些内存单元已被释放,之前指向它的指针却还在被使用。这些内存有可能被运行时系统重新分配给程序使用,从而导致了无法预测的错误
  • 重复释放:程序试图去释放已经被释放过的内存单元,或者释放已经被重新分配过的内存单元,就会导致重复释放错误。通常重复释放内存会导致C++运行时系统打印出大量错误及诊断信息。
  • 内存泄漏:不再需要使用的内存单元如果没有被释放就会导致内存泄漏。如果程序不断地重复进行这类操作,将会导致内存占用剧增。

C++11的智能指针

在C++98中,智能指针通过一个模板类型auto_ptr来实现。auto_ptr以对象的方式管理堆分配的内存,并在适当的时间(比如析构),释放所获得的堆内存。这种堆内存管理的方式只需要程序员将new操作返回的指针作为auto_ptr的初始值即可,程序员不用再显式地调用delete。不过auto_ptr有一些缺点(拷贝时返回一个左值,不能调用delete等),所以在C++11标准中被废弃了。C++11标准中改用unique_ptrshared_ptrweak_ptr等智能指针来自动回收堆分配的对象。

这里我们可以看一个C++11中使用新的智能指针的简单例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <memory>
#include <iostream>
using namespace std;

int main() {
unique_ptr<int> up1(new int(11)); // 无法复制的 unique_ptr
unique_ptr<int> up2 = up1; // 不能通过编译
unique_ptr<int> up3 = move(up1); // 现在p3是数据唯一的 unique_ptr智能指针
cout << *up3 << endl; // 11
cout << *up1 << endl; // 运行时错误
up3.reset(); // 显式释放内存
up1.reset(); // 不会导致运行时错误
cout << *up3 << endl; // 运行时错误

shared_ptr<int> sp1(new int(22));
shared_ptr<int> sp2 = sp1;
cout << *sp1 << endl; // 22
cout << *sp2 << endl; // 22

sp1.reset();
cout << *sp2 << endl; // 22
return 0;

在代码中使用了两种不同的智能指针unique_ptrshared_ptr来自动地释放堆对象的内存。由于每个智能指针都重载了*运算符,用户可以使用up1这样的方式来访问所分配的雄内存。而在该指针析构或者调用reset成员的时候,智能指针都可能释放其拥有的堆内存。从作用上来讲,unique_ptrshared_ptr还是和以前的auto_ptr保持了一致。

不过从代码中还是可以看到,unique_ptrshared_ptr在对所占内存的共享上还是有一定区别的。直观地看来,unique_ptr与所指对象的内存绑定紧密,不能与其他unique_ptr类型的指针对象共享所指对象的内存。比如,本例中的unique_ptr<int> up2 = up1不能通过编译,是因为每个unique_ptr都是唯一地“拥有”所指向的对象内存,由于up1唯一地占有了new分配的堆内存,所以up2无法共享其“所有权”。事实上,这种“所有权”仅能够通过标准库的move函数来转移。

我们可以看到代码中up3的定义,unique_ptr<int> up3 = move(up1),一旦“所有权”转移成功了,原来的unique_ptr指针就失去了对象内存的所有权。此时再使用已经“失势”的unique_ptr,就会导致运行时的错误。

从实现上讲,unique_ptr则是一个删除了拷贝构造函数、保留了移动构造函数的指针封装类型。程序员仅可以使用右值对unique_ptr对象进行构造,而且一旦构造成功,右值对象中的指针即被“窃取”,因此该右值对象即刻失去了对指针的“所有权”。而shared_ptr同样形如其名,允许多个该智能指针共享地“拥有”同一堆分能对象的内存。与unique_ptr不同的是,由手在实现上采用了引用计数,所以一旦一个shared_ptr指针放弃了“所有权”(失效),其他的shared_ptr对象内存的引用并不会受到影响。虽然sp1调用了reset成员函数,但由于sp1sp2共享了new分配的堆内存,所以sp1调用reset成员函数只会导致引用计数的降低,而不会导致堆内存的释放。只有在引用计数归零的时候,shared_ptr才会真正释放所占有的堆内存的空间。

在C++11标准中,除了unique_ptrshared_ptr,智能指针还包括了weak_ptr这个类模板。weak_ptr的使用更为复杂一点,它可以指向shared_ptr指针指向的对象内存,却并不拥有该内存。而使用weak_ptr成员lock,则可返回其指向内存的一个shared_ptr对象,且在所指对象内存已经无效时,返回指针空值(nullptr)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <memory>
#include <iostream>
using namespace std;

void Check(weak_ptr<int>& wp) {
shared_ptr<int> ap = wp.lock(); //转换为shared_ptr<int>
if (sp != nullptr)
cout << "still " << *sp << endl;
else
cout << "pointer is invalid" << endl;
}
int main() {
shared_ptr<int> sp1 (new int(22));
shared_ptr<int> sp2 = sp1;
weak_ptr<int> wp = sp1; //指向shared_ptr<int>所指对象

cout >> *sp1 << endl; // 22
cout >> *sp2 << endl; // 22
Check(wp) // still 22

sp1.reset()
cout << *sp2 << endl; // 22
Check(wp); // still 22
sp2.reset();
Check(wp) // pointer is invalid

sp1sp2都有效的时候,调用wplock函数,将返回一个有效的shared_ptr对象供使用,此后我们分别调用了sp1sp2reset函数,这会导致对唯一的堆内存对象的引用计数降至0。而一旦引用计数归0,shared_ptr<int>就会释放堆内存空间,使之失效。此时我们再调用weak_ptrlock函数时,则返回一个指针空值nullptr

垃圾回收的分类

垃圾回收的方式虽然很多,但主要可以分为两大类

  • 基于引用计数的垃圾回收器
    • 引用计数主要是使用系统记录对象被引用(引用、指针)的次数。当对象被引用的次数变为0时,该对象即可被视作“垃圾”而回收。
    • 使用引用计数做垃圾回收的算法的一个优点是实现很简单,与其他垃圾回收算法相比,该方法不会造成程序暂停,因为计数的增减与对象的使用是紧密结合的。
    • 此外,引用计数也不会对系统的缓存或者交换空间造成冲击,因此被认为“副作用”较小。
    • 但是这种方法比较难处理“环形引用”问题,此外由于计数带来的额外开销也并不小,所以在实用上也有一定的限制。
  • 基于跟踪处理的垃圾回收器
    • 跟踪处理的垃圾回收机制基本方法是产生跟踪对象的关系图,然后进行垃圾回收。
    • 使用跟踪方式的垃圾回收算法主要有以下几种:
      • 标记-清除(Mark-Swep)
        • 首先该算法将程序中正在使用的对象视为“根对象”,从根对象开始查找它们所引用的堆空间,并在这些堆空间上做标记。当标记结束后所有被标记的对象就是可达对象( Reachable Object)或活对象( Live Object),而没有被标记的对象就被认为是垃圾
        • 在第二步的清扫(Swep)阶段会被回收掉。
        • 这种方法的特点是活的对象不会被移动,但是其存在会出现大量的内存碎片的问题。
      • 标记-整理(Mark-Compact)
        • 标记完之后,不再遍历所有对象清扫垃圾,而是将活的对象向“左”靠齐,这就解决了内存碎片的问题。
        • 标记-整理的方法有个特点就是移动活的对象,因此相应的,程序中所有对堆内存的引用都必须更新
      • 标记-拷贝(Mark-Copy)
        • 这种算法将堆空间分为两个部分:From和To。
        • 刚开始系统只从From的堆空间里面分配内存,当From分配满的时候系统就开始垃圾回收:从From堆空间找出所有活的对象,拷贝到To的堆空间里。这样一来,From的堆空间里面就全剩下垃圾了。而对象被拷贝到T0里之后,在To里是紧湊排列的。
        • 接下来是需要将From和To交换一下角色,接着从新的From里面开始分配。
        • 标记-拷贝算法的一个问题是堆的利用率只有一半,而且也需要移动活的对象。

提高性能及操作硬件的能力

常量表达式

运行时常量性与编译时常量性

常量通常是通过 const关键字来修饰的。比如const int i = 3

上述代码就声明了一个名字为i的常量。const还可以修饰函数参数、函数返回值、函数本身、类等。在不同的使用条件下, const有不同的意义,不过大多数情况下, const描述的都是一些“运行时常量性”的概念,即具有运行时数据的不可更改性。不过有的时候,我们需要的却是编译时期的常量性,这是 const关键字无法保证的。

1
2
3
4
5
6
7
8
9
10
11
12
const int Getconst() { return 1; }

void Constless(int cond) {
int arr[Getconst()] = {0}; //无法通过编译
enum{ e1 = Getconst(), e2 }; //无法通过编译
switch (cond) {
case Getconst():
break;
default:
break;
}
}

我们定义了一个返回常数1的函数Getconst()。我们使用了const关键字修饰了返回类型。不过编译后我们发现,无论将Getconst的结果用于需要初始化数组arr的声明中,还是用于匿名枚举中,或用于switch-case的case表达式中,编译器都会报告错误。这些语句都需要的是编译时期的常量值。而const修饰的函数返回值,只保证了在运行时期内其值是不可以被更改的。这是两个完全不同的概念。

C++11中对象时期常量的回答是constexpr,即常量表达式(constant expression)。可以用下面的声明方法

1
constexpr int Getconst() {return 1;}

即在函数表达式前加上constexpr关键字即可。有了常量表达式这样的声明,编译器就可以在编译时期对Getconst表达式进行值计算(evaluation),从而将其视为一个绵译时期的常量。常量表达式实际上可以作用的实体不仅限于函数,还可以作用于数据声明,以及类的构造函数等。

常量表达式函数

通常我们可以在函数返回类型前加入关键字constexpr来使其成为常量表达式函数。不过并非所有的函数都有资格成为常量表达式函数。事实上,常量表达式函数的要求非常严格。总结起来,大概有以下几点:

  • 函数体只有单一的return返回语句。
    • 函数体中只有一条语句,且该条语句必须是 return语句。
    • 这就意味着形如int i = 1; return i;这样的多条语句的写法是无法通过编译的。
    • 不过一些不会产生实际代码的语句不会导致编译器的“抱怨”。
  • 函数必须返回值(不能是void函数)。
    • 形如constexpr void f() {}这样的不返回值的函数就不能是常量表达式。
    • 因为无法获得常量的常量表达式是不被认可的。
  • 在使用前必须已有定义。
    -
  • return返回语句表达式中不能使用非常量表达式的函数、全局数据,且必须是一个常量表达式。
    • 形如constexpr int g() { return e();}或者形如constexpr int h() { return g;}的常量表达式定义是不能通过编译的。
    • 如果我们要使得g()是个编译时的常量,那么其return表达式语句就不能包含运行时才能确定返回值的函数。

常量表达式值

通常情况下,常量表达式值必须被常量表达式赋值,而跟常量表达式函数一样,常量表达式值在使用前必须被初始化。而使用constexpr声明的数据最常被问起的问题是,下列两条语句:

1
2
const int i = 1;
constexpr int j = 1;

在大多数情况下是没有区别的。如果i在全局名字空间中,编译器一定会为i产生数据。而对于j,如果不是有代码显式地使用了它的地址,编译器可以选择不为它生成数据,而仅将其当做编译时期的值。

有的时候,我们在常量表达式中会看到浮点数。通常情况下,编译器对浮点数做编译时期常量这件事情很敏感。因为编译时环境和运行时环境可能有所不同,那么编译时的浮点常量和实际运行时的浮点数常量可能在精度上存在差别。而对于自定义类型的数据,要使其成为常量表达式值的话,则不像内置类型这么简单。C++标准中,constexpr关键字是不能用于修饰自定义类型的定义的。比如下面这样的类型定义和使用

1
2
constexpr struct Mytype { int i; }
constexpr Mytype mt = { 0 };

在C++11中,就是无法通过编译的。正确地做法是,定义自定义常量构造函数。

1
2
3
4
5
6
struct Mytype {
constexpr Mytype(int x): i(x) {}
int i;
};

constexpr Mytype mt = { 0 };

我们对Mytype的构造函数进行了定义。不过在定义前,我们加上了constexpr关键字。通过这样的定义,Mytype类型的constexpr的变量mt的定义就可以通过编译了。

常量表达式的构造函数也有使用上的约束,主要的有以下两点

  • 函数体必须为空。
  • 初始化列表只能由常量表达式来赋值。

形如下面的常量表达式构造函数都是无法通过编译的

1
2
int f();
struct Mytype { int i; constexpr Mytype(): i(f()) {} };

常量表达式的其他应用

常量表达式是可以用于模板函数的。不过由于模板中类型的不确定性,所以模板函数是否会被实例化为一个能够满足编译时常量性的版本通常也是未知的。针对这种情况,C++11标准规定,当声明为常量表达式的模板函数后,而某个该模板函数的实例化结果不满足常量表达式的需求的话,constexpr会被自动忽略。该实例化后的函数将成为一个普通函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Notliteral {
Notliteral() { i = 5; }
int i;
};
Notliteral nl;

template <typename T> contexpr T Constexp(T t) {
return t;
}

void g() {
Nothiteral nl;
Notliteral nl1 = Constexp(nl);
constexpr Notliteral nl2 = Constexp(nl); //无法通过编译
int a = Constexp(1);

结构体Notliteral不是一个定义了常量表达式构造函数的类型,因此是不能够声明为常量表达式值的。而模板函数Constexp一旦以Notliteral为参数的话,那么其constexpr关键字将被忽略,如nl1变量所示。实例化为Constexp<Notliteral>的函数将不是一个常量表达式函数,因此,我们也看到nl2是无法通过编译的。而在可以实例化为常量表达式函数的时候,Constexp则可以用于常量表达式值的初始化。比如本例中的a,就是由实例化为Constexp<int>的常量表达式函数所初始化的。

对于常量表达式的应用,还有一个有趣的问题就是函数递归问题。在标准中说明,符合C++11标准的编译器对常量表达式函数应该至少支持512层的递归。

1
2
3
4
5
6
7
8
9
10
11
12
#include<iostream>
using namespace std;
constexpr int Fibonacci(int n) {
return (n == 1) ? 1 : ((n == 2) ? 1 : Fibonacci(n-1) + Fibonacci(n-2));
}

int main() {
int fib[] = {
Fibonacci(11), Fibonacci(12),
Fibonacci(13), Fibonacci(14),
Fibonacci(15),Fibonacci (16)
};

早在C++模板刚出现的时候,就出现了基于模板的编译时期运算的编程方式,这种编程通常被称为模板元编程(template meta-programming)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
template <long num>
struct Fibonacci {
static const long val = Fibonacci<num-1>::val + Fibonacci<num-2>::val;
};

template <> struct Fibonacci<2>{ static const long val = 1; }
template <> struct Fibonacci<1>{ static const long val = 1; }
template <> struct Fibonacci<0>{ static const long val = 0; }

int main() {
fib[] = {
Fibonacci<11>::val, Fibonacci<12>::val,
Fibonacci<13>::val, Fibonacci<14>::val,
Fibonacci<15>::val, Fibonacci<16>::val
};
}

定义了一个非类型参数的模板Fibonacci。该模板类定义了一个静态变量val,而val的定义方式是递归的。因此模板将会递归地进行推导。此外,我们还通过偏特化定义了模板推导的边界条件,即斐波那契的初始值。那么模板在推导到边界条件的时候就会终止推导。通过这样的方法,我们同样可以在编译时进行值计算,从而生成数组的值。通过constexpr进行的运行时值计算,跟模板元编程非常类似。因此有的程序员自然地称利用constexpr进行编译时期运算的编程方式为constexpr元编程。

变长模板

变长函数和变长的模板参数

通过使用变长函数(variadic funciton),printf的实现能够接受任何长度的参数列表。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdarh.h>

double Sumoffloat(int count, ...) {
va_list ap;
double sum = 0;
va_start(ap, count); //获得变长列表的句柄ap
for (int i = 0; i < count; i ++)
sum += va_arg(ap, double); //每次获得一个参数
va_end(ap);
return sum;
}

在被调用者中,需要通过一个类型为va_list的数据结构ap来辅助地获得参数。可以看到,这里代码首先使用va_start函数对ap进行初始化,使得ap成为被传递的变长参数的一个句柄。而后代码再使用va_arg函数从ap中将参数一一取出用于运算。由于这里是计算浮点数的和,所以每次总是给va_arg传递一个double类型作为参数。图显示了一种变长函数的可能的实现方式,即以句柄ap为指向各个变长参数的指针,而va_arg则通过改变指针的方式来返回下一个指针所指向的对象。

変长模板:模板参数包和函数参数包

tuple为例,我们需要以下代码来声明tuple是一个变长类模板

1
template <typename... Elements> class tuple;

可以看到,我们在标示符Elements之前的使用了省略号来表示该参数是变长的。在C++11中,Elements被称作是一个“模板参数包”。这是一种新的模板参数类型。有了这样的参数包,类模板tuple就可以接受任意多个参数作为模板参数。对于以下实例化的tuple模板类:

1
tuple<int, char, double>

编译器则可以将多个模板参数打包成为“单个的”模板参数包Elements,即Element在进行模板推导的时候,就是一个包含int、char和 double三种类型类型集合。

与普通的模板参数类似,模板参数包也可以是非类型的,比如

1
2
template<int... A> class Nontypevariadictemplate{};
Nontypevariadictemplate<1, 0, 2> ntvt;

就定义了接受非类型参数的变长模板Nontypevariadictemplate。这里,我们实例化参数(1,0,2)的模板实例该声明方式相当于

1
2
template<int, int, int> class Nontypevariadictemplate{};
Nontypevariadictemplate<1, 0, 2> ntvt;

这样的类模板定义和实例化。除了类型的模板参数包和非类型的模板参数包,模板参数包实际上还是模板类型的。

一个模板参数包在模板推导时会被认为是模板的单个参数(虽然实际上它将会打包任意数量的实参)。为了使用模板参数包,我们总是需要将其解包(unpack)。在C++11中,这通常是通过一个名为包扩展(pack expansion)的表达式来完成。比如:

1
template<typename... A> class Template: private B<A...>{};

这里的表达式A...就是一个包扩展。直观地看,参数包会在包扩展的位置展开为多个参数。比如:

1
2
3
template<typename T1, typename T2> class B {};
template<typename...A> class Template: private B<A...>{};
Template<X, Y> xy;

这里我们为类模板声明了一个参数包A,而使用参数包A则是在Template的私有基类B<A...>中,那么最后一个表达式就声明了一个基类为B<X,Y>的模板类Template<X,Y>的对象xy。其中XY两个模板参数先是被打包为参数包A,而后又在包扩展表达式A...中被还原。

通过定义递归的模板偏特化定义,我们可以使得模板参数包在实例化时能够层层展开,直到参数包中的参数逐渐耗尽或到达某个数量的边界为止。下面的例子是一个用变长模板实现tuple的代码。

1
2
3
4
5
6
7
template<typename... Elements> class tuple; //变长模板的声明

template<typename Head, typename... Tail> //递归的偏特化定义
class tuple<Head, Tail...> : private tuple<Tail...> {
Head head;
};
template<> class tuple<> {}; //边界条件

我们声明了变长模板类tuple,其只包含一个模板参数,即Elements模板参数包。此外,我们又偏特化地定义了一个双参数的tuple的版本。该偏特化版本的tuple包含了两个参数,一个是类型模板参数Head,另一个则是模板参数包TailHead型的数据作为tuple<Head,Tail...>的第一个成员,而将使用了包扩展表达式的模板类tuple<Tail...>作为tuple<Head,Tail...>的私有基类。这样来,当程序员实例化一个形如tuple <double, int, char, float>的类型时,则会引起基类的递归构造,这样的递归在tuple的参数包为0个的时候会结束。

我们再来看一个使用非类型模板的一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
using namespace std;

template <long... nums> struct Multiply;
template <long first, long... last>
struct Multiply<first, last...> {
static const long val = first * Multiply<last...>::val;
};

template<>
struct Multiply<> {
static const long val = 1;
};

int main() {
cout << Multiply<2, 3, 4, 5>::val << endl;
cout << Multip1y<22,44,66,88,9>::val << endl;
}

变长模板:进阶

标准定义了以下7种参数包可以展开的位置

  • 表达式
  • 初始化列表
  • 基类描述列表
  • 类成员初始化列表
  • 模板参数列表
  • 通用属性列表
  • lambda函数的捕捉列表

语言的其他“地方”则无法展开参数包。而对于包扩展而言,其解包也与其声明的形式息息相关。事实上,我们还可以声明一些有趣的包扩展表达式。比如声明了Arg为参数包,那么我们可以使用Arg&&...这样的包扩展表达式,其解包后等价于Arg1&&, ..., Argn&&

一个更为有趣的包扩展表达式如下:

1
template<typename...A> class T: private B<A>...{}'

注意这个包扩展跟下面的类模板声明

1
template<typename...A> class T: private B<A...>{};

在解包后是不同的,对于同样的实例化T<X,Y>,前者会解包为:class T<X, Y>: private B<X>, private B<Y>{};即多重继承的派生类,而后者则会解包为class T<X, Y>: private B<X, Y>{};即派生于多参数的模板类的派生类,这点存在着本质的不同。

在C++11中,标准还引入了新操作符sizeof...,其作用是计算参数包中的参数个数。通过这个操作符,我们能够实现参数包更多的用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <cassert>
#include <iostream>
using namespace std;
template<class...A> void Print(A...arg) {
assert(false);
}

//特化6参数的版本
void Print(int a1, int a2, int a3, int a4, int a5, int a6) {
cout << a1 << a2 << a3 << a4 << a5 << a6 << endl;
}

template<class...A> int Vaargs(A ...args) {
int size = sizeof...(A); //计算变长包的长度
switch(size) {
case 0: Print(99, 99, 99, 99, 99, 99);
break;
case 1: Print(99, 99, args..., 99, 99, 99);
break;
case 2: Print(99, 99, args..., 99, 99);
break;
case 3: Print(args..., 99, 99, 99);
break;
case 4: Print(99, args..., 99);
break;
case 5: Print(99, args...);
break;
case 6: Print(args...);
break;
default: Print(0, 0, 0, 0, 0, 0);
}
return size;
}

原子类型与原子操作

原子操作与C++11原子类型

所谓原子操作,就是多线程程序中“最小的且不可并行化的”的操作。通常对一个共享资源的操作是原子操作的话,意味着多个线程访问该资源时,有且仅有唯一一个线程在对这个资源进行操作。那么从线程(处理器)的角度看来,其他线程就不能够在本线程对资源访问期间对该资源进行操作,因此原子操作对于多个线程而言,就不会发生有别于单线程程序的意外状况。通常情况下,原子操作都是通过“互斥”( mutual exclusive)的访问来保证的。实现互斥通常需要平台相关的特殊指令,这在C++11标准之前,这常常意味着需要在C/C++代码中嵌入内联汇编代码。

在C++11的并行程序中,使用原子类型是非常容易的。事实上,由于C++11与C11标准都支持原子类型,因此我们可以简单地通过#include<cstdatomic>头文件中来使用对应于内置类型的原子类型定义。<cstdatomic>中包含的原子类型定义如表所示。

原子类型名称 对应的内置类型名称
atomic_bool bool
atomic_char char
atomic_schar signed char
atomic_uchar unsigned char
atomic_int int
atomic_uint unsigned int
atomic_short short
atomic_ushort unsigned short
atomic_long long
atomic_ulong unsigned long
atomic_llong long long
atomic_ullong unsigned long long
atomic_char16_t char16_t
atomic_char32_t char32_t
atomic_wchar_t wchar_t

程序员可以使用atomic类模板任意定义出需要的原子类型。比如下列语句:

1
std::atomic<T> t;

就声明了一个类型为T的原子类型変量t。编译器会保证产生并行情况下行为良好的代码,以避免线程间对数据t的竞争。

对于线程而言,原子类型通常属于“资源型”的数据,这意味着多个线程通常只能访问单个原子类型的拷贝。因此在C++11中,原子类型只能从其模板参数类型中进行构造,标准不允许原子类型进行拷贝构造、移动构造,以及使用operator=等,以防止发生意外。比如

1
2
atomic<float> af {1.2f}; 
atomic<float> af1 {af};//无法通过编译

其中,af1{af}的构造方式在C++11中是不允许的。不过从atomic<T>类型的变量来构造其模板参数类型T的变量则是可以的。比如:

1
2
3
atomic<float> af {1.2f};
float f = af;
float f1 {af};

这是由于atomic类模板总是定义了从atomic<T>T的类型转换函数的缘故。在需要时,编译器会隐式地完成原子类型到其对应的类型的转换。

内存模型,顺序一致性与memory order

要了解顺序一致性以及内存模型,我们不妨看看代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a {0};
atomic<int> b {0};
int valueset (int) {
int t = 1;
a = t;
b = 2;
}

int Observer(int) {
cout << a << b << endl;
} //可能有多种输出

int main() {
thread t1(valueset, 0);
thread t2(observer, 0);
t1.join();
t2.join();
}

我们创建了两个线程1和2,分别执行valuesetobserver。在valueset中,为ab分别赋值1和2。而在observer中,只是打印出ab的值。可以想象,由于observer打印ab的时间与valueset设置ab的时间可能有多种组合方式。

默认情况下,在C++11中的原子类型的变量在线程中总是保持着顺序执行的特性。我们称这样的特性为“顺序一致”的,即代码在线程中运行的顺序与程序员看到的代码顺序一致。

对于C++11中的内存模型而言,要保证代码的顺序一致性,就必须同时做到以下几点

  • 编译器保证原子操作的指令间顺序不变,即保证产生的读写原子类型的变量的机器指令与代码编写者看到的是一致的。
  • 处理器对原子操作的汇编指令的执行顺序不变。这对于x86这样的强顺序的体系结构而言,并没有任何的问题。

如前文所述,在C++11中,原子类型的成员函数(原子操作)总是保证了顺序一致性。这对于x86这样的平台来说,禁止了编译器对原子类型变量间的重排序优化。在C++11中,设计者给出的解决方式是让程序员为原子操作指定所谓的内存顺序。代码中可以采用一种松散的内存模型来放松对原子操作的执行顺序的要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;
atomic<int> a {0};
atomic<int> b {0};

int Valueset(int) {
int t = 1;
a.store(t, memory_order_relaxed);
b.store(2, memory_order_relaxed);
}
int Observer(int) {
cout << a << b << endl; //可能有多种输出
}

int main() {
thread t1(valueset, 0);
thread t2(observer, 0);
t1.join();
t2.join();
}

Valueset函数进行了改造。之前的对ab进行赋值的语句我们改用了atomic类模板的store成员。store能够接受两个参数,一个是需要写入的值,一个是名为memory_order的枚举值。这里我们使用的值是memory_order_relaxed,表示使用松散的内存模型,该指令可以任由编译器重排序或者由处理器乱序执行。这样一来,ab赋值语句的“先于发生”顺序得到了解除,我们也就可能得到最佳的运行性能。

大多数atomic原子操作都可以使用memory_order作为一个参数,在C++11中,标准一共定义了7种memory_order的枚举值。

枚举值 定义规则
memory_order_relaxed 不对执行顺序做任何保证
memory_order_acquire 本线程中,所有后续的读操作必须在本条原子操作完成后执行
memory_orfer_release 本线程中,所有之前的写操作完成后才能执行本条原子操作
memory_order_acq_rel 同时包含memory_order_acquire和memory_order_release标记
memory_order_consume 本线程中,所有后续的有关本原子类型的操作,必须在本条原子操作完成之后执行
memory_order_seq_cst 全部存取都按顺序执行

memory_order_seq_cst表示该原子操作必须是顺序一致的,这是C++11中所有atomic原子操作的默认值,不带memory_order参数的原子操作就是使用该值。而memory_order_relaxed则表示该原子操作是松散的,可以被任意重排序的。值得注意的是,并非每种memory_order都可以被atomic的成员使用。通常情况下,我们可以把atomic成员函数可使用的memory_order值分为以下3组

  • 原子存储操作(store)可以使用memorey_order_relaxedmemory_order_releasememory_order_seq_cst
  • 原子读取操作(load)可以使用memorey_order_relaxedmemory_order_consumememory_order_acquirememory_order_seq_cst
  • RMW操作,一些需要同时读写的操作,比如之前提过的atomic_flag类型的test_and_set()操作。又比如atomic类模板的atomic_compare_exchange()操作等都是需要同时读写的。RMW操作可以使用memorey_order_relaxedmemory_order_consumememory_order_acquirememory_order_releasememory_order_acq_relmemory_order_seq_cst

形如operator=operator+=的函数,事实上都是memory_order_seq_cst作为memory order参数的原子操作的简单封装。也即是说,之前小节中的代码都是采用顺序致性的内存模型。如之前提到的,memory_order_seq_cst这种memory order对于atomic类型数据的内存顺序要求过高,容易阻碍系统发挥线程应有的性能。而memorey_order_relaxed对内存顺序毫无要求。

但在另外一些情况下,则还是可能无法满足真正的需求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;

atomic<int> a;
atomic<int> b;

int Thread1(int) {
int t = 1;
a.store(t, memory_order_relaxed);
b.store(2, memory_order_relaxed);
}

int Thread2(int) {
while(b.load(memory_order_relaxed) != 2); //自旋等待
cout << a.load(memory_order_relaxed) << endl;
}

int main() {
thread t1(Thread1, 0);
thread t2(Thread2, 0);

t2.join();
t2.join();
return 0;
}

这里我们并不希望完全禁用关于原子类型的优化,而采用了memory_order_relaxed作为memory order参数。在一些弱内存模型的机器上,这两条a、b赋值语句将有可能任意一条被先执行。那么对于Thread2函数而言,它先是自旋等待b的值被赋为2,随后将a的值输出。按照松散的内存顺序,我们输出的a的值则有可能为0,也有可能为1。

如果读者仔细地分析的话,我们所需要的只是a.store先于b.store发生,b.load先于a.load发生的顺序。这要这两个“先于发生”关系得到了遵守,对于整个程序而言来说,就不会发生线程间的错误。建立这种“先于发生”关系,即原子操作间的顺序则需要利用其他的memory order枚举值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <thread>
#include <atomic>
#include <iostream>
using namespace std;

atomic<int> a;
atomic<int> b;

int Thread1(int) {
int t = 1;
a.store(t, memory_order_relaxed);
b.store(2, memory_order_release); //本原子操作前所有的写原子操作必须完成
}

int Thread2(int) {
while(b.load(memory_order_acquire)!=2); //本原子操作必須完成才能执行之后所有的读原子操作
cout << a.load(memory_order_relaxed) << endl: //1
}

int main() {
thread t1(Thread1, 0);
thread t2(Thread2, 0);
t1.join();
t2.join();
}

一是b.store采用了memory_order_release内存顺序,这保证了本原子操作前所有的写原子操作必完成,也即a.store作必发生于b.store之前。二是b.load采用了memory_order_acquire作为内存顺序,这保证了本原子操作必须完成才能执行之后所有的读原子操作。即b.load必须发生在a.load操作之前。这样一来,通过确立“先于发生”关系的,我们就完全保证了代码运行的正确性,即当b的值为2的时候,a的值也确定地为1。而打印语句也不会在自旋等待之前打印a的值。

通常情况下,“先于发生”关系总是传递的,比如原子操作A发生于原子操作B之前而原子操作B又发生于原子操作C之前的话,则A一定发生于C之前。有了这样的顺序就可以指导编译器在重排序指令的时候在不破坏依赖规则(相当于多给了一些依赖关系)的情况下,仅在适当的位置插入内存栅栏,以保证执行指令时数据执行正确的同时获得最佳的运行性能。

形如其名,memory_order_releasememory_order_consume的配合会建立关于原子类程的“生产者消费者”的同步顺序。同样的,我们可以称之为 release- consume内存顺序。顺序一致、松散、release-acquire和release-consume通常是最为典型的4种内存顺序。其他的如memory_order_acq_rel,则是常用于实现一种叫做CAS( compare and swap)的基本同步元语,对应到atomic的原子操作compare_exchange_strong成员函数上。我们也称之为acquire-release内存顺序。

线程局部存储

线程局部存储(TLS, thread local storage)是一个已有的概念。简单地说,所谓线程局部存储变量,就是拥有线程生命期及线程可见性的变量。

线程局部存储实际上是由单线程程序中的全局/静态变量被应用到多线程程序中被线程共享而来。通常情况下,线程会拥有自己的栈空间,但是堆空间、静态数据区(如果从可执行文件的角度来看,静态数据区对应的是可执行文件的daa、bss段的数据,而从CC++语言层面而言,则对应的是全局/静态量)则是共享的。这样一来,全局、静态变量在这种多线程模型下就总是在线程间共享的。多全局、静态变量的共享虽然会带来一些好处,尤其对一些资源性的变量(比如文件句柄)来说也是应该的,不过并不是所有的全局、静态变量都适合在多线程的情况下共享。

各个编译器公司都有自己的TLS标准。我们在g++/clang++/xlc++中可以看到如下的语法:

1
__thread int errCode;

在全局或者静态变量的声明中加上关键字__thread,即可将变量声明为TLS变量。每个线程将拥有独立的errcode的拷贝,一个线程中对errcode的读写并不会影响另外一个线程中的errcode的数据。C++11对TLS标准做出了一些统一的规定。与__thread修饰符类似,声明一个TLS变量的语法很简单,即通过thread_local修饰符声明变量即可:

1
int thread_local errCode;

一旦声明一个变量为thread_local,其值将在线程开始时被初始化,而在线程结束时,该值也将不再有效。对于thread_local变量地址取值(&),也只可以获得当前线程中的TLS变量的地址值。

快速退出:quick_exit与at_quick_exit

首先我们可以看看terminate函数,没有被捕捉的异常就会导致terminate函数的调用。terminate函数在默认情况下,是去调用abort,不过用户可以通过set_terminate函数来改変要认的行为。

源自于C中的abort则更加低层。abort函数不会调用任何的析构函数,默认情况下,它会向合乎POSIX标准的系统抛出一个信号(signal):SIGABRT。相比而言,exit这样的属于“正常退出”范畴的程序终止,则不太可能有以上的问题。exit函数会正常调用自动变量的析构函数,并且还会调用atexit注册的函数。

在C++11中,标准引入了quick_exit函数,该函数并不执行析构函数而只是使程序终止。与 abort不同的是,abor的结果通常是异常退出(可能系统还会进行 coredump等以辅助程序员进行问题分析),而quick_exitexit同属于正常退出。此外,使用at_quick_exit注册的函数也可以在quick_exit的时候被调用。这样一来,我们同样可以像exit一样做一些清理的工作。在C++11标准中, at_quick_exitat_exit一样,标准要求编译器至少支持32个注册函数的调用。

1
2
3
4
5
6
7
8
9
10
11
12
#include <cstdlib>
#include <iostream>
using namespace std;
struct A { ~A() { cout << "Destruct A." << endl; } };

void closedevice() { cout << "device is closed. "<< endl; }

int main() {
A a;
at_quick_exit(closedevice);
quick_exit(0);
}

为改变思考方式而改变

指针空值nullptr

指针空值:从0到NULL,再到nullptr

一般情况下,NULL是一个宏定义。

1
2
3
4
5
6
#undef NULL
#if defined(__cpluspuls)
#define NULL 0
#else
#define NULL ((void*)0)
#endif

NULL可能被定义为字面常量0,或者是定义为无类型指针void*常量。编译器总是会优先把NULL看作是一个整型常量,这会引起一些二义性,比如intchar*的重载。在C++11新标准中,为二义性给出了新的答案,就是nullptrnullptr是一个所谓“指针空值类型”的常量。指针空值类型被命名为nullptr_t,事实上,我们可以在支持nullptr_t的头文件中找出如下定义

1
typedef decltype(nullptr) nullptr_t;

可以看到,nullptr_t的定义方式非常有趣,使用nullptr_t的时候必须include<cstddef>,而nullptr则不用。这大概就是由于nullptr是关键字,而nullptr_t是通过推导而来的缘故。

而相比于gcc等编译器将NULL预处理为编译器内部标识nulnullptr拥有更大的优势。简单而言,由于nullptr是有类型的,且仅可以被隐式转化为指针类型

nullptr和nullptr_t

C++11标准不仅定义了指针空值常量nullptr,也定义了其指针空值类型nullptr_t,也就表示了指针空值类型并非仅有nullptr一个实例。通常情况下,也可以通过nullptr_t来声明个指针空值类型的变量(即使看起来用途不大)。除去nullptrnullptr_t以外,C++中还存在各种内置类型。C++11标准严格规定了数据间的关系。大体上常见的规则简单地列在了下面:

  • 所有定义为nullptr_t类型的数据都是等价的,行为也是完全一致。
  • nullptr_t类型数据可以隐式转换成任意一个指针类型
  • nullptr_t类型数据不能转换为非指针类型,即使使用reinterpret_cast<nullptr_t>()的方式也是不可以的。
  • nullptr_t类型数据不适用于算术运算表达式。
  • nullptr_t类型数据可以用于关系运算表达式,但仅能与nullptr_t类型数据或者指针类型数据进行比较,当且仅当关系运算符为=、<=、>=等时返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <typeinfo>
using namespace std;
int main() {
//nullptr可以隐式转换为 char*
char *cp = nullptr;

// 不可转换为整型,而任何类型也不能转换为 nullptr_t
// 以下代码不能通过编译
// int n1 = nullptr;
// int n2 = reinterpret_cast<int>(nullptr)

//nul1ptr与nullptr_t类型变量可以作比较
//当使用=、<=、>=符号比较时返回true
nullptr_t nptr;
if (nptr == nullptr)
cout << "nullptr_t nptr == nullptr" << endl;
else
cout << "nullptr_t nptr != nullptr" << endl;

if (nptr < nullptr)
cout << "nullptr_t nptr < nullptr" << endl;
else
cout << "nullptr_t nptr !< nullptr" << endl;

// 不能转换为整型或bool类型,以下代码不能通过编译
// if (0 == nullptr);
// if (nullptr)
// 不可以进行算术运算,以下代码不能通过编译
// nullptr += 1;

//以下操作均可以正常进行
sizeof(nullptr);
typeid(nullptr);
throw(nullptr);
return 0;
}

一些关于nullptr规则的讨论

在C++11标准中,nullptr类型数据所占用的内存空间大小跟void*相同的,即sizeof(nullptr_t)==sizeof(void*)。两者在语法层面有着不同的内涵。nullptr是一个编译时期的常量,它的名字是一个编译时期的关键字,能够为编译器所识别。而(void*)0只是一个强制转换表达式,其返回的也是一个void*指针类型。而且最为重要的是,在C++语言中,nullptr到任何指针的转换是隐式的,而(void*)0则必须经过类型转换后才能使用。

默认函数的控制

类与默认函数

在C++中声明自定义的类,编译器会默认帮助程序员生成一些他们未自定义的成员函数。这样的函数版本被称为“默认函数”。这包括了以下一些自定义类型的成员函数

  • 构造函数
  • 拷贝构造函数
  • 拷贝赋值函数
  • 移动构造函数
  • 移动拷贝函数
  • 析构函数

此外,C++编译器还会为以下这些自定义类型提供全局默认操作符函数:

  • operator&
  • operator&&
  • operator*
  • operator->
  • operator->*
  • operator new
  • operator delete

在C++语言规则中,一旦程序员实现了这些函数的自定义版本,则编译器不会再为该类自动生成默认版本。有时这样的规则会被程序员忘记,最常见的是声明了带参数的构造版本,则必须声明不带参数的版本以完成无参的变量初始化。不过通过编译器的提示,这样的问题通常会得到更正。但更为严重的问题是,一旦声明了自定义版本的构造函数,则有可能导致我们定义的类型不再是POD的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <type_trait>
#include <iostream>

class Twocstor {
public:
// 提供了带参数版本的构造函数,则必须自行提供
// 不带参数版本,且Twocstor不再是POD类型
Twocstor() {};
Twocstor(int i): data(i) {}
private:
int data;
};

int main() {
cout << is_pod<Twocstor>::value << endl;
}

虽然提供了Twocstor()构造函数,它与默认的构造函数接口和使用方式也完全一致,不过该构造函数却不是平凡的,因此Twocstor也就不再是POD的了。使用is_pod模板类查看Twocstor,也会发现程序输出为0。

在C++11中,标准提供default关键字,程序员可以在默认函数定义或者声明时加上=default,从而显式地指示编译器生成该函数的默认版本。而如果指定产生默认版本后,程序员不再也不应该实现一份同名的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <type_traits>
#include <iostream》
using namespace std;

class Twocstor {
public:
// 提供了带参数版本的构造函数,再指示编译器
// 提供默认版本,则本自定义类型依然是POD类型
Twocstor() = default;
Twocstor(int i): data(i) {}
private:
int data;
}

另一方面,程序员在一些情况下则希望能够限制一些默认函数的生成。最典型地,类的编写者有时需要禁止使用者使用拷贝构造函数,在C++98标准中,我们的做法是将拷贝构造函数声明为private的成员,并且不提供函数实现。这样一来,一且有人试图(或者无意识)使用拷贝构造函数,编译器就会报错。

在C++11中,标准则给出了更为简单的方法,即在函数的定义或者声明加上=delete会指示编译器不生成函数的缺省版本。

“= default”与”= deleted”

C++11标准称= default修饰的函数为显式缺省(explicit defaulted)函数,而称= delete修饰的函数为删除(deleted)函数。C++11引入显式缺省和显式删除是为了增强对类默认函数的控制,让程序员能够更加精细地控制默认版本的函数。不过这并不是它们的唯一功能,而且使用上,也不仅仅局限在类的定义内。事实上,显式缺省不仅可以用于在类的定义中修饰成员函数,也可以在类定义之外修饰成员函数。

1
2
3
4
5
6
7
8
9
10
class Defaultedoptr {
public:
//使用“= default”来产生缺省版本
Defaultdoptr() = default;

//这里没使用“default”
Defaultedoptr & operator = (const Defaultedoptr &);
};
// 在类定义外用“= default”来指明使用缺省版本
inline Defaultedoptr & Defaultedoptr::operator =(const Defaultedoptr &) = default;

Defaultedoptr的操作符operator=被声明在了类的定义外,并且被设定为缺省版本。这在C++11规则中也是被允许的。在类定义外显式指定缺省版本所带来的好处是,程序员可以对一个 class定义提供多个实现版本。

对一些普通的函数仍然可以通过显式删除来禁止类型转换。

1
2
3
4
5
6
7
8
void Func(int i) {}
void Func(char c) = delete;

int main() {
Func(3);
Func('c'); // 显式删除char版本
return 0;
}

显式删除还有一些有趣的使用方式。比如使用显式删除来删除自定义类型的operator new操作符的话,皆可以做到避免在堆上分配该class的对象:

1
2
3
4
5
6
7
8
9
10
class NoHeapAlloc {
public:
void * operator new(std::size_t) = delete;
};

int main() {
NoHeapAlloc n;
NoHeapAlloc * a = new NoHeapAlloc(); // 失败
return 0;
}

lambda函数

C++11中的lambda函数

我们可以通过一个例子先来观察一下,如代码

1
2
3
4
5
int main() {
int girls = 3, boys = 4;
auto totalChild = [] {int x, int y} ->int( return x + y; }
return totalChild(girls, boys);
}

我们定义了一个lambda函数。该函数接受两个参数(int x, int y),并且返回其和。直观地看, lambda函数跟普通函数相比不需要定义函数名,取而代之的多了一对方括号[]。此外, lambda函数还采用了追踪返回类型的方式声明其返回值。其余方面看起来则跟普通函数定义一样。

而通常情况下, lambda函数的语法定义如下

1
[capture] (parameters) mutable -> return-type{statement}

其中:

  • [capture]:捕捉列表。捕捉列表总是出现在 lambda函数的开始处。事实上,[]是lambda引出符。编译器根据该引出符判断接下来的代码是否是lambda函数。捕捉列表能够捕捉上下文中的变量以供 lambda函数使用。具体的方法在下文中会再描述。
  • (parameters):参数列表。与普通函数的参数列表一致。如果不需要参数传递,则可以连同括号一起省略。
  • mutablemutable修饰符。默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。在使用该修饰符时,参数列表不可省略(即使参数为空)。
  • return-type:返回类型。用追踪返回类型形式声明函数的返回类型。出于方便,不需要返回值的时候也可以连同符号->一起省略。此外,在返回类型明确的情况下也可以省略该部分,让编译器对返回类型进行推导。
  • {statement}:函数体。内容与普通函数一样,不过除了可以使用参数之外,还可以使用所有捕获的变量。

在lambda函数的定义中,参数列表和返还类型都是可选的部分,而捕捉列表和函数体都可能为空。那么在极端情况下,C++11中最为简略的alambda函数只需要声明为[]{}就可以了。不过该lambda函数不能做任何事情。

1
2
3
4
5
6
7
8
int main() {
[]{}; // 最简单的lambda函数
int a = 3;
int b = 4;
[=] { return a + b; } // 省略了参数列表与返回类型,返回类型由编译器推断为int
auto fun1 = [&](int c) { b = a + c; } //省略了返回类型,无返回值
auto fun2 = [=, &b](int c) -> int { return b = a + c; };//各部分都很完整的lambda函数
}

直观地讲, lambda函数与普通函数可见的最大区别之一,就是lambda函数可以通过捕捉列表访问一些上下文中的数据。具体地,捕捉列表描述了上下文中哪些的数据可以被lambda使用,以及使用方式(以值传递的方式或引用传递的方式)。

lambda函数的运算是基于初始状态进行的运算。这与函数简单基于参数的运算是有所不同的。语法上,捕捉列表由多个捕捉项组成,并以逗号分割。捕提列表有如下几种形式:

  • [var]表示值传递方式捕捉变量var
  • [=]表示值传递方式捕捉所有父作用域的变量(包括this)
  • [&var]表示引用传递捕捉变量var
  • [&]表示引用传递捕捉所有父作用域的变量(包括this)
  • [this]表示值传递方式捕提当前的this指针。

通过一些组合,捕捉列表可以表示更复杂的意思。比如

  • [=, &a, &b]表示以引用传递的方式捕捉变量ab,值传递方式捕提其他所有变量。
  • [&, a, this]表示以值传递的方式捕捉变量athis,引用传递方式捕捉其他所有变量。

不过值得注意的是,捕捉列表不允许变量重复传递。下面一些例子就是典型的重复,会导致编译时期的错误。

  • [=, a]这里=已经以值传递方式捕捉了所有变量,捕捉a重复
  • [&, &this]这里&已经以引用传递方式捕捉了所有变量,再捕捉this也是一种重复。

lambda与仿函数

仿函数简单地说,就是重定义了成员函数operator ()的一种自定义类型对象。这样的对象有个特点,就是其使用在代码层面感觉跟函数的使用并无二样,但究其本质却并非函数。我们可以看一个仿函数的例子。

1
2
3
4
5
6
7
8
class _functor {
public:
int operator()(int x, int y) { return x + y; }
};
int main() {
int girls = 3, boys = 4;
_functor totalChild;
return totalChild(5, 6);

class _functoroperator()被重载,因此,在调用该函数的时候,我们看到跟函数调用一样的形式,只不过这里的totalChild不是函数名称,而是对象名称。

注意相比于函数,仿函数可以拥有初始状态,一般通过class定义私有成员,并在声明对象的时候对其进行初始化。私有成员的状态就成了仿函数的初始状态。而由于声明一个仿函数对象可以拥有多个不同初始状态的实例,因此可以借由仿函数产生多个功能类似却不同的仿函数实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Tax {
private:
float rate;
int base;
public:
Tax(float r, int b): rate(r), base(b) {}
float operator()(float money) { return (money - base) * rate; }
};

int main() {
Tax high(0.4, 3000);
Tax middle(0.25, 20000);
return 0;
}

这里通过带状态的仿函数,可以设定两种不同的税率的计算。而仔细观察的话,除去自定义类型_functor的声明及其对象的定义,除去在语法层面上的不同, lambda和仿函数有着相同的内涵,都可以捕捉一些变量作为初始状态并接受参数进行运算。

lambda的基础使用

最为简单的应用下,我们会利用 lambda函数来封装一些代码逻辑,使其不仅具有函数的包装性,也具有就地可见的自说明性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
extern int z;
extern float c;
void Calc(int&, int, float &, float);

void Testcalc() {
int x, y = 3;
int success = 0;
auto validate = [&]() -> bool {
if ((x == y+z) && (a == b+c))
return 1;
else
return 0;
};

Calc (x, y, a, b);
success += validate();
y = 1024;
b = 1e13;

Calc (x, y, a, b);
success += validate();
}

这里使用了一个auto关键字推导出了validate变量的类型为匿名 lambda函数。可以看到,我们使用lambda函数直接访问了Testcal中的局部的变量来完成这个工作。在没有 lambda函数之前,通常需要在Testcalc外声明同样一个函数,并且把 Testcalc中的变量当作参数进行传递。出于函数作用域及运行效率考虑,这样声明的函数通常还需要加上关键字 static和 inline。相比于一个传统意义上的函数定义, lambda函数在这里更加直观。

关于lambda的一些问题及有趣的实验

使用 lambda函数的时候,捕捉列表不同会导致不同的结果。具体地讲,按值方式传递捕提列表和按引用方式传递捕捉列表效果是不一样的。对于按值方式传递的捕捉列表,其传递的值在 lambda函数定义的时候就已经决定了。而按引用传递的捕捉列表变量,其传递的值则等于 lambda函数调用时的值。

1
2
3
4
5
6
7
8
9
10
11
int main() {
int j = 12;
auto by_val_lambda = [=] { return j + 1; };
auto by_ref_lambda = [&] { return j + 1; }
cout << "by val lambda: " << by_val_lambda() << endl;
cout << "by ref lambda: " << by_ref_lambda() << endl;

j ++;
cout << "by val lambda: " << by_val_lambda() << endl;
cout << "by ref lambda: " << by_ref_lambda() << endl;
}

结果如下

1
2
3
4
by val lambda: 13
by ref lambda: 13
by val lambda: 13
by ref lambda: 13

这个结果的原因是由于在by_val_lambda中,j被视为了一个常量,一旦初始化后不会再改变(可以认为之后只是一个跟父作用域中j同名的常量)而在by_ref_lambda中,j仍在使用父作用域中的值。

因此简单地总结的话,在使用 lambda函数的时候,如果需要捕捉的值成为 lambda函数的常量,我们通常会使用按值传递的方式捕捉;反之,需要捕捉的值成为 lambda函数运行时的变量(类似于参数的效果),则应该采用按引用方式进行捕捉。

从C++11标准的定义上可以发现, lambda的类型被定义为“闭包”(closure)的类,而每个 lambda表达式则会产生一个闭包类型的临时对象(右值)。因此,严格地讲, lambda函数并非函数指针。不过C++11标准却允许 lambda表达是向函数指针的转换,但前提是lambda函数没有捕捉任何变量,且函数指针所示的函数原型,必须跟 lambda函数有着相同的调用方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
int girls = 3, boys = 4;
auto totalchild = [](int x, int y) -> int { return x + y; };
typedef int (*allchild)(int x, int y);
typedef int (*oneChild)(int x);
allchild p;
p = totalchild;

onechild q;
q = totalchild; // 编译失败

decltype(totalchild) allpeople = totalchild; // 需通过decltype获得lambda类型
decltype(totalchild) totalpeople = p; // 编译失败,指针无法转成lambda
return 0;
}

我们可以把没有捕捉列表的totalchild转化为接受参数类型相同的allchild类型的函数指针。不过,转化为参数类型不一致的onechild类型则会失败。此外,将函数指针转化为lambda也是不成功的。值得注意的是,程序员也可以通过decltype的方式来获得 lambda函数的类型。

除此之外,还有一个问题是关于 lambda函数的常量性及 mutable关键字的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main() {
int val;
auto const_val_lambda = [=]() { val = 3; }; //编译失败,在const的lambda中修改常量

//非const的lambda,可以修改常量数据
auto mutable_val_lambda = [=]() mutable { val = 3; };

//依然是 const的lambda,不过没有改动引用本身
auto const_ref_lambda = [&] { val = 3; };

//依然是 const的lambda,通过参数传递val
auto const_param_lambda = [&](int v) { v = 3; };
const_param_lambda(val);
}

我们定义了4种不同的 lambda函数,这4种 lambda 函数本身的行为都是一致的,即修改父作用域中传递而来的val参数的值。不过对于const_val_lambda函数而言,编译器认为这是一个错误。而对于声明了 mutable属性的函数mutable_val_lambda,以及通过引用传递变量valconst_ref_lambda函数,甚至是通过参数来传递变量valconst_param_lambda,编译器均不会报错。如我们之前的定义中提到一样,C++11中,默认情况下 lambda函数是一个 const函数。按照规则,一个 const的成员函数是不能在函数体中改变非静态成员变量的值的。但这里明显编译器对不同传参或捕捉列表的 lambda函数执行了不同的规则有着不同的见解。

这跟 lambda函数的特别的常量性相关。lambda函数的函数体部分被转化为仿函数之后会成为一个 class 的常量成员函数。整个const_val_lambda看起来会是代码清单所示的样子。

1
2
3
4
5
6
7
8
class const_val_lambda {
public:
const_val_lambda(int v): val(v){}
public:
void operator()() const { val = 3; } /*注意:常量成员函数*/
private:
int val;
};

对于常量成员函数,其常量的规则跟普通的常量函数是不同的。具体而言,对于常量成员函数,不能在函数体内改变 class 中任何成员变量。

lambda的捕捉列表中的变量都会成为等价仿函数的成员变量,而常量成员函数(如operator())中改变其值是不允许的,因而按值捕捉的变量在没有声明为 mutable的 lambda函数中,其值一旦被修改就会导致编译器报错。

而使用引用的方式传递的变量在常量成员函数中值被更改则不会导致错误。简单地说,由于函数const_ref_lambda不会变引用本身,而只会改变引用的值,因此编译器将编译通过。至于按传参数的const_param_lambd就更加不会引起编译器的“抱怨”了。准确地讲,现有C+11标准中的 lambda等价的是有常量operator()的仿函数。因此在使用捕捉列表的时候必须注意,按值传递方式捕捉的变量是 lambda函数中不可更改的常量。

此外, lambda函数的mutable修饰符可以消除其常量性,不过这实际上只是提供了一种语法上的可能性。大多数时侯,我们使用默认版本的(非mutable)的lambda函数也就足够了。

lambda与STL

首先我们来看一个最为常见的STL算法for_each。简单地说,for_each算法的原型如下:

1
UnaryProc for_each(InputIterator beg, InputIterator end, UnaryProc op)

for_each算法需要一个标记开始的Iterator,一个标记结束的Iterator,以及一个接受单个参数的“函数”(即一个函数指针、仿函数或者lambda函数)。for_each的一个示意实现如下

1
2
3
for_each(Iterator begin, Iterator end, Function fn) {
for (Iterator i = begin; i ! end; ++i)
fn(*i);

通过for_each,我们可以完成各种循环操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <vector>
#include <algorithm>

vector<int> nums;
vector<int> largenums;

const int ubound = 10;

inline void Largenumsfunc(int i) {
if (i > ubound)
largenums.push_back (i);
}
void Above() {
//传统的for循环
for (auto itr = nums.begin(); itr != nums.end(): ++itr)
if (*itr >= ubound)
largenums.push_back(*itr);

//使用函数指针
for_each (nums.begin(), nums.end(), Largenumsfunc);

//使用lambda函数和算法for_each
for_each(nums.begin(), nums.end(), [-](int i) {
if (i > ubound)
largenums.push_back(i);
});
}

我们分别用了3种方式来遍历一个vector nums,找出其中大于ubound的值,并将其写入另外一个vector largenums中。第一种是传统的for循环;第二种,则更泛型地使用了for_each算法以及函数指针;第三种同样使用了for_each,但是第三个参数传入的是 lambda函数。首先必须指出的是使用for_each的好处,使用for_each算法不用关心Iterator,或者说循环的细节,只需要设定边界,作用于每个元素的操作,就可以在近似“一条语句”内完成循环,正如函数指针版本和 lambda版本完成的那样。

函数指针的方式看似简洁,不过却有很大的缺陷。第一点是函数定义在别的地方,比如很多行以前(后)或者别的文件中这样的代码阅读起来并不方便。第二点则是出于效率考虑,使用函数指针很可能导致编译器不对其进行 inline优化,在循环次数较多的时候,内联的 lambda和没有能够内联的函数指针可能存在着巨大的性能差别。因此,相比于函数指针,lambda拥有无可替代的优势。

融入实际应用

对齐支持

数据对齐

在了解为什么数据需要对齐之前,我们可以回顾一下打印结构体的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
using namespace std;
struct HowManyBytes{
char a;
int b;
};
int main() {
cout << "sizeof(char): " << sizeof(char) << endl;
cout << "sizeof(int): " << sizeof(int) << endl;
cout << "sizeof(HowManyBytes): " << sizeof(HowManyBytes) << endl;
cout << endl;
cout << "offset of char a: " << offsetof(HowManyBytes, a) << endl;
cout << "offset of int b: " << offsetof(HowManyBytes, b) << endl;
return 0;
}

结构体HowManyBytes由一个char类型成员a及一个int类型成员b组成。编译运行得到如下结果:

1
2
3
4
5
6
sizeof(char): 1
sizeof (int): 4
sizeof (Howmanybytes): 8

offset of char a: 0
offset of int b: 4

这个现象主要是由于数据对齐要求导致的。通常情况下,C/C++结构体中的数据会有一定的对齐要求。在这个例子中,可以通过offsetof查看成员的偏移的方式来检验数据的对齐方式。这里b并非紧邻着a排列。C/C++的int类型数据要求对齐到4字节,即要求int类型数据必须放在一个能够整除4的地址上;而char要求对齐到1字节。这就造成了成员a之后的3字节空间被空出,通常我们也称因为对齐而造成的内存留空为填充数据( padding data)。

对齐方式通常是一个整数,它表示的是一个类型的对象存放的内存地址应满足的条件。对齐的数据在读写上会有性能上的优势。比如频繁使用的数据如果与处理器的高速缓存器大小对齐,有可能提高缓存性能。而数据不对齐可能造成一些不良的后果,比较严重的当属导致应用程序退出。典型的,如在有的平台上,硬件将无法读取不按字对齐的某些类型数据,这个时候硬件会抛出异常来终止程序。而更为普遍的,在一些平台上,不按照字对齐的数据会造成数据读取效率低下

我们利用C++11新提供的修饰符alignas来重新设定Colorvector的对齐方式。

1
2
3
4
5
6
struct alignas(32) Colorvector {
double r;
double g;
double b;
double a;
};

C++11的alignof和alignas

alignof的操作数表示一个定义完整的自定义类型或者内置类型或者变量,返回的值是一个std::size_t类型的整型常量。如同sizeof操作符一样,alignof获得的也是一个与平台相关的值。

alignas既可以接受常量表达式,也可以接受类型作为参数,比如aligns(double) char c也是合法的描述符。其使用效果跟alignas(alignof(double)) char c是一样的。

在C++11标准之前,我们也可以使用一些编译器的扩展来描述对齐方式,比如GNU格式的__attribute__((__aligned__(8))就是一个广泛被接受的版本。

我们在使用常量表达式作为alignas的操作符的时候,其结果必须是以2的自然数幂次作为对齐值。对齐值越大,我们称其对齐要求越高;而对齐值越小,其对齐要求也越低。

在C++11标准中规定了一个“基本对齐值”(fundamental alignment)。一般情况下其值通常等于平台上支持的最大标量类型数据的对齐值(常常是long double)。我们可以通过alignof(std::max_align_t)来查询其值。

对齐描述符可以作用于各种数据。具体来说,可以修饰变量、类的数据成员等,而位域(bit field)以及用register声明的变量则不可以。

1
2
3
4
5
alignas(double) void f(); //错误:alignas不能修饰函数
alignas(double) unsigned char c[sizeof(double)); // 正确
extern unsigned char c [sizeof(double)];
alignas(float)
extern unsigned char c[sizeof(double)); // 错误:不同对齐方式的变量定义

我们再来看一个例子,这个例子中我们采用了模板的方式来实现一个固定容量但是大小随着所用的数据类型变化的容器类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
using namespace std;
struct aligns(alignof(double)*4) Colorvector {
double r;
double g;
double b;
double a;
};
//固定容量的模板数组
template <typename T>
class Fixedcapacityarray {
public:
void push_back(T t) {/*在data中加入t变量*/ }
char alignas(T) data[1024] = {0};
// int length = 1024 / sizeof(T);
};

int main() {
Fixedcapacityarray<char> arrch;
cout << "alignof(char):" << alignof(char) << endl;
cout << "alignof(arch. data): " << alignof(arch.data) << endl;
Fixedcapacityarray<colorvector> arrcv;
cout << "alignof(Colorvector):" << alignof(colorvector) << endl;
cout << "alignof(arrcv.data):" << alignof (arrcv.data) << endl;
return 0;
}

在本例中,Fixedcapacityarray固定使用1024字节的空间,但由于模板的存在,可以实例化为各种版本。这样一来,我们可以在相同的内存使用量的前提下,做出多种类型(内置或者自定义)版本的数组。

如我们之前提到的一样,为了有效地访问数据,必须使得数据按照其固有特性进行对齐。对于arrch,由于数组中的元素都是char类型,所以对齐到1就行了,而对于我们定义的arrcv,必须使其符合Colorvector的扩展对齐,即对齐到8字节的内存边界上。在这个例子中,起到关键作用的代码是下面这一句

1
char alignas(T) data[1024]={0};

该句指示data[1024]这个char类型数组必须按照模板参数T的对齐方式进行对齐。编译运行该例子后,可以在实验机上得到如下结果

1
2
3
4
alignof (char): 1
alignof (arrch.data): 1
alignof (Colorvector): 32
alignof (arrcv.data): 32

如果我们去掉alignas(T)这个修饰符,代码的运行结果会完全不同,具体如下

1
2
3
4
alignof (char): 1
alignof (arch.data): 1
alignof (Colorvector): 32
alignof (arrcv.data): 1

可以看到,由于char数组默认对齐值为1,会导致data[1024]数组也对齐到1。这肯定不是程序员愿意见到的。事实上,在C++11标准引入alignas修饰符之前,这样的固定容量的泛型数组有时可能遇到因为对齐不佳而导致的性能损失(甚至程序错误),这给库的编写者带来了很大的困扰。而引入alignas能够解决这些移植性的困难

在STL库中,还内建了std::align函数来动态地根据指定的对齐方式调整数据块的位置。该函数的原型如下:

1
void* align(std::size_t alignment, std::size_t size, void*& ptr, std::size_t& space);

该函数在ptr指向的大小为space的内存中进行对齐方式的调整,将ptr开始的size大小的数据调整为按alignment对齐。

通用属性

语言扩展到通用属性

扩展语法中比较常见的就是“属性”( attribute)。属性是对语言中的实体对象(比如函数、变量、类型等)附加一些的额外注解信息,其用来实现一些语言及非语言层面的功能,或是实现优化代码等的一种手段。不同编译器有不同的属性语法。比如对于g++,属性是通过GNU的关键字__attribute__来声明的。

1
2
3
4
5
6
7
8
extern int area(int n) __attribute__((const))
int main() {
int i;
int areas = 0;
for (i = 0; i < 10; i ++) {
areas += area(3) * i;
}
}

const属性告诉编译器,本函数返回值只依赖于输入,不会改变任何函数外的值,因此没有副作用,编译器可以对函数进行优化,从而大大提高了程序的执行性能。

C++11的通用属性

C++11语言中的通用属性使用了左右双中括号的形式[[attribute-list]],这样设计的好处是:既不会消除语言添加或者重载关键字的能力,又不会占用用户而的关键字的名字空间。语法上,C+11的通用属性可以作用于类型、变量、名称、代码块等。对于作用声的通用属性,既可以写在声明的起始处,也可以写在声明的标识符之后。而对于作用于整个语句的通用属性,则应该写在语句起始处。

而出现在以上两种规则描述的位置之外的通用属性,作用于哪个实体跟编译器具体的实现有关。我们可以看几个例子。第一个是关于通用属性应用于函数的,具体如下

1
[[attr1]] void func [[attr2]] ();

这里,[[attr1]]出现在函数定义之前,而[[attr2]]则位于函数名称之后,根据定义,[[attr1]][[attr2]]均可以作用于函数func

现有C++11标准中,只预定义了两个通用属性,分别是[[noreturn]][[carries_dependency]]

预定义的通用属性

[[noreturn]]是用于标识不会返回的函数的。这里必须注意,不会返回和没有返回值的(void)函数的区别。没有返回值的void函数在调用完成后,调用者会接着执行函数后的代码;而不会返回的函数在被调用完成后,后续代码不会再被执行。主要用于标识那些不会将控制流返回给原调用函数的函数,典型的例子有有终止应用程序语句的函数、有无限循环语句的函数、有异常抛出的函数等。通过这个属性,开发人员可以告知编译器某些函数不会将控制流返回给调用函数,这能帮助编译器产生更好的警告信息,同时编译器也可以做更多的诸如死代码消除、免除为函数调用者保存一些特定寄存器等代码优化工作。

1
2
3
4
5
6
7
8
9
10
11
12
void Dosomething1 ();
void Dosomething2 ();

[[noreturn]] void Throwaway() {
throw "expection";
} //控制流跳转到异常处理

void func() {
Dosomething1();
Throwaway();
Dosomething2();//该函数不可到达
}

由于Throwaway抛出了异常,Dosomething2水远不会被执行,这个时候将Throwaway标记为[[noreturn]]的话,编译器会不再为Throwaway之后生成调用Dosomething2的代码。当然,编译器也可以选择为func函数中的Dosomething2做出一些警告以提示程序员这里有不可到达的代码。不返回的函数除了是有异常抛出的函数外,还有可能是有终止应用程序语句的函数,或是有无限循环语句的函数等。

另外一个通用属性[[carries_dependency]]则跟并行情况下的编译器优化有关。事实上[[carries_dependency]]主要是为了解决弱内存模型平台上使用memory_order_consume内存顺序枚举问题。

memory_order_consume的主要作用是保证对当前原子类型数据的读取操作先于所有之后关于该原子变量的操作完成,但它不影响其他原子操作的顺序。要保证这样的“先于发生”的关系,编译器往往需要根据memory_model枚举值在原子操作间构建一系列的依赖关系,以减少在弱一致性模型的平台上产生内存栅栏。不过这样的关系则往往会由于函数的存在而被破坏。比如下面的代码

1
2
3
atomic<int*> a;
int* p = (int*)a.load(memory_order_consume);
func(p);

上面的代码中,编译器在编译时可能并不知道func函数的具体实现,因此,如果要保证load先于任何关于a(或是p)的操作发生,编译器往往会在func函数之前加入一条内存栅栏。然而,如果func的实现是

1
2
3
4
func(int * p) {
// 假设p2是一个 atomic<int*>的变量
p2.store(p, memory_order_release);
}

那么对于func函数来说,由于p2.store使用了memory_order_release的内存顺序,因此,p2.storep的使用会被保证在任何关于p的使用之后完成。这样一来,编译器在func函数之前加入的内存栅栏就变得毫无意义,且影响了性能。

而解决的方法正是使用[[carries_dependency]]。该通用属性既可以标识函数参数,又可以标识函数的返回值。当标识函数的参数时,它表示数据依赖随着参数传递进入函数,即不需要产生内存栅栏。而当标识函数的返回值时,它表示数据依赖随着返回值传递出函数,不需要产生内存栅栏。

对北京地铁抱有很高的期待,存下来这张图。以后也会不断积累有意思的图。

UNIX基础知识

UNIX体系结构

从严格意义上说,可将操作系统定义为一种软件,它控制计算机硬件资源,提供程序运行环境。我们通常将这种软件称为内核(kermel),因为它相对较小,而且位于环境的核心。

内核的接口被称为系统调用(systemcall)。公用函数库构建在系统调用接口之上,应用程序既可使用公用函数库,也可使用系统调用。

文件和目录

文件系统

UNIX文件系统是目录和文件的一种层次结构,所有东西的起点是称为根(root)的目录,这个目录的名称是一个字符“/“。

目录(directory)是一个包含目录项的文件。在逻辑上,可以认为每个目录项都包含一个文件名,同时还包含说明该文件属性的信息。文件属性是指文件类型(是普通文件还是目录等)、文件大小、文件所有者、文件权限(其他用户能否访问该文件)以及文件最后的修改时间等。statfstat函数返回包含所有文件属性的一个信息结构。

文件名

目录中的各个名字称为文件名(filename)。只有斜线(/)和空字符这两个字符不能出现在文件名中。斜线用来分隔构成路径名的各文件名,空字符则用来终止一个路径名。

创建新目录时会自动创建了两个文件名:.(称为点)和..(称为点点)。点指向当前目录,点点指向父目录。在最高层次的根目录中,点点与点相同。现今,几乎所有商业化的UNIX文件系统都支
持超过255个字符的文件名。

路径名

由斜线分隔的一个或多个文件名组成的序列(也可以斜线开头)构成路径名(pathname),以斜线开头的路径名称为绝对路径名(absolute pathname),否则称为相对路径名(relative pathname)。相对路径名指向相对于当前目录的文件。文件系统根的名字(/)是一个特殊的绝对路径名,它不包含文件名。

不难列出一个目录中所有文件的名字,以下命令的简要实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "apue.h"
#include <dirent.h>
int main(int arge, char *argv[]) {
DIR *dp;
struct dirent *dirp;
if (argc != 2)
err_quif("usage: ls directory_nane") a
if ((dp = opendir(argv[1])) == NULL)
err_ays ("can't open %s", argv[1]);
while ((dirp = readdir(dp)) !- NULL)
printe("%s\n", dirp->d_name);
closedir(dp);
exit(0);
}

在这个20行的程序中,有很多细节需要考虑。

  • 首先,其中包含了一个头文件apue.h。本书中几乎每一个程序都包含此头文件。它包含了某些标准系统头文件,定义了许多常量及函数原型。
  • 接下来,我们包含了一个系统头文件dirent.h。以便使用opendirreaddir的函数原型,以及dirent结构的定义。在其他一些系统里,这些定义被分成多个头文件。
  • main函数的声明使用了ISO C标准所使用的风格
  • 程序获取命令行的第1个参数argv[1]作为要列出其各个目录项的目录名。
  • 因为各种不同UNIX系统目录项的实际格式是不一样的,所以使用函数opendirreaddirclosedir对目录进行处理。
  • opendir函数返回指向DIR结构的指针,我们将该指针传送给readdir函数。然后,在循环中调用readdir来读每个目录项。它返回一个指向dirent结构的指针,而当目录中已无目录项可读时则返回null指针。在dirent结构中取出的只是每个目录项的名字(d_name)。使用该名字,此后就可用stat函数以获得该文件的所有属性。
  • 当程序将结束时,它以参数0调用函数exit()。函数exit()终止程序。按惯例,参数0的意思是正常结束,参数值1~255则表示出错。

工作目录

每个进程都有一个工作目录(working directory),有时称其为当前工作目录(current working directory),所有相对路径名都从工作目录开始解释。进程可以用chdir函数更改其工作目录。

起始目录

登录时,工作目录设置为起始目录(home directory),该起始目录从口令文件中相应用户的登录项中取得

输入和输出

文件描述符

文件描述符(flle descriptor)通常是一个小的非负整数,内核用以标识一个特定进程正在访间的文件。当内核打开一个现有文件或创建一个新文件时,它都返回一个文件描述符。在读、写文件时,可以使用这个文件描述符。

标准输入、标准输出和标准错误

按惯例,每当运行一个新程序时,所有的shell都为其打开了个文件描述符,即标准输入(standard input)、标准输出(standard output)以及标准错误(standarderror)。如果不做特殊处理,则这3个描述符都链接向终端。

不带缓冲的I/O

函数openreadwritelseek以及close提供了不带缓冲的I/O。这些函数都使用文件描述符。

如果愿意从标准输入读,并向标准输出写,则所示的程序可用于复制任一UNIX普通文件

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "apue.h"
#define BUFFSIZE 4096

int main (void) {
int n;
char buf [BUFFSIZE];
while ((n = read(STDIN_FILENO, buf, BUFFSIZE)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
exc_syn("write exror");
if(n < 0)
err_sys("read error");
exif(0);
}

头文件<unistd.h>(apue.h中包含了此头文件)及两个常量STDIN_FILENOSTDOUT_FILENO是POSIX标准的一部分,头文件<unistd.h>包含了很多UNIX系统服务的函数原型,

两个常量STDIN_FILENOSTDOUT_FILENO定义在<unistd.h>头文件中,它们指定了标准输入和标准输出的文件描述符。在POSIX标准中,它们的值分别是0和1,但是考虑到可读性,我们将使用这些名字来表示这些常量

read函数返回读取的字节数,此值用作要写的字节数。当到达输入文件的尾时,read返回0,程序停止执行。如果发生了一个读错误,read返回-1。出错时大多数系统函数返回-1。

标准I/O

标准I/O函数为那些不带缓冲的I/O函数提供了一个带缓冲的接口。使用标准I/O函数无需担心如何选取最佳的缓冲区大小使用标准I/O函数还简化了对输入行的处理。例如,fgets函数读取一个完整的行,而read函数读取指定字节数。

我们最熟悉的标准I/O函数是printf。在调用printf的程序中,总是包含<stdio.h>,该头文件包括了所有标准I/O函数的原型。

下面的程序的功能类似于前一个调用了readwrite的程序。它将标准输入复制到标准输出,也就能复制任一UNIX普通文件。

1
2
3
4
5
6
7
8
9
10
11
#include "apue.h"
int main (void) {
int c;
while((c = getc(stdin)) != EOF)
if (putc(c, stdout) == EOF)
exr_sys("output exror");

if(ferror(stdin))
err_sys("input error");
exit(0);
}

函数getc一次读取一个字符,然后函数putc将此字符写到标准输出。读到输入的最后一个字节时,getc返回常量EOF(该常量在<stdio.h>中定义)。标准I/O常量stdinstdout也在头文件<stdio.h>中定义,它们分别表示标准输入和标准输出。

程序和进程

程序

程序(program)是一个存储在磁盘上某个目录中的可执行文件。内核使用exec函数(7个exec函数之一),将程序读入内存,并执行程序。

进程和进程ID

程序的执行实例被称为进程(process)。某些操作系统用任务(task)表示正在被执行的程序,UNIX系统确保每个进程都有一个难一的数字标识符,称为进程ID(process ID)。进程ID总是一个非负整数。

程序用于打印进程ID

1
2
3
4
5
#include "apue.h"
int main (void) {
printf("hello world from process ID sid\n", (long)getpid());
exif(0);
}

此程序运行时,它调用函数getpid得到其进程ID。getpid返回一个pid_t数据类型。

进程控制

有3个用于进程控制的主要函数:forkexecwaitpid

该程序从标准输入读取命令,然后执行这些命令。它类似于shell程序的基本实施部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include "apue.h"
#include <sys/wait.h>

int
main(void)
{
char buf[MAXLINE]; /* from apue.h */
pid_t pid;
int status;

printf("%% "); /* print prompt (printf requires %% to print %) */
while (fgets(buf, MAXLINE, stdin) != NULL) {
if (buf[strlen(buf) - 1] == '\n')
buf[strlen(buf) - 1] = 0; /* replace newline with null */

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* child */
execlp(buf, buf, (char *)0);
err_ret("couldn't execute: %s", buf);
exit(127);
}

/* parent */
if ((pid = waitpid(pid, &status, 0)) < 0)
err_sys("waitpid error");
printf("%% ");
}
exit(0);
}

在这个30行的程序中,有很多功能需要考虑,

  • 用标准I/O函数fgets从标准输入一次读取一行。当键入文件结束符(通常是Ctrl+D)作为行的第一个字符时,fgets返回一个null指针,于是循环停止,进程也就终止。
  • 因为fgets返回的每一行都以换行符终止,后随一个null字节,因此用标准C函数strlen计算此字符串的长度,然后用一个null字节替换换行符。这样做是因为execlp函数要求的参数是以null结束的而不是以换行符结束的
  • 调用fork创建一个新进程。新进程是调用进程的一个副本,我们称调用进程为父进程,新创建的进程为子进程。fork对父进程返回新的子进程的进程ID(一个非负整数),对子进程则返回0。因为fork创建一个新进程,所以说它被调用一次(由父进程),但返回两次(分别在父进程中和在子进程中)。
  • 在子进程中,调用execlp以执行从标准输入读入的命令。这就用新的程序文件替换了子进程原先执行的程序文件。
  • 子进程调用execlp执行新程序文件,而父进程希望等待子进程终止,这是通过调用waitpid实现的,其参数指定要等待的进程(即pid参数是子进程ID)。waitpid函数返回子进程的终止状态(status变量)。
  • 该程序的最主要限制是不能向所执行的命令传递参数。例如不能指定要列出目录项的目录名,

线程和线程ID

通常,一个进程只有一个控制线程(thread)——某一时刻执行的一组机器指令。多个控制线程也可以充分利用多处理器系统的并行能力。

一个进程内的所有线程共享同一地址空间、文件描述符、找以及与进程相关的属性。因为它们能访问同一存储区,所以各线程在访问共享数据时需要采取同步措施以避免不一致性。与进程相同,线程也用ID标识。但是,线程ID只在它所属的进程内起作用。一个进程中的线程ID在另一个进程中没有意义。

控制线程的函数与控制进程的函数类似,但另有一套,线程模型是在进程模型建立很久之后才被引入到UNIX系统中的,然而这两种模型之间存在复杂的交互。

出错处理

当UNIX系统函数出错时,通常会返回一个负值,而且整型变量errno通常被设置为具有特定信息的值,文件<errno.h>中定义了errno以及可以赋与它的各种常量。这些常量都以字符E开头。

POSIX和ISO C将errno定义为一个符号,它扩展成为一个可修改的整形左值(Ivalue)。它可以是一个包含出错编号的整数,也可以是一个返回出错编号指针的函数。以前使用的定义是:

1
extern int errno;

但是在支持线程的环境中,多个线程共享进程地址空间,每个线程都有属于它自己的局部errno以避免一个线程干扰另一个线程。例如,Linux支持多线程存取errno,将其定义为:

1
2
extern int *_errno_location(vold),
#detine errno (*_errno_location())

对于errno应当注意两条规则。第一条规则是:如果没有出错,其值不会被侧程清除。因此,仅当函数的返回值指明出错时,才检验其值。第二条规则是:任何函数都不会将errno值设置为0,而且在<errno.h>中定义的所有常量都不为0。

C标准定义了两个函数,它们用于打印出错信息。

1
2
#include <string.h>
char *strerror (int errnum);

strerror函数将errnum(通常就是errno值)映射为一个出错消息字符串,并且返回此字符串的指针。

perror函数基于errno的当前值,在标准错误上产生一条出错消息,然后返回。

1
2
#include <stdio.h>
void perror (const char *msg);

它首先输出由msg指向的字符串,然后是一个冒号,一个空格,接着是对应于errno值的出错消息,最后是一个换行符。

1
2
3
4
5
6
7
8
9
#include "apue.h"
#include cerrno.b>

int main(int argc, char *argv[]) {
fprintf(stderr, "EACCES: %s\n", strerror(BACCES));
errno = ENOENT;
perror(argv[0]);
exit(0);
}

可将在<errno.h>中定义的各种出错分成两类;致命性的和非致命性的。对于致命性的错误,无法执行恢复动作。最多能做的是在用户屏幕上打印出一条出错消息或者将一条出错消息写入日志文件中,然后退出。对于非致命性的出错,有时可以较妥善地进行处理。

大多数非致命性出错是暂时的(如资源短缺),当系统中的活动较少时,这种出错很可能不会发生。与资源相关的非致命性出情包括:EAGAIN、ENFILE、ENOBUFS、ENOLCK、ENOSPC、EWOULDBLOCK,有时ENOMEM也是非致命性出错。当EBUSY指明共享资源正在使用时,也可将它作为非致命性出错处理。当EINTR中断一个慢速系统调用时,可将它作为非致命性出错处理。

对于资源相关的非致命性出错的典型恢复操作是延迟一段时间,然后重试。一些应用使用指数补偿算法,在每次选代中等待更长时间。

用户标识

用户ID

口令文件登录项中的用户ID(userID)是一个数值,它向系统标识各个不同的用户。系统管理员在确定一个用户的登录名的同时,确定其用户ID。用户不能更改其用户ID。

用户ID为O的用户为根用户(root)或超级用户(superuser)。在口令文件中,通常有一个登录项,其登录名为root。我们称这种用户的特权为超级用户特权。如果一个进程具有超级用户特权,则大多数文件权限检查都不再进行。某些操作系统功能只向超级用户提供。

口令文件登录项也包括用户的组D(group ID),它是一个数值。组ID也是由系统管理员在指定用户登录名时分配的。组被用于将若干用户集合到项目或部门中去。这种机制允许同组的各个成员之间共享资源。组文件将组名映射为数值的组ID。组文件通常是/etc/group。

1
2
3
4
5
#include "apue.h"
int main (void) {
printe ("uid = %d, gid = %d\n", getuid(), getgid());
exit(0);
}

附属组ID

除了在口令文件中对一个登录名指定一个组ID外,大多数UNIX系统版本还允许一个用户属于另外一些组。这一功能是从4.2BSD开始的,它允许一个用户属于多至16个其他的组。登录时,读文件/etc/group。寻找列有该用户作为其成员的前16个记录项就可以得到该用户的附属组ID(supplementary group ID)。

信号

信号(signa)用于通知进程发生了某种情况。进程有以下3种处理信号的方式。

  1. 忽略信号。有些信号表示硬件异常,例如,除以0或访问进程地址空间以外的存储单元等,因为这些异常产生的后果不确定,所以不推荐使用这种处理方式。
  2. 按系统默认方式处理。对于除数为0。系统默认方式是终止该进程。
  3. 提供一个函数,信号发生时调用该函数,这被称为捕捉该信号。通过提供自编的函数,我们就能知道什么时候产生了信号,并按期望的方式处理它。

很多情况都会产生信号,终端键盘上有两种产生信号的方法,分别称为中断键(通常是Delete键成Ctrl+C)和退出键(通常是Ctrl+\),它们被用于中断当前运行的进程。

另一种产生信号的方法是调用kill函数。在一个进程中调用此函数就可向另一个进程发送一个信号。当然这样做也有些限制:当向一个进程发送信号时,我们必须是那个进程的所有者或者是超级用户。

为了能捕捉到信号,程序需要调用signal函数,其中指定了当产生SIGINT信号时要调用的函数的名字。函数名为sig_int,当其被调用时,只是打印一条消息,然后打印一个新提示符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "apue.h"
#include <ays/wait.h>

statie void sig_int(int); /* our signal-catching function */

int main (void) {
char buf[MAXLINE]; /* trom apue.h */
pid_t pid;
int status;
if (signal(SIGINT, sig_int) == SIG_ERR)
err_sys("signal error");

printf("%% "); /* print prompt (printf requires %% to print %) */
while (fgets(buf, MAXLINE, stdin) != NULL) {
if (buf[strlen(buf) - 1] == '\n')
buf[strlen(buf) - 1] = 0; /* replace newline with null */

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* child */
execlp(buf, buf, (char *)0);
err_ret("couldn't execute: %s", buf);
exit(127);
}

/* parent */
if ((pid = waitpid(pid, &status, 0)) < 0)
err_sys("waitpid error");
printf("%% ");
}
exit(0);
}

void sig_int(int signo) {
printf("interrupt\n %%");
}

时间值

历史上,UNIX系统使用过两种不同的时间值。

  1. 日历时间。该值是自协调世界时(Coordinated Universal Time, UTC) 1970年1月1日00:00:00这个特定时间以来所经过的秒数累计值。这
    些时间值可用于记录文件最近一次的修改时间等,系统基本数据类型time_t用于保存这种时间值。
  2. 进程时间。也被称为CPU时间,用以度量进程使用的中央处理器资源。进程时间以时钟滴答计算。每秒钟曾经取为50、60或100个时钟滴答。系统基本数据类型clock_t保存这种时间值。

当度量一个进程的执行时间时,UNIX系统为一个进程维护了3个进程时间值:

  • 时钟时间:时钟时间又称为墙上时钟时间,它是进程运行的时间总量,其值与系统中同时运行的进程数有关。
  • 用户CPU时间:用户CPU时间是执行用户指令所用的时间量。
  • 系统CPU时间:系统CPU时间是为该进程执行内核程序所经历的时间。

例如,每当一个进程执行一个系统服务时,如read或write,在内核内执行该服务所花费的时间就计入该进程的系统CPU时间,用户CPU时间和系统CPU时间之和常被称为CPU时间,要取得任一进程的时钟时间、用户时间和系统时间是很容易的一只要执行命令time(1),其参数是要度量其执行时间的命令,例如:

1
2
3
4
5
$ ed /usr/include
$ time -p grep _POSIX_SOURCE */*.h > /dev/null
real 0m0.81s
user 0m0.11s
sys 0m0.07s

系统调用和库函数

所有的操作系统都提供多种服务的入口点。这些入口点被称为系统调用。Linux3.2.0提供了380个系统调用,FreeBSD8.0提供的系统调用超过450个。

UNIX所使用的技术是为每个系统调用在标准C库中设置一个具有同样名字的函数。用户进程用标准C调用序列来调用这些函数,然后,函数又用系统所要求的技术调用相应的内核服务。

以存储空间分配函数malloc为例。UNIX系统调用中处理存储空间分配的是sbrk(2),它不是一个通用的存储器管理器。它按指定字节数增加或减少进程地址空间。如何管理该地址空间却取决于进程。存储空间分配函数malloc(3)实现一种特定类型的分配,如果我们不喜欢其操作方式,则可以定义自己的malloc函数,它很可能将使用sbrk系统调用。两者职责不同,内核中的系统调用分配一块空间给进程,而库函数malloc则在用户层次管理这一空间。

系统调用和库函数之间的另一个差别是:系统调用通常提供一种最小接口,而库函数通常提供比较复杂的功能。我们从sbrk系统调用和malloc库函数之间的差别中可以看到这一点。进程控制系统调用(tork、exec和wait)通常由用户应用程序直接调用。但是为了简化某些常见的情况, UNIX系统也提供了一些库函数,如system和popen。

文件I/O

引言

UNIX系统中的大多数文件I/O只需用到5个函数:openreadwritelseekclose。本章描述的函数经常被称为不带缓冲的I/O (unbuffered I/O。术语不带缓冲指的是每个read和write都调用内核中的一个系统调用。

文件描述符

对于内核而言。所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。当读、写一个文件时,使用opencreat返回的文件描述符标识该文件,将其作为参数传送给readwrite

按照惯例,UNIX系统shell把文件描述符0与进程的标准输入关联,文件描述符1与标准输出关联,文件描述符2与标准错误关联。这是各种shell以及很多应用程序使用的惯例,与UNIX内核无关。应当把它们替换成符号常量STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO以提高可读性。这些常量都在头文件<unistd.h>中定义,文件描述符的变化范围是0~OPEN_MAX-1

函数open和openat

调用open或openat函数可以打开或创建一个文件。

1
2
3
#include <fcntl.h>
int open(const char *path, int oflag, .... /* mode_t mode */);
int openat(int fd, const char *path, int oflag, .../* mode_t mode */);

两函数的返回值:若成功,返回文件描述符;若出错,返回-1。

我们将最后一个参数写为...。ISO C用这种方法表明余下的参数的数量及其类型是可变的。对于open函数而言,仅当创建新文件时才使用最后这个参数。在函数原型中将此参数放置在注释中。

path参数是要打开或创建文件的名字。oflag参数可用来说明此函数的多个选项。用下列一个或多个常量进行“或”运算构成oflag参数(这些常量在头文件<fcntl.h>中定义)。

  • O_RDONLY:只读打开。
  • O_WRONLY:只写打开。
  • O_RDWR:读、写打开。
  • O_EXEC:只执行打开。
  • O_SEARCH:只搜索打开(应用于目录)。

大多数实现将O_RDONLY定义为0,O_WRONLY定义为1,O_RDWR定义为2。O_SEARCH常量的目的在于在目录打开时验证它的搜索权限。对目录的文件描述符的后续操作就不需要再次检查对该目录的搜索权限。

在这5个常量中必须指定一个且只能指定一个。下列常量则是可选的。

  • O_APPEND:每次写时都追加到文件的尾端。
  • O_CLOEXEC:把FD_CIOEXEC常量设置为文件描述符标志。
  • O_CREAT:若此文件不存在则创建它。使用此选项时,open函数需同时说明第3个参数mode,用mode指定该新文件的访问权限位
  • O_DIRECTORY:如果path引用的不是目录,则出错。
  • O_EXCL:如果同时指定了O_CREAT,而文件已经存在,则出错。用此可以测试一个文件是否存在,如果不存在,则创建此文件,这使测试和创建两者成为一个原子操作。
  • O_NOCTTY:如果path引用的是终端设备,则不将该设备分配作为此进程的控制终端。
  • O_NOFOLLOW:如果path引用的是一个符号链接,则出错。
  • O_NONBLOCK:如果path引用的是一个FIFO、一个块特殊文件或一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞方式。
  • O_SYNC:使每次write等待物理I/O操作完成,包括由该write操作引起的文件属性更新所需的I/O
  • O_TRUNC:如果此文件存在,而且为只写或读-写成功打开,则将其长度截断为0
  • 0_TTY_INIT:如果打开一个还未打开的终端设备,设置非标准termios参数值,使其符合Single UNIX Specification。

下面两个标志也是可选的。

  • O_DSYNC:使每次write要等待物理I/O操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需等待文件属性被更新,
    • O_DSYNCO_SYNC标志有微妙的区别。仅当文件属性需要更断以反映文件数据变化时,O_DSYNC标志才影响文件属性。而设置O_SYNC标志后,教据和属性总是同步更新。当文件用O_DSYN标志打开,在重写其现有的部分内容时,文件时间属性不会同步更新,与此相反,如果文件是用O_SYNC标志打开,那么对该文件的每一次write都将在write返回前更新文件时间,这与是否改写现有字节或追加写文件无关。
  • O_RSYNC:使每一个以文件描述符作为参数进行的read操作等待,直至所有对文件同一部分挂起的写操作都完成

由open和openat函数返回的文件描述符一定是最小的未用描述符数值。这一点被某些应用程序用来在标准输入、标准输出或标准错误上打开新的文件。例如,一个应用程序可以先关闭标准输出(通常是文件描述符1),然后打开另一个文件,执行打开操作前就能了解到该文件一定会在文件描述符1上打开。

fd参数把open和openat函数区分开,共有3种可能性。

  1. path参数指定的是绝对路径名,在这种情况下,后参数被忽略,openat函数就相当于open函数。
  2. path参数指定的是相对路径名,后参数指出了相对路径名在文件系统中的开始地址。fd参数是通过打开相对路径名所在的目录来获取。
  3. path参数指定了相对路径名,fd参数具有特殊值AT_FDCWD。在这种情况下,路径名在当前工作目录中获取,openat函数在操作上与open函数类似。

openat希望解决两个问题。

  • 让线程可以使用相对路径名打开目录中的文件,而不再只能打开当前工作目录。
  • 可以避免time of-check-to-time-of-use(TOCTTOU)错误。

TOCTTOU错误的基本思想是:如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序是脆弱的。因为两个调用并不是原子操作,在两个函数调用之间文件可能改变了,这样也就造成了第一个调用的结果就不再有效,使得程序最终的结果是错误的。文件系统命名空间中的TOCTTOU错误通常处理的就是那些颠覆文件系统权限的小把戏,这些小把戏通过骗取特权程序降低特权文件的权限控制或者让特权文件打开一个安全漏洞等方式进行。

文件名和路径名截断

在POSIX.1中,常量_POSIX_NO_TRUNC决定是要截断过长的文件名或路径名,还是返回一个出错。用fpathconfpathconf来查询目录具体支持何种行为,到底是截断过长的文件名还是返回出错。若_POSIX_NO_TRUNC有效,则在整个路径名超过PATH_MAX,或路径名中的任一文件名超过NAME_MAX时,出错返回,并将errno设置为ENAMETOOLONG

函数creat

也可调用creat函数创建一个新文件,

1
2
#include <fcntl.h>
int creat (const char path, mode_t mode);

返回值:若成功,返回为只写打开的文件描述符;若出错,返回-1。
注意,此函数等效于:

1
open(ptsh, O_WRONLY | O_CREAT | O_TRUNC, mode);

creat的一个不足之处是它以只写方式打开所创建的文件。在提供open的新版本之前,如果要创建一个临时文件,并要先写该文件,然后又读该文件,则必须先调用creatclose,然后再调用open。现在则可用下列方式调用open实现:

1
open (path, O_RDWR | O_CREAT | O_TRUNC, mode);

63

函数close

可调用close函数关闭一个打开文件。

1
2
#include <unistd.h>
int close (int fd);

返回值:若成功,返回0;若出错,返回-1。

关闭一个文件时还会释放该进程加在该文件上的所有记录锁。当一个进程终止时,内核自动关闭它所有的打开文件。很多程序都利用了这一功能而不显式地用close关闭打开文件。

函数lseek

每个打开文件都有一个与其相关联的“当前文件偏移量”(current file offset)。它通常是一个非负整数,用以度量从文件开始处计算的字节数。通常,读、写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。按系统默认的情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0。可以调用lseek显式地为一个打开文件设置偏移量。

1
2
#include <unistd.h>
off_t lseek(int fd, off_t offuet, int whence);

返回值:若成功,返回新的文件偏移量;若出错,返回为-1。

对参数offset的解释与参数whence的值有关

  • whenceSEEK_SET,则将该文件的偏移量设置为距文件开始处offset个字节。
  • whenceSEEK CUR,则将该文件的偏移量设置为其当前值加offsetoffset可为正或负。
  • whenceSEEK_END,则将该文件的偏移量设置为文件长度加offsetoffset可正可负。

lseek成功执行,则返回新的文件偏移量,为此可以用下列方式确定打开文件的当前偏移量:

1
2
off_t currpos;
currpos = lseek(fd, 0, SEEK_CUR);

这种方法也可用来确定所涉及的文件是否可以设置偏移量。如果文件描述符指向的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE

3个符号常量SEEK_SETSEBK_CURSEEK_END是在System V中引入的。在System V之前,whence被指定为0(绝对偏移量)、1(相对于当前位置的偏移量)或2(相对文件尾端的偏移量)。

lseek中的字符l表示长整型。在引入off_t教据类型之前,offset参数和返回值是长整型的。

所示的程序用于测试对其标准输入能否设置偏移量。

1
2
3
4
5
6
7
8
9
#include "apue.h"

int main (void) {
if(lseek(STDIN_FILENO, 0, SEEK_CUR) == -1)
printf("cannot seek\n");
else
printf("seek OK\n");
exit(0);
}

通常,文件的当前偏移量应当是一个非负整数,但是,某些设备也可能允许负的偏移量。但对于普通文件,其偏移量必须是非负值。因为偏移量可能是负值,所以在比较lseek的返回值时应当谨慎,不要测试它是否小于0,而要测试它是否等于-1。

文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞,这一点是允许的。位于文件中但没有写过的字节都被读为0。文件中的空洞并不要求在磁盘上占用存储区。具体处理方式与文件系统的实现有关,当定位到超出文件尾端之后写时,对于新写的数据需要分配磁盘块,但是对于原文件尾端和新开始写位置之间的部分则不需要分配磁盘块。

所示的程序用于创建一个具有空洞的文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "apue.h"
#include <fcntl.h>
char buf1[] = "abcdefghij";
char buf2[] = "ABCDEFGHIJ";

int main (void) {
int fd;
if((fd = creat("File.hole", FILE_MODE)) < 0)
err_sys ("creat error");
if(write(fd, buf1, 10) != 10)
err_sys("buti write error");
/*oftset now = 10 */

if(lseek(fd, 16384, SEEK_SET) == -1)
err_sys("lseek exroz");
/*offset now = 16384 */
if(write(fd, buf2, 10) != 10)
exr_sys("buf2 write excor"),
/*oftset now = 16394 */
exit(0);
}

运行该程序得到:

1
2
3
4
5
6
7
8
9
$ ./a.out
$ ls -l file.hoel
-rw-r--r-- 1 sar 16394 Nov 25 01:01 file.hole
$ od -c file.hole
0000000 a b c d e f g h i j \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0040000 A B C D E F G H I J
0040012

使用od(1)命令观察该文件的实际内容。命令行中的-c标志表示以字符方式打印文件内容。从中可以看到,文件中间的30个未写入字节都被读成0。每一行开始的一个7位数是以八进制形式表示的字节偏移量。

因为lseek使用的偏移量是用off_t类型表示的,所以允许具体实现根据各自特定的平台自行选择大小合适的数据类型。现今大多数平台提供两组接口以处理文件偏移量。一组使用32位文件偏移量,另一组则使用64位文件偏移量。

Single UNIX Specification向应用程序提供了一种方法,使其通过sysconf函数确定支持何种环境。图总结了定义的sysconf常量。

选项名称 说明 mame参数
_POSIX_V7_ILP32_OFF32 int、long、指针和ott_t类型是32位 _SC_V7_ILP32_OFF32
_POSIX_V7_ILP32_OFFBIG int、long、指针类型是32位。off_t类型至少是64位 _SC_V7_ILP32_OFFBIG
_POSIX_V7_LP64_OFF64 int类型是32位,long、指针和off_t是64位 _SC_V7_LP64_OFF64
_POSIX_V7_LP64_OFFBIG int类型是32位,long、指针和off_t类型至少是64位 _SC_V7_LP64_OFFBIG

C99编译器要求使用getconf(1)命令将所期望的数据大小模型映射为编译和链接程序所需的标志。根据每个平台支持环境的不同,可能需要不同的标志和库。

函数read

调用read函数从打开文件中读数据,

1
2
#include <unistd.h>
ssize_t read(int fd, vold *buf, size_t nbytes);

如read成功,则返回读到的字节数。如已到达文件的尾端,则返回0。有多种情况可使实际读到的字节数少于要求读的字节数:

  • 读普通文件时,在读到要求字节数之前已到达了文件尾端。例如,若在到达文件尾端之前有30个字节,而要求读100个字节,则read返回30。下一次再调用read时,它将返回0(文件尾端)。
  • 当从终端设备读时,通常一次最多读一行
  • 当从网络读时,网络中的缓冲机制可能造成返回值小于所要求读的字节数。
  • 当从管道或FIFO读时,如着管道包含的字节少于所需的数量,那么read将只返回实际可用的字节数。
  • 当从某些面向记录的设备读时,一次最多返回一个记录。
  • 当一信号造成中断,而已经读了部分数据量时。

读操作从文件的当前偏移量处开始,在成功返回之前,该偏移量将增加实际读到的字节数。POSIX.1从几个方面对read函数的原型做了更改。经典的原型定义是:

1
int read(int fd, char *buf, unsigned nbytes);

  • 首先,为了与ISO C一致,第2个参数由char*改为void*。在ISO C中,类型void*用于表示通用指针。
  • 其次,返回值必须是一个带符号整型(ssize_t),以保证能够返回正整数字节数、0(表示文件尾端)或-1(出错)。
  • 最后,第3个参数在历史上是一个无符号整型,这允许一个16位的实现一次读或写的数据可以多达65534个字节。

函数write

调用write函数向打开文件写数据

1
2
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);

其返回值通常与参数nbytes的值相同,否则表示出错。write出错的一个常见原因是磁盘已写满,或者超过了一个给定进程的文件长度限制。

对于普通文件,写操作从文件的当前偏移量处开始。如果在打开该文件时,指定了O_APPEND选项,则在每次写操作之前,将文件偏移量设置在文件的当前结尾处。在一次成功写之后,该文件偏移量增加实际写的字节数。

I/O的效率

图3-5程序只使用read和write函数复制一个文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "apue.h"
#define BUFFSIZE 4096

int main (void) {
int n;
char buf[BUFFSIZE];
while((n = read(STDIN_FILENO, buf, BUFFSIZE)) > 0)
if(write(STDOUT_FILENO, buf, n) != n)
err_sys("write error");

if(n < 0)
err_ays ("read error");
exif(0);
}

关于该程序应注意以下几点。

  • 它从标准输入读,写至标准输出,这就假定在执行本程序之前,这些标准输入、输出已由shell安排好。
  • 考虑到进程终止时,UNIX系统内核会关闭进程的所有打开的文件描述符,所以此程序并不关闭输入和输出文件。
  • 对UNIX系统内核而言,文本文件和二进制代码文件并无区别,所以本程序对这两种文件都有效。

让我们先用各种不同的BUFFSIZE值来运行此程序。图显示了用20种不同的缓冲区长度,读516581760字节的文件所得到的结果。

读文件的标准输出被重新定向到/dev/null上。此测试所用的文件系统是Linux ext4文件系统,其磁盘块长度为4096字节。这也证明了系统CPU时间的几个最小值差不多出现在BUFFSIZE为4096及以后的位置,继续增加缓冲区长度对此时间几乎没有影响。

大多数文件系统为改善性能都采用某种预读(read ahcad)技术。当检测到正进行顺序读取时,系统就试图读入比应用所要求的更多数据。并假想应用很快就会读这些数据。预读的效果可以从图中看出,缓冲区长度小至32字节时的时钟时间与拥有较大缓冲区长度时的时钟时间几乎一样。

文件共享

内核使用3种数据结构表示打开文件,它们之间的关系决定了在文件共享方面一个进程对另一个进程可能产生的影响。

  • 每个进程在进程表中都有一个记录项,记录项中包含一张打开文件描述符表,可将其视为一个矢量,每个描述符占用一项。与每个文件描述符相关联的是:
    • 文件描述符标志(close_on_exec)
    • 指向一个文件表项的指针
  • 内核为所有打开文件维持一张文件表。每个文件表项包含:
    • 文件状态标志(读、写、添写、同步和非阻塞等)
    • 当前文件偏移量
    • 指向该文件v节点表项的指针
  • 每个打开文件(或设备)都有一个v节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。这些信息是在打开文件时从磁盘上读入内存的。

图显示了一个进程对应的3张表之间的关系。该进程有两个不同的打开文件,一个文件从标准输入打开(文件描述符0),另一个从标准输出打开(文件描述符为1)。

创建v节点结构的目的是对在一个计算机系统上的多文体系统类型提供支持。Sun把这种文件系统称为虚拟文件系统(Virtual File System),把与文件系统无关的i节点部分称为V节点。

Linux没有将相关数据结构分为i节点和v节点,而是采用了一个与文件系统相关的i节点和一个与文件系统无关的i节点。

如果两个独立进程各自打开了同一文件,则有图中所示的关系。

我们假定第一个进程在文件描述符3上打开该文件,而另一个进程在文件描述符4上打开该文件。打开该文件的每个进程都获得各自的一个文件表项,但对一个给定的文件只有一个v节点表项。之所以每个进程都获得自己的文件表项,是因为这可以使每个进程都有它自己的对该文件的当前偏移量。

  • 在完成每个write后,在文件表项中的当前文件偏移量即增加所写入的字节数。如果这导致当前文件偏移量超出了当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量。
  • 如果用O_APPEND标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
  • 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。
  • lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。

可能有多个文件描述符项指向同一文件表项。在fork后也发生同样的情况,此时父进程、子进程各自的每一个打开文件描述符共享同一个文件表项。

注意,文件描述符标志和文件状态标志在作用范围方面的区别,前者只用于一个进程的一个描述符,而后者则应用于指向该给定文件表项的任何进程中的所有描述符。

原子操作

追加到一个文件

考虑一个进程,它要将数据追加到一个文件尾端。早期的UNIX系统版本并不支持open的O_APPEND选项,所以程序被编写成下列形式:

1
2
3
4
if(lseek(fd, OL, 2) < 0) /*position to EOF */
err_sys("lseek error");
if(write(fd, buf, 100) != 100) /* and write */
err_sys("write error");

对单个进程而言,这段程序能正常工作,但若有多个进程同时使用这种方法将数据追加写到同一文件,则会产生问题。

假定有两个独立的进程A和B都对同一文件进行追加写操作。每个进程都已打开了该文件,但未使用O_APPEND标志,此时,每个进程都有它自己的文件表项,但是共享一个v节点表项。假定进程A调用了lseek,它修改了当前偏移量,然后内核切换进程,进程B执行lseek也修改了当前偏移量设置为1500字节。这样造成了两个进程写入数据的重叠。

解决方法是使这lseekwrite两个操作对于其他进程而言成为一个原子操作。UNIX系统为这样的操作提供了一种原子操作方法,即在打开文件时设置O_APPEND标志,这样做使得内核在每次写操作之前,都将进程的当前偏移量设置到该文件的尾漏处,于是在每次写之前就不再需要调用lseek

函数pread和pwrite

Single UNIX Specification包括了XSI扩展,该扩展允许原子性地定位并执行I/O。preadpwrite就是这种扩展。

1
2
3
4
5
6
#include <uniatd.h>
ssize_t pread(int fd, void *buf, size_t nbytes, off_t offset);
// 返回值,读到的字节数,若已到文件尾,返回0;若出错,返回-1

ssize_t pwrite(int fd, const void *buf, size_t nbytes, off_t offset);
// 返回值,若成功,返回已写的字节数, 若出错,返回-1

调用pread相当于调用lseek后调用read,但是pread又与这种顺序调用有下列重要区别。

  • 调用pread时,无法中断其定位和读操作。
  • 不更新当前文件偏移量。

调用pwrite相当于调用lseek后调用write,但也与它们有类似的区别。

创建一个文件

对open函数的O_CREATO_EXCL选项,当同时指定这两个选项,而该文件又已经存在时,open将失败。我们曾提及检查文件是否存在和创建文件这两个操作是作为一个原子操作执行的。如果没有这样一个原子操作,那么可能会编写下列程序段:

1
2
3
4
5
6
7
8
if((fd = open(pathname, O_WRONLY)) < O) {
if(errno = ENOENT) {
if ((fd = creat(path, mode)) < 0)
err_sys ("creat ecror");
} else {
err_sys("open error");
}
}

如果在open和creat之间,另一个进程创建了该文件,就会出现问题。若在这两个函数调用之间,另一个进程创建了该文件,并且写入了一些数据。然后,原先进程执行这段程序中的creat,这时,刚由另一进程写入的数据就会被擦去。

一般而言,原子操作(atomic operation)指的是由多步组成的一个操作。如果该操作原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。

函数dup和dup2

下面两个函数都可用来复制一个现有的文件描述符。

1
2
3
4
5
#include <unistd.h>
int dup (int fd);
int dup2(int fd, int fd2);

// 两函数的返回值。若成功,返回新的文件描述符,若出错,返回-1

由dup返回的新文件描述符一定是当前可用文件描述符中的最小数值。对于dup2,可以用fd2参数指定新描述符的值。如果fd2已经打开,则先将其关闭。如若fd等于fd2,则dup2返回fd2,而不关闭它。否则,fd2FD_CLOEXEC文件描述符标志就被清除,这样fd2在进程调用exec时是打开状态。

这些函数返回的新文件描述符与参数fd共享同一个文件表项,如图所示。

在此图中,我们假定进程启动时执行了:

1
newfd = dup(1);

当此函数开始执行时,假定下一个可用的描述符是3(这是非常可能的,因为0, 1和2都由shell打开)。因为两个描述符指向同一文件表项,所以它们共享同一文件状态标志(读、写、追加等)以及同一当前文件偏移量。

复制一个描述符的另一种方法是使用fcntl函数。实际上,调用dup(fd);等效于fcntl(fd, F_DUPFD, 0);,而调用dup2(fd, fd2);等效于close(fd2); fcntl(fd, E_DUPFD, fd2);。在后一种情况下,dup2并不完全等同于close加上fcnt1。它们之间的区别具体如下。

  1. dup2是一个原子操作,而closefcnt1包括两个函数调用。有可能在closefcnt1之间调用了信号捕获函数,它可能修改文件描述符。如果不同的线程改变了文件描述符的话也会出现相同的问题。
  2. dup2fcntl有一些不同的errno。

函数sync、fsync和fdatasync

传统的UNIX系统实现在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式被称为延迟写(delayed write)。

通常,当内核需要重用缓冲区来存放其他磁盘块数据时,它会把所有延迟写数据块写入磁盘。为了保证磁盘上实际文件系统与缓冲区中内容的一致性,UNIX系统提供了syncfsyncfdatasync三个函数。

1
2
3
4
5
6
#include<unistd.h>
int fsync(int fd);
int fdatasync(int fd);
// 返回值:若成功,返回0;若出错,返回-1

void sync(void);

sync只是将所有修改过的块缓冲区排入写队列,然后就返回,它并不等待实际写磁盘操作结束,通常,称为update的系统守护进程周期性地调用(一般每隔30秒)sync函数。这就保证了定期冲洗(flush)内核的块缓冲区。命令sync(1)也调用sync函数。

fsync函数只对由文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束才返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保修改过的块立即写到磁盘上。

fdatasync函数类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。

函数fcntl

fcntl函数可以改变已经打开文件的属性,

1
2
3
#include<fcnti.h>
int fcntl(int fd, int cmd, /* int arg */);
// 返回值:若成功,则依赖于cmd(见下),若出错,返回-1

第3个参数总是一个整数,与上面所示函数原型中的注释部分对应。fcntl函数有以下5种功能

  1. 复制一个已有的描述符(cmd=F_DUPFDF_DUPFD_CLOEXEC).
  2. 获取/设置文件描述符标志(cmd=F_GETFDF_SETFD).
  3. 获取/设置文件状态标志(cmd=F_GETFLF_SETFL).
  4. 获取/设置异步I/O所有权(cmd=F_GETOWNF_SETOWN)。
  5. 获取/设置记录锁(cmd=F_GETLKF_SETLKF_SETLKW).
  • F_DUPFD:复制文件描述符fd。新文件描述符作为函数值返回。它是尚未打开的各描述符中大于或等于第3个参数值(取为整型值)中各值的最小值。新描述符与后共享同一文件表项。但是,新描述符有它自己的一套文件描述符标志,其FD_CLOEXEC文件描述符标志被清除
  • F_DUPFD_CLOEXEC:复制文件描述符,设置与新描述符关联的FD_CLOEXEC文件描述符标志的值,返回新文件描述符
  • F_GETFD:对应于fd的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志FD_CLOEXEC
  • F_SETFD:对于fd设置文件描述符标志。新标志值按第3个参数(取为整型值)设置
  • F_GETFL:对应于fd的文件状态标志作为函数值返回。我们在说明open函数时,已描述了文件状态标志。

遗憾的是,5个访问方式标志(O_RDONLYO_WRONLYO_RDWRO_EXECO_SEARCH)并不各占1位。这5个值互斥,一个文件的访问方式只能取这5个值之一。因此首先必须用屏蔽字O_ACCMODE取得访问方式位,然后将结果与这5个值中的每一个相比较

  • F_SETFL:将文件状态标志设置为第3个参数的值(取为整型值)。可以更改的几个标志是:O_APPENDO_NONBLOCKO_SYNCO_DSYNCO_RSYNCO_FSYNCO_ASYNC
  • F_GETOWN:获取当前接收SIGIO和SIGURG信号的进程ID或进程组ID
  • F_SETOWN:设置接收SIGIO和SIGURG信号的进程ID或进程组ID。正的arg指定一个进程ID,负的arg表示等于arg绝对值的一个进程组ID

fcntl的返回值与命令有关。如果出错,所有命令都返回-1,如果成功则返回某个其他值。下列4个命令有特定返回值:F_DUPFDF_GETFDF_GETFLF_GETOWN。第1个命令返回新的文件描述符,第2个和第3个命令返回相应的标志,最后一个命令返回一个正的进程ID或负的进程组ID

所示程序的第1个参数指定文件描述符,并对于该描述符打印其所选择的文件标志说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include "apue.h"
#include <fcntl.h>

int main(int argc, char *argv[]) {
int val;
if(argc != 2)
err_quit("usage; a.out <descriptort>");
if((val = fcntl(atoi(argv[1]), F_GETFL, 0)) < 0)
err_sys("fcntl error for id io", atoi(argv[1]));
switch (val & O_ACCMODE) {
case O_RDONLY:
printf("read only");
break;
case O_WRONLY:
printf("write only");
break;
case O_RDWR:
printf("read write");
break;
default:
err_dump("unknown access mode");
}
if(val & O_APPEND)
printt(", append");
if (val & O_NONBLOCK)
printf(", nonblocking");
if(val & O_SYNC)
printf(", synchronous writes");
# if !defined(_POSIX_C_SOURCE) && defined (O_FSYNC) && (O_FSYNC != 0_SYNC)
if (val & O_FSYNC)
printf(", synchronous writes");
#endif
putchar('\n');
exif(0);
}

注意,我们使用了功能测试宏_POSIX_C_SOURCE,并且条件编译了POSIX.1中没有定义的文件访问标志。

在修改文件描述符标志或文件状态标志时必须谨慎,先要获得现在的标志值,然后按照期望修改它,最后设置新标志值。不能只是执行F_SETFDF_SETFL命令,这样会关闭以前设置的标志位。下程序对于一个文件描述符设置一个或多个文件状态标志的函数。

1
2
3
4
5
6
7
8
9
10
#include "apue.h"
#include <fcntl.h>
void set_fl(int fd, int flags) {/* flags are tile status flags to turn on */
int val;
if((val = fcntl(fd, F_GETFL, 0)) < 0)
err_sys("fcntl F_GETFL error");
val |= flags; /*turn on flags */
if(fcntl(fd, F_SETFL, val) < 0)
err_sys("fcntl F_SETTL error");
}

如果将中间的一条语句改为:

1
2
val &= ~flags;
/*turn flags off */

就构成另一个函数,我们称为clr_fl,并将在后面某些例子中用到它。此语句使当前文件状态标志值val与flags的反码进行逻辑”与”运算。

在UNIX系统中,通常write只是将数据排入队列,而实际的写磁盘操作则可能在以后的某个时刻进行。而数据库系统则需要使用O_SYNC,这样一来,当它从write返回时就知道数据已确实写到了磁盘上,以免在系统异常时产生数据丢失程序运行时,设置O_SYNC标志会增加系统时间和时钟时间。

比较fsyncfdatasync,两者都更新文件内容,用了O_SYNC标志,每次写入文件时都更新文件内容。每一种调用的性能依赖很多因素,包括底层的操作系统实现、磁盘驱动器的速度以及文件系统的类型。

我们的程序在一个描述符(标准输出)上进行操作,但是根本不知道由shell打开的相应文件的文件名。因为这是shell打开的,因此不能在打开时按我们的要求设置O_SYNC标志。使用fcntl,我们只需要知道打开文件的描述符,就可以修改描述符的属性。在讲解非阻塞管道时还会用到fcntl,因为对于管道,我们所知的只有其描述符。

函数ioctl

ioctl函数一直是I/O操作的杂物箱。终端I/O是使用ioctl最多的地方

1
2
3
4
#include <unistd.h>
#include <sys/ioctl.h> /* asp and Linux */
int ioctl(int fd, int request, ...);
//返回值,若出错,返回-1,若成功,返回其他值

ioctl函数是Single UNIX Specification标准的一个扩展部分。UNIX系统实现用它进行很多杂项设备操作。有些实现甚至将它扩展到用于普通文件。

对于ISO C原型,它用省略号表示其余参数。但是,通常只有另外一个参数,它常常是指向一个变量或结构的指针。通常,还要求另外的设备专用头文件。例如,除POSIX.1所说明的基本操作之外,终端I/O的ioctl命令都需要头文件<termios.h>

每个设备驱动程序可以定义它自己专用的一组ioctl命令,系统则为不同种类的设备提供通用的ioctl命令。图中总结了FreeBSD支持的通用ioctl命令的一些类别。

类别 常量名 头文件 ioctl数
盘标号 DIOxxx <sys/disklabel.h> 4
文件I/O FIOxxx <sys/filio.h> 14
磁带I/O MTIOxxx <sys/mtio.h> 11
套接字I/O SIOxxx <sys/sockio.k> 73
终端I/O TIOxxx <aya/ttycom.h> 43

磁带操作使我们可以在磁带上写一个文件结束标志、倒带、越过指定个数的文件或记录等,对这些设备进行操作最容易的方法就是使用ioctl

/dev/fd

较新的系统都提供名为/dev/fd的目录,其目录项是名为0、1、2等的文件。打开文件/dev/fd/n等效于复制操述符n(假定描述符n是打开的)。

在下列函数调用中;

1
fd = open("/dev/fd/0", node);

大多数系统忽略它所指定的mode,而另外一些系统则要求mode必须是所引用的文件(在这里是标准输入)初始打开时所使用的打开模式的一个子集。因为上面的打开等效于

1
fd = dup(0);

所以描述符0fd共享同一文件表项。例如,若描述符0先前被打开为只读,那么我们也只能对fd进行读操作。即使系统忽略打开模式,而且下列调用是成功的:

1
fd = open("/dev/fd/0", O_RDWR);

我们仍然不能对fd进行写操作。

Linux实现中的/dev/fd是个例外。它把文件描述符映射成指向底层物理文件的符号链接。例如,当打开/dev/fd/0时,事实,上正在打开与标准输入关联的文件,因此返回的新文件描述符的模式与/dev/fd文件描述符的模式其实并不相关。

我们也可以用/dev/fd作为路径名参数调用creat,这与调用open时用O_CREAT作为第2个参数作用相同。例如,若一个程序调用creat,并且路径名参数是/dev/fd/1,那么该程序仍能工作。

某些系统提供路径名/dev/stdin/dev/stdout/dev/stderr,这些等效于/dev/fd/0/dev/fd/1/dev/fd/2/dev/fd文件主要由shell使用,它允许使用路径名作为调用参数的程序,能用处理其他路径名的相同方式处理标准输入和输出。例如,cat(1)命令对其命令行参数采取了一种特殊处理,它将单独的一个字符“-”解释为标准输入。例如:

1
filter file2 | cat file1 - file3 | lpr

首先catfile1,按着读其标准输入(也就是filter file2命令的输出),然后读file3,如果支持/dev/fd,则可以删除cat对“-”的特殊处理,于是我们就可键入下列命令行;

1
filter file2 | cat file1 /dev/fd/0 file3 | lpr

作为命令行参数的“-”特指标准输入或标准输出,这已由很多程序采用。但是这会带来一些问题,例如,如果用”-”指定第一个文件,那么看来就像指定了命令行的一个选项。/dev/fd则提高了文件名参数的一致性,也更加清晰。

文件和目录

函数stat、fstat、fstatat和lstat

本章主要讨论4个stat函数以及它们的返回信息。

1
2
3
4
5
6
#include <sys/stat.h>
int stat(const char *restrict pathmame, struct stat *restrict buf);
int fstat(int fd, struct stat *buf);
int lstat(const char *restrict pathmame, struct stat *restrict buf);
int fatatat(int fd, const char *restrict pathname, struct stat *restrict buf, int flag);
所有4个函数的返回值,若成功,返回0;若出错,返回-2

一旦给出pathname,stat函数将返回与此命名文件有关的信息结构。fstat函数获得已在描述符fd上打开文件的有关信息。lstat函数类似于stat,但是当命名的文件是一个符号链接时,lstat返回该符号链接的有关信息,而不是由该符号链接引用的文件的信息。

fstatat函数为一个相对于当前打开目录(由fd参数指向)的路径名返回文件统计信息。flag参数控制着是否跟随着一个符号链接。当AT_SYMLINK_NOFOLLOW标志被设置时,fstatat不会跟随符号链接,而是返回符号链接本身的信息。否则,在默认情况下,返同的是符号链接所指向的实际文件的信息。如果fd参数的值是AT_FDCWD,并且pathname参数是一个相对路径名,fstatat会计算相对于当前目录的pathname参数。如果pathname是一个绝对路径,后参数就会被忽略。这两种情况下,根据flag的取值,fstatat的作用就跟statlstat一样。

第2个参数buf是一个指针,它指向一个我们必须提供的结构。函数来填充由buf指向的结构。结构的实际定义可能随具体实现有所不同,但其基本形式是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct stat {
mode_t st_mode; /* file type & mode (permiosions) */
ino_t st_ino; /* i-node number (serial number) */
dev_t st_dev; /* device number (file system) */
dev_t st_rdev; /* device nunber for special files */
nlink_t st_nlink; /* number of links */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
off_t st_sizes
struct tinespec st_atime; /* time of last access */
struct timespec st_mtime; /* time of last modification */
struct tinespec st_ctime; /* time of last tille status change */
blksize_t st_blksizes /* best I/O block size */
blkcnt_t st_blocks; /* number of disk blocks allocated */
};

timespec结构类型按照秒和纳秒定义了时间,至少包括下面两个字段:

1
2
time_t tv_sec;
long tv_nsec;

使用stat函数最多的地方可能就是ls -l命令,用其可以获得有关一个文件的所有信息。

文件类型

至此我们已经介绍了两种不同的文件类型:普通文件和目录。UNIX系统的大多数文件是普通文件或目录,但是也有另外一些文件类型。文件类型包括如下几种,

  • 普通文件(regular file)。这是最常用的文件类型,这种文件包含了某种形式的数据。至于这种数据是文本还是二进制数据,对于UNIX内核而言并无区别。对普通文件内容的解释由处理该文件的应用程序进行。
    • 一个值得注意的例外是二进制可执行文件。为了执行程序,内被必须理解其格式。
  • 目录文件(directory file)。这种文件包含了其他文件的名字以及指向与这些文件有关信息的指针。对一个目录文件具有读权限的任进程都可以读该目录的内容,但只有内核可以直接写目录文件。
  • 块特殊文件(block special file)。这种类型的文件提供对设备(如磁盘)带缓冲的访问,每次访问以固定长度为单位进行。
  • 字符特殊文件(character special fle),这种类型的文件提供对设备不带缓冲的访问,每次访问长度可变。系统中的所有设备要么是字符特殊文件,要么是块特殊文件。
  • FIFO。这种类型的文件用于进程间通信,有时也称为命名管道(named pipe)。
  • 套接字(socket)。这种类型的文件用于进程间的网络通信。套接字也可用于在一台宿主机上进程之间的非网络通信。
  • 符号链接(symbolic link)。这种类型的文件指向另一个文件。

文件类型信息包含在stat结构的st_mode成员中。可以用表中的宏确定文件类型。这些宏的参数都是stat结构中的st_mode成员。

文件类型
S_ISREG() 管通文件
S_ISDIR() 目录文件
S_ISCHR() 字符特殊文件
S_ISBLK() 块特殊文件
S_ISFIFO() 管道或FIFO
S_ISLNK() 符号链接
S_ISSOCK() 套楼字

POSIX.1允许实现将进程间通信(IPC)对象(如消息队列和信号量等)说明为文件。表中的宏可用来从stat结构中确定IPC对象的类型。这些宏与上表中的不同,它们的参数并非st_mode,而是指向stat结构的指针。

对象的类型
S_TYPEISMQ() 消息队列
S_TYPEISSEM() 信号量
S_TYPEISSHM() 共享存储对象

程序取其命令行参数,然后针对每一个命令行参数打印其文件类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "apue.h"
int main (int argc, char *argv[]) {
int i;
struct stat buf;
char *ptr;

for (i = 1; i < argc; i ++) {
printf("%s: ", argv[i]);
if(lstat(argv[i], &buf) < 0) {
err.ret("lstat error");
continue;
}
if(S_ISREG(buf.st_mode))
ptr = "regular";
else if(S_ISDIR(buf.st_mode))
ptr = "directory";
else if (S_ISCHR(buf.st_mode))
ptr = "chacacter special";
else if(S_ISBLK(buf.st_mode))
ptr = "block special",
else if(S_ISFIFO(buf.st_mode))
ptr = "fifo";
else if (S_ISLNK(buf.st_mode))
ptr = "symbolic link";
else if(S_ISSOCK(buf.st_mode))
ptr = "Socket";
else
ptz = "*** unknown mode ***";
printl("%s\n", ptr);
exit(0);
}

早期的UNIX版本并不提供S_ISxxx宏,于是就需要将st_mode与屏蔽字S_IFMT进行逻辑“与”运算,然后与名为S_IFxxx的常量相比较。大多数系统在文件<sys/stat.h>中定义了此屏蔽字和相关的常量。如若查看此文件,则可找到S_ISDIR宏定义为:

1
#define S_ISDIR (mode) (((mode) & S_IFMT) == S_IFDIR)

设置用户ID和设置组ID

与一个进程相关联的ID有6个或更多

  • 我们实际上是谁
    • 实际用户ID
    • 实际组ID
  • 用于文件访问权限检查
    • 有效用户ID
    • 有效组ID
    • 附属组ID
  • 由exec通数保存

    • 保存的设置用户ID
    • 保存的设置组ID
  • 实际用户ID和实际组ID标识我们究竟是谁。这两个字段在登录时取自口令文件中的登录项。通常,在一个登录会话期间这些值并不改变,但是超级用户进程有方法改变它们。

  • 有效用户ID、有效组ID以及附属组ID决定了我们的文件访问权限。
  • 保存的设置用户ID和保存的设置组ID在执行一个程序时包含了有效用户ID和有效组ID的副本。

通常,有效用户ID等于实际用户ID,有效组ID等于实际组ID。每个文件有一个所有者和组所有者,所有者由stat结构中的st_uid指定,组所有者则由st_gid指定。

当执行一个程序文件时,进程的有效用户ID通常就是实际用户ID,有效组ID通常是实际组ID。但是可以在文件模式字(st_mode)中设置一个特殊标志,其含义是“当执行此文件时,将进程的有效用户ID设置为文件所有者的用户ID(st_uid)”。与此相类似,在文件模式字中可以设置另一位。它将执行此文件的进程的有效组ID设置为文件的组所有者ID(st_gid)。在文件模式字中的这两位被称为设置用户ID(set-user-ID)位和设置组ID(set-group-ID)位。

文件访问权限

st_mode值也包含了对文件的访问权限位。当提及文件时,指的是前面所提到的任何类型的文件。所有文件类型(目录、字符特别文件等)都有访问权限(access permission)。每个文件有9个访问权限位,可将它们分成3类:

st_mode屏蔽 含义
S_IRUSR 用户读
S_IWUSR 用户写
S_IXUSR 用户执行
S_IRGRP 组读
S_IWGRP 组写
S_IXGRP 组执行
S_IROTH 其他读
S_IWOTH 其他写
S_IXOTH 其他执行

在前3行中,术语用户指的是文件所有者(owner)。chmod(1)命令用于修改这9个权限位。该命令允许我们用u表示用户(所有者),用g表示组,用o表示其他。

3类访问权限(即读、写及执行)以各种方式由不同的函数使用。我们将这些不同的使用方式汇总在下面。

  • 第一个规则是,我们用名字打开任一类型的文件时,对该名字中包含的每一个目录,包括它可能隐含的当前工作目录都应具有执行权限。这就是为什么对于目录其执行权限位常被称为搜索位的原因。
    • 例如,为了打开文件/usr/include/stdio.h,需要对目录//usr/usr/inciude具有执行权限。然后,需要具有对文件本身的适当权限,这取决于以何种模式打开它。
    • 如果当前目录是/usr/include,那么为了打开文件stdio.h,需要对当前目录有执行权限。这是隐含当前目录的一个示例,打开stdio.h文件与打开./stdio.h作用相同。
    • 注意,对于目录的读权限和执行权限的意义是不相同的。读权限允许我们读目录,获得在该目录中所有文件名的列表。当一个目录是我们要访问文件的路径名的一个组成部分时,对该目录的执行权限使我们可通过该目录(也就是搜索该目录),寻找一个特定的文件名
  • 对于一个文件的读权限决定了我们是否能够打开现有文件进行读操作。
  • 对于一个文件的写权限决定了我们是否能够打开现有文件进行写操作。
  • 为了在open函数中对一个文件指定O_TRUNC标志,必须对该文件具有写权限。
  • 为了在一个目录中创建一个新文件,必须对该目录具有写权限和执行权限。
  • 为了删除一个现有文件,必须对包含该文件的目录具有写权限和执行权限。对该文件本身则不需要有读、写权限。
  • 如果用7个exec函数中的任何一个执行某个文件,都必须对该文件具有执行权限。该文件还必须是一个普通文件。

进程每次打开、创建或删除一个文件时,内核就进行文件访问权限测试,而这种测试可能涉及文件的所有者(st_uidst_gid)、进程的有效ID(有效用户ID和有效组ID)以及进程的附属组ID(若支持的话)。两个所有者ID是文件的性质,而两个有效ID和附属组ID则是进程的性质。内核进行的测试具体如下。

  1. 若进程的有效用户D是0(超级用户),则允许访问。这给予了超级用户对整个文件系统进行处理的最充分的自由。
  2. 若进程的有效用户ID等于文件的所有者ID(也就是进程拥有此文件),那么如果所有者适当的访问权限位被设置,则允许访问;否则拒绝访问。适当的访问权限位指的是,若进程为读而打开该文件,则用户读位应为1;若进程为写而打开该文件,则用户写位应为1;若进程将执行该文件,则用户执行位应为1.
  3. 若进程的有效组ID或进程的附属组ID之一等于文件的组D,那么如果组适当的访问权限位被设置,则允许访问:否则拒绝访问,
  4. 若其他用户适当的访问权限位被设置,则允许访问:否则拒绝访问。

按顺序执行这4步。注意,如果进程拥有此文件(第2步),则按用户访问权限批准或拒绝该进程对文件的访问——不查看组访问权限。类似地,若进程并不拥有该文件。但进程属于某个适当的组,则按组访问权限批准或拒绝该进程对文件的访问——不查看其他用户的访问权限。

新文件和目录的所有权

新文件的用户ID设置为进程的有效用户ID。关于组ID,POSIX.1允许实现选择下列之一作为新文件的组ID。

  1. 新文件的组D可以是进程的有效组ID.
  2. 新文件的组ID可以是它所在目录的组ID.

使用POSIX.I所允许的第二个选项(继承目录的组ID)使得在某个目录下创建的文件和目录都具有该目录的组ID。于是文件和目录的组所有权从该点向下传递。

函数access和faccessat

当用open函数打开一个文件时,内核以进程的有效用户ID和有效组ID为基础执行其访问权限测试。有时,进程也希望按其实际用户ID和实际组ID来测试其访问能力。即使一个进程可能已经通过设置用户ID以超级用户权限运行,它仍可能想验证其实际用户能否访问一个给定的文件。accessfaccessat函数是按实际用户ID和实际组ID进行访问权限测试的。

1
2
3
4
#include <unistd.h>
int access(const char *pathmame, int mode):
int faccessat(int fd, const char *pathname, int mode, int flag);
// 两个函数的返回值,若成功,返回0,若出错,返回-1

其中,如果测试文件是否已经存在,mode就为F_OK;否则mode是表中所列常量的按位或。

mode 说明
R_OK 测试读权限
W_OK 测试写权限
X_OK 测试执行权限

faccessat函数与access函数在下面两种情况下是相同的:一种是pathname参数为绝对路径,另一种是fd参数取值为AT_FDCWDpathname参数为相对路径。否则,faccessat计算相对于打开目录(由fd参数指向)的pathname

flag参数可以用于改变faccessat的行为,如果flag设置为AT_EACCESS,访问检查用的是调用进程的有效用户ID和有效组ID,而不是实际用户ID和实际组ID。

下文显示了access函数的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "apue.h"
#include<fcntl.h>
int main(int argc, char *argv[]) {
if (argc != 2)
err_quit("usage: a.out <pathname>");
if(access(argv[1], R_OK) < 0)
err_ret ("access error for %s", argv[1]);
else
printf("read access OK\n");
if(open(argv[1], O_RDONLY) < 0)
err_ret("open error for %s", argv[1]);
else
printf("open for reading OK\n");
exit(0);
}

在本例中,尽管open函数能打开文件,但通过设置用户ID程序可以确定实际用户不能正常读指定的文件。

函数umask

umask函数为进程设置文件模式创建屏蔽字,并返回之前的值。

1
2
3
#include<sys/stat.h>
mode_t umask (mode_t cmask);
// 返回值。之前的文件模式创建屏蔽字

其中,参数cmask是之前列出的9个常量(S_IRUSRS_IWUSR等)中的若干个按位“或”构成的。

在进程创建一个新文件或新目录时,就一定会使用文件模式创建屏蔽字。在文件模式创建屏蔽字中为1的位,在文件mode中的相应位一定被关闭。

程序创建了两个文件,创建第一个时,umask值为0,创建第二个时,umask值禁止所有组和其他用户的访问权限

1
2
3
4
5
6
7
8
9
10
11
12
13
#include"apue.h"
#include <fenti.h>
#define RWRWRW(S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH)

int main (void) {
umask (0);
if(creat("foo", RWRWRW) < 0)
err_sys("creat error for foo");
umask(S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH);
if (creat("bar", RWRWRW) < 0)
err_ays("creat error for bar");
exif(0);
}

若运行此程序可得如下结果,从中可见访问权限位是如何设置的。

1
2
3
4
5
6
7
8
9
$ umask         ;先打印当前文件模式创建屏蔽字
002
$ ./a.out
$ ls -l foo bar
-rw------- 1 sar 0 Dec 7 21:20 bar
-rw-rw-rw- 1 sar 0 Dec 7 21:20 foo

$ umask ;观察文件模式创建屏蔽字是否更改
002

UNIX系统的大多数用户从不处理他们的umask值。通常在登录时,由shell的启动文件设置一次,然后,再不改变。尽管如此,当编写创建新文件的程序时,如果我们想确保指定的访问权限位已经激活,那么必须在进程运行时修改umask值。例如,如果我们想确保任何用户都能读文件,则应将umask设置为0。否则,当我们的进程运行时,有效的umask值可能关闭该权限位。

用户可以设置umask值以控制他们所创建文件的默认权限。该值表示成八进制数,一位代表一种要屏蔽的权限。设置了相应位后,它所对应的权限就会被拒绝。常用的几种umask值是002、022和027。002阻止其他用户写入你的文件,022阻止同组成员和其他用户写入你的文件,027阻止同组成员写你的文件以及其他用户读、写或执行你的文件。

屏蔽位 含义
0400 用户读
0200 用户写
0100 用户执行
0040 组读
0020 组写
0010 组执行
0004 其他读
0002 其他写
0001 其他执行

函数chmod、fchmod和fchmodat

chmodfchmodfchmodat这3个函数使我们可以更改现有文件的访问权限。

1
2
3
4
5
#include <ays/stat.h>
int chmod(const char *pathname, mode_t mode);
int fchmod(int fd, mode_t mode);
int fchmodat(int fd, const char *pathname, mode_t mode, int flag);
// 3个函数返回值:若成功,返回0;若出错。返回-1

chmod函数在指定的文件上进行操作,而fchmod函数则对已打开的文件进行操作。fchmodat函数与chmod函数在下面两种情况下是相同的:一种是pathname参数为绝对路径,另一种是fd参数取值为AT_FDCWDpathname参数为相对路径。否则,fchmodat计算相对于打开目录(由fd参数指向)的pathnameflag参数可以用于改变fchmodat的行为,当设置了AT_SYMLINK_NOFOLLOW标志时,fchmodat并不会跟随符号链接。

为了改变一个文件的权限位,进程的有效用户D必须等于文件的所有者ID,或者该进程必须具有超级用户权限。参数mode是常量的按位或。

mode 说明
S_ISUID 执行时设置用户D
S_ISGID 执行时设置组D
S_ISVTX 保存正文(粘着位)
S_IRWXU 用户(所有者)读、写和执行
S_IRUSR 用户(所有者)读
S_IWUSR 用户(所有者)写
S_IXUSR 用户(所有者)执行
S_IRWXG 组读、写和执行
S_IRGRP 组读
S_IWGRP 组写
S_IXGRP 组执行
S_IRWXO 其他读、写和执行
S_IROTH 其他读
S_IWOTH 其他写
S_IXOTH 其他执行

注意,有9项是取自之前的9个文件访问权限位。我们另外加了6个,它们是两个设置ID常量(S_ISUIDS_ISGID)、保存正文常量(S_ISVTX)以及3个组合常量(S_IRWXUS_IRWXGS_IRWXO)。

程序修改了这两个文件的模式,

1
2
3
4
5
6
7
8
9
10
11
12
#include "apue.h"
int main(void) {
struct stat statbuf;
/* turn on set-group-ID and turn off group-execute */
if(stat("foo", &statbuf) < 0)
err_ays("stat error for foo");
if(chmod("foo", (statbuf.st_mode & ~S_IXGRP) | S_ISGID) < 0)
err_sys ("chmod error for foo");
/* set absolute mode to "rw-r--r--" */
if(chmod("bar", S_IRUSR | S_IWUSR I S_IRGRP | S_IROTH) < 0)
exr_sys("chmod error for bar");
exit(0);

在运行程序后,这两个文件的最后状态是:

1
2
3
$ ls -l foo bax
-rw-r--r-- 1 sar 0 Dec 7 21:20 bar
-rw-rwSrw- 1 sar 0 Dec 7 21:20 foo

在本例中,不管文件bar的当前权限位如何,我们都将其权限设置为一个绝对值。对文件foo,我们相对于其当前状态设置权限。为此,先调用stat获得其当前权限,然后修改它。我们显式地打开了设置组ID位、关闭了组执行位。

chmod函数在下列条件下自动清除两个权限位。

  • Solaris等系统对用于普通文件的粘着位赋予了特殊含义,在这些系统上如果我们试图设置普通文件的粘着位(S_ISVTX),且又没有超级用户权限,那么mode中的粘着位自动被关闭。这意味着只有超级用户才能设置普通文件的粘着位。这样做的理由是防止恶意用户设置粘着位,由此影响系统性能。
  • 新创建文件的组ID可能不是谓用进程所属的组。特别地,如果新文件的组ID不等于进程的有效组ID或者进程附属组ID中的一个,而且进程没有超级用户权限,那么设置组ID位会被自动被关闭。这就防止了用户创建一个设置组ID文件,而该文件是由并非该用户所属的组拥有的。

粘着位

S_ISVTX如果一个可执行程序文件的这一位被设置了,那么当该程序第一次被执行,在其终止时,程序正文部分的一个副本仍被保存在交换区(程序的正文部分是机器指令)。

这使得下次执行该程序时能较快地将其装载入内存。其原因是:通常的UNIX文件系统中,文件的各数据块很可能是随机存放的,相比较而言,交换区是被作为一个连续文件来处理的。对于在交换区中可以同时存放的设置了粘着位的文件数是有限制的,以免过多占用交换区空间,但无论如何这是一个有用的技术。因为在系统再次自举前,文件的正文部分总是在交换区中,这正是名字中“粘着”的由来。后来的UNIX版本称它为保存正文位(saved-textbit),因此也就有了常量S_ISVTX

如果对一个目录设置了粘着位,只有对该目录具有写权限的用户并且满足下列条件之一,才能删除或重命名该目录下的文件:

  • 拥有此文件;
  • 拥有此目录:
  • 是超级用户。

目录/tmp/var/tmp是设置粘着位的典型候选者,任何用户都可在这两个目录中创建文件。任一用户(用户、组和其他)对这两个目录的权限通常都是读、写和执行。但是用户不应能删除或重命名属于其他人的文件,为此在这两个目录的文件模式中都设置了粘着位。

函数chown、fchown、fchownat和1chown

下面几个chown函数可用于更改文件的用户ID和组ID。如果两个参数ownergroup中的任意一个是-1,则对应的ID不变。

1
2
3
4
5
6
#include <unistd.h>
int chown(const char *pathnome, uid_t owner, gid_t group);
int fchown(int fd, uid_t owner, gid_t group);
int fchownat(int fd, const char *pathrame, uid_t owner, gid_t group, int flag);
int lchown(const char *pariname, uid_t owner, gid_t group);
// 4个函数的返回值,若成功,返回0;若出错,返回-1

除了所引用的文件是符号链接以外,这4个函数的操作类似。在符号链接情况下,lchownfchownat(设置了AT_SYMLINK_NOFOLLOW标志)更改符号链接本身的所有者,而不是该符号链接所指向的文件的所有者。

fchown函数改变fd参数指向的打开文件的所有者,既然它在一个已打开的文件上操作,就不能用于改变符号链接的所有者,fchownat函数与chown或者lchown函数在下面两种情况下是相同的;一种是pathname参数为绝对路径,另一种是fd参数取值为AT_PDCNDpathname参数为相对路径。在这两种情况下,如果flag参数中设置了AT_SYMLINK_NOFOLLOW标志,fchownatlchown行为相同,如果flag参数中清除了AT_SYMLINK_NOFOLLOW标志,则fchownatchown行为相同。如果fd参数设置为打开目录的文件描述符,并且pathname参数是一个相对路径名,fchownat函数计算相对于打开目录的pathname。

_POSIX_CHOWN_RESTRICTED常量可选地定义在头文件<unistd.h>中,而且总是可以用pathconffpathconf函数进行查询。此选项还与所引用的文件有关一可在每个文件系统基础上,使该选项起作用或不起作用。在下文中,如提及“若_POSIX_CHOWN_RESTRICTED生效”,则表示“这适用于我们正在淡及的文件”,而不管该实际常量是否在头文件中定义。

_POSIX_CHOWN_RESTRICTED对指定的文件生效,则

  1. 只有超级用户进程能更改该文件的用户ID:
  2. 如果进程拥有此文件(其有效用户ID等于该文件的用户ID),参数owner等于-1或文件的用户ID,并且参数group等于进程的有效组ID成进程的附属组ID之一,那么一个非超级用户进程可以更改该文件的组ID

这意味着,当_POSIX_CHOWN_RESTRICTED有效时,不能更改其他用户文件的用户ID。你可以更改你所拥用的文件的组ID,但只能改到你所属的组。如果这些函数由非超级用户进程调用,则在成功返回时,该文件的设置用户ID位和设置组ID位都被清除。

文件长度

stat结构成员st_size表示以字节为单位的文件的长度。此字段只对普通文件、目录文件和符号链接有意义。

对于普通文件,其文件长度可以是0,在开始读这种文件时,将得到文件结束(end-of-file)指示。对于目录,文件长度通常是一个数(如16或512)的整倍数。

对于符号链接,文件长度是在文件名中的实际字节数。例如,在下面的例子中,文件长度7就是路径名usr/lib的长度:

1
lrwxrwxrwx 1 root  7 Sep 25 07:14 lib -> usr/1ib

(注意,因为符号链接文件长度总是由st_size指示,所以它并不包含通常C语言用作名字结尾的null字节。)

现今,大多数现代的UNIX系统提供字段st_blksizest_blocks。其中,第一个是对文件I/O较合适的块长度,第二个是所分配的实际512字节块块数。为了提高效率,标准I/O库也试图一次读、写st_blksize个字节。

文件中的空洞

我们提及普通文件可以包含空洞。空洞是由所设置的偏移量超过文件尾端,并写入了某些数据后造成的。作为一个例子,考虑下列情况:

1
2
3
4
$ ls -l core
-rw-r--r-- 8483248 Nov 18 12:18 core
$ du -s core
272 core

文件core的长度稍稍超过8MB,可是du命令报告该文件所使用的磁盘空间总量是272个512字节块(即139264字节)。很明显,此文件中有很多空洞。

对于没有写过的字节位置,read函数读到的字节是0。如果执行下面的命令,可以看出正常的I/O操作读整个文件长度:

1
2
$ wc -c core
8483248 core

带-c选项的wc(l)命令计算文件中的字符数(字节)。

如果使用实用程序(如cat(1)复制这个文件,那么所有这些空洞都会被填满,其中所有实际数据字节皆填写为0。

1
2
3
4
5
6
7
$ cat core > core.copy
$ ls -l oore*
-rw-r--r-- 1 sar 8483248 Nov 18 12:18 core
-rw-rw-r-- 1 sar 8483248 Nov 18 12:27 core.copy
$ du -s core*
272 core
16592 core.copy

从中可见,新文件所用的实际字节数是8495 104(512 x 16592)。此长度与ls命令报告的长度不同,其原因是,文件系统使用了若干块以存放指向实际数据块的各个指针。

文件截断

有时我们需要在文件尾端处截去一些数据以缩短文件。将一个文件的长度截断为0是一个特例,在打开文件时使用O_TRUNC标志可以做到这一点。为了截断文件可以调用函数truncateftruncate

1
2
3
4
#include <unistd.h>
int truncate(const char *puthoume, ott_t lempth);
int ftruncate(int fd, off_c length) i
两个函数的返回值,若成功,返回0;若出错。返回-1

这两个函数将一个现有文件长度裁断为length。如果该文件以前的长度大于length,则超过length以外的数据就不再能访问。如果以前的长度小于length,文件长度将增加,在以前的文件尾端和新的文件尾端之间的数据将读作0(也就是可能在文件中创建了一个空洞)。

文件系统

目前,正在使用的UNIX文件系统有多种实现。例如. Solaris支持多种不同类型的磁盘文件系统:传统的基于BSD的UNIX文件系统(称为UFS),读、写DOS格式软盘的文件系统(称为PCFS),以及读CD的文件系统(称为HSFS)。UFS是以Berkeiey快速文件系统为基础的。

我们可以把一个磁盘分成一个或多个分区。每个分区可以包含一个文件系统,i节点是固定长度的记录项,它包含有关文件的大部分信息。

如果更仔细地观察一个柱面组的i节点和数据块部分,则可以看到下图中所示的情况。

在图中有两个目录项指向同一个i节点。每个i节点中都有一个链接计数,其值是指向该i节点的目录项数。只有当链接计数减少至0时,才可删除该文件。这也是为什么删除一个目录项的函数被称之为unlink而不是delete的原因。在stat结构中,链接计数包含在st_nlink成员中,其基本系统数据类型是nlink_t。这种链接类型称为硬链接。

另外一种链接类型称为符号链接(symbolic link)。符号链接文件的实际内容(在数据块中)包含了该符号链接所指向的文件的名字。在下面的例子中,目录项中的文件名是3个字符的字符串lib,而在该文件中包含了7个字节的数据usr/lib:

1
lrwxrwxrwx l root      7 sep 25 07:14 lib -> /urs/lib

该i节点中的文件类型是S_IFLNK,于是系统知道这是一个符号链接。

i节点包含了文件有关的所有信息,文件类型、文件访问权限位、文件长度和指向文件数据块的指针等。stat结构中的大多数信息都取自1节点。只有两项重要数据存放在目录项中:文件名和i节点编号。i节点编号的数据类型是ino_t

因为目录项中的节点编号指向同一文件系统中的相应节点,一个目录项不能指向另一个文件系统的i节点。

当在不更换文件系统的情况下为一个文件重命名时,该文件的实际内容并未移动,只需构造一个指向现有i节点的新目录项,并删除老的目录项。链接计数不会改变。例如,为将文件/usr/lib/foo重命名为/usr/foo,如果目录/usr/lib/usr在同一文件系统中,则文件foo的内容无需移动。

假定我们在工作目录中构造了一个新目录:

1
mkdir testdir

下图显示了其结果。注意,该图显式地显示了...目录项。编号为2549的i节点,其类型字段表示它是一个目录,链接计数为2。任何一个叶目录(不包含任何其他目录的目录)的链接计数总是2。数值2来自于命名该目录(testdir)的目录项以及在该目录中的.项。

编号为1267的i节点,其类型字段表示它是一个目录,链接计数大于或等于3。它大于或等于3的原因是,至少有3个目录项指向它一个是命名它的目录项,第二个是在该目录中的.项,第三个是在其子目录testdir中的..项。注意,在父目录中的每一个子目录都使该父目录的链接计数增加1。

函数1ink、linkat、unlink、unlinkat和remove

如上节所述。任何一个文件可以有多个目录项指向其i节点。创建一个指向现有文件的链接的方法是使用link函数或linkat函数

1
2
3
4
#include <unistd.h>
int link(const char *existingpath, const chaz *newpath);
int linkat(int efd, const char *existingpash, int nfd, const char *newpath, int flag);
// 两个函数的返回值。若成功,返回0,若出错,返回-1

这两个函数创建一个新目录项newpath,它引用现有文件existingpath。如果newpath已经存在,则返回出错。只创建newpath中的最后一个分量,路径中的其他部分应当已经存在。

对于linkat函数,现有文件是通过efd和existingpath参数指定的,新的路径名是通过nfd和newpath参数指定的。默认情况下,如果两个路径名中的任一个是相对路径,那么它需要通过相对于对应的文件描述符进行计算。如果两个文件描述符中的任一个设置为AT_FDCWD,那么相应的路径名(如果它是相对路径)就通过相对于当前目录进行计算。如果任一路径名是绝对路径,相应的文件描述符参数就会被忽略

当现有文件是符号链接时,由flag参数来控制linkat函数是创建指向现有符号链接的链接还是创建指向现有符号链接所指向的文件的链接。如果在flag参数中设置了AT_SYMLINK_FOLLOW标志,就创建指向符号链接目标的链接。如果这个标志被清除了,则创建一个指向符号链接本身的链接。

创建新目录项和增加链接计数应当是一个原子操作。虽然POSIX.1允许实现支持跨越文件系统的链接,但是大多数实现要求现有的和新建的两个路径名在同一个文件系统中。如果实现支持创建指向一个目录的硬链接,那么也仅限于超级用户才可以这样做。其理由是这样做可能在文件系统中形成循环,大多数处理文件系统的实用程序都不能处理这种情况。因此,很多文件系统实现不允许对于目录的硬链接

为了剩除一个现有的目录项,可以调用unlink函数。

1
2
3
4
#include <unistd.h>
int unlink (const char *pathname);
int unlinkat (int fd, const char *pathnome, int flag);
// 两个函数的返回值。若成功。返回0,若出错。返回-1

这两个函数删除目录项,并将由pathname所引用文件的链接计数减1。如果对该文件还有其他链接,则仍可通过其他链接访问该文件的数据。如果出错,则不对该文件做任何更改。我们在前面已经提及,为了解除对文件的链接,必须对包含该目录项的目录具有写和执行权限。如果对该目录设置了粘着位,则对该目录必须具有写权限,并且具备下面三个条件之一:

  • 拥有该文件:
  • 拥有该目录:
  • 具有超级用户权限。

只有当链接计数达到0时,该文件的内容才可被删除。另一个条件也会阻止删除文件的内容——只要有进程打开了该文件,其内容也不能剩除。关闭一个文件时,内核首先检查打开该文件的进程个数:如果这个计数达到0,内核再去检查其链接计数:如果计数也是0,那么就删除该文件的内容。

如果pathname参数是相对路径名,那么unlinkat函数计算相对于由fd文件描述符参数代表的目录的路径名。如果fd参数设置为AT_FDCWD,那么通过相对于调用进程的当前工作目录来计算路径名。如果pathname参数是绝对路径名,那么fd参数被忽略。

flag参数给出了一种方法,使调用进程可以改变unlinkat函数的默认行为,当AT_REMOVEDIR标志被设置时,unlinkat函数可以类似于rmdir一样删除目录。如果这个标志被清除,unlinkatunlink执行同样的操作。

程序打开一个文件,然后解除它的链接。执行该程序的进程然后睡眠15秒,接着就终止

1
2
3
4
5
6
7
8
9
10
11
12
#include "apue.h"
#include <fcnt1.h>
int main (void) {
if (open("tempfile", O_RDWR) < 0)
err_sys("open error");
if (unlink("tempfile") < 0)
err_sys ("unlink error");
printt("flle unlinked\n");

sleep(15);
printt("done\n");
exit(0);

运行该程序,其结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ ls -l tempfile   #查看文件大小
-rw-r----- 1 sar 413265408 Jan 21 07:14 tempfile
$ df / home #检查可用磁盘空间
Filesystem lK-blocks Used Avallable Use% Mounted on
/dev/hda4 11021440 1956332 9065108 18% /home
$ ./a.out & #在后台运行程序
1364 #shell打印其进程ID
$ file unlinked #解除文件链接
ls -l tempfile #观察文件是否仍然存在
ls: tempfile: No such tile or directory #目录项已剩除
$ df /home #检查可用磁盘空间有无变化
Filesystem lK-blocks Used Available Use% Mounted on
/dev/hda4 11021440 1956332 9065108 18% /home
$ done #程序执行结束,关闭所有打开文件
df /home #现在,应当有更多可用磁盘空间
Filesysten lK-blocks Used Available Use% mounted on
/dev/hda4 11021440 1552352 9469088 15% /home
#现在,394.1MB磁盘空间可用

unlink的这种特性经常被程序用来确保即使是在程序崩溃时,它所创建的临时文件也不会遭留下来。进程用open或creat创建一个文件,然后立即调用unlink,因为该文件仍旧是打开的,所以不会将其内容删除,只有当进程关闭该文件或终止时(在这种情况下,内核关闭该进程所打开的全部文件),该文件的内容才被剥除,如果pathname是符号链接,那么uniink删除该符号链接,而不是删除由该链接所引用的文件。给出符号链接名的情况下,没有一个函数能剩除由该链接所引用的文件。

如果文件系统支持的话,超级用户可以调用unlink,其参数pathname指定一个目录,但是通常应当使用rmdir函数,而不使用unlink这种方式。

我们也可以用remove函数解除对一个文件或目录的链接。对于文件,remove的功能与unlink相同。对于目录,remove的功能与rmdir相同。

1
2
3
#include <stdio.h>
int remove (const char *pathname);
// 返回值,若成功,返回0,若出错,返回-1

ISO C指定remove函数删除一个文件,这更改了UNIX历来使用的名字unlink,其原因是实现C标准的大多数察UNIX系统并不支持文件链接;

函数rename和renameat

文件或目录可以用rename函数或者renameat函数进行重命名。

1
2
3
4
#include <stdio.h>
int rename (const char *oldname, const char *newname);
int renameat (int oldfd, const char *oldname, int newfd, const char *newname);
// 两个函数的返回值。若成功,返回0,若出错,返回-1

根据oldname是指文件、目录还是符号链接,有几种情况需要加以说明。我们也必须说明如果newname已经存在时将会发生什么。

  1. 如果oldname指的是一个文件而不是目录,那么为该文件或符号链接重命名。在这种情况下,如果newname已存在,则它不能引用一个目录。如果newname已存在,而且不是一个目录,则先将该目录项删除然后将oldname重命名为newname。对包含oldname的目录以及包含newname的目录,调用进程必须具有写权限,因为将更改这两个目录。
  2. 如若oldname指的是一个目录,那么为该目录重命名。如果newname已存在,则它必须引用一个目录,而且该目录应当是空目录。如果newname存在(而且是一个空目录),则先将其剩除,然后将oldname重命名为newname。另外,当为一个目录重命名时,newname不能包含oldname作为其路径前缀。例如,不能将/usr/foo重命名为/usr/foo/testdir,因为旧名字(/usr/foo)是新名字的路径前缀,因而不能将其删除。
  3. 如着oldname成newname引用符号链接,则处理的是符号链接本身,而不是它所引用的文件。
  4. 不能对...重命名。更确切地说, ...都不能出现在oldname和newname的最后部分。
  5. 作为一个特例,如果oldname和newname引用同一文件,则函数不做任何更改而成功返回。

如若newname已经存在,则调用进程对它需要有写权限(如同剧除情况一样)。另外,调用进程将删除oldname目录项,并可能要创建newname目录项,所以它需要对包含oldname及包含newname的目录具有写和执行权限。

除了当oldname或newname指向相对路径名时,其他情况下renameat函数与rename函数功能相同,如果oldhame参数指定了相对路径,就相对于oldfd参数引用的目录来计算oldname。类似地,如果newname指定了相对路径,就相对于newfd引用的目录来计算newname。oldfdnewfd参数(或两者)都能设置成AT_FDCWD,此时相对于当前目录来计算相应的路径名。

符号链接

符号链接是对一个文件的间接指针,硬链接直接指向文件的i节点。引入符号链接的原因是为了避开硬链接的限制:

  • 硬链接通常要求链接和文件位于同一文件系统中,
  • 只有超级用户才能创建指向目录的硬链接(在底层文件系统支持的情况下)。

对符号链接以及它指向何种对象并无任何文件系统限制,任何用户都可以创建指向目录的符号链接。符号链接一般用于将一个文件或整个目录结构移到系统中另一个位置。当使用以名字引用文件的函数时,应当了解该函数是否处理符号链接。也就是该函数是否跟随符号链接到达它所链接的文件。如若该函数具有处理符号链接的功能,则其路径名参数引用由符号链接指向的文件。否则,一个路径名参数引用链接本身,而不是由该链接指向的文件。

下表列出了本章中所说明的各个函数是否处理符号链接。在表中没有列出mkdirmkinfomknodrmdir这些函数,其原因是,当路径名是符号链接时,它们都出错返回。以文件描述符作为参数的一些函数(如fstatfchmod等)也未在该表中列出,其原因是,对符号链接的处理是由返回文件描述符的函数(通常是open)进行的。chown是否跟随符号链接取决于实现。

上表的一个例外是,同时用O_CREATO_EXCL两者调用open函数。在此情况下,若路径名引用符号链接,open将出错返回,errno设置为EEXIST,这种处理方式的意图是堵寨一个安全性漏洞,以防止具有特权的进程被诱骗写错误的文件。

使用符号链接可能在文件系统中引入循环。大多数查找路径名的函数在这种情况发生时都将出错返回,errno值为ELOOP

open打开文件时,如果传递给open函数的路径名指定了一个符号链接,那么open跟随此链接到达所指定的文件。若此符号链接所指向的文件并不存在,则open返回出错,表示它不能打开该文件。这可能会使不熟悉符号链接的用户感到迷惑,例如:

1
2
3
4
5
6
7
8
$ ln -s /no/such/file myfile    #创建一个符号链接
$ ls myfile
myfile

$ cat myfile #试图查看该文件
cat: myfile: No such tile or directory
$ ls -l myfile # 尝试-l选项
lrwxrwxrwx 1 sar 13 Jan 22 00:26 myfile -> /no/such/file

文件myfile存在,但cat却称没有这一文件。其原因是myfile是个符号链接,由该符号链接所指向的文件并不存在。ls命令的-l选项给我们两个提示:第一个字符是l,它表示这是一个符号链接,而->也表明这是一个符号链接。ls命令还有另一个选项-F,它会在符号链接的文件名后加一个@符号,在未使用-l选项时,这可以帮助我们识别出符号链接。

创建和读取符号链接

可以用symlinksymlinkat函数创建一个符号链接。

1
2
3
4
#include<unistd.h>
int symlink(const char *actualpath, const char *sympath);
int symlinkat(const char *actualpath, int fd, const char *sympath);
// 两个函数的返回值。若成功,返回0,若出错。返回-1

函数创建了一个指向actualpath的新目录项sympath。在创建此符号链接时,并不要求actualpath已经存在。并且,actualpathsympath
不需要位于同一文件系统中。

symlinkat函数与symlink函数类似,但sympath参数根据相对于打开文件描述符引用的目录(由fd参数指定)进行计算。如果sympath参数指定的是绝对路径或者fd参数设置了AT_FDCWD值,那么symlinkat就等同于symlink函数。

因为open函数跟随符号链接,所以需要有一种方法打开该链接本身,并读该链接中的名字。readlinkreadlinkat函数提供了这种功能。

1
2
3
4
#include <unistd.b>
ssize_t readlink (const char *restriet parhname, char *restrict buf, size_t bufsize);
ssize_t readlinkat (int fd, const char* restrict pathrame, char *restrict buf, size_t bufsize);
// 两个函数的返回值,若成功,返回读取的字节数,若出错。返回-1

两个函数组合了openreadclose的所有操作。如果函数成功执行,则返回读入buf的字节数。在buf中返回的符号链接的内容不以null字节终止。当pathname参数指定的是绝对路径名或者fd参数的值为AT_FDCWDreadlinkat函数的行为与readlink相同。但是,如果fd参数是一个打开目录的有效文件描述符并且pathname参数是相对路径名,则readlinkat计算相对于由fd代表的打开目录的路径名。

文件的时间

每个文件属性所保存的实际精度依赖于文件系统的实现。对于把时间戳记录在秒级的文件系统来说,纳秒这个字段就会被填充为0。对于时间戳的记录精度高于秒级的文件系统来说,不足秒的值被转换成纳秒并记录在纳秒这个字段中。对每个文件维护3个时间字段,它们的意义示于表。

字段 说明 例子 ls(l)选项
st_atim 文件数据的最后访问时间 read -u
st_ntim 文件教据的最后修改时间 write 默认
st_ctim 节点状态的最后更改时间 chmod、chown -c

注意,修改时间(st_mtim)和状态更改时间(st_ctim)之间的区别。修改时间是文件内容最后一次被修改的时间。状态更改时间是该文件的i节点最后一次被修改的时间。因为i节点中的所有信息都是与文件的实际内容分开存放的,所以,要记录文件数据修改时间和更改i节点中信息的时间。

图列出了我们已说明过的各种函数对这3个时间的作用。增加、删除或修改目录项会影响到它所在目录相关的3个时间。这就是在图中包含两列的原因,其中一列是与该文件(或目录)相关的3个时间,另一列是与所引用的文件(或目录)的父目录相关的3个时间。例如,创建一个新文件影响到包含此新文件的目录,也影响该新文件的i节点。但是,读或写一个文件只影响该文件的i节点,而对目录则无影响。

函数futimens、utimensat和utimes

一个文件的访问和修改时间可以用以下几个函数更改。futimensutimensat函数可以指定纳秒级精度的时间戳。用到的数据结构是与stat函数族相同的timespec结构。

1
2
3
4
#include <sys/stat.h>
int futimens(int fd, const struct timespec times[2]);
int utimensat(int fd, const char *path, const struct timespec times[2], int flag);
// 两个函数返回值,若成功,返回0,若出错,返回-1

这两个函数的times数组参数的第一个元素包含访问时间,第二元素包含修改时间。这两个时间值是日历时间。不足秒的部分用纳秒表示,时间戳可以按下列4种方式之一进行指定:

  1. 如果times参数是一个空指针,则访问时间和修改时间两者都设置为当前时间。
  2. 如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_NOW,相应的时间戳就设置为当前时间,忽略相应的tv_sec字段。
  3. 如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为UTIME_OMIT,相应的时间戳保持不变,忽略相应的tv_sec字段。
  4. 如果rimes参数指向两个timespec结构的数组,且tv_nsec字段的值为既不是UTIME_NOW也不是UTIME_OMIT,在这种情况下,相应的时间戳设置为相应的tv_sectv_nsec字段的值。

执行这些函数所要求的优先权取决于times参数的值。

  • 如果times是一个空指针,或者任一tv_nsec字段设为UTIME_NOW,则进程的有效用户ID必须等于该文件的所有者ID进程对该文件必须具有写权限,或者进程是一个超级用户进程。
  • 如果times是非空指针,并且任一tv_nsec字段的值既不是UTIME_NOW也不是UTIME_OMIT,则进程的有效用户ID必须等于该文件的所有者ID,或者进程必须是一个超级用户进程。对文件只具有写权限是不够的
  • 如果times是非空指针,并且两个tv_nec字段的值都为UTIME_OMIT,就不执行任何的权限检查

futimens函数需要打开文件来更改它的时间,utimensat函数提供了一种使用文件名更改文件时间的方法。pathname参数是相对于fd参数进行计算的,fd要么是打开目录的文件描述符,要么设置为特殊值AT_FDCWD。如果pathname指定了绝对路径,那么fd参数被忽略,utimensatflag参数可用于进一步修改默认行为,如果设置了AT_SYMLINK_NOFOLLOW标志,则符号链接本身的时间就会被修改。默认的行为是跟随符号链接,并把文件的时间改成符号链接的时间。

1
2
3
#include <sys/time.h>
int utimes(const char *pathname, const struct timeval times[2]);
// 函数返回值。若成功,返回0,若出错,返回-1

utimes函数对路径名进行操作。times参数是指向包含两个时间戳(访问时间和修改时间)元素的数组的指针,两个时间戳是用秒和微妙表示的。

1
2
3
4
struct timeval {
time_t tv_sec; /* seconds */
long tv_usec; /* microseconds */
};

注意,我们不能对状态更改时间st_ctim(i节点最近被修改的时间)指定一个值,因为调用utimes函数时,此字段会被自动更新。

程序使用带O_TRUNC选项的open函数将文件长度截断为0,但并不更改其访问时间及修改时间。为了做到这一点,首先用stat函数得到这些时间,然后截断文件,最后再用futimens函数重置这两个时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "apue.h"
#include <fcntl.h>
int main(int argc, char *argv[]) {
int i, fd;
struct stat statbuf;
struct timespec times[2];
for (i = 1; i < argc; i++) {
if(stat(argv[i], &statbuf) < 0) { /* fetch current times */
err_ret("%s: stat error", argv[i]);
continue;
}
if ((fd = open(argv[i], O_RDWR | O_TRUNC)) < 0) { /*truncate */
err_ret("%s: open error", argv[i]);
continue;
}
times[0] = statbuf.st_atin;
times[1] = statbuf.st_mtim;
if(futimens(fd, times) < 0) /* reset times */
err_ret ("%s: futinens error", argv[i]);
close (fd);
}
exit(0);
}

函数mkdir, mkdirat和rmdir

mkdirmkdirat函数创建目录,用rmdir函数删除目录。

1
2
3
4
#include <ays/stat.h>
int mkdir(const char *pathname, mode_t mode);
int mkdirat(int fd, const char *pathname, mode_t mode);
// 两个函数返回值。若成功,返回0;若出错,返回-1

这两个函数创建一个新的空目录。其中...目录项是自动创建的。所指定的文件访问权限mode由进程的文件模式创建屏蔽字修改。常见的错误是指定与文件相同的mode(只指定读、写权限)。但是,对于目录通常至少要设置一个执行权限位,以允许访问该目录中的文件名。

mkdirat函数与mkdir函数类似。当fd参数具有特殊值AT_FDCWD或者pathname参数指定了绝对路径名时,mkdiratmkdir完全一样。否则,fd参数是一个打开目录,相对路径名根据此打开目录进行计算。

rmdir函数可以删除一个空目录。空目录是只包含...这两项的目录。

1
2
3
#include<unistd.h>
int rmdir(const char *pathname);
// 返回值:若成功,返回0;若出错,返回-1

如果调用此函数使目录的链接计数成为0,并且也没有其他进程打开此目录,则释放由此目录占用的空间。如果在链接计数达到0时,有一个或多个进程打开此目录,则在此函数返回前删除最后一个链接及...项。另外,在此目录中不能再创建新文件。但是在最后一个进程关闭它之前并不释放此目录。

读目录

对某个目录具有访问权限的任一用户都可以读该目录,但是,为了防止文件系统产生混乱,只有内核才能写目录。

1
2
3
4
5
6
7
8
9
10
11
12
#include <dirent.h>
DIR *opendir(const char *pathname);
DIR *fdopendir(int fd);
// 两个函数返回值:若成功,返回指针,若出错,返回NULL
struct dirent *readdir(DIR *dp);
// 返回值。若成功,返回指针;若在目录尾或出错,返回NULL
void rewinddir(DIR *dp);
int closedir(DIR *dp);
// 返回值:若成功。返回0;若出错,返回-1
long telldir(DIR* dp);
// 返回值,与中关联的目录中的当前位置
void seekdir (DIR* dp, long loc);

fdopendir函数提供了一种方法,可以把打开文件描述符转换成目录处理函数需要的DIR结构。定义在头文件<dirent.h>中的dirent结构与实现有关。实现对此结构所做的定义至少包含下列两个成员:

1
2
ino_t d_ino;   /* i-node number */
char d_name[]; /* null-terninated tilename */

注意,d_name项的大小并没有指定,但必须保证它能包含至少NAME_MAX个字节(不包含终止null字节)。因为文件名是以null字节结束的,所以在头文件中如何定义数组d_name并无多大关系,数组大小并不表示文件名的长度。

DIR结构是一个内部结构,上述7个函数用这个内部结构保存当前正在被读的目录的有关信息。其作用类似于FILE结构。FILE结构由标准I/O库维护。

opendir执行初始化操作,使第一个readdir返回目录中的第一个目录项。DIR结构由fdopendir创建时,readdir返回的第一项取决于传给fdopendir函数的文件描述符相关联的文件偏移量。注意,目录中各目录项的顺序与实现有关。它们通常并不按字母顾序排列。

我们将使用这些对目录进行操作的例程编写一个遍历文件层次结构的程序,其目的是得到各种类型的文件计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#include "apue.h"
#include <dirent.h>
#include <limits.h>

/* function type that is called for each filename */
typedef int Myfunc(const char *, const struct stat *, int);

static Myfunc myfunc;
static int myftw(char *, Myfunc *);
static int dopath(Myfunc *);

static long nreg, ndir, nblk, nchr, nfifo, nslink, nsock, ntot;

int
main(int argc, char *argv[])
{
int ret;

if (argc != 2)
err_quit("usage: ftw <starting-pathname>");

ret = myftw(argv[1], myfunc); /* does it all */

ntot = nreg + ndir + nblk + nchr + nfifo + nslink + nsock;
if (ntot == 0)
ntot = 1; /* avoid divide by 0; print 0 for all counts */
printf("regular files = %7ld, %5.2f %%\n", nreg,
nreg*100.0/ntot);
printf("directories = %7ld, %5.2f %%\n", ndir,
ndir*100.0/ntot);
printf("block special = %7ld, %5.2f %%\n", nblk,
nblk*100.0/ntot);
printf("char special = %7ld, %5.2f %%\n", nchr,
nchr*100.0/ntot);
printf("FIFOs = %7ld, %5.2f %%\n", nfifo,
nfifo*100.0/ntot);
printf("symbolic links = %7ld, %5.2f %%\n", nslink,
nslink*100.0/ntot);
printf("sockets = %7ld, %5.2f %%\n", nsock,
nsock*100.0/ntot);
exit(ret);
}

/*
* Descend through the hierarchy, starting at "pathname".
* The caller's func() is called for every file.
*/
#define FTW_F 1 /* file other than directory */
#define FTW_D 2 /* directory */
#define FTW_DNR 3 /* directory that can't be read */
#define FTW_NS 4 /* file that we can't stat */

static char *fullpath; /* contains full pathname for every file */
static size_t pathlen;

static int /* we return whatever func() returns */
myftw(char *pathname, Myfunc *func)
{
fullpath = path_alloc(&pathlen); /* malloc PATH_MAX+1 bytes */
/* ({Prog pathalloc}) */
if (pathlen <= strlen(pathname)) {
pathlen = strlen(pathname) * 2;
if ((fullpath = realloc(fullpath, pathlen)) == NULL)
err_sys("realloc failed");
}
strcpy(fullpath, pathname);
return(dopath(func));
}

/*
* Descend through the hierarchy, starting at "fullpath".
* If "fullpath" is anything other than a directory, we lstat() it,
* call func(), and return. For a directory, we call ourself
* recursively for each name in the directory.
*/
static int /* we return whatever func() returns */
dopath(Myfunc* func)
{
struct stat statbuf;
struct dirent *dirp;
DIR *dp;
int ret, n;

if (lstat(fullpath, &statbuf) < 0) /* stat error */
return(func(fullpath, &statbuf, FTW_NS));
if (S_ISDIR(statbuf.st_mode) == 0) /* not a directory */
return(func(fullpath, &statbuf, FTW_F));

/*
* It's a directory. First call func() for the directory,
* then process each filename in the directory.
*/
if ((ret = func(fullpath, &statbuf, FTW_D)) != 0)
return(ret);

n = strlen(fullpath);
if (n + NAME_MAX + 2 > pathlen) { /* expand path buffer */
pathlen *= 2;
if ((fullpath = realloc(fullpath, pathlen)) == NULL)
err_sys("realloc failed");
}
fullpath[n++] = '/';
fullpath[n] = 0;

if ((dp = opendir(fullpath)) == NULL) /* can't read directory */
return(func(fullpath, &statbuf, FTW_DNR));

while ((dirp = readdir(dp)) != NULL) {
if (strcmp(dirp->d_name, ".") == 0 ||
strcmp(dirp->d_name, "..") == 0)
continue; /* ignore dot and dot-dot */
strcpy(&fullpath[n], dirp->d_name); /* append name after "/" */
if ((ret = dopath(func)) != 0) /* recursive */
break; /* time to leave */
}
fullpath[n-1] = 0; /* erase everything from slash onward */

if (closedir(dp) < 0)
err_ret("can't close directory %s", fullpath);
return(ret);
}

static int
myfunc(const char *pathname, const struct stat *statptr, int type)
{
switch (type) {
case FTW_F:
switch (statptr->st_mode & S_IFMT) {
case S_IFREG: nreg++; break;
case S_IFBLK: nblk++; break;
case S_IFCHR: nchr++; break;
case S_IFIFO: nfifo++; break;
case S_IFLNK: nslink++; break;
case S_IFSOCK: nsock++; break;
case S_IFDIR: /* directories should have type = FTW_D */
err_dump("for S_IFDIR for %s", pathname);
}
break;
case FTW_D:
ndir++;
break;
case FTW_DNR:
err_ret("can't read directory %s", pathname);
break;
case FTW_NS:
err_ret("stat error for %s", pathname);
break;
default:
err_dump("unknown type %d for pathname %s", type, pathname);
}
return(0);
}

函数chdir、fchdir和getcwd

每个进程都有一个当前工作目录,此目录是搜索所有相对路径名的起点。当用户登录到UNIX系统时,其当前工作目录通常是口令文件(/etc/passwd)中该用户登录项的第6个字段一用户的起始目录(home directory)。当前工作目录是进程的一个属性,起始目录则是登录名的一个属性。进程调用chdirfchdir函数可以更改当前工作目录。

1
2
3
4
#include <unistd.h>
int chdir(const char *pathame);
int fchdir(int fd);
// 两个函数的返回值,若成功,返回0;若出错,返回-1

在这两个函数中,分别用pathname或打开文件描述符来指定新的当前工作目录。

因为当前工作目录是进程的一个属性,所以它只影响调用chdir的进程本身,而不影响其他进程。

1
2
3
4
5
6
7
#include "apue.h"
int main (void) {
if (chdir("/tmp") < 0)
err_sys("chdir failed");
printe("chdir to /tmp succeeded\n");
exit(0);
}

如果编译图4-23程序,并且调用其可执行目标代码文件mycd,则可以得到下列结果:

1
2
3
4
5
6
$ pwd
/usr/lib
$ mycd
chdir to /tmp succeeded
$ pwd
/usr/lib

从中可以看出,执行mycd命令的shell的当前工作目录并没有改变,这是shell执行程序工作方式的一个副作用。每个程序运行在独立的进程中,shell的当前工作目录并不会随着程序调用chdir而改变。

为了改变shell进程自己的工作目录,shell应当直接调用chdir函数,为此,cd命令内建在shell中,因为内核必须维护当前工作目录的信息,所以我们应能获取其当前值。遗憾的是,内核为每个进程只保存指向该目录v节点的指针等目录本身的信息,并不保存该目录的完整路径名。

函数getcwd从当前工作目录(.)开始,用..找到其上一级目录,然后读其目录项,直到该目录项中的i节点编号与工作目录i节点编号相同,这样地就找到了其对应的文件名,逐层上移,直到遇到根,这样就得到了当前工作目录完整的绝对路径名。

1
2
3
#include <unistd.h>
char *getcwd(char *buf, size_t size);
// 返回值,若成功,返回buf,若出错,返回NULL

必须向此函数传递两个参数,一个是缓冲区地址buf,另一个是缓冲区的长度size(以字节为单位)。该缓冲区必须有足够的长度以容纳绝对路径名再加上一个终止null字节,否则返回出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int
main(void)
{
char *ptr;
size_t size;

if (chdir("/usr/spool/uucppublic") < 0)
err_sys("chdir failed");

ptr = path_alloc(&size); /* our own function */
if (getcwd(ptr, size) == NULL)
err_sys("getcwd failed");

printf("cwd = %s\n", ptr);
exit(0);
}

在更换工作目录之前,我们可以调用getcwd函数先将其保存起来。在完成了处理后,就可将所保存的原工作目录路径名作为调用参数传送给chdirfchdir函数向我们提供了一种完成此任务的便捷方法。

在更换到文件系统中的不同位置前,无需调用getcwd函数,而是使用open打开当前工作目录,然后保存其返回的文件描述符。当希望回到原工作目录时,只要简单地将该文件描述符传送给fchdir

设备特殊文件

st_devst_rdev这两个字段经常引起混淆

  • 每个文件系统所在的存储设备都由其主、次设备号表示。设备号所用的数据类型是基本系统数据类型dev_t。主设备号标识设备驱动程序;次设备号标识特定的子设备。
  • 我们通常可以使用两个宏;majorminor来访问主、次设备号,大多数实现都定义这两个宏。这就意味着我们无需关心这两个数是如何存放在dev_t对象中的。
  • 系统中与每个文件名关联的st_dev值是文件系统的设备号,该文件系统包含了这一文件名以及与其对应的i节点。
  • 只有字符特殊文件和块特殊文件才有st_rdev值。此值包含实际设备的设备号。

程序为每个命令行参数打印设备号,另外,若此参数引用的是字符特殊文件或块特殊文件,则还打印该特殊文件的st_rdev值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int
main(int argc, char *argv[])
{
int i;
struct stat buf;

for (i = 1; i < argc; i++) {
printf("%s: ", argv[i]);
if (stat(argv[i], &buf) < 0) {
err_ret("stat error");
continue;
}

printf("dev = %d/%d", major(buf.st_dev), minor(buf.st_dev));

if (S_ISCHR(buf.st_mode) || S_ISBLK(buf.st_mode)) {
printf(" (%s) rdev = %d/%d",
(S_ISCHR(buf.st_mode)) ? "character" : "block",
major(buf.st_rdev), minor(buf.st_rdev));
}
printf("\n");
}

exit(0);
}

文件访问权限位小结

我们已经说明了所有文件访问权限位,其中某些位有多种用途。列出了所有这些权限位,以及它们对普通文件和目录文件的作用。最后9个常量还可以分成如下3组:

  • S_IRWXU = S_IRUSR | S_IWUSR | S_IXUSR
  • S_IRWXG = S_IRGRP | S_IWGRP | S_IXGRP
  • S_IRWXO = S_IROTH | S_IWOTH | S_IXOTH

标准I/O库

流和FILE对象

对于标准I/O库,它们的操作是围绕流(stream)进行的。当用标准I/O库打开或创建一个文件时,我们已使一个流与一个文件相关联。对于ASCII字符集,一个字符用一个字节表示。标准I/O文件流可用于单字节或多字节字符集。流的定向(stream’s orientation)决定了所读、写的字符是单字节还是多字节的。当一个流最初被创建时,它并没有定向。如若在未定向的流上使用一个多字节I/O函数(见<wchar.h>),则将该流的定向设置为宽定向的。若在未定向的流上使用一个单字节I/O函数,则将该流的定向设为字节定向的。只有两个函数可改变流的定向。freopen函数清除一个流的定向;fwide函数可用于设置流的定向。

1
2
3
4
#include <stdio.h>
#include <wchar.h>
int fwide (FILE *fp, int mode);
// 返回值。若流是宽定向的,返回正值:若流是字节定向的,返回负值,若流是未定向的,返回0

根据mode参数的不同值,fwide函数执行不同的工作。

  • 如若mode参数值为负,fwide将试图使指定的流是字节定向的。
  • 如若mode参数值为正,fwide将试图使指定的流是宽定向的。
  • 如若mode参数值为0,fwide将不试图设置流的定向,但返回标识该流定向的值。

注意,fwide并不改变已定向流的定向。还应注意的是,fwide无出错返回。

当打开一个流时,标准I/O函数fopen返回一个指向FILE对象的指针。该对象通常是一个结构,它包含了标准I/O库为管理该流需要的所有信息,包括用于实际I/O的文件描述符、指向用于该流缓冲区的指针、缓冲区的长度、当前在缓冲区中的字符数以及出错标志等。应用程序没有必要检验FILE对象。为了引用一个流,需将FILE指针作为参数传递给每个标准I/O函数。在本书中,我们称指向FILE对象的指针(类型为FILE*)为文件指针。

标准输入、标准输出和标准错误

对一个进程预定义了3个流,并且这3个流可以自动地被进程使用,它们是:标准输入标准输出标准错误。这些流引用的文件与文件描述符STDIN_FILENOSTDOUT_FILENOSTDERR_FILENO所引用的相同。这3个标准I/O流通过预定义文件指针stdinstdoutstderr加以引用。这3个文件
指针定义在头文件<stdio.h>中。

缓冲

标准I/O库提供缓冲的目的是尽可能减少使用readwrite调用的次数。它也对每个I/O流自动地进行缓冲管理。标准I/O提供了以下3种类型的缓冲:

  • 全缓冲。在这种情况下,在填满标准I/O缓冲区后才进行实际操作。对于驻留在磁盘上的文件通常是由标准I/O库实施全缓冲的。在一个流上执行第一次I/O操作时,相关标准I/O函数通常调用malloc获得需使用的缓冲区,flush说明标准I/O缓冲区的写操作。缓冲区可由标准I/O例程自动flush,或者可以调用函数fflush冲洗一个流。
  • 行缓冲。在这种情况下,当在输入和输出中遇到换行符时,标准I/O库执行I/O操作。这允许我们一次输出一个字符,但只有在写了一行之后才进行实际I/O操作。当流涉及一个终端时(如标准输入和标准输出),通常使用行缓冲。
    • 对于行缓冲有两个限制。第一,因为标准I/O库用来收集每一行的缓冲区的长度是固定的,所以只要填满了缓冲区,那么即使还没有写一个换行符,也进行I/O操作。
    • 第二,任何时候只要通过标准I/O库要求从(a)一个不带缓冲的流,或者(b)一个行缓冲的流得到输入数据,那么就会flush所有行缓冲输出流。很明显,从一个不带缓冲的流中输入需要从内核获得数据。
  • 不带缓冲。标准I/O库不对字符进行缓冲存储。例如,用标准I/O函数fputs写15个字符到不带缓冲的流中,我们就期望这15个字符能立即输出。

标准错误流stderr通常是不带缓冲的,这就使得出错信息可以尽快显示出来,而不管它们是否含有一个换行符。

ISOC要求下列缓冲特征。

  • 当且仅当标准输入和标准输出并不指向交互式设备时,它们才是全缓冲的。
  • 标准错误决不会是全缓冲的。

但是,这并没有告诉我们如果标准输入和标准输出指向交互式设备时,它们是不带缓冲的还是行缓冲的;以及标准错误是不带缓冲的还是行缓冲的。很多系统默认使用下列类型的缓冲:

  • 标准错误是不带缓冲的
  • 若是指向终端设备的流,则是行缓冲的;否则是全缓冲的。

对任何一个给定的流,如果我们并不喜欢这些系统默认,则可调用下列两个函数中的一个更改缓冲类型

1
2
3
4
#include <stdio.h>
void setbuf(FILE *restrict fp, char *restrict buf);
int setvbuf(FILE *restrict fp, char *restrict buf, int mode, size_t site);
// 返回值:若成功,返回0;若出错,返回非0

这些函数一定要在流已被打开后调用,因为每个函数都要求一个有效的文件指针作为它们的第一个参数,而且也应在对该流执行任何一个其他操作之前调用。

可以使用setbuf函数打开或关闭缓冲机制。为了带缓冲进行I/O,参数buf必须指向一个长度为BUFSIZ的缓冲区(该常量定义在<stdio.h>中)。通常在此之后该流就是全缓冲的,但是如果该流与一个终端设备相关,那么某些系统也可将其设置为行缓冲的。为了关闭缓冲,将buf没置为NULL。

使用setvbuf,我们可以精确地说明所需的缓冲类型。这是用mode参数实现的:

  • _IOFBF:全缓冲
  • _IOLBF:行缓冲
  • _IONBF:不带缓冲

如果指定一个不带缓冲的流,则忽略bufsize参数。如果指定全缓冲或行缓冲,则bufsize可选择地指定一个缓冲区及其长度。如果该流是带缓冲的,而buf是NULL,则标准I/O库将自动地为该流分配适当长度的缓冲区。适当长度指的是由常量BUFSIZ所指定的值。

图5-1列出了这两个函数的动作,以及它们的各个选项。

要了解,如果在一个函数内分配一个自动变量类的标准I/O缓冲区。则从该函数返回之前,必须关闭该流。另外,其些实现将缓冲区的一部分用于存放它自己的
管理操作信息,所以可以存放在缓冲区中的实际数据字节数少于size。一般而言,应由系统选择缓冲区的长度,并自动分配缓冲区。

任何时候,我们都可强制冲洗一个流

1
2
3
#include<stdio.h>
int fflush (FILE *fp);
// 返回值,若成功,返回0,若出错,返回EOF

此函数使该流所有未写的数据都被传送至内核。作为一种特殊情形,如若fp是NULL,则此函数将导致所有输出流被冲洗。

打开流

下列3个函数打开一个标准I/O流。

1
2
3
4
5
#include <stdio.h>
FILE *fopen (const char *restrict pathname, const char *restrict type);
FILE *freopen (const char *restrict pathname, const char *restrict type, FILE *restrict fp);
FILE *fdopen(int fd, const char *type);
// 3个函数的返回值:若成功,返回文件指针;若出错,返回NULL

这3个函数的区别如下。

  1. fopen函数打开路径名为pathname的一个指定的文件。
  2. freopen函数在一个指定的流上打开一个指定的文件,如果该流已经打开,则先关闭该流。若该流已经定向,则使用freopen清除该定向。此函数一般用于将一个指定的文件打开为一个预定义的流:标准输入、标准输出或标准错误。
  3. fdopen函数取一个已有的文件描述符(我们可能从opendupdup2fcntlpipesocketsocketpairaccept函数得到此文件描述符),并使一个标准的I/O流与该描述符相结合。此函数常用于由创建管道和网络通信通道函数返回的描述符,因为这些特殊类型的文件不能用标准I/O函数fopen打开,所以我们必须先调用设备专用函数以获得一个文件描述符,然后用fdopen使一个标准I/O流与该描述符相结合。

fopenfreopen是ISO C的所属部分。而ISO C并不涉及文件描述符,所以仅有POSIX.1具有fdopen

type参数指定对该I/O流的读、写方式,ISO C规定type参数可以有15种不同的值,如表所示。

使用字符b作为type的一部分,这使得标准I/O系统可以区分文本文件和二进制文件。因为UNIX内核并不对这两种文件进行区分,所以在UNIX系统环境下指定字符b作为type的一部分实际上并无作用。

对于fdopentype参数的意义稍有区别。因为该描述符已被打开,所以fdopen为写而打开并不截断该文件。另外,标准I/O追加写方式也不能用于创建该文件,当用追加写类型打开一个文件后,每次写都将数据写到文件的当前尾端处。如果有多个进程用标准I/O追加写方式打开同一文件,那么来自每个进程的数据都将正确地写到文件中。

在涉及多个进程时,为了正确地支持追加写方式,该文件必须用O_APPEND标志打开。在每次写前,做一次lseek操作同样也不能正确工作。

当以读和写类型打开一个文件时(type中+号),具有下列限制。

  • 如果中间没有fflushfseekfsetposrewind,则在输出的后面不能直接跟随输入。
  • 如果中间没有fseekfsetposrewind,或者一个输入操作没有到达文件尾端,则在输入操作之后不能直接跟随输出。

图中列出了打开一个流的6种不同的方式。

注意,在指定wa类型创建一个新文件时,我们无法说明该文件的访问权限位。POSIX.I要求实现使用如下的权限位集来创建文件。

1
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP | S_IROTH | S_IWOTH

除非流引用终端设备,否则按系统默认,流被打开时是全缓冲的。若流引用终端设备,则该流是行缓冲的。一旦打开了流,那么在对该流执行任何操作之前,如果希望,则可使用前节所述的setbufsetvbuf改变缓冲的类型。

调用fclose关闭一个打开的流

1
2
3
#include <stdio.h>
int fclose(FILE *fp);
// 返回值:若成功。返回0;若出错,返回EOF

在该文件被关闭之前,冲洗缓冲中的输出数据。缓冲区中的任何输入数据被丢弃。如果标准I/O库已经为该流自动分配了一个缓冲区,则释放此缓冲区。当一个进程正常终止时,则所有带未写缓冲数据的标准I/O流都被冲洗,所有打开的标准I/O流都被关闭。

读和写流

一旦打开了流,则可在3种不同类型的非格式化I/O中进行选择,对其进行读、写操作。

  1. 每次一个字符的I/O,一次读或写一个字符,如果流是带缓冲的,则标准I/O函数处理所有缓冲。
  2. 每次一行的I/O。如果想要一次读或写一行,则使用fgetsfputs。每行都以一个换行符终止。当调用fgets时,应说明能处理的最大行长。
  3. 直接I/O。freadfwrite函数支持这种类型的I/O。每次I/O操作读或写某种数量的对象,而每个对象具有指定的长度。这两个函数常用于从二进制文件中每次读或写一个结构。

直接I/O也被称为:二进制I/O、一次一个对象I/O、面向记录的I/O或面向结构的I/O。

输入函数

以下3个函数可用于一次读一个字符。

1
2
3
4
5
#include <stdio.h>
int getc(FILE *fp);
int fgetc(FILE *fp);
int getchar(void);
// 3个函数的返回值,若成功,返回下一个字符;若已到达文件尾或出错,返回EOF

函数getchar等同于getc(stdin)。前两个函数的区别是,getc可被实现为宏,而fgetc不能实现为宏。这意味着以下几点。

  1. getc的参数不应当是具有副作用的表达式,因为它可能会被计算多次。
  2. 因为fgetc一定是个函数,所以可以得到其地址。这就允许将fgetc的地址作为一个参数传送给另一个函数。
  3. 调用fgetc所需时间很可能比调用getc要长,因为调用函数所需的时间通常长于调用宏。

这3个函数在返回下一个字符时,将其unsigned char类型转换为int类型。说明为无符号的理由是,如果最高位为1也不会使返回值为负。要求整型返回值的理由是,这样就可以返回所有可能的字符值再加。上一个已出错或已到达文件尾端的指示值。在<stdio.h>中的常量EOF被要求是一个负值,其值经常是-1。这就意味着不能将这3个函数的返回值存放在一个字符变量中,以后还要将这些函数的返回值与常量EOF比较。

注意,不管是出错还是到达文件尾端,这3个函数都返回同样的值。为了区分这两种不同的情况,必须调用ferrorfeof

1
2
3
4
5
#include <stdio.h>
int ferror (FILE *fp);
int feof(FILE *fp);
// 两个函数返回值:若条件为真,返回非0(真);否则。返回0(假)
void clearerr(FILE *fp):

在大多数实现中,为每个流在FILE对象中维护了两个标志;

  • 出错标志:
  • 文件结束标志

调用clearerr可以清除这两个标志。从流中读取数据以后,可以调用ungetc将字符再压送回流中。

1
2
3
#include <stdio.h>
int ungete(int c, FILE *fp);
// 返回值,若成功,返回c。若出错,返回EOF

压送回到流中的字符以后又可从流中读出,但读出字符的顺序与压送回的顺序相反。不能回送EOF。但是当已经到达文件尾端时,仍可以回送一个字符。下次读将返回该字符,再读则返回EOF。之所以能这样做的原因是,一次成功的ungetc调用会清除该流的文件结束标志。

当正在读一个输入流,并进行某种形式的切词或记号切分操作时,会经常用到回送字符操作。如果标准I/O库不提供回送能力,就需将该字符存放到一个我们自己的变量中,并设置一个标志以便判别在下一次需要一个字符时是调用getc,还是从我们自己的变量中取用这个字符,用ungetc压送回字符时,并没有将它们写到底层文件或设备中,而是将它们写回标准I/O库的波缓冲区中。

输出函数

对应于上面所述的每个输入函数都有一个输出函数。

1
2
3
4
5
#include <stdio.h>
int putc(int c, FILE *fp);
int fputc(int c, FILE *fp);
int putchar(int c);
// 3个函数返回值。若成功,返回c;若出错,返回EOF

与输入函数一样,putchar(c)等同于putc(c, stdout)putc可被实现为宏,而fputc不能实现为宏。

每次一行I/O

下面两个函数提供每次输入一行的功能。

1
2
3
4
#include <stdio.h>
char *fgets(char *restrict buf, int n, FILE *restrict fp):
char *gets(char *buf);
// 两个函数返回值,若成功,返回byf,着已到达文件尾端或出错,返回NULL

这两个函数都指定了缓冲区的地址,读入的行将送入其中。gets从标准输入读,而fgets则从指定的流读。对于fgets,必须指定缓冲的长度n。此函数一直读到下一个换行符为止,但是不超过n-1个字符,读入的字符被送入缓冲区。该缓冲区以null字节结尾。如若该行包括最后一个换行符的字符数超过n-1。则fgets只返回一个不完整的行,但是,缓冲区总是以null字节结尾,对fgets的下一次调用会继续读该行。

gets是一个不推荐使用的函数。其问题是调用者在使用gets时不能指定缓冲区的长度。这样就可能造成缓冲区溢出(如若该行长于缓冲区长度),写到缓冲区之后的存储空间中,从而产生不可预料的后果。getsfgets的另一个区别是,gets并不将换行符存入缓冲区中。

虽然ISO C要求提供gets,但请使用fgets,而不要使用gets

fputsputs提供每次输出一行的功能

1
2
3
4
#include <atdio.h>
int fputs(const char *restrict str, FILE *restrict fp);
int puts(const char *str);
// 两个函数返回值,若成功,返回非负值;若出错,返回EOF

函数puts将一个以null字节终止的字符串写到指定的流,尾端的终止符null不写出。注意,这并不一定是每次输出一行,因为字符串不需要换行符作为最后一个非null字节。通常,在null字节之前是一个换行符,但并不要求总是如此。

puts将一个以null字节终止的字符串写到标准输出,终止符不写出。但是,puts随后又将一个换行符写到标准输出。

标准I/O的效率

下面的程序使用getcputc将标准输入复制到标准输出。这两个例程可以实现为宏。

1
2
3
4
5
6
7
8
9
10
#include "apue.h"
int main (void) {
int c;
while ((c = getc(stdin)) != EOF)
if (putc(c, stdout) == EOF)
err_sys ("output error");
if(ferror (stdin))
err_sys("input error");
exit(0);
}

二进制I/O

提供了下列两个函数以执行二进制I/O操作。

1
2
3
4
#include <stdio.h>
size_t fread(void *restrict ptr, size_t size, size_t nobj, FILE *restrict fp);
size_t fwrite(const void *restrict ptr, size_t size, size_t nobj, FILE *restrict fp);
// 两个函数的返回值:读或写的对象数

这些函数有以下两种常见的用法。

读或写一个二进制数组。例如,为了将一个浮点数组的第2~5个元素写至一文件上,可以编写如下程序:

1
2
3
float data[10];
if(fwrite(&data[2], aizeof(float), 4, fp) != 4)
err_sys("fwrite error");

其中,指定size为每个数组元素的长度,nobj为欲写的元素个数。

读或写一个结构。例如,可以编写如下程序;

1
2
3
4
5
6
7
struct {
short count;
long total;
char name[NAMESIZE];
} item;
if (fwrite(&item, sizeof(item), 1, fp) != 1)
err_sys("fwrite error");

其中,指定size为结构的长度,nobj为1 (要写的对象个数),将这两个例子结合起来就可读或写一个结构数组。为了做到这一点,size应当是该结构的sizeofnoby应是该数组中的元素个数。

freadfwrite返回读或写的对象数。对于读,如果出错或到达文件尾端,则此数字可以少于nobj。在这种情况,应调用ferrorfeof以判断究竟是那一种情况。对于写,如果返回值少于所要求的nobj,则出错,使用二进制I/O的基本问题是,它只能用于读在同一系统上已写的数据。

当在一个系统上写的数据,要在另一个系统上进行处理时,这两个函数可能就不能正常工作,其原因是:

  • 在一个结构中,同一成员的偏移最可能随编译程序和系统的不同而不同(由于不同的对齐要求)。
    • 某些编译程序使结构中的各成员紧密包装(这可以节省存储空间,而运行性能则可能有所下降);
    • 或者准确对齐(以便在运行时易于存取结构中的各成员)。
    • 这意味着即使在同一个系统上,一个结构的二进制存放方式也可能因编译程序选项的不同而不同。
  • 用来存储多字节整数和浮点值的二进制格式在不同的系统结构间也可能不同。

定位流

有3种方法定位标准I/O流。

  • ftellfseek函数。这两个函数都假定文件的位置可以存放在一个长整型中。
  • ftellofseeko函数。这两个函数使文件偏移量可以不必一定使用长整型。它们使用off_t数据类型代替了长整型。
  • fgetposfsetpos函数。这两个函数使用一个抽象数据类型fpos_t记录文件的位置。这种数据类型可以根据需要定义为一个足够大的数,用以记录文件位置。

需要移植到非UNIX系统上运行的应用程序应当使用fgetposfsetpos

1
2
3
4
5
6
#include <stdio.h>
long ftell(FILE *fp);
// 返回值:若成功,返回当前文件位置指示;若出错,返回-1L
int fseek(FILE *fp, long offset, int whence);
// 返回值:若成功,返回0;若出错,返回-1
void rewind(FILE *fp);

对于一个二进制文件,其文件位置指示器是从文件起始位置开始度量,并以字节为度量单位的。ftell用于二进制文件时,其返回值就是这种字节位置。为了用fseek定位一个二进制文件,必须指定一个字节offset,以及解释这种偏移量的方式。whence的值与lseek函数的相同:SEEK_SET表示从文件的起始位置开始,SEEK_CUR表示从当前文件位置开始,SEEK_END表示从文件的尾端开始。

对于文本文件,它们的文件当前位置可能不以简单的字节偏移量来度量。这主要也是在非UNIX系统中,它们可能以不同的格式存放文本文件。为了定位一个文本文件,whence一定要是SEEK_SET。而且offset只能有两种值,0(后退到文件的起始位置),或是对该文件的ftell所返回的值。使用rewind函数也可将一个流设置到文件的起始位置。

除了偏移量的类型是off_t而非long以外,ftello函数与ftell相同,fseeko函数与fseek相同。

1
2
3
4
5
#include <atdio.h>
off_t ftello(FILE *fp);
// 返回值:若成功,返回当前文件位置,若出错,返回(off_t)-1
int fseeko(FILE *fp, off_t offset, int whence);
// 返回值,若成功,返回0,若出错,返回-1

实现可将off_t类型定义为长于32位。

fgetposfsetpos两个函数是ISO C标准引入的。

1
2
3
4
#include <stdio.h>
int fgetpos(FILE *restrict fp, fpos_e *restrict pos);
int fsetpos(FILE *fp, const fpos_t *pos);
// 两个函数返回值。若成功。返回0:若出错,返回非0

fgetpos将文件位置指示器的当前值存入由pos指向的对象中。在以后调用fsetpos时,可以使用此值将流重新定位至该位置

格式化I/O

格式化输出

格式化输出是由5个printf函数来处理的

1
2
3
4
5
6
7
8
9
#include<stdio.h>
int printf (const char *restrict format, ...);
int fprintf(FILE *restzict fp, const char *restrict format, ...);
int dprintf(int fd, const char *restrict format, ...);
// 3个函数返回值:若成功,返回输出字符数:若输出出错,返回负值
int sprintf(char *restrict buf, const char *restrict format, ...);
// 返回值,若成功,返回存入数组的字符数;若编码出错,返回负值
int snprintr(char *restrict buf, size_t m, const char *restrict format, ...);
// 返回值,若缓冲区足够大,返回将要存入数组的字符数;若编码出错,返回负值

printf将格式化数据写到标准输出,fprintf写至指定的流,dprintf写至指定的文件描述符,sprintf将格式化的字符送入数组buf中。sprintf在该数组的尾端自动加一个null字节,但该字符不包括在返回值中。

注意,sprintf函数可能会造成由buf指向的缓冲区的溢出。为了解决这种缓冲区溢出问题,引入了snprintf函数。在该函数中,缓冲区长度是一个显式参数,超过缓冲区尾的所有字符都被丢弃。如果缓冲区足够大,snprintf函数就会返回写入缓冲区的字符数。与sprintf相同,该返回值不包括结尾的null字节。若snprintf函数返回小于缓冲区长度n的正值,那么没有截断输出。若发生了一个编码的错误,snprintf返回负值。

虽然dprintf不处理文件指针,但我们仍然把它包括在处理格式化输出的函数中。注意,使用dprintf不需要调用fdopen将文件描述符转换为文件指针。

格式说明控制其余参数如何编写,以后又如何显示。每个参数按照转换说明编写,转换说明以百分号%开始,除转换说明外,格式字符串中的其他字符将按原样,不经任何修改被复制输出。转换说明有4个可选择的部分,下面将它们都示于方括号中:

1
%[flags] [fldwidth] [precision] [lenmodifier]convtype

标志 说明
(撇号)将整数按千位分组字符
- 在字段内左对齐输出
+ 总是显示带符号转换的正负号
(空格) 如果第一个字符不是正负号,则在其前面加上一个空格
# 指定另一种转换形式(例如。对于十六进制格式,加0x前缀
0 添加前号0(而非空格)进行填充

fldwidth说明最小字段宽度。转换后参数字符数若小于宽度,则多余字符位置用空格填充。字段宽度是一个非负十进制数,或是一个星号*precision说明整型转换后最少输出数字位数、浮点数转换后小数点后的最少位数、 字符串转换后最大字节数。精度是一个点.,其后跟随一个可选的非负十进制数或一个星号*

宽度和精度字段两者皆可为*。此时,一个整型参数指定宽度或精度的值。该整型参数正好位于被转换的参数之前。lenmodifier说明参数长度。其可能的值示于表中。

长度修饰符 说明
hh 将相应的参数按signed或unsigned char类型输出
h 将相应的参数按signed成unelgned short类型输出
l 将相应的参数按signed或unsigned long或宽字符类型输出
ll 将相应的参数按signed或unsigned long long类型输出
j intmax_t或uintmax_t
z size_t
t ptrdiff_t
L long double

convtype不是可选的。它控制如何解释参数。下表中列出了各种转换类型字符。

转换类型 说明
d、i 有符号十进制
o 无符号八进制
u 无符号十进制
x、X 无符号十六进制
f、F 双精度浮点数
e、E 指数格式双精度浮点数
g、G 根据转换后的值解释为f、F、e或E
a、A 十六进制指数格式双精度浮点数
c 字符(若带长度修饰符l,为宽字符)
s 字符串(若带长度修饰符l,为宽字符)
p 指向void的指针
n 到目前为止,此printf调用输出的字符的数目将被写入到指针所指向的带符号整型中
% 一个%字符
C 宽字符(XSI扩展,等效于lc)
S 宽字符串(XSI扩展,等效于ls)

下列5种printf族的变体类似于上面的5种,但是可变参数表(…)替换成了arg。

1
2
3
4
5
6
7
8
9
10
#include <stdarg.h>
#include <atdio.h>
int vprintf (const char *restrict format, va_list arg);
int vfprintf (FILE *restrict fp, const char *restrict format, va_list arg);
int vdprintf(int fd, const char *restrict format, va_list arg);
// 所有3个函数返回值,若成功,返回输出字符数;若输出出错,返回负值
int vsprintf(char *restrict buf, const char *restrict format, va_list arg);
// 函数返回值。若成功,返回存入数组的字符数,若编码出错,返回负值
int vsnprintf (char *restrict buf, size_t m, const char *restrict format, va_list arg);
// 函数返回值。若缓冲区足够大,返回存入数组的字特数:若编码出错,返回负值

格式化输入

执行格式化输入处理的是3个scanf函数。

1
2
3
4
5
#include <stdio.h>
int scanf(const char *restrict format, ...);
int fscanf(FILE *restrict fp, const char *restrict format, ...);
int sscanf(const char *restrict buf, const char *restrict format, ...);
// 3个函数返回值,赋值的输入项数:若输入出错或在任一转换前已到达文件尾墙,返回EOF

scanf族用于分析输入字符串,并将字符序列转换成指定类型的变量。在格式之后的各参数包含了变量的地址,用转换结果对这些变量赋值。

格式说明控制如何转换参数,以便对它们赋值。转换说明以*字符开始。除转换说明和空白字符外,格式字符串中的其他字符必须与输入匹配。若有一个字符不匹配,则停止后续处理,不再读输入的其余部分。

一个转换说明有3个可选择的部分,下面将它们都示于方括号中:

1
%[*| [fldwidth] [m] [lenmodifier)convtype

可选择的星号*用于抑制转换。按照转换说明的其余部分对输入进行转换,但转换结果并不存放在参数中。

fldwidth说明最大宽度(即最大字符数)。lenmodifier说明要用转换结果赋值的参数大小。由printf函数族支持的长度修饰符同样得到scanf族函数的支持。

convtype字段类似于printf族的转换类型字段,但两者之间还有些差别。一个差别是,作为一种选项,输入中带符号的可赋予无符号类型。例如,输入流中的-1可被转换成4294967295赋予无符号整型变量。

在字段宽度和长度修饰符之间的可选项m是赋值分配符。它可以用于%c%s以及%[转换符,迫使内存缓冲区分配空间以接纳转换字符串。在这种情况下,相关的参数必须是指针地址,分配的缓冲区地址必须复制给该指针。如果调用成功,该缓冲区不再使用时,由调用者负责通过调用free函数来释放该缓冲区。

printf族相同,scanf族也使用由<stdarg.h>说明的可变长度参数表。

1
2
3
4
5
6
#include <stdarg.h>
#include <stdio.h>
int vscanf(const char *restrict format, va_list arg);
int vfscant(FILE *restrict fp, const char *restrict format, va_list arg);
int vsscanf(const char *restrict buf, const char *restrict format, va_list arg);
// 3个函数返回值,指定的输入项目数;若输入出错或在任一转换前文件结束。返回EOF

实现细节

在UNIX中,标准I/O库最终都要调用第3章中说明的I/O例程,每个标准I/O流都有一个与其相关联的文件描述符,可以对一个流调用fileno函数以获得其描述符。

1
2
3
#include <stdio.h>
int fileno(FILE *fp);
// 返回值:与该流相关联的文件描述符

如果要调用dupfcntl等函数,则需要此函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#include "apue.h"

void pr_stdio(const char *, FILE *);
int is_unbuffered(FILE *);
int is_linebuffered(FILE *);
int buffer_size(FILE *);

int
main(void)
{
FILE *fp;

fputs("enter any character\n", stdout);
if (getchar() == EOF)
err_sys("getchar error");
fputs("one line to standard error\n", stderr);

pr_stdio("stdin", stdin);
pr_stdio("stdout", stdout);
pr_stdio("stderr", stderr);

if ((fp = fopen("/etc/passwd", "r")) == NULL)
err_sys("fopen error");
if (getc(fp) == EOF)
err_sys("getc error");
pr_stdio("/etc/passwd", fp);
exit(0);
}

void
pr_stdio(const char *name, FILE *fp)
{
printf("stream = %s, ", name);
if (is_unbuffered(fp))
printf("unbuffered");
else if (is_linebuffered(fp))
printf("line buffered");
else /* if neither of above */
printf("fully buffered");
printf(", buffer size = %d\n", buffer_size(fp));
}

/*
* The following is nonportable.
*/

#if defined(_IO_UNBUFFERED)

int
is_unbuffered(FILE *fp)
{
return(fp->_flags & _IO_UNBUFFERED);
}

int
is_linebuffered(FILE *fp)
{
return(fp->_flags & _IO_LINE_BUF);
}

int
buffer_size(FILE *fp)
{
return(fp->_IO_buf_end - fp->_IO_buf_base);
}

#elif defined(__SNBF)

int
is_unbuffered(FILE *fp)
{
return(fp->_flags & __SNBF);
}

int
is_linebuffered(FILE *fp)
{
return(fp->_flags & __SLBF);
}

int
buffer_size(FILE *fp)
{
return(fp->_bf._size);
}

#elif defined(_IONBF)

#ifdef _LP64
#define _flag __pad[4]
#define _ptr __pad[1]
#define _base __pad[2]
#endif

int
is_unbuffered(FILE *fp)
{
return(fp->_flag & _IONBF);
}

int
is_linebuffered(FILE *fp)
{
return(fp->_flag & _IOLBF);
}

int
buffer_size(FILE *fp)
{
#ifdef _LP64
return(fp->_base - fp->_ptr);
#else
return(BUFSIZ); /* just a guess */
#endif
}

#else

#error unknown stdio implementation!

#endif

注意,在打印缓冲状态信息之前,先对每个流执行I/O操作,第一个I/O操作通常就造成为该流分配缓冲区。

如果运行程序两次,一次使3个标准流与终端相连接,另一次使它们重定向到普通文件,则所得结果是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ ./a.out   # stdin, atdout和stderr都连至终端
enter any character

one line to standard error
stream = stdin, line buffered, buffer size = 1024
stream = atdout, line buffered, butter size = 1024
stream = stderr, unbuffered, buffer size = 1
stream = /etc/passwd, fully butfered, buffer 8120 = 4096
$ ./a.out < /etc/group > std.out 2> atd.ext
# 3个流都重定向,再次运行该程序
$ cat std.err
one line to standard error
$ cat std.out
enter any character
stream = stdin, fully buffered, buffer size = 4096
stream = stdout, fully buffered, buffer size = 4096
strean = stderr, unbuffered, buffer size = 1
stream = /etc/passwd, fully buffered, buffer size = 4096

从中可见,该系统的默认是:当标准输入、输出连至终端时,它们是行缓冲的。行缓冲的长度是1024字节。注意,这并没有将输入、输出的行长限制为1024字节,这只是缓冲区的长度。如果要将2048字节的行写到标准输出,则要进行两次write系统调用。当将这两个流重新定向到普通文件时,它们就变成是全缓冲的,其缓冲区长度是该文件系统优先选用的I/O长度(从stat结构中得到的st_blksize值)。从中也可看到,标准错误如它所应该的那样是不带缓冲的,而普通文件按系统默认是全缓冲的。

临时文件

ISO C标准I/O库提供了两个函数以帮助创建临时文件。

1
2
3
4
5
#include <stdio.h>
char *tmpnam(char *ptr);
// 返回值,指向唯一路径名的指针
FILE *tmptile(void);
// 返回值:若成功,返回文件指针;若出错,返回NULL

tmpnam函数产生一个与现有文件名不同的一个有效路径名字符串。每次调用它时,都产生一个不同的路径名,最多调用次数是TMP_MAXTMP_MAX定义在<stdio.h>中。虽然ISO C定义了TMP_MAX,但该标准只要求其值至少应为25。

ptr是NULL,则所产生的路径名存放在一个静态区中,指向该静态区的指针作为函数值返回。后续调用tmpnam时,会重写该静态区(这意味着,如果我们调用此函数多次,而且想保存路径名,则我们应当保存该路径名的副本,而不是指针的副本)。如若ptr不是NULL,则认为它应该是指向长度至少是L_tmpnam个字符的数组(常量L_tmpnam定义在头文件<stdio.h>中)。所产生的路径名存放在该数组中,pr也作为函数值返回。

tmpfile创建一个临时二进制文件(类型wb+),在关闭该文件或程序结束时将自动删除这种文件。注意,UNIX对二进制文件不进行特殊区分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "apue.h"
int main (void) {
char name[L_tmpnam], line[MAX_LINE];
FILE *fp;
printf("%s\n", tmpnam(NULL) );
/* tiret temp nane */
tmpnam(name);
/*second temp name */
printf ("%s\n", name);
if ((fp = tmpfile()) == NULL)
/*create temp file */
err_sys("tmpfile error");
fputs("one line of outputin", fp);/*write to temp file */
rewind(tp);
if (fgets(line, sizeof(line), fp) == NULL)
/* then read it back */
err_sys ("fgets error");
fputs(line, stdout);
/* print the line we wrote */
exit(0);
}

tmpfile函数经常使用的标准UNIX技术是先调用tmpnam产生一个唯一的路径名,然后,用该路径名创建一个文件,并立即unlink它。对一个文件解除链接并不删除其内容,关闭该文件时才删除其内容。而关闭文件可以是显式的,也可以在程序终止时自动进行。

Single UNIX Specification为处理临时文件定义了另外两个函数: mkdtempmkstemp,它们是XSI的扩展部分。

1
2
3
4
5
#include <atd11b.h>
char *mkdtemp(char *template);
// 返回值:若成功,返回指向目录名的指针;若出错,返回NULL
int mkstemp(char *template);
// 返回值,若成功,返回文件描述符,若出错,返回-1

mkdtemp函数创建了一个目录,该目录有一个唯一的名字;mkstemp函数创建了一个文件,该文件有一个唯一的名字。名字是通过template字符串进行选择的。这个字符串是后6位设置为xxxxxx的路径名。函数将这些占位符替换成不同的字符来构建一个唯一的路径名。如果成功的话,这两个函数将修改template字符串反映临时文件的名字。

mkdtemp函数创建的目录使用下列访问权限位集:S_IRUSR | S_IWUSR | S_IXUSR。注意,调用进程的文件模式创建屏蔽字可以进一步限制这些权限。如果目录创建成功,mkdtemp返回新目录的名字。

mkstemp函数以唯一的名字创建一个普通文件并,且打开该文件,该函数返回的文件描述符以读写方式打开。由mkstemp创建的文件使用访问权限位S_IRUSR | S_IWUSR。与temptile不同,mkstemp创建的临时文件并不会自动删除。如果希望从文件系统命名空间中删除该文件,必须自己对它解除链接。

使用tmpnamtempnam至少有一个缺点:在返回唯一的路径名和用该名字创建文件之间存在一个时间窗口,在这个时间窗口中,另一进程可以用相间的名字创建文件。因此应该使用tmpfilemkstemp函数,因为它们不存在这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "apue.h"
#include <errno.h>

void make_temp(char *template);

int
main()
{
char good_template[] = "/tmp/dirXXXXXX"; /* right way */
char *bad_template = "/tmp/dirXXXXXX"; /* wrong way*/

printf("trying to create first temp file...\n");
make_temp(good_template);
printf("trying to create second temp file...\n");
make_temp(bad_template);
exit(0);
}

void
make_temp(char *template)
{
int fd;
struct stat sbuf;

if ((fd = mkstemp(template)) < 0)
err_sys("can't create temp file");
printf("temp name = %s\n", template);
close(fd);
if (stat(template, &sbuf) < 0) {
if (errno == ENOENT)
printf("file doesn't exist\n");
else
err_sys("stat failed");
} else {
printf("file exists\n");
unlink(template);
}
}

运行程序,得到:

1
2
3
4
5
6
$ ./a.out
trying to create tirmt temp file...
temp name = /tmp/dirUmBT7h
file exists
trying to create second temp file..
Segmentation fault

两个模板字符串声明方式的不同带来了不同的运行结果。对于第一个模板,因为使用了数组,名字是在栈上分配的。但第二种情况使用的是指针,在这种情况下,只有指针自身驻留在栈上。编译器把字符串存放在可执行文件的只读段,当mkstemp函数试图修改字符申时,出现了段错误。

内存流

我们已经看到,标准I/O库把数据缓存在内存中,因此每次一字符和每次一行的I/O更有效。我们也可以通过调用setbufsetvbuf函数让I/O库使用我们自己的缓冲区。在SUSv4中支持了内存流。这就是标准I/O流,虽然仍使用FILE指针进行访问,但其实并没有底层文件。所有的I/O都是通过在缓冲区与主存之间来回传送字节来完成的。即便这些流看起来像文件流,它们的某些特征使其更适用于字符串操作。

有3个函数可用于内存流的创建,第一个是fmemopen函数。

1
2
3
#include <stdio.h>
FILE *fmemopen (void *restrict buf, size_t size, const char *restrict type);
// 返回值:若成功,返回流指针,若错误,返回NULL

fmemopen函数允许调用者提供缓冲区用于内存流:buf参数指向缓冲区的开始位置,size参数指定了缓冲区大小的字节数。如果buf参数为空, fmemopen函数分配size字节数的缓冲区。在这种情况下,当流关闭时缓冲区会被释放。

type参数控制如何使用流。type可能的取值如表。

type 说明
r或rb 为读而打开
w或wb 为写而打开
a或ab 追加:为在第一个null字节处写而打开
r+或r+b或rb+ 为读和写打开
w+或w+b或wb+ 把文件截断至0长,为读和写而打开
a+或a+b或ab+ 追加:为在第一个null字节处读和写打开

注意,这些取值对应于基于文件的标准I/O流的type参数取值,但其中有些微小差别。第一,无论何时以追加写方式打开内存流时,当前文件位置设为缓冲区中的第一个null字节。如果缓冲区中不存在null字节,则当前位置就设为缓冲区结尾的后一个字节。当流并不是以追加写方式打开时,当前位置设为缓冲区的开始位置。因为追加写模式通过第一个null字节确定数据的尾端,内存流并不适合存储二进制数据(二进制数据在数据尾端之前就可能包含多个null字节)。

第二,如果buf参数是一个null指针,打开流进行读或者写都没有任何意义。因为在这种情况下缓冲区是通过fmemopen进行分配的,没有办法找到缓冲区的地址,只写方式打开流意味着无法读取已写入的数据,同样,以读方式打开流意味着只能读取那些我们无法写入的缓冲区中的数据。

第三,任何时候需要增加流缓冲区中数据量以及调用fclosefflushfseekfseeko以及fsetpos时都会在当前位置写入一个null字节。

看一下对内存流的写入是如何在我们自己提供的缓冲区上进行操作的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "apue.h"

#define BSZ 48

int
main()
{
FILE *fp;
char buf[BSZ];

memset(buf, 'a', BSZ-2);
buf[BSZ-2] = '\0';
buf[BSZ-1] = 'X';
if ((fp = fmemopen(buf, BSZ, "w+")) == NULL)
err_sys("fmemopen failed");
printf("initial buffer contents: %s\n", buf);
fprintf(fp, "hello, world");
printf("before flush: %s\n", buf);
fflush(fp);
printf("after fflush: %s\n", buf);
printf("len of string in buf = %ld\n", (long)strlen(buf));

memset(buf, 'b', BSZ-2);
buf[BSZ-2] = '\0';
buf[BSZ-1] = 'X';
fprintf(fp, "hello, world");
fseek(fp, 0, SEEK_SET);
printf("after fseek: %s\n", buf);
printf("len of string in buf = %ld\n", (long)strlen(buf));

memset(buf, 'c', BSZ-2);
buf[BSZ-2] = '\0';
buf[BSZ-1] = 'X';
fprintf(fp, "hello, world");
fclose(fp);
printf("after fclose: %s\n", buf);
printf("len of string in buf = %ld\n", (long)strlen(buf));

return(0);
}

用于创建内存流的其他两个函数分别是open_memstreamopen_wmemstream

1
2
3
4
5
6
#include <stdio.h>
FILE *open_memstream(char **bufp, size_t *sizep);

#include <wchar.h>
FILE *open_wmemstream(wchar_t **bufp, size_t *sizep);
// 两个函数的返回值:若成功,返回流指针;若出错,返回NULL。

open_memstream函数创建的流是面向字节的,open_wmemstream函数创建的流是面向宽子节的。这两个函数与fmemopen的不同在于:

  • 创建的流只能打开;
  • 不能指定自己的缓冲区,但可以分别通过bufpsizep参数访问缓冲区地址和大小;
  • 关闭流后需要自行释放缓冲区;
  • 对流添加子节会增加缓冲区大小。

但是在对缓冲区地址和大小使用必须遵循:

  • 缓冲区地址和长度只有在调用fclosefflush后才有用;
  • 这些值只有在下一次流写入或调用fclose前才有用。

因为缓冲区可以增长,可能需要重新分配,所以缓冲区的内存地址在下一次调用fclosefflush时会改变。

系统数据文件和信息

引言

UNIX系统的正常运作需要使用大量与系统有关的数据文件,例如,口令文件/etc/passwd和组文件/etc/group就是经常被多个程序频繁使用的两个文件。由于历史原因,这些数据文件都是ASCII文本文件,并且使用标准I/O库读这些文件。但是,对于较大的系统,顺序扫描口令文件很花费时间,我们需要能够以非ASCII文本格式存放这些文件,但仍向使用其他文件格式的应用程序提供接口。

口令文件

UNIX系统口令文件包含了表中所示的各字段,这些字段包含在<pwd.h>中定义的passwd结构中。注意,POSIX.1只指定passwd结构包含的10个字段中的5个。大多数平台至少支持其中7个字段。

说明 struct passwd成员
用户名 char *pw_name
加密口令 char *pw_passed
数值用户ID uid_t pw_uid
数值组ID gid_t pw_gid
注释字段 char *pw_gecos
初始工作日录 char *pw_dir
初始shell char *pw_shell
用户访问类 char *pw_class
下次更改口令时间 time_t pw_change
账户有效期时间 time_t pw_expire

口令文件是/etc/passwd,而且是一个ASCII文件。每一行包含各字段,字段之间用冒号分隔。例如,在Linux中,该文件中可能有下列4行:

1
2
3
4
root:x:0:0:root:/root:/bin/bash
squid:x:23:23::/vax/spool/squid:/dev/null
nobody:x:65534:65534:Nobody:/home:/bin/sh
sar:x:205:105:Stephen Rago:/home/sar:/bin/bash

关于这些登录项,请注意下列各点:

  • 通常有一个用户名为root的登录项,其用户ID是0(超级用户)。
  • 加密口令字段包含了一个占位符。较早期的UNIX系统版本中,该字段存放加密口令字。将加密口令字存放在一个人人可读的文件中是一 个安全性漏洞,所以现在将加密口令字存放在另一个文件中。
  • 口令文件项中的某些字段可能是空。如果加密口令字段为空,这通常就意味着该用户没有口令。squid登录项有一空白字段:注释字段。空白注释字段不产生任何影响。
  • shell字段包含了一个可执行程序名,它被用作该用户的登录shell。若该字段为空,则取系统默认值,通常是/bin/sh。注意,squid登录项的该字段为/dev/nu11。显然,这是一个设备,不是可执行文件,将其用于此处的目的是,阻止任何人以用户squid的名义登录到该系统。
  • 为了阻止一个特定用户登录系统,替代方法是,将/bin/false用作登录shell。它简单地以不成功(非0)状态终止,该shell将此种终止状态判断为假。另一种常见方法是,用/bin/true禁止一个账户。它所做的一切是以成功(0)状态终止。某些系统提供nologin命令,它打印可定制的出错信息,然后以非0状态终止
  • 使用nobody用户名的一个目的是,使任何人都可登录至系统,但其用户ID(65534)和组ID(65534)不提供任何特权。该用户ID和组ID只能访问人人皆可读、写的文件。
  • 提供finger()命令的某些UNIX系统支持注释字段中的附加信息。其中,各部分之间都用逗号分隔:用户姓名、办公室地点、办公室电话号码以及家庭电话号码等。另外,如果注释字段中的用户姓名是一个&,则它被替换为登录名。例如,可以有下列记录:
1
sar:x:205:105:Steve Rago, SF 5-121, 555-1111, 555-2222:/home/sar:/bin/sh

使用finger命令就可打印Steve Rago的有关信息。

1
2
3
4
5
6
$ finger -p sar
Login: sar
Name: Steve Rago
Directory: /home/sar
shell: /bin/sh
Office: SF 5-121, 555-1111

某些系统提供了vipw命令,允许管理员使用该命令编辑口令文件。vipw命令串行化地更改口令文件,并且确保它所做的更改与其他相关文件保持一致。

POSIX.1定义了两个获取口令文件项的函数。在给出用户登录名或数值用户ID后,这两个函数就能查看相关项:

1
2
3
4
#include <pwd.h>
struct passwd *getpwuid(uid_t uid);
struct passwd *getpwnam(const char *name);
// 两个函数返回值。若成功。返回指针,若出错,返回NULL

getpwuid函数由ls(1)程序使用,它将inode节点中的数字用户ID映射为用户登录名。在键入登录名时,getpwnam函数由login(1)程序使用。

这两个函数都返回一个指向passwd结构的指针,该结构已由这两个函数在执行时填入信息。passwd结构通常是函数内部的静态变量,只要调用任一相关函数,其内容就会被重写。

如果要查看的只是登录名或用户ID,那么这两个POSIX.1函数能满足要求,但是也有些程序要查看整个口令文件。下列3个函数则可用于此种目的。

1
2
3
4
5
#include <pwd.h>
struct passwd *getpwent(void);
void setpwent (void);
// 返回值,着成功,返回指针,着出错或到达文件尾端,返回NULL
void endpwent (void);

调用getpwent时,它返回口令文件中的下一个记录项。它返回一个由它填写好的passwd结构的指针。每次调用此函数时都重写该结构。在第一次调用该函数时,它打开它所使用的各个文件。在使用本函数时,对口令文件中各个记录项的安排顺序并无要求。

函数setpwent反绕它所使用的文件,endpwent则关闭这些文件。在使用getpwent查看完口令文件后,一定要调用endpwent关闭这些文件。getpwent知道什么时间应当打开它所使用的文件(第一次被调用时),但是它并不知道何时关闭这些文件。

程序给出了getpwnam函数的一个实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <pwd.h>
#include <stddef.h>
#include <string.h>
struct passwd *
getpwnam (const char *name) {
struct passed *ptr;
setpwent();
while ((ptr = getpwent()) != NULL)
if (strcmp(name, ptr->pw_name) == 0)
break;
/* found a match */
endpwent ();
return(ptr);
/* ptr is NULL if no natch found */

在函数开始处调用setpwent是自我保护性的措施,以便确保如果调用者在此之前已经调用getpwent打开了有关文件情况下,反绕有关文件使它们定位到文件开始处。getpwnamgetpwuid完成后不应使有关文件仍处于打开状态,所以应调用endpwent关闭它们。

阴影口令

加密口令是经单向加密算法处理过的用户口令副本。因为此算法是单向的,所以不能从加密口令猜测到原来的口令。对于一个加密口令,找不到一种算法可以将其反变换到明文口令。但是可以对口令进行猜测,将猜测的口令经单向算法变换成加密形式,然后将其与用户的加密口令相比较。

某些系统将加密口令存放在另一个通常称为阴影口令(shadow password)的文件中。该文件至少要包含用户名和加密口令,与该口令相关的其他信息也可存放在该文件中。

说明 struct spwd成员
用户登录名 char *op_namp
加密口令 char *sp_pwdp
上次更改口令以来经过的时间 int sp_lstchg
经多少天后允许更改 int sp_min
要求更改尚余天数 int sp_max
超期警告天数 int sp_warn
账户不活动之前尚余天数 int sp_inact
账户超期天数 int sp_expire
保留 unsigned int sp_flag

只有用户登录名和加密口令这两个字段是必须的。其他的字段控制口令更改的频率,或者说口令的衰老以及账户仍然处于活动状态的时间。

阴影口令文件不应是一 般用户可以读取的。仅有少数几个程序需要访问加密口令,如login(1)passwd(1),这些程序常常是设置用户ID为root的程序。有了阴影口令后,普通口令文件/etc/passwd可由各用户自由读取。

有另一组函数可用于访问阴影口令文件。

1
2
3
4
5
6
#include <shadow.h>
struct sped *getspnan(const char *name);
struct spwd *getspent (void);
// 两个函数返回值,若成功,返回指针;若出错,返回NULL
void setspent (void);
void endspent (void);

组文件

UNIX组文件包含了所示字段。这些字段包含在<grp.h>中所定义的group结构中。

说明 struct group成员
组名 char *gr_name
加密口令 char *qr_passwd
数值组ID int qr_gid
指向各用户名指针的数组 char **gr_mem

字段gr_mem是一个指针数组,其中每个指针指向一个属于该组的用户名。该数组以null指针结尾。

可以用下列两个由POSIX.1定义的函数来查看组名或数值组ID.

1
2
3
4
#include <grp.h>
struct group *getgrgid(gid_t gid);
struct group *getgrnam(const char *name);
// 两个函数返回值,若成功,返回指针,若出错,返回NULL

如同对口令文件进行操作的函数一样,这两个函数通常也返回指向一个静态变量的指针,在每次调用时都重写该静态变量。

如果需要搜索整个组文件,则须使用另外几个函数。下列3个函数类似于针对口令文件的3个函数。

1
2
3
4
5
#include <grp.h>
struct group *getgrent (void);
// 返回值:若成功,返回指针;若出错或到达文件尾,返回NULL
void setgrent (void);
void endgrent (void),

setgrent函数打开组文件(如若它尚末被打开)并反绕它。getgrent函数从组文件中读下一个记录,如若该文件尚未打开,则先打开它。endgrent函数关闭组文件。

附属组ID

每个用户任何时候都只属于一个组。当用户登录时,系统就按口令文件记录项中的数值组ID,赋给他实际组ID,可以在任何时候执行newgrp(1)以更改组ID,如果newgrp命令执行成功,则实际组ID就更改为新的组ID,它将被用于后续的文件访问权限检查。执行不带任何参数的newgrp,则可返回到原来的组。

BSD引入了附属组ID(supplementary group ID)的概念。我们不仅可以属于口令文件记录项中组ID所对应的组,也可属于多至16个另外的组。文件访问权限检查相应被修改为:不仅将进程的有效组ID与文件的组D相比较,而且也将所有附属组ID与文件的组ID进行比较。

附属组ID是POSIX.1要求的特性。常量NGROUPS_MAX规定了附属组ID的数量,常用值是16。

使用附属组ID的优点是不必再显式地经常更改组。一个用户会参与多个项目,因此也就要同时属于多个组,此类情况是常有的。为了获取和设置附属组ID,提供了下列3个函数。

1
2
3
4
5
6
7
8
#include <unistd.h>
int getgroups (int gidsetize, gid_t grouplist[]);
// 返回值:若成功,返回附属组ID数量;若出错,返回-1
#include <grp.h> /* on Linux */
#include <unistd.h> /* on FreeBSD, Mac os x, and Solaris */
int setgroups (int ngroups, const gid_t grouplist[]);
int initgroups (const char *weemame, gid_t bassgid);
// 两个函数的返回值:若成功。返回0;若出错,返回-1

getgroups将进程所属用户的各附属组ID填写到数组grouplist中,填写入该数组的附属组ID数最多为gidsetsize个。实际填写到数组中的附属组ID数由函数返回。作为一种特殊情况,如若gidsetsize为0,则函数只返回附属组ID数,而对数组grouplist则不做修改。

setgroups可由超级用户调用以便为调用进程设置附属组ID表。grouplist是组ID数组,而ngroups说明了数组中的元素数。ngroups的值不能大于NGROUPS_MAX。通常,只有initgroups函数调用setgroupsinitgroups读整个组文件,然后对username确定其组的成员关系。然后,它调用setgroups,以便为该用户初始化附属组ID表。因为initgroups要调用setgroups,所以只有超级用户才能调用initgroups。除了在组文件中找到username是成员的所有组,initgroups也在附属组ID表中包括了basegidbasegidusername在口令文件中的组ID。

其他数据文件

至此仅讨论了两个系统数据文件——口令文件和组文件。在日常操作中,UNIX系统还使用很多其他文件。记录各网络服务器所提供服务的数据文件(/etc/services),记录协议信息的数据文件(/etc/protocols),记录网络信息的数据文件(/etc/networks)。

对于每个数据文件至少有3个函数:

  • get函数:读下一个记录,如果需要,还会打开该文件。此种函数通常返回指向一个结构的指针。当已达到文件尾端时返回空指针。大多数get函数返回指向一个静态存储类结构的指针,如果要保存其内容,则需复制它
  • set函数:打开相应数据文件(如果尚末打开),然后反绕该文件。如果希望在相应文件起始处开始处理,则调用此函数
  • end函数:关闭相应数据文件。如前所述,在结束了对相应数据文件的读、写操作后,总应调用此函数以关闭所有相关文件

另外,如果数据文件支持某种形式的键搜索,则也提供搜索具有指定键的记录的例程。例如,对于口令文件,提供了两个按键进行搜索的程序:getpwnam寻找具有指定用户名的记录;getpwuid寻找具有指定用户ID的记录。

下表中列出了一些这样的例程,这些都是UNIX常用的。在表中列出了针对口令文件和组文件的函数。表中也列出了一些与网络有关的函数。对于表中列出的所有
数据文件都有get、set和end函数

登录账户记录

大多数UNIX系统都提供下列两个数据文件:utmp文件记录当前登录到系统的各个用户;wtmp文件跟踪各个登录和注销事件。在V7中,每次写入这两个文件中的是包含下列结构的一个二进制记录:

1
2
3
4
5
6
struct utmp {
char ut_line[8], /* tty line: "ttyho", "ttydo", "ttypo", ... */
char ut_name[8]: /* login nane */
long ut_time;
/* seconds since Epoch */
};

登录时,login程序填写此类型结构, 然后将其写入到utmp文件中,同时也将其添写到wtmp文件中。注销时,init进程将utmp文件中相应的记录擦除(每个字节都填以null字节),并将一个新记录添写到wtmp文件中。在wtmp文件的注销记录中,ut_name字段清除为0。在系统再启动时,以及更改系统时间和日期的前后,都在wtmp文件中追加写特殊的记录项。who(1)程序读取utmp文件,并以可读格式打印其内容,

系统标识

POSIX.1定义了uname函数,它返回与主机和操作系统有关的信息。

1
2
3
#include <sys/utsname.h>
int uname(struct utsname *name);
// 返回值。着成功,返回非负值。着出错,返回一

通过该函数的参数向其传递一个utsname结构的地址,然后该函数填写此结构。POSIX.1只定义了该结构中最少需提供的字段(它们都是字符数组),而每个数组的长度则由实现确定。某些实现在该结构中提供了另外一些字段。

1
2
3
4
5
6
7
struct utsname {
char sysname[]; /* name of the operating system */
char nodename[]; /* name of this node */
char release[]; /* current release of operating system */
char version[]; /* current version of this release */
char machine[]; /* name of hardware type */
};

时间和日期例程

由UNIX内核提供的基本时间服务是计算自协调世界时(Coordinated Universal Time,UCT)公元1970年1月1日00:00:00这一 特定时间以来经过的秒数。这种秒数是以数据类型time_t表示的,我们称它们为日历时间。日历时间包括时间和日期。UNIX在这方面与其他操作系统的区别是:

  1. 以协调统一时间而非本地时间计时;
  2. 可自动进行转换,如变换到夏令时;
  3. 将时间和日期作为一个量值保存。

time函数返回当前时间和日期。

1
2
3
#include <tine.h>
time_t time(time_t *calptr);
// 返回值。若成功,返回时间值,若出错,返回-1

时间值作为函数值返回。如果参数非空,则时间值也存放在由calptr指向的单元内。

POSXI.1的实时扩展增加了对多个系统时钟的支持。时钟通过clockid_t类型进行标识。下表给出了标准值。

标识符 选项 说明
CLOCK_REALTIME 实时系统时间
CLOCK_MONOTONIC _POSIX_MONOTONIC_CLOCK 不带负跳数的实时系统时间
CLOCK_PROCESS_CPUTIMB_ID _POSIX_CPUTIME 调用进程的CPU时间
CLOCK_THREAD_CPUTIME_ID _POSIX_THREAD_CPUTIME 调用线程的CPU时间

clock_gettime函数可用于获取指定时钟的时间,返回的时间在timespec结构中,它把时间表示为秒和纳秒。

1
2
3
#include <sys/time.h>
int clock_gettime(clockid_t clock_id, struct timespec *tsp);
// 返回值,若成功,返回0;若出错,返回-1

当时钟ID设置为CLOCK_REALTIME时,clock_gettime函数提供了与time函数类似的功能,不过在系统支持高精度时间值的情况下,clock_ gettime可能比time函数得到更高精度的时间值。

1
2
3
#include <sys/time.h>
int clock_getres (clockid_t clock_id, struct timespec *tsp);
// 返回值。若成功,返回0;若出错,返回-1

clock_getres函数把参数tsp指向的timespec结构初始化为与clock_id参数对应的时钟精度。例如,如果精度为1毫秒,则tv_sec字段就是0,tv_nsec字段就是1000000。

要对特定的时钟设置时间,可以调用clock_settime函数。

1
2
3
#include <sys/time.h>
int clock_settime(clockid_t clock_id, const struct timespec *ap);
// 返回值:若成功,返回0;若出错,返回-1

SUSv4指定gettimeofday函数现在已弃用。然而,一些程序仍然使用这个函数,因为与time函数相比,gettimeofday提供了更高的精度(可到微秒级)。

1
2
3
#include <sys/time.h>
int gettimeofday(struct timeval *restrict tp, void *restrict tzp);
// 返回值。总是返回0

tzp的唯一合法值是NULL,其他值将产生不确定的结果。某些平台支持用tzp说明时区,但这完全依实现而定。

gettimeofday函数以距特定时间(1970年1月1日00:00:00)的秒数的方式将当前时间存放在tp指向的timeval结构中,而该结构将当前时间表示为秒和微秒。一旦取得这种从上述特定时间经过的秒数的整型时间值后,通常要调用函数将其转换为分解的时间结构,然后调用另一个函数生成人们可读的时间和日期。图6-9说明了各种时间函数之间的关系。(图中以虚线表示的3个函数localtimemktimestrftime都受到环境变量TZ的影响)

两个函数localtimegmtime将日历时间转换成分解的时间,并将这些存放在一个tm结构中。

1
2
3
4
5
6
7
8
9
10
11
struct tm {     /* a broken-down time */
int tm_sec; /* seconds after the minute: [0 - 60] */
int tm_min; /* minutes after the hour: [0 - 59] */
int tm_hour;/* hours after midnight: [0 - 23] */
int tm_mday;/* day of the month: [1 - 31] */
int tm_mon; /* months since January: [0 - 11] */
int tm_year;/* years since 1900 */
int tm_wday;/* days since Sunday: [0 - 6] */
int tm_yday;/* days since January 1: [0 - 365] */
int tm_isdst;/* daylight saving time tiag: <0, 0, >0 */
};

秒可以超过59的理由是可以表示润秒。注意,除了月日字段,其他字段的值都以0开始。如果夏令时生效,则夏令时标志值为正。如果为非夏令时时间,则该标志值为0;如果此信息不可用,则其值为负。

1
2
3
4
#include <time.h>
struct tm *gmtime(const time_t *calptr);
struct tm *localtime(const time_t *calper);
// 两个函数的返回值:指向分解的tm结构的指针,若出错,返回NULL

localtimegmtime之间的区别是:localtime将日历时间转换成本地时间,而gmtime则将日历时间转换成协调统一时间的年、月、日、时、分、
秒、周日分解结构。

函数mktime以本地时间的年、月、日等作为参数,将其变换成time_t值。

1
2
3
#include <time.h>
time_t mktime(struct tm *tmptr);
// 返回值。若成功,返回日历时间,若出错,返回-1

函数strftime是一个类似于printf的时间值函数。它非常复杂,可以通过可用的多个参数来定制产生的字符串。

1
2
3
4
5
6
7
8
#include <time.h>
size_t strftime(char *restrict buf, size_t maxsize,
const char *restrict format,
const struct tm *restrict tmptr);
size_t strftime_l(char *restrict buf, size_t maxsize,
const char *restrict format,
const struct tm *restrict tmptr, locale_t locale);
// 两个函数的返回值。若有空间。返回存入数组的字符数。否则,返回0

strftime_l允许调用者将区域指定为参数,除此之外,strftimestrftime_l函数是相同的。strftime使用通过TZ环境变量指定的区域。

tmptr参数是要格式化的时间值,由一个指向分解时间值tm结构的指针说明。格式化结果存放在一个长度为maxsize个字符的buf数组中,如果buf长度足以存放格式化结果及一个null终止符,则该函数返回在buf中存放的字符数(不包括null终止符);否则该函数返回0。

format参数控制时间值的格式。如同printf函数一样,转换说明的形式是百分号之后跟一个特定字符。format中的其他字符则按原样输出。两个连续的百分号在输出中产生一个百分号。

与printf函数的不同之处是,每个转换说明产生一个不同的定长输出字符串,在format字符串中没有字段宽度修饰符。图中列出了37种ISO C规定的转换说明。

程序演示了如何使用本章中讨论的多个时间函数。特别演示了如何使用strftime打印包含当前日期和时间的字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int
main(void)
{
time_t t;
struct tm *tmp;
char buf1[16];
char buf2[64];

time(&t);
tmp = localtime(&t);
if (strftime(buf1, 16, "time and date: %r, %a %b %d, %Y", tmp) == 0)
printf("buffer length 16 is too small\n");
else
printf("%s\n", buf1);
if (strftime(buf2, 64, "time and date: %r, %a %b %d, %Y", tmp) == 0)
printf("buffer length 64 is too small\n");
else
printf("%s\n", buf2);
exit(0);
}

程序的输出如下:

1
2
3
$ ./a.out
buffer length 16 is too small
time and date: 12:12:35 M, Thu Jan 19, 2012

strptime函数是strftime的反过来版本,把字符串时间转换成分解时间。

1
2
3
#include <time.h>
char *strptime(const char *restrict buf, const char *restrict format, struct tm *restrict tmptr);
// 返回值。指向上次解析的字符的下一个字符的指针;否则,返回NULL

format参数给出了buf参数指向的缓冲区内的字符串的格式。虽然与strftime函数的说明稍有不同,但格式说明是类似的。strptime函数转换说明符列在图6-12中。

我们曾在前面提及,图6-9中以虚线表示的3个函数受到环境变量TZ的影响。这3个函数是localtimemktimestrftime。如果定义了TZ,则这些函数将使用其值代替系统默认时区。如果TZ定义为空(即TZ=""),则使用协调统一时间UTC。

迸程环境

main函数

C程序总是从main函数开始执行。main函数的原型是:

1
int main(int angc, char *argv[]);

其中,argc是命令行参数的数目,argv是指向参数的各个指针所构成的数组。

当内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址一这是由连接
编辑器设置的,而连接编辑器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。

进程终止

有8种方式使进程终止(termination),其中5种为正常终止,它们是:

  1. 从main返回;
  2. 调用exit;
  3. 调用_exit_Exit;
  4. 最后一个线程从其启动例程返回;
  5. 从最后一个线程调用pthread_exit;。

异常终止有3种方式,它们是

  1. 调用abort;
  2. 接到一个信号;
  3. 最后一个线程对取消请求做出响应。

上节提及的启动例程是这样编写的,使得从main返回后立即调用exit函数。如果将启动例程以C代码形式表示(实际上该例程常常用汇编语言编写),则它调用main函数的形式可能是:

1
exit (main (argc, argv));

退出函数

3个函数用于正常终止一个程序:_exit_Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。

1
2
3
4
5
#include <stdlib.h>
void exit(int status);
void _Exit(int stane);
#include <unistd.h>
void _exit (int status);

exit函数总是执行一个标准I/O库的清理关闭操作:对于所有打开流调用fclose函数。

3个退出函数都带一个整型参数,称为终止状态(或退出状态,exit status)。大多数UNIX系统shell都提供检查进程终止状态的方法。如果:

  • 调用这些函数时不带终止状态;
  • main执行了一个无返回值的return语句;
  • main没有声明返回类型为整型

则该进程的终止状态是未定义的。但是,若main的返回类型是整型,并且main执行到最后一条语句时返回(隐式返回),那么该进程的终止状态是0。

main函数返回一个整型值与用该值调用exit是等价的。于是在main函数中exit(0);等价于return(0);

图中的程序是经典的“hello,world”实例。

1
2
3
4
#include <stdio.h>
main () {
printf("hello, world\n");
}

对该程序进行编译,然后运行,则可见到其终止码是随机的。如果在不同的系统上编译该程序,我们很可能得到不同的终止码,这取决于main函数返回时栈和寄存器的内容:

1
2
3
4
5
$ gcc hello.c
$ ./a.out
hello, world
$ echo $? #打印终止状态
13

现在,我们启用1999 ISO C编译器扩展,则可见到终止码改变了:

1
2
3
4
5
6
$ gcc-std=c99 hello.c   #启用gcc的1999 ISO C扩展
hello.c: 4: warning: return type defaults to 'int'
$ ./a.out
hello, world
$ echo $? #打印终止状态
0

注意,当我们启用1999 ISO C扩展时,编译器发出警告消息。打印该警告消息的原因是:main函数的类型没有显式地声明为整型。如果我们增加了这一声明,那么此警告消息就不会出现。但是,如果我们使编译器所推荐的警告消息都起作用(使用-wall标志),则可能见到类似于“control reaches end of nowoid function.”(控制到达非void函数的尾端)这样的警告消息。

main声明为返回整型,但在main函数体内用exit代替return,对某些C编译器而言会产生不必要的警告信息,因为这些编译器并不了解main中的exitreturn语句的作用相同。避开这种警告信息的一种方法是在main中使用return语句而不是exit

函数atexit

按照ISO C的规定,一个进程可以登记多至32个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序(exit handler),并调用atexit函数来登记这些函数。

1
2
3
#include <stdlib.h>
int atexit (void (*func) (void));
// 返回值,若成功,返回0;若出错,返回非0

其中,atexit的参数是一个函数地址,当调用此函数时无需向它传递任何参数,也不期望它返回一个值。exit调用这些函数的顺序与它们登记时候的顺序相反。同一函数如若登记多次,也会被调用多次。

ISOC要求,系统至少应支持32个终止处理程序,但实现经常会提供更多的支持。为了确定一个给定的平台支持的最大终止处理程序数,可以使用sysconf函数。

exit首先调用各终止处理程序,然后关闭(通过fclose)所有打开流。POSIX.1扩展了ISO C标准,它说明,如若程序调用exec函数族中的任一函数,则将清除所有已安装的终止处理程序。图7-2显示了一个C程序是如何启动的,以及它终止的各种方式。

注意,内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显式或隐式地(通过调用exit)调用_exit_Exit。进程也可非自愿地由一个信号使其终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "apue.h"
static void my_exit1(void);
static void my_exit2(void);

int main(void) {
if (atexit (my_exit2) != 0)
err_sys ("can't register my_exit2");
if (atexit (my_exit1) != 0)
err_sys ("can't register my_exit1");
if (atexit (my_exit1) != 0)
err_ays("can't register my_exiti");

printf("main is done\n");
return(0);
}
static void my_exit1 (void) {
printt("tirat exit handler\n");
}
static void my_exit2(void) {
printt("second exit handier\n");
}

执行该程序产生

1
2
3
4
5
$ ./a.out
main is done
first exit handler
first exit handler
second exit handler

终止处理程序每登记一次,就会被调用一次。在程序中,第一个终止处理程序被登记两次,所以也会被调用两次。注意,在main中没有调用exit,而是用了return语句。

命令行参数

当执行一个程序时,调用exec的进程可将命令行参数传递给该新程序。这是UNIX shell的一部分常规操作。

程序将其所有命令行参数都回显到标准输出上。

1
2
3
4
5
6
7
#include "apue.h"
int main (int argc, char *argv[]) {
int i;
for (i = 0; i < argc; i++)
printf("argv[%d]: %s\n", i, argv[i]);
exit(0);
}

环境表

每个程序都接收到一张环境表。与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以null结束的C字符串的地址。全局变量environ则包含了该指针数组的地址:

1
extern char **environ;

例如,如果该环境包含5个字符串,那么它看起来如图中所示。其中,每个字符串的结尾处都显式地有一个null字节。我们称environ为环境指针(environment pointer),指针数组为环境表,其中各指针指向的字符串为环境字符串。

按照惯例,环境由name = value这样的字符串组成,大多数预定义名完全由大写字母组成,但这只是一个惯例。

在历史上,大多数UNIX系统支持main函数带3个参数,其中第3个参数就是环境表地址:

1
int main(int argc, char *argv[], char *envp[]);

因为ISO C规定main函数只有两个参数,而且第3个参数与全局变量environ相比也没有带来更多益处,所以POSIX.1也规定应使用environ而不使用第3个参数。通常用getenvputenv函数来访问特定的环境变量,而不是用environ变量。但是,如果要查看整个环境,则必须使用environ指针。

C程序的存储空间布局

历史沿袭至今,C程序一直由下列几部分组成:

  • 正文段。这是由CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁执行的程序在存储器中也只需有一个副本,另外,正文段常常是只读的,以防止程序由于意外面修改其指令。
  • 初始化数据段。通常将此段称为数据段,它包含了程序中需明确地赋初值的变量。例如,C程序中任何函数之外的声明使此变量以其初值存放在初始化数据段中。
  • 未初始化数据段。通常将此段称为bss段,这一名称来源于早期汇编程序一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,内核将此段中的数据初始化为0或空指针。函数外的声明使此变量存放在非初始化数据段中。
  • 栈。自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次函数调用时,其返回地址以及调用者的环境信息都存放在栈中。然后,最近被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,C递归函数可以工作。递归函数每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量。
  • 堆。通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于未初始化数据段和栈之间

图7-6显示了这些段的一种典型安排方式。这是程序的逻辑布局,虽然并不要求一个具体实现一定以这种方式安排其存储空间,但这是一种我们便于说明的典型安排。堆顶和栈项之间未用的虚地址空间很大。

a.out中还有若干其他类型的段,如包含符号表的段、包含调试信息的授以及包含动态共享库链接表的段等。这些部分并不装载到进程执行的程序映像中。

从图7-6还可注意到,未初始化数据段的内容并不存放在磁盘程序文件中。其原因是,内核在程序开始运行前将它们都设置为0.需要存放在磁盘程序文件中的段只有正文段和初始化数据段。

size()命令报告正文段、数据段和bss段的长度(以字节为单位)。例如:

1
2
3
$ size /usr/bin/cc
text data bss dec hex filename
346919 3576 6680 357175 57337 /usz/bin/cc

第4列和第5列是分别以十进制和十六进制表示的3段总长度。

共享库

共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑。

在不同的系统中,程序可能使用不同的方法说明是否要使用共享库,比较典型的有cc(1)和ld(1)命令的选项。作为长度方面发生变化的例子,先用无共享库方式创建下列可执行文件(典型的hello.c程序):

1
2
3
4
5
6
$ gcc -static hello.o.       #阻止gcc使用共享库
$ ls -l a.out
-rwxrwxr-x 1 sar 879443 Sep 2 10:39 a.out
$ size a.out
text data bss dec hex filename
787775 6128 11272 805175 c4937 a.out

如果再使用共享库编译此程序,则可执行文件的正文和数据段的长度都显著减小:

1
2
3
4
5
6
$ gcc hello.c 		#gcc默认使用共享库
$ ls -l a.out
-rwxrwxr-x 1 sar 8378 Sep 2 10:39 a.out
$ size a.out
text data bss dec hex filename
1176 504 16 1696 6a0 a.out

存储空间分配

ISO C说明了3个用于存储空间动态分配的函数。

  • malloc,分配指定字节数的存储区。此存储区中的初始值不确定。
  • calloc,为指定数量指定长度的对象分配存储空间。该空间中的每一位(bit)都初始化为0。
  • realloc,增加或减少以前分配区的长度。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,以便在尾端提供增加的存储区,而新增区域内的初始值则不确定。
1
2
3
4
5
6
#include <stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj, size_t size);
void *realloc(void *ptr, size_t newsize);
// 3个函数返回值:若成功,返回非空指针;若出错,返回NULL
void free (void *pr);

这3个分配函数所返回的指针一定是适当对齐的,使其可用于任何数据对象。例如,在一个特定的系统上,如果最苛刻的对齐要求是,double必须在8的倍数地址单元处开始,那么这3个函数返回的指针都应这样对齐。

因为这3个alloc函数都返回通用指针void*,所以如果在程序中包括了<stdlib.h>(以获得函数原型),那么当我们将这些函数返回的指针赋予一个不同类型的指针时,就不需要显式地执行强制类型转换。未声明函数的默认返回值为int,所以使用没有正确函数声明的强制类型转换可能会隐藏系统错误,因为int类型的长度与函数返回类型值的长度不同(本例中是指针)。

函数free释放pr指向的存储空间,被释放的空间通常被送入可用存储区池,以后,可在调用上述3个分配函数时再分配。

realloc函数使我们可以增、减以前分配的存储区的长度(最常见的用法是增加该区)。例如,如果先为一个数组分配存储空间,该数组长度为512,然后在运行时填充它,但运行一段时间后发现该数组原先的长度不够用,此时就可调用realloc扩充相应存储空间。如果在该存储区后有足够的空间可供扩充,则可在原存储区位置上向高地址方向扩充,无需移动任何原先的内容,并返回与传给它相同的指针值。如果在原存储区后没有足够的空间,则realloc分配另一个足够大的存储区,将现存的512个元素数组的内容复制到新分配的存储区。然后,释放原存储区,返回新分配区的指针。因为这种存储区可能会移动位置,所以不应当使任何指针指在该区中。

这些分配例程通常用sbrk(2)系统调用实现,该系统调用扩充(或缩小)进程的堆。虽然sbrk可以扩充成缩小进程的存储空间,但是大多数mallocfree的实现都不减小进程的存储空间。释放的空间可供以后再分配,但将它们保持在malloc池中而不返回给内核。大多数实现所分配的存储空间比所要求的要稍大一 些,额外的空间用来记录管理信息一分配块的长度、指向下一个分配块的指针等。这就意味着,如果超过一个已分配区的尾端或者在已分配区起始位置之前进行写操作,则会改写另一块的管理记录信息。这种类型的错误是灾难性的,但是因为这种错误不会很快就暴露出来,所以也就很难发现。

在动态分配的缓冲区前或后进行写操作,破坏的可能不仅仅是该区的管理记录信息。在动态分配的缓冲区前后的存储空间很可能用于其他动态分配的对象。这些对象与破坏它们的代码可能无关,这造成寻求信息破坏的源头更加困难。

其他可能产生的致命性的错误是:释放一个已经释放了的块;调用free时所用的指针不是3个alloc函数的返回值等。如若一个进程调用malloc函数,但却忘记调用free函数,那么该进程占用的存储空间就会连续增加,这被称为泄漏(leakage)。如果不调用free函数释放不再使用的空间,那么进程地址空间长度就会慢慢增加,直至不再有空闲空间。此时,由于过度的换页开销,会造成性能下降。

替代的存储空间分配程序

有很多可替代mallocfree的函数。某些系统已经提供替代存储空间分配函数的库。

libmalloc

它提供了一套与ISO C存储空间分配函数相匹配的接口。libmalloc库包括mallopt函数,它使进程可以设置一些变量,并用它们来控制存储空间分配程序的操作。还可使用另一个名为mallinfo的函数,以对存储空间分配程序的操作进行统计。

vmalloc

它允许进程对于不同的存储区使用不同的技术。除了一些vmalloc特有的函数外,该库也提供了ISO C存储空间分配函数的伤真器。

quick-fit

历史上所使用的标准malloc算法是最佳适配或首次适配存储分配策略。quick-fit(快速适配)算法比上述两种算法快,但可能使用较多存储空间。该算法基于将存储空间分裂成各种长度的缓冲区,并将未使用的缓冲区按其长度组成不同的空闲区列表。现在许多分配程序都基于快速适配

jemalloc

jemalloc函数实现是FreeBSD 8.0中的默认存储空间分配程序,它是库函数malloc族在FreeBSD中的实现。它的设计具有良好的可扩展性, 可用于多处理器系统中使用多线程的应用程序。

TCMalloc

TCMalloc函数用于替代malloc函数族以提供高性能、高扩展性和高存储效率。从高速缓存中分配缓冲区以及释放缓冲区到高速缓存中时,它使用线程本地高速缓存来避免锁开销。它还有内置的堆检查程序和维分析程序帮助调试和分析动态存储的使用。

函数alloca

alloca的调用序列与malloc相同,但是它是在当前函数的栈帧上分配存储空间,而不是在堆中。其优点是,当函数返回时,自动释放它所使用的栈帧,所以不必再为释放空间而费心。其缺点是alloca函数增加了栈帧的长度,而某些系统在函数已被调用后不能增加栈帧长度,于是也就不能支持alloca函数。

环境变量

环境字符串的形式是:name=value

ISO C定义了一个函数getenv,可以用其取环境变量值,但是该标准又称环境的内容是由实现定义的。

1
2
3
#include <stdlib.h>
char *getenv (const char *name):
// 返回值,指向与name关联的value的指针; 若未找到,返回NULL

注意,此函数返回一个指针,它指向name-value字符串中的value。我们应当使用getenv从环境中取一个指定环境变量的值,而不是直接访问environ。

POSIX.1定义了某些环境变量。

除了获取环境变量值,有时也需要设置环境变量。我们可能希望改变现有变量的值,或者是增加新的环境变量。遗憾的是,并不是所有系统都支持这种能力。

3个函数的原型是:

1
2
3
4
5
6
#include <stdlib.h>
int putenv(char *str);
// 函数返回值,若成功,返回0,若出错,返回非0
int setenv(const char *name, const char *value, int rewrite);
int unsetenv (const char *name);
// 两个函数返回值,若成功,返回0:若出错,返回-1

这3个函数的操作如下。

  • putenv取形式为name=value的字符串,将其放到环境表中。如果name已经存在,则先删除其原来的定义。
  • setenv将name设置为value,如果在环境中name已经存在,那么
    • rewrite非0,则首先剩除其现有的定义;
    • rewrite为0,则不删除其现有定义(name不设置为新的value,而且也不出错)。
  • unsetenv删除name的定义。 即使不存在这种定义也不算出错。

注意,putenvsetenv之间的差别。setenv必须分配存储空间,以便依据其参数创建name-value字符串。putenv可以自由地将传递给它的参数字符串直接救到环境中。确实,许多实现就是这么做的,因此,将存放在栈中的字符串作为参数传递给putenv就会发生错误,其原因是,从当前函数返回时,其栈帧占用的存储区可能将被重用。

环境表(指向实际name-value字符串的指针数组)和环境字符串通常存放在进程存储空间的顶部(栈之上)。删除一个字符串很简单——只要先在环境表中找到该指针,然后将所有后续指针都向环境表首部顺次移动一个位置。但是增加一个字符串或修改一个现有的字符串就困难得多。环境表和环境字符串通常占用的是进程地址空间的顶部,所以它不能再向高地址方向扩展。同时也不能移动在它之下的各栈帧,所以它也不能向低地址方向扩展。两者组合使得该空间的长度不能再增加。

  • 如果修改一个现有的name:
    • 如果新value的长度少于或等于现有value的长度, 则只要将新字符串复制到原字符串所用的空间中;
    • 如果新value的长度大于原长度,则必须调用malloc为新字符串分配空间,然后将新字符串复制到该空间中,接着使环境表中针对name的指针指向新分配区。
  • 如果要增加一个新的name,则操作就更加复杂。首先,必须调用malloc为name-value字符串分配空间,然后将该字符串复制到此空间中,
    • 如果这是第一次增加一个新name,则必须调用malloc为新的指针表分配空间。接着,将原来的环境表复制到新分配区,并将指向新name-value字符串的指针存放在该指针表的表尾,然后又将一个空指针存放在其后。最后使environ指向新指针表。
    • 如果这不是第一次增加一个新name,则可知以前已调用ma11oc在堆中为环境表分配了空间,所以只要调用realloc,以分配比原空间多存放一个指针的空间。然后将指向新name-value字符串的指针存放在该表表尾,后面跟着一个空指针。

函数setjmp和1ongjmp

在C中,goto语句是不能跨越函数的,而执行这种类型跳转功能的是函数setjmplongjmp。这两个函数对于处理发生在很深层嵌套函数调用中的出错情况是非常有用的。

考虑程序。其主循环是从标准输入读一行,然后调用do_line处理该输入行。do_line函数调用get_token从该输入行中取下一个标记。一行中的第一个标记假定是一条某种形式的命令,switch语句就实现命令选择。对程序中示例的命令调用cmd_add函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "apue.h"
#define TOK_ADD 5

void do_line(char *);
void cmd_add(void);
int get_token(void);
int main(void) {
char line[MAXLINE];
while (fgets(line, MAXLINE, stdin) != NULL)
do_line(line);
exit(0);
}

char *tok_ptr; /* global pointer for get_token() */
void do_line (char *ptr) { /* process one line of input */
int cmd;
tok_ptz = ptr;
while ((cmd = get_token()) > 0) {
switch(cmd) { /* one case for each command */
case TOK_ADD:
cand_add();
break;
}
}
}

void cmd_add (void) {
int token;
token = get_token(); /* rest of processing for this command */
}
int get_token (void) {
/* fetch next token from line pointed to by tok_ptr */
}

程序的骨架部分在读命令、确定命令的类型,然后调用相应函数处理每一条命令这类程序中是非常典型的。

自动变量的存储单元在每个函数的栈桢中。数组line在main的栈帧中,整型cmd在do_line的栈帧中,整型token在cmd_add的栈帧中。

如上所述,这种形式的栈安排是非常典型的,但并不要求非如此不可。栈并不一定要向低地址方向扩充。某些系统对栈并没有提供特殊的硬件支持,此时一个C
的实现可能要用链表实现栈帧。

解决这深层跳转的方法就是使用非局部goto一setjmplongjmp函数。非局部指的是,这不是由普通的C语言goto语句在一个函数内实施的跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数中。

1
2
3
4
#include <setjmp.h>
int setjup(jmp_but env);
// 返回值,若直接调用,返回0;若从longjmp返回,则为非0
void longjmp(jmp_buf env, int val);

在希望返回到的位置调用setjmp,在本例中,此位置在main函数中。因为我们直接调用该函数,所以其返回值为0。setjmp参数env的类型是一 个特殊类型jmp_buf。这一数据类型是某种形式的数组,其中存放在调用longjmp时能用来恢复栈状态的所有信息。因为需在另一个函数中引用env变量,所以通常将env变量定义为全局变量。

当检查到一个错误时,则以两个参数调用longjmp函数。第一个就是在调用setjmp时所用的env第二个参数是具非0值的val,它将成为从setjmp处返回的值。使用第二个参数的原因是对于一个setjmp可以有多个longjmp。例如,可以在cmd_add中以val为1调用longjmp,也可在get_token中以val为2调用longjmp。在main函数中,setjmp的返回值就会是1或2,通过测试返回值就可判断造成返回的longjmp是在cmd_add还是在get_token中。

程序中给出了经修改过后的main和cmd_add函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "apue.h"
#include <setjmp.h>
#define TOK_ADD 5
jmp_buf jupbuffer;
int main (void) {
char line[MAXLINE];
if (setjmp(jmpbuffer) != 0)
printf ("error");
while (fgets (line, MAXLINE, stdin) != NULL)
do_line(line);
exit(0);
}

void end_edd(void) {
int token;
token = get_token();
if (token < 0) /* an error has occurred */
longjmp(jmpbuffer, 1);
/* rest of processing for this command */
}

执行main时,调用setjmp,它将所需的信息记入变量jmpbuffer中并返回0。然后调用do_line,它又调用cmd_add,假定在其中检测到一个错误。longjmp使栈反绕到执行main函数时的情况,也就是抛弃了cmd_adddo_line的栈帧。调用longjmp造成main中setjmp的返回,但是,这一次的返回值是1(longjmp的第二个参数)。

函数getrlimit和setrlimit

每个进程都有一组资源限制,其中一些可以用getrlimitsetrlimit函数查询和更改。

1
2
3
4
#include <sys/resource.h>
int getrlimit (int resource, struct rlimit *rlptr);
int setrlimit(int resource, const struct rlimit *rlpr);
// 两个函数返回值,若成功,返回0,若出错,返回非0

对这两个函数的每一次调用都指定一个资源以及一个指向下列结构的指针。

1
2
3
4
struct rlimit {
rlim_t elim_cur; /* soft limit: current limit */
rlim_t rlim_max; /* hard limit: maximum velue for rlim_cur */
};

在更改资源限制时,须遵循下列3条规则。

  1. 任何一个进程都可将一个软限制值更改为小于或等于其硬限制值。
  2. 任何一个进程都可降低其硬限制值,但它必须大于或等于其软限制值。这种降低,对普通用户而言是不可逆的。
  3. 只有超级用户进程可以提高硬限制值。

常量RLIM_INFINITY指定了一个无限量的限制。这两个函数的resource参数取下列值之一。

  • RLIMIT_AS:进程总的可用存储空间的最大长度(字节)。这影响到sbrk函数和map函数。
  • RLIMIT_CORE:core文件的最大字节数,若其值为0则阻止创建core文件。
  • RLIMIT_CPU:CPU时间的最大量值(秒),当超过此软限制时,向该进程发送SIGXCPU信号。
  • RLIMIT_DATA:数据段的最大字节长度。这是初始化数据、非初始以及堆的总和。
  • RLIMIT_FSIZE:可以创建的文件的最大字节长度。当超过此软限制时,则向该进程发送SIGXFSZ信号。
  • RLIMIT_MEMLOCK:一个进程使用mlock(2)能够锁定在存储空间中的最大字节长度。
  • RLIMIT_MSGQUEUE:进程为POSIX消息队列可分配的最大存储字节数。
  • RLIMIT_NICE:为了影响进程的调度优先级,nice值可设置的最大限制。
  • RLIMIT_NOFTLE:每个进程能打开的最多文件数。更改此限制将影响到syscont函数在参数_SC_OPEN_MAX中返回的值
  • RLIMIT_NPROC:每个实际用户ID可拥有的最大子进程数。更改此限制将影响到sysconf函数在参数_SC_CHILD_MAX中返回的值。
  • RLIMIT_NPTS:用户可同时打开的伪终端的最大数量。
  • RLIMIT_RSS:最大驻内存集字节长度(resident set size in bytes,RSS)、如果可用的物理存储器非常少,则内核将从进程处取回超过RSS的部分。
  • RLIMIT_SBSIZE:在任一给定时刻,一个用户可以占用的套接字缓冲区的最大长度。
  • RLIMIT_SIGPENDING:一个进程可排队的信号最大数量。这个限制是sigqueue函数实施的。
  • RLIMIT_STACK:栈的最大字节长度。
  • RLIMIT_SWAP:用户可消耗的交换空间的最大字节数。
  • RLIMIT_VMEM:这是RLIMIT_AS的同义词。

资源限制影响到调用进程并由其子进程继承。这就意味着,为了影响一个用户的所有后续进程,需将资源限制的设置构造在shell之中。

程序打印由系统支持的所有资源当前的软限制和硬限制。为了在各种实现上编译该程序,我们已经条件地包括了各种不同的资源名。rlim_t类型必须足够大才能表示文件大小限制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include "apue.h"
#include <sys/resource.h>

#define doit(name) pr_limits(#name, name)

static void pr_limits(char *, int);

int
main(void)
{
#ifdef RLIMIT_AS
doit(RLIMIT_AS);
#endif

doit(RLIMIT_CORE);
doit(RLIMIT_CPU);
doit(RLIMIT_DATA);
doit(RLIMIT_FSIZE);

#ifdef RLIMIT_MEMLOCK
doit(RLIMIT_MEMLOCK);
#endif

#ifdef RLIMIT_MSGQUEUE
doit(RLIMIT_MSGQUEUE);
#endif

#ifdef RLIMIT_NICE
doit(RLIMIT_NICE);
#endif

doit(RLIMIT_NOFILE);

#ifdef RLIMIT_NPROC
doit(RLIMIT_NPROC);
#endif

#ifdef RLIMIT_NPTS
doit(RLIMIT_NPTS);
#endif

#ifdef RLIMIT_RSS
doit(RLIMIT_RSS);
#endif

#ifdef RLIMIT_SBSIZE
doit(RLIMIT_SBSIZE);
#endif

#ifdef RLIMIT_SIGPENDING
doit(RLIMIT_SIGPENDING);
#endif

doit(RLIMIT_STACK);

#ifdef RLIMIT_SWAP
doit(RLIMIT_SWAP);
#endif

#ifdef RLIMIT_VMEM
doit(RLIMIT_VMEM);
#endif

exit(0);
}

static void
pr_limits(char *name, int resource)
{
struct rlimit limit;
unsigned long long lim;

if (getrlimit(resource, &limit) < 0)
err_sys("getrlimit error for %s", name);
printf("%-14s ", name);
if (limit.rlim_cur == RLIM_INFINITY) {
printf("(infinite) ");
} else {
lim = limit.rlim_cur;
printf("%10lld ", lim);
}
if (limit.rlim_max == RLIM_INFINITY) {
printf("(infinite)");
} else {
lim = limit.rlim_max;
printf("%10lld", lim);
}
putchar((int)'\n');
}

注意,在doit宏中使用了ISO C的字符串创建算符(#),以便为每个资源名产生字符串值。例如:

1
doit (RLIMIT_CORE);

这将由C预处理程序扩展为:

1
pr_limits("RLIMIT_CORE", RLIMIT_CORE);

在FreeBSD下运行此程序,得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./a.out
RLIMIT_AS (infinite) (infinite)
RLIMIT_CORE (infinite) (infinite)
RLIMIT_CPU (infinite) (infinite)
RLIMIT_DATA 536870912 536870912
RLIMIT_FSIZE (infinite) (infinite)
RLIMIT_MEMLOCK (infinite) (infinite)
RLIMIT_NOFILE 3520 3520
RLIMIT_NPROC 1760 1760
RLIMIT_NPTS (infinite) (infinite)
RLIMIT_RSS (infinite) (infinite)
RLIMIT_SBSIZE (infinite) (infinite)
RLIMIT_STACK 67108864 67108864
RLIMIT_SWAP (infinite) (infinite)
RLIMIT_VMEM (infinite) (infinite)

进程控制

进程标识

每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。

进程ID是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。大多数UNIX系统实现延迟复用算法,使得赋予新建进程的ID不同于最近终止进程所使用的ID。这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。

系统中有一些专用进程,但具体细节随实现而不同。ID为0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。1通常是init进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是/sbin/init。此进程负责在自举内核后启动一个UNIX系统。init通常读取与系统有关的初始化文件,并将系统引导到一个状态。init进程决不会终止。它是一个普通的用户进程,但是它以超级用户特权运行。

除了进程ID,每个进程还有一些其他标识符。下列函数返回这些标识符。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>
pid_t getpid(void);
// 返回值:调用进程的进程ID
pid_t getppid (void);
// 返回值:调用进程的父进程ID
uid_t getuid(void);
// 返回值:调用进程的实际用户ID
uid_t geteuid (void);
// 返回值:调用进程的有效用户ID
gid_t getgid(void);
// 返回值:调用进程的实际组ID
gid_t getegid (void);
// 返回值,调用进程的有效组ID

注意,这些函数都没有出错返回。

函数fork

现有的进程可以调用fork函数创建一个新进程。

1
2
3
#include <unistd.h>
pid_t fork (void);
// 返回值:子进程返回0,父进程返回子进程ID;若出错,返回-1

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是0,而父进程的返回值则是新建子进程的进程ID。将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。fork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总是可以调用getppid以获得其父进程的进程ID(进程ID 0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。

子进程和父进程继续执行fork调用之后的指令。子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段。由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为替代,使用了写时复制(Copy-On-Write,COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。

Linux 3.2.0提供了另一种新进程创建函数clone系统调用,它允许调用者控制哪些部分由父进程和子进程共享。

程序演示了fork函数,从中可以看到子进程对变量所做的改变并不影响父进程中该变量的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "apue.h"

int globvar = 6; /* external variable in initialized data */
char buf[] = "a write to stdout\n";

int
main(void)
{
int var; /* automatic variable on the stack */
pid_t pid;

var = 88;
if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1)
err_sys("write error");
printf("before fork\n"); /* we don't flush stdout */

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* child */
globvar++; /* modify variables */
var++;
} else {
sleep(2); /* parent */
}

printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar,
var);
exit(0);
}

如果执行此程序则得到:

1
2
3
4
5
6
7
8
9
10
11
12
$ ./a.out
a write to stdout
before fork
pid = 430, glob = 7, var = 89 #子进程的变量值改变了
pid = 429, glob = 6, var = 88 #父进程的变量值没有改变
$ a.out > temp.out
$ cat temp.out
a write to atdout
before fork
pid = 432, glob = 7, var = 89
before fork
pid = 431, glob = 6, var = 80

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程问通信。

当写标准输出时,我们将buf长度减去1作为输出字节数,这是为了避免将终止null字节写出。strlen计算不包含终止null字节的字符串长度,而sizeof则计算包括终止null字节的缓冲区长度。两者之间的另一个差别是,使用strlen需进行一次函数调用,而对于sizeof而言, 因为缓冲区已用已知字符串进行初始化,其长度是固定的,所以sizeof是在编译时计算缓冲区长度。

当以交互方式运行该程序时,只得到该printf输出的行一次,其原因是标准输出缓冲区由换行符冲洗。但是当将标准输出重定向到一个文件时,却得到printf输出行两次。其原因是,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。在exit之前的第二个printf将其数据追加到已有的缓冲区中。当每个进程终止时,其缓冲区中的内容都被写到相应文件中。

在重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项。考虑下述情况,一个进程具有了个不同的打开文件,它们是标准输入、标准输出和标准错误。在从fork返回时,我们有了如图8-2中所示的结构。

重要的一点是,父进程和子进程共享同一个文件偏移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由shell实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例子中,当父进程等待子进程时,子进程写到标准输出:而在子进程终止后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式的交互就要困难得多,可能需要父进程显式地动作。

如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。

fork之后处理文件搞述符有以下两种常见的情况,

  1. 父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。
  2. 父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件损述符,这样就不会干扰对方使用的文件描述符。

除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:

  • 实际用户ID、实际组ID、有效用户ID、有效组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 设置用户ID标志和设置组D标志
  • 当前工作目录
  • 根目录
  • 文件模式创建屏蔽字
  • 信号屏蔽和安排
  • 对任一打开文件描述符的执行时关闭(close-on-exec)标志
  • 环境
  • 连接的共享存储段
  • 存储映像
  • 资源限制

父进程和子进程之间的区别具体知下。

  • fork的返回值不同。
  • 进程ID不同。
  • 这两个进程的父进程ID不同:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。
  • 子进程的tms_utimetms_stimetms_cutimetms_ustime的值设置为0。
  • 子进程不继承父进程设置的文件锁。
  • 子进程的未处理闹钟被清除。
  • 子进程的未处理信号集设置为空集。

使fork失败的两个主要原因是:

  • 系统中已经有了太多的进程。
  • 该实际用户ID的进程总数超过了系统限制。

fork有以下两种用法:

  • 一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。
  • 一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用exec

某些操作系统将fork之后执行exec组合成一个操作,称为spawn。Single UNIX Specification在高级实时选项组中确实包括了spawn接口。但是该接口并不想替换forkexec

函数vfork

vfork函数的调用序列和返回值与fork相同,但两者的语义不同。vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。vforkfork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是也就不会引用该地址空间。不过在子进程调用execexit之前,它在父进程的空间中运行。如果子进程修改数据(除了用于存放vfork返回值的变量)、进行函数调用、或者没有调用execexit就返回都可能会带来未知的结果。

vforkfork之间的另一个区别是,vfork保证子进程先运行,在它调用execexit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "apue.h"

int globvar = 6; /* external variable in initialized data */

int main(void)
{
int var; /* automatic variable on the stack */
pid_t pid;

var = 88;
printf("before vfork\n"); /* we don't flush stdio */
if ((pid = vfork()) < 0) {
err_sys("vfork error");
} else if (pid == 0) { /* child */
globvar++; /* modify parent's variables */
var++;
_exit(0); /* child terminates */
}

/* parent continues here */
printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar,
var);
exit(0);
}

运行该程序得到:

1
2
3
$ ./a.out
betore vtork
pid = 29039, glob = 7, var = 89

子进程对变量做增1的操作,结果改变了父进程中的变量值。因为子进程在父进程的地址空间中运行,所以这并不令人惊讶。但是其作用的确与fork不同

函数exit

进程有5种正常终止及3种异常终止方式。5种正常终止方式具体如下。

  1. main函数内执行return语句。
  2. 调用exit函数。此函数由ISO C定义,其操作包括调用各终止处理程序,然后关闭所有标准I/O流等。
  3. 调用_exit_Exit函数。ISO C定义_Exit,其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。在UNIX系统中,_Exit_exit是同义的。_exit函数由exit调用,它处理UNIX系统特定的细节。_exit是由POSIX.1说明的。
  4. 进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。
  5. 进程的最后一个线程调用pthread_exit函数。

3种异常终止具体如下。

  1. 调用abort。它产生SIGABRT信号。
  2. 当进程接收到某些信号时。信号可由进程自身、其他进程成内核产生。例如,若进程引用地址空间之外的存储单元、或者除以0,内核就会为该进程产生相应的信号。
  3. 最后一个线程对“取消”请求作出响应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。

对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于3个终止函数(exit_exit_Exit),实现这一点的方法是,将其退出状态(exitstatus)作为参数传送给函数。在异常终止情况,内核产生一个指示其异常终止原因的终止状态(termination status)。在任意一种情况下,该终止进程的父进程都能用waitwaitpid函数取得其终止状态。

注意,这里使用了“退出状态”和“终止状态”两个术语,以表示有所区别。在最后调用exit时,内核将退出状态转换成终止状态。如果子进程正常终止,则父进程可以获得子进程的退出状态。子进程将其终止状态返回给父进程。但是如果父进程在子进程之前终止,该子进程的父进程都改变为init进程。我们称这些进程由init进程收养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1(init进程的ID)。这种处理方法保证了每个进程有一个父进程。

如果子进程完全消失了,父进程在最终准备好检查子进程是否终止时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用waitwaitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开文件。在UNIX术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程(zombie), ps命令将僵死进程的状态打印为Z。如果编写一个长期运行的程序,它fork了很多子进程,那么除非父进程等待取得子进程的终止状态,不然这些子进程终止后就会变成僵死进程。

由init进程收养的进程终止时会发生什么?init被编写成无论何时只要有一个子进程终止,init就会调用一个wait函数取得其终止状态。这样也就防止了在系统中塞满僵死进程。当提及“一个init的子进程”时,这指的可能是init直接产生的进程,也可能是其父进程已终止,由init收养的进程。

函数wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。因为子进程终止是个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。调用waitwaitpid的进程可能会发生:

  • 如果其所有子进程都还在运行,则阻塞。
  • 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
  • 如果它没有任何子进程,则立即出错返回。

如果进程由于接收到SIGCHLD信号而调用wait,我们期望wait会立即返回。但是如果在随机时间点调用wait,则进程可能会阻塞。

1
2
3
4
#include <sys/wait.h>
pid_t wait (int *statloc);
pid_t waitpid (pid_t pid, int *statioc, int options);
// 两个函数返回值:若成功,返回进程ID;若出错,返回0或-1

这两个函数的区别如下。

  • 在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项,可使调用者不阻塞。
  • waitpid并不等待在其调用之后的第一个终止子进程,它有着千个选项,可以控制它所等待的进程。

如果子进程已经终止,并且是一个僵死进程,则wait立即返回并取得该子进程的状态;否则wait使其调用者阻塞,直到一个子进程终止。如调用者阻塞而且它有多个子进程,则在其某子进程终止时,wait就立即返回。因为wait返回终止子进程的进程ID,所以它总能了解是哪一个子进程终止了。

这两个函数的参数statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。

依据传统,这两个函数返回的整型状态字是由实现定义的。其中某些位表示退出状态(正常返回),其他位则指示信号编号(异常返回),有一位指示是否产生了core文件等。POSIX.1规定,终止状态用定义在<sys/wait.h>中的各个宏来查看。有4个互斥的宏可用来取得进程终止的原因,它们的名字都以WIF开始。基于这4个宏中哪一个值为真,就可选用其他宏来取得退出状态、信号编号等。这4个互斥的宏示于图84中。

说明
WIFEXITED(statu) 若为正常终止子进程返回的状态,则为真。对于这种情况可执行WEXITSTATUS(status),获取了进程传送给exitexit参数的低8位
WIFSIGNALED (status) 若为异常终止子进程返回的状态,则为真(接到一个不捕捉的信号)。对于这种情况,可执行WTERMSIG(status),获取使子进程终止的信号编号。另外,有些实现定义宏WCOREDUMP(statu),已产生终止进程的core文件,则它返回真
WIFSTOPPED (status) 若为当前智停子进程的返回的状态,则为真。对于这种情况,可执行WSTOPSIG(status),获取使子进程暂停的信号编号
WIFCONTINUED (status) 若在作业控制暂停后已经继续的子进程返回了状态,则为真

函数pr_exit使用宏以打印进程终止状态的说明。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include"apue.h"
#include <sys/wait.h>
void pr_exit(int status) {
if (WIFEXITED(status))
printf ("normal termination, exit status = %d\n", WEXITSTATUS (status));
else if (WIFSIGNALED (status))
printt("abnormal termination, signal number = %d%s\n", WTERMSIG(status),
#ifdef WCOREDUMP
WCOREDUMP(status) ? "(core file generated)" : "");
#else
"");
#endif
else if (WIFSTOPPED(status))
printe("child stopped, signal number = %d\n", WSTOPSIG(status));
}

程序调用pr_exit函数,演示终止状态的各种值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include "apue.h"
#include <sys/wait.h>

int
main(void)
{
pid_t pid;
int status;

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid == 0) /* child */
exit(7);

if (wait(&status) != pid) /* wait for child */
err_sys("wait error");
pr_exit(status); /* and print its status */

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid == 0) /* child */
abort(); /* generates SIGABRT */

if (wait(&status) != pid) /* wait for child */
err_sys("wait error");
pr_exit(status); /* and print its status */

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid == 0) /* child */
status /= 0; /* divide by 0 generates SIGFPE */

if (wait(&status) != pid) /* wait for child */
err_sys("wait error");
pr_exit(status); /* and print its status */

exit(0);
}

运行该程序可得:

1
2
3
4
$ ./a.out
normal termination, exit status = 7
abnormal termination, signal number = 6 (core file generated)
abnormal temination, signal number = 8 (core tile generated)

现在,我们可以从WTERMSIG中打印信号编号。可以查看<signal.h>头文件验证SIGABRT的值为6,SIGFPE的值为8。

如果一个进程有几个子进程。那么只要有一个子进程终止,wait就返回。如果我们需要的是等待一个特定进程的函数。POSIX.1定义了waitpid函数以提供这种功能。对于waitpid函数中pid参数的作用解释如下,

  • pid== -1等待任一子进程。此种情况下,waitpidwait等效。
  • pid>0等待进程ID与pid相等的子进程。
  • pid==0等待组ID等于调用进程组ID的任一子进程。
  • pid<-1等待组ID等于pid绝对值的任一子进程。

waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态存放在由statloc指向的存储单元中。对于wait,其唯一的出错是调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种出错)。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。

options参数使我们能进一步控制waitpid的操作。此参数或者是0,或者是常量按位或运算的结果。

常量 说明
WCONTINUED 若实现支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态
WNOHANG 若由pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时其返回值为0
WUNTRACED 若某实现支持作业控制,而由pid指定的任一子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。WIFSTOPPED宏确定返回值是否对应于一个停止的子进程

waitpid函数提供了wait函数没有提供的3个功能。

  1. waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。
  2. waitpid提供了一个wait的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。
  3. waitpid通过WUNTRACEDWCONTINUED选项支持作业控制。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "apue.h"
#include <sys/wait.h>

int
main(void)
{
pid_t pid;

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* first child */
if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid > 0)
exit(0); /* parent from second fork == first child */

/*
* We're the second child; our parent becomes init as soon
* as our real parent calls exit() in the statement above.
* Here's where we'd continue executing, knowing that when
* we're done, init will reap our status.
*/
sleep(2);
printf("second child, parent pid = %ld\n", (long)getppid());
exit(0);
}

if (waitpid(pid, NULL, 0) != pid) /* wait for first child */
err_sys("waitpid error");

/*
* We're the parent (the original process); we continue executing,
* knowing that we're not the parent of the second child.
*/
exit(0);
}

第二个子进程调用sleep以保证在打印父进程ID时第一个子进程已终止。在fork之后,父进程和子进程都可继续执行,并且我们无法预知哪一个会先执行。在fork之后,如果不使第二个子进程休眠,那么它可能比其父进程先执行,于是它打印的父进程ID将是创建它的父进程, 而不是init进程(进程ID 1)。

执行图8-8程序得到:

1
2
$ ./a.out
$ second child, parent pid = 1

注意,当原先的进程(也就是exec本程序的进程)终止时,shell打印其提示符,这在第二个子进程打印其父进程ID之前。

函数waitid

waitid函数类似于waitpid,但提供了更多的灵活性。

1
2
3
#include <sys/wait.h>
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
// 返回值:若成功,返回0;若出错:返回-1

waitpid相似,waitid允许一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型,而不是将此与进程ID或进程组ID组合成一个参数。id参数的作用与idtype的值相关。该函数支持的idtype类型列在下表中。

常量 说明
P_PID 等待一特定进程,id包含要等待子进程的进程ID
P_PGID 等待一特定进程组中的任一子进程,id包含要等待子进程的进程组ID
P_ALL 等待任一子进程,忽略id

options参数是各标志的按位或运算。这些标志指示调用者关注哪些状态变化。

常量 说明
WCONTINUED 等待一进程,它以前曾被停止,此后又已继续,但其状态尚未报告
WEXITED 等特已退出的进程
WNOHANG 如无可用的子进程退出状态,立即返回而非阻塞
WNOWAIT 不破坏子进程退出状态。该子进程退出状态可由后续的wait,wastid或waitpid调用取得
WSTOPPED 等待一进程,它已经停止,但其状态尚未报告

WCONTINUEDWEXITEDWSTOPPED这3个常量之一必须在options参数中指定。

infop参数是指向siginfo结构的指针。该结构包含了造成子进程状态改变有关信号的详细信息。

函数wait3和wait4

wait3wait4两个函数提供的功能比POSIX.1函数waitwaitpidwaitid所提供功能的要多一个,这与附加参数有关。该参数允许内核返回由终止进程及其所有子进程使用的资源概况。

1
2
3
4
5
6
7
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/time.h>
#include <sys/resource.h>
pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
// 两个函数返回值:若成功,返回进程ID;若出错,返回-1

资源统计信息包括用户CPU时间总量、系统CPU时间总量、缺页次数、接收到信号的次数等。

竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件(race condition)。如果在fork之后的某种逻辑显式或隐式地依赖于在fork之后是父进程先运行还是子进程先运行,那么fork函数就会是竞争条件活跃的滋生地。

如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止,则可使用下列形式的循环:

1
2
while (getppid()!= 1)
sleep(1):

这种形式的循环称为轮询(polling),它的问题是浪费了CPU时间,因为调用者每隔1s都被唤醒,然后进行条件测试。

在父进程和子进程的关系中,常常出现下述情况。在fork之后,父进程和子进程都有一些事情要做。例如,父进程可能要用子进程ID更新日志文件中的一个记录,而子进程则可能要为父进程创建一个文件。在本例中,要求每个进程在执行完它的一套初始化操作后要通知对方,并且在继续运行之前,要等待另一方完成其初始化操作。这种情况可以用代码描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "apse.h"
TELLWAIT(); /* set things up for TELL_XXX&WAIT_XXX */
if ((pid = fork()) < 0)
err_sys ("fork error");
else if (pid == 0) { /*child*/
/*child does whatever io necessary ...*/
TELL_PARENT (getppid()):
/* tell parent we're done*/
WAIT_PARENT(); /* and wait fox parent*/
/* and the child continues on its way */
exit (0);
}
/* parent does whatever is necessary ...*/
TELL_CNILD(pid);
/* tell child we're done*/
WAIT_CHILD(); /* and wait for child*/
/* and the parent continues on its way ... */
exit(0);

假定在头文件apue.h中定义了需要使用的各个变量。5个例程TELLWAITTELL_PARENTTELL_CHILDWAIT_PARENT以及WAIT_ CHILD可以是宏,也可以是函数。

程序输出两个字符串:一个由子进程输出,另一个由父进程输出。因为输出依赖于内核使这两个进程运行的顺序及每个进程运行的时间长度,所以该程序包含了一个竞争条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "apue.h"
static void charatatime(char *);
int main (void) {
pid_t pid;
if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid == 0)
charatatime("output from child\n");
else
charatatime("output from parent\n");
exit(0);
}
static void charatatime (char *str) {
char *ptr;
int c;
setbuf(stdout, NULL):
/*set unbuffered */
for (ptr = str; (c = *ptr++) != 0)
putc(c, stdout);
}

在程序中将标准输出设置为不带缓冲的,于是每个字符输出都需调用一次write。本例的目的是使内核能尽可能多次地在两个进程之间进行切换,以便演示竞争条件。下面的实际输出说明该程序的运行结果是会改变的。

1
2
3
4
5
6
7
8
9
$ ./a.out
ooutput from child
utput from parent
$ ./a.out
ooutput from child
utput trom parent
$ ./a.out
output from child
output from parent

修改程序,使其使用TELLWAIT函数,于是形成了下边的程序。行首标以+号的行是新增加的行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "apue.h"
static void charatatine(char *);
int main (void) {
pid_t pid;
TELL_WAIT();
if ((pid = fork()) < 0)
err_ays("fork error");
else if (pid == 0) {
WAIT_PARENT(); /* parent goes first*/
charatatime ("output from child\n");
} else {
charatatime ("output trom parent\n") i
TELL_CHILD(pid);
}
exit (0);
}

static void charatatime (char *str) {
char *ptr;
int c;
setbuf (stdout, NULL);
/*set unbuffered*/
for (ptr = str; (c = *ptr++) != 0;)
putc(c, stdout);
}

运行此程序则能得到所预期的输出——两个进程的输出不再交叉混合。上边的程序是使父进程先运行。如果将fork之后的行改成:

1
2
3
4
5
6
7
else if (pid == 0) {
charatatime ("output from child\n");
TELL_PARENT (getppid());
} else {
WAIT_CHILD(); /*child goes first */
charatatime ("output from parent\n");
}

则子进程先运行。

函数exec

fork函数创建新的子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

有7种不同的exec函数可供使用,它们常常被统称为exec函数,我们可以使用这7个函数中的任一个。这些exec函数使得UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。

1
2
3
4
5
6
7
8
9
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */);
int execv(const char *pathmame, char *const angv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /*(char *)0*/);
int execvp(const char *filename, char *const angv[]);
int fexecve(int fd, char *const angv[], char *const envp[]);
// 7个函数返回值:若出错,返回-1,若成功,不返回

这些函数之间的第一个区别是前4个函数取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。当指定flename作为参数时:

  • 如果filename中包含/,则就将其视为路径名;
  • 否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件。

PATH变量包含了一张目录表(称为路径前缀),目录之间用冒号(:)分隔。例如,下列name-value环境字符串指定在4个目录中进行搜索。

1
PATH=/bin:/ust/bin:/usr/local/bin:.

最后的路径前缀.表示当前目录。(零长前缀也表示当前目录。在value的开始处可用:表示,在行中间则要用::表示,在行尾以:表示。)

如果execlpexecvp使用路径前缀中的一个找到了一个可执行文件,但是该文件不是由连接编辑器产生的机器可执行文件,则就认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该 filename作为shell的输入。

fexecve函数避免了寻找正确的可执行文件。而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。否则,拥有特权的恶意用户就可以在找到文件位置并且验证之后,但在调用进程执行该文件之前替换可执行文件(或可执行文件的部分路径)。

第二个区别与参数表的传递有关(l表示列表list,v表示矢量vector)。函数execlexeclpexecle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。对于另外4个函数(execvexecvpexecvefexecve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这4个函数的参数。

在使用ISO C原型之前,对execlexecleexeclp三个函数表示命令行参数的一般方法是:

1
char *arg0, char *arg1, ..., char *argn, (char *)0

这种语法显式地说明了最后一个命令行参数之后跟了一个空指针。如果用常量0来表示一个空指针,则必须将它强制转换为一个指针:否则它将被解释为整型参数。如果一个整型数的长度与char *的长度不同,那么exec函数的实际参数将出错。

最后一个区别与向新程序传递环境表相关。以e结尾的3个函数(execleexecvefexecve)可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境。通常,一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想要为子进程指定某一个确定的环境。

在使用ISOC原型之前,execle的参数是:

1
char *pathname, char *arg0, char *argn, (char *)0, char *envp[]

从中可见,最后一个参数是指向环境字符串的各字符指针构成的数组的指针。而在ISO C原型中,所有命令行参数、空指针和envp指针都用省略号(…)表示。

这7个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助。字母p表示该函数取flename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。v表示该函数取一个arg[]矢量。最后,字母e表示该函数取envp[]数组,而不使用当前环境。

每个系统对参数表和环境表的总长度都有一个限制。这种限制是由ARG_MAX给出的。在POSIX.1系统中,此值至少是4096字节。当使用shell的文件名扩充功能产生一个文件名列表时,可能会受到此值的限制。例如,命令

1
grep getrlimit /usr/share/man/*/*

在某些系统上可能产生如下形式的shell错误:

1
Argument list too long

为了摆脱对参数表长度的限制,我们可以使用xargs(1)命令,将长参数表断开成几部分。

前面曾提及,在执行exec后,进程ID没有改变。但新程序从调用进程继承了的下列属性:

  • 进程ID和父进程ID
  • 实际用户ID和实际组ID
  • 附属组ID
  • 进程组ID
  • 会话ID
  • 控制终端
  • 闹钟尚余留的时间
  • 当前工作日录
  • 根目录
  • 文件模式创建屏蔽字
  • 文件锁
  • 进程信号屏蔽
  • 未处理信号
  • 资源限制
  • nice值
  • tms_utimetms_stimetms_cutime以及tms_cstime

对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关。进程中每个打开描述符都有一个执行时关闭标志。若设置了此标志,则在执行exec时关闭该描述符;否则该描述符仍打开。除非特地用fcntl设置了该执行时关闭标志,否则系统的默认操作是在exec后仍保持这种描述符打开。

注意,在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。

在很多UNIX实现中,这7个函数中只有execve是内核的系统调用。另外6个只是库函数,它们最终都要调用该系统调用。

在这种安排中,库函数execlpexecvp使用PATH环境变量,查找第一个包含名为filename的可执行文件的路径名前缀。fexecve库函数使用/proc把文件措述符参数转换成路径名,execve用该路径名去执行程序。

程序演示了exec函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "apue.h"
#include <sys/wait.h>

char *env_init[] = { "USER=unknown", "PATH=/tmp", NULL };

int
main(void)
{
pid_t pid;

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* specify pathname, specify environment */
if (execle("/home/sar/bin/echoall", "echoall", "myarg1",
"MY ARG2", (char *)0, env_init) < 0)
err_sys("execle error");
}

if (waitpid(pid, NULL, 0) < 0)
err_sys("wait error");

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* specify filename, inherit environment */
if (execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0)
err_sys("execlp error");
}

exit(0);
}

在该程序中先调用execle,它要求一个路径名和一个特定的环境。下一个调用的是execlp,它用一个文件名,并将调用者的环境传送给新程序。execlp在这里能够工作是因为目录/home/sar/bin是当前路径前缀之一。注意,我们将第一个参数(新程序中的argv[0])设置为路径名的文件名分量。某些shell将此参数设置为完全的路径名。这只是一个惯例。我们可将argv[0]设置为任何字符串。当login命令执行shell时就是这样做的。在执行shell之前,login在argv[0]之前加一个/作为前缀,这向shell指明它是作为登录shell被调用的。登录shell将执行启动配置文件(start-up profile)命令,而非登录shell则不会执行这些命令。

更改用户ID和更改组ID

在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。

一般而言,在设计应用时,我们总是试图使用最小特权(least privilege)模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。

可以用setuid函数设置实际用户ID和有效用户ID。与此类似,可以用setgid函数设置实际组ID和有效组ID。

1
2
3
4
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
// 两个函数返回值,若成功,返回0,若出错,返回-1

关于谁能更改ID有若干规则。现在先考虑更改用户ID的规则

  1. 若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID(saved set-user-ID)设置为uid;
  2. 若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid。不更改实际用户ID和保存的设置用户ID。
  3. 如果上面两个条件都不满足,则errno设置为EPERM,并返回-1。

在此假定_POSIX_SAVED_IDS为真。如果没有提供这种功能。则上面所说的关于保存的设置用户ID部分都无效。

关于内核所维护的3个用户ID,还要注意以下几点。

  1. 只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login(1)程序设置的,而且决不会改变它。因为login是一个超级用户进程,当它调用setuid时,设置所有3个用户ID。
  2. 仅当对程序文件设置了设置用户ID位时,exec函数才设置有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持其现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。自然地,不能将有效用户ID设置为任一随机值。
  3. 保存的设置用户ID是由exec复制有效用户ID而得到的。如果设置了文件的设置用户ID位。则在exec根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了。

函数setreuid和sotregid

历史上,BSD支持setreuid函数,其功能是交换实际用户ID和有效用户ID的值。

1
2
3
4
#include <unistd.h>
int setreuid(uid_t ruid, uid_t exid);
int setregid(gid_t rgid, gid_t egid);
// 两个函数返回值,若成功,返回0;若出错,返回-1

如若其中任一参数的值为-1,则表示相应的ID应当保持不变。

规则很简单:一个非特权用户总能交换实际用户ID和有效用户ID。这就允许一个设置用户ID程序交换成用户的普通权限,以后又可再次交换回设置用户ID权限。POSIX.1引进了保存的设置用户ID特性后,允许一个非特权用户将其有效用户ID设置为保存的设置用户ID。

函数seteuid和sotegid

POSIX.1包含了两个函数seteuidsetegid。它们类似于setuidsetgid,但只更改有效用户ID和有效组ID。

1
2
3
4
#include <unistd.h>
int seteuid(uid_t uid);
int setegid(gid_t gid);
// 两个函数返回值:若成功,返回0;若出错,返回-1

一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户则可将有效用户ID设置为uid。

图中给出了本节所述的更改3个不同用户ID的各个函数。

组ID

本章中所说明的一切都以类似方式适用于各个组ID。附属组ID不受setgidsetregidsetegid函数的影响。

为了说明保存的设置用户ID特性的用法,先观察一个使用该特性的程序。我们所观察的是at(1)程序,它用于调度将来某个时刻要运行的命令。

为了防止被欺骗而运行不被允许的命令或读、写没有访问权限的文件,at命令和最终代表用户运行命令的守护进程必须在两种特权之间切换:用户特权和守护进程特权。下面列出了其工作步骤。

  • 程序文件是由root用户拥有的, 并且其设置用户ID位已设置。当我们运行此程序时,得到下列结果:
    • 实际用户ID=我们的用户ID(未改变)
    • 有效用户ID=root
    • 保存的设置用户ID=root
  • at程序做的第一件事就是降低特权,以用户特权运行。它调用setuid函数把有效用户D设置为实际用户ID。此时得到:
    • 实际用户ID=我们的用户ID(未改变)
    • 有效用户ID=我们的用户ID
    • 保存设置用户ID=root(未改变)
  • at程序以我们的用户特权运行,直到它需要访问控制哪些命令即将运行,这些命令需要何时运行的配置文件时,at程序的特权会改变,这些文件由为用户运行命令的守护进程持有。at命令调用setuid函数把有效用户ID设为root,因为setuid的参数等于保存的设置用户ID,所以这种调用是许可的。现在得到:
    • 实际用户ID-我们的用户ID(未改变)
    • 有效用户ID=root
    • 保存的设置用户ID=root(未改变)
    • 因为有效用户ID是root,文件访问是允许的。
  • 修改文件从而记录了将要运行的命令以及它们的运行时间以后,at命令通过调用seteusid,把有效用户ID设置为用户ID,降低它的特权。防止对特权的误用。此时我们可以得到:
    • 实际用户ID=我们的用户ID(未改变)
    • 有效用户ID=我们的用户ID
    • 保存的设置用户ID=root(来改变)
  • 守护进程开始用root特权运行,代表用户运行命令,守护进程调用fork,子进程调用setuid将它的用户ID更改至我们的用户ID。因为子进程以root特权运行,更改了所有的ID,所以
    • 实际用户ID=我们的用户ID
    • 有效用户ID=我们的用户ID
    • 保存的设置用户ID=我们的用户ID

现在守护进程可以安全地代表我们执行命令,因为它只能访问我们通常可以访问的文件,我们没有额外的权限。

以这种方式使用保存的设置用户ID,只有在需要提升特权的时候,我们通过设置程序文件的设置用户ID而得到的额外权限。然而,其他时间进程在运行时只具有普通的权限。如果进程不能在其结束部分切换回保存的设置用户ID,那么就不得不在全部运行时间都保持额外的权限(这可能会造成麻烦)。

解释器文件

所有现今的UNIX系统都支持解释器文件(interpreter file)。这种文件是文本文件,其起始行的形式是:

1
#! pathname [ optional-argument]

在感叹号和pathname之间的空格是可选的。最常见的解释器文件以下列行开始:

1
#!/bin/sh

pathname通常是绝对路径名,对它不进行什么特殊的处理(不使用PATH进行路径搜索)。对这种文件的识别是由内核作为exec系统调用处理的一部分来完成的。内核使调用exec函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname所指定的文件。一定要将解释器文件(文本文件,它以!开头)和解释器(由该解释器文件第一行中的pathname指定)区分开来。

让我们观察一个实例,从中可了解当被执行的文件是个解释器文件时,内核如何处理exec函数的参数及该解释器文件第一行的可选参数。程序调用exec执行一个解释器文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "apue.h"
#include <sys/wait.h>

int
main(void)
{
pid_t pid;

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* child */
if (execl("/home/sar/bin/testinterp",
"testinterp", "myarg1", "MY ARG2", (char *)0) < 0)
err_sys("execl error");
}
if (waitpid(pid, NULL, 0) < 0) /* parent */
err_sys("waitpid error");
exit(0);
}

下面先显示要被执行的该解释器文件的内容(只有一行),接着是运行程序得到的结果。

1
2
3
4
5
6
7
8
9
$ cat /home/max/bin/teatinterp
#!/hone/sax/bin/echoarg foo

$ ./a.out
argv[0]: /home/sar/bin/echoarg
argv[1]: foo
argv[2]: /hone/sar/bin/testinterp
argv[3]: myarg1
argv[4]: MY ARG2

程序echoarg(解释器)回显每一个命令行参数。注意,当内核exec解释器(/home/sar/bin/echoarg)时,argv[0]是该解释器的pathnameargv[1]是解释器文件中的可选参数,其余参数是pathname(/home/sar/bin/testinterp)以及所示的程序中调用execl的第2个和第3个参数(myarg1MY ARG2)。调用execl时的argv[1]argv[2]已右移了两个位置。注意,内核取execl调用中的pathname而非第一个参数(testinterp),因为一般而言,parhname包含了比第一个参数更多的信息。

在解释器pathname后可跟随可选参数。如果一个解释器程序支持-f选项,那么在pathname后经常使用的就是f。例如,可以以下列方式执行awk(1)程序:

1
awk -f myfile

它告诉awk从文件myfile中读awk程序。

在解释器文件中使用-f选项,可以写成:

1
#!/bin/awk -f

例如,下面展示了在/usr/local/bin/awkexample中的一个解释器文件程序。

1
2
3
4
5
6
7
#!/usr/bin/awk -f
# Note: on Solario, use nawk instead
BEGIN {
for (i = 0; i < ARGC; i ++)
prints "ARGV[%d] = %s\n", i, ARGV[i]
exit
}

如果路径前缀之一是/usr/local/bin。则可以用下列方式执行程序

1
2
3
4
5
$ awkexample file1 FILENAME2 f3
ARGV[0] = awk
ARGV[1] = file1
ARGV[2] = FILENAME2
ARGV[3] = f3

执行/bin/awk时,其命令行参数是:

1
/bin/awk -t /usr/local/bin/awkexample filel FILENAME2 f3

解释器文件的路径名(/usr/local/bin/awkexample)被传送给解释器。因为不能期望解释器(在本例中是/bin/awk)会使用PATH变量定位该解释器文件,所以只传送其路径名中的文件名是不够的,要将解释器文件完整的路径名传送给解释器。当awk读解释器文件时,因为#是awk的注释字符,所以它忽略第一行。

由于下述理由,解释器文件是有用的:

  1. 有些程序是用某种语言写的脚本,解释器文件可将这一事实隐藏起来。例如,只需使用下列命令行:awkexample optional-arguments,并不需要知道该程序实际上是一个awk脚本,否则就要awk -f awkexample opriomal-arguments
  2. 解释器脚本在效率方面也提供了好处。为了运行awk程序,它调用forkexecwait。于是,用一个shell脚本代替解释器脚本需要更多的开销。
  3. 解释器脚本使我们可以使用除/bin/sh以外的其他shell来编写shell脚本。当execlp找到一个非机器可执行的可执行文件时,它总是调用/bin/sh来解释执行该文件。但是,用解释器脚本则可简单地写成:#!/bin/csh

函数system

ISO C定义了system函数,但是其操作对系统的依赖性很强。POSIX.1包括了system接口,它扩展了ISO C定义,描述了system在POSIX.1环境中的运行行为。

1
2
#include <stdlib.h>
int system(const char *cmdstring);

如果cmdstring是一个空指针,则仅当命令处理程序可用时,system返回非0值,这一特征可以确定在一个给定的操作系统上是否支持system函数。在UNIX中,system总是可用的。因为system在其实现中调用了forkexecwaitpid,因此有3种返回值。

  1. fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置errno以指示错误类型。
  2. 如果exec失败(表示不能执行shell), 则其返回值如同shell执行了exit(127)一样
  3. 否则所有3个函数(forkexecwaitpid)都成功,那么system的返回值是shell的终止状态,其格式已在waitpid中说明。

程序是system函数的一种实现。它对信号没有进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include	<sys/wait.h>
#include <errno.h>
#include <unistd.h>

int
system(const char *cmdstring) /* version without signal handling */
{
pid_t pid;
int status;

if (cmdstring == NULL)
return(1); /* always a command processor with UNIX */

if ((pid = fork()) < 0) {
status = -1; /* probably out of processes */
} else if (pid == 0) { /* child */
execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127); /* execl error */
} else { /* parent */
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) {
status = -1; /* error other than EINTR from waitpid() */
break;
}
}
}
return(status);
}

shell的-c选项告诉shell程序取下一个命令行参数(在这里是cmdstring)作为命令输入(而不是从标准输入或从一个给定的文件中读命令)。shell对以null字节终止的命令字符串进行语法分析,将它们分成命令行参数。传递给shell的实际命令字符串可以包含任一有效的shell命令。例如,可以用<和>对输入和输出重定向。

如果不使用shell执行此命令,而是试图由我们自己去执行它,那将相当困难。首先,我们必须用execlp而不是execl。像shell那样使用PATH变量。我们必须将null字节终止的命令字符串分成各个命令行参数,以便调用execlp。最后,我们也不能使用任何一个shell元字符。

注意,我们调用_exit而不是exit。这是为了防止任一标准I/O缓冲(这些缓冲会在fork中由父进程复制到子进程)在子进程中被冲洗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include "apue.h"
#include <sys/wait.h>

int
main(void)
{
int status;

if ((status = system("date")) < 0)
err_sys("system() error");

pr_exit(status);

if ((status = system("nosuchcommand")) < 0)
err_sys("system() error");

pr_exit(status);

if ((status = system("who; exit 44")) < 0)
err_sys("system() error");

pr_exit(status);

exit(0);
}

运行程序得到:

1
2
3
4
5
6
7
8
9
10
11
12
$ ./a.out
Sat Feb 25 19:36:59 EST 2012
normal termination, exit status = 0

sh: nosuchcomnand: command not found
normal termination, exit atatus = 127
sar console Jan 1 14:59
sar ttys000 Feb 7 19:08
sar ttys001 Jan 15 15:28
sar ttys002 Jan 15 21:50
sar ttys003 Jan 21 16:02
nornal termination, exit status = 44

使用system而不是直接使用forkexec的优点是:system进行了所需的各种出错处理以及各种信号处理。在UNIX的早期系统中没有waitpid函数,于是父进程用下列形式的语句等待子进程

1
while ((lastpid = wait(&status)) != pid && lastpid != -1) ;

如果调用system的进程在调用它之前已经生成子进程,那么将引起问题。因为上面的while语句一直循环执行。直到由system产生的子进程终止才停止,如果不是用pid标识的任一子进程在pid子进程之前终止,则它们的进程ID和终止状态都被while语句丢弃。实际上,由于wait不能等待一个指定的进程以及其他一些原因,POSIX.1才定义了waitpid函数。如果不提供waitpid函数,popenpclose函数也会发生同样的问题。

如果在一个设置用户ID程序中调用system,那会发生什么呢?这是一个安全性方面的漏洞,决不应当这样做。程序是一个简单程序,它只是对其命令行参数调用system函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include "apue.h"

int
main(int argc, char *argv[])
{
int status;
if (argc < 2)
err_quit("command-line argument required");
if ((status = system(argv[1])) < 0)
err_sys("system() error");
pr_exit(status);
exit(0);
}

将此程序编译成可执行目标文件tsys。我们给予tsys程序的超级用户权限在system中执行了forkexec之后仍被保持下来。有些实现通过更改/bin/sh,当有效用户ID与实际用户ID不匹配时,将有效用户ID设置为实际用户ID,这样可以关闭上述安全漏洞。在这些系统中,上述示例的结果就不会发生。不管调用system的程序设置用户ID位状态如何,都会打印出相同的有效用户ID。

如果一个进程正以特殊的权限(设置用户ID或设置组ID)运行,它又想生成另一个进程执行另一个程序,则它应当直接使用forkexec,而且在fork之后、exec之前要更改回普通权限。设置用户ID或设置组ID程序决不应调用system函数。

这种警告的一个理由是:system调用shell对命令字符串进行语法分析,而shell使用IFS变量作为其输入字段分隔符。早期的shell版本在被调用时不将此变量重置为普通字符集。这就允许一个恶意的用户在调用system之前设置IFS,造成system执行一个不同的程序。

进程会计

大多数UNIX系统提供了一个选项以进行进程会计(process accounting)处理。启用该选项后,每当进程结束时内核就写一个会计记录。典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的CPU时间总量、用户ID和组ID、启动时间等。

一个至今没有说明的函数(acct)启用和禁用进程会计。唯一使用这一函数的是accton(8)命令。超级用户执行一个带路径名参数的accton命令启用会计处理。会计记录写到指定的文件中,在FreeBSD和MacOSX中,该文件通常是/var/account/acct;在Linux中,该文件是/var/account/pacct;在Solaris中,该文件是/var/adm/pacct。执行不带任何参数的accton命令则停止会计处理。会计记录结构定义在头文件<sys/acct.h>中,虽然每种系统的实现各不相同,但会计记录样式基本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef u_short comp_t;
/* 3-bit base 8 exponent;13-bit fraction*/
struct acct {
char ac_flag; /* flag (see Figuze 8.26) */
char ac_stat; /* ternination status (signal 6 core tlag only) */
/*(Solaris only) */
usd_t ac_uid; /*real user ID*/
gid_t ac_gid; /* real group tD*/
dev_t ac_tty; /*controlling texminal*/
time_t ac_btime;/*starting calendar time*/
comp_t ac_utime;/*user cru time*/
comp_t ac_stime;/* aystem cru time*/
comp_t ac_etime;/*clapsed time*/
comp_t ac_mem; /*average memory usage*/
coap_t ac_io; /* bytes transferred (by read and write) */
/* "blocks" on BSD systems*/
comp_t ac_rw; /* blocks read or written*/
/* (not present on B80 systens) */
char ac_comm[8];/*comand name: [8] for solaris,"
/* [10] for Mac OS X, [16] for FreeBSD, and*/
/* [17] for Linux*/

ac_flag成员记录了进程执行期间的某些事件。

ac_flag 说明
AFORK 进程是由fork产生的,但从未调用exec
ASU 进程使用超级用户特权
ACORE 进程转储core
AXSIG 进程由一个信号杀死
AEXPND 扩展的会计条目
ANVER 新记录格式

会计记录所需的各个数据(各CPU时间、传输的字符数等)都由内核保存在进程表中,并在一个新进程被创建时初始化(如fork之后在子进程中)。进程终止时写一个会计记录。这产生两个后果。

  • 第一,我们不能获取永远不终止的进程的会计记录。像init这样的进程在系统生命周期中一直在运行,并不产生会计记录。这也同样适合于内核守护进程,它们通常不会终止。
  • 第二,在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。为了确定启动顺序,需要读全部会计文件,并按启动日历时间进行排序。这不是一种很完善的方法,因为在一个给定的秒中可能启动了多个进程。

会计记录对应于进程而不是程序。在fork之后,内核为子进程初始化一个记录,而不是在一个新程序被执行时初始化。虽然exec并不创建一个新的会计记录,但相应记录中的命令名改变了,AFORK标志则被消除。这意味着,如果一个进程顺序执行了3个程序,只会写一个会计记录。在该记录中的命令名对应于程序C,但CPU时间是程序A、B和C之和。

为了得到某些会计数据以便查看,我们编写了测试程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "apue.h"

int
main(void)
{
pid_t pid;

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid != 0) { /* parent */
sleep(2);
exit(2); /* terminate with exit status 2 */
}

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid != 0) { /* first child */
sleep(4);
abort(); /* terminate with core dump */
}

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid != 0) { /* second child */
execl("/bin/dd", "dd", "if=/etc/passwd", "of=/dev/null", NULL);
exit(7); /* shouldn't get here */
}

if ((pid = fork()) < 0)
err_sys("fork error");
else if (pid != 0) { /* third child */
sleep(8);
exit(0); /* normal exit */
}

sleep(6); /* fourth child */
kill(getpid(), SIGKILL); /* terminate w/signal, no core dump */
exit(6); /* shouldn't get here */
}

运行该测试程序,然后从会计记录中选择一些字段并打印出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include "apue.h"
#include <sys/acct.h>

#if defined(BSD) /* different structure in FreeBSD */
#define acct acctv2
#define ac_flag ac_trailer.ac_flag
#define FMT "%-*.*s e = %.0f, chars = %.0f, %c %c %c %c\n"
#elif defined(HAS_AC_STAT)
#define FMT "%-*.*s e = %6ld, chars = %7ld, stat = %3u: %c %c %c %c\n"
#else
#define FMT "%-*.*s e = %6ld, chars = %7ld, %c %c %c %c\n"
#endif
#if defined(LINUX)
#define acct acct_v3 /* different structure in Linux */
#endif

#if !defined(HAS_ACORE)
#define ACORE 0
#endif
#if !defined(HAS_AXSIG)
#define AXSIG 0
#endif

#if !defined(BSD)
static unsigned long
compt2ulong(comp_t comptime) /* convert comp_t to unsigned long */
{
unsigned long val;
int exp;

val = comptime & 0x1fff; /* 13-bit fraction */
exp = (comptime >> 13) & 7; /* 3-bit exponent (0-7) */
while (exp-- > 0)
val *= 8;
return(val);
}
#endif

int
main(int argc, char *argv[])
{
struct acct acdata;
FILE *fp;

if (argc != 2)
err_quit("usage: pracct filename");
if ((fp = fopen(argv[1], "r")) == NULL)
err_sys("can't open %s", argv[1]);
while (fread(&acdata, sizeof(acdata), 1, fp) == 1) {
printf(FMT, (int)sizeof(acdata.ac_comm),
(int)sizeof(acdata.ac_comm), acdata.ac_comm,
#if defined(BSD)
acdata.ac_etime, acdata.ac_io,
#else
compt2ulong(acdata.ac_etime), compt2ulong(acdata.ac_io),
#endif
#if defined(HAS_AC_STAT)
(unsigned char) acdata.ac_stat,
#endif
acdata.ac_flag & ACORE ? 'D' : ' ',
acdata.ac_flag & AXSIG ? 'X' : ' ',
acdata.ac_flag & AFORK ? 'F' : ' ',
acdata.ac_flag & ASU ? 'S' : ' ');
}
if (ferror(fp))
err_sys("read error");
exit(0);
}

BSD派生的平台不支持ac_stat成员,所以我们在支持该成员的平台上定义了HAS_AC_STAT常量。为了进行测试,执行下列操作步骤,

  1. 成为超级用户,用accton命令启用会计处理。注意,当此命令结束时,会计处理已经启用,因此在会计文件中的第一个记录应来自这一命令。
  2. 终止超级用户shell,运行程序。这会追加6个记录到会计文件中(超级用户shell一个、父进程一个、4个子进程各一个)。在第二个子进程中,execl并不创建一个新进程,所以对第二个进程只有一个会计记录。
  3. 成为超级用户,停止会计处理。因为在accton命令终止时已经停止会计处理,所以不会在会计文件中增加一个记录。
  4. 运行程序,从会计文件中选出字段并打印。

用户标识

任一进程都可以得到其实际用户ID和有效用户ID及组ID。但是,我们有时希望找到运行该程序用户的登录名。我们可以调用getpwuid(getuid()),但是如果一个用户有多个登录名,这些登录名又对应着同一个用户ID,又将如何呢? 系统通常记录用户登录时使用的名字,用getlogin函数可以获取此登录名

1
2
3
#include <unistd.h>
char *getlogin (void);
// 返回值:若成功,返回指向登录名字符串的指针,若出错,返回NULL

如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon)。给出了登录名,就可用getpwnam在口令文件中查找用户的相应记录,从而确定其登录shell等。

进程调度

UNIX系统历史上对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级是由内核确定的。进程可以通过调整nice值选择以更低优先缓运行(通过调整nice值降低它对CPU的占有,因此该进程是“友好的”)。只有特权进程允许提高调度权限。POSIX实时扩展增加了在多个调度类别中选择的核口以进一步细调行为。

Single UNIX Specification 中nice值的范围在0~(2*NZERO)-1之间,有些实现支持0~2*NZEROnice值越小,优先级越高。虽然这看起来有点倒退,但实际上是有道理的:你越友好,你的调度优先级就越低。NZERO是系统默认的nice值。

注意,定义NZERO的头文件因系统而异。除了头文件以外,Linux3.2.0可以通过非标准的sysconf参数(_SC_NZERO)来访问NZERO的值。

进程可以通过nice函数获取或更改它的nice值。使用这个函数,进程只能影响自己的nice值,不能影响任何其他进程的nice值。

1
2
3
#include <unistd.h>
int nice(int incr);
// 返回值:若成功,返回新的nice值NZERO;若出错,返回-1

incr参数被增加到调用进程的nice值上。如果incr太大,系统直接把它降到最大合法值,不给出提示。类似地,如果incr太小,系统也会无声息地把它提高到最小合法值。由于-1是合法的成功返回值,在调用nice函数之前需要清楚errno,在nice函数返回-1时,需要检查它的值。如果nice调用成功,并且返回值为-1,那么errno仍然为0。如果errno不为0,说明nice调用失败。

getpriority函数可以像nice函数那样用于获取进程的nice值,但是getpriority还可以获取一组相关进程的nice

1
2
3
#include <sys/resource.h>
int getpriority(int which, id_t who);
// 返回值:若成功。返回-NZERO~NZERO-1之间的nice值;若出错,返回-1

which参数可以取以下三个值之一:PRIO_PROCESS表示进程,PRIO_PGRP表示进程组,PRIO_USER表示用户ID,which参数控制who参数是如何解释的,who参数选择感兴趣的一个或多个进程。如果who参数为0,表示调用进程、进程组或者用户(取决于which参数的值)。当which设为PRIO_USER并且who为0时,使用调用进程的实际用户ID。如果which参数作用于多个进程,则返回所有作用进程中优先级最高的(最小的nice值)。

setpriority函数可用于为进程、进程组和属于特定用户ID的所有进程设置优先级。

1
2
3
#include <sys/resource.h>
int setpriority(int which, id_t who, int value);
// 返回值:若成功,返回0;若出错,返回-1

参数whichwhogetpriority函数中相同。value增加到NZERO上,然后变为新的nice值。

程序度最了调整进程nice值的效果。两个进程并行运行,各自增加自己的计数器。父进程使用了默认的nice值,子进程以可选命令参数指定的调整后的nice值运行。运行10s后,两个进程都打印各自的计数值并终止。通过比较不同nice值的进程的计数值的差异,我们可以了解nice值时如何影响进程调度的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include "apue.h"
#include <errno.h>
#include <sys/time.h>

#if defined(MACOS)
#include <sys/syslimits.h>
#elif defined(SOLARIS)
#include <limits.h>
#elif defined(BSD)
#include <sys/param.h>
#endif

unsigned long long count;
struct timeval end;

void
checktime(char *str)
{
struct timeval tv;

gettimeofday(&tv, NULL);
if (tv.tv_sec >= end.tv_sec && tv.tv_usec >= end.tv_usec) {
printf("%s count = %lld\n", str, count);
exit(0);
}
}

int
main(int argc, char *argv[])
{
pid_t pid;
char *s;
int nzero, ret;
int adj = 0;

setbuf(stdout, NULL);
#if defined(NZERO)
nzero = NZERO;
#elif defined(_SC_NZERO)
nzero = sysconf(_SC_NZERO);
#else
#error NZERO undefined
#endif
printf("NZERO = %d\n", nzero);
if (argc == 2)
adj = strtol(argv[1], NULL, 10);
gettimeofday(&end, NULL);
end.tv_sec += 10; /* run for 10 seconds */

if ((pid = fork()) < 0) {
err_sys("fork failed");
} else if (pid == 0) { /* child */
s = "child";
printf("current nice value in child is %d, adjusting by %d\n",
nice(0)+nzero, adj);
errno = 0;
if ((ret = nice(adj)) == -1 && errno != 0)
err_sys("child set scheduling priority");
printf("now child nice value is %d\n", ret+nzero);
} else { /* parent */
s = "parent";
printf("current nice value in parent is %d\n", nice(0)+nzero);
}
for(;;) {
if (++count == 0)
err_quit("%s counter wrap", s);
checktime(s);
}
}

执行该程序两次:一次用默认的nice值,另一次用最高有效nice值(最低调度优先级)。程序运行在单处理器Linux系统上,以显示调度程序如何在不同nice值的进程间进行CPU的共享。否则,对于有空闲资源的系统,如多处理器系统(或多核CPU),两个进程可能无需共享CPU(运行在不同的处理器上),就无法看出具有不同nice值的两个进程的差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ ./a.out
NZERO 20
current nice value in parent 10 20
current nice value in child is 20, adjusting by 0
now child nice value is 20
child count = 1859362
parent count = 1845338
$ ./a.out 20
NZERO = 20
current nice value in parent is 20
current nice vaiue in child is 20, adjuating by 20
now child nice value is 39
parent count = 3595709
child count = 52111

当两个进程的nice值相同时,父进程占用50.2%的CPU,子进程占用49.8%的CPU。可以看到,两个进程被有效地进行了平等对待。相比之下,当子进程有最高可能nice值(最低优先级)时,我们看到父进程占用98.5%的CPU,而子进程只占用1.5%的CPU。这些值取决于进程调度程序如何使用nice值。因此不同的UNIX系统会产生不同的CPU占用比。

进程时间

我们可以度量3个时间:墙上时钟时间、用户CPU时间和系统CPU时间。任一进程都可调用times函数获得它自己以及已终止子进程的上述值。

1
2
3
#include <sys/times.h>
clock_t times (struct tms *buf));
// 返回值:若成功,返回流逝的墙上时钟时间,若出错,返回-1

此函数填写由buf指向的tms结构,该结构定义如下:

1
2
3
4
5
6
struct tms {
clock_t tms_utime; /* user cpu time */
clock_t tms_stime; /* system CPU time */
clock_t tms_cutime; /* user cru time, terninated children */
clock_t tms_cstime; /* aystem CPU time, terminated children */
}

注意,此结构没有包含墙上时钟时间。times函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用其相对值。所有由此函数返回的clock_t值都用_SC_CLK_TCK(由sysconf函数返回的每秒时钟滴答数)转换成秒数。

程序将每个命令行参数作为shell命令串执行,对每个命令计时,并打印从tms结构取得的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include "apue.h"
#include <sys/times.h>

static void pr_times(clock_t, struct tms *, struct tms *);
static void do_cmd(char *);

int
main(int argc, char *argv[])
{
int i;

setbuf(stdout, NULL);
for (i = 1; i < argc; i++)
do_cmd(argv[i]); /* once for each command-line arg */
exit(0);
}

static void
do_cmd(char *cmd) /* execute and time the "cmd" */
{
struct tms tmsstart, tmsend;
clock_t start, end;
int status;

printf("\ncommand: %s\n", cmd);

if ((start = times(&tmsstart)) == -1) /* starting values */
err_sys("times error");

if ((status = system(cmd)) < 0) /* execute command */
err_sys("system() error");

if ((end = times(&tmsend)) == -1) /* ending values */
err_sys("times error");

pr_times(end-start, &tmsstart, &tmsend);
pr_exit(status);
}

static void
pr_times(clock_t real, struct tms *tmsstart, struct tms *tmsend)
{
static long clktck = 0;

if (clktck == 0) /* fetch clock ticks per second first time */
if ((clktck = sysconf(_SC_CLK_TCK)) < 0)
err_sys("sysconf error");

printf(" real: %7.2f\n", real / (double) clktck);
printf(" user: %7.2f\n",
(tmsend->tms_utime - tmsstart->tms_utime) / (double) clktck);
printf(" sys: %7.2f\n",
(tmsend->tms_stime - tmsstart->tms_stime) / (double) clktck);
printf(" child user: %7.2f\n",
(tmsend->tms_cutime - tmsstart->tms_cutime) / (double) clktck);
printf(" child sys: %7.2f\n",
(tmsend->tms_cstime - tmsstart->tms_cstime) / (double) clktck);
}

进程关系

终端登录

当系统自举时,内核创建进程ID为1的进程,也就是init进程。init进程使系统进入多用户模式。init读取文件/etc/ttys,对每一个允许登录的终端设备,init调用一次fork,它所生成的子进程则exec getty程序。

getty对终端设备调用open函数,以读、写方式将终端打开。getty输出“login:”之类的信息,并等待用户键入用户名。如果终端支持多种速度,则getty可以测试特殊字符以便适当地更改终端速度。当用户键入了用户名后,getty的工作就完成了。然后它以类似于下列的方式调用login程序:

1
execle("/bin/login", "login", "-p", username, (char *)0, envp);

init以一个空环境调用gettygetty以终端名和在gettytab中说明的环境字符串为login创建一个环境(envp参数)。-p标志通知login保留传递给它的环境,也可将其他环境字符串加到该环境中,但是不要替换它。图9-2显示了login刚被调用后这些进程的状态。

因为最初的init进程具有超级用户特权,所以图9-2中的所有进程都有超级用户特权。图9.2中底部3个进程的进程ID相同,因为进程ID不会因执行exec而改变。并且,除了最初的init进程,所有进程的父进程ID均为1,login能处理多项工作。因为它得到了用户名,所以能调用getpwnam取得相应用户的口令文件登录项。然后调用getpass(3)以显示提示“Password:”,接着读用户键入的口令。它调用crypt(3)将用户键入的口令加密,并与该用户在阴影口令文件中登录项的pw_passwd字段相比较。

如果用户正确登录,login就将完成如下工作。

  • 将当前工作目录更改为该用户的起始目录(chdir)。
  • 调用chown更改该终端的所有权,使登录用户成为它的所有者。
  • 将对该终端设备的访问权限改变成“用户读和写”。
  • 调用setgidinitgroups设置进程的组ID。
  • 用login得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户名(USER和LOGNAME)以及一个系统默认路径(PATH)。

login进程更改为登录用户的用户ID(setuid)并调用该用户的登录shell,其方式类似于:

1
execl("/bin/sh", "-sh", (char *)0);

argv[0]的第一个字符负号是一个标志,表示该shell被作为登录shell调用。shell可以查看此字符,并相应地修改其启动过程。

进程组

每个进程除了有一进程ID之外,还属于一个进程组。进程组是一个或多个进程的集合。通常,它们是在同一作业中结合起来的,同一进程组中的各进程接收来自同一终端的各种信号。每个进程组有一个唯一的进程组ID。进程组ID类似于进程ID一它是一个正整数,并可存放在pid_t数据类型中。函数getpgrp返回调用进程的进程组ID。

1
2
3
#include <unistd.h>
pid_t getpgrp(void);
// 返回值:调用进程的进程组ID

每个进程组有一个组长进程。组长进程的进程组ID等于其进程ID。进程组组长可以创建一个进程组、创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间区间称为进程组的生命期。某个进程组中的最后一个进程可以终止,也可以转移到另一个进程组。

进程调用setpgid可以加入一个现有的进程组成者创建一个新进程组。

1
2
3
#include <unistd.h>
int setpgid(pid_t pid, pid_t pgid);
// 返回值:若成功,返回0;若出错:返回-1

setpgid函数将pid进程的进程组ID设置为pgid。如果这两个参数相等,则由pid指定的进程变成进程组组长。如果pid是0,则使用调用者的进程ID。另外,如果pgid是0,则由pid指定的进程ID用作进程组ID。

一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用了exec后,它就不再更改该子进程的进程组ID。

在大多数作业控制shell中,在fork之后调用此函数,使父进程设置其子进程的进程组ID,并且也使子进程设置其自己的进程组ID。这两个调用中有一个是冗余的,但让父进程和子进程都这样做可以保证,在父进程和子进程认为子进程已进入了该进程组之前,这确实已经发生了。如果不这样做,在fork之后,由于父进程和子进程运行的先后次序不确定,会因为子进程的组员身份取决于哪个进程首先执行而产生竞争条件。

会话

会话(session)是一个或多个进程组的集合。

通常是由shell的管道将几个进程编成一组的。例如,图9-6中的安排可能是由下列形式的shell命令形成的:

1
2
proc1 | proc2 &
proc3 | proc4 | proc5

进程调用setsid函数建立一个新会话。

1
2
3
#include <unistd.h>
pid_t setsid (void);
// 返回值:若成功,返回进程组ID:若出错,返回-1

如果调用此函数的进程不是一个进程组的组长,则此函数创建一个新会话。具体会发生以下3件事。

  1. 该进程变成新会话的会话首进程(session leader,会话首进程是创建该会话的进程)。此时,该进程是新会话中的唯一进程。
  2. 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID。
  3. 该进程没有控制终端。如果在调用setsid之前该进程有一个控制终端,那么这种联系也被切断。

如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,而其进程ID则是新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长。Single UNIX Specification只说明了会话首进程,而没有类似于进程ID和进程组ID的会话ID。显然,会话首进程是具有唯一进程ID的单个进程,所以可以将会话首进程的进程ID视为会话ID。会话ID这一概念是由SVR4引入的。getsid函数返回会话首进程的进程组ID。

1
2
3
#include <unistd.h>
pid_t getsid (pid_t pid);
// 返回值:若成功,返回会话首进程的进程组ID;若出错,返回-1

如若pid是0,getsid返回调用进程的会话首进程的进程组ID。出于安全方面的考虑,一些实现有如下限制:如若pid并不属于调用者所在的会话,那么调用进程就不能得到该会话首进程的进程组ID。

P245

控制终端

会话和进程组还有一些其他特性。

  • 一个会话可以有一个控制终端(controlling terminal)。这通常是终端设备(在终端登录情况下)或伪终端设备(在网络登录情况下)。
  • 建立与控制终端连接的会话首进程被称为控制进程(controlling process)。
  • 一个会话中的几个进程组可被分成一个前台进程组(foreground process group)以及一个或多个后台进程组(background process group)。
  • 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组。
  • 无论何时健入终端的中断键(常常是Delete或Ctrl+C),都会将中断信号发送至前台进程组的所有进程。
  • 无论何时键入终端的退出键(常常是Crtl+\),都会将退出信号发送至前台进程组的所有进程。
  • 如果终端接口检测到调制解调器(或网络)已经断开连接,则将挂断信号发送至控制进程(会话首进程)。

通常,我们不必担心控制终端,登录时,将自动建立控制终端。

有时不管标准输入、标准输出是否重定向,程序都要与控制终端交互作用。保证程序能与控制终端对话的方法是open文件/dev/tty。在内核中,此特殊文件是控制终端的同义语。自然地,如果程序没有控制终端,则对于此设备的open将失败。

函数tcgetpgrp、tcsetpgrp和tcgetsid

需要有一种方法来通知内核哪一个进程组是前台进程组,这样,终端设备驱动程序就能知道将终端输入和终端产生的信号发送到何处。

1
2
3
4
5
#include <unistd.h>
pid_t tcgetpgrp(int fd);
// 返回值:若成功,返回前台进程组ID;若出错,返回-1
int tcsetpqrp(int fd, pid_t psrpid);
// 返回值,若成功,返回0,若出错,返回-1

函数tcgetpgrp返回前台进程组ID,它与在fd上打开的终端相关联。如果进程有一个控制终端,则该进程可以调用tcsetpgrp将前台进程组ID设置为pgrpidpgrpid值应当是在同一会话中的一个进程组的ID。fd必须引用该会话的控制终端。大多数应用程序并不直接调用这两个函数。它们通常由作业控制shell调用。

给出控制TTY的文件描述符,通过tcgetsid函数,应用程序就能获得会话首进程的进程组ID。

1
2
3
#include <termios.h>
pid_t tcgetsid(int fd);
// 返回值:若成功,返回会话首进程的进程组ID,若出错:返回-1

需要管理控制终端的应用程序可以调用tcgetsid函数识别出控制终端的会话首进程的会话ID(它等价于会话首进程的进程组ID)。

作业控制

作业控制允许在一个终端上启动多个作业(进程组),它控制哪一个作业可以访问该终端以及哪些作业在后台运行。作业控制要求以下3种形式的支持。

  1. 支持作业控制的shell。
  2. 内核中的终端驱动程序必须支持作业控制。
  3. 内核必须提供对某些作业控制信号的支持。

从shell使用作业控制功能的角度观察,用户可以在前台或后台启动一个作业。一个作业只是几个进程的集合,通常是一个进程管道。例如:

1
vi main.c

在前台启动了只有一个进程组成的作业。下面的命令;

1
2
pr *.c | lpr &
make all &

在后台启动了两个作业。这两个后台作业调用的所有进程都在后台运行。

当启动一个后台作业时,shell赋予它一个作业标识符,并打印一个或多个进程ID。下面的脚本显示了Kornshell是如何处理这一点的。

1
2
3
4
5
6
7
$ make all > Make.out a
[1] 1475
$ pr *.c | lpr &
[2] 1490
$ 键入回车
[2] + Done pr *.c | lpr &
[1] + Done make all > Make.out &

make是作业编号1,所启动的进程ID是1475。下一个管道是作业编号2。其第一个进程的进程ID是1490,当作业完成而且键入回车时,shell通知作业已经完成。键入回车是为了让shell打印其提示符,shell并不在任意时刻打印后台作业的状态改变——它只在打印其提示符让用户输入新的命令行之前才这样做。如果不这样处理,则当我们正输入一行时,它也可能输出,于是,就会引起混乱。

我们可以键入一个影响前台作业的特殊字符一挂起键(通常采用Ctrl+Z),与终端驱动程序进行交互作用。键入此字符使终端驱动程序将信号SIGTSTP发送至前台进程组中的所有进程,后台进程组作业则不受影响。实际上有3个特殊字符可使终端驱动程序产生信号,并将它们发送至前台进程组,它们是:

  • 中断字符(一般采用Delete或Ctrl+C)产生SIGINT
  • 退出字符(一般采用Ctrl+\)产生SIGQUIT
  • 挂起字符(一般采用Ctrl+Z)产生SIGTSTP

终端驱动程序必须处理与作业控制有关的另一种情况。我们可以有一个前台作业,若干个后台作业,这些作业中哪一个接收我们在终端上键入的字符呢?只有前台作业接收终端输入。如果后台作业试图读终端,这并不是一个错误,但是终端驱动程序将检测这种情况,并且向后台作业发送信号SIGTTIN。该信号通常会停止此后台作业,而shell则向有关用户发出这种情况的通知,然后用户就可用shell命令将此作业转为前台作业运行,于是它就可读终端。

shell执行程序

让我们检验一下shell是如何执行程序的,以及这与进程组、控制终端和会话等概念的关系。为此,再次使用ps命令。

首先使用不支持作业控制的、在Solaris上运行的经典Bourne shell。如果执行:

1
ps -o pid,ppid,prid,sid,comm

则其输出可能是:

1
2
3
PID PPID PGID BID COMMAND
949 947 949 949 sh
1774 949 949 949 ps

ps的父进程是shell,这正是我们所期望的,shell和ps命令两者位于同一会话和前台进程组(949)中。因为我们是用一个不支持作业控制的shell执行命令时得到该值的,所以称其为前台进程组。

如果在后台执行命令:

1
ps -o pid,ppid,paid, oid, comm &

则唯一改变的值是命令的进程ID;

1
2
3
PID PPID PGID SID COMMAND
949 947 949 949 sh
1812 949 949 949 ps

因为这种shell不知道作业控制,所以没有将后台作业放入自己的进程组,也没有从后台作业处取走控制终端。

现在看一看Bourne shell如何处理管道。执行下列命令:

1
ps -o pid,ppid,pyid,sid,comm | catl

其输出是:

1
2
3
4
PID PPID PGID SID COMMAND
949 947 949 949 sh
1823 949 949 949 catl
1824 1823 949 949 ps

注意,管道中的最后一个进程是shell的子进程,该管道中的第一个进程则是最后一个进程的子进程。从中可以看出,shell fork一个它自身的副本,然后此副本再为管道中的每条命令各fork一个进程。

如果在后台执行此管道

1
ps -o pid, ppid, paid, sid, comm | catl &

则只改变进程ID。因为shell并不处理作业控制,后台进程的进程组ID仍是949,如何会话的进程组ID一样。

如果一个后台进程试图读其控制终端,则会发生什么呢?例如,若执行:

1
cat > temp.foo &

在有作业控制时,后台作业被放在后台进程组,如果后台作业试图读控制终端,则会产生信号SIGTTIN。在没有作业控制时,其处理方法是: 如果该进程自己没有重定向标准输入,则shell自动将后台进程的标准输入重定向到/dev/null。读/dev/null则产生一个文件结束。这就意味着后台cat进程立即读到文件尾,并正常终止。

前面说明了对后台进程通过其标准输入访问控制终端的适当的处理方法,但是,如果一个后台进程打开/dev/tty并且读该控制终端,又将怎样呢?对此问题的回答是“看情况”。但是这很可能不是我们所期望的。例如:

1
crypt < salazies | lpr &

就是这样的一条管道。我们在后台运行它,但是crypt程序打开/dev/tty,更改终端的特性(禁止回显),然后从该设备读,最后重置该终端特性。当执行这条后台管道时,crypt在终端上打印提示符“Password:”,但是shell读取了我们所输入的加密口令,并试图执行以加密口令为名称的命令。我们输送给shell的下一行则被crypt进程取为口令行,于是saiaries也就不能正确地被译码,结果将一堆无用的信息送到了打印机。在这里,我们有了两个进程,它们试图同时读同一设备,其结果则依赖于系统。前面说明的作业控制以较好的方式处理一个终端在多个进程间的转接。

返回到Bourneshell实例,在一条管道中执行3个进程,我们可以检验Bourne shell使用的进程控制方式

1
ps -o pid,ppid,paid, sid, comm | catl | cat2

其输出为:

1
2
3
4
5
PID PPID PGID SID COMMAND
949 947 949 949 sh
1988 949 949 949 cat2
1989 1988 949 949 ps
1990 1988 949 949 cat1

信号

引言

信号是软件中断。很多比较重要的应用程序都需处理信号,信号提供了一种处理异步事件的方法,例如,终端用户键入中断键,会通过信号机制停止一个程序,或及早终止管道中的下一个程序。

信号概念

首先,每个信号都有一个名字。这些名字都以3个字符SIG开头。例如,SIGABRT是夭折信号,当进程调用abort函数时产生这种信号。SIGALRM是闹钟信号,由alarm函数设置的定时器超时后将产生此信号。

在头文件<signal.h>中,信号名都被定义为正整数常量(信号编号)。实际上,实现将各信号定义在另一个头文件中,但是该头文件又包括在<signal.h>中。内核包括对用户级应用程序有意义的头文件,这被认为是一种不好的形式,所以如若应用程序和内核两者都需使用同一定义,那么就将有关信息放置在内核头文件中,然后用户级头文件再包括该内核头文件。

不存在编号为0的信号,kill函数对信号编号0有特殊的应用。POSIX.1将此种信号编号值称为空信号。很多条件可以产生信号。

  • 当用户按某些终端键时,引发终端产生的信号。在终端上按Delete键通常产生中断信号(SIGINT)。这是停止一个已失去控制程序的方法。
  • 硬件异常产生信号:除数为0、无效的内存引用等。这些条件通常由硬件检测到,并通知内核。然后内核为该条件发生时正在运行的进程产生适当的信号。
  • 进程调用kill(2)函数可将任意信号发送给另一个进程或进程组。自然,对此有所限制:接收信号进程和发送信号进程的所有者必须相同,或发送信号进程的所有者必须是超级用户。
  • 用户可用kill(1)命令将信号发送给其他进程。此命令只是kill函数的接口。常用此命令终止一个失控的后台进程。
  • 当检测到某种软件条件已经发生,并应将其通知有关进程时也产生信号。这里指的不是硬件产生条件(如除以0),而是软件条件。例如SIGURG(在网络连接上传来带外的数据)、SIGPIPE(在管道的读进程已终止后,一个进程写此管道)以及SIGALRM(进程所设置的定时器已经超时)。

信号是异步事件的经典实例。产生信号的事件对进程而言:是随机出现的。进程不能简单地测试一个变量(如errno)来判断是否发生了一个信号,而是必须告诉内核”在此信号发生时,请执行下列操作”。

在某个信号出现时,可以告诉内核按下列3种方式之一进行处理,我们称之为信号的处理或与信号相关的动作。

  1. 忽略此信号。大多数信号都可使用这种方式进行处理,但有两种信号却决不能被忽略。它们是SIGKILLSIGSTOP。这两种信号不能被忽略的原因是它们向内核和超级用户提供了使进程终止或停止的可靠方法。另外,如果忽略某些由硬件异常产生的信号(如非法内存引用或除以0),则进程的运行行为是未定义的。
  2. 捕捉信号。为了做到这一点,要通知内核在某种信号发生时,调用一个用户函数。在用户函数中,可执行用户希望对这种事件进行的处理。
  3. 执行系统默认动作。注意,对大多数信号的系统默认动作是终止该进程

图10-1列出了所有信号的名字,说明了哪些系统支持此信号以及对于这些信号的系统默认动作。在系统默认动作列,”终止+core”表示在进程当前工作目录的core文件中复制了该进程的内存映像(该文件名为core)。大多数UNIX系统调试程序都使用core文件检查进程终止时的状态。

在下列条件下不产生core文件:

  • 进程是设置用户ID的,而且当前用户并非程序文件的所有者;
  • 进程是设置组ID的,而且当前用户并非该程序文件的组所有者;
  • 用户没有写当前工作目录的权限;
  • 文件已存在,而且用户对该文件设有写权限;
  • 文件太大。

core文件的权限(假定该文件在此之前并不存在)通常是用户读/写。

下面逐一说明这些信号。

  • SIGABRT:调用abort函数时产生此信号。进程异常终止。
  • SIGALRM:当用alarm函数设置的定时器超时时,产生此信号。
  • SIGBUS:指示一个实现定义的硬件故障。当出现某些类型的内存故障时,实现常常产生此种信号。
  • SIGCANCEL:这是Solaris线程库内部使用的信号。它不适用于一般应用。
  • SIGCHLD:在一个进程终止或停止时,SIGCHLD信号被送给其父进程。按系统默认,将忽略此信号。如果父进程希望被告知其子进程的这种状态改变,则应捕捉此信号。信号捕捉函数中通常要调用一种wait函数以取得子进程ID和其终止状态。
  • SIGCONT:此作业控制信号发送给需要继续运行,但当前处于停止状态的进程。如果接收到此信号的进程处于停止状态,则系统默认动作是使该进程继续运行;否则默认动作是忽略此信号。
  • SIGEMT:指示一个实现定义的硬件故障
  • SIGFPE:此信号表示一个算术运算异常,如除以0、浮点溢出等。
  • SIGFREEZE:此信号仅由Solaris定义。 它用于通知进程在冻结系统状态之前需要采取特定动作,例如当系统进入休眠或挂起状态时可能需要做这种处理。
  • SIGHUP:如果终端接口检测到一个连接断开,则将此信号送给该终端相关的控制进程(会话首进程)。此信号被送给session结构中s_leader字段所指向的进程。仅当终端的CLOCAL标志没有设置时,在上述条件下才产生此信号。(如果所连接的终端是本地的,则设置该终端的CLOCAL标志。它告诉终端驱动程序忽略所有调制解调器的状态行。)
  • SIGILL:此信号表示进程已执行一条非法硬件指令。
  • SIGINFO:这是一种BSD信号,当用户按状态键(一般采用Ctrl+T)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程。此信号通常造成在终端上显示前台进程组中各进程的状态信息。
  • SIGINT:当用户按中断键(一般采用Delete或Ctrl+C)时,终端驱动程序产生此信号并发送至前台进程组中的每一个进程。当一个进程在运行时失控,特别是它正在屏幕上产生大量不需要的输出时,常用此信号终止它。
  • SIGIO:此信号指示一个异步I/O事件。对SIGIO的系统默认动作是终止或忽略。遭憾的是,这依赖于系统。
  • SIGIOT:这指示一个实现定义的硬件故障。
  • SIGJVM1:Solaris上为Java虚拟机预留的一个信号。
  • SIGJVM2:Solaris上为Java虚拟机预留的另一个信号。
  • SIGKILL:这是两个不能被捕捉或忽略信号中的一个。它向系统管理员提供了一种可以杀死任一进程的可靠方法。
  • SIGLOST:运行在Solaris NFsv4客户系统中的进程,恢复阶段不能重新获得锁,此时将由这个信号通知该进程。
  • SIGLWP:此信号由Solaris线程库内部使用,并不做一般使用。在FreeBSD中,SIGLMP是SIGTHR的别名。
  • SIGPIPE:如果在管道的读进程已终止时写管道,则产生此信号。当类型为SOCK_STREM的套接字已不再连接时,进程写该套接字也产生此信号。
  • SIGPOLL:这个信号在SUSv4中已被标记为弃用,将来的标准可能会将此信号移除。当在一个可轮询设备上发生一个特定事件时产生此信号。
  • SIGPROF:这个信号在SUSw4中已被标记为弃用,将来的标准可能会将此信号移除。当setitimer(2)函数设置的梗概统计间隔定时器(profiling interval timer)已经超时时产生此信号。
  • SIGPWR:这是一种依赖于系统的信号。它主要用于具有不间断电源(UPS)的系统。如果电源失效,则UPS起作用,而且通常软件会接到通知。在这种情况下,系统依靠蓄电池电源继续运行,所以无须做任何处理。但是如果蓄电池也将不能支持工作,则软件通常会再次接到通知,此时,系统必项使其各部分都停止运行。这时应当发送SIGPWR信号。
  • SIGQUIT:当用户在终端上按退出键(一般采用Ctrl+\)时,中断驱动程序产生此信号,并发送给前台进程组中的所有进程。此信号不仅终止前台进程组
    (如SIGINT所做的那样),同时产生一个core文件。
  • SIGSEGV:指示进程进行了一次无效的内存引用(通常说明程序有错,比如访问了一个未经初始化的指针)。
  • SIGSTKFLT:此信号仅由Linux定义。它出现在Linux的早期版本,企图用于数学协处理器的栈故障。该信号并非由内核产生,但仍保留以向后兼容。
  • SIGSTOP:这是一个作业控制信号,它停止一个进程。它类似于交互停止信号(SIGTSTP),但是SIGSTOP不能被捕捉或忽略。
  • SIGSYS:该信号指示一个无效的系统调用。由于某种未知原因,进程执行了一条机器指令,内核认为这是一条系统调用,但该指令指示系统调用类型的参数却是无效的。这种情况是可能发生的,例如,若用户编写了一道使用新系统调用的程序,然后运行该程序的二进制可执行代码,而所用的操作系统却是不支持该系统调用的较早版本,于是就出现上述情况。
  • SIGTERM:这是由kill命令发送的系统默认终止信号。由于该信号是由应用程序捕获的,使用SIGTERM也让程序有机会在退出之前做好清理工作,从而优雅地终止(相对于SIGKILL而言。SIGKILL不能被捕捉或者忽略)。
  • SIGTHAW:此信号仅由Solaris定义。在被挂起的系统恢复时,该信号用于通知相关进程,它们需要采取特定的动作。
  • SIGTHR:FreeBSD线程库预留的信号,它的值定义或与SIGLWP相同。
  • SIGTRAP:指示一个实现定义的硬件故障。
  • SIGTSTP:交互停止信号,当用户在终端上按挂起键(一般采用Ctrl+Z)时,终端驱动程序产生此信号。该信号发送至前台进程组中的所有进程。速憾的是,停止具有不同的含义。当讨论作业控制和信号时,我们谈及停止和继续作业。但是,终端驱动程序一直使用术语“停止”表示用Cul+S字符终止终端输出,为了继续启动该终端输出,则用Ctrl+Q字符。为此,终端驱动程序称产生交互停止信号的字符为挂起字符,而非停止字符。
  • SIGTTIN:当一个后台进程组进程试图读其控制终端时,终端驱动程序产生此信号。在下列例外情形下不产生此信号:
    • 读进程忽略或阻塞此信号;
    • 读进程所属的进程组是孤儿进程组,此时读操作返回出错,errno设置为EIO。
  • SIGTTOU:当一个后台进程组进程试图写其控制终端时,终端驱动程序产生此信号。与上面所述的SIGTTIN信号不同,一个进程可以选择允许后台进程写控制终端。如果不允许后台进程写,则与SIGTTIN相似,也有两种特殊情况:
    • 写进程忽略或阻塞此信号;
    • 写进程所属进程组是孤儿进程组。在第2种情况下不产生此信号,写操作返回出错,errno设置为EIO。
  • SIGURG:此信号通知进程已经发生一个紧急情况。在网络连接上接到带外的数据时,可选择地产生此信号。
  • SIGUSRI:这是一个用户定义的信号,可用于应用程序,
  • SIGUSR2:这是另一个用户定义的信号,与SIGUSR1相似,可用于应用程序。
  • SIGVTALRM:当一个由setitime(2)函数设置的虚拟间隔时间已经超时时,产生此信号。
  • SIGWAITING:此信号由Solaris线程库内部使用,不做他用。
  • SIGWINCH:内核维持与每个终端或伪终端相关联窗口的大小。进程可以用ioctl函数得到成设置窗口的大小。如果进程用ioctl的设置窗口大小命令更改了窗口大小,则内核将SIGWINCH信号发送至前台进程组。
  • SIGXCPU:Single UNIX Specification的XSI扩展支持资源限制的概念。如果进程超过了其软CPU时间限制,则产生此信号。
  • SIGXFSZ:如果进程超过了其软文件长度限制,则产生此信号。
  • SIGXRES:此信号仅由Solaris定义。可选择地使用此信号以通知进程超过了预配置的资源值。

函数signal

UNIX系统信号机制最简单的接口是signal函数。

1
2
3
#include <signal.h>
void (*signal (int signo, void (*func) (int)))(int);
// 返回值:若成功,返回以前的信号处理配置。若出错:返回SIG_ERR

signal函数由ISO C定义。因为ISO C不涉及多进程、进程组以及终端I/O等,所以它对信号的定义非常含糊,以致于对UNIX系统而言几乎毫无用处。

signo参数是信号名。func的值是常量SIG_IGN、常量SIG_DFL或当接到此信号后要调用的函数的地址。如果指定SIG_IGN,则向内核表示忽略此信号(记住有两个信号SIGKILLSIGSTOP不能忽略)。如果指定SIG_DFL,则表示接到此信号后的动作是系统默认动作。当指定函数地址时,则在信号发生时,调用该函数,我们称这种处理为捕捉该信号,称此函数为信号处理程序(signal handler) 或信号捕捉函数(signal-catching function)。

signal函数原型说明此函数要求两个参数,返回-一个函数指针,而该指针所指向的函数无返回值(void)。第一个参数signo是一个整型数,第二个参数是函数指针,它所指向的函数需要一个整型参数,无返回值。signal的返回值是一个函数地址,该函数有一个整型参数(即最后的(int))。用自然语言来描述也就是要向信号处理程序传送一个整型参数,而它却无返回值。

当调用signal设置信号处理程序时,第二个参数是指向该函数(也就是信号处理程序)的指针。signal的返回值则是指向在此之前的信号处理程序的指针。很多系统用附加的依赖于实现的参数来调用信号处理程序。本节开头所示的signal函数原型太复杂了,如果使用下面的typedef,则可使其简单一些。

1
typedet void Sigfunc(int);

然后,可将signal函数原型写成:

1
Sigfunc *signal (int, Sigfunc *);

我们已将此typedef包括在apue.h文件中,并随本章中的函数一起使用。如果查看系统的头文件<signal.h>,则很可能会找到下列形式的声明:

1
2
3
#define SIG_ERR (void (*)())-1
#define SIG_DFL (void (*)())0
#define SIG_IGN (void (*)())1

这些常量可用于表示“指向函数的指针,该函数要求一个整型参数,而且无返回值”。signal的第二个参数及其返回值就可用它们表示。这些常量所使用的3个值不一定是-1、0和1,但它们必须是3个值而决不能是任一函数的地址。大多数UNIX系统使用上面所示的值。

给出了一个简单的信号处理程序,它捕捉两个用户定义的信号并打印信号编号。它使调用进程在接到一信号前挂起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "apue.h"
static void sig_usr(int) /* one handler for both signals */
int main(void) {
if (signal (SIGUSR1, sig_usr) == SIG_ERR)
err_sys("can't catch SIGUSR1");
if (signal (SIGUSR2, sig_usr) == SIG_ERR)
err_sys("can't catch STGUSR2");
for (; ;)
pause();
}
static void sig_usr(int signo) /* arqunent is signal number */ {
if (signo == SIGUSR1)
printf("received SIGUSR1\n");
else if (signo == SIGUSR2)
printf("received SIGUSR2\n");
else
err_dump("received signal %d\n", signo);
}

我们使该程序在后台运行,并且用kill(1)命令将信号发送给它。注意,在UNIX系统中,杀死(kill)这个术语是不恰当的。kill(1)命令和kill(2)函数只是将一个信号发送给一个进程或进程组。该信号是否终止进程则取决于该信号的类型,以及进程是否安排了捕捉该信号。

1
2
3
4
5
6
7
8
$./a.out &   在后台启动进程
[1] 7216 作业控制thell打印作业编号和进程ID
$ kill -USR1 7226 向该进程发送SIGUSR1
received SIGUSR1
$ kill -USR2 7216 向该进程发送SIGUSR2
received SIGUSR2
$ kill 7216 向该进程发送SIGTERM
[1]+ Terminated ./a.out

因为执行程序的进程不捕捉SIGTERM信号,而对该信号的系统默认动作是终止,所以当向该进程发送SIGTERM信号后,该进程就终止.

程序启动

当执行一个程序时,所有信号的状态都是系统默认或忽略,通常所有信号都被设置为它们的默认动作,除非调用exec的进程忽略该信号。确切地讲, exec函数将原先设置为要捕捉的信号都更改为默认动作,其他信号的状态则不变(一个进程原先要捕捉的信号,当其执行一个新程序后,就不能再捕捉了,因为信号捕捉函数的地址很可能在所执行的新程序文件中已无意义)。

一个具体例子是一个交互,shell如何处理针对后台进程的中断和退出信号。对于一个非作业控制shell,当在后台执行一个进程时,例如:

1
cc main.cc &

shell自动将后台进程对中断和退出信号的处理方式设置为忽略。于是, 当按下中断字符时就不会影响到后台进程。如果没有做这样的处理,那么当按下中断字符时,它不但终止前台进程,也终止所有后台进程。

很多捕捉这两个信号的交互程序具有下列形式的代码:

1
2
3
4
5
void sig_int(int), sig_quit (int);
it (signal (SIGINT, SIG_IGN) != SIGIGN)
signal (SIGINT, sig_int);
if (signal (SIGQUIT, SIG_IGN) != SIG_IGN)
signal (SIGQUIT, sig_quit);

这样处理后,仅当SIGINTSIGQUIT当前未被忽略时,进程才会捕捉它们。从signal的这两个调用中也可以看到这种函数的限制,不改变信号的处理方式就不能确定信号的当前处理方式。

进程创建

当一个进程调用fork时,其子进程继承父进程的信号处理方式。因为子进程在开始时复制了父进程内存映像,所以信号捕捉函数的地址在子进程中是有意义的。

不可靠的信号

在早期的UNIX版本中(如V7),信号是不可靠的。不可靠在这里指的是,信号可能会丢失:一个信号发生了,但进程却可能一直不知道这一 点。同时,进程对信号的控制能力也很差,它能捕捉信号或忽略它。有时用户希望通知内核阻塞某个信号:不要忽略该信号,在其发生时记住它,然后在进程做好了准备时再通知它。这种阻塞信号的能力当时并不具备。

早期版本在进程每次接到信号对其进行处理时,随即将该信号动作重置为默认值:

1
2
3
4
5
6
7
int  sig_int();   /*my signal handling function */
...
signal(SIGINT, sig_int) /* establish handler */
...
sig_int() {
signal (SIGINT, sig_int): /* reestabliah handler for next time */
/*process the signal */

这段代码的一个问题是:在信号发生之后到信号处理程序调用signal函数之间有一个时间窗口。在此段时间中,可能发生另一次中断信号。第二个信号会造成执行默认动作,而对中断信号的默认动作是终止该进程。这种类型的程序段在大多数情况下会正常工作,使得我们认为它们是正确无误的,而实际上却并非如此。

这些早期版本的另一个问题是:在进程不希望某种信号发生时,它不能关闭该信号。进程能做的一切就是忽略该信号。有时希望通知系统“阻止下列信号发生,如果它们确实产生了,请记住它们。”能够显现这种缺陷的的一个经典实例是下列程序段,它捕捉一个信号,然后设置一个表示该信号已发生的标志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int sig_int():
/* my signal handling function */
int sig_int_flags
/* set nonzero when signal oecurs */
main() {
signal (SIGINT, sig_int);
/* establish handler */
while (sig_int_flag == 0)
/* go to sleep, waiting for signal */
pause();
}
sig_int() {
signal (SIGINT, sig_int);
/* reestablish handler for next time */
sig_int_tiag = 1;
/* set flag for main loop to examine */
}

其中,进程调用pause函数使自己休眠,直到捕捉到一个信号。当捕提到信号时,信号处理程序将标志sig_int_flag设置为非0值。从信号处理程序返回后,内核自动将该进程唤醒,它检测到该标志为非0,然后执行它所需做的。但是这里有一个时间窗口,在此窗口中操作可能失误。如果在测试sig_int_flag之后、调用pause之前发生信号,则此进程在调用pause时可能将永久休眠(假定此信号不会再次产生)。于是,这次发生的信号也就丢失了。

中断的系统调用

早期UNIX系统的一个特性是:如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就被中断不再继续执行。该系统调用返回出错,其errno设置为EINTR。这样处理是因为一个信号发生了,进程捕捉到它,这意味着已经发生了某种事情,所以是个好机会应当唤醒阻塞的系统调用。

为了支持这种特性,将系统调用分成两类:低速系统调用其他系统调用。低速系统调用是可能会使进程永远阻塞的类系统调用,包括:

  • 如果某些类型文件(如读管道、终端设备和网络设备)的数据不存在,则读操作可能会使调用者永远阻塞;
  • 如果这些数据不能被相同的类型文件立即接受,则写操作可能会使调用者永远阻塞;
  • 在某种条件发生之前打开某些类型文件,可能会发生阻塞(例如要打开一个终端设备,需要先等待与之连接的调制解调器应答);
  • pause函数(按照定义,它使调用进程体眼直至捕捉到一个信号)和wait函数;
  • 某些ioctl操作;
  • 某些进程间通信函数。

在这些低速系统调用中,一个值得注意的例外是与磁盘I/O有关的系统调用。虽然读、写一个磁盘文件可能暂时阻塞调用者(在磁盘驱动程序将请求排入队列,然后在适当时间执行请求期间),但是除非发生硬件错误,I/O操作总会很快返回,并使调用者不再处于阻塞状态。

对于中断的readwrite系统调用早期版本允许实现自行选择。如若read系统调用已接收并传送数据至应用程序缓冲区,但尚来接收到应用程序请求的全部数据,此时被中断,操作系统可以认为该系统调用失败,并将errno设置为EINTR;另一种处理方式是允许该系统调用成功返回,送回值是已接收到的数据量。与此类似,如若write已传输了应用程序缓冲区中的部分数据,然后被中断,操作系统可以认为该系统调用失败,并将errno设置为EINTR。另一种处理方式是允许该系统调用成功返回,返回值是已写部分的数据量。历史上,从System V派生的实现将这种系统调用视为失败,而BSD派生的实现则处理为部分成功返回。

与被中断的系统调用相关的问题是必须显式地处理出错返回。典型的代码序列(假定进行一个读操作,它被中断,我们希望重新启动它)如下:

1
2
3
4
5
again:
if ((n = read(fd, buf, BUFFSIZE)) < 0) {
if (errno == EINTR)
goto again; /* just an interrupted aystem call */
/* handle other errors */

4.2BSD引进了某些被中断系统调用的自动重启动。自动重启动的系统调用包括,ioctlreadreadvwritewritevwaitwaitpid。如前所述,其中前5个函数只有对低速设备进行操作时才会被信号中断。而waitwaitpid在捕捉到信号时总是被中断。4.3BSD允许进程基于每个信号禁用此功能。POSIX.1要求只有中断信号的SA_RESTART标志有效时,实现才重启动系统调用。

4.2BSD引入自动重启动功能的一个理由是:有时用户并不知道所使用的输入、输出设备是否是低速设备。如果我们编写的程序可以用交互方式运行,则它可能读、写终端低速设备。如果在程序中捕捉信号,而且系统并不提供重启动功能,则对每次读、写系统调用就要进行是否出错返回的测试,如果是被中断的,则再调用读、写系统调用。

可重入函数

进程捕捉到信号并对其进行处理时,进程正在执行的正常指令序列就被信号处理程序临时中断,它首先执行该信号处理程序中的指令。如果从信号处理程序返回,则继续执行在捕捉到信号时进程正在执行的正常指令序列。但在信号处理程序中,不能判断捕捉到信号时进程执行到何处。Single UNIX Specification说明了在信号处理程序中保证调用安全的函数。这些函数是可重入的并被称为是异步信号安全的(async-signal safe)。除了可重入以外,在信号处理操作期间,它会阻塞任何会引起不一致的信号发送。图10-4列出了这些异步信号安全的函数。

其他的大多数函数是不可重入的,因为:

  • 已知它们使用静态数据结构;
  • 它们调用malloc或free;
  • 它们是标准I/O函数。

SIGCLD语义

SIGCLDSIGCHLD这两个信号很容易被混淆。SIGCLD是System V的一个信号名,其语义与名为SIGCHLD的BSD信号不同。POSIX.1采用BSD的SIGCHLD信号。BSD的SIGCHLD信号语义与其他信号的语文相类似,子进程状态改变后产生此信号,父进程需要调用一个wait函数以检测发生了什么。

对于SIGCLD的早期处理方式是:

  1. 如果进程明确地将该信号的配置设置为SIG_IGN,则调用进程的子进程将不产生僵死进程。注意,这与其默认动作(SIG_DFL)“忽略”不同。子进程在终止时,将其状态丢弃。如果调用进程随后调用一个wait函数,那么它将阻塞直到所有子进程都终止,然后该wait会返回-1,并将其errno设置为ECHILD
  2. 如果将SIGCLD的配置设置为捕捉,则内核立即检查是否有子进程准备好被等待,如果是这样,则调用SIGCLD处理程序,第2种方式改变了为此信号编写处理程序的方法,这一点可在下面的实例中看到。

进入信号处理程序后,首先要调用signal函数以重新设置此信号处理程序(在信号被重置为其默认值时,它可能会丢失,立即重新设置可以减少此窗口时间)。下边展示了这一点。程序一行行地不断重复输出“SIGCLD received”,最后进程用完其栈空间并异常终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include	"apue.h"
#include <sys/wait.h>

static void sig_cld(int);

int
main()
{
pid_t pid;

if (signal(SIGCLD, sig_cld) == SIG_ERR)
perror("signal error");
if ((pid = fork()) < 0) {
perror("fork error");
} else if (pid == 0) { /* child */
sleep(2);
_exit(0);
}

pause(); /* parent */
exit(0);
}

static void
sig_cld(int signo) /* interrupts pause() */
{
pid_t pid;
int status;

printf("SIGCLD received\n");

if (signal(SIGCLD, sig_cld) == SIG_ERR) /* reestablish handler */
perror("signal error");

if ((pid = wait(&status)) < 0) /* fetch child status */
perror("wait error");

printf("pid = %d\n", pid);
}

此程序的问题是,在信号处理程序的开始处调用signal,按照上述第2种方式,内核检查是否有需要等待的子进程(因为我们正在处理一个SIGCLD信号,所以确实有这种子进程),所以它产生另一个对信号处理程序的调用。信号处理程序调用signal,整个过程再次重复。

为了解决这一问题,应当在调用wait取到子进程的终止状态后再调用signal。此时仅当其他子进程终止,内核才会再次产生此种信号。

可靠信号术语和语义

当一个信号产生时,内核通常在进程表中以某种形式设置一个标志。当对信号采取了这种动作时,我们说向进程递送了一个信号。在信号产生(generation)和递送(delivery)之间的时间间隔内,称信号是未决的(pending)。

进程可以选用“阻塞信号递送”。如果为进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作或捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略。内核在递送一个原来被阻塞的信号给进程时(而不是在产生该信号时),才决定对它的处理方式。于是进程在信号递送给它之前仍可改变对该信号的动作。进程调用sigpending函数来判定哪些信号是设置为阻塞并处于未决状态的。

POSIX.1允许系统递送该信号一次或多次。如果递送该信号多次,则称这些信号进行了排队。但是除非支持POSIX.1实时扩展,否则大多数UNIX并不对信号排队,而是只递送这种信号一次。

如果有多个信号要递送给一个进程,POSIX.1并没有规定这些信号的递送顺序。但是POSIX.1基础部分建议:在其他信号之前递送与进程当前状态有关的信号,如SIGSEGV。每个进程都有一个信号屏蔽字(signal mask),它规定了当前要阻塞递送到该进程的信号集。对于每种可能的信号,该屏蔽字中都有一位与之对应。对于某种信号,若其对应位已设置,则它当前是被阻塞的。进程可以调用sigprocmask来检测和更改其当前信号屏蔽字。信号编号可能会超过一个整型所包含的二进制位数,因此POSIX.1定义了一个新数据类型sigset_t,它可以容纳一个信号集。例如,信号屏蔽字就存放在其中一个信号集中。

函数kill和raise

kill函数将信号发送给进程或进程组。raise函数则允许进程向自身发送信号。

1
2
3
4
#include <signal.h>
int kill (pid_t pid, int sigmo) i
int raise(int signo);
// 两个函数返回值:若成功。返回0;若出错:返回-!

调用raise(signo)等价于调用kill(getpid(), signo);

killpid参数有以下4种不同的情况

  • pid>0:将该信号发送给进程ID为pid的进程。
  • pid==0:将该信号发送给与发送进程属于同一进程组的所有进程(这些进程的进程组ID等于发送进程的进程组ID),而且发送进程具有权限向这些进程发送信号。这里用的术语“所有进程”不包括实现定义的系统进程集。对于大多数UNIX系统,系统进程集包括内核进程和init(pid为1)。
  • pid<0:将该信号发送给其进程组ID等于pid绝对值,而且发送进程具有权限向其发送信号的所有进程。如前所述,所有进程并不包括系统进程集中的进程。
  • pid==-1:将该信号发送给发送进程有权限向它们发送信号的所有进程。如前所述,所有进程不包括系统进程集中的进程。

如前所述,进程将信号发送给其他进程需要权限。超级用户可将信号发送给任一进程。对于非超级用户,其基本规则是发送者的实际用户ID或有效用户ID必须等于接收者的实际用户ID 或有效用户ID。如果实现支持_POSIX_SAVED_IDS,则检查接收者的保存设置用户ID(而不是有效用户ID)。在对权限进行测试时也有一个特例:如果被发送的信号是SIGCONT,则进程可将它发送给属于同一会话的任一其他进程。POSIX.1将信号编号0定义为空信号。如果signo参数是0,则kill仍执行正常的错误检查,但不发送信号。这常被用来确定一个特定进程是否仍然存在。如果向一个并不存在的进程发送空信号,则kill返回-1,errno被设置为ESRCH

还应理解的是,测试进程是否存在的操作不是原子操作。在kill向调用者返回测试结果时,原来已存在的被测试进程此时可能已经终止,所以这种测试并无多大价值。如果调用kill为调用进程产生信号,而且此信号是不被阻塞的,那么在kill返回之前,signo或者某个其他未决的、非阻塞信号被传送至该进程。

函数alarm和pause

使用alarm函数可以设置一个定时器(闹钟时间),在将来的某个时刻该定时器会超时。当定时器超时时,产生SIGALRM信号。如果忽略或不捕捉此信号,则其默认动作是终止调用该alarm函数的进程。

1
2
3
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 返回值:0或以前设置的网钟时间的余留秒数

参数seconds的值是产生信号SIGALRM需要经过的时钟秒数。当这一时刻到达时,信号由内核产生,由于进程调度的延迟,所以进程得到控制从而能够处理该信号还需要一个时间间隔。

每个进程只能有一个闹钟时间。如果在调用alarm时,之前已为该进程注册的闹钟时间还没有超时,则该闹钟时间的余留值作为本次alarm函数调用的值返回。以前注册的闹钟时间则被新值代替。如果有以前注册的尚未超过的周钟时间,而且本次调用的seconds值是0。则取消以前的闹钟时间,其余留值仍作为alarm函数的返回值。

虽然SIGALRM的默认动作是终止进程,但是大多数使用限钟的进程捕捉此信号。如果此时进程要终止,则在终止之前它可以执行所需的清理操作。如果我们想捕捉SIGALRM信号,则必须在调用alarm之前安装该信号的处理程序。如果我们先调用alarm,然后在我们能够安装SIGALRM处理程序之前已接到该信号,那么进程将终止。

pause函数使调用进程挂起直至捕捉到一个信号。

1
2
3
#include cunistd.h>
int pause (void);
// 返回值:-1,errno设置为EINTR

只有执行了一个信号处理程序并从其返回时,pause才返回。在这种情况下,pause返回-1,errno设置为EINTR

使用alarmpause,进程可使自己休眠一段指定的时间。sleep1函数看似提供了这种功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include	<signal.h>
#include <unistd.h>

static void
sig_alrm(int signo)
{
/* nothing to do, just return to wake up the pause */
}

unsigned int
sleep1(unsigned int seconds)
{
if (signal(SIGALRM, sig_alrm) == SIG_ERR)
return(seconds);
alarm(seconds); /* start the timer */
pause(); /* next caught signal wakes us up */
return(alarm(0)); /* turn off timer, return unslept time */
}

这种简单实现有以下3个问题,

  1. 如果在调用sleep1之前,调用者已设置了闹钟,则它被sleep1函数中的第一次alarm调用擦除。可用下列方法更正这一点检查第一次调用alarm的返回值,如其值小于本次调用alarm的参数值,则只应等到已有的阔钟超时。如果之前设置的闹钟超时时间晚于本次设置值,则在sleep1函数返回之前,重置此闹钟,使其在之前闹钟的设定时间再次发生超时。
  2. 该程序中修改了对SIGALRM的配置。如果编写了一个函数供其他函数调用,则在该函数被调用时先要保存原配置,在该函数返回前再恢复原配置。更正这一点的方法是:保存signal函数的返回值,在返回前重置原配置。
  3. 在第一次调用alarmpause之间有一个竞争条件。在一个繁忙的系统中,可能alarm在调用pause之前超时,并调用了信号处理程序。如果发生了这种情况,则在调用pause后,如果没有捕捉到其他信号,调用者将永远被挂起。

有两种方法可以更正第3个问题。第一种方法是使用setjmp,另一种方法是使用sigprocmasksigsuspend

SVR2中的sleep实现使用了setjmplongjmp,以避免前一个实例的第3个问题中说明的竞争条件。此函数的一个简化版本称为sleep2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include	<setjmp.h>
#include <signal.h>
#include <unistd.h>

static jmp_buf env_alrm;

static void
sig_alrm(int signo)
{
longjmp(env_alrm, 1);
}

unsigned int
sleep2(unsigned int seconds)
{
if (signal(SIGALRM, sig_alrm) == SIG_ERR)
return(seconds);
if (setjmp(env_alrm) == 0) {
alarm(seconds); /* start the timer */
pause(); /* next caught signal wakes us up */
}
return(alarm(0)); /* turn off timer, return unslept time */
}

sleep2函数中却有另一个难以察觉的问题,它涉及与其他信号的交互。如果SIGALRM中断了某个其他信号处理程序,则调用longjmp会提早终止该信号处理程序。

除了用来实现sleep函数外,alarm还常用于对可能阻塞的操作设置时间上限值。例如,程序中有一个读低速设备的可能阻塞的操作,我们希望超过一定时间量后就停止执行该操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include "apue.h"
static void sig_alrm(int);

int
main(void)
{
int n;
char line[MAXLINE];

if (signal(SIGALRM, sig_alrm) == SIG_ERR)
err_sys("signal(SIGALRM) error");

alarm(10);
if ((n = read(STDIN_FILENO, line, MAXLINE)) < 0)
err_sys("read error");
alarm(0);

write(STDOUT_FILENO, line, n);
exit(0);
}

static void
sig_alrm(int signo)
{
/* nothing to do, just return to interrupt the read */
}

这种代码序列在很多UNIX应用程序中都能见到,但是这种程序有两个问题:

  • 在第一次alarm调用和read调用之间有一个竞争条件。如果内核在这两个函数调用之间使进程阻塞,不能占用处理机运行,而其时间长度又超过闹钟时间,则read可能永远阻塞。大多数这种类型的操作使用较长的闹钟时间,例如1分钟或更长一点,使这种问题不会发生,但无论如何这是一个竞争条件。
  • 如果系统调用是自动重启动的,则当从SIGALRM信号处理程序返回时,read并不被中断。在这种情形下,设置时间限制不起作用。

信号集

我们需要有一个能表示多个信号信号集(signalser)的数据类型。我们将在sigprocmask类函数中使用这种数据类型,以便告诉内核不允许发生该信号集中的信号。如前所述,不同的信号的编号可能超过一个整型量所包含的位数,所以一般而言,不能用整型量中的一位代表一种信号,也就是不能用一个整型量表示信号集。POSIX.1定义数据类型sigset_t以包含一个信号集,并且定义了下列5个处理信号集的函数。

1
2
3
4
5
6
7
8
#include <signal.h>
int sigemptyset (sigset_t *set);
int sigfillset (sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset (sigset_t *ser, int signo);
// 4个函数返回值:若成功,返回0;若出错,返回-1
int sigismember lconst sigset_t *set, int sigmo);
// 返回值:若真。返回1;若假,返回0

函数sigemptyset初始化由set指向的信号集,清除其中所有信号。函数sigfillset初始化由set指向的信号集,使其包括所有信号。所有应用程序在使用信号集前,要对该信号集调用sigemptysetsigfillset一次。这是因为C编译程序将不赋初值的外部变量和静态变量都初始化为0,而这是否与给定系统上信号集的实现相对应却并不清楚。一旦已经初始化了一个信号集,以后就可在该信号集中增、删特定的信号。函数sigaddset将一个信号添加到已有的信号集中,sigdelset则从信号集中删除一个信号。对所有以信号集作为参数的函数,总是以信号集地址作为向其传送的参数。

如果实现的信号数目少于一个整型最所包含的位数,则可用一位代表一个信号的方法实现信号集。sigemptyset函数将整型设置为0。sigfillset函数则将整型中的各位都设置为1。这两个函数可以在<signal.h>头文件中实现为宏:

1
2
#define sigemptyset(ptr) (*(ptz) = 0)
#define sigfillset(ptr)(*(ptr) = (sigset_t)0, 0)

注意,除了设置信号集中各位为1外,sigfillset必须返回0,所以使用C语言的逗号算符,它将逗号算符后的值作为表达式的值返回。

使用这种实现,sigaddset开启一位(将该位设置为1),sigdelset则关闭一位(将该位设置为0),sigismember测试一个指定的位。因为没有信号编号为0,所以从信号编号中减1以得到要处理位的位编号数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include	<signal.h>
#include <errno.h>

/*
* <signal.h> usually defines NSIG to include signal number 0.
*/
#define SIGBAD(signo) ((signo) <= 0 || (signo) >= NSIG)

int
sigaddset(sigset_t *set, int signo)
{
if (SIGBAD(signo)) {
errno = EINVAL;
return(-1);
}
*set |= 1 << (signo - 1); /* turn bit on */
return(0);
}

int
sigdelset(sigset_t *set, int signo)
{
if (SIGBAD(signo)) {
errno = EINVAL;
return(-1);
}
*set &= ~(1 << (signo - 1)); /* turn bit off */
return(0);
}

int
sigismember(const sigset_t *set, int signo)
{
if (SIGBAD(signo)) {
errno = EINVAL;
return(-1);
}
return((*set & (1 << (signo - 1))) != 0);
}

也可将这3个函数在<signal.h>中实现为各一行的宏,但是POSIX.1要求检查信号编号参数的有效性,如果无效则设置errno。在宏中实现这一点比函数要难。

函数sigprocmask

调用函数sigprocmask可以检测或更改,或同时进行检测和更改进程的信号屏蔽字。

1
2
3
#include <signal.h>
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
// 返回值:若成功,返回0:若出错,返回-1

首先,若oset是非空指针,那么进程的当前信号屏蔽字通过oset返回。其次,若set是一个非空指针,则参数how指示如何修改当前信号屏蔽字。下表说明了how可选的值。SIG_BLOCK是或操作,而SIG_SETMASK则是赋值操作。注意,不能阻塞SIGKILLSIGSTOP信号。

how 说明
SIG_BLOCK 该进程新的信号屏蔽字是其当前信号屏蔽字和set指向信号集的井集。set包含了希望阻塞的附加信号
SIG_UNBLOCK 该进程新的信号屏蔽字是其当前信号屏蔽字和set所指向信号集补整的交集。set包含了希望解除阻塞的信号
SIG_SETMASK 该进程新的信号屏蔽是set指向的值

如果set是个空指针,则不改变该进程的信号屏蔽字,how的值也无意义。在调用sigprocmask后如果有任何未决的、不再阻塞的信号,则在sigprocmask返回前,至少将其中之一递送给该进程。

sigprocmask是仅为单线程进程定义的。处理多线程进程中信号的屏蔽使用另一个函数。

函数sigpending

sigpending函数返回一信号集,对于调用进程而言,其中的各信号是阻塞不能递送的,因而也一定是当前未决的。该信号集通过set参数返回。

1
2
3
#include <signal.h>
int sigpending(sigset_t *set);
// 返回值:若成功,返回0;若出错:返回-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include "apue.h"

static void sig_quit(int);

int
main(void)
{
sigset_t newmask, oldmask, pendmask;

if (signal(SIGQUIT, sig_quit) == SIG_ERR)
err_sys("can't catch SIGQUIT");

/*
* Block SIGQUIT and save current signal mask.
*/
sigemptyset(&newmask);
sigaddset(&newmask, SIGQUIT);
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0)
err_sys("SIG_BLOCK error");

sleep(5); /* SIGQUIT here will remain pending */

if (sigpending(&pendmask) < 0)
err_sys("sigpending error");
if (sigismember(&pendmask, SIGQUIT))
printf("\nSIGQUIT pending\n");

/*
* Restore signal mask which unblocks SIGQUIT.
*/
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");
printf("SIGQUIT unblocked\n");

sleep(5); /* SIGQUIT here will terminate with core file */
exit(0);
}

static void
sig_quit(int signo)
{
printf("caught SIGQUIT\n");
if (signal(SIGQUIT, SIG_DFL) == SIG_ERR)
err_sys("can't reset SIGQUIT");
}

进程阻塞SIGQUIT信号,保存了当前信号屏蔽字(以便以后恢复),然后休眠5秒。在此期间所产生的退出信号SIGQUIT都被阻塞,不递送至该进程,直到该信号不再被阻塞。在5秒休眠结束后,检查该信号是否是未决的,然后将SIGQUIT设置为不再阻塞。

注意,在设置SIGQUIT为阻塞时,我们保存了老的屏蔽字。为了解除对该信号的阻塞,用老的屏蔽字重新设置了进程信号屏蔽字(SIG_SETMASK)、另一种方法是用SIG_UNBLOCK使阻塞的信号不再阻塞。但是,应当了解如果编写一个可能由其他人使用的函数,而且需要在函数中阻塞一个信号,则不能用SIG_UNBLOCK简单地解除对此信号的阻塞,这是因为此函数的调用者在调用本函数之前可能也阻塞了此信号。在这种情况下必须使用SIG_SETMASK将信号屏蔽字恢复为先前的值。这样也就能继续阻塞该信号。

在休眠期间如果产生了退出信号,那么此时该信号是未决的,但是不再受阻塞,所以在sigprocmask返回之前,它被递送到调用进程,从程序的输出中可以看到这一点:SIGQUIT处理程序(sig_quit)中的printf语句先执行,然后再执行sigprocmask之后的printf语句。然后该进程再休眠5秒。如果在此期间再产生退出信号,那么因为在上次捕捉到该信号时,已将其处理方式设置为默认动作,所以这一次它就会使该进程终止。

函数sigaction

sigaction函数的功能是检查或修改(或检查并修改)与指定信号相关联的处理动作。此函数取代了UNIX早期版本使用的signal函数。

1
2
3
#include <signal.h>
int sigaction(int signo, const struct sigaction *restrict act, struct sigaction *restrict oact);
// 返回值:若成功,返回0,若出错,返回-1

其中,参数signo是要检测或修改其具体动作的信号编号。若act指针非空,则要修改其动作。如果oact指针非空,则系统经由oact指针返回该信号的上一个动作。此函数使用下列结构:

1
2
3
4
5
6
7
8
9
struct sigaction {
void (*sa_handler)(int); /* addr of signal handler, */
/* or SIG_IGN, or SIG_DFL */
sigset_t sa_mask;
/* additional signals to block */
int sa_flags; /* signal options */
/* aiternate nandler */
void (*sa_sigaction) (int, siginfo_t *, void *);
};

当更改信号动作时,如果sa_handler字段包含一个信号捕捉函数的地址(不是常量SIG_IGNSIG_DEL),则sa_mask字段说明了一个信号集,在调用该信号捕捉函数之前,这一信号集要加到进程的信号屏蔽字中。仅当从信号捕捉函数返回时再将进程的信号屏蔽字恢复为原先值。这样,在调用信号处理程序时就能用塞某些信号。在信号处理程序被调用时,操作系统建立的新信号屏蔽字包括正被递送的信号。因此保证了在处理一个给定的信号时,如果这种信号再次发生,那么它会被阻塞到对前一个信号的处理结束为止。若同一种信号多次发生,通常并不将它们加入队列,所以如果在某种信号被阻塞时,它发生了5次,那么对这种信号解除阻塞后,其信号处理函数通常只会被调用一次。一旦对给定的信号设置了一个动作,那么在调用sigaction显式地改变它之前,该设置就一直有效。这种处理方式与早期的不可靠信号机制不同,符合POSIX.1在这方面的要求。act结构的sa_flags字段指定对信号进行处理的各个选项。图中详细列出了这些选项的意义。若该标志已定义在基本POSIX.1标准中,那么SUS列包含“。”;若该标志定义在基本POSIX.1标准的XSI扩展中,那么该列包含“XSI”。

sa_sigaction字段是一个替代的信号处理程序,在sigaction结构中使用了SA_SIGINFO标志时,使用该信号处理程序。对于sa_sigaction字段和sa_handler字段两者,实现可能使用同一存储区,所以应用只能一次使用这两个字段中的一个。

通常,按下列方式调用信号处理程序:

1
void handler(int signo);

但是,如果设置了SA_SIGINFO标志,那么按下列方式调用信号处理程序:

1
void handler(int signo, siginfo_t *info, void *context);

siginfo结构包含了信号产生原因的有关信息。该结构的大致样式如下所示。

1
2
3
4
5
6
7
8
9
10
struct siginfo {
int si_signo; /* signal number */
int si_ezrno; /* if nonzero, errno value from <errno.h> */
int si_code; /* additional into (depends on signal) */
pid_t si_pid; /* sending process ID */
uid_t si_uid; /* sending process real user ID */
void *si_addr; /* address that caused the fault */
int si_status; /* exit value or signal number */
union sigval si_vaive; /* application-specitic value */
/* possibly other tields also */

sigval联合包含下列字段:

1
2
int sival_int;
void *sival_ptr;

应用程序在递送信号时,在si_value.sival_int中传递一个整型数或者在si_value.sival_ptr中传递一个指针值。

图10-17示出了对于各种信号的si_code值, 这些信号是由Single UNIX Specification定义的。注意,实现可定义附加的代码值。

若信号是SIGCHLD,则将设置si_pid.si_statussi_uid字段。若信号是SIGBUSSIGILLSIGFPESIGSEGV,则si_addr包含造成故障的根源地址,该地址可能并不准确。si_errno字段包含错误编号,它对应于造成信号产生的条件,并由实现定义。

信号处理程序的context参数是无类型指针,它可被强制类型转换为ucontext_t结构类型,该结构标识信号传递时进程的上下文。该结构至少包含下列字段:

1
2
3
4
5
6
7
ucontext_t *uc_link;    /* pointer to context resumed when */
/*this context returns */
sigset_t uc_sigmask; /* signals blocked when this context */
/*is active */
stack_t uc_stack; /* stack used by this context */
mcontext_t uc_mcontext;/*machine-specitie representation of */
/* saved context */

uc_stack字段描述了当前上下文使用的栈,至少包括下列成员:

1
2
3
void *ss_sp;     /* stack base or pointer */
size_t no_size; /*stack size */
int ss_flags; /* flags */

当实现支持实时信号扩展时,用SA_SIGINFO标志建立的信号处理程序将造成信号可靠地排队。一些保留信号可由实时应用使用。如果信号由sigqueue函数产生,那么siginfo结构能包含应用特有的数据。

函数sigsetjmp和sig1ongjmp

在信号处理程序中经常调用longjmp函数以返回到程序的主循环中,而不是从该处理程序返回。但是,调用longjmp有一个问题。当捕捉到一 一个信号时,进入信号捕捉函数,此时当前信号被自动地加到进程的信号屏蔽字中。这阻止了后来产生的这种信号中断该信号处理程序。

setjmplongjmp保存和恢复信号屏蔽字。为了允许两种形式并存,POSIX.1并没有指定setjmplongjmp对信号屏蔽字的作用,而是定义了两个新函数sigsetjmpsiglongjmp。在信号处理程序中进行非局部转移时应当使用这两个函数。

1
2
3
4
#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savemask);
// 返回值:若直接调用,返回0:若从siq1ongjmp调用返回,则返回非0
void siglongjmp(sigjmp_buf env, int val);

这两个函数和setjmplongjmp之间的唯一区别是sigsetjmp增加了一个参数。如果savemask非0,则sigsetjmpenv中保存进程的当前信号屏蔽字。调用siglongjmp时,如果带非0 savemasksigsetjmp调用已经保存了env,则siglongjmp从其中恢复保存的信号屏蔽字。

程序演示了在信号处理程序被调用时,系统所设置的信号屏蔽字如何自动地包括刚被捕捉到的信号。此程序也示例说明了如何使用sigsetjmpsiglongjmp函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include "apue.h"
#include <setjmp.h>
#include <time.h>

static void sig_usr1(int);
static void sig_alrm(int);
static sigjmp_buf jmpbuf;
static volatile sig_atomic_t canjump;

int
main(void)
{
if (signal(SIGUSR1, sig_usr1) == SIG_ERR)
err_sys("signal(SIGUSR1) error");
if (signal(SIGALRM, sig_alrm) == SIG_ERR)
err_sys("signal(SIGALRM) error");

pr_mask("starting main: "); /* {Prog prmask} */

if (sigsetjmp(jmpbuf, 1)) {

pr_mask("ending main: ");

exit(0);
}
canjump = 1; /* now sigsetjmp() is OK */

for ( ; ; )
pause();
}

static void
sig_usr1(int signo)
{
time_t starttime;

if (canjump == 0)
return; /* unexpected signal, ignore */

pr_mask("starting sig_usr1: ");

alarm(3); /* SIGALRM in 3 seconds */
starttime = time(NULL);
for ( ; ; ) /* busy wait for 5 seconds */
if (time(NULL) > starttime + 5)
break;

pr_mask("finishing sig_usr1: ");

canjump = 0;
siglongjmp(jmpbuf, 1); /* jump back to main, don't return */
}

static void
sig_alrm(int signo)
{
pr_mask("in sig_alrm: ");
}

此程序演示了另一种技术,只要在信号处理程序中调用siglongjmp就应使用这种技术。仅在调用sigsetjmp之后才将变量canjump设置为非0值。在信号处理程序中检测此变量,仅当它为非0值时才调用siglongjmp。这提供了一种保护机制,使得在jmpbuf (跳转缓冲)尚未由sigsetjmp初始化时,防止调用信号处理程序。(在本程序中,sigiongjmp之后程序很快就结束,但是在较大的程序中,在siglongjmp之后的较长一段时间内,信号处理程序可能仍旧被设置)。在一般的C代码中(不是信号处理程序),对于longjmp并不需要这种保护措施。但是,因为信号可能在任何时候发生,所以在信号处理程序中,需要这种保护措施。

在程序中使用了数据类型sig_atomic_t,这是由ISO C标准定义的变量类型,在写这种类型变量时不会被中断。这意味着在具有虚拟存储器的系统上,这种变量不会跨越页边界。可以用一条机器指令对其进行访问。这种类型的变量总是包括ISO类型修饰符volatile,其原因是:该变量将由两个不同的控制线程一main函数和异步执行的信号处理程序访问。

可将图10-21分成三部分,左面部分(对应于main),中间部分(sig_usr1)和右面部分(sig_alrm)。在进程执行左面部分时,信号屏蔽字是0(没有信号是阻塞的)。而执行中间部分时,其信号屏蔽字是SIGUSR1。执行右面部分时,信号屏蔽字是SIGUSR1 | SIGALRM

执行程序,得到下面的输出:

1
2
3
4
5
6
7
8
9
10
11
$./a.out & 在后台启动进程
starting main:
[1] 531
$ kill -USR1 531 作业控制shell打印其进程ID
starting sig_usr1: SIGUSR1 向该进程发送SIGUSRI

$ in sig_alrm: SIGUSRI SIGALRM
finishing sig_usr1 SIGUSR1
ending main

[1] + Done ./a.out &

该输出与我们所期望的相同:当调用一个信号处理程序时,被捕捉到的信号加到进程的当前信号屏蔽字中,当从信号处理程序返回时,恢复原来的屏蔽字。另外,siglongjmp恢复了由sigsetjmp所保存的信号屏蔽字。

如果在Limux中将图10-20程序中的sigsetjmpsiglongjmp分别替换成setjmplongjmp,则最后一行输出变成:

1
ending main: SIGUSRI

这意味着在调用setjmp之后执行main函数时,其SIGUSR1是阻塞的。这多半不是我们所希望的。

函数sigsuspend

上面已经说明,更改进程的信号屏蔽字可以阻塞所选择的信号,或解除对它们的阻塞。使用这种技术可以保护不希望由信号中断的代码临界区。

如果在信号阻塞时,产生了信号,那么该信号的传递就被推迟直到对它解除了阻塞。对应用程序而言,该信号好像发生在解除对SIGINT的阻塞和pause之间(取决于内核如何实现信号)。如果发生了这种情况,或者如果在解除阻塞时刻和pause之间确实发生了信号,那么就会产生问题。因为可能不会再见到该信号,所以从这种意义上讲,在此时间窗口中发生的信号丢失了,这样就使得pause永远阻塞。这是早期的不可靠信号机制的另一个问题。

为了纠正此问题。需要在一个原子操作中先恢复信号屏蔽字,然后使进程休眠。这种功能是由sigsuspend函数所提供的。

1
2
3
#include <signal.h>
int sigsuspend(const sigset_t *sigmask);
// 返回值。-1,并将errno设置为EINTR

进程的信号屏蔽字设置为由sigmask指向的值。 在捕捉到一个信号或发生了一个会终止该进程的信号之前,该进程被挂起。如果捕捉到一个信号面且从该信号处理程序返回,则sigsuspend返回,并且该进程的信号屏蔽字设置为调用sigsuspend之前的值。

注意,此函数没有成功返回值。如果它返回到调用者,则总是返回-1,并将errno设置为EINTR(表示一个被中断的系统调用)。

下面的程序显示了保护代码临界区,使其不被特定信号中断的正确方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include "apue.h"

static void sig_int(int);

int
main(void)
{
sigset_t newmask, oldmask, waitmask;

pr_mask("program start: ");

if (signal(SIGINT, sig_int) == SIG_ERR)
err_sys("signal(SIGINT) error");
sigemptyset(&waitmask);
sigaddset(&waitmask, SIGUSR1);
sigemptyset(&newmask);
sigaddset(&newmask, SIGINT);

/*
* Block SIGINT and save current signal mask.
*/
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0)
err_sys("SIG_BLOCK error");

/*
* Critical region of code.
*/
pr_mask("in critical region: ");

/*
* Pause, allowing all signals except SIGUSR1.
*/
if (sigsuspend(&waitmask) != -1)
err_sys("sigsuspend error");

pr_mask("after return from sigsuspend: ");

/*
* Reset signal mask which unblocks SIGINT.
*/
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");

/*
* And continue processing ...
*/
pr_mask("program exit: ");

exit(0);
}

static void
sig_int(int signo)
{
pr_mask("\nin sig_int: ");
}

注意,当sigsuspend返回时,它将信号屏蔽字设置为调用它之前的值。在本例中,SIGINT信号将被阻塞。因此将信号屏蔽恢复为之前保存的值(oldmask)。

sigsuspend的另一种应用是等待一个信号处理程序设置一个全局变量。程序用于捕捉中断信号和退出信号,但是希望仅当捕捉到退出信号时,才唤醒主例程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include "apue.h"

volatile sig_atomic_t quitflag; /* set nonzero by signal handler */

static void
sig_int(int signo) /* one signal handler for SIGINT and SIGQUIT */
{
if (signo == SIGINT)
printf("\ninterrupt\n");
else if (signo == SIGQUIT)
quitflag = 1; /* set flag for main loop */
}

int
main(void)
{
sigset_t newmask, oldmask, zeromask;

if (signal(SIGINT, sig_int) == SIG_ERR)
err_sys("signal(SIGINT) error");
if (signal(SIGQUIT, sig_int) == SIG_ERR)
err_sys("signal(SIGQUIT) error");

sigemptyset(&zeromask);
sigemptyset(&newmask);
sigaddset(&newmask, SIGQUIT);

/*
* Block SIGQUIT and save current signal mask.
*/
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0)
err_sys("SIG_BLOCK error");

while (quitflag == 0)
sigsuspend(&zeromask);

/*
* SIGQUIT has been caught and is now blocked; do whatever.
*/
quitflag = 0;

/*
* Reset signal mask which unblocks SIGQUIT.
*/
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");

exit(0);
}

此程序的样本输出是:

1
2
3
4
5
6
7
8
$ ./a.out
^C 键入中断字符
interrupt
^C 再次健入中断字符
interrupt
^C 再一次
interrupt
^\$ 用退出符终止

可以用信号实现父、子进程之间的同步,这是信号应用的另一个实例。给出了TELLWAITTELL_PARENTTELL_CHILDWAIT_PARENTWAIT_CHILD5个例程的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include "apue.h"

static volatile sig_atomic_t sigflag; /* set nonzero by sig handler */
static sigset_t newmask, oldmask, zeromask;

static void
sig_usr(int signo) /* one signal handler for SIGUSR1 and SIGUSR2 */
{
sigflag = 1;
}

void
TELL_WAIT(void)
{
if (signal(SIGUSR1, sig_usr) == SIG_ERR)
err_sys("signal(SIGUSR1) error");
if (signal(SIGUSR2, sig_usr) == SIG_ERR)
err_sys("signal(SIGUSR2) error");
sigemptyset(&zeromask);
sigemptyset(&newmask);
sigaddset(&newmask, SIGUSR1);
sigaddset(&newmask, SIGUSR2);

/* Block SIGUSR1 and SIGUSR2, and save current signal mask */
if (sigprocmask(SIG_BLOCK, &newmask, &oldmask) < 0)
err_sys("SIG_BLOCK error");
}

void
TELL_PARENT(pid_t pid)
{
kill(pid, SIGUSR2); /* tell parent we're done */
}

void
WAIT_PARENT(void)
{
while (sigflag == 0)
sigsuspend(&zeromask); /* and wait for parent */
sigflag = 0;

/* Reset signal mask to original value */
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");
}

void
TELL_CHILD(pid_t pid)
{
kill(pid, SIGUSR1); /* tell child we're done */
}

void
WAIT_CHILD(void)
{
while (sigflag == 0)
sigsuspend(&zeromask); /* and wait for child */
sigflag = 0;

/* Reset signal mask to original value */
if (sigprocmask(SIG_SETMASK, &oldmask, NULL) < 0)
err_sys("SIG_SETMASK error");
}

其中使用了两个用户定义的信号:SIGUSR1由父进程发送给子进程,SIGUSR2由子进程发送给父进程。

如果在等待信号发生时希望去休眠,则使用sigsuspend函数是非常适当的。

函数abort

前面已提及abort函数的功能是使程序异常终止。

1
2
3
#include <stdlib.h>
void abort (void);
// 此函数不返回值

此函数将SIGABRT信号发送给调用进程(进程不应忽略此信号)。ISO C规定,调用abort将向主机环境递送一个未成功终止的通知,其方法是调用raise(SIGABRT)函数。ISO C要求若捕捉到此信号而且相应信号处理程序返回,abort仍不会返回到其调用者。如果捕捉到此信号,则信号处理程序不能返回的唯一方法是它调用exit_exit_Exitlongjmpsiglongjmp

让进程捕捉SIGABRT的意图是:在进程终止之前由其执行所需的清理操作。如果进程并不在信号处理程序中终止自己,POSIX.1声明当信号处理程序返回时,abort终止该进程。POSIX.1的要求是:如果abort调用终止进程,则它对所有打开标准I/O流的效果应当与进程终止前对每个流调用fclose相同。

abort函数是按POSIX.1说明实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void
abort(void) /* POSIX-style abort() function */
{
sigset_t mask;
struct sigaction action;

/* Caller can't ignore SIGABRT, if so reset to default */
sigaction(SIGABRT, NULL, &action);
if (action.sa_handler == SIG_IGN) {
action.sa_handler = SIG_DFL;
sigaction(SIGABRT, &action, NULL);
}
if (action.sa_handler == SIG_DFL)
fflush(NULL); /* flush all open stdio streams */

/* Caller can't block SIGABRT; make sure it's unblocked */
sigfillset(&mask);
sigdelset(&mask, SIGABRT); /* mask has only SIGABRT turned off */
sigprocmask(SIG_SETMASK, &mask, NULL);
kill(getpid(), SIGABRT); /* send the signal */

/* If we're here, process caught SIGABRT and returned */
fflush(NULL); /* flush all open stdio streams */
action.sa_handler = SIG_DFL;
sigaction(SIGABRT, &action, NULL); /* reset to default */
sigprocmask(SIG_SETMASK, &mask, NULL); /* just in case ... */
kill(getpid(), SIGABRT); /* and one more time */
exit(1); /* this should never be executed ... */
}

首先查看是否将执行默认动作,若是则冲洗所有标准I/O流。这并不等价于对所有打开的流调用fclose(因为只冲洗,并不关闭它们),但是当进程终止时,系统会关闭所有打开的文件。如果进程捕捉此信号并返回,那么因为进程可能产生了更多的输出,所以再一次冲洗所有的流。不进行冲洗处理的唯一条件是如果进程捕捉此信号,然后调用_exit_Exit。在这种情况下,任何来冲洗的内存中的标准I/O缓存都被丢弃。我们假定捕捉此信号,而且_exit_Exit的调用者并不想要冲洗缓冲区。我们阻塞除SIGABRT外的所有信号,这样就可知如果对kill的调用返回了,则该进程一定已捕捉到该信号,并且也从该信号处理程序返回。

函数system

POSIX.1要求system忽略SIGINTSIGQUIT,阻塞SIGCHLD。在给出一个正确地处理这些信号的一个版本之前,先说明为什么要考虑信号处理。

程序是system函数的另一个实现,它进行了所要求的信号处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include	<sys/wait.h>
#include <errno.h>
#include <signal.h>
#include <unistd.h>

int
system(const char *cmdstring) /* with appropriate signal handling */
{
pid_t pid;
int status;
struct sigaction ignore, saveintr, savequit;
sigset_t chldmask, savemask;

if (cmdstring == NULL)
return(1); /* always a command processor with UNIX */

ignore.sa_handler = SIG_IGN; /* ignore SIGINT and SIGQUIT */
sigemptyset(&ignore.sa_mask);
ignore.sa_flags = 0;
if (sigaction(SIGINT, &ignore, &saveintr) < 0)
return(-1);
if (sigaction(SIGQUIT, &ignore, &savequit) < 0)
return(-1);
sigemptyset(&chldmask); /* now block SIGCHLD */
sigaddset(&chldmask, SIGCHLD);
if (sigprocmask(SIG_BLOCK, &chldmask, &savemask) < 0)
return(-1);

if ((pid = fork()) < 0) {
status = -1; /* probably out of processes */
} else if (pid == 0) { /* child */
/* restore previous signal actions & reset signal mask */
sigaction(SIGINT, &saveintr, NULL);
sigaction(SIGQUIT, &savequit, NULL);
sigprocmask(SIG_SETMASK, &savemask, NULL);

execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127); /* exec error */
} else { /* parent */
while (waitpid(pid, &status, 0) < 0)
if (errno != EINTR) {
status = -1; /* error other than EINTR from waitpid() */
break;
}
}

/* restore previous signal actions & reset signal mask */
if (sigaction(SIGINT, &saveintr, NULL) < 0)
return(-1);
if (sigaction(SIGQUIT, &savequit, NULL) < 0)
return(-1);
if (sigprocmask(SIG_SETMASK, &savemask, NULL) < 0)
return(-1);

return(status);
}

system的返回值

注意system的返回值,它是shell的终止状态,但shell的终止状态并不总是执行命令字符串进程的终止状态。如果执行一条如date那样的简单命令,其终止状态是0。执行shell命令exit 44,则得终止状态44。在信号方面又如何呢?

Bourne shell有一个在其文档中没有说清楚的特性,其终止状态是128加上一个信号编号,该信号终止了正在执行的命令。用交互方式使用shell可以看到这一点。

1
2
3
4
5
6
7
8
9
10
$ sh       确保运行Bourneshell
$ sh -c "sleep 30"
^C 键入中断符
$ echo $? 打印最后一条命令的终止状态
130
$ sh -c "sleep 30"
^\sh: 962 Quit - core dumped 键入退出符
$ echo $? 打印最后一条命令的终止状态
131
$ exit 离开Bourne shell

在所使用的系统中,SIGINT的值为2,SIGQUIT的值为3,于是给出shell终止状态130、131。

函数sleep、nanosleep和clock_nanosleep

两个sleep的实现都是有缺陷的。

1
2
3
#include <unistd.h>
unsigned int sleep(unsigned int seconds);
// 返回值:0或未休眠完的秒数

此函数使调用进程被挂起直到满足下面两个条件之一。

  1. 已经过了seconds所指定的墙上时钟时间。
  2. 调用进程捕捉到一个信号并从信号处理程序返回。

如同alarm信号一样,由于其他系统活动,实际返回时间比所要求的会迟一些。在第1种情形,返回值是0。当由于捕捉到某个信号sleep提早返回时(第2种情形),返回值是未休眠完的秒数(所要求的时间减去实际休眠时间)。

尽管sleep可以用alarm函数实现,但这并不是必需的。如果使用alarm,则这两个函数之间可能相互影响。

给出的是一个POSIX.1 sleep函数的实现,它可靠地处理信号,避免了早期实现中的竞争条件,但是仍未处理与以前设置的闹钟的交互作用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
include "apue.h"

static void
sig_alrm(int signo)
{
/* nothing to do, just returning wakes up sigsuspend() */
}

unsigned int
sleep(unsigned int seconds)
{
struct sigaction newact, oldact;
sigset_t newmask, oldmask, suspmask;
unsigned int unslept;

/* set our handler, save previous information */
newact.sa_handler = sig_alrm;
sigemptyset(&newact.sa_mask);
newact.sa_flags = 0;
sigaction(SIGALRM, &newact, &oldact);

/* block SIGALRM and save current signal mask */
sigemptyset(&newmask);
sigaddset(&newmask, SIGALRM);
sigprocmask(SIG_BLOCK, &newmask, &oldmask);

alarm(seconds);
suspmask = oldmask;

/* make sure SIGALRM isn't blocked */
sigdelset(&suspmask, SIGALRM);
/* wait for any signal to be caught */
sigsuspend(&suspmask);

/* some signal has been caught, SIGALRM is now blocked */

unslept = alarm(0);

/* reset previous action */
sigaction(SIGALRM, &oldact, NULL);

/* reset signal mask, which unblocks SIGALRM */
sigprocmask(SIG_SETMASK, &oldmask, NULL);
return(unslept);
}

程序中没有使用任何形式的非局部转移,所以对处理SIGALRM信号期间可能执行的其他信号处理程序没有任何影响。nanosleep函数与sleep函数类似,但提供了纳秒级的精度。

1
2
3
#include <time.h>
int nanosleep (const struct timespec *reqtp, struct timespec *remp);
// 返回值,若休眠到要求的时间,返回0,若出错,返回-1

这个函数挂起调用进程,直到要求的时间已经超时或者某个信号中断了该函数。reqtp参数用秒和纳秒指定了需要休眠的时间长度。如果某个信号中断了休眠间隔,进程并没有终止,remtp参数指向的timespec结构就会被设置为未休眠完的时间长度。如果对未休眠完的时间并不感兴趣,可以把该参数置为NULL,如果系统并不支持纳秒这一精度,要求的时间就会取整。因为nanosleep函数并不涉及产生任何信号,所以不需要担心与其他函数的交互。

随着多个系统时钟的引入,需要使用相对于特定时钟的延迟时间来挂起调用线程。clock_nanosleep函数提供了这种功能,

1
2
3
#include <time.h>
int clock_nanosleep (clockid_t clock_id, int flag, const struct tinespec *reqtp, struct timespec *remtp);
// 返回值:若休眠要求的时间,返回0;若出错,返回错误码

clook_id参数指定了计算延迟时间基于的时钟。flags参数用于控制延迟是相对的还是绝对的。flags为0时表示休眠时间是相对的,如果flags值设置为TIMER_ABSTIME,表示休眠时间是绝对的。其他的参数reqtpremtp,与nanosleep函数中的相同。在时钟到达指定的绝对时间值以前,可以为其他的clock_nanosleep调用复用reqtp参数相同的值。

注意,除了出错返回,调用

1
clock_nanosleep (CLOCK_REALTIME, 0, reqtp, remtp);

和调用
1
nanosleep (reqtp, remtp);

的效果是相同的。使用相对休眠的问题是有些应用对休眠长度有精度要求,相对休眠时间会导致实际休眠时间比要求的长。例如,某个应用程序希望按固定的时间间隔执行任务,就必须获取当前时间,计算下次执行任务的时间,然后调用nanosleep。在获取当前时间和调用nanosleep之间,处理器调度和抢占可能会导致相对休眠时间超过实际需要的时间间隔。即便分时进程调度程序对休眠时间结束后是否会马上执行用户任务并没有给出保证,使用绝对时间还是改善了精度。

函数sigqueue

通常一个信号带有一个位信息:信号本身。除了对信号排队以外,这些扩展允许应用程序在递交信号时传递更多的信息。这些信息嵌入在siginfo结构中。除了系统提供的信息,应用程序还可以向信号处理程序传递整数或者指向包含更多信息的缓冲区指针。使用排队信号必须做以下几个操作。

  1. 使用sigaction函数安装信号处理程序时指定SA_SIGINFO标志,如果没有给出这个标志,信号会延迟,但信号是否进入队列要取决于具体实现。
  2. sigaction结构的sa_sigaction成员中(而不是通常的sa_handler字段)提供信号处理程序。实现可能允许用户使用sa_handler字段,但不能获取sigqueue函数发送出来的额外信息。
  3. 使用sigqueue函数发送信号。
1
2
3
#include <aigna1.h>
int sigqueue (pid_t pid, int signo, const union sigval value);
// 返回值:若成功,返回0,若出错,返回-1

sigqueue函数只能把信号发送给单个进程,可以使用value参数向信号处理程序传递整数和指针值,除此之外,sigqueue函数与kill函数类似。

信号不能被无限排队。回忆SIGQUEUE_MAX限制。到达相应的限制以后,sigqueue就会失败,将errno设为EAGAIN,随着实时信号的增强,引入了用于应用程序的独立信号集。这些信号的编号在SIGRTMIN~SIGRTMAX之间,包括这两个限制值。注意,这些信号的默认行为是终止进程。

作业控制信号

POSIX.1认为有以下6个与作业控制有关。

  • SIGCHLD:子进程已停止或终止。
  • SIGCONT:如果进程已停止,则使其继续运行。
  • SIGSTOP:停止信号(不能被捕捉或忽略)。
  • SIGTSTP:交互式停止信号。
  • SIGTTIN:后台进程组成员读控制终端。
  • SIGTTOU:后台进程组成员写控制终端。

SIGCHLD以外,大多数应用程序并不处理这些信号,交互式shell则通常会处理这些信号的所有工作。当键入挂起字符(通常是Ctrl+Z)时,SIGTSTP被送至前台进程组的所有进程。当我们通知shell在前台或后台恢复运行一个作业时,shell向该作业中的所有进程发送SIGCONT信号。与此类似。如果向一个进程递送了SIGTTINSIGTTOU信号,则根据系统默认的方式,停止此进程,作业控制shell了解到这一点后就通知我们。

一个例外是管理终端的进程,例如,vi(1)编辑器。当用户要挂起它时,它需要能了解到这一点。这样就能将终端状态恢复到vi启动时的情况。另外,当在前台恢复它时,它需要将终端状态设置回它所希望的状态,并需要重新绘制终端屏幕。

在作业控制信号间有某些交互。当对一个进程产生4种停止信号(SIGTSTPSIGSTOPSIGTTINSIGTTOU)中的任意一种时,对该进程的任一未决SIGCONT信号就被丢弃。与此类似,当对一个进程产生SIGCONT信号时,对同一进程的任一未决停止信号被丢弃。

注意,如果进程是停止的,则SIGCONT的默认动作是继续该进程:否则忽略此信号。通常,对该信号无需做任何事情。当对一个停止的进程产生一个SIGCONT信号时,该进程就继续,即使该信号是被阻塞或忽略的也是如此。

程序演示了当一个程序处理作业控制时通常所使用的规范代码序列。该程序只是将其标准输入复制到其标准输出,而在信号处理程序中以注释形式给出了管理屏幕的程序所执行的典型操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include "apue.h"

#define BUFFSIZE 1024

static void
sig_tstp(int signo) /* signal handler for SIGTSTP */
{
sigset_t mask;

/* ... move cursor to lower left corner, reset tty mode ... */

/*
* Unblock SIGTSTP, since it's blocked while we're handling it.
*/
sigemptyset(&mask);
sigaddset(&mask, SIGTSTP);
sigprocmask(SIG_UNBLOCK, &mask, NULL);

signal(SIGTSTP, SIG_DFL); /* reset disposition to default */

kill(getpid(), SIGTSTP); /* and send the signal to ourself */

/* we won't return from the kill until we're continued */

signal(SIGTSTP, sig_tstp); /* reestablish signal handler */

/* ... reset tty mode, redraw screen ... */
}

int
main(void)
{
int n;
char buf[BUFFSIZE];

/*
* Only catch SIGTSTP if we're running with a job-control shell.
*/
if (signal(SIGTSTP, SIG_IGN) == SIG_DFL)
signal(SIGTSTP, sig_tstp);

while ((n = read(STDIN_FILENO, buf, BUFFSIZE)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
err_sys("write error");

if (n < 0)
err_sys("read error");

exit(0);
}

当程序启动时,仅当SIGTSTP信号的配置是SIG_DFL,它才安排捕捉该信号。其理由是:当此程序由不支持作业控制的shell (如/bin/sh)启动时,此信号的配置应当设置为SIG_IGN。实际上,shell并不显式地忽略此信号, 而是由init将这3个作业控制信号SIGTSTPSIGTTINSIGTTOU设置为SIG_IGN。然后,这种配置由所有登录shell继承。只有作业控制shell才应将这3个信号重新设置为SIG_DFL

当键入挂起字符时,进程接到SIGTSTP信号,然后调用该信号处理程序。此时,应当进行与终端有关的处理:将光标移到左下角、恢复终端工作方式等。在将SIGTSTP重置为默认值(停止该进程),并且解除了对此信号的阻塞之后,进程向自己发送同一信号SIGTSTP。因为正在处理SIGTSTP信号,而在捕捉该信号期间系统自动地阻塞它,所以应当解除对此信号的阻塞。到达这一点时,系统停止该进程。仅当某个进程向该进程发送一个SIGCONT信号时,该进程才继续。我们不捕捉SIGCONT信号。该信号的默认配置是继续运行停止的进程,当此发生时,此程序如同从kill函数返回一样继续运行。当此程序继续运行时,将SIGTSTP信号重置为捕捉,并且做我们所希望做的终端处理。

信号名和编号

本节介绍如何在信号编号和信号名之间进行映射。某些系统提供数组

1
extern char *sys_siglist[]:

数组下标是信号编号,数组中的元素是指向信号名符串的指针。

可以使用psignal函数可移植地打印与信号编号对应的字符串。

1
2
#include <signal.h>
void psignal(int signo, const char *msg):

字符串msg(通常是程序名)输出到标准错误文件,后面跟随一个冒号和一个空格,再后面对该信号的说明,最后是一个换行符。如果msg为NULL,只有信号说明部分输出到标准错误文件。

如果在sigaction信号处理程序中有siginfo结构,可以使用psiginfo函数打印信号信息。

1
2
#include <signal.h>
void psiginfo(const siginfo_t *info, const char *msg);

它的工作方式与psignal函数类似。虽然这个函数访问除信号编号以外的更多信息,但不同的平台输出的这些额外信息可能有所不同。

如果只需要信号的字符描述部分,也不需要把它写到标准错误文件中(如可以写到日志文件中),可以使用strsignal函数。

1
2
3
#include <string.h>
char *strsignal(int signo);
// 返回值:指向描述该信号的字符串的指针

给出一个信号编号,strsignal将返回描述该信号的字符串。应用程序可用该字符串打印关于接收到信号的出错信息。

Solaris提供一对函数,一个函数将信号编号映射为信号名,另一个则反之。

1
2
3
4
#include <signal.h>
int sig2str(int signo, char *str);
int str2sig(const char *str, int *signop);
// 两个函数的返回值:若成功,返回0;若出错,返回-1

在编写交互式程序,其中需接收和打印信号名和信号编号时,这两个函数是有用的。

sig2str函数将给定信号编号翻译成字符串,并将结果存放在str指向的存储区。调用者必须保证该存储区足够大,可以保存最长字符串,包括终止null字节。Solaris在<signal.h>中包含了常量SIG2STR_MAX,它定义了最大字符串长度。该字符串包括不带“SIG”前缀的信号名。例如,SIGKILL被翻译为字符串“KILL”,并存放在str指向的存储缓冲区中。

str2sig函数将给出的信号名翻译成信号编号,该信号编号存放在signop指向的整型中。名字要么是不带“SIG”前缀的信号名,要么是表示十进制信号编号的字符串(如“9”)。注意,sig2strstr2sig与常用的函数做法不同,当它们失败时,并不设置errno。

线程

线程概念

典型的UNIX进程可以看成只有一个控制线程:一个进程在某一时刻只能做一件事情。每个线程处理各自独立的任务有很多好处,通过为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。每个线程在进行事件处理时可以采用同步编程模式,同步编程模式要比异步编程模式简单得多。

多个进程必须使用操作系统提供的复杂机制才能实现内存和文件描述符的共享,而多个线程自动地可以访问相同的存储地址空间和文件播述符。

每个线程都包含有表示执行环境所必需的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行程序的代码、程序的全局内存和堆内存、栈以及文件描述符。线程接口也称为“pthread”或“POSIX线程”。

POSIX线程的功能测试宏是_POSTX_THREADS。应用程序可以把这一个宏用于#ifdef测试,从而在编译时确定是否支持线程:也可以把_SC_THREADS常数用于调用sysconf函数,进而在运行时确定是否支持线程。

线程标识

就像每个进程有一个进程ID一样,每个线程也有一个线程ID。进程ID在整个系统中是唯一的,但线程ID不同,线程ID只有在它所属的进程上下文中才有意义。进程ID是用pid_t数据类型来表示的,是一个非负整数。线程ID是用pthread_t数据类型来表示的,实现的时候可以用一个结构来代表pthread_t数据类型,所以可移植的操作系统实现不能把它作为整数处理。因此必须使用一个函数来对两个线程ID进行比较。

1
2
3
#include <pthread.h>
int pthread_equal (pthread_t tid1, pthread_t tid2);
// 返回值。若相等,返回非0数值;否则,返回0

用结构表示pthread_t数据类型的后果是不能用一种可移植的方式打印该数据类型的值。在程序调试过程中打印线程ID有时是非常有用的, 而在其他情况下通常不需要打印线程ID。

线程可以通过调用pthread_self函数获得自身的线程ID。

1
2
3
#include <pthread.h>
pthread_t pthzead_self (void);
// 返回值,调用线程的线程ID

当线程需要识别以线程ID作为标识的数据结构时,pthread_self函数可以与pthread_equal函数一起使用。例如,主线程可能把工作任务放在一个队列中,用线程ID来控制每个工作线程处理哪些作业。主线程把新的作业放到一个工作队列中,由3个工作线程组成的线程池从队列中移出作业。主线程不允许每个线程任意处理从队列顶端取出的作业,而是由主线程控制作业的分配,主线程会在每个待处理作业的结构中放置处理该作业的线程ID,每个工作线程只能移出标有自己线程ID的作业。

线程创建

在传统UNIX进程模型中,每个进程只有一个控制线程。从概念上讲,这与基于线程的模型中每个进程只包含一个线程是相同的。在POSIX线程(pthread)的情况下,程序开始运行时,它也是以单进程中的单个控制线程启动的。在创建多个控制线程以前,程序的行为与传统的进程并没有什么区别。新增的线程可以通过调用pthread_create函数创建。

1
2
3
4
#include <pthread.h>
int pthread_create (pthread_t *restrict tidp, const pthread_attr_t *restrict attr,
void *(*start_rtn) (void *), void *restrict arg);
// 返回值,若成功,返回0;否则,返回错误编号

pthread_create成功返回时, 新创建线程的线程ID会被设置成tidp指向的内存单元。attr参数用于定制各种不同的线程属性。新创建的线程从start_rtn函数的地址开始运行, 该函数只有一个无类型指针参数arg。如果需要向start_rtn函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数传入。

线程创建时并不能保证哪个线程会先运行,是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。

注意,phread函数在调用失败时通常会返回错误码,它们并不像其他的POSIX函数—样设置errno。每个线程都提供errno的副本,这只是为了与使用errno的现有函数兼容。在线程中,从函数中返回错误码更为清晰整洁,不需要依赖那些随着函数执行不断变化的全局状态,这样可以把错误的范围限制在引起出错的函数中。

可以写一个小的测试程序来完成打印线程ID任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include "apue.h"
#include <pthread.h>

pthread_t ntid;

void
printids(const char *s)
{
pid_t pid;
pthread_t tid;

pid = getpid();
tid = pthread_self();
printf("%s pid %lu tid %lu (0x%lx)\n", s, (unsigned long)pid,
(unsigned long)tid, (unsigned long)tid);
}

void *
thr_fn(void *arg)
{
printids("new thread: ");
return((void *)0);
}

int
main(void)
{
int err;

err = pthread_create(&ntid, NULL, thr_fn, NULL);
if (err != 0)
err_exit(err, "can't create thread");
printids("main thread:");
sleep(1);
exit(0);
}

这个实例有两个特别之处,需要处理主线程和新线程之间的竞争。第一个特别之处在于,主线程需要休眠,如果主线程不休眼,它就可能会退出,这样新线程还没有机会运行,整个进程可能就已经终止了,这种行为特征依赖于操作系统中的线程实现和调度算法。

第二个特别之处在于新线程是通过调用pthread_self函数获取自己的线程ID的,而不是从共享内存中读出的,或者从线程的启动例程中以参数的形式接收到的。pthread_create函数会通过第一个参数(tidp)返回新建线程的线程ID。在这个例子中,主线程把新线程ID存放在ntid中,但是新建的线程并不能安全地使用它,如果新线程在主线程调用pthread_create返回之前就运行了,那么新线程看到的是未经初始化的ntid的内容,这个内容并不是正确的线程ID。

运行程序,得到:

1
2
3
$ ./a.out
main thread: pid 20075 tid 1 (0x1)
new thread: p1d 20075 tid 2 (0x2)

正如我们期望的,两个线程的进程ID相同,但线程ID不同。在FreeBSD上运行程序,得到:

1
2
3
$ ./a.out
main thread: pid 37396 tid 673190208 (0x28201140)
new thread: pid 37396 tid 673200320 (0x28217140)

也如我们期望的,两个线程有相同的进程ID。把它们转化成十六进制,就像前面提到的,FreeBSD使用指向线程数据结构的指针作为它的线程ID。

线程终止

如果进程中的任意线程调用了exit_Exit或者_exit,那么整个进程就会终止。与此相类似,如果默认的动作是终止进程,那么,发送到线程的信号就会终止整个进程。

单个线程可以通过3种方式退出,因此可以在不终止整个进程的情况下,停止它的控制流。

  1. 线程可以简单地从启动例程中返回,返回值是线程的退出码。
  2. 线程可以被同一进程中的其他线程取消。
  3. 线程调用pthread_exit
1
2
#include <pthread.h>
void pthread_exit (void *rval_ptr):

rval_ptr参数是一个无类型指针,与传给启动例程的单个参数类似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指针。

1
2
3
#include <pthread.h>
int pthread_join (pthread_t thread_void **rval_ptr);
// 返回值,若成功,返回0;否则,返回错误编号

调用线程将一直阻塞,直到指定的线程调用pthread_exit。从启动例程中返回或者被取消。如果线程简单地从它的启动例程返回,rval_ptr就包含返回码。如果线程被取消,由rval_ptr指定的内存单元就设置为PTHREAD_CANCELED

可以通过调用pthread_join自动把线程置于分离状态,这样资源就可以恢复。如果线程已经处于分离状态,pthread_join调用就会失败,返回EINVAL,尽管这种行为是与具体实现相关的。

如果对线程的返回值并不感兴趣,那么可以把rval_ptr设置为NULL。在这种情况下,调用pthread_join函数可以等待指定的线程终止,但并不获取线程的终止状态。

程序展示了如何获取已终止的线程的退出码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include "apue.h"
#include <pthread.h>

void *
thr_fn1(void *arg)
{
printf("thread 1 returning\n");
return((void *)1);
}

void *
thr_fn2(void *arg)
{
printf("thread 2 exiting\n");
pthread_exit((void *)2);
}

int
main(void)
{
int err;
pthread_t tid1, tid2;
void *tret;

err = pthread_create(&tid1, NULL, thr_fn1, NULL);
if (err != 0)
err_exit(err, "can't create thread 1");
err = pthread_create(&tid2, NULL, thr_fn2, NULL);
if (err != 0)
err_exit(err, "can't create thread 2");
err = pthread_join(tid1, &tret);
if (err != 0)
err_exit(err, "can't join with thread 1");
printf("thread 1 exit code %ld\n", (long)tret);
err = pthread_join(tid2, &tret);
if (err != 0)
err_exit(err, "can't join with thread 2");
printf("thread 2 exit code %ld\n", (long)tret);
exit(0);
}

运行程序,得到的结果是:

1
2
3
4
5
$ ./a.out
thread 1 returning
thread 2 exiting
thread 1 exit code 1
thread 2 exit code 2

可以看到,当一个线程通过调用pthread_exit退出或者简单地从启动例程中返回时,进程中的其他线程可以通过调用pthread_join函数获得该线程的退出状态。

pthread_createpthread_exit函数的无类型指针参数可以传递的值不止一个,这个指针可以传递包含复杂信息的结构的地址,但是注意,这个结构所使用的内存在调用者完成调用以后必须仍然是有效的。例如,在调用线程的栈上分配了该结构,那么其他的线程在使用这个结构时内存内容可能已经改变了。又如,线程在自己的栈上分配了一个结构,然后把指向这个结构的指针传给pthread_exit,那么调用pthread_join的线程试图使用该结构时,这个核有可能已经被撤销,这块内存也已另作他用。

程序给出了用自动变量(分配在栈上)作为pthread_exit的参数时出现的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
struct foo {
int a, b, c, d;
};

void
printfoo(const char *s, const struct foo *fp)
{
printf("%s", s);
printf(" structure at 0x%lx\n", (unsigned long)fp);
printf(" foo.a = %d\n", fp->a);
printf(" foo.b = %d\n", fp->b);
printf(" foo.c = %d\n", fp->c);
printf(" foo.d = %d\n", fp->d);
}

void *
thr_fn1(void *arg)
{
struct foo foo = {1, 2, 3, 4};

printfoo("thread 1:\n", &foo);
pthread_exit((void *)&foo);
}

void *
thr_fn2(void *arg)
{
printf("thread 2: ID is %lu\n", (unsigned long)pthread_self());
pthread_exit((void *)0);
}

int
main(void)
{
int err;
pthread_t tid1, tid2;
struct foo *fp;

err = pthread_create(&tid1, NULL, thr_fn1, NULL);
if (err != 0)
err_exit(err, "can't create thread 1");
err = pthread_join(tid1, (void *)&fp);
if (err != 0)
err_exit(err, "can't join with thread 1");
sleep(1);
printf("parent starting second thread\n");
err = pthread_create(&tid2, NULL, thr_fn2, NULL);
if (err != 0)
err_exit(err, "can't create thread 2");
sleep(1);
printfoo("parent:\n", fp);
exit(0);
}

在Linux上运行此程序,得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ ./a.out
thread 1:
structure at 0x7f2c83682ed0
foo.a = 1
foo.b = 2
foo.c = 3
foo.d = 4
parent starting second thread
thread 2: ID is 139829159933636
parent:
structure at 0x7t2c83682ed0
foo.a = -2090321472
foo.b = 32556
foo.c = 1
foo.d = 0

可以看到,当主线程访问这个结构时,结构的内容已经改变了。注意第二个线程(tid2)的栈是如何覆盖第一个线程的栈的。为了解决这个问题,可以使用全局结构,或者用malloc函数分配结构。

线程可以通过调用pthread_cancel函数来请求取消同一进程中的其他线程。

1
2
3
#include <pthread.h>
int pthread_cancel (pthread_t tid);
// 返回值,若成功,返回0;否则,返回错误编号

在默认情况下,pthread_cancel函数会使得由tid标识的线程的行为表现为如同调用了参数为PTHREAD_CANCELEDpthread_exit函数,但是,线程可以选择忽略取消或者控制如何被取消。注意pthread_cancel并不等待线程终止,它仅仅提出请求。

线程可以安排它退出时需要调用的函数,这与进程在退出时可以用atexit函数安排退出是类似的。这样的函数称为线程清理处理程序(thread cleanup handier),一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。

1
2
3
#include <pthread.h>
void pthread_eleanup_push (void (*rtn) (void *), void *arg);
void pthread_cleanup_pop (int execute);

当线程执行以下动作时,清理函数rm是由pthread_cleanup_push函数调度的,调用时只有一个参数arg

  • 调用pthread_exit时:
  • 响应取消请求时:
  • 用非零execute参数调用pthread_cleanup_pop时。

如果execute参数设置为0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序.这些函数有一个限制,由于它们可以实现为宏,所以必须在与线程相同的作用域中以匹配对的形式使用。pthread_cleanup_push的宏定义可以包含字符{,这种情况下,在pthread_cleanup_pop的定义中要有对应的匹配字符}

给出一个如何使用线程清理处理程序的例子,它描述了其中涉及的清理机制。注意,虽然我们从来没想过要传一个参数0给线程启动例程,但还是需要把pthread_cleanup_pop调用和pthread_cleanup_push调用匹配起来,否则,程序编译就可能通不过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include "apue.h"
#include <pthread.h>

void
cleanup(void *arg)
{
printf("cleanup: %s\n", (char *)arg);
}

void *
thr_fn1(void *arg)
{
printf("thread 1 start\n");
pthread_cleanup_push(cleanup, "thread 1 first handler");
pthread_cleanup_push(cleanup, "thread 1 second handler");
printf("thread 1 push complete\n");
if (arg)
return((void *)1);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
return((void *)1);
}

void *
thr_fn2(void *arg)
{
printf("thread 2 start\n");
pthread_cleanup_push(cleanup, "thread 2 first handler");
pthread_cleanup_push(cleanup, "thread 2 second handler");
printf("thread 2 push complete\n");
if (arg)
pthread_exit((void *)2);
pthread_cleanup_pop(0);
pthread_cleanup_pop(0);
pthread_exit((void *)2);
}

int
main(void)
{
int err;
pthread_t tid1, tid2;
void *tret;

err = pthread_create(&tid1, NULL, thr_fn1, (void *)1);
if (err != 0)
err_exit(err, "can't create thread 1");
err = pthread_create(&tid2, NULL, thr_fn2, (void *)1);
if (err != 0)
err_exit(err, "can't create thread 2");
err = pthread_join(tid1, &tret);
if (err != 0)
err_exit(err, "can't join with thread 1");

运行程序会得到:

1
2
3
4
5
6
7
8
9
$ ./a.out
thread 1 start
thread 1 push complete
thread 2 start
thread 2 push complete
cleanup: thread 2 second handler
cleanup: thread 2 first handler
thread 1 exit code 1
thread 2 exit code 2

从输出结果可以看出,两个线程都正确地启动和退出了,但是只有第二个线程的清理处理程序被调用了。因此,如果线程是通过从它的启动例程中返回而终止的话,它的清理处理程序就不会被调用。还要注意,清理处理程序是按照与它们安装时相反的顺序被调用的。

在FreeBSD或者MacOSX上,pthread_cleanup_push是用宏实现的,而宏把某些上下文存放在栈上。当线程1在调用pthread_cleanup_push和调用pthread_cleanup_pop之间返回时,栈已被改写,而这两个平台在调用清理处理程序时就用了这个被改写的上下文。在Single UNIX Specification中,函数如果在调用pthread_cleanup_pushpthread_cleanup_pop之间返回,会产生未定义行为。唯一的可移植方法是调用pthread_exit

现在,让我们了解一下线程函数和进程函数之间的相似之处。

进程原语 线程原语 描述
fork pthread_create 创建新的控制流
exit pthread_exit 从现有的控制流中退出
waitpid pthread_join 从控制流中得到退出状态
atexit pthread_cancel_push 注册在退出控制流时调用的函数
getpid pthread_self 获取控制流的ID
abort pthread_cancel 请求控制流的非正常退出

在默认情况下,线程的终止状态会保存直到对该线程调用pthread_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它的终止状态,因为对分离状态的线程调用pthread_join会产生未定义行为。可以调用pthread_detach分离线程。

1
2
3
#include <pthread.h>
int pthread_detach (pthread_t sid);
// 返回值:若成功,返回0;否则,返回情谈编号

线程同步

当一个线程可以修改的变量,其他线程也可以读取或者修改的时候,我们就需要对这些线程进行同步,确保它们在访问变量的存储内容时不会访问到无效的值。在变量修改时间多于一个存储器访问周期的处理器结构中,当存储器读与存储器写这两个周期交叉时,这种不一致就会出现。

为了解决这个问题,线程不得不使用锁,同一时间只允许一个线程访问该变量。如果线程B希望读取变量,它首先要获取锁。同样,当线程A更新变量时,也需要获取同样的这把锁。这样,线程B在线程A释放锁以前就不能读取变量。

两个或多个线程试图在同一时间修改同一变量时,也需要进行同步。考虑变量增量操作的情况,增量操作通常分解为以下3步。

  1. 从内存单元读入寄存器
  2. 在寄存器中对变量做增量操作,
  3. 把新的值写回内存单元

如果两个线程试图几乎在同一时间对同一个变量做增量操作而不进行同步的话,结果就可能出现不一致。如果修改操作是原子操作,那么就不存在竞争。如果数据总是以顺序一致出现的,就不需要额外的同步。当多个线程观察不到数据的不一致时,那么操作就是顺序一致的。在现代计算机系统中,存储访问需要多个总线周期,多处理器的总线周期通常在多个处理器上是交叉的,所以我们并不能保证数据是顺序一致的。

互斥量

可以使用pthread的互斥接口来保护数据,确保同一时间只有一个线程访问数据。互斥量(mutex)从本质上说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。对互斥量进行加锁以后,任何其他试图再次对互斥量加锁的线程都会被阻塞,直到当前线程释放该互斥锁。如果释放互斥量时有一个以上的线程阻塞,那么所有该锁上的阻塞线程都会变成可运行状态,第一个变为运行的线程就可以对互斥量加锁,其他线程就会看到互斥量依然是锁着的,只能回去再次等待它重新变为可用。在这种方式下,每次只有一个线程可以向前执行。

只有将所有线程都设计成遵守相同数据访问规则的。互斥机制才能正常工作。操作系统并不会为我们做数据访问的申行化。如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。

互斥变量是用pthread_mutex_t数据类型表示的。在使用互斥变量以前,必须首先对它进行初始化,可以把它设置为常量PTHREAD_MUTEX_ INITIALIZER(只适用于静态分配的互斥量),也可以通过调用pthread_mutex_init函数进行初始化。如果动态分配互斥量(例如,通过调用malloc函数),在释放内存前需要调用pthread_mutex_destroy

1
2
3
4
#include <pthread.h>
int pthread_mutex_init (pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy (pthread_mutex_t* mutex);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

要用默认的属性初始化互斥量,只需把attr设为NULL。

对互斥量进行加锁,需要调用pthread_mutex_lock。如果互斥量已经上锁,调用线程将阻塞直到互斥量被解锁。对互斥量解锁,需要调用pthread_mutex_unlock

1
2
3
4
5
#include epthread.h>
int pthread_mutex_lock (pthread_mutex_t *mutex);
int pthread_mutex_trylock (pthread_mutex_t *mutex);
int pthread_mutex_unlock (pthread_mutex_t *mutex);
// 所有函数的返回值,若成功,返回0;否则,返回错误编号

如果线程不希望被阻塞,它可以使用pthread_mutex_trylock尝试对互斥量进行加锁。如果调用pthread_mutex_trylock时互斥量处于未锁住状态,那么pthread_mutex_trylock将锁住互斥量,不会出现阻塞直接返回0;否则pthread_mutex_trylock就会失败,不能锁住互斥量,返回EBUSY。

当一个以上的线程需要访问动态分配的对象时,我们可以在对象中嵌入引用计数,确保在所有使用该对象的线程完成数据访问之前,该对象内存空间不会被释放。

在使用该对象前,线程需要调用foo_hold对这个对象的引用计数加1。当对象使用完毕时,必须调用foo_rele释放引用。最后一个引用被释放时,对象所占的内存空间就被释放。在这个例子中,我们忽略了线程在调用foo_hold之前是如何找到对象的。如果有另一个线程在调用foo_hold时阻塞等待互斥锁,这时即使该对象引用计数为0,foo_rele释放该对象的内存仍然是不对的。可以通过确保对象在释放内存前不会被找到这种方式来避免上述问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdlib.h>
#include <pthread.h>

struct foo {
int f_count;
pthread_mutex_t f_lock;
int f_id;
/* ... more stuff here ... */
};

struct foo *
foo_alloc(int id) /* allocate the object */
{
struct foo *fp;

if ((fp = malloc(sizeof(struct foo))) != NULL) {
fp->f_count = 1;
fp->f_id = id;
if (pthread_mutex_init(&fp->f_lock, NULL) != 0) {
free(fp);
return(NULL);
}
/* ... continue initialization ... */
}
return(fp);
}

void
foo_hold(struct foo *fp) /* add a reference to the object */
{
pthread_mutex_lock(&fp->f_lock);
fp->f_count++;
pthread_mutex_unlock(&fp->f_lock);
}

void
foo_rele(struct foo *fp) /* release a reference to the object */
{
pthread_mutex_lock(&fp->f_lock);
if (--fp->f_count == 0) { /* last reference */
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_destroy(&fp->f_lock);
free(fp);
} else {
pthread_mutex_unlock(&fp->f_lock);
}
}

避免死锁

如果线程试图对同一个互斥量加锁两次,那么它自身就会陷入死锁状态,但是使用互斥量时,还有其他不太明显的方式也能产生死锁。例如,程序中使用一个以上的互斥量时,如果允许一个线程一直占有第一个互斥量,并且在试图锁住第二个互斥量时处于阻塞状态,但是拥有第二个互斥最的线程也在试图锁住第一个互斥量。因为两个线程都在相互请求另一个线程拥有的资源,所以这两个线程都无法向前运行,于是就产生死锁。

可以通过仔细控制互斥量加锁的顺序来避免死锁的发生。例如,假设需要对两个互斥量A和B同时加锁。如果所有线程总是在对互斥量B加锁之前锁住互斥量A,那么使用这两个互斥量就不会产生死锁(当然在其他的资源上仍可能出现死锁)。可能出现的死锁只会发生在一个线程试图锁住另一个线程以相反的顺序锁住的互斥量。

可以先释放占有的锁,然后过一段时间再试。这种情况可以使用pthread_mutex_trylock接口避免死锁。如果已经占有某些锁而且pthread_mutex_trylock接口返回成功,那么就可以前进。但是,如果不能获取锁,可以先释放已经占有的锁,做好清理工作,然后过一段时间再重新试。

在同时需要两个互斥量时,总是让它们以相同的顺序加锁,这样可以避免死锁。第二个互斥量维护着一个用于跟踪foo数据结构的散列列表。这样hashlock互斥量既可以保护foo数据结构中的散列表fh,又可以保护散列链字段e_nextfoo结构中的t_lock互斥量保护对foo结构中的其他字段的访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
#include <stdlib.h>
#include <pthread.h>

#define NHASH 29
#define HASH(id) (((unsigned long)id)%NHASH)

struct foo *fh[NHASH];

pthread_mutex_t hashlock = PTHREAD_MUTEX_INITIALIZER;

struct foo {
int f_count;
pthread_mutex_t f_lock;
int f_id;
struct foo *f_next; /* protected by hashlock */
/* ... more stuff here ... */
};

struct foo *
foo_alloc(int id) /* allocate the object */
{
struct foo *fp;
int idx;

if ((fp = malloc(sizeof(struct foo))) != NULL) {
fp->f_count = 1;
fp->f_id = id;
if (pthread_mutex_init(&fp->f_lock, NULL) != 0) {
free(fp);
return(NULL);
}
idx = HASH(id);
pthread_mutex_lock(&hashlock);
fp->f_next = fh[idx];
fh[idx] = fp;
pthread_mutex_lock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
/* ... continue initialization ... */
pthread_mutex_unlock(&fp->f_lock);
}
return(fp);
}

void
foo_hold(struct foo *fp) /* add a reference to the object */
{
pthread_mutex_lock(&fp->f_lock);
fp->f_count++;
pthread_mutex_unlock(&fp->f_lock);
}

struct foo *
foo_find(int id) /* find an existing object */
{
struct foo *fp;

pthread_mutex_lock(&hashlock);
for (fp = fh[HASH(id)]; fp != NULL; fp = fp->f_next) {
if (fp->f_id == id) {
foo_hold(fp);
break;
}
}
pthread_mutex_unlock(&hashlock);
return(fp);
}

void
foo_rele(struct foo *fp) /* release a reference to the object */
{
struct foo *tfp;
int idx;

pthread_mutex_lock(&fp->f_lock);
if (fp->f_count == 1) { /* last reference */
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_lock(&hashlock);
pthread_mutex_lock(&fp->f_lock);
/* need to recheck the condition */
if (fp->f_count != 1) {
fp->f_count--;
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_unlock(&hashlock);
return;
}
/* remove from list */
idx = HASH(fp->f_id);
tfp = fh[idx];
if (tfp == fp) {
fh[idx] = fp->f_next;
} else {
while (tfp->f_next != fp)
tfp = tfp->f_next;
tfp->f_next = fp->f_next;
}
pthread_mutex_unlock(&hashlock);
pthread_mutex_unlock(&fp->f_lock);
pthread_mutex_destroy(&fp->f_lock);
free(fp);
} else {
fp->f_count--;
pthread_mutex_unlock(&fp->f_lock);
}
}

分配函数现在锁住了散列列表锁,把新的结构添加到了散列桶中,而且在对散列列表的锁解锁之前,先锁定了新结构中的互斥量。因为新的结构是放在全局列表中的,其他线程可以找到它,所以在初始化完成之前,需要阻塞其他线程试图访问新结构。

foo_find函数锁住散列列表锁,然后搜索被请求的结构。如果找到了,就增加其引用计数并返回指向该结构的指针。注意,加锁的顺序是,先在foo_find函数中锁定散列列表锁,然后再在foo_hold函数中锁定foo结构中的f_lock互斥量。

现在有了两个锁以后,foo_rele函数就变得更加复杂了。如果这是最后一个引用,就需要对这个结构互斥量进行解锁,因为我们需要从散列列表中删除这个结构,这样才可以获取散列列表锁,然后重新获取结构互斥量。从上一次获得结构互斥量以来我们可能被阻塞着,所以需要重新检查条件,判断是否还需要释放这个结构。如果另一个线程在我们为满足锁顺序而阻塞时发现了这个结构并对其引用计数加1,那么只需要简单地对整个引用计数减1,对所有的东西解锁,然后返回。

函数pthread_mutex_timedlock

当线程试图获取一个已加锁的互斥量时,pthread_mutex_timedlock互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock函数与pthread_mutex_lock是基本等价的,但是在达到超时时间值时,pthread_mutex_timedlock不会对互斥量进行加锁。而是返回错误码ETIMEDOUT。

1
2
3
4
#include <pthread.h>
#include <time.h>
int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct tinespec *restrict tsptr);
// 返回值,若成功,返回0;否则,返回错误编号

超时指定愿意等待的绝对时间(与相对时间对比而言,指定在时间X之前可以阻塞等待,而不是说愿意阻塞Y秒)。这个超时时间是用timespec结构来表示的,它用秒和纳秒来描述时间。

程序给出了如何用pthread_mutex_timedlock避免永久阻塞。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include "apue.h"
#include <pthread.h>

int
main(void)
{
int err;
struct timespec tout;
struct tm *tmp;
char buf[64];
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock(&lock);
printf("mutex is locked\n");
clock_gettime(CLOCK_REALTIME, &tout);
tmp = localtime(&tout.tv_sec);
strftime(buf, sizeof(buf), "%r", tmp);
printf("current time is %s\n", buf);
tout.tv_sec += 10; /* 10 seconds from now */
/* caution: this could lead to deadlock */
err = pthread_mutex_timedlock(&lock, &tout);
clock_gettime(CLOCK_REALTIME, &tout);
tmp = localtime(&tout.tv_sec);
strftime(buf, sizeof(buf), "%r", tmp);
printf("the time is now %s\n", buf);
if (err == 0)
printf("mutex locked again!\n");
else
printf("can't lock mutex again: %s\n", strerror(err));
exit(0);
}

运行结果输出如下:

1
2
3
4
5
$ ./a.out
mutex is locked
current time in 12:41:58 MI
the time is now 11:42:08 AM
can't lock mutex again: Connection timed out

这个程序故意对它已有的互斥量进行加锁,目的是演示pthread_mutex_timedlock是如何工作的。不推荐在实际中使用这种策略,因为它会导致死锁。

读写锁

读写锁(reader-writerlock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:

  • 读模式下加锁状态,
  • 写模式下加锁状态,
  • 不加锁状态。

一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。当读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。

虽然各操作系统对读写锁的实现各不相同,但当读写锁处于读模式锁住的状态,而这时有一个线程试图以写模式获取锁时,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。

读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为一次只有一个线程可以在写模式下拥有这个锁。当读写锁在读模式下时,只要线程先获取了读模式下的读写镇,该锁所保护的数据结构就可以被多个获得读模式锁的线程读取。

读写锁也叫做共享互斥锁(shared-exclusive lock)。 当读写锁是读模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住的时候,就可以说成是以互斥模式锁住的。与互斥量相比,读写锁在使用之前必须初始化,在释放它们底层的内存之前必须销毁。

1
2
3
4
#include <pthread.h>
int pthread_rwlock_init (pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy (pthread_rwlock_t *rwlock);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

读写锁通过调用pthread_rwlock_init进行初始化。如果希望读写锁有默认的属性,可以传一个null指针给attr。Single UNIX Specification在XSI扩展中定义了PTHREAD_RWLOCK_INITIALIZER常量。如果默认属性就足够的话,可以用它对静态分配的读写锁进行初始化。

在释放读写锁占用的内存之前,需要调用pthread_rwlock_destroy做清理工作。如果pthread_rwlock_init为读写锁分配了资源,pthread_nwlock_destroy将释放这些资源。如果在调用pthread_rwlock_destroy之前就释放了读写锁占用的内存空间,那么分配给这
个锁的资源就会丢失。要在读模式下锁定读写锁,需要调用pthread_rwlock_rdlock。要在写模式下锁定读写锁,需要调用pthread_rwlock_wrlock。不管以何种方式锁住读写锁,都可以调用pthread_rwlock_unlock进行解锁。

1
2
3
4
5
#include <pthread.h>
int pthread_rwlock_rdlock (pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock (pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock (pthread_rwlock_t *rwlock);
// 所有函数的返回值,若成功,返回0;否则,返回错误编号

各种实现可能会对共享模式下可获取的读写锁的次数进行限制,所以需要检查pthread_rwlock_rdlock的返回值。即使pthread_rwlock_wrlockpthread_rwlock_unlock有错误返回,而且从技术上来讲,在调用函数时应该总是检查错误返回,但是如果锁设计合理的话,就不需要检查它们。错误返回值的定义只是针对不正确使用读写锁的情况(如未经初始化的锁),或者试图获取已拥有的锁从而可能产生死锁的情况。但是需要注意,有些特定的实现可能会定义另外的错误返回。

Single UNIX Specification还定义了读写锁原语的条件版本。

1
2
3
4
#include <pthread.h>
int pthreed_rwlock_tryrdlock (pthread_rwlock_t*rwlock);
int pthread_rwlock_trywrlock (pthread_rwlock_t*rwlock);
// 两个函数的返回值:若成功,返回0;否则,返阀错误编号

可以获取锁时,这两个函数返回0;否则,它们返回错误EBUSY。这两个函数可以用于我们前面讨论的遵守某种锁层次但还不能完全避免死锁的情况。

程序解释了读写锁的使用。作业请求队列由单个读写锁保护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#include <stdlib.h>
#include <pthread.h>

struct job {
struct job *j_next;
struct job *j_prev;
pthread_t j_id; /* tells which thread handles this job */
/* ... more stuff here ... */
};

struct queue {
struct job *q_head;
struct job *q_tail;
pthread_rwlock_t q_lock;
};

/*
* Initialize a queue.
*/
int
queue_init(struct queue *qp)
{
int err;

qp->q_head = NULL;
qp->q_tail = NULL;
err = pthread_rwlock_init(&qp->q_lock, NULL);
if (err != 0)
return(err);
/* ... continue initialization ... */
return(0);
}

/*
* Insert a job at the head of the queue.
*/
void
job_insert(struct queue *qp, struct job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
jp->j_next = qp->q_head;
jp->j_prev = NULL;
if (qp->q_head != NULL)
qp->q_head->j_prev = jp;
else
qp->q_tail = jp; /* list was empty */
qp->q_head = jp;
pthread_rwlock_unlock(&qp->q_lock);
}

/*
* Append a job on the tail of the queue.
*/
void
job_append(struct queue *qp, struct job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
jp->j_next = NULL;
jp->j_prev = qp->q_tail;
if (qp->q_tail != NULL)
qp->q_tail->j_next = jp;
else
qp->q_head = jp; /* list was empty */
qp->q_tail = jp;
pthread_rwlock_unlock(&qp->q_lock);
}

/*
* Remove the given job from a queue.
*/
void
job_remove(struct queue *qp, struct job *jp)
{
pthread_rwlock_wrlock(&qp->q_lock);
if (jp == qp->q_head) {
qp->q_head = jp->j_next;
if (qp->q_tail == jp)
qp->q_tail = NULL;
else
jp->j_next->j_prev = jp->j_prev;
} else if (jp == qp->q_tail) {
qp->q_tail = jp->j_prev;
jp->j_prev->j_next = jp->j_next;
} else {
jp->j_prev->j_next = jp->j_next;
jp->j_next->j_prev = jp->j_prev;
}
pthread_rwlock_unlock(&qp->q_lock);
}

/*
* Find a job for the given thread ID.
*/
struct job *
job_find(struct queue *qp, pthread_t id)
{
struct job *jp;

if (pthread_rwlock_rdlock(&qp->q_lock) != 0)
return(NULL);

for (jp = qp->q_head; jp != NULL; jp = jp->j_next)
if (pthread_equal(jp->j_id, id))
break;

pthread_rwlock_unlock(&qp->q_lock);
return(jp);
}

在这个例子中,凡是需要向队列中增加作业或者从队列中删除作业的时候,都采用了写模式来锁住队列的读写锁。不管何时搜索队列,都需要获取读模式下的锁,允许所有的工作线程并发地搜索队列。在这种情况下,只有在线程搜索作业的频率远远高于增加或剩除作业时,使用读写锁才可能改善性能。工作线程只能从队列中读取与它们的线程ID匹配的作业。由于作业结构同一时间只能由一个线程使用,所以不需要额外的加锁。

带有超时的读写锁

与互斥量一样,Single UNIX Specification提供了带有超时的读写锁加锁函数,使应用程序在获取读写锁时避免陷入永久阻塞状态。这两个函数是pthread_rwlock_timedrdlockpthread_rwlock_timedwrlock

1
2
3
4
5
#include <pthread.h>
#include <time.h>
int pthread_rwlook_timedrdlock (pthread_rwlock_t *restrict rwlock, const struct timespec *restrict tsptr);
int pthread_rwlock_timedwrlock (pthread_rwlock_t *restrict rwlock, const struct timespec *restrict tsptr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

这两个函数的行为与它们“不计时的”版本类似。tsptr参数指向timespec结构,指定线程应该停止阻塞的时间。如果它们不能获取锁,那么超时到期时,这两个函数将返回ETIMEDOUT错误。与pthread_mutex_timedlock函数类似,超时指定的是绝对时间,而不是相对时间。

条件变量

条件变量是线程可用的另一种同步机制。条件变量给多个线程提供了一个会合的场所。条件变量与互斥量一起使用时,允许线程以无竞争的方式等待特定的条件发生。条件本身是由互斥量保护的。线程在改变条件状态之前必须首先锁住互斥量。其他线程在获得互斥量之前不会察觉到这种改变,因为互斥量必须在锁定以后才能计算条件。

在使用条件变量之前,必须先对它进行初始化。由pthread_cond_t数据类型表示的条件变量可以用两种方式进行初始化,可以把常量PTHREAD_COND_INITIALIZER赋给静态分配的条件变量,但是如果条件变量是动态分配的,则需要使用pthread_cond_init函数对它进行初始化。

在释放条件变量底层的内存空间之前,可以使用pthread_cond_destroy函数对条件变量进行反初始化(deinitialize)。

1
2
3
4
#include <pthread.h>
int pthread_cond_init (pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
int pthread_cond_destroy (pthread_cond_t *cond);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

除非需要创建一个具有非默认属性的条件变量,否则pthread_cond_init函数的attr参数可以设置为NULL。我们使用pthread_cond_wait等待条件变量变为真。如果在给定的时间内条件不能满足,那么会生成一个返回错误码的变量。

1
2
3
4
#include <pthread.h>
int pthread_cond_wait (pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
int pthcead_cond_timedwast (pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex, const struct timespec *restrict tsptr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住,pthread_cond_timedwait函数的功能与pthread_cond_wait函数相似,只是多了一
个超时(tsptr)。超时值指定了我们愿意等待多长时间,它是通过timespec结构指定的。

如果超时到期时条件还是没有出现,pthread_cond_timewait将重新获取互斥最,然后返回错误ETIMEDOUT。从pthread_cond_wait或者pthread_cond_timedwait调用成功返回时,线程需要重新计算条件,因为另一个线程可能已经在运行并改变了条件。

有两个函数可以用于通知线程条件已经满足。pthread_cond_signal函数至少能唤醒一个等待该条件的线程,而pthread_cond_broadcast函数则能唤醒等待该条件的所有线程。POSIX规范为了简化pthread_cond_signal的实现,允许它在实现的时候唤醒一个以上的线程。

1
2
3
4
#include (pthread.h>
int pthread_cond_signal (pthread_cond_t *cond);
int pthread_cond_broadcast (pthread_cond_t *cond);
// 两个函数的返回值:若成功,返回0:否则,返回锗误编号

在调用pthread_cond_signal或者pthread_cond_broadcast时,我们说这是在给线程或者条件发信号。必须注意,一定要在改变条件状态以后再给线程发信号。

给出了如何结合使用条件变量和互斥量对线程进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <pthread.h>

struct msg {
struct msg *m_next;
/* ... more stuff here ... */
};

struct msg *workq;

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;

pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;

void
process_msg(void)
{
struct msg *mp;

for (;;) {
pthread_mutex_lock(&qlock);
while (workq == NULL)
pthread_cond_wait(&qready, &qlock);
mp = workq;
workq = mp->m_next;
pthread_mutex_unlock(&qlock);
/* now process the message mp */
}
}

void
enqueue_msg(struct msg *mp)
{
pthread_mutex_lock(&qlock);
mp->m_next = workq;
workq = mp;
pthread_mutex_unlock(&qlock);
pthread_cond_signal(&qready);
}

条件是工作队列的状态。我们用互斥量保护条件,在while循环中判断条件。把消息放到工作队列时,需要占有互斥量,但在给等待线程发信号时,不需要占有互斥量。只要线程在调用pthread_cond_signal之前把消息从队列中拖出了,就可以在释放互斥量以后完成这部分工作。因为我们是在while循环中检查条件,所以不存在这样的问题:线程醒来,发现队列仍为空,然后返回继续等待。如果代码不能容忍这种竞争,就需要在给线程发信号的时候占有互斥量。

自旋锁

自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前一直处于忙等(自旋)阻塞状态。自旋锁可用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。

自旋锁通常作为底层原语用于实现其他类型的锁。根据它们所基于的系统体系结构,可以通过使用测试并设置指令有效地实现。当自旋锁用在非抢占式内核中时是非常有用的:除了提供互斥机制以外,它们会阻塞中断,这样中断处理程序就不会让系统陷入死锁状态,因为它需要获取已被加锁的自旋锁。在这种类型的内核中,中断处理程序不能休眠,因此它们能用的同步原语只能是自旋锁。

很多互斥量的实现非常高效。以至于应用程序采用互斥锁的性能与曾经采用过自旋锁的性能基本是相同的。事实上,有些互斥量的实现在试图获取互斥量的时候会自旋一小段时间,只有在自旋计数到达某一阙值的时候才会休眠。

自旋锁的接口与互斥量的接口类似,这使得它可以比较容易地从一个替换为另一个。可以用pthread_spin_init函数对自旋锁进行初始化。用pthread_spin_destroy函数进行自旋锁的反初始化。

1
2
3
4
#include <pthread.h>
int pthread_spin_init (pthread_spinlock_t *lock, int pshared);
int pthzead_spin_destroy (pthread_spinlock_t *lock);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

只有一个属性是自旋锁特有的,这个属性只在支持线程进程共享同步(Thread Process SharedSynchronization)选项的平台上才用得到。pshared参数表示进程共享属性,表明自旋锁是如何获取的。如果它设为PTHREAD_PROCESS_SHARED,则自旋锁能被可以访问锁底层内存的线程所获取,即便那些线程属于不同的进程,情况也是如此。否则pshared参数设为PTHREAD_PROCESS_PRIVATE,自旋锁就只能被初始化该锁的进程内部的线程所访问。

可以用pthread_spin_lockpthread_spin_trylock对自旋锁进行加锁,前者在获取锁之前一直自旋,后者如果不能获取锁,就立即返回EBUSY错误。注意,pthread_spin_trylock不能自旋。不管以何种方式加锁,自旋锁都可以调用pthread_spin_unlock函数解锁。

1
2
3
4
5
#include <pthread.h>
int pthread_spin_lock (pthread_spinlock_t *lock);
int pthread_spin_trylock (pthread_spinlock_t *lock);
int pthread_spin_unlock (pthread_spinlock_t *lock);
// 所有函数的返回值,若成功,返回0;否则,返回错误编号

注意,如果自旋锁当前在解锁状态的话,pthread_spin_lock函数不要自旋就可以对它加锁。如果线程已经对它加锁了,结果就是未定义的。调用pthread_spin_lock会返回EDEADLK错误(或其他错误),或者调用可能会永久自旋。具体行为依赖于实际的实现。试图对没有加锁的自旋锁进行解锁,结果也是未定义的。

不管是pthread_spin_lock还是pthread_spin_trylock,返回值为0的话就表示自旋锁被加锁。需要注意,不要调用在持有自旋锁情况下可能会进入休眠状态的函数。如果调用了这些函数,会浪费CPU资源,因为其他线程需要获取自旋锁需要等待的时间就延长了。

屏障

屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都到达某一点,然后从该点继续执行。我们已经看到一种屏障,pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出。

但是屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作,可以使用pthread_barrier_init函数对屏障进行初始化,用thread_barrier_destroy函数反初始化。

1
2
3
4
5
6
#include <pthread.h>
int pthread_barrier_init (pthreed_barrier_t *restrict barrier,
const pthread_barrierattr_t *restrict attr,
unsigned int count);
int pthread_barrier_destroy (pthread_barrier_t *barrier);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

初始化屏障时,可以使用count参数指定,在允许所有线程继续运行之前,必须到达屏障的线程数目。使用attr参数指定屏障对象的属性。现在设置attr为NULL,用默认属性初始化屏障。如果使用pthread_barrier_init函数为屏障分配资源,那么在反初始化屏障时可以调用pthread_barrier_destroy函数释放相应的资源。

可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等所有其他线程赶上来。

1
2
3
#include <pthread.h>
int pthread_barrier_wait (pthread_barrier_t *barrier);
// 返回值:若成功,返回0或者PTHREAD_BARRIER_SERIAL_THREAD;否则,返回错误编号

调用pthread_barrier_wait的线程在屏障计数(调用pthread_barrier_init时设定)未满足条件时,会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有的线程都被唤醒。

对于一个任意线程,pthread_barrier_wait函数返回了PTHREAD_BARRIER_SERIAL_THREAD。剩下的线程看到的返回值是0。这使得一个线程可以作为主线程,它可以工作在其他所有线程已完成的工作结果上。

一旦达到屏障计数值,而且线程处于非阻塞状态,屏障就可以被重用。但是除非在调用了pthread_barrier_destroy函数之后,又调用了pthread_barrier_init函数对计数用另外的数进行初始化,否则屏障计数不会改变。

给出了在一个任务上合作的多个线程之间如何用屏障进行同步。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
#include "apue.h"
#include <pthread.h>
#include <limits.h>
#include <sys/time.h>

#define NTHR 8 /* number of threads */
#define NUMNUM 8000000L /* number of numbers to sort */
#define TNUM (NUMNUM/NTHR) /* number to sort per thread */

long nums[NUMNUM];
long snums[NUMNUM];

pthread_barrier_t b;

#ifdef SOLARIS
#define heapsort qsort
#else
extern int heapsort(void *, size_t, size_t,
int (*)(const void *, const void *));
#endif

/*
* Compare two long integers (helper function for heapsort)
*/
int
complong(const void *arg1, const void *arg2)
{
long l1 = *(long *)arg1;
long l2 = *(long *)arg2;

if (l1 == l2)
return 0;
else if (l1 < l2)
return -1;
else
return 1;
}

/*
* Worker thread to sort a portion of the set of numbers.
*/
void *
thr_fn(void *arg)
{
long idx = (long)arg;

heapsort(&nums[idx], TNUM, sizeof(long), complong);
pthread_barrier_wait(&b);

/*
* Go off and perform more work ...
*/
return((void *)0);
}

/*
* Merge the results of the individual sorted ranges.
*/
void
merge()
{
long idx[NTHR];
long i, minidx, sidx, num;

for (i = 0; i < NTHR; i++)
idx[i] = i * TNUM;
for (sidx = 0; sidx < NUMNUM; sidx++) {
num = LONG_MAX;
for (i = 0; i < NTHR; i++) {
if ((idx[i] < (i+1)*TNUM) && (nums[idx[i]] < num)) {
num = nums[idx[i]];
minidx = i;
}
}
snums[sidx] = nums[idx[minidx]];
idx[minidx]++;
}
}

int
main()
{
unsigned long i;
struct timeval start, end;
long long startusec, endusec;
double elapsed;
int err;
pthread_t tid;

/*
* Create the initial set of numbers to sort.
*/
srandom(1);
for (i = 0; i < NUMNUM; i++)
nums[i] = random();

/*
* Create 8 threads to sort the numbers.
*/
gettimeofday(&start, NULL);
pthread_barrier_init(&b, NULL, NTHR+1);
for (i = 0; i < NTHR; i++) {
err = pthread_create(&tid, NULL, thr_fn, (void *)(i * TNUM));
if (err != 0)
err_exit(err, "can't create thread");
}
pthread_barrier_wait(&b);
merge();
gettimeofday(&end, NULL);

/*
* Print the sorted list.
*/
startusec = start.tv_sec * 1000000 + start.tv_usec;
endusec = end.tv_sec * 1000000 + end.tv_usec;
elapsed = (double)(endusec - startusec) / 1000000.0;
printf("sort took %.4f seconds\n", elapsed);
for (i = 0; i < NUMNUM; i++)
printf("%ld\n", snums[i]);
exit(0);
}

这个例子给出了多个线程只执行一个任务时,使用屏障的简单情况。在更加实际的情况下,工作线程在调用pthread_barrier_wait函数返回后会接着执行其他的活动。

在这个实例中,使用8个线程分解了800万个数的排序工作。每个线程用堆排序算法对100万个数进行排序。然后主线程调用一个函数对这些结果进行合并。并不需要使用pthread_barrier_wait函数中的返回值PTHREAD_BARRIER_SERIAL_THREAD来决定哪个线程执行结果合并操作,因为我们使用了主线程来完成这个任务。这也是把屏障计数值设为工作线程数加1的原因,主线程也作为其中的一个候选线程。

线程控制

线程限制

Single UNIX Speeification定义了与线程操作有关的一些限制,与其他的系统限制一样,这些限制也可以通过sysconf函数进行查询。

限制名称 描述 name参数
PTHREAD_DESTRUCTOR_ITERATIONS 线程退出时操作系统实现试图销毁线程特定数据的最大次数 _SC_THREAD_DESTRUCTOR_ITERATIONS
PTHREAD_KEYS_MAX 进程可以创建的健的最大数目 _SC_THREAD_KEYS_MAX
PTHREAD_STACK_HIN 一个线程的栈可用的最小字节数 _SC_THREAD_STACK_MIN
PTHREAD_THREADS_MAX 进程可以创建的最大线程数 SC_THREAD_THREADS_MAX

这些限制的使用是为了增强应用程序在不同的操作系统实现之间的可移植性。

线程属性

pthread接口允许我们通过设置每个对象关联的不同属性来细调线程和同步对象的行为。通常,管理这些属性的函数都遵循相同的模式。

  1. 每个对象与它自己类型的属性对象进行关联(线程与线程属性关联,互斥量与互斥量属性关联,等等)。一个属性对象可以代表多个属性。属性对象对应用程序来说是不透明的。这意味着应用程序并不需要了解有关属性对象内部结构的详细细节,这样可以增强应用程序的可移植性。取而代之的是,需要提供相应的函数来管理这些属性对象。
  2. 有一个初始化函数,把属性设置为默认值。
  3. 还有一个销毁属性对象的函数。如果初始化函数分配了与属性对象关联的资源,销毁函数负责释放这些资源。
  4. 每个属性都有一个从属性对象中获取属性值的函数。由于函数成功时会返回0;失败时会返回错误编号,所以可以通过把属性值存储在函数的某一个参数指定的内存单元中,把属性值返回给调用者。
  5. 每个属性都有一个设置属性值的函数。在这种情况下,属性值作为参数按值传递。

所有调用pthread_create函数的实例中,传入的参数都是空指针,而不是指向pthread_attr_t结构的指针。可以使用pthread_attr_t结构修改线程默认属性,并把这些属性与创建的线程联系起来。可以使用pthread_attr_init函数初始化pthread_attr_t结构。在调用pthread_attr_init以后,pthread_attr_t结构所包含的就是操作系统实现支持的所有线程属性的默认值。

1
2
3
4
#include <pthread.h>
int pthread_attr_init (pthread_attr_t *attr);
int pthread_attr_destroy (pthread_attr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

如果要反初始化 pthread_attr_t结构, 可以调用pthread_attr_destroy函数。如果pthread_attr_init的实现对属性对象的内存空间是动态分配的,pthread_attr_destroy就会释放该内存空间。除此之外,pthread_attr_ destroy还会用无效的值初始化属性对象,因此,如果该属性对象被误用,将会导致pthread_create函数返回错误码。

下表总结了POSIX.1定义的线程属性。

名称 描述
detachstate 线程的分离状态属性
guardsize 线程栈未尾的警戒缓冲区大小(字节数)
stackaddr 线程栈的最低地址
stackstze 线程栈的最小长度(字节数)

如果在创建线程时就知道不需要了解线程的终止状态,就可以修改pthread_attr_t结构中的detachstate线程属性,让线程一开始就处于分离状态。可以使用pthread_attr_setdetachstate函数把线程属性detachstate设置成以下两个合法值之一:PTHREAD_CREATE_DETACHED,以分离状态启动线程;或者PTHREAD_CREATE_JOINABLE,正常启动线程,应用程序可以获取线程的终止状态。

1
2
3
4
#include <pthread.h>
int pthreed_attr_getdetachatate (const pthread_attr_t *restrict attr, int *detackstate);
int pthread_attr_setdetachstate (pthread_attr_t *attr, int *detachstate);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

可以调用pthread_attr_getdetachstate函数获取当前的detachstate线程属性。第二个参数所指向的整数要么设置成PTHREAD_CREATE_DETACHED,要么设置成PTHREAD_CREATE_JOINABLE,具体要取决于给定pthread_attr_t结构中的属性值。

给出了一个以分离状态创建线程的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "apue.h"
#include <pthread.h>

int
makethread(void *(*fn)(void *), void *arg)
{
int err;
pthread_t tid;
pthread_attr_t attr;

err = pthread_attr_init(&attr);
if (err != 0)
return(err);
err = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
if (err == 0)
err = pthread_create(&tid, &attr, fn, arg);
pthread_attr_destroy(&attr);
return(err);
}

注意,此例忽略了pthread_attr_destroy函数调用的返回值。在这个实例中,我们对线程属性进行了合理的初始化,因此pthread_attr_destroy应该不会失败。但是,如果pthread_attr_destroy确实出现了失败的情况,将难以清理:必须销毁刚刚创建的线程,也许这个线程可能已经运行,并且与pthread_attr_destroy函数可能是异步执行的。忽略pthread_attr_destroy的错误返回可能出现的最坏情况是,如果pthread_attr_init已经分配了内存空间,就会有少量的内存泄漏。另一方面,如果pthread_attr_init成功地对线程属性进行了初始化,但之后pthread_attr_ destroy的清理工作失败,那么将没有任何补救策略,因为线程属性结构对应用程序来说是不透明的,可以对线程属性结构进行清理的唯一接口是pthread_attr_destroy,但它失败了。

可以在编译阶段使用_POSIX_THREAD_ATTR_STACKADDR_POSIX_THREAD_ATTR_STACKSIZE符号来检查系统是否支持每一个线程栈属性。如果系统定义了这些符号中的一个,就说明它支持相应的线程栈属性。或者,也可以在运行阶段把_SC_THREAD_ATTR_STACKADDR_SC_THREAD_ATTR_STACKSIZE参数传给sysconf函数,检查运行时系统对线程视属性的支持情况。

可以使用函数pthread_attr_getstackpthread_attr_setstack对线程栈属性进行管理。

1
2
3
4
5
6
#include <pthread.h>
int pthread_attr_getstack (const pthread_attr_t *restrict attr,
void **restrict stackaddr,
size_t *restrict stacksize);
int pthread_attr_setstack (pthread_attr_t *attr, void *stackaddr, size_t stacksize);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

对于进程来说,虚地址空间的大小是固定的。因为进程中只有一个栈,所以它的大小通常不是问题。但对于线程来说,同样大小的虚地址空间必须被所有的线程栈共享。如果应用程序使用了许多线程,以致这些线程栈的累计大小超过了可用的虚地址空间,就需要减少默认的线程栈大小。另一方面,如果线程调用的函数分配了:大量的自动变量,或者调用的函数涉及许多很深的栈帧(stack frame),那么需要的栈大小可能要比默认的大。

如果线程栈的虚地址空间都用完了,那可以使用malloc或者mmap来为可替代的栈分配空间,并用pthread_attr_setstack函数来改变新建线程的栈位置。由stackaddr参数指定的地址可以用作线程栈的内存范围中的最低可寻址地址,该地址与处理器结构相应的边界应对齐。当然,这要假设mallocmmap所用的虚地址范围与线程栈当前使用的虚地址范围不同。

stackaddr线程属性被定义为栈的最低内存地址,但这并不一定是栈的开始位置。对于一个给定的处理器结构来说,如果栈是从高地址向低地址方向增长的,那么stackaddr线程属性将是栈的结尾位置,而不是开始位置。

应用程序也可以通过pthread_attr_getstacksizepthread_attr_setstacksize函数读取或设置线程属性stacksize

1
2
3
4
5
#include <pthread.h>
int pthread_attr_getstacksize (const pthread_attr_t *restrict attr,
size_t *restrict stacksize);
int pthread_attr_setstacksize (pthread_attr_t *addr, size_t stacksize);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

如果希望改变默认的栈大小,但又不想自己处理线程栈的分配问题,这时使用pthread_attr_setstacksize函数就非常有用。设置stacksize属性时,选择的stacksize不能小于PTHREAD_STACK_MIN

线程属性guardsize控制着线程栈未尾之后用以避免栈溢出的扩展内存的大小。可以把guardsize线程属性设置为0,不允许属性的这种特征行为发生,在这种情况下,不会提供警戒缓冲区。同样,如果修改了线程属性stackaddr,系统就认为我们将自己管理栈,使栈警戒缓冲区机制无效,这等同于把guardsize线程属性设置为0。

1
2
3
4
#include <pthread.h>
int pthread_attr_getquardsize (const pthread_attr_t *restrict attr, size_t *restrict guardsie);
int pthread_attr_setguardsize (pthread_attr_t *attr, size_t guandsite);
// 两个函数的返回值:若成功,返回0;否则,返回锖谟编号

如果guardsize线程属性被修改了,操作系统可能会把它取为页大小的整数倍。如果线程的栈指针溢出到警戒区域,应用程序就可能通过信号接收到出错信息。

同步属性

互斥量属性

互斥量属性是用pthread_mutexattr_t结构表示的。对互斥量进行初始化时,可以通过使用PTHREAD_MUTEX_INITIALIZER常量或者用指向互斥量属性结构的空指针作为参数调用pthread_mutex_init函数,得到互斥量的默认属性。

对于非默认属性,可以用pthread_mutexattr_init初始化pthread_mutexattr_t结构,用pthread_mutexattr_destroy来反初始化。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_init (pthread_mutexattr_t *attr);
int pthread_mutexattr_destroy (pthread_mutexattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

pthread_mutexattr_init函数将用默认的互斥量属性初始化pthread_mutexattr_t结构。值得注意的3个属性是:进程共享属性、健壮属性以及类型属性。POSIX.1中,进程共享属性是可选的。可以通过检查系统中是否定义了_POSIX_THREAD_PROCESS_SHARED符号来判断这个平台是否支持进程共享这个属性,也可以在运行时把_SC_THREAD_PROCESS_SHARED参数传给sysconf函数进行检查。

在进程中,多个线程可以访问同一个同步对象,进程共享互斥量属性需设置为PTHREAD_PROCESS_PRIVATE。如果进程共享互斥量属性设置为PTHREAD_PROCESS_SHARED,从多个进程彼此之间共享的内存数据块中分配的互斥量就可以用于这些进程的同步。

可以使用pthread_mutexattr_getpshared函数查询pthread_mutexattr_t结构,得到它的进程共享属性,使用pthread_mutexattr_ setpshared函数修改进程共享属性。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_getpahared (const pthread_mutexattr_t *restrict attr, int *restrict prhared);
int pthread_mutexattr_setpshared (pthread_mutexattr_t *attr, int pthared);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

进程共享互斥量属性设置为PTHREAD_PROCESS_PRIVATE时,允许pthread线程库提供更有效的互斥量实现,这在多线程应用程序中是默认的情况。在多个进程共享多个互斥量的情况下,pthread线程库可以限制开销较大的互斥量实现。

互斥量健壮属性与在多个进程间共享的互斥量有关。这意味着,当持有互斥量的进程终止时,需要解决互斥量状态恢复的问题。这种情况发生时,互斥量处于锁定状态,恢复起来很困难。其他阻塞在这个锁的进程将会一直阻塞下去,可以使用pthread_mutexattr_getrobust函数获取健壮的互斥量属性的值。可以调用pthread_mutexattr_setrobust函数设置健壮的互斥最属性的值。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_getrobust (const pthread_mutexattr_t *restrict attr, int *restrict robust);
int pthread_mutexattr_setrobust (pthread_mutexattr_t *attr, int robust);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

健壮属性取值有两种可能的情况。默认值是PTHREAD_MUTEX_STALLED,这意味着持有互斥量的进程终止时不需要采取特别的动作。这种情况下,使用互斥量后的行为是未定义的,等待该互斥量解锁的应用程序会被有效地“拖住”。另一个取值是PTHREAD_MUTEX_ROBUST。这个值将导致线程调用pthread_mutex_lock获取锁,而该锁被另一个进程持有,但它终止时并没有对该镇进行解锁,此时线程会阻塞,从pthread_mutex_lock返回的值为EOWNERDEAD而不是0。

使用健壮的互斥量改变了我们使用pthread_mutex_lock的方式,因为现在必须检查3个返回值而不是之前的两个:不需要恢复的成功、需要恢复的成功以及失败。但是,即使不用健壮的互斥量,也可以只检查成功或者失败。

如果应用状态无法恢复,在线程对互斥量解锁以后,该互斥量将处于永久不可用状态。为了避免这样的问题,线程可以调用pthread_mutex_consistent函数,指明与该互斥量相关的状态在互斥量解锁之前是一致的。

1
2
3
#include <pthread.h>
int pthread_mutex_consistent (pthread_mutex_t *mutex);
// 返回值,若成功,返回0;否则,返回错误编号

如果线程没有先调用pthread_mutex_consistent就对互斥最进行了解锁,那么其他试图获取该互斥最的阻塞线程就会得到错误码ENOTRECOVERABLE。如果发生这种情况,互斥量将不再可用。线程通过提前调用pthread_mutex_consistent,能让互斥量正常工作,这样它就
可以持续被使用。

类型互斥量属性控制着互斥量的锁定特性。POSIX.1定义了4种类型:

  • PTHREAD_MUTEX_NORMAL:标准互斥量类型,不做任何特殊的错误检查或死锁检测。
  • PTHREAD_MUTEX_ERRORCHECK:此互斥量类型提供错误检查。
  • PTHREAD_MUTEX_RECURSIVE:此互斥量类型允许同一线程在互斥量解锁之前对该互斥量进行多次加锁。递归互斥量维护锁的计数,在解锁次数和加锁次数不相同的情况下,不会释放锁。所以,如果对一个递归互斥量加锁两次,然后解锁一次,那么这个互斥量将依然处于加锁状态,对它再次解锁以前不能释放该锁。
  • PTHREAD_MUTEX_DEFAULT:此互斥量类型可以提供默认特性和行为。操作系统在实现它的时候可以把这种类型自由地映射到其他互斥量类型中的一种。

这4种类型的行为如表所示。”不占用时解锁”这一栏指的是,一个线程对被另一个线程加锁的互斥量进行解锁的情况。“在已解锁时解锁”这一栏指的是, 当一个线程对已经解锁的互斥量进行解锁时将会发生什么,这通常是编码错误引起的。

互斥量类型 没有解锁时重新加镇? 不占用时解锁? 在已解锁时解锁?
PTHREAD_MUTEX_NORMAL 死锁 未定义 未定义
PTHREAD_MUTEX_ERRORCHECK 返回错误 返回错误 返回错误
PTHREAD_MUTEX RECURSIVE 允许 返回错误 返回错误
PTHREAD_MUTEX_DEFAULT 未定义 未定义 未定义

可以用pthread_mutexattr_gettype函数得到互斥量类型属性,用pthread_mutexattr_settype函数修改互斥量类型属性。

1
2
3
4
#include <pthread.h>
int pthread_mutexattr_gettype (const pthread_mutexattr_t *restrict attr, int *restrict type);
int pthread_mutexattr_settype (pthread_mutexattr_t *attr, int type);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

如果递归互斥量被多次加锁,然后用在调用pthread_cond_wait函数中,那么条件永远都不会得到满足,因为pthread_cond_wait所做的解锁操作并不能释放互斥量。如果需要把现有的单线程接口放到多线程环境中,递归互斥量是非常有用的,但由于现有程序兼容性的限制,不能对函数接口进行修改。然而,使用递归锁可能很难处理,因此应该只在没有其他可行方案的时候才使用它们。

程序解释了有必要使用递归互斥量的另一 种情况。这里,有一个“超时”(timeout)函数,它允许安排另一个函数在未来的某个时间运行。假设线程并不是很昂贵的资源,就可以为每个挂起的超时函数创建一个线程。线程在时间来到时将一 直等待,时间到了以后再调用请求的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
#include "apue.h"
#include <pthread.h>
#include <time.h>
#include <sys/time.h>

extern int makethread(void *(*)(void *), void *);

struct to_info {
void (*to_fn)(void *); /* function */
void *to_arg; /* argument */
struct timespec to_wait; /* time to wait */
};

#define SECTONSEC 1000000000 /* seconds to nanoseconds */

#if !defined(CLOCK_REALTIME) || defined(BSD)
#define clock_nanosleep(ID, FL, REQ, REM) nanosleep((REQ), (REM))
#endif

#ifndef CLOCK_REALTIME
#define CLOCK_REALTIME 0
#define USECTONSEC 1000 /* microseconds to nanoseconds */

void
clock_gettime(int id, struct timespec *tsp)
{
struct timeval tv;

gettimeofday(&tv, NULL);
tsp->tv_sec = tv.tv_sec;
tsp->tv_nsec = tv.tv_usec * USECTONSEC;
}
#endif

void *
timeout_helper(void *arg)
{
struct to_info *tip;

tip = (struct to_info *)arg;
clock_nanosleep(CLOCK_REALTIME, 0, &tip->to_wait, NULL);
(*tip->to_fn)(tip->to_arg);
free(arg);
return(0);
}

void
timeout(const struct timespec *when, void (*func)(void *), void *arg)
{
struct timespec now;
struct to_info *tip;
int err;

clock_gettime(CLOCK_REALTIME, &now);
if ((when->tv_sec > now.tv_sec) ||
(when->tv_sec == now.tv_sec && when->tv_nsec > now.tv_nsec)) {
tip = malloc(sizeof(struct to_info));
if (tip != NULL) {
tip->to_fn = func;
tip->to_arg = arg;
tip->to_wait.tv_sec = when->tv_sec - now.tv_sec;
if (when->tv_nsec >= now.tv_nsec) {
tip->to_wait.tv_nsec = when->tv_nsec - now.tv_nsec;
} else {
tip->to_wait.tv_sec--;
tip->to_wait.tv_nsec = SECTONSEC - now.tv_nsec +
when->tv_nsec;
}
err = makethread(timeout_helper, (void *)tip);
if (err == 0)
return;
else
free(tip);
}
}

/*
* We get here if (a) when <= now, or (b) malloc fails, or
* (c) we can't make a thread, so we just call the function now.
*/
(*func)(arg);
}

pthread_mutexattr_t attr;
pthread_mutex_t mutex;

void
retry(void *arg)
{
pthread_mutex_lock(&mutex);

/* perform retry steps ... */

pthread_mutex_unlock(&mutex);
}

int
main(void)
{
int err, condition, arg;
struct timespec when;

if ((err = pthread_mutexattr_init(&attr)) != 0)
err_exit(err, "pthread_mutexattr_init failed");
if ((err = pthread_mutexattr_settype(&attr,
PTHREAD_MUTEX_RECURSIVE)) != 0)
err_exit(err, "can't set recursive type");
if ((err = pthread_mutex_init(&mutex, &attr)) != 0)
err_exit(err, "can't create recursive mutex");

/* continue processing ... */

pthread_mutex_lock(&mutex);

/*
* Check the condition under the protection of a lock to
* make the check and the call to timeout atomic.
*/
if (condition) {
/*
* Calculate the absolute time when we want to retry.
*/
clock_gettime(CLOCK_REALTIME, &when);
when.tv_sec += 10; /* 10 seconds from now */
timeout(&when, retry, (void *)((unsigned long)arg));
}
pthread_mutex_unlock(&mutex);

/* continue processing ... */

exit(0);
}

如果我们不能创建线程,或者安排函数运行的时间已过,这时问题就出现了。在这些情况下,我们只需在当前上下文中调用之前请求运行的函数。因为函数要获取的愤和我们现在占有的锁是同一个,所以除非该锁是递归的,否则就会出现死锁。

我们使用makethread函数以分离状态创建线程,因为传递给timeout函数的func函数参数将在未来运行,所以我们不希望一直空等线程结束。可以调用sleep等待超时到期,但它提供的时间粒度是秒级的。如果希望等待的时间不是整数秒,就需要用nanosleep或者clock_nanosleep函数,它们两个提供了更高精度的休眠时间。

读写锁属性

读写锁与互斥量类似,也是有属性的。可以用pthread_rwlockattr_init初始化pthread_rwlockattr_t结构,用pthread_rwlockattr_destroy反初始化该结构。

1
2
3
4
#include <pthread.h>
int pthread_rwlockattr_init (pthread_rwlockattr_t *attr);
int pthread_rwlockattr_deatroy (pthread_rwlockattr_t *attr);
// 两个函数的返回值:若成功,返回0;否则,返回错误编号

读写锁支持的唯一属性是进程共享属性。它与互斥量的进程共享属性是相同的。就像互斥量的进程共享属性一样,有一对函数用于读取和设置读写锁的进程共享属性。

1
2
3
4
#include <pthread.h>
int pthread_rwlockattr_getpshared (const pthread_rwlockattr_t *restrict attr, int *restrict pshared);
int pthread_rwlockattr_setpshared (pthread_rwlockattr_t *attr, int pshared);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

条件变量属性

目前定义了条件变量的两个属性:进程共享属性和时钟属性。与其他的属性对象一样,有一对函数用于初始化和反初始化条件变量属性。

1
2
3
4
#include <pthread.h>
int pthread_condattr_init (pthread_condattr_t *attr);
int pthread_condattr_destroy (pthread_condattr_t *attr);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

与其他的同步属性一样,条件变量支持进程共享属性。它控制着条件变量是可以被单进程的多个线程使用,还是可以被多进程的线程使用。要获取进程共享属性的当前值,可以用pthread_condattr_getpshared函数。设置该值可以用pthread_condattr_setpshared函数。

1
2
3
4
#include <pthread.h>
int pthread_condattr_getpshared (const pthread_condattr_t *restrict attr, int *restrict pshared);
int pthread_condattr_setpshared (pthread_condattr_t *attr, int pshared);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

时钟属性控制计算pthread_cond_timedwait函数的超时参数(tspr)时采用的是哪个时钟。可以使用pthread_condattr_getclock函数获取可被用于pthread_cond_timedwait函数的时钟ID,在使用pthread_cond_timedwait函数前需要用pthread_condattr_t对象对条件变量进行初始化。可以用pthread_condattr_setclock函数对时钟ID进行修改。

1
2
3
4
#include epthread.h>
int pthread_condattr_getclock (const pthread_condattr_t *restrict attr, clockid_t *restrict clock_id);
int pthread_condattr_setclock (pthread_condattr_t *attr, clockid_t clock_d);
// 两个函数的返回值;若成功,返回0;否则,返回镨误编号

屏障属性

屏障也有属性。可以使用pthread_barrierattr_init函数对屏障属性对象进行初始化,用pthread_barrierattr_destroy函数对屏障属性对象进行反初始化。

1
2
3
4
#include <pthread.h>
int pthread_barrierattr_init (pthread_barrier *attr);
int pthread_barrierattr_destroy (pthread_barrierattr_t *attr);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

目前定义的屏障属性只有进程共享属性,它控制着屏障是可以被多进程的线程使用,还是只能被初始化屏障的进程内的多线程使用。与其他属性对象一样,有一个获取属性值的函数(pthread_barrierattr_getpshared)和一个设置属性值的函数(pthread_barrierattr_setpshared)。

1
2
3
4
#include <pthread.h>
int pthread_barrierattr_getpshared (const pthread_barrier *restrict attr, int *restrict pthared);
int pthread_barrierattr_setpshared (pthread_barrierattr_t *attr, int pshared);
// 两个函数的返回值,若成功,返回0;否则,返回错误编号

进程共享属性的值可以是PTHREAD_PROCESS_SHARED(多进程中的多个线程可用),也可以是PTHREAD_PROCESS_PRIVATE(只有初始化屏障的那个进程内的多个线程可用)。

重入

如果一个函数在相同的时间点可以被多个线程安全地调用,就称该函数是线程安全的。除了图中列出的函数,其他函数都保证是线程安全的。

另外,ctermidtmpnam函数在参数传入空指针时并不能保证是线程安全的。类似地。如果参数mbstate_t传入的是空指针,也不能保证wertombwcsrtombs函数是线程安全的。

支持线程安全函数的操作系统实现会在<unistd.h>中定义符号_POSIX_THREAD_SAFE_FUNCTIONS。应用程序也可以在sysconf函数中传入_SC_THREAD_SAFE_FUNCTIONS参数在运行时检查是否支持线程安全函数。

操作系统实现支持线程安全函数这个特性时,对POSIX.1中的一些非线程安全函数,它会提供可替代的线程安全版本,图中列出了这些函数的线程安全版本。这些函数的命名方式与它们的非线程安全版本的名字相似,只不过在名字最后加了_r,表明这些版本是可重入的。很多函数并不是线程安全的,因为它们返回的数据存放在静态的内存缓冲区中。通过修改接口,要求调用者自己提供缓冲区可以使函数变为线程安全。

线程安全函数
getgrgid_r
localtime_r
getgrnam_r
readdir_r
getlogin_r
strerror_r
getpwnam_r
strtok_z
getpwuid_r
ttynane_r
gmtime_r

如果函数对异步信号处理程序的重入是安全的,那么就可以说函数是异步信号安全的。

POSIX.1还提供了以线程安全的方式管理FILE对象的方法。可以使用flockfileftrylockfile获取给定FILE对象关联的锁。这个锁是递归的:当你占有这把锁的时候,还是可以再次获取该锁,而且不会导致死锁。虽然这种锁的具体实现并无规定,但要求所有操作FILE对象的标准例程的动作行为必须看起来就像它们内部调用了flockfilefunlockfile

1
2
3
4
5
#include <stdio.h>
int ftrylockfile(FILE *fp);
// 返回值:若成功,返回0;若不能获取,返回非0数值
void flocktile(FILE *fp);
void funlockfile (FTLE *fp);

如果标准例程都获取它们各自的锁,那么在做一次一个字符的I/O时就会出现严重的性能下降。在这种情况下,需要对每一个字符的读写操作进行获取锁和释放锁的动作。为了避免这种开销,出现了不加锁版本的基于字符的标准I/O例程。

1
2
3
4
5
6
7
#include <stdio.h>
int getchar_unlocked (void);
int getc_unlocked (FILE *fp);
// 两个函数的返回值:若成功,返回下一个字符,若遇到文件尾或者出错,返回EOF
int putchar_unlocked(int e);
int putc_unlocked(int e, FILE *fp);
// 两个函数的返回值,若成功,返回c;若出错,返回BOF

除非被flockfile(或ftrylockfile)和funlockfile的调用包围,否则尽量不要调用这4个函数,因为它们会导致不可预期的结果(比如,由于多个控制线程非同步访问数据引起的种种问题)。一旦对FILE对象进行加锁,就可以在释放锁之前对这些函数进行多次调用。这样就可以在多次的数据读写上分摊总的加解锁的开销。

给出了getenv的可重入的版本。这个版本叫做getenv_r。它使用pthread_once函数来确保不管多少线程同时竞争调用getenv_r,每个进程只调用thread_init函数一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <string.h>
#include <errno.h>
#include <pthread.h>
#include <stdlib.h>

extern char **environ;

pthread_mutex_t env_mutex;

static pthread_once_t init_done = PTHREAD_ONCE_INIT;

static void
thread_init(void)
{
pthread_mutexattr_t attr;

pthread_mutexattr_init(&attr);
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);
pthread_mutex_init(&env_mutex, &attr);
pthread_mutexattr_destroy(&attr);
}

int
getenv_r(const char *name, char *buf, int buflen)
{
int i, len, olen;

pthread_once(&init_done, thread_init);
len = strlen(name);
pthread_mutex_lock(&env_mutex);
for (i = 0; environ[i] != NULL; i++) {
if ((strncmp(name, environ[i], len) == 0) &&
(environ[i][len] == '=')) {
olen = strlen(&environ[i][len+1]);
if (olen >= buflen) {
pthread_mutex_unlock(&env_mutex);
return(ENOSPC);
}
strcpy(buf, &environ[i][len+1]);
pthread_mutex_unlock(&env_mutex);
return(0);
}
}
pthread_mutex_unlock(&env_mutex);
return(ENOENT);
}

要使getenv_r可重入,需要改变接口,调用者必须提供它自己的缓冲区,这样每个线程可以使用各自不同的缓冲区避免其他线程的干扰。但是,注意,要想使getenv_r成为线程安全的,这样做还不够,需要在搜索请求的字符时保护环境不被修改。可以使用互斥量,通过getenv_rputenv函数对环境列表的访问进行串行化。

可以使用读写锁,从而允许对getenv_r进行多次并发访问,但增加的并发性可能并不会在很大程度上改善程序的性能,这里面有两个原因第一,环境列表通常并不会很长,所以扫描列表时并不需要长时间地占有互斥量;第二,对getenvputenv的调用也不是频繁发生的,所以改善它们的性能并不会对程序的整体性能产生很大的影响。

即使可以把getenv_r变成线程安全的,这也不意味着它对信号处理程序是可重入的。如果使用的是非递归的互斥量,线程从信号处理程序中调用getenv_r就有可能出现死锁。如果信号处理程序在线程执行getenv_r时中断了该线程,这时我们已经占有加锁的env_mutex,这样其他线程试图对这个互斥量的加锁就会被阻塞,最终导致线程进入死锁状态。所以,必须使用递归互斥量阻止其他线程改变我们正需要的数据结构,还要阻止来自信号处理程序的死锁。

线程特定数据

线程特定数据(thread-specific data), 也称为线程私有数据(thread-private data),是存储和查询某个特定线程相关数据的一种机制。我们希望每个线程可以访问它自己单独的数据副本,而不需要担心与其他线程的同步访问问题。

线程模型促进了进程中数据和属性的共享,许多人在设计线程模型时会遇到各种麻烦。那么为什么有人想在这样的模型中促进阻止共享的接口呢?这其中有两个原因。

  • 有时候需要维护基于每线程(per-bread)的数据。因为线程ID并不能保证是小而连续的整数,所以就不能简单地分配一个每线程数据数组,用线程ID作为数组的索引。
  • 它提供了让基于进程的接口适应多线程环境的机制。系统调用和库例程在调用或执行失败时设置errno,为了让线程也能够使用那些原本基于进程的系统调用和库例程,errno被重新定义为线程私有数据。这样,一个线程做了重置errno的操作也不会影响进程中其他线程的errno值。

在分配线程特定数据之前,需要创建与该数据关联的键。这个键将用于获取对线程特定数据的访问。使用pthread_key_create创建一个键

1
2
3
#include <pthread.h>
int pthread_key_create (pthread_key_t *keyp, void (*destructor) (void *));
// 返回值:若成功,返回0;否则,返回错误编号

创建的键存储在keyp指向的内存单元中,这个键可以被进程中的所有线程使用,但每个线程把这个键与不同的线程特定数据地址进行关联。创建新键时,每个线程的数据地址设为空值。除了创建键以外,pthread_key_create可以为该键关联一个可选择的析构函数。当这个线程退出时,如果数据地址已经被置为非空值,那么析构函数就会被调用,它唯一的参数就是该数据地址。如果传入的析构函数为空,就表明没有析构函数与这个键关联。当线程调用pthread_exit或者线程执行返回,正常退出时,析构函数就会被调用。同样,线程取消时,只有在最后的清理处理程序返回之后,析构函数才会被调用。如果线程调用了exit_exit_Exitabort,或者出现其他非正常的退出时,就不会调用析构函数。

线程通常使用malloc为线程特定数据分配内存,析构函数通常释放已分配的内存。如果线程在没有释放内存之前就退出了,那么这块内存就会丢失,即线程所属进程就出现了内存泄漏。

线程退出时,线程特定数据的析构函数将按照操作系统实现中定义的顺序被调用。析构函数可能会调用另一个函数,该函数可能会创建新的线程特定数据,并且把这个数据与当前的键关联起来。当所有的析构函数都调用完成以后,系统会检查是否还有非空的线程特定数据值与键关联,如果有的话,再次调用析构函数。这个过程将会一直重复直到线程所有的键都为空线程特定数据值,或者已经做了PTHREAD_DESTRUCTOR_LITERATIONS中定义的最大次数的尝试。

对所有的线程,我们都可以通过调用pthread_key_delete来取消键与线程特定数据值之间的关联关系。

1
2
3
#include <pthread.h>
int pthread_key_delete (pthreed_key_t key);
// 返回值:若成功,返回0;否则,返回错误编号

注意,调用pthread_key_delete并不会激活与键关联的析构函数。要释放任何与键关联的线程特定数据值的内存,需要在应用程序中采取额外的步骤。

需要确保分配的键并不会由于在初始化阶段的竞争而发生变动。下面的代码会导致两个线程都调用pthread_key_create

1
2
3
4
5
6
7
8
9
10
11
void destructor (void *);

pthread_key_t key;
int init_done = 0;

int threadfunc (void *arg) {
if (!init_done) {
init_done = 1;
err = pthread_key_create (&key, destructor);
}
}

有些线程可能看到一个键值,而其他的线程看到的可能是另一个不同的键值,这取决于系统是如何调度线程的,解决这种竞争的办法是使用pthread_once

1
2
3
4
#include <pthread.h>
pthread_once_t iniflag = PTHREAD_ONCE_INIT;
int pthread_once (pthread_once_t *initflag, void (*inifn(void));
// 返回值:若成功,返回0;否则,返回错误编号

initflag必须是一个非本地变量(如全局变量或静态变量),而且必须初始化为PTHREAD_ONCE_INIT

如果每个线程都调用pthread_once,系统就能保证初始化例程initfn只被调用一次,即系统首次调用pthread_once时。创建键时避免出现冲突的一个正确方法如下:

1
2
3
4
5
6
7
8
9
10
11
void destructor (void *);

pthread_key_t key;
pthreed_once_t init_done = PTHREAD_ONCE_INTT;

void thread_init (void) {
exit = pthread_key_create(&key, destructor);
}
int threadfunc (void *arg) {
pthread_once(&init_done, thread_init);
}

键一旦创建以后,就可以通过调用pthread_setspecific函数把键和线程特定数据关联起来。可以通过pthread_getspecific函数获得线程特定数据的地址。

1
2
3
4
5
#include <pthread.h>
void *pthread_getspecitic (pthread_key_t key);
// 返回值,线程特定数据值,若没有值与该键关联,返回NULL
int pthread_setspecific (pthread_key_t key, const void *value);
// 返回值:若成功,返回0;否则,返回错误编号

如果没有线程特定数据值与键关联,pthread_getspecific将返回一个空指针,我们可以用这个返回值来确定是否需要调用pthread_setspecific

可以使用线程特定数据来维护每个线程的数据缓冲区副本,用于存放各自的返回字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <limits.h>
#include <string.h>
#include <pthread.h>
#include <stdlib.h>

#define MAXSTRINGSZ 4096

static pthread_key_t key;
static pthread_once_t init_done = PTHREAD_ONCE_INIT;
pthread_mutex_t env_mutex = PTHREAD_MUTEX_INITIALIZER;

extern char **environ;

static void
thread_init(void)
{
pthread_key_create(&key, free);
}

char *
getenv(const char *name)
{
int i, len;
char *envbuf;

pthread_once(&init_done, thread_init);
pthread_mutex_lock(&env_mutex);
envbuf = (char *)pthread_getspecific(key);
if (envbuf == NULL) {
envbuf = malloc(MAXSTRINGSZ);
if (envbuf == NULL) {
pthread_mutex_unlock(&env_mutex);
return(NULL);
}
pthread_setspecific(key, envbuf);
}
len = strlen(name);
for (i = 0; environ[i] != NULL; i++) {
if ((strncmp(name, environ[i], len) == 0) &&
(environ[i][len] == '=')) {
strncpy(envbuf, &environ[i][len+1], MAXSTRINGSZ-1);
pthread_mutex_unlock(&env_mutex);
return(envbuf);
}
}
pthread_mutex_unlock(&env_mutex);
return(NULL);
}

我们使用pthread_once来确保只为我们将使用的线程特定数据创建一个键。如果pthread_getspecific返回的是空指针,就需要先分配内存缓冲区,然后再把键与该内存缓冲区关联。否则,如果返回的不是空指针,就使用pthread_getspecific返回的内存缓冲区。

对析构函数,使用free来释放之前由malloc分配的内存。只有当线程特定数据值为非空时,析构函数才会被调用。

取消选项

有两个线程属性并没有包含在pthread_attr_t结构中,它们是可取消状态和可取消类型。这两个属性影响着线程在响应pthread_cancel函数调用时所呈现的行为。

可取消状态属性可以是PTHREAD_CANCEL_ENABLE,也可以是PTHREAD_CANCEL_DISABLE。线程可以通过调用pthread_setcancelstate修改它的可取消状态。

1
2
3
#include <pthread.h>
int pthread_setcancelstate (int state, int *oldstate);
// 返回值:若成功,返回0;否则,返回错误编号

pthread_setcancelstate把当前的可取消状态设置为state,把原来的可取消状态存储在由oldstare指向的内存单元,这两步是一个原子操作。

线程启动时默认的可取消状态是PTHREAD_CANCEL_ENABLE。当状态设为PTHREAD_CANCEL_DISABLE时,对pthread_cancel的调用并不会杀死线程。相反,取消请求对这个线程来说还处于挂起状态,当取消状态再次变为PTHREAD_CANCEL_ENABLE时,线程将在下一个取消点上对所有挂起的取消请求进行处理。

可以调用pthread_testcancel函数在程序中添加自己的取消点。

1
2
#include <pthread.h>
void pthread_testcancel (void);

调用pthread_testcancel时,如果有某个取消请求正处于挂起状态,而且取消并没有置为无效,那么线程就会被取消。但是,如果取消被置为无效,pthread_testcancel调用就没有任何效果了。

我们所描述的默认的取消类型也称为推迟取消。调用pthread_cancel以后,在线程到达取消点之前,并不会出现真正的取消。可以通过调用pthread_setcanceltype来修改取消类型。

1
2
3
#include <pthread.b>
int pthread_setcanceltype(int tyye, int *oldtype);
// 返回值:若成功,返回0;否则,返回错误编号

pthread_setcanceltype函数把取消类型设置为type(类型参数可以是PTHREADCANCEL_DEFERRED,也可以是PTHREAD_CANCEL_ASYNCKRONOUS),把原来的取消类型返回到oldype指向的整型单元。

异步取消与推迟取消不同,因为使用异步取消时,线程可以在任意时间撒消,不是非得遇到取消点才能被取消。

线程和信号

每个线程都有自己的信号屏蔽字,但是信号的处理是进程中所有线程共享的。这意味着单个线程可以阻止某些信号,但当某个线程修改了与某个给定信号相关的处理行为以后,所有的线程都必须共享这个处理行为的改变。这样,如果一个线程选择忽略某个给定信号,那么另一个线程就可以通过以下两种方式撤消上述线程的信号选择:恢复信号的默认处理行为,或者为信号设置个新的信号处理程序。

进程中的信号是递送到单个线程的。如果一个信号与硬件故障相关,那么该信号一般会被发送到引起该事件的线程中去,而其他的信号则被发送到任意一个线程。

10.12节讨论了进程如何使用sigprocmask函数来阻止信号发送。然而,sigprocmask的行为在多线程的进程中并没有定义,线程必须使用pthread_sigmask

1
2
3
#include <signal.h>
int pthread_sigmask(int how, const sigset_t *restrict set, sigset_t *restrict oset);
// 返回值:若成功,返回0:否则,返回错误编号

pthread_sigmask函数与sigprocmask函数基本相同,不过pthread_sigmask工作在线程中,而且失败时返回错误码,不再像sigprocmask中那样设置errno并返回-1,set参数包含线程用于修改信号屏蔽字的信号集。how参数可以取下列3个值之一:

  • SIG_BLOCK,把信号集添加到线程信号屏蔽字中,
  • SIG_SETMASK,用信号集替换线程的信号屏蔽字,
  • SIG_UNBLOCK,从线程信号屏蔽字中移除信号集。

如果oset参数不为空,线程之前的信号屏蔽字就存储在它指向的sigset_t结构中。线程可以通过把set参数设置为NULL,并把oset参数设置为sigset_t结构的地址,来获取当前的信号屏蔽字。这种情况中的how参数会被忽略。

线程可以通过调用sigwait等待一个或多个信号的出现

1
2
3
#include <signal.h>
int sigwait (const sigset_t *restrict set, int *restrict signop);
// 返回值:若成功,返回0;否则,返回错误编号

set参数指定了线程等待的信号集。返回时,signop指向的整数将包含发送信号的数量。如果信号集中的某个信号在sigwait调用的时候处于挂起状态,那么sigwait将无阻塞地返回。在返回之前,sigwait将从进程中移除那些处于挂起等待状态的信号。如果具体实现支持捧队信号,并且信号的多个实例被挂起,那么sigwait将会移除该信号的一个实例,其他的实例还要继续捧队。

为了避免错误行为发生,线程在调用sigwait之前,必须阻塞那些它正在等待的信号。sigwait函数会原子地取消信号集的阻塞状态,直到有新的信号被递送。在返回之前,sigwait将恢复线程的信号屏蔽字。如果信号在sigwait被调用的时候没有被阻塞,那么在线程完成对sigwait的调用之前会出现一个时间窗,在这个时间窗中,信号就可以被发送给线程。使用sigwait的好处在于它可以简化信号处理,允许把异步产生的信号用同步的方式处理。

为了防止信号中断线程,可以把信号加到每个线程的信号屏蔽字中。然后可以安排专用线程处理信号。这些专用线程可以进行函数调用,不需要担心在信号处理程序中调用哪些函数是安全的,因为这些函数调用来自正常的线程上下文,而非会中断线程正常执行的传统信号处理程序。如果多个线程在sigwait的调用中因等待同一个信号而阻塞,那么在信号递送的时候,就只有一个线程可以从sigwait中返回。如果一个信号被捕获,而且一个线程正在sigwait调用中等待同一信号,那么这时将由操作系统实现来决定以何种方式递送信号。操作系统实现可以让sigwait返回,也可以激活信号处理程序,但这两种情况不会同时发生。

要把信号发送给进程,可以调用kill。要把信号发送给线程,可以调用pthread_kill

1
2
3
#include <signal.h>
int pthread_kill (pthread_t thread, int signo);
// 返回值:若成功,返回0;否则,返回错误编号

可以传一个0值的signo来检查线程是否存在。如果信号的默认处理动作是终止该进程,那么把信号传递给某个线程仍然会杀死整个进程。

注意,闹钟定时器是进程资源,并且所有的线程共享相同的闹钟。所以,进程中的多个线程不可能互不干扰(或互不合作)地使用闹钟定时器。

线程和fork

当线程调用fork时,就为子进程创建了整个进程地址空间的副本。子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在fork返回以后,如果紧接着不是马上调用exec的话,就需要清理锁状态。

在子进程内部,只存在一个线程,它是由父进程中调用fork的线程的副本构成的。如果父进程中的线程占有锁,子进程将同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁、需要释放哪些锁。如果子进程从fork返回以后马上调用其中一个exec函数,就可以避免这样的问题。这种情况下,旧的地址空间就被丢弃,所以锁的状态无关紧要。但如果子进程需要继续做处理工作的话,这种策略就行不通,还需要使用其他的策略。

在多线程的进程中,为了避免不一致状态的问题,POSIX.1声明,在fork返回和子进程调用其中一个exec函数之间,子进程只能调用异步信号安全的函数。这就限制了在调用exec之前子进程能做什么,但不涉及子进程中锁状态的问题,要清除锁状态,可以通过调用pthread_atfork函数建立fork处理程序。

1
2
3
#include <pthread.h>
int pthread_atfork (void (*prepare) (void), void (*parent) (void), void (*child)(void));
// 返回值:若成功,返回0:否则,返回借误编号

pthread_atfork函数最多可以安装3个帮助清理锁的函数。prepare fork处理程序由父进程在fork创建子进程前调用。这个fork处理程序的任务是获取父进程定义的所有锁。parent fork处理程序是在fork创建子进程以后、返回之前在父进程上下文中调用的。这个fork处理程序的任务是对prepare fork处理程序获取的所有锁进行解锁。child fork处理程序在fork返回之前在子进程上下文中调用。与parent fork处理程序一样,child fork处理程序也必须释放prepare fork处理程序获取的所有锁。

注意,不会出现加锁一次解锁两次的情况,虽然看起来也许会出现。子进程地址空间在创建时就得到了父进程定义的所有锁的副本。因为prepare fork处理程序获取了所有的镜,父进程中的内存和子进程中的内存内容在开始的时候是相同的。当父进程和子进程对它们锁的副本进程解锁的时候,新的内存是分配给子进程的,父进程的内存内容是复制到子进程的内存中(写时复制),所以看起来父进程对它所有的锁的副本进行了加锁,子进程对它所有的锁的副本进行了加锁。父进程和子进程对在不同内存单元的重复的锁都进行了解锁操作,就好像出现了下列事件序列。

  1. 父进程获取所有的锁
  2. 子进程获取所有的锁
  3. 父进程释放它的锁
  4. 子进程释放它的锁

可以多次调用pthread_atfork函数从而设置多套fork处理程序。如果不需要使用其中某个处理程序,可以给特定的处理程序参数传入空指针,它就不会起任何作用了。使用多个fork处理程序时,处理程序的调用顺序并不相同。parentchild fork处理程序是以它们注册时的顺序进行调用的,而prepare fork处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。

例如,假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次是A在B之前,模块B必须在模块A之前设置它的fork处理程序。当父进程调用fork时,就会执行以下的步骤,假设子进程在父进程之前运行:

  1. 调用模块A的prepare fork处理程序获取模块A的所有锁。
  2. 调用模块B的prepare fork处理程序获取模块B的所有锁。
  3. 创建子进程
  4. 调用模块B中的child fork处理程序释放子进程中模块B的所有镜。
  5. 调用模块A中的child fork处理程序释放子进程中模块A的所有锁。
  6. fork函数返回到子进程
  7. 调用模块B中的parent fork处理程序释放父进程中模块B的所有锁。
  8. 调用模块A中的parent fork处理程序来释放父进程中模块才的所有锁。
  9. fork函数返同到父进程
"apue.h"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <pthread.h>

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

void
prepare(void)
{
int err;

printf("preparing locks...\n");
if ((err = pthread_mutex_lock(&lock1)) != 0)
err_cont(err, "can't lock lock1 in prepare handler");
if ((err = pthread_mutex_lock(&lock2)) != 0)
err_cont(err, "can't lock lock2 in prepare handler");
}

void
parent(void)
{
int err;

printf("parent unlocking locks...\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
err_cont(err, "can't unlock lock1 in parent handler");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
err_cont(err, "can't unlock lock2 in parent handler");
}

void
child(void)
{
int err;

printf("child unlocking locks...\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
err_cont(err, "can't unlock lock1 in child handler");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
err_cont(err, "can't unlock lock2 in child handler");
}

void *
thr_fn(void *arg)
{
printf("thread started...\n");
pause();
return(0);
}

int
main(void)
{
int err;
pid_t pid;
pthread_t tid;

if ((err = pthread_atfork(prepare, parent, child)) != 0)
err_exit(err, "can't install fork handlers");
if ((err = pthread_create(&tid, NULL, thr_fn, 0)) != 0)
err_exit(err, "can't create thread");

sleep(2);
printf("parent about to fork...\n");

if ((pid = fork()) < 0)
err_quit("fork failed");
else if (pid == 0) /* child */
printf("child returned from fork\n");
else /* parent */
printf("parent returned from fork\n");
exit(0);
}

定义了两个互斥量,lock1lock2prepare fork处理程序获取这两把锁,child fork处理程序在子进程上下文中释放它们,parent fork处理程序在父进程上下文中释放它们。运行该程序,得到如下输出:

1
2
3
4
5
6
7
8
$ ./a.out
thread started.
parent about to tork...
preparing locks.
child unlocking locks.
child returned from fork
parent unlocking locks.
parent returned from fork

可以看到,prepare fork处理程序在调用fork以后运行,child fork处理程序在fork调用返回到子进程之前运行,parent fork处理程序在fork调用返回给父进程之前运行。虽然pthread_atfork机制的意图是使fork之后的锁状态保持一致,但它还是存在一些不足之处,只能在有限情况下可用。

  • 没有很好的办法对较复杂的同步对象(如条件变量或者屏障)进行状态的重新初始化。
  • 某些错误检查的互斥量实现在child fork处理程序试图对被父进程加锁的互斥量进行解锁时会产生错误。
  • 递归互斥量不能在child fork处理程序中清理,因为没有办法确定该互斥量被加锁的次数。
  • 如果子进程只允许调用异步信号安全的函数,child fork处理程序就不可能清理同步对象,因为用于操作清理的所有函数都不是异步信号安全的。实际的问题是同步对象在某个线程调用fork时可能处于中间状态,除非同步对象处于一致状态,否则无法被清理。
  • 如果应用程序在信号处理程序中调用了fork(这是合法的,因为fork本身是异步信号安全的),pthread_atfork注册的fork处理程序只能调用异步信号安全的函数,否则结果将是未定义的。

守护进程

守护进程的特征

父进程ID为0的各进程通常是内核进程,它们作为系统引导装入过程的一部分而启动。(init是个例外,它是一个由内核在引导装入时启动的用户层次的命令),内核进程是特殊的,通常存在于系统的整个生命期中。它们以超级用户特权运行,无控制终端,无命令行。

对于需要在进程上下文执行工作但却不被用户层进程上下文调用的每一个内核组件,通常有它自己的内核守护进程。例如,在Linux中,

  • kswapd守护进程也称为内存换页守护进程。它支持虚拟内存子系统在经过一段时间后将脏页面慢慢地写回磁盘来回收这些页面;
  • flush守护进程在可用内存达到设置的最小阈值时将脏页面冲洗至磁盘,它也定期地将脏页面冲洗回磁盘来减少在系统出现故障时发生的数据丢失,多个冲洗守护进程可以同时存在,每个写回的设备都有一个冲洗守护进程;
  • sync_supers守护进程定期将文件系统元数据冲洗至磁盘。
  • job守护进程帮助实现了ext4文件系统中的日志功能。

init是一个系统守护进程,除了其他工作外,主要负责启动各运行层次特定的系统服务。这些服务通常是在它们自己拥有的守护进程的帮助下实现的。

rpcbind守护进程提供将远程过程调用(Remote Procedure Call, RPC)程序号映射为网络端口号的服务。rsyslogd守护进程可以被由管理员启用的将系统消息记入日志的任何程序使用。可以在一台实际的控制台上打印这些消息,也可将它们写到一个文件中。

cron守护进程在定期安排的日期和时间执行命令。许多系统管理任务是通过cron每隔一段固定的时间就运行相关程序而得以实现的。atd守护进程与cron类似,它允许用户在指定的时间执行任务,但是每个任务它只执行一次,而非在定期安排的时间反复执行。cupsd守护进程是个打印假脱机进程,它处理对系统提出的各个打印请求。sshd守护进程提供了安全的远程登录和执行设施。

注意,大多数守护进程都以超级用户(root)特权运行。所有的守护进程都没有控制终端,其终端名设置为问号。内核守护进程以无控制终端方式启动。用户层守护进程缺少控制终端可能是守护进程调用了setsid的结果。大多数用户层守护进程都是进程组的组长进程以及会话的首进程,而且是这些进程组和会话中的唯一进程(rsyslogd是一个例外)。最后,应当引起注意的是用户层守护进程的父进程是init进程。

编程规则

在编写守护进程程序时需遵循一些基本规则,以防止产生不必要的交互作用。

  • 首先要做的是调用umask将文件模式创建屏蔽字设置为一个已知值(通常是0)。由继承得来的文件模式创建屏蔽字可能会被设置为拒绝某些权限。如果守护进程要创建文件,那么它可能要设置特定的权限。例如,若守护进程要创建组可读、组可写的文件,继承的文件模式创建屏蔽字可能会屏蔽上述两种权限中的一 种,而使其无法发挥作用。另一方面,如果守护进程调用的库函数创建了文件,那么将文件模式创建屏蔽字设置为一个限制性更强的值(如007)可能会更明智,因为库函数可能不允许调用者通过一个显式的函数参数来设置权限。
  • 调用fork,然后使父进程exit。这样做实现了下面几点。第一,如果该守护进程是作为一条简单的shell命令启动的,那么父进程终止会让shell认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组ID,但获得了一个新的进程ID,这就保证了子进程不是一个进程组的组长进程。这是下面将要进行的setsid调用的先决条件。
  • 调用setsid创建一个新会话。然后执行3个步骤,使调用进程:
    • 成为新会话的首进程,
    • 成为一个新进程组的组长进程,
    • 没有控制终端。
  • 将当前工作目录更改为根目录。从父进程处继承过来的当前工作目录可能在一个挂载的文件系统中。因为守护进程通常在系统再引导之前是一直存在的,所以如果守护进程的当前工作目录在一个挂载文件系统中,那么该文件系统就不能被卸载。或者。某些守护进程还可能会把当前工作目录更改到某个指定位置,并在此位置进行它们的全部工作。
  • 关闭不再需要的文件描述符。这使守护进程不再持有从其父进程继承来的任何文件描述符。可以使用open_max函数或getrlimit函数来判定最高文件描述符值,并关闭直到该值的所有描述符。
  • 某些守护进程打开/dev/null使其具有文件描述符0、1和2,这样,任何一个试图读标准输入、写标准输出或标准错误的库例程都不会产生任何效果。因为守护进程并不与终端设备相关联。即使守护进程是从交互式会话启动的,但是守护进程是在后台运行的,所以登录会话的终止并不影响守护进程。

函数可由一个想要初始化为守护进程的程序调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include "apue.h"
#include <syslog.h>
#include <fcntl.h>
#include <sys/resource.h>

void
daemonize(const char *cmd)
{
int i, fd0, fd1, fd2;
pid_t pid;
struct rlimit rl;
struct sigaction sa;

/*
* Clear file creation mask.
*/
umask(0);

/*
* Get maximum number of file descriptors.
*/
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
err_quit("%s: can't get file limit", cmd);

/*
* Become a session leader to lose controlling TTY.
*/
if ((pid = fork()) < 0)
err_quit("%s: can't fork", cmd);
else if (pid != 0) /* parent */
exit(0);
setsid();

/*
* Ensure future opens won't allocate controlling TTYs.
*/
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
err_quit("%s: can't ignore SIGHUP", cmd);
if ((pid = fork()) < 0)
err_quit("%s: can't fork", cmd);
else if (pid != 0) /* parent */
exit(0);

/*
* Change the current working directory to the root so
* we won't prevent file systems from being unmounted.
*/
if (chdir("/") < 0)
err_quit("%s: can't change directory to /", cmd);

/*
* Close all open file descriptors.
*/
if (rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
for (i = 0; i < rl.rlim_max; i++)
close(i);

/*
* Attach file descriptors 0, 1, and 2 to /dev/null.
*/
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);

/*
* Initialize the log file.
*/
openlog(cmd, LOG_CONS, LOG_DAEMON);
if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
syslog(LOG_ERR, "unexpected file descriptors %d %d %d",
fd0, fd1, fd2);
exit(1);
}
}

若daemonize函数由main程序调用,然后main程序进入休眠状态,那么可以用ps命令检查该守护进程的状态:

1
2
3
4
5
6
$ ./a.out
$ ps -efj
UID PID PPID PGID SID TTY CMD
sar 13800 1 13799 13799 ? ./a.out
$ ps -efj | grep 13799
sar 13800 1 13799 13799 ? ./a.out

我们也可用ps命令验证,没有活动进程存在的ID是13799,这意味着,守护进程在一个孤儿进程组中,它不是会话首进程,因此没有机会被分配到一个控制终端。这一结果是在daemonize函数中执行第二个fork造成的。可以看出,守护进程已经被正确地初始化了。

出错记录

守护进程存在的一个问题是如何处理出错消息。因为它本就不应该有控制终端,所以不能只是简单地写到标准错误上,需要有一个集中的守护进程出错记录设施。

有以下3种产生日志信息的方法,

  1. 内核例程可以调用log函数。任何一个用户进程都可以通过打开(open)并读取(read)/dev/klog设备来读取这些消息。
  2. 大多数用户进程(守护进程)调用syslog(3)函数来产生日志消息。这使消息被发送至UNIX域数据报套接字/dev/log
  3. 无论一个用户进程是在此主机上,还是在通过TCPIP网络连接到此主机的其他主机上,都可将日志消息发向UDP端口514。注意,syslog函数从不产生这些UDP数据报,它们要求产生此日志消息的进程进行显式的网络编程。

通常,syslogd守护进程读取所有3种格式的日志消息。此守护进程在启动时读一个配置文件,其文件名一般为/etc/syslog.cont。该文件决定了不同种类的消息应送向何处。例如,紧急消息可发送至系统管理员(着已登录),并在控制台上打印,而警告消息则可记录到一个文件中。该设施的接口是syslog函数。

1
2
3
4
5
6
#include <syslog.h>
void openlog(const char *ident, int option, int facility) :
void syslog(int prionity, const char *formar, ...);
void closelog(void);
int setlogmask(int markpri);
// 返回值,前日志记录优先级屏蔽字值

调用openlog是可选择的。如果不调用openlog,则在第一次调用syslog时,自动调用openlog。调用closelog也是可选择的,因为它只是关闭曾被用于与synlogd守护进程进行通信的描述符。

调用openlog使我们可以指定一个ident,以后,此ident将被加至每则日志消息中。ident一般是程序的名称,option参数是指定各种选项的位屏蔽。表中介绍了可用的option(选项)。

|option|说明|
|LOG_CONS|若日志消息不能通过UNIX域数据报送至syslogd,则将该消息写至控制台|
|LOG_NDELAY|立即打开至syslogd守护进程的UNIX域数据报套接字,不要等到第一条消息已经被记录时再打开。通常,在记录第一条消息之前,不打开该套接字|
|LOG_NOWATT|不要等待在将消息记入日志过程中可能已创建的子进程。因为在syslog调用wait时,应用程序可能已获得了子进程的状态。这种处理限止了与捕提SIGCKLD信号的应用程序之间产生的冲突|
|LOG_OOELAY|在第一条消息被记录之前越迟打开至syslogd守护进程的连接|
|LOG_PERROR|除将日志消息发送给syslogd以外,还将它写至标准出错|
|LOG_PID|记录每条消息都要包含进程ID。此选项可供对每个不同的请求都fork一个子进程的守护进程使用|

openlogfacility参数值选取自下图。设置facility参数的目的是可以让配置文件说明,来自不同设施的消息将以不同的方式进行处理。如果不调用openlog,或者以facility为0来调用它,那么在调用syslog时,可将facility作为priority参数的一个部分进行说明。

调用syslog产生一个日志消息。其priority参数是facilitylevel的组合,它们可选取的值分别列于facilitylevel中。level值按优先级从最高到最低依次排列。

format参数以及其他所有参数传至vsprintf函数以便进行格式化。在format中,每个出现的%m字符都先被代换成与errno值对应的出错消息字符串(strerror)。setlogmask函数用于设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字。当设置了记录优先级屏蔽字时,各条消息除非已在记录优先级屏蔽字中进行了设置,否则将不被记录。

在一个守护进程中,可能包含有下面的调用序列:

1
2
openlog("lpd", LOG_PID, LOG_LPR);
syslog (LOG_ERR, "open error for %s: %m”, filename);

第一个调用将ident字符串设置为程序名,指定该进程ID要始终被打印。对syslog的调用指定一个出错条件和一个消息字符串。如若不调用openlog,则第二个调用的形式可能是:

1
syslog (LOG_ERR | LOG_LPR, "open error for %s: %m", filename);

其中,将priority参数指定为levelfacility的组合。

除了syslog,很多平台还提供它的一种变体来处理可变参数列表。

1
2
3
#include <syslog.h>
#include <stdarg.h>
void vsyslog(int priority, const char *format, va_list arg);

单实例守护进程

为了正常运作,某些守护进程会实现为,在任一时刻只运行该守护进程的一个副本。文件和记录锁机制为一种方法提供了基础,该方法保证一个守护进程只有一个副本在运行,文件和记录锁提供了一种方便的互斥机制。如果守护进程在一个文件的整体上得到一把写锁,那么在该守护进程终止时,这把锁将被自动删除。这就简化了复原所需的处理,去除了对以前的守护进程实例需要进行清理的有关操作。

所示的函数说明了如何使用文件和记录镜来保证只运行一个守护进程的一个副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <syslog.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <sys/stat.h>

#define LOCKFILE "/var/run/daemon.pid"
#define LOCKMODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)

extern int lockfile(int);

int
already_running(void)
{
int fd;
char buf[16];

fd = open(LOCKFILE, O_RDWR|O_CREAT, LOCKMODE);
if (fd < 0) {
syslog(LOG_ERR, "can't open %s: %s", LOCKFILE, strerror(errno));
exit(1);
}
if (lockfile(fd) < 0) {
if (errno == EACCES || errno == EAGAIN) {
close(fd);
return(1);
}
syslog(LOG_ERR, "can't lock %s: %s", LOCKFILE, strerror(errno));
exit(1);
}
ftruncate(fd, 0);
sprintf(buf, "%ld", (long)getpid());
write(fd, buf, strlen(buf)+1);
return(0);
}

守护进程的每个副本都将试图创建一个文件,并将其进程ID写到该文件中。这使管理人员易于标识该进程。如果该文件已经加了锁,那么lockfile函数将失败,errno设置为EACCES或EAGAIN,函数返回1,表明该守护进程已在运行。否则将文件长度截断为0,将进程ID写入该文件,函数返回0。

需要将文件长度截断为0,其原因是之前的守护进程实例的进程ID字符串可能长于调用此函数的当前进程的进程ID字符串。例如,若以前的守护进程的进程ID是12345,而新实例的进程ID是9999,那么将此进程ID写入文件后,在文件中留下的是99995。将文件长度截断为0就解决了此问题。

守护进程的惯例

  • 若守护进程使用锁文件,那么该文件通常存储在/var/run目录中。然而需要注意的是,守护进程可能需要具有超级用户权限才能在此目录下创建文件,锁文件的名字通常是name.pid,其中,name是该守护进程或服务的名字,
  • 若守护进程支持配置选项,那么配置文件通常存放在/etc目录中。配置文件的名字通常是name.conf。其中,name是该守护进程或服务的名字。例如,syslogd守护进程的配置文件通常是/etc/syslog.conf
  • 守护进程可用命令行启动,但通常它们是由系统初始化脚本之一(/etc/rc*/etc/init.d/*)启动的。如果在守护进程终止时,应当自动地重新启动它,则我们可在/etc/inittab中为该守护进程包括respawn记录项,这样,init就将重新启动该守护进程。
  • 若一个守护进程有一个配置文件,那么当该守护进程启动时会读该文件,但在此之后一般就不会再查看它。若某个管理员更改了配置文件, 那么该守护进程可能需要被停止,然后再启动,以使配置文件的更改生效。为避免此种麻烦,某些守护进程将捕捉SIGHUP信号,当它们接收到该信号时,重新读配置文件。

程序说明了守护进程可以重读其配置文件的一种方法。该程序使用sigwait以及多线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include "apue.h"
#include <pthread.h>
#include <syslog.h>

sigset_t mask;

extern int already_running(void);

void
reread(void)
{
/* ... */
}

void *
thr_fn(void *arg)
{
int err, signo;

for (;;) {
err = sigwait(&mask, &signo);
if (err != 0) {
syslog(LOG_ERR, "sigwait failed");
exit(1);
}

switch (signo) {
case SIGHUP:
syslog(LOG_INFO, "Re-reading configuration file");
reread();
break;

case SIGTERM:
syslog(LOG_INFO, "got SIGTERM; exiting");
exit(0);

default:
syslog(LOG_INFO, "unexpected signal %d\n", signo);
}
}
return(0);
}

int
main(int argc, char *argv[])
{
int err;
pthread_t tid;
char *cmd;
struct sigaction sa;

if ((cmd = strrchr(argv[0], '/')) == NULL)
cmd = argv[0];
else
cmd++;

/*
* Become a daemon.
*/
daemonize(cmd);

/*
* Make sure only one copy of the daemon is running.
*/
if (already_running()) {
syslog(LOG_ERR, "daemon already running");
exit(1);
}

/*
* Restore SIGHUP default and block all signals.
*/
sa.sa_handler = SIG_DFL;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
err_quit("%s: can't restore SIGHUP default");
sigfillset(&mask);
if ((err = pthread_sigmask(SIG_BLOCK, &mask, NULL)) != 0)
err_exit(err, "SIG_BLOCK error");

/*
* Create a thread to handle SIGHUP and SIGTERM.
*/
err = pthread_create(&tid, NULL, thr_fn, 0);
if (err != 0)
err_exit(err, "can't create thread");

/*
* Proceed with the rest of the daemon.
*/
/* ... */
exit(0);
}

该程序调用了daemonize来初始化守护进程。从该函数返回后,调用already_running函数以确保该守护进程只有一个副本在运行。到达这一点时,SIGHUP信号仍被忽略,所以需恢复对该信号的系统默认处理方式;否则调用sigwait的线程决不会见到该信号。如同对多线程程序所推荐的那样,阻塞所有信号,然后创建一个线程处理信号。该线程的唯一工作是等待SIGHUPSIGTERM。当接收到SIGHUP信号时,该线程调用reread函数重读它的配置文件。当它接收到SIGTERM信号时,会记录消息并退出。

高级I/O

非阻塞I/O

对于一个给定的描述符,有两种为其指定非阻塞I/O的方法。

  1. 如果调用open获得描述符,则可指定O_NONBLOCK标志。
  2. 对于已经打开的一个描述符,则可调用fcntl,由该函数打开O_NONBLOCK文件状态标志。

程序是一个非阻塞I/O的实例,它从标准输入读500000字节,并试图将它们写到标准输出上。该程序先将标准输出设置为非阻塞的,然后用for循环进行输出,每次write调用的结果都在标准错误上打印。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "apue.h"
#include <errno.h>
#include <fcntl.h>

char buf[500000];

int
main(void)
{
int ntowrite, nwrite;
char *ptr;

ntowrite = read(STDIN_FILENO, buf, sizeof(buf));
fprintf(stderr, "read %d bytes\n", ntowrite);

set_fl(STDOUT_FILENO, O_NONBLOCK); /* set nonblocking */

ptr = buf;
while (ntowrite > 0) {
errno = 0;
nwrite = write(STDOUT_FILENO, ptr, ntowrite);
fprintf(stderr, "nwrite = %d, errno = %d\n", nwrite, errno);

if (nwrite > 0) {
ptr += nwrite;
ntowrite -= nwrite;
}
}

clr_fl(STDOUT_FILENO, O_NONBLOCK); /* clear nonblocking */

exit(0);
}

若标准输出是普通文件,则可以期望write只执行一次。

1
2
3
4
5
6
7
$ ls -l /etc/services    打印文件长度
-rw-r--r-- 1 root 677959 Jun 23 2009 /etc/services
s ./a.out < /ete/services > temp.file 先试一个普通文件
read 500000 bytes
nwrite = 500000, errno = 0 一次写
$ ls -l temp.tile 检验输出文件长度
-rw-rw-t-- 1 sar 500000 Apr 1 13:03 temp.file

但是,若标准输出是终端,则期望write有时返回小于500000的一个数字,有时返回错误。

记录锁

记录锁(record locking)的功能是:当第一个进程正在读或修改文件的某个部分时,使用记录锁可以阻止其他进程修改同一文件区。对于UNIX系统而言,”记录”这个词是一种误用,因为UNIX系统内核根本没有使用文件记录这种概念。一个更适合的术语可能是字节范围锁(byte-range locking),因为它锁定的只是文件中的一个区域(也可能是整个文件)。

fcntl记录锁

1
2
3
#include <fcntl.h>
int fcntl(int fd, int cmd, .../* struct flock *flockptr */);
// 返回值:若成功,依赖于cmd(见下)。否则,返回-1

对于记录锁,cmdF_GETLKF_SETLXF_SETLKW。第三个参数是一个指向flock结构的指针。

1
2
3
4
5
6
7
struct flock {
short l_type; /* F_RDLCK, E_WRLCK, or F_UNLCK */
short l_whence; /* SEEK_SET, SEEK_CUR, OF SEEK_END */
off_t l_start; /* offset in bytes, relative to l_whence */
off_t l_len; /* length, in bytes; 0 means lock to EOF */
pid_t l_pid; /* returned with F_GETLK */
};

flock结构说明如下。

  • 所希望的锁类型:F_RDLCK(共享读锁)、F_WRLCK(独占性写锁)或F_ONLCK(解锁个区域)
  • 要加锁或解锁区域的起始字节偏移量(l_startl_whence)
  • 区域的字节长度(l_len)
  • 进程的ID(l_pid)持有的锁能阻塞当前进程(仅由F_GETLK返回)

关于加锁或解锁区域的说明还要注意下列几项规则。

  • 指定区域起始偏移量的两个元素与lseek函数中最后两个参数类似。l_whence可选用的值是SEEK_SETSEEK_CURSEEK_END
  • 锁可以在当前文件尾端处开始或者越过尾端处开始。但是不能在文件起始位置之前开始。
  • 如若l_len为0。则表示锁的范围可以扩展到最大可能偏移量。这意味着不管向该文件中追加写了多少数据,它们都可以处于锁的范围内(不必猜测会有多少字节被追加写到了文件之后),而且起始位置可以是文件中的任意一个位置
  • 为了对整个文件加锁,我们设置l_startl_whence指向文件的起始位置,并且指定长度(l_len)为0

上面提到了两种类型的锁,共享读锁(l_typeL_RDLCK)和独占性写锁(L_WRLCK)。基本规则是:任意多个进程在一个给定的字节上可以有一把共享的读锁,但是在一个给定字节上只能有一个进程有一把独占写锁。进一步而言,如果在一个给定字节上已经有一把或多把读锁,则不能在该字节上再加写锁;如果在一个字节上已经有一把独占性写锁,则不能再对它加任何读锁

上面说明的兼容性规则适用于不同进程提出的锁请求,并不适用于单个进程提出的多个锁请求。如果一个进程对一个文件区间已经有了一把锁,后来该进程又企图在同一文件区间再加一把锁,那么新锁将替换已有镜。加读锁时,该描述符必须是读打开。加写锁时,该描述符必须是写打开。下面说明一下fcntl函数的3种命令。

  • F_GETLK判断由flockptr所描述的锁是否会被另外一把锁所排斥(阻塞)。如果存在一把锁,它阻止创建由flockptr所描述的锁,则该现有锁的信息将重写flockptr指向的信息。如果不存在这种情况,则除了将l_type设置为E_UNLCK之外,flockptr所指向结构中的其他信息保持不变
  • F_SETLK设置由flockptr所描述的锁。如果我们试图获得一把读锁(l_typeF_RDLCK)或写锁(l_typeF_WRLCK),而兼容性规则阻止系统给我们这把锁,那么fcntl会立即出错返回,此时errno设置为EACCES或EAGAIN。此命令也用来清除由flockptr指定的锁(l_typeF_UNLCK)
  • F_SETLKW这个命令是F_SETLK的阻塞版本。如果所请求的读愤或写锁因另一个进程当前已经对所请求区域的某部分进行了加锁而不能被授予,那么调用进程会被置为休眠。如果请求创建的锁已经可用,或者休眠由信号中断,则该进程被唤醒

应当了解,用E_GETLK测试能否建立一把锁,然后用F_SETLKE_SETLKW企图建立那把锁,这两者不是一个原子操作。因此不能保证在这两次fcntl调用之间不会有另一个进程插入并建立一把相同的锁。如果不希望在等待锁变为可用时产生阻塞,就必须处理由F_SETLK返回的可能的出错。

在设置或释放文件上的一把锁时,系统按要求组合或分裂相邻区。例如,若第100~199字节是加锁的区,需解锁第150字节, 则内核将维持两把锁,一把用于第100~149字节,另一把用于第151~199字节。假定我们又对第150字节加锁,那么系统将会再把3个相邻的加锁区合并成一个区(第100~199字节)。

为了避免每次分配flock结构,然后又填入各项信息,可以用函数lock_reg来处理所有这些细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "apue.h"
#include <fcntl.h>

int
lock_reg(int fd, int cmd, int type, off_t offset, int whence, off_t len)
{
struct flock lock;

lock.l_type = type; /* F_RDLCK, F_WRLCK, F_UNLCK */
lock.l_start = offset; /* byte offset, relative to l_whence */
lock.l_whence = whence; /* SEEK_SET, SEEK_CUR, SEEK_END */
lock.l_len = len; /* #bytes (0 means to EOF) */

return(fcntl(fd, cmd, &lock));
}

因为大多数锁调用是加锁或解锁一个文件区域(命令E_GETLK很少使用),故通常使用下列5个宏中的一个。

1
2
3
4
5
6
7
8
9
10
#define read_lock(fd, offset, whence, len)\
lock_reg((fd), F_SETLK, F_RDLCK, (offset), (whence), (len))
#define readw_lock (fd, offset, whence, len) \
lock_reg((fd), F_SETLKW, F_RDLCK, (offset), (whence), (len))
#define write_lock(fd, offset, whence, len) \
lock_reg((fd), F_SETLK, F_WRLCK, (offset), (whence), (len))
#define writew_lock (fd, offset, whence, len) \
lock_reg((fd),F_SETLKW, F_WRLCK, (offset),(whence), (len))
#define un_lock (fd, offset, whence, len) \
lock_reg((fd), F_SETLK, F_UNLCK, (offset), (whence), (len))

下边定义了一个函数lock_test,我们将用它测试一把锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "apue.h"
#include <fcntl.h>

pid_t
lock_test(int fd, int type, off_t offset, int whence, off_t len)
{
struct flock lock;

lock.l_type = type; /* F_RDLCK or F_WRLCK */
lock.l_start = offset; /* byte offset, relative to l_whence */
lock.l_whence = whence; /* SEEK_SET, SEEK_CUR, SEEK_END */
lock.l_len = len; /* #bytes (0 means to EOF) */

if (fcntl(fd, F_GETLK, &lock) < 0)
err_sys("fcntl error");

if (lock.l_type == F_UNLCK)
return(0); /* false, region isn't locked by another proc */
return(lock.l_pid); /* true, return pid of lock owner */
}

如果存在一把锁,它阻塞由参数指定的锁请求,则此函数返回持有这把现有锁的进程的进程ID,否则此函数返回0。通常用下面两个宏来调用此函数。

1
2
3
4
#define	is_read_lockable(fd, offset, whence, len) \
(lock_test((fd), F_RDLCK, (offset), (whence), (len)) == 0)
#define is_write_lockable(fd, offset, whence, len) \
(lock_test((fd), F_WRLCK, (offset), (whence), (len)) == 0)

注意,进程不能使用lock_test函数测试它自己是否在文件的某一部分持有一把锁。F_GETLK命令的定义说明,返回信息指示是否有现有的锁阻止调用进程设置它自己的锁。因为F_SETLKF_SETLKW命令总是替换调用进程现有的锁,所以调用进程决不会阻塞在自己持有的锁上,于是,F_GETLK命令决不会报告调用进程自己持有的锁。

如果一个进程已经控制了文件中的一个加锁区域。然后它又试图对另一个进程控制的区域加锁,那么它就会休眠,在这种情况下,有发生死锁的可能性。子进程对第0字节加锁,父进程对第1字节加锁。然后,它们中的每一个又试图对对方已经加锁的字节加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include "apue.h"
#include <fcntl.h>

static void
lockabyte(const char *name, int fd, off_t offset)
{
if (writew_lock(fd, offset, SEEK_SET, 1) < 0)
err_sys("%s: writew_lock error", name);
printf("%s: got the lock, byte %lld\n", name, (long long)offset);
}

int
main(void)
{
int fd;
pid_t pid;

/*
* Create a file and write two bytes to it.
*/
if ((fd = creat("templock", FILE_MODE)) < 0)
err_sys("creat error");
if (write(fd, "ab", 2) != 2)
err_sys("write error");

TELL_WAIT();
if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid == 0) { /* child */
lockabyte("child", fd, 0);
TELL_PARENT(getppid());
WAIT_PARENT();
lockabyte("child", fd, 1);
} else { /* parent */
lockabyte("parent", fd, 1);
TELL_CHILD(pid);
WAIT_CHILD();
lockabyte("parent", fd, 0);
}
exit(0);
}

运行图14-7中的程序得到:

1
2
3
4
5
$ ./a.out
parent: got the lock, byte 1
child: got the lock, byte 0
parent: writem_lock error: Resource deadlock avoided
child: got the lock, byte 1

检测到死锁时,内核必须选择一个进程接收出错返回。在本实例中,选择了父进程,但这是一个实现细节。在某些系统上,子进程总是接到出错信息,在另一些系统上,父进程总是接到出错信息。在某些系统上,当试图使用多把锁时,有时是子进程接到出错信息,有时则是父进程接到出错信息。

锁的隐含继承和释放

关于记录锁的自动继承和释放有3条规则。

  • 锁与进程和文件两者相关联。这有两重含义。第一重很明显,当一个进程终止时,它所建立的锁全部释放;第二重则不太明显,无论一个描述符何时关闭,该进程通过这一描述符引用的文件上的任何一把锁都会释放(这些锁都是该进程设置的)。
    • 这就意味着,如果执行下列4步:
      • fd1 = open (pathname, ...);
      • read_lock(fd1, ...);
      • fd2 = dup(fdi);
      • close (fd2);
    • 则在close(fd2)后,在fd1上设置的锁被释放。如果将dup替换为open,其效果也一样:
      • fd1 = open (pathname, ...);
      • read_lock(fd1, ...);
      • fd2 = open(pathname, ...);
      • close (fd2);
  • fork产生的子进程不继承父进程所设置的锁。这意味着,若一个进程得到一把锁,然后调用fork,那么对于父进程获得的锁而言,子进程被视为另一个进程。对于通过fork从父进程处继承过来的描述符,子进程需要调用fcntl才能获得它自己的锁。
    • 这个约束是有道理的,因为锁的作用是阻止多个进程同时写同一个文件。如果子进程通过fork继承父进程的锁,则父进程和子进程就可以同时写同一个文件。
  • 在执行exec后,新程序可以继承原执行程序的锁。但是注意,如果对一个文件描述符设置了执行时关闭标志,那么当作为exec的一部分关闭该文件描述符时,将释放相应文件的所有锁。

FreeBSD实现

先简要地观察FreeBSD实现中使用的数据结构。这会帮助我们进一步理解记录锁的自动继承和释放的第一条规则:锁与进程和文件两者相关联。考虑一个进程,它执行下列语句(忽略出错返回)。

1
2
3
4
5
6
7
8
9
10
11
fd1 = open (pathname, ...);
write_lock(fd1, 0, SEEK_SET, 1);
/* parent write locks byte 0 */
if ((pid = fork()) > 0) {
/* parent */
fd2 = dup(fd1);
fd3 = open (pathname, ...];
} else if (pid == 0) {
read_lock(fd1, 1, SEEK_SET, 1); /* child read locks byte 1 */
}
pause();

图显示了父进程和子进程暂停(执行pause())后的数据结构情况。

前面已经给出了openfork以及dup调用后的数据结构。有了记录锁后,在原来的这些图上新加了lockf结构,它们由i节点结构开始相互链接起来。每个lockf结构描述了一个给定进程的一个加锁区域(由偏移量和长度定义的)。图中显示了两个lockf结构,一个是由父进程调用write_lock形成的,另一个则是由子进程调用read_lock形成的。每一个结构都包含了相应的进程ID。

在父进程中,关闭fd1fd2fd3中的任意一个都将释放由父进程设置的写锁。在关闭这3个描述符中的任意一个时,内核会从该描述符所关联的i节点开始,逐个检查lockf链接表中的各项,并释放由调用进程持有的各把锁。

程序展示了lockfile函数的实现,守护进程可用该函数在文件上加写锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <unistd.h>
#include <fcntl.h>

int
lockfile(int fd)
{
struct flock fl;

fl.l_type = F_WRLCK;
fl.l_start = 0;
fl.l_whence = SEEK_SET;
fl.l_len = 0;
return(fcntl(fd, F_SETLK, &fl));
}

另一种方法是用write_lock函数定义lockfile函数。

1
#define lockfile(fd) write_lock((fd),0, SEEK_SET, 0)

在文件尾端加锁

在对相对于文件尾端的字节范围加锁或解锁时需要特别小心。大多数实现按照l_whenceSEEK_CURSEEK_END值,用l_start以及文件当前位置或当前长度得到绝对文件偏移量。但是,常常需要相对于文件的当前长度指定一把锁,但又不能调用fstat来得到当前文件长度,因为我们在该文件上没有锁。考虑以下代码序列:

1
2
3
4
writew_lock(fd, 0, SEEK_END, 0);
write(fd, buf, 1):
un_lock(fd, 0, SEEK_END);
write(fd, buf, 1);

该代码序列所做的可能并不是你所期望的,它得到一把写锁,该写锁从当前文件尾端起,包括以后可能追加写到该文件的任何数据。假定,该文件偏移量处于文件尾端时,执行第一个write,这个操作将文件延伸了1个字节,而该字节将被加锁。跟随其后的是解锁操作,其作用是对以后追加写到文件上的数据不再加锁。但在其之前刚追加写的一个字节则保留加锁状态。当执行第二个写时,文件尾端又廷伸了1个字节,但该字节并未加锁。

当对文件的一部分加锁时,内核将指定的偏移量变换成绝对文件偏移量。另外,除了指定一个绝对偏移量(SEEK_SET)之外,fcntl还允许我们相对于文件中的某个点指定该偏移量,这个点是指当前偏移量(SEEK_CUR)或文件尾端(SEEK_END)。当前偏移量和文件尾端可能会不断变化,而这种变化又不应影响现有锁的状态,所以内核必须独立于当前文件偏移量或文件尾端而记住锁。如果想解除的锁中包括第一次write所写的1个字节,那么应指定长度为-1。负的长度值表示在指定偏移量之前的字节数。

建议性锁和强制性锁

强制性锁会让内核检查每一个openreadwrite,验证调用进程是否违背了正在访问的文件上的某一把锁。强制性锁有时也称为强迫方式锁(enforcement-mode locking)。

对一个特定文件打开其设置组ID位、关闭其组执行位便开启了对该文件的强制性锁机制。因为当组执行位关闭时,设置组ID位不再有意义。如果一个进程试图读(read)或写(write)一个强制性锁起作用的文件,而欲读、写的部分又由其他进程加上了锁,取决于3方面的因素:操作类型(read或write)、其他进程持有的锁的类型(读锁或写锁)以及read或write的描述符是阻塞还是非阻塞的,下边列出了8种可能性。

通常,即使正在打开的文件具有强制性记录锁,该open也会成功。如果欲打开的文件具有强制性记录锁(读锁或写锁),而且open调用中的标志指定为O_TRUNCO_CREAT,则不论是否指定O_NONBLOCKopen都立即出错返回,errno设置为EAGAIN。

I/O多路转接

当从一个描述符读,然后又写到另一个描述符时,可以在下列形式的循环中使用阻塞I/O:

1
2
3
while ((n=read(STDIN_FILENO, buf, BUFSIZ)) > 0)
if (write(STDOUT_FILENO, buf, n) != n)
err_sys ("write error");

这种形式的阻塞I/O到处可见。但是如果必须从两个描述符读,我们不能在任一个描述符上进行阻塞读(read),否则可能会因为被阻塞在一个描述符的读操作上而导致另一个描述符即使有数据也无法处理。所以为了处理这种情况需要另一种不同的技术。将一个进程变成两个进程(用fork),每个进程处理一条数据通路。图中显示了这种安排。

如果使用两个进程,则可使每个进程都执行阻塞read。如果子进程接收到文件结束符,那么该子进程终止。然后父进程接收到SIGCHLD信号。但是,如果父进程终止,那么父进程应通知子进程停止。为此可以使用一个信号(如SIGUSR1),但这使程序变得更加复杂。

另一个方法是仍旧使用一个进程执行该程序,但使用非阻塞I/O读取数据。其基本思想是:将两个输入描述符都设置为非阻塞的,对第一个描述符发一个read。如果该输入上有数据,则读数据并处理它。如果无数据可读,则该调用立即返回。然后对第二个描述符作同样的处理。在此之后,等待一定的时间(可能是若干秒),然后再尝试从第一个描述符读。这种形式的循环称为轮询。这种方法的不足之处是浪费CPU时间。

还有一种技术称为异步I/O(asynchronous I/O)。进程告诉内核:当描述符准备好可以进行I/O时,用一个信号通知它。这种技术有两个问题。首先,尽管一些系统提供了各自的受限形式的异步I/O,但POSIX采纳了另外一套标准化接口。

这种技术的第二个问题是,这种信号对每个进程而言只有1个(SIGPOLLSIGIO)。如果使该信号对两个描述符都起作用,那么进程在接到此信号时将无法判别是哪一个描述符准备好了,需将这两个描述符都设置为非阻塞的,并顺序尝试执行I/O。

一种比较好的技术是使用I/O多路转接(I/O multiplexing)。为了使用这种技术,先构造一张我们感兴趣的描述符(通常都不止一个)的列表,然后调用一个函数,直到这些描述符中的一个已准备好进行I/O时,该函数才返回。pollpselectselect这3个函数使我们能够执行I/O多路转接。在从这些函数返回时,进程会被告知哪些描述符已准备好可以进行I/O。POSIX指定,为了在程序中使用select,必须包括<sys/select.h>

函数select和pselect

select函数使我们可以执行I/O多路转接。传给select的参数告诉内核:

  • 我们所关心的描述符
  • 对于每个描述符我们所关心的条件
  • 愿意等待多长时间

select返回时,内核告诉我们

  • 已准备好的描述符的总数量:
  • 对于读、写或异常这3个条件中的每一个,哪些描述符已准备好。

使用这种返回信息,就可调用相应的I/O函数(一般是readwrite),并且确知该函数不会阻塞。

1
2
3
#include <sys/select.h>
int select(int maxfdp1, fd_set *restrict readfds, fd_set *restrict writefds, fd_set *restriet exceptfds, struct timeval *restrict tvptr);
// 返回值,准备就绪的描述符数目,若超时,返回0,若出错,返回-1

最后一个参数指定愿意等待的时间长度,单位为秒和微秒。有以下3种情况。

  • tvptr == NULL
    • 永远等待。如果捕捉到一个信号则中断此无限期等待。当所指定的描述符中的一个已准备好或捕捉到一个信号则返回。如果捕捉到一个信号,则select返回-1,errno设置为EINTR。
  • tvptr->tv_sec == 0 && tvptr->tv_usec == 0
    • 根本不等待。测试所有指定的描述符并立即返回。这是轮询系统找到多个描述符状态而不阻塞select函数的方法。
  • tvptr->tv_sec != 0 || tvptr->n_usec != 0
    • 等待指定的秒数和微秒数。当指定的描述符之一已准备好,或当指定的时间值已经超过时立即返回。如果在超时到期时还没有一个描述符准备好,则返回值是0。与第一种情况一样,这种等待可被捕捉到的信号中断。

中间3个参数readfdswritefdsexceptfds是指向描述符集的指针。这3个描述符集说明了可读、可写或处于异常条件的描述符集合。每个描述符集存储在一个fd_set数据类型中。这个数据类型是由实现选择的,它可以为每一个可能的描述符保持一位。我们可以认为它只是一个很大的字节数组。

对于fd_set数据类型,唯一可以进行的处理是:分配一个这种类型的变量,将这种类型的一个变量值赋给同类型的另一个变量,或对这种类型的变量使用下列4个函数中的一个。

1
2
3
4
5
6
#include <sys/select.h>
int FD_ISSET(int fd, fd_set *fdset);
// 返回值:若fd在描述符集中,返回非0值;否则,返回0
void FD_CLR(int fd, fd_set *fdset);
void FD_SET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);

这些接口可实现为宏或函数。调用FD_ZERO将一个fd_set变量的所有位设置为0。要开启描述符集中的一位,可以调用FD_SET。调用FD_CLR可以清除一位。最后,可以调用FD_ISSET测试描述符集中的一个指定位是否已打开。

在声明了一个描述符集之后,必须用FD_ZERO将这个描述符集置为0,然后在其中设置我们关心的各个描述符的位。具体操作如下所示:

1
2
3
4
5
fd_set rset;
int fd;
FD_ZERO(&rset);
FD_SET(fd, &rset);
FD_SET(STDIN_FILENO, &rset);

select返回时,可以用FD_ISSET测试该集中的一个给定位是否仍处于打开状态:

1
if (FD_ISSET(fd, &rset));

select的中间3个参数(指向描述符集的指针)中的任意一个(或全部)可以是空指针,这表示对相应条件并不关心。如果所有3个指针都是NULL,则select提供了比sleep更精确的定时器。

select第一个参数maxfdp1的意思是“最大文件描述符编号值加1”。也可将第一个参数设置为FD_SETSIZE,它指定最大描述符数(经常是1024)。通过指定我们所关注的最大描述符,内核就只需在此范围内寻找打开的位,而不必在了个描述符集中的数百个没有使用的位内搜索。例如,图14-16所示的两个描述符集的情况就好像是执行了下述操作:

1
2
3
4
5
6
7
8
fd_set readset, writeset;
PO_ZERO(&readset);
PD_ZERO(&writeset);
FD_SET(0, &readset);
FD_SET(3, &readset);
FD_SET(1, &writeset);
FD_SET(2, &writeset);
select(4, &readset, &writeset, NULL, NULL);

因为描述符编号从0开始,所以要在最大描述符编号值上加1。第一个参数实际上是要检查的描述符数(从描述符0开始)。

select有3个可能的返回值。

  1. 返回值-1表示出错。这是可能发生的,例如,在所指定的描述符一个都没准备好时捕捉到一个信号。在此种情况下,一个描述符集都不修改
  2. 返回值0表示没有描述符准备好。若指定的描述符一个都没准备好,指定的时间就过了,那么就会发生这种情况。此时,所有描述符集都会置0
  3. 一个正返回值说明了已经准备好的描述符数。该值是3个描述符集中已准备好的描述符数之和,所以如果同一描述符已准备好读和写,那么在返回值中会对其计两次数。在这种情况下,3个描述符集中仍旧打开的位对应于已准备好的描述符。

对于“准备好”的含义要作一些更具体的说明。

  • 若对读集(readfds)中的一个描述符进行的read操作不会阻塞,则认为此描述符是准备好的。
  • 若对写集(writefds)中的一个描述符进行的wrile操作不会阻塞,则认为此描述符是准备好的。
  • 若对异常条件集(exceptfds)中的一个描述符有一个未决异常条件,则认为此播述符是准备好的。
  • 对于读、写和异常条件,普通文件的文件描述符总是返回准备好。

一个描述符阻塞与否并不影响select是否阻塞,也就是说,如果希望读一个非阻塞描述符,并且以超时值为5秒调用select,则select最多阻塞5s。相类似,如果指定一个无限的超时值,则在该描述符数据准备好,或捕捉到一个信号之前,select会一直阻塞。

如果在一个描述符上碰到了文件尾端,则select会认为该描述符是可读的。然后调用read,它返回0;这是UNIX系统指示到达文件尾端的方法。POSIX.1也定义了一个select的变体,称为pselect

1
2
3
4
5
6
#include <ays/seiect.h>
int pselect (int maxfdpl, fd_set *restrict readfds,
fd_set *restrict writefds, fd_set *restrict exceptfds,
const struct timespec *restrict tsptr,
const sigset_t *restrict sigmask);
// 返回值:准备就绪的描述符数目,若超时,返回0。若出错,返回-1

除下列几点外,pselectselect相同。

  • select的超时值用timeval结构指定,但pselect使用timespec结构。timespec结构以秒和纳秒表示超时值,而非秒和微秒。
  • pselect的超时值被声明为const,这保证了调用pselect不会改变此值。
  • pselect可使用可选信号屏蔽字。若sigmask为NULL,那么在与信号有关的方面,pselect的运行状况和select相同。否则,sigmask指向一信号屏蔽字,在调用pselect时,以原子操作的方式安装该信号屏蔽字。在返回时,恢复以前的信号屏蔽字。

函数poll

poll函数类似于select,但是程序员接口有所不同。poll函数可用于任何类型的文件描述符。

1
2
3
#include <poll.h>
int poll (struct pollfd fdarray[], nfds_t nfds, int timeout);
// 返回值:准备就绪的描述行数目,若超时,返回0;若出错,返回-1

select不同,poll不是为每个条件(可读性、可写性和异常条件)构造一个描述符集,而是构造一个pollfd结构的数组,每个数组元素指定一个描述符编号以及我们对该描述符感兴趣的条件。

1
2
3
4
struct pollfd {
int fd; /* file deacriptor to check, or <0 to sgnore */
short events; /* events of interest on fd */
short revents; /* events that occurred on fd */

fdarray数组中的元素数由nfds指定。

应将每个数组元素的events成员设置为表中所示值的一个或几个,通过这些值告诉内核我们关心的是每个描述符的哪些事件。返回时,revents成员由内核设置,用于说明每个描述符发生了哪些事件。(注意,poll没有更改events成员。这与select不同,select修改其参数以指示哪一个描述符已准备好了。)

标志名 说明
POLLIN 可以不阻塞读高优先级数据以外的数据(等效于POLLRDNORM POLLRDBAND)
POLLRDNORM 可以不阻塞地读普通数据
POLLRDBAND 可以不阻塞地读优先级数据
POLLPRI 可以不阻塞地读高优先级数据
POLLOUT 可以不阻塞地写普通数据
POLLMRNORM 与POLLOUT相同
POLLWRBAND 可以不阻塞地写优先级数据
POLLERR 已出错
POLLHUP 已挂断
POLLNVAL 描述符没有引用一个打开文件

当一个描述符被挂断(POLLHUP)后,就不能再写该描述符,但是有可能仍然可以从该描述符读取到数据。

poll的最后一个参数指定的是我们愿意等待多长时间。如同select一样,有3种不同的情形。

  • timeout == -1
    • 永远等待。(某些系统在<stropts.h>中定义了常量INFTIM,其值通常是-1。)当所指定的描述符中的一个已准备好,或捕捉到一个信号时返回。如果捕捉到一个信号,则poll返回-1,errno设置为EINTR。
  • timeout == 0
    • 不等待。测试所有描述符并立即返回。这是一种轮询系统的方法,可以找到多个描述符的状态而不阻塞poll函数。
  • timeout > 0
    • 等待timeout毫秒,当指定的描述符之一已准备好,或timeout到期时立即返回。如果timeout到期时还没有一个描述符准备好,则返回值是0。

select一样,一个描述符是否阻塞不会影响poll是否阻塞。

异步I/O

使用POSIX异步I/O接口,会带来下列麻烦:

  • 每个异步操作有了处可能产生错误的地方:一处在操作提交的部分,一处在操作本身的结果,还有一处在用于决定异步操作状态的函数中。
  • 与POSIX异步IO接口的传统方法相比,它们本身涉及大量的额外设置和处理规则。
    • 事实上,并不能把非异步I/O函数称作“同步”的,因为尽管它们相对于程序流来说是同步的,但相对于I/O来说并非如此。当从write函数的调用返回时,写的数据是持久的,我们称这个写操作为“同步”的。也不能依靠把传统的调用归类为“标准”的I/O调用来区到传统的I/O函数和异步I/O函数,因为这样会使它们和标准I/O库中的函数调用相混淆。
  • 从错误中恢复可能会比较困难。

System V异步I/O

在System V中,异步I/O是STREAMS系统的一部分,它只对STREAMS设备和STREAMS管道起作用。SystemV的异步I/O信号是SIGPOLL

除了调用ioctl指定产生SIGPOLL信号的条件以外,还应为该信号建立信号处理程序。应当在调用ioctl之前建立信号处理程序。

常量 说明
S_INPUT 可以不阻塞地读取数据(非高优先级数据)
S_RDNORM 可以不阻塞地读取普通数据
S_RDBAND 可以不阻塞地读取优先级数据
S_BANDURG 若此常量和S_RDBAND一起指定,当我们可以不阻塞地读取优先数据时,产生SIGURG信号而非SIGPOLL
S_HIPRI 可以不阻塞地读取高优先级数据
S_OUTPUT 可以不阻塞地写普通数据
S_WRNGRM S_OUTPUT相同
S_WRBAND 可以不阻塞地写优先级数据
S_MSG 包含SIGPOLL信号的消息已经到达流头部
S_ERROR 流有错误
S_HANGUP 流已挂起

BSD异步I/O

在BSD派生的系统中,异步I/O是信号SIGIOSIGURG的组合。SIGIO是通用异步I/O信号,SIGURG则只用来通知进程网络连接上的带外数据已经到达。为了接收SIGIO信号,需执行以下3步。

  1. 调用signalsigactionSIGIO信号建立信号处理程序。
  2. 以命令F_SETOWN调用tentl来设置进程ID或进程组ID,用于接收时对于该描述符的信号。
  3. 以命令F_SETPL调用fcnt1设置O_ASYNC文件状态标志,使在该描述符上可以进行异步I/O。

POSIX异步I/O

POSIX异步I/O接口为对不同类型的文件进行异步I/O提供了一套一致的方法。这些异步I/O接口使用AIO控制块来描述I/O操作。aiocb结构定义了AIO控制块。该结构至少包括下面这些字段:

1
2
3
4
5
6
7
8
9
struct alocb {
int aio_fildes; /* file descriptor */
off_t aio_offset; /* file offset for I/O */
volatile void *aio_buf; /* buffer for I/O */
size_t aio_nbytes; /* number of bytes to transfer */
int aio_reqprior /* priority */
struct sigevent aio_sigevent; /* signal information */
int aio_lio_opcode; /* operation for list I/O */
};

aio_fields字段表示被打开用来读或写的文件损述符。读或写操作从aio_offset指定的偏移量开始。对于读操作,数据会复制到缓冲区中,该缓冲区从aio_buf指定的地址开始。对于写操作,数据会从这个缓冲区中复制出来。aio_ nbytes字段包含了要读或写的字节数。注意,异步I/O操作必须显式地指定偏移量。异步I/O接口并不影响由操作系统维护的文件偏移量。如果使用异步I/O接口向一个以追加模式(使用O_APPEND)打开的文件中写入数据,AIO控制块中的aio_offset字段会被系统忽略。

其他字段和传统I/O函数中的不一致。应用程序使用aio_reqprio字段为异步I/O请求提示顺序。然而,系统对于该顺序只有有限的控制能力,因此不一定能遵循该提示。aio_lio_opcode字段只能用于基于列表的异步I/O,aio_sigevent字段控制,在I/O事件完成后,如何通知应用程序。这个字段通过sigevent结构来描述。

1
2
3
4
5
6
7
struct migevent {
int sigev_notitys /* notity type */
int sigev_signos /* aignai number */
union sigval sigev_value; /* notify arqument */
void (*sigev_notify_funetion) (union sigval); /* notify function */
pthread_attr_t *sigev_notify_attributes; /* notity attrs */
};

sigev_notify字段控制通知的类型。取值可能是以下3个中的一个。

  • SIGEV_NONE:异步I/O请求完成后,不通知进程。
  • SIGEV_SIGNAL:异步IO请求完成后,产生由sigev_signo字段指定的信号。如果应用程序已选择捕捉信号,且在建立信号处理程序的时候指定了SA_SIGINFO标志,那么该信号将被入队(如果实现支持捶队信号)。信号处理程序会传送给一个siginfo结构,该结构的si_value字段被设置为sigev_value(如果使用了SA_SIGINFO标志)。
  • SIGEV_THREAD:当异步I/O请求完成时,由sigev_notify_function字段指定的函数被调用。sigev_value字段被传入作为它的唯一参数。除非sigev_notify_attributes字段被设定为pthread属性结构的地址,且该结构指定了一个另外的线程属性,否则该函数将在分离状态下的一个单独的线程中执行。

在进行异步I/O之前需要先初始化AIO控制块,调用aio_read函数来进行异步读操作,或调用aio_write函数来进行异步写操作。

1
2
3
4
#include caio.h>
int aio_read (struct aiocb *aiocb);
int aio_write (struct aiocb *aiocb);
// 两个函数的返回值:若成功,返回0;若出错,返回-1

当这些函数返回成功时,异步I/O请求便已经被操作系统放入等待处理的队列中了。这些返回值与实际I/O操作的结果没有任何关系。I/O操作在等待时,必须注意确保AIO控制块和数据库缓冲区保持稳定;它们下面对应的内存必须始终是合法的,除非I/O操作完成,否则不能被复用。要想强制所有等待中的异步操作不等待而写入持久化的存储中,可以设立一个AIO控制块并调用aio_fsync函数。

1
2
3
#include <aio.h>
int aio_async(int op, struct aiocb *aiocb);
// 返回值,若成功,返回0;若出错,返回-1

AIO控制块中的aio_fildes字段指定了其异步写操作被同步的文件。如果op参数设定为O_DSYNC,那么操作执行起来就会像调用了fdatasync一样。否则,如果op参数设定为O_SYNC,那么操作执行起来就会像调用了fsync一样。

aio_readaio_write函数一样,在安排了同步时,aio_fsync操作返回。在异步同步操作完成之前,数据不会被持久化。AIO控制块控制我们如何被通知,就像aio_readaio_write函数一样,为了获知一个异步读、写或者同步操作的完成状态,需要调用aio_error函数:

1
2
#include <aio.h>
int aio_error (const struct aiocb *aiocb);

返回值为下面4种情况中的一种

  • 0:异步操作成功完成。需要调用aio_return函数获取操作返回值。
  • -1:对aio_error的调用失败。这种情况下,errno会告诉我们为什么。
  • EINPROGRESS:异步读、写或同步操作仍在等待。
  • 其他情况:其他任何返回值是相关的异步操作失败返回的错误码。

如果异步操作成功,可以调用aio_return函数来获取异步操作的返回值。

1
2
#include <aio.h>
size_t aio_return (const struct aiocb *aiocb);

直到异步操作完成之前,都需要小心不要调用aio_return函数。操作完成之前的结果是来定义的。还需要小心对每个异步操作只调用一次aio_return。一旦调用了该函数,操作系统就可以释放掉包含了I/O操作返回值的记录。如果aio_return函数本身失败,会返回-1,并设置errno。其他情况下,它将返回异步操作的结果,即会返回readwrite或者fsync在被成功调用时可能返回的结果。

执行I/O操作时,如果还有其他事务要处理而不想被I/O操作阻塞,就可以使用异步I/O。然如果在完成了所有事务时,还有异步操作未完成时,可以调用aio_suspend函数来阻塞进直到操作完成。

1
2
3
#include <aio.h>
int aio_suspend (const struct aiocb *const list[], int nent, const struct timespec *timeout);
// 返回值:若成功,返回0;若出错,返回-1

aio_suspend可能会返回三种情况中的一种。

  • 如果我们被一个信号中断,它将会返回-1,并将errno设置为EINTR。
  • 如果在没有任何I/O操作完成的情况下,阻塞的时间超过了函数中可选的timeout参数所指定的时间限制,那么aio_suspend将返回-1,并将errno设置为EAGAIN。
  • 如果有任何I/O操作完成,aio_suspend将返回0。
  • 如果在我们调用aio_suspend操作时,所有的异步I/O操作都已完成,那么aio_suspend将在不阻塞的情况下直接返回。

list参数是一个指向AIO控制块数组的指针,nent参数表明了数组中的条目数。数组中的空指针会被跳过,其他条目都必须指向已用于初始化异步I/O操作的AIO控制块。

当还有我们不想再完成的等待中的异步I/O操作时,可以尝试使用aio_cancel函数来取消它们。

1
2
#include <aio.h>
int aio_cancel(int fd, struct aiocb *aiocb);

fd参数指定了那个未完成的异步I/O操作的文件描述符。如果aiocb参数为NULL,系统将会尝试取消所有该文件上未完成的异步I/O操作。其他情况下,系统将尝试取消由AIO控制块描述的单个异步I/O操作。我们之所以说系统“尝试”取消操作,是因为无法保证系统能够取消正在进程中的任何操作。

aio_cancel函数可能会返回以下4个值中的一个。

  • AIO_ALLDONE:所有操作在尝试取消它们之前已经完成。
  • AIO_CANCELED:所有要求的操作已被取消
  • AIO_NOTCANCELED:至少有一个要求的操作没有被取消.
  • -1:对aio_cancel的调用失败,错误码将被存储在errno中。

如果异步I/O操作被成功取消,对相应的AIO控制块调用aio_error函数将会返回错误ECANCELED。如果操作不能被取消,那么相应的AIO控制块不会因为对aio_cancel的调用而被修改。

还有一个函数也被包含在异步I/O接口当中,尽管它既能以同步的方式来使用,又能以异步的方式来使用,这个函数就是lio_listio。该函数提交一系列由一个AIO控制块列表描述的IO请求。

1
2
3
4
#include <aio.h>
int lio_lintio(int mode, struct aiocb *restrict const list[restrict],
int nent, struct sigevent *restrict sigev);
// 返回值,若成功,返回0;若出错,返回-1

mode参数决定了I/O是否真的是异步的。如果该参数被设定为LIO_WAITlio_listio函数将在所有由列表指定的I/O操作完成后返回。在这种情况下,sigev参数将被忽略。如果mode参数被设定为LIO_NOWAITlio_listio函数将在I/O请求入队后立即返回。进程将在所有I/O操作完成后,按照sigev参数指定的,被异步地通知。如果不想被通知,可以把sigev设定为NULL。注意,每个AIO控制块本身也可能肩用了在各自操作完成时的异步通知。被sigev参数指定的异步通知是在此之外另加的,并且只会在所有的I/O操作完成后发送。

list参数指向AIO控制块列表,该列表指定了要运行的I/O操作的。nent参数指定了数组中的元素个数。AIO控制块列表可以包含NULL指针,这些条目将被忽略。

在每一个AIO控制块中,alo_lio_opcode字段指定了该操作是一个读操作(LIO_READ)、写操作(LIO_WRITE),还是将被忽略的空操作(LIO_NOP)。 读操作会按照对应的AIO控制块被传给了aio_read函数来处理。类似地,写操作会按照对应的AIO控制块被传给了aio_write函数来处理。

实现会限制我们不想完成的异步I/O操作的数量。这些限制都是运行时不变量。

可以通过调用sysconf函数并把name参数设置为_SC_IO_LISTIO_MAX来设定AIO_LISTIO_MAX的值。类似地,可以通过调用sysconf并把name参数设置为_SC_AIO_MAX来设定AIO_MAX的值,通过调用sysconf并把其参数设置为_SC_AIO_PRIO_DELTA_MAX来设定AIO_PRIO_DELTA_MAX的值。

名称 描述 可接受的最小值
AIO_LISTIO_MAX 单个列表I/O调用中的最大I/O操作数 _POSIX_AIO_LISTIO_MAX(2)
AIO_MAX 未完成的异步I/O操作的最大数目 _POSIX_AIO_MAX(1)
AIO_PRIO_DELTA_MAX 进程可以减少的其异步I/O优先级的最大值 0

引入POSIX异步操作IO接口的初衷是为实时应用提供一种方法,避免在执行I/O操作时阻塞进程。

从输入文件中读取一个块,翻译之,然后再把这个块写到输出文件中。重复该步骤直到遇到文件尾端,read返回0。程序展示了如何使用异步I/O函数完成。

"apue.h"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#include <ctype.h>
#include <fcntl.h>
#include <aio.h>
#include <errno.h>

#define BSZ 4096
#define NBUF 8

enum rwop {
UNUSED = 0,
READ_PENDING = 1,
WRITE_PENDING = 2
};

struct buf {
enum rwop op;
int last;
struct aiocb aiocb;
unsigned char data[BSZ];
};

struct buf bufs[NBUF];

unsigned char
translate(unsigned char c)
{
/* same as before */
}

int
main(int argc, char* argv[])
{
int ifd, ofd, i, j, n, err, numop;
struct stat sbuf;
const struct aiocb *aiolist[NBUF];
off_t off = 0;

if (argc != 3)
err_quit("usage: rot13 infile outfile");
if ((ifd = open(argv[1], O_RDONLY)) < 0)
err_sys("can't open %s", argv[1]);
if ((ofd = open(argv[2], O_RDWR|O_CREAT|O_TRUNC, FILE_MODE)) < 0)
err_sys("can't create %s", argv[2]);
if (fstat(ifd, &sbuf) < 0)
err_sys("fstat failed");

/* initialize the buffers */
for (i = 0; i < NBUF; i++) {
bufs[i].op = UNUSED;
bufs[i].aiocb.aio_buf = bufs[i].data;
bufs[i].aiocb.aio_sigevent.sigev_notify = SIGEV_NONE;
aiolist[i] = NULL;
}

numop = 0;
for (;;) {
for (i = 0; i < NBUF; i++) {
switch (bufs[i].op) {
case UNUSED:
/*
* Read from the input file if more data
* remains unread.
*/
if (off < sbuf.st_size) {
bufs[i].op = READ_PENDING;
bufs[i].aiocb.aio_fildes = ifd;
bufs[i].aiocb.aio_offset = off;
off += BSZ;
if (off >= sbuf.st_size)
bufs[i].last = 1;
bufs[i].aiocb.aio_nbytes = BSZ;
if (aio_read(&bufs[i].aiocb) < 0)
err_sys("aio_read failed");
aiolist[i] = &bufs[i].aiocb;
numop++;
}
break;

case READ_PENDING:
if ((err = aio_error(&bufs[i].aiocb)) == EINPROGRESS)
continue;
if (err != 0) {
if (err == -1)
err_sys("aio_error failed");
else
err_exit(err, "read failed");
}

/*
* A read is complete; translate the buffer
* and write it.
*/
if ((n = aio_return(&bufs[i].aiocb)) < 0)
err_sys("aio_return failed");
if (n != BSZ && !bufs[i].last)
err_quit("short read (%d/%d)", n, BSZ);
for (j = 0; j < n; j++)
bufs[i].data[j] = translate(bufs[i].data[j]);
bufs[i].op = WRITE_PENDING;
bufs[i].aiocb.aio_fildes = ofd;
bufs[i].aiocb.aio_nbytes = n;
if (aio_write(&bufs[i].aiocb) < 0)
err_sys("aio_write failed");
/* retain our spot in aiolist */
break;

case WRITE_PENDING:
if ((err = aio_error(&bufs[i].aiocb)) == EINPROGRESS)
continue;
if (err != 0) {
if (err == -1)
err_sys("aio_error failed");
else
err_exit(err, "write failed");
}

/*
* A write is complete; mark the buffer as unused.
*/
if ((n = aio_return(&bufs[i].aiocb)) < 0)
err_sys("aio_return failed");
if (n != bufs[i].aiocb.aio_nbytes)
err_quit("short write (%d/%d)", n, BSZ);
aiolist[i] = NULL;
bufs[i].op = UNUSED;
numop--;
break;
}
}
if (numop == 0) {
if (off >= sbuf.st_size)
break;
} else {
if (aio_suspend(aiolist, NBUF, NULL) < 0)
err_sys("aio_suspend failed");
}
}

bufs[0].aiocb.aio_fildes = ofd;
if (aio_fsync(O_SYNC, &bufs[0].aiocb) < 0)
err_sys("aio_fsync failed");
exit(0);
}

注意,我们使用了8个缓冲区,因此可以有最多8个异步I/O请求处于等待状态。在检查操作的返回值之前,必须确认操作已经完成。当aio_error返回的值既非EINPROGRESS亦非-1时,表明操作完成。除了这些值之外,如果返回值是0以外的任何值,说明操作失败了。一旦检查过这些情况,便可以安全地调用aio_return来获取I/O操作的返回值了。

只要还有事情要做,就可以提交异步I/O操作。当存在未使用的AIO控制块时,可以提交一个异步读操作。读操作完成后,翻译缓冲区中的内容并将它提交给一个异步写请求。当所有AIO控制块都在使用中时,通过调用aio_suspend等待操作完成。

函数readv和writev

readvwritev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。

1
2
3
4
#include <ays/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovent);
ssize_t writev(int fd, const struct iovec *iov, int iovent);
// 两个函数的返回值,已读或已写的字节数;若出错,返回-1

这两个函数的第二个参数是指向iovec结构数组的一个指针:

1
2
3
struct iovec {
void *iov_bases /* starting address of buffer */
size_t iov_len; /* size of buffer */

iov数组中的元素数由iovent指定,其最大值受限于IOV_MAX。图中显示了这两个函数的参数和iovec结构之间的关系。

writev函数从缓冲区中来集输出数据的顺序是:iov[0]iov[1]直至iov[iovent-1]writev返回输出的字节总数,通常应等于所有缓冲区长度之和。

readv函数则将读入的数据按上述同样顺序散布到缓冲区中。readv总是先填满一个缓冲区,然后再填写下一个。readv返回读到的字节总数。如果遇到文件尾端,己无数据可读,则返回0。

函数readn和writen

管道、FIFO以及某些设备(特别是终端和网络)有下列两种性质。

  1. 一次read操作所返回的数据可能少于所要求的数据,即使还没达到文件尾端也可能是这样。这不是一个错误,应当继续读该设备。
  2. 一次write操作的返回值也可能少于指定输出的字节数。这可能是由某个因素造成的。

在读、写磁盘文件时从未见到过这种情况,除非文件系统用完了空间,或者接近了配额限制,不能将要求写的数据全部写出。

通常,在读、写一个管道、网络设备或终端时,需要考虑这些特性。下面两个函数readnwriten的功能分别是读、写指定的N字节数据,并处理返回值可能小于要求值的情况。这两个函数只是按需多次调用readwrite直至读、写了N字节数据。

1
2
3
4
#include "apue.h"
ssize_t readn(int fd, void *buf, size_t nbytes);
ssize_t writen(int fd, void *buf, size_t nbyees);
// 两个函数的返回值:读、写的字节数,若出错,返回-1

在要将数据写到上面提到的文件类型上时,就可调用writen,但是仅当事先就知道要接收数据的数量时,才调用readn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "apue.h"

ssize_t /* Read "n" bytes from a descriptor */
readn(int fd, void *ptr, size_t n)
{
size_t nleft;
ssize_t nread;

nleft = n;
while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) {
if (nleft == n)
return(-1); /* error, return -1 */
else
break; /* error, return amount read so far */
} else if (nread == 0) {
break; /* EOF */
}
nleft -= nread;
ptr += nread;
}
return(n - nleft); /* return >= 0 */
}

注意,若在已经读、写了一些数据之后出错,则这两个函数返回的是已传输的数据量,而非错误。与此类似,在读时,如达到文件尾端,而且在此之前已成功地读了一些数据,但尚未满足所要求的量,则readn返回已复制到调用者缓冲区中的字节数。

存储映射I/O

存储映射I/O(memory-mapped I/O)能将一个磁盘文件映射到存储空间中的一个缓冲区上,于是。当从缓冲区中取数据时,就相当于读文件中的相应字节。与此类似,将数据存入缓冲区时,相应字节就自动写入文件。这样,就可以在不使用readwrite的情况下执行I/O。

为了使用这种功能,应首先告诉内核将一个给定的文件映射到一个存储区域中。这是由map函数实现的。

1
2
3
#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int fd, off_t off);
// 返回值:若成功,返回映射区的起始地址;若出错,返回MAP.PAILED

  • addr参数用于指定映射存储区的起始地址。通常将其设置为0。这表示由系统选择该映射区的起始地址。此函数的返回值是该映射区的起始地址。
  • fd参数是指定要被映射文件的描述符。在文件映射到地址空间之前,必须先打开该文件。len参数是映射的字节数,off是要映射字节在文件中的起始偏移量。
  • prot参数指定了映射存储区的保护要求,如下所示。
prot 说明
PROT_READ 映射区可读
PROT_WRITE 映射区可写
PROT_EXEC 映射区可执行
PROT_NONE 映射区不可访问

可将prot参数指定任意组合的按位或。对指定映射存储区的保护要求不能超过文件open模式访问权限。

图中显示了一个存储映射文件。在此图中,“起始地址”是mmap的返回值。映射存储区位于堆和栈之间。

下面是flag参数影响映射存储区的多种属性

  • MAP_FIXED:返回值必须等于addr,因为这不利于可移植性,所以不鼓励使用此标志。如果未指定此标志,而且addr非0,则内核只把addr视为在何处设置映射区的一种建议,但是不保证会使用所要求的地址。将addr指定为0可获得最大可移植性。
  • MAR_SHARED:这一标志描述了本进程对映射区所进行的存储操作的配置。此标志指定存储操作修改映射文件。
  • MAP_PRIVATE:本标志说明,对映射区的存储操作导致创建该映射文件的一个私有副本。所有后来对该映射区的引用都是引用该副本。

off的值和addr的值(如果指定了MAP_FIXED)通常被要求是系统虚拟存储页长度的倍数。虚拟存储页长可用带参数_SC_PAGESIZESC_PAGE_SIZEsysconf函数得到。因为offaddr常常指定为0,所以这种要求一般并不重要。

既然映射文件的起始偏移量受系统虚拟存储页长度的限制,那么如果映射区的长度不是页长的整数倍时,文件长为12字节, 系统页长为512字节,则系统通常提供512字节的映射区,其中后500字节被设置为0。可以修改后面的这500字节,但任何变动都不会在文件中反映出来。于是,不能用mmap将数据添加到文件中。

与映射区相关的信号有SIGSEGVSIGBUS。信号SIGSEGV通常用于指示进程试图访问对它不可用的存储区。如果映射存储区被mmap指定成了只读的,那么进程试图将数据存入这个映射存储区的时候,也会产生此信号。如果映射区的某个部分在访问时已不存在,则产生SIGBUS信号。例如,假设用文件长度映射了一个文件,但在引用该映射区之前,另一个进程已将该文件截断。此时,如果进程试图访问对应于该文件已截去部分的映射区,将会接收到SIGBUS信号。

子进程能通过fork继承存储映射区(因为子进程复制父进程地址空间,而存储映射区是该地址空间中的一部分),但是由于同样的原因,新程序则不能通过exec继承存储映射区。

调用mprotect可以更改一个现有映射的权限。

1
2
3
#include <sys/mman.h>
int mprotect (void *addr, size_t len, int prot);
// 返回值,若成功,返回0;若出错,返回-1

prot的合法值与mmapprot参数的一样。请注意,地址参数addr的值必须是系统页长的整数倍。

如果修改的页是通过MAP_SHARED标志映射到地址空间的,那么修改并不会立即写回到文件中。相反,何时写回脏页由内核的守护进程决定,决定的依据是系统负载和用来限制在系统失败事件中的数据损失的配置参数。因此,如果只修改了一页中的一个字节,当修改被写回到文件中时,整个页都会被写回。

如果共享映射中的页已修改,那么可以调用msync将该页冲洗到被映射的文件中。msync函数类似于fsync,但作用于存储映射区。

1
2
3
#include <sys/man.h>
int msync(void *addr, size_t len, int flags);
// 返回值:若成功,返回0;若出错,返回-1

如果映射是私有的,那么不修改被映射的文件。与其他存储映射函数一样,地址必须与页边界对齐。

flags参数使我们对如何冲洗存储区有某种程度的控制。可以指定MS_ASYNC标志来简单地调试要写的页。如果希望在返回之前等待写操作完成,则可指定MS_SYNC标志。一定要指定MS_ASYNCMS_SYNC中的一个。

MS_INVALIDATE是一个可选标志, 允许我们通知操作系统丢弃那些与底层存储器没有同步的页。若使用了此标志,某些实现将丢弃指定范围中的所有页,但这种行为并不是必需的。

当进程终止时,会自动解除存储映射区的映射,或者直接调用munmap函数也可以解除映射。关闭映射存储区时使用的文件描述符并不解除映射区。

1
2
3
#include <sys/mman.h>
int munmap(void *addr, size_t len);
// 返回值:若成功,返回0;若出错,返回-1

munmap并不影响被映射的对象,也就是说,调用munmap并不会使映射区的内容写到磁盘文件上。对于MAR_SHARED区磁盘文件的更新, 会在我们将数据写到存储映射区后的某个时刻,按内核虚拟存储算法自动进行。在存储区解除映射后,对MAP_PRIVATE存储区的修改会被丢弃。

程序用存储映射I/O复制文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "apue.h"
#include <fcntl.h>
#include <sys/mman.h>

#define COPYINCR (1024*1024*1024) /* 1 GB */

int
main(int argc, char *argv[])
{
int fdin, fdout;
void *src, *dst;
size_t copysz;
struct stat sbuf;
off_t fsz = 0;

if (argc != 3)
err_quit("usage: %s <fromfile> <tofile>", argv[0]);

if ((fdin = open(argv[1], O_RDONLY)) < 0)
err_sys("can't open %s for reading", argv[1]);

if ((fdout = open(argv[2], O_RDWR | O_CREAT | O_TRUNC,
FILE_MODE)) < 0)
err_sys("can't creat %s for writing", argv[2]);

if (fstat(fdin, &sbuf) < 0) /* need size of input file */
err_sys("fstat error");

if (ftruncate(fdout, sbuf.st_size) < 0) /* set output file size */
err_sys("ftruncate error");

while (fsz < sbuf.st_size) {
if ((sbuf.st_size - fsz) > COPYINCR)
copysz = COPYINCR;
else
copysz = sbuf.st_size - fsz;

if ((src = mmap(0, copysz, PROT_READ, MAP_SHARED,
fdin, fsz)) == MAP_FAILED)
err_sys("mmap error for input");
if ((dst = mmap(0, copysz, PROT_READ | PROT_WRITE,
MAP_SHARED, fdout, fsz)) == MAP_FAILED)
err_sys("mmap error for output");

memcpy(dst, src, copysz); /* does the file copy */
munmap(src, copysz);
munmap(dst, copysz);
fsz += copysz;
}
exit(0);
}

该程序首先打开两个文件,然后调用fstat得到输入文件的长度。在为输入文件调用map和设置输出文件长度时都需使用输入文件长度。可以调用ftruncate设置输出文件的长度。如果不设置输出文件的长度,则对输出文件调用mmap也可以,但是对相关存储区的第一次引用会产生SIGBUS信号。

然后对每个文件调用mmap,将文件映射到内存,最后调用memcpy将输入缓冲区的内容复制到输出缓冲区。为了限制使用内存的量,我们每次最多复制1GB的数据(如果系统没有足够的内存,可能无法把一个很大的文件中的所有内容都映射到内存中)。在映射文件中的后一部分数据之前,我们需要解除前一部分数据的映射。

在从输入缓冲区(src)取数据字节时,内核自动读输入文件:在将数据存入输出缓冲区(dst)时,内核自动将数据写到输出文件中。

数据被写到文件的确切时间依赖于系统的页管理算法。某些系统设置了守护进程,在系统运行期间,它慢条斯理地将改写过的页写到磁盘上。如果想要确保数据安全地写到文件中,则需在进程终止前以MS_SYNC标志调用msync

使用mmapmemcpy复制,与使用readwrite相比,花费了更多的用户时间,但却减少了系统时间。在Linux中,用readwrite消耗的系统时间要比使用mmapmemcpy略好一些。这两种版本的方法是殊途同归的。

二者的主要区别在于,与mmapmemcpy相比,readwrite执行了更多的系统调用,并做了更多的复制。readwrite将数据从内核缓冲区中复制到应用缓冲区(read),然后再把数据从应用缓冲区复制到内核缓冲区(write)。而mmapmemcpy则直接把数据从映射到地址空间的一个内核缓冲区复制到另一个内核缓冲区。当引用尚不存在的内存页时,这样的复制过程就会作为处理页错误的结果面出现(每次错页读发生一次错误, 每次错页写发生一次错误)。如果系统调用和额外的复制操作的开销和页错误的开销不同,那么这两种方法中就会有一种比另一种表现更好。

进程间通信

引言

进程间通信(InterProcess Communication, IPC)是各种进程通信方式的统称。

  • 半双工管道
  • FIFO
  • 全双工管道
  • 命名全双工管道
  • XSI消息队列
  • XSI信号量
  • XSI共享存储
  • 消息队列(实时)
  • 信号量
  • 共享存储(实时)
  • 套接字
  • STREAMS

管道

管道是UNIX系统IPC的最古老形式,所有UNIX系统都提供此种通信机制。管道有以下两种局限性:

  1. 历史上,它们是半双工的(即数据只能在一个方向上流动)。现在,某些系统提供全双工管道。
  2. 管道只能在具有公共祖先的两个进程之间使用。通常,一个管道由一个进程创建,在进程调用fork之后,这个管道就能在父进程和子进程之间使用了。

半双工管道仍是最常用的IPC形式。每当在管道中键入一个命令序列,让shell执行时,shell都会为每一条命令单独创建一个进程,然后用管道将前一条命令进程的标
准输出与后一条命令的标准输入相连接。

管道是通过调用pipe函数创建的。

1
2
3
#include <unistd.h>
int pipe(int fd[2]);
// 返回值,若成功,返回0;若出错,返回-1

经由参数fd返回两个文件描述符:fd[0]为读而打开,f[1]为写而打开。fd[1]的输出是fd[0]的输入。

fstat函数对管道的每一端都返回一个FIFO类型的文件描述符。可以用S_ISFIFO宏来测试管道。POSIX.1规定stat结构的st_size成员对于管道是未定义的。但是当fstat函数应用于管道读端的文件描述符时,很多系统在st_size中存储管道中可用于读的字节数。但是,这是不可移植的。

单个进程中的管道几乎没有任何用处。通常,进程会先调用pipe,接着调用fork,从而创建从父进程到子进程的IPC通道,反之亦然。

fork之后做什么取决于我们想要的数据流的方向。对于从父进程到子进程的管道,父进程关闭管道的读端(fd[0]),子进程关闭写端(fd[1])。

对于一个从子进程到父进程的管道,父进程关闭fd[1],子进程关闭fd[0]

当管道的一端被关闭后,下列两条规则起作用。

  1. 当读(read)一个写已被关闭的管道时,在所有数据都被读取后,read返回0;表示文件结束。
  2. 如果写(write)一个读端已被关闭的管道,则产生信号SIGPIPE。如果忽略该信号或者捕捉该信号并从其处理程序返回,则write返回-1,errno设置为EPIPE.

在写管道(或FIFO)时,常量PIPE_BUF规定了内核的管道缓冲区大小。如果对管道调用write,而且要求写的字节数小于等于PIPE_BUF,则此操作不会与其他进程对同一管道(或FIFO)的write操作交叉进行。但是,若有多个进程同时写一个管道(或FIFO),而且我们要求写的字节数超过PIPE_BUF,那么我们所写的数据可能会与其他进程所写的数据相互交叉。用pathconffpathconf函数可以确定PIPE_BUF的值。

程序创建了一个从父进程到子进程的管道,并且父进程经由该管道向子进程传送数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "apue.h"

int
main(void)
{
int n;
int fd[2];
pid_t pid;
char line[MAXLINE];

if (pipe(fd) < 0)
err_sys("pipe error");
if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid > 0) { /* parent */
close(fd[0]);
write(fd[1], "hello world\n", 12);
} else { /* child */
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
}
exit(0);
}

在上面的例子中,直接对管道描述符调用了readwrite。更有趣的是将管道描述符复制到了标准输入或标准输出上。通常,子进程会在此之后执行另一个程序,该程序或者从标准输入(已创建的管道)读数据,或者将数据写至其标准输出(该管道)。

我们希望通过管道将输出直接送到分页程序。为此,先创建一个管道,fork一个子进程,使子进程的标准输入成为管道的读端,然后调用exec,执行用的分页程序。程序显示了如何实现这些操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include "apue.h"
#include <sys/wait.h>

#define DEF_PAGER "/bin/more" /* default pager program */

int
main(int argc, char *argv[])
{
int n;
int fd[2];
pid_t pid;
char *pager, *argv0;
char line[MAXLINE];
FILE *fp;

if (argc != 2)
err_quit("usage: a.out <pathname>");

if ((fp = fopen(argv[1], "r")) == NULL)
err_sys("can't open %s", argv[1]);
if (pipe(fd) < 0)
err_sys("pipe error");

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid > 0) { /* parent */
close(fd[0]); /* close read end */

/* parent copies argv[1] to pipe */
while (fgets(line, MAXLINE, fp) != NULL) {
n = strlen(line);
if (write(fd[1], line, n) != n)
err_sys("write error to pipe");
}
if (ferror(fp))
err_sys("fgets error");

close(fd[1]); /* close write end of pipe for reader */

if (waitpid(pid, NULL, 0) < 0)
err_sys("waitpid error");
exit(0);
} else { /* child */
close(fd[1]); /* close write end */
if (fd[0] != STDIN_FILENO) {
if (dup2(fd[0], STDIN_FILENO) != STDIN_FILENO)
err_sys("dup2 error to stdin");
close(fd[0]); /* don't need this after dup2 */
}

/* get arguments for execl() */
if ((pager = getenv("PAGER")) == NULL)
pager = DEF_PAGER;
if ((argv0 = strrchr(pager, '/')) != NULL)
argv0++; /* step past rightmost slash */
else
argv0 = pager; /* no slash in pager */

if (execl(pager, argv0, (char *)0) < 0)
err_sys("execl error for %s", pager);
}
exit(0);
}

在调用fork之前,先创建一个管道。调用fork之后,父进程关闭其读端,子进程关闭其写端。然后子进程调用dup2,使其标准输。入成为管道的读端。当执行分页程序时,其标准输入将是管道的读端。

将一个描述符复制到另一个上(在子进程中,fd[0]复制到标准输入),在复制之前应当比较该描述符的值是否已经具有所希望的值。如果该描述符已经具有所希望的值,并且调用了dup2close,那么该描述符的副本将关闭。

在本程序中,如果shell没有打开标准输入,那么程序开始处的fopen应已使用描述符0。也就是最小未使用的描述符,所以fd[0]决不会等于标准输入。尽管如此,无论何时调用dup2close将一个描述符复制到另一个上,作为一种保护性的编程措施,都要先将两个描述符进行比较。

函数popen和pclose

常见的操作是创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据,为此,标准I/O库提供了两个函数popenpclose。这两个函数实现的操作是:创建一个管道,fork一个子进程,关闭未使用的管道端,执行一个shell运行命令,然后等待命令终止。

1
2
3
4
5
#include<stdio.h>
FILE *popen (const char *cmdstring, const char *type);
// 返回值,若成功,返回文件指针;若出错,返回NULL
int pclose(FILE *fp);
// 返回值:若成功,返回cmdetring的终止状态;若出错,返回-1

函数popen先执行fork,然后调用exec执行cmdstring,并且返回一个标准I/O文件指针。如果type是”r”,则文件指针连接到cmdstring的标准输出。如果ype是”w”,则文件指针连接到cmdstring的标准输入。

有一种方法可以帮助我们记住popen的最后一个参数及其作用,这就是与fopen进行类比。如果type是”r”,则返回的文件指针是可读的,如果type是”w”,则是可写的。

pclose函数关闭标准I/O流,等待命令终止,然后返回shell的终止状态。如果shell不能被执行,则pclose返回的终止状态与shell已执行exit(127)一样,cmdstring由Bourne shell以下列方式执行:

1
sh -c cmdstring

这表示shell将扩展cmdstring中的任何特殊字符。例如,可以使用:

1
fp = popen("ls *.c", "r");

或者

1
fp = popen("cmd 2>&1", "r");

程序是我们编写的popenpclose

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
#include "apue.h"
#include <errno.h>
#include <fcntl.h>
#include <sys/wait.h>

/*
* Pointer to array allocated at run-time.
*/
static pid_t *childpid = NULL;

/*
* From our open_max(), {Prog openmax}.
*/
static int maxfd;

FILE *
popen(const char *cmdstring, const char *type)
{
int i;
int pfd[2];
pid_t pid;
FILE *fp;

/* only allow "r" or "w" */
if ((type[0] != 'r' && type[0] != 'w') || type[1] != 0) {
errno = EINVAL;
return(NULL);
}

if (childpid == NULL) { /* first time through */
/* allocate zeroed out array for child pids */
maxfd = open_max();
if ((childpid = calloc(maxfd, sizeof(pid_t))) == NULL)
return(NULL);
}

if (pipe(pfd) < 0)
return(NULL); /* errno set by pipe() */
if (pfd[0] >= maxfd || pfd[1] >= maxfd) {
close(pfd[0]);
close(pfd[1]);
errno = EMFILE;
return(NULL);
}

if ((pid = fork()) < 0) {
return(NULL); /* errno set by fork() */
} else if (pid == 0) { /* child */
if (*type == 'r') {
close(pfd[0]);
if (pfd[1] != STDOUT_FILENO) {
dup2(pfd[1], STDOUT_FILENO);
close(pfd[1]);
}
} else {
close(pfd[1]);
if (pfd[0] != STDIN_FILENO) {
dup2(pfd[0], STDIN_FILENO);
close(pfd[0]);
}
}

/* close all descriptors in childpid[] */
for (i = 0; i < maxfd; i++)
if (childpid[i] > 0)
close(i);

execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
_exit(127);
}

/* parent continues... */
if (*type == 'r') {
close(pfd[1]);
if ((fp = fdopen(pfd[0], type)) == NULL)
return(NULL);
} else {
close(pfd[0]);
if ((fp = fdopen(pfd[1], type)) == NULL)
return(NULL);
}

childpid[fileno(fp)] = pid; /* remember child pid for this fd */
return(fp);
}

int
pclose(FILE *fp)
{
int fd, stat;
pid_t pid;

if (childpid == NULL) {
errno = EINVAL;
return(-1); /* popen() has never been called */
}

fd = fileno(fp);
if (fd >= maxfd) {
errno = EINVAL;
return(-1); /* invalid file descriptor */
}
if ((pid = childpid[fd]) == 0) {
errno = EINVAL;
return(-1); /* fp wasn't opened by popen() */
}

childpid[fd] = 0;
if (fclose(fp) == EOF)
return(-1);

while (waitpid(pid, &stat, 0) < 0)
if (errno != EINTR)
return(-1); /* error other than EINTR from waitpid() */

return(stat); /* return child's termination status */
}

首先,每次调用popen时,应当记住所创建的子进程的进程ID, 以及其文件描述符或FILE指针。我们选择在数组childpid中保存子进程ID,并用文件描述符作为其下标。于是,当以FILE指针作为参数调用pclose时,调用标准I/O函数fileno得到文件描述符,然后取得子进程ID,并用其作为参数调用waitpid。因为一个进程可能调用popen多次,所以在动态分配childpid数组时(第一次调用popen时),其数组长度应当是最大文件描述符数,于是该数组中可以存放与最大文件描述符数相同的子进程ID数。

POSIX.1要求popen关闭那些以前调用popen打开的、现在仍然在子进程中打开着的I/O流。为此,在子进程中从头逐个检查childpid数组的各个元素,关闭仍旧打开着的描述符。若pclose的调用者已经为信号SIGCHLD设置了一个信号处理程序,则pclose中的waitpid调用将返回一个错误EINTR。因为允许调用者捕捉此信号,所以当waitpid被一个捕捉到的信号中断时,我们只是再次调用waitpid

注意,如果应用程序调用waitpid,并且获得了popen创建的子进程的退出状态,那么我们会在应用程序调用pclose时调用waitpid,如果发现子进程已不再存在,将返回-1,将errno设置为ECHILD。

注意,popen决不应由设置用户ID或设置组ID程序调用。当它执行命令时,popen等同于:

1
execl("/bin/sh", "sh", "-c" command, NULL);

它在从调用者继承的环境中执行shell,并由shell解释执行command。一个恶意用户可以操控这种环境,使得shell能以设置ID文件模式所授予的提升了的权限以及非预期的方式执行命令。

popen特别适用于执行简单的过滤器程序,它变换运行命令的输入成输出。当命令希望构造它自己的管道时,就是这种情形。

协同进程

UNIX系统过滤程序从标准输入读取数据,向标准输出写数据。几个过滤程序通常在shell管道中线性连接。当一个过滤程序既产生某个过滤程序的输入,又读取该过滤程序的输出时,它就变成了协同进程(coprocess)。

协同进程通常在shell的后台运行,其标准输入和标准输出通过管道连接到另一个程序。popen只提供连接到另一个进程的标准输入或标准输出的一个单向管道,而协同进程则有连接到另一个进程的两个单向管道:一个接到其标准输入,另一个则来自其标准输出。我们想将数据写到其标准输入,经其处理后,再从其标准输出读取数据。

FIFO

FIFO有时被称为命名管道。未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还要有一个共同的创建了它们的祖先进程。但是,通过FIFO,不相关的进程也能交换数据。

通过stat结构的st_mode成员的编码可以知道文件是否是FIFO类型。可以用S_ISFIFO宏对此进行测试。创建FIFO类似于创建文件。确实,FIFO的路径名存在于文件系统中。

1
2
3
4
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode);
int mkfifoat(int fd, const char *path, mode_t mode);
// 两个函数的返回值,若成功,返回0;若出错,返回-1

mkfifo函数中mode参数的规格说明与open函数中mode的相同。

mkfitoat函数和mkfifo函数相似,但是mkfifoat函数可以被用来在fd文件描述符表示的目录相关的位置创建一个FIFO。像其他at函数一样,这里有3种情形:

  1. 如果path参数指定的是绝对路径名,则后参数会被忽略掉,并且mkfifoat函数的行为和`mkfifo类似
  2. 如果path参数指定的是相对路径名,则细参数是一个打开目录的有效文件描述符,路径名和目录有关
  3. 如果path参数指定的是相对路径名,并且fd参数有一个特殊值AT_FDCWD,则路径名以当前目录开始,mkfifoatmkfifo类似。

当我们用mkfifo或者mkfifoat创建FIFO时,要用open来打开它。确实,正常的文件I/O函数(如closereadwriteunlink)都需要FIFO。

当open一个FIFO时,非阻塞标志(O_NONBLOCK)会产生下列影响。在一般情况下(没有指定O_NONBLOCK),只读open要阻塞到某个其他进程为写而打开这个FIFO为止。类似地,只写open要阻塞到某个其他进程为读而打开它为止。如果指定了O_NONBLOCK,则只读open立即返回。但是,如果没有进程为读而打开一个FIFO,那么只写open将返回-1,并将errno设置成ENXIO。

类似于管道,若write一个尚无进程为读而打开的FIFO,则产生信号SIGPIPE。若某个FIFO的最后一个写进程关闭了该FIFO,则将为该FIPO的读进程产生一个文件结束标志。

一个给定的FIFO有多个写进程是常见的。这就意味着,如果不希望多个进程所写的数据交叉,则必须考虑原子写操作。和管道一样,常量PIPB. BUF说明了可被原子地写到FIFO的最大数据量。

FIFO有以下两种用途。

  1. shell命令使用FIFO将数据从一条管道传送到另一条时,无需创建中间临时文件。
  2. 客户进程-服务器进程应用程序中,FIFO用作汇聚点,在客户进程和服务器进程二者之间传递数据

实例:用FIFO复制输出流
FIFO可用于复制一系列shell命令中的输出流。这就防止了将数据写向中间磁盘文件(类似于使用管道来避免中间磁盘文件)。但是不同的是管道只能用于两个进程之间的线性连接,而FIFO是有名字的,因此它可用于非线性连接。

1
2
3
mkfifo fifo1
prog3 < fifo1 &
prog1 < infile | tee fifo1 | prog2

创建FIFO,然后在后台启动prog3,从FIFO读数据。然后启动prog1,用tee将其输出发送到FIFO和prog2。

FIFO的另一个用途是在客户进程和服务器进程之间传送数据。如果有一个服务器进程,它与很多客户进程有关,每个客户进程都可将其请求写到一个该服务器进程创建的众所周知的FIFO中。因为该FIFO有多个写进程,所以客户进程发送给服务器进程的请求的长度要小于PIPE_BUF字节。这样就能避免客户进程的多次写之间的交叉。

每个客户进程都在其请求中包含它的进程ID。然后服务器进程为每个客户进程创建一个FIFO,所使用的路径名是以客户进程的进程ID为基础的。虽然这种安排可以工作,但服务器进程不能判断一个客户进程是否崩溃终止,这就使得客户进程专用FIFO会遗留在文件系统中。另外,服务器进程还必须得捕捉SIGPIPE信号,因为客户进程在发送一个请求后有可能没有读取响应就终止了,于是留下一个只有写进程(服务器进程)而无读进程的客户进程专用FIFO。

如果服务器进程以只读方式打开众所周知的FIFO (因为它只需读该FIFO),则每当客户进程个数从1变成0时,服务器进程就将在FIFO中读到(read)一个文件结束标志为使服务器进程免于处理这种情况,一种常用的技巧是使服务器进程以读-写方式打开该众所周知的FIFO

XSI IPC

有3种称作XSI IPC的IPC:消息队列、信号量以及共享存储器。它们之间有很多相似之处。

标识符和键

每个内核中的IPC结构(消息队列、信号量或共享存储段)都用一个非负整数的标识符(idemtifier)加以引用。当一个IPC结构被创建,然后又被删除时,与这种结构相关的标识符连续加1,直至达到一个整型数的最大正值,然后又回转到0。

标识符是IPC对象的内部名。为使多个合作进程能够在同一IPC对象上汇聚,需要提供一个外部命名方案。为此,每个IPC对象都与一个键(key)相关联。将这个键作为该对象的外部名。无论何时创建IPC结构(通过调用msggetsemgetshmget创建),都应指定一个键。这个键的数据类型是基本系统数据类型key_t,通常在头文件<sys/types.h>中被定义为长整型。这个键由内核变换成标识符。

有多种方法使客户进程和服务器进程在同一IPC结构上汇聚

  1. 服务器进程可以指定键IPC_PRIVATE创建一个新IPC结构,将返回的标识符存放在某处(如一个文件)以便客户进程取用。键IPC_PRIVATE保证服务器进程创建一个新IPC结构。缺点是需要读写文件。IPC_PRIVATE键也可用于父进程子关系。父进程指定IPC_PRIVATE创建一个新IPC结构,所返回的标识符可供fork后的子进程使用。接着,子进程又可将此标识符作为exec函数的一个参数传给一个新程序。
  2. 可以在一个公用头文件中定义一个客户进程和服务器进程都认可的键。然后服务器进程指定此键创建一个新的IPC结构。这种方法的问题是该键可能已与一个IPC结构相结合,在此情况下,get函数(msggetsemgetshmget)出错返回。服务器进程必须处理这一错误,删除已存在的IPC结构,然后试着再创建它。
  3. 客户进程和服务器进程认同一个路径名和项目ID,接着,调用函数ftok将这两个值变换为一个键。然后在方法(2)中使用此键。ftok提供的唯一服务就是由一个路径名和项目ID产生一个键。
1
2
3
#include <sys/ipc.h>
key_t ftok(const char *path, int id);
// 返回值,若成功,返回键,若出错,返回(key_t)-1

path参数必须引用一个现有的文件。当产生键时,只使用过参数的低8位。ftok创建的键通常是用下列方式构成的:按给定的路径名取得其stat结构中的部分st_devst_ino字段, 然后再将它们与项目ID组合起来。如果两个路径名引用的是两个不同的文件,那么ftok通常会为这两个路径名返回不同的键。但是,因为i节点编号和键通常都存放在长整型中,所以创建健时可能会丢失信息。这意味着,对于不同文件的两个路径名。如果使用同一项目ID,那么可能产生相同的键。

3个get函数(msggetsemgetshmget)都有两个类似的参数:一个key和一个整型flag。在创建新的IPC结构(通常由服务器进程创建)时,如果keyIPC_PRIVATE或者和当前某种类型的IPC结构无关,则需要指明flagIPC_CREAT标志位。为了引用一个现有队列(通常由客户进程创建),key必须等于队列创建时指明的key的值,并且IPC_CREAT必须不被指明。注意,决不能指定IPC_PRIVATE作为键来引用一个现有队列,因为这个特殊的健值总是用于创建一个新队列。为了引用一个用IPC_PRIVATE键创建的现有队列,一定要知道这个相关的标识符,然后在其他IPC调用中(如msgsndmsgrev)使用该标识符,这样可以绕过get函数。

如果希望创建一个新的IPC结构,而且要确保没有引用具有同一标识符的一个现有IPC结构,那么必须在flag中同时指定IPC_CREATIPC_EXCL位。这样做了以后,如果IPC结构已经存在就会造成出错,返回EEXIST(这与指定了O_CREATO_EXCL标志的open相类似)。

权限结构

XSI IPC为每一个IPC结构关联了一个ipc_perm结构。该结构规定了权限和所有者,它至少包括下列成员:

1
2
3
4
5
6
7
struct ipc_perm {
uid_t uid; /* owner's effective user id */
gid_t gid; /* owner's effective group id */
uid_t cuid; /* creator's effective user id */
gid_t cgid; /* creator's effective group id */
mode_t mode; /* access modes */
};

mode字段对于任何IPC结构都不存在执行权限。另外,消息队列和共享存储使用术语“读”和“写”,而信号量则用术语“读”和“更改”(alter)。

结构限制

所有3种形式的XSIIPC都有内置限制。大多数限制可以通过重新配置内核来改变。在对这3种形式的IPC中的每一种进行描述时,我们都会指出它的限制。

优点和缺点

XSI IPC的一个基本问题是:IPC结构是在系统范围内起作用的,没有引用计数。例如,如果进程创建了一个消息队列,并且在该队列中放入了几则消息,然后终止,那么该消息队列及其内容不会被删除。它们会一直留在系统中直至发生下列动作为止:由某个进程调用msgrcvmsgctl读消息或删除消息队列:成某个进程执行ipcrm(1)命令删除消息队列:或正在自举的系统删除消息队列。将此与管道相比,当最后一个引用管道的进程终止时,管道就被完全地删除了。对于FIFO面言,在最后一个引用FIFO的进程终止时,虽然FIFO的名字仍保留在系统中,直至被显式地删除,但是留在PIPO中的数据已被删除了。

XSI IPC的另一个问题是:这些IPC结构在文件系统中没有名字。不能用ls命令查看IPC对象,不能用rm命令删除它们,也不能用chmod命令修改它们的访问权限。于是,又增加了两个新命令ipcs(1)ipcrm(1)

表中对这些不同形式IPC的某些特征进行了比较。

IPC类型 无连接? 可靠的? 流控制? 记录? 消息类型或优先级?
消息队列
STREAMS
UNIX域流套接字
UNIX域数据报套接字
FIFO(非STREAMS)

“无连接”指的是无需先调用某种形式的打开函数就能发送消息的能力。如前所述,因为需要有某种技术来获得队列标识符,所以我们并不认为消息队列是无连接的。因为所有这些形式的IPC被限制在一台主机上,所以它们都是可靠的。当消息通过网络传送时,就要考虑丢失消息的可能性。“流控制”的意思是如果系统资源(缓冲区)短缺,或者如果接收进程不能再接收更多消息,则发送进程就要休眠。当流控制条件消失时,发送进程应自动唤醒。

图中没有显示的一个特征是:IPC设施能否自动地为每个客户进程创建一个到服务器进程的唯一连接。

消息队列

消息队列是消息的链接表,存储在内核中,由消息队列标识符标识。在本节中,我们把消息队列简称为队列,其标识符简称为队列ID。

msgget用于创建一个新队列或打开一个现有队列。msgsnd将新消息添加到队列尾缩。每个消息包含一个正的长整型类型的字段、一个非负的长度以及实际数据字节数(对应于长度),所有这些都在将消息添加到队列时,传送给msgsndmsgrev用于从队列中取消息。我们并不一定要以先进先出次序取消息。也可以按消息的类型字段取消息。

每个队列都有一个msqid_ds结构与其相关联:

1
2
3
4
5
6
7
8
9
10
struct msqid_ds {
struct ipc_perm msg_perm;
msgqnum_t msg_qnum; /* # of nessages on queue */
msglen_t msg_qbytes; /* max # of bytes on queue */
pid_t msg_lspid; /* pid of last msgsnd() */
pid_t msg_lrpid; /* pid of last magrev() */
time_t msg_stime; /* last-msgsnd() time */
time_t msg_rtime; /* last-msgrev() time */
time_t msg_ctime; /* last-change time */
}

调用的第一个函数通常是msgget,其功能是打开一个现有队列或创建一个新队列。

1
2
3
#include <sys/msg.h>
int msgget(key_t key, int flag);
// 返回值:若成功,返回消息队列ID,若出错,返回-1

在创建新队列时,要初始化msqid-ds结构的下列成员。

  • ipc-perm结构中的mode成员按flag中的相应权限位设置。
  • msg_qnummsg_lspidmsg_lrpidmsg_stimemsg_rtime都设置为0。
  • msg_ctime设置为当前时间。
  • msg_qbytes设置为系统限制值。

若执行成功,msgget返回非负队列ID。此后,该值就可被用于其他3个消息队列函数。msgctl函数对队列执行多种操作。它和另外两个与信号量及共享存储有关的函数(semctlshmctl)都是XSI IPC的类似于ioctl的函数(亦即垃圾桶函数)。

1
2
3
#include <sys/msg.h>
int magctl(int msqid, int cmd, struct msqid_ds *buf);
// 返回值,若成功,返回0;若出错,返回-1

cmd参数指定对msqid指定的队列要执行的命令。

  • IPC_STAT取此队列的msqid_ds结构,并将它存放在buf指向的结构中。
  • IPC_SET将字段msg_perm.uidmsg_perm.gidmsg_perm.modemsg_qbytesbuf指向的结构复制到与这个队列相关的msqid_ds结构中。此命令只能由下列两种进程执行:
    • 一种是其有效用户ID等于msg_perm.cuidmsg_perm.uid
    • 另一种是具有超级用户特权的进程。只有超级用户才能增加msg_qbytes的值。
  • IPC_RMID从系统中删除该消息队列以及仍在该队列中的所有数据。这种删除立即生效。仍在使用这一消息队列的其他进程在它们下一次试图对此队列进行操作时,将得到EIDRM错误。此命令只能由下列两种进程执行:
    • 一种是其有效用户ID等于msg_perm.cuidmsg_perm.uid
    • 另一种是具有超级用户特权的进程。

调用msgsnd将数据放到消息队列中:

1
2
3
#include <sys/msg.h>
int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);
// 返回值,若成功,返回0;若出错,返回-1

正如前面提及的,每个消息都由3部分组成:

  • 一个正的长整型类型的字段;
  • 一个非负的长度(nbytes)
  • 实际数据字节数(对应于长度)。

消息总是放在队列尾端。ptr参数指向一个长整型数,它包含了正的整型消息类型,其后紧接着的是消息数据(若mbytes是0,则无消息数据)。若发送的最长消息是512字节的,则可定义下列结构:

1
2
3
4
struct mymesg {
long mtype; /* positive nessage type */
char mtext[512]; /* nessage data, of length mbytes */
};

ptr就是一个指向mymesg结构的指针。接收者可以使用消息类型以非先进先出的次序取消息。

某些平台既支持32位环境,又支持64位环境。这影响到长整型和指针的大小。64位应用程序的mtype字段的一部分可能会被32位应用程序视为mtext字段的组成部分,而32位应用程序的mtext字段的前4个字节会被64位应用程序解释为mtype字段的组成部分。

参数flag的值可以指定为IPC_NOWAIT。这类似于文件I/O的非阻塞I/O标志。若消息队列已满(或者是队列中的消息总数等于系统限制值,或队列中的字节总数等于系统限制值),则指定IPC_NOWAIT使得msgsnd立即出错返回EAGAIN。如果没有指定IPC_NOWAIT,则进程会一直阻塞到;有空间可以容纳要发送的消息;或者从系统中删除了此队列,或者捕捉到一个信号,并从信号处理程序返回。

注意,对删除消息队列的处理不是很完善。因为每个消息队列没有维护引用计数器,所以在队列被删除以后,仍在使用这一队列的进程在下次对队列进行操作时会出错返回。信号量机构也以同样方式处理其删除。相反,删除一个文件时,要等到使用该文件的最后一个进程关闭了它的文件描述符以后,才能删除文件中的内容。

msgsnd返回成功时,消息队列相关的msqid_ds结构会随之更新,表明调用的进程ID(msg_lspid)、调用的时间(msg_stime)以及队列中新增的消息(msg_qnum)。

msgrcv从队列中取用消息。

1
2
3
#include <sys/msg.h>
saize_t msgrev(int msqid, void *ptr, size_t nbytes, long type, int flag);
// 返回值,若成功,返回消息数据部分的长度,若出错,返回-1

msgsnd一样,ptr参数指向一个长整型数(其中存储的是返回的消息类型),其后跟随的是存储实际消息数据的缓冲区。nbyes指定数据缓冲区的长度。若返回的清息长度大于nbytes,而且在flag中设置了MSG_NOERROR位,则该消息会被截断。如果没有设置这一标志,而消息又太长,则出错返回E2BIG。

参数type可以指定想要哪一种消息。

  • type == 0返回队列中的第一个消息,
  • type > 0返回队列中消息类型为type的第一个消息。
  • type < 0返回队列中消息类型值小于等于type绝对值的消息,如果这种消息有若干个,则取类型值最小的消息。

type值非0用于以非先进先出次序读消息。例如,若应用程序对消息赋予优先权,那么type就可以是优先权值。如果一个消息队列由多个客户进程和一个服务器进程使用,那么type字段可以用来包含客户进程的进程ID(只要进程ID可以存放在长整型中)。

可以将flag值指定为IPC_NOWAIT,使操作不阻塞,这样,如果没有所指定类型的消息可用,则msgrcv返回-1,error设置为ENOMSG。如果没有指定IPC_NOWAIT,则进程会一直阻塞到有了指定类型的消息可用,或者从系统中删除了此队列(返回-1,error设置为EIDRN),或者捕捉到一个信号并从信号处理程序返回。

msgrev成功执行时,内核会更新与该消息队列相关联的msqid_ds结构,以指示调用者的进程ID(msg_lrpid)和调用时间(msg_rtime), 并指示队列中的消息数减少了1个(msg_qnum)。

如若需要客户进程和服务器进程之间的双向数据流,可以使用消息队列或全双工管道。消息队列原来的实施目的是提供高于一般速度的IPC,但现在与其他形式的IPC相比,在速度方面已经没有什么差别了。

信号量

信号量是一个计数器,用于为多个进程提供对共享数据对象的访问。

为了获得共享资源,进程需要执行下列操作。

  1. 测试控制该资源的信号量。
  2. 若此信号量的值为正,则进程可以使用该资源。在这种情况下,进程会将信号量值减1,表示它使用了一个资源单位
  3. 否则,若此信号量的值为0。则进程进入休眼状态,直至信号量值大于0。进程被唤醒后,它返回至步骤(1)

当进程不再使用由一个信号量控制的共享资源时,该信号量值增1。如果有进程正在休眠等待此信号量,则唤醒它们。为了正确地实现信号量,信号量值的测试及减1操作应当是原子操作。为此,信号最通常是在内核中实现的。

常用的信号量形式被称为二元信号量(binary semuphore)。它控制单个资源,其初始值为1。但是,一般而言,信号量的初值可以是任意一个正值,该值表明有多少个共享资源单位可供共享应用。遭憾的是,XSI信号量与此相比要复杂得多。以下3种特性造成了这种不必要的复杂性。

  1. 信号量并非是单个非负值,而必需定义为含有一个或多个信号量值的集合。当创建信号量时,要指定集合中信号量值的数量
  2. 信号量的创建(semget)是独立于它的初始化(semct1)的。这是一个致命的缺点,因为不能原子地创建一个信号量集合,并且对该集合中的各个信号量值赋初值。
  3. 即使没有进程正在使用各种形式的XSIPC。它们仍然是存在的。有的程序在终止时并没有释放已经分配给它的信号量,所以我们不得不为这种程序担心。后面将要说明的undo功能就是处理这种情况的。

内核为每个信号量集合维护着一个semid_ds结构:

1
2
3
4
5
6
struct semid_ds {
struct ipc_perm sem_perm;
unsigned short sem_nsems; /* # of semaphores in set */
time_t sem_otimes /* last-semop() time */
time_t sem_ctime; /* last-change time */
};

每个信号量由一个无名结构表示,它至少包含下列成员:

1
2
3
4
5
6
struct {
unsigned short semval; /* semaphore value, always >= 0 */
pid_t sempid; /* pid for last operation */
unsigned short semncnt; /* # processes awaiting senval>curval */
unsigned short senzcnts /* # processes awaiting senval==0 */
};

当我们想使用XSI信号量时,首先需要通过调用函数semget来获得一个信号量ID。

1
2
3
#include <sys/sem.h>
int semget (key_t kay, int nsems, int flag);
// 返回值:若成功,返回信号量ID,若出错,返回-1

创建一个新集合时,要对semid_ds结构的下列成员赋初值。

  • 初始化ipc_perm结构。该结构中的mode成员被设置为flag中的相应权限位。
  • sem_otime设置为0。
  • sem_ctime设置为当前时间。
  • sem_nsems设置为nsems

nsems是该集合中的信号量数。如果是创建新集合,则必须指定nsems。如果是引用现有集合(一个客户进程),则将nsems指定为0。

semctl函数包含了多种信号量操作。

1
2
#include <sys/sem.h>
int semctl(int semid, int semnum, int cmd, ... /* union semun arg */);

第4个参数是可选的,是否使用取决于所请求的命令,如果使用该参数,则其类型是semun,它是多个命令特定参数的联合(union):
1
2
3
4
5
union semun {
int val; /* for SETVAL */
struct semid_ds *buf; /* for IPC_STAT and IPC_SET */
unsigned short *array;/* for GETALL and SETALL */
}

注意,这个选项参数是一个联合,而非指向联合的指针。

cmd参数指定下列10种命令中的一种,这些命令是运行在semid指定的信号量集合上的。其中有5种命令是针对一个特定的信号量值的,它们用semnum指定该信号量集合中的一个成员。semnum值在0和nsems-1之间,包括0和nsems-1。

  • IPC_STAT对此集合取semid_ds结构,并存储在由arg.buf指向的结构中。
  • IPC_SETarg.buf指向的结构中的值,设置与此集合相关的结构中的sem_perm.uidsem_perm.gidsem_perm.mode字段。此命令只能由两种进程执行:
    • 一种是其有效用户ID等于sem_perm.cuidsem_perm.uid的进程,
    • 另一种是具有超级用户特权的进程
  • IPC_RMID从系统中删除该信号量集合。这种删除是立即发生的。删除时仍在使用此信号量集合的其他进程,在它们下次试图对此信号量集合进行操作时,将出错返回EIDRM。此命令只能由两种进程执行:
    • 一种是其有效用户ID等于sem_perm.cuidsem_perm.uid的进程;
    • 另一种是具有超级用户特权的进程。
  • GETVAL:返回成员semnumsemval值。
  • SETVAL:设置成员semnumsemval值。该值由arg.val指定。
  • GETPID:返回成员semnumsempid值。
  • GETNCNT:返回成员semnumsemncnt值。
  • GETZCNT:返回成员semnumsemzcnt值。
  • GETALL:取该集合中所有的信号量值。这些值存储在arg.array指向的数组中。
  • SETALL:将该集合中所有的信号量值设置成arg.array指向的数组中的值。

对于除GETALL以外的所有GET命令,semctl函数都返回相应值。对于其他命令,若成功则返回值为0,若出错,则设置errno井返回-1。

函数semop自动执行信号量集合上的操作数组。

1
2
3
#include <sys/sem.h>
int semop(int semid, struct sembuf semoparray[], size_t nops);
// 返回值:若成功,返回0;若出错,返回-1

参数semoparray是一个指针,它指向一个由sembuf结构表示的信号量操作数组:

1
2
3
4
5
struct sembuf {
unsigned short sem_num; /* menber # in set (0, 1, ..., nsems-1 */
short sem_op; /* operation(negative, 0, or positive */)
short sem_flg; /* IPC_NOWAIT, SEM_UNDO */
};

参数nops规定该数组中操作的数量(元素数)。对集合中每个成员的操作由相应的sem_op值规定。此值可以是负值、0或正值。

  1. 最易于处理的情况是sem_op为正值。这对应于进程释放的占用的资源数。sem_op值会加到信号量的值上。如果指定了undo标志,则也从该进程的此信号量调整值中减去sem_op
  2. sem_op为负值,则表示要获取由该信号量控制的资源。

如若该信号量的值大于等于sem_op的绝对值(具有所需的资源),则从信号量值中减去sem_op的绝对值。这能保证信号量的结果值大于等于0。如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。

如果信号量值小于sem_op的绝对值(资源不能满足要求),则适用下列条件。

  1. 若指定了IPC_NOWAIT,则semop出错返回EAGAIN。
  2. 若未指定IPC_NOWAIT,则该信号量的semncnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起直至下列事件之一发生:
    1. 此信号量值变成大于等于sem_op的绝对值(即某个进程已释放了某些资源)。此信号量的semncnt值减1(因为已结束等待),并且从信号量值中减去sem_op的绝对值。如果指定了undo标志,则sem_op的绝对值也加到该进程的此信号量调整值上。
    2. 从系统中删除了此信号量。在这种情况下,函数出错返回EIDRM。
    3. 进程捕捉到一个信号,并从信号处理程序返同,在这种情况下,此信号量的semncnt值减1(因为调用进程不再等待),并且函数出错返回EINTR。
  3. sem_op为0,这表示调用进程希望等待到该信号量值变成0。
    1. 如果信号量值当前是0,则此函数立即返回,
    2. 如果信号量值非0,则适用下列条件。
      1. 若指定了IPC_NOWAIT,则出错返回EAGAIN。
      2. 若未指定IPC_NOWAIT,则该信号量的semzcnt值加1(因为调用进程将进入休眠状态),然后调用进程被挂起,直至下列的一个事件发生。
        1. 此信号量值变成0。此信号量的semzcnt值减1 (因为调用进程已结束等待)。
        2. 从系统中删除了此信号量。在这种情况下,函数出错返回BIDRM.
        3. 进程捕提到一个信号,并从信号处理程序返回,在这种情况下,此信号量的semzcnt值减1(因为调用进程不再等待),并且函数出错返回BINTR。

semop函数具有原子性,它或者执行数组中的所有操作,或者一个也不做。

exit时的信号量调整

如果在进程终止时,它占用了经由信号量分配的资源,那么就会成为一个问题。无论何时只要为信号量操作指定了SEM_UNDO标志,然后分配资源(sem_op值小于0),那么内核就会记住对于该特定信号量,分配给调用进程多少资源(sem_op的绝对值)。当该进程终止时,不论自愿或者不自愿,内核都将检验该进程是否还有尚来处理的信号最调整值,如果有,则按调整值对相应信号量值进行处理。

如果用带SETVALSETALL命令的semctl设置一个信号量的值,则在所有进程中,该信号量的调整值都将设置为0。

若使用信号量,则先创建一个包含一个成员的信号量集合,然后将该信号量值初始化为1。为了分配资源,以sem_op为-1调用semop。为了释放资源,以sem_op为-1调用semop。对每个操作都指定SEM_UNDO,以处理在未释放资源条件下进程终止的情况。

若使用记录锁,则先创建一个空文件,并且用该文件的第一个字节(无需存在)作为锁字节。为了分配资源,先对该字节获得一个写锁。释放该资源时,则对该字节解锁。记录锁的性质确保了当一个锁的持有者进程终止时,内核会自动释放该锁。

若使用互斥量,需要所有的进程将相同的文件映射到它们的地址空间里,并且使用PTHREAD_PROCESS_SHARED互斥量属性在文件的相同偏移处初始化互斥量。为了分配资源,我们对互斥量加锁。为了释放锁,我们解锁互斥量。如果一个进程没有释放互斥量而终止,恢复将是非常困难的,除
非我们使用鲁棒互斥量。

共享存储

共享存储允许两个或多个进程共享一个给定的存储区。因为数据不需要在客户进程和服务器进程之间复制,所以这是最快的一种IPC。使用共享存储时要掌握的唯一窍门是,在多个进程之间同步访问一个给定的存储区。若服务器进程正在将数据放入共享存储区,则在它做完这一操作之前,客户进程不应当去取这些数据。通常,信号量用于同步共享存储访问。

我们已经看到了共享存储的一种形式,就是在多个进程将同一个文件映射到它们的地址空间的时候。XSI共享存储和内存映射的文件的不同之处在于,前者没有相关的文件。XSI共享存储段是内存的匿名段。

内核为每个共享存储段维护着一个结构,该结构至少要为每个共享存储段包含以下成员:

1
2
3
4
5
6
7
8
9
10
struct shmid_ds {
struct ipc_perm shm_perm;
size_t shm_segsz; /* size of segment in bytes */
pid_t shm_lpid; /* pid of last shoop() */
pid_t shm_cpid; /* pid of creator */
shmatt_t shm_nattch; /* number of current attaches */
time_t shm_atime; /* last-attach time */
time_t shm_dtime; /* last-detach time */
time_t shm_ctime; /* last-change time */
};

shmatt_t类型定义为无符号整型。

调用的第一个函数通常是shmget,它获得一个共享存储标识符:

1
2
3
#include <sys/shm.h>
int shmget(key_t key, size_t size, int flag);
// 返回值:若成功,返回共享存储ID,若出错,返回-1

当创建一个新段时,初始化shmid_ds结构的下列成员。

  • ipc_perm结构中的modeflag中的相应权限位设置
  • shm_lpidshm_nattachshm_atimeshm_dtime都设置为0
  • shm_ctime设置为当前时间
  • shm_segsz设置为请求的size

参数size是该共享存储段的长度,以字节为单位。实现通常将其向上取为系统页长的整倍数。但是,若应用指定的size值并非系统页长的整倍数,那么最后一页的余下部分是不可使用的。如果正在创建一个新段(通常在服务器进程中),则必须指定其size。如果正在引用一个现存的段(一个客户进程),则将size指定为0。当创建一个新段时,段内的内容初始化为0。

shmctl函数对共享存储段执行多种操作:

1
2
3
#include<sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 返回值,若成功,返回0;若出错,返回-1

cmd参数指定下列5种命令中的一种,使其在shmid指定的段上执行。

  • IPC_STAT取此段的shmid_ds结构,并将它存储在由buf指向的结构中。
  • IPC_SETbuf指向的结构中的值设置与此共享存储段相关的shmid_ds结构中的下列3个字段:shm_perm.uidshm_perm.gidshm_perm.mode。此命令只能由下列两种进程执行,
    • 一种是其有效用户ID等于shm_perm.cuidshm_perm.uid的进程;
    • 另一种是具有超级用户特权的进程。
  • IPC_RMID从系统中剥除该共享存储段。因为每个共享存储段维护着一个连接计数(shmid_ds结构中的shm_nattch字段),所以除非使用该段的最后一个进程终止或与该段分离,否则不会实际上删除该存储段。不管此段是否仍在使用,该段标识符都会被立即删除,所以不能再用shmat与该段连接。此命令只能由下列两种进程执行,
    • 一种是其有效用户ID等于shm_perm.cuidshm_perm.uid的进程;
    • 另一种是具有超级用户特权的进程。

Linux提供了另外两种命令。

  • SHM_LOCK在内存中对共享存储段加锁。此命令,只能由超级用户执行。
  • SHM_UNLOCK解锁共享存储段。此命令只能由超级用户执行。

一旦创建了一个共享存储段,进程就可调用shmat将其连接到它的地址空间中。

1
2
3
#include <sys/shm.h>
void *shmat(int shmid, const void *addr, int flag);
// 返回值:若成功,返回指向共享存储段的指针,若出错,返回-1

共享存储段连接到调用进程的哪个地址上与addr参数以及flag中是否指定SRM_RND位有关。

  • 如果addr为0,则此段连接到由内核选择的第一个可用地址上。这是推荐的使用方式。
  • 如果addr非0,并且没有指定SHM_RND,则此段连接到addr所指定的地址上。
  • 如果addr非0,并且指定了SHM_RND,则此段连接到(addr - (addr mod SHMLBA))所表示的地址上。SHM_RND命令的意思是“取整”。SHMLBA的意思是“低边界地址倍数”,它总是2的乘方。该算式是将地址向下取最近1个SHMLBA的倍数。

当对共享存储段的操作已经结束时,则调用shmdt与该段分离。注意,这并不从系统中删除其标识符以及其相关的数据结构。该标识符仍然存在,直至某个进程(一般是服务器进程)带IPC_RMID命令的调用shmctl特地删除它为止。

1
2
3
#include <sys/shm.h>
int shmdt(const void *addr);
// 返回值:若成功,返回0;若出错,返回-1

addr参数是以前调用shmat时的返回值。如果成功,shmdt将使相关shmid_ds结构中的shm_nattch计数器值减1。

内核将以地址0连接的共享存储段放在什么位置上与系统密切相关。程序打印一些特定系统存放各种类型的数据的位置信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include "apue.h"
#include <sys/shm.h>

#define ARRAY_SIZE 40000
#define MALLOC_SIZE 100000
#define SHM_SIZE 100000
#define SHM_MODE 0600 /* user read/write */

char array[ARRAY_SIZE]; /* uninitialized data = bss */

int
main(void)
{
int shmid;
char *ptr, *shmptr;

printf("array[] from %p to %p\n", (void *)&array[0],
(void *)&array[ARRAY_SIZE]);
printf("stack around %p\n", (void *)&shmid);

if ((ptr = malloc(MALLOC_SIZE)) == NULL)
err_sys("malloc error");
printf("malloced from %p to %p\n", (void *)ptr,
(void *)ptr+MALLOC_SIZE);

if ((shmid = shmget(IPC_PRIVATE, SHM_SIZE, SHM_MODE)) < 0)
err_sys("shmget error");
if ((shmptr = shmat(shmid, 0, 0)) == (void *)-1)
err_sys("shmat error");
printf("shared memory attached from %p to %p\n", (void *)shmptr,
(void *)shmptr+SHM_SIZE);

if (shmctl(shmid, IPC_RMID, 0) < 0)
err_sys("shmctl error");

exit(0);
}

在一个基于Intel的64位Linux系统上运行此程序,其输出如下:

1
2
3
4
5
$ ./a.out
array[] from 0x6020c0 to Ox60bd00
stack around Ox7ffr957b146c
malloced fron 0x9e3010 to 0x9fb6b0
shared nemory attached from 0x7fba578ab000 to 0x7fba578c36a0

图显示了这种情况,注意,共享存储段紧靠在栈之下。

回忆一下mmap函数,它可将一个文件的若干部分映射至进程地址空间。这在概念上类似于用shmat连接一个共享存储段。两者之间的主要区别是,用mmap映射的存储段是与文件相关联的,而XSI共享存储段则并无这种关联。

在读设备/dev/zero时,该设备是0字节的无限资源。它也接收写向它的任何数据,但又忽略这些数据。我们对此设备作为IPC的兴趣在于,当对其进行存储映射时,它具有一些特殊性质。

  • 创建一个未命名的存储区,其长度是mmap的第二个参数,将其向上取整为系统的最近页长。
  • 存储区都初始化为0。
  • 如果多个进程的共同祖先进程对mmap指定了MAP_SHARED标志,则这些进程可共享此存储区。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include "apue.h"
#include <fcntl.h>
#include <sys/mman.h>

#define NLOOPS 1000
#define SIZE sizeof(long) /* size of shared memory area */

static int
update(long *ptr)
{
return((*ptr)++); /* return value before increment */
}

int
main(void)
{
int fd, i, counter;
pid_t pid;
void *area;

if ((fd = open("/dev/zero", O_RDWR)) < 0)
err_sys("open error");
if ((area = mmap(0, SIZE, PROT_READ | PROT_WRITE, MAP_SHARED,
fd, 0)) == MAP_FAILED)
err_sys("mmap error");
close(fd); /* can close /dev/zero now that it's mapped */

TELL_WAIT();

if ((pid = fork()) < 0) {
err_sys("fork error");
} else if (pid > 0) { /* parent */
for (i = 0; i < NLOOPS; i += 2) {
if ((counter = update((long *)area)) != i)
err_quit("parent: expected %d, got %d", i, counter);

TELL_CHILD(pid);
WAIT_CHILD();
}
} else { /* child */
for (i = 1; i < NLOOPS + 1; i += 2) {
WAIT_PARENT();

if ((counter = update((long *)area)) != i)
err_quit("child: expected %d, got %d", i, counter);

TELL_PARENT(getppid());
}
}

exit(0);
}

POSIX信号量

POSIX信号量接口意在解决XSI信号量接口的儿个缺陷,

  • 相比于XSI接口。POSIX信号量接口考虑到了更高性能的实现
  • POSIX信号量接口使用更简单,没有信号量集,在熟悉的文件系统操作后一些接口被模式化了。尽管没有要求一定要在文件系统中实现,但是一些系统的确是这么实现的。
  • POSIX信号量在删除时表现更完美。使用POSIX信号量时,操作能继续正常工作直到该信号量的最后一次引用被释放。

POSIX信号量有两种形式;命名的和未命名的。它们的差异在于创建和销毁的形式上,但其他工作一样。未命名信号量只存在于内存中,并要求能使用信号量的进程必须可以访问内存。这意味着它们只能应用在同一进程中的线程,或者不同进程中已经映射相同内存内容到它们的地址空间中的线程。相反,命名信号量可以通过名字访问,因此可以被任何已知它们名字的进程中的线程使用。

我们可以调用sem_open函数来创建一个新的命名信号量或者使用一个现有信号量。

1
2
3
#include <semaphore.h>
sem_t *sem_open (const char *name, int oflag, .../* mode_t mode, unsigned int value */ );
// 返回值,若成功,返回指向信号量的指针,若出错,返回SEM_FAILED

当使用一个现有的命名信号量时,我们仅仅指定两个参数:信号量的名字和oflag参数的0值。当这个oflag参数有O_CREAT标志集时,如果命名信号量不存在,则创建一个新的。如果它已经存在,则会被使用,但是不会有额外的初始化发生。

当我们指定O_CREAT标志时,需要提供两个额外的参数。mode参数指定谁可以访问信号量。mode的取值和打开文件的权限位相同:用户读、用户写、用户执行、组读、组写、组执行、其他读、其他写和其他执行。赋值给信号量的权限可以被调用者的文件创建屏蔽字修改。注意,只有读和写访问要紧,但是当我们打开一个现有信号量时接口不允许指定模式。

在创建信号量时,value参数用来指定信号量的初始值。它的取值是0~SEM_VALUE_MAX。如果我们想确保创建的是信号量,可以设置oflag参数为O_CREATIO_EXCL。如果信号量已经存在,会导致sem_open失败。

为了增加可移植性,在选择信号量命名时必须遵循一定的规则。

  • 名字的第一个字符应该为斜杠(/)。
  • 名字不应包含其他斜杠以此避免实现定义的行为。例如,如果文件系统被使用了,那么名字/mysem//mysem会被认定为是同一个文件名,但是如果实现没有使用文件系统,那么这两种命名可以被认为是不同的
  • 信号量名字的最大长度是实现定义的。名字不应该长于_POSIX_NAME_MAX个字符长度。因为这是使用文件系统的实现能允许的最大名字长度的限制。

如果想在信号量上进行操作,sem_open函数会为我们返回一个信号量指针,用于传递到其他信号量函数上。当完成信号最操作时,可以调用sem_close函数来释放任何信号量相关的资源。

1
2
3
#include <semaphore.h>
int sem_close(sem_t *sem);
// 返回值:若成功,返回0;若出错,返回-1

如果进程没有首先调用sem_close而退出,那么内核将自动关闭任何打开的信号量。注意,这不会影响信号量值的状态,如果已经对它进行了增1操作,这并不会仅因为退出而改变。类似地。如果调用sem_close,信号量值也不会受到影响。在XSI信号量中没有类似SEM_UNDO标志的机制。可以使用sem_unlink函数来销毁一个命名信号量。

1
2
3
#include <semaphore.h>
int sem_unlink(const char *name);
// 返回值:若成功,返回0;若出错,返回-1

sem_unlink函数删除信号量的名字。如果没有打开的信号量引用,则该信号量会被销毁。否则,销毁将延迟到最后一个打开的引用关闭。

不像XSI信号量,我们只能通过一个函数调用来调节POSIX信号量的值。计数减1和对一个二进制信号量加锁或者获取计数信号量的相关资源是相类似的。

注意,信号量和POSIX信号量之间是没有差别的。是采用二进制信号量还是用计数信号量取决于如何初始化和使用信号量。如果一个信号量只是有值0或者1,那么它就是二进制信号量。当二进制信号量是1时,它就是“解锁的”,如果它的值是0,那就是“加锁的”。

可以使用sem_wait或者sem_trywait函数来实现信号量的减1操作。

1
2
3
4
#include <semaphore.h>
int sem_trywait (sem_t *sem);
int sem_wait (sem_t *sem);
// 两个函数的返回值:若成功,返回0;若出错则返回-1

使用sem_wait函数时,如果信号量计数是0就会发生阻塞。直到成功使信号量减1或者被信号中断时才返回。可以使用sem_trywait函数来避免阻塞。调用sem_trywait时,如果信号量是0,则不会阻塞,而是会返回-1并且将errno置为EAGAIN。

第三个选择是阻塞一段确定的时间。为此,可以使用sem_timewait函数。

1
2
3
4
#include <semaphore.h>
#include <time.h>
int sen_timedwait (sem_t *restrict sem, const struct timespec *restrict tsptr);
// 返回值:若成功,返回0;若出错,返回-1

想要放弃等待信号量的时候,可以用tsptr参数指定绝对时间。超时是基于CLOCK_REALTIME时钟的。如果信号量可以立即减1,那么超时值就不重要了,尽管指定的可能是过去的某个时间,信号量的减1操作依然会成功。如果超时到期并且信号量计数没能减1,sem_timedwait将返回-1且将errno设置为ETIMEDOUT。

可以调用sem_post函数使信号量值增1。这和解锁一个二进制信号量或者释放一个计数信号量相关的资源的过程是类似的。

1
2
3
#include <semaphore.h>
int sem_post (sem_t *sem);
// 返回值:若成功,返回0;若出错,返回-1

调用sem_post时,如果在调用sem_wait(或者sem_timedwait)中发生进程阻塞,那么进程会被唤醒并且被sem_post增1的信号量计数会再次被sem_wait(或者sem_timedwait)减1。

当我们想在单个进程中使用POSIX信号量时,使用未命名信号量更容易。这仅仅改变创建和销毁信号量的方式。可以调用sem_init函数来创建一个未命名的信号量。

1
2
3
#include <senaphore.h>
int sem_init (sem_t *sem, int pshared, unsigned int value);
// 返回值:若成功,返回0;若出错,返回-1

pshared参数表明是否在多个进程中使用信号量。如果是,将其设置成一个非0值。value参数指定了信号量的初始值。

需要声明一个sem_t类型的变量并把它的地址传递给sem_init来实现初始化,而不是像sem_open函数那样返回一个指向信号量的指针。如果要在两个进程之间使用信号量,需要确保sem参数指向两个进程之间共享的内存范围。

对未命名信号量的使用已经完成时,可以调用sem_destroy函数丢弃它。

1
2
3
#include <semaphore.h>
int sem_destroy(sem_t *sem);
// 返回值,若成功,返回0,若出错,返回-1

调用sem_destroy后,不能再使用任何带有sem的信号量函数,除非通过调用sem_init重新初始化它。

sem_getvalue函数可以用来检索信号量值。

1
2
3
#include <semaphore.h>
int sem_getvalue (sem_t *restrict sem, int *restrict valp);
// 返回值:若成功,返回0;若出错,返回-1

成功后,valp指向的整数值将包含信号量值,试图要使用刚读出来的值时,信号量的值可能已经变了。除非使用额外的同步机制来避免这种竞争,否则sem_getvalue函数只能用于调试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include "slock.h"
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>

struct slock *
s_alloc()
{
struct slock *sp;
static int cnt;

if ((sp = malloc(sizeof(struct slock))) == NULL)
return(NULL);
do {
snprintf(sp->name, sizeof(sp->name), "/%ld.%d", (long)getpid(),
cnt++);
sp->semp = sem_open(sp->name, O_CREAT|O_EXCL, S_IRWXU, 1);
} while ((sp->semp == SEM_FAILED) && (errno == EEXIST));
if (sp->semp == SEM_FAILED) {
free(sp);
return(NULL);
}
sem_unlink(sp->name);
return(sp);
}

void
s_free(struct slock *sp)
{
sem_close(sp->semp);
free(sp);
}

int
s_lock(struct slock *sp)
{
return(sem_wait(sp->semp));
}

int
s_trylock(struct slock *sp)
{
return(sem_trywait(sp->semp));
}

int
s_unlock(struct slock *sp)
{
return(sem_post(sp->semp));
}

网络IPC:套接字

引言

将描述套接字网络进程间通信接口,进程用该接口能够和其他进程通信,无论它们是在同一台计算机上还是在不同的计算机上。实际上,这正是套接字接口的设计目标之一:同样的接口既可以用于计算机间通信,也可以用于计算机内通信。

套接字描述符

套接字是通信端点的抽象。正如使用文件描述符访问文件,应用程序用套接字描述符访问套接字。套接字描述符在UNIX系统中被当作是一种文件描述符。事实上,许多处理文件描述符的函数(如read和write)可以用于处理套接字描述符。为创建一个套接字,调用socket函数:

1
2
3
#include <sys/socket.h>
int socket (int domain, int type, int protocol);
// 返回值,若成功,返回文件(套接字)描述符。若出错,返回-1

参数domain(域)确定通信的特性,包括地址格式。图中总结了由POSIX.1指定的各个域。各个域都有自己表示地址的格式,而表示各个域的常数都以AF_开头,意指地址族(address family)。

大多数系统还定义了AF_LOCAL域,这是AF_UNIX的别名。AF_UNSPEC域可以代表“任何”域。

|域|描述|
|AF_INET|IPv4因特网域|
|AF_INET6|IPv6因特网域|
|AF_UNIX|UNIX域|
|AF_UPSPEC|未指定|

参数type确定套接字的类型,进一步确定通信特征。图中总结了由POSIX.1定义的套接字类型,但在实现中可以自由增加其他类型的支持

类型 描述
SOCK_DGRAM 固定长度的、无连接的、不可靠的报文传递
SOCK_RAW IP协议的数据报接口
SOCK_SEQPACKET 固定长度的、有序的、可靠的、面向连接的报文传递
SOCK_STREAM 有序的、可靠的、双向的、面向连接的字节流

参数protocol通常是0,表示为给定的域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol选择一个特定协议。在AF_INET通信域中,套接字类型SOCK_STREAM的默认协议是传输控制协议(Transmission Control Protocol, TCP)。在AF_INET通信域中,套接字类型SOCK_DGRAM的默认协议是UDP。图列出了为因特网域套接字定义的协议。

协议 描述
IPPROTO_IP IPv4网际协议
IPPROTO_IPV6 IPv6网际协议
IPPROTO_ICMP 因特网控制报文协议(Internet Cantrol Message Protacel)
IPPROTO_RAW 原始护数据包协议
IPPROTO_TCP 传输控制协议
IPPROTO_UDP 用户数据报协议(User Datagram Protocol)

对于数据报(SOCK_DGRAM)接口,两个对等进程之间通信时不需要逻辑连接。只需要向对等进程所使用的套接字送出一个报文。

因此数据报提供了一个无连接的服务。另一方面,字节流(SOCK_STREAM)要求在交换数据之前,在本地套接字和通信的对等进程的套接字之间建立一个逻辑连接。数据报是自包含报文。发送数据报近似于给某人邮寄信件。你能邮寄很多信,但你不能保证传递的次序,并且可能有些信件会丢失在路上。每封信件包含接收者地址, 使这封信件独立于所有其他信件。每封信件可能送达不同的接收者。

相反,使用面向连接的协议通信就像与对方打电话。首先,需要通过电话建立一个连接,连接建立好之后,彼此能双向地通信。每个连接是端到端的通信链路。对话中不包含地址信息,就像呼叫两端存在一个点对点虚拟连接,并且连接本身暗示特定的源和目的地。

SOCK_STREAM套接字提供字节流服务,所以应用程序分辨不出报文的界限。这意味着从SOCK_STREAM套接字读数据时,它也许不会返回所有由发送进程所写的字节数。最终可以获得发送过来的所有数据,但也许要通过若干次函数调用才能得到。

SOCK_SEQPACKET套接字和SOCK_STREAM套接字很类似,只是从该套接字得到的是基于报文的服务而不是字节流服务。这意味着从SOCK_SEQPACKET套接字接收的数据量与对方所发送的一致。流控制传输协议(Stream Control Transmission Protocol, SCTP)提供了因特网域上的顺序数据包服务。

SOCK_RAM套接字提供一个数据报接口,用于直接访问下面的网络层(即因特网域中的IP层)。使用这个接口时,应用程序负责构造自己的协议头部,这是因为传输协议(如TCP和UDP)被绕过了。当创建一个原始套接字时,需要有超级用户特权,这样可以防止恶意应用程序绕过内建安全机制来创建报文。

调用socket与调用open相类似。在两种情况下,均可获得用于I/O的文件描述符。当不再需要该文件描述符时,调用close来关闭对文件或套接字的访问,并且释放该描述符以便重新使用。虽然套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可以接受套接字描述符。


未指定和由实现定义的行为通常意味着该函数对套接字描述符无效。例如,lseek不能以套接字横述符为参数,因为套接字不支持文件偏移量的概念。

套接字通信是双向的。可以采用shutdown函数来禁止一个套接字的I/O。

1
2
3
#include <sys/socket.h>
int shutdown (int sockfd, int how);
// 返回值:若成功,返回0;若出错,返回-1

如果howSHUT_RD(关闭读端),那么无法从套接字读取数据。如果howSHUT_WR(关闭写端),那么无法使用套接字发送数据。如果howSHUT_RDWR,则既无法读取数据,又无法发送数据。

为何使用shutdown呢?首先,只有最后一个活动引用关闭时,close才释放网络端点。这意味着如果复制一个套接字(如采用dup),要直到关闭了最后一个引用它的文件描述符才会释放这个套接字。而shutdown允许使一个套接字处于不活动状态,和引用它的文件描述符数目无关。其次,有时可以很方便地关闭套接字双向传输中的一个方向。例如,如果想让所通信的进程能够确定数据传输何时结束,可以关闭该套接
字的写端,然而通过该套接字读端仍可以继续接收数据。

寻址

宇节序

字节序是一个处理器架构特性,用于指示像整数这样的大数据类型内部的字节如何排序。网络协议指定了字节序,因此异构计算机系统能够交换协议信息而不会被字节序所混淆。TCP/IP协议栈使用大端字节序。应用程序交换格式化数据时,字节序问题就会出现。

对于TCP/IP应用程序,有4个用来在处理器字节序和网络字节序之间实施转换的函数。

1
2
3
4
5
6
7
8
9
#include <arpa/inet.h>
uint32_t htonl (uint32_t hostint32);
// 返回值;以网络字节序表示的32位整数
uint16_t htons(uint16_t hostintl6);
// 返回值:以网络字节序表示的16位整数
uint32_t ntohl (uint32_t netint32);
// 返回值;以主机字节序表示的32位整数
uint16_t ntohs(uint16_t netint16);
// 返回值。以主机字节序表示的16位整数

h表示“主机”字节序,n表示“网络”字节序。l表示“长”(即4字节)整数,s表示“短”(即4字节)整数。虽然在使用这些函数时包含的是<arpa/inet.h>头文件,但系统实现经常是在其他头文件中声明这些函数的,只是这些头文件都包含在<arpa/inet.h>中。对于系统来说,把这些函数实现为宏也是很常见的。

地址格式

一个地址标识一个特定通信域的套接字端点,地址格式与这个特定的通信域相关。为使不同格式地址能够传入到套接字函数,地址会被强制转换成一个通用的地址结构sockaddr

1
2
3
4
struct sockaddr {
sa_family_t sa_family; /*address family */
char sa_data[]; /*variabie-length address */
};

套接字实现可以自由地添加额外的成员并且定义sa_data成员的大小。

因特网地址定义在<netinet/in.h>头文件中。在IPv4因特网域(AF_INET)中,套接字地址用结构sockaddr_in表示:

1
2
3
4
5
6
7
8
9
struct in_adds {
in_addr_t s_addr;
/* IPv4 address */
};
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in_addr sin_addr;/* IPv4 address */
};

数据类型in_port_t定义成uint16_t。数据类型in_addr_t定义成uint32_t。这些整数类型在<stdint.h>中定义并指定了相应的位数。

AF_INET域相比较,IPv6因特网域(AF_INET6)套接字地址用结构sockaddr_in6表示:

1
2
3
4
5
6
7
8
9
10
struct_in6_addr {
uint8_t s6_addr[16]; /* IPv6 address */
};
struct sockaddr_in6 {
sa_family_t sin6_family; /* address family */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* trattic class and flow info */
struct in6_addr sin6_addr;/* IPv6 addreas*/
uint32_t sin6_scope_id; /* set of interfaces for scope */
};

在Linux中,sockaddr_in定义如下:

1
2
3
4
5
6
struct sockaddr_in {
sa_family_t sin_family; /* address family */
in_port_t sin_port; /* port number */
struct in6_addr sin6_add; /* IPV4 address */
unsigned char sin_zero[8];/* filler */
};

其中成员sin_zero为填充字段,应该全部被置为0。

注意,尽管sockaddr_insockaddr_in6结构相差比较大,但它们均被强制转换成sockaddr结构输入到套接字例程中。将会看到UNIX域套接字地址的结构与上述两个因特网域套接字地址格式的不同。

有时,需要打印出能被人理解而不是计算机所理解的地址格式。有两个新函数inet_ntopinet_pton具有相似的功能,而且同时支持IPv4地址和IPv6地址。

1
2
3
4
5
#include <arpa/inet.h>
const char *inet_ntop(int domain, const void *restrict addit, char *restrict str, socklen_t size);
// 返回值,若成功,返回地址字符串指针:若出错,返回NULL
int inet_pton(int domain, conat char * restrict str, void *restrict addr);
// 返回值,若成功,返回1;若格式无效,返回0;若出错,返回-1

函数inet_ntop将网络字节序的二进制地址转换成文本字符串格式。inet_pton将文本字符串格式转换成网络字节序的二进制地址。参数domain仅支持两个值:AF_INETAF_INET6

对于inet_ntop,参数size指定了保存文本字符串的缓冲区(str)的大小。 两个常数用于简化工作:INET_ADDRSTRLEN定义了足够大的空间来存放一个表示IPv4地址的文本字符串;INET6_ADDRSTRLEN定义了足够大的空间来存放一个表示IPv6地址的文本字符串。对于inet_pton,如果domainAF_INET,则缓冲区addr需要足够大的空间来存放一个32位地址;如果domainAF_INET6,则需要足够大的空间来存放一个128位地址。

地址查询

理想情况下,应用程序不需要了解一个套接字地址的内部结构。如果一个程序简单地传递一个类似于sockaddr结构的套接字地址,并且不依赖于任何协议相关的特性,那么可以与提供相同类型服务的许多不同协议协作。

网络配置信息被存放在许多地方。这个信息可以存放在静态文件(如/etc/hosts/etc/services)中,也可以由名字服务管理,如域名系统(Domain Name System,DNS)或者网络信息服务(Network Information Service,NIS)。无论这个信息放在何处,都可以用同样的函数访问它。

通过调用gethostent,可以找到给定计算机系统的主机信息。

1
2
3
4
5
#include <netdb.h>
struct hostent *gethostent (void);
// 返回值:若成功,返回指针,若出错,返回NULL
void sethostent(int stayopen);
void endhostent (void);

如果主机数据库文件没有打开,gethostent会打开它。函数gethostent返回文件中的下一个条目。函数sethostent会打开文件,如果文件已经被打开,那么将其回绕。当stayopen参数设置成非0值时,调用gethostent之后,文件将依然是打开的。函数endhostent可以关闭文件。

gethostent返回时,会得到一个指向hostent结构的指针,该结构可能包含一个静态的数据缓冲区,每次调用gethostent,缓冲区都会被覆盖。hostent结构至少包含以下成员:

1
2
3
4
5
6
7
struct hostent{
char *h_name; /* name of host */
char **h_aliases; /* pointer to alternate host name array +/
int h_addrtype; /*address type */
int h_length; /* length in bytes of address */
char **h_addr_list;/* pointer to array of network addresses */
};

返回的地址采用网络字节序。

另外两个函数gethostbynamegethostbyaddr,原来包含在hostent函数中,现在则被认为是过时的。

能够采用一套相似的接口来获得网络名字和网络编号,

1
2
3
4
5
6
7
#include <netdb.h>
struct netent *getnetbyaddr (uint32_t net, int type);
struct netent *getnetbyname (const char *name);
struct netent *getnetent (void);
// 3个函数的返回值,若成功,返回指针;若出错,返回NULL
void setnetent (int stayopen);
void endnetent (void);

netent结构至少包含以下字段:

1
2
3
4
5
6
struct netent {
char *n_name; /*network nane */
char **n_aliases; /* alternate network name array pointer */
int n_addrtype; /*address type */
uint32_t n_net, /*network nunber */
};

网络编号按照网络字节序返回。地址类型是地址族常量之一(如AF_INET)。我们可以用以下函数在协议名字和协议编号之间进行映射。

1
2
3
4
5
6
7
#include <netdib.h>
struct protoent *getprotobyname(const char *name);
struct protoent *getprotobynumber (int proto);
struct protoent *getprotoent (void);
// 3个函数的返回值:若成功,返回指针,若出错,返回NULL
void setprotoent (int stayopen);
void endprotoent (void);

POSIX.1定义的protoent结构至少包含以下成员:

1
2
3
4
5
struct protoent {
char *p_name; /* protocol name */
char **p_aliases; /* pointer to alternane protocol name array */
int p_proto; /* protocol number */
};

服务是由地址的端口号部分表示的。每个服务由一个唯一的众所周知的端口号来支持。可以使用函数getservbyname将一个服务名映射到一个端口号,使用函数getservbyport将一个端口号映射到一个服务名,使用函数getservent顺序扫描服务数据库。

1
2
3
4
5
6
7
#include <netdb.h>
struct servent *getservbyname (const char *name, const char *proto);
struct servent *getserbyport (int port, const char *proto);
struct servent *getservent (void);
// 3个函数的返回值,若成功,返回指针,若出错,返图NULL
void setservent (int stayopen);
void endservent (void);

servent结构至少包含以下成员:

1
2
3
4
5
6
struct servent {
char *s_name; /* service name */
char **s_aliases; /* pointer to alternate service name array */
int s_port; /* port nunber */
char *s_proto; /* nane of protocol */
};

POSIX.1定义了若干新的函数,允许一个应用程序将一个主机名和一个服务名映射到一个地址,或者反之。这些函数代替了较老的函数gethostbynamegethostbyaddr

getaddrinfo函数允许将一个主机名和一个服务名映射到一个地址。

1
2
3
4
5
6
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *restrict host, const char *restrict service,
const struct addrinfo *restrict hint, struct addrinfo **restrict res);
// 返回值,若成功,返回0;若出错,返回非0错误码
void freeaddrinfo(struct addrinfo *ai);

需要提供主机名、服务名,或者两者都提供。如果仅仅提供一个名字,另外一个必须是一个空指针。主机名可以是一个节点名或点分格式的主机地址。getaddrinfo函数返回一个链表结构addrinfo。可以用freeaddrinfo来释放一个或多个这种结构,这取决于用ai_next字段链接起来的结构有多少。

addrinfo结构的定义至少包含以下成员:

1
2
3
4
5
6
7
8
9
10
struct addrinto {
int ai_flags; /* custonize behavior */
int ai_family; /* address fanily */
int ai_socktype; /* socket type */
int ai_protocol; /* protocol */
socklen_t ai_addrlen; /* length in bytes of address */
struct sockaddr *ai_addr; /* address */
char *ai_canonname;/* canonical name of host */
struct addrinfo *ai_next; /* next in list */
};

可以提供一个可选的hint来选择符合特定条件的地址。hint是一个用于过滤地址的模板,包括ai_familyai_flagsai_protocolai_socktype字段。剩余的整数字段必须设置为0,指针字段必须为空。图总结了ai_flags字段中的标志,可以用这些标志来自定义如何处理地址和名字。

标志 描述
AI_ADDRCONFIG 查询配置的地址类型(Pv4或IPv6)
AI_ALL 查找IPv4和IPv6地址(仅用于AI_V4MAPPED)
AI_CANONNAME 需要一个规范的名字(与别名相对)
AI_NUMERICHOST 以数字格式指定主机地址,不翻译
AL_NUMERICSERV 将服务指定为数字编口号,不翻译
AI_PASSIVE 套接字地址用于监听绑定
AI_V4NAPPED 如没有找到IPv6地址,返回映射到IPV6格式的IPv4地址

如果getaddrinfo失败,不能使用perrorstrerror来生成错误消息,而是要调用gai_strerror将返回的错误码转换成错误消息。

1
2
3
#include <netdb.h>
const char *gai_strerror (int emor);
// 返回值:指向描述错误的字符串的指针

getnameinfo函数将一个地址转换成一个主机名和一个服务名。

1
2
3
4
5
6
#include <sys/socket.h>
#include <netdb.h>
int getnameinfo (const struct sockaddr *restrict addr, socklen_t alen,
char *restrict host, socklen_t hostlen,
char *reatrict service, socklen_t servlen, int flags);
// 返回值:若成功,返回0;若出错,返回非0值

套接字地址(addr)被翻译成一个主机名和一个服务名。如果host非空,则指向一个长度为hostlen字节的缓冲区用于存放返回的主机名。同样,如果service非空,则指向一个长度为servlen字节的缓冲区用于存放返回的主机名。

flags参数提供了一些控制翻译的方式。

标志 描述
NI_DGRAM 服务基于数据报而非基于流
NI_NAMEREQD 如果找不到主机名,将其作为一个错误
NI_NOFQDN 对于本地主机,仅返回全限定域名的节点名部分
NI_NUMERICHOST 返回主机地址的数字形式,非主机名
NI_NUMERICSCOPE 对于IPv6,返回范围ID的数字形式,而非名字
NI_NUMERICSERV 返回服务地址的数字形式(即端口号),而非名字

getaddrinfo函数的使用方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#include "apue.h"
#if defined(SOLARIS)
#include <netinet/in.h>
#endif
#include <netdb.h>
#include <arpa/inet.h>
#if defined(BSD)
#include <sys/socket.h>
#include <netinet/in.h>
#endif

void
print_family(struct addrinfo *aip)
{
printf(" family ");
switch (aip->ai_family) {
case AF_INET:
printf("inet");
break;
case AF_INET6:
printf("inet6");
break;
case AF_UNIX:
printf("unix");
break;
case AF_UNSPEC:
printf("unspecified");
break;
default:
printf("unknown");
}
}

void
print_type(struct addrinfo *aip)
{
printf(" type ");
switch (aip->ai_socktype) {
case SOCK_STREAM:
printf("stream");
break;
case SOCK_DGRAM:
printf("datagram");
break;
case SOCK_SEQPACKET:
printf("seqpacket");
break;
case SOCK_RAW:
printf("raw");
break;
default:
printf("unknown (%d)", aip->ai_socktype);
}
}

void
print_protocol(struct addrinfo *aip)
{
printf(" protocol ");
switch (aip->ai_protocol) {
case 0:
printf("default");
break;
case IPPROTO_TCP:
printf("TCP");
break;
case IPPROTO_UDP:
printf("UDP");
break;
case IPPROTO_RAW:
printf("raw");
break;
default:
printf("unknown (%d)", aip->ai_protocol);
}
}

void
print_flags(struct addrinfo *aip)
{
printf("flags");
if (aip->ai_flags == 0) {
printf(" 0");
} else {
if (aip->ai_flags & AI_PASSIVE)
printf(" passive");
if (aip->ai_flags & AI_CANONNAME)
printf(" canon");
if (aip->ai_flags & AI_NUMERICHOST)
printf(" numhost");
if (aip->ai_flags & AI_NUMERICSERV)
printf(" numserv");
if (aip->ai_flags & AI_V4MAPPED)
printf(" v4mapped");
if (aip->ai_flags & AI_ALL)
printf(" all");
}
}

int
main(int argc, char *argv[])
{
struct addrinfo *ailist, *aip;
struct addrinfo hint;
struct sockaddr_in *sinp;
const char *addr;
int err;
char abuf[INET_ADDRSTRLEN];

if (argc != 3)
err_quit("usage: %s nodename service", argv[0]);
hint.ai_flags = AI_CANONNAME;
hint.ai_family = 0;
hint.ai_socktype = 0;
hint.ai_protocol = 0;
hint.ai_addrlen = 0;
hint.ai_canonname = NULL;
hint.ai_addr = NULL;
hint.ai_next = NULL;
if ((err = getaddrinfo(argv[1], argv[2], &hint, &ailist)) != 0)
err_quit("getaddrinfo error: %s", gai_strerror(err));
for (aip = ailist; aip != NULL; aip = aip->ai_next) {
print_flags(aip);
print_family(aip);
print_type(aip);
print_protocol(aip);
printf("\n\thost %s", aip->ai_canonname?aip->ai_canonname:"-");
if (aip->ai_family == AF_INET) {
sinp = (struct sockaddr_in *)aip->ai_addr;
addr = inet_ntop(AF_INET, &sinp->sin_addr, abuf,
INET_ADDRSTRLEN);
printf(" address %s", addr?addr:"unknown");
printf(" port %d", ntohs(sinp->sin_port));
}
printf("\n");
}
exit(0);
}

这个程序说明了getaddrinfo函数的使用方法。如果有多个协议为指定的主机提供给定的服务,程序会打印出多条信息。如果想将输出限制在AF_INET协议族, 可以在提示中设置ai_family字段。在一个测试系统上运行这个程序时,得到了以下输出:

1
2
3
4
5
$ ./a.out harry nfs
flags canon fanily inet type stream protocol TCP
hoat harry address 192.168.1.99 port 2049
flags canon fanily inet type dataqran protocol UDP
host harry address 192.168.1.99 port 2049

将套接字与地址关联

将一个客户端的套接字关联。上一个地址没有多少新意,可以让系统选一个默认的地址。然而,对于服务器,需要给一个接收客户端请求的服务器套接字关联上一个众所周知的地址。客户端应有一种方法来发现连接服务器所需要的地址,最简单的方法就是服务器保留一个地址并且注册在/etc/services或者某个名字服务中。

使用bind函数来关联地址和套接字。

1
2
3
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t len);
// 返回值,若成功,返回0;若出错,返回-1

对于使用的地址有以下一些限制。

  • 在进程正在运行的计算机上,指定的地址必须有效;不能指定一个其他机器的地址。
  • 地址必须和创建套接字时的地址族所支持的格式相匹配。
  • 地址中的端口号必须不小于1024,除非该进程具有相应的特权(即超级用户)。
  • 一般只能将一个套接字端点绑定到一个给定地址上,尽管有些协议允许多重绑定。

对于因特网域,如果指定IP地址为INADDR_ANY(<netinet/in.h>中定义的),套接字端点可以被绑定到所有的系统网络接口上。这意味着可以接收这个系统所安装的任何一个网卡的数据包。

可以调用getsockname函数来发现绑定到套接字上的地址:

1
2
3
4
#include <sys/socket.h>
int getsockname(int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict alenp);
// 返回值:若成功,返回0;若出错,返回-1

调用getsockname之前,将alenp设置为一个指向整数的指针,该整数指定缓冲区sockaddr的长度。返回时,整数会被设置成返同地址的大小。如果地址和提供的缓冲区长度不匹配,地址会被自动截断而不报错。如果当前没有地址绑定到该套接字,则其结果是未定义的。

如果套接字已经和对等方连接,可以调用getpeername函数来找到对方的地址。

1
2
3
4
#include <sys/socket.h>
int getpeername (int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict alenp);
// 返回值:若成功,返回0;若出错,返回-1

除了返回对等方的地址,函数getpeernamegetsockname一样。

建立连接

如果要处理一个面向连接的网络服务(SOCK_STREAMSOCK_SEQPACKET),那么在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。使用connect函数来建立连接。

1
2
3
#include <sys/socket.h>
int connect (int sockfd, const struct sockaddr *addr, socklen_t len);
// 返回值:若成功,返回0;若出错,返回-1

connect中指定的地址是我们想与之通信的服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址。

如果一个服务器运行在一个负载很重的系统上,就很有可能发生错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "apue.h"
#include <sys/socket.h>

#define MAXSLEEP 128

int
connect_retry(int sockfd, const struct sockaddr *addr, socklen_t alen)
{
int numsec;

/*
* Try to connect with exponential backoff.
*/
for (numsec = 1; numsec <= MAXSLEEP; numsec <<= 1) {
if (connect(sockfd, addr, alen) == 0) {
/*
* Connection accepted.
*/
return(0);
}

/*
* Delay before trying again.
*/
if (numsec <= MAXSLEEP/2)
sleep(numsec);
}
return(-1);
}

这个函数展示了指数补偿(exponential backoff) 算法。如果调用connect失败,进程会休眠一小段时间,然后进入下次循环再次尝试,每次循环休眠时间会以指数级增加,直到最大延迟为2分钟左右。

如果connect失败,套接字的状态会变成未定义的。因此,如果connect失败,可迁移的应用程序需要关闭套接字。如果想重试,必须打开一个新的套接字。这种更易于迁移的技术如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "apue.h"
#include <sys/socket.h>

#define MAXSLEEP 128

int
connect_retry(int domain, int type, int protocol,
const struct sockaddr *addr, socklen_t alen)
{
int numsec, fd;

/*
* Try to connect with exponential backoff.
*/
for (numsec = 1; numsec <= MAXSLEEP; numsec <<= 1) {
if ((fd = socket(domain, type, protocol)) < 0)
return(-1);
if (connect(fd, addr, alen) == 0) {
/*
* Connection accepted.
*/
return(fd);
}
close(fd);

/*
* Delay before trying again.
*/
if (numsec <= MAXSLEEP/2)
sleep(numsec);
}
return(-1);
}

需要注意的是,因为可能要建立一个新的套接字,给connect_retry函数传递一个套接字描述符参数是没有意义。我们现在返回一个已连接的套接字描述符给调用者,而并非返回一个表示调用成功的值

如果套接字描述符处于非阻塞模式,那么在连接不能马上建立时,connect将会返回-1并且将errno设置为特殊的错误码EINPROGRESS。应用程序可以使用poll或者select来判断文件描述符何时可写。如果可写,连接完成。connect函数还可以用于无违接的网络服务(SOCK_DGRAM)。这看起来有点矛盾,实际上却是一个不错的选择。如果用SOCK_DGRAM套接字调用connect,传送的报文的目标地址会设置成connect调用中所指定的地址,这样每次传送报文时就不需要再提供地址。另外,仅能接收来自指定地址的报文。

服务器调用listen函数来宣告它愿意接受连接请求。

1
2
3
#include <sys/socket.h>
int listen(int sockfd, int backlog);
// 返回值:若成功,返回0;若出错,返回一

参数backlog提供了一个提示,提示系统该进程所要入队的未完成连接请求数量。其实际值由系统决定,但上限由<sys/socket.h>中的SOMAXCONN指定。一旦队列满,系统就会拒绝多余的连接请求,所以backlog的值应该基于服务器期望负载和处理量来选择,其中处理量是指接受连接请求与启动服务的数量。一旦服务器调用了listen,所用的套接字就能接收连接请求。使用accept函数获得连接请求并建立连接。

1
2
3
4
#include <sys/socket.h>
int accept (int sockfd, struct sockaddr *restrict addr,
socklen_t *restrict len);
// 返回值:若成功,返回文件(套接字)描述符;若出错,返回-1

函数accept所返回的文件描述符是套接字描述符,该描述符连接到调用connect的客户端。这个新的套接字描述符和原始套接字(sockfd)具有相同的套接字类型和地址族。传给accept的原始套接字没有关联到这个连接,而是继续保持可用状态并接收其他连接请求。

如果不关心客户端标识,可以将参数addrlen设为NULL。否则,在调用accept之前,将addr参数设为足够大的缓冲区来存放地址,并且将len指向的整数设为这个缓冲区的字节大小。返回时,accept会在缓冲区填充客户端的地址,并且更新指向len的整数来反映该地址的大小。

如果没有连接请求在等待,accept会阻塞直到一个请求到来。如果sockfd处于非阻塞模式,accept会返回-1,并将errno设置为EAGAIN或EWOULDBLOCK。

如果服务器调用accept,并且当前没有连接请求,服务器会阻塞直到一个请求到来。另外,服务器可以使用pollselect来等待一个请求的到来。在这种情况下,一个带有等待连接请求的套接字会以可读的方式出现。

函数可以用来分配和初始化套接字供服务器进程使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include "apue.h"
#include <errno.h>
#include <sys/socket.h>

int
initserver(int type, const struct sockaddr *addr, socklen_t alen,
int qlen)
{
int fd;
int err = 0;

if ((fd = socket(addr->sa_family, type, 0)) < 0)
return(-1);
if (bind(fd, addr, alen) < 0)
goto errout;
if (type == SOCK_STREAM || type == SOCK_SEQPACKET) {
if (listen(fd, qlen) < 0)
goto errout;
}
return(fd);

errout:
err = errno;
close(fd);
errno = err;
return(-1);
}

数据传输

既然一个套接字端点表示为一个文件描述符,那么只要建立连接,就可以使用readwrite来通过套接字通信。通过在connect函数里面设置默认对等地址,数据报套接字也可以被“连接”。在套接字描述符上使用readwrite是非常有意义的,因为这意味着可以将套接字描述符传递给那些原先为处理本地文件而设计的函数。而且还可以安排将套接字描述符传递给予进程,而该子进程执行的程序并不了解套接字。

如果想指定选项,从多个客户端接收数据包,或者发送带外数据,就需要使用6个为数据传递而设计的套接字函数中的一个。3个函数用来发送数据,3个用于接收数据。首先,考查用于发送数据的函数。最简单的是send,它和write很像,但是可以指定标志来改变处理传输数据的方式。

1
2
3
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
// 返回值:若成功,返回发送的字节数:若出错,返回-1

类似write,使用send时套接字必须已经连接。参数bufnbytes的含义与write中的一致。

然而,与write不同的是,send支持第4个参数flags。图总结了这些标志。

标志 描述
MSG_CONFIRM 提供链路层反馈以保持地址映射有效
MSG_DONTROUTE 勿将数据包路由出本地网络
MSG_DONTWAIT 允许非阻塞操作
MSG_EOF 发送数据后关闭套接字的发送端
MSG_EOR 如果协议支持,标记记录结束
MSG_MORE 延迟发送数据包允许写更多数据
MSG_NOSIGNAL 在写无连接的套接字时不产生SIGPIPE信号
MSG_OOB 如果协议支持,发送带外数据

即使send成功返回,也并不表示连接的另一端的进程就一定接收了数据。我们所能保证的只是当send成功返回时,数据已经被无错误地发送到网络驱动程序上。对于支持报文边界的协议,如果尝试发送的单个报文的长度超过协议所支持的最大长度,那么send会失败,并将errno设为EMSGSIZE。对于字节流协议,send会阻塞直到整个数据传输完成。函数sendtosend很类似。区别在于sendto可以在无连锁的套接字上指定一个目标地址。

1
2
3
4
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags,
const struct sockaddr *destaddr, socklen_t destlen);
// 返回值:若成功,返回发送的字节数;若出错,返回-1

对于面向连接的套接字,目标地址是被忽略的,因为连接中隐含了目标地址。对于无连接的套接字,除非先调用connect设置了目标地址,否则不能使用sendsendto提供了发送报文的另一种方式。

通过套接字发送数据时,还有一个选择。可以调用带有msghdr结构的sendmsg来指定多重缓冲区传输数据,这和writev函数很相似

1
2
3
#include <sys/socket.h>
ssize_t sendmsg(int sockfd, const struct msghdr *mig, int flags);
// 返回值:若成功,返回发送的字节数;若出错,返回-1

POSIX.1定义了msghdr结构,它至少有以下成员:

1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address oize in bytes */
struct iovec *msg_iov; /* array of I/O butters */
int msg_iovlen; /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_eontrollen;/* number of ancillary bytes */
int msg_flags; /* flags for received nessage */
};

函数recvread相似,但是recv可以指定标志来控制如何接收数据。

1
2
3
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
// 返回值,返回数据的字节长度;着无可用数据或对等方已经按序结束,返回0;若出错,返回-1

标志 描述
MSG_CMSG_CLOEXEC 为UNIX域套接字上接收的文件描述符设置执行时关闭标志
MSG_DONTWAIT启用非阻塞操作
MSG_ERRQUEUE 接收错误信息作为辅助数据
MSG_OOB 如果协议支持,获取带外数据
MSG_PEEK 返回数据包内容而不真正取走数据包
MSG_TRUNC 即使数据包被截断,也返回数据包的实际长度
MSG_WAITALL 等待直到所有的数据可用

当指定MSG_PEEK标志时,可以查看下一个要读取的数据但不真正取走它。当再次调用read或其中一个recv函数时,会返回刚才查看的数据。对于SOCK_STREAM套接字, 接收的数据可以比预期的少。MSG_WAITALL标志会阻止这种行为,直到所请求的数据全部返回,recv函数才会返回。对于SOCK_DGRAMSOCK_SEQPACKET套接字,MSG_WAITALL标志没有改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。

如果发送者已经调用shutdown来结束传输,或者网络协议支持按默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv会返回0。

如果有兴趣定位发送者,可以使用recvfrom来得到数据发送者的源地址。

1
2
3
4
5
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags,
struct sockaddr *restrict addr,
socklen_t *restrict addrlen);
// 返回值:返回数据的字节长度,若无可用数据或对等方已经按序结束,返回0;若出错,返回-1

如果addr非空,它将包含数据发送者的套接字端点地址。当调用recvfrom时,需要设置addrlen参数指向一个整数,该整数包含addr所指向的套接字缓冲区的字节长度。返回时,该整数设为该地址的实际字节长度。因为可以获得发送者的地址,recvfrom通常用于无连接的套接字。否则,recvfrom等同于recv

为了将接收到的数据送入多个缓冲区,类似于readv,或者想接收辅助数据,可以使用recvmsg

1
2
3
#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
// 返回值,返回数据的字节长度。若无可用数据或对等方已经按序结束,返回0;若出错,返回-1

recvmsgmsghdr结构指定接收数据的输入缓冲区。可以设置参数flags来改变recvmsg的默认行为。返回时,msghdr结构中的msg_flags字段被设为所接收数据的各种特征。recvmsg中返回的各种可能值总结在图中。

标志 描述
MSG_CTRUNC 控制数据被阶段
MSG_EOR 接收记录结束符
MSG_ERRQUEUE 接收错误信息作为辅助数据
MSG_OOB 接收带外数据
MSG_TRUNC 一般数据被截断

程序显示了一个与服务器通信的客户端从系统的uptime命令获得输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include "apue.h"
#include <netdb.h>
#include <errno.h>
#include <sys/socket.h>

#define BUFLEN 128

extern int connect_retry(int, int, int, const struct sockaddr *,
socklen_t);

void
print_uptime(int sockfd)
{
int n;
char buf[BUFLEN];

while ((n = recv(sockfd, buf, BUFLEN, 0)) > 0)
write(STDOUT_FILENO, buf, n);
if (n < 0)
err_sys("recv error");
}

int
main(int argc, char *argv[])
{
struct addrinfo *ailist, *aip;
struct addrinfo hint;
int sockfd, err;

if (argc != 2)
err_quit("usage: ruptime hostname");
memset(&hint, 0, sizeof(hint));
hint.ai_socktype = SOCK_STREAM;
hint.ai_canonname = NULL;
hint.ai_addr = NULL;
hint.ai_next = NULL;
if ((err = getaddrinfo(argv[1], "ruptime", &hint, &ailist)) != 0)
err_quit("getaddrinfo error: %s", gai_strerror(err));
for (aip = ailist; aip != NULL; aip = aip->ai_next) {
if ((sockfd = connect_retry(aip->ai_family, SOCK_STREAM, 0,
aip->ai_addr, aip->ai_addrlen)) < 0) {
err = errno;
} else {
print_uptime(sockfd);
exit(0);
}
}
err_exit(err, "can't connect to %s", argv[1]);
}

这个程序连接服务器,读取服务器发送过来的字符串并将其打印到标准输出。因为使用的是SOCK_STREAM套接字,所以不能保证调用一次recv就会读取整个字符串,因此需要重复调用直到它返回0。

如果服务器支持多重网络接口或多重网络协议,函数getaddrinfo可能会返回多个候选地址供使用。轮流尝试每个地址,当找到一个允许连接到服务的地址时便可停止。使用connect_retry函数来与服务器建立一个连接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
#include "apue.h"
#include <netdb.h>
#include <errno.h>
#include <syslog.h>
#include <sys/socket.h>

#define BUFLEN 128
#define QLEN 10

#ifndef HOST_NAME_MAX
#define HOST_NAME_MAX 256
#endif

extern int initserver(int, const struct sockaddr *, socklen_t, int);

void
serve(int sockfd)
{
int clfd;
FILE *fp;
char buf[BUFLEN];

set_cloexec(sockfd);
for (;;) {
if ((clfd = accept(sockfd, NULL, NULL)) < 0) {
syslog(LOG_ERR, "ruptimed: accept error: %s",
strerror(errno));
exit(1);
}
set_cloexec(clfd);
if ((fp = popen("/usr/bin/uptime", "r")) == NULL) {
sprintf(buf, "error: %s\n", strerror(errno));
send(clfd, buf, strlen(buf), 0);
} else {
while (fgets(buf, BUFLEN, fp) != NULL)
send(clfd, buf, strlen(buf), 0);
pclose(fp);
}
close(clfd);
}
}

int
main(int argc, char *argv[])
{
struct addrinfo *ailist, *aip;
struct addrinfo hint;
int sockfd, err, n;
char *host;

if (argc != 1)
err_quit("usage: ruptimed");
if ((n = sysconf(_SC_HOST_NAME_MAX)) < 0)
n = HOST_NAME_MAX; /* best guess */
if ((host = malloc(n)) == NULL)
err_sys("malloc error");
if (gethostname(host, n) < 0)
err_sys("gethostname error");
daemonize("ruptimed");
memset(&hint, 0, sizeof(hint));
hint.ai_flags = AI_CANONNAME;
hint.ai_socktype = SOCK_STREAM;
hint.ai_canonname = NULL;
hint.ai_addr = NULL;
hint.ai_next = NULL;
if ((err = getaddrinfo(host, "ruptime", &hint, &ailist)) != 0) {
syslog(LOG_ERR, "ruptimed: getaddrinfo error: %s",
gai_strerror(err));
exit(1);
}
for (aip = ailist; aip != NULL; aip = aip->ai_next) {
if ((sockfd = initserver(SOCK_STREAM, aip->ai_addr,
aip->ai_addrlen, QLEN)) >= 0) {
serve(sockfd);
exit(0);
}
}
exit(1);
}

为了找到它的地址,服务器需要获得其运行时的主机名。如果主机名的最大长度不确定,可以使用HOST_NAME_MAX代替。如果系统没定义HOST_NAME_MAX,可以自己定义。POSIX.1要求主机名的最大长度至少为255字节,不包括终止null字符,因此定义HOST_NAME_MAX为256来包括终止null字符。

对于无连接的套接字,数据包到达时可能已经没有次序,因此如果不能将所有的数据放在一个数据包里,则在应用程序中就必须关心数据包的次序。数据包的最大尺寸是通信协议的特征,另外,对于无连接的套接字,数据包可能会丢失。如果应用程序不能容忍这种丢失,必须使用面向连接的套接字。

容忍数据包丢失意味着两种选择。一种选择是,如果想和对等方可靠通信,就必须对数据包编号,并且在发现数据包丢失时,请求对等应用程序重传,还必须标识重复数据包并丢弃它们,因为数据包可能会延迟或疑似丢失,可能请求重传之后,它们又出现了。

另一种选择是,通过让用户再次尝试那个命令来处理错误。对于简单的应用程序,这可能就足够了,但对于复杂的应用程序,这种选择通常不可行。因此,一般在这种情况下使用面向连接的套接字比较好。

面向连接的套接字的缺陷在于需要更多的时间和工作来建立一个连接,并且每个连接都需要消耗较多的操作系统资源。

程序是采用数据报套接字接口的uptime客户端命令版本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
#include "apue.h"
#include <netdb.h>
#include <errno.h>
#include <sys/socket.h>

#define BUFLEN 128
#define TIMEOUT 20

void
sigalrm(int signo)
{
}

void
print_uptime(int sockfd, struct addrinfo *aip)
{
int n;
char buf[BUFLEN];

buf[0] = 0;
if (sendto(sockfd, buf, 1, 0, aip->ai_addr, aip->ai_addrlen) < 0)
err_sys("sendto error");
alarm(TIMEOUT);
if ((n = recvfrom(sockfd, buf, BUFLEN, 0, NULL, NULL)) < 0) {
if (errno != EINTR)
alarm(0);
err_sys("recv error");
}
alarm(0);
write(STDOUT_FILENO, buf, n);
}

int
main(int argc, char *argv[])
{
struct addrinfo *ailist, *aip;
struct addrinfo hint;
int sockfd, err;
struct sigaction sa;

if (argc != 2)
err_quit("usage: ruptime hostname");
sa.sa_handler = sigalrm;
sa.sa_flags = 0;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGALRM, &sa, NULL) < 0)
err_sys("sigaction error");
memset(&hint, 0, sizeof(hint));
hint.ai_socktype = SOCK_DGRAM;
hint.ai_canonname = NULL;
hint.ai_addr = NULL;
hint.ai_next = NULL;
if ((err = getaddrinfo(argv[1], "ruptime", &hint, &ailist)) != 0)
err_quit("getaddrinfo error: %s", gai_strerror(err));

for (aip = ailist; aip != NULL; aip = aip->ai_next) {
if ((sockfd = socket(aip->ai_family, SOCK_DGRAM, 0)) < 0) {
err = errno;
} else {
print_uptime(sockfd, aip);
exit(0);
}
}

fprintf(stderr, "can't contact %s: %s\n", argv[1], strerror(err));
exit(1);
}

除了增加安装一个SIGALRM的信号处理程序以外,基于数据报的客户端中的main函数和面向连接的客户端中的类似。使用alarm函数来避免调用recvfrom时的无限期阻塞。

对于面向连接的协议,需要在交换数据之前连接到服务器。对于服务器来说,到来的连接请求已经足够判断出所需提供给客户端的服务。但是对于基于数据报的协议,需要有一种方法通知服务器来执行服务。本例中,只是简单地向服务器发送了1字节的数据。服务器将接收它,从数据包中得到地址,并使用这个地址来传送它的响应。如果服务器提供多个服务,可以使用这个请求数据来表示需要的服务,但由于服务器只做一件事情,1字节数据的内容是无关紧要的。

如果服务器不在运行状态,客户端调用recvfrom便会无限期阻塞。对于这个面向连接的实例,如果服务器不运行,connect调用会失败。为了避免无限期阻塞,可以在调用recvfrom之前设置警告时钟。

套接字选项

套接字机制提供了两个套接字选项接口来控制套接字行为。一个接口用来设置选项,另一个接口可以查询选项的状态。可以获取或设置以下3种选项。

  1. 通用选项,工作在所有套接字类型上。
  2. 在套接字层次管理的选项,但是依赖于下层协议的支持。
  3. 特定于某协议的选项,每个协议独有的。

可以使用setsockopt函数来设置套接字选项。

1
2
3
4
#include <sys/socket.h>
int setsockopt(int sockfd, int level, int option, const void *val,
socklen_t len);
// 返回值,若成功,返回0;若出错,返回-1

参数level标识了选项应用的协议。如果选项是通用的套接字层次选项,则level设置成SOL_SOCKET。否则,level设置成控制这个选项的协议编号。对于TCP选项,levelIPPROTO_TCP,对于IP,levelIPPROTO_IP

选项 参数val的类型 描述
SO_ACCEPTCONN int 返回信息指示该套接字是否能被监听
SO_BROADCAST int 如果*val非0,广播数据报
SO_DEBUG int 如果*val非0,启用网络驱动调试功能
SO_DONTROUTE int 如果*val非0。绕过通常路由
SO_ERROR int 返回挂起的套接字错误并清除
SO_KEEPALIVE int 如果*val非0。启用周期性keep-alive报文
SO_LINGER struct linger 当还有未发报文雨套接字已关闭时,延迟时间
SO_OOBINLINE int 如果*val非0,将带外数据放在普通数据中
SO_RCVBUF int 接收缓冲区的字节长度
SO_RCVLOWAT int 接收调用中返回的最小数据字节数
SO_RCVTIMEO struct timeval 套接字接收调用的超时值
SO_REUSEADDR int 如果*val非0,重用bind中的地址
SO_SNDBUF int 发送缓冲区的字节长度
SO_SNDLOWAT int 发送调用中传送的最小数据字节数
SO_SNDTIMEO struct timeval 套接字发送调用的超时值
SO_TYPE int 标识套接字类型

参数val根据选项的不同指向一个数据结构或者一个整数。一些选项是on/off开关。如果整数非0,则启用选项。如果整数为0,则禁止选项。参数len指定了val指向的对象的大小。可以使用getsockopt函数来查看选项的当前值。

1
2
3
4
#include <sys/socket.h>
int getsockopt (int sockfd, int level, int option, void *restrict val,
socklen_t *rentrict lenp);
// 返回值:若成功,返回0;若出错,返回-1

多数lenp是一个指向整数的指针。在调用getsockopt之前,设置该整数为复制选项缓冲区的长度。如果选项的实际长度大于此值,则选项会被截断。如果实际长度正好小于此值,那么返回时将此值更新为实际长度。

带外数据

带外数据(out-of-band data)是一些通信协议所支持的可选功能,与普通数据相比,它允许更高优先级的数据传输。带外数据先行传输,即使传输队列已经有数据。TCP支持带外数据,但是UDP不支持。套接字接口对带外数据的支持很大程度上受TCP带外数据具体实现的影响。

TCP将带外数据称为紧急数据(urgent data)。TCP仅支持一个字节的紧急数据,但是允许紧急数据在普通数据传递机制数据流之外传输。为了产生紧急数据,可以在3个send函数中的任何一个里指定MSG_OOB标志。如果带MSG_OOB标志发送的字节数超过一个时,最后一个字节将被视为紧急数据字节。

如果通过套接字安排了信号的产生,那么紧急数据被接收时,会发送SIGURG信号。可以通过调用以下函数安排进程接收套接字的信号:

1
fcntl(sockfd, F_SETOWN, pid);

F_GETOWN命令可以用来获得当前套接字所有权,对于F_SETOWN命令,负值代表进程组ID,正值代表进程ID。因此,调用

1
owner = fcntl (socked, F_GETOWN, 0);

将返回owner,如果owner为正值, 则等于配置为接收套接字信号的进程的ID。如果owner为负值,其绝对值为接收套接字信号的进程组的ID。

TCP支持紧急标记(urgentmark)的概念, 即在普通数据流中紧急数据所在的位置。如果采用套接字选项SO_OOBINLINE,那么可以在普通数据中接收紧急数据。为帮助判断是否已经到达紧急标记,可以使用函数sockatmark

1
2
3
#include <sys/socket.h>
int sockatmark(int sockfd);
// 返回值:若在标记处,返回1;若没在标记处,返回0;若出错,返回-1

当下一个要读取的字节在紧急标志处时,sockatmark返回1。当带外数据出现在套接字读取队列时,select函数会返回一个文件描述符并且有一个待处理的异常条件。可以在普通数据流上接收紧急数据,也可以在其中一个recv函数中采用MSG_OOB标志在其他队列数据之前接收紧急数据。TCP队列仅用一个字节的紧急数据。如果在接收当前的紧急数据字节之前又有新的紧急数据到来,那么已有的字节会被丢弃。

非阻塞和异步I/O

通常,recv函数没有数据可用时会阻塞等待。同样地,当套接字输出队列没有足够空间来发送消息时,send函数会阻塞。在套接字非阻塞模式下,行为会改变。在这种情况下,这些函数不会阻塞而是会失败,将errno设置为EWOULDBLOCK成者EAGAIN。当这种情况发生时,可以使用pollselect来判断能否接收或者传输数据。

在基于套接字的异步I/O中,当从套接字中读取数据时, 或者当套接字写队列中空间变得可用时,可以安排要发送的信号SIGIO。启用异步I/O是一个两步骤的过程。

  1. 建立套接字所有权,这样信号可以被传递到合适的进程。
  2. 通知套接字当I/O操作不会阻塞时发信号。

可以使用3种方式来完成第一个步骤。

  • fcntl中使用F_SETOWN命令。
  • fcctl中使用FIOSETOWN命令。
  • fcctl中使用SIOCSPGRP命令。

要完成第二个步骤,有两个选择

  1. fcntl中使用F_SETFL命令并且启用文件标志O_ASYNC
  2. ioctl中使用FIOASYNC命令。

高级进程间通信

UNIX域套接字

UNIX域套接字用于在同一台计算机上运行的进程之间的通信。虽然因特网域套接字可用于同一目的,但UNIX域套接字的效率更高。UNIX域套接字仅仅复制数据,它们并不执行协议处理,不需要添加或删除网络报头,无需计算校验和,不要产生顺序号,无需发送确认报文。UNIX域套接字提供流和数据报两种接口,UNIX域数据报服务是可靠的,既不会丢失报文也不会传递出错。UNIX域套接字就像是套接字和管道的混合。可以使用它们面向网络的域套接字接口或者使用socketpair函数来创建一对无命名的、相互连接的UNIX域套接字。

1
2
3
#include <sys/socket.h>
int socketpair(int domain, int type, int protocol, int sockfd[2]);
// 返回值:若成功,返回0;若出错,返回-1

虽然接口足够通用,允许socketpair用于其他域,但一般来说操作系统仅对UNIX域提供支持,一对相互连接的UNIX域套接字可以起到全双工管道的作用:两端对读和写开放。我们将其称为fd管道(fd-pipe),以便与普通的半双工管道区分开来。

fd_pipe函数使用socketpair函数来创建一对相互连接的UNIX域流套接字。

1
2
3
4
5
#include "apue.h"
#include <sys/socket.h>
int fd_pipe(int fd[2]) {
return (socketpair (AF_UNIX, SOCK_STREAM, 0, fd));
}

套接字是和文件描述符相关联的,消息到达时,可以用套接字来通知。对每个消息队列使用一个线程。每个线程都会在msgrcv调用中阻塞。当消息到达时,线程会把它写入一个UNIX域套接字的一端。当poll指示套接字可以读取数据时,应用程序会使用这个套接字的另外一端来接收这个消息。

main函数中创建了一些消息队列和UNIX域套接字,并为每个消息队列开启了一个新线程。然后它在一个无限循环中用poll来轮询选择一个套接字端点。当某个套接字可读时,程序可以从套接字中读取数据并把消息打印到标准输出上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include "apue.h"
#include <poll.h>
#include <pthread.h>
#include <sys/msg.h>
#include <sys/socket.h>

#define NQ 3 /* number of queues */
#define MAXMSZ 512 /* maximum message size */
#define KEY 0x123 /* key for first message queue */

struct threadinfo {
int qid;
int fd;
};

struct mymesg {
long mtype;
char mtext[MAXMSZ];
};

void *
helper(void *arg)
{
int n;
struct mymesg m;
struct threadinfo *tip = arg;

for(;;) {
memset(&m, 0, sizeof(m));
if ((n = msgrcv(tip->qid, &m, MAXMSZ, 0, MSG_NOERROR)) < 0)
err_sys("msgrcv error");
if (write(tip->fd, m.mtext, n) < 0)
err_sys("write error");
}
}

int
main()
{
int i, n, err;
int fd[2];
int qid[NQ];
struct pollfd pfd[NQ];
struct threadinfo ti[NQ];
pthread_t tid[NQ];
char buf[MAXMSZ];

for (i = 0; i < NQ; i++) {
if ((qid[i] = msgget((KEY+i), IPC_CREAT|0666)) < 0)
err_sys("msgget error");

printf("queue ID %d is %d\n", i, qid[i]);

if (socketpair(AF_UNIX, SOCK_DGRAM, 0, fd) < 0)
err_sys("socketpair error");
pfd[i].fd = fd[0];
pfd[i].events = POLLIN;
ti[i].qid = qid[i];
ti[i].fd = fd[1];
if ((err = pthread_create(&tid[i], NULL, helper, &ti[i])) != 0)
err_exit(err, "pthread_create error");
}

for (;;) {
if (poll(pfd, NQ, -1) < 0)
err_sys("poll error");
for (i = 0; i < NQ; i++) {
if (pfd[i].revents & POLLIN) {
if ((n = read(pfd[i].fd, buf, sizeof(buf))) < 0)
err_sys("read error");
buf[n] = 0;
printf("queue id %d, message %s\n", qid[i], buf);
}
}
}

exit(0);
}

注意,我们使用的是数据报(SOCK_DGRAM)套接字而不是流套接字。这样做可以保持消息边界,以保证从套接字里一次只读取一条消息。

这种技术可以(非直接地)在消息队列中运用poll或者select。只要为每个队列分配一个线程的开销以及每个消息额外复制两次(一次写入套接字,另一次从套接字里读取出来)的开销是可接受的,这种技术就会使XSI消息队列的使用更加容易。

使用上述的程序发送消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "apue.h"
#include <sys/msg.h>

#define MAXMSZ 512

struct mymesg {
long mtype;
char mtext[MAXMSZ];
};

int
main(int argc, char *argv[])
{
key_t key;
long qid;
size_t nbytes;
struct mymesg m;

if (argc != 3) {
fprintf(stderr, "usage: sendmsg KEY message\n");
exit(1);
}
key = strtol(argv[1], NULL, 0);
if ((qid = msgget(key, 0)) < 0)
err_sys("can't open queue key %s", argv[1]);
memset(&m, 0, sizeof(m));
strncpy(m.mtext, argv[2], MAXMSZ-1);
nbytes = strlen(m.mtext);
m.mtype = 1;
if (msgsnd(qid, &m, nbytes, 0) < 0)
err_sys("can't send message");
exit(0);
}

这个程序需要两个参数:消息队列关联的键值以及一个包含消息主体的字符串。发送消息到服务器端时,它会打印如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
$ ./pollmag &      在后台运行服务器
[1]12814
$ queue ID 0 is 196608
queue ID 1 18 196609
queue ID 2 18 196610

$ ./sendmsg 0x123 "hello, world" 给第一个队列发送一条消息
queue id 196608, message hello, world
$ ./sendmsg 0x124 "just a test" 给第二个队列发送一条消息
queue id 196609, nessage just a test
$ ./ sendmsg 0x125 "bye" 给第三个队列发送一条消息
queue id 196610, nessage bye

命名UNIX域套接字

虽然socketpair函数能创建一对相互连接的套接字,但是每一个套接字都没有名字。这意味着无关进程不能使用它们。

可以命名UNIX域套接字,并可将其用于告示服务。但是要注意,UNIX域套接字使用的地址格式不同于因特网域套接字。套接字地址格式会随实现而变。UNIX域套接字的地址由sockaddr_un结构表示。sockaddr_un结构在头文件<sys/un.h>中的定义如下:

1
2
3
4
struct sockaddr_un {
sa_family_t sun_tamily; /* AF_UNIX */
char sun_path[108]; /* pathnome */
};

sockaddr_un结构的sun_path成员包含一个路径名。当我们将一个地址绑定到一个UNIX域套接字时,系统会用该路径名创建一个S_IFSOCK类型的文件。该文件仅用于向客户进程告示套接字名字。该文件无法打开,也不能由应用程序用于通信。如果我们试图绑定同一地址时,该文件已经存在,那么bind请求会失败。当关闭套接字时,并不自动删除该文件,所以必须确保在应用程序退出前,对该文件执行解除链接操作。

所示的程序是一个将地址绑定到UNIX域套接字的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "apue.h"
#include <sys/socket.h>
#include <sys/un.h>

int
main(void)
{
int fd, size;
struct sockaddr_un un;

un.sun_family = AF_UNIX;
strcpy(un.sun_path, "foo.socket");
if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0)
err_sys("socket failed");
size = offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path);
if (bind(fd, (struct sockaddr *)&un, size) < 0)
err_sys("bind failed");
printf("UNIX domain socket bound\n");
exit(0);
}

确定绑定地址长度的方法是,先计算sun_path成员在sockaddr_un结构中的偏移量,然后将结果与路径名长度(不包括终止null字符)相加。因为sockaddr_un结构中sun_path之前的成员与实现相关,所以我们使用<stddef.h>头文件(包括在apue.h中)中的offsetof宏计算sun_path成员从结构开始处的偏移量。如果查看<stddef.h>,则可见到类似于下列形式的定义:

1
#define offsetof (TYPE, MEMBER) ((int)&((TYPE *)0)->MEMBER)

假定该结构从地址0开始,此表达式求得成员起始地址的整型值。

唯一连接

服务器进程可以使用标准bindlistenaccept函数,为客户进程安排一个唯一UNIX域连接。客户进程使用connect与服务器进程联系。在服务器进程接受了connect请求后,在服务器进程和客户进程之间就存在了唯一连接。

图17-6展示了客户进程和服务器进程存在连接之前二者的情形。服务器端把它的套接字绑定到sockaddr_un的地址并监听新的连接请求。图17-7展示了在服务器端接受客户端连接请求后,客户端和服务器端之间建立的唯一的连接。

1
2
3
4
5
6
7
#include"apue.h"
int serv_listen (const char *name);
// 返回值:若成功,返回要监听的文件描述符;若出错,返回负值
int serv_accept (int listenfd, uid_t *uidptr);
// 返回值:若成功,返回新文件描述符;若出错,返回负值
int cli_conn(const char *name);
// 返回值:若成功,返回文件描述符;若出错,返回负值

服务器进程可以调用serv_listen函数声明它要在一个众所周知的名字上监听客户进程的连接请求。当客户进程想要连接至服务器进程时,它们将使用该名字。serv_listen函数的返回值是用于接收客户进程连接请求的服务器UNIX域套接字。服务器进程可以使用serv_accept函数等待客户进程连接请求的到达。当一个请求到达时,系统自动创建一个新的UNIX域套接字,并将它与客户端套接字连接,最后将这个新套接字返回给服务器。此外,客户进程的有效用户ID存放在uidptr指向的存储区中。客户进程调用cli_conn函数连接至服务器进程。客户进程指定的name参数必须与服务器进程调用serv_listen函数时所用的名字相同。函数返回时,客户进程得到接连至服务器进程的文件描述符。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include "apue.h"
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>

#define QLEN 10

/*
* Create a server endpoint of a connection.
* Returns fd if all OK, <0 on error.
*/
int
serv_listen(const char *name)
{
int fd, len, err, rval;
struct sockaddr_un un;

if (strlen(name) >= sizeof(un.sun_path)) {
errno = ENAMETOOLONG;
return(-1);
}

/* create a UNIX domain stream socket */
if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0)
return(-2);

unlink(name); /* in case it already exists */

/* fill in socket address structure */
memset(&un, 0, sizeof(un));
un.sun_family = AF_UNIX;
strcpy(un.sun_path, name);
len = offsetof(struct sockaddr_un, sun_path) + strlen(name);

/* bind the name to the descriptor */
if (bind(fd, (struct sockaddr *)&un, len) < 0) {
rval = -3;
goto errout;
}

if (listen(fd, QLEN) < 0) { /* tell kernel we're a server */
rval = -4;
goto errout;
}
return(fd);

errout:
err = errno;
close(fd);
errno = err;
return(rval);
}

首先,调用socket创建一个UNIX域套接字。然后将欲赋给套接字的众所周知的路径名填入sockaddr_un结构。该结构是调用bind的参数。注意,不需要设置某些平台提供的sun_len字段,因为操作系统会用传送给bind函数的地址长度设置该字段。最后,调用listen函数来通知内核该进程将作为服务器进程等待客户进程的连接请求。当收到一个客户进程的连接请求后,服务器进程调用serv_accept函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include "apue.h"
#include <sys/socket.h>
#include <sys/un.h>
#include <time.h>
#include <errno.h>

#define STALE 30 /* client's name can't be older than this (sec) */

/*
* Wait for a client connection to arrive, and accept it.
* We also obtain the client's user ID from the pathname
* that it must bind before calling us.
* Returns new fd if all OK, <0 on error
*/
int
serv_accept(int listenfd, uid_t *uidptr)
{
int clifd, err, rval;
socklen_t len;
time_t staletime;
struct sockaddr_un un;
struct stat statbuf;
char *name;

/* allocate enough space for longest name plus terminating null */
if ((name = malloc(sizeof(un.sun_path + 1))) == NULL)
return(-1);
len = sizeof(un);
if ((clifd = accept(listenfd, (struct sockaddr *)&un, &len)) < 0) {
free(name);
return(-2); /* often errno=EINTR, if signal caught */
}

/* obtain the client's uid from its calling address */
len -= offsetof(struct sockaddr_un, sun_path); /* len of pathname */
memcpy(name, un.sun_path, len);
name[len] = 0; /* null terminate */
if (stat(name, &statbuf) < 0) {
rval = -3;
goto errout;
}

#ifdef S_ISSOCK /* not defined for SVR4 */
if (S_ISSOCK(statbuf.st_mode) == 0) {
rval = -4; /* not a socket */
goto errout;
}
#endif

if ((statbuf.st_mode & (S_IRWXG | S_IRWXO)) ||
(statbuf.st_mode & S_IRWXU) != S_IRWXU) {
rval = -5; /* is not rwx------ */
goto errout;
}

staletime = time(NULL) - STALE;
if (statbuf.st_atime < staletime ||
statbuf.st_ctime < staletime ||
statbuf.st_mtime < staletime) {
rval = -6; /* i-node is too old */
goto errout;
}

if (uidptr != NULL)
*uidptr = statbuf.st_uid; /* return uid of caller */
unlink(name); /* we're done with pathname now */
free(name);
return(clifd);

errout:
err = errno;
close(clifd);
free(name);
errno = err;
return(rval);
}

服务器进程在调用serv_accept中阻塞,等待一个客户进程调用cli_conn。从accept返回时,返回值是连接到客户进程的崭新的描述符。另外,accept函数也经由其第二个参数(指向sockaddr_un结构的指针)返回客户进程赋给其套接字的路径名(包含客户进程ID的名字)。接着,程序复制这个路径名,并确保它是以null终止的(如果路径名占用了sockaddr_un结构里的sun_path成员所有的可用空间,那就没有空间存放终止null字符)。然后,调用stat函数验证:该路径名确实是一个套接字;其权限仅允许用户读、用户写以及用户执行。还要验证与套接字相关联的3个时间参数不比当前时间早30秒。

如若通过了所有这些检验,则可认为客户进程的身份(其有效用户ID)是该套接字的所有者。虽然这种检验并不完善,但这是对当前系统所能做到的最佳方案。

客户进程调用cli_conn函数对连到服务器进程的连接进行初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#include "apue.h"
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>

#define CLI_PATH "/var/tmp/"
#define CLI_PERM S_IRWXU /* rwx for user only */

/*
* Create a client endpoint and connect to a server.
* Returns fd if all OK, <0 on error.
*/
int
cli_conn(const char *name)
{
int fd, len, err, rval;
struct sockaddr_un un, sun;
int do_unlink = 0;

if (strlen(name) >= sizeof(un.sun_path)) {
errno = ENAMETOOLONG;
return(-1);
}

/* create a UNIX domain stream socket */
if ((fd = socket(AF_UNIX, SOCK_STREAM, 0)) < 0)
return(-1);

/* fill socket address structure with our address */
memset(&un, 0, sizeof(un));
un.sun_family = AF_UNIX;
sprintf(un.sun_path, "%s%05ld", CLI_PATH, (long)getpid());
printf("file is %s\n", un.sun_path);
len = offsetof(struct sockaddr_un, sun_path) + strlen(un.sun_path);

unlink(un.sun_path); /* in case it already exists */
if (bind(fd, (struct sockaddr *)&un, len) < 0) {
rval = -2;
goto errout;
}
if (chmod(un.sun_path, CLI_PERM) < 0) {
rval = -3;
do_unlink = 1;
goto errout;
}

/* fill socket address structure with server's address */
memset(&sun, 0, sizeof(sun));
sun.sun_family = AF_UNIX;
strcpy(sun.sun_path, name);
len = offsetof(struct sockaddr_un, sun_path) + strlen(name);
if (connect(fd, (struct sockaddr *)&sun, len) < 0) {
rval = -4;
do_unlink = 1;
goto errout;
}
return(fd);

errout:
err = errno;
close(fd);
if (do_unlink)
unlink(un.sun_path);
errno = err;
return(rval);
}

调用socket函数创建UNIX域套接字的客户进程端,然后用客户进程专有的名字填入sockaddr_un结构。

绑定的路径名的最后5个字符来自客户进程ID。仅在该路径名已存在时调用unlink。然后,调用bind将名字赋给客户进程套接字。这在文件系统中创建了一个套接字文件,所用的名字与被绑定的路径名一样。接着,调用chmod关闭除用户读、用户写以及用户执行以外的其他权限。

serv_accept中,服务器进程检验这些权限以及套接字用户ID以验证客户进程的身份。然后,必须填充另一个sockaddr_un结构,这次用的是服务进程众所周知的路径名。最后,调用connect函数初始化与服务进程的连接。

传送文件描述符

在两个进程之间传送打开文件描述符的技术是非常有用的。它使一个进程(通常是服务器进程)能够处理打开一个文件所要做的一切操作(包括将网络名翻译为网络地址、拨号调制解调器、协商文件锁等)以及向调用进程送回一个描述符,该描述符可被用于以后的所有I/O函数。涉及打开文件或设备的所有细节对客户进程而言都是透明的。

当一个进程向另一个进程传送一个打开文件描述符时,我们想让发送进程和接收进程共享同一文件表项。图中显示了所期望的安排。

在技术上,我们是将指向一个打开文件表项的指针从一个进程发送到另外一个进程。该指针被分配存放在接收进程的第一个可用描述符项中。两个进程共享同一个打开文件表,这与fork之后的父进程和子进程共享打开文件表的情况完全相同)。

当发送进程将描述符传送给接收进程后,通常会关闭该描述符。发送进程关闭该描述符并不会真的关闭该文件或设备,其原因是该描述符仍被视为由接收进程打开(即使接收进程尚未接收到该描述符)。下面定义本章用以发送和接收文件报述符的3个函数。

1
2
3
4
5
6
#include "apue.h"
int send_fd(int fd, int fd_no_send);
int send_err(int fd, int status, const char *errmsg);
// 两个函数的返回值,若成功,返回0;若出错,返回-1
int recv_fd(int fd, ssize_t (*userfunc) (int, const void *, size_t));
// 返回值:若成功,返回文件描述符:若出错,返回负值

当一个进程(通常是服务器进程)想将一个描述符传送给另一个进程时,可以调用send_fdsend_err。等待接收描述符的进程(客户进程)调用recv_fdsend_fd使用fd代表的UNIX域套接字发送描述符fd_to_sendsend_err使用fd发送errmsg以及后随的stahus字节。status的值应在-1~—255。

客户进程调用recv_fd接收描述符。如果一切正常(发送者调用了send_fd),则函数返回值为非负描述符。否则,返回值是由send_err发送的status(-1~—255的一个负值)。另外,如果服务器进程发送了一条出错消息,则客户进程调用它自己的userfunc函数处理该消息。userfunc的第一个参数是常量STDERR_FILENO,然后是指向出错消息的指针及其长度。userfunc函数的返回值是已写的字节数或负的出错编号值。客户进程常将普通的write函数指定为userfunc

为发送一个描述符,send_fd先发送2字节0,然后是实际描述符。为了发送一条出错消息,send_err发送errmsg,然后是1字节0,最后是status字节的绝对值(1~255)。recv_fd函数读取套接字中所有字节直至遇到null字符。null字符之前的所有字符都传送给调用者的userfuncrecv_fd读取的下一个字节是状态(status)字节。若状态字节为0,则表示一个描述符已传送过来,否则表示没有描述符可接收。send_err函数在将出错消息写到套接字后,即调用send_fd函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "apue.h"

/*
* Used when we had planned to send an fd using send_fd(),
* but encountered an error instead. We send the error back
* using the send_fd()/recv_fd() protocol.
*/
int
send_err(int fd, int errcode, const char *msg)
{
int n;

if ((n = strlen(msg)) > 0)
if (writen(fd, msg, n) != n) /* send the error message */
return(-1);

if (errcode >= 0)
errcode = -1; /* must be negative */

if (send_fd(fd, errcode) < 0)
return(-1);

return(0);
}

为了用UNIX域套接字交换文件描述符,调用sendmsg(2)recvmsg(2)函数。这两个函数的参数中都有一个指向msghdr结构的指针,该结构包含了所有关于要发送或要接收的消息的信息。该结构的定义大致如下:

1
2
3
4
5
6
7
8
9
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* address size in bytes */
struct iovec *msg_iov; /* array of I/O butters */
int msg_iovlens /* number of elements in array */
void *msg_control; /* ancillary data */
socklen_t msg_controllen; /* number of ancillery bytes */
int msg_flags; /* flags for received message */
};

前两个元素通常用于在网络连接上发送数据报,其中目的地址可以由每个数据报指定。接下来的两个元素使我们可以指定一个由多个缓冲区构成的数组(散布读和聚集写),这与对readvwritev函数的说明一样。msg_flags字段包含了描述接收到的消息的标志,总结了这些标志。

两个元素处理控制信息的传送和接收。msg_control字段指向cmsghdr(整制信息头)结构,msg_controllen字段包含控制信息的字节数。

1
2
3
4
5
6
struct cmsghdr {
socklen_t cmsg_len; /* data byte count, including header */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specirie type */
/* followed by the actual control message data */
};

在此定义3个宏,用于访问控制数据,一个宏用于帮助计算cmsg_len所使用的值。

1
2
3
4
5
6
7
8
9
#include <sys/socket.h>
unsigned char *CMSG_DATA(struct cmsghdr* cp);
// 返回值:返回一个指针,指向与cmsghdr结构相关联的数据
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr* mp);
// 返回值,返回一个指针,指向与msghdr结构相关联的第一个cmsghdr结构,若无这样的结构,返回null
struct cmsghdr *CMSG_NXTHDR(struct maghdr *mp, struct cmsghdr *cp);
// 返回值:返回一个指针,指向与msghdr结构相关联的下一个cmsghdr结构,该msghdr结构给出了当前的cmsghdr结构;若当前cmsghdr结构已是最后一个,返回NULL
unsigned int CMSG_LEN(unsigned int nbytes);
// 返回值。返回为nbytes长的数据对象分配的长度

指令集基本原理

引言

本附录主要介绍指令集体系结构。我们主要关注四个主题。

  • 第一,对各种指令集进行了分类并对各种方法的优势和劣势进行某种量化评估。
  • 第二,给出一些指令集测量数据,并对其进行分析,这些数据大体与特定的指令集无关。
  • 第三,讨论语言与编译器问题以及它们对指令集体系结构的影响。
  • 最后,展示这些思想如何在MIPS指令集中得到反映,MIPS 指令集是-种典型的RISC体系结构。
  • 在附录的末尾是有关指令集设计的一些谬论和易犯错误。

在这个附录中,我们将研究大量体系结构方面的测试结果。显然,这些测量结果依赖于被测程序和用于进行这些测量的编译器。

指令集体系结构的分类

处理器中的内部存储类型是最基本的区别,所以在这一节,我们将主要关注体系结构中这一部分的各种选项。主要选项包括栈、累加器或寄存器组。操作数可以显式命名,也可以隐式命名:在栈体系结构中,操作数隐式位于栈的顶部,而在累加器体系结构中,操作数隐式为累加器。通用寄存器体系结构只有显式操作数,或者为寄存器,或者为存储器位置。图A-1显示了此类体系结构的框图,表A-1显示了代码序列 C=A+B 在这三类指令集中通常是如何显示的。显式操作数也许可以直接从存储器访问,也可能需要首先加载到临时存储中,具体取决于体系结构的类别及特定指令的选择。


图A-1 4类指令集体系结构的操作数位置。这些箭头指示操作数是算术逻辑单元(ALU)操作的输入还是结果,或者既是输入又是结果。较浅的阴影表示输入,深色阴影表示结果。在(a)中,栈顶寄存器(TOS)指向顶部输入操作数,它与下面的操作数合并在一起。第一个操作数从栈中移走,结果占据第二个操作数的位置,对TOS进行更新,以指向结果值。所有操作数都是隐式的。在(b)中,累加器既是隐式输入操作数,也是结果。在(c)中,一个输入操作数是寄存器,一个在存储器中,结果保存在寄存器中。在(d)中,所有操作数都是寄存器,而且和栈体系结构类似,只能通过独立指令将其传送到存储器中:在(a)中为push或pop,在(d)中为1oad或store

  • 注意,对于栈和累加器体系结构,Add指令拥有隐式操作数,对于寄存器体系结构拥有显式操作数。假定A、B和C都属于存储器,A和B的值不能被销毁。图A-1显示了针对每类体系结构的Add运算。

如上面的图和表所示,实际上有两种类型的寄存器计算机。一类可以用任意指令来访问存储器,称为寄存器-存储器体系结构,另一类则只能用载入和存储指令来访问存储器,称为载入-存储体系结构。第三类将所有操作数都保存在存储器中,称为存储器-存储器体系结构。一些指令集体系结构的寄存器要多于单个累加器,但对这些特殊寄存器的使用设置了一些限制。 此类体系结构有时被称为扩展累加器或专用寄存器计算机。

尽管大多数早期计算机都使用栈或累加器类型的体系结构,但在1980年之后的几乎所有新体系结构都使用了载入-存储寄存器体系结构。通用寄存器(GPR)计算机之所以会出现,其主要原因有两个。第一,寄存器(类似于处理器内部其他形式的存储器)快于存储器。第二,对编译器来说,使用寄存器要比使用其他内部存储形式的效率更高。例如,在寄存器计算机中,在对表达式(AXB) - (BXC) - (AXD)求值时,可以按任意顺序执行乘法计算,这种做法的效率更高一些,可能是操作数位置的原因,也可能是流水线因素的原因。不过,在栈计算机上,硬件只能按唯一的顺序对表达式进行求值, 这是因为操作数是隐藏在栈中的,它必须多次载入操作数。

更重要的是,寄存器可用于保存变量。当变量被分配到寄存器中时,可以降低存储器通信流量、加快程序速度(由于寄存器的速度快于存储器),提高代码密度(由于寄存器的名称位数少于存储器位置的名称位数)。

在A.8节将会解释,编译器编写入员希望所有寄存器都是等价的、无保留的。较早的计算机在满足这一期望方面打了折扣,将一些寄存器专门用于一些特殊应用,显著降低了通用寄存器的数量。如果真正通用寄存器的数目过小,那尝试将变量分配到寄存器中就没有什么好处。

编译器将所有未确认用途的寄存器保留给表达式求值使用。有多少个寄存器才是足够的呢?其答案当然取决于编译器的有效性。大多数编译器会为表达式求值保留一些寄存器,为参数传递使用一些,其余寄存器可用于保存变量。现代编译器技术能够有效地使用大量寄存器,从而增加了最新体系结构的寄存器数目。

有两个重要指令集特性可以用来区分GPR体系结构。这两个特性都关注一个典型算术或逻辑指令(ALU指令)操作的本质。第一个特性关注一个ALU指令是有两个还是三个操作数。在三操作数格式中,指令包含一个结果操作数和两个源操作数。在两操作数格式中,操作数之一既是运算的源操作数,又是运算的结果操作数。GPR体系结构的第二个区别是考虑ALU指令中可能有多少个操作数是存储器地址。一个典型ALU指令所支持的存储器操作数数量可以是
0~3个。表A-2给出了这两种特性的组合及其计算机示例。尽管共有7种可能组合方式,但其中3种就可以对几乎全部现有计算机进行分类。前面曾经提到,这3种是载入存储(也称为寄存器-寄存器)、寄存器-存储器和存储器-存储器。

ALU指令中没有存储器引用的计算机称为载入-存储或寄存器-寄存器计算机。每条典型ALU指令中有多个存储器操作数的指令称为寄存器-存储器或存储器-存储器,具体取决于它们是拥有一个还是 一个以上的存储器操作教。

表A-3显示了每一类型的优势和劣势。当然,这些优势和劣势不是绝对的:它们是定性的,它们的实际影响取决于编译器和实现策略。采用存储器存储器运算的GPR计算机很容易被编译器忽略,用作一种载入存储计算机。体系结构方面最普遍的影响之一是对指令编码和执行一项任务所需指令数的影响。在附录C和第3章中可以了解这些不同体系结构对实现方法的影响。

  • 符号(m,n)表示存储器操作数有m个,共有n个操作数。一般来说,可选项较少的计算机简化了编译器的任务,因为编译器需要作出的决策较少。具有大量灵活指令格式的计算机减少了程序编码需要的位数。寄存器的数目对指令大小也有所影响,因为对于指令中的每个寄存器分类符,需要log2(寄存器个数)。因此,对于寄存器-寄存器体系结构而言,寄存器个数加倍需要增加3个位,大约是32位指令的10%。

存储器寻址

一个体系结构,无论是载入存储式,还是允许任何操作数都是存储器引用,它都必须定义如何解释存储器地址以及如何指定这些地址。这里给出的测量值大体与计算机无关,但并非绝对如此。在某些情况下,这些测量值受编译器技术的影响很大。由于编译器技术扮演着至关重要的角色,所以这些测量都是使用一种优化编译器测得的。

解释存储器地址

根据地址和长度会访问到什么对象呢?本书中讨论的所有指令集都是字节寻址的,提供对字节(8位)半字(16位)和字(32位)的访问方式。大多数计算机还提供了对双字(64位)的访问。关于如何对一个较大对象中的字节进行排序,有两种不同的约定方式。小端字节顺序将地址为“…x000” 的字节放在双字的最低有效位置(小端)。字节的编号为:

7 6 5 4 3 2 1 0

大端字节顺序将地址为“x.. .x000”的字节放在双字的最高有效位置(大端)。字节的编号为:

0 1 2 3 4 5 6 7

在同一台计算机内部进行操作时,字节顺序通常不会引起人们的注意——只有那些将相同位置同时作为字和字节进行访问的程序才会注意到这一区别。但是,在采用不同排序方式的计算机之间交换数据时,字节顺序就会成为一个问题。在对比字符串时,小端排序也不能与字的正常排序方式相匹配。字符串在寄存器中是反向表示的,如backwards显示为“SDRAWKCAB”。

第二个存储器问题是:在许多计算机中,对大于一字节的对象进行寻址时都必须是对齐的。大小为s字节的对象,字节地址为A,如果A mod s=0,则对该对象的寻址是对齐的。图A-2显示了寻址为对齐和不对齐时的地址。

图A-2在字节寻址计算机中,字节、半宇、字和双字对象的对齐与未对齐地址。对于每种未对齐示例,一些对象需要两次存储器访问才能完成。每个对齐对象总是可以在一次存储器访问中完成,只要存储器与对象的宽度相同即可。本图显示的存储器宽度为8个字节。标记各列的字节偏移指定了该地址的低3位

由于存储器通常与一个字或双字的倍数边界对齐,所以非对齐寻址会增加硬件复杂性。一个非对齐存储器寻址可能需要多个对齐的存储器引用。因此,即使在允许非对齐寻址的计算机中,采用对齐寻址的程序也可以运行得更快些。即使数据是对齐的,要支持字节、半字和字寻址也需要一个对齐网络来对齐64位寄存器中的字节、半字和字。例如,在表A-4中,假定从低3位取值为4的地址中读取一个字节。我们需要右移3个字节,对准64位寄存器中正确位置的字节。根据具体指令,计算机可能还需要对这个量进行符号扩展。存储过程很容易:只有存储器的寻址字节可被修改。在某些计算机中,字节、半字和字操作不会影响到寄存器的上半部分。尽管本书中讨论的所有计算机都允许对存储器进行字节、半字和字访问,但只有IBM 360/370、Intel 80x86和VAX支持对不足完整宽度的寄存器操作数进行ALU运算。

既然已经讨论了存储器寻址的各种解释方法,现在可以讨论指令用来指定地址的方式了,这些方式称为寻址方式。

寻址方式

给定地址后,我们就知道了去访问存储器的哪些字节。在这一小节中,我们将研究寻址方式——体系结构如何指定要访问对象的地址。除了存储器中的位置之外,寻址方式还指定常量和寄存器。在使用存储器位置时,由寻址方式指定的实际存储器地址称为有效地址。

表A-4显示了在最近计算机中用到的所有数据寻址方式。立即数或直接操作数寻址通常被看作存储器寻址模式(即使它们访问的值位于指令流中也是如此),不过,由于寄存器通常没有存储器地址,所以我们将它们分离出来。我们已经将那些依赖于程序计数器的寻址模式(称为PC相对寻址)分离出来。PC相对寻址主要用于在控制转移指令中指定代码地址,A.6 节将对此进行讨论。

  • 在自动递增/递减和比例寻址方式中,变量d指定所访问数据项的大小(即,该指令访问的是1、2、4或8字节中的哪一种)。只有当被访问元素位于存储器中的连续位置时,这些寻址方式才有用。RISC计算机使用位移量寻址来模拟寄存器间接寻址(地址为0)和直接寻址(基址寄存器中为0)。在我们的测量结果中,使用为每种模式显示的第一个名称。在A.9.5节定义了用作硬件描述的C语言扩展。

Mem[Regs[R1]]是指存储器位置的内容,这一位置的地址由寄存器1(R1)的内容给出。对于小于一个字的数据 ,我们将在后面介绍用于访问和转移此类数据的扩展。

寻址模式能够大幅臧少指令数目,它们也会增加构建计算机的复杂度,对于实施这些方式的计算机,还可能增加每条指令的平均时钟周期数目(CPI)。 因此,各种寻址模式的使用对于帮助架构师选择包含哪些功能是十分重要的。

图A-3中给出在VAX体系结构上对3个程序中寻址方式使用样式的测量结果。在这个附录中,我们使用较旧的VAX体系结构进行一些测量, 这是因为它拥有最丰富的寻址方式,对存储器寻址的限制最少。例如,表A-2给出了VAX支持的所有方式。

图A-3 存储器寻址方式(包括立即数)的用法小结。几乎所有存储器访问都采用这几种主要寻址方式。一半的操作数引用采用寄存器寻址方式,而另一半则采用存储器寻址方式(包括立即数)。当然,编译器会影响到选用哪种寻址模式。VAX上的存储器间接寻址方式可使用位移量、自动递增或自动递减来形成初始存储器地址;在这些程序中,几乎所有存储器间接引用都以位移量寻址方式为基准方式。

位移量寻址方式,

在使用位移量类型的寻址方式时,一个主要问题就是所用位移量的范围。根据所使用的各种位移量大小,可以决定支持哪些位移量大小。由于位移量字段的大小直接影响到指令的长度,所以其选择非常重要。图A-4是利用基准测试程序对载入-存储体系结构中数据访问进行的测量结果。

图A-4 位移值的分布非常广泛。既存在大量小数值,又有相当数量的大数值。位移值的广泛分布是由于变量有多个存储区域,而且访问它们的位移量不同,而且编译器使用的总寻址机制也各不相同。x轴是位移量以2为底的对数值,即表示该位移量所需要的字节大小。x轴上的零表示位移值0的百分比。该曲线没有包含符号位,存储布局对它会有严重影响。大多数位移值是正数,但最大的位移值(14位以上)为负值。

立即数或直接操作数寻址方式

在进行算术运算、比较(主要用于分支)和移动时,如果希望将常量放在寄存器中,可以使用立即数。后一种情景可用于写在代码中的常量(这种常量较小)和地址常量(这种常量可能很大)。对于立即数的使用,重点是要知道是需要对所有运算都支持立即数,还是仅对一部分运算支持立即数。图A-5显示了在一个指令集中,立即数在一般类型的整数和浮点运算中的使用频率。

图A-5 大约有四分之一的数据传送和ALU运算拥有立即操作数。下面的长条表示整数程序在大约五分之一的指令中使用立即数,而浮点程序在大约六分之一的指令中使用立即数。对于载入操作,载入立即数指令将16位载入一个32位寄存器的任一半中。载入立即数并不是严格意义上的载入,因为它们并不访问存储器。偶尔会使用一对载入立即数来载入32位常量,但这种情况很少见。

另一个重要指令集测量是立即数的取值范围。与位移值相似,立即数取值的大小也影响到指令长度。如图A-6所示,小立即数的应用最多。不过,有时也会使用大型立即数,更多的是用在地址计算中。

图A-6 立即数值的分布。 x轴给出表示立即值取值大小所需要的位数——0表示立即数字段值为0。大多数立即数取值为正数。对于CINT2000,大约20%为负数,对于CFP2000,大约30%为负数。这些测量是在Alpha上执行的,其中最大立即数为16位,被测程序与图A-4中相同。对VAX进行的类似测量(它支持32位立即数)表明:大约20%~25%的立即数长于16位。因此,16 位的长度覆盖大约80%,8位可以覆盖大约50%

小结:存储器寻址

首先,我们预测一种新的体系结构至少支持以下寻址方式:位移量寻址、立即数寻址和寄存器间接寻址,这主要是因为它们非常普及。图A-3显示它们代表了我们测量中所使用的75%~99%的寻址方式。其次,我们预测位移量寻址方式中的地址大小至少为12~16位,根据图A-4的图题,这些大小将占到位移量的75%~99%。最后,我们预测立即数字段的大小至少为8~16位。这一说法在它提到的图题中没有得到证实。

我们已经介绍了指令集分类并决定采用寄存器-寄存器体系结构,再加上前面关于数据寻址模式的建议,下面将介绍数据的大小与意义。

操作数的类型与大小.

如何指定操作数的类型呢?通常,通过在操作码中进行编码来指定操作数的类型。或者,用一些可以被硬件解读的标签对数据进行标记。这些标签指定操作数的类型,并相应选择操作。

我们首先从台式机和服务器体系结构开始。通常操作数的类型(整数、单精度浮点、字符等)有效地确定了其大小。常见操作数类型包括字符(8位)半字(16位)字(32位)、单精度浮点(也是1个字)和双精度浮点(2个字)。整数几乎都是用二进制补码数字表示的。字符通常用ASCII表示,但随着计算机的国际化,16位Unicode(在Java中使用)也正在普及。几乎所有的计算机都遵循了相同的浮点标准——IEEE标准。

一些体系结构提供了对字符串的操作,不过这些操作通常都十分有限,将字符串中的每个字符都看作单个字符。支持对字符串执行的典型操作包括比较和移动。

对于商务应用程序,一些体系结构支持二进制格式,通常称为压缩十进制或二进制编码十进制——用4个位对0至9的数值进行编码,两个十进制数位被压缩到两个字节中。数值字符串有时称为非压缩十进制,通常提供在被称为压缩和解压缩的操作之间来回转换。

使用十进制操作数的一个理由是获得与二进制数字完全匹配的结果,这是因为一些十进制小数无法用二进制准确表示。十进制中的准确计算在二进制中可能十分接近但并非完全准确。

我们的SPEC基准测试使用字节或字符、半字(短整数)字(整数)双字(长整数)和浮点数据类型。图A-7给出了为这些程序引用存储器对象的大小动态分布。对不同数据类型的访问频率有助于确定哪些类型最为重要,应当加以高效支持。计算机是否应当拥有64位访问路径,或者用两个时钟周期来访向一个双字是否可行?我们前面曾经看到,字节访问需要一个对齐网络:将字节作为基元类型提供支持有多么重要?图A-7使用存储器引用来查看被访问数据
的类型。

图A-7 对于基准测试程序,所访问数据的大小分布。双字数据类型用于表示浮点程序中的双精度浮点值,还用于表示地址,这是因为该计算机使用64位地址。在采用32位地址的计算机上,64 位地址将被32位地址代替,所以整数程序中的几乎所有双字访问都变为单字访问

在一些体系结构中,寄存器中的对象可以作为字节或半字进行访问。但是,这种访问是非常罕见的——在VAX上,不超过12%的寄存器引用采用这种方式,也就是这些程序中所有操作数访问的大约6%。

指令集中的操作

大多数指令集体系结构支持的操作符可以如表A-5那样进行分类。关于所有体系结构的一条经验规律是:执行最多的指令是一个指令集中的简单操作。例如,表A-6给出了10种简单指令,对于一组在流行80x86上运行的整数程序,这10种简单指令占所执行指令的96%。因为它们很常见,所以这些指令的实现应当确保它们能够快速完成。

  • 在不同体系结构中,指令集中对系统功能的支持有所不同,但所有计算机都必须对一些基本的系统功能提供指令支持。指令集对后4类的支持数量可能为0,也可能包含大量特殊指令。在任何计算机中都提供浮点指令,供那些大量使用浮点数的应用程序使用。这些指令有时是可选指令集的一部分。十进制和字符串指令有时是基元类型,比如在VAX或IBM360中,也可能是由编译器使用更简单的指令合成的。图形指令通常会对许多较小的数据项进行并行操作。例如,对2个64位操作数执行8个8位加法。

前面曾经提到,表A-6中的指令在每一台计算机的每个应用程序(台式机、服务器和嵌入式)中都可以找到,会对表A-5中的操作进行一些变化,而这主要取决于该指令集包含哪些数据类型。

  • 简单指令是这个列表的主体,占所执行指令的96%。 这些百分数是5个SPECint92程序的均值。.

控制流指令

由于分支与跳转行为的测量在相当程度上与其他测量值和程序无关,所以我们现在研究控制流指令的使用,它与上一节的操作之间没有什么共同点。

关于改变控制流的指令,没有非常一致的术语。在20世纪50年代,它们通常被称为转移(transfer)。在20世纪60年代,开始使用分支(branch)一词。后来计算机还另外引入了一些名称。在本书中,当控制中的改变是无条件时,我们使用跳转(jump),当改变是有条件时,使用分支。

我们可以区分4种不同类型的控制流变化:

  • 条件分支;
  • 跳转;
  • 过程调用;
  • 过程返回;

由于每个事件都是不同的,可能使用不同指令,可能拥有不同行为,所以我们希望知道这些事件的相对频率。图A-8中给出了这些控制流指令在一个载入-存储计算机上的出现频率,我们就是在这种计算机上运行基准测试的。

图A-8 将控制流指令分为三类:调用或返回、跳转和条件分支。条件分支显然占绝大多数。每种类型的计数分别用三个长条来显示。用于收集这些统计数字的程序和计算机与图A-3中的数字相同

控制流指令的寻址方式

控制流指令中的目标地址在任何情况下都必须指定。在绝大多数情况下,这个目标是在指令中明确指定的,但过程返回是一个重要例外,这是因为在编译时无法知道要返回的目标位置。指定目标的最常见方法是提供一个将被加到程序计数器(PC)的位移量。这类控制流指令被称为PC相对指令。由于目标位置通常与当前指令的距离较近,而且,在指定相对于当前PC的位置时,需要的位数较少,所以PC相对分支或跳转具备一些优势。采用PC相对寻址还可以使代码的运行不受装载位置的影响。这一特性称为位置无关,可以在链接程序时减少一些工作,而且对于在执行期间进行动态链接的程序也比较有用。

如果在编译时不知道目标位置,为了实现返回和间接跳转,需要一种不同于PC相对寻址的方法。这时,必须有一种动态指定目标的方法,使目标能够在运行时发生变化。这种动态寻址可能非常简单,只需要给出包含目标地址的寄存器名称即可;跳转可能允许使用任意寻址方式来提供目标地址。

这些寄存器间接跳转对于其他4种重要功能也是有用的。

  • case或switch语句,大多数编程语言中都会有这些语句(用于选择几种候选项之一)。
  • 虚拟函数或虚拟方法,存在于诸如C++或Java之类的面向对象式语言中(允许根据参数类型调用不同例程)。
  • 高阶函数或函数指针,存在于诸如C或C++等语言中(它允许以参数方式传递一些函数,提供面向对象编程的一种好处)。
  • 动态共享库(允许仅当程序实际调用一个库时才在运行时加载和链接,而不是在运行程序之前进行静态加载和链接)。

在所有这4种情况下,目标地址在编译时都是未知的,因此,通常是在寄存器间接跳转之前从存储器加载到寄存器中。

由于分支通常使用PC相对寻址来指定其目标,一个重要的问题就是关注分支目标距离分支有多远。了解这些位移量的分布有助于选择支持哪些分支偏移量,从而会影响到指令长度和编码。图A-9给出了指令中PC相对分支的位移量分布。这些分支中大约有75%是正向的。

图A-9 分支距离(以目标与分支指令之间的指令数来表示)。整数程序中的最常见分支是转向可以用4~8位编码的目标地址。这一结果告诉我们,短位移量字段对于分支指令通常足够了,有了较小分支位移量的较短指令,设计者可以提高编码密度。这些测量结果是在载入-存储计算机上测得的,所有指令都与字边界对齐。对于同一程序,如果体系结构需要的指令较少(比如VAX),那分支距离就较短。但是,如果计算机的指令长度是变化的,可以与任意字节连接对齐,则表示该位移量所需要的位数可能会增加。

条件分支选项

由于大多数控制流改变都是分支,所以决定如何指定分支条件是很重要的。表A-7列出了当前使用的3种主要技术及其优缺点。

分支的最明显特性之一是大量比较都是简单的测试,其中有很多是与0进行比较。因此,一些体系结构选择将这些比较当作特殊情景进行处理,特别是在使用比较并分支指令中。图A-10给出了条件分支中用到的各种不同比较的频率。

  • 尽管条件代码可以由ALU运算设定(用于其他目的),但对程序的测量显示,这种情况会很少发生。当条件代码由一大组指令或一组偶然选定的指令设定,而不是由指令中的一个比特来设定时,就会出现条件代码的重要实现问题。拥有比较和分支指令的计算机通常会限制比较范围,使用条件寄存器进行更复杂的比较。通常,对于根据浮点比较进行的分支和基于整数比较进行的分支会采用不同技术。由于根据浮点比较执行的分支数目要远小于根据整数比较进行的分支数目,所以这种做法是合理的。

图A-10 条件分支中不同比较类型的使用频率。编译器与体系结构的这种组合中,小于(或等于)分支占主导地位。这些测量值包含了分支中的整数和浮点比较。用于收集这些统计数字的程度和计算机与图A4中相同

过程调用选项

过程调用和返回包括控制转移,还可能涉及一些状态保存过程;至少必须将返回地址保存在某个地方,有时保存在特殊的链接寄存器中,有时只是保存在GPR中。一些较早的体系结构提供了一种用于保存许多寄存器的机制,而较新的体系结构需要编译器为所存储和恢复的每个寄存器生成存储和载入操作。

在保存寄存器时,有两种基本约定:要么保存在调用位置,要么保存在被调用的过程内部。调用者保存是指发出调用的过程必须保存它希望在调用之后进行访问的寄存器,因此,被调用的过程不需要为寄存器操心。被调用者保存与之相反:被调用过程必须保存它希望使用的寄存器,而调用者不受限制。在某些时间必须使用调用者保存方法,其原因在于两种不同过程中对全局可见变量的访向样式。例如,假定我们有一个过程P1,它调用过程P2,这两个过程都对全局变量x进行处理。如果P1已经将x分配给一个寄存器,它必须确保在调用P2之前将x保存到P2知晓的一个位置。编译器希望知道被调用过程可能在什么时候访问寄存器分配量,由于不同进程可能是分别编译的,所以增加了获知这一信息的难度。假定P2可能不会触及x,但可能调用另一个可能访向x的进程P3,而P2和P3是分别编译的。由于这些复杂性的存在,大多数编译器会采用比较稳健的方式,由调用者将所有可能在调用期间访问的变量都保存起来

在可以采用任一约定的情况下,有些程序更适于采用被调用者保存,有些程序更适于采用调用者保存。结果,今天的大多数实际系统都采用这两种机制的组合方式。这一约定在应用程序二进制接口(ABI)中指定,它确定了一些基本规则,指出哪些寄存器应当由调用者保存,哪些应当由被调用者保存。

小结:控制流指令

控制流指令属于执行频率最高的一部分指令。尽管条件分支有许多选项,但我们希望新体系结构中的分支寻址能够跳转到分支指令之前或之后数百条指令处。这一要求意味着PC相对分支位移量至少为8位。我们还希望看到跳转指令采用寄存器间接和PC相对寻址,来支持过程返回和当前系统的许多其他功能。

我们现在已经从汇编语言程序员或编译器编写人员的层次,完成了对指令体系结构的浏览。我们介绍了一种采用位移量、立即数和寄存器间接寻址方式的载入-存储体系结构。所介绍的数据为8位、16位、32位和64位整数,还有32位和64位浮点数。指令包括简单操作、PC相对条件分支、用于过程调用的跳转和链接指令,还有用于过程返回的寄存器间接跳转(还有其他一些应用)。

现在我们需要选择如何采用一种便于硬件执行的方式来表示这一体系结构。

指令集编码

显然,上述选择会影响到如何对这些指令进行编码,表示为供计算机执行的二进制形式。这种表示形式不仅影响到程序经过编译后的大小,还会影响到处理器的实现,处理器必须对这种表示形式进行译码,快速找出操作和操作数。操作通常在一个称为操作码的字段中指定。后面将会看到,一个重要决定是如何通过编码将寻址方式与操作结合在一起。

这一决定取决于寻址方式的范围和操作码与寻址方式之间的独立程序。一些较早的计算机有1~5个操作数,每个操作数有10种寻址方式。对于如此大量的组合情况,通常需要为每个操作数使用独立地址标识符:地址标识符告诉使用哪种寻址方式来访问该操作数。

另一个极端是仅有一个存储器操作数、仅有一或两种寻址方式的载入存储计算机;显然,在这种情况下,可以将寻址方式作为操作码的一部分进行编码。

在对指令进行编码时,由于寄存器字段和寻址方式字段可能在-条指令中出现许多次,所以寄存器数目和寻址方式的数目都对指令大小有显著影响。事实上,对于大多数指令,对寻址方式字段和寄存器字体进行编码时所占用的位数,要远多于指定操作码所占用的位数。在对指令集进行编码时,架构师必须平衡以下几种竞争力量。

  1. 希望允许尽可能多的寄存器和寻址方式。
  2. 寄存器字段和寻址方式字段的大小对平均指令大小存在影响,从而对平均程序大小产生影响。
  3. 希望编码后的指令长度易于以流水线实施方式处理。至少,架构师希望指令长度为字节的总数,而不是任意位长度。

图A-11给出三种常见的指令集编码选择。第一种称为变长编码,这是因为它几乎允许对所有操作使用所有寻址方式。当存在许多寻址方式和操作时,这是最佳选择。第二种选择称为定长编码,因为它将操作和寻址方式合并到操作码中。通常,采用定长编码时,所有指令的大小都相同;当寻址方式与操作数较少时,其效果最好。变长编码与定长编码之间进行的权衡是程序大小与处理器译码的难易程度。变长编码在表示程序时尽力减少所用位数,各条指令的大小和要执行的工作量可能会有很大变化。


图A~11指令编码的三种基本变体:变长编码、定长编码、混合编码。变长格式可以支持任何数目的操作数,每个地址标识符确定操作数的寻址方式和标识符的长度。这种方式的代码表示长度通常是最短的,因为不会包含没有使用的字段。定长格式中的操作数个数总是相同的,寻址方式作为操作码的一部分进行指定。它生成的代码规模通常是最大的。尽管字段的位置不会变化,但不同指令会将其用于不同目的。混合编码方法拥有多种由操作码指定的格式,添加了一到两个字段来指定寻址方式,还有一到两个字段来指定操作数地址

让我们看一条80x86指令,作为变长编码的一个例子。

1
add EAX, 1000(EBX)

add是指一条有两个操作数的32位整数加法指令,这个操作码占1个字节。80x86地址标识符为1或2个字节,指定源/目标寄存器(EAX)和第二个操作数的寻址方式(在这个例子中为位移量)与基址寄存器(EBX)。这一组合占用1个字节来指定操作数。在32位模式中,地址字段的大小为1或4个字节。由于1000大于2^8,所以这条指令的长度是1+1+4=6个字节。

80x86指令的长度介于1~17个字节之间。80x86程序通常短于RISC体系结构,后者使用定长格式。

有了变长编码和定长编码这两种指令集设计的极端情况之后,立即就可以想到第三种选择:降低变长体系结构中指令大小与任务的变化程度,但提供多种指令长度,以缩小代码尺寸。这种混合式方法是第三种编码选择,稍后将会看到其示例。

RISC中 的精简代码

嵌入式应用程序中的成本和缩短代码非常重要,随着RISC计算机开始进入这一领域,32位定长格式已经成为一种负担。为应对这一情况,几家制造商提供了其RISC指令集的一种新混合版本,同时拥有16位和32位指令。这些较窄的指令支持较少的运算、较小的地址:与立即数字段、较少的寄存器和两地址格式,而不是RISC计算机的典型三地址格式。

小结:指令集编码

前几节讨论了指令集设计时所作的决策,这些决策决定了架构师是否能够在变长指令编码和定长指令编码之间进行选择。给定选择之后,那些看重代码规模多于性能的架构师会选择变长编码,而看重性能多于看重代码规模的架构师则会选择定长编码。附录E给出了架构师的13个选择结果示例。在第3章和附录C中进一步讨论这种变化对处理器性能的影响。我们几乎已经为将在A.9节介绍的MIPS指令集体系结构奠定了基础。但在开始介绍之前,先来简要地了解一下编译器技术及其对程序特性的影响,这也会有所帮助。

交叉问题:编译器的角色

今天,几乎所有的台式机和服务器应用程序都是用高级语言编写的。这种开发意味着:由于所执行的大多数指令都是编译器的输出,所以指令集体系结构基本上就是编译器目标。在这些应用程序的早期,在体系结构方面作出的决策经常是为了简化汇编语言编程,或者是针对特定内核。由于编译器会显著影响到计算机的性能,所以理解今天的编译器技术对于设计、高效实现指令集是至关重要的。

体系结构方面的选择会影响到为一台计算机生成的代码质量和为其构造优良编译器的复杂性,这种影响可能是正面的,也可能是负面的。在这一节,我们主要从编译器的视角来讨论指令集的关键目标。首先回顾对当前编译器的剖析。接下来讨论编译器技术如何影响架构师的决策,还有架构师如何增大或降低编译器生成良好代码的难度。最后回顾编译器和多媒体处理。

目前编译器的结构

首先让我们看看今天的最佳编译器是什么样的。图A-12显示了目前编译器的结构。

图A-12 编译器通常包括2~4遍扫描(pass),一些优化程度更高的编译器会有更多遍扫描。当输入相同时,以不同优化级别编译的程序应当给出相同结果,图上的结构将这种可能性增至最大。优化扫描的设计是希望获得最优代码,如果希望加快编译速度,并且可以接受较低质量的代码,那就可以跳过优化扫描。扫描就是编译器读取和转换整个程序的一个阶段(phase)。由于优化扫描是独立的,所以有多种语言使用了相同的优化和代码生成扫描。一种新的语言只需要一个新的前端即可

编译器编写人员的首要目标是正确性——所有有效程序的编译结果都必须正确。第二个目标通常是编译后的代码速度。通常,还有一整套目标排在这两个目标之后,包括快速编译、支持调试、语言之间的互操作性。正常情况下,编译器中的各次扫描将更抽象的高级表示转换为逐渐降低层级的表示方式。最后到达指令集级别。这种结构可以帮助控制转换的复杂度,更容易编写出没有错误的编译器。

正确编写编译器是一件很复杂的事情,而所能完成的优化程度主要受这一复杂度的限制。尽管采用多遍扫描结构可以帮助降低编译器的复杂性,但它也意味普编译器必须进行排序,某些转换必须在其他转换之前完成。在图A-12所示的优化编译器框图中可以看出,某些高级优化要在执行很久之后,才可能知道最终代码会是什么样子。一旦执行这种转换,编译器就不太可能再返回并重新审视所有步骤,甚至撤消这些转换,这样的成本太过高昂。无论是编译时间还是复杂性,都不允许进行这种迭代。因此,编译器假定最后的几个步骤有能力处理特殊的问题。

例如,在知道被调用过程的确切大小之前,编译器通常就必须选择对哪些进程调用进行内联展开。编译器编写人员将这一问题称为阶段排序问题。这种转换排序如何与指令集体系结构互动呢?一种名为全局公共子表达式消去法的优化提供了一个很好的例子。这种优化找出一个表达式计算相同取值的两个实例,并将第一次计算的结果值保存在临时存储位置。然后利用这个临时值,清除这一公共表达式的第二次计算。为使这一优化发挥显著效用,必须将临时值分配到寄存器中。否则,先将临时值存储在存储器中,之后再重新载入它,其成本将会抵消因为不用重复计算该表达式所节省的成本。

的确存在一些情况:如果没有将临时值保存到寄存器中,这一优化会减缓代码的运行速度。寄存器分配通常是全局优化扫描即将结束、马上要生成代码时进行的,所以阶段排序使上述问题变得复杂。因此,执行这一优化的优化程序必须假定寄存器分配器会将这一临时值分配到寄存器中。根据转移类型,可以将现代编译器执行的优化进行如下分类:

  • 高级优化一般对源代码执行,并将输出结果传送给之后的优化扫描;
  • 本地优化仅对直行代码段(编译器设计者称为基本块)内的代码进行优化;
  • 全局优化将本地优化扩展到分支范围之外,并引入了一组专为优化循环的转换;
  • 寄存器分配将寄存器与操作数关联在一起;
  • 与处理器相关的优化尝试充分利用特定的体系结构知识;

寄存器分配

鉴于寄存器分配在加快代码速度和使其他优化发挥效用方面扮演的角色,可以说它是最重要的优化之一。今天的寄存器分配算法以一种称为图形着色的技术为基础。这种图形着色技术背后的基本思想是构造一幅图,用来表示可能执行的寄存器分配方案,然后利用这个图来分配寄存器。大致来说,问题在于如何使用有限种颜色,使相关图中两个相邻节点的颜色都不相同。这种方法的重点是将活跃变量全部分配到寄存器中。图形着色问题的求解时间通常是图形大小的指数函数。不过,有一些启发式算法在实际中的应用效果很好,生成分配结果的时间近似与图形大小成线性关系。

当至少有16个通用寄存器(多多益善)可用于为整数变量进行全局分配,而且有其他寄存器分配为浮点变量时,图形着色方法的效果最好。遗憾的是,如果寄存器的数目很少,图形着色的启发式算法很可能会失败,所以图形着色的效果不是太好。

优化对性能的影响

有时很难将一些较简单的优化(本地优化和与处理器相关的优化)与代码生成器中完成的转换隔离开来。典型优化的示例在表A-8中给出。表A-8的最后一列指明对源程序执行所列优化转换的频率。

  • 第三列给出一些常见优化在一组12个小型Fortran和Pascal程序中的静态应用频率。在测量过程中,编译器共完成了9个本地与全局优化。图中给出了这些优化中的6种,剩下3种的总静态频率占18%。“未测量”是指没有测上该优化方法的使用次数。与处理器相关的优化通常是在代码生成器中完成,所有这些优化都未在此次试验中测量。所示百分比是特定类型的静态优化所占的比例。

图A-13显示了对两个程序的指令进行各种优化的效果。在这个例子中,与未经优化的程序相比,已优化程序执行的指令数会减少大约25%~90%。该图表明在提议新指令集功能之前首先浏览已优化代码的重要性,因为编译器可能会将架构师正在尝试改进的指令完全清除。

图A-13 当编译器优化级别变化时,SPEC2000 中lucas和mcf程序中指令数目的变化。第0级表示未优化代码。第1级包含本地优化、代码调度和本地寄存器分配。第2级包括全局优化、循环转换(软件流水线)和全局寄存器分配。第3级增加了过程整合。

编译器与高级语言之间的互动显著影响着程序利用指令集体系结构的方式。这里有两个重要问题:如何对变量进行分配和寻址?需要多少个寄存器才能对变量进行适当分配?为了回答这些问题,必须看看当前高级语言用来保存数据的三个独立区域。

  • 栈用于分配本地变量。栈会在进程调用与返回时相应增大或缩小。栈内的对象是相对于栈指针进行行寻址的,这些对象主要是标量(单变量),而不是数组。栈用于活动记录,而不是用于表达式求值。因此,几乎从来不会在栈中压入或弹出数值。
  • 全局数据区用于静态分配所声明的对象,比如全局变量和常量。这些对象中有很大一部分都是数组或者其他聚合数据结构。
  • 堆用于分配那些不符合栈规则的动态对象。堆中的对象用指针访问,通常不是标量。

对于分配到栈中的对象,寄存器分配的处理效率要远高于对全局变量的处理效率,而寄存器分配对于分配到堆中的对象基本上不可能实现,因为它们是用指针访问的。全局变量和一些栈变量也不可能分配,因为它们具有别名,也就是说有多种方法可以引用变量的地址,从而不能合法地将其放到寄存器中。

例如,考虑以下代码序列,其中&返回变量的地址,*取得指针所指向的对象:

1
2
3
4
p = &a;  // 将a的地址放入p中
a = .. // 直接为a赋值
*p = .. // 使用ρ为a赋值
...a... // 访问a

不可能跨过对*p的赋值而对变量a进行寄存器分配,同时还不生成错误代码。在使用别名时,通常很难甚至不可能判断指针可能指向哪些对象,所以会导致一个实质性问题。编译器必然为保守的,如果有指针可能指向过程中的多个本地变量之一,某些编译器就不会在寄存器中分配该过程的任意本地变量。

架构师如何帮助编译器编写人员

今天,编译器的复杂性并非来自对诸如A=B+C等简单语句的转换。大多数程序都具有局部简单性,简单转换的效果很好。之所以会有这种复杂性,是因为程序规模庞大而且其全局互动非常复杂,还因为编译器的结构决定了在判定哪种代码序列最佳时,一次只能判断一步。编译器编写人员在工作时,通常会遵循他们自己对一条体系结构基础原理的推论:加快常见情况的速度、保证少见情况的正确性。

一些指令集特性可以为编译器编写人员提供帮助。这些特性不应被看作需要严格执行的规则,而应当看到一种指南,便于编写出生成高效、正确代码的编译器。

  • 提供正则性——只要可能,指令集的三个要素操作、数据类型和寻址方式,就应当是正交的。如果体系结构的两个方面互不影响,就说它们是正交的。以操作和寻址方式为例,如果对于任何一个可以应用寻址方式的操作,都可以向其应用所有寻址方式,那就说操作和寻址是正交的。这种正则性有助于简化代码生成过程,如果在决定生成何种代码时,需要分散在编译器的两遍扫描中做出决策,那这一特性尤为重要。这一特性的一个典型反例是:限制可供特定指令类型使用的寄存器。针对专用寄存器体系结构的编译器通常会陷入这种两难境地。因为这一限制,编译器可能会发现有许多空闲寄存器,但却都不适用!
  • 提供原型而非解决方案——与一种语言构造或内核功能“相匹配”的特殊功能通常是无法使用的。为支持高级语言所做的尝试可能仅对一种语言有效,也可能与该种语言的正确、高效实现相偏离,可能有点过头,也可能有所不及。
  • 简化候选项之间的权衡——对于编译器编写人员来说,最艰巨的任务之一就是对于所出现的任何一段代码,都能指出哪种指令序列最为适合。设计者所
    做的任何事情,只要能够帮助编译器编写人员了解替代代码序列的成本,就能帮助改进代码。在进行这种复杂权衡时,最困难的情景之一发生在寄存器存储器体系结构中,就是判断一个变量的引用次数达到多大数值以后,将其载入寄存器的成本才会更低一些。
  • 提供一些指令,将编译时的已知量绑定为常量——编译器编写人员特别讨厌处理器在运行时费力解读一个在编译时就已经知晓的取值。有些指令需要解读在编译时就已经固定的取值,这就是以上原则的绝佳反例之一。例如, VAX进程调用指令(calls)会动态解释一个掩码,这个掩码说明在进行调用时要保存哪些寄存器,但它在编译时就已经固定下来了。

编译器对多媒体指令的支持

SIMD指令实际是一种出色体系结构的简化版本,它拥有自己的编译器技术。多媒体内核最初是为科学代码发明的,通常也是可以向量化的,当然通常是处理较短的向量。我们可以将Intel 的MMX和SSE或者PowerPC的AltiVec 看作简单的短向量计算机:MMX的向量可以有8个8位元素、4个16位元素或2个32位元素,AItiVec 的向量长度是以上长度的两倍。它们被实现为宽寄存器中的相邻窄元素。

这些微处理器体系结构将向量寄存器大小设定到体系结构内部:对于MMX,元素大小的总和限制为64位,AltiVec 限制为128位。当Intel决定扩展到128位向量时,它添加了一整套新指令,名为流式SIMD扩展(SSE)。

向量计算机的一个主要优势是:一次载入许多元素,然后将执行与数据传输重叠起来,从而隐藏存储器访问的延迟。向量寻址方式的目标是收集散布在存储器中的数据,以紧凑方式放置它们,便于对其进行高效处理,然后再将处理结果放回所属位置。向量计算机包括步幅寻址和集中/分散寻址,以提高可向量化程序的数目。步幅寻址在每次访问之间跳过数量固定的一些字,所以顺序寻址经常被称为单位步幅寻址。集中与分散寻址在另一个向量寄存器中查找其地址:将其看作向量计算机的寄存器间接寻址。与之相对,从向量的角度来看,这些短向量SIMD计算机仅支持单位步幅访问:存储器访向一次从单个宽存储器位置载入或存储所有元素。由于多媒体应用程序的数据经常是一些流,起始点和终止点都在存储器中,步幅寻址方式和集中/分散寻址方式是成功实现向量化的必备条件。

下面的例子将一个向量计算机与MMX进行对比,将像素的色彩表示方式由RGB(红、绿、蓝)转换为YUV (发光度色度),每个像素用3个字节表示。这种转换只需要三行C代码,放在循环中即可:

1
2
3
Y = (9798*R  + 19235*G + 3736*B) / 32768;
U = (-4784*R - 9437*G + 4221*B) / 32768 + 128;
V = (20218*R - 16941*G - 3277*B) / 32768 + 128;

宽度为64位的向量计算机可以同时计算8个像素。一个采用步幅寻址的媒体向量计算机将执行以下操作:

  • 3次向量载入(以获得RGB);
  • 3次向量相乘(转换R);
  • 6次向量乘加(转换G和B);
  • 3次向量移位(除以32768);
  • 2次向量加(加上128);
  • 3次向量存储(存储YUV)。

总共有20条指令用于执行前面C代码中转换8个像素的20个操作。(由于向量可能有32 个64 位元素,这一代码实际上可以转换最多32x 8 = 256个像素。)

与之相对,Intel网站显示一个对8个像素执行相同计算的库例程使用了116条MMX指令和6个80x86指令。指令数之所以会增加到6倍是因为没有步幅存储器访问,需要大量的指令来载入RGB像素并解包,然后再打包并存储YUV像素。

采用受体系结构限制的短向量,而且没有多少寄存器和简单的存储器寻址方式,就很难利用向量化编译器技术。因此,这些SIMD指令更可能出现于硬编码库中,而不是编译后的代码中。

小结:编译器的角色

这一节给出了几点建议。第一,我们希望一种新的指令集体系结构中至少拥有16个通用寄存器(另外用于浮点数的寄存器不计在内),以简化使用图形着色的寄存器分配。关于正交性的建议意味着所支持的全部寻址方式都适用于所有传送数据的指令。最后的三点建议(提供原型而非解决方案、简化候选项之间的权衡、不要在运行时绑定常量)都意味着注重简单性是最稳妥的。换句话说,要想清楚,在指令集设计中,少就是多。SIMD扩展是一个出色营销的例子,而不是软硬件协调设计的杰出成果。

融会贯通:MIPS体系结构

在这一节,我们介绍一种名为MIPS的简单64位载入-存储体系结构。MIPS和RISC系列的指令集体系结构的基础与前几节讨论的内容相似。下面回顾一 下我们在每一节对桌面应用程序的期望。

  • A.2节——以载入-存储体系结构使用通用寄存器。
  • A.3节——支持以下寻址方式:位移量(地址偏移大小为12~16位)立即数(大小为8~16位)和寄存器间接寻址。
  • A.4节——支持以下数据大小和类型: 8位、16位、32位和64位整数以及64位IEEE 754浮点数。
  • A.5节——支持以下简单指令(它们占所执行指令的绝大多数):载入、存储、加、减、移动寄存器和移位。
  • A.6节——相等、 不相等、小于、分支(长度至少为8位的PC相对地址)、跳转、调用和返回。
  • A.7节——如果关注性能则使用定长指令编码, 如果关注代码规模则使用变长指令编码。
  • A.8节——至少提供 16个通用寄存器,确保所有寻址模式可应用于所有数据传送指令,希望获得最小规模的指令集。这一节并没有包含浮点程序,但它们经常使用独立的浮点寄存器。其理由是增大寄存器的总数,但不会在指令格式或通用寄存器堆的速度方面产生问题。不过,这两个方面并非相互独立。

我们在介绍MIPS时,将展示它是如何遵循这些建议的。与最近的大多数计算机类似, MIPS强调:

  • 简单的载入-存储指令集;
  • 针对流水线效率的设计,包括定长指令集编码;
  • 编译器目标的效率。

从1985年诞生第一个MIPS处理器以来,已经发布了MIPS的许多版本。我们将使用其中一个现在被称为MIPS64的子集,它经常被简写为MIPS。

MIPS的寄存器

MIPS64有32个64位通用寄存器(GPR),郎R0、R…..R31。GPR有时也称为整数寄存器。此外,还有一组32位浮点寄存器(FPR),即F0、F…..F31,它可以保存32个单精度(32位)值或32位双精度(64位)值。(在保存一个单精度数时,另一半FPR没有使用。)它提供了单精度和双精度浮点运算(32位和64位)。MIPS还包括在单个64位浮点寄存器中对两个单精度操作数进行运算的指令。

R0的值总是0。一些特殊寄存器可以与通用寄存器进行相互转换。其中一个例子就是浮点状态寄存器,用于保存有关浮点运算结果的信息。还有一些指令用于在FPR和GPR之间移动数据。

MIPS的数据类型

MIPS的数据类型包括8位字节、16 位半字、32 位字和64位双字整型数据和32位单精度与64位双精度浮点数据。添加半字是因为它们在诸如C之类的语言中存在这一类型,而且在一些关注数据结构大小的程序(比如操作系统)中非常普遍。出于类似原因添加了单精度浮点操作数。

MIPS64操作对64位整数和32位或64位浮点数进行操作。字节、半字和字被载入通用寄存器中,并通过重复0或符号位来填充GPR的64个位。一旦载入之后,就可以用64位整数运算对其进行操作。

MIPS的数据传输的寻址方式

仅有的数据寻址方式就是立即数寻址和位移寻址,均采用16位字段。寄存器间接寻址通过在16位位移字段中放置0来实现,而采用16位字段的寻址则是以寄存器0为基址寄存器来完成的。尽管这种体系结构中仅支持两种寻址方式,但通过包含0提供了四种有效方式。MIPS存储器可以用64位地址进行字节寻址。它有一个方式位,允许软件选择大端或小端。由于它是一个载入-存储体系结构,介于存储器与GPS或FRP之间的所有引用都是通过载入或存储完成的。通过支持上述数据类型,涉及GPR的存储器访问可以是字节、半字、字或双字。FPR可以载入和存储单精度或双精度数。所有存储器访问都必须对齐。

MIPS指令格式

由于MIPS只有两种寻址方式,所以能把它们编码到操作码中。为便于处理器实现流水线和译码,所有指令的长度都是32位,其中有一个6位的主操作码。图A-14显示了指令布局。这些格式非常简单,提供16位字段用于位移量寻址、立即数常量寻址或PC相对分支寻址。

图A-14 MIPS的指令布局。所有指令均采用三种类型之一进行编码 ,在每种格式的相同位置有公共字段

MIPS 操作

MIPS支持前面推荐的简单操作及其他一些操作。共有四大类指令:载入与存储、ALU运算、分支与跳转、浮点运算。

任意通用或浮点寄存器都可以载入或存储,只是载入R0没有任何效果。表A-9给出载入与存储指令的一些例子。单精度浮点数占据浮点寄存器的一半。单、双精度之间的转换必须显式完成。浮点格式为IEEE754。表A-12中列出了这部分给出的全部MIPS指令。

  • 均使用单一寻址方式,需要存储器值对齐。当然,载入和存储都可用于全部所示数据类型。

为理解这些图形,需要对最初在A.3.2节使用的C描述语言再进行些扩展。

  • 只要所传送数据的长度不够明确,则向符号←附加一个下标。因此,←n表示传送一个n位量。我们使用x, y←z表示应当将z传送给x和y
  • 使用一个下标来表示选择字段中的某一位。在标记字段中的各个位时,最高有效位从0开始。下标可能是单个数位,也可能是一个子范围。
  • 变量Mem用作一个表示主存储器的数组,它是按字节地址索引,可以传送任意数目的字节。
  • 使用一个上标来表示复制字段(例如,048 给出长度为48位的全零字段)。
  • 使用符号#将两个字段串联在一起, 可能出现在数据传输的任一端。

例如,假定R8和R10为64位寄存器:

1
Regs[R10]32, 63 ←32 (Mem[Regs [R8]]10) 24 ## Mem[Regs{R8]]

上式的含义是对某一存储器位置的字节进行符号扩展(该存储器位置由寄存器R8的内容寻址),构成一个32位量,存储在寄存器R10的低位。(R10的高位不变。)

所有ALU指令都是寄存器-寄存器指令。表A-10给出了算术/逻辑指令的一些例子。这些操作包括简单的算术和逻辑运算:加、减、AND、 OR、XOR 和移位。所有这些指令的立即数形式都是用16位符号扩展立即数提供的。操作LUI (加载高位立即数)加载一个寄存器的第32~47位,并将该寄存器的其他位设置为0。LUI 允许在两条指令中内置一个32位常数,也可以在一个额外指令中使用任意常数32位地址进行数据传送。

前面曾经提到,R0用于合并常见操作。载入常数的操作,其实就是执行一个源操作数为R的加立即数指令,寄存器-寄存器移动就是源操作数之一为 R0的加法。我们有时会使用助记符LI(表示载入立即数, load immediate)来表示前者,用助记符MOV表示后者。

MIPS控制流指令

MIPS提供比较指令,它比较两个寄存器,查看第一个寄存器是否小于第二个寄存器。如果该条件为真,则这些指令在目标寄存器中放入1 (表示真);否则,放入数值0。因为这些操作会对寄存器进行“置位”,所以它们被称为“相等置位”、“不相等置位”、“小于置位”,等等。这些比较指令还有立即数形式。

控制是通过一组跳转指令和一组分支指令处理的。表A-11给出了一些典型的分支与跳转指定。通过两种指定目标地址的方法以及是否设定链接来区分四种跳转指令。两种跳转指令使用一个进行两位移位的26位偏移量,然后替代程序计数器的低28位,以确定目标地址(程序计数器是指该跳转指令后面指令的程序计数器)。另两种跳转指令指定了包含目标地址的寄存器。共有两类跳转:单纯跳转和跳转并链接(用于过程调用)。后者将返回地址(下-条顺序指令的地址)放在R31中。

所有分支都是有条件的。分支条件由指令指定,它可以测试寄存器源操作数是否为0;寄存器可以包含一个数据值或比较的结果。还有一些条件分支指令用于判断一个寄存器是否为负数,以及两个寄存器是否相等。分支目标地址由一个16位有符号偏移量指定,该偏移量被左移两位,然后加到指向下一顺序指令的程序计数器。还有一个分支用来关注浮点条件分支的浮点状态寄存器,如下文所述。

条件分支是流水线执行的主要挑战;因此,许多体系结构增加了用于将简单分支转换为条件算术指令的指令。MIPS 包含在等于零或不等于零时执行的条件移动。目标寄存器的值或者保持不变,或者由源寄存器之一的副本 替代,具体取决于其他源寄存器的值是否为零。

MIPS浮点运算

浮点运算操控浮点寄存器,并指出运算是以单精度还是双精度执行。操作MOV.S和MOV.D将一个单精度(Mov.S)或双精度(MOV.D)浮点寄存器复制到另一个同种类型的寄存器中。操作MFC1、MTC1、 DMFC1 和DMTC1在单精度或双精度寄存器与整数寄存器之间移动数据,而且还提供了整数向浮点的转换,反之亦然。

浮点运算为加、臧、乘、除;后缀D表示双精度,后缀S表示单精度(例如, ADD.D、 ADD.S、SUB.D、SUB.S、MUL.D、MUL.S、DIV.D、DIV.S)。 浮点比较指令会对特殊浮点状态寄存器中的一个位进行置位,有一对分支指令可以检测这个位,即: BC1T 和BC1F,也就是浮点真分支和浮点假
分支。

为进一步提高图形例程的性能, MIPS64中提供了一些可以针对64位高低位执行2个32位浮点操作的指令。这些成对单精度操作包括:ADD.PS、 SUB.PS、MUL.PS和DIV.PS。(它们使用双精度载入和存储指令进行载入和存储。)MIPS64认识到多媒体应用程序的重要性,还引入了整数与浮点乘加指令: MADD、 MADD.S、MADD.D和MADD.PS。在这些合并操作中,寄存器的宽度都相同。表A-12给出了一部分 MIPS64操作的清单及其含义。


MIPS指令集的使用

为使读者了解哪些指令使用得更为频繁,表A-13中给出了5个SPECint2000程序中各指令及指令类别的使用频率,表A-14给出了对于5个SPECfp2000程序的相同数据。


存储器层次结构回顾

引言

本附录对存储器层次结构进行了快速回顾,包括缓存与虚拟存储器的基础知识、性能公式、简单优化。第一节介绍下面36个术语。

  • 缓存(cache)
  • 全相联(fully associative)
  • 写入分派(write allocate)
  • 虚拟存储器(virtual memory)
  • 重写(脏)位(dirtybit)
  • 统一缓存(unifed cache)
  • 存储器停顿周期(memorystallcycles)
  • 块偏移(block offset)
  • 每条指令缺失数(misses per instruction)
  • 直接映射(direct mnapped)
  • 写回(write back)
  • 块(block)
  • 有效位(valid bit)
  • 数据缓存(data cache)
  • 局域性(locality)
  • 块地址(block address)
  • 命中时间(hittime)
  • 地址跟踪(address trace)
  • 直写(write through)
  • 缓存缺失(cache miss)
  • 组(set)
  • 指令缓存(instruction cache)
  • 页面错误(page fault)
  • 随机替换(random replacement)
  • 存储器平均访问时间(average memory access time)
  • 缺失率(missrate)
  • 索引字段(index feld)
  • 缓存命中(cache hit)
  • n路组相联(n-way set associative)
  • 无写入分派(no-write allocate)
  • 页(page)
  • 最近最少使用(least-recently used)
  • 写入缓冲区(write buffer)
  • 缺失代价(miss penalty)
  • 标志字段(tagfield)
  • 写停顿(write stall)

缓存是指地址离开处理器后遇到的最高级或第一级存储器层次结构。由于��域性原理适用于许多级别,而且充分利用局域性来提高性能的做法非常普遍,所以现在只要利用缓冲方法来重复使用常见项目,就可以使用缓存,文件缓存、名称缓存等都是一些实例。

如果处理器在缓存中找到了所需要的数据项,就说发生了缓存命中。如果处理器没有在缓存中找到所需要的数据项,就是发生了缓存缺失。从主存储器中提取固定大小且包含所需字的数据集,并将其放在缓存中,这个数据集称为块。时域局域性告诉我们:我们很可能会在不远的将来再用到这个字,所以把它放在缓存中是有用的,在这里可以快速访问它。由于空域局域性,马上用到这个块中其他数据的可能性也很高。

缓存缺失需要的时间取决于存储器的延迟和带宽。延迟决定了提取块中第一个字的时间,带宽决定了提取这个块中其他内容的时间。缓存缺失由硬件处理,会导致采用循序执行方式的处理器暂停或停顿,直到数据可用为止。在采用乱序执行方式时,需要使用该结果的指令仍然必须等待,但其他指令可以在缺失期间继续进行。

与此类似,程序引用的所有对象不一定都要驻存在主存储器中。虚拟存储器意味着一些对象可以驻存在磁盘上。地址空间通常被分为固定大小的块,称为页。在任何时候,每个页要么在主存储器中,要么在磁盘上。当处理器引用一个页中既不在缓存中也不在主存储器中的数据项时,就会发生页错误,并把整个页从磁盘移到主存储器中。由于页错误消耗的时间太长,所以它们由软件处理,处理器不会停顿。在进行磁盘访问时,处理器通常会切换到其他某一任务。从更高级别来看,缓存和主存储器在对引用局域性的依赖性方面、在大小和单个位成本等方面的关系,类似于主存储器与磁盘的相应关系。

表B-1给出了各种计算机(从高端台式机到低端服务器)每一级存储器层次结构的大小与访问时间范围。

  • 嵌入式计算机可能没有磁盘存储,存储器和缓存也要小得多。在移向层次结构的更低级别时,访问时间延长,从而有可能以较低的反应速度来管理数据传输。实现技术显示了这些功能所用的典型技术。

缓存性能回顾

由于局域性的原因,再加上存储器越小其速度越快,所以存储器层次结构可以显著提高性能。评价缓存性能的一种方法是扩展第1章给出的处理器执行时间公式。我们现在考虑处理器在等待存储器访问而停顿时的周期数目,称为存储器停顿周期。性能结果为处理器周期与存储器停顿周期之和与时钟周期时间的乘积:

CPU执行时间 = (CPU时钟周期+存储器停顿周期) x时钟周期时间

此公式假定CPU时钟周期包括处理缓存命中的时间,并假定处理器在发生缓存缺失时停顿。

存储器停顿周期数取决于缺失数目和每次缺失的成本,后者称为缺失代价:

存储器停顿周期 = 缺失数 x 缺失代价 = IC x 缺失/指令 x 缺失代价 = IC x 存储器访问/指令 x 缺失率 x 缺失代价

最后一种形式的优点在于其各个分量容易测量。我们已经知道如何测量指令数(IC)。(对于推测处理器,只计算提交的指令数。)可以采用同一方式来测量每条指令的存储器引用数;每条指令需要一次指令访问,很容易判断它是否还需要数据访问。

注意,我们计算出缺失代价,作为平均值,但下面将其作为常数使用。在发生缺失时,缓存后面的存储器可能因为先前的存储器请求或存储器刷新而处于繁忙状态。在处理器、总线和存储器的不同时钟之间进行交互时,时钟周期数也可能发生变化。所以请记住,为缺失代价使用单一数值是一种简化。

缺失率分量就是缓存访问中导致缺失的访问比例(即,导致缺失的访问数除以总访问数)。缺失率可以用缓存模拟器测量,它会取得指令与数据引用的地址跟踪,模拟该缓存行为,以判断哪些引用命中,哪些缺失,然后汇报命中与缺失总数。今天的许多微处理器提供了用于计算缺失与存储器引用数的硬件,这一缺失率测量方式要容易得多、快得多。

由于读取和写入操作的缺失率和缺失代码通常是不同的,所以上面的公式得出的是一个近似值。存储器停顿时钟周期可以用每条指令的存储器访问次数、读写操作的缺失代价(以时钟周期为单位入读写操作的缺失率来定义:

存储器停顿时钟周期 = IC x 每条指令的读取操作 x 读取缺失率 x 读取缺失代价 + IC x 每条指令的写入操作 x 写入缺失率 x 写入缺失代价

我们通常会合并读写操作,求出读取与写入操作的平均缺失度与缺失代价,以简化上面的完整公式:

存储器停顿时钟周期 = IC x 存储器访问/指令 x 缺失率 x 缺失代价

缺失率是缓存设计中最重要的度量之一,但在后面各节将会看到,它不是唯一的度量标准。

假定有一个计算机,当所有存储器访向都在缓存中命中时,其每条指令的周期数(CPI)为1.0。仅有的数据访问就是载入和存储,占总指令数的50%。如果缺失代价为25个时钟周期,缺失率为2%,当所有指令都在缓存中命中时,计算机可以加快多少?

首先计算计算机总是命中时的性能:

CPU执行时间 = (CPU时钟周期+存储器停顿周期) x 时钟周期 = (IC x CPI+0) x 时间周期 = IC x 1.0 x 时间周期

现在,对于采用实际缓存的计算机,首先计算存储器停顿周期:

存储器停顿周期 = IC x 存储器访问数/指令 x 缺失率 x 缺失代价 = IC x (1+0.5) x 0.02 x 25 = IC x 0.75

式中,中间项(1+0.5)表示每条指令有1次指令访问和0.5次数据访问。总性能为:

CPU执行时间(缓存) = (IC x 1.0 + IC x 0.75) x 时间周期 = 1.75 x IC x 时钟周期

性能比是执行时间的倒数:

CPU执行时间(缓存)/CPU执行时间 = (1.75 x IC x 时钟周期) / (1.0 x IC x 时钟周期) = 1.75

没有缓存缺失时,计算机的速度为有缺失时的1.75倍。

一些设计师在测量缺失率时更愿意表示为每条指令的缺失数,而不是每次存储器引用的缺失数。这两者的关系为:
缺失数/指令数 = 缺失数 x 存储器访问数 / 指令数 = 缺失数 x 存储器访问数 / 指令数

如果知道每条指令的平均存储器访问数,后面一个公式是有用的,因为这样可以将缺失率转换为每条指令的缺失数,反之亦然。例如,我们可以将上面示例中每次存储器引用的缺失率转换为每条指令的缺失:

缺失数/指令数 = 缺失数 x 存储器访问数/指令数 = 0.02 x 1.5 = 0.030

顺便说一下, 每条指令的缺失数经常以每千条指令的缺失数给出,以显示整数而非小数。因此,上面的答案也可以表示为每1000条指令发生30次缺失。

表示为“每条指令的缺失数”的好处在于它与硬件实现无关。例如,推测处理器提取的指令数大约是实际提交指令数的两倍,如果测量每次存储器引用而非每条指令的缺失数,那么就可以人为地降低缺失率。其缺点在于每条指令的缺失数与体系结构相关;例如,对于80x86与MIPS,每条指令的存储器访问平均数可能会有很大不同。因此,对于仅使用单一计算机系列的架构师,最常使用的是每条指令的缺失数,不过与RISC体系结构的相似性也可以让人们深入理解其他体系结构。

为了展示这两个缺失率公式的等价性,让我们重做上面的例题,这一次假定每千条指令的缺失率为30。根据指令个数,存储器停顿时间为多少?

重新计算存储器停顿周期:存储器停顿周期 = 缺失数 x 缺失代价 = IC x 缺失数/指令数 x 缺失代价 = IC/1000 x 缺失数/(指令数x1000) x 缺失代价 = IC/1000 x 30 x 25 = IC x 0.75

得到的答案与前面的例题相同,表明这两个公式的等价性。

4个存储器层次结构问题

我们通过回答有关存储器层次结构第一级的4个常见问题来继续对缓存的介绍。

  • 问题1:一个块可以放在上-级的什么位置? (块的放置)
  • 问题2:如果一个块在上一级中,如何找到它? (块的识别)
  • 问题3:在缺失时应当替换哪个块? (块的替换)
  • 问题4:在写入时会发生什么? (写入策略)

这些问题的答案可以帮助我们理解存储器在层次结构不同级别所做的不同折中;因此,我们对每个示例都会问这四个问题。

问题1:一个块可以放在缓存中的什么位置?

图B-1显示,根据对块放置位置的限制,可以将缓存组织方式分为以下三类。

  • 如果每个块只能出现在缓存中的一个位置,就说这一缓存是直接映射的。这种映射通常是:(块地址)MOD(缓存中的块数)
  • 如果一个块可以放在缓存中的任意位置,就说该缓存是全相联的。
  • 如果一个块可以放在缓存中由有限个位置组成的组(set)内,则该缓存是组相联的。组就是缓存中的一组块。块首先映射到组,然后这个块可以放在这个组中的任意位置。通常以位选择方式来选定组:(块地址)MOD(缓存中的组数),如果组中有n个块,则称该缓存放置为n路组相联。

从直接映射到全相联的缓存范围实际上就是组相联的一个统一体。直接映射就是一路组相联,拥有m个块的全相联缓存可以称为“m路组相联”。同样,直接映射可以看作是拥有m个组,全相联可以看作拥有一个组。

图B-1 这一缓存示例有8个块帧,存储器有32个块。三种缓存选项由左向右给出。在全相联中,来自较低层级的块12可以进入该缓存8个块帧的任意一个。采用直接映射时,块12只能放在块帧4(12 mod 8)中。组相联拥有这两者的一些共同特性,允许这个块放在第0组的任意位置(12 mod 4)。由于每个组中有两个块,所以这意味着块12可以放在缓存的块0或块1中。实际缓存包含数千个块帧,实际存储器包含数百万个块。拥有四个组、每组两个块的组相联组织形式称为两路组相联。假定缓存中没有内容,而且所关心的块地址确认了较低级别的块12

今天的绝大多数处理器缓存为直接映射、两路组相联或四路组相联,其原因将在稍后介绍。

问题2:如果一个块就在缓存中,如何找到它?

缓存中拥有每个块帧的地址标志,给出块地址。每个缓存块的标志中可能包含所需要的信息,会对这些标志进行查看,以了解它是否与来自处理器的块地址匹配。由于速度非常重要,所以会对所有可能标志进行并行扫描,这是一条规则。

必须存在一种方法来获知缓存块中不包含有效信息。最常见的过程是向标志中添加一个有效位,表明这一项是否包含有效地址。如果没有对这个位进行置位,那就不可能存在对这一地址的匹配。

在继续讨论下一问题之前,先来研究一个处理器地址与缓存的关系。图B-2显示了址是如何划分的。第一次划分是在块地址和块偏移之间,然后再将块帧地址进一步分为标志字段和索引字段。块偏移字段从块中选择期望数据,索引字段选择组,通过对比标志字段来判断是否命中。尽管可以对标志之外的更多地址位进行对比,但并不需要如此,原因如下所述。

  • 在对比中不应使用偏移量,因为整个块或者存在或者不存在,因此,根据定义,所有块偏移都会导致匹配。
  • 核对索引是多余的,因为它是用来选择待核对组的。例如,存储在第0组的地址,其索引字段必须为0,否则就不能存储在第0组中;第1组的索引值必须为1,以此类推。这一优化通过缩小缓存标志的宽度来节省硬件和功率。

图B-2 组相联或直接映射缓存中地址的三个组成部分。标志用于检查组中的所有块,索引用于选择该组。块偏移是块中所需数据的地址。全相联缓存没有索引字段

如果总缓存大小保持不变,增大相联度将提高每个组中的块数,从而降低索引的大小、增大标志的大小。即,图B-2中的标志索引边界因为相联度增大而向右移动,到端点处就是全相关缓存,没有索引字段。

问题3:在缓存缺失时应当替换哪个块?

当发生缺失时,缓存控制器必须选择一个用期望数据替换的块。直接映射布置方式的好处就是简化了硬件判决——事实上,简单到没有选择了:只会查看一个块帧,以确定是否命中,而且只有这个块可被替换。对于全相联或组相联布置方式,在发生缺失时会有许多块可供选择。主要有以下三种策略用来选择替换哪个块。

  • 随机——为进行均匀分配,候选块是随机选择的。一些系统生成伪随机块编号,以实现可重复的行为,这在调试硬件时有一定的用处。
  • 最近最少使用(LRU)——为尽量避免抛弃不久就会用到的信息,会记录下对数据块的访问。依靠过去行为来预测未来,将替换掉未使用时间最久的块。LRU依赖于局域性的一条推论:如果最近用过的块很可能被再次用到,那么放弃最近最少使用的块是一种不错的选择。
  • 先入先出(FIFO)——因为LRU的计算可能非常复杂,所以这一策略是通过确定最早的块来近似LRU,而不是直接确定LRU。

随机替换的一个好处是易于用硬件实现。随着要跟踪块数的增加,LRU的成本也变得越得来高,通常只能采用近似法。一种常见的近似方法(经常称为“伪LRU”)是为缓存中的每个组设定一组比特,每个比特应于缓存中的一路,一路就是组相联缓存中的条(bank);四路组相联缓存中有四路。在访向一组时开启一个特定比特,这一比特与包含所需块的路相对应;如果与一个组相关联的所有比特都被开启,除最近刚被开启的比特之外,将所有其他比特关闭。在必须替换一个块时,处理器从相应被关闭的路中选择一个块,如果有多种选择,则随机选定。这种方法会给出近似LRU,这是因为自上次访问组中的所有块之后,被替换块再没有被访问过。

表B-2给出了LUR、随机和FIFO替换方式中的缺失率之差。

  • 对于最大的缓存,LRU和随机方式之间没有什么差别,当缓存较小时,LUR胜过其他几种方式。当缓存较小时,FIFO通常优于随机方式。

问题4:在写入时发生什么?

大多数处理器缓存访问都是读取操作。所有指令访问都是读取,大多数指令不会向存储器写入数据。要加快常见情景的执行速度,就意味着要针对读取操作对缓存进行优化,尤其是处理器通常会等待读取的完成,而不会等待写入操作。但Amdahl定律提醒我们,高性能设计不能忽视写入操作的速度。

幸运的是,常见情景也是容易提升速度的情景。可以在读取和比对标志的同时从缓存中读取块,所以只要有了块地址就开始读取块。如果读取命中,则立即将块中所需部分传送给处理器。如果读取缺失,那就没有什么好处。

不能对写入操作应用这一优化。要想修改一个块,必须先核对标志,以查看该地址是否命中。由于标志核对不能并行执行,所以写入操作需要的时间通常要长于读取。另一种复杂性在于处理器还指定写入的大小,通常介于1~8个字节之间;只能改变一个块的相应部分。而读取则与之不同,可以毫无顾虑地访问超出所需的更多字节。写入策略通常可以用来区分缓存设计。在写入缓存时,有下面两种基本选项。

  • 直写——信息被写入缓存中的块和低一级存储器中的块。
  • 写回——信息仅被写到缓存中的块。修改后的缓存块仅在被替换时才被写到主存储器。

为减少在替换时写回块的频率,通常会使用一种称为重写(脏)位的功能。这一状态位表示一个块是脏的(在缓存中经历了修改)还是干净的(未被修改)。如果它是干净的,则在缺失时不会写回该块,因为在低级存储器中可以看到缓存中的相同信息。

写回和直写策略都有自己的优势。采用写回策略时,写入操作的速度与缓存存储器的速度相同,一个块中的多个写入操作只需要对低一级存储器进行一次写入。由于一些写入内容不会进入存储器,所以写回方式使用的存储器带宽较少,使写回策略对多处理器更具吸引力。由于写回策略对存储器层次结构其余部分及存储器互连的使用少于直写,所以它还可以节省功耗,对于嵌入式应用极具吸引力。

相对于写回策略,直写策略更容易实现。缓存总是清洁的,所以它与写回策略不同,读取缺失永远不会导致对低一级存储器的写入操作。直写策略还有一个好处:下一级存储器中拥有数据的最新副本,从而简化了数据一致性。数据一致性对于多处理器和IO来说非常重要。多级缓存使直写策略更适于高一级缓存,这是因为写入操作只需要传播到下一个较低级别,而不需要传播到所有主存储器。

稍后将会看到,IO和多处理器有些反复无常:它们希望为处理器缓存使用写回策略,以减少存储器通信流量,又希望使用直写策略,以与低级存储器层次结构保持缓存一致。如果处理器在直写期间必须等待写入操作的完成,则说该处理器处于写入停顿状态。减少写入停顿的常见优化方法是写入缓冲区,利用这一优化,数据被写入缓冲区之后,处理器就可以立即继续执行,从而将处理器执行与存储器更新重叠起来。稍后将会看到,即使有了写入缓冲区也会发生写入停顿。

由于在写入时并不需要数据,所以在发生写入缺失时共有以下两种选项。

  • 写入分派——在发生写入缺失时将该块读到缓存中,随后对其执行写入命中操作。在这一很自然的选项中,写入缺失与读取缺失类似。
  • 无写入分派——这显然是一种不太寻常的选项,写入缺失不会影响到缓存。而是仅修改低一级存储器中的块。

因此,在采用无写入分派策略时,在程序尝试读取块之前,这些块一直都在缓存之外,但在采用写入分派策略时,即使那些仅被写入的块也会保存在缓存中。让我们看一个例子。

假定一个拥有许多缓存项的全相联写回缓存,在开始时为空。下面是由5个存储器操作组成的序列(地址放在中括号内):

1
2
3
4
5
Write Mem[100];
Write Mem[100];
Read Mem[200];
Write Mem[200];
Write Mem[100] .

在使用无写入分派和写入分派时,命中数和缺失数为多少?

对于无写入分派策略,地址100不在缓存中,在写入时不进行分派,所以前两个写入操作将导致缺失。地址200 也不在缓存中,所以该读取操作也会导致缺失。接下来对地址200进行的写入将会命中。最后一个对地址100的写入操作仍然是缺失。所以对无写入分派策略,其结果是4次缺失和1次命中。

对于写入分派策略,前面对地址100 和地址200的访问导致缺失,由于地址100和地址200都可以在缓存中找到,所以其他写入操作将会命中。因此,采用写入分派时,其结果为2次缺失和3次命中。

任何一种写入缺失策略都可以与直写或写回策略起使用。通常,写回缓存采用写入分派策略,希望对该块的后续写入能够被缓存捕获。直写缓存通常使用无写入分派策略。其原因在于:即使存在对该块的后续写入操作,这些写入操作仍然必须进入低一级存储器,那还有什么好处呢?

举例:Opteron数据缓存

为了展示这些思想的本质,图B-3给出了AMD Opteron微处理器中数据缓存的组织方式。该缓存包含65536 (64KB)字节的数据,块大小为64字节,采用两路组相联布置方式、最近使用最少替代策略、写缺失时采用写入分派。

图B-3 Opteron 微处理器中数据缓存的组织方式。这个64 KB的缓存为两路组相联,块大小为64字节。长为9位的索引从512个组中进行选择。一次读取命中的四个步骤 (按发生顺序表示为带圆圈的数字)标记了这一组织方式。块偏移量的三位加上索引,提供了RAM地址,恰好选择8个字节。因此,该缓存保存了由4096个64位字组成的群组,每个群组包含512个组的一半。从低级存储器至缓存的线路用于在发生缺失时载入缓存,不过未在这一示例中 展示。离开处理器的地址大小为40位,这是因为它是物理地址而不是虚拟地址。图B-14解释了Opteron 如何从虚拟地址映射到物理地址,以进行缓存访问

我们通过图B-3中标注的命中步骤来跟踪一次缓存命中的过程。(这4个步骤用带圆圈的数字表示。)如B.5节所述,Opteron 向缓存提供48位虚拟地址进行标志比对,它将同时被翻译为40位物理地址。

Opteron之所以没有利用虚拟地址的所有64位,是因为它的设计者认为还没有人会需要那么大的虚拟地址空间,而较小的空间可以简化Opteron虚拟地址的映射。设计者计划在未来的微处理器中增大此虚拟地址。

进入缓存的物理地址被分为两个字段:34 位块地址和6位块偏移量(64=2^6,34+6=40)。块地址进一步分为地址标志和缓存索引。第1步显示了这一划分。

缓存索引选择要测试的标志,以查看所需块是否在此缓存中。索引大小取决于缓存大小、块大小和级相联度。Opteron 缓存的组相联度被设置为2,索引计算如下:

2^索引 = 缓存大小/(块大小x组相联度) = 65536/(64x2) = 512 = 2^9

因此,索引宽9位,标志宽34-9=25位。尽管这是选择正确块所需要的索引,但64个字节远多于处理器希望一次使用的数目。因此,将缓存存储器的数据部分安排为宽8个字节更有意义一些,这是64位Opteron处理器的自然数据字。因此,除了用于索引正确缓存块的9个位之外,还使用来自块偏移量的另外3个位来索引恰好8个字节。索引选择是图B-3中的第2步。

在从缓存中读取这两个标志之后,将它们与处理器所提供块地址的标志部分进行对比。这一对比是图中第3步。为了确保标志中包含有效信息,必须设置有效位,否则,对比结果将被忽略。假定有一个标志匹配,最后一步是通知处理器,使用2选1多工器的获胜输入从缓存中载入正确数据。Opteron可以在2个时钟周期内完成这四个步骤,因此,如果后面2个时钟周期中的指令需要使用载入结果,那就得等待。

在Opteron中,写入操作的处理要比读取操作更复杂,这一点与在任何缓存中都是一致的。如果要写入的字在缓存中,前三步相同。由于Opteron是乱序执行的,所以只有在它发出指令已提交而且缓存标志比对结果显示命中的信号之后,才会将数据写到缓存中。

到目前为止,我们假定的是缓存命中的常见情景。那在缺失时会发生什么情况呢?在读取缺失时,缓存会向处理器发出信号,告诉它数据还不可用,并从下一级层级结构中读取64个字节。对于该块的前8个字节,延迟为7个时钟周期,对于块的其余部分,为每8个字节需要2个时钟周期。由于数据缓存是组相联的,所以需要选择替换哪个块。Opteron 使用LRU (选择在最早之前被引用的块),所以每次访问都必须更新LRU位。替换一个块意味着更新数据、地址标志、有效位和LRU位。

由于Opteron使用写回策略,旧的数据块可能已经被修改,所以不能简单地放弃它。Opteron为每个块保存1个脏位,以记录该块是否被写入。如果“牺牲块”被修改,它的数据和地址就被发送给牺牲块缓冲区。(这种结构类似于其他计算机中的写入缓冲区。)Opteron有8个牺牲块的空间。它会将牺牲块写到低一级层次结构,这一操作与其他缓存操作并行执行。如果牺牲块缓冲区已满,缓存就必须等待。

由于Opteron在读取缺失和写入缺失时都会分派一个块,所以写入缺失与读取缺失非常类似。

我们已经看到数据缓存是如何工作的,但它不可能提供处理器所需要的所有存储器:处理器还需要指令。尽管可以尝试用一个缓存来提供数据、指令两种缓存,但这样可能会成为瓶颈。例如,在执行载入或存储指令时,流水化处理器将会同时请求数据字和指令字。因此,单个缓存会表现为载入与存储的结构性冒险,从而导致停顿。解决这一问题的一种简单方法是分开它:一个缓存专门用于指令,另一个缓存专门用于数据。最近的处理器中都使用了独立缓存,包括Opteron在内。因此,它有一个64 KB的指令缓存和64 KB的数据缓存。

处理器知道它是在发射一个指令 地址,还是一个数据地址,所以可能存在用于这两者的独立端口,从而使存储器层次结构和处理器之间的带宽加倍。采用分离缓存还提供了分别优化每个缓存的机会:采用不同的容量、块大小和相联度可能会得到更佳性能。

表B-3显示指令缓存的缺失率低于数据缓存。指令与数据缓存分离,消除了因为指令块和数据块冲突所导致的缺失,但这种分离固定了每种类型所能使用的缓存空间。与缺失率相比,哪个更重要呢?要公平地对比指令数据分离缓存和统一缓存, 需要总缓存大小相同。例如,分离的16 KB指令缓存和16 KB数据缓存应当与32KB统一缓存相对比。要计算分离指令与数据缓存的平均缺失率,需要知道对每种缓存的存储器引用百分比。从附录A中的数据可以找到:

指令引用为100%(100%+26%+10%),大约为74%;数据引用为(26%+ 10%)(100%+26%+10%),大约为26%。稍后将会看到,分割对性能的影响并非仅限于缺失率的变化。

缓存性能

由于指令的数目与硬件无关,所以用这个数值来评价处理器性能是很有诱惑力的。由于缺失率也与硬件的速度无关,所以评价存储器层次结构性能的相应焦点就主要集中在缺失率上。后面将会看到,缺失率可能与指令数目一样产生误导。存储器层次结构性能的一个更好度量标准是存储器平均访问时间:

存储器平均访问时间=命中时间+缺失时间x缺失代价

式中,命中时间是指在缓存中命中的时间;其他两项已经在前面看到过。平均访问时间的各个分量可以用绝对时间衡量,比如,一次命中的时间为0.25~1.0 ns,也可以用处理器等待该存储器的时间周期数来衡量,比如一次缺失代价为150~200 个时钟周期。注意,存储器平均访问时间仍然是性能的间接度量;尽管它优于缺失率,但并不能替代执行时间。

这个公式可以帮助我们决定是选择分离缓存还是统一缓存。

16KB指令缓存加上16KB数据缓存相对于一个32KB统一缓存,哪一种的缺失率较低?利用表B-3中的缺失率数据来帮助计算正确答案,假定36%的指令为数据传输指令。假定一次命中需要1个时钟周期,缺失代价为100个时钟周期。对于统一缓存,如果仅有一个缓存端口来满足两个同时请求,一次载入或存储命中另需要一个时钟周期。利用第3章的流水线技术,统一缓存会导致结构性冒险。每种情况下的存储器平均访问时间为多少?假定采用具有写入缓冲区的直写缓存,忽略由于写入缓冲区导致的停顿。

首先让我们将每千条指令的缺失数转换为缺失率。求解上面的一般公式,缺失率为:

缺失率 = ( (缺失率/1000条指令) / 1000) / (存储器访问数/指令数)

由于每次指令访问都正好有一次存储器访问来提取指令,所以指令缺失率为:

缺失率(16 KB指令) = 3.82 / 1000 = 0.004

由于36%的指令为数据传输,所以数据缺失率为:

缺失率(16 KB数据) = 40.9 / 1000 / 0.36 = 0.114

统一缺失率需要考虑指令和数据访问:

缺失率(32 KB统一) = 43.3 / 1000 / (1.00 +0.36) = 0.0318

如上所述,大约74%的存储器访问为指令引用。因此,分离缓存的总缺失率为:

(74% x 0.004)+(26% x 0.114) = 0.0326

因此,32KB 统一缓存的实际缺失率略低于两个16 KB缓存。

存储器平均访问时间公式可分为指令访问和数据访问:

存储器平均访问时间 = 指令百分比 x (命中时间+指令缺失率x缺失代价) + 数据百分比 x (命中时间+数据缺失率x缺失代价)

因此,每种组织方式的时间为:

存储器平均访问时间(分离) = 74% x (1 + 0.004 x 200) + 26% x (1 + 0.114 x 200) = (74% x 1.80)+(26% x 23.80) = 7.52

存储器平均访问时间(统一) = 74% x (1 + 0.0318 x 200) + 26% x (1 + 1 + 0.0318 x 200) = 7.62

因此,在这个示例中,尽管分离缓存(每时钟周期提供两个存储器端口,从而避免了结构性冒险)的实际缺失率较差,但其存储器平均访问时间要优于单端口统一缓存。

存储器平均访问时间与处理器性能

一个显而易见的问题是:因缓存缺失导致的存储器平均访问时间能否预测处理器性能。

首先,还有其他原因会导致停顿,比如使用存储器的I/O 设备产生争用。由于存储器层次结构导致的停顿远多于其他原因导致的停顿,所以设计人员经常假定所有存储器停顿都是由于缓存缺失导致的。我们这里也采用这一简化假定,但在计算最终性能时,一定要考虑所有存储器停顿。

第二,上述问题的回答也受处理器的影响。采用循序执行处理器,那回答基本上就是肯定的。处理器会在缺失期间停顿,存储器停顿时间与存储器平均访问时间存在很强的相关性。现在假定采用循序执行,但下一小节会返回来讨论乱序处理器。

如上一节所述,可以为CPU时间建立如下模型:

CPU时间 = (CPU执行时钟周期+存储器停顿时钟周期) x 时钟周期时间

这个公式会产生一个问题:一次缓存命中的时钟周期应看作CPU执行时钟周期的一部分,还是存储器停顿时钟周期的一部分?尽管每一种约定都有自己的正当理由,但最为人们广泛接受的是将命中时钟周期包含在CPU执行时钟周期中。

我们现在可以研究缓存对性能的影响了。

让我们对第一个示例使用循序执行计算机。假定缓存缺失代价为200个时钟周期,所有指令通常都占用1.0个时钟周期(忽略存储器停顿)。假定平均缺失率为2%,每条指令平均有1.5 次存储器引用,每千条指令的平均缓存缺失数为30。如果考虑缓存的行为特性,对性能的影响如何?使用每条指令的缺失数及缺失率来计算此影响。

CPU时间=ICx( CPI执行 + 存储器停顿时钟周期/指令数)x时钟周期时间

其性能(包括缓存缺失)为:

CPU时间包括缓存= IC x [1.0+(30/1000 X 200)] x周期时钟时间 = ICx7.00x时钟周期时间

现在使用缺失率计算性能:

CPU时间 = IC x (CPU执行+缺失率x存储器访问/指令x缺失代价)x时钟周期时间

CPU时间(包括缓存) = IC x [1.0x (1.5 x 2% X 200)] x 时钟周期时间 = ICx7.00x时钟周期时间

在有、无缓存情况下,时钟周期时间和指令数均相同。因此,CPU时间提高至7倍,CPI 从“完美缓存”的1.00增加到可能产生缺失的缓存的7.00。在根本没有任何存储器层次结构时,CPU 将再次升高到1.0+200x1.5=301,比带有缓存的系统长出40多倍。

如上例所示,缓存特性可能会对性能产生巨大影响。此外,对于低CPI、高时钟频率的处理器,缓存缺失会产生双重影响。

  1. CPI(执行)越低,固定数目的缓存缺失时钟周期产生的相对影响越高。
  2. 在计算CPI时,一次缺失的缓存缺失代价是以处理器时钟周期进行计算的。因此,即使两个计算机的存储器层次结构相同,时钟频率较高的处理器在每次缺失时会占用较多的时钟周期,CPI的存储器部分也相应较高。

对于低CPI、高时钟频率的处理器,缓存的重要性更高,因此,如果在评估此类计算机的性能时忽略缓存行为,其危险性更大。Amdahl 定律再次发挥威力!

尽管将存储器平均访问时间降至最低是一个合理的目标(在本附录中大多使用这一目标),但请记住,最终目标是缩短处理器执行时间。下面的例子说明如何区分这两者。

两种不同缓存组织方式对处理器性能的影响如何?假定完美缓存的CPI为1.6,时钟周期时间为0.35 ns,每条指令有1.4 次存储器引用,两个缓存的大小都是128 KB,两者的块大小都是64字节。一个缓存为直接映射,另一个为两路组相联。图B-3显示,对于组相联缓存,必须添加一个多工器,以根据标志匹配在组中的块之间作出选择。由于处理器的速度直接与缓存命中的速度联系在一起,所以假定必须将处理器时钟周期时间扩展1.35 倍,才能与组相联缓存的选择多工器相适应。对于一级近似,每一种缓存组织方式的缓存缺失代价都是65纳秒。

首先,计算存储器平均访问时间,然后再计算处理器性能。假定命中时间为1 个时钟周期,128 KB直接映射缓存的缺失率为2.1%,同等大小的两路组相联缓存的缺失率为1.9%。

存储器平均访问时间为:

存储器平均访问时间=命中时间+缺失率x缺失代价

因此,每种组织方式的时间为:

存储器平均访问时间(一路) = 0.35+(0021 x 65)=1.72ns

存储器平均访问时间(两路) = 0.35 x 1.35+(0019 X 65)=1.71ns

这一存储器平均访问时间优于两路组相联缓存。

处理器性能为:

CPU时间= IC x ( CPI(执行) + 缺失数/指令数 x 缺失代价) x 时钟周期时间 = IC x [(CPI(执行) x 时钟周期时间) + (缺失率x(存储器访问次数/指令数)x缺失代价x时钟周期时间)]

将(缺失代价x时钟周期时间)代以65ns,可得每种缓存组织方式的性能为:

CPU时间(一路)= ICx [1.6 x 0.35 + (0.021x1.4x65)] = 2.47 x IC
CPU时间(两路)= ICx [1.6 x 0.35 x 1.35 + (0.019x1.4x65)] = 2.49 x IC

相对性能为:

CPU时间(两路)/CPU时间(一路) = (2.49x指令数)/(2.47x指令数) = 1.01

与存储器平均访问时间的对比结果相反,直接映射缓存的平均性能略好一些,这是因为尽管两组组相联的缺失数较少,但针对所有指令扩展了时钟周期。由于CPU时间是我们的基本评估,而且由于直接映射的构建更简单一些,所以本示例中的优选缓存为直接映射。

缺失代价与乱序执行处理器

对于乱序执行处理器,如何定义“缺失代价”呢?是存储器缺失的全部延迟,还是仅考虑处理器必须停顿时的“暴露”延迟或无重叠延迟?对于那些在完成数据缺失之前必须停顿的处理器,不存在这一问题。

让我们重新定义存储器停顿,得到缺失代价的一种新定义,将其表示为非重叠延迟:

存储器停顿周期/缺失数 = 缺失数/指令数 x (总缺失代价-直叠缺失延迟)

与此类似,由于一些乱序处理器会拉长命中时间,所以性能公式的这一部分可以除以总命中延迟减去重叠命中延迟之差。可以对这一公式进一 步扩 展,将总缺失延迟分解为没有争用时的延迟和因为争用导致的延迟,以考虑乱序处理器中的存储器资源。我们仅关注缺失延迟。我们现在必须决定以下各项。

  • 存储器延迟长度——在乱序处理器中如何确定存储器操作的起止时刻。
  • 延迟重叠的长度——如何确定与处理器相重叠的起始时刻(或者说,在什么时刻我们说存储器操作使处理器停顿)。

由于乱序执行处理器的复杂性,所以不存在单一的准确定义。

由于在流水线退出阶段只能看到已提交的操作,所以我们说:如果处理器在一个时钟周期内没有退出(retire)最大可能数目的指令,它就在该时钟周期内停顿。我们将这一停顿记在第一条未退出指令的账上。这一定义绝不像看上去那么简单。例如,为缩短特定停顿时间而应用某一种优化,并不一定总能缩短执行时间,这是因为此时可能会暴露出另一种类型的停顿(原本隐藏在所关注的停顿背后)。

关于延迟,我们可以从存储器指令在指令窗口中排队的时刻开始测量,也可以从生成地址的时刻开始,还可以从指令被实际发送给存储器系统的时刻开始。只要保持一致,任何一种选项都是可以的。

让我们重做上面的例题,但这一次假定具有较长时钟周期时间的处理器支持乱序技术,但仍采用直接映射缓存。假定65 ns的缺失代价中有30%可以重叠,也就是说,CPU存储器平均停顿时间现在为45.5 ns。

乱序计算机的存储器访问时间为:

存储器平均访问时间(一路、乱序) = 0.35 x 1.35 + (0.021 x45.5)= 1.43 ns .

乱序缓存的性能为:

CPU时间(一路、乱序) = IC x [1.6 x 0.35 x 1.35 + (0.021 x 1.4 x 45.5)] = 2.09 x IC

因此,尽管乱序计算机的时钟周期时间要慢得多,直接映射缓存的缺失率也更高一些,但如果它能隐藏30%的缺失代价,那仍然可以稍快一些。

总而言之,尽管乱序处理器存储器停顿的定义和测量比较复杂,但由于它们会严重影响性能,所以应当了解这些问题。这一复杂性的出现是因为乱序处理器容忍由缓存缺失导致一定的延迟,不会对性能造成伤害。因此,设计师在评估存储器层次结构的权衡时,通常使用乱序处理器与存储器的模拟器,以确保一项帮 助缩短平均存储器延迟的改进能够真的有助于提高程序性能。为了帮助总结本节内容,同时也作为一个方便使用的参考,图B-4列出了本附录中的缓存公式。



图B-4 本附录中的性能公式汇总。第一个公式计算缓存索引大小,其余公式帮助评估性能。后两个公式处理多级缓存

6种基本的缓存优化

存储器平均访问时间公式为我们提供了一个框架,用于展示提高缓存性能的缓存优化方法:

存储器平均访问时间=命中时间+缺失率x缺失代价

因此,我们将6种缓存优化分为以下3类。

  • 降低缺失率——较大的块、较大的缓存、较高的关联度。
  • 降低缺失代价——多级缓存,为读取操作设定高于写入操作的优先级。
  • 缩短在缓存中命中的时间——在索引缓存时避免地址转换。

改进缓存特性的经典方法是降低缺失率,我们给出3种实现技术。为了更好地理解导致缺失的原因,首先介绍一个模型,将所有缺失分为3个简单类别。

  • 强制缺失(Compulsory)——在第一次访问某个块时,它不可能会在缓存中,所以必须将其读到缓存中。这种缺失也被称为冷启动缺失或首次引用缺失。
  • 容量缺失(Capacity)——如果缓存无法容纳程序执行期间所需要的全部块,由于一些块会被放弃,过后再另行提取,所以会(在强制缺失之外)发生容量缺失。
  • 冲突缺失(Conflict)——如果块布置策略为组相联或直接映射,则会(在强制缺失和容量缺失之外)发生冲突缺失,这是因为如果有太多块被映射到一个组中,这个组中的某个块可能会被放弃,过后再另行提取。这种缺失也被称为碰撞缺失。其要点就是:由于对某些常用组的请求数超过n,所以本来在全相联缓存中命中的情景会在n路组相联缓存中变为缺失。

表B-4显示了根据3C分类后的缓存缺失相对频率。强制缺失在无限缓存中发生,容量缺失在全相联缓存中发生。冲突缺失在从全相联变为八路相联、四路相联…时发生。图B-5以图形方式展示相同数据。上图显示绝对缺失率,下图绘制了当缓存大小变化时,各类缺失占总缺失数的百分比曲线。

  • 强制缺失与缓存大小无关,而容量缺失随容量的增加而降低,冲突缺失随相联度的增大而降低。

图B-5以围形方式显示了相同数据。注意,在不超过128KB时,大小为N的直接映射缓存的缺失率大约与大小为N/2的两路组相联缓存的缺失率相同。大于128KB的缓存不符合这一规则。注意,“容量” 列给出的也是全相联缺失率。

图B-5 根据表B-4中的3C数据,每种不同缓存大小的总缺失率(上)和缺失率分布(下)。上图显示实际数据缓存缺失率,下图显示每个类别的百分比

为了展示相联度的好处,将冲突缺失划分为每次相联度下降时所导致的缺失。共有4类冲突缺失,其计算方式如下所示。

  • 八路——从全相联(无冲突)到八路相联时产生的冲突缺失。
  • 四路——从八路相联到四路相联时产生 的冲突缺失。
  • 两路——从四路相联到两路相联时产生的冲突缺失。
  • 一路——从两路相联到一路相联(直接映射)时产生的冲突缺失。

从图中可以看出,SPEC2000程序的强制缺失率非常低,对许多长时间运行的程序都是如此。

从概念上来讲,冲突缺失是最容易避免的:全相联布置策略就可以避免所有冲突缺失。但是,全相联的硬件实现成本非常高昂,可能会降低处理器时间频率,从而降低整体性能。除了增大缓存之外,针对容量缺失没有什么办法了。如果上一级存储器远小于程序所需要的容量,那就会有相当一部分时间用于在层次结构的两级之间移动数据,我们说这种存储器层次结构将会摆动。由于需要太多的替换操作,所以摆动意味着计算机的运行速度接近于低级存储器的速度,甚至会因为缺失开销变得更慢。

另外一种降低3C缺失的方法是增大块的大小,以降低强制缺失数,但稍后将会看到,大型块可能会增加其他类型的缺失。

3C分类使我们可以更深入地了解导致缺失的原因,但这个简单的模型也有它的局限性;它让我们深入地了解了平均性能,但不能解释个体缺失。例如,由于较大的缓存可以将引用扩展到更多个块中,所以改变缓存大小会改变冲突缺失和容量缺失。因此,当缓存大小变化时,一个缺失可能会由容量缺失变为冲突缺失。注意,3C分类还忽略了替换策略,一方面是因为其难以建模,另一方面是因为它总体来说不太重要。但在具体环境中,替换策略可能会实际导致异常行为,比如,在大相联度下得到较低的缺失率,这与3C模型的结果矛盾。

遗憾的是,许多降低缺失率的技术也会增加命中时间或缺失代价。在使用3种优化方法降低缺失率时,必须综合考虑提高整体系统速度的目标,使两者达到平衡。第一个例子显示了平衡观点的重要性。

第一种优化方法:增大块大小以降低缺失率

降低缺失率的最简单方法是增大块大小。图B-6针对一组程序及缓存大小,给出了块大小;与缺失率的折中。较大的块大小也会降低强制缺失。这一降低是因为局域性原理分为两个部分:时间局域性和空间局域性。较大的块充分利用了空间局域性的优势。

图B-6 对于5种不同大小的缓存,缺失率与块大小的相互关系。注意,如果与缓存大小相比,块大小过大,则缺失率实际上会上升。每条曲线表示一个不同大小的缓存。

同时,较大的块也会增加缺失代价。由于它们降低了缓存中的块数,所以较大块可能会增大冲突缺失,如果缓存很小,甚至还会增加容量缺失。显然,没有理由要将块大小增大到会升高缺失率的程度。如果它会增加存储器平均访问时间,那降低缺失率也没有什么好处。缺失代价的增加会超过缺失率的下降。

  • 注意,对于4KB缓存,块大小为256字节时的缺失率高于32字节。在本例中,缓存大小必须为256KB,以使块大小为256字节时能够降低缺失。

表B-5显示了图B-6中绘制的实际缺失率。假定存储器系统的开销为80个时钟周期,然后每2个时钟周期提交16 个字节。因此,它可以在82个时钟周期内提供16个字节,在84个时钟周期内提供32个字节,以此类推。对于表B-5中的每种缓存大小,哪种缓存大小的存储器平均时间最短?

如果我们假定命中时间为1个时钟周期,与块大小无关,那么在4KB缓存中,对16字节块的访问时间为:

存储器平均访问时间 = 1+(8.57% x 82) = 8.027时钟周期

在256 KB缓存中,对256字节块的存储器平均访问时间为:

存储器平均访问时间 = 1+(0.49% x 112) = 1.549时钟周期

表B-6显示了这两个极端值之间所有块与缓存大小的存储器平均访问时间。粗体项目表示对于给定缓存大小能够实现最快访问的块大小:若缓存大小为4KB,则块大小为32字节时的访问速度最快;若缓存大小大于4 KB,则块大小应为64字节。事实上,这些数值也正是当前处理器缓存的常见块大小。

  • 注意,绝大多数的块大小为32字节和64字节。每种缓存大小的最短平均访问时间用黑体标出。

在所有这些技术中,缓存设计者都在尝试尽可能同时降低缺失率和缺失代价。块大小的选择有赖于低级存储器的延迟和带宽。高延迟和高带宽鼓励采用大块,因为缓存在每次缺失射能够获取的字节可以多出许多,而缺失代价却很少增加。相反,低延迟和低带宽则鼓励采用小块,因为这种情况下采用较大块不会节省多少时间。例如,一个小块的两倍缺失代价可能接近一个两倍大小块的缺失代价。更多的小块还可能减少冲突缺失。注意,图B-6和表B-6给出了基于缺失率最低、存储器平均时间最短选择块大小时的差别。

在了解了较大块对强制缺失和容量缺失的正面与负面影响之后,下面两小节将研究较高容量和较高相联度的可能性。

第二种优化方法:增大缓存以降低缺失率

降低表B-4和图B-5中容量缺失的最明显方法是增加缓存的容量,其明显的缺点是可能延长命中时间、增加成本和功耗。这一技术在片外缓存中尤其常用。

第三种优化方法:提高相联度以降低缺失率

表B-4和图B-5显示了缺失率是如何随着相联度的增大而得以改善的。从中可以看出两个一般性的经验规律。第一条规律是:对于这些特定大小的缓存,从实际降低缺失数的功效来说,八路组相联与全相联是一样有效的。通过对比表B-4中的八路项目与容量缺失列可以看出这差别,其中的容量缺失是使用全相联缓存计算得出的。

从图中观察得到的第二条规则称为2:1缓存经验规律:大小为N的直接映射缓存与大小为N/2的两路组相联缓存具有大体相同的缺失率。这一规律对3C图形中小于128KB的缓存也是成立的。

与许多此类示例类似,要改善存储器平均访问时间的一个方面,可能会导致另一方面的恶化。增大块大小可以降低缺失率,但会提高缺失代价;增大相联度可能会延长命中时间。因此,加快处理器时钟速度的压力鼓励使用简单的缓存设计,但提高相联度的回报是提高缺失代价,如下例所示。

假定提高相联度将会延长时钟周期时间,如下所示:

  • 时钟周期时间(两路) = 1.36 x 时钟周期时间(一路)
  • 时钟周期时间(四路) = 1.44 x 时钟周期时间(一路)
  • 时钟周期时间(八路) = 1.52 x 时钟周期时间(一路)

假定命中时间为1个时钟周期,直接映射情景的缺失代价为到达第2级缓存的25个时钟周期,在第2级缓存中绝对不会缺失,还假定不需要将缺失代价舍入为整数个时钟周期。

每种相联度的存储器平均访问时间为:

存储器平均访问时间(八路) = 命中时间(八路) + 缺失率路x缺失代价(八路) = 1.52+缺失率(八路)x25

  • 存储器平均访问时间(四路) = 1.44+缺失率(四路) x 25
  • 存储器平均访问时间(两路) = 1.36+缺失率(两路) x 25
  • 存储器平均访问时间(一路) = 1.00+缺失率(一路) x 25

每种情况下的缺失代价相同,所以我们使其保持25个时钟周期。例如,对于一个4 KB的直接映射缓存,存储器平均访问时间为:

存储器平均访问时间(一路) = 1.00+ (0.098 x 25) = 3.44

对于512 KB八路组相联缓存,该时间为:

存储器平均访问时间(八路) = 1.52+(0.006x25) = 1.66

利用这些公式及表B-4中的缺失率,表B-7给出了每种缓存和相联度时的存储器平均访问时间。该表显示,对于不大于8KB、不超过四路相联度的缓存,本例中的公式成立。从16 KB开始,较大相联度的较长命中时间超过了因为缺失降低所节省的时间。

注意,在本例中,我们没有考虑较慢时钟频率对程序其余部分的影响,因此低估了直接映射缓存的收益。

第四种优化方法:采用多级缓存降低缺失代价

降低缓存缺失已经成为缓存研究的传统焦点,但缓存性能公式告诉我们:通过降低缺失代价同样可以获得降低缺失率所带来的好处。此外,图2-2显示的技术趋势表明:处理器的速度增长快于DRAM,从而使缺失代价的相对成本随时间的推移而升高。处理器与存储器之间的性能差距让架构师开始思考这样一个问题:是应当加快缓存速度以与处理器速度相匹配呢?还是让缓存更大一些,以避免加宽处理器与主存储器之间的鸿沟?

一个回答是:两者都要。在原缓存与存储器之间再添加一级缓存可以简化这一决定。第一级缓存可以小到足以与快速处理器的时钟周期时间相匹配。而第二级缓存则大到足以捕获本来可能进入主存储器的访问,从而降低实际缺失代价。尽管再添加一级层次结构的思路非常简单,但它增加了性能分析的复杂程度。第二级缓存的定义也并非总是那么简单。首先让我们为一个二级缓存定义存储器平均访问时间。用下标L1和L2分别指代第一级、 第二级缓存,原公式为:

存储器平均访问时间=命中时间L1 + 缺失率L1 x 缺失代价L1

缺失代价L1=命中时间L2 + 缺失率L2 x 缺失代价L2

存储器平均访问时间=命中时间L1 + 缺失率L1 x (命中时间L2 + 缺失率L2 x 缺失代价12)

在这个公式中,第二级缺失率是针对第-级缓存未能找到的内容进行测量的。为了避免模糊,对二级缓存系统采用以下术语。

  • 局部缺失率——此比值即是缓存中的缺失数除以对该缓存进行存储器访问的总数。可以想到,对于第一级缓存, 它等于缺失率L1,对于第二级缓存,它等于缺失率L2
  • 全局缺失率——缓存中的缺失数除以处理器生成的存储器访问总数。利用以上术语,第一级缓存的全局缺失率仍然为缺失率L1,但对于第二级缓存则为缺失率L1x缺失率L2

第二级缓存的这一局部缺失率很大,这是因为第一级缓存已经提前解决了存储器访问中便于实现的部分。这就是为什么说全局缺失率是一个更有用的度量标准:它指出在处理器发出的存储器访问中,有多大比例指向了存储器。

这是一个让每条指令缺失数度量闪光的地方。利用这一度量标准,不用再担心局部缺失率或全局缺失率的混淆问题,只需要扩展每条指令的存储器停顿,以增加第二级缓存的影响。

每条指令的平均存储器停顿时间=每条指令的缺失数L1x命中时间L2 + 每条指令的缺失数L2x缺失代价L2

假定在1000次存储器引用中,第一级缓存中有40次缺失,第二级缓存中有20次缺失。各缺失率等于多少?假定L2缓存到存储器的缺失代价为200个时钟周期,L2缓存的命中时间为10个时钟周期,L1 的命中时间为1个时钟周期,每条指令共有1.5次存储器引用。每条指令的存储器平均访问时间和平均停顿周期为多少?

第一级缓存的缺失率(局部缺失率或全局缺失率)为40/1000=4%。第二级缓存的局部缺失率为20/40=50%。第二级缓存的全局缺失率为20/1000=2%。则:

存储器平均访问时间=命中时间L1+缺失率L1 x (命中时间L2+缺失率L2x缺失代价L2) = 1+4% x (10+50% x 200) = 5.4个时钟周期

为了知道每条指令会有多少次缺失,我们将1000次存储器引用除以每条指令的1.5次存储器引用,得到667条指令。因此,我们需要将缺失数乘以1.5,得到每千条指令的缺失数。于是得到每千条指令的L1缺失数为40x 1.5=60次,L2缺失数为20x1.5= 30次。关于每条指令的平均存储器停顿,假定缺失数在指令与数据之间是均匀分布的:

每条指令的平均存储器停顿=每条缺失的缺失数L1 x 命中时间L2 + 每条指令的缺失数L2 x 缺失代价L2 = (60/1000) x 10 + (30/1000) x 200 = 0.060 x 10 + 0.030 x 200 = 6.6个时钟周期

如果从存储器平均访问时间(AMAT)中减去L1命中时间,然后再乘以每条指令的平均存储器引用数,则可以得到每条指令平均存储器停顿值:(5.4- 1.0) x 1.5 = 4.4 x 1.5 - 6.6个时钟周期

注意,这些公式是针对混合式读取与写入操作的,假定采用写回第一级缓存。显然,直写第一级缓存会将所有写入操作都发往第二二级,而不是仅限于缺失,而且还可能使用写入缓冲区。

图B-7和图B-8显示了一个设计中的缺失率和相对执行时间是如何随着第一二级缓存的大小而变化的。从两个图中可以有两点体悟。第一,如果第二级缓存远大于第一级缓存,则全局缓存缺失率与第二级缓存的单一缓存缺失率非常类似。第二,局部缓存缺失率不是第二级缓存的良好度量标准;它是第一级缓存缺失率的函数,因此可以通过改变第一级缓存而变化。所以,在评估第一级缓存时,应当使用全局缓存缺失率。

图B-7 多级缓存的缺失率随缓存大小的变化。小于两个64 KB一级缓存总和的第二级缓存没有什么意义,从其高缺失率中可以反映出这一点。大于256 KB之后,单个缓存在全局缺失率在10%以内。单级缓存的缺失率随大小的变化是根据第二级缓存的局部缺失率和全局缺失率绘制的,采用的是32 KB一级缓存。L2缓存(统一缓存)为两路组相联,采用替换策略。分别有独立的L2指令与数据缓存,它们都是64 KB两路组相联,采用LRU替换策略。L1与L2缓存的块大小均为64字节。

图B-8 相对执行时间与第二级缓存大小的关系。图中的每两个长条表示一次L2缓存命中的不同时钟周期。引用执行时间1.00 是指一个8192 KB第二级缓存在第二级命中的延迟为1个时钟周期。

有了这些定义,我们可以考虑第二级缓存的参数。两级缓存之间的首要区别就是第一级缓存的速度影响着处理器的时钟频率,而第二级缓存的速度仅影响第一级缓存的缺失代价。因此,我们可以在第二级缓存中考虑许多不能用于第一级缓存的替代选项。在设计第二级缓存时,主要有两个问题:是否要降低CPI的存储器平均访问时间部分?其成本有多高?

首先要决定的是第二级缓存的大小。由于第一级缓存中的所有内容都可能在第二级缓存中,所以第二级缓存应当远大于第一级缓存。如果第二级缓存只是稍大一点,那局部缺失率将会很高。这一观察结果激励了巨型第二级缓存的设计。

给定以下数据,第二级缓存相联度对其缺失代价的影响如何?

  • 直接映射的命中时间L2为10个时钟周期。
  • 两路组相联将命中时间增加0.1个时钟周期,达到10.1个时钟周期。
  • 直接映射的局部缺失率L2为25%。
  • 缺失代价I2为200个时钟周期。
  • 两路组相联直接映射的局部缺失率L2为20%。

对于直接映射第二级缓存,第一级缓存缺失代价为:
缺失代价(一路L2) = 10 + 25% x 200 = 60.0个时钟周期

加上相联度的成本仅使命中成本增加0.1个时钟周期,由此得到新的第一级缓存缺失代价为:

缺失代价(两路L2) = 10.1 + 20% x 200 = 50.1个时钟周期

事实上,第二级缓存几乎总是与第一级缓存、处理器同步。相应地,第二级命中时间必须为整数个时钟周期。如果幸运的话,我们可以将第二级命中时间缩短到10个时钟周期;如果不够幸运,则会舍入到11个周期。相对于直接映射第二级缓存,任一选项都是一种改进:

  • 缺失代价两路L2 = 10+20% x 200 = 50.0个时钟周期
  • 缺失代价两路12 = 11+20% x 200 = 51.0个时钟周期

现在我们可以通过降低第二级缓存的缺失率来降低缺失代价了。

另一条关注事项涉及第一级缓存中的数据是否在第二级缓存中。多级包含是存储器层次结构的一种自然策略:L1 数据总是出现在L2中。这种包含性是我们所希望的,因为仅通过检查第二级缓存就能确定IO与缓存之间(或多处理器中的缓存之间)的一致性。

包含性的一个缺点就是:测量结果可能提议对较小的第一级缓存使用较小的块,对较大的第二级缓存使用较大的块。例如,Pentium 4的L1缓存中的块为64个字节,L2缓存中的块为128个字节。包含性仍然能够得到保持,但在第二级缺失时要做更多工作。如果一级块所映射的二级块将被替换,则第二级缓存必须使所有此类一级块失效,从而会略微提高第一级缺失率。为了避免此类问题,许多缓存设计师使所有各级缓存的块大小保持一致。

但是,如果设计师只能承受略大于L1缓存的L2缓存呢?它是不是有很大一部分空间要被用作L1缓存的冗余副本?在此种情况下,可以使用一种明显相反的策略:多级互斥,L1中的数据绝对不会出现在L2缓存中。典型情况下,在采用互斥策略时,L1中的缓存缺失将会导致L1与L2的块交换,而不是用L2块来替代L1块。这一策略防止了L2缓存中的空间浪费。例如,AMD Opteron芯片使用两个64 KB L1缓存和1 MB L1缓存来执行互斥策略。

这些问题表明,尽管一些新手可能会独立地设计第一级和第二级缓存,但在给定一个兼容第二级缓存时,第一级缓存设计师的任务要简单一些。比如,如果下一级有写回缓存来为重复写入操作提供支持,而且使用了多级包含,那使用直写的风险就会小一些。所有缓存设计的基础都是在加速命中和减少缺失之间实现平衡。对于第二级缓存,命中数要比第一级缓存中少得多,所以重心更多地偏向减少缺失。因为这一认知,人们开始采用大得多的缓存和降低缺失率的技术,比如更高的相联度和更大的块。

第五种优化方法:使读取缺失的优先级高于写入缺失,以降低缺失代价

这一优化方法在完成写入操作之前就可以为读取操作提供服务。我们首先看一下写入缓冲区的复杂性。

采用直写缓存时,最重要的改进就是一个大小合适的写入缓冲区。但是,由于写入缓冲区可能包含读取缺失时所需要的更新值,所以它们的确会使存储器访问变得复杂。

看以下序列:

1
2
3
SW R3, 512(R0)   ;M[512] ← R     (cache index 0)
LW R1, 1024(R0) ;R1 ← M[1024] (cache index 0)
LW R2, 512(R0) ;R2 ← M[512] (cache index 0)

假定有一个直接映射直写缓存,它将512和1024映射到同一块中,假定有一个四字写入缓存区,在读取缺失时不会进行检查。R2中的值是否总等于R3中的值?

使用第2章的术语,这是存储器中的一个“写后读”数据冒险。我们通过跟踪一次缓存访问来了解这种危险性。R3的数据在存储之后被放在写入缓冲区中。随后的载入操作使用相同的缓存索引,因此产生一次缺失。第二条载入指令尝试将位置512处的值放到寄存器R2 中,这样也会导致一次缺失。如果写入缓冲区还没有完成向存储器中位置512的写入,对位置512的读取就会将错误的旧值放到缓存块中,然后再放入R2中。如果没有事先防范,R3是不等于R2的!

摆脱这一两难境地的最简单方法是让读取缺失一直等待到写入缓冲区为空为止。一种替代方法是在发生读取缺失时检查写入缓冲区的内容,如果没有冲突而且存储器系统可用,则让读取缺失继续。几乎所有桌面与服务器处理器都使用后一方法,使读取操作的优先级高于写入操作。

处理器在写回缓存中的写入成本也可以降低。假定一次读取缺失将替换一个脏服务器块。我们不是将这个脏块写到存储器中,然后再读取存储器,而是将这个脏块复制到缓冲区中,然后读存储器,然后再写存储器。这样,处理器的读取操作将会很快结束(处理器可能正在等待这一操作的完成)。和前一种情况类似,如果发生了读取缺失,处理器或者停顿到缓冲区为空,或者检查缓冲区中各个字的地址,以了解是否存在冲突。

命中时间会影响到处理器的时钟频率,所以它是至关重要的;在今天的许多处理器中,缓存访问时间限制都限制了时钟频率,即使那些使用多个时钟周期来访问缓存的处理器也是如此。因此,缩短命中时间可以对各个方面提供帮助,从而具有多重重要性,超出了存储器平均访问时间公式的限制。

第六种优化方法:避免在索引缓存期间进行地址转换,以缩短命中时间

即使一个小而简单的缓存也必须能够将来自处理器的虚拟地址转换为用以访问存储器的物理地址。如B.4节所述,处理器就是将主存储器看作另一级存储器层次结构,因此,必须将存在于磁盘上的虚拟存储器地址映射到主存储器。

根据“加快常见情景速度”这一指导原则,我们为缓存使用虚拟地址,因为命中的出现频率当然远高于缺失。这种缓存被称为虚拟缓存,而物理缓存用于表示使用物理地址的传统缓存。问题是:在索引缓存中应当使用虚拟地址还是使用物理地址,在标志对比中应当使用虚拟地址还是使用物理地址。如果对索引和标志都完全采用虚拟寻址,那在缓存命中时就可以省掉地址转换的时间。

那为什么不是所有体系结构都构建虚拟寻址的缓存呢?一个原因是要提供保护。在将虚拟地址转换为物理地址时,无论如何都必须检查页级保护。一种解决方案是在缺失时从TLB复制保护信息,添加一个字段来保存这一信息,然后在每次访问虚拟寻址缓存时进行核对。另一个原因是:在每次切换进程时,虚拟地址会指向不同的物理地址,需要对缓存进行刷新。图B-9显示了这一刷新对缺失率的影响。一种解决方案是增大缓存地址标志的宽度,增加一个进程识别符标志(PID)。如果操作系统将这些标志指定给进程,那么只需要在PID被回收时才刷新缓存;也就是说,PID 可以区分缓存中的数据是不是为此这个程序准备的。图B-9显示了通过PID避免缓存刷新而对缺失率的改善。

图B-9 一个程序的缺失率随虚拟寻址缓存大小的变化,分三种情况测量:没有进程切换(单进程)、使用进程识别符标志(PID)进行进程切换,有进程切换但没有PID,即清除(purge) 模式。PID使单进程绝对缺失率增加0.3%~0.6%,比清除模式节省0.6%~4.3%。

虚拟缓存没有更加普及的第三个原因是操作系统和用户程序可能为同一物理地址使用两种不同的虚拟地址。这些重复地址称为同义地址或别名地址,可能会在虚拟缓存中生成同一数据的两个副本;如果其中一个被修改了,另一个就会包含错误值。而采用物理缓存是不可能发生的,因为这些访问将会首先被转换为相同的物理缓存块。

同义地址问题的硬件解决方案称为别名消去,保证每个缓存块都拥有一个独一无二的物理地址。软件可以强制这些别名共享某些地址位,从而大大简化了这一问题。 比如Sun要求所有别名地址的后面18位都必须相同;这一限制称为页面着色。注意,页面着色就是向虚拟存储器应用的组相联映射:使用64(2%)个组来映射4 KB(212)个页面,确保物理地址和虚拟地址的后18位匹配。这一限制意味着不大于 218 (256 K)字节直接映射缓存绝对不会为块使用重复的物理地址。从缓存的角度来看,页着色有效地增大了页偏移,因为软件保证了虚拟、物理页地址的最后几位是相同的。

最后一部分与虚拟地址相关的领域是I/O。I/O 通常使用物理地址,从而需要映射到虚拟地址,以与虚拟地址进行交换。一种使虚拟缓存与物理缓存均能实现最佳性能的备选方法是使用一部分页偏移量(也就是虚拟地址与物理地址保持一致的那一部分)来索引缓存。在使用索引读取缓存的同时,地址的虚拟部分被转换,标志匹配使用了物理地址。

这一备选方法允许缓存读取操作立即开始,而标志对比仍然使用物理地址。这种虚拟索引、物理标志备选方法的局限性是直接映射缓存不能大于页大小。为了利用这一技巧,虚拟页大小至少为2(9+6)个字节,即32 KB。如果不是这样,则必须将该索引的一部分由虚拟地址转换为物理地址。图B-10显示了在使用这一技术时的缓存、转换旁视缓冲区(TLB)和虚拟存储器的组织方式。

图B-10 一种从虛拟地址到L2缓存访问的虚设存储器层次结构的整体图像。页大小为16 KB。TLB是拥有256项的两路组相联。L1 缓存是一个直接映射16 KB,L2缓存是一个总容量为4 MB的四路组相联。这两者的块大小都是64个字节。虚拟地址为64位,物理地址为40位

相联度可以将此索引保存在地址的物理部分,但仍支持大型缓存。回想一下,索引的大小受以下公式的控制:

2^索引 = 缓存大小/(块大小x组相联)

例如,使相联度和缓存大小同时加倍并不会改变索引的大小。作为一个极端示例,IBM 3033缓存是一个十六路组相联,尽管研究表明:在八路以上的组相联中,对缺失率没有什么好处。尽管IBM体系结构中存在页大小为4KB这一障碍,这一高相联度允许使用物理索引对64 KB缓存进行寻址。

基本缓存优化方法小结

本节介绍了用于降低缺失率与缺失代价、缩短命中时间的技术,这些技术通常会影响到存储器平均访向公式的其他部分,还会影响到存储器层次结构的复杂性。表B-8总结了这些技术,并估计了对复杂性的影响,“+”表示该技术对该因素有改进,“-” 表示该技术对该因素有伤害,空白表示没有影响。本图中任何一项优化方法都不能对一个以上的类别提供帮助。

虛拟存储器

必须有一种方法,用于在许多进程之间共享较少量的物理空间。

其中一种做法——虚拟存储器,将物理存储器划分为块,并分配给不同的进程。这种方法必然要求采用一种保护机制来限制各个进程,使其仅能访问属于自己的块。虚拟存储器的许多形式还缩短了程序的启动时间,因为程序启动之前不再需要物理存储器中的所有代码和数据。尽管由虚拟存储器提供的保护对于目前的计算机来说是必需的,但共享并不是发明虚拟内存的原因。如果一个程序对物理内存来说变得过于庞大,就需要由程序员负责将其装进去。程序员将程序划分为片段,然后确认这些互斥的片断,在执行时间根据用户程序控制来加载或卸载这些覆盖段(overlay)。程序员确保程序绝对不会尝试访问超出计算机现有的物理主存储器,并确保会在正确的时间加载正确的覆盖段。容易想到,这种责任降低了程序员的生产效率。

虚拟存储器的发明是为了减轻程序员的这一负担;它自动管理表示为主存储器和辅助存储的两级存储器层次结构。图B-11显示了程序从虚拟存储器到物理存储器的映射,共有4个页面。

除了共享受保护的存储器空间和自动管理存储器层次结构之外,虚拟存储器还简化了为执行程序而进行的加载过程。这种被称为再定位(relocation)的机制允许同一程序在物理存储器中的任意位置运行。图B-11中的程序可以定位在物理存储器中的任何位置,也可以放在磁盘上,只需要改变它们之间的映射即可。(在虚拟存储器普通之前,处理器中包含一个用于此目的的再定位寄存器。)硬件解决方案的一种替代方法是使用软件,在每次运行一个程序时,改变其所有地址。

图B-11 左侧给出位于相邻虚拟地址空间中的逻辑程序。它包括A、B、C和D4个页。这些块中有3个的实际位置在物理主存储器中,另一个位于磁盘上

第1章中几个有关缓存的一般性存储器层次结构思想与虚拟存储器类似,当然,其中有许多术语不同。页或段表示块,页错误或地址错误用于缺失。有了虚拟存储器,处理器会给出虚拟地址,由软硬件组合方式转换为物理地址,再来访问主存储器。这一过程称为存储器映射或地址转换。今天,由虚拟地址控制的两级存储器层次结构为DRAM和磁盘。表B-9显示了虚拟存储器存储器层次结构参数的典型范围。

除了表B-9中提到的量化区别之外,缓存与虚拟存储器之间还有其他一些区别,如下所述。

  • 发生缓存缺失时的替换主要由硬件控制,而虚拟存储器替换主要由操作系统控制。缺失代价越长,正确作出决定就显得越重要,所以操作系统可以参与其中,花费一些时间来决定要替换哪些块。
  • 处理器地址的大小决定了虚拟存储器的大小,但缓存大小与处理器地址大小无关。
  • 除了在层次结构中充当主存储器的低一级后援存储之外,辅助存储还用于文件系统。事实上,文件系统占用了大多数辅助存储。它通常不在地址空间中。

虚拟存储器还包含几种相关技术。虚拟存储器系统可分为两类:页,采用大小固定的块;段,采用大小可变的块。页大小通常固定为4096至8 192字节,而段大小是变化的。任意处理器所支持的最大段范围为216个字节至232个字节,最小段为1个字节。图B-11显示了这两种方法可以如何划分代码和数据。


图B-11 分页和分段方式对程序的划分示例

是使用页虚拟存储器还是段虚拟存储器,这一决定会影响处理器。页寻址方式的地址是单一固定大小,分为页编号和页内偏移量,与缓存寻址类似。单一地址对分段地址无效,可变大小的段需要1个字来表示段号,1个字表示段内的偏移量,总共2个字。对编译器来说,不分段地址空间更简单一些。

这两种方法的优缺点已经在操作系统教科书中进行了很好的阐述,表B-10总结了这些观点。由于替换问题(表中第三行),今天很少再有计算机使用纯粹的分段方法。一些计算机使用一种名为页式分段的混合方式,在这种方式中,一个段由整数个页组成。由于存储器不需要是连续的,也不需要所有段都在主存储器中,从而简化了替换过程。最近的一种混合方式是由计算机提供多种页面大小,较大页面的大小为最小页面大小的整数倍,且为2的幂。

  • 这两者都可能浪费存储器,取决于块大小及各分段能否很好地容纳于主存储器中。采用不受限指针的编程语言需要传递段和地址。一种称为页式分段的混合方法可以发挥这两者的最佳状态:分段由页组成,所以替换一个块是很轻松的,而一个段仍被看作一个逻辑单位。

再谈存储器层次结构的4个问题

我们现在已经为回答虚拟存储器的四个存储器层次结构问题做好了准备。

问题1:一个块可以放在主存储器的什么位置?

虚拟存储器的缺失代价涉及旋转磁存储设备的访问,因此非常高。如果在较低缺失率与较简单放置算法之间进行选择,操作系统设计人员通常选择较低缺失率,因为其缺失代价可能会高得离谱。因此,操作系统允许将块放在主存储器中的任意位置。根据图B-1中的术语,这一策略可以标记为全相联的。

问题2:如果一个块在主存储器中,如何找到它?

分页和分段都依靠一种按页号或段号索引的数据结构。这种数据结构包含块的物理地址。对于分段方式,会将偏移量加到段的物理地址中,以获得最终物理地址。对于分页方式,该偏移量只是被串接到这一物理分布地址。

图B-13 通过页表将虚拟地址映射到物理地址

这一包含物理页地址的数据结构通常采用一种分页表的形式。这种表通常根据虚拟页号进行索引,其大小就是虚拟地址空间中的页数。如果虚拟地址为32位、4 KB页、每个页表项(PTE)大小为4字节,则页表的大小为(2^32/2^12) x 2^2 = 2^22即4 MB。

为了缩小这一数据结构,一些计算机向虚拟地址应用了一种散列功能。这种散列允许数据结构的长度等于主存储器中物理页的数目。这一数目可以远小于虚拟页的数目。这种结构被称为反转分页表。利用前面的例子,一个512 MB的物理存储器可能只需要1 MB (8 x 512 MB/4KB)的反转分页表;每个页表项另外需要4字节,用于表示虚拟地址。

为了缩短地址转换时间,计算机使用一个专门进行这些地址变换的缓存,称为变换旁视缓冲区,或者简称为变换缓冲区。

问题3:在虛拟存储器缺失时应当替换哪个块?

前面曾经提到,操作系统的最高指导原则是将页错误降至最低。几乎所有操作系统都与这一指导原则保持一致,尝试替换最近使用最少(LRU)的块,这是因为如果用过去的信息来预测未来,将来用到这种块的可能性最低。

为了帮助操作系统评估LRU,许多处理器提供了一个使用位或参考位,从逻辑上来说,只要访问一个页,就应对其进行置位。(为了减少工作,通过仅在发生转换缓冲区缺失时对其进行置位,稍后将对此进行介绍。)操作系统定期对这些使用位清零,之后再记录它们,以判断在一个特定时间段时使用了哪些页。通过这种方式进行跟踪,操作系统可以选择最近引用最少的一个页。

问题4:在写入时发生什么?

主存储器的下一级包含旋转磁盘,其访问会耗时数百万个时钟周期。由于访问时间的巨大差异,还没有人构建一种虚拟存储器操作系统,在处理器每次执行存储操作时将主存储器直写到磁盘上。因此,这里总是采用写回策略。

由于对低一级的非必需访问会带来如此之高的成本,所以虚拟存储器系统通常会包含一个重写位。利用这一重写位,可以仅将上次读取磁盘之后经过修改的块写至磁盘。

快速地址变换技术

分页表通常很大,从而存储在主存储器中,有时它们本身就是分页的。分页意味着每次存储器访问在逻辑上至少要分两次进行,第一次存储器访问是为了获得物理地址,第二次访问是为了获得数据。第2章曾经提到,我们使用局域性来避免增加存储器访问次数。将地址变换局限在一个特殊缓存中,存储器访问就很少再需要第二次访问来转换数据。 这一特殊地址变换缓存被称为变换旁视缓冲区(TLB),有时也称为变换缓冲区(TB)。

TLB项就像是一个缓存项目,其中的标志保存了虚拟地址部分,数据部分保存了特殊页帧编号、保护字段、有效位,通常还有一个使用位和重写位。要改变页表中某一项的特殊页帧编号或保护字段,操作系统必须确保旧项不在TLB中;否则,系统就不能正常运行。注意,这个重写位意味着对应页曾被改写过,而不是指TLB中的地址变换或数据缓存中的特殊块经过改写。操作系统通过改变页表中的值,然后再使相应TLB项失效来重置这些位。在从分页表中重新加载该项时,TLB会获得这些位的准确副本。

图B-14给出了Opteron 数据TLB组织方式,并标出了每一个变换步骤。这个TLB使用全相联布置;因此,变换首先向所有标志发送虚拟地址(步骤1和步骤2)。当然,这些标志必须标记为有效,以允许进行匹配。同时,根据TLB中的保护信息核对存储器访问的类型,以确认其是否有效(也在步骤2中完成)。


图B-14 在地址变换期间Opteron数据TLB的操作。一次 TLB命中的4个步骤用带圆圈的数字显示。这个TLB有40项。

和缓存中的理由相似,TLB中也不需要包含页偏移量的12个位。匹配标志通过一个40选1多工器,高效地发送相应的物理地址(步骤3)。然后将页偏移量与物理页帧合并,生成一个完整的物理地址(步骤4)。地址大小为40位。

选择页大小

最显而易见的体系结构参数是页大小。页大小的选择实际就是在偏向较大页与偏向较小页的力量之间进行平衡的问题。以下因素偏向较大尺寸。

  • 页表的大小与页大小成反比,因此,增大页的大小可以节省存储器(或其他用于存储器映射的资源)。
  • B.3节曾经提到,分页较大时,可以允许缓存命中时间较短的较大缓存。
  • 与传递较小页相比,从(向)辅助存储传递较大页(有可能通过网络)的效率更高一些。
  • TLB项目的数量受限,所以分页较大意味着可以高效地映射更多存储器,从而可以降低TLB缺失数量。

由于最后这个原因,近来的微处理器决定支持多种页大小;对于一些程序,TLB缺失对CPI的重要性可能与缓存缺失相同。

采用较小分页的主要动机是节省存储。当虚拟内存的相邻区域不等于页大小的整数倍时,采用较小页可以减少存储的浪费空间。页面中这种未使用存储器的术语名称为内部碎片。假定每个进程有三个主要段(文本、堆和栈),每个进程的平均浪费存储量为页大小的1.5倍。当然,当页大小非常大(超过32KB)时,那就可能浪费存储(主存储器和辅助存储器)和I/O带宽了。

最后一项关注是进程启动时间;许多进程都很小,较大的页面可能会延长调用一个进程的时间。

虚拟存储器和缓存小结

由于虚拟存储器、TLB、第一级缓存、第二级缓存都映射到虚拟与物理地址空间的一部分,所以人们可能会混淆哪些位去了哪里。图B-15给出了一个从64位虚拟地址到41位物理地址的假设示例,它采用两级缓存。这一L1缓存的缓存大小和页大小都是8 KB,所以它是虚拟寻址、物理标记的。L2缓存为4 MB。这两者的块大小都是64个字节。

第一,64位虚拟地址在逻辑上被划分为虚拟页号和页偏移量。前者被发送到TLB,以备变换为物理地址,后者的高位被发送到L1缓存,充当索引。如果TLB匹配命中,则将物理页号发送到L1缓存标志,检查是否匹配。如果匹配,则是L1缓存命中。块偏移随后为处理器选择该字。

如果L1缓存核对显示为缺失,则使用物理地址尝试L2缓存物理地址的中间部分用作4MB L2缓存的索引。将所得到的L2缓存标志与物理地址的上半部分对比,以检查是否匹配。如果匹配,我们得到一次L2缓存命中,数据被送往处理器,它使用块偏移量来选择所需字。在L2缺失时,会使用物理地址从存储器获取该块。

尽管这是一个简单示例,但该图与真实缓存之间的主要区别只是重复问题。因为,只有一个L1缓存。如果有两个L1缓存,会重复该图的上半部分。注意,这会导致拥有两个TLB,而这正是典型情况。因此,一个缓存和TLB用于指令,由PC驱动,一个缓存和TLB用于数据,由实际地址驱动。

第二种简化是所有缓存与TLB都是直接映射的。如果有任何一个是n路组相联的,则会将每一组标志存储器、比较器和数据存储器重复n次,并用一个 n选1多工器将数据存储器连接在一起,以选择命中内容。当然,如果总缓存大小保持不变,则缓存索引也会收缩log2n 位,如图B-4中的公式所示。


图B-15 一个从虚拟地址到L2缓存访问的虚设存储器层次结构的整体图像。页大小为8 KB。TLB为直接映射,有256项。L1缓存为直接映射8 KB,L2缓存为直接映射,大小为4 MB。两者的块都是64字节。虚拟地址为64位,物理地址为41位。这一简单图像与实际缓存的主要差别在于这一图像多个组成部分的重复

虚拟存储器的保护与示例

在多重编程中,计算机由几个并行执行的程序共享,它的发明为程序之间的保护和共享提供了新的要求。这些要求与今天计算机中的虚拟存储器紧密捆绑在一起,所以我们在这里用两个虚拟存储器的示例来介绍这一主题。

多重编程导致了进程概念的出现。打个比方,进程就是程序呼吸的空气和生活的空间——即一个正在运行的程序加上持续运行它所需要的所有状态。时分共享是多重编程的一种变体,由几个同时进行交互的用户来共享处理器和存储器,给人的感觉是所有用户都拥有自己的计算机。因此,它在任何时刻都必须能够从一个进程切换到另一进程。 这种交换被称为进程切换或上下文切换。

一个进程无论是从头到尾持续执行,还是被反复中断,与其他进程进行切换,其运行都必须正常进行。维护正确进程行为的责任由程序和操作系统的设计者共同分担。计算机设计师必须确保进程状态的处理器部分能够保存和恢复。操作系统设计师必须确保这些进程不会相互干扰对方的计算。

保护一个进程的状态免受其他进程损害的最安全方法就是将当前信息复制到磁盘上。但是,一次进程切换可能需要几秒的时间一这对时分共享环境来说过长了。这一问题的解决方法是由操作系统对主存储器进行划分,使几个不同进程能够在存储器同时拥有自己的状态。这种划分意味着操作系统设计师需要计算机设计师的帮助,以提供保护,使一个进程无法修改其他进程。除了保护之外,计算机还为进程之间共享代码和数据提供了支持,允许进程之间进行通信,或者通过减少相同信息的副本数目来节省存储器。

保护进程

使进程拥有自己的分页表,分别指向存储器的不同页面,这样可以为进程提供保护,避免相互损害。显然,必须防止用户修改它们的分页表,或者以欺骗方式绕过保护措施。

分段虛拟存储器举例:Intel Pentium中的保护方式

最早的8086使用分段寻址,但它没有提供任何虚拟存储器或保护。分段拥有基础寄存器,但没是界限寄存器,也没有访问核查,在能够载入分段寄存器之前,必须将相应段载入物理存储器中。Intel对虚拟存储器和保护的专注在8086的后续产品中得到体现,扩展了一些字段来支持更大地址。这种保护机制非常精巧,精心设计了许多细节,以尝试避免安全漏洞。我们将其称为IA-32。接下来的几页内容将重点介绍Intel的一些安全措施。

第一种增强是将传统的两级保护模型加倍: IA-32 有四级保护,最内层(0)对应于传统的内核模式,最外层(3)是权限最少的模型。IA-32 为每一级提供独立栈,以避免突破不同级别之间的安全措施。还有一些与传统分页表类似的数据结构,其中包含了段的物理地址,还有一个对变换地址进行的核对清单。

Intel设计师并没有就此驻足不前。IA-32 划分了地址空间,让操作系统和用户都能访问整个空间。IA-32用户能够在保持全面保护的情况下调用这一空间 中的操作系统例程,甚至还可以向其传送参数。由于操作系统栈不同于用户栈,所以这一安全调用可不是一 个简单操作。另外,LA-32允许操作系统为那些传递给被调用例程的参数保持这些被调用例程的保护级别禁止用户进程要求操作系统间接访问一些该进程自己不能访问的东西,就可以防止这一潜在保护漏洞。

Intel设计师有一条指导原则:尽可能对操作系统持怀疑态度,并支持共享和保护。作为这种受保护共享的一个应用示例,假定有一个薪金支付系统,它要填写支票,并更新有关本年度截至当前为止的总薪金和津贴支付信息。因此,我们希望赋予该程序读取薪金、当前信息和修改当前信息的功能,但不能修改薪金。稍后将会看到支持这些功能的机制。

增加界限检查和存储器映射

增强Intel 处理器的第一个步骤是利用分段寻址来检查界限和提供基址。IA-32中的分段寄存器中包含的不是基址,而是指向虚拟存储器数据结构的索引,这种结构称为描述符表。描述符表扮演着传统分页表的角色。IA-32 上与页表项等价的是段描述符。它包含可以在PTE中找到的字段,如下所述。

  • 存在位——等价于 PTE有效位,用于表明这是一个有效变换。
  • 基址字段——等价于一个页帧地址,包含该段第一个字节的物理地址。
  • 访问位——类似于某些体系结构中的引用位或使用位,可以为替换算法提供帮助。
  • 属性字段——为使用这一段的操作指定有效操作和保护级别。

还有一个在分页系统中没有出现的界限段,它确定这一分段有效偏移量的上限。图B-16给出了IA-32段描述符的示例。


图B-16 IA-32 段描述符由其属性字段中的位进行区分。基址位、界限位、存在位、可读位和可写位的用途都是不言自明的。D是指令的默认寻址大小:16位或32位。G是段界限的粒度:0表示采用字段,1表示采用4 KB的页。在开启分页以设置页表大小时,将G设置为1。 DPL表示描述符权限级别一根据代码权限级别核对 DPL,以查看是否允许访问。一致性是指代码采用被调用代码的权限级别,而不是调用者的权限级别;用于库例程。向下扩展段颠倒检查过程,以基址字段为高位标记,界限字段为低位标记。可以猜到,这种方式用于向下发展的栈段。字数控制从当前栈向调用门上新栈复制的字数。调用门描述符的其他两个字段——目标选择位和目标偏移量,分别选择该调用目标的描述符及其内部的偏移量。IA-32 保护模型中的段描述符远不止这三种

除了这种段式寻址之外,IA-32 提供了一种可选的分页系统。32位地址的上面部分选择段描述符,中间部分是描述符所选页表中的索引。下 面介绍不依赖分页的保护系统。

增加共享和保护

为提供受保持的共享,地址空间的一伴由所有进程共享,另一半由各进程独享,分别称为全局地址空间和局部地址空间。为每一半都提供一个拥有适当名字的描述符表。指向共享段的描述符被放在全局描述符表中,而指向专用段的描述符则被放在局部描述符表中。程序向IA-32 段寄存器中载入一个索引和一个位,索引指向描述符表,这一个位表明程序希望获得哪个表。根据描述符中的属性对操作进行检查,将来自处理器的偏移量加到描述符中的基址来构成物理地址,前提是这一偏移量要小于界限字段。每个段描述符都有一个独立的2位字段,提供这个段的合法访问级别。仅当程序尝试以段描述符中的较低保护级别使用段时,才会发生违反错误。

我们现在可以介绍如何调用上述薪金支付程序来更新当前信息,但不允许它更新薪金数据。可以向程序提供该信息的一个描述符,描述符的可写字段被清零,表明程序能够读取数据但不能写数据。然后可以提供一个受信任的程序,它只会写入当前最新信息。在向这一程序提供的描述符中,其可写字段已被置位(见图B-16)。薪金支付程序使用一个代码段描述符来调用受信任的代码,描述符的一致性字段已被置位。这种设置意味着被调用程序取得了被调用代码的权限级别,而不是调用者的权限级别。因此,薪金支付程序可以读取薪金信息,并调用受信任的程序来更新当前总值,但薪金支付程序不能修改这些薪金信息。

增加从用户到操作系统门的安全调用,为参数继承保护级别

允许用户介入操作系统是非常大胆的一步。但是,硬件设计师如何能在不信任操作系统或其他代码的情况下增加安全系统的可能性呢? IA-32 方法是限制用户能够进入代码段的位置,将参数安全地放到正确的栈中,并确保用户参数不会取得被调用代码的保护级别。为限制进入其他代码,IA-32 提供了一种被称为调用门(call gate)的特殊段描述符,用属性字段中的一位来识别。与其他描述符不同,调用门是一个对象在存储器中的完整物理地址;处理器提供的偏移量被忽略。如上所述,它们的目的是防止用户随机进入一段受保护或拥有更高权限的代码段中。在我们这个编程示例中,这意昧着薪金支付程序唯一能够调用受信任代码的位置就是在准确的边界位置。一致性段的正常工作需要这一限制。

如果调用者和被调用者“相互怀疑”,都不相信对方,那会怎么样呢?在图B-16询问描述符的字数字段中可以找到解决方案。当一条调用指令调用一个调用门描述符时,描述符将局部栈中的一些字复制到这个段级别相对应的栈中,字的数量由描述符指定。这一复制过程允许用户首先将参数压入局部栈中,从而实现参数传递。随后由硬件将参数安全地传送给正确的栈。在从调用门返回时,会将参数从栈中弹出,并将返回值复制到正确的栈中。注意,这一模型与目前在寄存器中传递参数的实际做法不兼容。

这一机制仍然未能关闭潜在的安全漏洞:操作系统以操作系统的安全级别来使用作为参数传递的用户地址,而不是使用用户级别。IA-32在每个处理器段寄存器中专门拿出2个位来指定所请求的保护级别,从而解决了上述问题。当这些地址参数载入段寄存器时,它们将所请求的保护级别设置为正确值。IA-32硬件随后使用所请求的保护级别来防止出现欺骗:对于使用这些参数的系统例程,如果其权限保护级别高于被请求级别,则不允许该例程访问任何段。

分页虚拟存储器举例: 64位Opteron存储器管理

常用的模型是在80386中引入的一种平面32位地址空间,它将段寄存器的所有基址值都设置为0。因此,AMD在64位模式中摒弃了多个段。它假定段基址为0,忽略了界限字段。页大小为4KB、2 MB和4MB。

AMD64体系结构的64位虚拟地址被映射到52位物理地址,当然,具体实现可以采用较少的位数,以简化硬件。例如,Opteron 使用48位虚拟地址和40位物理地址。AMD64需要虚拟地址的高16 位就是低48位的符号扩展,这称为规范格式。

64位地址空间页表的大小是惊人的。因此,AMD64使用了一种多级层次结构分页表来映射地址空间,使其保持合理大小。级别数取决于虚拟地址空间的大小。图B-17显示了Opteron的48位虚拟地址的四级变换。

这些页表中各个表的偏移量来自4个9位字段。地址变换时,首先将第一个偏移量加到页映射第4级基址寄存器,然后从这个位置读取存储器,获取下一级页表的基址。然后再将下一级地址偏移量添加到这个新获取的地址,再次访问存储器,以确定第三个页表的基址。再次重复以上过程。最后一个地址字段被加到这一最终基址,使用两者之和读取存储器,获得所引用页面的物理地址。这个地址与12位页面偏移量串接在一起,获得完整的物理地址。注意,Opteron体系结构中的页表可以放在单个4KB页中。

Opteron在每个页表中使用64位的项目。前12位为保留位,供以后使用,接下来的52位包含物理页帧编号,最后12位提供保护、使用信息。尽管不同页表级别之间的字段会有所变化,但基本上都有以下位。

  • 存在位——表明该页存在于存储器中。
  • 读取/写入位——表明一个页是只读的,还是读写的。
  • 用户/管理员位——表明用户是可以访问该页,还是仅限于上面三个权限级别。
  • 脏位——表明该页是否已经被修改。
  • 访问位——表明自该位上次清零以来,是否曾读取或写入过该页。
  • 页面大小位——表明最后一级是4 KB页面,还是4 MB页面;如果是后者,则Opteron只使用3个页面级别,而不是4个。
  • 不执行位——未出现在 80386保护机制中,添加这个位是为了防止代码在某些页内执行。
  • 页级缓存禁用——表明是否可以缓存该页。
  • 页级直写——表明该页对数据缓存应用写回还是直写。


图B-17 Opteron 虚拟地址的映射。拥有4个页表级别的Opteron虚拟存储器实现方式支持40位的有效物理地址大小。每个页表有512项,所以每一级字段的宽度为9位。AMD64体系结构文档允许虚拟地址大小从当前的48位增长到64位,允许物理地址从当前的40位增长到52位

由于Opteron在TLB缺失时通常会经历四级页表,所以有3个可能位置来核对保护限制。Opteron仅服从底级PTE,检查其他各项只是为了确保设置了有效位。由于该项目的长度为8个字节,每个页表有512项,而且Opteron拥有大小为4 KB的页,所以这些页表的长度恰好为一页。 这些四级字段的每一个字段长9位,页偏移量为12位。这一推导留出64-(4 x 9 + 12)=16位进行符号扩展,以确保地址的规范化。

尽管我们已经解释了合法地址的转换,那什么会防止用户创建非法地址转换并阻止故障发生呢?这些页表本身是受保护的,用户程序不能对其进行写入。因此,用户可以尝试任意虚拟地址,但操作系统通过控制页表项来控制访问哪个物理存储器。为了实现进程之间的存储器共享,在每个地址空间中都设置一个页表项,指向同一个物理存储器页。

Opteron采用4个TLB来缩短地址转换时间,两个TLB用于指令访问,两个用于数据访问。与多级缓存类似,Opteron 通过两个较大的L2 TLB来减少TlB缺失:一个用于指令,一个用于数据。表B-11介绍了数据TLB。


小结:32 位Intel Pentium与64位AMD Opteron的保护对比

Opteron中的存储器管理是当今大多数桌面或服务器计算机的典型代表,依靠页级地址变换和操作系统的正确操作,为共享计算机的多个进程提供安全性。尽管Intel也提出了一些替代方案,但它还是沿袭了AMD的领先作法,接纳了AMD64体系结构。因此,AMD和Intel都支持80x86的64位扩展;但出于兼容性原因,这两者都支持复杂的分段保护机制。

结语

要想制造出能够跟上处理器步伐的存储器系统,其难度极大,无论多么先进的计算机,其主存储器的制造原材料都与最廉价的计算机一致,这一事实为上述难度添加了新的注脚。这里能为我们提供帮助的是局域性原理一当前计算机中存 储器层次结构的各个级别(从磁盘到TLB)都证明了它的正确性。

但是,到存储器的相对延迟不断增加,2011 年达到数百个时钟周期,这就意味着,如果程序员和编译器编写入员希望自己的程序能够正常执行,就必须了解缓存和TLB的参数。

流水线:基础与中级概念

引言

我们在本附录中首先介绍流水线的基础知识,包括数据路径含义的讨论、冒险的介绍、流水线性能的研究。这一节介绍基本的五级RISC,它是本附录其余部分的基础。C.2 节介绍冒险问题、它们为什么会导致性能问题、应当如何应对。C.3节讨论如何实际实现这个简单的五级流水线,重点是控制和如何应对冒险。C.4节讨论流水线和指令集设计各个方面之间的相互关系,讨论了有关异常的重要主题以及它们与流水线的相互交互。C.5节讨论如何扩展五级流水线,以处理运行时间更长的浮点指令。C.6节在一个案例研究中将这些概念结合在一起,这个案例研究的对象是一个深度流水线处理器——MIPS R4000/4400,它既包括八级整数流水线,又包括浮点流水线。C.7节介绍了动态调度的概念,以及记分板在动态调度实现中的用法。它是作为交叉问题介绍的。C.7节还简单探讨了第3章中介绍的更复杂Tomasulo 算法。

什么是流水线

流水线是一种将多条指令重叠执行的实现技术。一条指令的执行需要多个操作,流水线技术充分利用了这些操作之间的并行性。就像装配线一样,不同步骤并行完成不同指令的不同部分。这些步骤中的每一步都称为流水级或流水段。流水级前后相连形成流水线一指令在一端进入,通过这些流水级,在另一段退出。

指令流水线的吞吐量由指令退出流水线的频率决定。由于流水线级是连在一起的,所以所有流水级都必须做好同时工作的准备。将一条指令在流水线中下移一步所需的时间为处理器周期。由于所有各级同时进行,所以处理器周期的长度由最缓慢流水线级所需时间决定。在计算机中,这一处理器周期通常为1个时钟周期(有时为2个,但要少见得多)。流水线设计者的目标是平衡每条流水线的长度。如果各级达到完美平衡,那么每条指定在流水线处理器中的时间(假定为理想条件)等于:

非流水线机器上每条指令的时间/流水级的数目

在这些条件下,因为实现流水线而得到的加速比等于流水级的数目,但一般情况下,这些流水线之间不会达到完美平衡;此外,流水线还会产生一些开销。因此,在流水线处理器上,处理每条指令的时间不会等于其最低可能值,但可以非常接近。

流水线可以缩短每条指令的平均执行时间。根据我们认证的基准,这一缩短量可以计作每条指令时钟周期数(CPI)的下降、时钟周期时间的缩短,或者这两者的组合。如果在开始时,处理器需要多个时钟周期来处理一条指令,那我们通常将流水线的作用看作是降低了CPI。

如果在开始时,处理器需要一个(长)时钟周期来处理一条指令,那就认为流水线缩短了时钟周期时间。流水线技术开发了串行指令流中各指令之间的并行度。它与某些加速技术不同,其真正的好处在于它对程序员是透明的。在这一附录中,我们将首先使用一个经典的五级流水线来介绍流水线的概念;其他章节研究了更复杂流水线技术在现代处理器的应用。在更深入地讨论流水线及其在处理器的应用之前,我们需要一个简单的指令集,下面将对此进行介绍。

RISC指令集基础知识

在本书中,我们一直使用RISC(精简指令集计算机)体系结构或载入-存储体系结构来说明基本概念,当然,本书介绍的几乎所有思想都适用于其他处理器。本节介绍典型RISC 体系结构的核心。RISC体系结构有几个关键属性,这些属性大大简化了其实现方式。

  • 所有数据操作都是对寄存器中数据的操作,通常会改变整个寄存器(每个寄存器为32位或64位)。
  • 只有载入和存储操作会影响到存储器,它们分别将数据从存储器移到寄存器或从寄存器移到存储器。通常存在一些可以载入或存储低于一个完整寄存器的内容。
  • 指令格式的数量很少,所有指令通常都是同一种大小。

这些简单属性极大地简化了流水线的实现,这也是如此设计这些指令集的原因。为与本书其他部分保持一致, 我们使用MIPS64,它是MIPS指令集的64位版本。这些扩展64位指令通常在助记符的开头或末尾加上字母D加以标识。例如,DADD是加法指令的64位版本,而LD则是载入指令的64位版本。

与其他RISC体系结构类似,MIPS 指令集提供了32个寄存器,不过寄存器0的值总是0。大多数RISC体系结构,比如MIPS,通常有以下三类指令(更多详细信息请参见附录A)。

(1) ALU指令——这些指令取得两个寄存器或者一个寄存器与一个符号扩展立即数(称为ALU立即数指令,它们在MIPS中有一个16位偏移量),对它们进行操作,然后将结果存储到第三个寄存器中。典型操作包括加(DADD)、减(DSUB)和逻辑运算(例如AND或OR),后者不区分32位和64位版本。这些指令的立即数版本使用相同助记符,但添加了后缀I。在MIPS中,包括算术运算的有符号形式和无符号形式;无符号形式的末尾有一个字母U(例如,DADDU、DSUBU、DADDIU),不会生成溢出异常(因此在32位和64位模式中是一样的)。

(2)载入和存储指令——这些指令获取一个寄存器源(称为基址寄存器)和一个立即数字段(在MIPS中为16位,称为偏移量)作为操作数。基址寄存器的内容与符号扩展偏移量之和(称为有效地址)用作存储器地址。对于载入指令,使用第二个寄存器操作数,用于存放从存储器载入的数据。对于存储指令,第二个寄存器操作数是要存入存储器的数据源。载入字(LD)和存储字(SD)等指令载入或存储整个64位寄存器内容。

(3)分支与跳转——分支是指控制的条件转移。在RISC体系结构中,通常有两种方式来指定分支条件:采用一组条件位(有时称为条件码),或者通过寄存器对之间、寄存器与零之间的有限对比来设定。MIPS采用后者。对于本附录,我们仅考虑两个寄存器是否相等。在所有RISC体系结构中,分支目的地都是通过将符号扩展偏移量(MIPS中为16位)加到当前程序计数器中获得的。在许多RISC体系结构中都提供了无条件跳转,但本附录中不会介绍跳转。

RISC指令集的简单实现

为了理解如何以流水线形式来实现RISC指令集,需要理解在没有流水线时它是如何实现的。这一节给出了一种简单实现,每一条指令 最多需要5个时钟周期。我们将这一基本实现扩展到流水线版本,从而大幅降低CPI。在所有不采用流水线的实现方式中,我们给出的方式并非最经济或性能最高的。它的设计只是可以很自然地引向流水线实现。实现此指令集需要引入几个不属于该体系结构的临时寄存器,引入它们是为了简化流水线。我们的实现将仅关注RISC体系结构中整数操作部分的流水线,这部分操作包括载入-存储字、分支和整数ALU操作。

这个RISC子集中的每条指令都可以在最多5个时钟周期内实现。这5个时钟周期如下所述。

  • 指令提取周期(IF)
    • 将程序计数器(PC)发送到存储器,从存储器提取当前指令。向程序计数器加4 (因为每条指令的长度为4个字节),将程序计数器更新到下一个连续程序计数器。
  • 指令译码/寄存器提取周期(ID)
    • 对指令进行译码,并从寄存器堆中读取与寄存器源说明符相对应的寄存器。在读取寄存器时对其进行相等测试,以确定是否为分支。必要时,对指令的偏移量字段进行符号扩展。符号扩展后的偏移量添加到所实现的程序计数器上,计算出可能的分支目标地址。在较为积极的实现方式中,如果这一条件判断的结果为真,则可以将分支目标地址存储到程序计数器中,以在这一级的末尾完成分支。
    • 指令译码与寄存器的读取是并行执行的,这可能是因为在RISC体系结构中,寄存器说明符位于固定位置。这一技术称为固定字段译码。注意,我们可能会读取一个不会使用的寄存器。由于一个指令的立即数部分也位于同一位置,所以在需要符号扩展立即数时,也是在这一周期计算的。
  • 执行/有效地址周期(EX)
    • ALU对上一周期准备的操作数进行操作,根据指令类型执行三条指令之一。
      • 存储器引用——ALU 将基址寄存器和偏移量加到一起,形成有效地址。
      • 寄存器-寄存器ALU指令——ALU 对读自寄存器堆的值执行由ALU操作码指定的操作。
      • 寄存器-立即数ALU指令——ALU对读自寄存器堆的第一个值和符号扩展立即数执行由ALU操作码指定的操作。
    • 在载入-存储体系结构中,有效地址与执行周期可以合并到一个时钟周期中,这是因为没有指令需要计算数据地址并对数据执行操作。
  • 存储器访问(MEM)
    • 如果该指令是一条载入指令,则使用上一周期计算的有效地址从存储器中读取数据。如果是一条存储指令,则使用有效地址将从寄存器堆的第二个寄存器读取的数据写入存储器。
  • 写回周期(WB)
    • 寄存器寄存器ALU指令或载入指令。
    • 将结果写入寄存器堆,无论是来自寄存器系统(对于载入指令),还是来自ALU(对于ALU指令)。

在这一实现中,分支指令需要2个周期,存储指令需要4个周期,所有其他指令需要5个周期。假定分支频率为12%,存储频率为10%,对于这一典型指令分布, 总CPI 为4.54。但是,无论是在获取最佳性能方面,还是在给定性能级别的情况尽量减少使用硬件方面。

RISC处理 器的经典五级流水线

我们几乎不需要进行什么改变就能实现上述执行过程的流水化,只要在每个时钟周期开始一条新的指令就行。上一节的每个时钟周期都变成一个流水
线级——流水线中的一个周期。这样会得到表C-1所示的执行模式,这是绘制流水线结构的典型方式。尽管每条指令需要5个周期才能完成,但在每个时钟周期内,硬件都会启动一条新的命令,执行5个不同指令的某一部分。

  • 在每个时钟周期,提取另一条指令,并开始它的五周期执行过程。如果在每个时钟周期都启动一条指令,其性能最多可达到非流水化处理器的5倍。流水线中各个阶段的名称与非流水线实现方式中各个周期的名称相同: IF=指令提取、ID=指令译码、EX=执行、MEM=存储器访问、WB=写回。

首先,我们必须确定在处理器的每个时钟周期都会发生什么,确保不会在同一时钟周期内对相同数据路径源执行两个不同操作。例如,不能要求同一个ALU同时计算有效地址和执行减法操作。因此,我们必须确保流水线中的指令重叠不会导致这种冲突。幸运的是,RISC指令集比较简单,使资源评估变得相对容易。图C-1以流水线形式绘制了一个RISC数据路径的简化版本。可以看到,主要功能单元是在不同周期使用的,因此多条指令的执行重叠不会入多少冲突。从以下三点可以看出这一事实。


图C-1 流水线可以看作一系列随时间移动的数据路径。本图给出了数据路径不同部分之间的重叠,时钟周期5 (CC5)表示稳定状态。由于寄存器用作ID级中的一个源和WB级中的目的地,所以它出现两次。我们表示它在该级的一个部分进行读取,在另一部分进行写入,分别用左右两侧的实线或虚线来表示。缩写IM表示指令存储器、DM表示数据存储器、CC表示时钟周期

第一,我们使用分离的指令存储器和数据存储器,我们通常用分离的指令和数据缓存来实现它们。在使用存储器时,在指令提取和数据存储器访向之间可能会发生冲突,而使用分离缓存则可以消除这种冲突。注意,如果我们的流水线处理器的时钟周期等于非流水线版本的时钟周期,则存储器系统必须提供5倍的带宽。这一需求的增加是提高性能的一种成本。

第二,在两个阶段都使用了寄存器堆:一个是在ID中进行读取,一个是在WB中进行写入。这些用法是不同的,所以我们干脆在两个地方画出了寄存器堆。因此,每个时钟周期需要执行两次读取和一次写入。为了处理对相同寄存器的多次读取和一次写入,我们在时钟周期的前半部分写寄存器,在后半部分读寄存器。

第三,图C-1没有涉及程序计数器。为了在每个时钟周期都启动一条新指令,我们必须在每个时钟周期使程序计数器递增并存储它,这必须在IF阶段完成,以便为下一条指令做好准备。此外,还必须拥有一个加法器,在ID期间计算潜在的分支目标。另外一个问题是分支在ID级改变程序计数器。

尽管确保流水线中的指令不会试图在相同时间使用硬件资源是至关重要的,我们还必须确保不同流水级中的指令不会相互干扰。这种分离是通过在连续流水级之间引入流水线寄存器来完成的,这样会在时钟周期的末尾,将一个给定流水级得出的所有结果都存储到寄存器中,在下一个时钟周期用作下一级的输入。图C-2给出了画有这些流水线寄存器的流水线。

图C-2 此流水线显示了连续流水级之间的流水线寄存器。注意,寄存器防止流水线相邻级中两条不同指令之间的干扰。在将一条给定指令的数据从一级带至另一级的过程中,寄存器也扮演着重要角色。寄存器的边沿触发特性(也就是说,取值在时钟沿即时改变)是非常关键的。否则,来自一条指
令的数据可能会干扰另一指令的执行!

尽管许多图形都为了简便而省略了这些寄存器,但它们是流水线正常操作所必需的。当然,即使在一些没有 采用流水化的多周期数据路径中也需要类似寄存器(因为 只有寄存器中的值能够在跨过时钟边界之后仍然得以保存)。在流水化处理器中,如果要将中间结果从一级传送到另一级,而源位置与目标位置可能并非直接相邻,流水线寄存器也会在这种传送过程种扮演关键角色。例如,要在存储指令中存储的寄存器值是在ID期间读取的,但要等到MEM才会真正用到;它在MEM级中通过两个流水线寄存器传送给数据存储器。与此类似,ALU指令的结果是在EX期间计算的,但要等到WB才会实际存储;它通过两个流水线寄存器才到达那里。有时对流水线寄存器进行命名是有用的,根据这些寄存器所连接的流水级对其进行命名,所以这些
寄存器称为IF/ID、ID/EX、EXMEM和MEM/WB。

流水化的基本性能问题

流水化提高了CPU指令吞吐量(单位时间内完成的指令数),但不会缩短单条指令的执行时间。事实上,由于流水线控制会产生开销,它通常还会稍微延长每条指令的执行时间。尽管单条指令的运行速度并没有加快,指令吞吐量的增长意味着程序可以更快速地运行,总执行时间缩短。除了因为流水线延迟产生的局限之外,流水级之间的失衡和流化化开销也会造成限制。流水级之间的不平衡会降低性能,这是因为时钟的运行速度不可能快于最缓慢的流水级。

流水线开销包含流水线寄存器延迟和时钟偏差。流水线寄存器增加了建立时间,也就是在发出触发写操作的时钟信号之前,寄存器输入必须保持稳定的时间,而且时钟周期的传播也会产生延迟。时钟偏差是时钟到达任意两个寄存器时刻之间的最大延迟,时钟周期的下限也受此因素的影响。如果时钟周期小于时钟偏差与延迟开销之和,那时钟周期中就没有留给有用工作的时间了,所以再增加流水线也就没用了。

考虑上一节的非流水化处理器。假定其时钟周期为1 ns,ALU运算和分支需要4个周期,存储器操作需要5个周期。假定这些操作的相对频率分别为40%、20%和40%。假设由于时钟偏差和建立时间的原因,对处理器实现流水化使时钟增加了0.2 ns的开销。忽略所有延迟影响,通过流水线获得的指令执行速率加速比为多少?

在非流水化处理器上,指令平均执行时间为:

指令平均执行时间=时钟周期x平均CPI = Ins x [(40% + 20%)x4 + 40%x5] = Ins x 4.4 = 4.4ns

在流水线实现方式中,时钟的运行速度必须等于最慢流水级的速度加上开销时间,也就是1+0.2=1.2 ns;这就是指令平均执行时间。因此,通过流水化获得的加速比为:

流水化加速比 = 非流水化指令平均执行时间/流水化指令平均执行时间 = 4.4ns/1.2ns = 3.7倍

0.2 ns的开销基本上确定了流水化的效能限度。如果此开销不受时钟周期变化的影响,那么从Amdahl定律可知,这一开销限制了加速比。

如果流水线中每条指令独立于所有其他指令,那这种简单的RISC 流水线对于整数指令可以正常运行。实际上,流水线中的指令可能是相互依赖的;这是下一节的主题。

流水化的主要阻碍——流水线冒险

有一些被称为冒险的情景,会阻止指令流中的下一条指令在其自己的指定时钟周期内执行。冒险降低了流水化所能获得的理想加速比。共有以下三类冒险。

  1. 结构冒险,在重叠执行模式下,如果硬件无法同时支持指令的所有可能组合方式,就会出现资源冲突,从而导致结构冒险。
  2. 数据冒险,根据流水线中的指令重叠,指令之间存在先后顺序,如果一条指令取决于先前指令的结果,就可能导致数据冒险。
  3. 控制冒险,分支指令及其他改变程序计数器的指令实现流水化时可能导致控制冒险。

流水线中的冒险会使流水线停顿。为了避免冒险,经常要求在流水线中的一些指令延迟时,其他一些指令能够继续执行。对于本附录中讨论的流水线,当一条指令被停顿时,在停顿指令之后发射的所有指令也被停顿(这些指令在流水线中的位置不会远于停顿指令)。而在停顿指令之前发射的指令必须继续执行(它们在流水线中的位置要更远一些),否则就永远不会清除冒险情况。结果,在停顿期间不会提取新的指令。

带有停顿的流水线性能

停顿会导致流水线性能下降,低于理想性能。现在让我们看一个简单的公式,求解流水化的实际加速比,首先从上一节的公式开始:

流水化加速比=非流水化指令平均执行时间/流水化指令平均执行时间 = (非流水化CPI x 非流水化时钟周期)/(流水化CPI x流水化时钟周期) = (非流水化CPI/流水化CPI)x(非流水化时钟周期/流水化时钟周期)

流水化可以看作CPI或时钟周期时间的降低。由于传统上使用CPI来比较流水线,所以让我们从这里开始。流水化处理器的理想CPI几乎总等于1。因此,可以计算流水化CPI为:

流水化CPI = 理想CPI+每条指令的流水线停顿时钟周期 = 1+每条指令的流水线停顿时钟周期

如果忽略流水化的周期时间开销,并假定流水级之间达到完美平衡,则两个处理器的周期时间相等,得到:

加速比=非流水化CPI/(1+每条指令的流水线停顿周期)

一种简单而重要的情景是所有指令的周期数都相同,必然等于流水级数目(也称为流水线深度)。在这种情况下,非流水化CPI等于流水线的深度,得到:

加速比=流水深度/(1+每条指令的流水线停顿周期)

如果没有流水线停顿,由此公式可以得到一个很直观的结果:流水化可以使性能提高的倍数等于流水线深度。

或者,如果将流水化看作时钟周期时间的改善,那可以假定非流水化处理器的CPI以及流水化处理器的CPI为1。于是得到:

流水化加速比=(非流水化CPI/流水化CPI)x(非流水化时钟周期/流水化时钟周期) = (1/(1+每条指令的流水线停顿周期))x(非流水化时钟周期/流水化时钟周期)

当流水级达到完美平衡,而且没有开销时,流水化处理器的时钟周期小于非流水化处理器的时钟周期,缩小因子等于流水线深度:

流水化时钟周期=非流水化时钟周期/流水线深度

流水线深度=非流水化时钟周期/流水化时钟周期

从而得到以下公式:

流水化加速比=(1/(1+每条指令的流水线停顿周期))x(非流水化时钟周期/流水化时钟周期) = (1/(1+每条指令的流水线停顿周期))x流水线深度

因此,如果没有停顿,则加速比等于流水级数,与我们对理想情况的直观感受一致。

结构冒险

当处理器以流水线方式工作时,指令的重叠执行需要实现功能单元的流水化和资源的复制,以允许在流水线中出现所有可能的指令组合。如果由于资源冲突而不能容许某些指令组合,就说该处理器存在结构冒险

结构冒险最常见于某功能单元未能完全流水化的情况。这时,一系列使用该非流水化单元的指令不能以每时钟周期执行一条指令的速度前进。结构冒险另一常见出现方式是某一资源的复制不足以执行流水线中的所有指令组合。例如,处理器可能仅有一个寄存器堆写端口,但在特定情况下,流水线可能希望在一个时钟周期内执行两个写操作。这就会生成结构冒险。

当指令序列遇到这种冒险时,流水线将会使这些指令中的一个停顿,直到所需单元可用为止。这种停顿会增大CPI值,不再是其通常的理想值1。一些流水化处理器为数据和指令共享单存储器流水线。结果,当指令中包含数据存储器引用时,它会与后面指令的指令引用冲突,如图C-3所示。为避免这一冒险,在发生数据存储器访问时,我们使流水线停顿一个时钟周期。停顿通常被称为流水线气泡,或就称为气泡,因为它会飘浮穿过流水线,占据空间却不执行有用工作。在讨论数据冒险时,将会看到另一种停顿类型。

图C-3 只要发生存储器引用,仅有一个存储器端口的处理器就会生成一次冲突. 在这个示例中,载入指令因为数据访问而使用存储器的同时,指令3希望从存储器中提取一条指令

设计者经常使用仅含有流水级名称的简图来表示停顿行为,如表C-2所示。在表C-2中展示停顿时,指出那些没有执行操作的周期,只是将指令3向右移动(使其执行过程的开始与结束都推后1个周期)。当流水线气泡穿过流水线时,其效果就是占据了该指令时隙的资源。

  • 载入指令实际强占了指令提取周期,导致流水线停顿——在4号时钟周期没有启动指令(它通常会启动指令i+3)。因为被提取的指令停顿,所以流水线中位于停顿指令之前的所有其他指令都可以正常进行。停顿周期将继续穿过流水线,所以在8号时钟周期中没有完成指令。有时在绘制这些流水线表时,让流水线占据整个水平行,指令3被移到下一行;无论采用哪种画法,效果都是一样的,因为指令i+3直到5号周期才开始执行。因为以上形式占据的空间较少,所以我们采用了这一形式。注意,本图假定指令i+1和i+2不是存储器引用。

让我们看看载入结构冒险的成本有多高。假定数据引用占总体的40%,流水化处理器的理想CPI为1(忽略结构冒险)。假定与没有冒险的处理器相比,有结构冒险处理器的时钟频率为其1.05 倍。不考虑所有其他性能损失,有结构冒险和无结构冒险相比,哪种流水线更快?快多少?

有几种方法可以求解这一问题。 最简单的一种可能就是计算两种处理器的平均指令时间:

平均指令时间 = CPI x 时钟周期时间

由于没有停顿,所以理想处理器的平均指令时间就是时钟周期时间理想。有结构冒险处理器的平均指令时间为:

平均指令时间=CPIx时钟周期时间 = (1+0.4x1)x(时钟周期时间(理想)/1.05) = 1.3x时钟周期时间(理想)

显然,没有结构冒险的处理器更快一些; 根据平均指令时间的比值,我们可以得出结论,无冒险处理器的速度快1.3倍。

为避免出现这种结构冒险,设计师可以为指令提供独立的存储器访问,既可以将缓存分为独立的指令缓存和数据缓存,也可以使用一组缓冲区来保存指令,这种缓冲区通常称为指令缓冲区。

如果其他因素相同,无结构冒险处理器的CPI总是更低一些。那设计师为什么还会允许结构冒险呢?其主要目的是为了降低单元成本,因为要实现所有功能单元的流水化,或者复制它们,成本都太高昂了。例如,那些在每个周期内支持指令与数据缓存访问的处理器需要有2倍的总存储器带宽,通常要求管脚处也可以承受较高的带宽。

与此类似,要完全实现浮点(FP)乘法器需要耗用大量门电路。如果结构冒险很罕见,那就不值得花费这么大的代价来避免它。

数据冒险

流水化的主要效果是通过重叠指令的执行过程来改变它们的相对执行时间。这种重叠引入了数据与控制冒险。当流水线改变对操作数的读写访问顺序,使该顺序不同于在非流水化处理器上依次执行指令时的顺序,这时可能发生数据冒险。考虑以下指令的流水化执行:

1
2
3
4
5
DADD     R1, R2, R3
DSUB R4, R1, R5
AND R6, R1, R7
OR R8, R1, R9
XOR R10, R1, R11

DADD之后的所有指令都用到了DADD 指令的结果。如图C-4所示,DADD 指令在WB流水级写入R1的值,但DSUB指令在其ID级中读取这个值。这一问题称为数据冒险。除非提前防范这种问题,否则DSUB指令将会读取错误值并试图使用它。事实上,DSUB 指令使用的值甚至是不确定的:我们可能假定DSUB使用的R1值总是由DADD之前的指令赋值,认为这种假定是合乎逻辑的,但事实并非总是如此。如果在DADD和DSUB指令之间发生中断,DADD 的WB级将结束,而该点的R1值将是DADD的结果。这种不可预测的行为显然是不可接受的。

AND指令也会受到这种冒险的影响。从图C-4 中可以看出,在5号时钟周期之前,R1的写入操作是不会完成的。因此,在4号时钟周期读取寄存器的AND指令会得到错误结果。

图C-4在后面三条指令中使用DADD指令的结果时,由于要等到这些指令读取寄存器之后才会向其中写入,所以会导致冒险。

XOR指令可以正确执行,因为它的寄存器读取是在6号时钟周期进行的,这时寄存器写入已经完成。OR指令的执行也不会招致冒险,因为我们在该时钟周期的后半部分执行寄存器堆读取,而写入是在前半部分执行的。

下一小节将讨论一种技术,用于消除涉及DSUB和AND指令的冒险停顿。

利用转发技术将收据冒险停顿减至最少

图C-4中提出的问题可以使用一种称为转发(forwarding)的简单硬件技术来解决(这一技术也称为旁路,有时也称为短路)。转发技术的关键是认识到DSUB要等到DADD实际生成结果之后才会真正用到它。DADD 将此结果放在流水线寄存器中,如果可以把它从这里转移到DSUB需要的地方,那就可以避免出现停顿。根据这一观察结果,转发的工作方式如下所述。

  1. 来自EX/MEM和MEMWB流水线寄存器的ALU结果总是被反馈回ALU的输入端。
  2. 如果转发硬件检测到前一个ALU操作已经对当前AlU操作的源寄存器进行了写入操作,则控制逻辑选择转发结果作为ALU输入,而不是选择从寄存器堆中读取的值。

注意,采用转发技术后,如果DSUB停顿,则DADD将会完成,不会触发旁路。当两条指令之间发生中断时,这一关系同样成立。

如图C4中的示例所示,我们需要转发的结果可能不只来自前一条指令,还可能来自提前两个周期启动的指令。图C-5显示了带有旁通路径的示例,它重点突出了寄存器读取与写入的时机。这一代码序列可以无停顿执行。

图C-5 一组依赖DADD结果的指令使用转发路径来避免数据冒险。DSUB 和AND指令的输入是从流水线寄存器转发到第一个ALU输入。0R接收的结果是通过寄存器堆转发而来的,这一点很容易实现,只需要在周期的后半部分读取寄存器、在前半部分写入寄存器就能轻松完成,如寄存器上的虚线所示。注意,转发结果可以到达任一ALU输入;事实上,ALU输入既可以使用来自相同流水线寄存器的转发输入,也可以使用来自不同流水线寄存器的转发输入。例如,当AND指令为AND R6,R1,R4时就会发生这种情况

可以将转发技术加以推广,将结果直接传送给需要它的功能单元:可以将一个功能单元输出到寄存器中的结果直接转发到另一个功能单元的输入,而不仅限于同一单元的输出与输入之间。例如以下序列:

1
2
3
DADD      R1, R2, R3
LD R4,0(R1)
SD R4,12(R1)

为防止这一序列中出现停顿,我们需要将ALU输出值和存储器单元输出值从流水线寄存器转发到ALU和数据存储器输入。图C-6给出了这一示例的所有转发路径。


图C-6 在MEM期间执行的存储操作需要转发操作数。载入结果由存储器输出转发到要存储的存储器输入端。此外,ALU指令被转发到ALU输入,供载入和存储指令进行地址计算(这与转发到另一个ALU操作没有区别)。如果存储操作依赖与其直接相邻的前一个ALU操作(图中未示出),则需要转发其结果,以防止出现停顿

需要停顿的数据冒险

遗憾的是,并非所有潜在数据冒险都可以通过旁路方式处理。考虑以下指令序列:

1
2
3
4
LD      R1, 0(R2)
DSUB R4,R1,R5
AND R6,R1,R7
OR R8,R1,R9

这一示例中旁通路径的流水化数据路径如图C-7所示。这种情况不同于背靠背ALU操作的情景。LD指令在4号时钟周期(其MEM周期)结束之前不会得到数据,而DSUB指令需要在该时钟周期的开头就得到这一数据。因此,因为使用载入指令结果而产生的数据冒险无法使用简单的硬件消除。如图C-7所示,这种转发路径必须进行时间上的回退操作。我们能够立即将该结果从流水线寄存器转发给ALU,供AND操作使用,该操作是在载入操作之后两个时钟周期启动的。与此类似,OR 指令也没有问题,因为它是通过寄存器堆接收这个值的。对于DSUB指令,转发结果在时钟周期结束时才会抵达,这显然太晚了,因为这一指令需要在此时钟周期开始时启动。

载入指令有一种不能单由转发来消除的延迟。而是需要增加一种称为流水线互锁,以保持正确的执行模式。一般情况下, 流水线互锁会检测冒险,并在该冒险被清除之前使流水线停顿。在这种情况下,互锁使流水线停顿,让希望使用某一数据的指令等待,直到源指令生成该数据为止。这种流水线互锁引入一次停顿或气泡,就像应对结构冒险时所做的一样。停顿指令的CPI会增加,延长数目等于停顿的长度(在本例中为1个时钟周期)。

图C-7 载入指令可以将其结果旁路至AND和OR指令,但不能旁路至DSUB,因为这将意味着在“负向时间”上转发结果

表C-3使用流水级名称显示了停顿前后的流水线。因为停顿会导致从DSUB开始的指令在时间上向后移动1个周期,转发给AND指令的数据现在是通过寄存器堆到达的,而对于OR指令根本不需要转发。由于插入了气泡,需要增加一个周期才能完成这一序列。4号时钟周期内没有启动指令(在6号周期没有指令完成)。

分支冒险

对于我们的MIPS流水线,控制冒险造成的性能损失可能比数据冒险还要大。在执行分支时,修改后的程序计数器的值可能等于(也可能不等于)当前值加4。回想一下,如果分支将程序计数器改为其目标地址,它就是选中分支;否则就是未选中分支。如果指令i为选中分支,通常会等到ID末尾,完成地址计算和对比之后才会改变程序计数器。表C-4表明,处理分支的最简单方法是:一旦在ID期间(此时对指令进行译码)检测到分支,就对该分支之后的指令重新取值。第一个IF周期基本上是一次停顿,因为它从来不会执行有用工作。读者可能已经注意到,如果分支未被选中,由于事实上已经正确地提取了指令,所以IF级的重复是不必要的。表C-4分支在五级流水线中导致一个周期的停顿分支指令

  • 分支指令之后的指令已被提取,但随后被忽略,在已经知道分支目标之后,重新开始提取操作。如果分支未被选中,则分支后续指令的第二个IF就有些多余了,这一点可能比较明显。

如果每个分支产生一个停顿周期, 将会使性能损失10%~30%,具体取决于分支频率,所以我们将研究一些用于应对这一损失的技术。

降低流水线分支代价

有许多方法可以处理由分支延迟导致的流水线停顿,我们在这一小节讨论4种简单的编译时机制。在这4种机制中,分支的操作是静态的,也就是说,在整个执行过程中,它们对每条分支来说都是固定的。软件可以尝试利用硬件机制和分支行为方面的知识将分支代价降至最低。

处理分支的最简单机制是冻结或冲刷流水线,保留或删除分支之后的所有指令,直到知道分支目标为止。这种解决方案的吸引力主要在于其软硬件都很简单。这也是表C-4所示流水线中较早使用的解决方案。在这种情况下,分支代价是固定的,不能通过软件来缩减。一种性能更高但仅略微复杂一点的机制是将每个分支都看作未选中分支,允许硬件继续执行,就好像该分支未被执行一样。这时必须非常小心,在确切知道分支输入之前,不要改变处理器状态。这一机制的复杂性在于必须要知道处理器状态可能何时被指令改变,以及如何“撤销”这种改变。

在简单的五级流水线中,这种预测未选中机制的实现方式是继续提取指令,就好像分支指令是一条正常指令一样。流水线看起来好像没有什么异常发生。但是,如果分支被选中,就需要将已提取的指令转为空操作,重新开始在目标地址位提取指令。表C-5显示了这两种情况。一种替代机制是将所有分支都看作选中分支。只要对分支指令进行了译码并计算了目标地址,我们就假定该分支将被选中,开始在目标位置提取和执行。因为在我们的五级流水线中,不可能在知道分支输出结果之前知道目标地址,所以这种方法对于这一流水线没有好处。在一些处理器中,特别是那些拥有隐性设定条件代码或者拥有更强大分支条件的处理器中,是可以在分支输出之前知道分支目标的,这时,预测选中机制可能就有意义了。

无论是在预测选中还是预测未选中机制中,编译器总是可以通过代码组织方式,使最频繁的路径与硬件选择相匹配,从而提高性能。我们的第四种机制为编译器提高性能提供了更多的机会。

  • 当分支未被选中时(在ID期间确定),我们提取未选中指令,继续进行。当在ID期间确定选中该分支时,则在分支目标处重新开始提取。这将导致该分支后面的所有指令停顿1个时钟周期。在某些处理器中使用的第四种机制称为延迟分支。这一技术在早期RISC处理器中的使用非常广泛,在五级流水线中的工作状态相当好。在延迟分支中,带有一个分支延迟的执行周期为:
  • 分支指令
  • 依序后续指令
  • 选中时的分支目标

依序后续指令位于分支延迟时隙中。无论该分支是否被选中,这一指令都会执行。表C-6中给出具有分支延迟的五级流水线的流水线行为特性。尽管分支延迟可能长于一个1周期,但在实际中,几乎所有具有延迟分支的处理器都只有单个指令延迟;如果流水线的潜在分支代价更长,则使用其他技术。

  • 延迟时隙中的指令(对于MIPS,只有一个延迟时隙)被执行。如果分支未被选中,则继续执行分支延迟指令之后的指令;如果分支被选中,则继续在分支目标处执行。当分支延迟时隙中的指令也是分支时,其含义有些模糊:如果该分支未被选中,延迟分支时隙中的分支应当怎么办呢?由于这一混淆,采用延迟分支的体系结构经常禁止在延迟时隙中放入分支。

编译器的任务是让后续指令有效并可用。因此使用了多种优化方式。图C-8给出了三种可以调度分支延迟的方式。

图C-8 分支延迟时隙的调度。每一对中的上框显示调度之前的代码;下框表示调度后的代码。在(a)中,延迟时隙内载入的是分支之前的一条不相关指令。这是最佳选择。策略(b)与(c)在策略(a)不可能实现时使用。在(b)和(c)的序列中,由于分支条件中使用了R1,所有不能将DADD指令移到分支之后(它的目的地是R1 )。在(b)中,分支延迟时隙中填充了分支的目标指令;这时一般需要复制目标指令,因为其他路径也可能会到达这一目标指令。当分支的选中机率很高时(比如循环分支)优选策略(b)。最后,如(c)中所示,可以用未被选中的指令填充延迟时隙。为使这一优化方法能够对(b)和(c)有效,当分支进入非预测方向时,必须可以执行经过移动的指令。这里所说的“可以”是指浪费了工作量,但程序仍能正确执行。例如,当分支进入非预测方向时,R7成为一个未被用到的临时寄存器,那(c)策略就属于这一情景

延迟分支调度的局限性源于: (1)对于可排在延迟时隙中的指令有限制;(2)我们在编译时预测一个分支是否可能被选中的能力有限。为了提高编译器填充分支延迟时隙的能力,大多数具有条件分支的处理器都引入了一种取消或废除分支。在取消分支中,指令包含了预测分支的方向。当分支的行为与预期一致时,分支延迟时隙中的指令就像普通的延迟分支一样执行。 当分支预测错误时,分支延迟时隙中的指令转为空操作。

分支机制的性能

这些机制的各自实际性能怎么样呢?假定理想CPI为1,考虑分支代价的实际流水线加速比为:

流水线加速比 = 流水线深度 / (1+分支导致的流水线停顿周期)

由于:

分支导致的流水线停顿周期 = 分支频率 x 分支代价

得到:

流水线加速比 = 流水线深度 / (1+分支频率x分支代价)

分支频率和分支代价可能都存在因为无条件分支和有条件分支导致的分量。但是,由于后者出现得更为频繁,所以它们起主导作用。

对于一个更深的流水线,比如在MIPS R4000中,在知道分支目标地址之前至少需要三个流水级,在计算分支条件之前需要增加一个周期,这里假定条件比较时寄存器中没有停顿。三级延迟导致表C-7中所列三种最简单预测机制的分支代价。假定有如下频率,计算因分支使该流水线的CPI增加了多少。

  • 无条件分支:4%
  • 有条件分支、未选中:6%
  • 条件分支、选中:10%

表C-7三种最简单预测机制对于一个较深流水线的分支代价

分支机制 无条件分支代价 未选中分支代价 选中分支代价
冲刷流水线 2 3 3
预测选中 2 3 2
预测未选中 2 0 3

将无条件、有条件未选中和有条件选中分支的相对频率乘以各自的代价,就可以求出CPI。结果如表C-8所示。

这些机制之间的差别大体随这一较长的延迟而增大。如果基础CPI为1,分支是唯一的停顿源,则理想流水线的速度是使用停顿流水线机制的流水线的1.56倍。 预测未选中机制在相同假定条件下优于停顿流水线机制,为其1.13 倍。

通过预测降低分支成本

当流水线变得越来越深,而且分支的潜在代价增加时,仅使用延迟分支及类似机制就不够了。这时需要寻求一种更积极的方式来预测分支。这些机制分为两类:依赖编译时可用信息的低成本静态机制;根据程序特性对分支进行动态预测的策略。下面将讨论这两种方法。

静态分支预测

改进编译时分支预测的一种重要方式是利用先前运行过程收集的一览数据。之所以值得这样做,是因为人们观测到分支的行为特性经常是双峰分布的;也就是说,各个分支经常是严重偏向于选中或未选中两种情景之一。图C-9显示了使用这一策略成功地进行了分支预测。使用相同输入数据来运行程序,以收集一览数据;其他研究表明,如果改变输入,使不同运行的一览数据发生变化,只会使基于一览数据的预测准确度有微小变化。

任意分支预测机制的有效性都同时取决于机制的精度和条件分支的频率,在SPEC中,其变化范围为3%~24%。 整数程序的错误预测率较高,此类程序的分支频率通常较高,这一事实是静态分支预测的主要限制。在下一节中,我们考虑动态分支预测器,最新的处理器都采用这种机制。

图C-9 对于一种基于一览数据的预测器,SPEC92的错误预测率变化幅度很大,但浮点程序通常优于整型程序,前者的平均错误预测率为9%,标准偏差为4%,后者的平均错误预测率为15%,标准偏差为5%。实际性能取决于预测精度和分支频率,其变化范围为3%~24%

动态分支预测和分支预测缓冲区

最简单的动态分支预测机制是分支预测缓冲区或分支历史表。分支预测缓冲区是一个小型存储器,根据分支指令地址的低位部分进行索引。这个存储器中包含一个位(bit),表明该分支最近是否曾被选中。这一机制是最简单的缓冲区形式;它没有标志,仅当分支延迟过长,超过可能目标PC计算所需要的时间时,用于缩短分支延迟。

采用这样一种缓冲区时,我们事实上并不知道预测是否正确——它也可能是由另外一个具有相同低位地址的分支放入的。这个预测就是一种提示,我们假定它是正确的,并开始在预测方向上开始提取。如果这一提示最终是错误的,那将预测位反转后存回。这个缓冲区实际上就是一个缓存,对其所有访问都会命中,而且在后面可以看到,缓冲区的性能取决于两点:对所关注分支的预测频繁程度,该预测在匹配时的准确度。在分析性能之前,对分支预测机制的精度进行一点微小而重要的提升是很有用的。

这种简单的1位预测机制在性能上有一处短板: 即使某个分支几乎总是被选中,在其未被选中时,我们也可能会得到两次错误预测,而不是一次, 因为错误预测会导致该预测位反转。

为了弥补这一弱点,经常使用2位预测机制。在两位预测机制中,预测必须错过两次之后才会进行修改。图C-10给出了2位预测机制的有限状态处理器。

分支预测缓冲可以实现为一个小的特殊“缓存”,在IF流水线中使用指令地址进行访问,或者实现为一对比特,附加到指令缓存中的每个块,并随指令一起提取。如果指令的译码结果为一个分支,并且该分支被预测为选中,则在知道PC之后立即从目标位置开始提取。否则,继续进行顺序提取和执行。如图C-10所示,如果预测结果错误,将改变预测位。

图C-10 2位预测机制中的状态。许多分支被选中和不被选中的概率并非均等,而是严重偏向其中一种状态,对于此类分支,2位预测器的错误预测率经常低于1位预测器。在这种预测器中,使用2个数位对系统中的4种状态进行编码。这一2位机制实际上是一种更具一般性的机制的具体化,这种通用机制对于预测缓冲区中的每一项都有n位饱和计数器。对于一个n位计数器,其取值介于2至2^n-1之间:当计数器大于或等于其最大值(2^n-1)的一半时,分支被预测为选中;否则,预测其未选中。对n位预测器的研究已经证明,2位预测器的效果几乎与n位预测器相同,所以大多数系统都采用2位分支预测器,而不是更具一般性的n位预测器

在实际应用程序中,如果使用每项两位的分支预测缓冲区,可以预测得到什么样的准确度?图C-11显示,对于SPEC89基准测试,一个拥有4096项的分支预测缓冲区将得到超过99%~82%的预测准确度,或者说错误预测率为1%~18%。根据2005年的标准,一个4K项的缓冲区(比如得出上述结果的缓冲区)算是很小了,较大的缓冲区可以得到更好点的结果。

由于我们尝试开发更多的IP,所以分支预测的准确度变得非常关键。在图C-11中可以看出,整数程序的预测器精度低于循环密集的科学程序(前者的分支频率通常也更高一些)。我们可以采用两种方式来解决这一问题: 增大缓冲区的大小,增加每种预测机制的准确度。但如图C-12所示,一个拥有4000项的缓冲区,其性能大体与无限缓冲区相当,至少对于SPEC这样的基准测试如此。图C-12中的数据清楚地表明缓冲区的命中率并非主要限制因素。前面曾经提到,仅提高每个预测器的位数而不改变预测器结构,其影响也是微乎其微的。因此,我们需要研究一下如何提高每种预测器的精度。


图C-11 对于SPEC89基准测试,4096 项2位预测缓冲区的预测准确度。整数基准测试(gcc、espresso、eqntott和li )的预测错误率大体高于浮点程序,前者的均值为11%,后者为4%。忽略浮点内核(nasa7、matrix300和tomcatv)仍然会使浮点基准测试的准确度高于整数基准测试。收集这些数据及本节其余数据的分支预测研究采用IBM Power体系结构及针对该系统的优化代码执行。


图C-12 对于SPEC89基准测试,4096项2位预测缓冲区与无限缓冲区的预测准确度对比。尽管这些数据是针对一部分 SPEC基准测试的较早版本收集的,但较新版本的结果也大体相当,只是可能需要8000项来匹配一个无限2位预测器

如何实现流水化

在开始介绍基本流水化之前,需要回顾一下MIPS非流水版本的一种简单实现。

MIPS的简单实现

本节将继续C.1节的风格,首先给出一种简单的非流水化实现,然后是流水化实现。但我们这一次的例子是专门针对MIPS体系结构的。
在这一小节,我们主要关注MIPS中一部分整数运算的流水线,其中包括载入-存储字、等于零时分支和整数ALU运算。在本附录的后半部分将整合这些基础浮点操作。尽管我们仅讨论MIPS的一个子集,但这些基本原则可以扩展到对所有指令的处理。

每种MIPS指令都可以在最多5个时钟周期中实现。这5个时钟周期分述如下。

(1)指令提取周期(IF)

1
2
IR <- Mem[PC];
NPC ← PC+4;

操作——送出PC,并将指令从存储器提取到指令寄存器中(IR);将PC递增4,以完成下一顺序指令的寻址。IR用于保存将在后续时钟周期中需要的指令;与此类似,寄存器NPC用于保存下一顺序PC。

(2)指令译码/寄存器提取周期(ID )。

1
2
3
A←Regs[rs];
B←Regs[rt];
Imm←IR的符号扩展立即数字段;

操作——对该指令进行译码,并访问寄存器堆,以读取寄存器(rs和rt为寄存器识别符)。通用寄存器的输出被读入两个临时寄存器(A和B)中,供之后的时钟周期使用。IR 的低16位也进行了符号扩展,存储在临时寄存器Imm中,供下一个周期使用。

指令译码与寄存器读取是并行完成的,这一点之所以成为可能,是因为在MIPS格式中,这些字段放在固定位置。因为在所有MIPS格式中,指令的立即数部分都位于同一位置,所以在这一周期还会计算符号扩展立即数,以备在下一周期使用。

(3)执行/实际地址周期(EX)

ALU对前一周期准备的操作数进行操作,根据MIPS指令类型执行以下4种功能之一。

  • 存储器引用:
    • ALUOutput ← A + Imm;
    • 操作——ALU将操作数相加,得到实际地址,并将结果放在寄存器ALUOutput中。
  • 寄存器-寄存器ALU指令:
    • ALUOutput ← A func B;
    • 操作——ALU 对寄存器A和寄存器B中的取值执行由功能代码指定的操作。结果放在临时寄存器ALUOutput中。
  • 寄存器立即数ALU指令:
    • ALUOutput ← A op Imm;
    • 操作——ALU 对寄存器A和寄存器Imm中的值执行由操作代码指定的操作。结果放在临时寄存器ALUOutput中。
  • 分支:
    • ALUOutput ← NPC + (Imm << 2);
    • Cond ← (A==0)
    • 操作——ALU将NPC加到Imm中的符号扩展立即数,将该立即数左移2位,得到一个字偏移量,以计算分支目标的地址。检查已经在上一周期读取的寄存器A,以确保该分支是否被选中。由于我们仅考虑分支的一种形式(BEQZ),所以是与0进行对比。注意,BEQZ实际上是一个伪指令,它会转换为一个以R0为操作数的BEQ。为简单起见,我们这里仅考虑这一种分支形式。

MIPS的载入-存储体系结构意味着实际地址与执行周期可以合并到一个时钟周期中,因为此时没有指令需要同时计算数据地址和指令目标地址,并对数据执行操作。各种形式的跳转指令未包含在上述整数指令中,它们与分支相类似。

(4)存储器访问/分支完成计算(MEM )。

对所有指令更新PC: PC←NPC;

  • 存储器引用:
    • LMD ← Mem[ALUOutput]Mem[ALUQutput]←B;
    • 操作——在需要时访问存储器。 如果指令为载入指令,则从存储器返回数据,将其放入LMD(载入存储器数据)寄存器中;如果是存储指令,则将来自B寄存器的数据写入存储器。无论是哪种情况,所使用的地址都是在上一周期计算得出并放在寄存器ALUOutput中的地址。
  • 分支:
    • if (cond) PC ← ALU0utput
    • 操作——如果该指令为分支指令,则用寄存器ALUOutput中的分支目标地址代替PC。

(5)写回周期(WB )。

  • 寄存器-寄存器ALU指令:
    • Regs[rd]←ALU0utput;
  • 寄存器立即数ALU指令:
    • Regs[rt]←ALUOutput;
  • 载入指令:
    • Regs[rt]←LMD;
    • 操作——无论结果来自存储器系统(在LMD中),还是ALU(在ALUOutput中),都将其写到寄存器堆中,寄存器目标字段也在两个位置之一(rd或rt)之一,具体取决于实际操作码。

图C-13显示了一条指令是如何流经数据路径的。在每个时钟周期结束时,在该时钟周期计算并会在后面时钟周期用到的所有值(无论是供本条指令使用,还是供下一条指令使用)都被写入存储设备中,可能是存储器、通用寄存器、PC或临时寄存器(即,LMD、lmm、A、B、IR、NPC、ALUOutput或Cond )。临时寄存器在一个指令的时钟周期之间保存值,而其他存储元件则是状态的可视部分,在连续指令之间保存值。

尽管今天的所有处理器都是流水化的,但这种多周期实现方式合理地近似呈现了早些时候是如何实现大多数处理器的。可以使用一种简单的有限状态机来实现一种遵循上述5周期结构的控制。对于更复杂的处理器,可以使用微代码控制。在任何一种情况下,类似上述内容的指令序列决定着控制结构。

在这种多周期实现中可以消除一些硬件冗余。 例如,有两个ALU: 一个用于使PC递增,一个用于实际地址和ALU计算。由于不会在同一时钟周期用到它们,所以可以通过添加多工器和共享同一ALU来合并它们。同样,由于数据和指令访问发生在不同时钟周期内,所以指令和数据可以存储在相同存储器中。

我们没有优化这一简单实现方式,而是保持图C-13所示设计方式,为流水化实现提供了更好的基础。

图C-13 MIPS 数据路径的实现允许每条指令在4或5个时钟周期内执行。尽管PC显示在指令提取使用的数据路径部分、寄存器显示在指令译码/寄存器提取使用的数据路径部分,实际上这些功能单元都是由一条指令读取和写入的。尽管这些功能单元显示在对其进行读取的周期内,实际上,PC是在存储器访问时钟周期内写入,寄存器是在写回时钟周期内写入的。在这两种情况下,在后续流水级中的写入是由多工器输出指示(在存储器访问或写回中),它将一个取值带回PC或寄存器。流水线的大多数复杂性是由这些反向流动信号引入的,因为它们表明存在冒险的可能

作为本节所讨论多周期设计的一种替代方式,我们也可以将CPU实现为每条指令占用1个长时钟周期。在这种情况下,由于一条指令之内的时钟周期之间不需要任何通信,所以将会删除临时寄存器。每条指令都在1个长时钟周期内执行,在该时钟周期结束时将结果写入数据存储器、寄存器或PC。此类处理器的CPI为1。不过,这时的时钟周期大约等于多周期处理器时钟周期的5倍,这是因为所有指令都需要遍历全部功能单元。设计人员从来不会使用这种单周期实现方式,其原因有两个。第一,单周期实现对于大多数CPU的效率极低,因为对于不同指令,它们的任务量会有合理的变动,从而时钟周期时间也会变动。第二,单周期实现需要重复功能单元,而在多周期实现中,这些功能单元是可以共享的。不过,这种单周期数据路径可以让我们说明流水化是如何从时钟周期时间的角度(而非CPI的角度)来为处理器提供改进的。

MIPS基本流水线

和以前一样,只需要在每个时钟周期启动一个新指令,几乎不需要什么改变就可以对图C-13的数据路径实现流水线。因为每个流水级在每个时钟周期都是活动状态,所以流水级中的所有操作都必须在1个时钟周期内完成,任何操作组合都必须能够立即发生。此外,数据路径的流水化要求必须将流水级之间传递的数值放在寄存器中。图C-14 显示的MIPS流水线中包含了每个流水级之间的适当寄存器,称为流水线寄存器或流水线锁存器。用这些寄存器相联的流水线名称对其进行标记。图C-14的绘制方式清楚地显示了各级之间通过流水线寄存器的连接。

用于在一条指令的时钟周期之间保存临时值的所有寄存器都包含在这些流水线寄存器中。指令寄存器(IR)是IF/ID寄存器的一部分,当它们用于提供寄存器名称时,对其字段进行标记。这些流水线寄存器用于从一个流水级向下一个流水级传送数据和控制。在后续流水级上需要的所有值都必须放在这样一个寄存器中,并从一个流水线寄存器中复制到下一个寄存器,直到不再需要这些值为止。如果我们尝试仅在早期未流水化数据路径中使用临时寄存器,就有可能在完成所有应用之前覆盖这些值。例如,寄存器操作数的字段,用于在载入或ALU操作中执行写入操作,这一字段是从MEM/WB流水线寄存器而非IF/ID寄存器中提供的。这是因为我们希望一个载入或ALU操作写入该操作指定的寄存器,而不是该指令当前从IF传送到ID的寄存器字段!这一目标寄存器字段就是从一个流水线寄存器复制到下一个寄存器,直到在WB级用到它为止。


图C-14 通过增加一组寄存器实现流水化的数据路径,每对流水线之间一个寄存器。寄存器用于从一个流水级向下一个流水级传送值和控制信息。我们也可以将PC看作一个流水线寄存器,它位于流水线的IF级之前,指向每个流水级的一个流水线寄存器。回想一下,PC是一个边缘触发寄存器,在时钟周期结束时对其进行写入;因此,在写入PC时不存在争用条件。PC的多工器已被移动,恰好在一个流水级(IF)中写入PC。如果我们没有移动它,那在发生分支时可能会出现冲突,因为两条指令都会尝试将不同值写到PC中。大多数数据路径是由左向右流动,即从较早时间移向较晚时间。从右向左流动的数据(携带着分支的写回信息和PC信息)增加了流水线的复杂性

任何一条指令在某一时刻恰好在一个流水级中处于活动状态;任何以指令名义执行的操作都发生在一对流水线寄存器之间。因此,我们还可以通过以下方式来研究流水线的行为:查看在不同指令类型下,各流水级上必须执行什么操作。表C-9展示了这一观点。流水线寄存器的字段命名显示了数据在流水级之间的流动。注意,前两级的操作与当前指令类型无关。由于要等到ID级结束时才会对指令进行译码,所以前两级的操作必须与当前指令无关。IF行为取决于EXMEM中的指令是否为选中分支。如果是,则会在IF结束时将EXMEM中分支指令的分支目标地址写入PC中;如果不是,则写回递增后的PC。寄存器源操作数的固定位置编码对于在ID期间提取寄存器是至关重要的。

  • 在IF中,除了提取指令和计算新PC之外,我们还将递增后的PC存储到PC和流水线寄存器(NPC)中,供以后计算分支目标地址时使用。这个结构与图C-14中的组织方式相同,在正使用两个来源之一更新PC。在D中,我们提取寄存器,对IR(立即数字段)的低16位进行符号扩展,并沿R和NPC进行传送。在EX期间执行ALU运算或地址计算,沿IR和B寄存器进行传送(如果该指令为存储指令)。如果该指令为选中分支,我们还将cond的值设置为1。在MEM阶段,我们循环使用存储器,必要时写PC,并传送在最后流水级中使用的值。最后,在WB期间用ALU输出值或载入值来更新寄存器字段。为简单起见,我们总是将整个R从一级传送到下一级,实际上,在一条指令沿流水线流动时,对IR的需要越来越少。

为了控制这一简单流水线,我们只需要决定如何设定图C-14 数据路径中4个多工器的控制方式。ALU级的2个多工器根据指令类型设定,由ID/EX寄存器的IR字段规定。上面的ALU输入多工器根据该指令是否为分支来进行设定,下面的多工器根据该指令是寄存器-寄存器ALU操作还是任意其他类型的操作来设定。If级中的多工器选择是使用递增PC 的值,还是EX/MEM.ALUOutput(分支目标)的值来写入PC。这个多工器由EX/MEM.cond字段控制。第
四个多工器由WB级的指令是载入指令还是ALU指令来控制。除了这4个多工器之外,还需要另外1个多工器,虽然未在图C-14中画出,但只要看一下ALU操作的WB级就可以清楚地看出是存在该选择器的。根据指令类型(是寄存器寄存器ALU,还是ALU立即数或载入),目标寄存器字段为两个不同位置中的一个。因此,我们需要一个多工器来选择MEM/WB寄存器中IR的正确部分,以指定寄存器目标字段,假定该指令写入一个寄存器。

实现MIPS流水线的控制

将一条指令从指令译码级(I)移入此流水线执行级(EX)的过程通常称为指令发射,已经执行这一步骤的指令称为已发射。对于MIPS整数流水线,所有数据冒险都可以在该流水线的ID阶段进行检查。如果存在数据冒险,该指令将在被发射之前停顿。与此类似,我们可以确定在ID期间需要哪种转发,并设定适当的控制。如果在流水线早期检查互锁,除非整个处理器停顿,否则硬件从来不需要挂起一条已经改变处理器状态的指令,从而降低了硬件的复杂性。

或者,我们可以在使用操作数的一个时钟周期之始(对于此流水线来说,为EX和MEM)检查冒险或转发。为了说明这两种方法之间的区别,我们将展示如何通过在ID中进行检查来消除因为载入指令所导致的写后读(RAW)冒险互锁(称为载入互锁),并说明如何实现指向ALU输入的转发路径。表C-10列出了我们必须处理的各种环境。

  • 这个表显示,对于跟在写指令后面的两条指令,只需要对其目标和源进行对比。在发生停顿时,一旦继续执行后,流水线相关性与第三种情况类似。当然,涉及R0的冒险是可以忽略的,这是因为寄存器中总是包含零,而且上述测试经扩展后可以完成这一任务。

接下来实现载入互锁。如果存在一个因为载入指令导致的RAW冒险,当需要该载入数据的指令存位于ID级时,该载入数据将位于EX级。因此,我们可以用一个很小的表来描述所有可能存在的情景,可以直接将其转换为实现方式。表C-11显示了当使用载入结果的指令位于ID级时,检测到负载互锁的逻辑。

  • 表中第1行和第2行测试载入目标寄存器是否为ID中寄存器——寄存器操作的源寄存器之一。表中第3行判断载入目标寄存器是否为载入或存储有限地址、ALU立即数或分支测试的源。请记住,IF/ID 寄存器保存着ID中指令的状态,它可能会用到载入结果,而ID/EX保存着EX中指令的状态,它是载入指令。

一旦检测到冒险,控制单元必须插入流水线停顿,并防止IF和ID级中的指令继续前进。前面曾经看到,所以控制信息都承载于流水线寄存器中。(仅承载指令就足够了,因为所有控制都是由其派生而来的。)因此,在检测冒险时,只需要将ID/EX流水线寄存器的控制部分改为全0,它正好是一个空操作(一个不做任何事情的指令,比如DADD R0, R0, R0)。此外,我们只篅循环使用IF/ID 寄存器中的内容,以保存被停顿的指令。在具有更复杂冒险的流水线中,这些思想同样适用:对比一组流水线寄存器,并转换为空操作,以防止错误执行。

转发逻辑的实现尽管需要考虑更多种情况,但大体类似。要实现转发逻辑,关键是要注意到流水线寄存器中既包含了要转发的数据,也包含了源、目标寄存器字段。所有转发在逻辑上都是从ALU或数据存储器的输出到ALU输入、数据存储器输入或零检测单元。因此,我们可以对比EXMEM和MEMWB级中所包含IR的目标寄存器与ID/EX和EXMEM寄存器中所包含IR的源寄存器,以此来实现转发。表C-12显示了这些对比,以及当转发结果的目的地是EX中当前指令的ALU输入时,可能执行的转发操作。

  • 为了判断是否应当发生转发操作,一共需要10次不同比较。顶部和底部的ALU输入分别指代与第一、第二ALU源操作数相对应的输入,如图C-13及图C15中所示。请记住,EX中目标指令的流水线销存器是ID/EX,而源值来自EX/MEM或MEM/WB的ALUOutput部分,或者MEMWB的LMD部分。有一个复杂问题未能通过这一逻辑解决:处理多条向相同寄存器进行写入的指令。例如,在代码序列DADD R1, R2, R3; DADDI: R1, R1, #2; DSUB R4, R3, R1期间,该逻辑必须确保DSUB指令使用的是DADDI指令的教据,而不是DADD指令的结果。为了处理这一情景,可以扩展上述逻辑:仅在未对相同输入转发来自EXMEM的结果时,才检测来自MEMWB的转发。由于DADDI结果将位于EX/MEM中,所以将转发该结果,而不是MEMWB中的DADD结果。

除了在需要启用转发路径时必须确定的比较器和组合逻辑之外,还必须扩大ALU输入端的多工器,并添加一些连接,这些连接源于转发结果所用的流水线寄存器。图C-15给出了流水化数据路径的相关段,其中添加了所需要的多工器和连接。

图C-15 向ALU转发结果需要在每个ALU多工器上另外增加三个输入,增加三条指向这些新输入的路径。这些路径对应于以下三者的一个旁路: (1)EX结束时的ALU输出;(2)MEM级结束时的ALU输出;(3)MEM级结束时中的存储器输出

对于MIPS,冒险检测和转发硬件是相当简单的;我们将会看到,当为处理浮点数而对这一流水线进行扩展时,事情多少会变得复杂一些。在此之前,需要处理分支。

处理流水线中的分支

在MIPS中,分支(BEQ和BNE)需要检测一个寄存器,看其是否等于另一个寄存器(该寄存器可能为R0)。如果仅考虑BEQZ和BNEZ的情景(它们需要零检测),那有可能通过将零检测移到周期内部,在ID周期结束时完成此判断。为了充分利用尽早判断出该分支是否命中的优势,都必须尽早计算PC(无论命中还是未命中)。在ID期间计算分支目标地址需要增加一个加法器,因为前面一直用于实现此功能的主ALU在EX之前是不可用的。图C-16给出了修订后的流水化数据路径。通过增加独立的加法器,并在ID期间作出分支判断,分支仅需要停顿1个时钟周期。尽管这样将分支延迟缩减为1个周期,但这意味着如果一个 ALU指令后面跟有一个依赖该指令结果的分支,那就会招致数据冒险停顿。表C-13显示了表C-9中修订后的流水线表的分支部分。

在一些处理器中,分支冒险的代价(以时钟周期为单位)比我们这里的例子中更昂贵,因为评估分支条件和计算目的地的时间可能更长一些。例如,拥有独立译码和寄存器提取级的处理器可能存在分支延迟(控制冒险的长度),其长度至少为1个时钟周期。除非经过处理,否则分支延迟会转变为分支代价。许多实现更复杂指令集的较旧CPU,其分支延迟为4个时钟周期,甚至更长一些,而大型深度流水化处理器的分支代价经常为6或7个时钟周期。一般来说,流水线越深,以时钟周期度量的分支代价就越糟。当然,较长分支代价的相对性能性能影响取决于处理器的总CPI。低CPI的处理器可以承受更昂贵的分支,这是因为分支导致处理器性能下降的百分比较低。

图C-16 将零检测和分支目标计算移到流水线的ID级中,可以缩减因为分支冒险导致的停顿。注意,我们已经进行了两处重要改变,每个改变消除了分支指令的三周期停顿之一。第一处变化是将分支目标地址计算和分支条件判断移到ID周期。第二处变化是在IF阶段写入指令的PC,或者使用在ID阶段计算的分支目标地址,或者使用在IF期间计算的递增PC。在对比中,图C-14从EXMEM寄存器中获取分支目标地址,并在MEM时钟周期内写入结果。在图C-14中曾经提到,可以将PC看作流水线寄存器(例如,作为ID/IF的一部分),会在每个IF周期结束时向其中写入下一条指令的地址

  • 它使用一个独立的加法器(和图C-16一样),在ID期间计算分支目标地址。新操作或发生变化的操作以黑体标出。由于分支目标地址加法是在ID期间发生的,所以针对所有指令都会经过这一步骤;分支条件(Regs[IF/ID. IR6..10] op 0)也对所有指令有效。是选择顺序PC还是分支目标PC,仍然是在IF期间决定的,但它现在仅使用来自ID级的取值,这些值与上一条指令设定的值相对应。这一改变使分支代价降低两个周期,一个周期是因为提前计算分支目标和条件,一个周期是因为在同一时钟周期内控制PC选择,而没有推至下一周期。由于cond的值被设置为0,所以除非ID中的指令为选中分支,则该处理器必须在ID结束之前对指令进行译码。因为该分支在ID结束时完成,所以分支指令没有用到EX、MEM和WB级。对于偏移量长于分支的跳转,还会进一步增加复杂性。我们可以增加一个加法器,将IR中低26位左移2位后的值与PC相加,以应对上述复杂性。

妨碍流水线实现的难题

处理异常

异常情景在流水化CPU中更难处理:由于指令的重叠,更难以判断一条指令是否能安全比改变CPU的状态。在流水化CPU中,指令是一段一段地执行, 在几个时钟周期内不会完成。不幸的是,流水线中的其他指令可能会引发一些异常,强制CPU在流水线中的指令尚未完成中止执行。

异常的类型与需求

对于一些改变指令正常执行顺序的异常情景,在不同CPU中会采用不同的术语来进行指述。人们会使用中断、错误和异常等词,但具体用法并不一致。我们用异常一词来涵盖包括下列内容的所有此类机制:

  • I/O设备需求
  • 从用户程序调用操作系统服务
  • 跟踪指令执行
  • 断点(程序员请求的中断)
  • 整数算术溢出
  • 浮点算术异常
  • 页面错误(不在主存储器中)
  • (在需要对齐时)存储器访问未对齐
  • 违反存储器保护规则
  • 使用未定义或未实现的指令
  • 硬件故障
  • 电源故障

当我们希望引用这些异常中的某一具体类别时,将使用一个较长的名称,比如I/O中断、浮点异常或页面错误。表C-14显示了上述常见异常事件的各种不同名称。


  • IBM 360和80x86上的每个事件都称为中断,而680x0中的每个事件称为异常。VAX将事件划分为中断或异常。在VAX中断中使用了设备、软件和紧急等修饰词语,而VAX异常则细分为故障、陷阱和中止。

尽管我们使用异常一词来涵盖所有这些事件,但各种事件都有自己的重要特性,决定了硬件中需要采取什么操作。关于异常的需求,可以从五个半独立的方面进行描述。

  1. 同步与异步——如果每次以相同数据和存储器分配执行程序时,事件都在同一位置发生,那事件就是同步的。由于硬件故障等异常,可能会由CPU和存储器外部的设备导致异步事件。异步事件通常可以在完成当前指令后处理,使其处理变得更容易一些。
  2. 用户请求与强制——如果用户任务直接请求某一事件,那它就是用户请求事件。在某种意义上,用户请求的异常不是真正的异常,因为它们是可预测的。但是,由于对这些用户请求事件使用了用于存储和恢复状态的相同机制,所以也将它们看作异常。因为对于触发这一异常的指令来说,其唯一功能就是引发该异常,所以用户请求异常总是可以在完成该指令之后加以处理。强制异常是由某一不受用户程序控制的硬件事件导致的。强制异常不可预测,所以更难以实现。
  3. 用户可屏蔽与用户不可屏蔽——如果一个事件可以借由用户任务来屏蔽或禁用,那它就是用户可屏蔽的。这一屏蔽只是控制硬件是否对异常进行回应。
  4. 指令内部与指令之间一这种分类取决于妨碍指令完成的事件是发生在执行过程中间(无论多短),还是被看作是发生在指令之间。发生在指令内部的异常通常是同步的,因为就是这条指令触发了异常。在指令内部发生的异常实现起来要难于指令之间的异常,因为该指令必须被停止、重新启动。发生在指令内部的异步异常是因为灾难性情景(例如,硬件故障)出现的,所以总会导致程序终止。
  5. 恢复与终止——如果程序的执行总是在中断之后停止,那它就是终止事件。如果程序的执行在中断之后继续,那它就是恢复事件。终止执行的异常实现起来更容易一些,因为CPU不需要在处理异常之后重启同一程序。

表C-15根据这5个类别对表C-14中的示例进行了划分。难点在于实现指令之间发生的中断,对于这种中断必须恢复指令的执行。此类异常的实现要求必须调用另一个程序,以保存所执行程序的状态、解决导致异常的原因,然后恢复程序的状态,之后才再次尝试导致该异常的指令。对正在执行的程序来说,这个过程事实上必须是不可见的。如果流水线使处理器能够在不影响程序执行的前提下处理异常、保存状态并重新启动,那就说流水线或处理器是可重新启动的。早期的超级计算机和微处理器通常缺少这一特性,但今天的几乎所有处理器都支持这一特性,至少整数流水线是这样的,因为虚拟存储器的实现需要这一特性。

表C-15 用5个类别来确定表C-14所示不同异常类型所需要的操作

  • 必须允许恢复的异步被标记为恢复,尽管软件可能选择终止程序。发生在指令内部的可恢复、同步、强制异常是最难实现的。我们可能希望违反存储器保护访问的异常总是导致终止;但是,现代操作系统使用存储器保护来裣测事件,比如首次尝试使用一个页面,或者首次尝试写入一个页面。因此,CPU应当能够在此类异常之后恢复执行。

停止和恢复执行

和在非流水化实现中一样,最困难的异步有两种特征:

  1. 发生在指令内部(即,在指令执行过程期间发生,与EX或MEM流水级相对应);
  2. 必须可以重新启动。

比如,在MIPS流水线中,由数据提取导致的虚拟存储器页面错误只可能发生在该指令MEM级的某一时间之后。在出现该错误时,会有其他几条指令正在执行。页面错误必须是可重新启动的,需要另一进程(比如操作系统)的干预。因此,必须能够安全关闭流水线并保存其状态,使指令能够以正确状
态重新启动。重启过程的实现通常是通过保存待重启指令的PC来实现的。如果被重启的指令不是分支,则继续提取依次排在后面的指令,以正常方式开始执行。如果被重启的指令为分支指令,则重新计算分支条件,根据计算结果提取目标指令或直通指令。在发生异步时,流水线控制可以采取以下步骤安全地保存流水线状态。

  1. 在下一个IF向流水线中强制插入一个陷阱指令。
  2. 在选中该陷阱指令之前,禁止错误指令的所有写入操作,禁止流水线中后续所有指令的写入操作。可以通过以下方式来实现:从生成该异常的指令开始(不包括该指令之前的指令),将流水线中所有指令的流水线锁存置零。这样可以禁止在处理异常之前对未完成指令的状态进行任何更改。
  3. 在操作系统的异常处理例程接收控制权之后,它会立即保存故障指令的PC。后面从异常返回时会用到这个值。

当我们使用延迟分支时,如上一节所述,由于流水线中的指令可能不是顺序相关的,所以仅用一个PC不再可能重建处理器的状态。因此,我们需要保存和恢复的PC数目等于分支延迟的长度加1。这一步骤是在上述第(3)步完成的。

在处理异常之后,特殊指令通过重新加载PC并重启指令流(在MPS中使用指令RFE)从异常中返回处理器。如果流水线可以停止,使紧临错误指令之前的指令能够完成,使其之后的指令可以从头重新启动,那就说该流水线拥有精确异常。在理想情况下,错误指令可能还没有改变状态,要正确地处理一些异常,要求错误指令不产生任何影响。对于其他异常,比如浮点异常,某些处理器上的错误指令会首先写入其结果,然后才能处理异常。在此种情况下,即使目标位置与源操作数之一的目标位置相同,也必须准备硬件来提取源操作数。因为浮点操作可能持续许多个周期,所以其他某一指令非常可能已经写入了这些源操作数(在下一节将会看到,浮点操作经常是乱序完成的)。为了克服这一问题,最近的许多高性能CPU引入了两种操作模
式。一种模式有精确异常,另一种(快速或性能模式)则没有精确异常。当然,精确异常模式要慢一些,因为它允许浮点指令之间的重叠较少。在一些高性能CPU中,精确模式通常要慢得多(相差10倍以上),因此仅能用于代码调试。

在许多系统中对精确异常的支持是必备功能,而在其他一些系统中,它“只是”存在一定的价值,因为它可以简化操作系统接口。至少,任何需要分页或IEEE算术陷阱处理程序的处理器都必须使其异常为精确异常,可以用硬件实现,也可以辅以一定的软件支持。对于整数流水线,创建精确异常的任务比较简单,支持虚拟内存是存储器引用支持精确异常的极大动力。

MIPS中的异常

表C-16显示了MIPS流水级,以及每一级中可能发生哪些问题异常。采用流水化时,由于有多条指令同时执行,所以在同一时钟周期中可能出现多个异常。例如,考虑如下指令序列:

1
2
LD     IF     ID     EX     MEM     WB
DADD IF ID EX MEM WB

这一对指令可能同时导致数据页面错误和算术异常,这是因为ID 处于MEM级,而DADD位于EX级。要处理这一情景,可以先处理数据页面错误然后再重启执行过程。第二个异常将再次发生(如果软件正确,第一个异常将不再发生),这样,在发生第二个异常时,就可以单独对其进行处理。

  • 由指令或数据存储器访问产生的异常可能占到8种情景的6种。现实中的情景并不像这个简单例子中那样明了。异常可能乱序发生;也就是说,可能在一条指令先行产生异常之后,排在前面的指令才产生异常。再次考虑上述序列,LD后面跟着DADD。当LD处于IF时可能产生数据页面错误,而DADD指令位于IF时可能会产生指令页面错误。指令页面错误尽管由后一指令导致,但实际上它将会首先发生。

由于我们正在实现精确异常,所以流水线需要首先处理由LD指令导致的异常。为了解释如何实现这一过程,我们将位于LD指令位置的指令称为i,将位于DADD 指令位置的指令称为i+1。流水线不能在发生异常时直接处理它,因为这样会导致这些异常的发生顺序不同于非流水化顺序。硬件会将一条给定指令 产生的所有异常都记录在一个与该指令相关联的状态向量中。这个异常状态向量将一直随该指令 向流水线下方移动。一且在异常状态向量中设定了异常指示,则关闭任何可能导致数据值写入(包括寄存器写入和存储器写入)的控制信号。由于存储指令可能在MEM期间导致异常,所以硬件必须准备好在存储指令产生异常时阻止其完成。

当一条指令进入WB时(或者将要离开MEM时),将检查异步状态向量。如果发现存在任何异常,则按照它们在非流水化处理器中的发生顺序进行处理——首先处理与最早指令相对应的异常(通常位于该指令的最早流水级)。这样可以保证:指令i引发的所有异常将优先得到处理,早于指令i+1引发的所有异常。当然,任何在较早流水级中以指令i名义采取的操作都是无效的,但由于对寄存器堆和存储器的写入操作都被禁用,所以还没有改变任何状态。

一些处理器拥有功能更强大、运行时间更长的指令,下一小节将介绍在此类处理器的流水线中实现异常时存在的问题。

指令集的复杂性

所有MIPS指令的结果都不会超过1个,我们的MIPS流水线仅在指令执行结束时写入结果。在保证一条指令完成时,称为已提交。在MIPS整数流水线中,当所有指令到达MEM级的末尾(或者WB的开头),而且没有指令在该级之前更新状态,则说这些指令已提交。因此,精确异常非常简单。一些处理器的指令会在指令执行中间更改状态,更改状态时,该指令及其之前的指令可能还未完成。例如,IA-32体系结构中的自动递增寻址模式可以在一条指令的执行过程中更新寄存器。在这种情况下,如果该指令由于异常而终止,则会使处理器状态发生变化。尽管我们知道哪些指令会导致异常,但由于该指令处于半完成状态,所以在未添加硬件支持的情况下,异常是不准确的。在这样一个非精确异常之后重启指令流是有难度的。我们也可以避免在指令提交之前更新状态,但这种做法的难度很大,或者成本很高,这是因为可能会用到经过更新的状态:考虑一条VAX指令,它多次递增同一寄存器。为了保持精确异常模型,大多数拥有此类指令的处理器能够在提交指令之间回退所做的状态更改。如果发生异常,处理器将使用这一功能将处理器状态还原为开始中断指令之前的值。

一些在执行期间更新存储器状态的指令也会增加难度。为中断和重启这些指令,规定这些指令使用通用寄存器作为工作寄存器。因此,部分完成指令的状态总是位于寄存器中,这些寄存器在发生异常时被保存,在异常之后恢复,使指令可以继续执行。在VAX中,有另外一个状态位记录指令何时开始更新存储器状态,从而在重启流水线时,CPU知道是从头开始重启指令,还是从指令的中间开始。IA-32字符串指令也使用寄存器作为工作存储器,这样,在保存和恢复寄存器时,也会保存和恢复这些指令的状态。

奇数个状态位可能会导致另外一组不同的难题:可能另外增加流水线冒险,可能需要额外的硬件来进行保存和恢复。条件代码就是这种情况的一个好例子。许多处理器隐式设定条件码,将其作为指令的一部分。这种方法具有一定的优势,因为条件码将条件的判断与实际分支分离开来。但是,在调度条件代码设定与分支之间的流水线延迟时,由于大多数指令都会设定条件码,而且不能在条件判定与分支之间的延迟时隙中使用,所以隐式设定条件可能会增加调度难度。

另外,在具有条件码的处理器中,处理器必须判断何时确定分支条件。这就需要找出分支之前最后一次设置条件代码是在什么时候。 在大多数隐式设定条件码的处理器中,其实现方式是推迟分支条件判断,直到先前的所有指令都有机会设定条件代码为止。当然,显式设定条件码的体系结构允许在条件测量与待调度分支之间插入延迟;但是,流水线控制必须跟踪最后一条设定条件码的指令,以便知道何时确定分支条件。实际上,必须将条件码当作一个操作数对待,需要进行RAW冒险检测,就像MIPS必须对寄存器进行检测一样。流水线中最后一个棘手领域是多周期操作。

假定我们尝试实现下面这样一个VAX指令序列的流水化:

1
2
3
4
MOVL        R1, R2                 ;在寄存器之间移动
ADDL3 42(R1), 56(R1)+,@(R1) ;对存储器位置求和
SUBL2 R2, R3 ;减去寄存器
MOVC3 @(R1)[R2], 74(R2), R3 ;移动字符串

这些指令所需要的时钟周期数有很大差别,低至1 个,高至数百个。它们所需要的数据存储器访问数也不一定,有的不需要访向数据存储器,有的可能需要数百次访问。数据冒险非常复杂,在指令之间和指令内部均会发生。一种简单的解决方案是让所有指令的执行周期数相同,但这种解决方案是不可接受的,因为它会引入数目庞大的冒险和旁通条件,形成-条极长的流水线。在指令级实现VAX的流水化是很困难的,但VAX 8800设计师找到了-种非常聪明的解决方案。他们实现了微指令执行的流水化。微指令就是一种简 单指令,在序列中用于实现更复杂的指令集。由于微指令都很简单(它们看起来与MIPS非常相似),所以流水线控制要容易得多。从1995年开始,所有Intel IA-32微处理器都使用这一策略将IA-32 指令转换为微操作,然后再实现微操作的流水化。

作为对比,载入-存储处理器拥有一些简 单操作,完成的工作数量相似,更容易实现流水化。如果架构师认识到指令集设计与流水化之间的关系,他们就可以设计出能够高效流水化的设计体系结构。20 世纪80年代,人们认识到指令集的复杂性会增加流水化的难度、降低流水化的效率。20 世纪90年代,所有公司都转向更简单的指令集,目标在于降低积极实现的复杂性。

扩展MIPS流水线,以处理多周期操作

我们现在希望研究如何扩展MIPS流水线,以处理浮点运算。这一节的重点是介绍基本方法和各种候选设计方案,最后是对一种MIPS浮点流水线的性能测量。要求所有MTPS浮点运算都在1个时钟周期内完成是不太现实的,甚至在2个时钟周期内也有很大难度。这样做就意味着要么接受缓慢的时钟,要么在浮点单元中使用大量逻辑,或者同时接受两者。而实际情况是,浮点流水线将会允许更长的操作延迟。如果我们设想浮点指令拥有与整数指令相同的流水线,那就容易理解了,当然流水线中会有两处重要改变。第一,为了完成操作,EX周期可能要根据需要重复多次,不同操作的重复次数可能不同。第二,可能存在多个浮点功能单元。如果待发射指令会导致它所用功能单元的结构性冒险,或者导致数据冒险,将会出现停顿。

针对本节,我们假定MIPS实现中有以下4个独立的功能单元。

  1. 主整数单元,处理载入和存储、整型ALU操作,还有分支。
  2. 浮点与整数乘法器。
  3. 浮点加法器,处理浮点加、减和转换。
  4. 浮点和整型除法器。

如果我们还假定这些功能单元的执行级没有实现流水化,那么图C-17给出了最终的流水线结构。由于EX未被流水化,所以在前一指令离开EX之前,不会发射任何其他使用这一功能单元的指令。另外,如果一条指令不能进入EX级,该指令之后的整个流水线都会被停顿。


图C-17 增加了三个未流水化浮点功能单元的MIPS流水线。因为每个时钟周期仅发射一条指令,所以所有指令都会经历整型运算的标准流水线。只是浮点运算在到达EX级时会循环。在它们完成EX级之后,则进入MEM和WB级,以完成执行

事实上,中间结果可能不会像图C-17所建议的那样围绕EX单元循环;而是在EX流水级拥有一些大于1的时钟延迟。我们可以推广如图C-17所示的浮点流水线结构,以允许实现某些级的流水化,并允许多个操作同时进行。为了描述这样一个流水线,我们必须定义功能单元的延迟以及启动间隔(或称重复间隔)。我们采用之前的相同方式来定义延迟:生成结果的指令与使用结果的指令之间的周期数。起始间隔或重复间隔是指在发出两个给定类型的操作之间必须间隔的周期数。例如,我们将使用如表C-17所示的延迟和启动间隔。根据这一延迟定义,整型ALU运算的延迟为0,因为其结果可以在下一时钟周期使用;载入指令的延迟为1,因为这些结果可以相隔一个周期之后使用。由于大多数操作都会在EX的开头使用其操作数,所以延迟通常是指eX之后的级数(一条指令在EX生成结果),例如,ALU运算之后有0个流水级,而载入指令则有1级。一个重要的例外是存储指令,它会在一个周期之后使用被存储的值。因此,存储指令的延迟是针对被存储的值而言,而不是针对基址寄存器,所以少1个周期。流水线延迟基本上等于执行流水线深度减去1个时钟周期,而流水线深度等于从EX级到生成结果的流水级之间的级数。因此,对于上面的示例流水线,浮点加法中的级数为4,而浮点乘法的级数为7。为了获得更高的时钟频率,设计师需要降低每个流水级中的逻辑级数,而这样会增大更复杂操作所需要的流水级数。高时钟频率的代价是延长了操作的延迟。


表C-17中的示例流水线结构允许多达4个同时执行的浮点加、7个同时执行的浮点/整数乘,和一个浮点除。图C-18说明了如何通过扩展图C-17来绘制这个流水线。在图C-18中,重复间隔是通过增加额外的流水级来实现的,它们由增加的流水线寄存器隔开。由于这些单元是相互独立的,所以我们分别对各级进行命名。需要多个时钟周期的流水级,比如除法单元,将被进一步细分,以显示这些流水级的延迟。由于它们不是完整的流水级,所以只有一个操作是活动的。这一流水线结构还可以使用本附录前面的类似图表展示,表C-18显示了一组独立的浮点运算和浮点载入、存储指令。自然,浮点运算的较长延迟增加了RAW冒险和所导致停顿的频率,在本节后面将会看到这一点。


图C-18 一条支持同时执行多个浮点操作的流水线。浮点乘法器和加法器被完全流水化,深度分别为7级和4级。浮点除法器未被流水化,而是需要24个时钟周期才能完成。在未招致RAW停顿的情况下,从发射浮点操作到使用操作结果之间的指令延迟由执行级中消耗的周期数来决定。例如,浮点加之后的第四条指令可以使用浮点加的结果。对于整数AlU操作,执行流水线的深度总是为1,下一条指令就可以使用这些结果

  • 用斜体表示的流水级显示了需要数据的位置,而用粗体表示的流水级显示了有结果可用的位置。指令标记符上的“.D”扩展表示双精度(64位)浮点运算。浮点载入和存储使用64位路径连向存储器,所以流水线时序与整数载入或存储一样。

图C-18中的流水线结构需要另外引入流水线寄存器(例如,A1/A2、 A2/A3、A3/A4),并修改连向这些寄存器的连接。ID/EX 寄存器必须进行扩展,以将ID 连接到EX、DIV、M1和A1;我们可以用标记ID/EX、ID/DIV、ID/M1或ID/A1来引用与之后流水线之一相关联的寄存器部分。ID 与所有其他流水级之间的流水线寄存器可以看作逻辑分离的寄存器,事实上也确实可以实现为分离寄存器。由于在一个流水级中只能同时有一个操作,所以控制信息可以与该流水级头部的寄存器关联在一起。

长延迟流水线 中的冒险与转发

对于如图C-18所示的流水线,冒险检测与转发有许多不同方面。

  1. 因为除法单元未被完全流水化,所以可能发生结构冒险。需要对这些冒险进行检测,还将需要停顿指令发射。
  2. 因为指令的运行时间不同,所以一个周期内需要的寄存器写入次数可能会大于1。
  3. 由于指令不会循序到达WB,所以有可能存在写后写(WAW)冒险。注意,由于寄存器读总是在ID中发生,所以不可能存在读后写(WAR)冒险。
  4. 指令的完成顺序可能不同于其发射顺序,从而导致异常问题。
  5. 由于操作的延迟较长,所以RAW冒险的停顿将会变得更为频繁。

由于操作延迟较长而导致停顿的增加,基本上与整数流水线一样。在描述这一浮点流水线中出现的新问题并探讨其解决方案之前,先让我们研究一下RAW冒险的可能影响。表C-19给出了一个典型浮点代码序列和最终的停顿。在这一节的末尾,将会研究这一浮点流水线中我们所选部分SPEC的性能。

  • 与较浅的整数流水线相比,较长的流水线大体会增大停顿频率。这个序列中的每条指令都会依赖于先前的指令,而且只要数据可用就可以继续进行,这里假定流水线具有完全旁通和转发。S.D必须多停顿一个周期,使其MEM不会与ADD.D冲突。添加硬件可以很轻松地处理这种情况。

现在看看因为写入导致的问题,如前面列表中第(2)项和第(3)项所述。如果我们假定浮点寄存器堆有一个写端口,那么浮点操作序列(以及浮点载入指令与浮点运算的结合)可能导致寄存器写端口的冲突。考虑表C-20所示的流水线序列。在时钟周期11中,所有3条指令将到达WB,希望写入寄存器堆。由于仅有一个寄存器堆写入端口,所以处理器必须依次完成各条指令。这个单一寄存器端口就代表着一种结构化冒险。我们可以增加端口的数目来解决这一问题,但由于所增加的写入端口可能很少用到,所以这种解决方案可能并没有什么吸引力。之所以很少用到这些端口,是因为写入端口的最大稳定状态数为1。 我们选择检测对写端口的访问,将其作为结构胃险加以实施。

  • 这不是最坏情况,因为浮点单元中的先前除法操作也可能在同一时钟周期完成。注意,尽管在时钟周期10中,MUL.D、ADD.D和L.D都处于MEM级,但仅有L.D在实际使用该存储器,所以关于MEM不存在结构性冒险。

共有两种不同方法来实现这一互锁。 第一种方法是跟踪ID级对写端口的使用,并在一条指令发射之前使其停顿,就像对于任何其他结构冒险一样。可以用一个移位寄存器来跟踪写端口的使用,这个移位寄存器可以指示已发射指令将会在何时使用这个寄存器堆。如果ID中的指令需要与已发射指令同时使用寄存器堆,那ID中的指令将会停顿一个周期。在每个时钟周期,保留寄存器将会移动1位。这种实现有一个好处一它能保持一个特性:所有互连检测与停顿插入都在ID流水级内进行。其成本是需要增加移位寄存器和写冲突逻辑。我们在本节始终假定采用这一机制。

一种替代方案是当一个冲突指令尝试进入MEM级或WB级时,使其停顿。如果我们等到冲突指令希望进入MEM或WB级时才使其停顿,可以选择停顿任一指令。一种简单的启动式方法(尽管有时是次优方法)是为那些延迟最长的单元赋予优先级,这是因为它是最可能导致另一指令因RAW冒险而停顿的指令。这种方案的好处在于在进入容易检测冲突的MEM或WB级之前不需要检测冲突。缺点是,由于停顿现在可能会出现在两个地方,所以使流水线控制变得复杂。注意,在进入MEM之前的停顿将会导致EX、A4或M7流水级被占用,可能会强制停顿返回流水线中。同样,WB之前的停顿将会导致MEM倒退。

我们的另一个问题是可能出现WAW冒险。为了看到这些冒险的存在,考虑表C-20中的示例。如果L.D指令早一个周期发射,且其目的地为F2,则会产生WAW冒险,因为它会早于ADD.D一个周期写入F2。注意,只有当ADD.D的结果被改写,而且从来没有任何指令使用这一结果时,才会发生这一冒险!如果在ADD.D和L.D之间会用到F2,那流水线将会因为RAW冒险而需要停顿,在ADD.D完成之前不会发射L.D。对于这个流水线,我们可以宣称只有在发射无用指令时才会发生WAW冒险,但仍然必须检测这些冒险,并在完成工作时确保L.D的结果出现在F2中。

有两种可能方式来处理这一WAW冒险。第一种方法是延迟载入指令的发射,直到ADD.D进入MEM为止。第二种方法是废除ADD.D的结果:检测冒险并改变控制,使ADD.D不会写入其结果。之后L.D就可以立即发射。由于这种冒险非常少见,所以两种方案都很有效一可以选择任何一种易于实现的方案。在任一情况下, 都可以在发射L.D的ID期间检测冒险,使L.D停顿或者使ADD.D成为空操作都很容易。难以处理的情景是检测L.D可能在ADD.D之前完成,因为这时需要知道流水线的长度和ADD.D 的当前位置。幸运的是,这一代码序列 (两个写操作之间没有插入读操作)很少出现,所以可以使用一种简单的解决方案:如果ID中的一条指令希望和一条已经发射的指令同时写同一寄存器,就不要向EX发射指令。

在检测可能出现的冒险时,必须考虑浮点指令之间的冒险,以及浮点指令与整型指令之间的冒险。除了浮点载入-存储和浮点-整数寄存器移动之外,浮点与整数寄存器是相互分离的。所有整数指令都是针对整数寄存器进行操作,而浮点操作仅对它们自己的寄存器进行操作。因此,在检测浮点与整数指令之间的冒险时,只需要考虑浮点载入-存储和浮点寄存器移动。流水线控制的这种简化是整数和浮点数据采用分离寄存器堆的另一项好处。(其主要好处是在各寄存器堆数目不变的情况下使寄存器数目加倍,还能在不增加各寄存器端口的情况下增加带宽。除了需要增加寄存器堆之外,其主要缺点是偶尔需要在两组寄存器之间进行移动所带来的微小成本。)假定流水线在ID中进行所有冒险检测,必须在执行3种检查之后才能发射一条指令。

(1)检查结构冒险——一直等到所需要功能单元不再繁忙为止(在这个流水线中,只有除法操作需要),并确保在需要寄存器写端口时可用。

(2)检查RAW数据冒险——一直等到源寄存器未被列为流水线寄存器中的目的地为止。(当这一指令需要结果时,这些寄存器不可用。)这里需要进行大量检查,具体取决于源指令和目标指令,前者决定结果何时可用,后者决定何时需要该取值。例如,如果ID中的指令是一个浮点运算,其源寄存器为F2,那F2在ID/A1、A1/A2或A2/A3中不能被列为目的地,它们与一些浮点加法指令相对应,当ID中的指令需要结果时,这些指令还不能完成。(ID/A1 是ID输出寄存器中被发送给A1的部分。)如果我们希望重叠执行除法的最后几个周期,由于需要将除法接近完成时的情景作为特殊情况加以处理,所以除法运算需要的技巧性更强-些。实际上,设计师可能会忽略这一优化,以简化发射测试。

(3)检查WAW数据冒险——判断A1,… A4,D,M1,…,M7中是否有任何指令的目标寄存器与这一指令相同。如果确实如此,则暂停发射ID中的指令。尽管对于多周期浮点运算来说,冒险检测要更复杂一些,但其概念与MIPS整数流水线是一样的。对于转发逻辑也是如此。可通过以下方式来实现转发:检查EXMEM、A4/MEM、M7MEM、D/MEM或MEM/WB寄存器中的目标寄存器是否为浮点指令的源寄存器之一。如果确实如此,则必须启用适当的输入多工器,以选择转发数据。

保持精确异常处理

用以下代码序列可以说明长时间运行指令所导致的另一个问题:

1
2
3
DIV.D     F0,F2, F4
ADD.D F10,F10,F8
SUB.D F12,F12,F14

这一代码序列看起来非常简单;其中没有相关性。但是,由于指令的完成顺序可能不同于其发射顺序,所以会出现一个问题。在这个示例中,我们可能预期ADD.D和SUB.D先于DIV.D之前完成。这称为乱序完成,在拥有长时操作的流水线中很常见。由于冒险检测会禁止违反指令之间的任何相关性,那乱序完成为什么会成为一个问题呢?假定在ADD.D已经完成而DIV.D还未完成时,SUB.D导致了浮点算术异常,最终会出现我们应当尽力避免的不精确异常。看起来,我们似乎可以像对整数流水线那样,通过清空浮点流水线来解决这一问题。但是,异常的发生位置可能无法让我们清空流水线。例如,如果DIV.D 决定在完成加法之后获取浮点算术异常,那可能无法获得硬件级别的精确异常。事实上,由于ADD.D 破坏了它的一个操作数,所以即使在软件的帮助下,也无法恢复到DIV.D之前的状态。这一问题的出现是因为指令的完成顺序与其发射顺序不同。共有四种可能方法来处理乱序完成情况。

第一种是忽略问题,容忍非精确异常。20世纪60年代和70年代早期采用这一方法。在某些超级计算机中仍在使用这种方法,在这种超级计算机中,某些特定类型的异常或者不允许出现,或者由硬件进行处理,不需要使流水线停止。在大多数现代处理器中很难使用这一方法,因为虚拟存储器等功能和IEEE 浮点标准都需要通过软件和硬件的组合来实现精确异常。前面曾经提到,最近的一些处理器已经通过引入两种执行模式解决了这一问题,一种模式速度很快,但可能是非精确的,另一种模式较慢,但却是精确的。在实现较慢的精确模式时,或者使用模式切换,或者显式插入一些指令,用于测试浮点异常。无论采用哪种实现方式,浮点流水线中所允许的重叠和重新排序数目都受到严重限制,以实现在同一时间只有一条浮点指令是活动的。

第二种方法是缓冲一个操作的结果,直到先前发射的所有操作都完成为止。一些CPU实际上使用了这一方案,但是,当操作的运行时间差别很大时,由于要缓冲的结果数变得非常庞大,所以这种方法的成本就会非常高昂。此外,必须绕过来自队列的结果,以便在等待较长指令的同时继续发射指令。这就需要大量比较器和一个非常大的乘法器。

这一基本方法有两种可行的变化形式。第一种形式是历史文件。历史文件跟踪寄存器的原始值。在发生异常而且必须将状态回滚到某一乱序完成的指令之前时,可以从历史文件中恢复寄存器的原始值。在诸如VAX之类的处理器上,采用一种类似技术来实现自动递增和自动递减寻址。另一种方法是未来文件,它跟踪寄存器的较新值;当所有先前指令均已完成时,则从未来文件中更新主寄存器堆。当发生异常时,主寄存器堆拥有中断状态的精确值。

所使用的第三种方法是允许异常变得不十分精确,但保存足够的信息,以便陷阱处理例程可以生成精确的异常序列。这意味着要知道流水线中有哪些操作以及其PC。因此,在处理异常之后,由软件完成那些最后完成指令之前的所有指令,然后该序列就能重新启动了。考虑以下最糟代码序列:

1
2
3
指令1——最终中断执行的长时运行指令。
指令2, ...,指令m-1 ——系列未完成的指令。
指令n——一条已完成指令。

给定流水线中所有指令的PC和异常返回PC之后,软件就可以得到指令1和指令n的状态。由于指令n已经完成,所以我们希望在指令n+1 重新开始执行。在处理异常之后,软件必须模拟指令1,…,指令m-1的执行。然后,我们可以从异常中返回,并在指令m重新启动。由处理器正确执行这些指令的复杂性才是这一方案的主要挑战。

对于简单的类MIPS流水线,有一个重要简化:如果指令2,…,指令n都是整数指令,那我们就知道当指令n完成时,指令2,…,指令n-1也都已完成。因此,只有浮点操作需要处理。为使这一情况易于处理,必须限制可以重叠执行的浮点指令数。例如,如果我们仅重叠两条指令,那只有中断指令需要由软件来完成。如果浮点流水线很深,或者如果存在大量浮点功能单元,那这一限制可能会限制吞吐量。SPARC 体系结构中使用了这一方法,以允许重叠浮点与整数操作。最后一种方法是一种混合方案,它仅在确保所发射指令之前的所有指令都已完成,而且没有导致异常时,才允许继续指令发射。这样就能确保在发生异常时,中断指令之后的指令都不会完成,而中断指令之前的全部指令都可以完成。这意味着有时要停顿CPU,以保持精确异常。为使这一方案有效,浮点功能单元必须在EX流水级的早期判断是否可能存在异常

MIPS浮点流水线的性能

图C-18中的MIPS浮点流水线既可以对除法单元生成结构性停顿,也可以对RAW冒险生成停顿(它还可能拥有WAW冒险,但在实际中很少发生)。图C-19以各实例为基础,列出了每种浮点操作的停顿周期数目(即,每个浮点基准测试的第一个长条表示每个浮点加、减或转换的浮点结果停顿数)。可以看到,每个操怍的停顿周期与浮点运算的延迟相关,介于功能单元延迟的46%~59%之间。

图C-19 对于SPEC89浮点基准测试,每种主要浮点运算类型的停顿。

图C-20给出了5种SPECfp基准测试整数与浮点停顿的完整分类。图中共给出4类停顿:浮点结果停顿、浮点比较停顿、载入与分支延迟、浮点结构延迟。编译器尝试在调度分支延迟之前调度载入与浮点延迟。每个指令的总停顿数介于0.65~1.21之间。

图C-20 针对5种SPEC89浮点基准测试,MIPS浮点流水线上发生的停顿。在所有情况下,浮点结果停顿都占绝大多数,每条指令平均为0.71次停顿,也就是停顿周期的82%。比较操作生成的停顿数为每条指令平均0.1次,为第二大停顿源。除法结构性冒险的影响仅在doduc测试中比较明显一些

融会贯通: MIPS R4000流水线

在本节,我们将研究MIPS R4000 处理器系列的流水线结构和性能,该系列包括4400。R4000实现MIPS64,但它为整数和浮点程序使用的流水线深度都超过我们使用的五级流水线设计。这一较深流水线可以将五级整数流水线分解为八级,以允许实现更高的时钟频率。由于缓存访问的时间要求很高,所以通过分解存储器访问可以获得更多的流水级。这种更深的流水线有时被称为超流水线。

图C-21显示了八级流水线结构,其中使用了数据路径的抽象版本。图C-22显示了流水线中连续指令的重叠。注意,尽管指令和数据存储器占用多个周期,但它们已经完全实现流水化,所以在每个时钟 周期都可以开始一条新指令。事实上,流水线会在完成缓存命中检测之前使用数据。每一流水级的过程如下所述。

  • IF——指令提取的前半部分,PC选择与指令缓存访问的初始化实际上发生在这里。
  • IS——指令提取的后半部分,完成指令缓存访问。
  • RF——指令译码与寄存器提取、冒险检查、指令缓存命中检测。
  • EX——执行,包括实际地址计算、ALU操作和分支目标计算与条件判断。
  • DF——数据提取,数据缓存访问的前半部分。
  • DS——数据提取的后半 部分,完成数据缓存访问。
  • TC——标记检查 ,判断数据缓存访问是否命中。
  • WB——载入和寄存器-寄存器操作的写回过程。


图C-21 R4000 的八级流水线结构使用流水化指令与数据缓存。图中对流水级进行了标记,它们的详细功能用文字描述。垂直虚线表示流水级界限以及流水线锁的位置。指令实际上在IS结束时可供使用,但标记检查是在RF完成的,与此同时提取寄存器值。因此,我们将指令存储器标记为在整个RF中运行。由于在知道缓存访问是否命中之前,不能将数据写入寄存器,所以数据存储器访问需要TC级

图C-22 R4000 整数流水线的结构导致了2周期载入延迟。由于数据值在DS结束时可用,而且可能被旁路,所以有可能产生2周期延迟。如果TC中的标记检查显示这是一次缺失,则流水线将回退一个周期,在此时刻有正确数据可供使用

除了显著增加所需要的转发数量之外,这种较长延迟的流水线既会增加载入延迟,又会增加分支延迟。由于数据值在DS的末尾才可用,所以图C-22将载入延迟显示为2个周期。表C-21显示了在载入指令之后立即使用的简略流水线调度。它显示在3个或4个周期之后使用载入指令的结果时,就需要进行转发。

图C-23显示基本分支延迟为3个周期,这是因为分支条件是在EX期间计算的。MIPS体系结构有一个延迟1周期的分支。R4000 为该分支延迟的其余2个周期使用预测未选中策略。如表C-22所示,未选中分支就是延迟1个周期的分支,而选中分支是在一个1周期延迟时隙之后跟有2个空闲周期。这一指令集提供了一种类似于分支的指令,前面已经对其进行了介绍,它可以帮助填充该延迟时隙。流水线互锁一方面要插入选中分支的2周期分支停顿代价,另一方面也造成因为使用载入结果而导致的数据冒险停顿。

  • 通常的转发路径可在2个周期之后使用,所以DADD和DSUB通过停顿之后的转发来获取其取值。OR指令从寄存器堆中获取该值。由于载入之后的两条指令可能是独立的,因此不会停顿,所以旁路可能指向载入之后3或4个周期的指令。


深度流水线除了增加载入与分支的停顿之外,还会增加ALU运算的转发级别数。在MIPS五级流水线中,两个寄存器-寄存器ALU指令之间的转发可能发生于ALUMEM或MEMWB寄存器。在R4000流水线中,ALU旁路可能有4种可能来源: EX/DF、DF/DS、DS/TC和TC/WB。表C-22如表中上半部分所示,选中分支有一个1周期延迟时隙,后面跟有一个2周期停顿,而如表中下半部分所示,未选中分支只有一个1周期延迟时隙

浮点流水线

R4000浮点单元由3个功能单元缓存:浮点除法器、浮点乘法器和浮点加法器。加法器逻辑在乘法或除法的最后一个步骤使用。双精度浮点运算可能占用2个周期(对于求相反数)到112个周期(对于求平方根)。此外,各种单元的起始速度不同。浮点功能单元可以看作拥有8个不同流水级,如表C-23中所列;以不同顺序组合这些流水级,即可执行各种浮点运算。

这些流水级的每个流水级都有单个副本,各种指令对一个流水级可以使用0次或多次,使用顺序也可以不同。表C-24给出了最常见双精度浮点运算所使用的延迟、初始速率和流水级。

  • 延迟值假定目标指令是一个浮点运算。当目标指令为存储指令时,延迟会少1个周期。流水级的显示顺序就是各个运算使用它们的顺序。标记S+A表示在这个时钟周期内同时使用S和A流水级。标记D28表示D流水级在一行中使用28次。

根据表C-24中的信息,我们可以判断一个由不同独立浮点运算组成的序列是否可以无停顿发射。如果因为该序列的时序而对于共享流水级发生冲突,则需要停顿。表C-25、表C-26、表C-27、表C-28给出了4种可能存在的常见两指令序列:乘法后面跟有加法、加法后面跟有乘法、除法后面跟有加法、加法后面跟有除法。这些表中显示了第二条指令所有感兴趣的起始位置,以及第二条指令在每个位置是发射还是停顿。当然,可能一共有三条指令是活动的,在这种情况下,发生停顿的可能性要高得多,列表也要更为复杂。

  • 第二列指出一个特定类型的指令在n个周期之后发射时是否会停顿,其中n为时钟周期编号,在此周期内发生第二指令的U级。导致停顿的流水级用黑体表示。注意,这个表仅给出了乘法指令与时钟周期1、7之间发射的一个加法指令之间的交互。在这种情况下,如果加法指令在乘法之后4或5个周期发射,则该加法指令会停顿;否则,它会无停顿发射。注意,如果加法指令在周期4发射,由于它在下一个时钟周期仍然会与乘法指令相冲突,所以加法指令会停顿2个周期;但是,如果加法指令在周期5发射,由于这样会消除冲突,所以它仍然仅停顿1个时钟周期。

  • 除法在周期0处开始,在周期35处完成;表中给出除法的最后10个周期。由于除法指令大量使用了加法指令所需要的舍入硬件,因此,只要加法指令是在周期28至周期33中的任一周期中启动,该除法指令都会使其停顿。注意,在周期28处启动的加法指令将一直停顿到周期36。如果加法指令在除法指令之后立即启动,由于加法指令可能在除法指令用到共享流水级之前完成,所以不会导致冲突,如同我们在表C-26中的乘加一样。和前面的表一样,这个示例假定在时钟周期26和35之间只有一个加法指令到达U级。

  • 如果除法指令晚于加法1个周期启动,则除法指令会停顿,但在此之后不存在冲突。

R4000流水线的性能

本节,我们将研究在R4000流水线结构上运行SPEC92基准测试时所发生的停顿。流水线停顿或损失的原因共有四大类。

  1. 载入停顿——在载入之后1或2个周期再使用载入结果时导致的延迟。
  2. 分支停顿——每个选中分支上发生的两周期停顿再加上未填充或已取消分支延迟时隙。
  3. 浮点结果停顿——因为浮点操作数的RAW冒险所导致的停顿。
  4. 浮点结构停顿——因为浮点流水线中功能单元的冲突产生发射限制,进而导致的延迟。

图C-24给出了对于10个SPEC92基准测试,R4000 流水线的流水线CPI分类。表C-29给出了相同的数据。


图C-24 10个SPEC92基准测试的流水线CPI,假定采用完美缓存。流水线CPI的变化范围为1.2~2.8。最左边的5个程序为整数程序,分支延迟是CPI的主要组成因素。最右边5个程序为浮点程序,浮点结果停顿是其主要因素。表C-29给出了绘制这一图形的数值

  • 主要因素为浮点结果停顿(对于分支和浮点输入均是如此)和分支停顿,载入停顿和浮点结构性停顿的影响很小。

根据图C-24和表C-29中的数据,可以看出深度流水线的代价。与经典五级流水线相比,R4000流水线的分支延迟要长得多。较长的分支延迟会显著增加在分支上花费的周期数,特别是对于分支频率较高的整数程序。浮点程序一个值得注意的影响是: 与结构性冒险相比,浮点功能单元的延迟会导致更多的停顿,主要源于初始间隔限制和不同浮点指令对功能单元的冲突。因此,降低浮点运算的延迟应当是第一目标, 而不是实现功能单元的更多流水线或重复。当然,降低延迟可能会增加结构性停顿,这是因为许多潜在的结构性停顿被隐藏在数据冒险之后。

交叉问题

RISC指令 集及流水线效率

我们已经讨论了指令集简化对于构建流水线的好处。简单指令集还有另外一个好处:这样可以更轻松地调度代码,以提高流水线的执行效率。为了解这一点,考虑一个简单示例:假定我们需要对存储器中的两个值相加,并将结果存回存储器。在一些高级指令集中,这一任务只需要一条指令;而在其他一些指令集中则需要两条或三条指令。一个典型的RISC 体系结构需要四条指令(两条载入指令、一条加法指令和一条存储指令)。在大多数流水线中,不可能在没有插入停顿的情况下顺序调度这些指令。对于RISC指令集,各个操作都是单独的指令,可以使用编译器进行各别调度或动态硬件调度技术进行各别调度。这些效率优势如此明显,再加上其实现非常容易,所以复杂指令集的几乎所有近期流水线实现实际上都将其复杂指令转换为类似于RISC的简单操作,然后再对这些操作进行调度和流水化。

动态调度流水线

简单流水线提取一条指令并发射它,除非流水线中的已有指令和被提取的指令之间存在数据相关性,而且不能通过旁路或转发来隐藏。转发逻辑降低了实际流水线延迟,使特定的相关性不会导致冒险。如果存在不可避免的冒险,则冒险检测硬件会使流水线停顿(从使用该结构的冒险开始)。在清除这种相关性之前,不会提取或发射新指令。为了弥补这些性能损失,编译器可以尝试调度指令来避免冒险;这种方法称为编译器调度或静态调度。几种早期处理器使用了另外一种名为动态调度的方法,硬件借此方法重新安排指令的执行过程,以减少停顿。

到目前为止,本附录讨论的所有技术都使用循序指令发射,这意味着如果一条指令在流水线中停顿,将不能处理后续指令。在采用循序发射时,如果两条指令之间存在冒险,即使后面存在一些不相关的、不会停顿的指令,流水线也会停顿。在前面开发的MIPS流水线中,结构性冒险和数据冒险都是在指令译码(ID)期间进行检查的:当一条指令可以正确执行时,该指令是从ID发射出去的。为使一条指令在其操作数可用时立即开始执行,不受其先前停顿指令的影响,我们必须将发射过程分为两部分:检查结构性冒险,等待数据冒险的消失。循序对指令进行译码和发射;但是,我们希望指令在其数据操作数可用时立即开始执行。因此,流水线是乱序执行的,也就暗示是乱序完成的。为了实现乱序执行,我们必须将ID流水级分为两级。

  1. 发射——指令译码,检查结构性冒险。
  2. 读取操作数——等到没有数据冒险,随后读取操作数。

IF级进入发射级,EX级跟在读取操作数级之后,这一点与MIPS流水线中一样。同MIPS浮点流水线一样,执行可能占用多个周期,具体取决于所执行的操作。因此,我们可能需要区分一条指令何时开始执行,何时完成执行;在这两个时刻之间,指令处于执行过程中。这样就允许多条指令同时处于执行过程中。除了对流水线结构的修改之外,我们还将改变功能单元设计:改变单元数、操作延迟和功能单元流水化,以更好地探索这些更高级的流水线技术。

采用记分卡的动态调度

在动态调度流水线中,所有指令都循序通过发射级(循序发射);但是,它们可能在第二级(读取操作数级)停顿,或绕过其他指令,然后进行乱序执行状态。记分卡技术在有足够资源、没有数据依赖性时,允许指令乱序执行;这一功能是在CDC 6600记分卡中开发的,并因此而得名。

在我们了解如何在MIPS流水线中使用记分卡之前,非常重要的一点是要观察到当指令乱序执行时可能会出现WAR冒险,这种冒险在MPS浮点或整数流水线中是不存在的。例如,考虑以下代码序列:

1
2
3
DIV.D         F0,F2,F4
ADD.D F10,F0,F8
SUB.D F8,F8,F14

ADD. D和SUB.D之间存在一种反相关性:如果流水线在ADD.D之前执行SUB.D,它将违犯反相关性,产生错误的执行结果。与此类似,为避免违反输出相关性,也必须检查WAW冒险(例如,当SUB.D的目标寄存器为F10时将会发生此种冒险)。后面将会看到,记分卡通过停顿反相关中涉及的后续指令,避免了这两种冒险。记分卡的目标是:通过尽早执行指令,保持每时钟周期1条指令的执行速率(在没有结构性冒险时)。因此,当下一条要执行的指令停顿时,如果其他指令不依赖于任何活动指令或停顿指令,则发射和执行这些指令。记分卡全面负责指令发射与执行,包括所有冒险检测任务。要充分利用乱序执行,需要在其EX级中同时有多条指令。这一点可以通过多个功能单元、流水化功能单元或同时利用两者来实现。由于这两种功能(流水化功能单元和多个功能单元)对于流水线控制来说基本上是等价的,所以我们将假定处理器拥有多个功能单元。CDC 6600拥有16个独立的功能单元,包括4个浮点单元、5个存储器引用单元和7个整数运算单元。在采用MIPS体系结构的处理器上,记分卡主要在浮点单元上发挥作用,因为其他功能单元的延迟非常小。让我们假定一共有两个乘法器、一个加法器、一个除法单元和一个完成所有存储器引用、分支和整数运算的整数单元。图C-25给出了该处理器的基本结构。


图C-25 带有记分卡的MIPS处理器的基本结构。记分卡的功能是控制指令执行(垂直控制线)。所有数据在寄存器堆和总线上的功能单元之间流动(水平线,在CDC6600中称为干线)。共有两个浮点乘法器、一个浮点除法器、一个浮点加法器和一个整数单元。一组总线(两个输入和一个输出)充当一组功能单元。记分卡的细节在表C-30至表C-33中给出

每条指令都进入记分卡,在这里构建一条数据相关性记录;这一步与指令发射相对应,并替换MIPS流水线中的ID步骤。记分卡随后判断指令什么时候能够读取它的操作数并开始执行。如果记分卡判断该指令不能立即执行,它监控硬件中的所有变化,以判断该指令何时能够执行。记分卡还控制一条指令什么时候能将其结果写到目标寄存器中。因此,所有冒险检测与解决都集中在记分卡。我们后面将会看到记分卡的一张表格(表C-30),但首先需要理解流水线发射与执行部分的步骤。

每条指令需要经历4个执行步骤。(由于我们现在主要考虑浮点运算,所以不考虑存储器访问步骤。)我们先粗略地查看一下这些步骤,然后再详细研究记分卡如何记录一些必要信息,用于判断执行过程何时由一个步骤进行到下一个步骤。这四个步骤代替了标准MIPS流水线中的ID、EX和WB步骤,如下所示。
(1)发射——如果指令的一个功能单元空闲,没有其他活动指令以同一寄存器为目标寄存器,则记分卡向功能单元发射指令,并更新其内部数据结构。这一步代替了MIPS流水线中ID步骤的一部分。只要确保没有其他活动功能单元希望将自己的结果写入目标寄存器,就能保证不会出现WAW冒险。如果存在结构性冒险或WAW冒险,则指令发射停顿,在清除这些冒险之前,不会再发射其他指令。当发射级停顿时,会导致指令提取与发射之间的缓冲区填满;如果缓冲区只是一项,则指令提取立即停顿。如果缓冲区是拥有多条指令的队列,则在队列填满后停顿。

(2)读取操作数——记分卡监视源操作数的可用性。如果先前发射的活动指令都不再写入源操作数,而该源操作数可用。当源操作数可用时,记分卡告诉功能单元继续从寄存器读取操作数,并开始执行。记分卡在这一步动态解决 RAW冒险,可以发送指令以进行乱序执行。这一步和发射步骤一起,完成了简单MIPS流水线中ID步骤的功能。

(3)执行——功能单元接收到操作数后开始执行。结果准备就绪后,它通知记分卡已经完成执行。这一步代替了MIPS流水线中的EX步骤,在MIPS浮点流水线中耗用多个周期。

(4)写结果——一旦记分卡知道功能单元已经完成执行,则检查WAR冒险,并在必要时停顿正在完成的指令。

如果有一个与我们先前示例相类似的代码序列,其中ADD.D和SUB.D都使用F8,则存在WAR冒险。在这个示例中,有如下代码:

1
2
3
DIV.D     F0,F2,F4
ADD.D F10,F0,F8
SUB.D F8,F8,F14

ADD.D有一个源操作数为F8,就是SUB.D的目标寄存器。但ADD.D实际上取决于前面的一条指令。记分卡仍将SUB.D停顿于它的写结果阶段,直到ADD.D读取它的操作数为止。一般来说,在以下情况下,不能允许一条正在执行的指令写入其结果:

  • 在正在执行的指令前面(即按发射顺序)有一条指令还没有读取其操作数;
  • 这些操作数之一与正执行指令的结果是同一寄存器。

如果不存在这一WAR冒险,或者已经清除,则记分卡会告诉功能单元将其结果存储到目标寄存器中。这一步骤代替了简单MIPS流水线中的WB步骤。

乍看起来,记分卡在区分RAW和WAR冒险时似乎会有困难。

因为只有当寄存器堆中拥有一条指令的两个操作数时,才会读取这些操作数,所以记分卡未能利用转发。只有当寄存器都可用时才会进行读取。这一代价并没有读者最初想象得那么严重。这里与我们前面的简单流水线不同,指令会在完成执行之后立即将结果写入寄存器堆(假定没有WAR冒险),而不是等待可能间隔几个周期的静态指定写入时隙。由于结果的写入和操作数的读取不能重叠,所以仍然会增加一个周期的延迟。我们需要增加缓冲,以消除这一开销。记分卡根据自已的数据结构,通过与功能单元的沟通来控制指令从一个步骤到下一个步骤的进展。但这种做法有一点点复杂。指向寄存器堆的源操作数总线和结果总线数目是有限的,所以可能会存在结构性冒险。记分卡必须确保允许进入第(2)、(4)步的功能单元数不会超过可用总线数。这里不会进行深入讨论,仅提及CDC 6600在解决这一问题时,将16个功能单元分为四组,并为每一组提供一组总线,称为数据干线。在一个时钟周期内,一个组中只有一个单元可以读取其操作数或写入其结果。

现在让我们看一个拥有五个功能单元的MIPS记分卡所保持的详尽数据结构。表C-30显示了在如下这一简单指令序列执行时,记分卡中的信息。

1
2
3
4
5
6
L.D      F6 ,34(R2)
L.D F2 ,45(R3)
MUL.D F0,F2,F4
SUB.D F8,F6,F2
DIV.D F10,F0,F6
ADD.D F6, F8,F2

  • 每个功能单元在功能单元状态表中有一个对应项。一旦发射一条指令后,就在功能单元状态表中保留其操作数记录。最后,寄存器状态表指示哪个单元将生成每个未给出的结果;项数与寄存器数相等。指令状态表表明: (1)第一个L.D已经完成并写入其结果,(2) 第二个L.D已经完成执行,但还没有写入其结果。MUL.D、SUB.D 和DIV.D都已经发射,但正在停顿,等待其操作数。功能单元状态表明第一个乘法单元正在等待整数单元,加法单元正在等待整数单元,除法单元正在等待第一个乘法单元。ADD.D 指令因为结构性冒险而停顿,当SUB.D完成时将会清除这一冒险。如果这些记分卡中某个中的项目没有用到,则保持为空。例如,Rk字段在载入时没有用到,Mult2单元没有用到,因此它们的字段没有意义。另外,一旦读取一个操作数之后,Rj和Rk字段将被设置为“否”。表C-33表明了最后一步为什么至关重要。

记分卡共有三个部分,如下所述。

  • 指令状态——指出该指令处于四个步骤中的哪一步。
  • 功能单元状态——指出功能单元(FU)的状态。共有9个字段用来表示每个功能单元的状态。
    • 忙——指示该单元是否繁忙。
    • Op一在此单元中执行的运算 (例如,加或减)。
    • Fi——目标寄存器。
    • Fj, Fk——源寄存器编号 。
    • Qj, Qk——生成源寄存器Fj、 Fk的功能单元。
    • Rj, Rk——指示Fj、 Fk已准备就绪但尚未读取的标记。在读取操作数后将其设置为“否”。
  • 寄存器结果状态——如果一条活动指令以该寄存器为目标寄存器,则指出哪个功能单元将写入每个寄存器。只要没有向该寄存器写入的未完成指令,则将此字段设置为空。

现在让我们看一下在表C-30中开始的代码序列如何继续执行。之后,我们就能更详细地研究记分卡用于控制执行的条件了。

假定浮点功能单元的EX周期延迟如下(选择这些延迟是为了说明行为特性,并非代表性数值):加法为2个时钟周期,乘法为10个时钟周期,除法为40个时钟周期。利用表C-30前面的代码段,并从表C-30中指令状态指示的时刻开始,说明当MUL.D和DIV.D分别准备好写入结果状态时,状态表中是什么样的。

从第二个L.D到MUL.D、ADD.D和SUB.D,从MUL.D到DIV.D和从SUB.D到ADD.D,存在RAW数据冒险。在DIV.D和ADD.D以及SUB.D之间存在WAR数据冒险。最后,加法功能单元对于ADD.D和SUB.D中存在结构性冒险。当MUL.D和DIVD准备好写入其结果时,这些表分别如表C-31和表C-32所示。


现在,我们可以研究一下为使每条指令能够继续,记分卡必须做些什么,以此来详细了解记分卡是如何工作的。表C-33说明,为使每条指令能够继续执行,记分卡需要些什么,并记录在指令继续执行时需要哪些必要的操作。记分卡记录操作数标志符信息,比如寄存器编号。例如,在发射指令时,必须记录源寄存器。因为我们将寄存器的内容称为Regs[D],其中D为寄存器名称,所以不存在模糊性。例如,Fj[FU]←S1会导致寄存器名称S1被放在Fj[FU]中,而不是寄存器S1的内容。


记分卡的成本和收益也是人们的关注点。CDC 6600设计师测量到FORTRAN程序的性能改进因数为1.7,对于人工编码的汇编语言改进因数为2.5。但是,这些数据是在软件流水线调度、半导体主存储器和缓存(缩短了存储器访问时间)之前的那一时期测得的。CDC 6600上记分卡所拥有的逻辑数与一个功能单元相当,这是相当低的。主要成本在于存在大量总线——其数量大约是CPU循序执行(或者每个执行周期仅启动一条执行)时所需数量的四倍。人们近来对动态调度的关注有所增加,其目的就是希望在每个时钟周期内发射更多条指令(反正都要支付增加总线带来的成本),一些很自然地以动态调度为基础的思想也是提升此关注度的推动因素。记分卡利用可用ILP,在最大程度上降低因为程序真数据相关所导致的停顿数目。在消除停顿方面,记分卡受以下几个因素的影响。

(1)指令间可用并行数——这一因素决定了能否找到要执行的独立指令。如果每条指令都依赖于它前面的指令,那就找不到减少停顿的动态调度方案。如果必须从同一基本模块中选择同时存在于流水线中的指令(在6600中就是如此),那这一限制是 十分严重的。

(2)记分卡的项数——这一因素决定 了流水线为了查找不相关指令可以向前查找多少条指令。这组作为潜在执行对象的指令被称为窗口。记分卡的大小决定了窗口的大小。在这一节,我们假定窗口不会超过一个分支,所以窗口(及记分卡)总是包含来自单个基本模块的直行代码。第3章说明如何将窗口扩展到超出一个分支之外。

(3)功能单元的数目和类型——这一因素决定 了结构性冒险的重要性,它可能会在使用动态调度时增加。

(4)存在反相关和输出相关——它们会 导致WAR和WAW停顿。

谬论与易犯错误

乍看起来,WAW冒险似乎永远不可能在一个代码序列中出现,因为没有哪个编译器会生成对同一寄存器的两次写入操作,却在中间没有读取操作,但当序列出乎意料之外时,却可能发生WAW冒险。例如,第一次写入操作可能在-个选中分支的延迟时隙中,而调度器认为该分支未被选中。下面是可能导致这一情景的代码序列:

1
2
3
4
        BNEZ R1 , foo
DIV.D F0,F2,F4 ;从未被选中移入延迟时隙
...
foo: L.D F0,qrs

如果该分支被选中,则在DIV.D可以完成之前,L.D将到达WB,导致WAW冒险。硬件必须检测这一冒险,并暂停发射L.D。另外一种可能发生这种情景的方式是第二次读取操作存在于陷阱例程中。一条要写入结果的指令导致陷阱中断,当陷阱处理器中的一条指令完成对同一寄存器的写入之后,原指令继续完成,这时就会发生上述情景。硬件也必须检测并阻止这一情景。