12장 메모리 관리에서는 커널이 물리적 메모리를 관리하는 방법에 대해 다루었다. 스스로의 메모리를 관리뿐만 아니라 커널은 user-space의 프로세스의 메모리도 관리해야한다. 이 메모리는 프로세스 주소 공간이라고 한다. 리눅스는 가상 메모리 운영체제이고, 그러므로 시스템상의 프로세스에 메모리 자원이 가상화된다. 메모리에 대한 개별 프로세스의 관점은 시스템의 물리 메모리를 독차지 하고 있다고 생각한다. 가장 중요한 것은, 하나의 프로세스의 주소공간이 물리적 메모리보다 더 크다는 것이다.
프로세스의 주소 공간은 가상 메모리와 프로세스가 사용할 수 있는 주소로 구성되어 있다. 각각의 프로세스는 flat한 32 혹은 64비트의 주소공간을 가진다. 여기서 flat이라는 용어는 주소 공간이 단일 범위내에 존재한다는 것을 의미한다. 예를 들어 32비트 주소 공간은 0부터 4294967295까지 확장할 수 있다. 몇몇 운영체제는 세그먼트화 된 주소 공간을 제공한다. 세그먼트화 된 주소 공간은 단일한 선형적인 범위 내에 존재하는 것이 아닌 여러 세그먼트에 존재하는 것이다. 현대의 가상 메모리 운영체제는 일반적으로 flat한 메모리 모델을 이용한다. 보통 flat한 주소 공간은 각각의 프로세스에 유일하다. 한 프로세스의 메모리 주소는 다른 프로세스의 주소 공간의 같은 메모리 주소와 전혀 연관되어있지 않다. 두 프로세스 모두 같은 주소(상대적 주소 공간)에 다른 데이터를 가질 수 있다. 또는 프로세스는 다른 프로세스와 그들의 주소 공간을 공유할지 결정할 수 있다. 이러한 프로세스를 thread라고 한다.
메모리 공간은 주소 공간에서 주어진 값이다. 이 특정한 값은 프로세스의 32비트 주소 공간의 특정 바이트를 나타낸다. 비록 프로세스가 4GB의 메모리 주소까지 가질 수 있으나, 모든 것에 접근할 권한을 가지는 것은 아니다. 주소 공간의 흥미로운 점은 프로세스가 접근할 권한을 가지는 메모리 주소의 간격이다. 이런 간격들을 메모리 구역이라고 한다. 프로세스는 커널을 통해 동적으로 메모리 구역을 자신의 주소 공간으로 추가 및 삭제할 수 있다.
프로세스는 유효한 메모리 구역인 경우에만 메모리 주소에 접근할 수 있다. 메모리 구역은 연관된 권한이 있다(읽을 수 있는 권한, 쓸 수 있는 권한, 실행할 수 있는 권한). 만약 프로세스가 유효하지 않은 메모리 구역의 메모리 주소에 접근한다면, 혹은 유효한 공간을 유효하지 못한 방법으로 접근한다면, 커널은 프로세스를 "Segmentation Fault" 메세지와 함께 종료시킨다.
메모리 구역은 다음을 포함한다.
malloc()
과 연관된 익명의 메모리 매핑커널은 프로세스의 주소공간을 memory descriptor라는 데이터 구조체를 이용해 표현한다. 이 구조체는 프로세스 주소 공간과 연관된 모든 정보를 포함한다. Memory descriptor는 mm_struct
구조체에 의해 표현되며 <linux/mm_types.h>에 정의되어있다.
mm_users
필드는 이 주소 공간을 이용하고 있는 프로세스의 수를 나타낸다. mm_count
필드는 mm_struct
의 주요한 레퍼런스 카운트이다. 이 말은, 9개의 쓰레드가 있다면 mm_users
는 9이겠지만, mm_count
는 1이라는 것이다. 커널이 주소 공간에서 실행되고 관련된 레퍼런스 카운트를 부딪힐 필요가 있다면, 커널은 mm_count
를 증가시킨다.
mmap
과 mm_rb
필드는 같은 것을 포함하는 다른 두 데이터 구조체이다: 모든 메모리 구역은 이 주소 공간에 있다. mmap
은 연결 리스트에 저장하는 반면, mm_rb
는 red-black 트리에 저장한다.
커널이 보통 같은 데이터를 저장하기 위해 두개의 데이터 구조체를 이용하는 것은 피하려고 하지만, 여기서는 그것이 훨씬 유용하다. mmap
데이터 구조체는 간단하고 효과적인 요소 탐색을 가능하게 한다. 반면 mm_rb
데이터 구조체는 주어진 요소를 찾는데 더 적합하다. 커널은 mm_sturct
를 복제하는 것이 아니라 그저 객체를 포함하는 것이다. 트리와 연결 리스트 모두를 같은 데이터를 접근하는데 사용하는 것을 threaded tree라고 부르기도 한다.
모든 mm_struct
구조체는 mmlist
필드를 통해 doubly 연결리스트로 연결되어있다. 첫 요소는 init_mm
memory descriptor에 있다.
메모리 descriptor는 프로세스 디스크립터의 mm
필드에 저장되어있다. 그러므로 current->mm은 현재 프로세스의 메모리 디스크립터를 나타낸다. copy_mm()
함수는 fork()
하는 동안 부모의 메모리 descriptor를 자식에게 복사한다. mm_struct
구조체는 allocate_mm()
함수를 통해 mm_cachep 슬랩 캐시에서 할당된다. 보통 각각의 프로세스는 유일한 mm_struct를 가지므로, 유일한 프로세스 주소 공간이다.
프로세스는 그들의 주소 공간을 공유할지 CLONE_VM 플래그의 평균을 통해 결정할 수 있다. 프로세스는 그러면 thread라고 불린다. 쓰레드는 커널의 일반적인 프로세스이다.
CLONE_VM이 설정된 경우에 allocate_mm()
함수는 호출되지 않고 프로세스의 mm필드는 copy_mm()
을 통해 부모의 메모리 descriptor를 가리키도록 설정된다.
특정 주소공간과 관련된 프로세스가 나간다면, exit_mm()
함수가 호출된다. 이 함수는 잡다한 일을 하고 몇몇 통계를 업데이트한다. 이후 mmput()
을 호출해 메모리 descriptor의 mm_users(유저 카운터)를 줄인다. 만약 유저 수가 0에 도달하면 mmdrop()
이 호출되어 mm_count(사용 카운터)를 줄인다. 이 값이 0에 도달하면 free_mm()
매크로가 호출되어 mm_struct를 mm_cachep 슬랩 캐시에 반환한다.
커널 쓰레드는 프로세스 주소 공간이 없고, 따라서 관련된 메모리 descriptor도 없다. 그러므로 커널쓰레드 프로세스 descriptor의 mm 필드도 NULL이다. 이것이 바로 커널 쓰레드의 정의이다.
커널 쓰레드가 user-space 메모리에 접근하지 않기 때문에 이러한 사실은 문제가 없다. 커널쓰레드가 user-space에 어떠한 페이지도 가지고 있지 않기 때문에 메모리 descriptor와 페이지 테이블을 가질 필요도 없다. 그럼에도 불구하고 커널 쓰레드는 페이지 테이블과 같인 데이터를 통해 커널 메모리에 접근해야할 때도 있다. 커널 쓰레드에게 필요한 데이터를 제공하기 위해, 커널 쓰레드는 이전에 실행된 작업의 메모리 descriptor를 이용한다.
프로세스가 스케줄될때면 프로세스의 mm필드에서 참조된 프로세스 주소 공간이 로드된다. active_mm 필드는 새로운 주소 공간에 맞춰 업데이트된다. 커널 쓰레드는 주소 공간이 없고, mm필드도 NULL값을 가진다. 그러므로 커널 쓰레드가 스케줄될때, 커널은 mm이 NULL임을 인지하고 이전 프로세스의 주소 공간을 로드된 상태를 유지한다. 커널은 그 후 커널 쓰레드의 프로세스 descriptor의 active_mm 필드를 이전 프로세스의 메모리 descriptor를 참조하도록 업데이트 한다. 그러면 커널 쓰레드는 이전 프로세스의 페이지 테이블을 필요한 만큼 쓸 수 있다. 커널 쓰레드가 user-space 메모리에 접근하지 않기 때문에, 다른 프로세스와 마찬가지로 주소공간의 정보만을 이용해 커널의 메모리를 저장한다.
vm_area_struct
는 메모리 구역을 표현한다. 리눅스 커널에서는 메모리 구역은 보통 가상 메모리 구역(VMA)으로 불린다. vm_area_struct
구조체는 주어진 주소공간에서 연속적인 간격으로 단일 메모리 구역을 나타낸다. 커널은 각각의 메모리 구역을 고유한 메모리 객체로 다룬다. 각각의 메모리 구역은 권한, 관련된 연산과 같은 속성을 포함한다. 이 방법으로 각각의 VMA 구조체는 다른 유형의 메모리 구역을 표현할 수 있다. 이는 VFS 레이어에서 이용한 객체지향적 접근과 유사하다.
vm_start 필드는 간격에서 시작하는(가장 낮은) 주소이고, vm_end 필드는 간격에서 마지막(가장 높은) 주소의 다음 첫 바이트이다. 즉 [vm_start, vm_end)의 범위를 갖는다.
vm_mm 필드는 VMA의 연관된 mm_struct를 가리킨다. 각각의 VMA는 연관된 고유한 mm_struct에 대해 유일하다. 그러므로 두 독립된 프로세스가 같은 파일을 각자의 주소 공간에 매핑하더라도, 각각은 고유한 vm_area_struct를 가진다. 반대로 주소 공간을 공유하는 두 쓰레드는 모든 vm_area_struct를 공유한다.
vm_flags 필드는 메모리 구역에 포함된 페이지의 행동과 정보를 제공하는 비트 플래그를 포함한다. 특정 물리 페이지와 연관된 권한과 다르게 VMA 플래그는 커널의 행동을 특정한다. 게다가 vm_flags는 메모리 구역안의 각각의 페이지 혹은 메모리 구역 전체와 연관된 정보를 포함한다.
vm_flags중 가장 중요한 VM_READ, VM_WRITE, VM_EXEC 플래그는 특정 메무리 구역의 페이지의 읽기, 쓰기, 실행 권한을 나타낸다. 이것들은 적절한 접근 권한을 형성하기 위해 필요한 만큼 조합되어 이 VMA에 접근하는 프로세스가 따라야 하는 것을 나타낸다.
VM_SHARED 플래그는 메모리 구역이 여러 프로세스에 걸쳐 공유된 매핑을 포함하는지 나타낸다. 만약 플래그가 설정되었다면, shared mapping이라고 부른다. 만약 플래그가 설정되지 않았다면 단일 프로세스만이 이 매핑을 볼 수 있고 이를 private mapping이라고 부른다.
VM_IO 플래그는 이 메모리 구역이 기기의 I/O 공간의 매핑인지 나타낸다. 이 필드는 기기의 I/O 공간에서 mmap()
이 호출될 때 해당 기기 드라이버에 의해 설정되곤한다. 무엇보다도 메모리 영역이 어떠한 프로세스의 코어 덤프에도 포함되지 말아야하는 것을 의미한다. VM_RESERVED 플래그는 교체되지 말아야 할 메모리 구역을 특정한다.
VM_SEQ_READ 플래그는 커널에게 어플리케이션이 연속적(선형적)인 읽기를 수행하고 있다는 힌트를 제공한다. 커널은 백업 파일에서 수행되는 미리 읽기를 늘리도록 선택할 수 있다. VM_RAND_READ 플래그는 정확히 반대되는 것을 명시한다. 커널은 백업 파일에서 미리읽기를 줄이거나 비활성화 하는 선택을 할 수 있다. 이 플래그들은 MADV_SEQUENTIAL 과 MADV_RANDOM 플래그가 설정된 madvidse()
시스템콜에 의해 설정된다. 미리 읽기(Read-ahead)는 추가적인 데이터가 필요해질 것을 기대하고 연속적으로 요청된 데이터의 다음을 읽어오는 행위이다.
vm_ops
필드는 주어진 메모리 영역과 연관된 연산 테이블을 가리킨다. vm_area_struct
는 어떠한 유형의 메모리 영역을 표현하는 제너릭 객체이고, 연산 테이블은 객체의 특정 인스턴스에 가능한 특정 연산들을 나타낸다. 연산 테이블은 vm_operations_struct
구조체로 표현되고 <linux/mm.h>에 정의되어있다.
메모리 구역은 mmap과 mm_rb 필드에 의해 접근된다. 두 데이터 구조체는 독립적으로 메모리 descriptor와 관련된 모든 메모리 영역 객체를 가리킨다. 사실 mmap과 mm_rb 필드는 모두 같은 vm_area_struct
구조체의 포인터를 포함한다(표현만 약간 다를 뿐이다).
첫번째 필드인 mmap은 모든 메모리 구역 객체를 단일 연결 리스트에 연결한다. 각각의 vm_area_struct
는 vm_next
필드로 연결된다. 구역은 주소의 오름차순으로 정렬된다. 첫번째 메모리 구역은 mmap
이 가리키는 vm_area_struct
구조체이며, 마지막 구조체는 NULL을 가리킨다.
두번째 필드인 mm_rb는 모든 메모리 구역 객체를 red-black 트리에 넣어 연결한다. Red-black 트리의 루트는 mm_rb이고, 주소 공간의 각각의 vm_area_struct 구조체는 vm_rb 필드에 의해 트리로 엮여있다.
Red-Black Tree
balanced binary tree의 일종. 각각의 요소는 node라고 하고 시작 노드는 트리의 root라고 부른다. 대부분의 노드는 왼쪽과 오른쪽의 두 자식 노드를 가진다. 몇몇은 하나의 자식만 가질 수도 있고, leaves라 불리는 마지막 노드는 자식이 없다. 어떤 노드던 왼쪽에 있는 요소들은 값이 작고, 오른쪽에 있는 요소들은 값이 크다. 게다가 각각의 노드는 두가지 규칙에 의해 색깔이 정해진다. 빨간 노드의 자식은 검정색이고, 노드에서 leaf로 가는 모든 경로는 같은 수의 검정 노드가 있어야한다. 루트 노드는 항상 빨간색이다. 트리를 탐색하고 삽입하고 삭제하는 것은 O(log(n))이 걸린다.
연결리스트는 모든 노드가 탐색되어야할 때 사용된다. Red-black 트리는 주소 공간의 특정 메모리 구역을 찾을 때 사용된다. 이런 방법으로 커널은 중복된 데이터 구조체를 이용해 최적의 성능을 제공한다.
특정 프로세스의 주소 공간과 메모리 구역의 내부를 들여다보자. 이 작업은 /proc 파일 시스템과 pmap(1) 유틸리티를 이용한다.
첫째로 text section과 data section, bss가 있다. 이 프로세스가 C 라이브러리와 동적으로 연결되었다고 가정하면, 세개의 메모리 구역은 libc.so에 존재하고 ld.so에도 존재한다. 마지막으로 프로세스에 스택도 존재한다.
Text section은 읽을 수 있고 실행가능하다(오브젝트 코드가 기대됨). 반면 data section과 bss는 읽을수 있고 쓸 수 있지만 실행은 불가능한 것으로 표시된다. 스택은 읽고 쓰고 실행할 수 있다.
전체 주소 공간은 1340KB정도를 차지하는데, 40KB 정도만이 쓸수 있고 private하다. 만약 메모리 구역이 공유되었거나 쓰기 불가능하다면, 커널은 백업 파일의 복사본을 하나만 메모리에 둔다. 만약 매핑이 읽기만 가능하다고 생각한다면, 메모리에 한번만 올리는 것이 안전하다. 따라서 C 라이브러리는 1212KB의 물리 메모리만 점유하면 된다.
기기 00:00에 매핑된 파일이 없는 메모리 구역과 inode가 0인 것을 주목해라. 이는 매핑이 0으로 이루어진 zero page이다. Zero page를 쓰기 가능한 메모리 구역에 매핑함으로써 그 구역은 0으로 초기화 되는 효과를 받는다. 이는 bss에게 기대되는 zeroed 메모리 구역을 제공한다. 매핑이 공유되지 않았기 때문에 프로세스가 데이터를 쓰는대로 복사본이 만들어지고 값이 0에서부터 업데이트 된다.
각각의 프로세스와 연관된 메모리 구역은 vm_area_struct
구조체에 상응한다. 프로세스가 쓰레드가 아니였기 때문에 그것의 task_struct
에서 참조된 고유한 mm_struct
구조체를 가진다.
커널은 자주 메모리 영역에 연산을 수행한다. 이런 연산은 자주 사용되며, mmap() 루틴의 기반을 형성한다. 다라서 이른 작업들을 보조하기 위해 정의된 몇몇 함수가 존재한다. 이는 <linux/mm.h>에 정의되어 있다.
커널은 주어진 메모리 주소에 존재하는 VMA를 찾는 함수인 find_vma()
를 제공한다. 이 함수는 vm_end 필드가 addr보다 큰 첫번째 메모리 구역을 탐색한다. 다시 말해, 이 함수는 이 함수는 addr을 포함하거나, addr보다 큰 주소에서 시작하는 첫번째 메모리 구역을 찾는 것이다. 만약 그런 구역이 존재하지 않는다면, 함수는 NULL을 반환한다. 존재한다면 해당하는 vm_area_struct 포인터가 반환된다. find_vma()
함수의 결과는 메모리 디스크럽터의 mmap_cache 필드에 캐시된다. 하나의 VMA에 관한 연산이 동일한 VMA에 더 많은 연산으로 이어질 확률이 높기 때문에, 캐시된 결과는 hit rate가 상당히 높다.(locality)
초기 mmap_cache의 확인은 캐시된 VMA가 원하는 주소를 포함하는지 점검한다. 단순히 VMA의 vm_end 필드가 addr보다 큰지 확인하는 것은 그 VMA가 addr보다 높은 값을 가지는 첫번째 요소임을 보장하지 않는다.
만약 캐시가 원하는 VMA를 포함하지 않는다면, 함수는 red-black 트리를 탐색해야한다. 만약 현재 VMA의 vm_end가 addr보다 크다면, 함수는 왼쪽 자식을 따라간다. 이 함수는 addr을 포함하는 VMA를 찾는 순간 종료된다. 찾지 못했다면 트리를 계속 순회하고, 찾은 첫번째 VMA를 반환한다. 없다면 NULL을 반환한다.
find_vma()
와 동일하게 동작하나, addr 전의 마지막 VMA를 반환한다는 것이 차이점이다.
find_vma_intersection()
함수는 주어진 주소 범위에 겹치는 첫번째 VMA를 반환한다.
static inline struct vm_area_struct * find_vma_intersection(
struct mm_struct *mm,
unsigned long start_addr,
unsigned long end_addr)
{
struct vm_area_struct *vma;
vma = find_vma(mm, start_addr);
if (vma && end_addr <= vma->vm_start) vma = NULL;
return vma;
}
첫번째 인자는 탐색할 주소 공간, start와 end는 범위의 시작과 끝을 나타낸다. find_vma()
가 NULL을 반환한다면 find_vma_intersection()
역시 그럴 것이며, 반대로 find_vma()
가 유요한 VMA를 반환한다면, find_vma_intersction()
은 주어진 주소 범위의 끝보다 나중에 시작하지 않는 이상 같은 VMA를 반환할 것이다.
do_mmap()
함수는 커널이 새로운 선형 주소 범위를 생성하는데 이용한다. 생성된 주소 간격이 존재하는 주소 간격에 인접해있고, 같은 권한을 공유한다면 두 간격은 하나로 병합되기 때문에, 이 함수가 새로운 VMA를 생성한다고 하는 것은 기술적으로 옳지 않다. 이게 불가능하다면(병합이 안된다면), 새로운 VMA가 생성된다. 어떤 경우라도, do_mmap()
은 프로세스의 주소 공간에 새로운 주소 간격을 추가하는데 이용된다(메모리 공간의 확장 혹은 새로운 생성). do_mmap()
함수는 다음과 같다.
unsigned long do_mmap(struct file *file, unsigned long addr,
unsigned long len, unsigned long prot,
unsigned long flag, unsigned long offset);
file과 offset이 명시되어 있지 않다면 이를 anonymous mapping이라고 하며, 둘다 명시되었다면 이를 file-backed mapping이라고 한다.
addr 함수는 자유 간격에 대해 어디서부터 탐색을 시작할 첫번째 주소를 명시한다. prot 인자는 메모리 구역의 페이지의 접근 권한을 나타낸다. flags 인자는 남은 VMA 플래그에 대응되는 플래그를 나타낸다.
만약 유효하지 않은 인자가 하나라도 있으면 do_mmap()
함수는 음수를 반환한다. 그렇지 않으면 가상 메모리의 올바른 간격이 위치한다. 가능하면 간격은 인접한 메모리 구역과 병합되며, 그렇지 않은 경우 새로운 vm_area_struct 구조체가 할당된다.
do_munmap()
함수는 주소 간격을 특정 프로세스 주소공간에서 제거한다.
int do_munmap(struct mm_struct *mm, unsigned long start, size_t len);
첫번째 인자는 start 주소에서 시작하는 길이 len 바이트의 간격이 제거된 주소공간을 명시한다. 성공하면 0이 반환되고, 실패하면 음의 에러코드가 반환된다.
munmap() 시스템콜은 user-space에서 프로세스의 주소 간격을 제거할 수 있도록 이용가능해진다.
애플리케이션이 물리 주소와 매핑된 가상 메모리에서 동작하는 반면, 프로세서는 그들의 물리 주소에서 바로 수행한다. 결과적으로, 애플리케이션이 가상 메모리 주소에 접근하면, 프로세서가 요청을 해결하기 이전에 물리 주소로 변환되어야 한다. 이 과정을 수행하는 것은 페이지 테이블을 통해 가능하다. 페이지 테입르은 가상 주소를 청크로 나눔으로써 동작한다. 각각의 청크는 테이블의 인덱스로 이용된다. 테이블은 다른 테이블이나 연관된 물리 페이지를 가리킨다.
리눅스에서는 페이지 테이블은 3계층으로 되어있다. 여러 계층으로 구성된 테이블은 드문드문 위치한 주소 공간을 가능하게 한다. 만약 페이지 테이블이 단일 정적 배열로 만들어졌다면, 32비트 아키텍쳐에서도 크기가 엄청났을 것이다. 리눅스는 하드웨어단에서 지원하지 않더라도 3계층의 페이지 테이블을 이용한다.
최상단 계층의 페이지 테이블은 pgd_t 타입의 배열로 구성된 page global directory(PGD)이다. 대부분의 아키텍쳐에서 pgd_t 타입은 unsigned long이다. PGD의 엔트리들은 2계층의 엔트리들인 PMD를 가리킨다.
2계층은 pmd_d 타입의 배열로 구성된 page middle directory(PMD)이다. PMD의 엔트리들은 PTE의 엔트리를 가리킨다.
마지막 계층은 간단하게 페이지 테이블이라고 불리고, pte_t 타입의 엔트리로 구성되어있다. 페이지 테이블의 엔트리는 물리 페이지를 가리킨다.
대부분의 아키텍쳐에서 페이지 테이블 탐색은 하드웨어에 의해 처리된다. 일반적인 연산에서는 하드웨어가 페이지 테이블을 이용해 대부분을 처리한다. 그렇기 때문에 하드웨어가 사용할 수 있도록 커널이 반드시 설정을 해두어야한다.
각각의 프로세스는 자신의 페이지 테이블을 가지고 있다. 메모리 디스크립터의 pgd 필드는 프로세스의 PGD를 가리킨다. 페이지 테이블을 조작하고 탐색하기 위해서 page_table_lock을 필요로 한다.
가상 메모리의 페이지에 대한 대부분의 접근은 대응되는 물리 메모리로 변환되기 때문에, 페이지 테이블의 성능이 매우 중요하다. 불행히도 메모리상에 있는 이 주소들을 탐색하는 것은 너무 빨리 끝날수 있다. 이를 원활하게 진행하기 위해 대부분의 프로세서는 translation lookaside buffer(TLB)를 구현한다. TLB는 가상-물리 매핑의 하드웨어 캐시 역할을 한다. 가상 주소에 접근하면 프로세서는 TLB에 해당 매핑이 캐싱되어있는지 확인한다. 만약 있다면 물리주소가 바로 반환된다. 그렇지 않으면 페이지 테이블을 탐색해 대응하는 물리 주소를 찾는다.