unix环境高级编程
0x00 环境搭建
先去官网下载源码http://www.apuebook.com/
1 | #mac如下操作即可(我是m1) |
0x01 UNIX基础知识概述
- 我们可以将操作系统定义为一种软件,它用来控制计算机硬件资源,提供程序运行环境,我们将这个软件称为内核(kernel)。
- 内核的接口被称为系统调用(system call),下图的阴影部分。公用函数库构建在系统调用之上,应用程序既可以使用公用函数,也可以使用系统调用。shell是一个特殊的应用程序,为运行其他应用程序提供了一个接口。
0x02 文件I/O
1. 文件描述符
- 文件描述符是一个小的非负整数,内核用其标识一个正在访问的文件。当内核打开一个文件或者创建一个文件的时候,会返回一个文件描述符。读写文件时就用这个文件描述符。
- 在运行一个新程序的时候,所有的shell都会为其打开3个文件描述符(标准输入(0),标准输出(1),标准错误(2)),如果不做特殊处理,这3个文件描述符都会链接到shell。
- 使用open新打开的文件会从3开始以此递加
1 |
|
1 | $ ./a.out > data # 标准输入是终端,标准输出为data |
1 |
|
2. 常用函数
- open
1 | int open(const char *path,int oflag,.../* mode_t mode */); |
- close
1 | int close(int fd); |
- lseek
1 | off_t lseek(int fd, off_t offset, int whence); |
- read
1 | ssize_t read(int fd,void *buf,int count); |
- write
1 | ssize_t write(int fd, const void *buf, size_t nbyte); |
1 |
|
- dup和dup2
1 | int dup(int oldfd); |
返回值:
成功:dup函数返回当前系统可用的fd中最小的整数值。
dup2函数返回第一个不小于newfd的整数值,分两种情况: 1. 如果newfd已经打开,则先将其关闭,再复制文件描述符;
2. 如果newfd等于oldfd,则dup2函数返回newfd,而不关闭它。
失败:dup和dup2函数均返回-1,并设置errno。
- fcntl
1 | int fcntl(int fd, int cmd, .../* int arg */); |
fcntl函数有以下5种功能
- 复制一个已有的描述符(cmd=F_DUPFD或F_DUPFD_CLOEXEC)
- 获取/设置文件描述符标志(cmd=F_GETFD或F_SETFD)
- 获取/设置文件状态标志(cmd=F_GETFL或F_SETFL)
- 获取/设置异步I/O所有权(cmd=F_GETOWN或F_SETFL)
- 获取/设置记录锁(cmd=F_GETLK、F_SETLK或F_SETLKW)
3.文件共享(简略介绍进程表)
内核负责管理维护所有进程,为了管理进程,内核在内核空间创建了一个被称为进程表的数据结构,这个数据结构中记录了所有进程,每个进程在数据结构中都被称为一个进程表项。
进程表中除了记录所有进程的PID,还使用一个字段记录了所有进程的指针,指向了每个进程的进程控制块。
进程控制块中有一个是跟文件描述符相关的,如下图
i-node的信息是打开文件时,从磁盘读入内存的。里面存放了文件所有者、文件长度、指向文件实际数据在磁盘地址等。
如果两个独立进程打开了同一个文件,如下图。
之所以有文件表项是因为每个进程都可以有自己对该文件的偏移量。
- 文件状态标志:可读可写等
- 偏移量:就是文件读写位置(去看lseek函数)
- 文件长度是实时更新的
dup函数执行之后的文件表项:
0x03 文件和目录
1. 常用函数
- stat
1 | int stat(const char * file_name,struct stat *buf); |
1 | struct stat { |
2. 文件类型
- 普通文件:最常见的文件类型,至于这种数据是文本还是二进制数据,对于内核来说都一样,对于普通文件内容的解释由处理该文件的应用程序进行。
- 目录文件:这种文件包含了其他文件的名字,以及指向这些文件的有关信息的指针。对一个目录文件具有读权限的任一进程都可以读目录的内容,但是只有内核可以直接写目录文件。
- 块特殊文件:这种类型的文件提供对设备带缓冲的访问,每次访问以固定长度为单位进行。
- 字符特殊文件:这种类型的文件提供对设备不带缓冲的访问,每次访问时长度可变。(系统中的设备(打印机、磁盘、网络等等设备,一般位于/dev目录下)要么是字符特殊文件,要么是块特殊文件)。
- FIFO:用于进程间通信,有时也被称为管道(named pipe)。
- 套接字(socket):用于进程间的网络通信。
- 符号链接:这种类型的文件指向另一个文件。
这些信息存在stat结构的st_mode成员中
1 | S_ISREG() // 普通文件 |
0x04 进程
- kernel使用exec函数将程序从磁盘读入内存并执行。
- 正在执行的程序被称为进程,每个进程都有一个唯一的数字标识符,称为PID(Process ID)
并行:指两个或两个以上的事件或活动在同一时刻发生,在多道程序下,并行使多个程序同一时刻可在不同CPU上同时执行
并发:在同一个CPU上同时运行多个程序(CPU在多个程序之间切换,只是看起来在运行多个程序)
1 |
|
1. PCB进程控制块
操作系统中用进程控制块(Process Control Block)来描述和管理进程
PCB是进程存在的唯一标识,PCB主要包含如下信息:
- 进程描述信息
- 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
- 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
- 进程控制和管理信息
- 进程当前状态:如new、ready、running、waiting或blocked等
- 进程优先级:进程抢占CPU的优先级
- 资源分配清单
- 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的I/O设备信息
- CPU相关信息
- CPU中各个寄存器的值,当进程被切换时,CPU的状态信息被保存在相应的PCB中,以便进程重新执行
这个图没画全,进程表应该还有PID,可以看前面的补充一下
2. 常用函数
- fork
1 | pid_t fork( void); |
fork函数用于创建一个新的进程,称为子进程,它与父进程同时运行fork函数后的下一条指令。fork函数被调用一次会返回两次。两次返回的区别是子进程返回0和父进程返回子进程ID。如果出错会返回负值。
子进程会获得父进程的副本,并且子进程和父进程不共享存储空间。
一般来,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。
- kill
1 | int kill(pid_t pid,int signo) |
向进程或者进程组发送一个信号(成功返回0,失败返回-1)
bash的kill命令实际上是对kill函数的一种封装(kill(pid, SIGINT);
)
- getpid/getppid
1 | pid_t getpid(void); |
- wait
1 | pid_t wait (int * status); |
- waitpid
1 | pid_t waitpid(pid_t pid,int * status,int options); |
宏 | 说明 |
---|---|
WIFEXITED(status) | 如果子进程正常结束,它就返回真;否则返回假。 |
WEXITSTATUS(status) | 如果WIFEXITED(status)为真,则可以用该宏取得子进程exit()返回的结束代码。 |
WIFSIGNALED(status) | 如果子进程因为一个未捕获的信号而终止,它就返回真;否则返回假。 |
WTERMSIG(status) | 如果WIFSIGNALED(status)为真,则可以用该宏获得导致子进程终止的信号代码。 |
WIFSTOPPED(status) | 如果当前子进程被暂停了,则返回真;否则返回假。 |
WSTOPSIG(status) | 如果WIFSTOPPED(status)为真,则可以使用该宏获得导致子进程暂停的信号代码。 |
1 | // int options 提供了一些另外的选项来控制waitpid()函数的行为 |
参数 | 说明 |
---|---|
WNOHANG | 如果pid指定的子进程没有结束,则waitpid()函数立即返回0,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号。 |
WUNTRACED | 如果子进程进入暂停状态,则马上返回 |
- execl
1 | int execl(const char * path, const char * arg, ...); |
- execlp
1 | int execlp(const char * file, const char * arg, ...); |
上述两个函数的区别是,execlp可以直接调用系统自带的命令,但是execl需要将路径一同带上。
3. fork一些细节
1 |
|
1 | 0 |
为了避免这种情况,我们可以在子进程里加一个break退出循环或者直接关掉进程exit(0)|return
。
父进程和子进程打开文件描述符会共享用一个文件表项,这说明它们会用同一个文件偏移量。
在fork之后处理文件描述符有两种常见的情况:
- 父进程等待子进程完成。这种情况下父进程不需要进行任何处理,在子进程终止后,再重新对偏移量进行更新。
- 父进程和子进程执行不同的程序段。
1 |
|
4. 孤儿进程和僵尸进程
因为父进程不能预测子进程什么时候结束,当一个子进程完成它的工作后,它的父进程需要调用wait或者waitpid函数获取子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
init进程是内核启动的第一个用户级的进程,它会启动getty(用户登录),实现运行级别和处理孤儿进程等功能。
5. 进程组
- 每一个进程都属于一个进程组,创建进程组的目的是简化向组内所有进程发送信号的操作,如果一个信号是发给一个进程组的,则这个组内的所有进程都会收到该信号
- 进程组内的所有进程都有相同的PGID,等于该组组长的PID。组长是该进程组第一个创建的进程。
- 通常是由shell的管道符将几个进程编成一组
- 所有的进程以init进程为根,形成一个树状结构
6. 会话session
- 当一个用户注销的时候,内核会终止用户之前启动的所有进程,为了简化这个任务,内核将几个进程组合并成一个会话。
- 用户一次登录就形成一次会话。
- 会话的sid是会话里的第一个进程的pid,这个进程通常是用户的shell。
维持一个会话最常见的有两种方式:
- 一是基于某种凭证,比如web网站的登录会话,在登录验证之后,服务器就会返回一个session id作为凭证。用户之后的请求总是会带上这个id,而服务器可以通过这个id知道用户是谁。直到用户注销登录、登录超时或者服务器清洗掉相应的session id,这个id就会失效。会话结束。
- 第二种方式是基于连接的,当用户和系统之间连接启动时,系统会对用户进行验证,验证通过之后,来自这个连接的操作都是属于这个用户的。
linux系统的会话是使用第二种方式维持的,最常见的两种连接为:
- 本地连接:用户直接通过键盘和显示器来跟系统进行交互,连接基本上不可能被修改
- 远程连接:用协议进行加密,避免连接被修改
7. job
- 一个会话的几个进程组可以被分成一个前台进程组以及一个或者多个后台进程组。
- 无论何时输入终端的退出键,都会将退出信号发送给前台进程组的所有进程。
- 前台进程组中的进程可以向终端设备进行读写操作
- 后台进程组中的进程只能向终端设备进行写操作
8. 控制终端
- 每个会话最多有一个对应的终端,会话中的进程从这个终端得到输入并输出,该终端被称为控制终端(controlling terminal)。
- 会话的领头进程打开一个终端之后,该终端就会成为该会话的控制终端,与该终端建立连接的进程被称为控制进程。
- 我们打开多个终端窗口时,实际上就创建了多个终端会话。每个会话都会有自己的前台工作和后台工作。
终端是用户输入shell命令的窗口,shell把一些消息输出到终端,同时接收终端设备的输入。可以理解为,终端中输入命令,shell用来解释命令。
0x05 进程间通信IPC
进程之间会保证相对独立,一个进程不能随便访问另一个进程的地址空间,这是系统安全性的保证和需要。但有些时候进程需要协作一起完成任务。
进程间通信有两种基本模型:共享内存和消息传递
- 共享内存:建立起一块供协作进程共享的内存区域。
- 消息传递:通过在协作进程间交换消息来实现通信。
1. 直接通信和间接通信
进程间通信从通信路径上可以分为直接通信和间接通信
直接通信:进程A直接将消息发送给进程B,不经过内核。
send(A,message) receive(B,message)
在发送数据之前,我们需要建立链路,链路的建立需要操作系统的支持,因为进程间通信实际上是打破了进程之间的隔离,没有操作系统的支持是无法完成的。
这种方案的链路需要有以下功能:
- 在需要通信的每个进程之间,自动建立链路。进程仅需要知道对方身份就可以交流
- 每个链路只与两个进程相关
- 每对进程之间只有一个链路
间接通信:进程A把信息发给内核,让内核把消息发给进程B。
在内核中存在一块区域,用来维护进程间的通信的队列:每个消息队列都有着唯一的id,两个进程之间只有共享了一个消息队列才能通信。
send(Q,message) receive(Q,message)
2. 管道
子进程从父进程继承文件描述符。管道来源于早期unix命令行输入时的想法,让上一个进程的输出重定向为下一个进程的输入。流水线方式,称为管道机制。
管道通过调用pipe函数创建的,shell会为每个命令单独创建一个进程,然后用管道将前一个命令进程的标准输出和标准输入相连接。
1 |
|
1 |
|
管道的局限:
- 管道是半双工的,数据只能向一个方向流动;
- 所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令、或记录)等等;
- 只能用于具有亲缘关系的进程之间。
3. 命名管道FIFO
未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还需要有一个共同的创建了它们的祖先进程。通过FIFO,不相关的进程也可以交换数据。
FIFO是一种文件类型,可以看到p标识
我们向管道写入内容,但是却没有直接输出,我们需要另外开一个终端进行输出,这里可以理解为暂存管道。
4. 消息队列
- 消息队列存储在内核中,可以进行双工通信
- 进程发送的消息可以是定长也可以是变长,如果只发送定长消息,那么系统级实现就简单。但是编程会变困难;发送变长消息则是相反。
- 消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
1 | int msgget(key_t key, int msgflag); |
1 | int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); |
1 | ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); |
5. 共享内存通信
当我们需要交换比较大的数据的时候,一发一收不能及时的感知数据,就不能使用消息队列了。
每个进程都有自己的独立虚拟内存空间,我们可以通过shmget
创建一份共享内存,然后通过ipcs
命令查看我们创建的共享内存。它往往与信号机制配合使用,不然很多进程共享一块内存,同时写就可能会出现问题。
6. 信号量
信号量是一个计数器,主要实现进程之间的同步和互斥,而不是存储通信内容。它经常作为一种锁的机制,防止进程在访问共享资源的时候,还有其他进程也访问。
0x06 信号
1. 常用函数
- sigset_t
1 | sigset_t是代表信号集等数据结构,一个进程有一个信号集,这个信号集表示当前阻塞了哪些信号 |
- signal
1 | void (*signal(int sig, void (*func)(int)))(int); |
SIGABRT | (Signal Abort) 程序异常终止。 |
---|---|
SIGFPE | (Signal Floating-Point Exception) 算术运算出错,如除数为 0 或溢出(不一定是浮点运算)。 |
SIGILL | (Signal Illegal Instruction) 非法函数映象,如非法指令,通常是由于代码中的某个变体或者尝试执行数据导致的。 |
SIGINT | (Signal Interrupt) 中断信号,如 ctrl-C,通常由用户生成。 |
SIGSEGV | (Signal Segmentation Violation) 非法访问存储器,如访问不存在的内存单元。 |
SIGTERM | (Signal Terminate) 发送给本程序的终止请求信号。 |
- sigaction
1 | int sigaction(int signum,const struct sigaction *act ,struct sigaction *oldact); |
1 | struct sigaction{ |
1 |
|
- kill
1 | int kill(pid_t pid,int signo); |
- abort
1 | void abort(void); |
信号集:
- sigemptyset
1 | int sigemptyset(sigset_t *set); |
- sigfillset
1 | int sigfillset(sigset_t * set); |
- sigaddset
1 | int sigaddset(sigset_t *set, int signum); |
- sigdelset
1 | int sigdelset(sigset_t * set,int signum); |
- sigismember
1 | int sigismember(const sigset_t *set,int signum); |
- sigprocmask
1 | int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oldset); |
how取值:
1.SIG_BLOCK: 该值代表的功能是将set所指向的信号集中所包含的信号加到当前的信号屏蔽字中,作为新的信号屏蔽字。
2.SIG_UNBLOCK:将参数set所指向的信号集中的信号从当前的信号屏蔽字中移除。
3.SIG_SETMASK:设置当前信号屏蔽字为参数set所指向的信号集中所包含的信号。
- sigpending
1 | int sigpending(sigset_t *set); |
2. 信号
信号在内核中一般有三种状态:
- 信号递达:实际执行信号的处理动作称为信号递达
- 信号未决:信号从产生到递达之间的状态
- 信号阻塞:被阻塞的信号产生时保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的操作。
- 信号递达的几种方式:忽略,默认处理,自定义
- 1表示阻塞,0表示不阻塞。1表示未决,0表示可以递达。
- 屏蔽状态字就是阻塞信号集,未决状态字就是未决信号集
3. 代码例子
1 | // 编写程序,电脑1秒种能数多个数字 |
1 | // 设置阻塞信号集并把所有常规信号的未决状态打印至屏幕 |
1 | // 父进程创建三个子进程,然后让父进程捕获SIGCHLD信号完成对子进程的回收。 |
0x07 线程
1. 线程的基本概念
进程:在系统中运行的一个应用程序,拥有独立的虚拟空间。一个进程所拥有的数据只属于它自己。一个进程最少拥有一个线程
线程:是进程中相对独立的可执行单元,与父进程以及其他线程共享该进程的所有虚拟空间和全局变量,但是拥有独立的栈(局部变量对线程来说是私有的)和寄存器。
pid是进程标识符,tid是线程标识符。
一个线程由线程id,当前指令指针(PC),寄存器集合和栈组成。
上下文:记录不同线程分别执行到哪里,这样切换进程的时候就知道做完哪些工作了。
上下文切换:运行了线程1的指令->保存状态->读取线程2上一次的运行状态->运行线程2
时间片:定一个时间,比如每个线程执行10毫秒,或者每个线程执行100条命令。
信号量/锁:设一个标志位,比如一个工作要等待线程1工作完成才能继续,把它锁住。线程挂起,等待有信号通知才检查标志位的是信号量,不断检查标志位的是锁。
原子操作:在这些代码没执行完之前,不能进行上下文切换,哪怕没有时间片。
调度器:实现包含但不限于这些功能的东西叫调度器。
当一个程序被创建的时候,会有一个进程被操作系统创建,与此同时会有一个线程立即运行。一个进程中的线程是没有父子之分的,都是平级关系,退出一个不会影响另一个。
2. 常见函数
- pthread_create
1 | int pthread_create(pthread_t *tidp, const pthread_attr_t *attr, |
- pthread_exit
1 | void pthread_exit(void* retval); |
- pthread_join
1 | int pthread_join(pthread_t thread, void **retval); |
- pthread_detach
1 | int pthread_detach(pthread_t tid); |
- pthread_cancel
1 | int pthread_cancel(pthread_t thread); |
- pthread_equal
1 | int pthread_equal(pthread_t t1, pthread_t t2); |
- pthread_self
1 | pthread_t pthread_self(void); |
3. 代码例子
1 | // 编写程序,主线程循环创建5个子线程,并让子线程判断自己是第几个子线程并输出 |
1 | // 编写程序,让主线程取消子线程的执行 |
0x08 线程同步
1. 线程同步的概念
在一个线程在对内存进行操作的时候,其他线程都不可以对着该内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作。
2. 解决同步问题的方法
原子操作:这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch
互斥锁(mutex):用于多线程中,防止两条线程同时对同一个公共资源进行读写的机制。互斥锁是多个线程一起去抢,抢到锁的线程先执行,没有抢到锁的线程需要等待,等互斥锁使用完释放后,其它等待的线程再去抢这个锁。
3. 常见函数
- pthread_mutex_init
1 | int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr); |
- pthread_mutex_destroy
1 | int pthread_mutex_destroy(pthread_mutex_t *mutex) |
- pthread_mutex_lock
1 | int pthread_mutex_lock(pthread_mutex_t *mutex); |
- pthread_mutex_unlock
1 | int pthread_mutex_unlock(pthread_mutex_t *mutex); |
- pthread_mutex_trylock
1 | int pthread_mutex_trylock( pthread_mutex_t *mutex ); |