Linux 系统编程:进程、文件 I/O、IPC 与网络
Linux 系统编程是直接调用内核提供的系统调用来完成进程管理、文件操作、网络通信等任务。和应用层库不同,系统调用是程序和操作系统内核之间最直接的接口。
这篇覆盖嵌入式 Linux 开发里最常用的几块:进程与信号、文件与 I/O、进程间通信、网络编程。
一、进程与信号
进程基础
程序是磁盘上的可执行文件,进程是程序运行起来之后的实体。同一个程序可以同时跑多个进程,比如你开两个终端各跑一个 vim,这是两个进程,各自有独立的状态,互不干扰。
每个进程有独立的地址空间——代码段、数据段、堆、栈都是自己的,内核通过页表保证进程之间不能互相读写内存。这个隔离是操作系统稳定性的基础:一个进程崩溃,其他进程不受影响。
进程在运行过程中有几种状态:
- 运行(Running):正在占用 CPU 执行
- 可运行(Runnable):准备好了,等待调度器分配 CPU
- 睡眠(Sleeping):在等待某个事件(I/O、信号、定时器),不占 CPU
- 僵尸(Zombie):进程已经退出,但父进程还没调用
wait回收,进程表项还在 - 停止(Stopped):收到
SIGSTOP被暂停
stateDiagram-v2
[*] --> Runnable : 进程创建(fork)
Runnable --> Running : 调度器分配 CPU
Running --> Runnable : 时间片用完
Running --> Sleeping : 等待 I/O / 信号 / 定时器
Sleeping --> Runnable : 事件就绪
Running --> Stopped : SIGSTOP
Stopped --> Runnable : SIGCONT
Running --> Zombie : 进程退出
Zombie --> [*] : 父进程 wait() 回收
每个进程有唯一的 PID,ps aux 可以查看所有进程,/proc/<pid>/ 目录下存放着进程的所有运行时信息——内存映射、打开的文件、信号状态等。
fork:创建子进程
fork() 把当前进程完整复制一份,得到一个子进程。调用一次,返回两次:父进程里返回子进程的 PID,子进程里返回 0。
1 |
|
fork 之后子进程是父进程的完整拷贝——代码、数据、堆栈、文件描述符全部复制,但两者独立,改一个不影响另一个。
实际上现代内核用**写时复制(Copy-on-Write)**优化 fork:fork 之后父子进程共享同一份物理内存页,只有某一方要写的时候才真正复制那一页。这让 fork 很快,不需要立刻复制几百 MB 的内存。
exec:替换进程映像
exec 把当前进程替换成另一个程序,原来的代码和数据全部丢弃,从新程序的 main 开始执行。
1 |
|
fork + exec 是 Linux 启动新程序的标准模式,Shell 执行命令就是这样做的。
sequenceDiagram
participant Shell
participant Parent as 父进程
participant Child as 子进程
participant Prog as 新程序
Shell->>Parent: 执行命令
Parent->>Child: fork():复制父进程
Note over Parent,Child: 父进程得到子PID,子进程得到0
Child->>Prog: exec():替换为新程序
Note over Child,Prog: 原来的代码数据全部丢弃
Prog-->>Parent: 子进程退出
Parent->>Parent: waitpid():回收子进程
waitpid:回收子进程
子进程退出后,内核保留它的进程表项(包含退出状态),等父进程来读取。这段时间子进程处于僵尸状态——它已经不运行了,但 PID 还占着,ps 里能看到状态是 Z。
如果父进程一直不回收,僵尸进程越积越多,PID 资源耗尽,系统就无法创建新进程。waitpid() 读取子进程退出状态并清理这个表项:
1 | int status; |
WNOHANG 作为第三个参数可以非阻塞检查:有子进程结束返回其 PID,没有返回 0。
信号
信号是进程间发送异步通知的机制,类似硬件中断。常见信号:
| 信号 | 含义 | 默认行为 |
|---|---|---|
SIGTERM |
请求终止 | 进程退出 |
SIGKILL |
强制终止 | 进程退出,不可捕获 |
SIGINT |
Ctrl+C | 进程退出 |
SIGSEGV |
段错误 | 进程崩溃 |
SIGCHLD |
子进程状态变化 | 忽略 |
SIGUSR1/2 |
用户自定义 | 进程退出 |
发送信号:
1 | kill(pid, SIGTERM); // 代码里发信号 |
用 sigaction 注册信号处理函数:
1 |
|
running 必须加 volatile——信号处理函数异步调用,编译器不知道它会修改这个变量,不加 volatile 可能被优化掉,循环变成死循环。
信号处理函数是被内核在任意时刻插入执行的,就像硬件中断处理函数。它可能打断主程序正在执行的任何代码——包括正在 malloc 内部持有锁的时候。如果信号处理函数里也调用 malloc,就会发生死锁。所以信号处理函数里只能调用异步信号安全的函数,这个列表在 man 7 signal-safety 里,printf 严格来说不在其中,但实践中常用。推荐的做法是在信号处理函数里只设置一个 volatile 标志位,主循环里检查这个标志位再做实际处理。
二、文件与 I/O
一切皆文件
“一切皆文件”是 Linux 的核心设计哲学。普通文件、目录、硬件设备(/dev/ttyUSB0、/dev/sda)、管道、socket,在内核里都用同一套接口操作——open、read、write、close。
这个设计的好处很实际:你写一段读串口的代码,换成读普通文件几乎不用改,因为接口一样。网络 socket 也是文件描述符,所以 read/write 直接能用。
文件描述符(fd)是一个整数,是进程和内核之间的句柄。内核里每个进程维护一张文件描述符表,fd 就是这张表的下标,表里存着指向内核文件结构体的指针。进程只操作 fd 这个整数,真正的资源管理在内核里。
每个进程启动时默认有三个 fd:
0:标准输入 stdin1:标准输出 stdout2:标准错误 stderr
open() 从 3 开始分配,关闭后这个编号可以复用。
graph LR
subgraph 进程
fd0["fd 0 (stdin)"]
fd1["fd 1 (stdout)"]
fd2["fd 2 (stderr)"]
fd3["fd 3"]
fd4["fd 4"]
end
subgraph 内核
f0["终端设备"]
f1["终端设备"]
f2["终端设备"]
f3["普通文件"]
f4["socket"]
end
fd0 --> f0
fd1 --> f1
fd2 --> f2
fd3 --> f3
fd4 --> f4
基本文件操作
核心 API:
1 | int open(const char *pathname, int flags, mode_t mode); |
pathname:文件路径flags:打开方式,必须包含O_RDONLY/O_WRONLY/O_RDWR之一,可用|追加其他标志mode:创建文件时的权限位,如0644;不创建文件时可省略- 返回文件描述符,失败返回 -1
1 | ssize_t read(int fd, void *buf, size_t count); |
buf:用户缓冲区指针count:最多读/写的字节数- 返回实际读/写字节数;
read返回 0 表示到达文件末尾(EOF);返回 -1 表示出错
1 | off_t lseek(int fd, off_t offset, int whence); |
offset:偏移量(字节),可以为负数whence:基准位置,SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)- 返回新的文件偏移量;
lseek(fd, 0, SEEK_END)可用来获取文件大小
1 | int close(int fd); |
- 关闭文件描述符,释放内核资源;进程退出时所有 fd 自动关闭,但不要依赖这一点
open() 常用 flags:
| flag | 含义 |
|---|---|
O_RDONLY |
只读 |
O_WRONLY |
只写 |
O_RDWR |
读写 |
O_CREAT |
不存在则创建 |
O_TRUNC |
打开时清空 |
O_APPEND |
追加写 |
O_NONBLOCK |
非阻塞 |
使用示例:
1 |
|
非阻塞 I/O
默认 read() 是阻塞的,没有数据就一直等。对设备、管道、socket,阻塞会让程序卡死。O_NONBLOCK 让没有数据时立刻返回 -1,errno 为 EAGAIN:
1 | int fd = open("/dev/ttyUSB0", O_RDWR | O_NONBLOCK); |
对已有 fd 用 fcntl 设置:
1 | int fcntl(int fd, int cmd, ... /* arg */); |
F_GETFL:获取 fd 当前的状态标志(返回值就是 flags)F_SETFL:设置 fd 的状态标志(传入新的 flags)- 常见用法是先
F_GETFL取出原有标志,再或上O_NONBLOCK后写回,避免覆盖其他标志
1 | int flags = fcntl(fd, F_GETFL); |
select / poll
非阻塞轮询 CPU 空转很低效。select 和 poll 让内核监听多个 fd,有任何一个就绪才返回:
1 | int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, |
nfds:监听的 fd 中最大值加 1(内核用来确定扫描范围)readfds:关心”可读”事件的 fd 集合,传NULL表示不关心writefds:关心”可写”事件的 fd 集合exceptfds:关心”异常”事件的 fd 集合,通常传NULLtimeout:超时时间,NULL表示无限等,{0, 0}表示立刻返回(纯轮询)- 返回就绪 fd 的总数,0 表示超时,-1 表示出错
操作 fd_set 的四个宏:
FD_ZERO(&set):清空集合FD_SET(fd, &set):把 fd 加入集合FD_CLR(fd, &set):把 fd 从集合移除FD_ISSET(fd, &set):检查 fd 是否在集合里(select返回后用这个判断哪个就绪)
1 |
|
select 的问题:fd 上限 1024,每次调用要重设 fd_set,内核遍历全部 fd,fd 多了性能差。poll 取消了 1024 限制,但内核遍历问题没解决。
epoll
epoll 用事件驱动,只通知就绪的 fd,O(1) 复杂度,是高性能服务器的核心:
1 |
|
epoll 三个接口:
1 | int epoll_create1(int flags); |
flags:通常填 0;EPOLL_CLOEXEC表示 exec 后自动关闭该 fd- 返回 epoll 实例的文件描述符
1 | int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); |
epfd:epoll 实例 fdop:操作类型,EPOLL_CTL_ADD(注册)、EPOLL_CTL_DEL(删除)、EPOLL_CTL_MOD(修改)fd:要监听的目标 fdevent:监听的事件类型,EPOLLIN(可读)、EPOLLOUT(可写)、EPOLLET(ET 模式)
1 | int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); |
events:输出数组,存放就绪的事件maxevents:数组大小,最多返回多少个事件timeout:超时毫秒数,-1 表示无限等待,0 表示立即返回- 返回就绪事件数量
LT 和 ET 模式
理解这两种模式需要先明白 epoll 在监听什么:它监听的是 fd 的状态。
- LT(水平触发,默认):只要 fd 处于”有数据可读”的状态,每次
epoll_wait就通知你。你可以分多次慢慢读,只要没读完,下次调用还会通知。 - ET(边缘触发):只在状态发生变化时通知一次——从”没数据”变成”有数据”的那一刻。之后不管你读没读完,不再通知,直到又有新数据到来触发新的状态变化。
ET 模式下如果只读了一部分就返回,剩下的数据再也不会触发通知,就永远堆在缓冲区里。所以 ET 必须循环读直到 EAGAIN(表示缓冲区已空):
1 | ev.events = EPOLLIN | EPOLLET; // 开启 ET 模式 |
mmap:内存映射
普通的文件读写流程是:read 系统调用 → 内核把磁盘数据读到页缓存 → 再从页缓存拷贝到用户空间缓冲区。有两次拷贝,还有系统调用开销。
mmap 直接把文件的页缓存映射到进程地址空间,访问这块内存时如果数据不在内存里,内核自动触发缺页中断把数据从磁盘加载进来。整个过程没有额外的拷贝,访问文件就像访问内存数组,随机访问性能比 lseek + read 好很多。
1 |
|
mmap 适合:大文件随机访问、进程间共享内存、嵌入式 Linux 驱动里访问设备寄存器。
ioctl:设备控制
ioctl(input/output control)是 read/write 的补充——凡是无法用读写表达的设备控制操作,都通过 ioctl 来做。设置串口波特率、查询网卡信息、控制终端窗口大小,本质上都是同一个系统调用:
1 |
|
fd 是设备文件描述符,request 是操作码(由驱动定义),第三个参数是传入或传出的数据指针。
串口波特率设置:
1 |
|
查询网卡 MAC 地址:
1 |
|
查询终端窗口大小:
1 | struct winsize ws; |
嵌入式驱动开发里,驱动通过 _IO/_IOR/_IOW/_IOWR 宏定义自己的操作码,用户程序再通过 ioctl 调用。read/write 负责数据流,ioctl 负责控制面,两者分工明确。
三、进程间通信(IPC)
进程有独立地址空间,不能直接读写对方的内存。内核提供了几种 IPC 机制。
管道(Pipe)
管道是单向字节流,只能用于有亲缘关系的进程(父子进程)。
1 | int pipe(int pipefd[2]); |
pipefd[0]:读端文件描述符pipefd[1]:写端文件描述符- 返回 0 表示成功,-1 表示失败
创建管道后再 fork(),父子进程各自拿到两端的副本。通信是单向的,所以用不到的那端要关掉——否则写端没有全部关闭,读端的 read() 不知道数据是否结束,会一直阻塞等 EOF。
1 |
|
用完不需要的那端必须关掉——写端没有全部关闭,读端的 read() 不会返回 EOF,会一直阻塞。
Shell 里的 ls | grep foo 就是管道,ls 的 stdout 接到 grep 的 stdin。
命名管道(FIFO)
普通管道只能在父子进程间用,命名管道在文件系统里有路径,任意两个进程都能通过路径通信:
1 | int mkfifo(const char *pathname, mode_t mode); |
pathname:FIFO 文件的路径,如/tmp/myfifomode:权限位,如0666- 返回 0 成功,-1 失败(已存在会返回
EEXIST)
创建后用普通的 open / read / write / close 操作,和普通文件接口完全一样。区别在于 open() 会阻塞——写端 open 时会等读端也打开,反之亦然,两边都准备好才继续。
1 | // 进程 A:写 |
open() 会阻塞直到另一端也打开——写端等读端,读端等写端,两边都准备好才继续。
共享内存
管道传数据要经过两次拷贝:写端把数据从用户空间拷到内核缓冲区,读端再从内核缓冲区拷到用户空间。每次 write/read 都要陷入内核,有系统调用开销。
共享内存完全不同——内核把同一块物理内存同时映射到两个进程的虚拟地址空间。进程 A 往这块地址写数据,进程 B 直接在自己的地址空间里就能读到,没有任何拷贝,也不需要系统调用,这就是零拷贝。
代价是没有任何同步保护,A 在写的时候 B 也可能在读,读到的是中间状态,数据损坏。所以共享内存几乎总是配合信号量一起用。
涉及的核心 API:
1 | int shm_open(const char *name, int oflag, mode_t mode); |
name:共享内存对象名,以/开头,如/myshmoflag:O_CREAT | O_RDWR创建并读写,O_RDONLY只读mode:权限位,如0666- 返回文件描述符,失败返回 -1
1 | void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); |
addr:映射起始地址,传NULL让内核自动选length:映射长度(字节)prot:PROT_READ | PROT_WRITE可读写,PROT_READ只读flags:MAP_SHARED修改对其他进程可见,MAP_PRIVATE写时复制(私有副本)fd:shm_open返回的文件描述符offset:从文件哪个偏移开始映射,通常填 0- 返回映射地址,失败返回
MAP_FAILED
1 | int shm_unlink(const char *name); // 删除共享内存对象 |
1 |
|
编译加 -lrt。共享内存没有任何同步保护,多进程同时读写必须配合信号量。
信号量(Semaphore)
信号量是一个整数计数器,配合两个原子操作使用:
sem_wait():计数器减 1。如果减完之后小于 0,调用方阻塞,直到别人postsem_post():计数器加 1,如果有人在等,唤醒其中一个
初始值决定了信号量的语义。初始值设为 1,就是一把互斥锁——同时只允许一个人进临界区;初始值设为 0,就是一个通知机制——一方等待,另一方完成后发出信号;初始值设为 N,就是限制并发数量最多 N 个。
配合共享内存的典型用法是初始值 0:进程 B 调用 sem_wait 阻塞等待,进程 A 写完数据后调用 sem_post 通知,B 被唤醒后安全读取。
核心 API:
1 | sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value); |
name:信号量名,以/开头oflag:O_CREAT创建,O_CREAT | O_EXCL创建且若已存在则报错mode:权限位,如0666value:初始值,0 表示通知模式,1 表示互斥锁,N 表示并发限制
1 | int sem_wait(sem_t *sem); // P 操作:计数 -1,为 0 则阻塞 |
1 |
|
消息队列
消息队列是内核维护的消息链表,支持双向通信和消息优先级,通过 POSIX mqueue 接口操作。
和管道相比有两个本质区别。管道是无结构的字节流,你往里写数据,对面读出来,自己判断边界。消息队列不一样——每次 mq_send 是一条独立的消息,mq_receive 收到的一定是完整的一条,不会粘在一起,也不会被拆开。
另一个区别是优先级。消息队列里的消息按优先级排序,mq_receive 总是先取出优先级最高的那条,而不是最早进来的那条。这对需要插队处理紧急消息的场景很有用,比如传感器报警消息要比普通数据包先处理。
核心 API:
1 | mqd_t mq_open(const char *name, int oflag, mode_t mode, struct mq_attr *attr); |
name:消息队列名,以/开头oflag:O_CREAT | O_WRONLY创建写端,O_RDONLY读端mode:权限位,如0666attr:队列属性,NULL用默认值;指定时填mq_maxmsg(队列最大消息数)和mq_msgsize(每条消息最大字节数)
1 | int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio); |
msg_ptr:消息内容指针msg_len:消息长度(字节),必须 ≤mq_msgsizemsg_prio:优先级,数字越大优先级越高,0 为最低
1 | ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio); |
msg_len:缓冲区大小,必须 ≥mq_msgsize,否则报EMSGSIZEmsg_prio:输出参数,返回收到消息的优先级,不关心可传NULL- 返回实际收到的字节数
1 | int mq_close(mqd_t mqdes); // 关闭描述符 |
1 |
|
编译加 -lrt。
IPC 方式对比
| 方式 | 方向 | 速度 | 适用场景 |
|---|---|---|---|
| 管道 | 单向 | 中 | 父子进程,简单数据流 |
| 命名管道 | 单向 | 中 | 任意进程,简单数据流 |
| 共享内存 | 双向 | 最快 | 大量数据,需配合信号量 |
| 消息队列 | 双向 | 中 | 结构化消息,需要优先级 |
| Unix socket | 双向 | 快 | 同机器通用 IPC |
graph TD
subgraph 管道 / 命名管道
PA[进程 A] -->|write 拷贝到内核缓冲区| KB[内核缓冲区]
KB -->|read 拷贝到用户空间| PB[进程 B]
end
subgraph 共享内存
P1[进程 A] <-->|直接读写,零拷贝| SHM[共享物理内存页]
P2[进程 B] <-->|直接读写,零拷贝| SHM
end
四、网络编程:Socket
Socket 是网络通信的抽象接口,本质上也是文件描述符,可以用 read/write 操作。
核心 API 一览:
1 | int socket(int domain, int type, int protocol); |
domain:地址族,AF_INET(IPv4)、AF_INET6(IPv6)、AF_UNIX(本机 Unix socket)type:SOCK_STREAM(TCP,字节流)、SOCK_DGRAM(UDP,数据报)protocol:通常填 0,让内核根据前两个参数自动选- 返回 socket 文件描述符
1 | int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
- 把 socket 绑定到本地地址和端口
addr:填struct sockaddr_in(IPv4)并强转为struct sockaddr *- 服务器必须 bind;客户端通常不用,内核自动分配临时端口
1 | int listen(int sockfd, int backlog); |
- 把 socket 设为监听状态,只有 TCP 服务器需要
backlog:未完成连接队列的最大长度,通常填 10~128
1 | int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); |
- 从已完成连接队列取出一个连接,返回新的连接 fd
addr:输出参数,填充客户端地址信息- 没有新连接时阻塞等待
1 | int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
- 客户端主动发起连接,TCP 三次握手在这里完成
- 成功后这个 fd 就可以直接
read/write
1 | ssize_t send(int sockfd, const void *buf, size_t len, int flags); |
- TCP 专用的发送/接收,
flags通常填 0,等同于write/read
1 | ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, |
- UDP 专用,每次调用都要指定/获取对端地址,因为 UDP 无连接
TCP 服务器
TCP 通信流程:socket → bind → listen → accept → read/write → close
sequenceDiagram
participant C as 客户端
participant S as 服务器
S->>S: socket() / bind() / listen()
C->>S: connect() → SYN
S->>C: SYN+ACK
C->>S: ACK(三次握手完成)
S->>S: accept() 返回连接 fd
C->>S: write() / send()
S->>C: read() 收到数据
S->>C: write() 响应
C->>S: close() → FIN
S->>C: ACK
S->>C: FIN
C->>S: ACK(四次挥手完成)
1 |
|
TCP 客户端
1 | int fd = socket(AF_INET, SOCK_STREAM, 0); |
字节序
字节序是多字节数据在内存中的存储顺序。一个 int 占 4 个字节,这 4 个字节放进内存时谁排在前面,不同架构的答案不同。
同一个 32 位整数 0x12345678,存在内存里有两种方式:
- 大端(Big-Endian):高字节存低地址,内存里是
12 34 56 78,看起来和写法一样 - 小端(Little-Endian):低字节存低地址,内存里是
78 56 34 12,和写法相反
x86/ARM 默认小端,网络协议(TCP/IP)规定用大端,也叫网络字节序。如果直接把本机的 int 发出去,对方可能是大端机器,读到的值就是错的。所以发送前要转换,接收后也要转换:
1 | htons(port) // 16 位,host to network |
TCP 粘包问题
TCP 是字节流协议,不是消息协议。它只保证字节按顺序到达,不知道也不关心你的数据在逻辑上分几条消息。发送方调用两次 write,TCP 可能把它们合并成一个包发出去(Nagle 算法),接收方一次 read 就全收到了——这就是粘包。反过来,一次 write 的数据也可能被拆成多个包,接收方要调用多次 read 才能凑齐。
对比 UDP:UDP 是数据报协议,每次 sendto 就是一个独立的数据报,recvfrom 收到的一定是一个完整的数据报,没有粘包问题。但 UDP 可能丢包、乱序,需要应用层自己处理。
TCP 粘包的解决方案是自己定义消息边界,最常见的是定长头部:
1 | // 发送:先发 4 字节长度,再发数据 |
UDP
UDP 不需要连接,直接发,每次 recvfrom 收到一个完整数据报,没有粘包问题:
1 | // 发送方 |
epoll + socket:事件驱动服务器
上面的服务器每次只能处理一个连接。用 epoll 可以单线程处理大量连接:
1 |
|
这是 Nginx、Redis 事件循环的基本结构——一个线程用 epoll 管理成千上万个连接。
graph TD
A[epoll_create] --> B[epoll_ctl 注册 server_fd]
B --> C[epoll_wait 阻塞等待]
C --> D{事件类型}
D -->|server_fd 可读| E[accept 新连接]
E --> F[epoll_ctl 注册 conn_fd]
F --> C
D -->|conn_fd 可读| G[read 读取数据]
G --> H{读取结果}
H -->|r > 0| I[write 回写数据]
I --> C
H -->|r <= 0| J[epoll_ctl 删除 + close]
J --> C
Unix Domain Socket
同机器进程通信,用 Unix domain socket 比 TCP 快,接口几乎一样,只是地址换成文件路径:
1 |
|
同机器进程通信优先考虑 Unix domain socket,不占端口,比 TCP 快。
这篇覆盖的内容在实际开发里是相互咬合的:进程用 fork + exec 起来,用信号通信,用 IPC 共享数据;文件 I/O 的 fd 模型贯穿始终,管道、socket、设备文件本质上都是 fd;网络编程建立在 socket 上,高并发靠 epoll,而 epoll 又回到了 fd 的事件模型。
把这几块打通之后,Nginx 的事件循环、Redis 的单线程模型、嵌入式 Linux 的串口通信这类代码读起来就不会陌生了。






