Linux 系统编程
Linux 系统编程
通用 Makefile 文件
- 通用 Makefile 文件的代码如下:
1 |
|
目录相关操作
获取当前工作目录
- 我们可以调用库函数
getcwd()
获取当前工作目录的绝对路径:
- 如果传入的
buf
为NULL
,且size
为0
,则getcwd()
会调用malloc
申请合适大小的内存空间,填入当前工作目录的绝对路径,然后返回malloc
申请的空间的地址。- 注意:
getcwd()
不负责free
申请的空间,free
是调用者的职责。
- 注意:
- 代码:
1 |
|
- 也可以与库函数
error()
来联动进行错误处理,从下图中的数据手册可以看到getcwd()
出错的时候会设置errno
这个参数:
- 库函数
error()
的用法:
- 代码如下:
1 |
|
- 运行结果:
改变当前工作目录
- 我们可以调用库函数
chdir()
改变当前工作目录的绝对路径:
- 代码:
1 |
|
- 注意:当前工作目录是进程的属性,也就是说每一个进程都有自己的当前工作目录。且父进程创建 (
fork
)子进程的时候,子进程会继承父进程的当前工作目录。 - 运行结果:
创建目录
mkdir()
函数可以用来创建目录。
- 代码:
1 |
|
- 当不知道
mode_t
的类型是什么的时候,可以使用以下方法来查看:
1 |
|
- 运行结果:
删除空目录
rmdir()
可以删除空目录。
- 代码:
1 |
|
目录流
- 使用目录流,可以查看目录中的内容。
- 流模型:“流"类似"水流”,顺序访问流中的数据时,是不需要关注位置的。
- 目录流与文件流相比,文件流中的基本单位是字符或字节。而目录流中的基本单位是目录项。如下图所示:
打开目录流
opendir()
可以打开一个目录,得到一个指向目录流的指针DIR*
。
关闭目录流
closedir()
关闭目录流。
读取目录流
readdir()
读目录流,得到指向下一个目录项的指针。
- 结构体
dirent
的定义:
- 读取一个目录内的内容并打印的代码:
1 |
|
递归地打印目录
- 实现青春版
tree
命令:输出内容分为三部分- 1)目录的名字;
- 2)递归打印每一个目录项;
- 3)最后是统计信息。
- 代码:
1 |
|
- 效果:
文件相关操作
打开文件
- 系统调用
open()
打开文件。- 打开成功:返回新的文件描述符(最小可用的文件描述符);
- 打开失败:返回
-1
,设置errno
。
- 部分
flags
的含义:
- 代码:
1 |
|
- 通过系统调用
open()
的过程介绍内核管理文件的数据结构:- 文件描述符表的数组长度默认是 1024,并且可以进行设置:
- 系统调用
open()
的内核管理文件流程:
关闭文件
- 系统调用
close()
关闭文件。- 关闭成功:返回
0
; - 关闭失败:返回
-1
,设置errno
。
- 关闭成功:返回
- 系统调用
close()
的内核管理文件流程:
读取文件
- 系统调用
read()
读取文件。- 读取成功:返回实际读取的字节数目(
0
,表示读取的起始位置在文件末尾); - 读取失败:返回
-1
,设置errno
。
- 读取成功:返回实际读取的字节数目(
- 系统调用
read()
的内核管理文件流程:
写入文件
- 系统调用
write()
读取文件。- 写入成功:返回实际写入字节数目(其中
n≤count
); - 写入失败:返回
-1
,设置errno
。
- 写入成功:返回实际写入字节数目(其中
- 系统调用
write()
的内核管理文件流程:
修改文件偏移量
- 系统调用
lseek()
修改文件偏移量,本质上就是修改current file offset (pos)
的数值。- 修改成功:返回文件的位置(距离文件开头的字节数目);
- 修改失败:返回
-1
,设置errno
。
- 系统调用
lseek()
的内核管理文件流程:
- 文件库函数和文件系统调用之间的关系:
文件描述符和文件流的异同
- 文件描述符的系统调用与文件流的函数:
- 文件描述符和文件流的数据访问流程:
同步内存中所有已修改的文件数据到储存设备
- 函数
fsync()
同步内存中所有已修改的文件数据到储存设备。- 将和文件描述符相关联的脏页刷新到磁盘。
- 刷新成功:返回
0
; - 刷新失败:返回
-1
并设置errno
。
修改文件长度
- 函数
ftruncate()
修改文件长度为length
个字节大小。 - 用法如下:
- 修改文件的长度有两种情况:
- 情况二可能会出现文件空洞的情况,此时数据全为
0
的页不会分配磁盘空间。
- 情况二可能会出现文件空洞的情况,此时数据全为
- 使用代码示例:
1 |
|
- 关于文件空洞的测试:
获取文件状态信息
- 函数
fstat()
用于获取文件状态信息。- 获取成功:返回
0
; - 获取失败:返回
-1
,设置errno
。
- 获取成功:返回
- 其具体用法和状态信息结构体如下:
- 代码示例:
1 |
|
- 打印结果:
文件描述符的复制
- 系统调用
dup()
用于对文件描述符的复制。- 复制成功:返回新的文件描述符;
- 复制失败:返回
-1
,设置errno
。
- 其具体用法如下,分为
dup()
和dup2()
两个系统调用:
- 系统调用
dup()
的内核管理文件流程:
- 使用
dup()
函数实现重定向:
1 |
|
- 使用
dup2()
函数实现重定向:
1 |
|
- 分别执行完两个代码的运行结果:
- 第一个代码重定向
STDERR_FILENO
文件描述符到application.log
文件,写入一段语句; - 第二个代码同样重定向
STDERR_FILENO
文件描述符到application.log
文件,写入一段语句。
- 第一个代码重定向
零拷贝(mmap)
- 系统调用零拷贝(
mmap()
)能够省去内核态和用户态的内存拷贝。
- 系统调用零拷贝(
mmap()
)的工作原理:- 相当于将文件的一部分内存通过
I/O
操作拷贝到物理内存中,并且内核态和用户态共用这部分内存,不再需要重复拷贝。
- 相当于将文件的一部分内存通过
- 系统调用零拷贝(
mmap()
和munmap()
)的使用方法:
- 系统调用零拷贝(
mmap()
和munmap()
)的返回值:
- 系统调用零拷贝(
mmap()
和munmap()
)的复制大文件使用场景:
- 代码示例:
1 |
|
CPU 的虚拟化
前置知识
- 内核的职责:管理硬件资源
- 共享资源的方式:
- 时分共享(CPU)
- 空分共享(内存)
- 操作系统通过让一个进程运行一段时间,然后切换到其它进程,缺点是会造成性能损失(需要进行上下文切换)。
- 如何实现 CPU 的时分共享?
- 底层机制:如何进行上下文切换
- 上层策略:调度策略
认识进程
- 用户角度:进程就是正在执行的程序
- 内核角度:要执行的任务(
struct task_t
)- 进程之间必须隔离,进程之间是相互看不到的,感知不到另外的进程存在。
- 以进程的角度看,就像它独占计算机的所有资源(抽象机制:CPU 的虚拟化)。
xv6
操作系统的进程相关结构体:
底层机制:实现上下文切换的三种方式及优缺点
- 指标:
- 性能:不应该增加太多的系统开销
- 控制权:操作系统应该保留控制权
方式一:直接运行(无限制)
- 优点:简单、快
- 缺点:没有控制权、不安全。
- 如何限制应用程序的权限?
- 不能让用户态应用程序访问非法的内存空间和执行一些特权指令。
- 需要硬件的协助即 CPU 的模态(模式):
- 用户态:应用程序(不能访问非法的内存空间和执行一些特权指令)
- 内核态:操作系统(可以访问机器的所有资源)
方式二:受限直接运行协议
- 应用程序如何执行特权操作?
- 通过系统调用!
- 通过特殊指令
trap
来切换用户态和内核态:- 缺点在于控制权不是主动掌握在操作系统上。
方式三:受限直接运行协议(时钟中断)
- 方式二采用协作(
yield()
)的方式,等待系统调用。 - 方式三采用非协作方式(抢占方式),操作系统能够主动进行控制。
- 时钟中断的方式:
- 受限直接运行协议(时钟中断):
- 进程之间是隔离的(感知不到内核和其他进程的存在)。
- 进程是资源分配的最小单位(任务<-分配资源)。
- 上下文切换:
- 调用系统调用
- 切换进程
和进程相关的常用命令
显示进程
ps
命令显示和终端关联的进程:
ps x
显示和用户关联的进程:
ps aux
显示所有用户相关的进程:
top
每 3 秒统计一次进程信息:
pstree
打印进程树:
- 前台进程:
- 后台进程:
获取进程的标识
- 获取进程的标识的用法:
- 代码示例:
1 |
|
- 运行结果:
Linux
进程id
的分配策略:
进程的基本操作
- 创建进程:
fork()
- 终止进程:
exit()
、_exit()
、abort()
、wait()
、waitpid()
- 执行程序:
exec
函数簇
创建进程:fork()
- 系统调用
fork()
的用法:
-
系统调用
fork()
的返回值:- 成功:
- 父进程:子进程的
pid
- 子进程:
0
- 父进程:子进程的
- 失败:
- 父进程:
-1
,并且不会创建子进程,设置errno
。
- 父进程:
- 成功:
-
惯用法代码:
1 |
|
- 测试结果:
- 到底是父进程先执行还是子进程先执行是不确定的(不能假定到底是谁先执行)。
- 系统调用
fork()
的原理:
代码段:父子进程共享(不能修改)
- 栈、堆、数据段(父子进程私有)
- 测试代码:
1 |
|
- 测试结果:
用户态缓冲区(文件流):父子进程是私有的
- 测试代码:
1 |
|
- 测试结果:
- 用户缓冲区父子进程私有经典题目一:
- 总共输出 24 个
a
。
- 总共输出 24 个
- 用户缓冲区父子进程私有经典题目二:
- 总共输出 14 个
a
。
- 总共输出 14 个
打开文件(共享的)、文件描述符列表(私有的)
- 对于父子进程,打开文件(共享的)、文件描述符列表(私有的)
- 测试代码:
1 |
|
- 测试结果:
终止进程
- 基本概念:
正常终止
- 库函数
exit()
的步骤以及系统调用atexit()
的用法和返回值:
- 示例代码:
1 |
|
- 测试结果:
- 系统调用
_exit()
的用法:- 退出状态码传至操作系统。
- 测试程序:
1 |
|
- 测试结果:
异常终止
- 系统调用
abort()
的用法:
- 测试代码:
1 |
|
- 测试结果:
- 内核给该进程发送
SIGABRT
信号
孤儿进程和僵尸进程
- 孤儿进程:子进程存活,父进程终止了
- 测试代码:
1 |
|
- 测试结果:
- 分析:孤儿进程会被
1
号进程(init
进程)收养,该进程一直循环执行wait
函数。
- 僵尸进程:子进程死亡时,有一些信息会保存在内核(
pid
、退出状态、CPU 时间…),方便父进程以后查看这个信息,并且给父进程发送SIGCHLD
信号,但父进程默认会忽略信号。- 如何给僵尸进程收尸:
wait
、waitpid
。
- 如何给僵尸进程收尸:
wait()
- 系统调用
wait()
的用法和返回值:
- 测试程序:
1 |
|
- 测试结果:
waitpid()
- 系统调用
waitpid()
的用法:
- 系统调用
waitpid()
的返回值:
- 测试程序:
1 |
|
- 测试结果:
exec 函数簇
环境变量 env 的定义:
- 打印环境变量
env
测试程序:
1 |
|
- 测试结果:
exec 函数簇
exec
函数簇的用法:
exec
函数簇的测试程序:
1 |
|
- 测试结果:
exec
现象和原理:- 从上图可以看到,
pid
和ppid
没有改变,因此没有创建新的进程,并且在新的可执行程序mian
函数的第一行开始执行。 - 原理在于:
- 执行
exec
函数簇会清除进程的代码段、数据段、堆、栈、上下文; - 加载新的可执行程序(设置代码段、数据段);
- 从新的可执行程序
mian
函数的第一行开始执行。
- 执行
- 从上图可以看到,
- 清除进程的代码段、数据段、堆、栈、上下文如下图所示:
system 的实现和惯用法
system()
系统调用的用法:
- 简易
system()
的实现代码:
1 |
|
- 惯用法说明:
Simple_shell 的实现
- 使用
exec
函数簇实现建议shell
:
1 |
|
进程间通信
管道 pipe
- 管道:内核管理的一个数据结构
- 管道需要读端和写端都就绪,
open
才会返回。 - 当写端写入数据时,
read
才会返回,否则是阻塞状态。 - 如果写端关闭,读端是可以读到剩余数据,如果数据读完了,读端会读到
EOF
(read
会返回0
);
- 管道需要读端和写端都就绪,
- 创建管道:
- 进程间管道通信惯用法:
- 先
pipe
- 再
fork
- 父进程关闭管道一端
- 子进程关闭管道的另一端
- 先
- 代码:
1 |
|
有名管道 mkfifo
- 有名管道
mkfifo
:- 管道需要读端和写端都就绪,
open
才会返回。 - 当写端写入数据时,
read
才会返回,否则是阻塞状态。 - 如果写端关闭,读端是可以读到剩余数据,如果数据读完了,读端会读到
EOF
(read
会返回0
); - 如果读端关闭,往管道写数据,内核发送
SIGPIPE
信号。
- 管道需要读端和写端都就绪,
五种 I/O 模型
- 五种
I/O
模型:
多路 I/O 复用
select 系统调用
select
系统调用用法:- 作用:将多个阻塞点变成一个阻塞点!
select
系统调用参数:
select
系统调用详细参数和使用方法:
select
系统调用工作原理:
- 使用
select
系统调用实现点对点聊天系统:- 需要注意的点:如果一方将管道写端关闭了,
read
系统调用会一直读,但返回值是 0,即读到 0 个 Bytes。
- 需要注意的点:如果一方将管道写端关闭了,
- 用户 1 代码:
1 |
|
- 用户 2 代码:
1 |
|
select
系统调用的缺陷:- 监听的文件描述符的个数是有限的;
- 当
select
系统调用返回时,还需要遍历fd_set
,找到就绪的文件描述符。
信号
基本概念
- 信号是内核通知应用程序外部事件的一种机制。
- 事件源:
- 内核会感知事件,并给进程发送相应的信号。
- 信号的处理方式:
- 标准信号 1:
- 标准信号 2:
信号的执行流程
- 注册信号处理函数:
- 示例代码:
1 |
|
- 信号的处理流程:
- 注册函数是跑在用户态的。
- 信号的特点:
- 不稳定;
- 异步的(什么时候收到信号是不确定的,收到信号后,会立刻马上执行信号处理函数);
- 不同心态关于信号的语义也不一样。
注册信号处理函数
- 注册信号处理函数:
- 示例代码 1:
1 |
|
- 示例代码 2:
1 |
|
发送信号
kill
命令:
pid
相关权限和返回值:
- 示例代码:
1 |
|
线程
- 线程:一条执行的流程。
- 引入线程:
- 进程是资源分配的最小单位;
- 线程是调度的最小单位;
- 线程共享进程的所有资源。
- 为什么要引入线程?
- 进程之间的切换(
CPU
的高速缓存,TLB
失效),开销大。用进程中的线程之间切换,开销较小。 - 进程之间通信,需要打破隔离避障,线程之间的通信,开销较小。
- 进程的创建和销毁比较耗时,而线程的创建和销毁要轻量很多。
- 进程之间的切换(
线程的基本操作和创建线程
- 获取线程的标识:
- 创建线程:
pthread
库设计原则:- 返回值是
int
类型,表示调用成功或失败。- 成功:
0
; - 失败:错误码,不会设置
errno
。
- 成功:
thread_t
:返回时,存放创建线程ID
。attr_t
:线程属性,一般填NULL
,表示用默认属性。start_routine
:线程的入口函数。arg
:线程的入口函数的参数。
- 返回值是
- 示例代码:
1 |
|
- 运行结果:
- 向线程的入口函数传递参数(
void*
)代码:
1 |
|
- 运行结果:
终止线程
- 进程的终止:
- 从
main
返回 exit()
- 收到信号
- 从
- 线程的终止:
- 从
start_routine
返回 pthread_exit()
pthread_cancel()
- 从
线程显式终止函数
pthread_exit()
线程显式终止函数:
pthread_join()
等待一个线程的结束:
- 示例代码(
1
到100
求和,分两个线程执行):
1 |
|
- 运行结果:
- 注意:不能返回指向该线程栈上数据的指针,因为当线程退出时,该线程的栈会销毁。
1 |
|
游离线程
pthread_detach()
用于将线程设置为游离状态的函数,使线程在终止时自动释放资源:
- 示例代码:
1 |
|
- 主线程无法再获取游离线程的返回结果:
线程清理函数
- 线程清理函数:用于在线程退出时执行预定义的清理操作。
execute
参数:0
:栈中的args
参数出栈但不执行cleanup
线程清理函数。- 非
0
:栈中的args
参数出栈并执行cleanup
线程清理函数。
- 与进程之间的对比:
- 示例代码:
1 |
|
- 运行结果:
- 注意事项:
- 从
start_routine
返回,不会执行线程清理函数。 pthread_cleanup_push
和pthread_cleanup_pop
必须成对出现。- 必须成对出现的原因在于源码中采用了宏函数的特性。
- 从
线程的同步
- 原子性:CPU 指令是原子性的。
- 相关术语:
- 竞态条件(
race condition
):- 多个执行流程
- 共享资源
- 程序的结果(状态取决于执行流程调度的情况)
- 异步和同步:
- 异步:任何调度情况都可以出现、两个执行流程不做任何交流。
- 同步:让一些调度不可能出现(同步会有一些开销)。
- 互斥锁、条件变量。
- 并发和并行:
- 并发:一种现象,在一个时间段中,执行流程可以交替执行。
- 并行:一种技术,同一时刻,可以执行多个执行流程(并行是并发的一种)。
- 竞态条件(
- 线程的同步:
- 互斥地访问资源
- 等待某个条件成立
- 互斥锁函数:
- 互斥锁的使用代码:
1 |
|
- 运行结果:
- 银行例子(细粒度锁):
1 |
|
- 运行结果:
死锁
- 以下四个条件同时成立会造成死锁:
- 互斥
- 持有并等待
- 不能抢占
- 循环等待
- 死锁代码例子:
1 |
|
- 三个线程都处于阻塞状态:
- 解决方案 1:破坏循环等待
- 必须按照固定的顺序,依次获取锁:
1 |
|
- 运行结果:
- 解决方案 2:不能抢占
1 |
|
- 解决方案 3:持有并等待
1 |
|
- 解决方案 4:解决互斥,实现原子性
等待条件成立
- 条件变量(
pthread_cond_t
):- 条件变量只是提供了一个等待、唤醒机制;
- 至于条件何时成立,何时不成立,取决于业务。
- 1、初始化:
- 2、当条件不成立,等待:
pthread_cond_wait()
返回时,条件一定成立吗?- 不一定!
- 3、当条件成立时,唤醒等待的线程:
- 4、销毁
生产者消费者模型
- 生产者消费者模型:
- 阻塞队列:
- 当队列满时,如果线程往阻塞队列中添加东西,线程会陷入阻塞;
- 当队列空时,如果线程往阻塞队列中取出东西,线程会陷入阻塞;
- 生产者,产生任务:
- 如果队列满了,生产者陷入阻塞,等待队列不满(
not_full
); - 如果队列不满,将任务添加到阻塞队列,队列非空,唤醒消费者(
not_empty
);
- 如果队列满了,生产者陷入阻塞,等待队列不满(
- 消费者,完成任务:
- 如果队列空了,消费者陷入阻塞,等待队列非空(
not_empty
); - 如果队列非空,从阻塞队列中获取任务,队列不满,唤醒生产者(
not_full
);
- 如果队列空了,消费者陷入阻塞,等待队列非空(
- 阻塞队列:
阻塞队列
- 阻塞队列(有界队列):
blockQ.h
文件:
1 |
|
blockQ.c
文件:
1 |
|
实现生产者消费者模型
- 通过线程池(能够避免频繁地创建和销毁线程)实现生产者消费者模型:
- 应用程序应该包含多少个线程要通过两个方面考虑:
- CPU 的核数
- 任务的负载
I/O
密集型任务- 计算密集型任务
- 代码示例:
1 |
|
- 生产者消费者模型运行结果:
Linux 系统编程
http://example.com/2025/05/18/linux_program/