相关文章推荐
玩足球的手套  ·  如何修复java中action ...·  1 年前    · 
踏实的刺猬  ·  vue.js - nuxt.js ...·  1 年前    · 
xnu(MacOS, iOS)里管道的实现

xnu(MacOS, iOS)里管道的实现

version: xnu-7195.121.3

管道是我们在系统中经常用到的进程间通讯的方式,但因为常用几乎没有只系想过它是怎么实现的,最近刚好在debug一个与管道相关问题,发现如果掌握管道的实现对系统各个模块的理解和配合会有很大的帮助。其中涉及libC,系统调用,CPU架构及中断,vfs,管道的实现,以及一些文件系统的知识。

管道在用户空间的使用

Ref: (转):从内核代码聊聊pipe的实现 - 笨拙的菜鸟 - 博客园

在了解实现之前先简单看看管道是怎么使用的,

一开始接触Linux或者MacOS,相信很多人都是从命令开始;当一个命令的输出,需要作为另一个命令的输入时,我们就会使用管道来实现这个功能;例如,我们经常需要在某个文档中查找是否存在某个单词,我们就可以用如下方式:

cat test.txt | grep 'hello'

这行命令表示在test.txt文件中查找包含单词'hello'的句子。我们先解释下这行命令是怎么实现的;

我们知道终端也是一个进程,当我们输入一个命令执行时,其实是终端程序调用fork和exec产生一个子进程执行命令程序;当终端在执行这行命令时,会先解析输入的参数,当发现输入的命令行中有‘|’符号时,就会知道在命令行中包含了管道,因此,在终端程序中,

会先fork出一个子进程,并执行exec将cat载入内存;

接着在cat程序中,用函数pipe定义出管道;

在定义出管道之后,再调用fork,生成一个子进程;

在父进程cat中关闭管道读端,将cat进程的标准输出重定向到管道的写端;

在子进程中将管道的写端关闭,将标准输入重定向到管道的读端,再调用exec将grep进程载入内存;

最后,cat的输出就可以最为grep的输入了;

这里需要说明的是,父进程cat对管道的操作必须在fork之前,否则父进程cat对管道的操作会继承到子进程,这样会导致子进程无法读取父进程的数据

主要涉及几个系统调用函数:

pipe(int fildes[2]); //用于新建管道,返回两个fd(file description)给用户,0是读端,1是写端
read / write / open / close //都是对文件操作的函数,这里我们可以直接对pipe返回的两个fd进行文件操作

系统调用

系统调用是用户态与内核态之间的桥梁,当程序在用户空间中调用系统调用,那么当前线程便可以进入到内核态中,系统在高权级环境下执行功能。

其调用顺序大概是这样的

以我们今天用到的pipe为例,

1. 应用程序调用pipe, 并带上两个有fd的数组指针

2. libC其实做的只是包装一层,实际上会触发进入内核的操作

3. 系统进入内核态后,会根据应用程序进入内核前提供的系统调用id找到对应系统调用要执行的函数pipe

4. 在内核中完成pipe的创建并给用户返回两个文件句柄

但是系统调用在xnu的实现会稍微有点复杂,一个是分为x86和arm32/64,另一个是xnu的syscall中还分几种,主要是mach和unix的syscall

进入内核

像pipe,read,write等API都是由libC提供的,我们在写代码的时候也经过会引用这个头文件unistd.h (POSIX标准),应用程序就是通过这些调用进入内核的

### x86

Ref: The life of an XNU unix syscall on amd64

在x86中,syscall一共分为5种,系统调用的id 会加一个用class num左移24位的offset,例如pipe属于unix的syscall,pipe的syscall id为42,所以最终在xnu的syscall id为0x200000002A

syscall的分类

// Ref: xnu/osfmk/mach/i386/syscall_sw.h
#define SYSCALL_CLASS_NONE  0   /* Invalid */
#define SYSCALL_CLASS_MACH  1   /* Mach */  
#define SYSCALL_CLASS_UNIX  2   /* Unix/BSD */
#define SYSCALL_CLASS_MDEP  3   /* Machine-dependent */
#define SYSCALL_CLASS_DIAG  4   /* Diagnostics */
#define SYSCALL_CLASS_IPC   5   /* Mach IPC */
/* Macros to simpllfy constructing syscall numbers. */
#define SYSCALL_CONSTRUCT_MACH(syscall_number) \
            ((SYSCALL_CLASS_MACH << SYSCALL_CLASS_SHIFT) | \
             (SYSCALL_NUMBER_MASK & (syscall_number)))
#define SYSCALL_CONSTRUCT_UNIX(syscall_number) \
            ((SYSCALL_CLASS_UNIX << SYSCALL_CLASS_SHIFT) | \
             (SYSCALL_NUMBER_MASK & (syscall_number)))
#define SYSCALL_CONSTRUCT_MDEP(syscall_number) \
            ((SYSCALL_CLASS_MDEP << SYSCALL_CLASS_SHIFT) | \
             (SYSCALL_NUMBER_MASK & (syscall_number)))
#define SYSCALL_CONSTRUCT_DIAG(syscall_number) \
            ((SYSCALL_CLASS_DIAG << SYSCALL_CLASS_SHIFT) | \
             (SYSCALL_NUMBER_MASK & (syscall_number)))

传统x86中,进入系统调用有三种方法:

- 特定的中断向量

在xnu/osfmk/x86_64/idt_table.h 中有定义中断向量表,以及中断处理函数,但实际上在idt64.s中对USER_TRAP_SPC做了特殊处理,

0x80,0x81,0x82中断会被忽略

/* A trap with a special-case handler, hence we don't need to define anything */
#define USER_TRAP_SPC(n, f)
USER_TRAP_SPC(0x80, idt64_unix_scall)
USER_TRAP_SPC(0x81, idt64_mach_scall)
USER_TRAP_SPC




    
(0x82, idt64_mdep_scall)

- SYSENTER/SYSCALL

SYSENTER和SYSCALL 都是x86体系支持的指令

在初始化代码中xnu/osfmk/i386/mp_desc.c中,可以看到系统分别会让SYSENTER 和SYSCALL 跳到 hi64_sysenter 和 hi64_syscall

/*
 * Set MSRs for sysenter/sysexit and syscall/sysret for 64-bit.
cpu_syscall_init(cpu_data_t *cdp)
#pragma unused(cdp)
    wrmsr64(MSR_IA32_SYSENTER_CS, SYSENTER_CS);
    wrmsr64(MSR_IA32_SYSENTER_EIP, DBLMAP((uintptr_t) hi64_sysenter));
    wrmsr64(MSR_IA32_SYSENTER_ESP, current_cpu_datap()->cpu_desc_index.cdi_sstku);
    /* Enable syscall/sysret */
    wrmsr64(MSR_IA32_EFER, rdmsr64(MSR_IA32_EFER) | MSR_IA32_EFER_SCE);
     * MSRs for 64-bit syscall/sysret
     * Note USER_CS because sysret uses this + 16 when returning to
     * 64-bit code.
    wrmsr64(MSR_IA32_LSTAR, DBLMAP((uintptr_t) hi64_syscall));
    wrmsr64(MSR_IA32_STAR, (((uint64_t)USER_CS) << 48) | (((uint64_t)KERNEL64_CS) << 32));
     * Emulate eflags cleared by sysenter but note that
     * we also clear the trace trap to avoid the complications
     * of single-stepping into a syscall. The nested task bit
     * is also cleared to avoid a spurious "task switch"
     * should we choose to return via an IRET.
    wrmsr64(MSR_IA32_FMASK, EFL_DF | EFL_IF | EFL_TF | EFL_NT);
}

附:MSR(Model Specific Register)是x86架构中的概念,指的是在x86架构处理器中,一系列用于控制CPU运行、功能开关、调试、跟踪程序执行、监测CPU性能等方面的寄存器。

在 MSR的定义中,CPU在处理sysenter时,会把rip设置成MSR_IA32_SYSENTER_EIP的值,处理syscall时,会把rip设置成MSR_IA32_LSTAR的值。

然而hi64_sysenter,实际上一路跟踪代码,在非32位的CPU上并不会真正执行对应的功能,而是会返回INVALID

所以其实只用syscall指令真正能执行系统调用,从hi64_syscall —> ... —> hndl_syscall —> hndl_unix_scall64 —> unix_syscall64

在 unix_syscall64中会调取从sysent调取syscall id对应的处理函数,sysent是如何生成的会在下一节介绍

callp = &sysent[syscode];

所以回到本节一开始提到的unistd实现pipe或者其他系统调用的API时,只需要简单的包装一层,直接使用SYSCALL指令,并给寄存器赋上正确的值即可

### ARM64

Ref: https://stackzverflow.com/questions/56985859/ios-arm64-syscalls

相对于x86复杂的调用栈,ARM64上会显得简单很多,用户可以使用SVC触发软中断

ARM64的exception level的设计,把 CPU运行状态分为EL0和EL1,当从EL0进入EL1的时候,xnu都会统一调用fleh_synchronous / sleh_synchronous

sleh_synchronous根据ESR 得到exception的类型,再通过读x16获得系统调用的id

fleh_synchronous —> sleh_synchronous —> handle_svc


根据handle_svc的代码,arm64下的系统调用也大概分几种

- The canonical source for "UNIX syscalls" is the file bsd/kern/syscalls.master in the XNU source tree. Those take syscall numbers from 0 up to about 540 in the latest iOS 13 beta.

- The canonical source for "Mach syscalls" is the file osfmk/kern/syscall_sw.c in the XNU source tree. Those syscalls are invoked with negative numbers between -10 and -100 (e.g. -28 would be task_self_trap).

- Unrelated to the last point, two syscalls mach_absolute_time and mach_continuous_time can be invoked with syscall numbers -3 and -4 respectively.

- A few low-level operations are available through platform_syscall with the syscall number 0x80000000.


如果syscode是正数,就会调用unix_syscall

在unix_syscall中,和x86的处理方法一样,也会调用syscall id对应的处理函数

callp   = &sysent[syscode];

所以libC做的是在x16写入syscall id,同时也可以在x0-x8写入相关上下文,然后就执行SVC即可

系统调用表


在xnu中,BSD(unix)的系统调用表sysent是由shell脚本自动生成的,生成脚本是/xnu/bsd/kern/makesyscalls.sh

而makesyscalls.sh是读取xnu/bsd/kern/syscalls.master生成的,能很简单的找到

42	AUE_PIPE	ALL	{ int pipe(void); }

所以每次当我们使用unix系统调用,并且syscall id 为42时,系统在内核态就会运行pipe(void)

管道的实现


上一章我们讲到应用程序使用系统调用后,无论x86还是arm64最终都会进入到内核,并通过查表执行内核的pipe函数

具体实现在bsd/kern/sys_pipe.c

不过有没有想过,为什么新建一个管道,明明用来传数据的,最后给用户返回的却是两个fd,而且fd的操作方法和普通的文件一样,

可以用open/close打开和关闭,也可以用read/write来读写?

vfs

上面的这个问题就设计到unix强大的vfs了,在unix世界中,万物皆为文件。虚拟文件系统(Virtual File System)对系统的文件进行了抽象,基本架构和Linux是一样的,它是一个内核软件层,在具体的文件系统之上抽象的一层,用来处理与Posix文件系统相关的所有调用,表现为能够给各种文件系统提供一个通用的接口,使上层的应用程序能够使用通用的接口访问不同文件系统,同时也为不同文件系统的通信提供了媒介。

比方说,用户写入一个文件,使用POSIX标准的write接口,会被操作系统接管,转调sys_write这个系统调用。然后VFS层接受到这个调用,通过自身抽象的模型,转换为对给定文件系统、给定设备的操作,这一关键性的步骤是VFS的核心,需要有统一的模型,使得对任意支持的文件系统都能实现系统的功能。这就是VFS提供的统一的文件模型(common file model),底层具体的文件系统负责具体实现这种文件模型,负责完成POSIX API的功能,并最终实现对物理存储设备的操作。


下面是VFS抽象模型支持的大部分系统调用:

文件系统相关:mount, umount, umount2, sysfs, statfs, fstatfs, fstatfs64, ustat

目录相关:chroot,pivot_root,chdir,fchdir,getcwd,mkdir,rmdir,getdents,getdents64,readdir,link,unlink,rename,lookup_dcookie

链接相关:readlink,symlink

文件相关:chown, fchown,lchown,chown16,fchown16,lchown16,hmod,fchmod,utime,stat,fstat,lstat,acess,oldstat,oldfstat,oldlstat,stat64,lstat64,lstat64,open,close,creat,umask,dup,dup2,fcntl, fcntl64,select,poll,truncate,ftruncate,truncate64,ftruncate64,lseek,llseek,read,write,readv,writev,sendfile,sendfile64,readahead


pipefs


所以很容易理解,系统里管道同样也实现了一套这样的vfs实现,我们称为pipefs

在xnu/bsd/kern/sys_pipe.c中我们能找到pipe所支持的vfs的操作

static const struct fileops pipeops = {
    .fo_type     = DTYPE_PIPE,
    .fo_read     = pipe_read,
    .fo_write    = pipe_write,
    .fo_ioctl    = pipe_ioctl,
    .fo_select   = pipe_select,
    .fo_close    = pipe_close,
    .fo_drain    = pipe_drain,
    .fo_kqfilter = pipe_kqfilter,
};


管道的创建


在之前系统调用中有提到,应用程序调用pipe函数创建管道,最终在内核中执行的是同名函数`pipe(proc_t p, __unused struct pipe_args *uap, int32_t *retval)`

最后申请的两个fd会分别写在retval[0] / retval[1],随后只要该进程p对fd进行打开、关闭、读写等操作,就会对应pipefs的操作了

int
pipe(proc_t p, __unused struct pipe_args *uap, int32_t *retval)
	struct fileproc *rf, *wf;
	struct pipe *rpipe, *wpipe;
	int error;
	/* 写端和读端是成对获取,成对管理的,这里先获取一个pipepair,分别包含rpipe和wpipe */
	error = pipepair_alloc(&rpipe, &wpipe); 
	if (error) {
		return error;
	 * for now we'll create half-duplex pipes(refer returns section above).
	 * this is what we've always supported..
	/* 为读端申请fd */
	error = falloc(p, &rf, &retval[0], vfs_context_current());
	if (error) {
		goto freepipes;
	rf->f_flag = FREAD;
	rf->f_data = (caddr_t)rpipe;
	rf->f_ops = &pipeops;  // 重点:把读端的handler函数设置成pipeops
	/* 为写端申请fd */
	error = falloc(p, &wf, &retval[1], vfs_context_current());
	if (error) {
		fp_free(p, retval[0], rf);
		goto freepipes;
	wf->f_flag = FWRITE;
	wf->f_data = (caddr_t)wpipe;
	wf->f_ops = &pipeops;  // 重点:把写端的handler函数也设置成pipeops
	rpipe->pipe_peer = wpipe;
	wpipe->pipe_peer = rpipe;
	/* 最后做一些清理操作 */
	proc_fdlock_spin(p);
	procfdtbl_releasefd(p, retval[0], NULL);
	procfdtbl_releasefd(p, retval[1], NULL);
	fp_drop(p, retval[0], rf, 1);
	fp_drop(p, retval[1], wf, 1);
	proc_fdunlock(p);
	return 0;