System V IPC相关系统接口:
System V IPC未遵循“一切都是文件”的Unix哲学,而是采用标识符ID和键值来标识一个System V IPC对象。每种System V IPC都有一个相关的get调用,该函数返回一个整形标识符ID,System V IPC后续的函数操作都要作用在该标识符ID上。
System V IPC对象的作用范围是整个操作系统,内核没有维护引用计数。调用各种get函数返回的ID是操作系统范围内的标识符,对于任何进程,无论是否存在亲缘关系,只要有相应的权限,都可以通过操作System V IPC对象来达到通信的目的。
System V IPC对象具有内核持久性。哪怕创建System V IPC对象的进程已经退出,哪怕有一段时间没有任何进程打开该IPC对象,只要不执行删除操作或系统重启,后面启动的进程依然可以使用之前创建的System V IPC对象来通信
此外,我们无法像操作文件一样来操作System V IPC对象。System V IPC对象在文件系统中没有实体文件与之关联。我们不能用文件相关的操作函数来访问它或修改它的属性。所以不得不提供专门的系统调用(如msgctl、semop等)来操作这些对象。在shell中无法用ls查看存在的IPC对象,无法用rm将其删除,也无法用chmod来修改它们的访问权限。幸好Linux提供了ipcs、ipcrm和ipcmk等命令来操作这些对象。
还有System V IPC对象不是文件描述符,所以无法使用基于文件描述符的多路I/O技术。
1. 标识符与IPC Key
System V IPC对象是靠标识符ID来识别和操作的。该标识符要具有系统唯一性。这和文件描述符不同,文件描述符是进程内有效的。一个进程内的文件描述符4和另一个进程的文件描述符4可能毫不相关。但是IPC的标识符ID是操作系统的全局变量,只要知道该值具有相应的权限,任何进程都可以通过标识符进行进程间通信。
三种IPC对象操作的起点都是调用相应的get函数来获取标识符ID,如消息队列的get函数为:
int msgget(key_t key, int msgflg);
其中第一个参数是key_t类型,它其实是一个整形的变量。IPC的get函数将key转换成相应的IPC标识符。根据IPC get函数中的第二个参数oflag的不同,会有不同的控制逻辑。
因为key可以产生IPC标识符,就是同一个key调用IPC的get函数总是返回同一个整形值。实际上并非如此。在IPC对象的生命周期中,key到标识符ID的映射是稳定不变的,即同一个key调用get函数,总是返回相同的标识符ID。但是一旦key对应的IPC对象被删除或系统重启后,则重新使用key创建的新的IPC对象分配的标识符很可能是不同的。
不同进程可通过同一个key获取标识符ID,进而操作同一个System V IPC对象。那么现在问题就演变成了如何选择key。
对于key的选择,存在以下三种方法
- 第一种方法是随机选择一个整数值作为key值。作为key值的整数通常被放在一个头文件中,所有使用该IPC对象的程序都要包含该头文件。需要注意的是,要防止无意中选择了重复的key值,从而导致不需要通信的进程之间意外通信,以至发生程序混乱。一个技巧是将项目用到的所有key放入同一个同文件中,这样就可以方便的检查是否有重复的key值。
- 第二种方法是使用IPC_PRIVATE,使用方法如下:
id = msgget(IPC_PRIVATE,S_IRUSR | S_IWUSR);
这种方法无须指定IPC_CREATE和IPC_EXCL标志位,就能创建一个新的IPC对象。使用IPC_PRIVATE 时总是会创建新的IPC对象,从这个角度看将其称之为IPC_NEW或许更合理。
不过,使用IPC_PRIVATE来得到IPC标识符会存在一个问题,即不相干的进程无法通过key值来得到同一个IPC标识符。因为IPC_PRIVATE总是创建一个新的IPC对象。因此IPC_PRIVATE一般用于父子进程,父进程调用fork之前创建IPC对象,创建子进程后,子进程也就继承了IPC标识符,从而父子进程可以通信。当然无亲缘关系的进程也可以使用IPC_PRIVATE,只是稍微麻烦了一点,IPC对象的创建者必须想办法将IPC标识符共享出来,让其他进程有办法获取到,从而通过IPC标识符进行通信。
- 第三种方法是使用ftok函数,根据文件名生成一个key。ftok是file to key的意思,多个进程通过同一个路径名获得相同的key值,进而得到同一个IPC标识符。
ftok函数接口定义如下:
#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
在Linux实现中,该接口把通过path-name获取的信息和传入的第二个参数的低8位糅合在一起,得到一个整形的IPC key值 ,如图所示。需要注意的是,pathname对应的文件必须是存在的。
这个函数在Linux上的实现是:按照给定的路径名,获取到文件的stat信息,从stat信息中取出st_dev和st_ino,然后结合给出的proj_id,按照图所示的算法获取到32位的key值。
2. IPC的公共数据结构
三种System V IPC对象有很多共性,从代码层面上看也有很多公共的部分。权限结构就是其中一个。IPC的权限结构至少包括以下成员:
struct ipc_perm{
key_t key;
uid_t uid;
gid_t gid;
uid_t cuid;
gid_t cgid;
mode_t mode;
ulong_t seq ;
};
/消息队列控制相关的结构体/
struct msqid_ds {
struct ipc_perm msg_perm;
...
}
/*信号量控制相关的结构体*/
struct semid_ds {
struct ipc_perm sem_perm;
...
}
/*共享内存控制相关的结构体*/
struct shmid_ds {
struct ipc_perm shm_perm;
...
}
uid和gid字段用于指定IPC对象的所有权。cuid和cgid字段保存着创建该IPC对象的进程的有效用户ID和有效组ID。初始情况下,用户ID(uid)和创建者ID(cuid)的值是相同的。它们都是调用进程的有效ID。但是创建者ID(cuid)是不可改变的,而所有者ID则可以通过IPC_SET来改写
下面的代码演示了如何修改共享内存的uid字段
struct shmid_ds shm_ds;
if(shmctl(id,IPC_STAT,&shm_ds)) == -1
{
/*error handler*/
}
shm_ds.shm_perm.uid = newuid;
if(shmctl(id,IPC_SET,&shm_ds) == -1)
{
/*error handle*/
}
mode是用来控制读写权限。所有的System V IPC对象都不具备执行权限,只有读写权限。其中对于信号量而言,写权限意味着修改权限。IPC对象的权限控制,分别具有读写权限。
和文件的权限有点类似,IPC对象的权限被分成了三类:owner、group和other。创建对象时可以为各个类别设定不同的访问权限,代码如下:
msg_id = msgget(key,IPC_CREAT | S_IRUSR | S_IWUSR |S_IRGRP);
msg_id = msgget(key,IPC_CREAT | 0640);
当以一个进程尝试对IPC对象执行某种操作的时候,首先会检查权限。检查逻辑如下:
- 如果进程是特权进程,那么进程拥有对IPC对象的所有权限。
- 如果进程的有效用户ID与IPC对象的所有者或创建者ID匹配,那么会将对象的owner的权限赋值给进程。
- 如果进程的有效用户ID或任意一个辅助组ID与IPC对象的所有者组ID或创建者组ID匹配,那么会将IPC对象的group的权限赋予进程。
- 否则,将IPC对象的other权限赋予进程。
数据结构ipc_perm中的key和seq也很有意思。key比较简单,就是调用get函数创建IPC对象时传递进去的key值。如果key的值是IPC_PRIVATE,则实际的key值是0。
和key相比,成员变量seq就不那么好理解了。进程分配文件描述符时采用的是最小可用算法。比如文件描述符5曾经被分配给文件A,但是很快进程关闭了文件A。如果进程尝试打开另外一个文件,此时如果5是最小可用的槽位,那么新打开的文件描述符就是5.但是IPC对象的标识符ID分配不能采用这个算法。因为对个进程要通过标识符ID来通信,而标识符ID是整个系统内有效的。如果采用最小可用的算法,一般来讲,IPC对象的个数不会太多,那么这个数字很容易就被猜到了。举例来说,如果存在一个恶意程序要攻击消息队列,它只需要尝试很小范围内的数字,就可以猜到IPC对象的标识符ID,进而偷偷取走信息队列里面的信息。
内核为每一种System V IPC维护了一个ipc_ids类型的结构体。该结构体的组成如图所示:
结构体中seq字段记录了开机依赖创建该IPC对象的流水号。创建时seq的值自加,但是销毁的时候seq的值并不会自减。seq的值随着该种IPC对象的创建而单调递增,直到递增到上限(max_seq)在溢出回绕,重新从0开始。
当需要创建新的IPC对象时,三种IPC对象都会走到ipc_addid处。
ipc_addid函数会初始化IPC对象的很多成员变量,比如权限相关的uid、gid、cuid,也会维护该IPC对象的seq值。
前面提到,内核分配IPC对象标识符的时候,使用的并不是最小可用算法,如下:
#define IPCMNI 32768
#define SEQ_MULTIPLIER (IPCMIN)
static inline int ipc_buildid(int id, int seq)
{
return SEQ_MULTIPLIER * seq + id;
}
上面公式中的id就是最小可用的槽位,而seq是开机以来内核创建IPC对象的流水号。因此,返回的ID是一个比较大的值。仍然以消息队列为例,如果开机后,消息队列为空,创建的第一个消息队列的标识符必然为0。第二个是32769,第三个是65538。
根据上面的讨论可知,IPC对象的标识符ID虽然是通过get函数来获得的,但是和key值并不存在永久的关系,即不存在公式可以通过key值来计算出标识符ID。内核仅仅是关联了两者。重启系统之后,后者删除IPC对象之后,根据相同的key值再次创建,得到的标识符ID很可能并不相同。
内核面临着如何根据IPC对象的标识符ID,快速地找到内核中的IPC对象的难题,根据前面的计算公式,可得:
slot_index = 标识符ID % SEQ_MULTIPLIER
这个公式透露出了一个问题:整个系统内,每一种IPC对象的槽位有限,最多有IPCMNI个槽位。在ipc_addid函数中也证实了这一点,系统内的硬上限为IPCMNI,即32768。这个限制就决定了不能无限制地创建IPC对象。