《操作系统真象还原》第九章 线程(一) 在内核中实现线程

第九章 线程(一) 在内核中实现线程

本文是对《操作系统真象还原》第九章(一)学习的笔记,欢迎大家一起交流。

我们在本节的任务:

  1. 创建并初始化PCB
  2. 模拟pthread_create函数创建线程并执行线程函数

首先我们要明确内核级线程的优势,内核级线程是cpu的一个调度单位,当一个进程中的线程越多,享受cpu服务的时间也就越多。所谓线程,其实也就是去执行一个函数,和在进程的没有本质区别,但是借助书中的一个例子,我们喜欢吃黄瓜,宫保鸡丁里面有黄瓜,但是我们也可以点一个拍黄瓜让厨师专门做黄瓜,线程所执行的函数也是这样的,执行整个进程时可以顺便执行这个函数,也可以新起一个线程专门执行这个函数。

准备的数据结构

进程/线程状态

/* 进程或线程的状态 */
enum task_status {
   TASK_RUNNING,
   TASK_READY,
   TASK_BLOCKED,
   TASK_WAITING,
   TASK_HANGING,
   TASK_DIED
};

线程栈

定义线程栈,存储线程执行时的运行信息

/***********  线程栈thread_stack  ***********
 * 线程自己的栈,用于存储线程中待执行的函数
 * 此结构在线程自己的内核栈中位置不固定,
 * 用在switch_to时保存线程环境。
 * 实际位置取决于实际运行情况。
 ******************************************/
struct thread_stack {
   uint32_t ebp;
   uint32_t ebx;
   uint32_t edi;
   uint32_t esi;

    //这个位置会放一个名叫eip,返回void的函数指针(*epi的*决定了这是个指针),
    //该函数传入的参数是一个thread_func类型的函数指针与函数的参数地址
   void (*eip) (thread_func* func, void* func_arg);

    //以下三条是模仿call进入thread_start执行的栈内布局构建的,call进入就会压入参数与返回地址,因为我们是ret进入kernel_thread执行的
    //要想让kernel_thread正常执行,就必须人为给它造返回地址,参数
   void (*unused_retaddr);
   thread_func* function;           // Kernel_thread运行所需要的函数地址
   void* func_arg;                  // Kernel_thread运行所需要的参数地址
};

PCB

PCB以后还会进行补充,本节用到的东西如下:

/* 进程或线程的pcb,程序控制块, 此结构体用于存储线程的管理信息*/
struct task_struct {
   uint32_t* self_kstack;	        // 用于存储线程的栈顶位置,栈顶放着线程要用到的运行信息
   enum task_status status;
   uint8_t priority;		        // 线程优先级
   char name[16];                   //用于存储自己的线程的名字
   uint32_t stack_magic;	       //如果线程的栈无限生长,总会覆盖地pcb的信息,那么需要定义个边界数来检测是否栈已经到了PCB的边界
};

第一个结构self_kstack​即指向thread_stack

一个pcb占一个自然页,即4kb,低地址开始是pcb相关信息,高地址是线程的栈,向低地址扩展,所以在最后一项定义一个魔数,每次对该数值进行校验即可判断有没有溢出。

中断栈

本节中不会用到,但是要为它预留空间,所以先定义

/***********   中断栈intr_stack   ***********
 * 此结构用于中断发生时保护程序(线程或进程)的上下文环境:
 * 进程或线程被外部中断或软中断打断时,会按照此结构压入上下文
 * 寄存器,  intr_exit中的出栈操作是此结构的逆操作
 * 此栈在线程自己的内核栈中位置固定,所在页的最顶端
********************************************/
struct intr_stack
{
    uint32_t vec_no;	        // kernel.S 宏VECTOR中push %1压入的中断号
    uint32_t edi;
    uint32_t esi;
    uint32_t ebp;
    uint32_t esp_dummy;	        // 虽然pushad把esp也压入,但esp是不断变化的,所以会被popad忽略
    uint32_t ebx;
    uint32_t edx;
    uint32_t ecx;
    uint32_t eax;
    uint32_t gs;
    uint32_t fs;
    uint32_t es;
    uint32_t ds;

                                /* 以下由cpu从低特权级进入高特权级时压入 */
    uint32_t err_code;		    // err_code会被压入在eip之后
    void (*eip) (void);
    uint32_t cs;
    uint32_t eflags;
    void* esp;
    uint32_t ss;
};

代码部分

代码逻辑如下:

  1. 向内存申请一页空间,分配给要创建的线程
  2. 初始化该线程的PCB
  3. 通过PCB中的栈顶指针进一步初始化线程栈的运行信息
  4. 正式运行线程执行函数

thread_start

thread_start函数即对应了上面说的代码逻辑,对应第四步的汇编我们后面再说

/* 创建一优先级为prio的线程,线程名为name,线程所执行的函数是function(func_arg) */
struct task_struct *thread_start(char *name, int prio, thread_func function, void *func_arg)
{
    /* pcb都位于内核空间,包括用户进程的pcb也是在内核空间 */
    struct task_struct *thread = get_kernel_pages(1); // 为线程的pcb申请4K空间的起始地址

    init_thread(thread, name, prio);           // 初始化线程的pcb
    thread_create(thread, function, func_arg); // 初始化线程的线程栈

    // 我们task_struct->self_kstack指向thread_stack的起始位置,然后pop升栈,
    // 到了通过线程启动器来的地址,ret进入去运行真正的实际函数
    // 通过ret指令进入,原因:1、函数地址与参数可以放入栈中统一管理;2、ret指令可以直接从栈顶取地址跳入执行
    asm volatile("movl %0, %%esp; pop %%ebp; pop %%ebx; pop %%edi; pop %%esi; ret" : : "g"(thread->self_kstack) : "memory");
    return thread;
}

初始化pcb

即对task_struct结构体进行初始化

/* 初始化线程基本信息 , pcb中存储的是线程的管理信息,此函数用于根据传入的pcb的地址,线程的名字等来初始化线程的管理信息*/
void init_thread(struct task_struct *pthread, char *name, int prio)
{
    memset(pthread, 0, sizeof(*pthread));   // 把pcb初始化为0
    pthread->status = TASK_RUNNING;          //这个函数是创建线程的一部分,自然线程的状态就是运行态
    strcpy(pthread->name, name);
    pthread->priority = prio;
    /* self_kstack是线程自己在内核态下使用的栈顶地址 */
    pthread->self_kstack = (uint32_t *)((uint32_t)pthread + PG_SIZE); // 本操作系统比较简单,线程不会太大,就将线程栈顶定义为pcb地址
                                                                      //+4096的地方,这样就留了一页给线程的信息(包含管理信息与运行信息)空间
    pthread->stack_magic = 0x19870916; // /定义的边界数字,随便选的数字来判断线程的栈是否已经生长到覆盖pcb信息了
}

我们前面说过了一个pcb占一个自然页,即4kb,低地址开始是pcb相关信息,高地址是线程的栈,向低地址扩展,故有

pthread->self_kstack = (uint32_t *)((uint32_t)pthread + PG_SIZE);

目前的pcb布局如下(中断栈和线程栈现在还没有,下一步就有了):

image

初始化线程栈

/*用于根据传入的线程的pcb地址、要运行的函数地址、函数的参数地址来初始化线程栈中的运行信息,核心就是填入要运行的函数地址与参数 */
void thread_create(struct task_struct* pthread, thread_func function, void* func_arg) {
    /* 先预留中断使用栈的空间,可见thread.h中定义的结构 */
    //pthread->self_kstack -= sizeof(struct intr_stack);  //-=结果是sizeof(struct intr_stack)的4倍
    //self_kstack类型为uint32_t*,也就是一个明确指向uint32_t类型值的地址,那么加减操作,都是会是sizeof(uint32_t) = 4 的倍数
    pthread->self_kstack = (uint32_t*)((int)(pthread->self_kstack) - sizeof(struct intr_stack));

    //再预留thread_stack的位置
    pthread->self_kstack = (uint32_t*)((int)pthread->self_kstack) - sizeof(struct thread_stack);

    //我们已经留出了线程栈的空间,现在将栈顶变成一个线程栈结构体
    //指针,方便我们提前布置数据达到我们想要的目的
    struct thread_stack* kthread_stack = (struct thread_stack*)pthread->self_kstack;   
    kthread_stack->function = function;
    kthread_stack->func_arg = func_arg;

    //我们将线程的栈顶指向这里,并ret,就能直接跳入线程启动器开始执行。
    //为什么这里我不能直接填传入进来的func,这也是函数地址啊,为什么还非要经过一个启动器呢?其实是可以不经过线程启动器的
    kthread_stack->eip = kernel_thread;   
  
    //下面的寄存器用不到, 先置为0
    kthread_stack->ebp = kthread_stack->ebx = kthread_stack->esi = kthread_stack->edi = 0;
    //因为用不着,所以不用初始化这个返回地址kthread_stack->unused_retaddr

}

首先先预留中断时使用的栈,然后再预留线程栈的空间,预留完之后对线程栈进行初始化。

kernel_thread是通用的线程启动器,里面核心是执行function(func_arg)​,我们将eip初始化为该值,后面就可以直接去这个函数执行,后面再详说。

目前的pcb布局如下:

image

thread_start中的关键汇编

    /*4.上述准备好线程运行时的栈信息后,即可运行执行函数了*/
    asm volatile("movl %0,%%esp;    \
                pop %%ebp;          \
                pop %%ebx;          \
                pop %%edi;          \
                pop %%esi;          \
                ret"
                 :
                 : "g"(thread->self_kstack)
                 : "memory");

当来到这里时,首先将esp赋为thread->self_kstack,也就是pcb线程栈的最下端,然后不断pop,pop完四个寄存器,然后ret,此时正好对应线程启动器的指针,如下图:

image

然后执行ret,eip就会来到线程启动器,其中esp自动+4,布局如下:

image

然后再往下一步很多人讲错了,我们去执行线程启动器,反汇编如下:

image

会再push ebp,此时内存中布局如下,正好符合取参规范

image

可以验证以下下面的数据和上面的数据。

image

于是接下来,根据c语言的函数调用约定,kernel_thread​会取出占位的返回地址上边的两个参数,也就是执行函数的地址与执行函数的参数,然后调用执行函数运行

kernel_thread​如下:

// 线程启动器
/* 由kernel_thread去执行function(func_arg) , 这个函数就是线程中去开启我们要运行的函数*/
static void kernel_thread(thread_func* function, void* func_arg) {
   function(func_arg); 
}

​​

​​

posted @   fdx_xdf  阅读(20)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示