现代CPU处理机制和进程

对于计算机进程和CPU运行机制的大致理解

序言

主要是了解后好写代码,也好看得懂那些代码,出于逆向的需求,至于具体的代码编程再说.

以下主要是Core Dumped(看的翻译)和EfficLab(B站有号)的视频内容,不过用我自己的理解写了.

进程

用一种不正规的定义可以把线程当成一个正在执行(其实不一定,这就是不正规的点)的可执行文件.那么肯定有代码段,data段,rodata段,bss段,堆栈段等等.

然后根据CPU调度机制(保证根据每个进程的特点能得到一定的处理时间,不在这里的讨论范围内),必然要在多个进程中切换,那么如何有两个问题:

  • 如何保证进程之间无法访问呢?安全性
  • 如何保证切换时可以继续按照上一个状态执行呢?可持续性

那么就要保存相应的CPU状态,这里就通过PCB(ProcessControlBlock/进程控制块)来实现,大致如下:

pub enum ProcessState{
    New,
    Running,
    Ready,
    Waiting,
    Terminated
}

pub struct ProcessControlBlock{
    pid:u16, //标识符,即id号
    state:ProcessState, //状态
    program_counter:u16, //程序计数器
    general_purpose_registers:[u8;4], //通用寄存器列表
    instruction_register:u8, // 指令寄存器
    flags:[u1;3], //标志
    //以下是根据硬件
    stack_pointer:u16, //栈指针
    index_register:[u16;2], //索引寄存器
    //还有好多,比如指向父进程和子进程的PCB的指针
}

把各个进程的PCB放在一个队列里以此处理,这就是上下文机制.

同时,为了了解内存边界(比如说避免可以访问其他进程的内存,以及避免越界),事sd实上PCB也会存入内存管理的信息,还有一些外设.

有趣的是在Linux,进程和线程被统一为一个概念为任务,简化了.

进程控制以及相关API

pid_t fork(void); //创建新进程(拷贝)
pid_t wait(int *stat_loc); //等待子进程退出
int execvp(const char *file, char *const argv[]); //替换当前进程镜像,即进程的执行资源(寄存器资源,io资源,内存资源).比如你file传入一个可执行文件,那么就直接替换了.
getpid(); //获取当前进程的标识符

线程

首先要知道我们为什么要线程,或者说我们为什么要有两段代码实现并发.比如一个是IO进程,另一个处理进程会比fork新进程和不搞进程更节省空间和时间,以及更方便协调.

如何创建呢?本质上就是把计数器和需要并发的可执行实体相关联,而不是进程,那么这就是线程.

线程有个常见操作,就是构建线程池,比如我们前面举的例子,如果这个io很频繁,那我们大可以让它常驻,不进行销毁创建的开销.

注意一点,他们是同一个进程中的,那么就可以相互访问对方的栈,一般来说还是堆合适.

注意第二点,如果在一个线程中exit,就会全部退出.

消息机制

先来思考这个问题:进程是被隔离的,那么如何协作呢?

这就需要一个通信机制,用两种解决方式,共享内存(性能损耗低,但是危险还要同步)和消息传递.

共享内存

顾名思义.

一般来说,操作系统会隔离两个进程的内存如果谁想访问的话,就直接中断它.所以一般会通过系统调用创造一个共享内存区域,人恶化希望通过该区域进行通信的进程也需要通过系统调用将其附加到自己的地址空间.一旦操作系统授予了共享内存区域,它不再管理进程对该区域的操作,也就是说,数据的结构方式以及在共享区域中的具体位置完全由进程决定,而不是操作系统.下面举个例子:

考虑一个生产者----消费者模型,其中一个进程是生产者,生成数据,而另一个是消费者,读取数据。如果生产者和消费者对于某一个数据的类型理解错误,或者消费者从错误的地址读取了数据,,还要注意不能同时读入读取,否则就要竞争,那么就会出错误,这些太容易发生了.

浏览器就是使用共享内存的范例,我们可以在任务管理器看到他们会创造多个进程,这样子即使一个页面(进程)崩溃也不会全部崩溃.

消息传递机制

如果进程A想向进程B发送消息,它可以通过系统调用与B建立一个链接.操作系统的内核就会在自己的地址空间中建立一个队列(像一个邮箱),这个队列会处于需求而不同,比如创建两个队列让他们同时可以发送和接受消息.

但邮箱不在进程地址上面,所以还需要send()receive()两个系统调用.

这个邮箱就是端口,网络通信是不同计算机,而这些端口是计算机内部.

比如一个进程专门处理接受连接请求,就是监听端口.

对上面的总结

进程实现

  • 创建进程fork()exec() 系统调用。

  • 进程状态:就绪、运行、阻塞。

  • 进程调度:时间片轮转、优先级调度。

  • 通信方式(这些不了解,以后写点):

    • 管道(Pipe):父子进程间通信。
    • 消息队列:通过队列传递消息。
    • 共享内存:共享内存段用于快速通信。
    • 信号(Signal):通知进程事件。

线程实现

  • 创建线程:通过线程库(如 POSIX pthread)。

  • 线程同步

    • 互斥锁(Mutex):防止多个线程同时访问共享资源。
    • 条件变量(Condition Variable):实现线程间的协调。
    • 读写锁(RWLock):优化读多写少的场景。
  • 线程调度:同进程类似,但调度范围限制在进程内。

编程语言中的进程与线程**

  • 进程:使用 fork() 创建,使用 pipeshared memory 通信。
  • 线程:使用 pthread 创建,使用互斥锁和条件变量同步。

(C语言)

示例:多进程与多线程

多进程

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) { // 子进程
        printf("Child Process: PID = %d\n", getpid());
    } else if (pid > 0) { // 父进程
        printf("Parent Process: PID = %d, Child PID = %d\n", getpid(), pid);
    } else {
        perror("Fork failed");
    }

    return 0;
}

多线程

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* thread_func(void* arg) {
    printf("Thread %d is running\n", *(int*)arg);
    return NULL;
}

int main() {
    pthread_t threads[3];
    int thread_ids[3];

    for (int i = 0; i < 3; i++) {
        thread_ids[i] = i + 1;
        pthread_create(&threads[i], NULL, thread_func, &thread_ids[i]);
    }

    for (int i = 0; i < 3; i++) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

(以下是系统操作,由于我觉得上面和下面关系比较密切,就放一起了)

系统调用和中断

原始的原理

许多架构都有其特权指令,为了执行他们,需要表示不同模式.这就需要一个寄存器当成表示模式的寄存器,被称为模式位,为1时是特权模式,而0则不是.这可以通过晶体管来改变,毕竟只要规定了这个是亮的时候,特权模式才能开就行了.

特权模式如此重要,就不可能随意切换,这个过程是通过中断实现的.中断通过四个部分实现:

  1. CPU需要保存状态
  2. 有代码处理中断
  3. 完成后恢复被中断的程序状态
  4. 跳回原地址

这本质就是把控制流交给中断后跳转的例程,而且同时让模式位切换为1.

注意,任何实体都可以加载这个段,所以我们要各位小心,一旦他们取得后,甚至能阻止其他进程获得特权状态.故而现在操作系统是通过上面讲的方式解决的

由于特权模式就是为操作系统的内核使用,所以它一般被称为内核模式;而受限模式是给用户使用的,所以一般被称为用户模式.

可以看看具体的,先讲内核模式,比如IO操作,比如内存管理单元(用来限制CPU可访问的内存区域,控制它等于控制整个内存).另一个特权指令就是如何处理中断.

中断是会跳转的,处理终端的代码可不一定会一直在一个地方,如果跳到用户态的代码那就危险了.特权指令就允许一定可以跳到中断代码,用户访问不到,因为内存管理单元.硬件提供工具,操作系统控制.

那么用户态如何进行如IO呢?通过API,即系统调用,syscall.他们仍是函数,但是他们编译时是在用户态.所以他们的汇编会在一个地方触发中断,只执行必要的特权指令完成系统调用.

Pros Cons
1.抽象了系统操作 1.性能开销
2.安全性 2.操作系统获得控制权后可能先处理其他事项
3.可移植性 3.平台依赖性

系统调用的简化

其概念是如此成功以至于许多系统都通过一些简化指令来处理,而不需要手工设置中断.故而许多架构中用syscallsysret特权指令.

简单的来说就是一些由系统内核提供的函数。但是调用这些 函数的方法比较特殊,以Linux系统为例,在汇编环境下64位程序通过 syscall 指令进行系统调用,由rax 寄存器传递系统调用号,传参顺序依次为rdi ,中;而32为程序通过 rsi,rdx,r10,r8,r9,返回值存在 int 80操作进行系统调用,由 rax eax 寄存器传递系统调用号,传参顺序依次为 ebx,ecx,edx,esi,edi ,文档最后面找到。 ebp ,返回值存在 eax 中。32位和64位Linux系统调用表可以在这个 在Linux的glibc中,大部分系统调用已经被封装成普通的函数,如 read,write等,同时也将 open,execve syscall指令进行了封装,以64位Linux的glibc位例,调用read(0, buf, 0x10)函数也 可以是syscall(0, 0, buf, 0x10)(实际read函数的封装还会有一些其他的处理,但是功能基本等价)

//以上来自Vidarteam的hgame-mini的题目介绍.

安全上的方面

如果有用户使用特权指令,可以设置中断并且停止可疑的用户进程.

(以下请配合系统调度章节的第一段话)

驱动程序往往也是在内核模式下运行,因为操作系统编写者不可能为所有硬件写代码.由于和操作系统没有区分,如果驱动修改了几个重要值,操作系统就会崩溃.

有些有意思的案例,比如反作弊软件,或者杀毒软件.他们需要内核模式(反作弊可以通过内核模式直接看硬件输入,比如有人想通过键盘修改成手柄输入,只要反作弊能看硬件就知道它是键盘)所以极具风险.

系统调度(抢占式和非抢占式)

首先要明确一点,认为软件是在操作系统上运行是对实际情况的高度抽象,事实上是分配,只不过通过硬件和特权指令保证了操作系统的控制权.

硬件上的准备

如果一个程序没有一个系统调用,而且还有死循环,那就毁了,系统没法获得操作时间,其他进程就会被饿死.

解决方案是靠计时器,而且它只能通过特权指令控制,故而设置进程前就会启用计时器.

调度策略(视频就挺好的)

进程调度的秘密 | FIFO | SJF | PSJF | RR_哔哩哔哩_bilibili

冠以图灵奖之名的调度算法:MLFQ 多级反馈队列!_哔哩哔哩_bilibili

https://www.bilibili.com/video/BV1uW6uYSE7F

posted @   T0fV404  阅读(7)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)
点击右上角即可分享
微信分享提示