ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • xv6 - System Call & Traps
    프로젝트/운영체제 만들기 2023. 7. 12. 17:07

    글의 참고

    - book-rev11.pdf

    - https://github.com/mit-pdos/xv6-public

    - https://pdos.csail.mit.edu/6.828/2022/xv6.html

    - https://wiki.osdev.org/Xv6


    글의 전제

    - 32비트 x86 기반의 xv6 rev-11을 내 나름대로 분석한 내용이다.


    글의 내용

    - Systems calls, exceptions, and interrupts

    : `xv6`에서 모든 인터럽트들은 프로세스가 처리한다기 보다는 커널에서 처리한다. 예를 들어, `xv6`에서 스케줄러는 실제 프로세스라기 보다는 커널의 일부분이다. 스케줄러(커널)는 타이머 인터럽트를 받아서 현재 동작중인 프로세스에게서 CPU를 선점해서 다른 프로세스에게 할당한다. 이러한 일들은 대게 커널이 맞게된다.

    There are three cases when control must be transferred from a user program to the kernel. First, a system call: when a user program asks for an operating system service, as we saw at the end of the last chapter. Second, an exception: when a program performs an illegal action. Examples of illegal actions include divide by zero, attempt to access memory for a page-table entry that is not present, and so on. Third, an interrupt: when a device generates a signal to indicate that it needs attention from the operating system. For example, a clock chip may generate an interrupt every 100 msec to allow the kernel to implement time sharing. As another example, when the disk has read a block from disk, it generates an interrupt to alert the operating system that the block is ready to be retrieved.

    The kernel handles all interrupts, rather than processes handling them, because in most cases only the kernel has the required privilege and state.
     For example, in order to time-slice among processes in response the clock interrupts, the kernel must be involved, if only to force uncooperative processes to yield the processor

    : `xv6`에서 유저 레벨에서 커널 레벨로 반드시 전환되는 3가지 케이스를 설명하고 있다.

    `System call` vs `Exception` vs `Interrupt`

    : 유저 레벨에서 동작하다가 위의 이벤트가 발생할 경우, 커널 레벨로 전환된다. 그런데, 위에서 말했던 것처럼 `xv6`에서는 커널이 모든 인터럽트를 처리한다. `특정 커널  프로세스 인터럽트를 전담한다` 같은 개념은 없다. 커널이 모든 인터럽트를 처리한다. 여기서 하드웨어 인터럽트나 익셉션은 커널 레벨에서도 발생할 수 있다보니, 프로세스의 개념이 필요없을 수 있다. 그런데, 시스템 콜 같은 경우는 어떨까? 크게 다르지 않다. 그러나, 차이가 약간있다.

     

    : `xv6`에서 유저 레벨은 무조건 프로세스 기반으로 동작한다. 유저 레벨에서 뭔가가 동작하는데, 프로세스가 아니다? 왜 유저 레벨은 프로세스로 레벨을 나누었을까? 여러 가지 이유가 있겠지만, 유저 레벨에서 작업의 단위를 프로세스로 두는 것은 보호 메커니즘 를 적용하기 위해서다. 각 유저 프로세스는 자신만의 별도의 페이지 테이블을 가지고 있다. 페이징 메커니즘을 통해 유저 프로세스는 직접적으로 커널 영역에 접근이 불가능하다. 그리고 이 기능들은 하드웨어 차원에서 지원되는 부분이라서 그만큼 강력하다(세그먼트 기법으로도 보호 메커니즘을 구현할 수 있지만, 세그먼트는 외부 단편화 때문에 거의 사용되지 않고 있다).

     

    : 유저 레벨에서 `malloc`을 호출하면 커널에서는 어떤 동작이 이루어질까? 커널은 `malloc` 함수를 호출한 프로세스의 HEAP 영역 사이즈를 늘려준다. 끝이다. 엥? 위에서 말했다시피 `xv6`에서 커널에 별도의 프로세스는 존재하지 않는다. 스케줄러 또한 커널의 일부분일 뿐이다. 시스템 콜을 호출하면 현재 프로세스의 트랩 프레임에 유저 프로세스의 트랩 프레임을 저장하고(`myproc()->tf = tf` - 사실, 시스템 콜시에는 프로세스가 변경되지 않기 때문에 `myproc`이 시스템 콜을 호출한 프로세스가 된다), 기타 등등의 일을 하지만, 결국 시스템 콜의 처리는 커널이 한다. 그럼 커널과 프로세스는 어떻게 구분할까? 커널은 자신만의 컨택스트가 없다. 항상 남에 컨택스트를 빌린다(물론, 프로세스에게 스택을 할당할 때, 커널 스택 일부를 할당해주지만...).

    In all three cases, the operating system design must arrange for the following to happen. The system must save the processor’s registers for future transparent resume. The system must be set up for execution in the kernel. The system must chose a place for the kernel to start executing. The kernel must be able to retrieve information about the event, e.g., system call arguments. It must all be done securely; the system must maintain isolation of user processes and the kernel.

    To achieve this goal the operating system must be aware of the details of how the hardware handles system calls, exceptions, and interrupts. In most processors these three events are handled by a single hardware mechanism. For example, on the x86, a program invokes a system call by generating an interrupt using the `int` instruction. Similarly, exceptions generate an interrupt too. Thus, if the operating system has a plan for interrupt handling, then the operating system can handle system calls and exceptions too.

     

    : x86에서 익셉션, 인터럽트, 시스템 콜을 모두 다르게 바라봐야 할까? 그렇지 않다. 왜냐면, 앞에 3개 모두 운영 체제 입장에서는 그저 `인터럽트`로 인식하기 때문이다. 어떻게 3개 모두를 동일하게 인식할까? 이 3개가 발생하면, 운영 체제에게 인식되는 방법이 동일하다. 이 3개가 발생하면 모두 CPU는 인터럽트 벡터(인터럽트 핸들러)를 호출한다. 즉, 3개가 모두 단순히 숫자에 대응한다는 소리다. `페이지 폴트`, `fork 시스템 콜`, `디스크 드라이버 인터럽트` 등 모두 단지 숫자에 불과하다. CPU가 추상화 시킨 이 숫자들을  운영 체제는 특별한 루틴에 연결시킨다. 그게 `인터럽트 핸들러`다. 

    To achieve this goal the operating system must be aware of the details of how the hardware handles system calls, exceptions, and interrupts. In most processors these three events are handled by a `single hardware mechanism`. For example, on the x86, a program invokes a system call by generating an interrupt using the `int` instruction. Similarly, exceptions generate an interrupt too. Thus, if the operating system has a plan for interrupt handling, then the operating system can handle system calls and exceptions too.

     

    : 인터럽트를 배울 때, 아래의 내용을 한 번쯤은 들어보게 된다.

    인터럽트 핸들러가 호출되기 전에 이전 컨택스트의 정보를 저장하고, 인터럽트 핸들러가 루틴을 끝내면, 이전 컨택스트를 복구한다. 

     

    : 맞는말이다. x86 기준에서 최소한 몇 개의 레지스터들은 CPU가 알아서 저장하고 복구한다.

    The basic plan is as follows. An interrupts stops the normal processor loop and starts executing a new sequence called an `interrupt handler`. Before starting the interrupt handler, the processor `saves` its registers, so that the operating system can `restore` them when it returns from the interrupt. A challenge in the transition to and from the interrupt handler is that the processor should switch from user mode to kernel mode, and back.

     

    : `xv6`는 UNIX v6를 기반으로 만들어졌기 때문에, x86에서 사용하는 `exception`이라는 용어를 사용하지 않고, `trap`을 사용한다고 한다. 이것보다 더 중요한 것은 `trap`과 `interrupt`의 차이다. `trap`은 현재 동작중인 프로세스의 의해 발생하지만, `interrupt`는 현재 동작중인 프로세스와는 관계가 없다. 예를 들어, 하나의 프로세스가 동작 중에 디스크에서 블락 읽기 요청이 필요해졌다. 그러면 프로세스는 커널에 블락 요청을 한 후, `BLOCK` 상태에 빠진다. 그리고, 다른 프로세스들이 실행이 된다. 그렇게 시간이 흐르고, 디스크에서 블락 읽기가 준비되면 프로세서에게 인터럽트를 발생시킨다. 그런데, 인터럽트가 발생한 시점을 보면, 디스크 요청을 했던 프로세스는 현재 동작중이 아니다. 다른 프로세스가 동작중이다. 즉, 인터럽트는 프로세스와 병렬적으로 발생할 수 있는 구조를 가지고 있다. 이러한 점이 인터럽트가 굉장히 복잡한 이유다.

    A word on terminology: Although the official x86 term is exception, xv6 uses the term trap, largely because it was the term used by the PDP11/40 and therefore is the conventional Unix term. Furthermore, this chapter uses the terms trap and interrupt interchangeably, but it is important to remember that traps are caused by the current process running on a processor (e.g., the process makes a system call and as a result generates a trap), and interrupts are caused by devices and may not be related to the currently running process. For example, a disk may generate an interrupt when it is done retrieving a block for one process, but at the time of the interrupt some other process may be running. This property of interrupts makes thinking about interrupts more difficult than thinking about traps, because interrupts happen concurrently with other activities. Both rely, however, on the same hardware mechanism to transfer control between user and kernel mode securely, which we will discuss next

    : 그러나 결국 인터럽트든 트랩이든 하드웨어 메커니즘은 동일하게 `인터럽트`다.

     

    - X86 protection

    : x86은 보호 메커니즘은 4가지 보호 단계로 나뉜다. 0이 가장 높은 권한이고, 3이 가장 낮은 권한이다. 호환성을 위해 대부분의 운영체제들은 0과 3만을 이용해서 보호 메커니즘을 구현하고 있다. 0을 `커널 모드`, 3을 `유저 모드`라고 부른다. 현재 x86 프로세서가 실행 권한을 알고 싶다면 `코드 세그먼트 셀렉터`의 CPL 필드를 확인해봐야 한다. CPL은 앞에서 언급했듯이 `셀렉터`안에 존재하기 때문에, 리얼 모드가 아닌 보호 모드 이상에서 존재하는 개념이다.

     

    : x86 리얼 모드에서 인터럽트 테이블의 이름은 IVT(Interrupt Vector Table)였다. 그런데, 보호 모드로 오면서 부터는 IDT(Interrupt Descriptor Table)로 바뀌었다. 그 이유는 x86이 보호 모드로 오면서 구조 자체를 `디스크립터` 기반으로 바꾸었기 때문이다. 이 `디스크립터`는 `간접 참조`를 해주는 매개체다. 이러한 구조는 직접적 참조를 막음으로써, 나중에 실제 타겟에 구조가 바뀌더라도 디스크립터의 구조는 바뀌지 않기 때문에 사용자 입장에서 코드를 바꿀 필요가 없어진다. 즉, 유연한 구조를 위해 `디스크립터`를 도입한 것이다.

     

    : 본론으로 돌아와서 IDT안에는 인터럽트 핸들러가 저장된다. [CS:EIP] 를 통해서 인터럽트 핸들러의 위치를 알 수 있다. 대부분의 운영체제들이 플랫 세그먼테이션 기반의 모델을 사용하기 때문에, CS는 0이 된다. 그래서 대게는 EIP 만 가지고 인터럽트 핸들러의 주소를 파악하게 된다. 

    The x86 has 4 protection levels, numbered 0 (most privilege) to 3 (least privilege). In practice, most operating systems use only 2 levels: 0 and 3, which are then called kernel mode and user mode, respectively. The current privilege level with which the x86 executes instructions is stored in %cs register, in the field CPL.

    On the x86, interrupt handlers are defined in the interrupt descriptor table (IDT). The IDT has 256 entries, each giving the %cs and %eip to be used when handling the corresponding interrupt. To make a system call on the x86, a program invokes the int n instruction, where n specifies the index into the IDT. The int instruction performs the following steps:

     

    : 시스템 콜을 발생시키기 위해서는 x86 에서 제공하는 `int ${시스템 콜 번호}` 명령어를 사용해야 한다. `int $[시스템 콜 번호}` 명령어를 호출하면 발생하는 절차는 아래와 같다.

    • Fetch the n’th descriptor from the IDT, where n is the argument of int.
    • Check that CPL in %cs is <= DPL, where DPL is the privilege level in the descriptor.
    • Save %esp and %ss in CPU-internal registers, but only if the target segment selector’s PL < CPL.
    • Load %ss and %esp from a task segment descriptor.
    • Push %ss.
    • Push %esp.
    • Push %eflags.
    • Push %cs.
    • Push %eip.
    • Clear the IF bit in %eflags, but only on an interrupt.
    • Set %cs and %eip to the values in the descriptor.

    1" `int n`을 호출하면, 먼저 n 번째 IDT 디스크립터를 가져온다.

     

    2" n 번째 IDT 디스크립터의 DPL과 현재 CPL을 비교한다. 

     

    3" 만약, DPL이 CPL보다 권한이 높다면 현재(유저 레벨) ESP, SS 레지스터 값을 CPU 내부 레지스터에 저장한다. 권한이 높지 않다면, `스택 스위칭`은 발생하지 않는다. 이 때, SS와 ESP는 저장하지 않는다. 즉, `유저 레벨 <-> 유저 레벨`, `커널 레벨 <-> 커널 레벨`, `커널 레벨 -> 유저 레벨` 에서는 SS와 ESP는 저장하지 않는다. 오직, `유저 레벨 -> 커널 레벨` 에서만 SS와 ESP를 저장한다.

     

    4" 커널에서 등록한 TSS(task segment descriptor)로 부터 SS0(커널 스택 세그먼트), ESP0(커널 ESP)를 로드한다. 여기서 실제로 유저 레벨 스택에서 커널 스택으로 교체된것이다.

     

    5" 이제 순서대로 중요한 레지스터 5개를 저장한다. SS, ESP, EFLAGS, CS, EIP 순으로 저장한다. 마지막 에러 코드는 인텔에서 정의한 특정 익셉션에 한해서만 추가된다.

     

    6" 이제 핸들러를 실행할 일만 남았다. 핸들러를 실행하기 전에 실행할 핸들러(게이트)가 트랩인지 인터럽트인지 판별해서 ELFAGS의 `IF` 플래그를 SET 혹은 CLEAR하고, EIP에 가리키는 주소로 점프해서 핸들러로 진입한다.

     

    : CPL이 DPL보다 권한이 낮다면, 유저 레벨 코드가 커널 레벨 코드를 실행하는 것은 불가능하다. 대부분의 커널들은 유저 레벨의 CPL이 커널 레벨의 디스크립터의 DPL 보다 권한이 낮다. 그러나 딱 한 개 유저 레벨에서 커널 코드를 실행할 수 있는 방법이 있다. 물론 간접적이지만, 커널 코드를 실행시 킬 수 있다. 그게 바로 `시스템 콜`이다. 시스템 콜은 결국 인터럽트다. 그러나 하드웨어 인터럽트가 아닌, 소프트웨어 인터럽트다. 그래서 `int` 명령어를 통해서 시스템 콜을 실행한다.

     

    : 그런데, 시스템 콜은 결국 인터럽트고 인터럽트를 처리할 수 있는 핸들러가 있어야 한다. 이 핸들러는 x86 기반 IDT 시스템 자료 구조에 등록이 되어있어야 한다. 등록할 때, 해당 인터럽트에 대한 `벡터(번호)`를 지정를 해야한다. 그 번호를 호출해야 시스템 콜이 호출된다. 예를 들어, 시스템 콜이 인터럽트 벡터 80으로 할당되면 `int 80`으로 호출할 수 있다. 

     

    : 그런데 x86에서는 디스크립터에 접근할 때마다 CPL과 DPL을 검사한다. 이 CPL이 DPL보다 권한이 높아야 액세스가 가능하다. 그래서 모든 트랩 및 인터럽트는 DPL이 0이지만, 유일하게 시스템 콜은 DPL이 3이다. 왜냐면, 유저 레벨에서 액세스가 가능해야 하기 때문이다. 만약, 시스템 콜이 등록된 IDT 엔트리의 DPL이 CPL보다 권한이 높다면, x86은 `#GP`를 발생시킨다.

    The int instruction is a complex instruction, and one might wonder whether all these actions are necessary. For example, the check CPL <= DPL allows the kernel to forbid int calls to inappropriate IDT entries such as device interrupt routines. For a user program to execute int, the IDT entry’s DPL must be 3. If the user program doesn’t have the appropriate privilege, then int will result in int 13, which is a general protection fault. As another example, the int instruction cannot use the user stack to save values, because the process may not have a valid stack pointer; instead, the hardware uses the stack specified in the task segment, which is set by the kernel.

    : 그리고 권한이 변경될 때, 레지스터들이 저장되는 스택은 유저 스택이 아닌 커널 스택에 저장이 된다. 그 이유는 유저 프로세스의 스택 포인터를 믿을 수 없기 때문이다. 즉, 유저 레벨 프로세스는 조작이 가능하기 때문에 절대 믿으면 안된다는 것이다. 그래서 x86에서는 TSS 라는 하드웨어 메커니즘을 이용해서 커널 스택에다가 나중에 유저 레벨 복귀하기 위해서 유저 레벨의 컨택스트를 저장한다. 

     

    : x86은 `iret`은 인터럽트 핸들러가 모든 일을 마치고 이전 코드로 복귀할 때, 사용하는 명령어다. `int` 명령어를 사용할 때, 현재 스택에 푸쉬했던 내용들을 원래 순서 및 포맷에 맞춰 각 레지스터에 다시 원복시키고(이 때, 이전 스택으로 스위칭됨), EIP에 이전 수행 바로 다음 명령어 주소를 넣어서 계속 흐름을 이어나가도록 해준다.

    Figure 3-1 shows the stack after an int instruction completes and there was a privilege-level change (the privilege level in the descriptor is lower than CPL). If the int instruction didn’t require a privilege-level change, the x86 won’t save %ss and %esp. After both cases, %eip is pointing to the address specified in the descriptor table, and the instruction at that address is the next instruction to be executed and the first instruction of the handler for int n. It is job of the operating system to implement these handlers, and below we will see what xv6 does.

    An operating system can use the iret instruction to return from an int instruction. It pops the saved values during the int instruction from the stack, and resumes execution at the saved %eip.

     

    - Code: The first system calll

    : 유저 레벨 프로세스가 실행되는 지점은 `initcode.S`의 `start` 심볼로부터 시작한다. `start` 심볼(프로시저)은 유저 레벨에서 `exec` 함수를 출하면 실행되는 프로시저다. `eax`에 `sys_exec` 관련 기능 코드를 넣은 후에 시스템 콜을 호출한다. 그러면, `sys_exec`안에서 여러 가지 과정을 통해 유저 레벨에서 커널 레벨 코드로 바뀌게 된다. 이 과정에 대해 이제 자세히 알아보자.

    Chapter 1 ended with initcode.S invoking a system call. Let’s look at that again (8414). The process pushed the arguments for an exec call on the process’s stack, and put the system call number in %eax. The system call numbers match the entries in the syscalls array, a table of function pointers (3672). We need to arrange that the int instruction switches the processor from user mode to kernel mode, that the kernel invokes the right kernel function (i.e., sys_exec), and that the kernel can retrieve the arguments for sys_exec. The next few subsections describe how xv6 arranges this for system calls, and then we will discover that we can reuse the same code for interrupts and exceptions.

     

     

    - Code: Assembly trap handlers

    : x86에서 `0 - 31`번 까지의 예약된 인터럽트가 있기 때문에, `xv6`는 그 뒤쪽 32 - 63번 인터럽트 번호에 자신이 사용하는 32개의 하드웨어 인터럽트를 매핑한다. 그리고, `64`번을 시스템 콜 인터럽트 번호로 사용한다.

    Xv6 must set up the x86 hardware to do something sensible on encountering an `int` instruction, which causes the processor to generate a trap. The x86 allows for 256 different interrupts. Interrupts 0-31 are defined for software exceptions, like divide errors or attempts to access invalid memory addresses. Xv6 maps the 32 hardware interrupts to the range 32-63 and uses interrupt 64 as the system call interrupt.

    Tvinit (3367), called from main, sets up the 256 entries in the table idt. Interrupt i is handled by the code at the address in vectors[i]. Each entry point is different, because the x86 does not provide the trap number to the interrupt handler. Using 256 different handlers is the only way to distinguish the 256 cases.

    Tvinit handles T_SYSCALL, the user system call trap, specially: it specifies that the gate is of type ‘‘trap’’ by passing a value of 1 as second argument. Trap gates don’t clear the IF flag, allowing other interrupts during the system call handler.

    The kernel also sets the system call gate privilege to DPL_USER, which allows a user program to generate the trap with an explicit int instruction. xv6 doesn’t allow processes to raise other interrupts (e.g., device interrupts) with int; if they try, they will encounter a general protection exception, which goes to vector 13.

    : `xv6`에서 64번 인터럽트(시스템 콜)을 제외하고는 `int` 명령어로 다른 하드웨어 인터럽트 번호(32 ~ 63)를 사용하는 것을 허용하지 않는다. 만약에, 그럴 경우 #GPF[0x13] 가 발생할 것 이다.

     

    : 권한 레벨이 스위칭 되면 스택 영역 또한 스위칭된다. 즉, `낮은 권한 <-> 높은 레벨` 어느쪽 방향에 상관없이 스택 스위칭이 발생한다. 스택을 교체하는 이유는 유저 프로세스가 커널 영역을 어지럽힐 우려가 있기 때문이다. x86의 스택 스위칭 메커니즘은 하드웨어에 차원에서 이루어지기 때문에, 권한 레벨의 변경을 감지하면 프로세서가 TSS를 통해서 스택 스위칭을 하게 된다. `xv6` 소스코드상에서는 `switchuvm` 함수에서 확인이 가능하다.

    When changing protection levels from user to kernel mode, the kernel shouldn’t use the stack of the user process, because it may not be valid. The user process may be malicious or contain an error that causes the user %esp to contain an address that is not part of the process’s user memory. Xv6 programs the x86 hardware to perform a stack switch on a trap by setting up a task segment descriptor through which the hardware loads a stack segment selector and a new value for %esp. The function switchuvm (1860) stores the address of the top of the kernel stack of the user process into the task segment descriptor.

    When a trap occurs, the processor hardware does the following. If the processor was executing in user mode, it loads %esp and %ss from the task segment descriptor, pushes the old user %ss and %esp onto the new stack. If the processor was executing in kernel mode, none of the above happens. The processor then pushes the %eflags, %cs, and %eip registers. For some traps (e.g., a page fault), the processor also pushes an error word. The processor then loads %eip and %cs from the relevant IDT entry.

    xv6 uses a Perl script (3250) to generate the entry points that the IDT entries point to. Each entry pushes an error code if the processor didn’t, pushes the interrupt number, and then jumps to alltraps.

    : 권한 레벨이 변경되면 기존에 스택에 추가되던 `EFLAGS`, `CS`, `EIP`에 더해서 추가적으로 `ESP`와 `SS`가 더 추가된다.

     

    : 아래에서 자세히 보겠지만, xv6에서 `Alltraps` 프로시저는 모든 인터럽트 핸들러가 호출하는 함수다. 

    Alltraps (3304) continues to save processor registers: it pushes %ds, %es, %fs, %gs, and the general-purpose registers (3305-3310). The result of this effort is that the kernel stack now contains a struct trapframe (0602) containing the processor registers at the time of the trap (see Figure 3-2). The processor pushes %ss, %esp, %eflags, %cs, and %eip. The processor or the trap vector pushes an error number, and alltraps pushes the rest. The trap frame contains all the information necessary to restore the user mode processor registers when the kernel returns to the current process, so that the processor can continue exactly as it was when the trap started. Recall from Chapter 2, that userinit built a trapframe by hand to achieve this goal (see Figure 1-4).

    In the case of the first system call, the saved %eip is the address of the instruction right after the int instruction. %cs is the user code segment selector. %eflags is the content of the %eflags register at the point of executing the int instruction. As part of saving the general-purpose registers, alltraps also saves %eax, which contains the system call number for the kernel to inspect later.

    Now that the user mode processor registers are saved, alltraps can finishing setting up the processor to run kernel C code. The processor set the selectors %cs and %ss before entering the handler; alltraps sets %ds and %es (3313-3315).

    : 아래의 표를 알 수 있겠지만, 프로세서가 스택에 넣어주는 자료는 `SS, ESP, EFLAGS, CS, EIP` 까지만이다. 물론, `exception`이 발생하면 에러 코드도 넣어주긴 한다. 그런데, xv6에서는 모든 expcetion, trap, interrupt의 자료 구조를 `struct trapframe` 이라는 구조체로 통일했다. 그래서, 그 포맷을 맞추기 위해 `Alltraps` 프로시저가 호출된다.

    //PAGEBREAK: 36
    // Layout of the trap frame built on the stack by the
    // hardware and by trapasm.S, and passed to trap().
    struct trapframe {
      // registers as pushed by pusha
      uint edi;
      uint esi;
      uint ebp;
      uint oesp;      // useless & ignored
      uint ebx;
      uint edx;
      uint ecx;
      uint eax;
    
      // rest of trap frame
      ushort gs;
      ushort padding1;
      ushort fs;
      ushort padding2;
      ushort es;
      ushort padding3;
      ushort ds;
      ushort padding4;
      uint trapno;
    
      // below here defined by x86 hardware
      uint err;
      uint eip;
      ushort cs;
      ushort padding5;
      uint eflags;
    
      // below here only when crossing rings, such as from user to kernel
      uint esp;
      ushort ss;
      ushort padding6;
    };

    : `struct trapframe` 구조체와 위 스택 구조를 비교해보자. 아래 중간중간 `paddingX` 이라고 있는 변수들은 컴파일러 최적화때문에 `int` 형보다 작은 데이터 타입을 `int`로 맞추기 위해 추가되는 변수들이다. `SS, ESP, ELFAGS, CS, EIP`는 유저 레벨에서 `int` 명령어를 호출하는 순간 프로세서가 자동으로 저장해주는 부분이다. `에러 코드`와 `trapno`는 `xv6`에서 펄 스크립트(vectors.pl)를 통해서 자동으로 모든 256개 인터럽트 핸들러들에게 공통으로 푸쉬하는 코드다. 펄 스크립트는 이걸 자동으로 생성한다. 그리고 `세그먼트 레지스터`, `범용 레지스터`는 `alltraps`  심볼에서 푸쉬해준다. 즉, `xv6`의 트랩 프레임은 3개의 영역으로 나눌 수 있다.

     

    1" 프로세서가 자동으로 넣어주는 영역(SS, ESP, EFLAGS, CS, EIP)
    2" 인터럽트 핸들러에서 자동으로 넣어주는 영역(ERROR CODE, TRAP NUMBER)
    3" alltraps 프로시저에서 넣어주는 영역(DS, ES, FS, GS, EAX, ECX, EDX, EBX, OESP, EBP, ESI, EDI)

     

    : alltraps 에서 범용 레지스터를 저장하는 순서는 인텔 명령어 `pusha`를 사용했기 위와 같이 저장되는 것이다. 참고로, ESP는 저장되지 않는다.

     

    : 근데, 권한이 바뀔 때는 `SS, ESP` 까지 저장을 하는데, 만약 권한이 안 바뀌면 어떻게 될까? 예를 들어, 커널 레벨에서 동작중인데, 인터럽트가 발생하면 어떻게 되지? 그런 경우는 `SS, ESP`는 저장되지 않는다. 대신에 `EFLAGS, CS, EIP`가 저장된다. 그렇면, 스택에 저 내용들이 `strcut trapframe` 구조체에는 어떻게 저장되는 걸까?  

    : 위에 그림을 보면 ESP는 현재 EDI의 시작 주소를 가리키고 있다. 그리고, `strcut trapframe`의 제일 낮은 주소에는 EDI가 저장되어 있다. 그리고 마지막 주소에는 `SS, ESP`가 있다. 그래서, 만약에 `strcut trapframe = ESP`가 되면, 시스템 콜이 호출될 때는 권한 레벨이 바뀌면서 `SS, ESP`가 의미있는 값이겠지만, 만약 권한 레벨이 바뀌지 않으면, `SS, ESP`는 이전 실행 컨택스트의 데이터가 된다. 예를 들어, 커널 모드에서 뭔가 실행 중에 하드웨어 인터럽트가 발생하는 경우를 들 수가 있다. 그래서, 인터럽트 발생 시, SS나 ESP를 사용하면 안된다(이전 컨택스트를 망가트리게 되니깐). 여기서 또 중요한 점은 인터럽트 및 시스템 콜이 발생했을 때를 기준으로 이전과 현재의 프로세스는 동일하다는 것이다. 그런데, 프로세스가 하던 일이 다르다. 즉, 프로세스는 동일하나 실행 컨택스트만 바뀐 것이다.

     

    : `vectors.pl` 펄 스크립트를 통해 자동으로 인터럽트 핸들러를 생성 코드를 보자. 핵심적인 부분은 x86 IDT의 사이즈가 256이기 때문에 256번만 루프를 순환한다. x86에서 예약된 인터럽트인 0 ~ 31 중에서 에러 코드를 받는 익셉션이 `8`, `10, 11, 12, 13, 14`, `17`이다. 그래서 그 번호들을 제외하면, 에러 코드로 `0`을 넣어주고 있다. 이렇게 하는 이유는 `struct trapframe`의 포맷을 맞추기 위해서다. 이해가 가지 않으면 `그림 3-2` 과 `strcut trapframe`  구조체를 다시 한 번 상기시켜야 한다. 그리고 모든 핸들러에 에러 코드를 푸쉬했으면 그 다음 인터럽트 번호를 푸쉬한다. 그리고 모든 트랩 핸들러는 `alltraps` 프로시저로 제어권을 넘긴다.

    #!/usr/bin/perl -w

    # Generate vectors.S, the trap/interrupt entry points.
    # There has to be one entry point per interrupt number
    # since otherwise there's no way for trap() to discover
    # the interrupt number.

    print "# generated by vectors.pl - do not edit\n";
    print "# handlers\n";
    print ".globl alltraps\n";
    for(my $i = 0; $i < 256; $i++){
        print ".globl vector$i\n";
        print "vector$i:\n";
        if(!($i == 8 || ($i >= 10 && $i <= 14) || $i == 17)){
            print "  pushl \$0\n";
        }
        print "  pushl \$$i\n";
        print "  jmp alltraps\n";
    }

    print "\n# vector table\n";
    print ".data\n";
    print ".globl vectors\n";
    print "vectors:\n";
    for(my $i = 0; $i < 256; $i++){
        print "  .long vector$i\n";
    }

    # sample output:
    #   # handlers
    #   .globl alltraps
    #   .globl vector0
    #   vector0:
    #     pushl $0
    #     pushl $0
    #     jmp alltraps
    #   ...
    #   
    #   # vector table
    #   .data
    #   .globl vectors
    #   vectors:
    #     .long vector0
    #     .long vector1
    #     .long vector2
    #   ...

     

    : 그런데, 위의 펄 스크립트를 통해 만들어진 어셈블리 인터럽트 핸들러들은 결국 x86 IDT 테이블에 등록이 되어야 인터럽트 및 트랩이 발생하면 실행이 된다. 이런 등록 과정은 `tvinit` 함수가 담당한다. 아래 `tvinit`을 보면 일단 펄 스크립트를 통해 만들어진 256개의 `vectors[i]`를 IDT에 등록한다. 당연히 세그먼트 셀렉터는 커널 코드 영역으로 할당한다. 그리고 뒤쪽에 `시스템 콜`은 별도로 다시 작성한다. 이 때, 재미있는 부분은 3가지다. 

     

    1" 시스템 콜이 호출되면 실행되는 코드는 `커널 코드 세그먼트`다.
    2" 시스템 콜의 인터럽트 디스크립터 DPL은 유저 레벨 권한(3)으로 설정한다(DPL_USER). 왜냐면, 시스템 콜은 결국 유저가 호출하는 것이기 때문이다. 즉, 해당 인터럽트 디스크립터에 유저가 접근할 수 있어야 한다.
    3" 시스템 콜은 인터럽트 게이트가 아닌 트랩 게이트(SETGATE 매크로의 두 번째 인자로 `1` 전달)로 설정한다. 이 의미는 시스템 콜은 처리 도중 다른 인터럽트 및 트랩에 의해 선점될 수 있다는 것이다.

     

    : 왜 시스템 콜만 `트랩`으로 설정했을까? 일반적인 하드웨어 인터럽트보다 우선 순위가 낮기 때문이다. 

    // trap.c
    ...
    ...
    void
    tvinit(void)
    {
      int i;

      for(i = 0; i < 256; i++)
        SETGATE(idt[i], 0, SEG_KCODE<<3, vectors[i], 0);

      SETGATE(idt[T_SYSCALL], 1, SEG_KCODE<<3, vectors[T_SYSCALL], DPL_USER); 

      initlock(&tickslock, "time");
    }
    ...
    ...

     

    : 이렇게 시스템 콜의 핸들러에 대한 등록 과정은 끝이 난다. 그럼, 다시 정리하면 유저 레벨 프로세스가 최초로 실행되면 `initcode.S`에서 `sys_exec`을 호출한다. 그러면, `xv6`에서는 시스템 콜(64번)이 등록된 트랩 핸들러가 호출된다. 그런데 펄 스크립트를 통해 봤겠지만, 모든 인터럽트 핸들러는 최종적으로 `alltraps`를 호출한다. `alltraps`는 위에 잠깐 설명했지만, `세그먼트 레지스터`, `범용 레지스터` 들을 저장한 후 `trap` 함수를 호출한다. 여기서는 다시 `alltraps`가 `trap`을 호출하는 과정에 대해에 좀 더 자세히 알아본다.

     

    : alltraps이 모든 레지스터들을 저장하고 나면, 이제 진짜 유저 레벨의 `요청`을 처리할 때가 왔다(엄밀히 말하면, 이 요청은 시스템  콜일 수도 있고, 인터럽트 일 수도 있고, 트랩일 수도 있다). 요청에 대한 처리는 C 함수인 `trap`이 담당한다. 이 함수는 인자로 `struct trapframe`을 받는다. 그래서 아래 코드를 보면, `trap` 함수에게 `struct trapframe` 을 전달하기 위해 스택에 현재 만들어진 `트랩 프레임`의 시작 주소(현재 ESP)를 푸쉬하고 있다. 이 그림이 이해가 안갈 수 있다. 그럴 경우, 아래 코드를 반드시 `그림 3-2`와 같이 보면서 이해해야 한다.

     

    : 그런데 진입전에 세그먼트를 커널 세그먼트로 설정하고 있다. `alltraps`은 `xv6`에 등록된 모든 인터럽트 및 트랩, 시스템 콜 핸들러가 호출하는 프로시저다. 그런데, `alltraps`은 누가 자신을 호출했는지 모른다. 만약, 자신을 호출한 서비스가 인터럽트 혹은 익셉션이라면, 이미 데이터 세그먼트 또한 커널 데이터 세그먼트 이므로, 교체할 필요가 없다.

     

    : 그런데, 시스템 콜이 호출될 경우는 데이터 세그먼트가 유저 데이터 세그먼트일 것이다. 유저 레벨 3 이므로, 0으로 교체해줘야 한다. 코드 세그먼트 교체안하나? 인터럽트 및 트랩은 전부 커널에서 발생하는 이벤트라서 이쪽은 교체할 필요가 없다. 시스템 콜만 유저 레벨에서 호출된다. 그런데, 우리는 `tvinit`에서 `시스템 콜` 핸들러가 커널 코드 세그먼트로 등록되는 것을 확인했다. 그러므로, `alltraps`에서는 데이터 세그먼트만 커널 레벨로 교체해주면 된다. 그럼 이제 `trap` 함수를 한 번 살펴보자.

    // trapasm.S
    #include "mmu.h"

      # vectors.S sends all traps here.
    .globl alltraps
    alltraps:
      # Build trap frame.
      pushl %ds
      pushl %es
      pushl %fs
      pushl %gs
      pushal
      
      # Set up data segments.
      movw $(SEG_KDATA<<3), %ax
      movw %ax, %ds
      movw %ax, %es

      # Call trap(tf), where tf=%esp
      pushl %esp
      call trap
      addl $4, %esp

      # Return falls through to trapret...
    .globl trapret
    trapret:
      popal
      popl %gs
      popl %fs
      popl %es
      popl %ds
      addl $0x8, %esp  # trapno and errcode
      iret

    : 트랩 넘버가 시스템 콜(64)일 경우, `syscall` 함수를 호출하도록 되어있다. `syscall` 함수는 인자를 받지 않는다. 대신, 현재 동작하고 있는 프로세스의 트램 프레임에 시스템 콜을 통해 전달한 트랩 프레임으로 교체한다(`myproc()->tf = tf`).

     

    : 그렇다면, 아래에 `myproc()` 함수에서 반환되는 프로세스가 시스템 콜을 호출했던 유저 프로세스와 동일한 프로세스일까? 이 질문의 의도는 아래의 `trap` 함수에 전달되는 `tf` 인자는 유저 프로세스의 정보다. 이 정보를 현재 동작하고 있는 프로세스의 트랩 프레임에 넣어도 될까(`myproc()->tf`)? 2개는 같은 프로세스이기 때문에 가능하다. 엥? `xv6`에서 컨택스트 스위칭이 발생하지 않는 이상 프로세스의 교체는 이루어지지 않는다. 그렇기 때문에, 유저 레벨에서 시스템 콜을 발생시킨 프로세스와 `trap` 함수에서 `myproc`을 통해 반환되는 프로세스는 동일한 프로세서다. 

     

    : 만약, `myproc()->tf = tf` 시점에 인터럽트가 발생하면 어떻게 될까? 저 시점에 인터럽트가 발생할 수는 있긴한가? 발생할 수 있다. 시스템 콜은 트랩이기 때문에, 다른 인터럽트에 의해 선점될 수 있다. 그리고 `myproc()->tf = tf` 앞단에 인터럽트를 비활성화하는 코드가 존재하지 않는다. 그렇기 때문에, 인터럽트는 발생할 수 있다. 그럼 다시 돌아와서 인터럽트가 발생하면 어떻게 될까? 문제가 될 여지가 없다. 스택에 현재 실행 컨택스트를 그대로 보존하고 나서 관련 인터럽트 핸들러가 호출될 것이기 때문에, 문제가 될 건 없어보인다.

     

    : 전역적으로 사용되는 `myproc()->tf = tf`를 통해서 트랩 프레임 정보를 받게 된다. `myproc()`에서 반환되는 proc은 `mycpu()->proc`과 같은 프로세스다. `myproc()->proc`이 설정되는 코드는 딱 한군데 밖에 없다. 바로, `scheduler` 함수에서 호출된다. 이 내용은 스케줄러 쪽에서 다시 다룬다.

    // trap.c
    ...
    ...
    //PAGEBREAK: 41
    void
    trap(struct trapframe *tf)
    {
      if(tf->trapno == T_SYSCALL){
        if(myproc()->killed)
          exit();
        myproc()->tf = tf;
        syscall();
        if(myproc()->killed)
          exit();
        return;
      }

      switch(tf->trapno){
      case T_IRQ0 + IRQ_TIMER:
        if(cpuid() == 0){
          acquire(&tickslock);
          ticks++;
          wakeup(&ticks);
          release(&tickslock);
        }
        lapiceoi();
        break;
      case T_IRQ0 + IRQ_IDE:
        ideintr();
    ...
    ...
    }
    ####################################################################################################
    // syscall.c
    ...
    ...
    static int (*syscalls[])(void) = {
    [SYS_fork]    sys_fork,
    [SYS_exit]    sys_exit,
    [SYS_wait]    sys_wait,
    [SYS_pipe]    sys_pipe,
    [SYS_read]    sys_read,
    [SYS_kill]    sys_kill,
    [SYS_exec]    sys_exec,
    [SYS_fstat]   sys_fstat,
    [SYS_chdir]   sys_chdir,
    [SYS_dup]     sys_dup,
    [SYS_getpid]  sys_getpid,
    [SYS_sbrk]    sys_sbrk,
    [SYS_sleep]   sys_sleep,
    [SYS_uptime]  sys_uptime,
    [SYS_open]    sys_open,
    [SYS_write]   sys_write,
    [SYS_mknod]   sys_mknod,
    [SYS_unlink]  sys_unlink,
    [SYS_link]    sys_link,
    [SYS_mkdir]   sys_mkdir,
    [SYS_close]   sys_close,
    };


    void
    syscall(void)
    {
      int num;
      struct proc *curproc = myproc();

      num = curproc->tf->eax;
      if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
        curproc->tf->eax = syscalls[num]();
      } else {
        cprintf("%d %s: unknown sys call %d\n",
                curproc->pid, curproc->name, num);
        curproc->tf->eax = -1;
      }

    }

    : `trap` 함수 호출이 끝나면, x86은 `cdecl`을 사용하므로 스택 정리는 caller에서 진행해야 한다. 그러므로, 전달한 인자만큼 빼주는 작업을 해야한다(`addl $4, %esp`). 그런 다음, 가장 중요한 부분이다. 생각해보자. 유저 모드 프로세스가 커널 기능을 사용하기 위해 트랩을 발생시켰다. 그리고 커널은 유저 프로세스가 원하는 대로 해주기 위해 `trap` 함수를 호출했다. `trap`을 실행함으로써, 실제 유저 프로세스가 원하는 작업은 끝났다. 그러면 이제 어떻게 해야할까? 당연히 다시 복귀해야 한다. 그래서 아래 코드를 보면, `alltraps` 프로시저가 끝나고, 자연스럽게 `trapret` 프로시저로 이어지고 있다. `trapret`에서는 가장 먼저 `alltraps`에서 넣었던 범용 레지스터와 세그먼트 레지스터를 POP 한다. 그리고 펄 시크립트(`vectors.pl`)를 통해 만들어진 인터럽트 핸들러에서 자동으로 넣었던 `에러 코드와 트랩 번호`를 POP 한다(`addl $0x8, %esp`). 그리고 마지막으로 `iret` 명령어를 통해서 최초에 유저 모드에서 `int` 명령어를 통해서 넣었던 레지스터들을 다시 원복하게 된다. 

     

    : 위의 과정은 `유저 모드 -> 커널 모드`의 트랩 과정을 살펴본 것이다. 그래서 SS, ESP가 커널 스택에 저장이 됬다. 그러나, 트랩은 커널이 동작중에도 발생할 수 있다. 그러나, 커널 동작 중에 트랩 및 인터럽트가 발생했다고, 스택 스위칭이 발생하지는 않는다(이건 32비트 x86 기준이다. x86_64 부터는 `IST`가 도입되면서 권한에 상관없이 무조건 스택 스위칭이 발생한다. 참고로, `xv6`는 x86_64 버전이 없다). 권한이 변경되지 않기 때문이다.

    '프로젝트 > 운영체제 만들기' 카테고리의 다른 글

    [xv6] Spinlock  (0) 2023.07.17
    AT & T 문법  (0) 2023.07.15
    xv6 - Init process  (0) 2023.07.12
    [xv6] Page tables 상세 분석 1  (0) 2023.07.12
    TSS  (0) 2023.07.06
Designed by Tistory.