首页 > 系统相关 >Linux进程间通信源码分析

Linux进程间通信源码分析

时间:2023-06-03 22:12:00浏览次数:56  
标签:pipe struct int 间通信 源码 file Linux 进程 inode

概览

这篇文章从内核源码的角度整理一下Linux的进程间通信机制。

众所周知,Linux操作系统的通信机制有以下几种:

  • 信号
  • 管道(分为匿名管道和有名管道)
  • 信号量
  • 共享内存
  • 消息队列
  • Socket

本文主要内容包括其中前五个。

其中信号量、共享内存、消息队列在Linux中有两套API,实现方式大不相同:

  • System V Api, 主要由内核自行实现,移植性不高。
  • posix Api,由外部库函数实现,移植性较高。

System V进程间通信接口:

Posix进程间通信接口:

本文主要讲解System V的进程间通信机制以及其他通信方式(包括管道、信号等),你将可以看到Linux内核在一定程度上对这三种进程通信机制有着统一的设计与实现。

本文的不足:本人尚未积累太多开发经验,本文章的整理内容主要偏向于源码实现,大多数内容参考自书籍,几乎没能从应用层实践的角度给出自己的分析。希望我工作一段时间后,再来做补充。

信号

本章内容主要参考linux内核2.6版本,与2.4版本相比有一些变化,变得更清晰了。

本章先介绍以下内核中有关信号的数据结构,当然仅仅是挑选了几个有代表性(这里参考了《深入Linux内核架构》一书);

然后结合源码(Linux 2.6和2.4)看看内核是怎样发送信号、捕获信号、处理信号的;

最后信号处理函数的执行是在用户态的,这其中涉及到了用户与内核态的反复切换,比较有意思。

内核相关数据结构

task_struct结构中与信号有关的属性如下:

struct task_struct{
	struct sighand_struct *sighand;
	sigset_t blocked;
	struct sigpending pending;
    unsigned long sas_ss_sp;
	size_t sas_ss_size;
}

另外thread_info结构中有一个TIF_SIGPENDING标识位,如果该标志位为1,则标识进程有未决信号。

这是与linux2.4版本有着较大的区别,因为该标识为在2.4内核存放与task_struct结构中。而在2.4版本,task_struct结构存放于内核栈的最后,但由于task_struct结构越来越大,内核开发者们在之后的版本中使用thread_info代替了task_struct存放与内核栈最后。thread_info结构有一个指向task_stuct结构的指针,而task_struct结构本身则由slab分配器管理。

可能是为了效率的考量,开发者将一些标志位直接放在了thread_info中,其中就包括TIF_SIGPENDING标志位。

struct thread_info {
	// ...
	struct task_struct	*task;		/* main task structure */
	unsigned int		flags;	  // 标识位
	// ..
}

值得一提的是,2.6内核中使用32位无符号整数标识32个标识位,TIF_SIGPENDING标识只占用其中一位。其他的比较重要的标识位有TIF_NEED_RESCHED,标识进程是否应该被抢占,每次从内核返回用户空间时,都会检查这一标识,如果标识位置1,则转而调用schedule进行调度,这就表明本进程被其他进程抢占了。

再回到信号处理的相关数据结构中

  • sighand_struct结构包含一个数组,这个数组有64个元素,每个元素都是一个k_sigaction结构。而k_sigaction仅仅是sigaction的一层包装,sigaction就是用户设置相关信号处理函数时用到的数据结构。所以,64个sigactin中的每个都是对某种信号的反应,用户可以使用系统调用sigaction, 将sigaction.sa_handler设置成自定义函数,这样当进程收到信号时,会执行这个函数。当sigaction.sa_handler位SIG_DFL时,内核将执行信号对应的默认反应

    struct sigaction {
    	__sighandler_t	sa_handler;
    	unsigned long	sa_flags;
    	sigset_t	sa_mask;	/* mask last for extensibility */
    };
    
  • blocked成员,是一个long型变量,64位。相当于一个bitmap,如果其中某一位为1,则表示进程屏蔽了该信号,不会响应。

  • sigpending成员管理本进程所有还没有被处理的信号。

    struct sigpending {
    	struct list_head list;
    	sigset_t signal;
    };
    
    

    其中的sigpending.signal也相当于一个64位的bitmap,如果某位置1,表示该信号还没有被处理。

    每个未处理信号使用sigqueue结构表示:

    struct sigqueue {
    	struct list_head list;
    	siginfo_t info;
    };
    

    它们通过list_head链入sigpending中。

  • 在执行信号处理函数时,进程是运行在用户态的,需要额外的一个栈。如果用户额外地准备了一个运行栈,则sas_ss_sp指向这个栈,sas_ss_size则表示了这个栈的大小。但一般情况下,sas_ss_size = 0, 表示信号处理运行时栈与原始的用户栈共用。

相关数据结构的关系如下图所示:

image-20230529210030646

以上是内核中的数据结构,现在再来看一下操作系统提供给用户的接口。

传统的Linux信号量有32个,之后由加入了32个实时信号,现在一共有64个信号,可以使用kill -l 命令查看:

 kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

如果没有使用sigaction系统调用改变进程对信号的反应方式,进程则执行规定的默认动作。可以使用 man 7 signal查看,下面是POSIX.1-1990 标准:

       Signal     Value     Action   Comment
       ──────────────────────────────────────────────────────────────────────
       SIGHUP        1       Term    Hangup detected on controlling terminal
                                     or death of controlling process
       SIGINT        2       Term    Interrupt from keyboard
       SIGQUIT       3       Core    Quit from keyboard
       SIGILL        4       Core    Illegal Instruction
       SIGABRT       6       Core    Abort signal from abort(3)
       SIGFPE        8       Core    Floating-point exception
       SIGKILL       9       Term    Kill signal
       SIGSEGV      11       Core    Invalid memory reference
       SIGPIPE      13       Term    Broken pipe: write to pipe with no
                                     readers; see pipe(7)
       SIGALRM      14       Term    Timer signal from alarm(2)
       SIGTERM      15       Term    Termination signal
       SIGUSR1   30,10,16    Term    User-defined signal 1
       SIGUSR2   31,12,17    Term    User-defined signal 2
       SIGCHLD   20,17,18    Ign     Child stopped or terminated
       SIGCONT   19,18,25    Cont    Continue if stopped
       SIGSTOP   17,19,23    Stop    Stop process
       SIGTSTP   18,20,24    Stop    Stop typed at terminal
       SIGTTIN   21,21,26    Stop    Terminal input for background process
       SIGTTOU   22,22,27    Stop    Terminal output for background process

常见的信号有9,即杀死信号. 信号11默认行为是转储然后停止进程运行,该信号应该是C/C++初学者接触最多的信号了吧 :)

其他的信号可以继续查看man手册,这里就不贴了。

信号发送与信号捕获

信号发送

当我们在shell中使用kill命令给一个进程发送9号命令时,一般来说该进程会终止运行。这一节要做的,就是简要地介绍一下,内核是如何向指定进程发送信号的,指定进程又是如何捕获这个信号并做出反应的。

我们先使用 strace 命令跟踪这条shell命令:

$ starce kill -9 14000
execve("/bin/kill", ["kill", "-9", "14000"], 0x7ffffbd0b560 /* 33 vars */) = 0
// ......
	kill(14000, SIGKILL) 
//......
	exit_group(1)  

众所周知,shell是一个用户进程,当我们在命令行输入命令时,shell进程进行语法解析,然后fork子进程并在子进程中进行系统调用execve来执行用户想要运行的程序,这里要运行的是/bin/kill程序。而/bin/kill程序则最终调用系统调用kill对pid=14000的进程发送9号信号。

linux2.6.32.10内核对kill系统调用的定义如下:

// linux2.6.32.10/kernel/signal.c
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
{
	struct siginfo info;

	info.si_signo = sig;
	info.si_errno = 0;
	info.si_code = SI_USER;
	info.si_pid = task_tgid_vnr(current);
	info.si_uid = current_uid();

	return kill_something_info(sig, &info, pid);
}

kill_something_info之后的调用链为:

kill_something_info => kill_pid_info => group_send_sig_info => do_send_sig_info => send_signal => __send_signal

主要来看__send_signal函数:

// t : 给t进程发送信号
// sig : 信号的编号, 如9号为SIGKILL信号
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
			int group, int from_ancestor_ns)
{
	struct sigpending *pending;
	struct sigqueue *q;
	// ......

	// 如果group为0,则pending是task_struct中的pending
	pending = group ? &t->signal->shared_pending : &t->pending; 
	// ......
    
	// 分配一个sigqueue
	q = __sigqueue_alloc(t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
		override_rlimit);
	if (q) {
		list_add_tail(&q->list, &pending->list); // 加入到task_struct.pending中
    	// ...
    }
    
	complete_signal(sig, t, group); // 这个函数最终会调用signal_wake_up函数,设置TIF_SIGNALPENDING标志位,有可能的话,使得被发送的信号的进程抢占当前进程
	return 0;
}

以上代码为这个函数的骨干内容,主要分为两步:

  1. 分配一个sigqueue结构然后链接入目标进程的task_struct.pending中。
  2. complete_signal函数处理一些其他的检查,然后调用signal_wake_up。
    • signal_wake_up函数将设置目标进程的TIF_SIGPENDING标志位为1,然后调用wake_up_state => try_to_wake_up将目标进程唤醒。
      • try_to_wake_up函数,将目标进程的task_struct.state设置为TASK_RUNNING,并且将调用check_preempt_curr函数检查目标进程是否可以抢占当前进程,如果可以,那么在函数返回后,当前进程的TIF_NEED_RESCHED标志位将置为1,当本进程从内核态返回至用户态时,将调用一次schedule函数。于是本进程就被抢占了,但是下一个运行的进程是否就是目标进程呢?个人认为不一定,schedule函数还要自己计算一次优先级,选择优先级最高的进程恢复执行,优先级最高的进程不一定就是信号发送的目标进程。

image-20230531164035315

到现在为止,我们的shell进程的子进程/bin/kill已经向目标进程发送了信号,具体的表现为:

  • 目标进程的task_struct.pending中多出了一个sigqueue,具体描述了一个信号(信号编号sig、发送信号的进程pid等)。而sighand结构所保存的sigaction数组保存所有的信号处理函数,这里sighand[sig]所指向的函数就是相应的信号处理函数。
  • 目标进程的thread_info.flag的TIF_SIGPENDING被置1,表示该进程有未决信号。

/bin/kill进程只负责发送信号,至于信号的捕获与执行,都交给目标进程来做。

信号捕获

进程如何察觉到另一个进程给它发送了信号呢?这依赖于entry.S中相关代码,该文件定义了中断\系统调用的入口代码,以及中断\系统调用返回时需要做的额外工作,这些额外工作,就包括对信号的检查。linix2.6.32.10/arch/x86/kernel/entry_64.S文件上方有这样一段注释:

/* entry.S contains the system-call and fault low-level handling routines.
 * NOTE: This code handles signal-recognition, which happens every time after an interrupt and after each system call.
 * 渣翻: 这段代码会处理信号识别,这发生再每一次中断和系统调用之后。
 */

当一个进程从内核态返回用户态时,entry.S的相关代码会调用do_notify_resume函数,该函数检查TIF_SIGPENDING标识未是否置为1,如果是那就调用do_signal(该函数在下一节详细分析)处理该信号:

void
do_notify_resume(struct pt_regs *regs, void *unused, __u32 thread_info_flags)
{
	// ...
	if (thread_info_flags & _TIF_SIGPENDING) // 检查_TIF_SIGPENDING标识是否为1,若为1则调用do_signal
		do_signal(regs);
	// ...
}

可能linux2.4版本更容易看出内核在返回用户态时对SIGPENDING进行了检查,具体代码也位于entry.S文件的ret_with_reschedule处:

ret_with_reschedule:
	cmpl $0,need_resched(%ebx) #检查 need_resched标志,如果为1则调用schedule
	jne reschedule
	cmpl $0,sigpending(%ebx) # 检查 sigpending标志,如果为1则先处理信号
	jne signal_return
	
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)  # 也是调用do_signal 函数!
	jmp restore_all

好了说了这么多,我只想说明无论是linux2.6或者时2.4或者更高的内核版本,进程对信号的捕获都发生在内核返回至用户态的那一刻,它会检测SIGPENDING标志位是否为1,如果为1,表示进程有未决信号,那么此时就要去处理函数,处理完了再返回至用户态。

可以看到,从信号递送成功,再到信号被捕获,其中的时间间隔是不确定的。这个时间间隔主要取决于进程的优先级,如果该进程的优先级高,那么它被schedule函数选中的概率就大,那么该进程就会更快地从内核返回至用户态,也能更快地捕获信号。

但是以上结论是在进程A对进程B发送信号的场景下。如果内核在一次中断/系统调用中对当前进程发送信号,那么由于本进程本来就处于内核态,可以认为此时的这个信号立即被处理。
举个例子,进程C使用了还没有向操作系统分配的内存,会发生pagefault进入内核态处理异常,内核发现该地址还没分配给用户,于是内核向进程C发送SIGV信号,然后返回用户态前进程C立刻捕获这个SIGV信号,该信号的处理方式默认为终止进程运行、转储核心文件。然后进程C就结束了运行,用户“高高兴兴”地看见他熟悉的Segmentaion Fault错误提示。

信号处理函数的执行

从前面一节知道了进程何时捕获信号,这一节讲讲进程如何处理信号并执行信号处理函数。

接着上一节,进程捕获信号后调用do_signal函数对信号进程处理:

static void do_signal(struct pt_regs *regs)
{
	struct k_sigaction ka;
	siginfo_t info;
	int signr;
	sigset_t *oldset;
	// ...

	signr = get_signal_to_deliver(&info, &ka, regs, NULL);
	if (signr > 0) {
		// ....
		// 
		if (handle_signal(signr, &info, &ka, oldset, regs) == 0) {
			current_thread_info()->status &= ~TS_RESTORE_SIGMASK;
		}
		return;
	}

	// 一些其他检查...
}

do_siganl函数大致上分为两步:

  1. 调用get_signal_to_deliver函数取得要处理的信号。这个函数会在一个循环中预先对信号的默认行为进行处理:
    • 若信号的默认行为是忽略,则继续找下一个
    • 若信号的默认行为是停止,则先看看它是否需要转储核心文件,然后直接调用do_group_exit使进程终止运行(比如,上一节开头的那个例子,我们再shell命令行中使用kill命令发送9号信号给目标进程,那么目标进程检测到这个信号后将立刻退出,不再会有下面的步骤了)
    • 若信号有一个用户指定的动作,则跳出循环,将这个信号返回给上层do_signal函数
  2. 到这一步骤表明得到了一个非默认行为的信号,这个信号的处理函数被用户另外指定,于是再调用handle_signal函数去真正地处理它。

在分析handle_signal函数前,首先明确,用户规定的信号处理函数是在用户态运行的,这需要一个额外的用户栈。内核要做的就是将trapframe(ptregs)中的eip修改为信号处理函数的地址,然后分配一个额外的用户栈来执行信号处理函数,将trapframe中的esp指向这个额外的用户栈。而且,当用户处理函数执行完毕,应该使其再返回至内核,由内核恢复用户进程在信号处理前的运行上下文。

因此handle_signal的主要任务有:

  • 建立额外运行时栈,并修改运行时栈使其在执行完信号处理函数后再次系统调用至内核
  • 修改trapframe(ptregs)相关参数,使得进程从内核返回值处理函数开始执行

下面来看handle_signal实现:

static int
handle_signal(unsigned long sig, siginfo_t *info, struct k_sigaction *ka,
	      sigset_t *oldset, struct pt_regs *regs)
{
	int ret;
	// ...
	// 建立信号处理函数运行栈
	ret = setup_rt_frame(sig, ka, info, oldset, regs);
	// ...

	return 0;
}

可以看到,handle_signal函数的骨干只是调用了setup_rt_frame函数,看名字就可以猜想它的功能是建立信号处理函数的运行栈,该函数会调用__setup_rt_frame函数,主要工作就在这个函数完成,分段来看这个函数:

static int __setup_rt_frame(int sig, struct k_sigaction *ka, siginfo_t *info,
			    sigset_t *set, struct pt_regs *regs)
{
	struct rt_sigframe __user *frame;
	void __user *fp = NULL;
	int err = 0;
	struct task_struct *me = current;

	frame = get_sigframe(ka, regs, sizeof(struct rt_sigframe), &fp);

	// 一些检查 ......

可以看到__setup_rt_frame首先调用get_sigframe来获取一个rt_sigframe,rt_sigframe是信号处理函数运行时栈的一部分,其中包括返回地址和一些用来恢复用户进程在执行信号处理函数之前的上下文信息。

static inline void __user *
get_sigframe(struct k_sigaction *ka, struct pt_regs *regs, size_t frame_size,
	     void __user **fpstate)
{
	/* Default to using normal stack */
	unsigned long sp = regs->sp;  // 
    // 默认使用户原来的运行时栈
	int onsigstack = on_sig_stack(sp); 
	// 架构相关的代码 ......

	if (!onsigstack) {
		if (ka->sa.sa_flags & SA_ONSTACK) {
             // 若用户自己额外提供了一个运行时栈,则使用这个栈
			if (current->sas_ss_size)
				sp = current->sas_ss_sp + current->sas_ss_size; 
		} 
        // 架构相关的代码......
	}

	// ......
	// 如果使用原来的用户栈,相当于只是对栈进行了一下延申。
	sp = align_sigframe(sp - frame_size);

	// ......

	return (void __user *)sp;
}

一般情况下,信号处理的栈与原来的用户栈共用,regs->sp就是用户进入内核时,trapframe中保存的用户栈指针,内核默认使用这个地址减去frame_size的大小作为信号运行时栈的开始:

unsigned long sp = regs->sp; 
// ...
sp = align_sigframe(sp - frame_size);

image-20230531203625363

拿到rt_sigframe后,__setup_rt_frame函数对其中的书信赋值,首先复制用户进程进入内核时保存的上下文信息:

	// 接上文的__setup_rt_frame
    put_user_try {
		/* Create the ucontext.  */
		if (cpu_has_xsave)
			put_user_ex(UC_FP_XSTATE, &frame->uc.uc_flags);
		else
			put_user_ex(0, &frame->uc.uc_flags);
		put_user_ex(0, &frame->uc.uc_link);
		put_user_ex(me->sas_ss_sp, &frame->uc.uc_stack.ss_sp);
		put_user_ex(sas_ss_flags(regs->sp),
			    &frame->uc.uc_stack.ss_flags);
		put_user_ex(me->sas_ss_size, &frame->uc.uc_stack.ss_size);
		err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);
		err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));

然后设置sig_frame中的返回值:

         /* x86-64 should always use SA_RESTORER. */
		if (ka->sa.sa_flags & SA_RESTORER) {
			put_user_ex(ka->sa.sa_restorer, &frame->pretcode);
		} // ...
	} put_user_catch(err);

可以看到sigframe.pretcode被设置成了ka->sa.sa_restorer,这是个什么?在哪里被赋值?————它在glibc中被赋值,感兴趣的话可以按照下面这个调用路径查看glic2.37的源码:

__sigaction() => libc_sigaction => SET_SA_RESTORER => sa_restorer的定义

该函数是一段汇编代码,我不是很能看懂,但关键信息就是这段汇编代码会执行一次名为rt_sigreturn系统调用,这样就使得信号处理函数执行完后再一次进入内核。

关于pretcode的设置在多说几句。

Linux2.4中,返回函数直接被放置在了用户栈上,因此pretcode直接指向了用户栈的某处地址,可以不必使用ka->sa.sa_restorer这个值。因此《Linux内核源代码情景分析》一文中写道“SA_RESTORER这个标记是过时的,linux man page上已不建议使用”。但如今的man页面又说了该标记“Not intended for application use. ”,表示这个标记不被用户应用使用,而是被glibc库所维护。因此相应的内核代码也倾向于使用ka->sa.sa_restorer来设置返回地址了。

接着再回到__setup_rt_frame函数中,将对用户进入内核时保存的上下文进行修改,其中最重要的两个值是ip和esp。ip指向用户规定的信号处理函数,esp指向用户栈延长的位置:

	if (err)
		return -EFAULT;

	// 信号处理函数所需要的一个形参在这里复制
	regs->di = sig;

	regs->ax = 0;

	// ......
	// 修改上下文的ip和sp两个寄存器的值,使得从内核返回值用户处理函数处开始执行。
	regs->ip = (unsigned long) ka->sa.sa_handler;

	regs->sp = (unsigned long)frame;
	// ......

	return 0;
}

image-20230531203657439

如此,用户处理函数结束后,会将pretcpde处的地址当作函数返回地址,去执行glibc库定义的restorer函数,在这个函数中进行rt_return系统调用,由内核将用户的原上下文进程恢复:

image-20230531203919610

至于内核怎么恢复的,就不在这里展开细讲了。

匿名管道

从shell的角度看管道

先从shell是怎么处理匿名管道的命令行开始说起,因为这比较直观

我在学习C++时,经常需要查看C++可执行文件的符号表,以确定C++编译器的具体行为,这很简单,只需使用nm命令即可。但是C++对符号名进行了mangle,比如函数声明的函数名为f1(无参数),但是C++编译器将该函数名编码成了 _Z2f1v。为了方便学习,可以使用c++file命令行demangle这些符号名。我们可以直接使用如下shell命令查看C++编译器生成的符号表:

nm cppobj | c++filt

| 这跟竖线就像一个管道,使得nm命令的输出成为c++filt命令的输入,最后c++filt向中断输出最终处理结果。

不妨看一下xv6的shell源码实现,一方面是逻辑结构清晰,另一方面是它确实比较简单。

shell进程将解释上述命令行,在进行一些列词法和语法分析后,得出这个命令是管道命令:

// xv6/sh.c/runcmd()函数片段:
int p[2]; // pipe调用返回两个fd存放在这里
case PIPE:
    pcmd = (struct pipecmd*)cmd;
    if(pipe(p) < 0)	   // shell进程调用pipe系统调用,返回后,内存中已经存在了进行通信的缓冲区域
      panic("pipe");
    if(fork1() == 0){	// 先fork一个进程1, 子进程1进入if代码块。 结合上述场景,这个进程就是nm进程
      close(1);		 // 子进程1关闭标准输出,即fd = 1这个文件描述符不再指向控制台
      dup(p[1]);	// 子进程1将fd = 1这个文件描述符指向改成 fd = p[1] 这个文件描述符的指向,也即管道的输入端
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->left); // runcmd执行 | 左边的程序,会调用exec不再返回
    }
    if(fork1() == 0){ // 再fork一个进程2,下面代码块对子进程2做类似处理,但是重定向标准输入的文件描述符。结合上述场景,这个进程为c++filt
      close(0);
      dup(p[0]);
      close(p[0]);
      close(p[1]);
      runcmd(pcmd->right); // runcmd执行 | 右边的程序,会调用exec不再返回
    }
    close(p[0]);
    close(p[1]);
    wait();   // shell进程等待两个子进程执行完毕
    wait();
    break;

感到神秘的就只有pipe这个系统调用了,但我之后再讲这个玩意,先从shell和它的子进程门的角度粗浅地解释一下匿名管道的工作原理。

现在你只需要知道,pipe命令在内核中创建了一块内存,内核返回了两个文件描述符来让用户操作它,存放于int p[2],其中p[1]为管道的输入端描述符,p[0]为管道的输出端描述符。

下面画图示意管道建立的过程。首先由shell进程调用pipe系统调用,它会返回两个文件描述符,一个代表管道的输出端,另一个代表管道的输入端

image-20230601210551688

调用fork系统调用生成的子进程的文件描述符表与shell进程相同:

image-20230601210809458

然后管道左边的进程,即nm进程,将自己的fd = 1这个描述符重定向到管道的输出端,管道右边的进程,即c++filt进程,将自己的fd = 0这个描述符重定向到管道的输入端,并删除多余的文件描述符表:

image-20230601210902722

然后nm进程的输出就顺利地通过管道作为c++filt进程的输入了:

image-20230601211030158

sys_pipe实现

现在来看匿名管道的实现,参考Linux2.4内核源码,pipe系统调用对应的内核函数为sys_pipe:

asmlinkage int sys_pipe(int a0, int a1, int a2, int a3, int a4, int a5,
			struct pt_regs regs)
{
	int fd[2];
	int error;

	error = do_pipe(fd); // 主要工作在do_pipe函数中完成
	if (error)
		goto out;
	(&regs)->r20 = fd[1];
	error = fd[0];
out:
	return error;
}

将do_pipe的一些检查与错误处理去掉,得到下面的一份代码:

int do_pipe(int *fd)
{
	struct qstr this;
	char name[32];
	struct dentry *dentry;
	struct inode * inode;
	struct file *f1, *f2;
	int error;
	int i,j;
	// 1. 分配两个file结构
	f1 = get_empty_filp();
	f2 = get_empty_filp();
    // 2. 分配一个inode
    // 从slab分配器管理的inode_cachep中获得一个inode结构,这只是一个在内存中的结构。分配管道所使用的内存缓冲, 并设置管道特定的数据结构。
	inode = get_pipe_inode(); 
	// 3. 分配两个fd
	i = get_unused_fd();  // 从fd数组中找到一个空闲下标
	j = get_unused_fd();  // 从fd数组中找再到一个空闲下标
	// 4. 分配一个dentry
	sprintf(name, "[%lu]", inode->i_ino);
	this.name = name;
	this.len = strlen(name);
	this.hash = inode->i_ino; 
	dentry = d_alloc(pipe_mnt->mnt_sb->s_root, &this); // 分配一个dentry
	dentry->d_op = &pipefs_dentry_operations;  // 虚拟文件系统的体现
    // 5. 将分配的dentry与inode挂上钩
	d_add(dentry, inode);		
    // 6. 配置file结构的属性
	f1->f_vfsmnt = f2->f_vfsmnt = mntget(mntget(pipe_mnt));  // 设置pipe文件系统的安装点
	f1->f_dentry = f2->f_dentry = dget(dentry);  // 设置file结构的dentry指针
		// 配置读端file结构
	f1->f_pos = f2->f_pos = 0;
	f1->f_flags = O_RDONLY;
	f1->f_op = &read_pipe_fops;
	f1->f_mode = 1;
	f1->f_version = 0;
	    // 配置写端file结构
	f2->f_flags = O_WRONLY;
	f2->f_op = &write_pipe_fops;
	f2->f_mode = 2;
	f2->f_version = 0;
	// 7. 将fd数组与file结构联系起来
	fd_install(i, f1);
	fd_install(j, f2);
	fd[0] = i;
	fd[1] = j;
	return 0;

}

上面涉及的数据结构包括 : inode、file_struct、dentry都是文件系统的典型组件,如果你还不熟悉这些概念,那么可以先看看文件系统的部分,或者可以先试着看xv6的文件系统,比之linux简单很多,但是它大体结构是差不多的,下面是xv6文件系统的总图,可以看到与linux的文件系统有着很多相似的概念。

image-20221118225154849

再回到Linux do_pipe()函数中,它会调用get_pipe_inode来获取一个inode节点:

  1. 在inode_cachep中分配一个inode节点

  2. 调用pipe_new函数为这个inode节点做定制:

    struct inode* pipe_new(struct inode* inode)
    {
    	unsigned long page;
    
    	page = __get_free_page(GFP_USER);
    	if (!page)
    		return NULL;
    
    	inode->i_pipe = kmalloc(sizeof(struct pipe_inode_info), GFP_KERNEL); // 分配pipe_inode_info结构
    	if (!inode->i_pipe)
    		goto fail_page;
    	init_waitqueue_head(PIPE_WAIT(*inode));
    	PIPE_BASE(*inode) = (char*) page;   // 将缓冲区记录在pipe_inode_info结构中
    	PIPE_START(*inode) = PIPE_LEN(*inode) = 0;  // 目前缓冲区没有数据
    	PIPE_READERS(*inode) = PIPE_WRITERS(*inode) = 0; // 将管道的读者和写者数量清零
    	PIPE_WAITING_READERS(*inode) = PIPE_WAITING_WRITERS(*inode) = 0;
    	PIPE_RCOUNTER(*inode) = PIPE_WCOUNTER(*inode) = 1;
    
    	return inode;
    }
    

    当inode所代表的对象是一个管道时,inode.i_pipe将指向一个pipe_inode_info结构,该结构就描述了一个管道的特性:

    struct inode {
    	// ...
    	struct inode_operations	*i_op;
    	struct file_operations	*i_fop;	/* former ->i_op->default_file_ops */
    	struct super_block	*i_sb;
    
    	struct pipe_inode_info	*i_pipe; // 
    	//...
    }
    struct pipe_inode_info {
    	wait_queue_head_t wait;  // 等待队列
    	char *base;			// 指向缓冲区的起点
    	unsigned int start;
    	unsigned int readers;	// 管道有多少读者
    	unsigned int writers;   //       多少写者
    	unsigned int waiting_readers; 
    	unsigned int waiting_writers;
    	unsigned int r_counter;
    	unsigned int w_counter;
    };
    

    pipe_new中调用了kmalloc为管道分配了缓冲区,并将其地址赋值给了base指针。

在继续do_pipe函数前,我想谈一下Linux的虚拟文件系统。Linux号称“一切皆文件”,能够做到这一点Linux的虚拟文件系统功不可没。以我的理解,虚拟文件系统的有两部分骨干,一是数据结构,包括inode、file、inode等,一般情况下再简单的文件系统都有类似的数据结构;二是一些“操作接口”,这才是虚拟文件系统的精髓。以file结构为例,它除了存储一些必须信息外,它还存储了一个file_operations结构的指针,而file_operation结构中存储了很多函数指针:

struct file_operations {
	struct module *owner;
	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 datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
	ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
};

熟悉的函数有read、write、mmap、llseek等。这就是操作接口, file_operations规定了一个file能够做些什么操作,但没有具体实现它,具体的实现则由各自的不同种类的文件实现它,当然也可以仅仅选择几个函数去实现它(比如管道文件就没有实现mmap函数)这看上去很像面向对象语言的接口与实现的关系。

比如对于管道文件的只读端口,do_pipe会将file.fop设置为read_pipe_fops:

int do_pipe(int *fd)
{
	//.....
		// 配置读端file结构
	f1->f_flags = O_RDONLY;
	f1->f_op = &read_pipe_fops;
	// ......
}

struct file_operations read_pipe_fops = {
	llseek:		pipe_lseek,
	read:		pipe_read,
	write:		bad_pipe_w,
	poll:		pipe_poll,
	ioctl:		pipe_ioctl,
	open:		pipe_read_open,
	release:	pipe_read_release,
};

static ssize_t
bad_pipe_w(struct file *filp, const char *buf, size_t count, loff_t *ppos)
{
	return -EBADF;
}

对于读端,其write的具体实现只是返回一个错误值,表示不允许读操作。而且管道在概念上是字符流序列,因此它的llseek函数也是非法的:

static loff_t
pipe_lseek(struct file *file, loff_t offset, int orig)
{
	return -ESPIPE;
}

对于管道文件的只写端口,又会将file.fop设置为write_pipe_fops。

struct file_operations write_pipe_fops = {
	llseek:		pipe_lseek,
	read:		bad_pipe_r,
	write:		pipe_write,
	poll:		pipe_poll,
	ioctl:		pipe_ioctl,
	open:		pipe_write_open,
	release:	pipe_write_release,
};

对于ext2文件,这是Linux上最常规的文件,其file.fop设置为ext2_file_operations:

struct file_operations ext2_file_operations = {
	llseek:		ext2_file_lseek,
	read:		generic_file_read,
	write:		generic_file_write,
	ioctl:		ext2_ioctl,
	mmap:		generic_file_mmap,
	open:		ext2_open_file,
	release:	ext2_release_file,
	fsync:		ext2_sync_file,
};

虽然,管道、ext文件、设备文件等的底层实现各不相同,但对于应用层,能够只使用fd与几个系统调用将这些对象统统当成文件来操作。以read系统调用为例,它对应的内核函数为sys_read:

asmlinkage ssize_t sys_read(unsigned int fd, char * buf, size_t count)
{
	// ...
	file = fget(fd);
	if (file) {
		if (file->f_mode & FMODE_READ) {
			// ...
			if (!ret) {
				ssize_t (*read)(struct file *, char *, size_t, loff_t *);
				if (file->f_op && (read = file->f_op->read) != NULL) // 调用fop的read函数
                     ret = read(file, buf, count, &file->f_pos);
                	// ...
	}
	return ret;
}

不难发现,实际上的调用仅为 file->f_op->read(file, buf, count, &file->f_pos),不用去管各个文件类型的不同底层实现。如此就顺利的实现了“一切皆文件的特性”。当然,虚拟文件系统的“接口”远不止这些,dentry、inode甚至是super_block结构中都有一个类似这样的接口指针,它们一起构成了虚拟文件系统对外部文件系统的接口。

好了,到目前do_pipe函数结束位置,内核已经为shell进程建立了相关数据结构,并正确地设置好了具体的操作函数,示意图如下:

image-20230602170747061

管道的读写

管道的读写本质上是消费者生产者模型。

根据上一节关于Linux需文件系统的介绍,当上层调用者对一个管道文件描述符调用read时,会根据file结构中的f_op->read调用pipe_read函数:

pipe_read(struct file *filp, char *buf, size_t count, loff_t *ppos)
{
	struct inode *inode = filp->f_dentry->d_inode;
	ssize_t size, read, ret;
	// ...
	/* 内核同步操作*/
	ret = -ERESTARTSYS;
	if (down_interruptible(PIPE_SEM(*inode)))
		goto out_nolock;
	// 1. 如果管道缓冲区为空
	if (PIPE_EMPTY(*inode)) {
do_more_read:
		ret = 0;
		if (!PIPE_WRITERS(*inode)) // 如果没有写者,退出循环并返回
			goto out;

		for (;;) {
			PIPE_WAITING_READERS(*inode)++;
			pipe_wait(inode);     // 本进程陷入阻塞
			PIPE_WAITING_READERS(*inode)--;
			ret = -ERESTARTSYS; 
			if (signal_pending(current)) // 如果有信号,则先处理信号,退出循环并返回
				goto out;
			ret = 0;
			if (!PIPE_EMPTY(*inode))   // 检测到管道不为空退出循环,然后读取管道内容
				break;
			if (!PIPE_WRITERS(*inode)) // 如果没有写者,退出循环并返回
				goto out;
		}
	}

	// 2. 拷贝缓冲区中的数据到用户缓冲区
	ret = -EFAULT;
	while (count > 0 && (size = PIPE_LEN(*inode))) {
		char *pipebuf = PIPE_BASE(*inode) + PIPE_START(*inode);
		ssize_t chars = PIPE_MAX_RCHUNK(*inode);

		if (chars > count)
			chars = count;
		if (chars > size)
			chars = size;

		if (copy_to_user(buf, pipebuf, chars))
			goto out;

		read += chars;
		PIPE_START(*inode) += chars;
		PIPE_START(*inode) &= (PIPE_SIZE - 1);
		PIPE_LEN(*inode) -= chars;
		count -= chars;
		buf += chars;
	}
	// 3. 唤醒在管道上睡眠的写者
	if (count && PIPE_WAITING_WRITERS(*inode) && !(filp->f_flags & O_NONBLOCK)) {
		wake_up_interruptible_sync(PIPE_WAIT(*inode));
		if (!PIPE_EMPTY(*inode))
			BUG();
		goto do_more_read;
	}
	wake_up_interruptible(PIPE_WAIT(*inode));

	ret = read;
out:
	up(PIPE_SEM(*inode));
out_nolock:
	if (read)
		ret = read;
	return ret;
}

还是比较简单的,至于管道的写操作,也是不难。需要注意的是如果对一个没有读者的管道进行写操作,那么pipe_write函数就会对本进程发送一个sig_pipe信号

sigpipe:
	if (written)
		goto out;
	up(PIPE_SEM(*inode));
	send_sig(SIGPIPE, current, 0);

当进程收到sig_pipe信号后,默认的行为是终止进程的运行,关于信号的详细原理见上一章。

有名管道 FIFO

匿名管道有着很明显的缺点:匿名管道只能在有亲缘关系的进程之间起作用。有名管道很好地解决了这个问题。

匿名管道的inode只是在slab分配器中分配了一个,它只存在于内存中,且除了shell进程的子进程,其他进程都不能“看到”这个匿名管道;

区别于匿名管道,有名管道的inode是从磁盘中读出来的,这意味着所有的进程都可以在文件系统中“看到”有名管道的inode和文件名,所有的进程都可以使用open打开这个有名管道。初次之外,有名管道与匿名管道就没有区别了,有名管道同样在内存中一个缓冲区,且有名管道的读写函数是与匿名管道一样的。

mknod

那么既然有名管道的inode在磁盘上是有“身份证”的,那么在使用FIFO前,需要先船舰这个FIFO。

shell命令mkfifo,能够创建一个有名管道文件:

$ mkfifo testfifo
$ ll
total 12
drwxrwxr-x 2 ubuntu ubuntu 4096 Jun  3 20:55 ./
drwxrwxr-x 8 ubuntu ubuntu 4096 Jun  3 20:54 ../
-rw-rw-r-- 1 ubuntu ubuntu   14 Jun  3 21:01 regularfile
prw-rw-r-- 1 ubuntu ubuntu    0 Jun  3 20:54 testfifo|

mkfifo将调用内核底层的sys_mknod函数创建一个有名管道文件,实际上只是创建了一个inode节点:

asmlinkage long sys_mknod(const char * filename, int mode, dev_t dev)
{
	// ...
	if (!IS_ERR(dentry)) {
		switch (mode & S_IFMT) {
		case 0: case S_IFREG:
			error = vfs_create(nd.dentry->d_inode,dentry,mode);
			break;
		case S_IFCHR: case S_IFBLK: case S_IFIFO: case S_IFSOCK: // S_FIFO的情况
			error = vfs_mknod(nd.dentry->d_inode,dentry,mode,dev);
                // ...

	return error;
}

vfs_mknod函数也算是虚拟文件系统的一个接口了, 它根据inode.inode_operations中具体的mknod函数在一个具体的文件系统上创建一个有名管道文件:

int vfs_mknod(struct inode *dir, struct dentry *dentry, int mode, dev_t dev)
{
	// ...
	error = dir->i_op->mknod(dir, dentry, mode, dev);
	//.. 
}

一般Linux的主要文件系统是ext文件系统,我们的管道文件也是建立在ext文件系统上的,所以需要ext文件系统提供的具体mknod函数在其上建立有名管道文件。Linux2.4内核中调用的是ext2_mknod函数:

static int ext2_mknod (struct inode * dir, struct dentry *dentry, int mode, int rdev)
{
	struct inode * inode = ext2_new_inode (dir, mode);
	// ...
	init_special_inode(inode, mode, rdev);
	err = ext2_add_entry (dir, dentry->d_name.name, dentry->d_name.len, 
			     inode); // 将新建的节点加进所在目录在磁盘上的目录文件中
	if (err)
		goto out_no_entry;
	mark_inode_dirty(inode);  // 将inode标记位脏!
	d_instantiate(dentry, inode);// 将inode结构与dentry结构联系起来
	// ...
}

可以看到ext2_mknod函数调用ext2_new_inode函数分配一个inode,这个函数不光在内存在中分配了一个inode,同时还修改了磁盘上的相关结构,比如inode_map会有多一个bit被设置为1。

然后调用init_special_inode设置这个inode,我们这里这关注FIFO文件:

void init_special_inode(struct inode *inode, umode_t mode, int rdev)
{
	// ...
	} else if (S_ISFIFO(mode))
		inode->i_fop = &def_fifo_fops;
}
struct file_operations def_fifo_fops = {
	open:		fifo_open,	/* will set read or write pipe_fops */
};

会将inode.i_fop设置为def_fifo_fops,该结构只指定了一个open函数。

ext2_mknod最后调用mark_inode_dirty将这个inode标记为脏,如此一来,该有名管道文件就在磁盘上有了“据点”,所有其他进程都可以在Linux的ext文件系统上“看到”这个有名管道文件。

有名管道的操作

要操作一个FIFO,那么要先调用open函数打开这个有名管道。根据上一节的线索,打开一个管道文件会调用def_fifo_fops结构定义的fifo_open函数:

static int fifo_open(struct inode *inode, struct file *filp)
{
    // ...
	if (!inode->i_pipe) { 
		ret = -ENOMEM;
		if(!pipe_new(inode))  // 注意pipe_new, 与匿名管道一样,都会分配一个内存缓冲区、pipe_info_struct
			goto err_nocleanup;
	}
	filp->f_version = 0;

	switch (filp->f_mode) {
	case 1: // 只读模式
		filp->f_op = &read_fifo_fops;
		// ...
	
	case 2: // 只写
		filp->f_op = &write_fifo_fops;
		// ...
		break;
	
	case 3: // 读写
		filp->f_op = &rdwr_fifo_fops;
		// ...
	}
	// ...
}

如上所示,fifo_open函数会先调用pipe_new函数,该函数很熟悉吧?该函数就是上一章匿名管道用来分配内存缓冲区和pipe_info_strut的结构。

然后视上层调用者输入的模式不同,给file结构的f_op指针赋予不同的值:

struct file_operations read_fifo_fops = { // 只读模式
	llseek:		pipe_lseek,
	read:		pipe_read,
	write:		bad_pipe_w,
	poll:		fifo_poll,
	ioctl:		pipe_ioctl,
	open:		pipe_read_open,
	release:	pipe_read_release,
};

struct file_operations write_fifo_fops = { // 只写模式
	llseek:		pipe_lseek,
	read:		bad_pipe_r,
	write:		pipe_write,
	poll:		fifo_poll,
	ioctl:		pipe_ioctl,
	open:		pipe_write_open,
	release:	pipe_write_release,
};

struct file_operations rdwr_fifo_fops = {
	llseek:		pipe_lseek,
	read:		pipe_read,
	write:		pipe_write,
	poll:		fifo_poll,
	ioctl:		pipe_ioctl,
	open:		pipe_rdwr_open,
	release:	pipe_rdwr_release,
};

与上一章匿名管道的file_operations结构对比,可以发现有名和匿名管道除了poll函数的指针,其余函数指针都是相同的。因此有名管道的读写逻辑与匿名管道相同

SystemV进程间通信

Linux内核为SystemV的IPC提供了一个统一的系统调用:

int syscall(SYS_ipc, unsigned int call, int first,unsigned long second, unsigned long third, void *ptr,long fifth);

这是一个统一接口,能够同时对消息队列、共享内存、信号量进行操作。

其中参数call为具体的操作码:

#define SEMOP		 1
#define SEMGET		 2
#define SEMCTL		 3

#define MSGSND		11
#define MSGRCV		12
#define MSGGET		13
#define MSGCTL		14

#define SHMAT		21
#define SHMDT		22
#define SHMGET		23
#define SHMCTL		24

SEM开头的是信号量相关操作,MSG开头的是消息队列相关,SHM开头的共享内存相关操作。

当然库函数对这个函数做了包装,比如共享内存的常用API为:

  • shmget

  • shmat

  • shmdt

以下参考linux内核2.4版本,与2.6版本相比,几乎没有变化(仅从数据结构来讲)

消息(报文)队列

内核中用来实现消息队列的数据结构及它们之间的关系如下图所示:

image-20230524113020325

  1. 内核中有一个全局数据结构 msg_ids, msg.ids.entries指向一个ipc_id数组
  2. ipc_id数组的每一项都是一个kern_ipc_perm指针,指向一个kern_ipc_perm结构,而该结构内嵌在msg_queue结构中。ipc_id数组的索引下标就是标识
  3. msg_queue代表了一个报文队列,它的kern_ipc_perm.key就是,用户可通过键来向内核标识自己要读取\发送的报文队列。msg_queue维护三个链表头
    • q_messages: 将报文结构msg_msg依次通过它的m_list成员串联起来。且一个msg_msg结构的大小只有一个页,因此如果报文实际内容多余一个页面,则要msg_msgseg结构分段存放,它们通过next指针相连
    • receivers: 如果msg_queue暂时没有消息可获取,则想要接收者进程在本队列阻塞等待。
    • senders:如果msg_queue达到最大容量(q_cbytes == q_qbytes),不能再存放新的消息时,那么发送者在本队列等待。

标识相当于文件标识符fd,而相当于文件名。我们使用键来创建/获取一个msg_queue,之后则使用标识在这个msg_queue中发送和接收消息,这就好比使用文件名来创建/打开一个文件,之后使用fd读写该文件。

简单介绍一下SystemV的消息队列相关API:

  • msgget(key_t key, int msgflg)用户指定一个键值,返回一个标识符(就是上图的ipc_id数组的下标 + 一些额外处理)。如果该键值对应的消息队列未创建,内核会分配一个msg_queue,在ipc_id数组中找到一个空位,使其kern_ipc_perm指针指向msg_queue.kern_ipc_perm。如果该键值对应的消息队列已经创建,则返回这个已经创建队列的标识号。

  • int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);指定一个标识符,向该队列发送报文。如果队列已满,则阻塞等待。

  • msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp,int msgflg); 指定一个标识符,从该队列接收报文。如果队列为空,则阻塞等待。

共享内存

共享内存的效率比较高,但是它没有同步机制保证线程安全,如有必要,可能还要配合SystemV的信号量使用。

与消息队列类似,共享内存也有一个全局的ipc_ids数组名为shm_ids,每个ipc_id数组的是一个kern_ipc_perm指针。与消息队列不同kern_ipc_perm的宿主不是msg_queue而是shmid_kernel结构。

shmid_kernel结构与msg_queue结构除了都有kern_ipc_perm这一个相似点以外,其余大不相同。shmid_kernel最具特点的是,它拥有一个struct file指针

image-20230525214224534

这也暗示了了SystemV的共享内存是通过文件映射机制来完成的。事实上,不论是SystemV,或者是Posix,还是直接使用mmap进行共享内存通信,它们的底层机制都是文件映射,Linux内核底层代码的关键函数实现为do_mmap()。不同的进程选择不同虚拟地址,但是调用shmat函数是指定相同的标识号,那么最终两个进程会将其地址映射到同一个文件上。如此,尽管两个进程的虚拟地址不同,但是它们映射的物理地址都是相同的,这些物理页面组成一个文件。此外,与一般的文件不同,共享内存使用的文件在磁盘的位置是swap空间,且该文件在/dev/shm/目录下,该目录通常用来存储暂时文件,当系统重启时,swap_space将被初始化,那么上次建立的共享内存也就消失了。这样的机制符合共享内存的特点。

其中涉及到多个数据结构多个函数,按照我自己的理解,画了下面这张图作为总结.

image-20230525222006166

主要涉及到两大块的内容

  • 文件系统: 涉及的数据结构包括strut file ,struct inode, struct dentrt, struct address_space等
  • do_mmap函数: 建立用户空间与物理内存的映射

文件系统比较复杂,尤其是linux存在虚文件系统这一额外的层次, 而do_mmap要与内核的内存管理,文件系统两大模块打交道,因此也比较复杂.

感兴趣的可以看看 <linux内核源代码情景分析> 作为参考

简单介绍一下SystemV共享内存相关的API:

  • int shmget(key_t key, size_t size, int shmflg) : 获取 \ 开辟一个共享内存,返回其标识(即内核数据结构shm_ids所指向数组的数组下标, 当然还要加上一些额外处理,防止重复)
  • void *shmat(int shmid, const void *shmaddr, int shmflg) : 将本进程的虚拟地址shmaddr映射到内核所维护的共享内存中(即一个文件在内存的缓存)
  • shmdt : 消除映射

man page上有一个使用示例.

信号量

SystemV的信号量与一般意义上的信号量有较大不同,最大的区别是SystemV的信号量其实是一个信号量的集合,用户可通过对应的系统调用对这一信号量的集合做原子性的修改。

关于信号量的图我就不自己画了,截一张《深入Linux内核架构》的图片:

image-20230526112827510

可以看到,同样有一个全局数据结构ipc_ipds管理信号量,其中sem_base指针指向一个由struct sem结构组成的数组,该数组就是信号量的一个集合。那些被信号量操作阻塞的进程则在sem_pending链表中等待。

posix信号量

(新的)信号量可以用来进程间通信,也可以用来线程间通信,要看pshared的值的设定

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
The pshared argument indicates whether this semaphore is to be
shared between the threads of a process, or between processes.

If pshared has the value 0, then the semaphore is shared between
the threads of a process, and should be located at some address
that is visible to all threads (e.g., a global variable, or a
variable allocated dynamically on the heap).

If pshared is nonzero, then the semaphore is shared between
processes, and should be located in a region of shared memory
(see shm_open(3), mmap(2), and shmget(2)).  (Since a child
created by fork(2) inherits its parent's memory mappings, it can
also access the semaphore.)  Any process that can access the
shared memory region can operate on the semaphore using
sem_post(3), sem_wait(3), and so on.

之前版本的信号量只能用来进程间通信,可以看看sem_t的初始化函数:sem_init.c - nptl/sem_init.c - Glibc source code (glibc-2.37.9000) - Bootlin

int
attribute_compat_text_section
__old_sem_init (sem_t *sem, int pshared, unsigned int value)
{
  ASSERT_PTHREAD_INTERNAL_SIZE (sem_t, struct new_sem);

  /* Parameter sanity check.  */
  if (__glibc_unlikely (value > SEM_VALUE_MAX))
    {
      __set_errno (EINVAL);
      return -1;
    }

  /* Map to the internal type.  */
  struct old_sem *isem = (struct old_sem *) sem;

  /* Use the value the user provided.  */
  isem->value = value;

  /* We cannot store the PSHARED attribute.  So we always use the
     operations needed for shared semaphores.  */

  return 0;
}

ptrace

待补充

socket

待补充

参考资料

  • 《linux内核源代码情景分析》
  • 《深入linux内核架构》
  • Linux2.4
  • Linux2.6.32.10
  • glibc-2.37
  • xv6(x86版本)

标签:pipe,struct,int,间通信,源码,file,Linux,进程,inode
From: https://www.cnblogs.com/HeyLUMouMou/p/17454744.html

相关文章

  • Request类源码分析、序列化组件介绍、序列化类的基本使用、常用字段类和参数、反序列
    目录一、Request类源码分析二、序列化组件介绍三、序列化类的基本使用查询所有和查询单条四、常用字段类和参数(了解)常用字段类字段参数(校验数据来用的)五、反序列化之校验六、反序列化之保存七、APIVIew+序列化类+Response写的五个接口代码八、序列化高级用法之source(了解)九、......
  • 使用vscode sftp插件快速上传源码文件
    1.首先安装vscode插件2.使用ctrl+shift+p或者view-commandpalette打开命令面板,输入sftp并按enter键,出现编辑配置文件界面3.输入对应的主机名,密码,或者密钥文件即可{"name":"47.100.101.152","host":"47.100.101.152","protocol":"sftp",......
  • linux 中awk命令实现输出匹配字符的上下若干行
     001、[root@PC1test3]#lstest.txt[root@PC1test3]#cattest.txt##测试数据jjjjkkkgenejjjddddyyyiiiipppffff999genettteeeemmmaaaannn[root@PC1test3]#awk'BEGIN{idx=0}{ay1[NR]=$0;if($1=="......
  • LRU缓存与LinkedHashMap源码
    今天再刷LeetCode时,遇到了第146题LRU缓存。题目如下:请你设计并实现一个满足LRU(最近最少使用)缓存约束的数据结构。实现LRUCache类:LRUCache(intcapacity)以正整数作为容量capacity初始化LRU缓存intget(intkey)如果关键字key存在于缓存中,则返回关键字的值,否......
  • linux 计算机基础
    1.  GPL、BSD、MIT、Mozilla、Apache和LGPL的区别  GPLGPL许可证的核心:允许任何人观看、修改,并散播程序软件里的原始程序码,条件是如果你要发布修改后的版本就要连源代码一起公布,不允许修改后和衍生的代码做为闭源的商业软件发布和销售。Linux就是采用了GPL协议。......
  • (五)Spring源码解析:ApplicationContext源码解析
    一、概述1.1>整体概览在前面的内容中,我们针对BeanFactory进行了深度的分析。那么,下面我们将针对BeanFactory的功能扩展类ApplicationContext进行深度的分析。ApplicationConext与BeanFactory的功能相似,都是用于向IOC中加载Bean的。由于ApplicationConext的功能是大于BeanFactory的......
  • 【Linux中断】Linux系统中断机制简述
    Linux中断Linux中断处理过程1.使能中断,初始化相应的寄存器2.注册中断服务函数,也就是向irqTable数组的指定标号处写入中断服务函数3.中断发生以后进入IRQ中断服务函数,IRQ的中断服务函数在irqTable里面查找具体的中断处理函数,找到以后执行相应的中断处理函数Linux中断处理API函......
  • 深剖 Linux 信号量
    目录传统艺能......
  • linux 正则表达式
    目录一、正则表达式二、元字符三、次数符号四、位置锚定五、实验              一、正则表达式通配符功能是用来处理文件名,而正则表达式是处理文本内容中字符。分类:1. 基本正则表达式2.扩展正则表达式二......
  • 大件货运系统源码,技术架构:spring boot、mybatis、redis、vue、element-ui
    网络货运平台源码网络货运平台的功能网络货运是指利用互联网平台,通过物流配送的方式进行商品销售和物流运输的一种新型商业模式。这种模式将传统的货运模式与互联网技术相结合,通过网络平台进行交易、物流配送和结算等一系列流程,从而实现货物的快速、高效、便捷地运输。技术架构:spr......