“一切皆是文件”是 Unix/Linux的基本哲学之一。不仅普通的文件,目录、字符设备、块设备、套接字等在 Unix/Linux中都是以文件被对待;它们虽然类型不同,但是对其提供的却是同一套操作界面。如下图:
为了能够支持各种实际文件系统,VFS定义了所有文件系统都支持的基本的、概念上的接口和数据结构;同时实际文件系统也提供VFS所期望的抽象接口和数据结构,将自身的诸如文件、目录等概念在形式上与VFS的定义保持一致。
VFS数据结构:
(1)一些基本概念
超级块(super_block): 记录此文件系统整体信息的数据结构。描述文件系统的状态、文件系统类型、大小、区块数、索引节点数等,存放于磁盘的特定扇区中。
索引节点(inode):用于存储文件的元数据的一个数据结构。文件的元数据,也就是文件的相关信息,和文件本身是两个不同的概念。一个索引节点就包含了一个文件的所有信息如数据在磁盘上的地址,大小,文件类型,修改,创建日期,数据块,目录块等(但是不包含文件名)。
目录项(dentry: directory entry):目录项和超级块和索引节点不同,目录项并不是实际存在于磁盘上的。在使用的时候在内存中创建目录项对象,其实通过索引节点已经可以定位到指定的文件,但是索引节点对象的属性非常多,在查找,比较文件时,直接用索引节点效率不高,所以引入了目录项的概念。
文件对象(file):文件对象表示进程已打开的文件,从用户角度来看,在代码中操作的就是一个文件对象。而目录好比一个文件夹,用来容纳相关文件。因为目录可以包含子目录,所以目录是可以层层嵌套,形成文件路径。在Linux中,目录也是以一种特殊文件被对待的,所以用于文件的操作同样也可以用在目录上。
关于文件系统的三个易混淆的概念:
创建:以某种方式格式化磁盘的过程就是在其之上建立一个文件系统的过程。创建文现系统时,会在磁盘的特定位置写入关于该文件系统的控制信息。
注册:向内核报到,声明自己能被内核支持。一般在编译内核的时侯注册;也可以加载模块的方式手动注册。注册过程实 际上是将表示各实际文件系统的数据结构struct file_system_type 实例化。
安装:也就是我们熟悉的mount操作,将文件系统加入到Linux的根文件系统的目录树结构上;这样文件系统才能被访问。
(2)VFS数据结构
VFS依靠四个主要的数据结构和一些辅助的数据结构来描述其结构信息,这些数据结构表现得就像是对象;
1)超级块对象
存储一个已安装的文件系统的控制信息,代表一个已安装的文件系统;每次一个实际的文件系统被安装时,内核会从磁盘的特定位置读取一些控制信息来填充内存中的超级块对象。如下图:
2) 索引节点对象
索引节点对象存储了文件的相关信息,代表了存储设备上的一个实际的物理文件。如下图:
3)目录项对象
引入目录项的概念主要是出于方便查找文件的目的。一个路径的各个组成部分,不管是目录还是普通的文件,都是一个目录项对象。如下图:
4)文件对象
文件对象是已打开的文件在内存中的表示,主要用于建立进程和磁盘上的文件的对应关系。它由sys_open()现场创建,由sys_close()销毁。如下图:
5)其他VFS对象
根据文件系统所在的物理介质和数据在物理介质上的组织方式来区分不同的文件系统类型的。file_system_type结构用于描述具体的文件系统的类型信息。被Linux支持的文件系统,都有且仅有一个file_system_type结构而不管它有零个或多个实例被安装到系统中。
而与此对应的是每当一个文件系统被实际安装,就有一个vfsmount结构体被创建,这个结构体对应一个安装点。
进程通过task_struct中的一个域files_struct files来关联它当前所打开的文件对象;而我们通常所说的文件描述符其实是进程打开的文件对象数组的索引值。
文件对象通过域f_dentry找到它对应的dentry对象,再由dentry对象的域d_inode找到它对应的索引结点,这样就建立了文件对象与实际的物理文件的关联。
通过系统调用sys_open()和sys_read()来更好地理解VFS向具体文件系统提供的接口机制。
下图描述了从用户空间的read()调用到数据从磁盘读出的整个流程。当在用户应用程序调用文件I/O read()操作时,系统调用sys_read()被激发,sys_read()找到文件所在的具体文件 系统,把控制权传给该文件系统,最后由具体文件系统与物理介质交互,从介质中读出数据。如下图:
1)sys_open()
sys_open()系统调用打开或创建一个文件,成功返回该文件的文件描述符。下图是sys_open()实现代码中主要的函数调用关系图。
2)sys_read()
sys_read()系统调用用于从已打开的文件读取数据。如read成功,则返回读到的字节数。如已到达文件的尾端,则返回0。
2、ARM-Linux进程间通信
1)进程通信概念
Linux进程用户空间是相互独立的,一般而言是不能相互访问的。但很多情况下进程间需要互相通信,来完成系统的某项功能。在Linux下的多个进程间的通信机制叫做IPC(Inter-Process Communication) 。
2)进程通信目的
数据传输:一个进程需要将它的数据发送给另一个进程,发送的数据量在一个字节到几兆字节之间。
共享数据:多个进程想要操作共享数据,一个进程对共享数据的修改,别的进程应该立刻看到。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
资源共享:多个进程之间共享同样的资源。为了作到这一点,需要内核提供锁和同步机制。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
在Linux下有多种进程间通信的方法:半双工管道(pipe)、命名管道(FIFO)、消息队列、信号、信号量、共享内存、内存映射、套接字等。如下图:
管道(Pipe)及有名管道(Named Pipe):管道可用于具有亲缘关系进程间的通信。有名管道除具有管道所具有的功能外,还允许无亲缘关系进程间的通信。
信号(Signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式,用于通知进程有某事件发生,一个进程收到一个信号与处理器收到一个中断请求效果上可以说是一样的。
消息队列(Messge Queue):消息队列是消息的链接表,包括Posix消息队列和System V 消息队列。它克服了前两种通信方式中信息量有限的缺点,具有写权限的进程可以按照一定的规则向消息队列中添加新消息;对消息队列有读权限的进程则可以从消息队列中读取消息。
共享内存(Shared Memory):可以说这是最有效的进程间通信方式。它使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种通信方式需要依靠某种同步机制,如互斥锁和信号量等。
信号量(Semaphore):主要作为进程之间及同一进程的不同线程之间的同步和互斥手段。
套接字(Socket):这是一种更为一般的进程间通信机制,它可用于网络中不同机器之间的进程间通信,应用非常广泛。
1)管道
管道是Linux 中进程间通信的一种方式,它把一个程序的输出直接连接到另一个程序的输入。Linux 的管道主要包括两种:无名管道和命名管道。
(1)无名管道
无名管道有几个重要的限制:
无名管道是半双工的,数据只能在一个方向上流动,A进程传给B进程,不能反向传递管道只能用于父子进程或兄弟进程之间的通信,即具有亲缘关系的进程。如下图:
无名管道的特点:
1)无名管道是半双工的
2)无名管道没有名字:只能用于父子进程或者兄弟进程之间(具有亲缘关系的进程)。
3)无名管道不是普通的文件,并且只存在与内存中。
4)无名管道的缓冲区是有限的,该缓冲区的大小为4Kbyte。
int pipe(int file_descriptor[2]);//建立管道,该函数在数组上填上两个新的文件描述符后返回0,失败返回-1。
eg.int fd[2]
int result = pipe(fd);
通过使用底层的read和write调用来访问数据。向file_descriptor[1]写数据,从file_descriptor[0]中读数据。写入与读取的顺序原则是先进先出。
(2)命名管道(FIFO)
命名管道是一种特殊类型的文件,它在系统中以文件形式存在。这样克服了无名管道的弊端,他可以允许没有亲缘关系的进程间通信。如下图:
命名管道特点:
1)FIFO在文件系统中作为一个特殊的文件而存在。
2)虽然FIFO文件存在于文件系统中,但FIFO中的内容却存放在内存中,在Linux中,该缓冲区的大小为4Kbyte。
3)FIFO有名字,不同的进程可以通过该命名管道进行通信
4)FIFO所传送的数据是无格式的。
5)从FIFO读数据是一次性操作,数据一旦被读,它就从FIFO中被抛弃,释放空间以便写更多的数据。
6)当共享FIFO的进程执行完所有的I/O操作以后,FIFO将继续保存在文件系统中以便以后使用。
int mkfifo(const char *filename,mode_t mode); //建立一个名字为filename的命名管道,参数mode为该文件的权限(mode%~umask),若成功则返回0,否则返回-1,错误原因存于errno中。
例如:mkfifo( "/tmp/cmd_pipe", S_IFIFO | 0666 );
具体操作方法只要创建了一个命名管道然后就可以使用open、read、write等系统调用来操作。创建可以手工创建或者程序中创建。
int mknod(const char *path, mode_t mode, dev_t dev); //第一个参数表示你要创建的文件的名称,第二个参数表示文件类型,第三个参数表示该文件对应的设备文件的设备号。只有当文件类型为 S_IFCHR 或 S_IFBLK 的时候该文件才有设备号,创建普通文件时传入0即可。
2)信号(signal)
信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,进程不必通过任何操作来等待信号的到达。信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件。
信号来源:
信号事件的发生有两个来源:硬件来源,比如我们按下了键盘或者其它硬件故障;软件来源,最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。
进程对信号的响应:
进程可以通过三种方式来响应信号:(1)忽略信号,即对信号不做任何处理,但是有两个信号是不能忽略的:SIGKLL和SIGSTOP;(2)捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数;(3)执行缺省操作,Linux对每种信号都规定了默认操作。如下图:
void (*signal(int sig,void (*func)(int)))(int); //用于截取系统信号,第一个参数为信号,第二个参数为对此信号挂接用户自己的处理函数指针。返回值为以前信号处理程序的指针。
eg.int ret = signal(SIGSTOP, sig_handle);
int kill(pid_tpid,int sig); //kill函数向进程号为pid的进程发送信号,信号值为sig。当pid为0时,向当前系统的所有进程发送信号sig。
int raise(int sig);//向当前进程中自举一个信号sig, 即向当前进程发送信号。
unsigned int alarm(unsigned int seconds); //alarm()用来设置信号SIGALRM在经过参数seconds指定的秒数后传送给目前的进程。如果参数seconds为0,则之前设置的闹钟会被取消,并将剩下的时间返回。使用alarm函数的时候要注意alarm函数的覆盖性,即在一个进程中采用一次alarm函数则该进程之前的alarm函数将失效。
int pause(void); //使调用进程(或线程)睡眠状态,直到接收到信号,要么终止,或导致它调用一个信号捕获函数。
3)消息队列(Message queues)
消息队列是内核地址空间中的内部链表,具有特定的格式,存放在内存中并由消息队列标识符标识,并且允许一个或多个进程向它写入与读取消息。消息队列通过Linux内核在各个进程直接传递内容,消息顺序地发送到消息队列中,并以几种不同的方式从队列中获得,每个消息队列可以用IPC标识符唯一地进行识别。
消息队列克服了信号承载信息量少的问题,管道只能承载无格式字符流。
(1)消息缓冲区结构
在结构中有两个成员,mtype为消息类型,用户可以给某个消息设定一个类型,可以在消息队列中正确地发送和接受自己的消息。mtext为消息数据,采用柔性数组,用户可以重新定义msgbuf结构。
例如:
struct msgbuf{
long mtype;
char mtext[MSGMAX];//柔性数组
}
消息队列的实现包括创建或打开消息队列、添加消息、读取消息和控制消息队列4种操作:
msgget() 创建或打开消息队列函数,这里创建的消息队列的数量会受到系统消息队列数量的限制;
msgsnd()添加消息函数,它把消息添加到已打开的消息队列末尾;
msgrcv()读取消息函数,它把消息从消息队列中取走,与FIFO 不同的是,这里可以取走指定的某一条消息;
msgctl()控制消息队列函数,它可以完成多项功能。
(2)消息队列的本质
Linux的消息队列(queue)实质上是一个链表,它有消息队列标识符(queue ID)。
(3)消息队列与命名管道的比较
消息队列跟命名管道有不少的相同之处,通过与命名管道一样,消息队列进行通信的进程可以是不相关的进程,同时它们都是通过发送和接收的方式来传递数据的。
在命名管道中,发送数据用write,接收数据用read,则在消息队列中,发送数据用msgsnd,接收数据用msgrcv。而且它们对每个数据都有一个最大长度的限制。
与命名管道相比,消息队列的优势在于:(1)消息队列也可以独立于发送和接收进程而存在,从而消除了在同步命名管道的打开和关闭时可能产生的困难。(2)同时通过发送消息还可以避免命名管道的同步和阻塞问题,不需要由进程自己来提供同步方法。(3)接收程序可以通过消息类型有选择地接收数据,而不是像命名管道中那样,只能默认地接收。
(4)信号量(Semaphore)
信号量实质上就是一个标识可用资源数量的计数器,它的值总是非负整数。它们常常被用作一个锁机制,在某个进程正在对特定的资源进行操作时,信号量可以防止另一个进程去访问它。
在创建信号量时,根据信号量取值的不同,POSIX信号量还可以分为:
二值信号量:信号量的值只有0和1,这和互斥量很类似,若资源被锁住,信号量的值为0,若资源可用,则信号量的值为1;
计数信号量:信号量的值在0到一个大于1的限制值之间,该计数表示可用的资源的个数。
信号量是一种特殊的变量,它只取正整数值并且只允许对这个值进行两种操作:等待(wait)和信号(signal)。(P、V操作,P用于申请资源,V用于释放资源)。
p(sv):如果sv的值大于0,就给它减1;如果它的值等于0,就挂起该进程的执行;
V(sv):如果有其他进程因等待sv而被挂起,就让它恢复运行;如果没有其他进程因等待sv而挂起,则给它加1。
1)信号量数据结构
union semun{
int val;
struct semid_ds *buf;
unsigned short *array;
struct seminfo *__buf;
}
2)信号量操作sembuf结构
struct sembuf{
ushortsem_num;//信号量的编号
short sem_op;//信号量的操作。如果为正,则从信号量中加上一个值,如果为负,则从信号量中减掉一个值,如果为0,则将进程设置为睡眠状态,直到信号量的值为0为止。
short sem_flg;//信号的操作标志,一般为IPC_NOWAIT。
}
int semget(key_t key, int num_sems, int sem_flags); //semget函数用于创建一个新的信号量集合,或者访问一个现有的集合(不同进程只要key值相同即可访问同一信号量集合)。第一个参数key是ftok生成的键值,第二个参数num_sems可以指定在新的集合应该创建的信号量的数目,第三个参数sem_flags是打开信号量的方式。
int semop(int sem_id, struct sembuf *sem_ops, size_tnum_sem_ops); //semop函数用于改变信号量的值。第二个参数是要在信号集合上执行操作的一个数组,第三个参数是该数组操作的个数 。
int semctl(int sem_id, int sem_num, int command,...); //semctl函数用于信号量集合执行控制操作,初始化信号量的值,删除一个信号量等。 类似于调用msgctl(), msgctl()是用于消息队列上的操作。第一个参数是指定的信号量集合(semget的返回值),第二个参数是要执行操作的信号量在集合中的索引值(例如集合中第一个信号量下标为0),第三个command参数代表要在集合上执行的命令。
(5)共享内存(Share Memory)
共享内存是在多个进程之间共享内存区域的一种进程间的通信方式,使得多个进程可以直接读写同一块内存空间,它是针对其他通信机制运行效率较低而设计的。共享内存由IPC为进程创建的一个特殊地址范围,为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
需要注意的是:共享内存并未提供同步机制,在一个进程结束对共享内存的写操作之前,并无自动机制可以阻止另二个进程开始对它进行读取。所以,我们通常需要用其他的机制来同步对共享内存的访问。
共享内存数据结构:
1)消息队列、信号量以及共享内存的相似之处
它们被统称为XSIIPC,它们在内核中有相似的IPC结构(消息队列的msgid_ds,信号量的semid_ds,共享内存的shmid_ds),而且都用一个非负整数的标识符加以引用(消息队列的msg_id,信号量的sem_id,共享内存的shm_id,分别通过msgget、semget以及shmget获得),标志符是IPC对象的内部名,每个IPC对象都有一个键(key_t key)相关联,将这个键作为该对象的外部名。
2)XSIIPC和PIPE、FIFO的区别
XSIIPC的IPC结构是在系统范围内起作用,没用使用引用计数。如果一个进程创建一个消息队列,并在消息队列中放入几个消息,进程终止后,即使现在已经没有程序使用该消息队列,消息队列及其内容依然保留。而PIPE在最后一个引用管道的进程终止时,管道就被完全删除了。对于FIFO最后一个引用FIFO的进程终止时,虽然FIFO还在系统,但是其中的内容会被删除。
和PIPE、FIFO不一样,XSIIPC不使用文件描述符,所以不能用ls查看IPC对象,不能用rm命令删除,不能用chmod命令删除它们的访问权限。只能使用ipcs和ipcrm来查看可以删除它们。
(6)内存映射(Memory Map)
内存映射,是将一个文件映射到一块内存的方法。内存映射文件与虚拟内存有些类似,通过内存映射文件可以保留一个地址的区域,同时将物理存储器提交给此区域,内存文件映射的物理存储器来自一个已经存在于磁盘上的文件,而且在对该文件进行操作之前必须首先对文件进行映射。使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作。每一个使用该机制的进程通过把同一个共享的文件映射到自己的进程地址空间来实现多个进程间的通信(这里类似于共享内存,只要有一个进程对这块映射文件的内存进行操作,其他进程也能够马上看到)。如下图:
主要函数定义:
void *mmap(void*start,size_tlength,intprot,intflags,intfd,off_t offset); //mmap函数将一个文件或者其它对象映射进内存。 第一个参数为映射区的开始地址,设置为0表示由系统决定映射区的起始地址,第二个参数为映射的长度,第三个参数为期望的内存保护标志,第四个参数是指定映射对象的类型,第五个参数为文件描述符(指明要映射的文件),第六个参数是被映射对象内容的起点。成功返回被映射区的指针,失败返回MAP_FAILED[其值为(void *)-1]。
int munmap(void* start,size_t length); //munmap函数用来取消参数start所指的映射内存起始地址,参数length则是欲取消的内存大小。如果解除映射成功则返回0,否则返回-1,错误原因存于errno中错误代码EINVAL。
int msync(void *addr,size_tlen,int flags); //msync函数实现磁盘文件内容和共享内存取内容一致,即同步。第一个参数为文件映射到进程空间的地址,第二个参数为映射空间的大小,第三个参数为刷新的参数设置。
共享内存和内存映射文件的区别:
内存映射是利用虚拟内存把文件映射到进程的地址空间中去,在此之后进程操作文件,就像操作进程空间里的地址一样了,比如使用c语言的memcpy等内存操作的函数。这种方法能够很好的应用在需要频繁处理一个文件或者是一个大文件的场合,这种方式处理IO效率比普通IO效率要高。
共享内存是内存映射文件的一种特殊情况,内存映射的是一块内存,而非磁盘上的文件。共享内存的主语是进程(Process),操作系统默认会给每一个进程分配一个内存空间,每一个进程只允许访问操作系统分配给它的哪一段内存,而不能访问其他进程的。
内存映射与虚拟内存的区别和联系:
内存映射和虚拟内存都是操作系统内存管理的重要部分,两者有相似点也有不同点。
联系:虚拟内存和内存映射都是将一部分内容加载到内存,另一部放在磁盘上的一种机制。对于用户而言都是透明的。
区别:虚拟内存是硬盘的一部分,是内存和硬盘的数据交换区,许多程序运行过程中把暂时不用的程序数据放入这块虚拟内存,节约内存资源。内存映射是一个文件到一块内存的映射,这样程序通过内存指针就可以对文件进行访问。
虚拟内存的硬件基础是分页机制。另外一个基础就是局部性原理(时间局部性和空间局部性)。而内存映射文件并不是局部性,而是使虚拟地址空间的某个区域映射磁盘的全部或部分内容,不必进行文件I/O也不需要对文件内容进行缓冲处理。
(7)套接字
套接字是更为基础的进程间通信机制,与其他方式不同的是,套接字可用于不同机器之间的进程间通信。在Linux中,套接字是基于网络的,它也有自己的家族名字--AF_INET。
(1)套接字连接连接方式
有面向连接的和无连接的两种:
1)面向连接的套接字(SOCK_STREAM):进行通信前必须建立一个连接,面向连接的通信提供序列化的、可靠地和不重复的数据交付,而没有记录边界。每条信息可以被拆分成多个片段,并且每个片段都能确保到达目的地,然后在目的地将信息拼接起来。实现这种连接类型的主要协议是传输控制协议(TCP)。
2)无连接的套接字(SOCK_DGRAM):在通信开始之前并不需要建立连接,在数据传输过程中并无法保证它的顺序性、可靠性或重复性。
然而,数据报确实保存了记录边界,这就意味着消息是以整体发送的,而并非首先分成多个片段。实现这种连接类型的主要协议是用户数据报协议(UDP)。
(2)套接字连接过程
套接字的创建和使用与管道是有区别的,套接字明确地将客户端与服务器区分开来,可以实现多个客户端连到同一服务器。
几个基础函数定义 :
int socket(int domain, int type, int protocal);
int bind(int socket, const struct sockaddr *address, size_taddress_len);
int listen(int socket, int backlog);
int accept(int socket, struct sockaddr *address, size_t *address_len);
int connect(int socket,const struct sockaddr *addrsss, size_t address_len);
在socket编程中,一个服务可以接受多个客户端的连接,可以为每个客户端设定一个消息类型,服务器和客户端直接的通信可以通过此消息类型来发送和接受消息,而且多个客户端之间也可以通过消息类型来区分。