【Linux0.11 内核源码剖析】进程间通信——管道(pipe)

一、简介

所谓管道,是指用于连接一个读进程和一个写进程,以实现它们之间通信的共享文件,又称pipe文件。向管道(共享文件)提供输入的发送进程(即写进程),以字符流形式将大量的数据送入管道,而接收管道输出的接收进城(即读进程),可从管道中接收数据。由于发送进程和接收进程是利用管道进行通信的,故又称管道通信。这种方式能够传送大量数据,且很有效。

为了协调双方的通信,管道通信机制必须提供以下三方面的协调能力:

  • 互斥。当一个进程正在对pipe进行读/写操作时,另一个进程必须等待
  • 同步。当写(输入)进程把一定数量数据写入pipe后,便去睡眠等待,直到读(输出)进程取走数据后,再把它唤醒。当读进程读到一空pipe时,也应睡眠等待,直至写进程将数据写入管道后,才将它唤醒
  • 对方是否存在。只有确定对方已存在时,才能进行通信
从本质上来说,管道也是一种文件(记得有人说过,Linux中,一切皆文件),为了克服上面的问题,管道通信机制 限制了管道的大小,实际上管道是一个固定大小的缓冲区,在Linux中,该缓冲区的大小为4KB,恰好为一个页面大小。这使得它的大小收到限制,不像文件那样无限制的不加检验的增长。但同时也会导致当管道已满,而写通道仍有数据待输入,此时,管道的输入端write()调用则必须被阻塞(向管道的写入和读取,使用的是标准的库函数write() 和 read()),直到管道的输出端有数据被读取,管道腾出了足够的空间供 write()调用,才被唤醒写入。同样当读取进程工作的比写入进程快时,会存在管道中所有数据已被读取,此时,管道为空,则管道的输出端read()也将被阻塞,直到管道中数据被唤醒。
从管道读数据是一次性操作,数据一旦被读,它就从管道中被抛弃,释放空间一遍写更多的数据。事实上,C/C++编程时,从首位置开始逐步读取字符串中的数据,前面的字符会随之被抛弃。
二、管道的结构
在Linux 中,管道的实现并没有使用专门的数据结构,而是借助了文件系统中file 结构和 VFS的索引节点 inode。通过将两个 file 结构指向同一个临时的 VFS 索引节点inode,这个索引节点又指向同一个物理页面来实现的。如下图所示,相信可以看出管道的内部实现机制了:进程1 将自己的进程地址空间中的数据先复制到inode 指定的物理页面上,然后进程2 则复制 inode 指定的页面(同一个页面,即共享的数据页)上的数据到自己的进程地址空间上。这就完成了进程1的写,进程2的读,反之一样。换句话说共享了一个物理页。
                                                 
三、源码解读内部实现(Linux 0.11)
管道实现的源码在fs/pipe.c 中,pipe.c 中只有三个函数,write_pipe()、read_pipe() 和 sys_pipe()。下面分别看看这三个函数:
3.1、创建管道——sys_pipe
把管道看做一个循环队列,清楚管道的结构和机制,这对理解管道的读写操作非常重要!
 创建管道系统调用函数。
// 在fildes 所指的数组中创建一对文件句柄(描述符)。这对文件句柄指向一管道i 节点。fildes[0]
// 用于读管道中数据,fildes[1]用于向管道中写入数据。
// 成功时返回0,出错时返回-1。
int sys_pipe(unsigned long * fildes)
{
	struct m_inode * inode;
	struct file * f[2];
	int fd[2];
	int i, j;

	/*为管道文件在文件管理表中射你请空闲项*/
	// 从内核文件管理表中取两个空闲项(引用计数字段为0 的项),并分别设置引用计数为1。
	j = 0;
	for (i = 0; j<2 && i<NR_FILE; i++)
	if (!file_table[i].f_count)
		(f[j++] = i + file_table)->f_count++;
	// 如果只有一个空闲项,则释放该项(引用计数复位)。必须两个才有效
	if (j == 1)
		f[0]->f_count = 0;
	// 如果没有找到两个空闲项,则返回-1。
	if (j<2)
		return -1;

	/*为管道文件与进程建立联系创造条件。一旦父进程fork子进程,子进程将会拷贝这一切,同样与管道文件表项建立了联系*/
	// 针对上面取得的两个文件结构项,分别分配一文件句柄,并使进程的文件结构指针分别指向这两个
	// 文件结构,一个写入端,一个输出端。
	j = 0;
	for (i = 0; j<2 && i<NR_OPEN; i++)
	if (!current->filp[i]) {
		current->filp[fd[j] = i] = f[j];
		j++;
	}
	// 如果只有一个空闲文件句柄,则释放该句柄。
	if (j == 1)
		current->filp[fd[0]] = NULL;
	// 如果没有找到两个空闲句柄,则释放上面获取的两个文件结构项(复位引用计数值),并返回-1。
	if (j<2) {
		f[0]->f_count = f[1]->f_count = 0;
		return -1;
	}

	/*创建管道文件i 节点*/
	// 申请管道i 节点,并为管道分配缓冲区(1 页内存)。如果不成功,则相应释放两个文件句柄和文
	// 件结构项,并返回-1。
	if (!(inode = get_pipe_inode())) {//申请一个空闲内存页面,并将该页面的地址载入i节点
		current->filp[fd[0]] =
			current->filp[fd[1]] = NULL;
		f[0]->f_count = f[1]->f_count = 0;
		return -1;
	}

	/*将管道文件i 节点与文件管理表建立联系*/
	// 初始化两个文件结构,都指向同一个i 节点,读写指针都置零。第1 个文件结构的文件模式置为读,
	// 第2 个文件结构的文件模式置为写。
	f[0]->f_inode = f[1]->f_inode = inode;
	f[0]->f_pos = f[1]->f_pos = 0;
	f[0]->f_mode = 1;		/* read */
	f[1]->f_mode = 2;		/* write */

	// 将文件句柄数组复制到对应的用户数组中,并返回0,退出。
	put_fs_long(fd[0], 0 + fildes);
	put_fs_long(fd[1], 1 + fildes);
	return 0;
}
get_pipe_inode() 函数
 获取管道节点。返回为i 节点指针(如果是NULL 则失败)。
// 首先扫描i 节点表,寻找一个空闲i 节点项,然后取得一页空闲内存供管道使用。
// 然后将得到的i 节点的引用计数置为2(读者和写者),初始化管道头和尾,置i 节点的管道类型表示。
struct m_inode * get_pipe_inode(void)
{
	struct m_inode * inode;

	if (!(inode = get_empty_inode()))	// 如果找不到空闲i 节点则返回NULL。
		return NULL;
	if (!(inode->i_size = get_free_page())) {// 节点的i_size 字段指向缓冲区。i_size表示内存页面的起始地址
		inode->i_count = 0;					// 如果已没有空闲内存,则
		return NULL;						// 释放该i 节点,并返回NULL。
	}
	inode->i_count = 2;	/* 读/写两者总计 */
	PIPE_HEAD(*inode) = PIPE_TAIL(*inode) = 0;// 复位管道头尾指针。
	inode->i_pipe = 1;			// 置节点为管道使用的标志。
	return inode;		// 返回i 节点指针。
}
从上面可以看出, 管道的创建过程:1、为管道文件在文件管理表中申请空闲项;2、为管道文件与进程建立联系创造条件;3、创建管道文件i 节点;4、将管道文件i 节点与文件管理表建立联系;5、将管道文件句柄返回给用户进程。
// 管道头、管道尾、管道大小、管道空、管道满、管道头指针递增。
#define PIPE_HEAD(inode) ((inode).i_zone[0])
#define PIPE_TAIL(inode) ((inode).i_zone[1])
#define PIPE_SIZE(inode) ((PIPE_HEAD(inode)-PIPE_TAIL(inode))&(PAGE_SIZE-1))
#define PIPE_EMPTY(inode) (PIPE_HEAD(inode)==PIPE_TAIL(inode))
#define PIPE_FULL(inode) (PIPE_SIZE(inode)==(PAGE_SIZE-1))
/*
下面用C语言实现就是 #define INC_PIPE(head) (((*head)++)&(PAGE_SIZE-1))
用汇编实现,将数据放入寄存器中,速度更快
*/
#define INC_PIPE(head) _INC_PIPE(&(head))
extern _inline void _INC_PIPE(unsigned long *head) {
	_asm mov ebx, head  //head存入ebx中
	_asm inc dword ptr[ebx] //将ebx指向的数据加1
		_asm and dword ptr[ebx], 4095 //结果与4095
}
3.2、写管道——write_pipe
如果执行 write 函数(文件描述符为管道文件描述符),write函数会映射到系统调用函数 sys_write 中去执行,并最终执行到 write_pipe 函数中。
/*管道类似于一个循环队列*/
 管道写操作函数。
// 参数inode 是管道对应的i 节点,buf 是数据缓冲区指针,count 是将写入管道的字节数。
int write_pipe(struct m_inode * inode, char * buf, int count)
{
	int chars, size, written = 0;

	// 若将写入的字节计数值count 还大于0,则循环执行以下操作。
	while (count>0) {
		// 若当前管道中已经满了(size=0),没有空间可以写入数据了,则唤醒等待该节点的进程(读管道),
		//如果已没有读管道者,则向进程发送SIGPIPE 信号,并返回已写入的字节数并退出。
		// 若写入0 字节,则返回-1。否则在该i 节点上睡眠,等待管道腾出空间。

		while (!(size = (PAGE_SIZE - 1) - PIPE_SIZE(*inode))) {//可写入大小
			wake_up(&inode->i_wait);//唤醒读管道进程
			if (inode->i_count != 2) { /* 没有读进程 */
				current->signal |= (1 << (SIGPIPE - 1));
				return written ? written : -1;
			}
			sleep_on(&inode->i_wait);//挂起写管道进程
		}
		// 取管道头部到缓冲区末端空间字节数chars。如果其大于还需要写入的字节数count,则令其等于
		// count。如果chars 大于当前管道中空闲空间长度size,则令其等于size。
		/*管道可以看做一个循环队列,清楚chars 与 size的区别*/
		chars = PAGE_SIZE - PIPE_HEAD(*inode);//chars <= size
		if (chars > count)
			chars = count;
		if (chars > size)
			chars = size;
		// 写入字节计数减去此次可写入的字节数chars,并累加已写字节数到written。
		count -= chars;
		written += chars;
		// 令size 指向管道数据头部,调整当前管道数据头部指针(前移chars 字节)。
		/**/
		size = PIPE_HEAD(*inode);
		PIPE_HEAD(*inode) += chars;
		PIPE_HEAD(*inode) &= (PAGE_SIZE - 1);
		// 从用户缓冲区复制chars 个字节到管道中。对于管道i 节点,其i_size 字段中是管道缓冲块指针。
		while (chars-->0)//管道头部位置为读管道指针位置
			((char *)inode->i_size)[size++] = get_fs_byte(buf++);
	}
	// 唤醒等待该i 节点的进程(写管道),返回已写入的字节数,退出。
	wake_up(&inode->i_wait);
	return written;
}

把管道理解为一个循环队列,一个指针标明写入,一个指针标明读出。所以在判断管道满和空的情况下,是以循环队列的标准来判断,在写入的过程中,写管道指针一直指向数据的写入位置,一直向管道尾端移动,写满后,指针已经从管道的首段移动到尾端了,如下图所示。再次执行写管道操作时,系统会将写管道指针从管道尾端移动到管道首端,继续将数据写入管道。

Linux 0.11 默认:每次写管道执行完毕后,管道中就已经拥有了可以读出的数据,所以就会唤醒读管道进程;每次读管道操作完毕后,管道中就已经拥有了可以写入数据的空间,所以就会唤醒写管道进程。
如果管道中没有空间可以写入数据,此时系统将挂起写管道进程,然后切换到读管道进程去执行。 但是需要注意的是,任何进程挂起的一个原因是该进程的时间片已经用完了,必须挂起,然后根据公平调度原则去运行其余进程。所以这里的读管道、写管道进程不一定非要管道空、满了才挂起,一旦该进程的时间片用完了,那么下一次先执行的便是另一个进程,所以常有中间态的情况发生(管道首指针和尾指针位于中间,谁前谁后不一定)。
3.3、读管道——read_pipe
前面说到了LInux 0.11 默认规则,管道满了,就会唤醒读管道进程(唤醒不表示读管道进程可以立即执行)。写管道是移动管道首指针,读管道则是移动管道尾指针。
 管道读操作函数。
// 参数inode 是管道对应的i 节点,buf 是数据缓冲区指针,count 是读取的字节数。
int read_pipe(struct m_inode * inode, char * buf, int count)
{
	int chars, size, read = 0;

	// 若欲读取的字节计数值count 大于0,则循环执行以下操作。
	while (count>0) {
		// 若当前管道中没有数据(size=0),则唤醒等待该节点的进程,如果已没有写管道者,则返回已读
		// 字节数,退出。否则在该i 节点上睡眠,等待信息。
		while (!(size = PIPE_SIZE(*inode))) {//没有数据可读
			wake_up(&inode->i_wait);
			if (inode->i_count != 2) /* 没有写管道 */
				return read;
			sleep_on(&inode->i_wait);
		}
		// 取管道尾到缓冲区末端的字节数chars。如果其大于还需要读取的字节数count,则令其等于count。
		// 如果chars 大于当前管道中含有数据的长度size,则令其等于size。
		chars = PAGE_SIZE - PIPE_TAIL(*inode);//
		if (chars > count)
			chars = count;
		if (chars > size)
			chars = size;
		// 读字节计数减去此次可读的字节数chars,并累加已读字节数。
		count -= chars;
		read += chars;
		// 令size 指向管道尾部,调整当前管道尾指针(前移chars 字节)。
		size = PIPE_TAIL(*inode);
		PIPE_TAIL(*inode) += chars;
		PIPE_TAIL(*inode) &= (PAGE_SIZE - 1);
		// 将管道中的数据复制到用户缓冲区中。对于管道i 节点,其i_size 字段中是管道缓冲块指针。
		while (chars-->0)
			put_fs_byte(((char *)inode->i_size)[size++], buf++);
	}
	// 唤醒等待该管道i 节点的进程,并返回读取的字节数。
	wake_up(&inode->i_wait);
	return read;
}
读管道进程开始执行后,将从上一次读的位置开始把管道中的数据读出,当操作到管道尾端后也会将读管道指针从管道尾端移动到管道首端,从首端继续读取管道中的内容,直至将数据彻底读完。管道中的数据被读完后,读管道进程将再次被挂起,并切换到写管道进程去执行。
  • 对于读管道操作,如果管道中有数据,它就可以读出数据,如果没有数据,系统就会将它挂起。
  • 对于写管道操作,如果管道中有空间,它就可以写入数据,如果没有空间,系统就会将它挂起。
另外,无论哪种操作,一旦该操作进程的时间片用完了,同样会被挂起,当下次分配时间片时,将会接着上次的写/读位置继续操作。由于管道数据的写入和读出都是在内核代码中进行的,上面用户态进程切换并不会对数据的操作产生影响。虽然读管道进程和写管道进程都在同一个内存页面中进程操作,但数据永远不会出现混乱。

参考资料:(Linux 0.11源码)《Linux 内核设计的艺术》、《Linux 内核设计与实现》

  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Linux0.11内核源码解读第一季——汇编启动部分》是一本深入解析Linux0.11内核源代码的书籍。汇编启动部分是整个内核启动过程中的重要环节,能够帮助读者深入理解操作系统的启动和初始化流程。 首先,汇编启动部分是内核启动的第一步。它通过软件中断机制在实模式下启动。通过设置系统段描述符和全局描述符表,为操作系统提供必要的运行环境。在启动过程中,汇编启动部分会初始化中断向量表、设置栈段(SS)和栈指针(SP),并跳转到引导扇区加载内核文件。 接着,书籍详细分析了引导扇区的装载过程。引导扇区会被BIOS加载到内存地址0x7C00处,然后执行引导扇区的代码。在引导扇区中,汇编启动部分会进行一些必要的初始化工作,如设置栈段和栈指针,加载中断描述符表以及读取磁盘上的内核文件。 此外,书籍还介绍了一些启动相关的概念和知识,如分段机制、实模式和保护模式之间的切换等。读者通过学习这些知识,可以更加清楚地了解硬件和操作系统之间的交互过程。 总之,汇编启动部分是Linux0.11内核启动的关键环节,对于理解操作系统的启动过程非常重要。《Linux0.11内核源码解读第一季——汇编启动部分》通过深入剖析源代码,让读者能够全面了解Linux内核的启动过程,并通过这些知识来探索更深入的操作系统原理。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值