이미지 로딩 중...
AI Generated
2025. 11. 14. · 6 Views
Rust로 만드는 나만의 OS 링커 스크립트 완벽 가이드
OS 개발의 핵심인 링커 스크립트 작성 방법을 실전 중심으로 배웁니다. 메모리 레이아웃 설계부터 섹션 배치까지, 실무에서 바로 활용할 수 있는 노하우를 담았습니다.
목차
- 링커 스크립트 기본 구조 - 메모리 레이아웃의 청사진
- 메모리 영역 정의 - MEMORY 명령으로 하드웨어 제약 표현
- 섹션 정렬과 심볼 내보내기 - 페이징 준비와 런타임 참조
- KEEP과 DISCARD - 최적화 방지와 불필요 섹션 제거
- AT 명령 - LMA와 VMA 분리로 Flash에서 RAM 복사
- 페이지 테이블 정렬 - 4KB 경계와 하드웨어 요구사항
- 스택 공간 예약 - .bss에 고정 크기 스택 배치
- PROVIDE와 HIDDEN - 기본값 심볼과 외부 가시성 제어
- 멀티부트 헤더 배치 - GRUB 호환성과 매직 넘버
- 심볼 주소 계산 - SIZEOF와 ADDR로 런타임 정보 제공
1. 링커 스크립트 기본 구조 - 메모리 레이아웃의 청사진
시작하며
여러분이 Rust로 OS 커널을 컴파일했는데 실행이 안 되거나, 부팅 중에 이상한 곳에서 크래시가 발생한 적 있나요? 이런 문제의 90%는 링커 스크립트가 잘못 작성되어 코드와 데이터가 엉뚱한 메모리 주소에 배치되었기 때문입니다.
링커 스크립트는 컴파일된 오브젝트 파일들을 하나의 실행 파일로 묶을 때, 각 섹션(.text, .data, .bss 등)을 메모리의 어느 위치에 배치할지 결정하는 설계도입니다. 특히 OS 개발에서는 부트로더가 커널을 특정 주소에 로드하므로, 링커 스크립트로 정확한 메모리 레이아웃을 명시하지 않으면 시스템이 제대로 동작할 수 없습니다.
바로 이럴 때 필요한 것이 체계적인 링커 스크립트입니다. 이를 통해 커널 코드가 0x100000 같은 정확한 주소에 로드되도록 보장하고, 각 섹션이 의도한 순서대로 배치되어 안정적인 OS 부팅이 가능해집니다.
개요
간단히 말해서, 링커 스크립트는 링커(ld)에게 "어떤 섹션을 어디에 배치할지" 알려주는 명령서입니다. 일반 애플리케이션은 기본 링커 스크립트를 사용하지만, OS 커널은 특수한 메모리 레이아웃이 필요하므로 직접 작성해야 합니다.
왜 필요한지 실무 관점에서 설명하면, x86_64 부트로더(GRUB 등)는 커널을 물리 주소 0x100000(1MB)에 로드합니다. 하지만 링커가 기본 설정으로 0x400000 같은 주소를 가정하면 커널 코드가 잘못된 주소를 참조하여 크래시가 발생합니다.
예를 들어, 전역 변수 접근이나 함수 호출이 엉뚱한 메모리를 가리키는 경우가 생깁니다. 기존에는 컴파일러가 자동으로 링킹을 처리했다면, OS 개발에서는 ENTRY 포인트, 메모리 영역(MEMORY), 섹션 배치(SECTIONS)를 모두 수동으로 정의해야 합니다.
이 링커 스크립트의 핵심 특징은 세 가지입니다. 첫째, ENTRY로 부팅 시 첫 실행 코드를 지정합니다.
둘째, 메모리 주소와 권한(읽기/쓰기/실행)을 명시적으로 제어합니다. 셋째, 섹션 간 정렬(alignment)과 순서를 보장하여 하드웨어 요구사항을 만족시킵니다.
이러한 특징들이 중요한 이유는 잘못된 배치 하나로 전체 시스템이 부팅 실패할 수 있기 때문입니다.
코드 예제
/* 커널 진입점 정의 - 부트로더가 이 심볼로 점프 */
ENTRY(_start)
/* 출력 바이너리 포맷 지정 */
OUTPUT_FORMAT(elf64-x86-64)
OUTPUT_ARCH(i386:x86-64)
SECTIONS
{
/* 커널을 1MB 주소에 배치 (GRUB 표준) */
. = 1M;
/* 텍스트 섹션: 실행 코드 */
.text : ALIGN(4K) {
*(.multiboot_header) /* 멀티부트 헤더는 맨 앞 */
*(.text)
}
/* 읽기 전용 데이터 */
.rodata : ALIGN(4K) {
*(.rodata)
}
}
설명
이것이 하는 일: 부트로더가 커널을 메모리에 로드한 후 _start 심볼로 점프하여 실행을 시작하고, 코드(.text)와 데이터(.rodata)가 올바른 주소에 있도록 보장합니다. 첫 번째로, ENTRY(_start)는 링커에게 "_start 심볼이 프로그램의 시작점"이라고 알려줍니다.
부트로더는 이 주소로 점프하므로, 여러분의 Rust 코드에서 #[no_mangle] pub extern "C" fn _start()를 정의해야 합니다. 왜 이렇게 하는지는 명확합니다 - 시작점이 없으면 CPU가 어디서부터 실행할지 모릅니다.
그 다음으로, . = 1M이 실행되면서 현재 위치 카운터를 1MB(0x100000)로 설정합니다.
내부에서는 링커가 이후 모든 섹션을 이 주소부터 순차적으로 배치하기 시작합니다. ALIGN(4K)는 각 섹션을 4096바이트 경계에 정렬하는데, 이는 페이징 시스템이 4KB 단위로 동작하기 때문입니다.
세 번째 단계로, *(.multiboot_header)는 모든 오브젝트 파일에서 .multiboot_header 섹션을 찾아 맨 앞에 배치합니다. GRUB는 파일 처음 8KB 내에서 매직 넘버를 찾으므로, 이 순서가 매우 중요합니다.
*(.text)는 모든 실행 코드를 그 뒤에 배치하여 최종적으로 연속된 텍스트 섹션을 만들어냅니다. 여러분이 이 코드를 사용하면 커널이 정확히 1MB에서 시작하고, 멀티부트 헤더가 올바른 위치에 있어 GRUB가 커널을 인식할 수 있습니다.
실무에서의 이점은 첫째, 디버깅 시 주소가 예측 가능하고, 둘째, 페이징 설정이 간단해지며, 셋째, 다른 OS 개발자들과 표준을 공유할 수 있다는 점입니다.
실전 팁
💡 ENTRY 심볼과 실제 Rust 함수명이 일치하는지 nm 명령으로 확인하세요. 불일치 시 부팅 실패합니다.
💡 섹션 시작 주소를 심볼로 내보내려면 __text_start = .; 같은 구문을 사용하세요. Rust 코드에서 메모리 맵 확인에 유용합니다.
💡 . (위치 카운터)를 직접 조작할 때는 항상 ALIGN을 함께 사용하여 정렬 오류를 방지하세요.
💡 OUTPUT_FORMAT을 명시하지 않으면 링커가 잘못된 바이너리를 생성할 수 있으니 반드시 지정하세요.
💡 디버깅용으로 --print-map 옵션으로 최종 메모리 맵을 확인하면 섹션 배치를 시각적으로 검증할 수 있습니다.
2. 메모리 영역 정의 - MEMORY 명령으로 하드웨어 제약 표현
시작하며
여러분이 커널 이미지가 512KB를 넘었는데, 부트로더가 로드한 후 이상하게 동작하거나 일부 코드가 사라진 적 있나요? 이는 부트로더가 특정 메모리 영역(예: 0x100000~0x200000)만 사용 가능하다고 가정했는데, 여러분의 링커 스크립트가 그 제약을 무시했기 때문입니다.
하드웨어와 부트로더는 각각의 메모리 제약이 있습니다. BIOS는 0x0~0x100000을 예약하고, VGA 버퍼는 0xB8000에 있으며, MMIO 영역은 특정 주소에 매핑됩니다.
링커가 이를 모르면 커널 데이터가 하드웨어 레지스터를 덮어쓸 수 있습니다. 바로 이럴 때 필요한 것이 MEMORY 명령입니다.
이를 통해 "이 영역은 RAM, 이 영역은 ROM"이라고 명시하여 링커가 유효한 영역에만 섹션을 배치하도록 강제할 수 있습니다.
개요
간단히 말해서, MEMORY 명령은 사용 가능한 메모리 영역의 이름, 시작 주소, 크기, 속성(읽기/쓰기/실행)을 정의하는 블록입니다. 링커는 이 정보를 바탕으로 섹션을 올바른 영역에만 배치합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, ARM 임베디드 시스템에서는 Flash ROM(읽기 전용)과 SRAM(읽기/쓰기)이 서로 다른 주소에 있습니다. 코드는 Flash에, 데이터는 SRAM에 배치해야 하는데, MEMORY 없이는 이를 자동화할 수 없습니다.
예를 들어, STM32 MCU는 Flash가 0x08000000에, SRAM이 0x20000000에 있는 경우가 많습니다. 기존에는 주소를 하드코딩하고 수동으로 계산했다면, 이제는 MEMORY로 영역을 선언하고 SECTIONS에서 >RAM 같은 간단한 문법으로 자동 배치할 수 있습니다.
이 개념의 핵심 특징은 세 가지입니다. 첫째, 메모리 오버플로우를 컴파일 타임에 감지합니다(크기 초과 시 에러).
둘째, 속성(rwx)으로 잘못된 배치를 방지합니다(쓰기 불가 영역에 .data 배치 시 경고). 셋째, 여러 메모리 영역을 명명하여 코드 가독성을 높입니다.
이러한 특징들이 중요한 이유는 메모리 관련 버그가 런타임에 발견되면 디버깅이 매우 어렵기 때문입니다.
코드 예제
/* 하드웨어 메모리 맵 정의 */
MEMORY
{
/* GRUB가 커널을 로드하는 영역: 1MB~2MB */
RAM (rwx) : ORIGIN = 0x100000, LENGTH = 1M
/* VGA 텍스트 버퍼: 읽기/쓰기만 가능 */
VGA (rw) : ORIGIN = 0xB8000, LENGTH = 4K
/* 향후 확장용 고메모리: 4MB~8MB */
HIGHMEM (rwx) : ORIGIN = 0x400000, LENGTH = 4M
}
SECTIONS
{
.text : { *(.text) } >RAM /* 코드는 RAM에 */
.vga_buffer : { *(.vga) } >VGA /* VGA 데이터는 전용 영역에 */
}
설명
이것이 하는 일: 링커에게 "RAM은 0x100000부터 1MB, VGA는 0xB8000부터 4KB"라고 알려주어, 섹션 배치 시 이 영역을 벗어나면 에러를 발생시키고, 올바른 권한(rwx)을 확인합니다. 첫 번째로, RAM (rwx) : ORIGIN = 0x100000, LENGTH = 1M는 "RAM"이라는 이름의 영역을 선언하고, 읽기(r)/쓰기(w)/실행(x) 모두 가능하며, 1MB(0x100000)부터 시작해서 크기가 1MB라고 정의합니다.
왜 이렇게 하는지는 GRUB가 이 주소에 커널을 로드하고, 우리는 1MB 이내로 초기 커널을 제한하고 싶기 때문입니다. 그 다음으로, VGA 영역이 정의됩니다.
0xB8000은 x86에서 텍스트 모드 비디오 메모리의 표준 주소이며, 크기는 80x25 문자 * 2바이트 = 4000바이트이므로 4KB로 충분합니다. 내부에서 링커는 이 영역에 실행 권한(x)이 없으므로 코드 섹션을 배치하려 하면 경고합니다.
세 번째 단계로, SECTIONS에서 >RAM 문법을 사용하면 링커가 해당 섹션을 RAM 영역 내에 배치합니다. 만약 .text 섹션이 1MB를 초과하면 "region RAM overflowed" 에러가 발생하여 컴파일이 실패합니다.
마지막으로, .vga_buffer 섹션은 정확히 0xB8000에 배치되어, Rust 코드에서 이 주소로 문자를 쓰면 화면에 출력됩니다. 여러분이 이 코드를 사용하면 메모리 레이아웃 실수를 컴파일 타임에 잡을 수 있고, 팀원들이 메모리 맵을 한눈에 이해할 수 있습니다.
실무에서의 이점은 첫째, 하드웨어 사양 변경 시 MEMORY 블록만 수정하면 되고, 둘째, 멀티코어 시스템에서 각 코어에 별도 메모리 할당이 쉬우며, 셋째, DMA나 MMIO 영역을 명시적으로 예약하여 충돌을 방지합니다.
실전 팁
💡 MEMORY 영역 이름은 의미 있게 지어서(FLASH, SRAM, DRAM 등) 나중에 코드를 읽을 때 혼란을 방지하세요.
💡 LENGTH를 하드웨어 실제 크기보다 작게 설정하여 안전 마진을 두면 예상치 못한 오버플로우를 막을 수 있습니다.
💡 ARM 시스템에서는 LMA(Load Memory Address)와 VMA(Virtual Memory Address)를 분리할 때 AT 키워드와 함께 사용하세요.
💡 ASSERT 명령으로 섹션 크기를 검증하세요. 예: ASSERT(SIZEOF(.text) < 512K, "커널 코드가 너무 큽니다")
💡 여러 메모리 영역에 같은 섹션을 분산 배치하려면 MEMORY_REGION 함수와 와일드카드를 활용하세요.
3. 섹션 정렬과 심볼 내보내기 - 페이징 준비와 런타임 참조
시작하며
여러분이 페이징을 구현하려고 커널 끝 주소를 알아야 하는데, 하드코딩한 주소가 실제 빌드 때마다 달라져서 매번 수동으로 수정한 경험이 있나요? 또는 .bss 섹션을 0으로 초기화하려는데 시작과 끝 주소를 어떻게 알아내야 할지 막막했던 적이 있을 것입니다.
링커 스크립트는 섹션을 배치하는 것뿐만 아니라, 특정 위치를 "심볼"로 내보내서 Rust 코드에서 참조할 수 있게 합니다. 예를 들어, __kernel_end 심볼을 정의하면 페이지 할당자가 "이 주소 이후는 자유 메모리"라고 판단할 수 있습니다.
정렬(ALIGN)도 마찬가지로, 페이지 테이블은 4KB 경계에 있어야 하므로 섹션을 정렬하지 않으면 하드웨어 예외가 발생합니다. 바로 이럴 때 필요한 것이 심볼 내보내기와 ALIGN 지시자입니다.
이를 통해 빌드 시스템이 자동으로 계산한 주소를 코드에서 안전하게 사용하고, 하드웨어 요구사항을 만족시킬 수 있습니다.
개요
간단히 말해서, 심볼 내보내기는 __bss_start = .; 같은 구문으로 현재 위치를 변수처럼 만들어 Rust 코드에서 extern "C" { static __bss_start: u8; } 형태로 참조하는 기법입니다. ALIGN은 현재 위치를 특정 배수로 올림하여 섹션이 정렬되도록 합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, .bss는 초기화되지 않은 전역 변수를 담는 섹션으로, ELF 파일에는 크기 정보만 있고 실제 데이터는 없습니다. 부팅 시 _start 함수에서 .bss 영역을 수동으로 0으로 채워야 하는데, 이때 시작/끝 주소가 필요합니다.
예를 들어, static mut BUFFER: [u8; 1024];는 .bss에 있으므로 초기화하지 않으면 쓰레기 값이 들어갑니다. 기존에는 objdump로 주소를 확인하고 하드코딩했다면, 이제는 링커 스크립트가 심볼을 자동 생성하여 코드 변경 없이 빌드만으로 최신 주소를 반영할 수 있습니다.
이 개념의 핵심 특징은 세 가지입니다. 첫째, . (위치 카운터)를 읽어서 현재 주소를 캡처합니다.
둘째, ALIGN(4K) 같은 표현식으로 주소를 올림하여 정렬을 보장합니다. 셋째, 내보낸 심볼은 Rust에서 &__symbol as *const _ as usize로 주소를 얻을 수 있습니다.
이러한 특징들이 중요한 이유는 동적으로 변하는 메모리 레이아웃을 코드에서 안전하게 추적할 수 있기 때문입니다.
코드 예제
SECTIONS
{
.text : ALIGN(4K) {
__text_start = .; /* 코드 시작 주소 내보내기 */
*(.text)
__text_end = .; /* 코드 끝 주소 내보내기 */
}
.bss : ALIGN(4K) {
__bss_start = .; /* BSS 시작 */
*(.bss)
__bss_end = .; /* BSS 끝 */
}
/* 전체 커널 끝 (페이지 경계로 정렬) */
. = ALIGN(4K);
__kernel_end = .;
}
설명
이것이 하는 일: .text와 .bss 섹션의 시작/끝 주소를 심볼로 내보내고, 각 섹션을 4KB 경계에 정렬하여 페이징 시스템이 섹션 단위로 권한을 설정할 수 있게 합니다. 첫 번째로, __text_start = .;는 현재 위치 카운터(.)의 값을 __text_start라는 심볼에 할당합니다.
이 시점의 주소는 .text 섹션이 시작되는 바로 그 위치입니다. 왜 이렇게 하는지는 나중에 페이지 테이블에서 "0x100000~0x150000은 실행 가능"이라고 설정할 때 이 심볼을 사용하기 때문입니다.
그 다음으로, *(.text)가 실행되면서 모든 오브젝트 파일의 .text 섹션이 여기에 배치됩니다. 내부에서 링커는 각 함수의 크기를 합산하여 위치 카운터를 증가시킵니다.
__text_end = .;는 모든 코드가 배치된 직후의 주소를 캡처하여, 여러분은 __text_end - __text_start로 코드 크기를 계산할 수 있습니다. 세 번째 단계로, .bss 섹션도 동일하게 처리됩니다.
ALIGN(4K)는 .bss 시작을 4096의 배수로 올림하므로, 만약 .text가 0x101500에서 끝났다면 .bss는 0x102000부터 시작합니다. 마지막으로, __kernel_end = .;는 커널 전체의 끝 주소를 표시하며, 페이지 할당자는 이 주소 이후를 자유 메모리로 사용합니다.
여러분이 이 코드를 사용하면 Rust에서 extern "C" { static __bss_start: u8; static __bss_end: u8; }로 선언하고, unsafe { ptr::write_bytes(&__bss_start as *const _ as *mut u8, 0, &__bss_end as *const _ as usize - &__bss_start as *const _ as usize); }로 .bss를 0으로 초기화할 수 있습니다. 실무에서의 이점은 첫째, 빌드 자동화로 수동 주소 관리가 불필요하고, 둘째, 페이징 권한을 섹션 단위로 설정하여 보안이 강화되며, 셋째, 디버거에서 심볼명으로 주소를 확인할 수 있어 디버깅이 쉬워집니다.
실전 팁
💡 심볼을 Rust에서 사용할 때는 주소값만 필요하므로 &__symbol as *const _ as usize 패턴을 사용하세요. 역참조하면 안 됩니다.
💡 ALIGN을 섹션 앞뿐만 아니라 뒤에도 사용하여 다음 섹션도 정렬되도록 하세요. 예: . = ALIGN(4K);
💡 섹션 크기를 계산하는 심볼도 만들 수 있습니다. 예: __text_size = __text_end - __text_start;
💡 페이징 활성화 전에는 물리 주소를 사용하므로, 가상 메모리 전환 시 LMA/VMA 분리를 고려하세요.
💡 여러 심볼을 배열처럼 사용하려면 PROVIDE 키워드로 기본값을 제공하여 심볼이 정의되지 않아도 링크 에러를 방지하세요.
4. KEEP과 DISCARD - 최적화 방지와 불필요 섹션 제거
시작하며
여러분이 멀티부트 헤더를 작성했는데, 컴파일러 최적화로 "사용되지 않는 코드"로 간주되어 최종 바이너리에서 사라진 경험이 있나요? 또는 디버그 심볼이 커널 이미지를 10MB로 부풀려서 부트로더가 로드조차 하지 못한 적이 있을 것입니다.
링커는 기본적으로 참조되지 않는 섹션을 제거하는 최적화(-gc-sections)를 수행합니다. 하지만 멀티부트 헤더나 인터럽트 벡터 테이블처럼 코드에서 직접 참조하지 않지만 하드웨어/부트로더가 찾는 섹션은 반드시 보존해야 합니다.
반대로, .comment나 .debug_* 같은 섹션은 런타임에 불필요하므로 제거하여 바이너리 크기를 줄일 수 있습니다. 바로 이럴 때 필요한 것이 KEEP 명령과 /DISCARD/ 섹션입니다.
KEEP은 "이 섹션은 절대 제거하지 마"라고 링커에게 지시하고, /DISCARD/는 "이 섹션들은 최종 바이너리에 포함하지 마"라고 명시합니다.
개요
간단히 말해서, KEEP은 섹션 패턴을 괄호로 감싸서 링커가 가비지 컬렉션에서 제외하도록 하는 명령이고, /DISCARD/는 특수한 섹션 이름으로 여기에 배치된 섹션을 최종 출력에서 삭제합니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, GRUB는 커널 파일 처음 8KB를 스캔하여 0x1BADB002 매직 넘버를 찾습니다.
이 값이 들어있는 .multiboot_header 섹션이 없으면 "Not a valid multiboot kernel" 에러가 발생합니다. 하지만 Rust 코드 어디서도 이 섹션을 참조하지 않으므로, KEEP 없이는 링커가 제거합니다.
예를 들어, 인터럽트 디스크립터 테이블(IDT)도 마찬가지로 CPU가 하드웨어 예외 시 참조하지만 코드에서는 직접 호출하지 않습니다. 기존에는 더미 참조를 만들어서 링커를 속였다면, 이제는 KEEP으로 명시적으로 "이건 필요해"라고 선언하여 코드를 깔끔하게 유지할 수 있습니다.
이 개념의 핵심 특징은 세 가지입니다. 첫째, KEEP은 와일드카드를 지원하여 KEEP(*(.init)) 같은 패턴으로 모든 초기화 코드를 보존합니다.
둘째, /DISCARD/는 링커가 아예 출력 파일에 쓰지 않으므로 파일 크기를 크게 줄입니다. 셋째, 이 두 명령을 조합하면 "필요한 것만 남기고 나머지는 제거"하는 정밀한 제어가 가능합니다.
이러한 특징들이 중요한 이유는 임베디드 시스템에서 Flash 크기가 제한적이고, 부팅 속도가 파일 크기에 비례하기 때문입니다.
코드 예제
SECTIONS
{
.text : ALIGN(4K) {
/* 멀티부트 헤더는 반드시 보존 - GRUB가 찾음 */
KEEP(*(.multiboot_header))
/* 초기화 코드도 보존 - 직접 호출은 없지만 필수 */
KEEP(*(.init))
*(.text)
}
/* 불필요한 섹션 제거 */
/DISCARD/ : {
*(.comment) /* 컴파일러 주석 */
*(.debug_*) /* 디버그 심볼 */
*(.note.*) /* ELF 메타데이터 */
}
}
설명
이것이 하는 일: 링커에게 .multiboot_header와 .init 섹션은 참조 여부와 관계없이 반드시 유지하라고 지시하고, .comment와 디버그 섹션은 출력 파일에서 완전히 제거하여 바이너리를 경량화합니다. 첫 번째로, KEEP(*(.multiboot_header))는 모든 오브젝트 파일에서 .multiboot_header 섹션을 찾아서 가비지 컬렉션 대상에서 제외합니다.
여러분의 Rust 코드에서 #[link_section = ".multiboot_header"]로 표시한 구조체가 여기에 해당합니다. 왜 이렇게 하는지는 이 섹션이 0x1BADB002 매직 넘버를 포함하며, GRUB가 이를 찾지 못하면 부팅이 불가능하기 때문입니다.
그 다음으로, KEEP(*(.init))이 실행됩니다. .init 섹션은 C++의 전역 생성자나 Rust의 특수 초기화 코드가 들어가는데, 이들은 _start 전에 실행되어야 하지만 직접적인 함수 호출은 없습니다.
내부에서 링커는 이 섹션들을 "루트 노드"로 표시하여 도달 가능성 분석에서 항상 필요한 것으로 간주합니다. 세 번째 단계로, /DISCARD/ 섹션이 처리됩니다.
링커는 *(.comment), (.debug_), (.note.) 패턴에 매칭되는 모든 섹션을 찾아서 출력 파일에 쓰지 않습니다. 예를 들어, GCC가 생성한 .comment 섹션(컴파일러 버전 정보)이나 DWARF 디버그 정보(.debug_info, .debug_line 등)가 여기서 제거됩니다.
마지막으로, 이 최적화로 10MB였던 바이너리가 200KB로 줄어들어 부팅 시간이 크게 단축됩니다. 여러분이 이 코드를 사용하면 멀티부트 헤더가 항상 바이너리 앞부분에 존재하여 GRUB가 커널을 인식하고, 불필요한 디버그 정보가 제거되어 Flash 메모리를 절약할 수 있습니다.
실무에서의 이점은 첫째, -gc-sections 최적화를 안전하게 활성화할 수 있고, 둘째, 릴리스 빌드와 디버그 빌드를 링커 스크립트로 구분할 수 있으며, 셋째, 불필요한 섹션 제거로 보안 정보 노출을 방지합니다.
실전 팁
💡 KEEP을 남용하면 바이너리가 커지므로, 정말 필요한 섹션(부트 헤더, 인터럽트 벡터 등)에만 사용하세요.
💡 디버깅 시에는 /DISCARD/를 주석 처리하여 디버그 심볼을 유지하면 GDB로 소스 레벨 디버깅이 가능합니다.
💡 --print-gc-sections 링커 옵션으로 제거된 섹션 목록을 확인하여 의도치 않은 제거를 감지하세요.
💡 .eh_frame(예외 처리 정보)은 Rust에서 panic=abort 시 불필요하므로 /DISCARD/에 추가하세요.
💡 ARM Cortex-M에서는 벡터 테이블을 KEEP(*(.vector_table))로 보존해야 리셋 핸들러를 찾을 수 있습니다.
5. AT 명령 - LMA와 VMA 분리로 Flash에서 RAM 복사
시작하며
여러분이 임베디드 시스템에서 .data 섹션(초기화된 전역 변수)을 RAM에 배치했는데, 전원을 껐다 켜면 모든 값이 사라지는 문제를 겪은 적 있나요? 또는 코드는 Flash ROM에 있는데, 읽기/쓰기 데이터도 같은 곳에 있어서 런타임에 쓰기 시도 시 하드웨어 예외가 발생한 경험이 있을 것입니다.
임베디드 시스템(ARM, RISC-V 등)에서는 코드와 상수는 Flash ROM에 저장되지만, 변수는 SRAM에 있어야 합니다. 하지만 Flash에 바이너리를 플래싱할 때는 .data의 초기값도 함께 저장되어야 하므로, "Flash에 저장되지만 RAM에 로드"되는 이중 구조가 필요합니다.
이를 LMA(Load Memory Address, 로드 주소)와 VMA(Virtual Memory Address, 실행 주소)라고 합니다. 바로 이럴 때 필요한 것이 AT 명령입니다.
이를 통해 ".data 섹션은 0x20000000(SRAM)에서 실행되지만, 실제로는 0x08001000(Flash)에 저장된다"라고 명시하고, 부팅 시 복사 코드로 Flash에서 RAM으로 데이터를 옮깁니다.
개요
간단히 말해서, AT 명령은 섹션의 VMA(코드가 참조하는 주소)와 LMA(바이너리 파일에 저장되는 주소)를 분리하는 지시자입니다. .data : AT(ADDR(.rodata) + SIZEOF(.rodata)) 같은 구문으로 "실행 주소는 기본, 로드 주소는 AT 괄호 안"을 지정합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, STM32 MCU는 부팅 시 Flash(0x08000000)에서 코드를 실행하지만, SRAM(0x20000000)은 휘발성이므로 초기화된 변수의 초기값을 Flash에 백업해야 합니다. 부팅 시 스타트업 코드가 Flash에서 SRAM으로 데이터를 memcpy하여 변수를 올바른 초기값으로 설정합니다.
예를 들어, static mut COUNTER: u32 = 42;의 초기값 42는 Flash에 저장되고, 부팅 시 SRAM으로 복사됩니다. 기존에는 데이터를 코드에 하드코딩하여 런타임에 초기화했다면, 이제는 링커가 자동으로 LMA에 데이터를 배치하고, 여러분은 시작/끝 주소로 복사만 하면 됩니다.
이 개념의 핵심 특징은 세 가지입니다. 첫째, LOADADDR(.data)로 LMA를 얻고 ADDR(.data)로 VMA를 얻어서 복사 범위를 계산합니다.
둘째, Flash와 RAM을 모두 MEMORY로 정의하고 AT과 >를 조합하여 이중 배치를 구현합니다. 셋째, 컴파일러는 VMA만 알고 있으므로 코드 변경 없이 메모리 레이아웃만 바꿀 수 있습니다.
이러한 특징들이 중요한 이유는 Flash는 느리고 쓰기 횟수가 제한적이므로 변수를 RAM에서 관리해야 하기 때문입니다.
코드 예제
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS
{
.text : { *(.text) } >FLASH /* 코드는 Flash에 */
/* 데이터는 SRAM에서 실행, Flash에 저장 */
.data : AT(ADDR(.text) + SIZEOF(.text)) {
__data_start = .; /* VMA 시작 */
*(.data)
__data_end = .; /* VMA 끝 */
} >SRAM
__data_load_start = LOADADDR(.data); /* LMA 시작 */
}
설명
이것이 하는 일: .data 섹션이 SRAM(0x20000000)에서 실행되도록 VMA를 설정하지만, 실제 바이너리 파일에는 Flash의 .text 섹션 바로 뒤에 저장되어, 부팅 시 Flash에서 SRAM으로 복사됩니다. 첫 번째로, .data : AT(ADDR(.text) + SIZEOF(.text))는 .data 섹션의 LMA를 "텍스트 섹션 주소 + 텍스트 크기"로 설정합니다.
예를 들어, .text가 0x08000000부터 10KB라면 LMA는 0x08002800이 됩니다. 왜 이렇게 하는지는 Flash에 코드와 데이터를 연속으로 배치하여 플래싱을 한 번에 하기 위함입니다.
그 다음으로, } >SRAM이 실행되면서 VMA는 SRAM의 ORIGIN인 0x20000000으로 설정됩니다. 내부에서 링커는 심볼 참조(변수 주소)를 모두 VMA로 해석하므로, Rust 코드에서 &COUNTER는 0x20000000 영역의 주소를 반환합니다.
하지만 실제 바이너리 파일(.bin)에서는 0x08002800 오프셋에 초기값이 저장되어 있습니다. 세 번째 단계로, 부팅 시 스타트업 코드가 실행됩니다.
__data_load_start(LMA)에서 __data_start(VMA)로 __data_end - __data_start 바이트를 복사합니다. 예를 들어, unsafe { ptr::copy_nonoverlapping(__data_load_start as *const u8, __data_start as *mut u8, __data_end as usize - __data_start as usize); }로 구현합니다.
마지막으로, 복사가 완료되면 모든 전역 변수가 올바른 초기값을 가지며, 이후 쓰기가 SRAM에서 발생하여 하드웨어 예외가 없습니다. 여러분이 이 코드를 사용하면 Flash에 프로그램을 플래싱한 후 부팅만 하면 자동으로 데이터가 RAM에 로드되어, 전역 변수를 자유롭게 수정할 수 있습니다.
실무에서의 이점은 첫째, Flash 수명 연장(읽기만 발생), 둘째, 빠른 RAM 액세스로 성능 향상, 셋째, 표준 C/Rust 초기화 시맨틱을 따라 이식성이 높습니다.
실전 팁
💡 .bss는 초기값이 없으므로 AT 불필요 - 부팅 시 VMA를 0으로 채우기만 하면 됩니다.
💡 LOADADDR(.data)와 ADDR(.text) + SIZEOF(.text)가 일치하는지 ASSERT로 검증하여 실수를 방지하세요.
💡 복사 코드는 _start 함수 최상단에 배치하여 다른 초기화보다 먼저 실행되도록 하세요.
💡 대용량 .data는 부팅 시간을 늘리므로, 가능하면 상수는 .rodata로, 변수는 .bss로 이동하세요.
💡 DMA를 사용한다면 .data 섹션을 캐시 라인 경계(보통 32바이트)에 정렬하여 캐시 일관성 문제를 방지하세요.
6. 페이지 테이블 정렬 - 4KB 경계와 하드웨어 요구사항
시작하며
여러분이 페이징을 활성화했는데 "Page Fault" 예외가 발생하면서 시스템이 트리플 폴트로 리부팅된 경험이 있나요? 또는 CR3 레지스터에 페이지 테이블 주소를 설정했는데 하위 12비트가 0이 아니라는 경고를 받은 적이 있을 것입니다.
x86_64 페이징 시스템은 페이지 테이블(PML4, PDPT, PD, PT)이 4KB(0x1000) 경계에 정렬되어야 한다는 하드웨어 제약이 있습니다. CPU는 CR3 레지스터를 읽을 때 하위 12비트를 무시하고 4KB 배수만 사용하므로, 정렬되지 않은 주소를 쓰면 잘못된 테이블을 참조하여 페이지 폴트가 연쇄적으로 발생합니다.
ARM도 마찬가지로 TTB(Translation Table Base)가 16KB 정렬을 요구합니다. 바로 이럴 때 필요한 것이 섹션 정렬과 변수 정렬을 동시에 설정하는 링커 스크립트입니다.
이를 통해 페이지 테이블 구조체가 자동으로 올바른 경계에 배치되어 하드웨어가 요구하는 조건을 만족시킵니다.
개요
간단히 말해서, 페이지 테이블 정렬은 .page_tables : ALIGN(4K) 같은 섹션 정렬과 Rust의 #[repr(align(4096))] 속성을 결합하여 데이터 구조가 하드웨어 제약을 충족하도록 강제하는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, PML4 테이블은 512개의 8바이트 엔트리(총 4KB)로 구성되며, CPU는 CR3의 상위 52비트를 페이지 테이블의 물리 주소로 해석합니다.
하위 12비트는 플래그로 사용되므로, 테이블이 0x101000이 아닌 0x101234에 있으면 CPU는 0x101000을 읽어서 잘못된 데이터를 테이블로 간주합니다. 예를 들어, 정렬되지 않으면 무작위 메모리가 페이지 엔트리로 해석되어 커널이 존재하지 않는 주소에 접근하려 합니다.
기존에는 수동으로 align 4096 지시자를 코드마다 추가했다면, 이제는 링커 스크립트로 특정 섹션(.page_tables)을 자동으로 정렬하여 모든 페이지 테이블이 안전하게 배치됩니다. 이 개념의 핵심 특징은 세 가지입니다.
첫째, ALIGN(4K)는 섹션 시작을 4096의 배수로 올림하여 첫 번째 요소가 정렬됩니다. 둘째, 섹션 내 각 변수도 Rust의 #[repr(align)]로 정렬하여 배열의 모든 요소가 경계를 만족합니다.
셋째, 정렬을 검증하는 ASSERT로 빌드 타임에 오류를 잡습니다. 이러한 특징들이 중요한 이유는 런타임 페이징 버그가 디버깅하기 매우 어렵고 시스템 전체를 마비시키기 때문입니다.
코드 예제
/* Rust 코드에서 페이지 테이블 정의 */
#[repr(align(4096))]
#[link_section = ".page_tables"]
struct PageTable {
entries: [u64; 512],
}
/* 링커 스크립트 */
SECTIONS
{
.page_tables : ALIGN(4K) {
__page_tables_start = .;
*(.page_tables)
__page_tables_end = .;
} >RAM
/* 정렬 검증 - 시작 주소가 4KB 배수인지 확인 */
ASSERT(__page_tables_start % 4096 == 0, "페이지 테이블 정렬 오류")
}
설명
이것이 하는 일: .page_tables 섹션을 4KB 경계로 정렬하고, Rust 구조체도 4096바이트 정렬을 강제하여, 페이지 테이블 주소가 항상 0x????000 형태가 되도록 보장합니다. 첫 번째로, Rust의 #[repr(align(4096))]는 컴파일러에게 "이 구조체는 4096바이트 배수 주소에만 배치하라"고 지시합니다.
예를 들어, 스택이나 힙에 할당될 때도 이 제약이 유지되지만, 링커 스크립트와 조합하면 정확한 섹션에 고정할 수 있습니다. 왜 이렇게 하는지는 #[link_section]만으로는 섹션 내 위치를 보장하지 못하기 때문입니다.
그 다음으로, .page_tables : ALIGN(4K)가 실행되면서 링커는 위치 카운터를 4096의 배수로 올립니다. 내부에서 만약 현재 위치가 0x200234라면 0x201000으로 점프하여 공백을 남기고, 이 주소부터 .page_tables 섹션을 배치합니다.
*(.page_tables)는 Rust 코드의 PageTable 구조체를 여기에 넣습니다. 세 번째 단계로, ASSERT가 빌드 타임에 검증합니다.
__page_tables_start % 4096 == 0이 거짓이면 "페이지 테이블 정렬 오류" 메시지와 함께 링크가 실패합니다. 이는 휴먼 에러(예: ALIGN 빠뜨림)를 조기에 잡아줍니다.
마지막으로, 여러분은 unsafe { CR3::write(__page_tables_start as u64); }로 CR3에 주소를 쓸 때 하위 12비트가 0임을 보장받습니다. 여러분이 이 코드를 사용하면 페이지 테이블을 생성할 때마다 정렬을 고민할 필요 없이, 섹션에 넣기만 하면 자동으로 하드웨어 요구사항을 만족합니다.
실무에서의 이점은 첫째, 트리플 폴트 같은 치명적 버그를 사전 방지하고, 둘째, 여러 페이지 테이블을 배열로 관리할 때 모든 요소가 정렬되며, 셋째, ARM/RISC-V 등 다른 아키텍처로 포팅 시 정렬값만 바꾸면 됩니다.
실전 팁
💡 페이지 테이블뿐만 아니라 GDT, IDT도 정렬이 필요하므로 각각 전용 섹션을 만드세요.
💡 ALIGN(4K)는 공백을 만들 수 있으므로, 섹션 순서를 신중히 배치하여 메모리 낭비를 최소화하세요.
💡 동적 할당 페이지 테이블은 allocator가 정렬된 주소를 반환하도록 Layout::from_size_align(4096, 4096)을 사용하세요.
💡 ASSERT 외에도 #[cfg(debug_assertions)]로 런타임에 assert_eq!(addr & 0xFFF, 0)을 추가하면 디버그 빌드에서 이중 검증됩니다.
💡 x86_64의 PDPT는 32바이트 정렬만 필요하지만, 일관성을 위해 4KB로 통일하면 코드가 단순해집니다.
7. 스택 공간 예약 - .bss에 고정 크기 스택 배치
시작하며
여러분이 OS를 부팅했는데 첫 함수 호출에서 바로 크래시가 발생하거나, 스택 오버플로우가 커널 코드를 덮어써서 이상한 동작을 한 경험이 있나요? 또는 스택 포인터(RSP)를 수동으로 설정하려는데 어느 주소를 사용해야 할지 몰라서 임의의 값을 넣은 적이 있을 것입니다.
일반 프로그램은 OS가 스택을 설정하지만, OS 커널 자체는 스스로 스택을 준비해야 합니다. 부트로더가 제공하는 임시 스택은 작고 불안정하므로, 커널은 .bss 섹션에 충분한 스택 공간(보통 16KB~1MB)을 예약하고 _start 함수에서 RSP를 설정해야 합니다.
스택이 없거나 너무 작으면 함수 로컬 변수나 재귀 호출이 다른 메모리를 덮어써서 예측 불가능한 버그가 발생합니다. 바로 이럴 때 필요한 것이 링커 스크립트에서 스택을 섹션으로 정의하는 기법입니다.
이를 통해 스택 시작/끝 주소를 심볼로 내보내고, Rust 코드에서 RSP를 안전한 값으로 초기화할 수 있습니다.
개요
간단히 말해서, 스택 예약은 .bss 섹션 내에 .stack 서브섹션을 만들어 KEEP(*(SORT_NONE(.stack))) 같은 구문으로 고정 크기 버퍼를 배치하고, 끝 주소를 심볼로 내보내는 방법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, x86_64는 스택이 높은 주소에서 낮은 주소로 성장하므로, RSP는 스택 영역의 "끝"(높은 주소)을 가리켜야 합니다.
예를 들어, 스택이 0x200000~0x210000이면 RSP는 0x210000으로 초기화합니다. 스택 크기가 부족하면 깊은 재귀나 큰 로컬 배열이 .text 섹션을 덮어써서 실행 코드가 손상됩니다.
기존에는 하드코딩된 주소(예: 0x200000)를 사용했다면, 이제는 링커가 커널 크기에 따라 동적으로 스택 위치를 결정하여 코드 변경 없이 안전하게 확장됩니다. 이 개념의 핵심 특징은 세 가지입니다.
첫째, Rust에서 #[link_section = ".stack"] static mut STACK: [u8; 16384];로 스택 버퍼를 선언합니다. 둘째, 링커 스크립트에서 KEEP으로 보존하고 끝 주소를 __stack_top 심볼로 내보냅니다.
셋째, _start 함수에서 mov rsp, __stack_top으로 RSP를 설정하여 스택을 활성화합니다. 이러한 특징들이 중요한 이유는 스택 없이는 함수 호출조차 불가능하기 때문입니다.
코드 예제
/* Rust 코드: 16KB 스택 버퍼 정의 */
#[link_section = ".stack"]
#[no_mangle]
static mut STACK: [u8; 16384] = [0; 16384];
/* 링커 스크립트 */
SECTIONS
{
.bss : ALIGN(4K) {
__bss_start = .;
*(.bss)
/* 스택 섹션을 .bss 내에 배치 */
. = ALIGN(16); /* 16바이트 정렬 (SSE 요구사항) */
__stack_bottom = .;
KEEP(*(.stack))
__stack_top = .; /* RSP 초기값 */
__bss_end = .;
} >RAM
}
설명
이것이 하는 일: .stack 섹션에 16KB 버퍼를 배치하고, 그 끝 주소를 __stack_top으로 내보내어, _start 함수에서 mov rsp, __stack_top으로 스택 포인터를 설정합니다. 첫 번째로, Rust의 static mut STACK: [u8; 16384]는 16KB 배열을 .stack 섹션에 배치합니다.
= [0; 16384]는 명시적 초기화이지만, .bss로 처리되어 실제 바이너리에는 크기 정보만 있습니다. 왜 이렇게 하는지는 .data에 넣으면 바이너리가 16KB 커지기 때문입니다.
그 다음으로, . = ALIGN(16);이 실행되면서 스택을 16바이트 경계에 정렬합니다.
내부에서 SSE 명령어(movaps 등)는 16바이트 정렬된 스택을 요구하므로, 이를 만족시키지 않으면 #GP(General Protection Fault)가 발생합니다. __stack_bottom = .;는 스택 시작 주소를 캡처하여 나중에 스택 오버플로우 감지에 사용합니다.
세 번째 단계로, KEEP(*(.stack))이 STACK 배열을 여기에 배치하고, __stack_top = .;는 배치 직후의 주소(스택 버퍼 끝)를 저장합니다. 예를 들어, __stack_bottom이 0x200000이면 __stack_top은 0x204000(16KB 후)입니다.
마지막으로, _start 함수에서 unsafe { asm!("mov rsp, {}", in(reg) &__stack_top as *const _ as usize); }로 RSP를 설정하면, 이제 함수 호출과 로컬 변수가 정상 동작합니다. 여러분이 이 코드를 사용하면 스택 크기를 간단히 배열 크기로 조정할 수 있고, 디버거에서 __stack_bottom~__stack_top 범위를 확인하여 스택 사용량을 모니터링할 수 있습니다.
실무에서의 이점은 첫째, 스택 오버플로우 시 가드 페이지를 추가하여 조기 감지 가능하고, 둘째, 멀티태스킹 시 각 태스크에 독립 스택을 동일 방식으로 할당하며, 셋째, 스택 크기가 명시적이어서 메모리 예산 계산이 쉽습니다.
실전 팁
💡 스택 크기는 최대 재귀 깊이 * 프레임 크기로 계산하세요. 보통 커널은 16KB~64KB면 충분합니다.
💡 스택 상단과 하단에 가드 페이지(미매핑 페이지)를 두어 오버플로우 시 페이지 폴트로 감지하세요.
💡 __stack_bottom을 런타임에 참조하여 if rsp < __stack_bottom { panic!("Stack overflow"); } 같은 검사를 추가하세요.
💡 SMP 시스템에서는 각 CPU 코어마다 독립 스택이 필요하므로 .stack0, .stack1 같은 섹션을 여러 개 만드세요.
💡 UEFI 부팅 시에는 부트 서비스가 스택을 제공하지만, ExitBootServices 이후에는 자체 스택으로 전환해야 합니다.
8. PROVIDE와 HIDDEN - 기본값 심볼과 외부 가시성 제어
시작하며
여러분이 여러 모듈을 링크할 때 "multiple definition of __heap_start" 에러를 받거나, 외부 라이브러리가 링커 스크립트의 내부 심볼을 참조해서 이름 충돌이 발생한 경험이 있나요? 또는 선택적 기능(예: 힙 할당자)을 사용하지 않을 때 관련 심볼이 정의되지 않아 링크 에러가 난 적이 있을 것입니다.
링커 스크립트는 기본적으로 정의한 모든 심볼을 전역 네임스페이스에 노출합니다. 하지만 라이브러리나 모듈이 같은 이름의 심볼을 제공하면 충돌이 발생하고, 내부용 심볼(__internal_buffer 등)이 외부에 노출되면 ABI 안정성 문제가 생깁니다.
또한, 선택적 기능의 심볼이 항상 있어야 한다면 유연성이 떨어집니다. 바로 이럴 때 필요한 것이 PROVIDE 명령(기본값 제공)과 HIDDEN 속성(심볼 숨김)입니다.
PROVIDE는 "다른 곳에서 정의하지 않았으면 이 값을 사용"하는 약한 심볼을 만들고, HIDDEN은 "이 심볼은 링커 내부에서만 사용"하도록 제한합니다.
개요
간단히 말해서, PROVIDE는 PROVIDE(__heap_start = .); 같은 구문으로 기본값을 제공하되, 코드에서 같은 심볼을 정의하면 코드의 것이 우선되는 약한 바인딩을 만듭니다. HIDDEN은 HIDDEN(__internal = .);로 심볼을 링커 스크립트 내부로 제한하여 외부 가시성을 차단합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하면, 힙 할당자 라이브러리가 __heap_start를 정의하는 경우가 있습니다. 링커 스크립트도 동일 심볼을 정의하면 "multiple definition" 에러가 발생하지만, PROVIDE를 사용하면 라이브러리가 없을 때만 기본값을 제공하여 충돌을 방지합니다.
예를 들어, alloc 크레이트를 사용하지 않는 #![no_std] 프로젝트에서는 __heap_start가 불필요하지만, 링크 에러를 막기 위해 더미 값을 PROVIDE합니다. 기존에는 조건부 컴파일로 심볼을 분기했다면, 이제는 링커가 자동으로 "있으면 사용, 없으면 기본값"을 선택하여 빌드 설정이 단순해집니다.
이 개념의 핵심 특징은 세 가지입니다. 첫째, PROVIDE 심볼은 코드의 강한 정의가 항상 우선하므로 라이브러리와 공존 가능합니다.
둘째, HIDDEN 심볼은 nm 명령에서 보이지 않고 외부 링크도 불가능합니다. 셋째, 이 두 명령을 조합하여 PROVIDE(HIDDEN(__temp = .)); 같은 내부 기본값을 만들 수 있습니다.
이러한 특징들이 중요한 이유는 모듈화된 OS 개발에서 유연성과 안정성을 동시에 확보하기 때문입니다.
코드 예제
SECTIONS
{
.bss : {
/* 기본 힙 시작 주소 - 사용자 정의가 없으면 사용 */
PROVIDE(__heap_start = .);
/* 내부 임시 변수 - 외부에서 참조 불가 */
HIDDEN(__internal_bss_temp = .);
*(.bss)
/* 힙 끝 주소 제공 */
PROVIDE(__heap_end = ALIGN(4K));
}
/* 선택적 기능: TLS 지원 */
PROVIDE(__tls_start = 0); /* TLS 없으면 0 */
PROVIDE(__tls_end = 0);
}
설명
이것이 하는 일: __heap_start는 힙 할당자가 정의하지 않으면 링커 스크립트의 기본값을 사용하고, __internal_bss_temp는 링커 스크립트 내에서만 주소 계산에 사용되어 외부에서 참조할 수 없습니다. 첫 번째로, PROVIDE(__heap_start = .);는 현재 위치를 __heap_start에 할당하되, "약한 정의"로 표시합니다.
링커는 나중에 강한 정의(Rust의 #[no_mangle] static __heap_start: u8;)를 발견하면 그것을 우선시하고 PROVIDE 값을 무시합니다. 왜 이렇게 하는지는 사용자가 힙 위치를 커스텀하거나 외부 할당자를 쓸 수 있도록 하기 위함입니다.
그 다음으로, HIDDEN(__internal_bss_temp = .);이 실행되면서 심볼은 생성되지만 외부 가시성이 제거됩니다. 내부에서 이 심볼을 __heap_end = __internal_bss_temp + SIZEOF(.bss); 같은 표현식에 사용할 수 있지만, Rust 코드에서 extern "C" { static __internal_bss_temp: u8; }로 참조하면 "undefined reference" 에러가 발생합니다.
세 번째 단계로, TLS(Thread-Local Storage) 관련 심볼을 PROVIDE(0)으로 정의합니다. TLS를 사용하지 않는 단일 스레드 커널에서는 이 값들이 참조되지 않지만, 만약 라이브러리가 조건부로 참조한다면 0이라는 안전한 기본값을 제공하여 링크 에러를 방지합니다.
마지막으로, 여러분이 TLS를 활성화하면 .tls 섹션이 생성되고 링커가 자동으로 __tls_start를 실제 주소로 업데이트합니다. 여러분이 이 코드를 사용하면 힙 할당자를 교체하거나 제거해도 링커 스크립트 수정 없이 빌드가 성공하고, 내부 심볼이 노출되지 않아 ABI 버전 관리가 쉬워집니다.
실무에서의 이점은 첫째, 모듈 간 결합도를 낮춰 재사용성이 높아지고, 둘째, PROVIDE로 기본 설정을 제공하여 초보자도 쉽게 시작하며, 셋째, HIDDEN으로 구현 세부사항을 숨겨 리팩토링이 자유롭습니다.
실전 팁
💡 PROVIDE는 "기본값"을 제공하는 목적으로만 사용하고, 필수 심볼은 일반 정의를 사용하세요.
💡 HIDDEN과 static(링커 스크립트의 static, C와 다름)를 혼동하지 마세요. HIDDEN은 가시성, static은 주소 재배치 방식입니다.
💡 PROVIDE_HIDDEN 단축 명령으로 PROVIDE(HIDDEN(...))을 간결하게 작성할 수 있습니다.
💡 외부 라이브러리와 통합 시 PROVIDE를 사용하여 "링커 스크립트 또는 라이브러리 둘 중 하나"를 선택하게 하세요.
💡 디버깅 시 readelf -s로 심볼 테이블을 확인하여 PROVIDE 심볼이 사용되었는지 검증하세요.
9. 멀티부트 헤더 배치 - GRUB 호환성과 매직 넘버
시작하며
여러분이 커널을 컴파일하고 GRUB로 부팅했는데 "error: no multiboot header found" 메시지가 나오면서 부팅이 실패한 경험이 있나요? 또는 멀티부트 헤더를 코드에 넣었는데, 링커가 파일 중간에 배치해서 GRUB가 찾지 못한 적이 있을 것입니다.
GRUB Multiboot 스펙은 커널 파일의 처음 8KB(8192바이트) 내에서 4바이트 경계로 정렬된 0x1BADB002 매직 넘버를 검색합니다. 헤더가 이 범위 밖에 있거나 정렬되지 않으면 GRUB는 "유효하지 않은 커널"로 간주하고 부팅을 거부합니다.
헤더에는 매직 넘버, 플래그, 체크섬이 특정 순서로 있어야 하며, 추가 필드(로드 주소, 진입점 등)도 스펙을 따라야 합니다. 바로 이럴 때 필요한 것이 .multiboot_header 전용 섹션을 .text 맨 앞에 배치하는 링커 스크립트입니다.
이를 통해 헤더가 항상 파일 시작 부근에 위치하여 GRUB가 정확히 인식할 수 있습니다.
개요
간단히 말해서, 멀티부트 헤더 배치는 Rust로 헤더 구조체를 #[link_section = ".multiboot_header"]로 표시하고, 링커 스크립트에서 .text : { *(.multiboot_header) *(.text) }로 맨 앞에 배치하여 GRUB 스펙을 만족시키는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, GRUB는 부팅 시 커널 파일을 메모리에 로드하고 헤더를 찾아 플래그를 확인합니다.
플래그에 따라 비디오 모드 설정, 메모리 맵 제공, 모듈 로드 등을 수행하므로, 헤더가 잘못되면 커널이 필요한 정보를 받지 못합니다. 예를 들어, 플래그 비트 1은 "메모리 정보 요청"인데 이것이 없으면 사용 가능한 RAM 크기를 알 수 없습니다.
기존에는 어셈블리로 헤더를 하드코딩하고 수동으로 파일 앞에 넣었다면, 이제는 Rust 구조체로 타입 안전하게 정의하고 링커가 자동 배치합니다. 이 개념의 핵심 특징은 세 가지입니다.
첫째, 매직 넘버 0x1BADB002와 플래그, 체크섬의 합이 0이 되어야 합니다(스펙 요구사항). 둘째, 헤더는 4바이트 정렬되어야 하므로 ALIGN(4)를 사용합니다.
셋째, KEEP으로 최적화 제거를 방지하여 항상 존재하도록 합니다. 이러한 특징들이 중요한 이유는 헤더 없이는 GRUB가 커널을 부팅할 방법이 없기 때문입니다.
코드 예제
/* Rust 코드: 멀티부트 헤더 정의 */
#[repr(C, align(4))]
#[link_section = ".multiboot_header"]
struct MultibootHeader {
magic: u32, // 0x1BADB002
flags: u32, // 비트 0: 페이지 정렬, 비트 1: 메모리 정보
checksum: u32, // -(magic + flags)
}
#[link_section = ".multiboot_header"]
#[used]
static MULTIBOOT: MultibootHeader = MultibootHeader {
magic: 0x1BADB002,
flags: 0x00000003, // 페이지 정렬 + 메모리 정보
checksum: 0 - (0x1BADB002 + 0x00000003),
};
/* 링커 스크립트 */
SECTIONS
{
. = 1M;
.text : ALIGN(4) {
KEEP(*(.multiboot_header)) /* 맨 앞에 고정 */
*(.text)
}
}
설명
이것이 하는 일: .multiboot_header 섹션을 .text보다 먼저 배치하고 4바이트 정렬하여, GRUB가 파일 오프셋 0~8192 범위에서 0x1BADB002를 스캔할 때 반드시 발견하도록 보장합니다. 첫 번째로, Rust의 #[repr(C, align(4))]는 구조체를 C 레이아웃으로 만들고 4바이트 경계에 정렬합니다.
magic, flags, checksum은 각각 4바이트(u32)이므로 총 12바이트이며, 체크섬은 -(magic + flags)로 계산되어 세 값의 합이 0이 됩니다. 왜 이렇게 하는지는 GRUB가 체크섬을 검증하여 헤더 손상을 감지하기 때문입니다.
그 다음으로, #[used] 속성이 컴파일러에게 "이 정적 변수를 제거하지 마"라고 지시합니다. 내부에서 LLVM은 참조되지 않는 static을 최적화하려 하지만, #[used]가 이를 방지합니다.
#[link_section]은 이 변수를 .multiboot_header 섹션에 넣어 링커 스크립트로 위치를 제어할 수 있게 합니다. 세 번째 단계로, 링커 스크립트의 KEEP(*(.multiboot_header))가 실행되어 헤더를 .text 맨 앞(0x100000)에 배치합니다.
ALIGN(4)는 섹션 전체를 4바이트 경계에 맞추지만 헤더는 이미 구조체 정렬로 만족합니다. 마지막으로, GRUB는 파일을 로드하고 0x100000부터 스캔하여 즉시 0x1BADB002를 발견하고, 플래그 0x3을 읽어 "페이지 정렬된 모듈과 메모리 맵 제공"을 준비합니다.
여러분이 이 코드를 사용하면 GRUB가 커널을 인식하여 부팅하고, 메모리 맵(multiboot_info.mmap)을 통해 사용 가능한 RAM 영역을 알 수 있습니다. 실무에서의 이점은 첫째, 타입 안전한 Rust 코드로 헤더를 관리하여 매직 넘버 오타를 방지하고, 둘째, 플래그를 상수로 정의하여 가독성이 높으며, 셋째, Multiboot2로 업그레이드 시 구조체만 교체하면 됩니다.
실전 팁
💡 체크섬 계산을 컴파일 타임 상수로 하여 런타임 오버헤드를 제거하세요. 예: const CHECKSUM: u32 = 0 - (MAGIC + FLAGS);
💡 GRUB 메뉴에서 multiboot /boot/kernel.bin으로 로드하므로, 파일명과 경로를 grub.cfg에 정확히 명시하세요.
💡 QEMU로 테스트 시 -kernel 옵션은 멀티부트를 자동 처리하지만, 실제 하드웨어에서는 GRUB 설치가 필요합니다.
💡 Multiboot2는 더 많은 정보를 제공하지만 헤더가 복잡하므로, 초기에는 Multiboot1으로 시작하세요.
💡 objdump -h kernel.bin으로 .multiboot_header 섹션의 파일 오프셋을 확인하여 8KB 내에 있는지 검증하세요.
10. 심볼 주소 계산 - SIZEOF와 ADDR로 런타임 정보 제공
시작하며
여러분이 .data 섹션을 Flash에서 RAM으로 복사하려는데, 크기를 하드코딩해서 커널이 커질 때마다 수동으로 업데이트한 경험이 있나요? 또는 페이지 할당자가 커널 끝 주소를 알아야 하는데, 빌드할 때마다 objdump로 확인하고 소스 코드를 수정한 적이 있을 것입니다.
링커는 섹션의 크기, 주소, 정렬 등을 컴파일 타임에 계산하여 심볼로 내보낼 수 있습니다. SIZEOF(.text)는 텍스트 섹션의 바이트 크기를, ADDR(.data)는 가상 주소를, LOADADDR(.data)는 로드 주소를 반환합니다.
이 값들을 심볼로 만들면 Rust 코드에서 extern "C" { static __text_size: usize; }로 참조하여 동적으로 메모리를 관리할 수 있습니다. 바로 이럴 때 필요한 것이 링커 스크립트의 표현식 기능입니다.
이를 통해 "코드 크기", "데이터 복사 범위", "전체 커널 크기" 같은 정보를 빌드 시스템이 자동 계산하여 하드코딩을 제거할 수 있습니다.
개요
간단히 말해서, 심볼 주소 계산은 __kernel_size = SIZEOF(.text) + SIZEOF(.data) + SIZEOF(.bss); 같은 표현식으로 여러 섹션의 크기를 합산하거나, __data_lma = LOADADDR(.data);로 로드 주소를 얻어 심볼로 내보내는 기법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 페이지 할당자는 커널이 차지한 메모리 이후부터 할당을 시작해야 합니다.
커널 끝 주소를 하드코딩하면 코드 추가 시마다 충돌이 발생하지만, __kernel_end 심볼을 사용하면 링커가 자동으로 최신 주소를 제공합니다. 예를 들어, static mut ALLOCATOR: BumpAllocator = BumpAllocator::new(&__kernel_end as *const _ as usize);로 초기화합니다.
기존에는 빌드 스크립트로 objdump를 파싱하고 Rust 코드를 생성했다면, 이제는 링커 스크립트 한 곳에서 모든 주소를 관리하여 빌드 파이프라인이 단순해집니다. 이 개념의 핵심 특징은 세 가지입니다.
첫째, SIZEOF는 섹션의 메모리 크기(VMA 기준)를 반환하여 복사나 초기화 범위를 계산합니다. 둘째, ADDR과 LOADADDR을 비교하여 LMA/VMA 분리 여부를 확인합니다.
셋째, 표현식은 다른 심볼을 참조할 수 있어 복잡한 계산(예: 페이지 수 계산)이 가능합니다. 이러한 특징들이 중요한 이유는 메모리 레이아웃이 빌드마다 달라질 수 있기 때문입니다.
코드 예제
SECTIONS
{
.text : { *(.text) } >RAM
.data : AT(LOADADDR(.text) + SIZEOF(.text)) {
__data_start = .;
*(.data)
__data_end = .;
} >RAM
.bss : { *(.bss) } >RAM
/* 계산된 심볼 내보내기 */
__text_size = SIZEOF(.text);
__data_size = SIZEOF(.data);
__bss_size = SIZEOF(.bss);
/* 데이터 복사 정보 */
__data_lma = LOADADDR(.data); /* Flash 주소 */
__data_vma = ADDR(.data); /* RAM 주소 */
/* 전체 커널 크기 (페이지 수 계산용) */
__kernel_size = SIZEOF(.text) + SIZEOF(.data) + SIZEOF(.bss);
__kernel_pages = (__kernel_size + 4095) / 4096; /* 올림 */
}
설명
이것이 하는 일: 각 섹션의 크기를 SIZEOF로 계산하고, LOADADDR/ADDR로 로드/실행 주소를 구하여, 데이터 복사 루프와 페이지 할당자 초기화에 필요한 정보를 제공합니다. 첫 번째로, __text_size = SIZEOF(.text);는 링커가 .text 섹션에 배치한 모든 코드의 총 바이트 수를 계산하여 __text_size에 할당합니다.
예를 들어, 함수 10개가 각각 500바이트라면 __text_size는 5000입니다. 왜 이렇게 하는지는 나중에 "코드 영역을 읽기 전용으로 매핑"할 때 정확한 크기가 필요하기 때문입니다.
그 다음으로, __data_lma = LOADADDR(.data);가 실행되어 .data 섹션의 LMA(Flash 주소)를 저장합니다. 내부에서 LOADADDR은 AT 지시자로 설정된 주소를 반환하므로, 위 예제에서는 LOADADDR(.text) + SIZEOF(.text)와 동일한 값입니다.
__data_vma = ADDR(.data);는 VMA(RAM 주소)를 저장하여, 부팅 시 memcpy(__data_vma, __data_lma, __data_size)로 복사합니다. 세 번째 단계로, __kernel_size가 세 섹션의 크기를 합산합니다.
__kernel_pages는 이 크기를 4096으로 나누되, + 4095로 올림 효과를 냅니다. 예를 들어, __kernel_size가 10000이면 (10000 + 4095) / 4096 = 3.4...
→ 3 페이지로 계산됩니다. 마지막으로, 페이지 할당자는 &__kernel_end as *const _ as usize + __kernel_pages * 4096부터 자유 메모리로 사용합니다.
여러분이 이 코드를 사용하면 커널 크기가 변해도 코드 수정 없이 자동으로 올바른 주소를 참조하고, 빌드 로그에서 __kernel_size를 확인하여 Flash 용량을 사전 검증할 수 있습니다. 실무에서의 이점은 첫째, CI/CD에서 커널 크기 증가를 자동 감지하여 경고하고, 둘째, 여러 메모리 레이아웃(디버그/릴리스)을 하나의 스크립트로 관리하며, 셋째, 섹션 크기 정보를 부트 로그에 출력하여 최적화 대상을 식별합니다.
실전 팁
💡 SIZEOF는 VMA 크기를 반환하므로, .bss는 실제 파일 크기가 0이지만 SIZEOF는 메모리 크기를 반환합니다.
💡 ALIGN 함수로 크기를 올림할 수 있습니다. 예: __aligned_size = ALIGN(__kernel_size, 4K);
💡 표현식에서 나눗셈은 정수 나눗셈이므로 올림하려면 (size + align - 1) / align 패턴을 사용하세요.
💡 심볼을 Rust에서 사용할 때 타입은 중요하지 않으므로 extern "C" { static __kernel_size: u8; }로 선언하고 주소만 얻으세요.
💡 복잡한 계산은 링커 스크립트보다 Rust 코드에서 하는 것이 디버깅하기 쉬우므로, 기본 값만 내보내고 나머지는 코드로 처리하세요.