富源县住房和城乡建设局网站,备案信息 网站名,网站开发工资一般多少钱,wordpress 浏览缓慢目录 第一步#xff1a;添加用户进程虚拟空间
准备冲向我们的特权级3#xff08;用户特权级#xff09;
讨论下我们创建用户线程的基本步骤
更加详细的分析代码
用户进程的视图
说一说BSS段
继续看process.c中的函数
添加用户线程激活 现在#xff0c;我们做好了TSS…目录 第一步添加用户进程虚拟空间
准备冲向我们的特权级3用户特权级
讨论下我们创建用户线程的基本步骤
更加详细的分析代码
用户进程的视图
说一说BSS段
继续看process.c中的函数
添加用户线程激活 现在我们做好了TSS的工作就可以激流勇进准备实现一个用户进程了。现在“用户进程”这个词终于不是梦了。
第一步添加用户进程虚拟空间
进程与内核线程最大的区别是进程有单独的 4GB 空间这指的是虚拟地址物理地址空间可未必 有那么大看似无限的虚拟地址经过分页机制之后最终要落到有限的物理页中。 每个进程都拥有 4GB 的虚拟地址空间虚拟地址连续而物理地址可以不连续这就是保护模式下分页机制的优势。
我们需要单独为每个进程维护一个虚拟地址池用此地址池来记录该进程 的虚拟中哪些已被分配哪些可以分配
我们添加一行成员给我们的TaskStruct
/*** brief Task control block structure.** This structure represents a thread and stores its execution context.*/
typedef struct __cctaskstruct
{uint32_t *self_kstack; // Kernel stack pointer for the threadTaskStatus status; // Current status of the threadchar name[TASK_NAME_ARRAY_SZ]; // Thread nameuint8_t priority; // Thread priority leveluint8_t ticks;uint32_t elapsed_ticks;list_elem general_tag;list_elem all_list_tag;uint32_t *pg_dir;VirtualAddressMappings userprog_vaddr;uint32_t stack_magic; // Magic value for stack overflow detection
} TaskStruct;
就是VirtualAddressMappings userprog_vaddr;这是我们维护用户进程的虚拟地址池。
我们马上为进程创建页表和用户特权级栈
// Physical Memory Pools definition
// The MemoryPool structure represents a physical memory pool, managing the pools bitmap,
// the starting address of the physical memory, and the pools total size in bytes.
typedef struct __mem_pool
{Bitmap pool_bitmap; // Bitmap used by this pool to track allocated and free memory pagesuint32_t phy_addr_start; // The start address of the physical memory managed by this pooluint32_t pool_size; // Total size of this memory pool in bytesCCLocker lock;
} MemoryPool;
看到我们给memory.c中添加的MemoryPool了嘛就是这样我们需要确保内存申请的互斥。这个没啥好说的注意需要在内存池初始化的函数添加锁的初始化
// Function to initialize the physical memory pools
static void init_memory_pool(const uint32_t all_memory)
{...// init lockerslock_init(kernel_pool.lock);lock_init(user_pool.lock);...verbose_ccputs( memory pool init done\n);
}
我们需要改进一下virtual_memory的分配那里我们留下了一个坑
/* Allocate virtual memory pages from the specified pool (kernel or user).* If successful, return the starting virtual address; otherwise, return NULL. */
static void *vaddr_get(const PoolFlag pf, const uint32_t pg_cnt)
{int vaddr_start 0, bit_idx_start -1;uint32_t cnt 0;
switch (pf) // Determine which pool to allocate from based on the PoolFlag{case PF_KERNEL: // If the pool is the kernel memory pool{bit_idx_start bitmap_scan(kernel_vaddr.virtual_mem_bitmap, pg_cnt); // Find the first free block of pg_cnt pagesif (bit_idx_start -1){return NULL; // If no free block is found, return NULL}while (cnt pg_cnt){bitmap_set(kernel_vaddr.virtual_mem_bitmap, bit_idx_start cnt, 1); // Mark the pages as allocated in the bitmapcnt; // Increment the page count}vaddr_start kernel_vaddr.vaddr_start bit_idx_start * PG_SIZE; // Calculate the starting virtual address of the allocated pages }break;// Exit the case blockcase PF_USER: // If the pool is the user memory pool{TaskStruct *cur current_thread();bit_idx_start bitmap_scan(cur-userprog_vaddr.virtual_mem_bitmap, pg_cnt);if (bit_idx_start -1){return NULL;}
while (cnt pg_cnt){bitmap_set(cur-userprog_vaddr.virtual_mem_bitmap, bit_idx_start cnt, 1);}vaddr_start cur-userprog_vaddr.vaddr_start bit_idx_start * PG_SIZE;
/* (KERNEL_V_START - PG_SIZE) is already allocated as the user-level 3rd* stack in start_process */KERNEL_ASSERT((uint32_t)vaddr_start (KERNEL_V_START - PG_SIZE));}}return (void *)vaddr_start; // Return the starting virtual address of the allocated pages
}
重点看case PF_USER的部分这个是我们新增的。这里的逻辑是一样的我们就不多述了
之后是我们新增加的函数
/* Allocate 4KB of memory from the user space and return the corresponding* virtual address */
void *get_user_pages(uint32_t pg_cnt) {lock_acquire(user_pool.lock);void *vaddr malloc_page(PF_USER, pg_cnt);if (vaddr ! NULL) { // If the allocated address is not NULL, clear the// pages before returningk_memset(vaddr, 0, pg_cnt * PG_SIZE);}lock_release(user_pool.lock);return vaddr;
}
/* Associate the address vaddr with the physical address in the pf pool,* supports only single page allocation */
void *get_a_page(PoolFlag pf, uint32_t vaddr) {
MemoryPool* mem_pool pf PF_KERNEL ? kernel_pool : user_pool;lock_acquire(mem_pool-lock);
/* First, set the corresponding bit in the virtual address bitmap */TaskStruct *cur current_thread();int32_t bit_idx -1;
/* If the current thread is a user process requesting user memory, modify* the user processs virtual address bitmap */if (cur-pg_dir ! NULL pf PF_USER) {bit_idx (vaddr - cur-userprog_vaddr.vaddr_start) / PG_SIZE;KERNEL_ASSERT(bit_idx 0);bitmap_set(cur-userprog_vaddr.virtual_mem_bitmap, bit_idx, 1);
} else if (cur-pg_dir NULL pf PF_KERNEL) {/* If a kernel thread is requesting kernel memory, modify kernel_vaddr.*/bit_idx (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;KERNEL_ASSERT(bit_idx 0);bitmap_set(kernel_vaddr.virtual_mem_bitmap, bit_idx, 1);} else {KERNEL_PANIC_SPIN(get_a_page: not allowed to allocate user space in kernel or kernel space in user mode);}
void *page_phyaddr palloc(mem_pool);if (page_phyaddr NULL) {lock_release(mem_pool-lock);return NULL;}page_table_add((void *)vaddr, page_phyaddr);lock_release(mem_pool-lock);return (void *)vaddr;
}
get_user_pages没啥好说的实际上跟我们的get_kernel_pages简直一摸一样不过换了对象而已。注意一下我们还是需要给分配的时候上锁。不然的话容易出现竞争问题。
/* Associate the address vaddr with the physical address in the pf pool,* supports only single page allocation */
void *get_a_page(PoolFlag pf, uint32_t vaddr) {
MemoryPool* mem_pool pf PF_KERNEL ? kernel_pool : user_pool;lock_acquire(mem_pool-lock);
/* First, set the corresponding bit in the virtual address bitmap */TaskStruct *cur current_thread();int32_t bit_idx -1;
/* If the current thread is a user process requesting user memory, modify* the user processs virtual address bitmap */if (cur-pg_dir ! NULL pf PF_USER) {bit_idx (vaddr - cur-userprog_vaddr.vaddr_start) / PG_SIZE;KERNEL_ASSERT(bit_idx 0);bitmap_set(cur-userprog_vaddr.virtual_mem_bitmap, bit_idx, 1);
} else if (cur-pg_dir NULL pf PF_KERNEL) {/* If a kernel thread is requesting kernel memory, modify kernel_vaddr.*/bit_idx (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;KERNEL_ASSERT(bit_idx 0);bitmap_set(kernel_vaddr.virtual_mem_bitmap, bit_idx, 1);} else {KERNEL_PANIC_SPIN(get_a_page: not allowed to allocate user space in kernel or kernel space in user mode);}
void *page_phyaddr palloc(mem_pool);if (page_phyaddr NULL) {lock_release(mem_pool-lock);return NULL;}page_table_add((void *)vaddr, page_phyaddr);lock_release(mem_pool-lock);return (void *)vaddr;
}
可以看到这个函数比起来之前的分配还多了个参数 vaddrvaddr 用来指定绑定的虚拟地址所以此函数的功能是申请一页内存并用 vaddr映射到该页也就是说我们可以指定虚拟地址。而get_user_pages 和get_kernel_pages 不能指定虚拟地址 只能由内存管理模块自动分配虚拟地址分配什么咱们就用什么。此函数内部实现就是把之前介绍过的方法重新拼合了都是熟悉的函数较容易理解。
下一个函数是我们要在memory_tools中新添加的函数
/* Get the physical address mapped to the virtual address */
uint32_t addr_v2p(uint32_t vaddr) {uint32_t *pte pte_ptr(vaddr);/* (*pte) holds the physical page frame address of the page table,* remove the lower 12 bits of the page table entry attributes,* and add the lower 12 bits of the virtual address vaddr */return ((*pte PG_FETCH_OFFSET) (vaddr PG_OFFSET_MASK));
}
这个是将我们的虚拟地址映射回物理地址。
准备冲向我们的特权级3用户特权级
现在我们终于准备冲向特权级三了可是到目 前为止我们都工作在 0 特权级下如何从特权级 0 迈向特权级 3 呢
一直以来我们都在0 特权级下工作即使是在创建用户进程的过程中也是。可是我们知道一般情况 下CPU 不允许从高特权级转向低特权级除非是从中断和调用门返回的情况下。咱们系统中不打算使 用调用门因此咱们进入特权级 3 只能借助从中断返回的方式但用户进程还没有运行何谈被中断 更谈不上从中断返回了……但是 CPU 比较呆头呆脑我们可以骗过 CPU在用户进程运行之前使其以为 我们在中断处理环境中这样便“假装”从中断返回。
如何假装呢从大体上来看首先得在特权级0 的环境中其次是执行 iretd 指令。这么说太笼统了 下面咱们说得具体点。
从中断返回肯定要用到 iretd 指令iretd 指令会用到栈中的数据作为返回地址还会加载栈中 eflags 的值到 eflags 寄存器如果栈中 cs.rpl 若为更低的特权级处理器的特权级检查通过后会将栈中 cs 载入 到 CS 寄存器栈中 ss 载入 SS 寄存器随后处理器进入低特权级。因此我们必然要在栈中提前准备好数据供 iretd指令使用。您看既然已经涉及到栈操作了不如进行得更彻底一些咱们将进程的上下文都存到栈中通过一系列的 pop 操作把用户进程的数据装载到寄存器最后再通过 iretd 指令退出中断把退出中断彻底地“假装”一回。
现在完全可以再重复写一套退出中断的代码虽然仅为短短几行但依然“勤俭节约”我们可以复用之前的成果回忆一下退出中断的出口是汇编语言函数 intr_exit这是我们定义在 interrupt.S 中的此函数用来恢复中断发生时、被中断的任务的上下文状态并且退出中断。 由此我们得出关键点 1从中断返回必须要经过 intr_exit即使是“假装”。 在中断发生时我们在中断入口函数“intr%1entry”中通过一系列的 push 操作来保存任务的上下文,因此在intr_exit 中恢复任务上下文要通过一系列的pop 操作这属于“intr%1entry”的逆过程。
还记得我们的
/*** brief Interrupt stack structure.** This structure stores the register values when an interrupt occurs.* It is used to save and restore the threads execution context.*/
typedef struct
{uint32_t vec_no; // Interrupt vector numberuint32_t edi;uint32_t esi;uint32_t ebp;uint32_t esp_dummy; // Placeholder for alignmentuint32_t ebx;uint32_t edx;uint32_t ecx;uint32_t eax;uint32_t gs;uint32_t fs;uint32_t es;uint32_t ds;
/* From low to high privilege level */uint32_t err_code; // Error code pushed by the processorvoid (*eip)(void); // Instruction pointer at the time of interruptuint32_t cs; // Code segmentuint32_t eflags; // CPU flagsvoid *esp; // Stack pointeruint32_t ss; // Stack segment
} Interrupt_Stack;
任务的上下文信息被保存在任务pcb 中的 Interrupt_Stack 中注意Interrupt_Stack 并不要求有固定的位置它只是一种保存任务上下文的格式结构。
以下为叙述方便将称为栈。 在汇编函数intr_exit 里面的一系列的pop 操作是为了恢复任务的上下文从它退出中断是不可避免的这样才能“假装”从中断返回。 由此我们得出关键点2必须提前准备好用户进程所用的栈结构在里面填装好用户进程的上下文信息借一系列pop 出栈的机会将用户进程的上下文信息载入CPU 的寄存器为用户进程的运行准备好环境 当执行完 intr_exit 中的 iretd 指令后CPU 便恢复了任务中断前的状态中断前是哪个特权级就进入哪个特权级。
CPU 是如何知道从中断退出后要进入哪个特权级呢这是由栈中保存的CS 选择子中的RPL 决定的我们知道CS.RPL 就是 CPU 的 CPL当执行 iretd 时在栈中保存的 CS 选择子要被加载到代码段寄存 器 CS 中因此栈中 CS 选择子中的 RPL 便是从中断返回后 CPU 的新的 CPL。 我们进入假装返回到3 特权级 由此我们得出关键点 3我们要在栈中存储的 CS 选择子其 RPL 必须为 3。 大伙知道RPL 是选择子中的低2 位用以表示或者叫“揭露”访问者特权级因此RPL 是为了避免低特权级任务作弊使用指向高特权级内存段的选择子而提供的一种检测手段。虽然RPL 是CPU提供的、硬件级的方案但CPU 只负责接收选择子它自己可不知道所提交的选择子是否是造假的。因此它把RPL 的维护权交给了操作系统由操作系统去保证所有提交的选择子都是“真货”。
所以为了避免任务提交一 个假的选择子通常是指向特权级更高的内存段操作系统会将选择子的RPL 置为用户进程的CPL只有CPL 和RPL 在数值上同时小于等于选择子所指向的内存段的DPL 时CPU 的安全检测才通过从而避免了低特权级任务跨级访问高特权级的内存段。
既然用户进程的特权级为 3操作系统不能辜负 CPU 的委托它有责任把用户进程所有段选择子的RPL 都置为3因此在 RPLCPL3 的情况下用户进程只能访问 DPL 为3 的内存段即代码段、数据段、栈段。我们前面的工作中已经准备好了 DPL 为3 的代码段及数据段 由此我们得出关键点 4栈中段寄存器的选择子必须指向 DPL 为 3 的内存段。 对于可屏蔽中断来说任务之所以能进入中断是因为标志寄存器 eflags 中的 IF 位为1退出中断后还得保持 IF 位为 1继续响应新的中断。 由此我们得出关键点 5必须使栈中 eflags 的 IF 位为 1。 用户进程属于最低的特权级对于IO 操作不允许用户进程直接访问硬件只允许操作系统有直接的硬件控制。这是由标志寄存器 eflags 中 IOPL 位决定的必须使其值为 0。 由此我们得出关键点 6必须使栈中 eflags 的 IOPL 位为 0。 讨论下我们创建用户线程的基本步骤
笔者试试看使用Latex画图大家一下流程如果感觉这个图太糊了看不清楚到目录下本篇的目录下找advanced文件夹下看Latex生成的PDF比较好。
下面的图像是我们创建一个用户级线程的活 /* Execute a user process */
void create_process(void *filename, char *name) {/* Allocate memory for the process control block (PCB) in the kernel memory* pool */TaskStruct *thread get_kernel_pages(PCB_SZ_PG_CNT);init_thread(thread, name, DEFAULT_PRIO); // Initialize the threads PCBcreate_user_vaddr_bitmap(thread); // Create a virtual address bitmap for the user processcreate_thread(thread, start_process,filename); // Create the thread to start the user processthread-pg_dir create_page_dir(); // Create a page directory for the process/* Add the thread to the ready list and all thread list */Interrupt_Status old_status set_intr_status(INTR_OFF);KERNEL_ASSERT(!elem_find(thread_ready_list, thread-general_tag));list_append(thread_ready_list, thread-general_tag);
KERNEL_ASSERT(!elem_find(thread_all_list, thread-all_list_tag));list_append(thread_all_list, thread-all_list_tag);set_intr_status(old_status); // Restore the interrupt state
} // if u feel puzzled, see below!
创建的时候我们让我们的用户进程作为了一个filename发出去这个奇怪的名字需要到我们搓文件系统的时候才能理解最后就是调用filename的函数委托成用户进程发出去。其他部分跟我们创建内核线程简直一摸一样。
那又如何运行呢前面说过了进程的运行是由时钟中断调用 schedule由调用器 schedule调度 实现的。当schedule从就绪队列中获取的pcb恰好是新创建的进程时该进程马上就要被执行了。在 schedule 中调用了activate_process_settings来激活进程或线程的相关资源页表等随后通过 switch_to 函数调度进程根据先前进程创建时函数 thread_create 的工作已经将 kernel_thread 作为函数 switch_to 的返回地址即在 switch_to 中退出后处理器会执行 kernel_thread 函数“相当于”switch_to 调用 kernel_thread。
同样在之前的 thread_create 中已经将 start_process 和 filename作为了 kernel_thread 的参 数故在kernel_thread 中可以以此形式调用 start_process(user_prog)。函数 start_process 主要用来构建用户 进程的上下文它会将filename作为进程“从中断返回”的地址您懂的这里的“从中断返回”是假装 的目的是让用户进程顺利进入3 特权级。由于是从0 特权级的中断返回故返回地址filename被iretd 指 令使用为了复用中断退出的代码现在需要跳转到中断出口 intr_exitkernel.S 中汇编代码完成的函数 处利用那里的 iretd 指令使返回地址 filename作为 EIP 寄存器的值以使 filename得到执行故相当于start_process 调用 intr_exitintr_exit 调用 filename最终用户进程 filename在 3 特权级下执行。
更加详细的分析代码
我们更详细的分析一下代码。首先我们在之前的博客就提到了要处理Flags的事情这里再把我们selectors.h文件中关于EFLAGS寄存器的事情再看看
#define EFLAGS_MBS (1 1) // Must be set to 1.
#define EFLAGS_IF_1 (1 9) // IF 1, enables interrupts.
#define EFLAGS_IF_0 0 // IF 0, disables interrupts.
#define EFLAGS_IOPL_3 (3 12) // IOPL 3, allows user-mode I/O (for testing).
#define EFLAGS_IOPL_0 (0 12) // IOPL 0, restricts I/O access to kernel mode.
这个没啥好说的我们下一步看看我们的process.c文件下的函数
extern void intr_exit(void);
/* Build the initial context for a user process */
static void start_process(void *filename_) {void *function filename_; // The entry point of the user processTaskStruct *cur current_thread();cur-self_kstack sizeof(ThreadStack); // Move the stack pointer to the correct locationInterrupt_Stack *proc_stack (Interrupt_Stack *)cur-self_kstack; // Set up the interrupt stackproc_stack-edi proc_stack-esi proc_stack-ebp proc_stack-esp_dummy 0;proc_stack-ebx proc_stack-edx proc_stack-ecx proc_stack-eax 0;proc_stack-gs 0; // User mode does not use the gs register, set to 0proc_stack-ds proc_stack-es proc_stack-fs SELECTOR_U_DATA; // Set the data segment selectorproc_stack-eip function; // The address of the function to executeproc_stack-cs SELECTOR_U_CODE; // Set the code segment selectorproc_stack-eflags (EFLAGS_IOPL_0 | EFLAGS_MBS | EFLAGS_IF_1); // Set flags for user modeproc_stack-esp (void *)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) PG_SIZE); // Set up user stackproc_stack-ss SELECTOR_U_DATA; // Set the stack segment selectorasm volatile(movl %0, %%esp; \jmp intr_exit:: g(proc_stack): memory); // Switch to the user process context
}
这个我们需要好好说一说等到我们的走完了这个流程之后我们的PCB栈顶就变成了这个样子
--------------------------- - 高地址栈底
| ss (0x73) | 用户数据段选择子
---------------------------
| esp | 指向用户栈的顶端
---------------------------
| eflags | 标志寄存器 (0x202)
---------------------------
| cs (0x6B) | 用户代码段选择子
---------------------------
| eip | 入口函数地址
---------------------------
| err_code | 错误代码如果有
---------------------------
| ds (0x73) | 用户数据段选择子
---------------------------
| es (0x73) | 用户数据段选择子
---------------------------
| fs (0x73) | 用户数据段选择子
---------------------------
| gs (0x0) | 不使用设为0
---------------------------
| eax (0x0) |
---------------------------
| ecx (0x0) |
---------------------------
| edx (0x0) |
---------------------------
| ebx (0x0) |
---------------------------
| esp_dummy (0x0) | 对齐填充
---------------------------
| ebp (0x0) |
---------------------------
| esi (0x0) |
---------------------------
| edi (0x0) |
---------------------------
| vec_no | 中断向量号
--------------------------- - 低地址栈顶
用户进程的视图 就上面这个视图我们实际上就是构建这样的用户进程视图用户程序内存空间的最顶端用来存储命令行参数及环境变量这些内容是由某操作系统下的 C 运行库写进去的将来实现从文件系统加载用户进程并为其传递参数时会介绍这部分。紧接着是栈空间和堆空间栈向下扩展堆向上扩展栈与堆在空间上是相接的 这两个空间由操作系统管理分配由于栈与堆是相向扩展的操作系统需要检测栈与堆的碰撞。最下面的未初始化数据段 bss、初始化数据段 data 及代码段 text 由链接器和编译器负责。
在4GB 的虚拟地址空间中(0xc0000000-1)是用户空间的最高地址0xc00000000xffffffff 是内核空间。 我们也效仿这种内存结构布局把用户空间的最高处即0xc0000000-1及以下的部分内存空间用于存储用户进程的命令行参数之下的空间再作 为用户的栈和堆。命令行参数也是被压入用户栈的在后面章节介绍加载 用户进程时会了解因此虽然命令行参数位于用户空间的最高处但它 们相当于位于栈的最高地址处所以用户栈的栈底地址为 0xc0000000。 由于在申请内存时内存管理模块返回的地址是内存空间的下边界所以 我们为栈申请的地址应该是0xc0000000-PG_SIZE此地址是用户栈空间 栈顶的下边界。这里我们用宏来定义此地址即USER_STACK3_VADDR了。
#define USER_STACK3_VADDR (KERNEL_V_START - PG_SIZE)
更好说明我把代码单独给出来 proc_stack-esp (void *)((uint32_t)get_a_page(PF_USER, USER_STACK3_VADDR) PG_SIZE); // Set up user stack
“get_a_page(PF_USER, USER_STACK3_VADDR)”先获取特权级3 的栈的下 边界地址将此地址再加上PG_SIZE所得的和就是栈的上边界即栈底将此栈底赋值给proc_stack-esp。
也许您会有疑问此处为用户进程创建的 3 特权级栈它是在谁的内存空间中申请的换句话说 是安装在谁的页表中了不会是安装到内核线程使用的页表中了吧当然不是用户进程使用的 3 级栈 必然要建立在用户进程自己的页表中。在进程创建部分有一项工作是 create_page_dir这是提前为用户进入创建了页目录表在进程执行部分有一项工作是activate_process_settings这是使任务无论任务是否为新创建的进程或线程或是老进程、老 线程自己的页表生效。我们是在函数 start_process 中为用户进程创建了 3 特权级栈start_process 是在 执行任务页表激活之后执行的也就是在 activate_process_settings之后运行那时已经把页表更新为用户进程的 页表了所以3 特权级栈是安装在用户进程自己的页表中的。
栈段可以用普通的数据段第28 行为栈中SS 赋值为用户数据段选择子SELECTOR_U_DATA。 我们通过内联汇编将栈 esp 替换为我们刚刚填充好的proc_stack然后通过 jmp intr_exit 使程序流程跳转到中断出口地址 intr_exit通过那里的一系列 pop 指令和 iretd 指令将 proc_stack 中的数据载入 CPU 的寄存器从而使程序“假装”退出中断进入特权级 3。
我们下面说一下page_dir_activate函数。
/* Activate the page directory */
void page_dir_activate(TaskStruct *p_thread) {/********************************************************* This function may be called while the current task is a thread.* The reason we also need to reload the page table for threads is that* the last task switched might have been a process, and without restoring* the page table, the thread will mistakenly use the processs page table.********************************************************/
/* If its a kernel thread, set the page directory to 0x100000 (kernel* space) */uint32_t pagedir_phy_addr KERNEL_PAGE_MAPPINGS; // Default for kernel thread page directoryif (p_thread-pg_dir) { // If the thread has its own page directory (user process)pagedir_phy_addr addr_v2p((uint32_t)p_thread-pg_dir);}
/* Update the CR3 register to activate the new page table */asm volatile(movl %0, %%cr3 : : r(pagedir_phy_addr) : memory);
}
这里我们的pagedir_phy_addr就是我们安排页表的物理地址所在的位置。当然我们每一个进程是需要自己的一份页表视图的我们要加载到cr3上去如果我们当前的是用户进程这显然是自己私有的页表就需要我们直接进行一个加载挂上去也就是pd_dir不是空的
最后一个就是我们的activate_process_settings函数请看
void activate_process_settings(TaskStruct *p_thread) {KERNEL_ASSERT(p_thread);/* Activate the page table for the process or thread */page_dir_activate(p_thread);
/* If its a kernel thread, its privilege level is already 0,so the processor doesnt need to fetch the stack address from the TSSduring interrupts. */if (p_thread-pg_dir) {/* Update esp0 in the TSS for the processs privilege level 0 stack* during interrupts */update_tss_esp(p_thread);}
}
它的功能有两个一是激活线程或进程的页表二是更新tss 中的 esp0 为进程的特权级0 的栈。大伙儿知道进程与线程都是独立的执行流它们有各自的栈和页表只不过线程的页表是和其他线程共用的而进程的页表是单独的。进程或线程在被中断信号打断时处理器会进入0特权级并会在内核栈中保存进程或线程的上下文环境。如果当前被中断的是3特权级的用户进程处理器会自动到 tss 中获取 esp0 的值作为用户进程在内核态0 特权级的栈地址如果被中断的是0 特权级的内核线程由于内核线程已经是0 特权级进入中断后不涉及特权级的改变所以处理器并不会到 tss 中获取 esp0言外之意是即使咱们更新了线程 tss 中的 esp0处理器也不会使用它咱们就白费劲了
如果是用户进程的话才去更新 tss 中的 esp0。这两个功能的实现就是调用 tss.c 中定义的 update_tss_esp 和上面刚介绍的 page_dir_activate 完成的。顺便说一下只有在任务调度时才会切换页表及更新 0 级栈因此activate_process_settings是被 schedule 调用的
说一说BSS段
为什么要介绍BSS段很简单我们要搞内存分配仿照Linux 下 C 程序的布局方案来做不说编译器如何处理BSS段很容易把内存布局做错导致出现问题。
在 C 程序的内存空间中位于低处的三个段是代码段、数据段和 bss 段它们由编译器和链接器规划地址空间在程序被操作系统加载之前它们地址就固定了。而堆是位于 bss 段的上面栈是位于堆的上面它们共享 4GB 空间中除了代码段、数据段及顶端命令行参数和环境变量等以外的其余可用空间它们的地址由操作系统来管理在程序加载时为用户进程分配栈空间运行过程中为进程从堆中分配内存。堆向上扩展栈向下扩展因此在程序的加载之初操作系统必须为堆和栈分别指定起始地址。
在 Linux 中堆的起始地址是固定的这是由 struct mm_struct 结构中的 start_brk 来指定的堆的结束地址并不固定这取决于堆中内存的分配情况堆的上边界是由同结构中的brk 来标记的。堆是要安排在bss 以上的按理说我们只要找到bss 的结束地址就可以自由规划堆的起始地址了。
但其实我们不需要这么麻烦有更简单省事的做法为解释清楚这个问题这里对bss 多说两句。 大伙儿知道C 程序大体上分为预处理、编译、汇编和链接四个阶段。根据语法规则编译器会在汇编阶段将汇编程序源码中的关键字 section 或 segment汇编源码中通常用语法关键字 section 或 segment来逻辑地划分程序区域虽然很多书中都称此程序区域为段但实际上它们就是节 section。注意这里所说的 segment 和 section 是指汇编语法中的关键字仅指它们在汇编代码中的语法意义相同并不是指编译、链接中的 section 和 segment编译成节也就是之前介绍的 section此时只是生成了目标文件目标文件中的这些节还不是程序空间中的独立的代码段或数据段或者说仅仅是代码段或数据段的一部分。链接器将这些目标文件中属性相同的节section合并成段segment因此一个段是由多个节组成的我们平时所说的 C 程序内存空间中的数据段、代码段就是指合并后的 segment。为什么要将 section 合并成segment 是为了保护模式下的安全检查 为了操作系统在加载程序时省事。
大伙儿已经知道在保护模式下对内存的访问必须要经过段描述符段描述符用来描述一段内存区域的访问属性其中的S位和 TYPE 位可组合成多种权限属性处理器用这些属性来限制程序对内存的使用如果程序对某片内存的访问方式不符合该内存所对应的段描述符由访问内存时所使用的选择子决定中设置的权限比如对代码这种具备只读属性的内存区域执行了写操作处理器会检查到这种情况并抛出GP 常。
程序必须要加载到内存中才能执行为了符合安全检查程序中不同属性的节必须要放置到合适的段描述符指向的内存中。比如为程序中具有只读可执行的指令部分所分配的内存最好是通过具有只读、可执行属性的段描述符来访问否则若通过具有可写属性的段描述符来访问指令区域的话程序有可能会将自己的指令部分改写从而引起破坏。处理器对内存访问的安全检查主要体现在使用的段描述符段描述符是由选择子决定的而选择子是由操作系统提供的所以针对程序中不同属性的区域操作系统得知道用哪个段描述符来匹配程序中这些不同属性的区域片段也就是要在程序运行之前提前设置程序在运行时各种段寄存器如 cs、ds中的选择子。
综上所述在操作系统的视角中它只关心程序中这些节的属性是什么以便加载程序时为其分配不同的段选择子从而使程序内存指向不同的段描述符起到保护内存的作用。因此最好是链接器把目标文件中属性相同的节合并到一起这样操作系统便可统一为其分配内存了。按照属性来划分节大致上有三种类型。 可读写的数据如数据节.data 和未初始化节.bss。 只读可执行的代码如代码节.text 和初始化代码节.init。 只读数据如只读数据节.rodata一般情况下字符串就存储在此节。
经过这样的划分所有节都可归并到以上三种之一这样方便了操作系统加载程序时的内存分配。由链接器把目标文件中相同属性的节归并之后的节的集合便称为 segment它存在于二进制可执行文件中也就是 C 程序运行时内存空间中分布的代码段、数据段等段。
现在可以正式说说 bss 了我们已经知道text 段是代码段里面存放的是程序的指令data 段是数据段里面存放的是程序运行时的数据它们的共同点是都存在于程序文件中也就是在文件系统中存在原因很简单它们是程序运行必备的“材料”必须提前准备好基本上是固定好的内容。而 bss 并不存在于程序文件中它仅存在于内存中其实际内容是在程序运行过程中才产生的起初并无意义换句话说程序在运行时的那一瞬间并不需要 bss因此完全不需要事先在程序文件中存在程序文件中仅在 elf 头中有bss 节的虚拟地址、大小等相关记录这通常是由链接器来处理的对程序运行并不重要因此程序文件中并不存在 bss 实体。bss 中的数据是未初始化的全局变量和局部静态变量程序运行后才会为它们赋值因此在程序运行之初里面的数据没意义由操作系统的程序加载器将其置为0 就可以了虽然这些未初始化的全局变量及局部静态变量起初是用不上的但它们毕竟也是变量即使是短暂的生存周期也要占用内存必须提前为它们在内存中“占好座”bss 区域的目的也正在于此就是提前为这些未初始化数据预留内存空间。
我们来实际上看看使用指令readelf -l -h -S /bin/find注意-e这个选项现在已经没有了
ELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64Data: 2s complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: DYN (Position-Independent Executable file)Machine: Advanced Micro Devices X86-64Version: 0x1Entry point address: 0x8510Start of program headers: 64 (bytes into file)Start of section headers: 202280 (bytes into file)Flags: 0x0Size of this header: 64 (bytes)Size of program headers: 56 (bytes)Number of program headers: 13Size of section headers: 64 (bytes)Number of section headers: 31Section header string table index: 30
Section Headers:[Nr] Name Type Address OffsetSize EntSize Flags Link Info Align[ 0] NULL 0000000000000000 000000000000000000000000 0000000000000000 0 0 0[ 1] .interp PROGBITS 0000000000000318 00000318000000000000001c 0000000000000000 A 0 0 1[ 2] .note.gnu.pr[...] NOTE 0000000000000338 000003380000000000000030 0000000000000000 A 0 0 8[ 3] .note.gnu.bu[...] NOTE 0000000000000368 000003680000000000000024 0000000000000000 A 0 0 4[ 4] .note.ABI-tag NOTE 000000000000038c 0000038c0000000000000020 0000000000000000 A 0 0 4[ 5] .gnu.hash GNU_HASH 00000000000003b0 000003b00000000000000028 0000000000000000 A 6 0 8[ 6] .dynsym DYNSYM 00000000000003d8 000003d80000000000000ed0 0000000000000018 A 7 1 8[ 7] .dynstr STRTAB 00000000000012a8 000012a8000000000000061e 0000000000000000 A 0 0 1[ 8] .gnu.version VERSYM 00000000000018c6 000018c6000000000000013c 0000000000000002 A 6 0 2[ 9] .gnu.version_r VERNEED 0000000000001a08 00001a0800000000000000c0 0000000000000000 A 7 2 8[10] .rela.dyn RELA 0000000000001ac8 00001ac800000000000030f0 0000000000000018 A 6 0 8[11] .rela.plt RELA 0000000000004bb8 00004bb80000000000000d50 0000000000000018 AI 6 25 8[12] .init PROGBITS 0000000000006000 00006000000000000000001b 0000000000000000 AX 0 0 4[13] .plt PROGBITS 0000000000006020 0000602000000000000008f0 0000000000000010 AX 0 0 16[14] .plt.got PROGBITS 0000000000006910 000069100000000000000020 0000000000000010 AX 0 0 16[15] .plt.sec PROGBITS 0000000000006930 0000693000000000000008e0 0000000000000010 AX 0 0 16[16] .text PROGBITS 0000000000007210 00007210000000000001daa2 0000000000000000 AX 0 0 16[17] .fini PROGBITS 0000000000024cb4 00024cb4000000000000000d 0000000000000000 AX 0 0 4[18] .rodata PROGBITS 0000000000025000 0002500000000000000056d0 0000000000000000 A 0 0 32[19] .eh_frame_hdr PROGBITS 000000000002a6d0 0002a6d00000000000000a8c 0000000000000000 A 0 0 4[20] .eh_frame PROGBITS 000000000002b160 0002b1600000000000003318 0000000000000000 A 0 0 8[21] .init_array INIT_ARRAY 000000000002f0f0 0002f0f00000000000000008 0000000000000008 WA 0 0 8[22] .fini_array FINI_ARRAY 000000000002f0f8 0002f0f80000000000000008 0000000000000008 WA 0 0 8[23] .data.rel.ro PROGBITS 000000000002f100 0002f1000000000000001810 0000000000000000 WA 0 0 32[24] .dynamic DYNAMIC 0000000000030910 000309100000000000000200 0000000000000010 WA 7 0 8[25] .got PROGBITS 0000000000030b10 00030b1000000000000004e8 0000000000000008 WA 0 0 8[26] .data PROGBITS 0000000000031000 000310000000000000000478 0000000000000000 WA 0 0 32[27] .bss NOBITS 0000000000031480 000314780000000000000a80 0000000000000000 WA 0 0 32[28] .gnu_debugaltlink PROGBITS 0000000000000000 000314780000000000000049 0000000000000000 0 0 1[29] .gnu_debuglink PROGBITS 0000000000000000 000314c40000000000000034 0000000000000000 0 0 4[30] .shstrtab STRTAB 0000000000000000 000314f8000000000000012f 0000000000000000 0 0 1
Key to Flags:W (write), A (alloc), X (execute), M (merge), S (strings), I (info),L (link order), O (extra OS processing required), G (group), T (TLS),C (compressed), x (unknown), o (OS specific), E (exclude),D (mbind), l (large), p (processor specific)
Program Headers:Type Offset VirtAddr PhysAddrFileSiz MemSiz Flags AlignPHDR 0x0000000000000040 0x0000000000000040 0x00000000000000400x00000000000002d8 0x00000000000002d8 R 0x8INTERP 0x0000000000000318 0x0000000000000318 0x00000000000003180x000000000000001c 0x000000000000001c R 0x1[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000005908 0x0000000000005908 R 0x1000LOAD 0x0000000000006000 0x0000000000006000 0x00000000000060000x000000000001ecc1 0x000000000001ecc1 R E 0x1000LOAD 0x0000000000025000 0x0000000000025000 0x00000000000250000x0000000000009478 0x0000000000009478 R 0x1000LOAD 0x000000000002f0f0 0x000000000002f0f0 0x000000000002f0f00x0000000000002388 0x0000000000002e10 RW 0x1000DYNAMIC 0x0000000000030910 0x0000000000030910 0x00000000000309100x0000000000000200 0x0000000000000200 RW 0x8NOTE 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8NOTE 0x0000000000000368 0x0000000000000368 0x00000000000003680x0000000000000044 0x0000000000000044 R 0x4GNU_PROPERTY 0x0000000000000338 0x0000000000000338 0x00000000000003380x0000000000000030 0x0000000000000030 R 0x8GNU_EH_FRAME 0x000000000002a6d0 0x000000000002a6d0 0x000000000002a6d00x0000000000000a8c 0x0000000000000a8c R 0x4GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000000x0000000000000000 0x0000000000000000 RW 0x10GNU_RELRO 0x000000000002f0f0 0x000000000002f0f0 0x000000000002f0f00x0000000000001f10 0x0000000000001f10 R 0x1
Section to Segment mapping:Segment Sections...00 01 .interp 02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt 03 .init .plt .plt.got .plt.sec .text .fini 04 .rodata .eh_frame_hdr .eh_frame 05 .init_array .fini_array .data.rel.ro .dynamic .got .data .bss 06 .dynamic 07 .note.gnu.property 08 .note.gnu.build-id .note.ABI-tag 09 .note.gnu.property 10 .eh_frame_hdr 11 12 .init_array .fini_array .data.rel.ro .dynamic .got
我们找到了第二十七节就是我们的find中bss段的大小0000000000000a80。
我们的Section to Segment mapping:是链接器将节合并为段的结果展示.为了验证我们可以用此索引03 在“Program Headers”中查看第03个段 LOAD 0x0000000000006000 0x0000000000006000 0x00000000000060000x000000000001ecc1 0x000000000001ecc1 R E 0x1000
该段是类型为 LOAD 的段其Flg 标志中是“R E”这表示可读可执行充分说明了代码节.text 确实被合并成了代码段这里已经用线把它们的对应关系连接起来了。“Section to Segment mapping:”中索引为05的段其包括一行的节里面有数据节.data这说明05段很可能是数据段此段中还包括了.bss 节这说明链接器将.bss 节、.data 节等合并到了同一个段中该段很可能是数据段为了验证是否为数据段同样用此索引05 到“Program Headers”中查看第05 个段 LOAD 0x000000000002f0f0 0x000000000002f0f0 0x000000000002f0f00x0000000000002388 0x0000000000002e10 RW 0x1000
该段是第二个LOAD 段其Flg 为“RW”即“可读写”充分说明此段是数据段.text 和.data 等节的合并关系也用线标出了。 通常情况下段在文件中的大小 FileSiz 和在内存中的大小 MemSiz 应该是一致的而且在任何情况下段的MemSiz 都不会比FileSiz 小。如果在某段中合并了bss 节该段的MemSiz 应该大于FileSiz原因是bss节不占用文件系统空间只占用内存空间。
所以这样看来我们的堆的起始地址应该在 bss 之上但由于 bss 已融合到数据段中要实现用户进程的堆已不需要知道 bss 的结束地址将来咱们加载程序时会获取程序段的起始地址及大小因此只要堆的起始地址在用户进程地址最高的段之上就可以了。
继续看process.c中的函数
/* Create a new page directory, copying the kernel space entries from thecurrent page directory. Returns the virtual address of the new pagedirectory, or NULL on failure. */
uint32_t *create_page_dir(void) {
/* The user processs page table cannot be directly accessed, so we allocate* memory in the kernel space */uint32_t *page_dir_vaddr get_kernel_pages(1);if (!page_dir_vaddr) {console_ccos_puts(create_page_dir: get_kernel_page failed!);return NULL;}
/************************** 1. Copy the current page table entries for* kernel space *************************************//* Copy the kernel page directory entries starting at 0x300 (768th entry in* the page directory) */k_memcpy((uint32_t *)((uint32_t)page_dir_vaddr KERNEL_PGTB_INDEX * PDE_BYTES_LEN),(uint32_t *)(PG_FETCH_OFFSET KERNEL_PGTB_INDEX * PDE_BYTES_LEN), PDE_ENTRY_NR);/*****************************************************************************/
/************************** 2. Update the page directory address* **********************************/uint32_t new_page_dir_phy_addr addr_v2p((uint32_t)page_dir_vaddr);/* The page directory address is stored in the last entry of the page table.Update it with the new page directorys physical address */page_dir_vaddr[PDE_ENTRY_NR - 1] new_page_dir_phy_addr | PG_US_U | PG_RW_W | PG_P_1;/*****************************************************************************/
return page_dir_vaddr; // Return the virtual address of the newly created// page directory
}
这里是我们需要创建页目录表。操作系统是为用户进程服务的它提供了各种各样的系统功能供用户进程调用。为了用户进程可以访问到内核服务必须确保用户进程必须在自己的地址空间中能够访问到内核才行也就是说内核空间必须是用户空间的一部分。所有的用户进程应当共享的是同一个内核的内存视图这个事情很简单 看到这个表我们这个表中只需要内核页表的入口导入到每一个用户进程的页表上就好了。
/* Create the user processs virtual address bitmap */
static void create_user_vaddr_bitmap(TaskStruct *user_prog) {user_prog-userprog_vaddr.vaddr_start USER_VADDR_START;uint32_t bitmap_pg_cnt ROUNDUP((KERNEL_V_START - USER_VADDR_START) / PG_SIZE / 8, PG_SIZE);user_prog-userprog_vaddr.virtual_mem_bitmap.bits get_kernel_pages(bitmap_pg_cnt);user_prog-userprog_vaddr.virtual_mem_bitmap.btmp_bytes_len (KERNEL_V_START - USER_VADDR_START) / PG_SIZE / 8;bitmap_init(user_prog-userprog_vaddr.virtual_mem_bitmap);
}
上面的函数创建用户进程的虚拟地址位图实际上是初始化userprog_vaddr。这个没啥好说的自行对照我们如何初始化内核的就好。
添加用户线程激活
我们还差对schedule的处理
/*** brief Task scheduling function.* * This function implements simple task scheduling by selecting the next thread* to run from the ready list and performing a context switch.*/
void schedule(void)
{KERNEL_ASSERT(get_intr_status() INTR_OFF); // Ensure interrupts are disabled
TaskStruct *cur current_thread(); // Get the current running threadif (cur-status TASK_RUNNING){KERNEL_ASSERT(!elem_find(thread_ready_list, cur-general_tag)); // Ensure its not already in the listlist_append(thread_ready_list, cur-general_tag); // Add it to the ready listcur-ticks cur-priority; // Reset threads time slice based on prioritycur-status TASK_READY; // Set thread status to ready}
KERNEL_ASSERT(!list_empty(thread_ready_list)); // Ensure there is a next thread to runthread_tag NULL; // Clear the thread tag// Pop the next thread from the ready listthread_tag list_pop(thread_ready_list);TaskStruct *next elem2entry(TaskStruct, general_tag, thread_tag);next-status TASK_RUNNING; // Set the next thread as runningactivate_process_settings(next);switch_to(cur, next); // Perform context switch to the next thread
}
我们在函数switch_to的前面添加激活页表设置的函数现在就好了
#include include/device/console_tty.h
#include include/kernel/init.h
#include include/library/kernel_assert.h
#include include/thread/thread.h
#include include/user/process/process.h
void thread_a(void *args);
void thread_b(void *args);
void u_prog_a(void);
void u_prog_b(void);
int test_var_a 0, test_var_b 0;
int main(void)
{init_all();thread_start(k_thread_a, 31, thread_a, In thread A:);thread_start(k_thread_b, 16, thread_b, In thread B:);create_process(u_prog_a, user_prog_a);create_process(u_prog_b, user_prog_b);interrupt_enabled();while (1){}
}
void thread_a(void *arg[[maybe_unused]])
{while (1){console_ccos_puts(v_a:0x);console__ccos_display_int(test_var_a);}
}
void thread_b(void *arg[[maybe_unused]])
{while (1){console_ccos_puts( v_b:0x);console__ccos_display_int(test_var_b);}
}
void u_prog_a(void)
{while (1){test_var_a;}
}
void u_prog_b(void) { while(1) { test_var_b; }
}
就是这样但是你的第一反应是——不对啊你这两个真丁真嘛真的是用户函数嘛不着急的确这两个函数是在内核的地址空间上存在的这个绝对没毛病请看 nm middleware/kernel.bin | grep -P u_prog|test_var
c00091c0 B test_var_a
c00091c4 B test_var_b
c0001609 T u_prog_a
c0001627 T u_prog_b
但是我们需要注意的是——我们的处理器实际上不关心我们的“用户态函数”在地址的什么地方或者说它判定是不是用户态函数是根据当前所在的代码段的特权级看的当用户进程u_prog_a 运行后它的特权级为3它拥有自己独立的页表并且是通过为用户进程准备的DPL 为3的段描述符访问内存这完全符合用户进程的特征和行为而且最最重要的是我们早已把所有页目录项和页表项的US 位都置为1loader.S和memory.c 中所有涉及到PDE 和 PTE 的地方都用的是 PG_US_U这表示处理器允许所有特权级的任务可以访问目录项或页表项指向的内存所以用内核空间中的函数来模拟用户进程是没有问题的。 关于变化的事情呢多少有点难捕捉所以我就懒得放图了自行跑demo看看现象就好了。
测试一下丁不丁真我们可以在用户线程的函数上打一个断点这个时候看看我们的cs寄存器的值
(0) [0x000000001643] 002b:c0001643 (unk. ctxt): jmp .-17 (0xc0001634) ; ebef
bochs:11 sreg
es:0x0033, dh0x00cff300, dl0x0000ffff, valid1Data segment, base0x00000000, limit0xffffffff, Read/Write, Accessed
cs:0x002b, dh0x00cff900, dl0x0000ffff, valid1Code segment, base0x00000000, limit0xffffffff, Execute-Only, Non-Conforming, Accessed, 32-bit
ss:0x0033, dh0x00cff300, dl0x0000ffff, valid1Data segment, base0x00000000, limit0xffffffff, Read/Write, Accessed
ds:0x0033, dh0x00cff300, dl0x0000ffff, valid31Data segment, base0x00000000, limit0xffffffff, Read/Write, Accessed
fs:0x0033, dh0x00cff300, dl0x0000ffff, valid1Data segment, base0x00000000, limit0xffffffff, Read/Write, Accessed
gs:0x0000, dh0x00001000, dl0x00000000, valid0
ldtr:0x0000, dh0x00008200, dl0x0000ffff, valid1
tr:0x0020, dh0xc0808b00, dl0x9620006b, valid1
gdtr:base0xc0000900, limit0x37
idtr:base0xc0009360, limit0x17f
下一篇
从0开始的操作系统手搓教程28实现Syscall架构体系-CSDN博客文章浏览阅读382次点赞14次收藏8次。上一节中我们已经干了一个大事情那就是实现了用户级的线程可以叫线程了只需要派发一个打算以特权级为3的执行函数我们就可以执行它可是我们终归是要使用操作系统的一些服务的这可咋办直接让自己来干好像不对特权级不允许我们这样做但是我们的确需要知道一些事情。这个问题的解决思路是——按照我们内核规定的模式请求我们操作系统服务而不是越过操作系统自己把这个事情办了。上述的“规定的模式”就是我们所谈到的系统调用。https://blog.csdn.net/charlie114514191/article/details/146130681
代码
CCOperateSystem/Documentations/10_Implement_User_Thread at main · Charliechen114514/CCOperateSystemhttps://github.com/Charliechen114514/CCOperateSystem/tree/main/Documentations/10_Implement_User_Thread