이 일지에 나온 소스코드는 모두 laonOS 레파지토리에서 확인할 수 있습니다.

  • 작성의 편의를 위해 글 내용은 해라체로 작성되었음을 알려드립니다.
  • 운영체제와 x86_64 아키텍처에 기본적인 지식이 있다는 전제하에 진행됩니다.
  • 별도의 출처가 없는 이미지는 저에게 저작권이 있습니다.

0.1 아키텍쳐 잡설

AWS의 서버리스 컴퓨팅 서비스인 람다에서 아마존 자체 개발 프로세서인 Graviton2를 사용 가능해졌다는 뉴스를 본 적이 있다. Graviton2 프로세서는 RISC의 대표 격인 ARM ISA를 채택하고 있는데, 애플의 ARM 이주와 맞물려서 요즘 트렌드가 확실히 ARM이란 생각이 들었다. 사실 슈퍼컴퓨터 등지에서도 ARM은 인기 있는 아키텍처이다.

RISC는 32비트 전환 시기에 성능 향상으로 크게 주목받았지만 요즘은 클럭이 워낙 빨라져서 성능상의 이점은 찾기가 힘들고, 대부분 전력 효율 때문에 사용한다. 그래서 모바일 기기의 프로세서 대부분이 RISC이다.

인텔이 사용하는 x86-64 ISA는 CISC라서 전력 효율이 좋지 못하다. 곧 공개될 인텔의 Alder Lake 같은 경우 이기종 컴퓨팅 시스템 즉 ARM의 big.LITTLE처럼 고효율 코어와 성능 코어로 나눠 TDP를 낮춰보겠다는 심상인데 Intel7 공정 개발 완료로 얼마나 전력 효율이 높아질지 조금은 기대된다. 사실 인텔의 이기종 컴퓨팅 도전은 첫 번째가 아니다. 이미 Lakefield를 말아먹은 전적이 있다.

x86-64은 완전한 CISC는 아니고 내부적으로는 프론트엔드 디코더에서 micro-operation로 변환된 후 실질적으로는 RISC 프로세서에서 명령어를 수행한다. 이러한 디코더가 RISC의 TDP를 증가시키는 근본적인 원인이다.

잡설이 길었는데 결론적으로 나는 x86-64 기반 운영체제를 만들 예정이다. x86-64는 전력 효율이 좋진 못하지만, 어셈블리 프로그래밍이 간단해지며, 자료 또한 방대하기 때문이다. (ARM에서 4개 5개 opcode가 필요한 연산이 x86-64에서는 한 개로 가능하다.)

0.2 부트로더와 간단한 테스트 코드

부트로더를 섹터를 읽어와 메모리에 로드하는 어셈블리 코드를 짜서 직접 만들 수도 있겠지만, 크기 제한 때문에 대부분 비슷한 기능을 하고 (사실 크기 제한 때문에 대부분의 부트로더는 MBR에 위치한 부트로더가 추가적인 부트로더를 로드, 실행시키는 등의 여러 단계로 나뉘곤 한다.) 실제 환경에서의 테스트의 편리성을 위해 널리 사용되며 멀티 부트를 지원하는 GNU grub을 선택했다. grub은 뒤에서 나올 qemu과도 잘 작동한다.

x86에서는 cold boot이후 PC(EIP) 레지스터의 기본 주소인 reset vector에 BIOS 초기화 영역의 주소가 맵핑되어 BIOS가 실행된다. 실행된 BIOS의 POST 이후 0x7C00에 위치한 512B 크기의 MBR을 boot signature(0x55, 0xAA)를 확인한 후 로드하고 제어권을 넘긴다. (MBR의 크기가 512바이트인 이유는 MBR이 첫 번째 섹터를 의미하고 한 섹터의 크기는 512B였기 때문이다.) 마지막으로 MBR에 위치한 부트로더가 실행되며 운영체제를 로드한다.
역사적인 이유로 BIOS가 리얼 모드로 실행되고, 대부분의 부트로더는 전통적으로 0x100000(1MB)에서 커널을 로드하기 때문에 부트로더는 32비트 보호 모드로 전환하는 작업을 겸하기도 한다. (리얼 모드는 메모리를 1메가바이트까지만 접근 가능하다) 보통 1MB 앞에는 shadowing된 BIOS와 부트로더가 위치한다.

grub을 사용해도 몇몇 부분은 역시 어셈블리를 이용해야 한다. 커널의 진입점에서는 스택이 아직 생성되기 전이라서 C를 바로 사용할 수 없기 때문이다. 진입점에서 스택을 어셈블리어로 만들어줘야 한다.

grub은 멀티부트를 통해 커널을 로드하기 때문에 멀티부트 헤더를 맨 위에서 확인할 수 있다. 일단 필수적인 필드만 추가해놓았다. 다른 필드의 내용이 궁금하면 multiboot specification을 참고하면 된다.

os_stack의 위치를 주의 깊게 볼 필요가 있다. X86 스택은 높은 주소에서 낮은 주소로 확장되기 때문에 resb 다음에 위치하는 것을 확인할 수 있다. 주석으로 간단한 설명을 적어놓았으니 설명되지 않은 부분은 참고하면 좋다!

아래 어셈블리 코드는 윈도우에서 작동하지 않는다. 윈도우에서는 C 네임 데코레이션으로 함수 이름 앞에 _가 붙기 때문에 _cmain으로 바꿔줘야 정상적으로 링킹 된다. 윈도우는 이처럼 이식성이 떨어져서 운영체제 빌드는 *nix에서 하는 게 좋다. (MSYS2를 이용해도 좋지만 타겟 지정이 정상적으로 되진 않을 것이다. ld가 기본적으로 PE 파일만 생성하기 때문.)
(kernel.asm)

[bits 32] ;32비트 보호 모드에서 실행되므로 32bit 로 맞춰줌!

section .text
global main
extern cmain ;C 커널 진입점

MULTIBOOT_MAGIC    equ 0x1BADB002
MULTIBOOT_FLAGS    equ 0x0                                    ;부트로더에게 전달하는 플레그
MULTIBOOT_CHECKSUM equ  - (MULTIBOOT_MAGIC + MULTIBOOT_FLAGS) ;체크섬

OS_STACK_SIZE equ 8192      ;OS의 스택 크기 (8kb)

align 4 ;multiboot 헤더
        dd MULTIBOOT_MAGIC
        dd MULTIBOOT_FLAGS
        dd MULTIBOOT_CHECKSUM

main:
    mov esp, os_stack       ;스택 포인터 레지스터 설정
    mov ebp, esp            ;베이스 포인터 레지스터 설정, 레거시라서 불필요하지만 일단 추가.
    call cmain
    hlt	                    ;프로세서 idle 상태로 만들기. (임시로 넣어둠)

section .bss
align 4
    resb OS_STACK_SIZE		  ;스택 생성
os_stack:

위에서 말했듯이 대부분의 부트로더는 전통적으로 0x100000(1MB)에서 커널을 로드하고, grub 또한 1MB 이상 위치에서 커널을 로드하기 때문에 간단한 링커 스크립트를 이용해 1MB 위치에서 로드되게 만들어 줘야 한다.

스크립트를 보면 ALIGN(0x1000)을 이용해 각 세그먼트를 4KB로 정렬하는데, 이는 32비트 보호 모드에서의 메모리 액세스 속도 향상을 위한 것이다. (구조체의 정렬과 의도가 같다고 보면 된다.) 사실 ELF로 링크하기 때문에 자동으로 4KB 경계에 맞춰주긴 한다.
(link.ld)

ENTRY(main) /* kernel.asm의 main을 엔트리로 지정 */

SECTIONS {
    . = 0x00100000; /* 1MB 위치에서 로드함 */
    .text ALIGN(0x1000) : { *(.text) }
    .rodata ALIGN(0x1000) : { *(.rodata*) }
    .data ALIGN(0x1000) : { *(.data) }
    .bss ALIGN(0x1000) : { *(COMMON) *(.bss) }
}

이제 커널의 시작을 위한 기본적인 준비가 끝났으니 커널이 제대로 시작되는지 간단한 텍스트를 띄워 확인해보자! (아까 어셈블리에서 나온 cmain이 이것이다!) 텍스트 모드의 비디오 메모리 주소는 0xb8000부터 시작한다. 간단하게 화면을 먼저 지워주고, 해당 주소부터 쓰면 된다! 글씨에 여러 가지 스타일을 지정해줄 수도 있는데, 여기서 쓰인 스타일은 맨 아래 스크린샷에서 확인할 수 있다! (스타일에 관한 자세한 정보가 궁금하다면 VGA text mode 라고 검색하면 된다.)

(kernel.c)

void cmain(){
    volatile char *videoMem = (volatile char*)0xb8000; // 4B 텍스트 + 4B 스타일이기 때문에 char!
    const char *text = "Hello, World!";

    // 창 크기가 가로 80 세로 25이다.
    // 텍스트를 쓰기 전 창을 초기화한다.
    for(unsigned int i = 0; i < 80*25*2; i+=2) {
        videoMem[i] = ' ';
        videoMem[i+1] = 0x07;
    }

    // 텍스트를 쓴다.
    while(*text != '\0') {
        *videoMem++ = *text++;
        *videoMem++ = 0x0B;
    }
}

마지막으로 grub.cfg라는 grub 설정 파일을 만들어 줘야 한다. 지금은 필수적인 것만 적어 무척이나 짧지만 점차 커질 예정이다! ‘/boot/kernel.m’는 아래서 나올 빌드 자동화를 보면 이해할 수 있는데, 커널의 ELF 바이너리 파일이다.

(grub.cfg)

menuentry "2POS (2 POSIX functions implementation Operating System)" {
    multiboot /boot/kernel.m
}

0.3 빌드 자동화와 가상머신

디텍토리 구조
현재 디텍토리 구조

마지막으로 cmake를 통해 운영체제 빌드를 자동화했다. 익숙하고 ninja로 빌드 속도도 상승시킬 수 있어 운영체제 빌드에 자주 사용되는 make 대신 cmake를 선택했다.

(CMakeLists.txt)

cmake_minimum_required(VERSION 3.20)

project(2POS
        LANGUAGES C
        VERSION 0.1
        DESCRIPTION "2 POSIX functions implementation Operating System")

set(CMAKE_C_STANDARD 99)

message(STATUS "Project ${CMAKE_PROJECT_NAME}")
message(STATUS "Build Version ${CMAKE_PROJECT_VERSION}")

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")

add_subdirectory(kernel)

===============================================================================
(kernel\CMakeLists.txt)

cmake_minimum_required(VERSION 3.20)

include(Find)
include(StringUtility)

set(KERNEL_NAME kernel)
set(CMAKE_ASM_NASM_OBJECT_FORMAT elf) # NASM elf 형식으로 빌드

project(${KERNEL_NAME}
        LANGUAGES C ASM_NASM
        VERSION 0.1
        DESCRIPTION "2POS kernel")

message(STATUS "Include ${KERNEL_NAME}")

find_kernel_build_tools() # 툴 확인

set(SRC_KERNEL
        kernel.asm
        kernel.c
        )
set(LINKER_SCRIPT ${CMAKE_CURRENT_SOURCE_DIR}/link.ld)

set(CMAKE_C_LINK_EXECUTABLE
        "${LINKER_PATH} <FLAGS> <CMAKE_C_LINK_FLAGS> <LINK_FLAGS> <OBJECTS> -o <TARGET>"
        )
set(CMAKE_C_STANDARD 99)
set(KERNEL_C_FLAG
        -m32                 # 32비트로 빌드
        -ffreestanding       # 독립형 빌드
        -nostdlib            # std 라이브러리와 링킹 방지
        -nodefaultlibs       # 기본 라이브러리 무시
        -fno-builtin         # 인라인 비활성화
        -fno-stack-protector # -fstack-protector 끄기
        -target i386---elf   #타켓 지정
        )
set(KERNEL_ASM_FLAG
        )
set(KERNEL_LINK_FLAG
        -m elf_i386          # 32비트로 빌드
        -T ${LINKER_SCRIPT}  # 링커 스크립트
        )

add_executable(${KERNEL_NAME} ${SRC_KERNEL})
set_target_properties(${KERNEL_NAME} PROPERTIES
        PREFIX ""
        SUFFIX ""
        OUTPUT_NAME "kernel.m"
        LINK_DEPENDS ${LINKER_SCRIPT}
        RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/image/boot"
        )
target_compile_options(${KERNEL_NAME} PUBLIC
        $<$<COMPILE_LANGUAGE:C>:
        ${KERNEL_C_FLAG}
        -nostdinc                          # 표준 헤더 경로 무시
        -nostartfiles                      # 기본 초기화 작업 제외
        -Wno-unused-command-line-argument  # 사용되지 않는 인자 경고 무시
        >$<$<COMPILE_LANGUAGE:ASM_NASM>:${KERNEL_ASM_FLAG}>
        )
target_link_options(${KERNEL_NAME} PUBLIC ${KERNEL_LINK_FLAG})

# image
# |-boot
#   |- grub
#     |- grub.cfg
#   |- kernel.m

add_custom_command(TARGET ${KERNEL_NAME} # 빌드 전
        PRE_BUILD
        COMMENT "Init..."
        BYPRODUCTS "${CMAKE_BINARY_DIR}/image/boot/grub/grub.cfg"
        COMMAND ${CMAKE_COMMAND} -E remove_directory "${CMAKE_BINARY_DIR}/image" # 이전 폴더 정리
        COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/grub"
            "${CMAKE_BINARY_DIR}/image/boot/grub" # grub 설정 폴더 복사
        COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_BINARY_DIR}/bin"
        VERBATIM
        )
add_custom_command(TARGET ${KERNEL_NAME} # 빌드 후
        POST_BUILD
        BYPRODUCTS "${CMAKE_SOURCE_DIR}/bin/${CMAKE_PROJECT_NAME}.iso"
        COMMENT "Creation ISO image..."
        COMMAND ${GRUB_PATH} -o "${CMAKE_BINARY_DIR}/bin/${CMAKE_PROJECT_NAME}.iso"
            "${CMAKE_BINARY_DIR}/image" # 이미지 생성
        VERBATIM
        )

first_letter_uppercase(${KERNEL_NAME} UPPERCASE_KERNEL_NAME)
get_output_path(${KERNEL_NAME} KERNEL_PATH)

message(STATUS "${UPPERCASE_KERNEL_NAME} build output to: ${KERNEL_PATH}")

nasm으로 어셈블리 파일을 빌드하고, clang으로 C 파일을 빌드한 후에 두 오브젝트 파일을 링킹 하는 간단한 cmake이다. 추가로 빌드에는 nasm, ld, grub-mkrescue가 필요하다. cmake는 이해도 쉽고 대부분 중요한 부분은 주석을 달아놨으니 설명은 생략하겠다.

컴파일러가 실행 환경과 다른 타겟 코드를 생성하려면 gcc 같은 경우 크로스 컴파일러를 빌드해야 하는데 clang은 llvm IR을 거치기 때문에 본질적으로 크로스 컴파일러라서 선택했다.
빌드 로그
Ubuntu 20.04.3 환경에서 Clion Remote 기능을 이용해 원격으로 빌드했다.

운영체제를 테스트하는 가장 좋은 방법은 실제 컴퓨터에서 돌려보는 것이다. 하지만 효율성을 위해 가상 머신에서 테스트해봤다. 물리적인 컴퓨터는 변수가 많아서 간단한 테스트에는 가상 머신이 좋다. 가상머신 하바퍼퍼저인 qemu를 설치하고 다음 명령어를 입력하면 간단하게 테스트할 수 있다. (세세한 qemu 설정은 다음에 해볼 예정이다.) qemu-system-i386" -hda 2POS.iso

QEMU 화면

짠! 예쁜 시안 색 Hello, World! 가 출력됐다!

다음에 해볼 것들

다음 챕터에서는 64비트로 운영 모드를 변경해볼 예정이다. 시간이 남으면 스케쥴러도! 요즘 리눅스 커널 모듈을 rust로 작성한다는 말이 많던데, rust도 한번 훑어봐야겠다.

참고문헌

글쓴이

phruse

쉬운 길보다는 어려운 길을 즐깁니다. 다양한 분야에 관심이 많으며 언젠가 많은 사람이 사용하는 기반 기술을 개발하는 것이 목표입니다.