进程间传递打开的文件描述符,并不是传递文件描述符的值。那么在传递时究竟传递了什么?我们要先搞明白这个问题。
1、文件描述符
文件描述符的值与文件没有任何联系,只是该文件在进程中的一个标识,所以同一文件在不同进程中的文件描述符可能不一样,相同值 的文件描述符在不同进程中可能标识不同文件。
文件数据结构
Linux使用三种数据结构表示打开的文件:
— 文件描述符表 :进程级的列表,内含若干项,每一项都存储了当前进程中一个文件描述符及其对应的文件表指针。
— 文件表 :内核级的列表,内含若干文件表项,每一个文件表项对应文件描述符表中的一项,即,不同进程打开的同一文件对应内核中的不同文件表项,这样能够使每个进程都有它自己的对该文件的当前偏移量。
— v节点 :内核级的列表,每个打开的文件都只有一个v节点,包含文件类型,对文件进行操作的函数指针,有的还包括 i 节点。
三者的对应关系如下图所示:
不难发现,在进程之间传递 “文件描述符” 并不只是传递一个int
型的标识符,而是需要一套特殊的传递机制。本篇博客介绍的方法基于sendmsg
和 recvmsg
这两个函数。
首先在两个进程之间建立 UNIX域 socket 作为消息传递的通道,然后发送进程调用 sendmsg
向通道发送特殊的消息,内核对该消息做特殊的处理,从而将打开的文件描述符传递到接收进程。且接收方和发送方的文件描述符指向内核中相同的文件表项。所以,进程间传递文件描述符也算是实现了进程间共享文件,如下图所示:
2、使用UNIX域 socket 实现传递文件描述符
创建UNIX域很简单,用socketpair
函数即可,难的是如何使用sendmsg
和 recvmsg
函数进行发送和接收。两个函数的定义如下:
#include<sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr* msg, int flags);
参数sockfd
的含义不必多说,msg
可理解为被发送/接收的数据,在recvmsg
中他的行为类似于传出参数,flags
参数与recv/send
的同名参数含义一样。成功时返回实际发送/接收的字节数,失败返回-1。
该函数最难理解的就是msg
的类型 msghdr
,文件描述符就是通过msghdr
结构体的msg_control
成员发送的,而msg_control
又是 cmsghdr
类型,下面将重点介绍他们。
msghdr
结构体
结构体定义如下:
struct msghdr
{
void* msg_name;
socklen_t msg_namelen;
struct iovec* msg_iov;
int msg_iovlen;
void* msg_control;
socklen_t msg_conntrollen;
int msg_flags; 最后这个参数不管
}
结构体成员两两一组分为3组,这样分析清晰很多:
① socket 地址成员:msg_name
和 mag_namelen
msg_name
是指向socket
地址的指针,msg_namelen
是socket
地址的长度,他们只有在 通道使用UDP协议时才被使用。对于其他通道直接将两个参数设置为 NULL
和 0
即可。
对于recvmsg
函数,他们是传出参数,会返回发送方的 socket 地址。
msg_name
定义为void *
类型,因此并不需要将其显式转换为 struct sockaddr *
。
② 待发送的分散数据块:msg_iov
和 msg_iovlen
msg_iov
是一个结构体数组,每个结构体都封装一块内存的起始地址和长度,其定义如下:
struct iovec
{
void* iov_base; 内存起始地址
size_t iov_len; 这块内存的长度
};
而msg_iovlen
指定了内存块的个数,即,结构体数组的长度。
对于recvmsg
而言,数据将被读取并存放在msg_iovlen
块分散的内存中,这些内存的位置和长度由msg_iov
指向的iovec
数组指定,这称为分散读。
对于sendmsg
而言,msg_iovlen
块分散内存中的数据将被一并发送,这称为集中写。
③ 辅助(或附属)数据:msg_control
和 msg_controllen
msg_control
指向辅助数据起始地址,msg_controllen
指明辅助数据的长度。msg_control
的类型是struct cmsghdr*
,其定义如下:
struct cmsghdr
{
size_t cmsg_len; 辅助数据的总长度,由 CMSG_LEN 宏(马上讲)直接获取
int cmsg_level; 表示通道使用的的原始协议级别,与 setsockopt 函数的 level 参数相同
int cmsg_type; /* Protocol-specific type */控制信息类型,例如,SCM_RIGHTS,辅助数据是文件描述符;SCM_CREDENTIALS,辅助数据是一个包含证书信息的结构
/* followed by unsigned char cmsg_data[]; */被注释的 cmsg_data 指明了物理内存中真正辅助数据的位置,帮助理解
};
辅助信息分三部分,分别是 cmsghdr
结构体(又称头部) 、填充(对齐)字节 和 数据部分(数据部分后面可能还有填充字节,这是为了对齐),在内存中也是按此顺序排布。虽说这部分共称辅助数据,但其实真正的辅助数据只有后面的数据部分。
注意,辅助数据不止一段。每段辅助数据都由cmsghdr
结构体开始,每个cmsghdr
结构体只记录自己这一段辅助数据的大小。所以最终整个辅助数据大小还需要进行求和(求和方法马上讲)。
在实际使用时,需要我们填充的是cmsghdr
结构体 和 数据部分。Linux为我们提供了如下宏来填充他们:
#include <sys/socket.h>
#include <sys/param.h>
#include <sys/socket.h>
size_t CMSG_LEN(size_t length);
void* CMSG_DATA(struct cmsghdr *cmsg);
struct cmsghdr *CMSG_FIRSTHDR(struct msghdr *msgh);
struct cmsghdr *CMSG_NXTHDR(struct msghdr *msgh, struct cmsghdr *cmsg);
size_t CMSG_SPACE(size_t length);
size_t CMSG_ALIGN(size_t length);
CMSG_LEN() 宏:
传入参数:只需传入数据(第三)部分对象的大小。
返回值:系统自动计算整个辅助数据的大小(不包结尾的填充字节)并返回。
用途:直接把该宏赋值给msghdr
的cmsg_len
成员。
CMSG_DATA() 宏:
传入参数:指向cmsghdr
结构体的指针。
返回值:返回跟随在头部以及填充字节之后的辅助数据的第一个字节(如果存在,对于recv
)的地址。
用途:设置我们要传递的数据。例如要传递文件描述符时,代码如下
struct cmsgptr* cmptr;
int fd = *(int*)CMSG_DATA(cmptr); // 发送:*(int *)CMSG_DATA(cmptr) = fd;
CMSG_FIRSTHDR() 宏:
输入参数:指向msghdr
结构体的指针。
返回值:指向整个辅助数据中的第一段辅助数据的 struct cmsghdr
指针。如果不存在辅助数据则返回NULL
CMSG_NXTHDR() 宏:
输入参数:指向msghdr
结构体的指针,和指向当前cmsghdr
结构体的指针。
返回值:返回下一段辅助数据的 struct cmsghdr
指针,若没有下一段则返回NULL
。
用途:遍历所有段的辅助数据,代码如下:
struct msghdr msgh;
struct cmsghdr *cmsg;
for (cmsg = CMSG_FIRSTHDR(&msgh); cmsg != NULL; cmsg = CMSG_NXTHDR(&msgh,cmsg)
{
得到了当前段的 cmmsg,就能通过CMSG_DATA()宏取得辅助数据了
}
CMSG_SPACE() 宏:
输入参数:只需传入数据(第三)部分对象的大小。
返回值:计算每一段辅助数据的大小,包括结尾(两段辅助数据之间)的填充字节,注意,CMSG_LEN()
并不包括结尾的填充字节。
用途:计算整个辅助数据所需的总大小。如果有多段辅助数据,要使用多个CMSG_SPACE()
宏计算所有段辅助数据所需的总内存空间。
CMSG_LEN() 和 CMSG_SPACE()的区别:
printf("CMSG_LEN(sizeof(short))=%d/n", CMSG_LEN(sizeof(short))); 返回14
printf("CMSG_SPACE(sizeof(short))=%d/n", CMSG_SPACE(sizeof(short))); 返回16,说明这段辅助数据最后还有2字节的填充字节
CMSG_ALIGN()宏:
用的不多,先不管他。
综上,文件描述符是通过msghdr
的 辅助数据的 数据部分发送的。了解了msghdr
和 cmsghdr
两个结构体我们就可以使用sendmsg
和 recvmsg
函数发送文件描述符了。
3、实例程序
/* 本程序实现子进程打开一个文件描述符,然后将其传递给父进程,父进程通过其获得文件内容 */
#include<sys/socket.h>
#include<fcntl.h>
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
static const int CONTROL_LEN = CMSG_LEN(sizeof(int));
/**
* @brief 发送目标文件描述符
* @param fd 传递信息的 UNIX 域 文件描述符
* @param fd_to_send 待发送的文件描述符
*/
void send_fd(int fd, int fd_to_send)
{
struct iovec iov[1];
struct msghdr msg;
char buf[0];
iov[0].iov_base = buf;
iov[0].iov_len = 1;
msg.msg_name = NULL;
msg.msg_namelen = 0;
msg.msg_iov = iov;
msg.msg_iovlen = 1;
cmsghdr cm; /* 这是辅助数据头部结构体,文件描述符就是通过这个结构体以及后面的数据部分发送的 */
cm.cmsg_len = CONTROL_LEN; /* 辅助数据的字节数,包扩头部和真正的数据 */
cm.cmsg_level = SOL_SOCKET; /* 表示原始协议级别,与 setsockopt 的 level 参数相同 */
cm.cmsg_type = SCM_RIGHTS; /* 控制信息类型,SCM_RIGHTS 表示传送的内容是访问权 */
*(int*)CMSG_DATA(&cm) = fd_to_send;/* 设置真正数据部分为我们想发送的文件描述符 */
msg.msg_control = &cm; /* 设置辅助数据 */
msg.msg_controllen = CONTROL_LEN;
sendmsg(fd, &msg, 0);
}
/**
* @brief 接受文件描述符
* @param fd 传递信息的 UNIX 域 文件描述符
*/
int recv_fd(int fd)
{
struct iovec iov[1];
struct msghdr msg;
char buf[0];
iov[0].iov_base = buf;
iov[0].iov_len = 1;
msg.msg_name = NULL;
msg.msg_namelen = 0;
msg.msg_iov = iov;
msg.msg_iovlen = 1;
cmsghdr cm;
msg.msg_control = &cm;
msg.msg_controllen = CONTROL_LEN;
recvmsg(fd, &msg, 0);
int fd_to_read = *(int*)CMSG_DATA(&cm);
return fd_to_read;
}
int main()
{
int pipefd[2];
int fd_to_pass = 0;
/* 创建父,子进程间的管道,文件描述符 pipefd[0] 和 pipefd[1] 都是 UNIX 域 socket */
int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
assert(ret != -1);
pid_t pid = fork();
assert(pid >= 0);
if (pid == 0) /* 子进程 */
{
close(pipefd[0]);
fd_to_pass = open("test.txt", O_RDWR, 0666);
/* 子进程通过管道将文件描述符发送到父进程,如果文件 test.txt 打开失败,则子进程将标准输入文件描述符发送到父进程 */
send_fd(pipefd[1], (fd_to_pass > 0) ? fd_to_pass : 0);
close(fd_to_pass);
exit(0);
}
close(pipefd[1]);
fd_to_pass = recv_fd(pipefd[0]); /* 父进程从管道接收目标文件描述符 */
char buf[1024];
memset(buf, '\0', 1024);
read(fd_to_pass, buf, 1024);
printf("I got fd %d and data %s\n", fd_to_pass, buf);
close(fd_to_pass);
}
3、注意事项
① 一个描述符在传递过程中(从调用 sendmsg
发送到调用 recvmsg
接收),内核会将其标记为“在飞行中(in flight )。在这段时间内,即使发送方试图关闭该描述符,内核仍会为接收进程保持打开状态。因为发送文件描述符会使其引用计数加 1 。
② 文件描述符是通过辅助数据发送的,而不是正经的数据段,所以在发送时,总是发送至少 1 个字节的正经数据,即使这个数据没有任何实际意义。否则,当接收返回 0 时,接收方将不能确定没有收到数据(但辅助数据可能有文件描述符)。