• Wonhyuk Yang

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

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


지난 시간에는 MMU를 활성화하기 전에 필요한 여러가지 시스템 레지스터들을 세팅해줬어요. 이제 MMU를 활성화시키고 start_kernel 함수로 진입할 때가 되었네요. 자 그럼 힘차게 살펴보도록 할게요.

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

__primary_switch

__cpu_setup 함수 다음에는 __primary_switch 함수가 호출되어요. 해당 함수는 RELOCATBLE 관련 옵션이 꺼져있다면 굉장히 단순한데요, 아래의 코드에서 해당 함수가 어떻게 구성되었지 확인해볼까요?

  • 라인 2~3에서 x1 레지스터에 init_pg_dir의 주소를 저장해요. 그리고 __enable_mmu 함수를 호출해요. 이 함수에서 mmu가 활성화돼요.

  • 라인 4~6에서는 __primary_switched로 pc를 이동하려고 branch하는 내용이 있어요. 여기서 주의깊게 볼 것은 ldr 인스트럭션이에요.

ldr에 독특하게도 심볼 옆에 "=" 기호가 있는데요, 이것은 일종의 pseudo-instruction이에요. 위 코드에서 해당 인스트럭션은 x8 레지스터에 64비트 심볼인 __primary_switched를 대입해요. 앞서 PC-relative addressing을 숙지하셨다면 이게 어떻게 가능한지 궁금하시겠지요? 해당 인스트럭션은 literal pool을 생성해서 레지스터에 값을 할당하게 되는 것이에요. 실제로 컴파일된 파일을 살펴봐볼가요?


위의 내용은 vmlinux의 __primary_switch를 dump한 내용이에요. 보시면 ldr 인스트럭션이 코드와는 조금 달라진게 보이시나요? 목표 레지스터는 x8로 동일하지만 읽어오는 위치가 __primary_switch + 0x20으로 변경되었어요. 그리고 그 위치에 저장된 8바이트는 0xffff000011100324의 값이 들어있어요! 이 값은 물론 __primary_switched의 가상 주소와 동일하답니다~ 이제 감이 오시나요? 64비트 심볼을 레지스터에 불러들이기 위해서 임의로 literal pool을 생성한 것이에요. 그리고 ldr은 literal pool에서 값을 불러들이게 되는 것이지요!

literal pool에 대한 자세한 설명은 이 링크를 참고하세요

결과적으로 branch를 하는 주소는 __primary_switched의 가상 주소이고 이제부터는 가상 주소를 사용하여 코드가 실행되는 것이지요! 자 이제 __enable_mmu를 살펴보도록 할게요.


__enable_mmu

아래의 코드는 52비트 옵션이 비활성화되었다고 가정하고 불필요한 코드를 생략했어요.

  • Line 2~6은 CONFIG로 설정한 Granule size를 CPU에서 지원하는지 확인하고, boot_status를 업데이트해줘요. 이 부분은 그렇게 중요하지 않으니 이 정도로 넘어가도록 할게요.

  • Line 8~11에서는 init_pg_dir과 idmap_pg_dir을 각각 ttbr0과 ttbr1에 대입하는 부분이에요. ttbr 레지스터의 구성은 앞서 asid를 살펴보며 봤었는데 다시 한번 보도록 살펴볼게요.

위 그림은 TTBR0 레지스터의 구성인데 TTBR1도 이와 동일해요. 위에서 해주는 작업은 BADDR 필드를 채워넣는 작업인 것이지요!

CnP는 FEAT_TTCNP 기능이 구현되었을 때만 의미를 가지고, 구현되지 않았다면 0으로 예약된 곳이에요.
  • Line 12에서는 __cpu_setup에서 x0에 로드한 값을 SCTLR_EL1에 대입해요. 이 인스트럭션으로 인해 MMU가 활성화되어요. 그럼 이제 SCTLR에 대해 알아보도록 할게요!

이 레지스터도 상당히 많은 정보들을 담고 있어요. 실제로 값을 세팅하는 부분을 다 살피진 않을 것이고 몇 가지 포인트만 보도록 할게요. 우리가 살펴볼 필드는 노란색으로 칠해두었어요.


M, bit[0]

EL0과 EL1에서 MMU를 통해 translation을 활성화/비활성화를 결정하는 필드에요.


C, bit[2]

EL0과 EL1에서 데이터 캐시을 활성화/비활성화를 결정하는 필드에요.


I, bit[2]

EL0과 EL1에서 인스트럭션 캐시을 활성화/비활성화를 결정하는 필드에요.


커널에서는 위 필드들을 전부 1로 설정하여 Data/Instruction 캐시를 활성화하고 MMU도 활성화시켜요. 해당 레지스터에 값이 쓰이고 나서 다음 인스트럭션을 실행하도록 instruction barrier를 두어요. 그리고 instruction 캐시를 invalidate하고 있어요. 왜 이런 barrier들이 필요한지는 Cache와 Out of order에 대해 다룰 때 자세히 알아보고 지금은 이 정도로 넘어가도록 할게요.


그런데, 뭔가 이상한 점을 느끼지 못하셨나요? 우리가 SCTLR_EL1 레지스터의 MMU 필드를 1로 설정하고도 자연스럽게 다음 인스트럭션을 실행하는데요. 디버거로 상황을 유심히 살펴보도록 할게요.

위에서는 MMU를 활성화하는 인스트럭션에 멈춰있는 상태에요. 그리고 해당 인스트럭션 이후(정확히는 배리어 뒤부터)부터는 MMU가 활성화되겠지요? 그러면 PC는 어떨까요? 변화가 있을까요? PC는 다른 인스트럭션을 수행한 것처럼 다음 인스트럭션으로 이동하고 아래 그림처럼 0x40b7c280이란 주소를 가리키게 될 것이에요.

그렇다면 위 상황에서는 MMU가 활성화된 상태에서도 여전히 물리 주소 그대로 접근해서 인스트럭션 패치가 일어나겠지요. 그러면 "Page fault가 발생하는 것 아니냐?" 물으실 수 있을 것 같은데, 해당 물리 주소 영역은 identity mapping을 통해 매핑이 되어 있어요! 앞서 두 개의 매핑 테이블을 만들었는데 바로 이 시점에 사용되는 것이지요


System.map을 살펴보면 아래처럼 identity mapping한 범위[__idmap_text_start, __idmap_text_end]에 __enable_mmu와 __primary_switch가 속해있는 것을 확인할 수 있어요.

그렇기 때문에 primary_switch 마지막 부분에서 PC를 가상 주소로 옮기기 전에도 계속해서 물리 주소를 사용하며 프로그램이 동작할 수 있는 것이지요. 자 이제 마지막 함수인 __primary_switched로 가볼까요?


__primary_switched

자, 우리는 이제 MMU를 활성화시켰는데요, 그러면 남은 일들은 무엇일까요? C로 작성된 함수들을 실행할 수 있게 스택을 구성해야 하고요, init_pg 영역을 0으로 초기화한 것처럼 BSS 섹션을 0으로 초기화해야 해요. 복잡한 일을 하지 않기에 코드도 어렵지 않아요 RELOCATE 옵션이 꺼져있다고 가정하면 코드는 아래와 같을 거에요.

Line 2~3에서는 스택을 구성하는 부분이에요. init_thread_union은 thread_union 타입의 union으로 그 구성은 아래와 같이 되어 있어요.

이 녀석도 본체는 존재하지 않고 링커 스크립터를 통해 심볼에 값을 할당해요. 아무튼, 이 유니온은 여러 용도로 사용하는데 여기서는 스택으로 사용해요. 스택이 주소가 감소하는 방향으로 자라므로, THREAD_SIZE만큼 더해서 sp에 저장해요.


다시 __primary_switched로 돌아가서, Line 4~5에서는 init_task의 주소를 sp_el0에 저장해둬요.


Line 7~8에서는 벡터 테이블을 vbar_el1에 저장해요. 벡터 테이블만 세팅했을 뿐 실제 인터럽트를 위한 처리는 start_kernel 내부에서 다뤄져요.


Line 11~12는 C의 함수처럼 스택 프레임을 구성하는 과정이에요. 이를 통해 다른 함수를 호출하고 나서도 스택 프레임을 복원할 수 있어요. 즉, 만약 25번 라인의 __pi_memset 함수를 호출하고, 리턴해도 스택에 fp(x29)와 lr(x30)을 저장했기 때문에 값을 복원할 수 있는 것이지요! 간단한 함수를 디스어셈블해도 위와 같은 구조를 살펴볼 수 있어요. 아래는 너무나도 단순한 C 함수이에요.

해당 함수를 덤프하면, 아래와 같아요. 함수의 시작 부분에서 fp(x29)와 lr(x30)을 스택에 저장한 뒤, 함수 끝 부분에서 fp(x29)와 lr(x30)를 복원하는 모습 보이시나요?

이런 식으로, 어셈블리의 형태로 스택 프레임을 구성한 것이지요!


Line 14~18에서는 부트로더에게 전달받은 fdt 포인터를 __fdp_pointer 변수에 저장하고, 물리 주소와 가상 주소 간의 offset을 구해서 kimage_offset 변수에 저장해요. 해당 변수는 나중에 커널 이미지의 가상 주소와 물리 주소를 상호 변환할 때 사용되겠지요?

Line 21~26에서는 BSS 섹션을 0으로 초기화를 해주는 작업을 해줘요. User 어플리케이션의 BSS 섹션은 커널이 0으로 초기화해주지만, 우리는 커널 그 자체이므로 스스로 0으로 초기화해야겠지요?


Line 32~35에서는 앞서 봤던 C 함수의 종료 부분처럼 스택 프레임에 저장된 레지스터들을 복원하는 부분이에요. 정확히 x29, x30을 스택에서 로드하지 않지만 그 구색을 맞춰준 것이지요^^. 그리고 끝으로 start_kernel로 점프하며 head.S에서 벗어나게 돼요!


정리

이번 시간에는 MMU를 활성화하고, 가상 주소로 점프해서 스택을 세팅해주고 start_kernel로 진입하는 과정을 살펴봤어요. 이렇게 정리하니 쉬운 것 같네요 ;)

지금까지 head.S 필요한 부분만 살펴봤었는데, 어떠셨나요? 나름대로 필요한 부분만 축약해서 설명하는데도 이렇게 많은 분량이 나올지는 예상을 못했네요. 제가 처음 커널을 봤을 때는 모르고 넘어갔던 부분들을 최대한 잘 전달하려고 했어요. 이 내용들이 커널 분석에 많은 도움이 되면 좋겠네요. 이걸로 Linux kernel head.S 찍어먹기 시리즈를 마무리하도록 할게요!


Series


조회수 183회댓글 0개

관련 게시물

전체 보기