IT/Kernel

커널 빌드·부팅 과정 분석

싸후이 2007. 2. 26. 14:16
임베디드 시스템에 대한 관심과 수요가 높아질수록 리눅스에 대한 관심 또한 높아지고 있는 추세지만 리눅스 커널을 소스 코드 차원에서 더욱 깊숙이 들여다보려는 노력은 아직도 부족한 실정이다. 이번 글에서는 리눅스 커널 2.6 의 소스를 분석하며 이러한 시스템의 동작 원리를 심층적으로 살펴보고자 한다.

이제 10대의 중반에 들어서게 된 리눅스는 여러 상용 운영체제의 틈새에서 자신의 위치를 확실히 잡아가고 있는 모습이다. 특히 2003년 12월경에 발표된 리눅스 커널 2.6(이하 커널 2.6)은 뛰어난 성능 향상으로 임베디드 분야와 엔터프라이즈 분야에서 한 몫을 톡톡히 해내고 있다. 특히 커널의 스케줄링 부분이 향상되어 선점형 커널이 가능해짐으로써 실시간성을 요구하는 영역에 대한 활용이 크게 높아지게 되었다.

그저 이러한 성능 향상에만 만족할 것이 아닌 그 핵심을 이루는 원리에 보다 더 가깝게 다가가기 위해서는 리눅스 커널의 소스 코드를 직접 들여다보며 라인 단위로 분석하고 이해하는 과정이 필요할 것이다. 리눅스 커널 내에는 프로그래밍의 고급 기술과 각종 테크닉이 듬뿍 포함되어 있으므로 단순히 프로그래밍 스킬을 목적으로라도 충분히 공부해 볼 만하다 하겠다.

필자는 OS를 전공하지도 않았을 뿐더러 대단한 커널 전문가도 아니기 때문에 많이 모자라는 부분이 있을 테지만 같이 공부하는 입장에서 이해하기 힘든 부분이 어디인지 공유하는 공감대가 형성될 것이라고 생각되기 때문에 감히 이러한 시도를 해 보게 되었다. 모쪼록 독자들과 함께 조금이라도 더 배워보는 시간이 되었으면 하는 바람이다.

이번 글을 통해 다루고자 하는 것은 많이 사용되면서도 관련 자료가 좀 부족했던 부분을 중점적으로 다뤄보고자 한다. 지난 마소 6월호에서 커널 2.6에 대한 특집 기사를 다루며 일부분에 대한 분석이 이루어졌으므로 그 부분을 제외한 좀 더 다양한 분야를 다뤄볼 것이다.

여기에서는 임베디드 시스템에서 주로 사용되리라 생각되는 부팅 관련 부분을 ARM 계열의 프로세서와 관련하여 살펴볼 것이며, 새롭게 추가된 고성능 저널링 파일 시스템인 XFS 파일 시스템을 통한 파일 시스템 부분에 대한 분석과 리눅스가 주로 사용되는 영역인 네트워킹 분야에 대한 분석의 순서로 진행하고자 한다.

그 시작으로 리눅스의 빌드 및 부팅 과정을 살펴보려 한다. i386 아키텍처의 부팅 과정은 이미 웹 상의 여러 문서들과 서적에서 다루고 있으므로 여기서는 임베디드 시스템에서 많이 사용되는 MMU(Memory Management Unit)가 없는 ARM 계열의 프로세서에 uClinux 시스템의 빌드 및 부팅 과정을 살펴보도록 하겠다.

이는 상당히 아키텍처에 의존적인 하드웨어 관련 지식들을 포함하기 때문에 복잡한 영역이지만 MMU가 없는 시스템에서 메모리 관련 부분이 대폭 간소화 되었으므로 좀 더 간편하게 살펴볼 수 있으리라 생각된다.

uClinux 프로젝트
uClinux는 마이크로를 의미하는 ‘u’ 컨트롤러를 의미하는 C가 붙여진 말 그대로 마이크로 컨트롤러를 위해 최적화된 리눅스이다(‘유씨리눅스’라고 읽는다). uClinux는 MMU가 없는 프로세서(마이크로 컨트롤러)에서 리눅스를 동작시키기 위해 메모리 관리 부분을 개선하고 불필요한 부분을 제거한 버전으로 임베디드 시스템을 구축할 때 많이 이용되고 있다.

커널 2.6에서는 uClinux의 일부가 포함되어 있는데 커널 소스 트리의 arch 디렉토리를 살펴보면 모토롤라 m68K 계열의 MMU가 없는 프로세서를 위한 m68knommu라는 디렉토리가 있음을 볼 수 있다. uClinux 에는 이외에도 ARM 계열의 프로세서를 위한 armnommu 아키텍처도 지원하고 있지만 아직은 정식으로 리눅스 커널에는 포함되어 있지 않다.

필자가 m68k 프로세서와는 아직 인연이 닿지 않은 관계로 본 글에서는 uClinux 패치를 적용하여 armnommu 아키텍처에 대해서 살펴보기로 한다.

<그림 1> 삼성전자의 오픈소스 프로젝트 홈페이지(MMU가 없는 ARM 프로세서에 커널 2.6을 적용한 uClinux 프로젝트)

삼성전자의 오픈소스 프로젝트 홈페이지(opensrc.sec.samsung.com)에 가보면 MMU가 없는 ARM 계열의 프로세서를 위한 리눅스 커널 2.6 버전의 uClinux 패치를 받을 수 있다(<화면 1>). 또 ARM 프로세서를 위한 툴 체인과 커널을 테스트할 수 있는 에뮬레이터를 제공한다. 전체 설치 과정은 지면 관계상 생략했으니 홈페이지의 설치 문서를 참조하기 바란다.

여기서는 소스만을 살펴볼 것이기 때문에 공식 리눅스 커널 홈페이지에서 소스를 다운받은 후 홈페이지에서 hsc 패치를 다운받아 적용하면 된다. 필자가 최신 버전인 2.6.9를 이용했을 때 약간의 문제가 발생했으므로 여기서는 2.6.5 버전을 이용하도록 하겠다.

root@localhost ~ # wget http://www.kernel.org/pub/linux/kernel/
v2.6/linux-2.6.5.tar.bz2
root@localhost ~ # tar xvjf linux-2.6.5.tar.bz2
root@localhost ~ # wget http://opensrc.sec.samsung.com/downloads/
linux-2.6.5-hsc2.patch.gz
root@localhost ~ # gzip -dc linux-2.6.5-hsc2.patch.gz | patch -p0
root@localhost ~ # cd linux-2.6.5
커널 빌드 과정 분석
일단 부팅 과정을 살펴보기 전에 커널이 빌드되는 과정을 살펴보기로 하자. 커널이 빌드되는 과정은 Makefile과 링커 스크립트 파일인 linux.lds.S 파일을 통해 알 수 있으며 커널이 어떻게 부팅되어 동작하는지 알고 싶다면 이 과정도 알아두어야 한다.

linux/Makefile
당연히 커널 소스 트리의 최상위(앞으로 linux/라고 부르겠다)에서 make라고 입력한다면 최상위 디렉토리의 Makefile이 사용된다. 우리의 출발점도 역시 linux/Makefile이 되어야 할 것이다.

ifdef V
ifeq ("$(origin V)", "command line")
KBUILD_VERBOSE = $(V)
endif
endif
ifndef KBUILD_VERBOSE
KBUILD_VERBOSE = 0
endif

ifdef C
ifeq ("$(origin C)", "command line")
KBUILD_CHECKSRC = $(C)
endif
endif
ifndef KBUILD_CHECKSRC
KBUILD_CHECKSRC = 0
endif

ifdef O
ifeq ("$(origin O)", "command line")
KBUILD_OUTPUT := $(O)
endif
endif
먼저 맨 위쪽의 커널 버전 정보가 나온 후에는 이와 같은 명령행 옵션 체크 부분이 나온다. 커널 2.6에서는 복잡한 컴파일 과정을 그대로 보여주지 않고 간단한 형태로만 표시하는데 make V=1과 같은 형태로 옵션을 주면 예전과 같이 컴파일 과정을 그대로 볼 수 있다.

또한 C 옵션은 빌드 과정에서 체크 툴을 이용해 소스 파일을 체크하도록 하는 옵션이고 O 옵션을 통해 빌드된 파일의 출력 디렉토리를 지정할 수 있다. 그 후에는 빌드 과정에서 쓰이는 변수들을 적절히 설정한 후에 먼저 빌드를 위한 스크립트 파일들을 빌드한다.

ifeq ($(KBUILD_VERBOSE),1)
quiet =
Q =
else
quiet=quiet_
Q = @
endif

# Basic helpers built in scripts/
.PHONY: scripts_basic
scripts_basic:
$(Q)$(MAKE) $(build)=scripts/basic

clean: rm-files := $(wildcard $(CLEAN_FILES))
mrproper: rm-files := $(wildcard $(MRPROPER_FILES))
quiet_cmd_rmfiles = $(if $(rm-files),CLEAN $(rm-files))
cmd_rmfiles = rm -rf $(rm-files)

# Shorthand for $(Q)$(MAKE) -f scripts/Makefile.build obj=dir
# Usage:
# $(Q)$(MAKE) $(build)=dir
build := -f $(if $(KBUILD_SRC),$(srctree)/)scripts/Makefile.build obj
이것이 커널 2.6에서 도입된 새로운 형태의 빌드 방식으로서 맨 앞의 $(Q)(Quiet를 의미) 변수는 V 옵션에 의해 자세한 과정을 보일지 안 보일지 결정되어 치환된다. $(quiet) 변수는 역시 V 옵션에 의해 quiet_로 치환되거나 무시된다. 이 변수는 이후에 각 빌드 커맨드의 접두어가 되어 출력될 내용을 저장하게 된다.

빌드 과정에서 quiet_ 로 시작하는 커맨드를 만나면 그 내용을 출력하고 나머지 사항은 출력하지 않게 된다. 앞의 cmd_rmfiles 커맨드에서 quiet_cmd_rmfiles 커맨드는 현재 타겟(clean 혹은 mrproper)에 맞는 삭제할 파일이 존재하는지 검사하여 파일이 존재하는 경우에 'CLEAN (파일명)‘을 출력한다.

$(build) 변수는 Makefile의 아래쪽에 정의되어 있으며 script 디렉토리 아래에 있는 Makefile.build 파일에 포함된 규칙을 이용하여 빌드를 수행한다. Makefile.build 파일에 대한 내용은 이후에 좀 더 살펴보기로 하고 우선은 linux/Makefile의 내용을 살펴보기로 한다.

그 이후에는 make 명령 호출 시의 인자를 검사하여 menuconfig, xconfig 등과 함께 호출된 경우에는 scripts/kconfig 디렉토리의 내용을 빌드한다. 그리고 실제 커널 부분인 vmlinux 타겟을 빌드하기 위한 설정을 한다.

all:    vmlinux

# Objects we will link into vmlinux / subdirs we need to visit
init-y := init/
drivers-y := drivers/ sound/
net-y := net/
libs-y := lib/
core-y := usr/
SUBDIRS :=
init-y, drivers-y와 같이 -y 로 끝나는 타겟은 커널 컴파일 옵션 설정에서 built-in [*]을 선택한 것을 의미한다. 마찬가지로 -m은 모듈, -n은 선택되지 않았음을 의미한다. 기장 기본적이고 필수적인 디렉토리들은 이렇게 Makefile에서 직접 built-in 시키도록 설정되어 있으며 이후에 옵션 설정에 따라 SUBDIRS 변수에 빌드할 디렉토리의 목록이 추가된다. 그리곤 필요한 경우 .config 파일을 읽어 오고 이 파일이 새로 변경되었다면 .config.old 파일을 생성한다.

include $(srctree)/arch/$(ARCH)/Makefile

core-y += kernel/ mm/ fs/ ipc/ security/ crypto/

SUBDIRS += $(patsubst %/,%,$(filter %/, $(init-y) $(init-m) \
$(core-y) $(core-m) $(drivers-y) $(drivers-m) \
$(net-y) $(net-m) $(libs-y) $(libs-m)))

ALL_SUBDIRS := $(sort $(SUBDIRS) $(patsubst %/,%,$(filter %/, \
$(init-n) $(init-) \
$(core-n) $(core-) $(drivers-n) $(drivers-) \
$(net-n) $(net-) $(libs-n) $(libs-))))

init-y := $(patsubst %/, %/built-in.o, $(init-y))
core-y := $(patsubst %/, %/built-in.o, $(core-y))
drivers-y := $(patsubst %/, %/built-in.o, $(drivers-y))
net-y := $(patsubst %/, %/built-in.o, $(net-y))
libs-y1 := $(patsubst %/, %/lib.a, $(libs-y))
libs-y2 := $(patsubst %/, %/built-in.o, $(libs-y))
libs-y := $(libs-y1) $(libs-y2)
다음으로 주어진 아키텍처에 해당하는 디렉토리의 Makefile을 읽어온다. 여기서 $(srctree) 변수는 커널 소스의 루트 디렉토리를 가리킨다(O 옵션이 주어지지 않았다면 현재 디렉토리이다). core-y에 추가될 디렉토리를 지정해 준 뒤 $(SUBDIRS) 변수와 $(ALL_SUBDIRS) 변수를 설정한다.

$(SUBDIRS) 변수에는 빌드할 디렉토리(built-in이나 모듈로 설정된 부분) 정보가 들어가며 $(ALL_SUBDIRS) 변수에는 $(SUBDIRS) 변수에 포함될 디렉토리뿐 아니라 커널 내에 포함되지 않을 전체 디렉토리의 목록이 저장된다. 그리고 built-in으로 선택된 디렉토리들은 각 디렉토리의 오브젝트 파일들을 built-in.o 의 형태로 링크하도록 타겟의 이름을 변경한다.

head-y += $(HEAD)
vmlinux-objs := $(head-y) $(init-y) $(core-y) $(libs-y)
$(drivers-y) $(net-y)

quiet_cmd_vmlinux__ = LD $@
define cmd_vmlinux__
$(LD) $(LDFLAGS) $(LDFLAGS_vmlinux) $(head-y) $(init-y) \
--start-group \
$(core-y) \
$(libs-y) \
$(drivers-y) \
$(net-y) \
--end-group \
$(filter .tmp_kallsyms%,$^) \
-o $@
endef

# set -e makes the rule exit immediately on error

define rule_vmlinux__
+set -e; \
$(if $(filter .tmp_kallsyms%,$^),, \
echo ' GEN .version'; \
. $(srctree)/scripts/mkversion > .tmp_version; \
mv -f .tmp_version .version; \
$(MAKE) $(build)=init; \
) \
$(if $($(quiet)cmd_vmlinux__), \
echo ' $($(quiet)cmd_vmlinux__)' &&) \
$(cmd_vmlinux__); \
echo 'cmd_$@ := $(cmd_vmlinux__)' > $(@D)/.$(@F).cmd
endef

define rule_vmlinux
$(rule_vmlinux__); \
$(NM) $@ | grep -v '\(compiled\)\|\(\.o$$\)\|\( [aUw] \)\
|\(\.\.ng$$\)\|\(LASH[RL]DI\)' | sort > System.map

endef

LDFLAGS_vmlinux += -T arch/$(ARCH)/kernel/vmlinux.lds.s

# Finally the vmlinux rule
vmlinux: $(vmlinux-objs) $(kallsyms.o) arch/$(ARCH)/kernel
/vmlinux.lds.s FORCE
$(call if_changed_rule,vmlinux)
먼저 아키텍처 별로 정의한 $(HEAD) 변수가 존재하는 경우에는 이를 $(head-y) 변수에 포함시키고 vmlinux 타겟을 빌드하는 데 필요한 각 서브시스템 별 디렉토리를 설정한다. vmlinux를 빌드하는 커맨드는 마찬가지로 quiet 커맨드와 일반 커맨드가 존재한다.

$(vmlinux-objs) 변수에 설정된 디렉토리에 built-in.o 파일이 빌드된 상태라면 vmlinux로 링크된다. 이 때 아키텍처 별로 정의한 링커 스크립트 파일이 사용되도록 $(LDFLAGS_vmlinux) 변수를 설정한다. 그룹으로 묶은 core-y, driver-y, lib-y, net-y의 오브젝트 파일들(built-in.o)은 일반적으로 링크 단계에서 정의되지 않은 심벌을 찾기 위해 한번만 검색되는 것과 달리 재귀적인 검색이 가능하다.

make의 기본 타겟인 all에서 vmlinux를 요구하고 있으므로 vmlinux 부분이 실행되어 vmlinux 인자와 함께 if_changed_rule 함수를 호출한다. 이 함수는 타겟이 의존성을 가지는 파일들($(vmlinux-objs)에 지정된 built-in.o 파일들과 $(kallsyms.o)에 지정된 파일들과 해당 아키텍처의 kernel 디렉토리의 vmlinux.lds.s 파일)이 변경되었는지 검사하여 변경되었다면 rule_vmlinux를 실행하도록 되어 있다. rule_vmlinux는 cmd_vmlinux__를 실행하고 그에 대한 내용을 .vmlinux.cmd 파일에 저장한 뒤 System.map 파일을 생성한다.

linux/arch/armnommu/Makefile
먼저 ARM 계열의 프로세서에 맞도록 컴파일러 및 어셈블러 등의 옵션 설정을 추가한다. 그리고는 시스템 빌드 과정에서 쓰일 중요한 변수들의 값을 올바르게 설정하는 일을 한다.

# These are the default values for independt
DATAADDR := .
PROCESSOR := armv
head-y := arch/armnommu/kernel/head.o # default head
textaddr-y := 0x00008000 # default text address

# setup the machine name and the machine dependent settings
# in alphabetical order.
machine-$(CONFIG_ARCH_ATMEL) := atmel
textaddr-$(CONFIG_ARCH_ATMEL) := 0x01000000
machine-$(CONFIG_ARCH_S3C3410) := s3c3410
textaddr-$(CONFIG_ARCH_S3C3410) := 0x01020000
machine-$(CONFIG_ARCH_S5C7375) := s5c7375
textaddr-$(CONFIG_ARCH_S5C7375) := 0x00008000

# set the environment variables and export
MACHINE := $(machine-y)
TEXTADDR := $(textaddr-y)
ifeq ($(incdir-y),)
incdir-y := $(MACHINE)
endif
INCDIR := $(incdir-y)
export MACHINE PROCESSOR TEXTADDR GZFLAGS CFLAGS_BOOT
우선 $(DATAADDR) 변수와 $(PROCESSOR) 변수에 기본값을 설정하고 $(head-y) 변수와 $(textaddr-y) 변수도 초기화 한다. 그리고는 MMU가 없는 ARM 프로세서 중에서 현재 시스템에서 사용하고 있는 머신에 따른 세부 설정을 한다.

커널 컴파일시 S3C3410 머신을 선택했다면 앞의 Makefile에서 $(CONFIG_ARCH_S3C3410) 변수가 y 로 치환되며 나머지 값들(CONFIG_ARCH_ATMEL, CONFIG_ARCH_S5C7375)은 아무런 값을 갖지 않는다. 그러므로 MACHINE 변수에 입력되는 값은 machine-$(CONFIG_ARCH_S3C3410)인 “s3c3410"이 된다. 마찬가지로 TEXTADDR 변수도 0x01020000 값을 가지게 될 것이다.

# Does the machine has its own head.S ?
HEADMACH := arch/armnommu/mach-$(MACHINE)/head.S
ifeq ($(HEADMACH), $(wildcard $(HEADMACH)))
head-y := arch/armnommu/mach-$(MACHINE)/head.o
HEADMACH := ../mach-$(MACHINE)/head.o
else
HEADMACH :=
endif
export HEADMACH
ARM 계열의 프로세서도 각 머신별로 나누어진다. 커널 2.6.5 버전의 uClinux hsc2 패치에는 atmel과 s3c3410, s5c7375의 3가지 머신이 지원되고 있다. 각 머신에 종속적인 코드는 linux/arch/armnommu 디렉토리 밑에 mach-$(MACHINE)이라는 이름의 디렉토리 아래에 존재한다.

앞의 Makefile에서는 이러한 디렉토리가 존재하고 head.S 파일이 존재하는지 검사하여 존재하는 경우에는 이를 $(head-y) 변수로 지정하고 $(HEADMACH) 변수를 적절히 설정한다. $(head-y) 변수는 이미 앞에서 arch/armnommu/kernel/head.o로 지정되어 있기 때문에 머신 종속적인 head.S 파일이 존재하지 않는 경우에는 이 기본값을 사용하게 된다.

# If we have a machine-specific directory, then include it in the build.
core-y += arch/armnommu/kernel/ arch/armnommu/mm/
arch/arm/common/
ifneq ($(MACHINE),)
core-y += arch/armnommu/mach-$(MACHINE)/
endif

libs-y += arch/arm/lib/

# Default target when executing plain make
all: uCImage

boot := arch/armnommu/boot
OBJCOPYFLAGS :=-O binary -R .note -R .comment -S

uCImage: vmlinux linux.bin

linux.bin: vmlinux FORCE
@$(OBJCOPY) $(OBJCOPYFLAGS) vmlinux $@
@echo ' Kernel: $@ is ready'
또 아키텍처 종속적인 코드 부분을 core-y 변수에 추가하고 $(MACHINE) 변수가 정의되어 있다면 해당하는 mach-$(MACHINE) 디렉토리도 core-y에 추가한다. 그리고 ARM 아키텍처의 공통 라이브러리인 lib 디렉토리를 ilbs-y 변수에 추가한다. 그리고는 기본 타겟으로 uCImage를 지정하여 실행되도록 한다.

uCImage 타겟은 vmlinux와 linux.bin을 필요로 하는데 vmlinux는 앞에서 살펴보았듯이 linux/Makefile에서 빌드하고 그것을 이용하여 objcopy가 실행되어 linux.bin 파일을 생성한다. 이 때 $(OBJCOPYFLAGS)가 인자로 사용되는데 출력 포맷은 바이너리이고 .note 섹션 및 .comment 섹션의 정보와 모든 심벌 정보를 제거하도록 지정한다.

linux/scripts/Makefile.build
Makefile들을 살펴보면 $(Q)$(MAKE) $(build)=xxx 형태의 명령을 자주 볼 수 있다. 여기서 build는 linux/Makefile에 ‘build := -f $(if $(KBUILD_SRC),$(srctree)/)scripts/Makefile.build obj’로 정의되어 있다. 즉 'make -f scripts/Makefile.build obj=xxx'의 형태로 호출되는 것이다. 이제 이 linux/scripts/Makefile.build에 대해서 살펴보기로 하자.

# Read .config if it exist, otherwise ignore
-include .config

include $(obj)/Makefile

include scripts/Makefile.lib
우선 설정 파일을 읽어온다. -include는 파일이 없어도 에러를 내지 말고 무시하라는 뜻이다. 그리고 인자로 주어진 $(obj) 변수가 가리키는 디렉토리 내의 Makefile을 읽어온 후에 전체 빌드 과정에서 사용될 변수와 함수들이 정의된 Makefile.lib 파일을 읽어온다. Makefile.lib 파일에서 특히 관심 있게 살펴봐야 할 것은 다음의 함수들이다.

if_changed = $(if $(strip $? \
$(filter-out $(cmd_$(1)),$(cmd_$@))\
$(filter-out $(cmd_$@),$(cmd_$(1)))),\
@set -e; \
$(if $($(quiet)cmd_$(1)),echo'$(subst ','\'',$($(quiet)cmd_$(1)))';)\
$(cmd_$(1)); \
echo 'cmd_$@ := $(subst $$,$$$$,$(subst ','\'',$(cmd_$(1))))' >
$(@D)/.$(@F).cmd)

if_changed_rule = $(if $(strip $? \
$(filter-out $(cmd_$(1)),$(cmd_$@))\
$(filter-out $(cmd_$@),$(cmd_$(1)))),\
@set -e; \
$(rule_$(1)))
먼저 if_changed 함수를 살펴보면 먼저 다른 변수 정의와 달리 ‘=‘ 연산자로 정의되어 있는 것을 볼 수 있다. Makefile에서 변수 및 함수를 정의할 때 ’:=‘ 와 ’=‘ 등의 연산자를 사용할 수 있는데 ’:=‘ 연산자는 정의하는 시점에서 인자로 사용된 변수를 바로 치환하여 대입하고 '=' 연산자는 정의된 변수 및 함수가 사용되는 시점에서 인자로 사용된 변수를 치환하는 차이점이 있다.

즉 if_changed 함수에서 사용된 $@ 변수는 실제로 if_changed 함수가 호출될 때 함수를 호출한 타겟 이름으로 치환된다. 동작 과정은 타겟이 의존하는 파일들이 새로 변경되었는지를 검사하고($? 변수 참조) 타겟의 커맨드와 함수의 인자로 넘어온 커맨드가 다른지 검사하여(인자로 넘어온 값은 $(1) 변수를 통해 참조한다) 조건이 만족되는 경우에 다음의 명령을 수행하게 된다.

명령의 앞부분에 @를 사용하여 명령 수행 과정 자체가 출력되는 것을 방지한 후에 set -e를 지정하여 명령 수행 과정 중에서 에러가 발생한 경우에 즉시 수행을 멈추도록 설정한다. 그리고 echo 명령을 이용하여 제일 처음 make를 호출 시에 주어진 V 옵션의 결과에 따라 정의된 $(quiet) 변수를 이용하여 원하는 방식으로 수행 과정을 화면에 출력한다.

그 후에는 실제로 커맨드 부분을 수행하고 그 결과를 해당 디렉토리의 .(타겟이름).cmd라는 이름의 파일로 저장한다. 비슷한 방식으로 if_changed_rule 함수에 의해 rule 부분이 수행된다. 그 뒤에는 컴파일러, 어셈블러, 링커 등에서 사용될 플래그 값과 링크, 압축 등의 일을 수행하는 각종 커맨드를 정의한다.

linux/scripts/Makefile.build에서는 현재는 지원되지 않는 O_TARGET, L_TARGET 등의 이전 버전의 커널에서 사용되던 타겟들이 사용되었는지 검사하여 경고 메시지를 출력한다. 그 후에는 컴파일. 어셈블리 등의 실제 작업에 필요한 커맨드들을 정의한다.

ifneq ($(strip $(lib-y) $(lib-m) $(lib-n) $(lib-)),)
lib-target := $(obj)/lib.a
endif

ifneq ($(strip $(obj-y) $(obj-m) $(obj-n) $(obj-) $(lib-target)),)
builtin-target := $(obj)/built-in.o
endif
빌드할 타겟을 위한 변수를 정의한다. 먼저 라이브러리에 관련된 타겟들이 있는지 검사하여 타겟이 존재한다면 빌드될 파일의 이름을 lib.a로 지정한다. 여기서 $(obj)는 linux/scripts/Makefile.build을 호출할 때 인자로 주어진 디렉토리를 나타낸다. 같은 방식으로 오브젝트 파일로 빌드될 타겟이나 라이브러리 타겟이 있다면 빌드될 파일의 이름을 built-in.o 로 지정한다. 이렇게 정해진 타겟들은 다음의 커맨드 부분에서 사용된다.

#
# Rule to compile a set of .o files into one .o file
#
ifdef builtin-target
quiet_cmd_link_o_target = LD $@
# If the list of objects to link is empty,
just create an empty built-in.o

cmd_link_o_target = $(if $(strip $(obj-y)),\
$(LD) $(ld_flags) -r -o $@ $(filter $(obj-y), $^),\
rm -f $@; $(AR) rcs $@)

$(builtin-target): $(obj-y) FORCE
$(call if_changed,link_o_target)

targets += $(builtin-target)
endif # builtin-target
먼저 builtin-target의 경우를 살펴보자. 앞에서 보았듯이 builtin-target이라는 변수가 정의되어 있다면 오브젝트 파일을 빌드할 타겟이 존재하는 경우이다. 이 경우 커널 내로 포함되는 각각의 오브젝트 파일을(이 파일들의 목록은 $(obj-y) 변수에 저장되어 있다) built-in.o이라는 하나의 오브젝트 파일로 링크한다. quiet_cmd_link_o_target 커맨드는 단순히 "LD (타겟이름)“이라고만 출력한다.

실제 작업을 수행하는 cmd_link_o_target 커맨드는 $(obj-y) 변수를 검사하여 대상이 되는 오브젝트 파일이 존재하면 아래 줄의 $(LD)로 시작하는 명령을 수행하여 built-in.o 파일로 링크하는 일을 한다. $(filter $(obj-y), $^) 부분은 타겟의 의존성을 나타내는 부분에서 의미 없는 정보(FORCE)를 제거하는 일을 한다. 만약 $(obj-y) 변수가 아무 값도 가지고 있지 않는 경우에는 그 아래 줄이 수행되어 built-in.o 파일을 삭제하고 ar 명령을 통해 빈 built-in.o 파일을 생성한다.

실제로 이 커맨드가 수행되는 것은 그 아래쪽의 $(call if_changed, link_o_target) 부분을 통해서 이루어진다. 즉 $(obj-y) 변수에 속한 오브젝트 파일 중에서 새롭게 변경된 것이 있는 경우에만 cmd_link_o_target이 호출되도록 한다. 그리고 새로 링크된 파일을 $(targets) 변수에 추가한다. lib-target의 경우에도 이와 유사하게 동작한다.

# C (.c) files
# The C file is compiled and updated dependency information is generated.
# (See cmd_cc_o_c + relevant part of rule_cc_o_c)

quiet_cmd_cc_o_c = CC $(quiet_modtag) $@

ifndef CONFIG_MODVERSIONS
cmd_cc_o_c = $(CC) $(c_flags) -c -o $@ $<
..
endif

define rule_cc_o_c
$(if $($(quiet)cmd_checksrc),echo ' $($(quiet)cmd_checksrc)';) \
$(cmd_checksrc) \
$(if $($(quiet)cmd_cc_o_c),echo ' $($(quiet)cmd_cc_o_c)';) \
$(cmd_cc_o_c); \
$(cmd_modversions) \
scripts/basic/fixdep $(depfile) $@ '$(cmd_cc_o_c)'>$(@D)/.$(@F).tmp;\
rm -f $(depfile); \
mv -f $(@D)/.$(@F).tmp $(@D)/.$(@F).cmd
endef

# Built-in and composite module parts

%.o: %.c FORCE
$(call if_changed_rule,cc_o_c)
C 파일을 빌드하는 커맨드이다. 커맨드의 이름은 cmd_로 시작하며 다음으로 실행되는 프로그램의 이름(cc)이 오고 출력 파일의 확장자(o)와 소스 파일의 확장자(c) 순으로 결정된다. 즉, cmd_cc_o_c 커맨드는 cc 프로그램을 이용하여 c 파일을 o 파일로 빌드하는 커맨드이다.

quiet_cmd_cc_o_c는 단지 ‘CC (모듈인 경우 [M] 추가) (타겟이름)’ 이라는 문자열을 출력한다. 실제 cmd_cc_o_c 커맨드는 커널 컴파일 시 모듈 버전 정보를 포함시켰느냐의 여부에 따라 달라지는 데 포함되지 않은 경우에는 단순히 주어진 타겟 이름으로 컴파일을 수행한다.

rule_cc_o_c은 커맨드를 실행하는 규칙을 정의한다. 먼저 cmd_checksrc 커맨드를 실행하여 C 소스 파일을 검사하는데 이는 제일 처음 make를 수행시켰을 때 C 옵션을 설정했는지에 따라 달라진다. 그리고 cmd_cc_o_c와 cmd_modversions 커맨드를 실행하여 오브젝트 파일을 생성한 후에 linux/scripts/basic/fixdep 프로그램을 통해 의존성 관련 부분을 정리한 후에 이것을 .(타겟이름).cmd 파일로 저장한다. $@ 변수는 타겟 이름을 저장하는 데 이 중에서 $(@D) 변수는 $@ 변수의 디렉토리 부분을 $(@F) 변수는 $@ 변수의 파일명 부분을 저장한다.

linux/arch/armnommu/kernel/vmlinux.lds.S
vmlinux 타겟이 실행되면 링커가 실행될 때 linux/arch/armnommu/kernel/vmlinux.lds.s 파일을 이용하게 된다. 이 파일은 주어진 오브젝트 파일들을 어떤 식으로 링크하여 vmlinux 파일을 생성할 지에 대한 정보를 명시한 링커 스크립트 파일로 linux/arch/armnommu/kernel/Makefile 파일에 의해 linux/arch/armnommu/kernel/vmlinux.lds.S(대문자임에 주의) 파일로부터 생성된다(간단한 프리프로세싱 처리가 이루어진다). 이는 linux/scripts/Makefile.build 파일에 포함된 cmd_as_s_S 커맨드를 이용한다.

#include 

OUTPUT_ARCH(arm)
ENTRY(stext)

SECTIONS
{
. = TEXTADDR;
.init : { /* Init code and data */
_stext = .;
*(.init.text)
*(.proc.info)
*(.arch.info)
*(.taglist)
*(.init.data)
. = ALIGN(16);
*(.init.setup)
*(__early_param)
*(__param)
__initcall_start = .;
*(.initcall1.init)
*(.initcall2.init)
*(.initcall3.init)
*(.initcall4.init)
*(.initcall5.init)
*(.initcall6.init)
*(.initcall7.init)
__initcall_end = .;
*(.con_initcall.init)
*(.security_initcall.init)
. = ALIGN(32);
usr/built-in.o(.init.ramfs)
. = ALIGN(4096);
__init_end = .;
}
전체 출력 섹션은 크게 4가지로 구분된다. 첫 번째 섹션은 .init 섹션으로 시스템 초기화에 관련된 부분들이 맨 처음에 위치한다. ENTRY(stext) 커맨드에 의해 최초 시작 위치가 정해진다. stext는 linux/arch/arm-nommu/mach-s3c3410/head.S 파일에 존재하는 머신 의존적인 함수이며 커널에서 최초로 동작되는 부분이다. ‘.’ 변수는 현재 출력 위치의 주소 값을 저장하고 있다.

섹션이 위치할 주소는 TEXTADDR 변수에 의해 정해지는 데 이 값은 linux/arch/armnommu/Makefile에서 머신의 의존적인 값으로 결정된다. ‘. = TEXTADDR’ 이라는 표현은 현재 위치를 TEXTADDR 에 저장된 값으로 이동한다는 의미가 된다. ‘_stext = .' 이라는 표현은 현재 위치의 주소 값을 _stext 라는 변수에 저장한다는 의미이다. 그 후에는 각 입력 섹션에 맞는 위치의 순서대로 오브젝트 파일의 내용들이 링크된다. 실제로는 각 섹션 별로(__initcall_start, __initcall_end 와 같이) 섹션의 시작과 끝 주소를 저장하기 위한 변수들이 사용되고 있다.

섹션은 유사한 작업을 하는(동일한 속성을 지니는) 코드들을 함께 모을 수 있도록 하는 방법으로 소스 파일에서 gcc의 확장인 __attribute__ ((section ("섹션이름“))) 기능을 이용하여 지정할 수 있다. taglist라는 섹션에는 커널에서 사용할 메모리의 위치 및 크기, 루트 파일 시스템에 관련된 정보 등이 저장되어 있을 것이다. ALIGN(숫자)는 현재 위치의 주소 값을 주어진 숫자에 맞도록 정렬하게 한다.

/DISCARD/ : {          /* Exit code and data */
*(.exit.text)
*(.exit.data)
*(.exitcall.exit)
}
다음은 종료와 관련된 코드 및 데이터 부분인데 이 값은 모듈로 빌드되는 경우가 아니면(즉 지금처럼 커널로 빌드되는 경우라면) 의미가 없다. 커널이 종료된 후에는 아무런 작업도 할 수 없기 때문이다. 그래서 이러한 exit 에 관련된 섹션들은 포함시키지 않고 무시한다. /DISCARD/ 라는 출력 섹션은 주어진 입력 섹션의 내용을 포함시키지 않는다.

.text : {               /* Real text segment        */
_text = .; /* Text and read-only data */
*(.text)
*(.fixup)
*(.gnu.warning)
*(.rodata)
*(.rodata.*)
*(.glue_7)
*(.glue_7t)
*(.got) /* Global offset table */

_etext = .; /* End of text section */
}

. = ALIGN(16);
__ex_table : { /* Exception table */
__start___ex_table = .;
*(__ex_table)
__stop___ex_table = .;
}

RODATA
다음은 .text 섹션과 예외 테이블의 정보이다. 그 다음에는 linux/include/asm-generic/vmlinux.lds.h에 정의된 RODATA 매크로를 이용하여 읽기 전용 데이터와 커널 심벌 테이블 정보를 링크한다. 이 과정에서 GPL로 등록된 심벌들에 대한 데이터를 별도의 테이블로 관리하게 된다.

linux/arch/armnommu/kernel/vmlinux.S는 압축되지 않은 커널을 빌드하는 링커 스크립트이므로 전체 파일에 대해서 섹션 별로 자세히 분류되지만 zImage 등으로 압축된 커널을 빌드하게 되는 경우에는 .text 섹션 내에 압축된 실제 커널 이미지인 arch/armnommu/boot/compressed/piggy.o 파일이 포함된다(linux/arch/armnommu/boot/compressed/vmlinux.lds.in 파일 참조).

. = ALIGN(8192);

.data : {
*(.init.task)

. = ALIGN(4096);
__nosave_begin = .;
*(.data.nosave)
. = ALIGN(4096);
__nosave_end = .;

. = ALIGN(32);
*(.data.cacheline_aligned)

*(.data)
CONSTRUCTORS

_edata = .;
}
다음은 .data 섹션이다. .data 섹션의 맨 처음에는 태스크 구조체와 스택 정보가 있으므로 THREAD_SIZE 변수 값인 8192 단위로 정렬한다. 그 뒤로 저장되지 않는 데이터를 위치시킨 후에 CPU 캐시 라인에 맞도록 32바이트 단위로 정렬하여 캐시 라인에 정렬되는 데이터들을 링크한다. 그 후에는 .data라는 입력 섹션으로 설정된 일반 데이터를 위치시키게 된다. 데이터 섹션의 마지막 위치는 _edata라는 변수에 저장한다.

.bss : {
__bss_start = .; /* BSS */
*(.bss)
*(COMMON)
_end = . ;
}
다음은 .bss 섹션이다. BSS (Block Started by Symbol) 섹션은 초기화되지 않은 전역 변수들의 정보를 저장하며 시스템이 초기화 되는 과정에서 .bss 섹션의 데이터들은 0 으로 초기화된다. 링크 과정이 끝나고 생성된 vmlinux 이미지는 다음과 같은 형태로 배치되어 있을 것이다.
<그림 2> vmlinux 이미지의 메모리 맵

커널 부팅 과정 분석
이제 커널 이미지인 vmlinux가 빌드됐으니 그 실행 과정을 알아보기로 하자. 사실 여기까지 힘든 과정을 따라온 것에 비하면 앞으로 다룰 내용은 약간 싱겁게 느껴질지도 모르겠다.

linux/arch/armnommu/mach-s3c3410/head.S
링크 과정에서 살펴보았듯이 가장 먼저 수행되는 함수는 head.S에 포함된 stext 함수이다. linux/arch/armnommu/Makefile에서 살펴보았듯이 원래 stext가 포함된 기본 head.S 파일은 linux/arch/armnommu/kernel/ 디렉토리에 존재하지만 해당 머신이 존재하는 경우(즉, linux/arch/armnommu/Makefile에서 $(MACHINE) 변수가 설정된 경우)에는 linux/arch/armnommu/mach-(머신이름)/ 디렉토리에 존재하는 head.S 파일을 사용하게 된다.

그러므로 우리가 살펴볼 파일은 linux/arch/armnommu/mach-s3c3410/head.S 가 된다. 이 파일은 MMU가 없는 시스템에서 사용되도록 했기 때문에 복잡한 메모리 관련 부분이 생략되어 있으므로 시스템의 부팅 과정이 어떻게 이루어지는지 참고하기에 편리하다.

/*
* Kernel startup entry point.
*/
__INIT
.type stext, #function
ENTRY(stext)
mov r12, r0
mov r0, #PSR_F_BIT|PSR_I_BIT|MODE_SVC @ make sure svc mode
msr cpsr_c, r0 @ and all irqs disabled

adr r5, LC0
ldmia r5, {r5, r6, r8, r9, sp} @ Setup stack
...

LC0: .long __bss_start
.long processor_id
.long _end
.long __machine_arch_type
.long init_thread_union+8192
__INIT 매크로는 linux/include/linux/init.h에 정의된 것으로 현재 함수의 섹션을 .init.text로 설정하고 실행 가능함을 표시해 준다. 우선 r0 레지스터의 값을 r12 레지스터에 저장한 후에 r0에는 CPU의 상태를 변경하기 위한 값 (FIQ 및 IRQ 금지, SVC 모드)을 설정하여 CPSR(Current Processor Status Register)에 저장한다(msr 연산). 여기까지 3줄의 라인이 수행되면 프로세서는 모든 인터럽트가 금지되어 있는 상태에서 시스템 모드로 동작하게 된다.

이제 스택 포인터 (sp) 및 시스템에서 사용될 중요 변수들을 설정하는 작업을 수행한다. 먼저 r5 레지스터에 LC0 의 주소를 저장하고 이 값을 이용하여 다중 메모리 로드 연산(ldmia)을 수행하면 차례대로 r5 레지스터에는 __bss_start 변수의 주소가, r6 레지스터에는 processor_id 변수의 주소가, r8 레지스터에는 _end 변수의 주소가, r9 레지스터에는 __machine_arch_type 변수의 주소가 저장되고, sp (r13) 레지스터에는 init_thread_union 변수의 주소에 8192를 더한 값이 들어간다. 이 값은 init_thread 의 스택의 시작점이 된다.

        /*  Clear BSS */
mov r4, #0
1: cmp r5, r8
strcc r4, [r5],#4
bcc 1b

/* Pretend we know what our processor code is (for arm_id) */

ldr r2, S3C3410_PROCESSOR_TYPE
str r2, [r6]

ldr r2, S3C3410_MACH_TYPE
str r2, [r9]

mov fp, #0
b start_kernel

S3C3410_PROCESSOR_TYPE:
.long 0x34107700
S3C3410_MACH_TYPE:
.long MACH_TYPE_S3C3410
다음으로 .bss 영역을 0으로 초기화한다. r4 레지스터에 0을 저장하고 r5 레지스터와 r8 레지스터를 비교하는데 앞에 살펴보았듯이 r5 레지스터에는 .bss 영역의 시작 주소(__bss_start)가, r8 레지스터에는 .bss 영역의 마지막 주소(_end)가 저장되어 있다.

ARM 명령어는 명령어 내에 조건 옵션을 넣을 수 있기 때문에 여기서는 cc 옵션이 사용되었다. cc 는 c clear를 의미하는 것으로 cmp 결과 r5 레지스터의 값이 r8 레지스터의 값보다 작을 때 이 플래그가 설정된다. strcc는 cc 인 조건에서 str을 수행하라는 명령이다. 조건이 만족되지 않으면 실행되지 않는다. 다음 줄도 마찬가지로 cc 인 조건에서는 b (branch, jump) 명령으로 되돌아가서 .bss 영역을 모두 초기화할 때 까지 반복된다.

다음으로 프로세서 id 정보와 머신 아키텍처의 정보를 읽어 와서 각각 processor_id와 __machine_arch_type 변수에 저장한다. 일반적인 ARM 코드에서는 자신의 프로세서 id와 머신 아키텍처 정보를 알아내기 위해 좀 더 복잡한 루틴을 통해 검사하지만 임베디드 시스템에서는 자신이 어떤 하드웨어를 쓰고 있는지 알 수 있으므로 직접 값을 대입하였다.
마지막으로 실수 연산 레지스터에 0값을 설정하고 실제 리눅스 커널의 실행 함수인 start_kernel 함수를 호출한다.

linux/init/main.c::start_kernel
이 함수는 리눅스 커널의 거의 모든 초기화 부분을 처리하는 방대한 함수이다. 이 함수를 속속들이 다 설명하는 것은 굉장히 많은 시간과 노력이 필요할 뿐 아니라 여러 분야에 대한 전문적인 지식이 많이 필요하므로 여기서 다 설명할 수는 없다.

간략히 설명하면 start_kernel 함수는 시스템 전체에서 사용될 하드웨어 및 각 서브시스템에서 사용되는 자원들에 대한 초기화 루틴을 수행한다. 이 과정에서 먼저 arch_setup 함수가 호출되는데 이 함수는 커널이 시작될 때의 명령행 옵션을 인자로 받아 그에 알맞은 아키텍처 의존적인 초기화 루틴을 수행한다.

이 모든 과정이 끝나면 init_idle 함수를 호출하여 제일 낮은 우선순위를 갖는 idle 모드의 동작을 설정하고 rest_init() 함수를 호출하여 init라는 이름의 커널 쓰레드를 생성하는 데 이 커널 쓰레드는 사용자 모드의 /sbin/init 등의 프로그램으로 fork 되어 사용자 모드의 초기화 루틴을 수행한다(좀비 프로세스를 관리하는 1번 프로세스가 바로 이것이다).

커널을 분석해보는 재미를 느끼자
이번 글에서는 MMU가 없는 ARM 계열의 프로세서를 지원하는 uClinux를 통해 리눅스 커널 2.6의 빌드과 부팅 과정에 대해 간략히 알아보았다. uClinux가 아직 100% 모두 정확하게 공식 리눅스 커널 내에 포함되지 않았기 때문에 LXR(http://lxr.linux.no/source/)을 통해 살펴보기가 힘든 점이 있지만 프로젝트 홈페이지에서 패치 파일을 다운받아 직접 소스 파일을 따라가며 분석해 보는 맛을 느껴 보길 바란다.

필자의 부족한 실력과 시간 탓에 더욱 더 자세히 알아보지 못한 아쉬움이 남지만 독자들이 개인적으로 공부하면서 생기는 질문에 대해서는 아는 범위 내에서 성의껏 답변해 줄 생각이다. 이번 글에 연재된 내용 가운데 의문 사항이나 커널 빌드 및 부팅 과정에서의 질문 사항은 필자의 메일로 보내주기 바란다. 다음 글에서는 커널 2.6에서 정식으로 새롭게 추가된 XFS 파일 시스템에 대해서 알아보도록 하겠다.@

* 이 기사는 ZDNet Korea의 제휴매체인 마이크로소프트웨어에 게재된 내용입니다.

'IT > Kernel' 카테고리의 다른 글

Linux Kernel Configuration Guide  (0) 2010.06.16
linux kernel 옵션  (0) 2009.07.22
커널 2.6 옵션 설정  (0) 2007.02.26
네트워크 서브 시스템  (0) 2007.02.26
NetFilter Packet Flow  (0) 2007.01.15