이제 head.S를 분석하기 시작하나?" 생각하실 수 있지만 아쉽게도 조금 더 알아야 할 것들이 있어요. 이번 시간에는 Linker script와 리눅스 커널 virtual memory layout에 대해 알아 볼 것이에요. 아마 Linker script는 OS나 펌웨어 관련 프로젝트를 하지 않은 이상 일반적인 개발자가 접하기는 어려울 거에요. 그래서 이번 글에서는 가볍게 Linker script에 대해 "찍먹"할 예정이에요. 또 Virtual memory layout은 아마 들어보셨을 수 있는데, 유저 어플리케이션 관점에서의 가상 메모리 레이아웃하면 아마 아래 그림과 같이 떠올리실 것 같아요.
저는 위 이미지처럼 "text, data, bss, ... 섹션이 어디에 있고, heap 영역이 저기 있고 stack은 위에 있고, 그리고 상위 영역은 커널 영역이다"라고 전공 시간에 배웠던 기억이 나네요.
그림에는 단순히 커널 영역이라고 지정된 영역도 내부적으로는 역할에 따라 영역이 구분되요. 그리고 이 각각의 영역들은 Linker script와 몇 가지 옵션들로 결정되어요. 아무튼 이제 본격적으로 Linker script와 리눅스 커널의 VM layout에 대해 알아봐요!
해당 글의 타겟 아키텍처는 aarch64이고, kernel code는 5.1 버전을 다룹니다. 유의하세요!
Linker basic
Linking은 여러 오브젝트 파일들의 코드, 데이터 그 외의 다른 섹션들을 하나의 오브젝트 파일을 만느는 과정이라 말할 수 있어요. 일반적으로 우리가 소스 코드를 gcc를 통해 컴파일할 때 내부적으로는 preprocessing, compilation, assembly와 linking 과정이 순차적으로 진행돼요. 만약, Linking 과정 전에 멈추고 싶다면 -c 옵션을 주면 됩니다. 간단하게 아래 소스 코드를 이용해서 오브젝트 파일을 생성해볼게요.
$ gcc -c whoami.c
위 와 같이 커맨드를 입력하면 whoami.o 오브젝트 파일이 생길거에요. 이 오브젝트 파일은 실행이 가능할까요? 아마 호기심이 많은 분이라면 실행을 해보셨을 것 같은데요, 아쉽게도 실행이 되지 않아요. 왜냐하면, 해당 오브젝트 파일에 해당 파일은 아직 실행하기에 부족한 정보들이 많은 상태에요. 자세한 속사정은 elf 형식과 exec 과정을 다룰 때 이야기해봐요. 다시 본론으로 돌아와서 objdump 유틸리티를 사용해서 생성된 바이너리 코드들을 디스어셈블해볼게요.
워낙 작은 프로그램이다 보니 생성된 어셈블리도 굉장히 작네요. 우선 해당 오브젝트는 base address가 0으로 잡혀 있어요. 즉, 해당 오브젝트 파일의 text 섹션은 주소 0x00에 올라온다고 생각하는 거에요.주소 0x08에서 iamroot 함수를 호출하는 명령어가 있는데, 해당 라인의 바이너리를 살펴보면 첫 바이트만 0x94로 값이 있고 나머지는 전부 0x00이에요. 이 의미를 알기 위해서는 BL instruction에 대한 encoding을 알아야 해요. 아래 그림은 BL 인스트럭션의 인코딩 형식을 설명하는 그림이이에요. 하드웨어에 약하신 분들은 숨 한번 크게 들이쉬고 아래로 넘어가는 것을 추천할게요!
상위 6bit가 OP code이고, 하위 26bit가 상수로 들어오는 형태에요. imm26에 4를 곱하여(instruction의 크기가 항상 4바이트이므로), 현재 PC를 기준으로 +/-128MB만큼의 위치로 jump하는 동시에 리턴 주소를 x30에 저장하는 기능을 수행해요.
자 이제 다시 돌아가서, object를 덤프에서 나온 binary code 0x94000000은 imm26의 값이 0인 상태를 뜻해요. 이 의미는 아직 호출하는 함수 iamroot의 위치를 모르니까 비워둔 것이라는 뜻이에요. 이러한 비워둔 곳을 링커가 다른 오브젝트 파일에서 주소를 찾아와 채워주는 일을 진행하는 거에요! 그러면 "링커는 채워줘야 할 위치를 어떻게 알지?"라는 질문이 떠오르면 잘 따라오고 있는 것이에요. ELF에는 relocation 섹션이 존재해서 어느 위치가 비워져 있는지를 그리고 어떤 값을 넣을 지를 저장하고 있어요. readelf에 -r 옵션을 주면 relocation 섹션에 대한 정보를 확인할 수 있어요.
5번 라인을 뜻을 살펴보면, 마지막 열은 어떤 값을 써넣을지 알려주고 있어요. iamroot라는 심볼에 0을 더한 값을 채워넣으라는 말이지요. 그리고 어느 곳을 채워넣는 지는 첫 번째 열이 알려주고 있어요, 위에서 봤던 objdump 했을 때 offset 0x08 이었던 위치를 말하는 것이에요.
그러면 내가 symbol에 대한 정보를 가지고 있는지 아닌지는 어떻게 알까요? 역시나 이러한 정보도 ELF 포맷을 통해 저장하고 있어요. readelf -s 옵션을 주면 오브젝트의 심볼 테이블를 살펴볼 수 있어요.
우리가 살펴봐야 할 내용은 마지막 줄인데요, iamroot라는 전역(GLOBAL) 심볼은 Ndx가 UND인데요. 즉 해당 오브젝트 파일에는 iamroot라는 심볼을 찾을 수 없다라는 뜻이에요.
이제 링커가 다른 오브젝트 파일을 어떻게 합치는지 대략적인 느낌이 오시나요? 링커는 오브젝트 파일을 합치면서 Symbol resolution을 진행하는데, 위처럼 찾지 못한 심볼들을 다른 오브젝트 파일을 참고하면서 찾고, relocation이 필요한 위치를 relocation 테이블을 이용해 relocation하지요. 이 정도로 Linking process에 대한 설명을 마칠게요, 이제 오브젝트 파일들을 어떤 식으로 합칠지를 알려주는 설계도에 해당하는 Linker script에 대해 가볍게 알아봐요.
Simple linker script
Linker script를 통해 오브젝트 파일의 특정 섹션을 내가 원하는 주소로 배치할 수 있는데요. 아래와 예제를 작성해서 위에서 만든 오브젝트 파일을 linking 해볼게요.
iamroot.c 파일은 whoami 함수에서 호출되는 함수의 구현부를 담고 있어요. 그리고 simple.lds 파일은 정말 간단한 형태의 링커 스크립트 파일이에요. 이제 링커 스크립트의 내용을 한 번 살펴볼까요?
우선 3번째 라인을 보면 SECTIONS라는 명령어가 나와있어요. 이 내부에는 내가 만들 오브젝트의 레이아웃에 대한 정보를 담고 있어요, 해당 예제에는 .text 섹션만 포함하고 있어요. 그러면 .text 섹션이 어떤 위치에 배치되는지는 어떻게 결정될까요?
5번째 라인을 먼저 한번 살펴보시겠어요? 여기에는 "." 이라는 곳에, "=" 연산자를 사용해서 값을 대입해요. 2.36버전의 매뉴얼에는 어떻게 설명이 나와있는지 살펴볼까요?
위 내용을 한 번 읽어보셨나요? Linker script에서는 어떤 변수에 값을 할당하는 것이 아니라, 심볼에다 값을 할당하는 방식이에요. 이 방식을 통해 오브젝트 파일들에서 정의되지 않은 심볼을 Linking 단계에서 심볼에 대한 정보를 제공할 수 있다는 것이는데요, 이것은 아래 예제를 통해 좀 더 설명할게요.
그러면 "."도 어떤 심볼이라는 것을 추론할 수 있는데요, "."는 특별한 심볼로 위치 카운터로 사용돼요. 기본적으로는 0의 값을 가지는데, 위처럼 어떤 값을 할당해서 변경할 수 있어요. 이 심볼은 일종의 커서같은 역할을 하는데, 배치할 섹션의 위치를 명시하지 않으면 "."가 가지고 있는 값이 사용돼요. 그래서 .text가 배치될 위치는 "." 심볼이 저장하고 있는 0x1000이에요. 이렇게 섹션을 배치하면 배치한 만큼 "." 값이 증가되어요.
이제 7번 라인을 보면서, 새롭게 만들어지는 .text 섹션이 어떻게 구성되는지 살펴봐요. 의미를 있는 그대로 해석하면 "출력 오브젝트 파일에는 .text 섹션이 있는데 이 섹션은 whoami.o의 .text 섹션과, iamroot.o의 .text 섹션으로 구성된다"이에요.
위 예제처럼 포함할 오브젝트를 직접 지정할 수도 있지만 애스터리스크를 사용한 * (.text)로 모든 입력 파일의 .text 섹션을 표현할 수 있어요.
아래 그림은 정의된 순서대로 .text 섹션끼리 합치는 것을 보여줘요.
마지막으로 첫 번째의 ENTRY는 출력 오브젝트의 entry point를 _stext로 지정한다라는 의미에요. 즉 해당 오브젝트 파일을 실행하면, _stext에서 실행하게 되는거지요.
이제 해당 스크립트를 이용해서, linking을 진행해볼게요. 물론, 앞서서 iamroot 파일도 컴파일해서 오브젝트파일을 만들어야겠지요? ld 유틸리티에 -T 옵션에 우리가 만든 스크립트를 지정하면 해당 스크립트대로 오브젝트 파일이 생성이 돼요.
$ ld -T simple.ld whoami.o iamroot.o
output 파일을 지정하지 않으면 기본적인 이름 a.out으로 출력 파일이 생성되는데요, 이제 만들어진 오브젝트 파일을 살펴볼까요?
아래는 a.out의 섹션 정보를 보여준 로그에요.
우리의 설계대로 .text파일의 주소는 0x1000에서 시작하네요! 이제 오브젝트 파일을 디스어셈블해서 내용을 살펴볼게요.
우리가 지정한 대로, 0x1000에 whoami.o의 .text 섹션이 배치되었고, 바로 뒤에 iamroot.o의 .text 섹션이 배치되었어요. whoami.o 오브젝트에서 BL 인스트럭션에는 imm26 비어있었는데 지금은 값이 적절한 값이 대입되어 iamroot 함수의 주소 0x1014를 가리키게 되었어요.
해당 오브젝트 파일은 실행은 가능하지만 아마도 Segment falut가 뜰거에요 이건 큰 문제가 아니니 걱정하지 않아도 돼요. 제대로 동작하는지 확인하려면 디버거를 통해 연결해야해요. GDB를 통해 해당 실행 파일을 디버깅해볼게요. 일반적으로 시작 지점은 main이기에 break main을 통해 시작 지점에 중단점을 걸텐데, 우리는 entry point가 0x1000(=whoami)이므로 break *0x1000 또는 break whoami를 통해 디버깅을 시작해야해요.
3번 라인에서 0x1000에 중단점을 설정하고, Run을 하면 해당 주소에 딱 멈추게 됩니다. 우리가 entry point를 _stext에 설정한대로 프로그램은 _stext에서 부터 시작하게 되는 것이지요. 그런 다음은 우리가 C로 짠 로직대로 흘러가요 whoami+8에서 iamroot를 호출하고, 리턴하고 리턴하고... 그 뒤는 정의되지 않은 부분이라 segment fault가 발생하지요ㅎ.
어떤 아키텍쳐에서는 0x1000와 같은 임의의 주소에 이미지를 올릴 수 없는 경우가 있습니다. 또한 root 권한이 필요로 하는 경우도 있습니다.
자, 지루한 설명은 끝났고 이제부터 코드를 살펴볼 때가 된 것 같아요! 이제 head.S를 한 번 열어볼까요?
쉽게 설명하기 위해 발췌하고 싶은 부분만 가져왔기 때문에 실제로 보는 코드와는 차이가 있어요. CONFIG_EFI =N 일 때 위 와 같은 모습을 가져요.
head.S의 첫 시작 부분은 위와 같이 시작하는데요. 주석을 보시면 해당 코드는 PIC(position independent code)라고 설명이 되어있어요. 이 말은 해당 코드는 로딩된 물리 주소의 위치에 상관없이 동작할 수 있는 코드라는 뜻이지요. bootloader 입장에서는 2MB aligned된 주소면 다 적합한 후보니 커널 입장에서는 주소에 상관없이 동작할 수 있어야겠지요? 그러면 "어떻게 위치 독립적으로 실행되는가?"에 대한 설명은 나중에 할게요.
9번 라인에서는 __HEAD라는 MACRO가 나오는데요, 이 매크로의 정의는 아래와 같아요
#define __HEAD .section ".head.text","ax"
이 말은 이제부터 아래의 코드는 .head.text 섹션에 속하고 attribute는 ax이란 뜻이에요. 커널은 이런 식으로 용도에 따라 정의된 섹션을 만들어 사용해요.
Q) 그럼 왜 별도의 섹션으로 분리하였을까요? 한 번 생각해봐요!
위 질문에 대해 충분히 생각해보셨나요? 앞서 배운 내용과 연관이 있어요. 앞서 오브젝트 파일을 재배치할 때, 재배치의 최소 단위는 섹션이였지요? 그래서 특정 코드(또는 데이터)만 특정 위치에 배치하기 위해 이렇게 다른 섹션으로 정의한거에요. 이 경우에는 부트 헤더로 사용되는 부분이므로 커널 이미지의 제일 앞에 있어야 해요. 그래서 따로 섹션을 분리하고, 링커 스크립트를 통해 이미지 제일 앞 부분에 배치할 거에요~
14번 라인부터는 저번 시간에 봤던 이미지 헤더를 구현하는데요, 여기서 독특한 심볼이 나와요. 바로 16,17,18 라인에 사용되는 _kernel_* 변수처럼 보이는 토큰들이에요. 해당 토큰들의 태그를 따라가려하면 잘 되지 않을건데요. 이것들은 매크로와 링커 스크립트의 조합으로 완성되기에 추적하기가 꽤나 까다로워요.
우선 le64sym 매크로부터 따라가볼까요? 해당 매크로는 arch/arm64/include/asm/assembler.h에 정의되어 있어요.
골치 아프게도 .macro 디렉티브를 사용해서 매크로를 구현했어요. 차근 차근 읽어볼까요?
Line 1: "le64sym"이라는 매크로를 선언하고, 이 매크로는 인자로 sym을 받아요.
Line 2~3: \sym는 sym으로 받은 문자열을 사용한다는 뜻이에요, 그리고 \()는 \sym과 _lo32를 구분하기 위한 세퍼레이터이에요. 그리고 _lo32는 단순한 문자열이에요.
위 설명으로는 완전히 이해하기 힘든데, 해당 매크로를 통해 나오는 결과물은 무엇을 살펴보면 이해에 도움이 될 것 같네요. 더욱 자세한 설명은 이 링크를 참고하세요!
해당 매크로는 인자로 받은 토큰에 _lo32, _hi32 접미사를 붙인 심볼들을 2개 생성하는데요, 각각 4바이트의 크기를 가져요. 그러면 위의 _kernel_offset_le_lo32, _kernel_offset_le_hi32, 등등의 심볼들은 어디에 존재할까요? 해당 심볼들은 다른 오브젝트 파일에 정의되어 있지 않고, linker script에서 심볼에 값이 할당돼요. (앞에서 linking stage에서 새로운 심볼을 생성, 값을 할당할 수 있다는 것이 기억나시나요?)
vmlinux.lds.S는 전처리 과정이 필요한 링커 스크립트인데요. 정의된 SECTION 커맨드 가장 아래에 HEAD_SYMBOLS이라는 매크로가 있어요.
해당 매크로에서 _kernel_*_lo32, _kernel_*_hi32 심볼들을 정의해요. 해당 매크로는 arch/arm64/kernel/image.h에 정의되어 있고, 구현은 아래와 같아요.
## operator을 사용해서 인자로 받은 토큰을 _lo32와 _hi32 토큰과 이어붙이고 값을 할당하는 형태의 매크로에요. 즉, vmlinux.lds.S가 전처리가 완료되면 내부에는 해당 심볼에 대한 할당이 구분이 존재하겠지요. 전처리가 완료된 vmlinux.lds에서 한 번 값을 찾아볼까요?
위 스크립트에서 확인할 수 있다시피, SECTIONS 마지막 부분에는 헤더에서 사용하는 심볼에 대한 할당이 있어요. 그래서 linking process가 끝나면 header 적절한 값이 들어있게 되는 거에요.
정리
분량 조절의 실패로 VM layout에 대한 설명은 다음 시간에 이어서 진행해야겠어요. 이번 시간에는 Linking process에 대해 조금 알아봤는데요, Linker script를 통해 마음대로 원하는 섹션들을 배치하는 것 방법을 간단한 예제를 통해 공부했어요. 또한 Linking time에 심볼을 제공하는 방법을 리눅스 코드를 살펴보았어요. 이제 다음 시간에는 섹센을 어떤 주소에 어떻게 배치할 지 결정하는 리눅스 커널 VM layout에 대해 이어서 진행할게요.
안녕하세요, 디버깅을 하다보니 궁금한 점이 생겨서 여쭤보고 싶은게 있는데요,
혹시 페이지 테이블을 초기화하고 MMU를 켜기 전까지 부분을 디버깅할 때
심볼을 gdb에서 인식하도록 할 방법이 있을까요? (죄다 물리 주소라서 ㅠㅠ)
와우, 정말 귀중한 자료 감사합니다! 커널 스터디에 잘 참고하고 있습니다 :)