ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [xv6] Application Processor
    프로젝트/운영체제 만들기 2023. 7. 23. 00:30

    글의 참고

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

    - MultiProcessor Specification 1.4

    - 64-ia-32-architectures-software-developer-vol-3a-part-1-manual.pdf [Order Number: 253668-060US]

    - xv6 - DRAFT as of September 4, 2018


    글의 전제

    - 밑줄로 작성된 글은 강조 표시를 의미한다.

    - 그림 출처는 항시 그림 아래에 표시했다.


    글의 내용

    - Application Processor 코드 분석

    : `startothers` 함수는 AP`s가 아닌, BSP에 의해서만 호출되는 함수다. 그거도 딱 한 번만 호출된다. 이 함수는 BSP가 모든 AP`s 들을 깨우는 함수다(BSP는 제일 먼저 부트-업 되지만, AP`s 들을 깨우느라 결국 제일 마지막에 스케줄링(schduler()) 된다).

    // Start the non-boot (AP) processors.
    static void startothers(void)
    {
      extern uchar _binary_entryother_start[], _binary_entryother_size[];
      uchar *code;
      struct cpu *c;
      char *stack;

      // Write entry code to unused memory at 0x7000.
      // The linker has placed the image of entryother.S in
      // _binary_entryother_start.
      code = P2V(0x7000);
      memmove(code, _binary_entryother_start, (uint)_binary_entryother_size);

      for(c = cpus; c < cpus+ncpu; c++){
        if(c == mycpu())  // We've started already.
          continue;

        // Tell entryother.S what stack to use, where to enter, and what
        // pgdir to use. We cannot use kpgdir yet, because the AP processor
        // is running in low  memory, so we use entrypgdir for the APs too.
        stack = kalloc();
        *(void**)(code-4) = stack + KSTACKSIZE;
        *(void(**)(void))(code-8) = mpenter;
        *(int**)(code-12) = (void *) V2P(entrypgdir);

        lapicstartap(c->apicid, V2P(code));

        // wait for cpu to finish mpmain()
        while(c->started == 0)
          ;
      }
    }

    : `startothers` 함수에서 제일 처음 코드는 AP`s 들의 엔트리 포인트를 설정하는 코드다. AP`s 들의 엔트리 포인트는 `entryother.S` 파일이다. 이걸 어떻게 알 수 있을까? `_binary_entryother_start`, `_binary_entryother_size` 변수를 통해서 AP`s 들의 엔트리 포인트 파일이름을 알 수 있다. `xv6` 는 링커와 OBJCOPY에서 제공하는 기본적인 심볼을 이용한다( `_binary_XXXX_start`, `_binary_XXXX_size`). 이 내용은 이 글을 참고하자.

     

    : BSP가 AP`s 에게 전달하는 인자들은 조금 뒤에 알아본다.

     

    : `startothers` 함수의 루프의 조건을 보자. `ncpu` 변수는 `mpinit` 함수에서 MP 자료 구조를 파싱할 때, 현재 시스템에 몇 개의 프로세서가 존재하는지를 나타낸다. `c = cpus; c < cpus+ncpu; c++` 를 통해서, 시스템에 존재하는 모든 CPU를 순차적으로 탐색해서 `mpenter` 함수를 호출하게 된다. 여기서, `cpus` 변수를 순차적으로 탐색한다는 부분이 중요하다. 그러므로, BSP부터 순차적으로 탐색하게 된다. 왜 BSP 부터 탐색하는걸까? 뒤에 설명한다.

     

    : 이 부분은 번외편이다. 사실 Local APIC ID가 0이라고 해서 당연히 BSP 일 필요는 없고, 심지어 ID값이 연속적일 필요도 없다고 한다. 그러나, `MultiProcessor Specification 1.4` 스펙에서는 일반적으로 Local APIC ID를 할당할 때, 0부터 시작에서 점점 값이 커진다고 명시되어 있다. 대개 ID가 0이면, 이게 BSP다. 그러나 BSP와 AP를 명확히 구분하고 싶다면, `IA32_APIC_BASE_MSR` 레지스터를 통해서 해당 프로세서가 BSP인지 AP인지 확인할 수 있다.

     

    : 그런데 궁금한 부분이 있다. BSP도 루프문안으로 들어가서 `mpenter` 함수를 호출할까? BSP는 `mpenter` 함수를 호출하지 않는다. 그 이유는 조건문 `if(c == mycpu())`에 충족되기 때문이다. `mycpu` 함수를 잠깐 살펴보자.

    // proc.c
    ...
    ...
    // Must be called with interrupts disabled to avoid the caller being
    // rescheduled between reading lapicid and running through the loop.
    struct cpu* mycpu(void)
    {
      int apicid, i;
      
      if(readeflags()&FL_IF)
        panic("mycpu called with interrupts enabled\n");
      
      apicid = lapicid();
      // APIC IDs are not guaranteed to be contiguous. Maybe we should have
      // a reverse map, or reserve a register to store &cpus[i].
      for (i = 0; i < ncpu; ++i) {
        if (cpus[i].apicid == apicid)
          return &cpus[i];
      }
      panic("unknown apicid\n");
    }
    ...
    ...

    : `mycpu`는 Local APIC ID에 의존한다. 즉, 프로세서를 구분하는 값이 Local APIC ID가 된다는 것이다.

     

    // lapic.c
    ...
    ...

    int
    lapicid(void)

    {
      if (!lapic)
        return 0;
      return lapic[ID] >> 24;
    }

    : `lapic`는 전역 변수다. 그래서, 모든 프로세서에 공유하는 변수다. `lapic`에는 Local APIC의 메모리-맵 주소가 들어있다. 그래서 각 프로세서는 자신의 Local APIC의 레지스터에 액세스하고 싶다면, 여기에 접근해야만 한다. 재미있는 건 Local APIC ID 레지스터는 각 프로세서가 동일한 주소로 접근하지만, 다른 값을 주게된다.이게 무슨 말이냐면, Local APIC의 메모리-맵 주소는 대개 `0xFEE0_0000`이다. 모든 프로세서는 이 주소에 접근해서 자신의 Local APIC에 액세스한다. 그런데, 이상하지 않나? 모든 프로세서가 동일한 주소에 접근하는데, 다른 값을 준다. 즉, `1 프로세서`가 Local APIC ID 레지스터(0xFEE0_0020)에 읽었는데 `1`을 주고, `2 프로세서` 가 Local APIC ID 레지스터(0xFEE0_0020)에 읽었는데 `2`을 준 셈인거다. 이러한 부분은 SW 개발자에게는 보이지 않는 영역(HIDDEN, INVISABLE)이라서 그냥 그렇구나 하고 넘어갈 수 있는 용기가 좀 필요하다.

     

     : 인텔 제온 이상에서는 Local APIC 관련해서 몇 가지 모드가 나뉘는데, `xAPIC 모드`에서 MSB 8비트에 Local APIC ID가 할당되어 있다. `x2APIC 모드` 라면, 32비트를 모두 읽어야 한다(참고로, Local APIC ID 레지스터의 길이는 32비트다). `xv6`에서는 `xAPIC` 모드인 것 같다.

     

    : 그리고 원래 `cpus[i].apicid` 값은 `mpinit` 함수에서 `MP Configuration Table`을 파싱하는 과정에서 할당하는 값이다. 프로세서 엔트리에 Local APIC ID 라는 필드가 존재하는데, 이 2개의 값이 동일한가에 대한 직접적인 내용이 없다. 그런데, `MultiProcessor Specification 1.4` 스펙에서 `A.4 Constructing the MP Configuration Table` 내용을 보면 아래와 같은 내용이 있다.

    Next, the BSP enables each AP in turn by setting its AP status flag. Each AP follows these steps:
    1. It executes a CPU identification procedure, reads its local APIC ID from the Local Unit ID Register, and uses this information to complete its entry in the MP configuration table.
    ....

    - 참고 : MultiProcessor Specification 1.4

    : 위의 내용은 `MP Configuration Table` 을 직접 구성할 때, 고려해야 할 사항들을 명시한 것이다. 그런데, 프로세서 엔트리를 만들 때, `Local APIC ID` 레지스터의 내용을 사용하라고 되어있다. 즉, 이 내용을 통해 BIOS가 `MP Configuration Table` 를 만들 때, Local APIC ID 레지스터로 프로세서 엔트리를 만든다는 것을 알 수 있다.

     

    : 결론적으로, `mycpu()` 를 통해 반환되는 프로세서는 현재 Local APIC ID를 기준으로 반환되는 프로세서임을 나타낸다. `startothers` 함수 루프문안에서 최초로 반환되는 프로세서는 누굴까? 당연히 BSP다. 왤까? 최초 루프를 실행하는 시점에는 BSP만 활성화 되어 있기 때문이다. 그래서 c는 BSP가 되고, `mycpu` 함수에서 반환되는 Local APIC ID 또한 BSP의 Local APIC ID 이므로, 조건문이 참이 되어 다시 루프를 돌게 된다.

     

    : 두 번째 루프를 어떻게 될까? `mycpu` 함수는 여전히 BSP의 Local APIC ID를 반환한다. 왜냐면, `mycpu` 함수는 현재 동작하는 프로세서의 Local APIC ID를 반환하기 때문이다. 그런데, c는 시스템에 존재하는 두 번째 프로세서다. 그러므로, 당연히 조건문은 거짓이 되어 그 다음 코드들을 실행한다. 즉, 실제 `startothers` 함수를 실행하고 있는 프로세서는 BSP 이기 때문에, `mycpu` 함수에서 반환되는 프로세서는 무조건 BSP 일 수 밖에 없다. 그런데, `c`의 값은 `cpus` 변수의 2번째 요소인 AP이기 때문에 `if(c == mycpu())` 를 충족시키지 못해 다음 코드를 실행하게 되는 것이다. 이제 BSP가 AP`s 에게 전달하는 데이터들에 대해 알아보자.

     

    : BSP는 AP들이 초기화를 매끈하게 진행할 수 있게 3 가지 정보를 전달한다.

    0" `보호 모드 스택 주소` : AP`s 들이 사용할 스택 주소를 전달해준다. BSP가 AP`s 들의 스택을 결정해주는 이유는 2가지다.
    0.1" 시스템에서 메모리는 하나만 존재하므로, 겹치지 않게 할당해야 한다. 그 역할을 BSP가 한다. 
    0.2" 보호 모드 초기에 `페이징` 문제가 있다. AP`s 들은 활성화되는 시점에 리얼 모드에서 시작해서 빠르게 보호 모드로 전환한 뒤, 곧바로 페이징을 활성화한다. 그런데, AP`s 들이 페이징을 활성화한 시점에는 가상 주소 [0x000000:0x400000], [0x8000_0000:0x8040:0000] 만 할당받을 수 있다. 그런데, BSP 또한 `startothers`를 호출하는 시점에 사용 가능한 가상 주소는 [0x000000:0x400000], [0x8000_0000:0x8040:0000] 이다. 그래서 BSP에서 사용가능한 영역들은 AP`s 들에서도 사용이 가능하다. 그래서 BSP에서 스택 주소를 할당받아서, AP`s 들에게 전달하는 것이다. 만약, BSP가 할당 받은 가상 주소의 범위가 AP와 다르면 어떻게 될까? 예를 들어, BSP가 사용 가능한 가상 주소가 [0x000000:0x400000], [0x8000_0000:0x80C0:0000] 라고 하자. 그런데, BSP가 메모리 주소를 할당 받을 때, 본인이 직접 주소를 찾아서 주는게 아니라 `kalloc` 함수를 통해 페이지 사이즈(4KB)의 메모리를 할당받는다. 그런데, 할당받은 주소가 `0x80A0_A000`라고 치자. 이 값을 AP에 전달하면 어떻게 될까? 즉각적으로 #PF 가 발생한다. 

    " 이 시점이 되면, `kinit2` 함수가 `kinit1`과 나눠진 이유와 `main` 함수에서 상당히 뒷쪽에 호출되는 이유에 대해 설명할 수 있다.

    `kinit2` 함수를 `kinit1`과 별개로 나눈 이유는 여러 가지 이유가 있을 수 있겠지만, 바로 이 AP`s 들의 페이징 문제도 포함될 것 이다
    . 만약, `kinit2` 함수가 `startothers` 보다 먼저 호출되었다면 위에서 보여준 예시처럼, BSP와 AP`s 들의 사용 가능한 가상 주소 영역이 달랐을 것이다. 이렇게 되면 BSP에서 AP`s 에게 스택 주소를 할당해주지 못하게 된다. 혹시나 해주더라도, AP`s 에서 사용 불가능한 영역이여서 #PF 가 발생할 수 도 있다.

    1" 보호 모드 진입 시, `AP`s 들의 엔트리 포인트 주소` : AP`s 의 리얼 모드 엔트리 포인트 주소는 `0x7000`이다. 이 내용은 조금있다 다시 알아본다.

    2" `페이지 테이블 주소` : AP`s 들 또한 보호 모드로 진입하고 페이징을 활성화된다. `xv6`는 Higher-Half 커널이기 때문에  0x8000_0000의 첫 번째 페이지에 아이텐티티 매핑을 해야 한다. 이 내용을 잘 모른다면, 이 글을 참고하자. 

     

    : 이 값들을 전달하는 주소가 재미있다. AP`s 들의 리얼 모드 엔트리 포인트가 0x7000인데, 이 주소를 기준으로 4바이트 `down-grade` 방식으로 전달할 데이터를 삽입한다. 즉, 스택과 같이 0x7000을 기준으로 아래에 데이터가 저장된다. `entryother.s`의 내용은 다음과 같다.

    #include "asm.h"
    #include "memlayout.h"
    #include "mmu.h"

    # Each non-boot CPU ("AP") is started up in response to a STARTUP
    # IPI from the boot CPU.  Section B.4.2 of the Multi-Processor
    # Specification says that the AP will start in real mode with CS:IP
    # set to XY00:0000, where XY is an 8-bit value sent with the
    # STARTUP. Thus this code must start at a 4096-byte boundary.
    #
    # Because this code sets DS to zero, it must sit
    # at an address in the low 2^16 bytes.
    #
    # Startothers (in main.c) sends the STARTUPs one at a time.
    # It copies this code (start) at 0x7000.  It puts the address of
    # a newly allocated per-core stack in start-4,the address of the
    # place to jump to (mpenter) in start-8, and the physical address
    # of entrypgdir in start-12.
    #
    # This code combines elements of bootasm.S and entry.S.

    .code16           
    .globl start
    start:
      cli            

      # Zero data segment registers DS, ES, and SS.
      xorw    %ax,%ax
      movw    %ax,%ds
      movw    %ax,%es
      movw    %ax,%ss

      # Switch from real to protected mode.  Use a bootstrap GDT that makes
      # virtual addresses map directly to physical addresses so that the
      # effective memory map doesn't change during the transition.
      lgdt    gdtdesc
      movl    %cr0, %eax
      orl     $CR0_PE, %eax
      movl    %eax, %cr0

      # Complete the transition to 32-bit protected mode by using a long jmp
      # to reload %cs and %eip.  The segment descriptors are set up with no
      # translation, so that the mapping is still the identity mapping.
      ljmpl    $(SEG_KCODE<<3), $(start32)

    //PAGEBREAK!
    .code32  # Tell assembler to generate 32-bit code now.
    start32:
      # Set up the protected-mode data segment registers
      movw    $(SEG_KDATA<<3), %ax    # Our data segment selector
      movw    %ax, %ds                # -> DS: Data Segment
      movw    %ax, %es                # -> ES: Extra Segment
      movw    %ax, %ss                # -> SS: Stack Segment
      movw    $0, %ax                 # Zero segments not ready for use
      movw    %ax, %fs                # -> FS
      movw    %ax, %gs                # -> GS

      # Turn on page size extension for 4Mbyte pages
      movl    %cr4, %eax
      orl     $(CR4_PSE), %eax
      movl    %eax, %cr4
      # Use entrypgdir as our initial page table
      movl    (start-12), %eax
      movl    %eax, %cr3
      # Turn on paging.
      movl    %cr0, %eax
      orl     $(CR0_PE|CR0_PG|CR0_WP), %eax
      movl    %eax, %cr0

      # Switch to the stack allocated by startothers()
      movl    (start-4), %esp
      # Call mpenter()
      call  *(start-8)

      movw    $0x8a00, %ax
      movw    %ax, %dx
      outw    %ax, %dx
      movw    $0x8ae0, %ax
      outw    %ax, %dx
    spin:
      jmp     spin

    .p2align 2
    gdt:
      SEG_NULLASM
      SEG_ASM(STA_X|STA_R, 0, 0xffffffff)
      SEG_ASM(STA_W, 0, 0xffffffff)


    gdtdesc:
      .word   (gdtdesc - gdt - 1)
      .long   gdt

    : 위에서 얘기한 3개의 파라미터들이 `start(0x7000)` 심볼을 기준으로 4바이트 경계로 계산된다. GDT를 새로 만들어서 로딩하는 것을 보면, 각 프로세서마다 GDT 또한 별도로 존재한다는 것을 알 수 있다. 사실, GDT는 모든 프로세서가 공유가 가능하다. 그러나, 프로세서마다 별도의 `GDTR`은 가지고 있다. 그래서, 각 CPU는 보호 모드 진입 시, 반드시 자신의 GDTR에 GDT가 동일하더라도 로드해야 한다.

     

    : 각 프로세서가 `GDT`를 별도로 갖는게 아닌, `GDTR`을 갖는 것은 설계상 여러 가지 이점이 있어보인다. 결국, `간접 참조`를 한다는 것인데, 이런 부분은 내가 개발을 할 때 구조적인 부분에 대해소 좋은 영감을 준다.

     

    : `entrypgdir` 을 보호 모드 진입 초기 시점에, 모든 프로세서가 사용하는 이유는 `아이텐티티 매핑` 때문이고, 모든 프로세스가 `entrypgdir` 공유할 수 있는 이유는 이 변수가 `READ-ONLY` 형태로 사용되기 때문이다. 이 변수는 초기에만 사용되고 `switchkvm` 함수가 호출되는 시점에는 더 이상 사용되지 않는다.

    // Other CPUs jump here from entryother.S.
    static void mpenter(void)
    {
      switchkvm();
      seginit();
      lapicinit();
      mpmain();
    }

    // Common CPU setup code.
    static void mpmain(void)
    {
      cprintf("cpu%d: starting %d\n", cpuid(), cpuid());
      idtinit();       // load idt register
      xchg(&(mycpu()->started), 1); // tell startothers() we're up
      scheduler();     // start running processes
    }

    : `mpenter` 함수는 보호 모드에서 모든 AP`s 들의  엔트리 포인트 함수다. AP`s 들만 `mpenter` 함수를 호출한다. 그리고 AP`s 들은 하드웨어 초기화하지 않는다. 왜냐면, 시스템에 존재하는 대 부분의 하드웨어들은 1개만 존재하는 경우가 대 다수이고 이미 BIOS와 BSP에 의해서 부트-업 시점에 초기화가 완료되기 때문이다. 즉, AP`s 들에 의해서 다시 초기화가 될 필요가 없다. 대신, AP`s 들은 자신만의 페이지 테이블, GDT, Local APIC 가 존재하므로, 해당 자료 구조들을 자신에 설정에 맞게 초기화한다. 그리고, 마지막으로 `mpmain` 함수를 호출한다.

     

    : `mpmain` 함수는 모든 프로세서(BSP & AP)가 호출하는 함수다. 모든 프로세서들이 IDT를 초기화하는 것을 보면 각 프로세서마다 별도로 존재하는 것을 알 수 있다. 그리고 중요한 코드가 `xchg(&(mycpu()->started), 1)` 코드다.

     

    : 우리는 `startothers`에서 BSP가 `while(c->started == 0)` 때문에 무한 루프에 빠지는 것을 보았다. 특정 AP 가 `mpmain`에서 `xchg(&(mycpu()->started), 1)` 코드를 실행하면, BSP는 루프문에서 빠져나와 나머지 AP`s 들을 또 다시 활성화 시킨다.

     

    : 그런데, BSP가 실행하는 코드 `while(c->started == 0)`와 AP가 실행하는 코드 `xchg(&(mycpu()->started), 1)` 에서 `started` 값이 같다는 걸 어떻게 보장할까? `lapicstartap(c->apicid, V2P(code))` 함수를 통해서 해당 내용을 보장한다(이 함수의 내용은 이 글을 참고하자). 이 함수를 보면, 첫 번째 인자로 `c->apicid`를 전달한다. 즉, 저 Local APIC ID를 가진 AP를 깨운다는 의미다. 예를 들어, BSP가 실행하는 코드 `while(c->started == 0)`의 `c->started`가 2라고 하면, `lapicstartap(c->apicid, V2P(code))`를 통해 활성화되는 AP의 Local APIC ID 또한 2가 된다. 그렇다면, 이 AP는 결국 `mpmain` 함수를 실행하게 될 것이고, `xchg(&(mycpu()->started), 1)` 에서 `mycpu` 함수가 반환하는 Local APIC ID는 2가 될 것이다.

     

    : 최종적으로 BSP는 모든 AP`s 들이 `mpmain` 함수 호출을 완료한 것을 인지하면, 그 다음 함수인 `kinit2` 함수를 호출하게 된다.

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

    [xv6] Sleeplock  (0) 2023.07.23
    [멀티 프로세서] I/O APIC  (0) 2023.07.23
    CMOS  (0) 2023.07.22
    [xv6] Local APIC  (0) 2023.07.21
    [컴퓨터 구조] Local APIC  (0) 2023.07.19
Designed by Tistory.