반응형

아직 커널 코드어셈블리어로 작성하고 있고, 커널 크기가 변경되면 그때마다 부트 로더 코드TOTALSECTORCOUNT를 수정해야 결과를 확인할 수 있었습니다.

 

이제는 빌드 시에 자동으로 TOTALSECTORCOUNT 값을 업데이트하여 작업환경을 개선하고 C 코드를 어셈블리어 코드와 연결하여 함께 빌드하는 방법에 대해 알아봅니다.

 


지금까지 각 파트의 소스 파일단일 파일로 구성되었고, 각 파일은 NASM 컴파일러를 통해 바이너리(Binary) 파일 형태로 생성되었습니다.

 

현재 보호 모드 커널엔트리 포인트 소스 파일(EntryPoint.s) 하나로 구성되어 있으며, 512byte로 정렬되어 OS 이미지 파일에 결합되는 구조를 하고 있습니다.

 

앞으로는 C언어로 작성한 커널을 보호 모드 엔트리 포인트의 뒷부분에 연결하고 엔트리 포인트에서는 C 커널 시작 부분으로 이동하는 것이 전부이게끔 C 소스 파일을 추가하고 이를 빌드하여 보호 모드 커널 이미지에 통합합니다.

 

C 코드컴파일과 링크 과정을 거쳐서 최종 결과물이 생성됩니다.

 

▶ 컴파일

소스 파일을 중간 단계인 오브젝트 파일(Object File)로 변환하는 과정

● 소스 파일을 해석하여 코드 영역과 데이터 영역을 나누고, 이러한 메모리 영역에 대한 정보를 생성하는 단계

 

▶ 링크

● 오브젝트 파일들의 정보를 취합하여 실행 파일에 통합하며, 필요한 라이브러리 등을 연결해주는 역할

 

 

아래의 사진은 위의 빌드 과정을 나타낸 것입니다.

 

 

 


 

→ 빌드 조건과 제약 사항

 

엔트리 포인트C 코드를 실행하려면 적어도 아래의 세 가지 제약 조건은 만족해야 합니다.

  1. C 라이브러리를 사용하지 않게 빌드해야 한다.

  2. 0x10200 위치에서 실행하게끔 빌드해야 한다.

  3. 코드나 데이터 외에 기타 정보를 포함하지 않은 순수한 바이너리 파일 형태여야 한다.

 

▶ C 라이브러리를 사용하지 않게 빌드해야 한다.

 

부팅된 후 보호 모드 커널이 실행되면 C 라이브러리가 없으므로 라이브러리에 포함된 함수를 호출할 수 없습니다.

 

커널자신을 실행하기 위한 최소한의 환경만 설정하므로 C 라이브러리동적 링크(Dynamic Link) 또는 정적 링크(Static Link) 할 수 없어서 커널 코드에서 애플리케이션 코드처럼 printf() 함수를 사용할 수 없습니다.

 

그렇기에 작성된 커널 코드만 사용하도록 빌드해야 합니다.

 

▶ 0x10200 위치에서 실행하게끔 빌드해야 합니다.

0x10000 위치에는 이전에 작성한 한 섹터 크기의 보호 모드 엔트리 포인트가 있으므로, 결합된 C로 작성한 커널 부분빌드 시 512byte 이후인 0x10200의 위치부터 로딩(혹은 실행)되는 것을 전제로 하며, 해당 위치의 코드는 C 코드 중에 가장 먼저 실행되어야 하는 함수(엔트리 포인트)가 위치해야 합니다.

 

커널이 실행되는 주소가 중요한 이유는 선형 주소를 참조하게 생성된 코드나 데이터 때문입니다.

 

C언어에서 전역 변수의 주소나 함수의 주소를 참조하는 경우, 실제로 존재하는 선형 주소로 변환되기 때문에 메모리에 로딩되는 주소가 변한다면, 이러한 값들도 변경해줘야 정상적으로 동작합니다.

 

만약 아래와 같은 C 코드가 있을 때 0x0000에서 로딩되어 실행되는 경우0x10200에서 로딩되어 실행되는 경우어셈블리어 코드는 아래와 같습니다.

 

(g_iIndex 변수는 메모리 주소의 가장 앞쪽에 위치한다고 가정합니다.)

 

메모리에 로딩되는 주소에 따라 다르게 생성된 코드의 예

 

위의 코드를 보면 메모리에 로딩되는 주소에 따라 전역 변수의 주소에 접근하는 부분이 변하는데 이러한 이유 때문에 커널이 0x10200의 주소에서 실행되게 빌드하는 것이 필요합니다.

 

 

▶ 코드나 데이터 외에 기타 정보를 포함하지 않은 순수한 바이너리 파일 형태여야 한다.

 

일반적으로 설치한 GCC를 통해 실행 파일을 생성하면 ELF 파일 포맷이나 PE 파일 포맷과 같이 특정 OS에서 실행할 수 있는 포맷으로 생성되는데, 이때 파일 포맷들에는 실행하는 데 필요한 코드와 데이터 정보 이외의 불필요한 정보를 포함하고 있습니다.

 

해당 파일 포맷을 그대로 사용하면 엔트리 포인트에서 파일 포맷을 해석하여 해당 정보에 따라 처리해야 하는 기능이 포함되어야 하므로 코드가 복잡해지는데

 

부트 로더보호 모드 엔트리 포인트처럼 코드와 데이터만 포함된 바이너리 파일의 형태를 사용하면, 엔트리 포인트에서 해당 주소로 점프(jmp)하는 것만으로 C 코드를 실행할 수 있습니다.

 


 

→ 소스 파일 컴파일 - 라이브러리를 사용하지 않는 오브젝트 파일 생성

 

C 코드컴파일하여 오브젝트 파일을 생성하는 방법은 아주 간단합니다.

 

이미 설치된 GCC 컴파일러의 옵션으로 '-c'만 추가하면 됩니다.

 

'-c' 옵션소스 파일을 오브젝트 파일로 변환하는 컴파일 단계까지만 처리하는 것을 의미합니다.

 

'-o' 옵션으로 직접 오브젝트 파일 이름을 지시하지 않는 한 생성되는 오브젝트 파일의 이름은 C 파일 이름에 확장자만 .o로 변경되어 생성됩니다.

 

생성할 오브젝트 파일라이브러리를 사용하지 않고 홀로(Freestanding) 동작할 수 있는 형태여야 합니다.

 

GCC 컴파일러오브젝트 파일을 위해 '-ffreestanding' 옵션을 지원하며, '-c' 옵션을 함께 조합하면 라이브러리를 사용하지 않는 오브젝트 파일을 생성할 수 있습니다.

 

아래의 예시는 GCC를 사용하여 Main.c를 라이브러리를 사용하지 않는 Main.o로 컴파일하는 명령어입니다.

 

참고)

-m32 : 크로스 컴파일한 GCC가 기본적으로 64bit 생성하거나 생성할 수 있으므로, 32bit 코드 생성을 위해 설정한 옵션

 

Main.c를 라이브러리를 사용하지 않는 Main.o로 컴파일하는 방법


 

→ 오브젝트 파일 링크 - 라이브러리를 사용하지 않고 특정 주소에서 실행 가능한 커널 이미지 파일 생성 방법

 

실행 파일을 구성하는 섹션의 배치와 로딩될 주소, 코드 내에서 가장 먼저 실행될 코드인 엔트리 포인트를 지정해줘야 하기 때문오브젝트 파일을 링크하여 실행 파일을 만드는 방법은 소스 파일을 컴파일하는 방법보다 까다롭습니다.

 

특히 섹션을 배치하는 작업오브젝트 파일이나 실행 파일 구조와 관련이 있으며, 섹션을 배치하는 방식과 크기 정렬 방식에 따라서 OS의 메모리 구조와 크기가 달라지므로 다른 작업보다 까다롭지만 중요합니다.

 

섹션 배치를 다시 하는 이유실행 파일이 링크될 때 코드나 데이터 이외에 디버깅 관련 정보와 심볼(Symbol, 함수와 변수의 이름) 정보 등 커널을 실행하는 데 직접적인 관련이 없는 정보들이 포함되기 때문최종 바이너리 파일을 생성할 때 이 정보들을 제거하기 위함입니다.

 

섹션을 재배치하여 코드와 데이터를 실행 파일 앞쪽으로 이동시키면 손쉽게 나머지 부분을 제거할 수 있습니다.

 


 

→ 섹션 배치와 링커 스크립트, 라이브러리를 사용하지 않는 링크

 

▶ 섹션이란

● 실행 파일 또는 오브젝트 파일에 있으며 공통된 속성(코드, 데이터, 각종 심볼과 디버깅 정보 등)을 담은 영역

 

 

실행 파일이나 오브젝트 파일에는 무수히 많은 섹션이 있지만 핵심 역할을 하는 세 가지가 있습니다.

 

▶ .text 섹션

main()이나 함수의 실제 실행 가능한 코드가 저장되는 영역입니다.

● 일반적으로 읽기 전용(Read-only) 속성을 가집니다.

 

 

▶ .data 섹션

초기화된 전역 변수(Global Variable) 혹은 0이 아닌 값으로 초기화된 정적 변수(Static Variable) 등을 포함합니다.

데이터를 저장하는 섹션이기 때문에 일반적으로 읽기/쓰기 속성을 가집니다.

 

▶ .bss 섹션

● .data 섹션에 포함되는 데이터와 거의 같으나 초기화되지 않은 변수만 포함합니다.

0으로 초기화될 뿐, 실제 데이터가 없으므로 실행 파일이나 오브젝트 파일 상에는 별도의 영역을 차지하지 않지만 메모리에 로딩되었을 때 코드는 해당 영역 변수들의 초깃값이 0이라고 가정하기에 정상적으로 프로그램을 실행하기 위해서는 메모리에 로딩할 때 .bss 영역을 모두 0으로 초기화해야 합니다.

 

 

오브젝트 파일은 중간 단계의 생성물로, 다른 오브젝트 파일과 합쳐지기 때문소스 코드를 컴파일하여 생성한 오브젝트 파일은 각 섹션의 크기와 파일 내에 있는 오프셋 정보만 들어있습니다.

 

합쳐지는 순서에 따라서 섹션의 주소는 바뀔 수 있습니다.

 

오브젝트 파일들을 결합하여 정리하고 실제 메모리에 로딩될 위치를 결정하는 것이 바로 링커(Linker)이며, 이 과정을 링크(Link) 또는 링킹(Linking)이라고 합니다.

 

아래의 사진은 링크 과정을 그림으로 나타낸 것입니다.

 

 

링커의 주된 역할은 오브젝트 파일을 모아 섹션을 통합하고 그에 따라 주소를 조정하며, 외부 라이브러리에 있는 함수를 연결해주는 것이지만

링커가 실행 파일을 만들려면 파일 구성에 대한 정보가 필요한데 이때 사용하는 것링커 스크립트(Linker Script)입니다.

 

 

▶ 링커 스크립트

● 각 섹션의 배치 순서와 시작 주소, 섹션 크기 정렬 등의 정보를 저장해 놓은 텍스트 형태의 파일

 

아래의 링커 스크립트를 보면 .text와 .data, .bss가 있는 것으로 봐서 각 섹션에 대한 정보를 나타낸다는 것을 짐작할 수 있습니다.

 

참고)

Ubuntu(우분투 20.04 LTS)를 기준으로 find / -name "*elf_i386.x" 명령어를 이용하면 링커 스크립터 예제 파일의 경로를 확인할 수 있습니다.

(/usr/lib/x86_64-linux-gnu/ldscripts/elf_i386.x)

 

Windows(윈도 10)을 기준으로 링커 스크립터 예제 파일의 경로는 C:\cygwin\usr\cross\x86_64-pc-linux\lib\ldscripts\elf_i386.x 입니다.

 

우분투에서 링커 스크립터 파일의 경로 확인하는 명령어

 

링커 스크립트 1

 

링커 스크립트 2

 

링커 스크립트 3
링커 스크립트 4

 

링커 스크립트 5

 

링커 스크립트의 사용법은 매우 다양하고 복잡하지만, 기존 링커 스크립트에서 섹션 배치와 섹션 크기 정렬에 대한 내용만 수정하면 됩니다.

 

(링커 스크립트에 대한 자세한 내용은 http://sourceware.org/binutils/docs/ld/ 을 참고하면 됩니다.)

 

GCC크로스 컴파일한 후 생성된 32bit용 링커 스크립트 파일을 열면, 아래와 같은 구조가 반복되는 것을 확인할 수 있는데, 링커 스크립트의 구조를 아래에 표시된 기본 형식에 대입해보면 SectionName과 그 내부에 오브젝트 파일에서 통합할 섹션의 이름과 정렬할 기준값, 그리고 섹션의 초기값을 쉽게 찾을 수 있습니다.

 

링커 스크립트의 기본 형식

 

이제 32bit용 링커 스크립트 파일을 정리 및 재배치하기 위해 위의 elf-i386.x 파일01.Kernel32 디렉터리 밑에 elf_i386.x라는 이름으로 저장합니다.

 

섹션의 재배치텍스트나 데이터와 관계없는 섹션(.tdata, .tbss, .ctors, .got등)의 기본 구조, 즉 'SectionName{ ... }' 부분 전체를 코드 및 데이터 섹션의 뒷부분으로 이동하거나, 코드 및 데이터에 관련된 섹션(.text, .data, .bss, .rodata)을 가장 앞으로 이동함으로써 처리합니다.

 

필요한 섹션을 앞으로 이동하는 것이 수월하므로 관련된 섹션을 링커 스크립트의 가장 앞쪽으로 이동합니다.

 

 

섹션 크기 정렬 부분은 ALIGN() 부분의 값을 수정하여 변경할 수 있습니다.

 

크기 정렬 값은 임의의 값으로 설정해도 되지만, 편의상 데이터 섹션의 시작을 섹터 크기(512byte)에 맞춥니다.

 

만약 이후에 커널의 공간이 부족하다면 이 값을 더 작게 줄여서 보호 모드 커널이 차지하는 비중을 줄일 수 있습니다.

 

아래는 섹션 배치 및 섹션 정렬이 적용된 링커 스크립트 파일의 내용입니다.

 

링커 스크립트 파일이 복잡하여 직접 수정하는 것이 부담된다면, github.com/sean-baek/64bit_multicore_os/blob/main/01.Kernel32/elf_i386.x 에서 제가 수정한 01.Kernel32/elf_i386.x 파일을 사용하셔도 됩니다.

 

섹션 배치와 크기 정렬을 위해 수정한 링커 스크립트 1

 

섹션 배치와 크기 정렬을 위해 수정한 링커 스크립트 2

 

섹션 배치와 크기 정렬을 위해 수정한 링커 스크립트 3

 

섹션 배치와 크기 정렬을 위해 수정한 링커 스크립트 4

 

섹션 배치와 크기 정렬을 위해 수정한 링커 스크립트 5

 

위에서 수정한 링커 스크립트를 이용해서 라이브러리를 사용하지 않고 실행 파일을 생성하는 방법은 아래와 같습니다.

 

참고)

-melf_i386 : 크로스 컴파일했던 Binutils가 기본적으로 64bit 코드를 생성하거나 생성할 수 있으므로, 32bit 실행 파일을 위해 설정한 옵션

-T elf_i386.x : elf_i386.x 링커 스크립트를 이용해서 링크 수행

-nostdlib : 표준 라이브러리(Standard Libaray)를 사용하지 않고 링크 수행

-o Main.elf : 링크하여 생성할 파일 이름

 

링커 스크립트를 직접 지정하여 오브젝트 파일을 링크하는 예(Ubuntu)

 

링커 스크립트를 직접 지정하여 오브젝트 파일을 링크하는 예(Windows)

 


 

→ 로딩할 메모리 주소와 엔트리 포인트 지정

 

C 코드도 어셈블리어로 작성된 부트 로더보호 모드 엔트리 포인트처럼 로딩될 메모리를 미리 예측하고 그에 맞춰 이미지를 생성하는 것이 중요한데, 만약 이미지를 로딩할 주소에 맞춰서 생성하지 않으면 전역 변수와 같이 선형 주소를 직접 참조하는 코드는 모두 잘못된 주소에 접근하기 때문입니다.

 

메모리에 로딩하는 주소를 지정하는 방법두 가지가 있습니다.

  1. 링커 스크립트를 수정하는 방법

  2. 링커(LD) 프로그램의 명령줄(Command Line) 옵션으로 지정하는 방식

 

▶ 링커 스크립터를 통해 수정하는 방법

 

스크립트 파일'.text' 섹션을 아래와 같이 수정합니다.

 

'.text' 섹션의 주소를 수정하면 그 이후에 있는 '.data''.bss' 같은 섹션은 자동으로 '.text'가 로딩되는 주소 이후로 계산되므로 다른 섹션들은 수정하지 않아도 됩니다.

 

보호 모드 커널부트 로더에 의해 0x10000에 로딩되며, 0x10000의 주소에는 512byte 크기의 보호 모드 엔트리 포인트(EntryPoint.s) 코드가 있으니 C 코드는 0x10200 주소부터 시작합니다.

 

아래의 사진은 보호 모드 엔트리 포인트와 C언어 커널이 결합된 이미지가 로딩된 메모리의 배치를 나타낸 것입니다.

 

새롭게 생성된 디스크 이미지의 메모리 배치

 

링커 스크립트를 수정하여 로딩될 메모리 주소를 지정하는 예

 

링커 스크립트를 사용하지 않고, 커맨드 라인 옵션으로 지정하는 방법은 아래와 같습니다.

 

참고)

-Ttext : .text 섹션의 시작 주소 지정

 

커맨드 라인 옵션을 통해 로딩될 메모리 주소를 지정하는 예(Ubuntu)

 

커맨드 라인 옵션을 통해 로딩될 메모리 주소를 지정하는 예(Windows)

 

엔트리 포인트링커 스크립트 또는 커맨드 라인 옵션으로 지정할 수 있습니다.

 

링커 스크립트를 통해 지정하려면 아래와 같이 스크립트 파일의 상단에 있는 'ENTRY()' 부분을 수정하면 됩니다.

 

Ubuntu에서는 SEARCH_DIR() 부분은 아래와 같이 입력하지 않아도 됩니다.

 

링커 스크립트를 수정하여 엔트리 포인트 지정하는 예

 

커맨드 라인 옵션으로 엔트리 포인트를 지정하는 방법은 아래와 같습니다.

 

참고)

-e Main : main을 엔트리 포인트로 지정

 

커맨드 라인 옵션을 통한 엔트리 포인트 지정(Ubuntu)

 

커맨드 라인 옵션을 통한 엔트리 포인트 지정(Windows)

 

엔트리 포인트링커에 지정하는 작업빌드의 결과물이 OS에 의해 실행 가능한 파일 포맷(리눅스의 elf 파일 포맷, 윈도의 PE 파일 포맷 등) 일 때만 의미가 있는데

 

실행 파일을 바이너리 형태로 변환하는 FS64 OS의 경우는 엔트리 포인트 정보가 제거되므로 엔트리 포인트는 큰 의미가 없지만 단순히 링크 시에 발생하는 경고를 피하려고 설정하는 것입니다.

 

그래도 0x10000 주소에 존재하는 보호 모드 엔트리 포인트는 0x10200 주소로 이동(jmp)하므로, C 코드의 엔트리 포인트를 해당 주소에 강제로 위치시킬 필요가 있습니다.

 

특정 함수를 실행 파일의 가장 앞 쪽에 두려면 두 가지 순서를 조작해야 합니다.

  1. 오브젝트 파일 내의 함수 간의 순서

  2. 실행 파일 내의 함수 간의 순서

 

▶ 오브젝트 파일 내의 함수 간의 순서

오브젝트 파일은 소스 파일로부터 생성되고, 컴파일러는 특별한 옵션이 없는 한 소스 파일에 정의된 함수의 순서대로 오브젝트 파일의 내용을 생성하기 때문에 C 소스 파일을 수정하여 엔트리 포인트 함수를 가장 상위로 옮겨줌으로써 오브젝트 파일에 포함된 함수의 순서를 변경할 수 있습니다.

 

▶ 실행 파일 내의 함수 간의 순서

컴파일러와 마찬가지로 실행 파일은 오브젝트 파일로부터 생성되고, 링커는 특별한 옵션이 없는 한 입력으로 주어진 오브젝트 파일의 순서대로 통합하여 실행 파일을 생성하기 때문에 엔트리 포인트가 포함된 오브젝트 파일을 가장 앞쪽으로 옮겨줌으로써 C 코드의 엔트리 포인트를 0x10200에 위치 시킬 수 있습니다.

 


→ 실행 파일을 바이너리 파일로 변환

 

실행 파일에서 불필요한 섹션을 제외하고 꼭 필요한 코드 섹션과 데이터 섹션만 추출하려면 objcopy 프로그램을 사용하면 손쉽게 작업할 수 있습니다.

 

▶ objcopy

 

● 실행 파일 또는 오브젝트 파일을 다른 포맷으로 변환하거나 특정 섹션을 추출하여 파일로 생성해주는 프로그램으로 이전에 크로스 컴파일했던 binutils에 포함되어 있습니다.

● 섹션을 추출하여 바이너리로 바꾸는 작업만 수행하면 되므로 '-j', '-S', '-O' 옵션에 대해서만 알면 됩니다.

● objcopy 프로그램에 대한 자세한 내용은 http://sourceware.org/binutils/docs/binutils/objcopy.html#objcopy 를 참고하면 됩니다.

● -j : 실행 파일에서 해당 섹션만 추출(ex. .text 섹션만 추출하려면 '-j .text')

● -S : 실행 파일에서 재배치 정보와 심볼을 제거, C언어 커널은 함수 이름이나 변수 이름을 사용할 일이 없으므로 제거

● -O : 새로 생성할 파일의 포맷을 지정(ex. 실행 파일을 바이너리 파일 포맷으로 변환하려면 '-O binary')

 

아래는 Kernel32.elf 파일에서 코드 섹션과 데이터에 관련된 섹션만 추출하여 바이너리 형태의 Kernel32.bin 파일을 만드는 예시입니다.

 

참고)

-j .text -j .data -j .rodata -j .bss : 코드(.text)와 데이터(.data), 읽기 전용 데이터(.rodata, Read-Only Data), 초기화되지 않은 데이터(.bss) 섹션만 추출

-S : 재배치 정보와 심볼 정보 제거

-O : 생성할 파일 포맷

 

objcopy로 바이너리 파일 변환(Ubuntu)

 

objcopy로 바이너리 파일 변환(Windows)

 


참고)

링커 스크립트OUTPUT_FORMAT() 항목과 SectionName 항목을 잘 이용하면 objcopy 프로그램을 사용하지 않고도 바이너리 파일을 직접 생성할 수 있습니다.

 

OUTPUT_FORMEAT("binary")와 같이 'binary'로 지정하고, .text, .data, .bss 섹션을 제외한 나머지 섹션에 이름을 /DISCARD/로 교체하면 됩니다.

 

위의 방법처럼 링크 스크립트를 수정해서 직접 바이너리 파일을 생성하면 한 단계를 줄일 수 있어서 빌드 시간이 단축되지만 중간 과정에서 생성된 ELF 파일을 통해 여러 가지 정보를 얻을 수 있으며, ELF 파일을 바이너리 파일로 변환하는데 시간이 오래 걸리지 않으므로 ELF 파일을 거쳐서 생성하는 것이 좋습니다.

반응형

+ Recent posts