1. 信号铺垫
信号: Linux 系统提供的, 简单轻量的, 用于向指定进程发送特定事件, 让接受信号进程做识别和对应处理实现进程控制的一种异步通信机制.
1~31 普通信号 34 ~ 64 实时信号
信号概览
下面是Linux系统中所有标准信号的名称及其对应的数字:
- SIGHUP (1)
- SIGINT (2)
- SIGQUIT (3)
- SIGILL (4)
- SIGTRAP (5)
- SIGABRT (6)
- SIGIOT (6) # 同SIGABRT
- SIGBUS (7)
- SIGFPE (8)
- SIGKILL (9)
- SIGUSR1 (10)
- SIGSEGV (11)
- SIGUSR2 (12)
- SIGPIPE (13)
- SIGALRM (14)
- SIGTERM (15)
- SIGSTKFLT (16) # 在某些架构上可能不存在
- SIGCHLD (17)
- SIGCLD (17) # 同SIGCHLD
- SIGCONT (18)
- SIGSTOP (19)
- SIGTSTP (20)
- SIGTTIN (21)
- SIGTTOU (22)
- SIGURG (23)
- SIGXCPU (24)
- SIGXFSZ (25)
- SIGVTALRM (26)
- SIGPROF (27)
- SIGWINCH (28)
- SIGIO (29)
- SIGPOLL (29) # 同SIGIO
- SIGPWR (30)
- SIGSYS (31)
请注意,从32开始到64之间的信号(即实时信号)也存在,它们通常被用于进程间通信。这些信号在不同应用中的具体使用可能会有所差异,并且通常是用户自定义的。此外,某些特定于平台或架构的信号可能不在此列表中列出。
信号特性:
- 信号的产生与进程具有异步性. 这个信号在产生发过来之前, 发送信号的进程和接受信号的进程是隔绝的, 没有联系的, 不清楚对方在做什么的.
- 接受信号的进程能够识别并处理信号. 倘若接收方不认识这个信号, 或者不知道如何处理, 这个信号是没有意义的.
- 信号处理可能会被暂时搁置, 但信号一定要被记录下来. 有时候接受信号的进程可能正在做比较重要的事情, 暂时不能对信号做出处理, 因此要记录下来, 等到合适的时机做对应的处理.
信号处理:
- 默认处理: 所谓的默认处理, 说的直白一点就是进程按信号规定好的, 设定好的处理, 其中包含终止, 暂停, 忽略...等多种处理方式.
- 忽略处理: 忽略就是对信号不做任何处理, 直接忽略, 也有可能是信号的默认处理方式.
- 自定义处理(信号捕捉): 结合代码本身对捕捉到的特定信号进行自定义处理, 而不再遵循默认处理.
信号是个宏: 普通信号在进程中是一个宏, 其值与信号序列一致, 信号值与信号名称具有等价效力.
信号捕捉: 用系统调用signale(sig, hander)
, 且对信号的捕捉, 一次捕捉, 全局有效.
发信号: 实际上是写信号, 就是把对应进程的 pcb 中的信号位图对应位置把 0->1 的设置. 这个事情一般是操作系统才有权限进行处理.
信号保存: 是在对应的 task_struct 中以位图的形式保存收到的信号.
信号产生: 信号产生有许多方式
- 通过 kill 命令向指定进程发送信号
- 键盘产生
- 系统调用
豆包关于信号概念和特点的描述
信号: Linux 系统提供的向指定进程发送特定事件, 让接受信号进程做识别和对应处理的一种方式. 我说的对吗?
你的表述基本正确,但可以进一步完善使其更加准确和全面。下面详细阐述:
准确理解
信号是 Linux 系统中一种用于进程间通信(IPC)的机制,用于向指定进程或进程组发送特定事件通知,接收到信号的进程会根据信号的类型执行相应的操作,这些操作可以是系统默认行为、用户自定义处理函数或者忽略该信号。
更全面解释
- 信号的来源
-
- 硬件异常:例如除零错误、非法内存访问等,硬件检测到这些错误后,会触发相应的信号发送给产生错误的进程。比如,当程序试图除以零,CPU 会产生一个错误,内核会向该进程发送
SIGFPE
(浮点异常信号)。 - 软件事件:可以由用户通过终端命令(如按下
Ctrl + C
发送SIGINT
信号)、系统调用(如kill
命令)或者其他进程主动发送信号。例如,使用kill -9 <pid>
命令可以向指定进程 ID 为<pid>
的进程发送SIGKILL
信号,强制终止该进程。
- 硬件异常:例如除零错误、非法内存访问等,硬件检测到这些错误后,会触发相应的信号发送给产生错误的进程。比如,当程序试图除以零,CPU 会产生一个错误,内核会向该进程发送
- 信号的处理方式
-
- 默认处理:每个信号都有其默认的处理动作,例如
SIGINT
的默认动作是终止进程,SIGSTOP
的默认动作是暂停进程。 - 忽略信号:进程可以通过编程的方式设置忽略某些信号,例如使用
signal
或sigaction
系统调用将信号处理函数设置为SIG_IGN
。 - 自定义处理:进程可以为特定的信号注册自定义的信号处理函数,当接收到该信号时,执行自定义的代码逻辑。以下是一个简单的 Python 示例,用于自定义处理
SIGINT
信号:
- 默认处理:每个信号都有其默认的处理动作,例如
import signal
import timedef signal_handler(sig, frame):print('You pressed Ctrl+C! Program will not terminate now.')signal.signal(signal.SIGINT, signal_handler)print('Press Ctrl+C to test...')
while True:time.sleep(1)
总结
信号是 Linux 系统中一种灵活且重要的进程间通信机制,它为系统提供了一种处理异步事件的方式,使得进程能够对各种异常和外部事件做出及时响应。你的描述抓住了信号机制的核心要点,即向进程发送特定事件并让进程进行识别和处理。
可是我想问进程通信不是有了共享内存, 信号量和消息队列了吗, 为啥还要搞一个信号呢? 你看你的意思是信号也算是一种ipc进程通信吧
信号确实属于进程间通信(IPC)的一种方式,虽然系统中已经有共享内存、信号量和消息队列这些 IPC 机制,但信号有其独特的存在价值和应用场景,与其他 IPC 机制形成互补,下面为你详细分析:
与其他 IPC 机制的对比及自身特点
1. 异步通信
共享内存、信号量和消息队列通常用于进程间的同步数据传输和协作,这些机制往往是同步或半同步的通信方式,需要进程主动去读取或写入数据。而信号是一种异步通信机制,它可以在任何时候发送给进程,进程无需时刻关注信号的到来。例如,当用户在终端按下 Ctrl + C
时,系统会立即向正在运行的前台进程发送 SIGINT
信号,进程会在接收到信号时中断当前操作,进行相应处理,无需提前做好接收准备。
2. 简单轻量
信号的实现相对简单,开销较小。它只需要传递一个信号编号,不需要像共享内存那样分配和管理大块的内存空间,也不需要像消息队列那样进行复杂的消息组织和存储。因此,信号适合用于发送简单的通知,如进程终止、暂停等操作。例如,使用 kill
命令发送信号,只需要指定信号编号和目标进程 ID 即可,操作简便。
3. 处理系统事件和异常
信号可以用于处理系统级别的事件和异常情况,这是其他 IPC 机制难以胜任的。例如,当进程出现除零错误、非法内存访问等硬件异常时,系统会自动向该进程发送相应的信号(如 SIGFPE
、SIGSEGV
),进程可以捕获这些信号并进行错误处理,避免程序崩溃。而共享内存、信号量和消息队列主要用于进程间的数据交互和同步,无法直接处理这些系统异常。
4. 进程控制
信号可以用于对进程进行控制,如暂停、继续、终止等操作。通过发送 SIGSTOP
、SIGCONT
和 SIGKILL
等信号,可以方便地管理进程的运行状态。例如,在调试程序时,可以使用 SIGSTOP
信号暂停进程的执行,进行调试操作,然后使用 SIGCONT
信号继续进程的运行。这种进程控制功能是共享内存、信号量和消息队列所不具备的。
总结
虽然共享内存、信号量和消息队列等 IPC 机制提供了强大的数据传输和同步功能,但信号以其异步、简单轻量、处理系统事件和进程控制等独特优势,成为 Linux 系统中不可或缺的进程间通信方式,与其他 IPC 机制共同构成了丰富多样的进程通信手段。
2. 信号产生
下面多种方式是信号产生的方式, 真正发信号的只有操作系统. 这是因为发送信号的本质是修改 pid 对应的 signal 位图.
- 通过 kill 命令向指定进程发送信号 最典型的比如说我们的
kill -sig id
- 键盘产生
ctrl + c(2号信号)
ctrl + '\'(3号信号)
终止信号 - 系统调用
kill()
练习: 实现一个简单的 kill 程序.
raise == kill(getpid(), sigid)
abort(6 号进程的封装)
, 允许你捕捉, 但是在你捕捉之后还会把进程终止掉.
不能全部自定义信号处理: 9 号信号不允许自定义捕捉, 因此全部捕捉信号后不做处理是做不到的.
- 软件条件.
- eg1: 管道, 读端关闭, 写端继续写入会触发 sigpipe(13) 信号.
- eg2: 闹钟.
alarm()
-
- 验证: IO 很慢
- 闹钟具备几个特性. 计算机上的时间开机正确是因为主板上的计时器不断累积(电池供电). 且设置一次默认就仅触发一次(想要一直触发可以在触发函数里加上 alarm(time), 继续设置闹钟).
alarm(0)
表示取消闹钟. 闹钟在进程代码方面只能设置一个闹钟. - 闹钟的返回值. 其返回值是表示上一个闹钟的剩余触发时间.
- 异常
说明: 程序因非法操作/访问, 引发操作系统发送崩溃信号而崩溃. 程序接受到信号后, 可以默认信号处理退出, 也可以捕捉信号, 进行自定义处理.
程序的非法行为是通过各种监测发现的. 以 10/0 和*nullptr 为例说明.
除 0 操作: 10/0 被 CPU 进行计算的时候, CPU 会将其转换为累加运算, CPU 内部有一个 eflag 寄存器标志位, 如果该数过大, 导致溢出, 会被 eflag 标识, 然后操作系统就开始处理异常的进程了. 如果通过自定义信号处理的方式使得原来应该退出的进程没有退出, 会因为 CPU 调度轮转, 下一次轮到该进程执行的时候, 会恢复上下文数据, 也仍然把 eflag 恢复到 CPU 去了, 那么依旧会触发操作系统给进程发送对应崩溃信号(8 号信号). 因此, 推荐终止进程, 也就是推荐释放异常进程的上下文数据.
解引用空指针: 当执行到解引用空指针这一行代码的时候, CPU 会根据对应进程的页表以及集成到自身的硬件 MMU 转换为虚拟地址, 如果转换失败, 比如 nullptr, 就会将其写入到 CR2 寄存器中去, 仍然会被操作系统检测到, OS 会向对应异常进程发送 13 号终止信号.
core 与 term 的区别: 是否形成核心转储文件(debug 文件)
term: 异常终止
core: 异常终止 + 形成核心转储文件.
历史问题: 在 Linux 较老的版本上, core 是生成一个 core+进程号的文件, 这样的好处是可以保留多个进程在同一个目录下的版本, 坏处就是一旦该异常进程被不断调用, 会突然暴增大量 core 文件, 进而引发磁盘崩溃问题. 而在较新的内核版本上, 采用了 core 的文件命名, 这表明在同一个目录下最多有一个 core 文件, 大大减少了磁盘爆满的情况.
在默认情况下, 核心转储功能在云服务器上被默认关闭. 因为这样可以避免多次 core 带来硬盘空间告急的问题. 查看: ulimit -l, 打开: ulimit -c
core 的意义在于方便程序员发现和解决问题, 类似于一个辅助用的 debug 文件, 方便事后调试
.
core 文件解析要与 GDB 配合使用, 换言之需要把 core 文件加载到 gdb 中才可以进行解析, 不然是一堆乱码... gdb <可执行程序名>(在编译时要把程序编译为 debug 版本) <core文件名>
-> 实验: 故意写一个 10/0 代码用 gdb 解析core 文件
core 和 term 影响进程退出时的 coredump 标志位. 该标志位决定了该进程是以 term 退出还是以 core 方式退出. -> 实验: 故意写一个父子进程, 让父进程捕获子进程的推出信息看一下相关的标志位是否满足 core 为 1/0.
3. 信号保存
3.1. 信号保存的相关概念
信号处理是信号递达: 实际执行信号的处理动作称为信号递达(Delivery), 说白了就是处理信号的一个行为就是信号抵达.
信号未决表示信号还没有被处理, 信号未决与忽略处理是两码事: 信号从产生到递达之间的状态,称为信号未决(Pending).
信号可以被进程选择性阻塞, 信号阻塞与信号未决无关: 阻塞这个概念是信号一直处于未决状态, 直到进程结束或者主动解除阻塞状态. 信号阻塞是表示信号不被执行的一个表示, 而信号未决表示信号还没有被执行.
3.2. 信号在内存中的表示(维护)
信号保存由操作系统提供的三个表格进行维护: 分别是 block 表, pending 表以及 bander 表.
- pending 表是一个 int 位图, 从右向左依次表示 1~31 号信号, 最左比特位弃用. 其中, 比特位的位置表示信号的编号, 比特位的内容表示信号是否收到.
- block 表也是一个 int 位图, 比特位的位置表示信号的编号, 比特位的内容表示信号是否被阻塞.
- hander 表是一个函数指针数组, 数组的下标表示信号的编号, 而数组的内容代表 signal()系统调用所捕捉信号的自定义函数方法指针.
阻塞中的信号暂时不能被抵达. 虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
信号是否执行, 如何执行取决于三张表从上到下. 假如说一个信号是阻塞状态, 即使由 pending 表也不会执行, 假如说信号不阻塞, 也有 pending 表(表示信号产生了), 此时该信号根据自定义位图的行为进行操作, 如果 hander 表被 signal()
修改了, 那么就按自定义的行为执行.
普通信号只看是否产生, 不记次数. 常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
3.3. OS 提供的类型 sigset_t
OS 提供 sigset_t 类型, 而非直接使用 int 类型操作信号位图, 其中 sigset_t 类型被称为信号集. 之所以 OS 额外提供一个 sigset_t 类型, 一来是可以进行封装, 方便代码维护, 二来这样做会使得类型更加安全, 因为这是 OS 自己定义的.
阻塞表专业称呼是阻塞信号表, 在这里常被称呼为信号屏蔽字. 这里称呼的改变是专业性的, 是更加区分屏蔽和忽略的含义的.
sigset_t 类型是 OS 提供的用户级的类型, 使用起来更能保证代码运行安全性.
3.4. OS 提供的系统调用
#include <signal.h>
int sigemptyset(sigset_t *set); // 清空信号集, 把信号集全部置0
int sigfillset(sigset_t *set); // 填充信号集, 把信号集全部置1
int sigaddset (sigset_t *set, int signo); // 把信号集某个bit位置1
int sigdelset(sigset_t *set, int signo); // 把信号集某个bit位置0
int sigismember(const sigset_t *set, int signo); // 检查指定的信号是否在信号集中
链接: sigprocmask()
链接: sigpending()
实验: 阻塞 2 号信号的 pending 信号表的变化.
解除屏蔽后, pending 中的信号往往会被立刻处理~
pending 中的信号, 是在抵达前置 0 的, 而非抵达后. 这一点可以通过捕捉信号写一个代码进行验证.
4. 信号处理(信号捕捉)
4.1. 信号捕捉的 4 次状态切换
信号处理就是抵达信号. 我们进程对信号做处理, 说白了就是抵达信号~ 不过我们信号处理有三种方式, 就是默认, 忽略和自定义, 如果信号捕捉, 我们可以指定收到某信号做指定处理, 比如 signal(2, handler)/ignal(2, SIG_IGN)/ignal(2, SIG_DFL)
.
从用户态和内核态来说, 捕捉信号处理的基本过程呈现 "∞"字型, 信号捕捉的过程要有 4 步.
操作系统能直接以内核态身份执行 handler() 方法, 但是不安全. 在上面第 3 步之后, OS 会以切换到用户态去执行用户定义的代码, 因为这样即使用户定义的函数有安全风险, OS 也可以减少这种安全问题.
4.2. 用户态 与 内核态
4.2.1. 进程地址空间中的内核地址
无论进程如何切换(CPU 轮转调度), 每个进程中都会有一份 OS 地址空间, 总能找到 OS 系统.
OS 访问, 类似于访问库函数, 两者都是在自己的进程地址空间中做的跳转访问, 不过权限大小可能会改变.
OS 为了保证安全, 每次用户访问 OS 空间时, 一定会进行保护操作, 也就是通过系统调用进行约束用户.
4.2.2. 信号: 模拟中断行为的一个软件机制
信号类似于中断, 区别在于信号是纯软件, 而中断是软硬结合.
4.2.3. OS 的正常运行逻辑
4.2.3.1. OS 的循环轮转调度运行
OS 的运行逻辑本质上是一种无限循环. 这个无限循环是通过时钟每隔很短的时间向 CPU 发送一个中断信号(时钟中断), CPU 拿到对应的中断号, 然后执行已经预设在内存中的 OS 调度方法, 通常这个调度方法会检查当前的进程状态进行上下文切换和时间片检查(进程调度). 这样 OS 就算无限循环运行起来了.
Chat GPT: 详细总结 OS 的循环轮转调度
OS 的运行逻辑本质上是一种无限循环, 这个无限循环是通过时钟每隔很短的时间向 CPU 发送一个中断信号, CPU 拿到对应的中断号, 然后执行已经预设在内存中的 OS 调度方法, 这样 OS 就算无限循环运行起来了. 我说的大体方向是对的吗?
你说的方向是对的,确实,操作系统的运行本质上是基于一个不断循环的机制,这个机制依赖于 时钟中断 和 进程调度 来确保操作系统能够持续运行并管理各个任务。
具体来说,你提到的“无限循环”是通过操作系统的 调度器 来实现的,调度器会不断地选择当前应该执行的任务(进程)。时钟中断在其中扮演着关键角色,帮助操作系统管理任务的切换。
详细解释一下:
- 时钟中断的作用
-
- 操作系统需要定期地让 进程调度器 运行,以便根据一定的策略来切换正在执行的进程。为了实现这个目标,硬件中的 时钟(timer) 每隔固定的时间(通常是几毫秒)会向 CPU 发送一个 时钟中断。这就像给操作系统一个信号,提醒它有任务需要处理。
- 时钟中断使得操作系统能够周期性地中断当前正在运行的进程,转而执行进程调度代码,检查是否需要切换到另一个进程。
- CPU 响应中断
-
- 每当时钟中断发生时,CPU 会暂停当前执行的程序(比如正在执行的进程),并跳转到 中断处理程序,也就是操作系统的 时钟中断处理函数。
- 这个处理中断的函数通常会调用进程调度器,检查哪些进程应该被执行,哪些应该被挂起,哪些进程应该重新排队等。
- 进程调度
-
- 调度器会根据某些策略(如时间片轮转、优先级调度等)决定哪个进程接下来应该被调度执行。
- 调度器会进行 上下文切换,即保存当前进程的状态(如寄存器、程序计数器等),然后加载下一个进程的状态,使得下一个进程能够接着之前的状态继续执行。
- 操作系统的“无限循环”
-
- 操作系统中的这种调度循环其实是一个不断进行中断和上下文切换的过程。每次时钟中断发生,操作系统会检查当前的任务队列,调度下一个任务,这样就实现了操作系统的持续运行。
- 换句话说,操作系统通过这些时钟中断和调度的机制,实现了对各个进程的管理,并保持了一个“无限循环”的执行状态,直到系统关闭。
总结
你提到的 时钟中断 和 调度方法 是操作系统实现循环运行的关键机制。操作系统通过时钟中断定期打断进程的执行,执行调度程序,从而持续管理进程并保持系统的运行。这样的“无限循环”确保了操作系统能够处理各种任务,并且在多任务环境中公平地分配 CPU 时间给不同的进程。
操作系统自开机后会先初始化一些方法绑定到内存中去, 然后会进入无限循环直到硬件关机. 主要过程是, 引导程序会加载 OS 到内存中, 然后执行操作系统初始化并绑定必要的资源到内存中, 再进入一个主循环,不断地调度任务和响应中断,直到关机.
Chat GPT: OS 的初始化绑定
是的,基本上你说的方向是对的,操作系统在开机后确实会进行一系列初始化操作,绑定一些关键的功能到内存中,并且会进入一个类似“无限循环”的状态,直到系统关机。
1. 开机过程:启动引导
当计算机启动时,硬件会执行一系列的自检(如 BIOS 或 UEFI),确保硬件正常工作。接下来,引导程序(Bootloader) 会被加载到内存中。引导程序的任务是加载操作系统内核到内存中,并将控制权交给操作系统。
2. 操作系统内核的初始化
一旦操作系统内核被加载,它会执行一系列的初始化工作,确保系统能正常运行。这个过程包括:
- 硬件初始化:操作系统会初始化硬件设备,设置硬件中断向量、初始化设备驱动程序等。
- 内存管理初始化:操作系统会初始化内存管理系统,建立内存分配机制,设定虚拟内存和物理内存之间的映射关系。
- 文件系统初始化:操作系统会初始化文件系统,挂载根文件系统,并准备好对文件和目录的访问。
- 进程调度器初始化:操作系统会初始化进程调度器,设置初始的进程调度策略,准备好执行后续进程的管理。
这些初始化步骤确保了操作系统的各个子系统能够正常工作。
3. 进入内核主循环
操作系统的内核在初始化完成后,通常会进入一个 主循环,即类似“无限循环”的状态。这个主循环主要包括:
- 调度任务:操作系统会定期通过时钟中断或其他中断机制来检查需要执行的任务(进程或线程)。
- 中断处理:操作系统会响应外部或内部的中断请求(如硬件中断、系统调用中断、定时器中断等)。
- 管理资源:操作系统会持续管理计算机的资源,如 CPU、内存、磁盘、网络等,确保各个任务能有效地共享资源。
在此主循环中,操作系统会不断地执行这些任务,直到系统关机或重启。
4. 处理进程调度和系统调用
操作系统通过 进程调度器 来管理进程的执行。时钟中断是其中的一个关键机制,它会定期打断当前正在运行的进程,检查是否需要切换到另一个进程。操作系统也会处理来自用户程序的 系统调用,如 I/O 操作、内存分配、网络请求等。
5. 关机和退出
当操作系统收到关机命令时,它会执行一系列的清理操作:
- 停止所有进程的执行。
- 保存系统状态,确保数据完整性。
- 关闭硬件设备,释放系统资源。
- 最后通过关闭硬件的电源或进入软关机状态来退出。
总结
所以,操作系统从开机到关机的过程大致是:
- 引导加载操作系统。
- 操作系统初始化并绑定必要的资源到内存中。
- 进入一个主循环,不断地调度任务和响应中断,直到关机。
这个过程就是操作系统维持运行的“无限循环”,它的核心任务就是管理计算机资源,并确保各项任务能够高效且安全地执行。
4.2.3.2. OS 中的系统调用逻辑
OS 为了对外提供服务, 把自己的一些系统调用接口都统一在了函数指针表中, 然后在开机初始化在了向量表中. 换句话说, 当我们需要调用系统调用时, 只需要知道这个系统调用的系统调用号(函数指针表的下标)即可.
计算机 CPU 封装了寄存器, 当代码执行到我们系统调用的时候, 用户层的系统调用内部封装了向量表的软中断, 然后并把这个软中断和系统调用的系统调用号给到了 CPU, CPU 可以根据这个软中断找到向量表, 并知道对应的方法.
Chat GPT: OS 的系统调用详细过程
你说的方向是对的,基本上准确地描述了 Linux 系统调用的流程。不过,细节部分可以再稍微澄清一下,让我们更好地理解整个过程。
1. 系统调用接口和函数指针表
是的,操作系统会把所有的系统调用接口组织成一个表格,通常被称作 系统调用表。这个表里存储了每个系统调用对应的内核函数的地址。在 Linux 内核中,通常使用一个 函数指针数组 来实现这个表。每个系统调用都有一个唯一的系统调用号,类似于一个索引,可以通过该索引找到对应的内核函数。
2. 系统调用号与向量表
在 Linux 中,当操作系统初始化时,它会创建一个 系统调用向量表,这个表在内存中对应着每个系统调用的唯一编号(系统调用号)与实际的内核函数之间的映射关系。简单来说,就是将每个系统调用号映射到对应的处理函数。
- 系统调用号通常是一个 整数值,它充当了在系统调用表中的“索引”,通过这个系统调用号,我们可以找到对应的内核实现。
- 向量表实际上是一种“地址表”,它包含了函数的地址。每次发生系统调用时,CPU 根据系统调用号从这个表中查找并跳转到对应的内核函数。
3. 软中断和系统调用的触发
当用户程序需要发起系统调用时,它并不会直接执行内核代码,而是通过一个特殊的机制,触发一个 软中断,通常是通过 syscall
指令(在 x86_64 架构上)来实现。
- 软中断 是一种机制,允许用户程序以安全的方式从用户模式切换到内核模式。它通过一个特殊的中断向量(比如
0x80
或者syscall
指令)触发。 - 这个软中断会通知操作系统进入内核模式,CPU 会暂停用户程序的执行,保存当前上下文(比如寄存器和程序计数器等),然后跳转到内核的中断处理程序。
4. 系统调用的执行过程
当软中断发生时,操作系统的内核会做如下处理:
- 进入内核模式:CPU 会从用户模式切换到内核模式,并执行中断服务程序。这个过程通常会涉及到一个上下文切换。
- 读取系统调用号:系统调用的参数(包括系统调用号)通常会被放在寄存器中或者栈上。内核通过读取这些信息来知道用户程序请求了哪个系统调用。
- 查找对应的处理函数:通过系统调用号(它是一个整数),内核可以查找系统调用表,找到对应的内核处理函数。
- 执行内核函数:内核会执行相应的系统调用处理程序,完成用户程序请求的服务(例如文件操作、内存分配、网络通信等)。
- 返回用户模式:处理完成后,操作系统会通过上下文切换恢复用户程序的执行状态,返回用户模式,并继续执行用户程序。
总结
你理解的基本流程是正确的。操作系统通过一个系统调用号索引到系统调用表,利用软中断机制触发内核处理。这个过程通过寄存器和中断向量来实现。这样,用户程序就能够通过系统调用号来访问操作系统提供的服务,而不需要直接操作内核的实现细节。
简化来说,你可以把整个过程理解为:
- 用户程序调用系统调用接口。
- 通过软中断进入内核,系统调用号作为参数传递。
- 内核根据系统调用号查找系统调用表,执行对应的内核函数。
- 执行完毕后,返回用户程序。
这种设计确保了操作系统能够安全、稳定地处理用户请求,同时保持用户空间和内核空间的隔离。
外部中断: 由于外部中断而形成的中断信号.
陷阱(缺陷): CPU 内部自身形成的中断信号.
4.2.3.3. 用户态到内核态
计算机通过 CPU 的硬件配合和软件支持保证用户态无法跳转到内核空间, 而想要执行相关代码, 必须先陷入内核转变成内核态才能执行. CPU 有大量寄存器, 有个比较典型的是, 它以末尾两个比特位来标识当前是什么状态(0 标识内核态, 3 标识用户态).
4.3. sigaction 的使用
4.3.1. sigaction 的简单使用
链接: 实验: sigaction系统调用的使用
4.3.2. sigaction 参数-> sa_mask 的意义
如果当前信号正在被处理, 那么 OS 会暂时屏蔽对该信号的 pending, 直到处理完毕. 实验: 验证处理对信号的屏蔽状态. 操作系统之所以这样做, 是防止突然一瞬间收到大量信号, 而无限递归, 导致栈溢出~
而 sigaction 不光可以捕获信号, 还可以在抵达信号的时候自定义屏蔽的信号.
当然, sigaction 并不能屏蔽所有信号(比如 8 号信号), 因为系统设计者早已考虑了这一点.
5. 相关拓展概念
5.1. 可重入函数
可重入: 指的是函数的一种特性, 相对应的是不可重入, 可重入表明这个函数当一个执行流执行该函数的部分代码后, 另一个执行流再次进入该函数又执行了一遍依然可以保证逻辑正确(达到预期效果)的函数, 而不可重入是指在前面那种情况下再次进入该函数会打乱该函数逻辑, 造成内存泄漏等问题.
5.2. volatile 关键字
volatile: 一个 C 语言关键字, 这玩意是在编译器无论如何优化的情况下, 都会去保证内存的可见性的一个关键字.
实验: 当 g++编译器优化效果过高时, 特定代码会出现非预期状态.
5.3. SIGCHLD 信号
子进程退出会主动给父进程发送 SIGCHLD(17 号) 信号, 默认状态下父进程对此信号的递达动作是 ignore. 实验: 实验: 父进程用信号等待子进程
系统对 SIGCHLD 的 ignore 与用户主动设置的 ignore 效果不一样, 系统默认的会让没有父进程等待的子进程变成僵尸进程, 而用户主动设置的 ignore 行为会删除子进程最后的退出信息(子进程不会变成僵尸进程).
实验: 系统的 ignore 与用户主动设置的 ignore 差异验证.