将物理计算机抽象成虚拟计算机
进程:程序的运行时状态随时间的演进;除了程序状态,OS 还会保存一些额外的(只读)信息,比如 pid;程序希望知道这样的信息 系统调用 syscall,如 getpid() getcwd() getgid() getuid() getegid() geteuid() getpriority()
等等
查看进程
ps aux | grep <pid>
进程编号在不断递增,然而进程号一定是有限的。现代 linux 内核支持 PID 命名空间,在容器或者虚拟环境中隔离 PID 空间
Windows 中
spawn(path, argv)
_exit()
UNIX 中
fork()
execve
复制状态机时,原本的状态机(父进程)返回 pid,新的副本(子进程)返回 0
int pid = fork();
if (pid < 0) {
// 异常处理
}
if (pid == 0) {
// 子进程
} else {
// 父进程
}
子进程有一部分状态是直接复制自父进程,还有一部分会被操作系统处理(如 pid、打开的文件等等)
用 pstree
来查看进程树
如果一个子进程的父进程被中止了,会有托孤机制,将子进程的状态返回给 1 号进程,或者准确来说 systemd 进程
fork 只能创建一样的进程,如果要创建新的进程,需要用 execve
int execve(const char *filename, char *const argv[], char *const envp[])
argv
最后一位需要是 NULL
,用于定位参数结束
一般来说
pid = fork();
if (pid == 0) {
execve(...);
} else {
// 继续父进程
}
注意操作系统维护的那部分状态,在 execve
之后仍然是保持不变的;在 execve
之后的代码都不会被执行(因为已经让系统重置了状态机,原有的状态机不存在了)
execve
是唯一能“执行程序”的系统调用
进程的初始状态:execve
之后的状态
ELF 文件中有一个 entry point address,是程序开始运行的地址
在实际中,系统调用不一定需要进入内核
/proc/<pid>/maps
记录了所有内存映射状态;/proc/<pid>/mem
一定有一个系统调用可以改变进程的地址空间
// 映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
// 修改映射权限
int mprotect(void *addr, size_t length, int prot);
addr
:映射起始地址,通常为 NULL
,操作系统自动选择合适的位置,是 page-aligned 的位置;即使设置了值,也不一定会在指定的位置进行分配,而是在附近寻找一个 page-aligned 的位置分配length
:映射区域长度,单位为字节prot
:内存保护方式(权限),PROT_READ PROT_WRITE
flags
:控制映射行为,MAP_SHARED
共享映射,MAP_PRIVATE
私有映射fd
:要映射的文件描述符offset
:文件中的偏移量,表示从文件的哪个位置开始映射void *mem = mmap(
NULL, // let the kernel decides
4096, // allocate 4kb
PROT_READ | PROT_WRITE, // read and write
MAP_PRIVATE | MAP_ANONYMOUS, // private and anonymous
-1, // no file descriptor
0 // no offset
)
将文件加载进地址空间:
int fd = open(argv[0], O_RDONLY);
if (fd == -1) {
perror("OPEN");
return 1;
}
struct stat st;
if (fstat(fd, &st) == -1) {
perror("fstat");
close(fd);
return 1;
}
void *exe_map = mmap(
NULL,
st.st_size,
PROT_READ,
MAP_PRIVATE,
fd,
0
)
可以用 munmap
来回收
munmap(mem, 4096);
munmap(exe_map, st.st_size)
close(fd);
先使用 mmap
分配内存,但此时操作系统不会真正的分配给程序,要等到程序运行到需要这个内存的时候,才抛出 segment fault
的异常,然后从物理内存中进行分配;因此可以实现瞬间分配内存
mmap
和 malloc
的区别:
特性 | malloc | mmap |
---|---|---|
内存控制权 | 程序分配,系统释放 | 完全程序控制 |
默认初始化 | 一般初始化为可用内存块(实现相关) | 可能为脏页,需自己初始化 |
分配位置 | 堆上 | 虚拟地址空间,通常在 heap 之外 |
适用场景 | 小块频繁分配 | 大块内存、文件映射、共享内存 |
在使用 mmap
申请了一块内存之后,在这块内存上的所有操作都需要程序员手动管理(内存使用方式、生命周期、初始化、同步……)
文件:有名字的数据对象
/dev/urandom
)文件描述符:
/proc/<pid>/fd
ssize_t read(int fd, void *buf, size_t count);
对文件描述符而言,需要用 open
、close
等进行打开、关闭,dup
用于备份
int fd = open("sample.txt", O_RDWR | O_CREAT)
0 1 2
分别是 stdin、stdout、stderr,新打开的文件从 3 开始分配
文件描述符实际上是指向了一个文件中的 offset,顺带了一个对象;当 dup
时,会共享这个 offset。这是因为每个文件描述符指向一个文件表项,dup
之后两个文件描述符指向了同一个文件表项
NOTE操作系统打开一个文件时,涉及到三层数据结构:进程 文件描述符 i-node(文件元数据)。文件描述符是进程私有的整数句柄,各指向一个文件表项(包括 offset 和打开标志
O_RDONLY
等)。而文件表项又指向 i-node,表示文件元信息,包括实际磁盘地址等
在 Windows 系统中,文件描述符叫作 handle
任何“可读写”的东西都可以是文件,如真实设备 /dev/sda
/de/tty
,以及虚拟设备 /dev/urandom
等
管道:一个特殊的“文件”(流),读口支持 read
,写口支持 write
int pipe(int pipefd[2], int flags);
结合文件描述符的特点,可以用来实现进程之间的通信:先创建一个 pipe 对象,然后 fork
进程,如果想父进程写,子进程读,就在父进程中 close
读口,子进程中 close
写口
用 man 2 pipe
来查看创建管道时的约定
#include <fcntl.h>
#include <unistd.h>
int main() {
int pipefds[2];
int result = pipe(pipefds);
if (result == -1) {
// 异常处理
}
// 开始 fork
int pid = fork();
if (pid == -1) {
// 异常处理
}
if (pid == 0) {
// 子进程
close(pipefds[1]); // 关闭写口
} else {
// 父进程
close(pipefds[0]); // 关闭读口
}
}
上面的管道是匿名的,可以用 mkfifo
来显式创建一个管道
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>
#define FIFO_PATH "myfifo"
int main() {
if (mkfifo(FIFO_PATH, 0666) == -1) {
perror("mkfifo");
// 若已存在可以继续,不一定算错误
}
pid_t pid = fork(); // pid_t 就是 int
if (pid < 0) {
perror("fork");
exit(EXIT_FAILURE);
}
if (pid == 0) {
// 子进程:读取数据
int fd = open(FIFO_PATH, O_RDONLY);
if (fd == -1) {
perror("open (child)");
exit(EXIT_FAILURE);
}
char buf[128];
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Child (Reader) received: %s\n", buf);
} else {
printf("Child: nothing read\n");
}
close(fd);
exit(0);
} else {
// 父进程:写入数据
sleep(1); // 确保子进程先打开 FIFO
int fd = open(FIFO_PATH, O_WRONLY);
if (fd == -1) {
perror("open (parent)");
exit(EXIT_FAILURE);
}
const char* msg = "Hello from parent!";
write(fd, msg, strlen(msg));
printf("Parent (Writer) sent: %s\n", msg);
close(fd);
wait(NULL); // 等待子进程结束
// 删除 FIFO 文件
unlink(FIFO_PATH);
}
return 0;
}
Everything is a file 缺点:
现在的一般都是伪终端 pty
,由一对主/从设备构成(在 /dev/pts/
下),用软件模拟了物理
read()
:获取从设备的输出write()
:发送键盘输入到从设备/dev/tty
)对伪终端 /dev/pts/5
,可以人为写入:
echo Hello > /dev/pts/5
创建:openpty()
通过 /dev/ptmx
(pseudo-terminal master and slave) 申请一个新终端,返回两个文件描述符
有了主从设备之后,就可以通过对主设备进行修改实现许多功能
Ctrl-C
:主设备接收到后,向从设备发送 SIGINT
进程组(process group):最开始启动一个进程,默认是一个进程组,之后以 fork 的方式创建的进程就算在一个进程组中。在 UNIX 的每个会话(session)中,需要有一个 controlling terminal。
Ctrl-z
:最小化按钮
fg %1
:最大化按钮
会话组:每个进程继承父进程的 session ID (SID),一个 session ID 管理一个控制终端,当这个终端退出时,所有的进程都被发送 SIGHUP 的信号(nohup 依赖的原理)
进程组:每个进程继承父进程的 process group ID (PGID),同一时刻只能有一个前台进程组。当操作系统收到 Ctrl-c
时,会向前台进程组使所有进程发送 SIGINT 信号
联想之前的进程树的概念,即使某个子进程的父进程结束了(从而导致托孤),当 ctrl-c
时,这个子进程仍然会被杀掉,正是因为有 PGID 的存在
shell 语言:只有字符串,所有操作都是对字符串的处理(类似 C 语言中的预编译)
$()
,<()
cmd > file < file 2> /dev/null
,> file
重定向标准输出,< file
重定向标准输入,2> file
重定向标准错误流cmd1; cmd2
,cmd1 && cmd2
,cmd1 || cmd2
(短路求值)cmd1 | cmd2