-
[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
...
...
intlapicid(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