• Wonhyuk Yang

[Training] Arm v8 Linux kernel head.S 찍어먹기 (4)

최종 수정일: 2021년 6월 19일


앞선 시간에서는 리눅스 커널 VM layout이 어떻게 배치되었는지에 대해 알아보았어요. 그리고 실제 배치된 물리 메모리와 가상 메모리가 다른 점을 해결하기 위해 변환 테이블(Translation Table)을 만들 필요가 있다고 했는데요. 그러면 이번 시간에는 본격적으로 head.S를 차근차근 분석해보도록 할게요.

해당 글의 타겟 아키텍처는 aarch64이고, kernel code는 5.1 버전을 다룹니다. 유의하세요!

Head.S

부트 로더에서 커널 이미지 헤더로 제어권을 넘겨준다고 했는데요. 다시 한번 커널 이미지 헤더를 살펴볼까요?

부트 로더에서 14번 라인의 인스트럭션으로 jump하는 것을 QEMU를 통해서 확인했었는데요, 해당 인스트럭션은 stext로 branch를 하는 인스트럭션이에요. 이전 글을 잘 따라오셨다면 한 가지 질문이 드실 수 있는데요. stext는 심볼이고, 가상 주소로 되어있을텐데 물리 주소를 사용하는 현재에도 사용이 가능한가?


이에 답하기 전에, 혹시 BL 인스트럭션의 인코딩 형식이 기억나시나요? 해당 인스트럭션은 PC-Relative한 addressing 방식을 사용했는데요 여기서도 마찬가지에요. B 인스트럭션의 인코딩은 아래와 같아요.

현재 프로그램 카운터를 기준으로 +/- 128MB를 지정할 수 있는데요, 이는 커널 이미지(약 20MB)를 포함하고도 충분해요. 즉, PC-Relative한 인스트럭션을 통해 현재 로딩된 물리 주소와는 상관 없이 동작할 수 있는 것이지요. 이제 stext로 이동해볼게요.

해당 코드들은 __INIT매크로를 통해 .init.text 섹션에 속해요. ENTRY(stext)라는 매크로가 보이시나요? 눈치빠르신 분들은 아실 것 같은데, 해당 매크로는 stext라는 글로벌 심볼을 정의하는 것이에요. 내부 구현은 아래와 같이 간단히 되어있어요.

name이라는 문자를 .global 디렉티브를 사용해 글로벌 심볼이라 알려주고, ALIGN해주고 해당 name으로 label을 만들도록 되어있네요. ASM_NL은 new line의 의미로 우리의 경우 세미 콜론(;)으로 바뀌게 되요.


이제 stext 내부에서는 바로 preserve_boot_args라는 함수(?)를 호출하게 되는데요, preserve_boot_args는 아래와 같이 되어있어요.

이 함수에서는 이름 그대로, boot args들을 저장하는 일을 수행해요. boot_args는 아래와 같이 정의되어 있는데 이곳에 x0, x1, x2, x3 레지스터들을 차곡차곡 저장하는 거에요.

이때 boot_args 심볼을 가져오기 위해 독특한 인스트럭션을 사용하는데요, 이 녀석도 PC-Relative하게 심볼을 가져오기 위해서 사용한 것이에요. 해당 인스트럭션은 다른 PC-Relative와는 조금 색다른 점이 있는데, 소프트웨어적으로 만든 Pseudo instruction이에요. arch/arm64/include/asm/assembler.h에 adr_l 매크로가 정의되어 있는데 아래와 같아요.

주석을 보면, Pseudo-ops를 사용하면 PC-Relative하게 +/- 4GB의 범위를 지정할 수 있다고 나와 있어요. 우선 adrp 인스트럭션을 아래 그림을 보면서 어떻게 동작하는지 알아볼까요?


조금 복잡한 동작이지요? 풀어서 설명을 해드리면 imm은 immhi와 immlow 이렇게 두 개로 나뉘는데 인코딩되어 있는데, 이것들을 이어붙여 아래와 같이 imm를 만들어요.

단, 아래 하위 12bit는 전부 0으로 채워져 있어요. 이제 이 상수 값을 하위 12비트를 0으로 만든 PC 값에 더해요. 그러면 얻은 주소는 당연하게 하위 12비트는 모두 0이에요. 즉, 정확도를 희생하고 더 넓은 범위를 PC-Relative하게 가져오는 것이지요.


이렇게 유실된 하위 12비트는, 심볼 자체의 하위 12비트에서 가져와요. 이 두 값을 더하는 것이 adr_l이 하는 일이지요. 정리하자면 +/- 4GB의 영역을 PC-Relative하게 가져오는 Pseudo op인 것이지요! 위 설명으로는 아직 부족할 것 같으니 실제 예제를 살펴보며 확인해볼까요?


제가 빌드한 커널 이미지의 preserve_boot_args의 위치와, boot_args의 위치는 아래와 같아요.

그리고 빌드된 커널 이미지에서 preserve_boot_args를 디스어셈블한 내용은 아래와 같아요.

이때 adrp 명령어의 binary 값은 0xD0025A0이에요. 이 값을 인코딩에 따라 뜯어보면,

  • imm_lo: 0x02

  • imm_hi: 0x012D

  • imm: 0x4B6000 = (imm_hi << 14) | (imm_lo << 12)

이고, 이는 0xffff000011586000 - 0xffff0000110d0000에 해당하는 값이에요. 이렇게 인코딩에 PC-Relative한 offset값이 저장되어 있는 것이에요. 이제 여기서 심볼의 하위 12를 더하면, adr_l의 동작 완료에요. 여기서는 boot_args가 4KB aligned되어 있어서 0을 더하게 된 것이에요. 만약 하위 12비트에 어떤 값이 있었다면 그 값을 더했겠지요?

예리하신 분들은 "가상 주소의 하위 12비트를, 물리 메모리를 사용하는 이 시점에 사용해도 되는 것이냐!" 하실 것 같은데요. 앞서 물리 주소는 전부 2MB aligned 된 것을 주소를 베이스로 하는 것을 확인했고, 가상 주소도 2MB aligned된 것을 확인할 수 있어요 (KASAN, BFP, MODULE 영역의 크기를 더하면 나오겠지요?). 서로 공통적으로 최소한 4KB aligned 되어 있으므로 가상 주소나 물리 주소나 하위 12비트는 동일한 값을 가질 거에요.


이제 다시 preserve_boot_args 코드 블록으로 돌아갈게요. 이제 정말 어려운 부분이 나오는데, 바로 캐시에 관련된 내용이에요. 흐름을 얘기해드리면, 앞서서 store을 진행했으므로 해당 주소에 대한 cache를 invalidate하는 것이에요. 그러면 dmb sys는 무엇이고 왜 사용하는 것이냐? 물으실 것 같은데 이 부분에 설명을 하려면 Memory consistency orderCache coherence에 대한 기초부터 설명을 해야해요. 그래서 이 부분은 잠깐 덮어두고 나중에 캐시에 대해 제대로 살펴보도록 해요.


그러면 이제 boot_args는 저장했으니 el2_setup 함수로 진입할텐데요. 코드는 아래와 같아요.

우선 SPsel 레지스터에 1을 저장하는데요, 이 말은 "exception level마다 고유의 SP를 사용한다"와 같아요.

그리고 현재 Exception level을 가져와서 Exception Level 2인지를 확인하는데요, 해당 포스트는 hypervisor 기능을 분석하는게 주 목적이 아니므로 아쉬움을 뒤로하고 EL1이라 가정하고 진행할게요. 그러면 SCTLR_EL1에 엔디안에 관련된 세팅을 하고, 리턴 값(w0)으로 BOOT_CPU_MODE_EL1을 반환해요.

SCTLR_EL 레지스터에 대한 구체적인 설명은 생략했어요. 궁금하신 분은 Reference manual을 통해 확인해보세요.

이제 이렇게 반환된 부팅 모드를 저장하는 함수가 set_cpu_boot_mode_flag이에요. 아래에 long형 배열인 __boot_cpu_mode[2]의 주소를 가져오고, 가져온 부팅 모드 값이 BOOT_CPU_MODE_EL1이면 __boot_cpu_mode[0]에 저장하고, BOOT_CPU_MODE_EL2이면 __boot_cpu_mode[1]의 값을 저장하게 되는거지요. 물론, 메모리에 store 했으니 해당 주소에 대한 cache invalidate도 함께 이루어져요. 코드는 아래와 같아요.

핵심적인 부분은 아니므로 자세히 들여다 보지 않고 이 정도로 넘어갈게요.


set_cpu_boot_mode_flag 함수가 호출되기 전에 x23 레지스터에 __PHYS_OFFSET를 가져오고 2MB로 round down을 하는데요. 계산을 해보면 __PHYS_OFFSET은 KIMAGE_VADDR과 동일한 값인데, 이 값을 PC-Relative하게 가져오면 커널 이미지의 물리 베이스 주소가 나오겠지요? 그래서 x23에는 2MB aligned된 커널이미지의 물리 베이스 주소가 담기게 됩니다. 이 값을 유지한 채로 이제 본격적으로 (임시) Translation Table을 생성해요.


Translation Table

Translation Table을 만들기 위해서는, 페이지 테이블로 사용할 메모리가 필요해요. 하지만 현재는 부팅 초기 단계에요, 즉 현재 커널은 하드웨어에 대한 정보를 전혀가지고 있지 않아요. 물론, 사용가능한 메모리가 어디에, 얼마나 있는지도 모르지요. 그래서 Linker script를 통해 init Page Table로 사용할 영역을 잡아놓아요. 아래처럼 Linker script에서 공간을 만들어줘요.

init_pg_dir 심볼과 init_pg_end 심볼 사이에 필요한 만큼 공간을 배치해서, 해당 영역을 사용하여 Init Page Table을 만들게 되는 것이지요~ 그러면 Translation Table을 만드는데 필요한 크기(INIT_DIR_SIZE)는 어떻게 계산될 지 예상이 되시나요? Arm v8 architecture의 Paging을 잘 공부하셨다면 충분히 계산하실 수 있다고 믿고 있어요. 그럼 커널이 어떻게 구현했는지 같이 확인해볼까요?

arch/arm64/include/asm/kernel-pgtable.h에서 위와 같이 정의되어 있어요. 제일 아래에 보면 INIT_DIR_SIZE가 여러 다른 매크로 조합으로 정의 되어있는 것을 볼 수 있어요. PAGE_SIZEEARLY_PAGES의 곱으로 정의되어 있는데, PAGE_SIZE는 .config에 따라 결정되는 값이에요. 그리고 EARLY_PAGES는 필요한 페이지 수를 계산하는 매크로인데 인자로 가상 주소의 범위를 받아요.

페이지 수를 계산하는 방식은 생각하면 간단한데, 기본 아이디어는 "이전 단계 페이지 테이블 엔트리의 수만큼 다음 단계에 필요하다" 에요. 아래 그림처럼 Level K에서 3개의 entry가 사용되면 Level K+1에서 3개의 페이지를 사용하는 것이지요.

이런 식으로, EARLY_PAGES는 각각의 레벨에서 필요한 페이지들을 EARLY_ENTRIES라는 매크로를 통해 계산해요. 해당 매크로는 어렵지 않으니 설명을 생략할게요. 위 코드 스니펫을 잘 살펴보시면 SWAPPER_PGTABLE_LEVEL에 따라 다른 매크로를 구성하게 되어있는데요, 이 녀석도 .config에 따라 결정이 되어요. 하지만 하나 이상의 CONFIG와 연관이 되어 있어요.

우선 CONFIG_ARM64_4K_PAGES에 따라 section mapping을 적용할지 말지 결정을 하는데요, 그 이유는 위에 주석에 잘 설명이 되어있어요. booting.txt을 살펴볼 때, 부트로더가 2MB aligned된 물리 메모리에 베이스 주소를 잡는다고 했었는데요, 그렇기 때문에 section mapping을 적용할 수 있는 것이지요. 하지만 다른 페이지 사이즈를 사용하면 section mapping을 적용시킬 수가 없어요.

왜 section mapping이 되는지 안되는지 이해가 안되신다면, Reference manual에서 section mapping을 키워드로 다시 공부하셔야 해요.

이제 Section mapping을 적용하면 Page translation level은 한 단계가 줄어드므로, config에서 정한 CONFIG_PGTABLE_LEVEL에서 1만큼을 빼는 거에요. Section mapping을 하지 않으면 임시 Translation table의 레벨은 config에서 정한 값을 사용해요.


이제, Linker script를 통해 잡은 init page table 영역을 활용하여 Translation Table을 구성하는 과정에 대해 알아볼게요.

__create_page_tables 함수로 진입하면 아래와 같이 init_pg_dir 영역을 invalidate하고 초기화하는 과정을 살펴볼 수 있어요.

여기서 갑자기 왜 init page table 영역을 invalidate하고 0으로 초기화하지? 커널 이미지에 이미 0으로 초기화되어 포함되어 있지 않나? 라고 생각하실 수 있을 것 같아요. 위에서는 init page table이 커널 이미지에 포함된 것처럼 설명했지만 사실은 커널 이미지 파일(Image)에 포함되어 있지 않아요! 이미지 파일을 직접 뜯어보며 살펴볼까요?


vmlinux 파일을 readelf를 통해 bss 섹션 정보를 읽으면 아래와 같이 나와요.

bss 영역의 가상 주소는 0xffff00001172b000이고 크기는 0x6eca8이에요, 또한 해당 섹션이 위치한 파일 offset은 0x16baa00이네요. vmlinux를 hex editor로 열어서 0x16ba00으로 이동해볼까요?

0으로 가득 차 있을 줄 알았는데 ascii 문자가 가득하네요? 이게 어떻게 된 걸까요? 사실 bss 섹션은 헤더에만 위치와 크기가 존재할 뿐, 커널 이미지에 직접 포함되지 않아요. 그래서 뒤 따르는 comment 섹션의 시작 주소가 bss 섹션의 시작 주소와 동일한 0x16baa00인 것이지요. 이와 마찬가지로 init page table은 bss 섹션 뒤에 배치는데, 실제 커널 이미지에는 포함되어 있지 않아요.


결국, bootloader가 복사하는 커널 이미지에는 init page table이 포함되어 있지 않아요. 그러면 운이 나쁘다면 메모리에 어떤 값이 쓰여져 있을 수 있기 때문에 인위적으로 0으로 초기화하는 과정이 필요한 것이지요.


Series



조회수 325회댓글 0개

관련 게시물

전체 보기