操作系统其实就像一个软件外包公司,其内核就相当于这家外包公司的老板。所以接下来的整个课程中,请你将自己的角色切换成这家软件外包公司的老板,设身处地地去理解操作系统是如何协调各种资源,帮客户做成事情的。
趣谈操作系统组成,类比至外包软件公司
操作系统全貌概览
#修改密码passwd#添加用户useradd#查询用户、用户组cat/etc/passwdcat/etc/group浏览文件#ls-ldrwxr-xr-x6rootroot4096Oct202017apt-rw-r--r--1rootroot211Oct202017hosts你可以通过命令chown改变所属用户,chgrp改变所属组。
临时生效
exportJAVA_HOME=/root/jdk-XXX_linux-x64exportPATH=$JAVA_HOME/bin:$PATH永久生效,在用户的.bashrc文件中加上这两行
./filenamenohupcommand>out.file2>&1&这个时候,我们往往使用nohup命令。这个命令的意思是nohangup(不挂起),也就是说,当前交互命令行退出的时候,程序还要在。
这里面,“1”表示文件描述符1,表示标准输出,“2”表示文件描述符2,意思是标准错误输出,“2>&1”表示标准输出和错误输出合并了。合并到哪里去呢?到out.file里。
例如在Ubuntu中,我们可以通过apt-getinstallmysql-server的方式安装MySQL,然后通过命令systemctlstartmysql启动MySQL,通过systemctlenablemysql设置开机启动。之所以成为服务并且能够开机启动,是因为在/lib/systemd/system目录下会创建一个XXX.service的配置文件,里面定义了如何启动、如何关闭。
创建进程调用叫fork,因为实现上是fork一个老的进程来创建新进程。老的叫父进程,新的子进程。
调用fork时候,子进程把父进程完完整整copy了一份,包括数据+代码,所以如果没有特殊处理,那父子进程就会按照一样代码执行下去了。
所以fork函数会根据场景提供返回值
代码需要判定返回值,如果是0执行系统嗲用execve执行另外一段程序代码。否则按照原样执行。
对于操作系统也一样,启动的时候先创建一个所有用户进程的“祖宗进程”。
有时候,父进程要关心子进程的运行情况,这毕竟是自己身上掉下来的肉。有个系统调用waitpid,父进程可以调用它,将子进程的进程号作为参数传给它,这样父进程就知道子进程运行完了没有,成功与否。
每个进程都有自己的内存,互相之间不干扰,有独立的进程内存空间。进程内存有啥内容
分配内存时是按需分配,需要时才分配
对于文件的操作,下面6个系统调用最重要
Linux系统一切皆文件
每个文件,Linux都会分配一个文件描述符(FileDescriptor),这是一个整数。有了这个文件描述符,我们就可以使用系统调用,查看或者干预进程运行的方方面面。
所以说,文件操作是贯穿始终的,这也是“一切皆文件”的优势,就是统一了操作的入口,提供了极大的便利。
当项目遇到异常情况,例如项目中断,做到一半不做了。这时候就需要发送一个信号(Signal)给项目组。经常遇到的信号有以下几种:
当项目组收到信号的时候,项目组需要决定如何处理这些异常情况。对于一些不严重的信号,可以忽略,该干啥干啥,但是像SIGKILL(用于终止一个进程的信号)和SIGSTOP(用于中止一个进程的信号)是不能忽略的,可以执行对于该信号的默认动作。每种信号都定义了默认的动作,例如硬件故障,默认终止;也可以提供信号处理函数,可以通过sigaction系统调用,注册一个信号处理函数。
首先就是发个消息,不需要一段很长的数据,这种方式称为消息队列(MessageQueue)。由于一个公司内的多个项目组沟通时,这个消息队列是在内核里的,我们可以通过msgget创建一个新的队列,msgsnd将消息发送到消息队列,而消息接收方可以使用msgrcv从队列中取消息。
当两个项目组需要交互的信息比较大的时候,可以使用共享内存的方式,也即两个项目组共享一个会议室(这样数据就不需要拷贝来拷贝去)。大家都到这个会议室来,就可以完成沟通了。这时候,我们可以通过shmget创建一个共享内存块,通过shmat将共享内存映射到自己的内存空间,然后就可以读写了。
但是,两个项目组共同访问一个会议室里的数据,就会存在“竞争”的问题。如果大家同时修改同一块数据咋办?这就需要有一种方式,让不同的人能够排他地访问,这就是信号量的机制Semaphore。
不同机器的通过网络相互通信,要遵循相同的网络协议,也即TCP/IP网络协议栈。
。Linux内核里有对于网络协议栈的实现。如何暴露出服务给项目组使用呢?网络服务是通过套接字Socket来提供服务的。
我们可以通过Socket系统调用建立一个Socket。Socket也是一个文件,也有一个文件描述符,也可以通过读写函数进行通信。
如果你做过开发,你会觉得刚才讲的和平时咱们调用的函数不太一样。这是因为,平时你并没有直接使用系统调用。虽然咱们的办事大厅已经很方便了,但是为了对用户更友好,我们还可以使用中介Glibc,有事情找它就行,它会转换成为系统调用,帮你调用。Glibc是Linux下使用的开源的标准C库,它是GNU发布的libc库。Glibc为程序员提供丰富的API,除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了操作系统提供的系统服务,即系统调用的封装。
yum-ygroupinstall"DevelopmentTools"#include
在Linux下面,二进制的程序也要有严格的格式,这个格式我们称为ELF(ExecuteableandLinkableFormat,可执行与可链接格式)
gcc-c-fPICprocess.cgcc-c-fPICcreateprocess.cELF的第一种类型,可重定位文件(RelocatableFile)在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o文件,这就是ELF的第一种类型,可重定位文件(RelocatableFile)。
ELF文件的头是用于描述整个文件的。这个文件格式在内核中有定义,分别为structelf32_hdr和structelf64_hdr。
接下来我们来看一个一个的section,我们也叫节。
这些节的元数据信息也需要有一个地方保存,就是最后的节头部表(SectionHeaderTable)。在这个表里面,每一个section都有一项,在代码里面也有定义structelf32_shdr和structelf64_shdr。在ELF的头里面,有描述这个文件的节头部表的位置,有多少个表项等等信息。
多个文件互相调用,各个文件内的代码偏移会冲突,需要重定位。.rel.text,.rel.data就与重定位有关。
要想让create_process这个函数作为库文件被重用,不能以.o的形式存在,而是要形成库文件,最简单的类型是静态链接库.a文件(Archives),仅仅将一系列对象文件(.o)归档为一个文件,使用命令ar创建。
arcrlibstaticprocess.aprocess.o虽然这里libstaticprocess.a里面只有一个.o,但是实际情况可以有多个.o。当有程序要使用这个静态连接库的时候,会将.o文件提取出来,链接到程序中。
gcc-ostaticcreateprocesscreateprocess.o-L.-lstaticprocess在这个命令里,-L表示在当前目录下找.a文件,-lstaticprocess会自动补全文件名,比如加前缀lib,后缀.a,变成libstaticprocess.a,找到这个.a文件后,将里面的process.o取出来,和createprocess.o做一个链接,形成二进制执行文件staticcreateprocess。
这个链接的过程,重定位就起作用了,原来createprocess.o里面调用了create_process函数,但是不能确定位置,现在将process.o合并了进来,就知道位置了。
这个格式和.o文件大致相似,还是分成一个个的section,并且被节头表描述。只不过这些section是多个.o文件合并过的。但是这个时候,这个文件已经是马上就可以加载到内存里面执行的文件了,因而这些section被分成了需要加载到内存里面的代码段、数据段和不需要加载到内存里面的部分,将小的section合成了大的段segment,
并且在最前面加一个段头表(SegmentHeaderTable)。在代码里面的定义为structelf32_phdr和structelf64_phdr,这里面除了有对于段的描述之外,最重要的是p_vaddr,这个是这个段加载到内存的虚拟地址。
在ELF头里面,有一项e_entry,也是个虚拟地址,是这个程序运行的入口。
#exportLD_LIBRARY_PATH=.#./dynamiccreateprocess#total40-rw-r--r--.1rootroot1572Oct2418:38CentOS-Base.repo......ELF的第三种类型,共享对象文件(SharedObject)。静态链接库一旦链接进去,代码和变量的section都合并了,因而程序运行的时候,就不依赖于这个库是否存在。但是这样有一个缺点,就是相同的代码段,如果被多个程序使用的话,在内存里面就有多份,而且一旦静态链接库更新了,如果二进制执行文件不重新编译,也不随着更新。
因而就出现了另一种,动态链接库(SharedLibraries),不仅仅是一组对象文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享。
gcc-shared-fPIC-olibdynamicprocess.soprocess.o当一个动态链接库被链接到一个程序文件中的时候,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的名称。
gcc-odynamiccreateprocesscreateprocess.o-L.-ldynamicprocess当运行这个程序的时候,首先寻找动态链接库,然后加载它。默认情况下,系统在/lib和/usr/lib文件夹下寻找动态链接库。如果找不到就会报错,我们可以设定LD_LIBRARY_PATH环境变量,程序运行时会在此环境变量指定的文件夹下寻找动态链接库。
#exportLD_LIBRARY_PATH=.#./dynamiccreateprocess#total40-rw-r--r--.1rootroot1572Oct2418:38CentOS-Base.repo......基于动态链接库创建出来的二进制文件格式还是ELF,但是稍有不同。首先,多了一个.interp的Segment,这里面是ld-linux.so,这是动态链接器,也就是说,运行时的链接动作都是它做的。
另外,ELF文件中还多了两个section,一个是.plt,过程链接表(ProcedureLinkageTable,PLT),一个是.got.plt,全局偏移量表(GlobalOffsetTable,GOT)。
dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数。由于是运行时才去找,编译的时候,压根不知道这个函数在哪里,所以就在PLT里面建立一项PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用create_process函数,而是调用PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的create_process函数。
去哪里找代理代码呢?这就用到了GOT,这里面也会为create_process函数创建一项GOT[y]。这一项是运行时create_process函数在内存中真正的地址。
如果这个地址在dynamiccreateprocess调用PLT[x]里面的代理代码,代理代码调用GOT表中对应项GOT[y],调用的就是加载到内存中的libdynamicprocess.so里面的create_process函数了。
但是GOT怎么知道的呢?对于create_process函数,GOT一开始就会创建一项GOT[y],但是这里面没有真正的地址,因为它也不知道,但是它有办法,它又回调PLT,告诉它,你里面的代理代码来找我要create_process函数的真实地址,我不知道,你想想办法吧。
PLT这个时候会转而调用PLT[0],也即第一项,PLT[0]转而调用GOT[2],这里面是ld-linux.so的入口函数,这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面。下次,PLT[x]的代理函数就能够直接调用了。
知道了ELF这个格式,这个时候它还是个程序,那怎么把这个文件加载到内存里面呢?在内核中,有这样一个数据结构,用来定义加载二进制文件的方法。
structlinux_binfmt{structlist_headlh;structmodule*module;int(*load_binary)(structlinux_binprm*);int(*load_shlib)(structfile*);int(*core_dump)(structcoredump_params*cprm);unsignedlongmin_coredump;/*minimaldumpsize*/}__randomize_layout;对于ELF文件格式,有对应的实现。
staticstructlinux_binfmtelf_format={.module=THIS_MODULE,.load_binary=load_elf_binary,.load_shlib=load_elf_library,.core_dump=elf_core_dump,.min_coredump=ELF_EXEC_PAGESIZE,};load_elf_binary是不是你很熟悉?没错,我们加载内核镜像的时候,用的也是这种格式。
还记得当时是谁调用的load_elf_binary函数吗?具体是这样的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。
那do_execve又是被谁调用的呢?我们看下面的代码。
SYSCALL_DEFINE3(execve,constchar__user*,filename,constchar__user*const__user*,argv,constchar__user*const__user*,envp){returndo_execve(getname(filename),argv,envp);}学过了系统调用一节,你会发现,原理是exec这个系统调用最终调用的load_elf_binary。
exec比较特殊,它是一组函数:
在上面process.c的代码中,我们创建ls进程,也是通过exec。
既然所有的进程都是从父进程fork过来的,那总归有一个祖宗进程,这就是咱们系统启动的init进程。
在解析Linux的启动过程的时候,1号进程是/sbin/init。如果在centOS7里面,我们ls一下,可以看到,这个进程是被软链接到systemd的。
#ps-ef[root@deployer~]#ps-efUIDPIDPPIDCSTIMETTYTIMECMDroot100201800:00:29/usr/lib/systemd/systemd--system--deserialize21root200201800:00:00[kthreadd]root320201800:00:00[ksoftirqd/0]root520201800:00:00[kworker/0:0H]root920201800:00:40[rcu_sched]......root33720201800:00:01[kworker/3:1H]root38010201800:00:00/usr/lib/systemd/systemd-udevdroot41510201800:00:01/sbin/auditdroot49810201800:00:03/usr/lib/systemd/systemd-logind......root85210201800:06:25/usr/sbin/rsyslogd-nroot258010201800:00:00/usr/sbin/sshd-Droot2905820Jan0300:00:01[kworker/1:2]root2967220Jan0400:00:09[kworker/2:1]root3046710Jan0600:00:00/usr/sbin/crond-nroot3157420Jan0800:00:01[kworker/u128:2]......root3279225800Jan1000:00:00sshd:root@pts/0root32794327920Jan10pts/000:00:00-bashroot3290132794000:01pts/000:00:00ps-ef命令查看当前系统启动的进程,我们会发现有三类进程。
你会发现,PID1的进程就是我们的init进程systemd,PID2的进程是内核线程kthreadd,这两个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是2号进程。而用户态的进程,祖先都是1号进程。tty那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
pts的父进程是sshd,bash的父进程是pts,ps-ef这个命令的父进程是bash。这样整个链条都比较清晰了。
对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据项目执行计划书,一行一行执行下去。进程要比线程管的宽多了,除了执行指令之外,内存、文件系统等等都要它来管。
使用进程实现并行执行的问题也有两个。
在Linux中,有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如你输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。
gccdownload.c-lpthread这里我们画一张图总结一下,一个普通线程的创建和运行过程。
我们把线程访问的数据细分成三类:
栈的大小可以通过命令ulimit-a查看,默认情况下线程栈大小为8192(8MB)。我们可以使用命令ulimit-s修改。
intpthread_key_create(pthread_key_t*key,void(*destructor)(void*))可以看到,创建一个key,伴随着一个析构函数。key一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往key中填入不同的值,这就相当于提供了一个同名而不同值的全局变量。
intpthread_setspecific(pthread_key_tkey,constvoid*value)void*pthread_getspecific(pthread_key_tkey)而等到线程退出的时候,就会调用析构函数释放value。
我们先来看一种方式,Mutex,全称MutualExclusion,中文叫互斥。顾名思义,有你没我,有我没你。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。
gccmutex.c-lpthread使用Mutex,首先要使用pthread_mutex_init函数初始化这个mutex,初始化后,就可以用它来保护共享变量了。pthread_mutex_lock()就是去抢那把锁的函数,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,就被阻塞在那里等待。
如果不想被阻塞,可以使用pthread_mutex_trylock去抢那把锁,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,不会被阻塞,而是返回一个错误码。
当共享数据访问结束了,别忘了使用pthread_mutex_unlock释放锁,让给其他人使用,最终调用pthread_mutex_destroy销毁掉这把锁。
但是当它接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以条件变量和互斥锁是配合使用的。
写多线程的程序是有套路的,我这里用一张图进行总结。你需要记住的是,创建线程的套路、mutex使用的套路、条件变量使用的套路。
在Linux里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构task_struct进行管理。
设想一下,Linux的任务管理都应该干些啥?首先,所有执行的项目应该有个项目列表吧,所以Linux内核也应该先弄一个链表,将所有的task_struct串起来。
structlist_headtasks;任务IDpid_tpid;pid_ttgid;structtask_struct*group_leader;pid是processid,tgid是threadgroupID。
任何一个进程,如果只有主线程,那pid是自己,tgid是自己,group_leader指向的还是自己。但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的pid,tgid就是进程的主线程的pid,group_leader指向的就是进程的主线程。
这里既然提到了下发指令的问题,我就顺便提一下task_struct里面关于信号处理的字段。
/*Signalhandlers:*/structsignal_struct*signal;structsighand_struct*sighand;sigset_tblocked;sigset_treal_blocked;sigset_tsaved_sigmask;structsigpendingpending;unsignedlongsas_ss_sp;size_tsas_ss_size;unsignedintsas_ss_flags;这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。
信号处理函数默认使用用户态的函数栈,当然也可以开辟新的栈专门用于信号处理,这就是sas_ss_xxx这三个变量的作用。
上面我说了下发信号的时候,需要区分进程和线程。从这里我们其实也能看出一些端倪。task_struct里面有一个structsigpendingpending。如果我们进入structsignal_struct*signal去看的话,还有一个structsigpendingshared_pending。它们一个是本任务的,一个是线程组共享的。
关于信号,你暂时了解到这里就够用了,后面我们会有单独的章节进行解读。
在task_struct里面,涉及任务状态的是下面这几个变量:
volatilelongstate;/*-1unrunnable,0runnable,>0stopped*/intexit_state;unsignedintflags;state(状态)可以取的值定义在include/linux/sched.h头文件中。
/*Usedintsk->state:*/#defineTASK_RUNNING0#defineTASK_INTERRUPTIBLE1#defineTASK_UNINTERRUPTIBLE2#define__TASK_STOPPED4#define__TASK_TRACED8/*Usedintsk->exit_state:*/#defineEXIT_DEAD16#defineEXIT_ZOMBIE32#defineEXIT_TRACE(EXIT_ZOMBIE|EXIT_DEAD)/*Usedintsk->stateagain:*/#defineTASK_DEAD64#defineTASK_WAKEKILL128#defineTASK_WAKING256#defineTASK_PARKED512#defineTASK_NOLOAD1024#defineTASK_NEW2048#defineTASK_STATE_MAX4096从定义的数值很容易看出来,state是通过bitset的方式设置的,也就是说,当前是什么状态,哪一位就置一。
在运行中的进程,一旦要进行一些I/O操作,需要等待I/O完毕,这个时候会释放CPU,进入睡眠状态。在Linux中,有两种睡眠状态。
因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成TASK_UNINTERRUPTIBLE。于是,我们就有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,它的运行原理类似TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。
TASK_STOPPED是在进程接收到SIGSTOP、SIGTTIN、SIGTSTP或者SIGTTOU信号之后进入该状态。
TASK_TRACED表示进程被debugger等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。
一旦一个进程要结束,先进入的是EXIT_ZOMBIE状态,但是这个时候它的父进程还没有使用wait()等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
EXIT_DEAD是进程的最终状态。
EXIT_ZOMBIE和EXIT_DEAD也可以用于exit_state。
上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,我们称为标志。放在flags字段中,这些字段都被定义成为宏,以PF开头。我这里举几个例子。
#definePF_EXITING0x00000004#definePF_VCPU0x00000010#definePF_FORKNOEXEC0x00000040PF_EXITING表示正在退出。当有这个flag的时候,在函数find_alive_thread中,找活着的线程,遇到有这个flag的,就直接跳过。
PF_FORKNOEXEC表示fork完了,还没有exec。在_do_fork函数里面调用copy_process,这个时候把flag设置为PF_FORKNOEXEC。当exec中调用了load_elf_binary的时候,又把这个flag去掉。
进程的状态切换往往涉及调度,下面这些字段都是用于调度的。为了让你理解task_struct进程管理的全貌,我先在这里列一下
在Linux里面,对于进程权限的定义如下:
/*Objectiveandrealsubjectivetaskcredentials(COW):*/conststructcred__rcu*real_cred;/*Effective(overridable)subjectivetaskcredentials(COW):*/conststructcred__rcu*cred;这个结构的注释里,有两个名词比较拗口,Objective和Subjective。事实上,所谓的权限,就是我能操纵谁,谁能操纵我。“谁能操作我”,很显然,这个时候我就是被操作的对象,就是Objective,那个想操作我的就是Subjective。“我能操作谁”,这个时候我就是Subjective,那个要被我操作的就是Objectvie。
real_cred就是说明谁能操作我这个进程,而cred就是说明我这个进程能够操作谁。
structcred{......kuid_tuid;/*realUIDofthetask*/kgid_tgid;/*realGIDofthetask*/kuid_tsuid;/*savedUIDofthetask*/kgid_tsgid;/*savedGIDofthetask*/kuid_teuid;/*effectiveUIDofthetask*/kgid_tegid;/*effectiveGIDofthetask*/kuid_tfsuid;/*UIDforVFSops*/kgid_tfsgid;/*GIDforVFSops*/......kernel_cap_tcap_inheritable;/*capsourchildrencaninherit*/kernel_cap_tcap_permitted;/*capswe'repermitted*/kernel_cap_tcap_effective;/*capswecanactuallyuse*/kernel_cap_tcap_bset;/*capabilityboundingset*/kernel_cap_tcap_ambient;/*Ambientcapabilityset*/......}__randomize_layout;从这里的定义可以看出,大部分是关于用户和用户所属的用户组信息。
第一个是uid和gid,注释是realuser/groupid。一般情况下,谁启动的进程,就是谁的ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
第二个是euid和egid,注释是effectiveuser/groupid。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
第三个是fsuid和fsgid,也就是filesystemuser/groupid。这个是对文件操作会审核的权限。
一般说来,fsuid、euid,和uid是一样的,fsgid、egid,和gid也是一样的。因为谁启动的进程,就应该审核启动的用户到底有没有这个权限。
但是也有特殊的情况。
例如,用户A想玩一个游戏,这个游戏的程序是用户B安装的。游戏这个程序文件的权限为rwxr–r--。A是没有权限运行这个程序的,所以用户B要给用户A权限才行。用户B说没问题,都是朋友嘛,于是用户B就给这个程序设定了所有的用户都能执行的权限rwxr-xr-x,说兄弟你玩吧。
于是,用户A就获得了运行这个游戏的权限。当游戏运行起来之后,游戏进程的uid、euid、fsuid都是用户A。看起来没有问题,玩得很开心。用户A好不容易通过一关,想保留通关数据的时候,发现坏了,这个游戏的玩家数据是保存在另一个文件里面的。这个文件权限rw-------,只给用户B开了写入权限,而游戏进程的euid和fsuid都是用户A,当然写不进去了。完了,这一局白玩儿了。
那怎么解决这个问题呢?我们可以通过chmodu+sprogram命令,给这个游戏程序设置set-user-ID的标识位,把游戏的权限变成rwsr-xr-x。这个时候,用户A再启动这个游戏的时候,创建的进程uid当然还是用户A,但是euid和fsuid就不是用户A了,因为看到了set-user-id标识,就改为文件的所有者的ID,也就是说,euid和fsuid都改成用户B了,这样就能够将通关结果保存下来。
在Linux里面,一个进程可以随时通过setuid设置用户ID,所以,游戏程序的用户B的ID还会保存在一个地方,这就是suid和sgid,也就是saveduid和savegid。这样就可以很方便地使用setuid,通过设置uid或者suid来改变权限。
除了以用户和用户组控制权限,Linux还有另一个机制就是capabilities。
原来控制进程的权限,要么是高权限的root用户,要么是一般权限的普通用户,这时候的问题是,root用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个root的权限。这个太不安全了。
于是,我们引入新的机制capabilities,用位图表示权限,在capability.h可以找到定义的权限。我这里列举几个。
#defineCAP_CHOWN0#defineCAP_KILL5#defineCAP_NET_BIND_SERVICE10#defineCAP_NET_RAW13#defineCAP_SYS_MODULE16#defineCAP_SYS_RAWIO17#defineCAP_SYS_BOOT22#defineCAP_SYS_TIME25#defineCAP_AUDIT_READ37#defineCAP_LAST_CAPCAP_AUDIT_READ对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;没有的时候,就不能做,这样粒度要小很多。
cap_permitted表示进程能够使用的权限。但是真正起作用的是cap_effective。cap_permitted中可以包含cap_effective中没有的权限。一个进程可以在必要的时候,放弃自己的某些权限,这样更加安全。假设自己因为代码漏洞被攻破了,但是如果啥也干不了,就没办法进一步突破。
cap_inheritable表示当可执行文件的扩展属性设置了inheritable位时,调用exec执行该程序会继承调用者的inheritable集合,并将其加入到permitted集合。但在非root用户下执行exec时,通常不会保留inheritable集合,但是往往又是非root用户,才想保留权限,所以非常鸡肋。
cap_bset,也就是capabilityboundingset,是系统中所有进程允许保留的权限。如果这个集合中不存在某个权限,那么系统中的所有进程都没有这个权限。即使以超级用户权限执行的进程,也是一样的。
这样有很多好处。例如,系统启动以后,将加载内核模块的权限去掉,那所有进程都不能加载内核模块。这样,即便这台机器被攻破,也做不了太多有害的事情。
cap_ambient是比较新加入内核的,就是为了解决cap_inheritable鸡肋的状况,也就是,非root用户进程使用exec执行一个程序的时候,如何保留权限的问题。当执行exec的时候,cap_ambient会被添加到cap_permitted中,同时设置到cap_effective中。
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。文件与文件系统每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。