【操作系统】GeekOS完成记录(四) project2:添加用户态进程并实现系统调用

997 阅读9分钟

本文正在参加「金石计划」

从这一个project开始,我们会接触到用户态的编程、了解用户态的进程如何被系统感知,以及用户态和内核态之间是如何切换并交互的。

任务概述

image.png

这一段列举了我们需要完成的函数,并对它的功能做了一些简略的描述。

具体细节我后续会拆解并详细描述。

实验原理

文档第4 5 6 7 8 9 小点描述了大致流程。 image.png

简言之:自底层至顶层的依次是:

  • 通过段式分配内存
  • 加载可执行文件
  • 为用户进程分配内存空间,创建栈
  • 创建用户态线程
  • 添加系统调用
  • 在程序入口生成所有用户进程的祖先进程

段式存储

image.png

第4点讲到geekos采用段式存储的方式。

与页式存储类似,段式存储也需要一个表去管理段的分配和使用,这就是DT(discriptor table),而它又分全局(global)和局部(local)两类。

而选择子(selector)就是用于选择分配哪个段的。

任务详解

有了上述的背景知识,接下来我们来逐条分析并完成任务要求。

user.c中的Spawn()

  • 以下是各传入参数的说明(中文是我加的注释):
/*
 * Spawn a user process.
 * Params:
 *   program - the full path of the program executable file
 //可执行文件的绝对路径
 
 *   command - the command, including name of program and arguments
 //执行程序的名称及运行它的参数
 
 *   pThread - reference to Kernel_Thread pointer where a pointer to
 *     the newly created user mode thread (process) should be stored
 
 *   Returns:
 *   The process id (pid) of the new process, or an error code
 *   if the process couldn't be created.  Note that this function
 *   should return ENOTFOUND if the reason for failure is that
 *   the executable file doesn't exist.
 */
 
 int Spawn(const char *program, const char *command, struct Kernel_Thread **pThread)

pThread是Kernel_Thread这个结构体类型的指针的指针,Kernel_Thread我们在前期的文章中也提到过:

<kthread.h>
struct Kernel_Thread {
    ulong_t esp;			 /* offset 0 */
    volatile ulong_t numTicks;		 /* offset 4 */
    int priority;
    DEFINE_LINK(Thread_Queue, Kernel_Thread);
    void* stackPage;
    struct User_Context* userContext;
    struct Kernel_Thread* owner;
    int refCount;

    /* These fields are used to implement the Join() function */
    bool alive;
    struct Thread_Queue joinQueue;
    int exitCode;

    /* The kernel thread id; also used as process id */
    int pid;

    /* Link fields for list of all threads in the system. */
    DEFINE_LINK(All_Thread_List, Kernel_Thread);

    /* Array of MAX_TLOCAL_KEYS pointers to thread-local data. */
#define MAX_TLOCAL_KEYS 128
    const void* tlocalData[MAX_TLOCAL_KEYS];

};

其中的userContext为空时表示内核线程,不为空则表示用户线程

所以pThread实际上指的是:刚创建了一个进程,并用一个指针指向了该进程,而这里又存储了该指针。

  • 接下来是函数内给的hints:
    /*
     * Hints:
     * - Call Read_Fully() to load the entire executable into a memory buffer
     * - Call Parse_ELF_Executable() to verify that the executable is
     *   valid, and to populate an Exe_Format data structure describing
     *   how the executable should be loaded
     * - Call Load_User_Program() to create a User_Context with the loaded
     *   program
     * - Call Start_User_Thread() with the new User_Context
     *
     * If all goes well, store the pointer to the new thread in
     * pThread and return 0.  Otherwise, return an error code.
     */

它提示我们在这个过程中需要用到这几个函数。

整体流程为: 读取->解析->加载->创建并初始化线程的栈及上下文域。全过程穿插错误处理。

由此,可以由各个给定函数的传入参数及返回值,写出如下代码:

int Spawn(const char *program, const char *command, struct Kernel_Thread **pThread)
{
// TODO("Spawn a process by reading an executable from a filesystem");
    int res;

    /* 读取 ELF 文件 */
    char *exeFileData = NULL;
    ulong_t exeFileLength = 0;
    res = Read_Fully(program, (void **)&exeFileData, &exeFileLength);
    if (res != 0)
    {
        if (exeFileData != NULL)
            Free(exeFileData);
        return ENOTFOUND;
    }

    /* 分析 ELF 文件 */
    struct Exe_Format exeFormat;
    res = Parse_ELF_Executable(exeFileData, exeFileLength, &exeFormat);
    if (res != 0)
    {
        if (exeFileData != NULL)
            Free(exeFileData);
        return res;
    }

    /* 加载用户程序 */
    struct User_Context *userContext = NULL;
    res = Load_User_Program(exeFileData, exeFileLength, &exeFormat, command, &userContext);
    if (res != 0)
    {
        if (exeFileData != NULL)
            Free(exeFileData);
        if (userContext != NULL)
            Destroy_User_Context(userContext);
        return res;
    }
    if (exeFileData != NULL)
        Free(exeFileData);
    exeFileData = NULL;

    /* 开始用户进程 */
    struct Kernel_Thread *thread = NULL;
    thread = Start_User_Thread(userContext, false);

    if (thread == NULL)
    {
        if (userContext != NULL)
            Destroy_User_Context(userContext);
        return ENOMEM;
    }

    KASSERT(thread->refCount == 2);
    
    *pThread = thread;
    return 0;
}

函数所调用的Read_Fully()已存在;Parse_ELF_Executable在project1中已经完成了(详见完成记录(三)一文),这里不再赘述。 接下来讲讲Destroy_User_Context()的实现。

userseg.c中的Destroy_User_Context()

结合hints可写出如下代码:

void Destroy_User_Context(struct User_Context *userContext)
{
   /*
    * Hints:
    * - you need to free the memory allocated for the user process
    * - don't forget to free the segment descriptor allocated for the process's LDT
    */
   // TODO("Destroy a User_Context");
   KASSERT(userContext->refCount == 0);

   Free_Segment_Descriptor(userContext->ldtDescriptor);

   Disable_Interrupts(); 
   Free(userContext->memory);
   Free(userContext);
   Enable_Interrupts(); 
}

我们还可以深究一下User_Context里面有什么:

struct User_Context {
   /* We need one LDT entry each for user code and data segments. */
#define NUM_USER_LDT_ENTRIES 2

   /*
    * Each user context contains a local descriptor table with
    * just enough room for one code and one data segment
    * describing the process's memory.
    */
   struct Segment_Descriptor ldt[NUM_USER_LDT_ENTRIES];
   struct Segment_Descriptor* ldtDescriptor;

   /* The memory space used by the process. */
   char* memory;
   ulong_t size;

   /* Selector for the LDT's descriptor in the GDT */
   ushort_t ldtSelector;

   /*
    * Selectors for the user context's code and data segments
    * (which reside in its LDT)
    */
   ushort_t csSelector;
   ushort_t dsSelector;

   /* Code entry point */
   ulong_t entryAddr;

   /* Address of argument block in user memory */
   ulong_t argBlockAddr;

   /* Initial stack pointer */
   ulong_t stackPointerAddr;

   /*
    * May use this in future to allow multiple threads
    * in the same user context
    */
   int refCount;

#if 0
   int *semaphores;
#endif
};

userseg.c中的Load_User_Program()

先来看看它的传入参数注释:

/**
* Load a user executable into memory by creating a User_Context
* data structure.
* @param exeFileData  - a buffer containing the executable to load
* @param exeFileLength  - number of bytes in exeFileData
* @param exeFormat - parsed ELF segment information describing how to
*   load the executable's text and data segments, and the
*   code entry point address
* @param command - string containing the complete command to be executed:
*   this should be used to create the argument block for the
*   process
* @param pUserContext - reference to the pointer where the User_Context
*   should be stored
*
* @return 0 if successful, or an error code (< 0) if unsuccessful
*/
int Load_User_Program(char *exeFileData, ulong_t exeFileLength,
                     struct Exe_Format *exeFormat, const char *command,
                     struct User_Context **pUserContext)

很多参数在之前的文章中也已经注释过,这里不再赘述。

接下来关注函数内给出的hints:

    /*
    * Hints:
    * - Determine where in memory each executable segment will be placed
    //每个段会被放在内存中的哪里
    
    * - Determine size of argument block and where it memory it will
    *   be placed
    //参数块的大小以及它会被放在内存中的哪里
    
    * - Copy each executable segment into memory
    //将可执行段复制到内存中
    
    * - Format argument block in memory
    //格式化参数块
    
    * - In the created User_Context object, set code entry point
    *   address, argument block address, and initial kernel stack pointer
    *   address
    //对于用户进程,要对它的 User_Context进行赋值
    */

结合上述hints,我们可以写出如下代码:

int Load_User_Program(char *exeFileData, ulong_t exeFileLength,
                      struct Exe_Format *exeFormat, const char *command,
                      struct User_Context **pUserContext)
{
    // TODO("Load a user executable into a user memory space using segmentation");
    unsigned int i;
    struct User_Context *userContext = NULL;

    /* 需分配的最大内存空间 */
    ulong_t maxva = 0;
    /* 计算用户态进程所需的 最大 内存空间
    *内存空间=起始地址+该部分大小
    */
    for (i = 0; i < exeFormat->numSegments; i++)
    {
        struct Exe_Segment *segment = &exeFormat->segmentList[i];
        ulong_t topva = segment->startAddress + segment->sizeInMemory;
        if (topva > maxva)
            maxva = topva;
    }
    /* 程序参数数目 */
    unsigned int numArgs;
    /* 参数块大小 */
    ulong_t argBlockSize;
    Get_Argument_Block_Size(command, &numArgs, &argBlockSize);
    
    /* 用户进程大小 = 参数块总大小 + 进程堆栈大小(8192) */
    ulong_t size = Round_Up_To_Page(maxva) + DEFAULT_USER_STACK_SIZE;
    /* 参数块地址 */
    ulong_t argBlockAddr = size;
    size += argBlockSize;
    /* 按相应大小创建一个进程 */
    userContext = Create_User_Context(size);
    /* 如果进程创建失败则返回错误信息 */
    if (userContext == NULL)
    {
        return -1;
    }

    /* 将用户程序中的各段内容复制到分配的用户内存空间 */
    for (i = 0; i < exeFormat->numSegments; i++)
    {
        struct Exe_Segment *segment = &exeFormat->segmentList[i];
        memcpy(userContext->memory + segment->startAddress,
               exeFileData + segment->offsetInFile,
               segment->lengthInFile);
    }

    /* 格式化参数块 */
    Format_Argument_Block(userContext->memory + argBlockAddr, numArgs, argBlockAddr, command);
    
    /* 初始化userContext域中的信息:实体地址、参数块地址、栈指针地址 */
    userContext->entryAddr = exeFormat->entryAddr;
    userContext->argBlockAddr = argBlockAddr;
    userContext->stackPointerAddr = argBlockAddr;

    *pUserContext = userContext;

    return 0;
}

这里有个小技巧: 很多同学初接触到具体实现的时候可能比较懵,比如不知道如何“获取参数块的大小”、又如何“格式化参数块”。但其实,在编程的过程中,我们应该首先梳理出大致逻辑,最后才关心具体实现细节。比如这里,我们并不需要在一个加载用户态程序的代码中长篇大论地写格式化参数块的细节,后者很有可能已经被作者写好了放在某一处,我们只需要打开全局搜索,搜一下argument block这个关键词,看看有没有现成的库。

image.png

之前没来得及深入读读源码也没关系,不会妨碍能留意到argblock.h及argblock.c。

userseg.c中的Copy_From_User()&&Copy_To_User()

这两个函数的实现原理是相通的,都是在用户态和内核态之间相互复制数据。

以下是Copy_From_User()的传入参数数据注释

/*
 * Params:
 * destInKernel - address of kernel buffer
 * srcInUser - address of user buffer
 * bufSize - number of bytes to copy
 */

可以得知,就是给定了两个态缓冲区的地址以及要缓冲数据的大小,from和to的不同只是内核和用户地址顺序的互换,以及赋值指针的互换。

  • 验证用户地址空间是否合法可用userseg.c下的Validate_User_Memory;
  • 数组buffer填充可用链接库中的string.h里包含的memcpy()函数
bool Copy_From_User(void *destInKernel, ulong_t srcInUser, ulong_t bufSize)
{
    // TODO("Copy memory from user buffer to kernel buffer");
    struct User_Context *userContext = g_currentThread->userContext;
    
    /* 越界访问的错误处理 */
    if (!Validate_User_Memory(userContext, srcInUser, bufSize))
        return false;
        
    memcpy(destInKernel, userContext->memory + srcInUser, bufSize);
    return true;
}
bool Copy_To_User(ulong_t destInUser, void *srcInKernel, ulong_t bufSize)
{
    // TODO("Copy memory from kernel buffer to user buffer");
    struct User_Context *userContext = g_currentThread->userContext;

    /* 越界访问的错误处理 */
    if (!Validate_User_Memory(userContext, destInUser, bufSize))
        return false;
        
    memcpy(userContext->memory + destInUser, srcInKernel, bufSize);
    return true;
}

userseg.c中的Switch_To_Address_Space

void Switch_To_Address_Space(struct User_Context *userContext)
{
    // TODO("Switch to user address space using segmentation/LDT");
    ushort_t ldtSelector = userContext->ldtSelector;
    __asm__ __volatile__(
        "lldt %0"
        :
        : "a"(ldtSelector));
}

kthread.c中的Setup_User_Thread()

先来看函数给的hints:

    /*
     * Hints:
     * - Call Attach_User_Context() to attach the user context
     *   to the Kernel_Thread
     * - Set up initial thread stack to make it appear that
     *   the thread was interrupted while in user mode
     *   just before the entry point instruction was executed
     * - The esi register should contain the address of
     *   the argument block
     */
  • 要调用Attach_User_Context(),它是在user.c里定义的:

功能就是将给定的上下文加载到线程

/*
 * Associate the given user context with a kernel thread.
 * This makes the thread a user process.
 */
void Attach_User_Context(struct Kernel_Thread *kthread, struct User_Context *context)
{
    KASSERT(context != 0);
    kthread->userContext = context;

    Disable_Interrupts();

    /*
     * We don't actually allow multiple threads
     * to share a user context (yet)
     */
    KASSERT(context->refCount == 0);

    ++context->refCount;
    Enable_Interrupts();
}
  • 第二步是要要将thread相关信息入栈

综上所述:完整代码如下:

void Setup_User_Thread(
    struct Kernel_Thread *kthread, struct User_Context *userContext)
{
    // TODO("Create a new thread to execute in user mode");
    ulong_t eflags = EFLAGS_IF;
    unsigned int csSelector = userContext->csSelector; /* CS 选择子 */
    unsigned int dsSelector = userContext->dsSelector; /* DS 选择子 */

    Attach_User_Context(kthread, userContext);

    /* 初始化用户态进程堆栈 */
    /* 将以下数据压入堆栈 */
    Push(kthread, dsSelector);                    /* DS 选择子 */
    Push(kthread, userContext->stackPointerAddr); /* 堆栈指针 */
    Push(kthread, eflags);                        /* Eflags */
    Push(kthread, csSelector);                    /* CS 选择子 */
    Push(kthread, userContext->entryAddr);        /* 程序计数器 */
    Push(kthread, 0);                             /* 错误代码(0) */
    Push(kthread, 0);                             /* 中断号(0) */

    /* 初始化通用寄存单元,并向 esi 传递参数块地址 */
    Push(kthread, 0);                         /* eax */
    Push(kthread, 0);                         /* ebx */
    Push(kthread, 0);                         /* ecx */
    Push(kthread, 0);                         /* edx */
    Push(kthread, userContext->argBlockAddr); /* esi */
    Push(kthread, 0);                         /* edi */
    Push(kthread, 0);                         /* ebp */

    /* 初始化数据段寄存单元 */
    Push(kthread, dsSelector); /* ds */
    Push(kthread, dsSelector); /* es */
    Push(kthread, dsSelector); /* fs */
    Push(kthread, dsSelector); /* gs */
}

kthread.c中的Start_User_Thread()

Start_User_Thread是比Setup_User_Thread更高层面的操作,因此它会调用Setup_User_Thread,从hints中可知,还有 Create_Thread()、Make_Runnable_Atomic()。

struct Kernel_Thread *
Start_User_Thread(struct User_Context *userContext, bool detached)
{
    /*
     * Hints:
     * - Use Create_Thread() to create a new "raw" thread object
     * - Call Setup_User_Thread() to get the thread ready to
     *   execute in user mode
     * - Call Make_Runnable_Atomic() to schedule the process
     *   for execution
     */
    // TODO("Start user thread");
    
    /* 非用户态进程 返回错误 */
    if (userContext == NULL)
    {
        return NULL;
    }

    /* 建立用户态进程 */
    struct Kernel_Thread *kthread = Create_Thread(PRIORITY_USER, detached);
    if (kthread == NULL)
    {
        return NULL;
    }
    Setup_User_Thread(kthread, userContext);

    /* 将新创建的进程加入就绪进程队列时确保原子性 */
    Make_Runnable_Atomic(kthread);

    /* 返回指向该进程的指针 */
    return kthread;
}

main.c中的补全

在main.c中调用Spawn_Init_Process(),其实现如下:

static void Spawn_Init_Process(void)
{
    // TODO("Spawn the init process");
    struct Kernel_Thread *pThread;
    Spawn("/c/shell.exe", "/c/shell.exe", &pThread);
}

添加系统调用

在syscall.c中,列举了一些待我们补全的函数,和我们这次任务直接相关的应该是Sys_Spawn(),值得留意的是,有一个列表

const Syscall g_syscallTable[] = {
    Sys_Null,
    Sys_Exit,
    Sys_PrintString,
    Sys_GetKey,
    Sys_SetAttr,
    Sys_GetCursor,
    Sys_PutCursor,
    Sys_Spawn,
    Sys_Wait,
    Sys_GetPID,
};

它就类似于是一个索引,如果添加了新的sys函数,记得添加到这个列表里。

对syscall.c中各需要补全的函数的补全如下:

static int Sys_Spawn(struct Interrupt_State *state)
{
    // TODO("Spawn system call");
    int res;           //程序返回值
    char *program = 0; //存储进程名称的缓冲区地址
    char *command = 0; //命令参数
    struct Kernel_Thread *process;
    
    res = Copy_User_String(state->ebx, state->ecx, VFS_MAX_PATH_LEN, &program);
    if (res != 0)
    {
        goto fail;
    }
    res = Copy_User_String(state->edx, state->esi, 1023, &command);
    if (res != 0)
    { 
        goto fail;
    }

    Enable_Interrupts();                     
    res = Spawn(program, command, &process); //得到进程名称和用户命令后便可生成一个新进程
    if (res == 0)
    { //若成功则返回新进程ID号
        KASSERT(process != 0);
        res = process->pid;
    }
    Disable_Interrupts(); //关中断
fail:
    if (program != 0)
        Free(program);
    if (command != 0)
        Free(command);
    return res;
}

其他的:

static int Sys_Exit(struct Interrupt_State *state)
{
    // TODO("Exit system call");
    Exit(state->ebx);
}


static int Sys_PrintString(struct Interrupt_State *state)
{
    // TODO("PrintString system call");
    int result = 0;
    uint_t length = state->ecx; 
    uchar_t *buf = 0;
    if (length > 0)
    {
        if (Copy_User_String(state->ebx, length, 1023, (char **)&buf) != 0)
            goto done;
        Put_Buf(buf, length);
    }
done:
    if (buf != NULL)
        Free(buf);
    return result;
}

static int Sys_GetKey(struct Interrupt_State *state)
{
    // TODO("GetKey system call");
    return Wait_For_Key();
}

static int Sys_SetAttr(struct Interrupt_State *state)
{
    // TODO("SetAttr system call");
    Set_Current_Attr((uchar_t)state->ebx);
    return 0;
}

static int Sys_GetCursor(struct Interrupt_State *state)
{
    // TODO("GetCursor system call");
    /* 获取当前光标所在屏幕位置(行和列) */
    int row, col;
    Get_Cursor(&row, &col);
    if (!Copy_To_User(state->ebx, &row, sizeof(int)) ||
        !Copy_To_User(state->ecx, &col, sizeof(int)))
        return -1;
    return 0;
}

static int Sys_PutCursor(struct Interrupt_State *state)
{
    // TODO("PutCursor system call");
    return Put_Cursor(state->ebx, state->ecx) ? 0 : -1;
}

static int Sys_Wait(struct Interrupt_State *state)
{
    // TODO("Wait system call");
    int exitCode;
    /* 查找需要等待的进程 */
    struct Kernel_Thread *kthread = Lookup_Thread(state->ebx);
    /* 如果没有找到需要等待的进程,则返回错误代码 */
    if (kthread == 0)
        return -1;
    /* 等待指定进程结束 */
    Enable_Interrupts();
    exitCode = Join(kthread);
    Disable_Interrupts();
    return exitCode;
}

static int Sys_GetPID(struct Interrupt_State *state)
{
    // TODO("GetPID system call");
    return g_currentThread->pid;
}

运行

至此,project2就结束了,可以参照文档上的指示验证一下代码及思路是否正确。

image.png