Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

메모리에 대하여

9. 메모리에 대하여

여기에서 다루는 주제는 실질적인 프로그래밍 기술과는 관계없어 보일 수도 있을 것이며, 지극히 이론적인 것들이라서 재미없을 수도 있을 것이다. 그렇지만 여기에서 다루는 내용들은 당신이 성공적인 프로그래머가 되기 위한 든든한 후원자가 되어줄 것이다.

9.1. 컴퓨터는 메모리를 어떻게 바라보는가

우리는 이미 2장2절에서 컴퓨터가 메모리를 어떻게 보는지에 대해서 간단히 알아보았다. 컴퓨터는 메모리의 각 저장영역을 최소단위로 나누고, 여기에 일련의 연속된 번호를 붙여서 관리하게 된다. 최근의 컴퓨터들은 기본 512M이상의 크기를 가지게 되므로 기본 수백만 단위의 일련번호가 붙게 될것이다.

당신이 만든 프로그램은 필연적으로 데이터에 대한 조작을 필요로 하며, 이러한 데이터는 메모리에 쓰여지고 읽혀지게 된다. 기본적으로 컴퓨터는 데이터가 어느위치에 놓여야 하는지, 데이터가 어느정도의 저장영역을 필요로 하는지에 대해서 아는 바가 전혀 없으므로, 데이터가 어디에 어떻게 저장될지를 명령(instruction)을 통해서 컴퓨터에게 알려줘야 한다. 이런 명령역시 저장영역에 쓰여진후 메모리에 저장된다음 컴퓨터가 읽어가게 된다. 한마디로 모든게 메모리영역에 들어간다음 컴퓨터에 의해서 읽혀지고 실행되어진다.

필요로 하게 된다.

movl data_items(, %edi, 4), %ebx
            
예를 들어 위의 명령은 7의 저장영역을 필요로 한다. 처음 명령은 2바이트, 다음 레이지스터의 사용을 위해서 1바이트, 마지막으로 data_items영역을 위해서 4바이트를 사용하게 된다. 메모리상에서는 이러한 위치를 일련의 숫자를 통해서 찾아내게 된다. 메모리와 관련되어서 몇가지 용어를 정리해 보도록 하자.

Address

Address(주소)는 저장위치를 가리키기 위한 숫자다. 컴퓨터는 메모리의 첫번째 주소가 0이 되며, 계속 1씩 증가하며 메모리의 위치를 결정한다. 이러한 주소는 컴퓨터가 사용하기에는 편리하지만 인간이 보기에는 문제가 있기 때문에, 숫자 대신 이해하기 쉬운 심볼 (data_items 와 같은)을 사용한다.

Pointer

포인터는 주소값을 가지고 있는 메모리 혹은 레지스터영역이다. 위의 예제에서 %ebp가 포인터로, 현재의 stack영역을 point(가리킨다)한다. 대부분의 프로그래밍과정에 있어서 많은 수의 포인터를 사용하게 된다.

Byte

저장을 위한 최소단위다. x86프로세스의 경우 한바이트는 8bit로 0에서 255사이의 값을 가진다.

Word

레지스터의 일반적인 크다. x86 프로세스의 경우, 1 word는 4만큼의 저장영역(4byte)의 크기를 가진다.

앞으로는 저장영역이라는 단어대신에 위에서 정의 한 단어들을 쓰게 될 것이다.

9.2. Instruction Pointer

일단 레지스터가 무언지 그러고 어떤일을 하는지에 대해서 좀더 자세히 알아보도록 하겠다. 일단 우리는 몇가지 레지스터들에 대해서 배웠는데, 여기에서는 instruction pointer을 다루는 좀더 특별한 레지스터에 대해서 알아보도록 하겠다. 이 레지스터는 %eip로 사용할 수 있다. 우리는 컴퓨터는 모든걸 동일한 데이터로 취급하며 명령(instruction)도 예외가 없음을 알고 있다. 컴퓨터는 이게 명령이든지 아니면 일반 데이터든지 동일하게 처리를 하며, 또한 이들을 구분할 능력을 가지고 있지 않다. 그렇다면 컴퓨터는 어떻게 이러한 일련의 데이터에서 명령을 찾아내어서 그걸 실행을 시키게 되는 것일까 ? 해답은 instruction pointer에 있다. 이 instruction pointer라는 이름에서 우리는 이 포인터가 무언가를 가르킨다는 것을 알 수 있다. instruction pointer은 다음 명령(instruction)을 가르킨다. 컴퓨터는 instruction pointer를 살펴봄으로써, 다음에 어떤 명령을 실행해야 될지를 알수 이겠된다. 해당 명령을 실행하게 되면 instruction pointer는 다음 명령의 위치를 가리키도록 값이 증가가되며, 현재 실행해야될 명령을 마치게 되면, 다시 instruction pointer의 값을 살피게 된다. 이러한 명령실행 방식은 대부분의 경우에 유효할 것이다. 그러나 jmp등을 이용해서 명령을 점프하게 될경우는 어떻게 될가 ? 이럴 경우에 컴퓨터는 다음 명령이 아닌 다른 위치의 명령을 실행해야 할것이다. 이럴경우 우리는 일반적으로 아래와 같은 코드를 사용하게 될것이다.

jmp somewhere
            
이 코드는 아래의 코드와 동일한 일을 한다.
movl $somewhere, %eip
            
알다시피 somewhere는 프로그램의 영역을 참조하기 위한 상징이다. 그러므로 %eip가 직접 somewhere를 참조하도록 하는 것은 허용이 되지 않는다. 그러나 '$'표시를 이용하게 될경우 이를 허용할 수 있게 된다. 이는 $표시가 immediate mode addressiong모드로 somewhere를 값으로 다룰 수 있도록 해주기 때문이다. 만약 '$'표시를 사용하지 않을 경우 direct addressing mode가 되는데, 그럴경우 somewhere의 주소에 있는 값이 %eip로 이동이 되어버린다. 이것은 우리가 원하는 결과가 아니다. 아래는 실제 자주 사용되는 코드다.
movl $0, $ebx
            
0앞에 $표시가 붙어 있는데, 이것은 immediate-mode instruction를 의미하는 것이므로 0을 ebx에 집어 넣게 된다. 만약 $표시를 제거하게 되면 direct addressing mode가 되어서 0번주소의 값이 %ebx에 밀어넣어지게 된다. 메모리의 어드레싱 모드에 대한 자세한 내용은 2장을 참고하기 바란다.

9.3. 리눅스 프로그램에서의 메모리 구조

이번 장의 내용은 konstantin Boldyshev의 "Startup state of a Linux/i386 ELF binary"문서에 기반을 두고 있다. 관련된 문서는 http://linuxassembly.org/start.html을 참고하기 바란다.

당신이 만든 프로그램이 메모리에 적재될때, 각각의 .section의 이름이 가리키는 지점이 로드된다. 실제코드 (.text section)은 0x0848000 주소에 로드된다. .data섹션이 그다음에 위치하게 되고, 그뒤에 .bss 섹션이 위치하게 된다. .bss 는 버퍼영역으로 우리가 사용할 메모리의 영역을 미리확보할 수 있게 도와준다. 우리는 run-time전까지는 .bss에 값을 밀어 넣을 수 없다. .data섹션의 경우에는 공간을 할당(.long 등을 이용해서)하고 거기에 필요한 값을 즉시 밀어 넣을 수 있다. 이 값들은 프로그램이 컴파일되어서 만들어 질때, 프로그램 파일에 직접 들어가게 된다. 반면 .bss 섹션의 경우 프로그램이 실행전에는 초기화 되지 않는다. 그러므로 프로그램 파일 자체에 어떠한 값을 저장할 수 없다. 여기에는 단지 어느정도의 저장공간이 필요한지 에 대한 정보만이 있을 뿐이다. 이에 대한 내용은 뒤에 자세히 알아보록 할것이다.

마지막 저장영역은 0xbffffffff의 주소를 가진다. .text, .data, .bass 섹션은 0x0804800 이후에 위치하게 된다. .start 섹션은 위의 주소영역 사이에 위치하게 되며, 처음 두바이트는 0으로 채워지고 그다음에 프로그램의 이름이 온다. 프로그램의 이름의 끝은 NULL 문자 (\0)까지로 결정된다. 프로그램이름 다음에는 환경변수(12)가 위치하게 된다. 그다으 프로그램의 인자가 오게 된다. 프로그램인자는 프로그램을 실행시킬 때 유저에 의해서 프로그램에 전달되는 값이다. 예를 들어서 "maximum"이란 프로그램을 실행시킨다라고 하면, ./maximum 을 타이핑 해서 실행하게 되는데 ./maximum 이 프로그램 인자가 된다. 이경우으넨 단지 하나의 인자가 프로그램으로 전달될 것이다. 하지만 필요에 따라서 ."/maximum 48 59"와 같이 하나이상의 인자가 들어갈 수도 있다. 다음으로 stack(12) 영역이 있다. 스택은 임시로 자료를 저장하기 위한 영역으로 필요한 데이터를 밀어넣거나 가져올 수 있다. 스택의 제일 처음(꼭대기)는 %esp를 이용해서 가리킬 수 있으며, 값을 증가시킴으로써, 스택을 탐색할 수 있다. 스택에 어떤 값을 입력하면, 스택은 자연스럽게 다음 값을 가리킨다. 즉 값을 밀어넣게 되면 스택포인터가 감소하고 값을 가져오면(pop) 스택포인터가 증가하는 방식이다.

pushl %eax
            
위의 코드는 아래와 동일하다.
movl %eax, (%esp)
subl $4, %esp
            
%eax는 4바이트의 크기를 가진다. 이것을 스택에 밀어 넣었으니, 다음값을 넣을 때에는 4바이트 만큼 뒤로 밀려서 값을 넣어야 할 것이다. 이를 위해서 subl을 이용해서 %esp에서 4만큼을 빼주었다.
popl %eax
            
위의 코드는 pop을 한경우인데, 아래와 동일하다.
movl (%esp), %eax
addl $4, %esp
            
%esp에 괄호가 있는 이유는 %esp의 주소값이 아닌 %esp가 가리키는 곳의 값을 가져오길 원하기 때문이다. 4바이트 크기의 값을 꺼내왔기 때문에, %esp에서 4만큼을 더해줘서 스택의 다음 값을 가리키도록 해주었다.

이렇게 해서 stack는 값을 감소시키고, .bss섹션은 값을 증가시킨다는 것을 알게 되었다. 이들 중간영역은 break 라고 불리우며, 커널은 break을 벗어나서 서로의 영역을 침범하지 않도록 관리를 하고 있다. 만약 서로의 영역을 침범하게 되면 프로그램은 "segmentation falut" 에러 메시지를 출력하고 종료된다. 마찬가지로 당신의 프로그램이 0x08048000이전의 데이터영역에 접근할경우에도 동일한 에러메시지와 함께 종료가 된다.

9.4. Every Memory Address is Lie

그럼 왜 컴퓨터는 break 영역을 엑세스 하는걸 허용하지 않는 것일까 ? 이데 대한 답을 얻기 위해서는 컴퓨터가 메모리를 어떻게 다루는지에 대한 좀더 깊은 이해가 필요하다.

여기에서 여러분은 모든 프로그램이 같은 메모리 영역을 사용하는지에 대한 궁금증이 생길 것이다. 그런데 이렇게 될경우 다른 프로그램이 데이터를 덮어쓰거나 삭제하는게 가능해질 것이므로, 이는 프로그램의 작동에 치명적인 영향을 끼치게 될 것이다. 앞 장을 보더라도, 모든 프로그램은 동일한 주소에서 시작하는 것으로 보인다. 그러나 실제 이런일은 발생하지 않는다. 당신의 프로그램은 단지 virtual_memory(12) 에만 접근 가능하기 때문이다. 여러분이 사용하는 PC(12)를 포함한 거의 대부분의 프로그램은 물리적인 RAM(12)을 가지고 있을 것이다. 보통 256에서 1024Megabyte 좀 더 부유한 경우 그 이상의 램을 가지고 있을 것이다. 우리가 말하는 물리적 메모리 주소란 이러한 메모리의 칩에 직접적으로 부여된 영역을 말한다. 그렇다면 가상 메모리란 무엇인가 ? 가상의 메모리란 프로그램이 생각하는 메모리라고 간단히 생각할 수 있다. 여러분이 프로그램을 실행시키면, 프로그램이 로드되기 전에 Linux(12)는 물리적 메모리영역에서 사용가능한 비어있는 지역을 찾아내어서, 할당해 주고, 이 할당된 메모리의 주소를 0x0804800 이라고 알려주게 된다. 즉 프로그램마다 자신의 독립적인 가상의 공간을 만들어 주는 것이다.

이렇게 프로그램은 자신만의 가상의 공간을 만들고, 이 공간에서 놀게 된다. 당신의 컴퓨터에서 실행되는 모든 프로그램은 이 가상의 공간의 0x0804800 주소에 올려지고, 스택은 0xbffffff에서 시작이 된다. 이러한 가상의 공간에 주어지는 가상의 주소를 virtual address라고 한다. 모든 프로그램은 가상의 주소를 이용해서 데이터를 쓰거나 읽지만, 실제 데이터는 가상의 주소에 맵핑되는 물리적인 주소에 쓰여지게 된다. 이렇게 가상의 주소를 물리적인 주소에 대응시켜주는 과정을 mapping 이라고 한다. 이전장에서 우리는 bss와 stack사이의 메모리 영역을 break라고 정의한다고 배웠었다. 그러나 이러한 break가 존재하는 이유에 대해서 설명하진 않았는데, 그 이유에 대해서 알아보도록 하자. 가상메모리 세그먼트는 기본적으로 완벽하게 물리적 메모리 세그먼트와 맵핑을 시킬 수는 없다. 맵핑될 시간과 공간을 정확히 고려할 수 없기 때문이다. 그러다 보니 bss와 stack 사이에 가상메모리 주소로 매핑이 되지 않는 영역이 생겨날 수 있다. 이 부분은 프로그램에서 제어할 수 없는 영역이기 때문에 break영역이 되는 것이다.

가상 메모리를 이용함으로 써 얻을 수 있는 다른 이득도 있다. 굳이 물리적인 메모리가 아닌 하드디스크등도 메모리영역으로 사용할 수 있기 때문이다. 맵핑만 시켜주면 간단한 문제다. 당신의 컴퓨터가 16Mega의 메모리를 가지고 있다고 가정해보도록 하자. 이중 8Mega 정도는 Linux와 몇 가지 기본적인 응용프로그램들이 사용하고 있다. 그리고 당신은 gimp를 실행시켜서 그래픽 작업을 하기 원하는데, 최소한 20Mega 정도의 공간을 필요로 한다. 그런데 실제 남은 공간은 8Mega 정도로 gimp를 실행시킬만한 메모리 공간이 남아있지를 않다. 그러나 우리는 물리적 메모리 공간이 부족하더라도 프로그램을 실행시키는 방법을 알고 있다. swap 파티션(혹은 스왑파일)을 이용하면 된다. 그러면 Linux의 가상메모리 관리 프로세스는 부족한 양만큼을 swap영역에서 가져와서 쓰게 되므로 문제없이 gimp를 실행시킬 수 있게 된다. 이렇게 가상 메모리기법을 적용함으로써, 프로그램은 컴퓨터가 제공하는 이상의 메모리 공간을 사용할 수 있게 된다.

메모리는 pages라는 그룹으로 묶여서 나뉘게 된다. x86프로세스에서 실행되는 리눅스에서 한 페이지는 4096 byte의 크기를 가진다. 모든 메모리는 페이지단위로 매핑이 된다.

9.5. Getting More Memory

우리는 리눅스의 가상메모리가 리얼메모리와 swap를 함께 이용하고 있다는 것을 알고 있다. 만약 당신이 만든 프로그램이 아직 맵핑되지 않은 가상메모리를 접근하려고 한다면, segmentation fault 에러메시지를 출력하면서 종료되는 걸 확인할 수 있을 것이다. 이러한 문제는 여러분이 data section에 여러분이 필요한 만큼의 충분한 메모리 공간을 미리 확보하도록 해서 break point를 침범하지 않도록 하면 해결이 가능하다. 그러나 어느정도의 메모리 공간을 할당해야 할지 알아내기 힘든 경우가 발생한다. 예를 들어서 vi(12)와 같은 편집기를 만들경우, 편집기의 사용자가 어느정도의 문자를 쓰게 될지 알 수 없게 된다. 한줄정도로 편집을 마치고 저장할 수 있지만, 수십메가 이상의 데이터를 쓸 수 도 있다. 물론 한 1기가 정도 메모리를 할당해 버리는 경우를 생각해볼 수 있지만 단지 80 바이트의 문자를 쓰기를 원할경우 엄청난 낭비가 발생하게 된다. Linux(12)는 이러한 문제의 해결을 위해서 break point를 이동할 수 있게끔 수단을 제공하고 있다. 만약 더 많은 메모리 공간이 필요할 경우 이를 리눅스에 요청을 하면, 리눅스는 필요한 만큼의 메모리를 맵핑시켜주고 break point를 이에 맞게 이동시켜 준다.

이렇게 메모리를 추적하면서 break point를 변경시키는 방법은 한가지 문제점을 가지고 있다. 예를 들어 여러분이 파일을 열어서 데이터를 읽어들였다면, break point를 변경시켜서 필요한 만큼의 메모리 공간을 확보하게 될것이다. 여기에서 새로운 파일을 읽어들이기 위해서는 또 다시 break point의 이동이 필요하게 된다. 이 break point는 첫번째 파일의 영역을 벗어난 위치에 존재하게 될 것이므로, 맵핑은 되었지만 사용하지 않는 남는 공간이 생성되게 된다. 이미 맵핑이 되어있으므로, 다른 프로그램은 이 영역을 사용할 수 없게 된다. 만약 이러한 일이 계속적으로 반복된다면, 메모리 누수 현상이 발생하게 될 것이다. 그래서 메모리 관리가 필요하게 된다. 메모리 관리는 '''allocate'''와 '''deallocate'''의 두개의 기본적인 함수를 통해서 이루어지게 된다. 메모리가 필요하다면 allocate를 호출해서 필요한 공간을 할당받고, 필요 없을 경우 deallocate를 호출해서 이를 반환하게 된다.

9.6. 메모리 관리

그럼 어떤식으로 메모리를 관리하는지에 대해서 알아보도록 하자.

# 
.section .data

######### GLOBAL VARIABLE ###########
heap_begin:
.long 0

current_break:
.long 0

###### CONSTANTS ########
.equ UNAVAILABLE, 0
.equ AVAILABLE, 1
.equ BRK, 45                # brk(2) System Call
.equ LINUX_SYSTEMCALL, 0x80

###### STRCUTURE INFORMATION #######
.equ HEADER_SIZE, 8
.equ HDR_AVAIL_OFFSET, 0
.equ HDR_SIZE_OFFSET, 4
.section .text

######### FUNCTION ###########
## allcate_init
# PURPOSE : call this function initialize the functions
#           specifically, this sets heap_begin and current_break.
#           this has no parameters and no return value.
.globl allocate_init
.type allocate_init, @function

allocate_init:
pushl %ebp
movl  %esp, %ebp

# brk(2) system call
movl $BRK, %eax
movl $0, %ebx
int $LINUX_SYSCALL

incl %eax    # %eax now has last valid 
             # address, and we want the memory
             #location after that

movl %eax, current_break   #store the current break
movl %eax, heap_begin      #store the current break as our
                           #first address. this cause the allocate function
                           # to get more memory from Linux the first
                           #time it is run
movl %ebp, %esp            #exit function
popl %ebp
ret
##### END OF FUNCTION #####

##allocate##
#PURPOSE: This function is used to grab a section of memory.
#         It checks to see if there are any free blocks, and,
#         if not, it asks Linux for a new one.
#PARAMETERS: This function has one parameter - the size of the memory 
#            block we want allocate 
#RETURN VALUE:
#         This function returns the address of allocated
#         memory in %eax. if there is no memory available, it will
#         return 0 in %eax
##### PROCESSING ######
#Variables used;
#
#  %ecx - hold the size of requested memory
#  %eax - current memory segment being examined
#  %ebx - current break position
#  %edx - size of current memory segment
# We scan through each memory segment starting with heap_begin.
# We look at the size of each one, and if it has been allocated.
# If it's big enough for the requested size. and its available,
# if grabs that one. If it does not find a segment large enough,
# it asks Linux for more memory. In that case, it moves
# current_break up
.globl allocate
.type allocate,@function
.equ ST_MEM_SIZE, 8

allocate:
pushl %ebp
movl  %esp, %ebp

movl ST_MEM_SIZE(%ebp), %ecx # %ecx will hold the size we are
                             # looking for

movl heap_begin, %eax        # %eax will hold the current search 
                             # location
movl current_break, %ebx     # %ebx will hold the current break point

alloc_loop_begin:            #here we iterate through each
                             #memory segment
cmpl %ebx, %eax              #need more memory if these are equal
je move_break

movl HDR_SIZE_OFFSET(%eax), %edx  #grab the size of this memory
cmpl $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax)  # If the space unavailable, go the
je next_location                           #next one

cmpl %edx, %ecx              #If the space is avalable, compare
jle allocate_here            #the size to the needed size. If its
                             #big enough, go to allocate_here

#may want to add code here to
#combine allocations

next_location:
addl $HEADER_SIZE, %eax      #The total size of the memory segment
addl $edx, $eax              #is the sum of the size requested
                             #(currently stored in %edx), plus another
                             #8 storage locations for the header
                             #(4 for the AVAILABLE/UNAVAILABLE flag,
                             #and 4 for the size of the segment). so
                             #adding %edx and $8 to %eax will get
                             #the address of the next memory segment

jmp alloc_loop_begin

allocate_here:

movl $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax) #mark space as unavailable

addl $HEADER_SIZe, %eax

movl %ebp, %esp
popl %ebp
ret

move_break:

addl $HEADER_SIZE, %ebx      #noew we need to increase %ebx to
addl %ecx, %ebx              #where we _want_ memory to end, so we
                             #add space for the headers structure
                             #add space to the break for
                             #the data requested

                             #now its time to ask Linux for more
                             #memory

pushl %eax                   #save needed registers
pushl %ecx
pushl %ebx

movl %BRK, %eax

int $LINUX_SYSCALL

cmpl $0, %eax

jre error


popl %ebx                   #restore saved registers
popl %ecx
popl %eax

movl $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax) #set this memory as
                                          #unavailable, since we're about to
                                          #give it away
movl %ecx, HDR_SIZE_OFFSET(%eax)          #set the size of the memory
addl $HEADER_SIZE, %eax                   #move %eax to the actual start of
                                          #usable memory. %eax now holds the
                                          #return value
movl %ebx, current_break

movl %ebp, %esp
popl %ebp

ret

error:
movl $0, %eax
movl %ebp, %esp
popl %ebp
ret

####### END OF FUNCTION #######



## deallocate ##
# PURPOSE : The Purpose of this function is to give back
#           a segment of memory to the poll after we're done
#           using it. 
#
# PARAMETERS : The only parameter is the address of the memory we want to 
#              return to the memory pool
# RETURN VALUE : There is no return value 
# PROCESSING :
#          If you remmember, we actually hand program the
#          start of the memory that they can use, which is
#          8 storage locations after the actual start of the
#          memory segment. All we have to do is go back
#          8 locations and mark that memory as available,
#          so that the allocate function knows it can use it.
.globl deallocate
.type deallocate, @function
.equ ST_MEMORY_SEG, 4    # stack position of 
deallocate: 
                         # since the function is so simple, we
                         #don't need any of the fancy function
                         # stuff
movl ST_MEMORY_SEG(%esp), %eax  # get the address of the memory to free
                                #(normally this is 8(%ebp), buf since
                                #we didn't push %ebp or move %esp to
                                #%ebp, we can just do 4(%esp)
subl $HEADER_SIZE, %eax         #get the pointer to real beginning
                                #of the memory
movl $AVAILABLE, HDR_AVAIL_OFFSET(%eax) #mark it as available

ret
####### ENF OF FUNCTION ##########
위 코드는 그 자체가 완전한 프로그램이 아니다. 단지 program 섹션만 가지고 있으며, 때문에 _start 심볼을 가지고 있지 않다는 점을 주목하기 바란다. 그러므로 단독으로 실행될 수 없으며, 다른 코드에 링크되어서 해당 함수를 호출하는 형식으로만 사용될 수 있다. 아마도 C를 이용해서 다중소스 컴파일을 해보았다면 쉽게 이해할 수 있을 것이다. 자세한 내용은 나중에 다루도록 할 것이다. 일단은 다음과 같이 object형식으로 만들도록 한다.
# as alloc.s -o alloc.o
이제 코드를 분석해 보도록 하자.

9.7. 변수와 상수들

heap_begin:
.long 0

current_break:
.long 0
위 코드는 프로그램의 첫 부분으로 heap영역을 가리키기위해서 사용한다. 어셈블리 프로그램을 작성할 때 우리는 heap영역의 시작위치를 알 수가 없으며, 때문에 break point를 결정할 수도 없다. 그래서 0으로 일단 채워두었다. 주석을 보면 global variable(전역변수)라고 해둔걸 볼 수 있을 것이다. 프로그램을 작성하다보면 전역변수나 지역변수라는 말을 많이 들어 보았을 것이다. 지역변수는 procedure가 실행될 때 스택에 할당되는 변수를 말한다. 반면 전역변수는 프로그램이 시작될 때, 정의와 할당이 이루어진다. 이러한 이유로 전역변수는 프로그램이 종료될 때까지 프로그램 전역에 걸쳐서 사용되어지며, 지역변수는 프로시져의 종료와 동시에 사라지게 된다. 일반적으로 좋은 프로그램은 지역변수를 주로 사용하며, 전역변수는 제한적으로 사용한다. 지역변수에 대해서는 나중에 좀더 자세히 다루도록 하겠다.

다음은 상수(constants)영역이다. 상수는 어떤 값을 사용하기 쉽도록 재정의 한 Symbol 이라고 보면 된다. 아래의 예를 보도록 하자.

.equ UNAVAILABLE, 0
.equ AVAILABLE, 1
이는 UNAVAILABE를 사용할경우 이는 숫자 0을 사용하며, AVAILABLE를 사용할 경우 숫자 1이 사용되어 짐을 의미한다. 만약 이러한 상수대신에, 코드 중에 0과 1을 직접 사용하게 될경우 의미를 제대로 파악하기가 힘들 것이다. 또한 AVAILABLE의 값을 2로 하고 싶다고 할 경우에도, 단지 상수만 찾아서 값을 바꿔주면 된다. 상수를 사용하지 않았을 경우에는 일일이 코드를 찾아가면서 값을 바꿔줘야만 할 것이다.
.equ BRK, 45
.equ LINUX_SYSCALL, 0x80
int $0x80을 사용하는 것보다는 int $LINUX_SYSCALL이 훨씬 이해하고 사용하기도 쉽다. 일반적으로 상수는 프로그램상에 하드코딩 되는 값들을 관리하기 위해서 사용된다. 상수는 .equ를 이용해서 정의할 수 있다.

다음에서 구조체를 정의 하고 있다. 이 구조체는 메모리의 명확한 제어와 관련된 일련의 정보들로 이루어진다. 메모리를 사용하기 위한 가장 중요한 정보는, "시작위치"와 "크기"가 된다. 이들 정보를 위해서 4byte의 크기를 가지는 2개의 값이 정의가 되어야 할 것이다. 그리고 헤더정보를 가지는데, 이 헤더는 구조체의 크기를 알려주기 위해서 사용한다. 2개의 4byte크기의 상수가 사용되고 있음으로 8로 정의되면 될 것이다. 최종적으로 다음과 같이 구조체를 정의하고 있다.

.equ HEADER_SIZE, 8
.equ HDR_AVAIL_OFFSET, 0
.equ HDR_SIZE_OFFSET, 4
헤더에는 구조체의 크기를 가리키는 8이 정의되어 있다. available offset은 데이터가 저장되는 메모리상의 위치를 저장하기 위해서 사용한다. Size Offset 에는 할당된 메모리의 크기가 들어가게 된다. 메모리의 시작위치와 크기를 알게 되므로 메모리관리를 위한 최소한의 정보를 저장하기 위한 구색은 맞춘셈이다. 만약 메모리관리를 위해서 다른 어떤 정보를 더 추가해야 한다면, 상수하나를 더 추가하고 HEADER_SIZE를 변경시켜 주기만 하면 된다. 이러한 구조체는 전역변수들과 마찬가지로 코드의 가장 첫 부분에 위치하게 된다.

9.8. allocate_init 함수

이 함수는 간단하다. heap_begin과 current_break 변수값을 설정하게 되는데, 이들에 대한 내용은 이미 앞에서 다루었다. 이전 장의 내용을 주의깊게 읽어 보았다면, brk(2)시스템 호출을 이용해서 최근의 브레이크 포인터를 옮기는 아래의 코드를 쉽게 이해할 수 있을 것이다.

pushl %ebp
movl  %esp, %ebp

movl $BRK, %eax
movl $0, %ebx
int  $LINUX_SYSCALL

incl %eax
int $LINUX_SYSCALL이 실행된 후에, %eax는 주소의 마지막값이 채워지게 된다. 그러나 우리가 실제로 원하는 주소는 사용가능한 주소의 처음 값이다. 그래서 incl %eax를 이용해서 %eax를 증가시켰다. 이제 우리는 heap_begin영역으로 이동하면 된다. 아직 할당한 메모리가 없으므로 current_break와 heap_begin은 같은 값을 가지고 있을 것이다.
incl %eax
movl %eax, current_break
movl %eax, heap_begin
movl %ebp, %esp
popl %ebp
heap영역은 heap_begin과 current_break사이에 위치한다. 이 함수를 이용해서 힙의 처음 위치를 얻어냈으니 이제 allocate함수를 이용해서 heap을 필요한 만큼 확장시키기만 하면 된다.

9.9. allocate 함수

이 함수는 다음과 같은 일들을 하게 된다.

  • heap의 시작 위치로 간다.

  • heap의 끝인지 아닌지를 체크한다.

  • 만약 우리가 요청하는 메모리의 영역이 heap의 끝을 초과해 버린다면, unavailable(사용할수 없음) 을 리턴할 것이다.

  • 요청된 최근의 메모리 영역이 unavailable이라면, 2단계로 되돌아간다.

  • 때로는 available상태이지만, 메모리 영역이 요청한 만큼 충분하지가 않을 경우가 있다. 이경우에도 2 단계로 되돌아간다.

  • 현재 메모리 영역이 요청한 크기를 수용할만큼 충분히 크다면, unavailable를 표기하고 리턴한다.

위의 과정을 염두에 두고 코드를 다시 보게 되면, 아래의 코드의 내용이 쉽게 이해될 것이다.

다시 다음 코드로 넘어가보자.

pushl   %ebp
movl    %esp, %ebp
movl   ST_MEM_SIZE(%ebp), %ecx
movl   heap_begin, %ecx
movl   current_break, %ebx
위 코드는 레지스터를 초기화 하는 일을 하다. 처음 두라인은 함수에 들어가는 전형적인 코드들이다. 다음 라인에서는 스택에 할당하기 원하는 메모리의 사이즈를 밀어 넣는데, 이것은 함수의 인자로 사용될 것이다. 다음 라인에서 heap의 시작주소와 끝주소를 입력한다.

다음 섹션은 alloc_loop_begin이다. 여기에서는 우리가 필요로하는 메모리의 공간이 확보될 때까지, 지속적으로 메모리 영역을 체크하게 된다.

cmpl   %ebx, %eax
je     move_break
여기에서는 최근의 검사된 메모리의 영역인 %eax와 힙의 마지막 영역인 %ebx를 비교하게 된다. 검사한메모리의 여영역이, 마지막 힙의 영역과 같다면, 이는 우리가 필요로 요청한 메모리의 영역이상이 존재하고 있음을 의미한다. 그러므로 다음 단계는 건너 뛰고 바로 move_break로 점프하게 된다.
move_break:
addl  $HEADER_SIZE, %ebx
addl  %ecx, %ebx
pushl %eax
pushl %ecx
pushl %ebx
movl $BRK, %eax
int  $LINUX_SYSCALL
여기는 코드의 마지막 지점이다. %ebx에는 우리가 사용하기 원하는 메모리의 영역값이 들어가며, brk(2) 시스템 호출의 인자로 사용될 것이다. 그리고 스택에 저장하기 원하는 다른 레지스터 값들을 모두 저장한다. 마지막으로 brk(2)을 호출하고, 리턴값을 검사한다.
cmpl  $0, %eax
je    error
brk 시스템 호출에 문제가 없었다면, 스택의 레지스터 값들을 다시 복구 시키고, 메모리에 unavailabe 표시를 하고, 메모리사이즈를 기록한다음 %eax 의 포인터를 사용가능한 메모리의 처음으로 이동시킨다.
popl  %ebx
popl  %ecx
popl  %eax
movl  $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax)
movl  %ecx, HDR_SIZE_OFFSET(%eax)
addl  $HEADER_SIZE, %eax
마지막으로 새로운 브레이크 포인터와 할당된메모리의 포인터를 저장하고 리턴한다.

그럼 루프의 처음으로 다시 되돌아가서, 최근의 메모리가 힙의 마지막을 가리키지 않고 있을 경우 어떤일이 발생하는지를 알아보도록 하자.

movl HDR_SIZE_OFFSET(%eax), %edx
cmpl $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax)
je   next_locaton
            
처음 코드는 얻어낸 메모리영역의 크기를 %edx에 넣는 일을 한다. 그리고 나서 UNAVAILABLE표시가 되어 있는지를 확인한다. 만약 UNAVAILABLE로 표시가 되어 있다면, 이미 사용중인 영역이므로 next_location 으로 점프한다. 만약 AVAILABLE로 표시가 되어 있다면, 충분한 공간이 확보되어 있는지 아래의 코드로 확인하고, 충분한 공간이 확보되어 있다면 allocate_here로 점프하면 된다.
cmpl %edx, %ecx
jle allocate_here
            
allocate_here에서는 다음과 같은 일을 하게 된다.
movl $UNAVAILABLE, HDR_AVAIL_OFFSET(%eax)
addl $HEADER_SIZE, %eax
movl %ebp, %esp
popl %ebp
ret
            
확보된 메모리는 다른, 코드가 사용할 수 없도록 unabailable표시를 해두고, 헤더사이즈만큼 포인터를 이동하고, 필요한 값을 리턴하고 함수를 빠져나오면 된다. 헤더사이즈만큼 포인터를 이동하는 이유는, 이 함수를 사용하는 일반사용자는 헤더를 사용할 필요가 전혀 없기 때문이다. 사용자에게는 단지 사용가능한 메모리의 포인터만 넘겨주면 된다.

만약 사용가능한 영역인데, 크기가 충분하지 않을 경우는 어떻게 될까 ? 이 경우에는 next_location 코드영역(section) 으로 이동한다. 이 영역에서는, 현재 메모리 영역은 요청한 만큼의 메모리가 없으므로 루프의 처음인 alloc_loop_begin으로 이동하는 코드가 들어가야 할 것이다. 이와 더불어 다음 header를 가리킬 수 있도록 HEADER_SIZE만큼을 이동시켜야 한다.

addl $HEADER_SIZE, %eax
addl %edx, %eax
jmp  alloc_loop_begin
            

9.10. deallocate 함수

deallocate는 allocate 함수에 비해서 매우 쉽게 만들 수 있다. 단지 현재 메모리 영역을 AVAILABLE로 표시만 하면 되기 때문이다.

movl ST_MEMORY_SEG(%esp), %eax
subl $HEADER_SIZE, %eax
movl $AVAILABLE, HDR_AVAIL_OFFSET(%eax) 
ret
            
하는 일은 간단하다. 현재 스택으로 부터 메모리영역의 주소를 얻어온다음, 헤더부분으로 이동해서 AVAILABLE표시를 하기만 하면 된다.

9.11. 성능 및 다른 몇가지 문제들

여기에서 만든 코드는 실제 활용에는 문제가 있는 최소한의 기능만 구현한 학습용 코드일 뿐이다. 좀더 그럴듯하게 만들기 위해서는 allocator함수에 몇가지 문제점들을 잡아주어야만 한다.

이 함수의 가장 큰 문제점은 느리다라는 점이다. 만약 몇개의 메모리영역만 요청을 하게 된다면 속도는 그리 큰 문제가 되지 않을 것이다. 그러나 수천번의 메모리할당 요청이 들어오게 된다면 속도문제가 발생한다. 1000번째 메모리할당 요청을 하게 된다면, 여러분은 요청한 크기를 가진 사용가능한 메모리영역이 있는지를 확인하기 위해서 최대 999번의 검사를 해야 한다. 게다가 리눅스는 메모리의 페이지 정보를 메모리가 아닌 디스크에서 관리한다. 이는 매번의 검사를 함에 있어서, 속도가 느린 디스크를 일일이 검사해야 함을 의미한다. 당연히 매우 느려질 수 밖에 없다. 즉 이 프로그램은 할당요청이 늘어날때 마다 선형적으로 시간이 (linear time)늘어나게 된다. 모든 선형적인 시간이 소비되는 코드는 나쁜 코드이며, 가능한 상수시간(constant time)에 주어진 일을 해내는 코드를 만들어 내야 한다. 그러기 위해서는 메모리할당 요청을 관리할 수 있는 코드를 추가시켜야 한다. deallocate 함수의 경우 단지 4개의 명령만 사용되는 간단한 코드이므로 할당요청의 갯수가 많아졌다고 해서 특별히 문제될건 없다.

다른 또하나의 문제점은 brk(2) 시스템 호출을 자주한다는데 있다. 시스템 호출은 많은 시간이 소모되는 작업이다. 왜냐하면 단일한 프로세스의 mode 변환이 일어나기 때문이다. 거의 모든 kernel는 운영체제를 보호하기 위해서 user mode와 kernel mode로 모드를 분리해서 필요한 일을 하게 된다. 메모리 맵핑, 파일열기/쓰기와 같은 중요한 일을, 프로세스에게 전적으로 맡길경우 심각한 보안 위험을 가질 수 있기 때문으로, 위의 일들은 프로세스가 커널에게 요청하고 커널이 요청을 받아서 수행하는 식으로 이루어진다. 프로세스는 평상시에는 user mode로 작동을 하고, 커널에 어떤 요청을 해야 할경우 kernel mode로 변환을 하게 되고, 요청한 일을 끝냈을 경우 다시 user mode로 작동하게 된다. 이러한 모드변환을 context switch라고 부르기도 한다. 이러한 context switch는 x86 프로세스에서 매우 느리게 일어난다. 그러므로 가능하면 context switch가 일어나지 않도록 해주어야 한다. brk(2)는 메모리 맵핑 관련일을 하는 것으로, user 모드에서는 필요한 작업을 수행할 수 없으며, 때문에 kernelmode로 context switch 가 발생하게 된다. 우리가 만든함수는 매번 요청시 마다 brk 시스템호출을 해야하는 문제점을 가진다.

그리고 메모리를 비효율적으로 관리한다는 문제점을 가지고 있다. 예를 들어 5byte의 메모리 영역을 요청했는데, 사용가능한 영역으로?1000byte를 얻었다고 가능해보자. 우리가 요청한 메모리 영역보다 충분히 크므로, 문제 없이 리턴될 것이고 사용하는데도 문제는 없겠지만, 955 byte는 사용하지 않는 메모리 영역으로 낭비하게 된다. 이를 효율적으로 사용하기 위해서는 955 byte를 나중에 사용가능하도록 break를 분리해주어야 한다.

9.12. Allocator 사용하기

우리가 작성한 메모리관리자는 실제 사용하기에는 문제가 있지만, 작동 테스트 정도는 해볼 수 있다. 그래서 6장에서 예제로 다루었던 read-records.s 프로그램에 여기에서 만든 메모리관리자를 사용해 보도록 하겠다.

read-records.s 프로그램은 파일로 부터 읽어들인, 내용을 저장하기 위해서 .bss section을?사용하고 있다.

.bss
            
.bss section을 보면 record_buffer를 입출력 버퍼로 이용하고 있는데, 고정된 크기로 할당되어 있음을 알 수 있다. 이걸 우리가 만든 메모리 관리자를 이용해서 동적으로 할당하도록 코드를 수정한 후 테스트 하면 된다. 우리가 수정할 부분의 원본 코드는 다음과 같다.
.section .bss
.lcomm, record_buffer, RECORD_SIZE
            
우선 고정된 크기의 record_buffer는 필요가 없을 것이다. 동적으로 할당된 버퍼의 시작주소를 저장할 수 있는 공간만 있으면 된다. 시작주소를 저장하기 위한 공간은 4byte면 충분하다. 시작주소를 저장할 공간은 .data 영역에 선언하면 된다.
record_buffer_ptr:
.long 0
                
다음으로 프로그램이 시작된 뒤, 메모리 관리자를 초기화 시키기 위해서 allocate_init 를 호출하는 코드를 추가한다.
call allocate_init
                
이제 메모리를 요청하기 위해서, 필요한 메모리의 크기와 함께 allocate 함수를 호출하면 된다.
pushl $RECORD_SIZE
call  allocate
movl  %eax, record_buffer_ptr
                
이제 read_record를 호출하게 되면, 동적으로 만들어진 메모리의 주소를 이용하게 될 것이다. 예전 코드에서 포인터는 immediate-mode 상태로 record_buffer를 가리키고 있었는데, 이제는 record_buffer_ptr의 주소가 가리키는 영역을 이용해야 하기 때문에, direct mode로 record_buffer_ptr의 값을 이용하도록 변경해야 한다.
pushl $record_buffer    # 을
pushl record_buffer_ptr # 로 변경한다.
                
다음으로 레코드에서 이름이 저장된 주소를 찾는 부분을 변경해야 한다. 예전 코드는 $RECORD_FIRSTNAME + record_buffer로 이름이 저장된 주소를 찾을 수 있었다. 새로운 코드는 record_buffer_ptr이 가리키는 주소로 부터 이름이 있는 주소를 찾아내야 한다. 때문에 레지스터의 포인터를 record_buffer_ptr로 옮긴다음 $RECORD_FIRSTNAME만큼 포인터를 이동 시키는 일을 하도록 코드를 변경해야 한다.
pushl $RECORD_FIRSTNAME + record_buffer # 을
## 아래와 같이 변경한다.
movl record_buffer_ptr, %eax
addl $RECORD_FIRSTNAME, %eax
pusl %eax
                
마찬가지로 아래의 코드도 변경해야 한다.
movl $RECORD_FIRSTNAME + record_buffer, %eax #을
## 아래와 같이 변경한다.
movl record_buffer_ptr, %ecx
addl $RECORD_FIRSTNAME, %ecx
                
마지막으로 할당된 메모리를 되돌려주기 위해서 record_buffer_ptr을 호출하면 된다.
pushl record_buffer_ptr
call deallocate
                
위에서 언급한 부분을 수정한 후 아래와 같이 실행파일을 만들면 된다.
as read-records.s -o read-records.o
ld alloc.o read-record.o read-records.o write-newline.o count-chars.o -o read-records
                
성공적으로 실행파일을 만들었다면, 실행시켜서 테스트해보도록 하자.
# ./read-records
                

9.13. 더 많은 정보들

리눅스와 다른 운영체제 시스템에서의 메모리 관리에 대한 자세한 정보들은 아래의 링크들을 참고하기 바란다.

9.14. 복습

  • 리눅스 프로그램이 시작될 때의 메모리 구성에 대해서 설명하라.

  • heap란 무엇인가.

  • current break란 무엇인가.

  • 맵핑되지 않은 메모리에 접근을 시도할 경우 어떤 일이 발생하는가.

  • 운영체제는 어떻게 각각의 프로세스가 자신들에게 할당된 메모리를 초과해서 사용하지 막는가.

  • 메모리를 이용할때, 디스크상에서 어떤일이 발생하는지를 설명하라.

  • 메모리 할당자가 필요한 이유에 대해서 설명하라.