커널 내부의 메모리 할당은 커널 외부의 메모리 할당만큼 쉽지 않다. User-space와 다르게 커널은 쉽게 메모리를 할당할 여유가 없다. 이러한 제한과 가벼운 메모리 할당 방식 덕분에 커널에서의 메모리 보유는 user-space에서보다 더 복잡하다. 이번 장에서는 커널에서 메모리를 획득하기 위해 사용되는 방법들을 논한다.
커널은 물리적인 페이지를 메모리 관리의 기본 단위로 사용한다. 프로세서의 가장 작은 유닛이 바이트 혹은 word이지만, 메모리 관리 유닛(MMU, 메모리를 관리하고 가상 메모리 주소를 물리 주소로 전환하는 역할을 담당하는 하드웨어)이 페이지를 관리한다. 따라서 MMU는 시스템의 페이지 테이블을 관리한다. 가상 메모리의 관점에서는 페이지가 가장 작은 단위이다. 대부분의 32비트 아키텍쳐는 4KB 페이지를 사용하고, 64비트 아키텍쳐에서는 8KB 페이지를 사용한다.
커널은 시스템의 모든 물리적 페이지를 struct page
구조체로 관리한다.
/* 몇몇은 생략됨... */
struct page {
unsigned long flags;
atomic_t _count;
atomic_t _mapcount;
unsigned long private;
struct address_space *mapping;
pgoff_t index;
struct list_head lru;
void *virtual;
};
flags
필드는 페이지의 상태를 저장한다. _count
필드는 페이지의 사용 횟수를 저장한다. 즉 얼마나 많은 접근이 있었는지 나타낸다. 만약 이 값이 -1에 도달하면 아무도 이 페이지를 사용하지 않아 새 할당이 가능한 상태임을 나타낸다. 커널 코드는 직접적으로 이 필드에 접근하는 대신, page_count()
함수를 이용해야한다. 페이지가 이용 가능해 _count
가 -1이어도 page_count()
는 0을 반환하여 이 페이지가 이용 가능함을 나타낸다는 점을 유의해야한다.
virtual
필드는 페이지의 가상 주소를 나타낸다. High 메모리라 불리는 커널 주소 공간에 영원히 매핑되지 않는다. 이런 경우 이 필드 값은 NULL이고, 필요할 때 동적으로 할당된다.
페이지 구조체를 물리적 페이지와 연관지어 이해하는 것이 중요하다. 이 데이터 구조체의 목표는 물리적 메모리를 표현하기 위함이지 데이터를 표현하고자 하는 것이 아니다. 커널은 이 구조체를 이용해 시스템의 모든 페이지를 추적하고, free한 페이지를 탐색한다.
하드웨어의 한계때문에 커널은 모든 페이지를 동일하게 다룰 수 없다. 어떤 페이지들은 물리적 주소때문에 특정 작업에 사용되지 못한다. 이런 한계때문에 커널은 페이지를 다른 zone으로 나누었다. 커널은 비슷한 속성의 페이지들을 묶기 위해 zone을 이용한다.
리눅스에서도 메모리 addressing 관련 하드웨어 결점을 다루어야 했다.
이를 해결하기 위해 리눅스에서는 4개의 메모리 존으로 나누었다.
메모리 존의 실제 사용은 아키텍처에 의존적이다. 메모리 존은 물리적 상관관계가 아닌 단순 논리적 묶음으로 커널의 페이지 추적을 용이하게 한다. 메모리 존은 반드시 지켜져서 할당되어야 하는 것은 아니다. DMA 가능한 메모리는 ZONE_DMA로 가는게 권장 되지만, 메모리가 부족하거나 하는 경우에는 ZONE_NORMAL로 갈 수도 있는 것이다. 하지만 두 메모리 존 모두에 속할 수는 없다.
각각의 메모리 존은 구조체 struct zone
으로 표현된다. 구조체 자체가 크지만 시스템에 3개의 메모리 존만 존재하기 때문에 괜찮다. Zone 구조체의 몇가지 중요한 필드들을 확인해보면, lock
필드는 구조체의 동시 접근을 보호하기 위한 spin lock이다. watermark
배열은 그 존의 최소, 낮은, 그리고 높은 워터마크를 보유한다. 커널은 워터마크를 이용해 벤치마크를 진행, 메모리 존 별로 적합한 메모리 사용량을 유지하도록 한다. name
필드는 해당 존의 이름을 설정한다. 3개의 메모리 존은 각각 DMA, Normal, HighMem이라는 이름을 부여받는다.
커널은 메모리 요청을 위한 하나의 로우레벨 방법을 제공한다. 여러 인터페이스를 통해 이 방법에 접근할 수 있다.
/* 2의 order승 만큼의 연속적인 물리적 페이지를 할당 */
struct page * alloc_pages(gfp_t gfp_mask, unsigned int order)
/* 주어진 페이지를 논리적 페이지로 변환 */
void * page_address(struct page *page)
/* alloc_pages()와 동일하게 동작하고, 논리적 주소를 반환한다. */
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)
/* __get_free_pages()와 동일하게 동작하고, 0으로 채워진 페이지를 할당 */
unsigned long get_zeroed_page(unsigned int gfp_mask)
주로 user-space에서의 페이지 할당에 활용된다. 데이터를 0으로 채우지 않으면 민감한 데이터 정보가 유출될 수 있기 때문이다.
void __free_pages(struct page *page, unsigned int order)
void free_pages(unsigned long addr, unsigned int order)
void free_page(unsigned long addr)
자신이 할당한 메모리만 할당해제해야한다. 다른 페이지를 전달한다면 오염이 발생할 수 있다.
__get_free_pages()
를 호출할 때는 커널이 할당에 실패할 수 있기 때문에 코드상에서 에러를 체크하고 이를 처리해주어야한다.
위와 같은 페이지 할당 인터페이스들은 물리적으로 연속적인 페이지들을 필요한 만큼, 특히 하나에서 두페이지 정도를 할당할 때 유용하다. 바이트 크기의 할당을 위해 커널은 kmalloc()
을 제공한다.
kmalloc()
함수는 flags 인자가 있다는 것을 제외하면 user-space의 malloc()
함수와 유사하다. kmalloc()
함수는 커널의 메모리를 바이트 사이즈로 획득하는 간단한 인터페이스이다.
void * kmalloc(size_t size, gfp_t flags)
이 함수는 메모리를 연속적으로 할당한다. 에러가 발생하면 NULL을 반환한다. 커널의 메모리 할당은 부족한게 아닌 이상 대부분 성공한다. 그렇기 때문에 반환 값이 NULL인지 항상 확인해주어야한다.
플래그는 gfp_t 타입으로 표현된다. gfp는 __get_free_pages()
의 약자이다. 플래그는 3개의 카테고리로 나뉜다. Action modifier, zone modifier, 그리고 type이다. Action modifier는 커널이 어떻게 요청받은 메모리를 할당할 것인가를 나타낸다. Zone modifier는 어디에 메모리를 할당할지를 나타낸다. 커널은 물리적 메모리를 목적에 따라 여러 zone으로 나눈다. Type 플래그는 action과 zone modifier의 조합을 나타낸다. 따라서 type 플래그만 전달해도 된다.
/* Block, IO, File System 관련 작업을 수행할 수 있음 */
ptr = kmalloc(size, __GFP_WAIT | __GFP_IO | __GFP_FS);
Type 플래그 중에선 GFP_KERNEL 플래그를 많이 사용한다. 이 플래그를 이용하면 일반 우선순위의 할당이 이뤄지고 sleep할 수 있다. 이 플래그는 제한 조건이 없기 때문에 메모리 할랑이 높은 확률로 성공한다. 대척점에 있는 플래그가 GFP_ATOMIC 플래그이다. 이 플래그는 메모리의 할당이 sleep할 수 없게 하기 때문에 할당이 제한적이다. 충분한 크기의 연속된 메모리 청크가 없는 상황에서 커널은 caller를 sleep할 수 없기 때문에 메모리를 할당 해제하지 않는다. 반대로 GFP_KERNEL 할당은 caller를 sleep상태에 놓고 더티 페이지를 디스크에 플러시하는 등 작업을 수행할 수 있다. GFP_ATOMIC이 이런 일들을 수행할 수 없기 때문에 성공할 기회가 더 적다. 몇가지 상황들에서 사용하는 플래그들은 다음과 같다.
kmalloc()
으로 할당한 메모리를 해제하는 kfree()
함수에 대해 알아본다.
void kfree(const void *ptr)
User-space와 동일하게 kfree()
를 통해 적절하게 사용하지 않는 메모리를 할당 해제 해줌으로써 메모리 누수와 기타 버그를 예방할 수 있다.
vmalloc()
은 가상의 연속적인 메모리 할당을 한다(물리적으로 연속적일 필요 없다). 이는 user-space에서 malloc()
함수와 유사하다. malloc()
함수도 가상 메모리 주소는 연속적임을 보장하지만, 물리적인 램에 연속적으로 저장되는 것을 보장하지는 않는다. kmalloc()
은 물리적으로, 가상적으로 연속을 보장한다.
하드웨어 기기와 연관된 메모리 영역은 물리적으로 연속적이여야한다. 소프트웨어에서 사용하는 메모리 블록은 가상적으로만 연속이면 된다. 커널에서 나타나는 모든 메모리는 논리적으로 연속이다. 물리적으로 연속인 메모리는 특정 상황에만 필요함에도 대부분의 커널 코드는 kmalloc()
을 이용한다. 이는 성능을 위해서이다. vmalloc()
을 이용하면 페이지 테이블을 설정해야하며, TLB thrashing을 더 많이 발생시킨다. 따라서 vmalloc()
은 큰 영역의 메모리를 획득해야 할 때 이용한다. 예를 들면 모듈이 동적으로 커널에 삽입되어야 할 때, vmalloc()
을 이용한다.
TLB Thrashing
프로그램의 실행보다 페이지 교체에 더 많은 부하가 발생하는 상태
void * vmalloc(unsigned long size)
void vfree(const void *addr)
데이터 구조를 할당하고 해제하는 것은 커널에서 가장 공통된 작업이다. 잦은 할당과 할당해제를 돕기 위해 프로그래머는 free list를 이용한다. Free list는 이미 할당이 되어있고 사용 가능한 데이터 구조를 포함한다. 데이터 구조체의 새로운 인스턴스가 필요하면 free list에서 가져와서 사용하고, 사용이 끝나면 할당 해제 하는 대신 free list에 다시 반환한다.
Free list의 주요한 문제중 하나가 전역 컨트롤이 없다는 것이다. 메모리가 부족한 상황에서 모든 free list를 작게 만들 방법이 없다. 이를 해결하기 위해 리눅스 커널은 slab layer를 제공한다. Slab layer는 데이터 구조체 캐시 레이어처럼 동작한다.
Slab layer는 다른 객체들을 채시라는 그룹으로 나눈다. 객체 유형당 하나의 캐시가 존재한다. kmalloc()
은 slab layer를 이용한다.
캐시는 또다시 slab으로 나뉜다. Slab은 하나 혹은 여러 연속된 물리 페이지로 되어있다. 전형적으로 slab은 단일 페이지로 구성된다. 각각의 캐시는 여러 slab을 보유할 수 있다.
각각의 slab은 몇몇의 객체를 보유한다. 각각의 slab은 3가지 상태 중 하나이다: full, partial 혹은 empty. Full slab은 이미 모두 할당되어 추가적인 객체가 나올 수 없는 상태이다. Empty slab은 할당된 객체가 없다. Partial slab은 할당된 객체와 자유로운 객체가 공존한다.
Slab allocator는 __get_free_pages()
를 이용해 새로운 slab을 만든다.
struct kmem_cache * kmem_cache_create(const char *name,
size_t size,
size_t align,
unsigned long flags,
void (*ctor) (void*));
int kmem_cache_destroy(struct kmem_cache *cachep);
캐시를 만드는 함수이고 size
는 캐시의 각각의 요소의 크기, align
은 slab을 포함한 첫번째 객체의 offset, flags
는 캐시의 설정 정보를 의미한다. ctor
은 캐시의 생성자이다.
캐시를 삭제하기 위해서 kmem_cache_destroy
함수를 이용한다. 캐시를 삭제하기 전 다음 두가지 조건을 만족하는지 확인해야한다.
캐시가 생성되고 나서 할당하는 방법은 아래와 같다.
void * kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);
void kmem_cache_free(struct kmem_cache *cachep, void *objp);
할당 성공하면 객체의 포인터를 반환한다. 할당 해제를 위해 kmem_cache_free
을 이용하며, 할당 해제할 객체 포인터를 두번째 인자로 넣는다.
User-space에서 할당은 스택에서 발생하는데, 이는 우리가 할당할 크기를 사전에 알기 때문이다. User-space는 크고 동적으로 자라나는 스택을 사용할 수 있는 반면 커널은 작고 고정된 스택을 가지고 있다.
프로세스별 커널 스택의 크기는 아키텍쳐와 컴파일 타입 옵션에 따라 달라진다. 역사적으로 커널 스택은 프로세스 당 두 페이지씩이였고, 32비트에서는 8KB, 64비트에서는 16KB가 스택의 크기였다.
리눅스 커널 2.6 초기에는 단일 페이지의 커널 스택으로 바꾸자는 의견이 제시되었다. 이것이 가능해지면 각각의 프로세스는 단일 페이지를 갖는다. 이런 결정의 이유로는 프로세스당 적은 메모리를 사용하도록 할 수 있었고, 시스템 uptime이 증가함에 따라 물리적으로 연속된 두 페이지를 찾는 것이 어려웠다는 것 때문이다.
또한 각각의 프로세스의 전테 콜체인이 커널 스택에 맞아야한다.다만 인터럽트 핸들러 역시 커널 스택을 이용하기 때문에 인터럽트 핸들러 역시 커널 스택에 맞아야한다. 스택이 단일 페이지로 변경되면 인터럽트 핸들러는 더이상 맞지 않는다. 이 문제를 해결하기 위해 커널 개발자들은 인터럽트 스택을 구현했다. 인터럽트 스택은 프로세서별 단일 스택을 제공한다. 이 옵션으로 인터럽트 핸들러는 커널 스택을 공유할 필요 없이 자신의 스택을 이용할 수 있게 되었다.
어떤 주어진 함수에서도 스택 사용량을 최저로 유지해야한다. 쉽고 빠른 방법은 없지만 특정 함수에서의 모든 지역변수의 합을 최대 몇백바이트 정도로 유지해야한다. 스택에서 큰 정적 할당을 수행하는 경우는 위험하다. 반면 커널에서의 스택 할당은 user-space에서와 같이 일어난다. 스택 오버플로우는 예기치 못하게 문제를 발생시킨다. 커널이 스택을 관리하기 위해 어떠한 노력도 하지 않기 때문에 스택 오버플로우가 발생하면 초과한 데이터는 스택의 끝에 존재한다. 스택 너머에는 커널 데이터가 숨어있을 수도 있다. 최선의 경우 기기가 충돌나겠지만, 최악의 경우 오버플로우가 데이터를 오염시킬 수 있다. 그렇기 때문에 큰 메모리 할당의 경우 동적 할당 기법을 이용하는 것이 현명하다.
정의에 의하면 high memory의 페이지는 영구적으로 커널 주소 공간에 매핑되지 않을 수 있다. 그러므로 __GFP_HIGHMEM
플래그와 함게 할당된 페이지들은 논리적 주소를 가지고 있지 않을수도 있다.
x86 아키텍쳐에서는 896MB선을 넘는 물리 메모리는 high memory로 간주된다. 메모리가 할당된 후 이 페이지들은 커널의 논리 주소 공간으로 매핑되어야한다. x86에서는 high memory의 페이지들은 3GB와 4GB 사이 공간에 매핑된다.
커널 주소 공간으로 매핑하기 위해서는 다음 함수를 이용한다.
void *kmap(struct page *page);
void kunmap(struct page *page);
이 함수는 high memory가 아니더라도 동작한다. Low memory에 있는 페이지라면 가상 주소가 반환되지만, high memory에 있는 페이지라면 영구적인 매핑이 생성되고 그 주소가 반환된다. kmap()
은 프로세스 컨텍스트에서만 동작한다.
영구적인 매핑의 수가 제한되어있기 때문에, high memory는 필요가 없다면 매핑 해제되어야한다. 이를 위해 kunmap()
함수를 이용한다.
매핑을 해야하지만 현재 컨텍스트가 sleep할 수 없을 때 커널은 temporary mapping을 제공한다. 이것은 이미 예약된 매핑들이다. 커널은 atomic하게 high memory 페이지를 예약된 것으로 매핑할 수 있다. 결과적으로 임시 매핑은 sleep할 수 없는 인터럽트 핸들러와 같은 상황에 이용할 수 있다.
임시 매핑을 설정하는 것은 아래와 같다.
void *kmap_atomic(struct page *page, enum km_type type);
void kunmap_atomic(void *kvaddr, enum km_type type);
type
인자는 임시 매핑의 목적을 의미한다. 이 함수는 커널의 preemption도 비활성화 한다. kunmap_atomic()
함수는 대부분의 아키텍쳐에서 커널 preemption을 활성화 시키는 것 외에는 아무것도 하지 않는다. 커널은 그저 kmap_atomic()
매핑을 잊어버린다. 다음 atomic mapping은 이전것에 덮어씌운다.
SMP를 지원하는 현재 운영체제는 CPU별 데이터를 이용한다. 전형적으로 CPU별 데이터는 배열에 저장된다. 배열안의 각각의 요소는 시스템의 사용가능한 프로세서와 상응한다. 현재 프로세서 번호가 이 배열을 인덱스한다.
CPU별 데이터에서 커널 preemption이 유일한 우려사항이다. 다음 두가지 문제가 있다.
리눅스 커널 2.6에서는 percpu라는 새로운 인터페이스를 제공한다. percpu 인터페이스는 CPU별 데이터를 생성하고 조작하는 역할을 담당한다. CPU별 데이터를 다루는 이전 방식 역시 유효하고 허용된다. percpu 인터페이스는 SMP 컴퓨터에서의 더 간단하고 강력한 제어를 위해 제안되었다.
DEFINE_PER_CPU(type, name);
get_cpu_var(name)++; /* 이 프로세서의 name을 증가 */
put_cpu_var(name); /* 완료. 커널 preemption 가능 */
type과 name을 통해 각각의 프로세서별 변수 인스턴스를 생성한다. get 및 put 함수로 데이터를 획득할 수 있다. 다른 프로세서의 CPU별 데이터를 얻기 위해 다음 함수를 이용한다.
per_cpu(name, cpu)++;
다만 위의 함수는 커널 preemption을 막지도 않고, 다른 락킹 매커니즘을 제공하지도 않기 때문에 조심해야한다. 또한 위와 같이 컴파일 타임에 CPU별 데이터를 생성하고 조작하는 과정은 모듈에서 적용되지 않기 때문에 다른 방법을 이용한다.
커널은 kmalloc()
과 비슷하게 동적 할당자를 구현해 CPU별 데이터를 생성한다.
void *alloc_percpu(type);
void *__alloc_percpu(size_t size, size_t align);
void free_percpu(const void *);
alloc_percpu()
는 모든 프로세서에 주어진 타입의 인스턴스를 할당하는 매크로 함수이다.
// 예제
void *percpu_ptr;
unsigned long *foo;
percpu_ptr = alloc_percpu(unsigned long);
if (!ptr) /* error allocating memory */
foo = get_cpu_var(percpu_ptr);
/* manipulate foo */
put_cpu_var(percpu_ptr);
CPU별 데이터를 이용하면 락킹 요구사항을 감소할 수 있다. CPU별 데이터에 접근할 수 있는 프로세서의 의미론적 측면에서, 락킹이 전혀 필요하지 않을 수도 있다. 다만 기억해야할 것이 '이 프로세서는 이 데이터만 접근한다'는 규칙은 프로그래밍 협약일 뿐이란 것이다.
또한 CPU별 데이터는 캐시 무효화를 크게 감소시킨다. 캐시 무효화는 프로세서가 그들의 캐시를 동시에 유지하려고 하기 때문에 발생한다. CPU별 데이터를 이용함으로써 자신의 데이터만 접근하게 되고, 이를 통해 캐시 무효화가 발생하지 않도록 한다.
CPU별 데이터의 사용에서 요구되는 유일한 한가지는 커널 preemption을 비활성화 하는 것이다. 이는 락킹보다 훨씬 코스트가 적고 인터페이스에서 자동으로 해준다. 따라서 커널 코드에 CPU별 데이터를 이용하기로 했다면 새로운 인터페이스를 이용해보는 것도 좋다. 한가지 유의점은 하위 호환성이 없다는 것이다.
많은 할당법과 접근법들이 있기 때문에 커널에서 메모리를 얻는 방법이 중요하지만 어떤 것을 이용할지는 명백하지 않을 때가 있다. 만약 연속된 물리 페이지가 필요하다면 kmalloc()
을 사용하는 것이 좋다. 두가지 flag로 GFP_ATOMIC과 GFP_KERNEL이 자주 이용된다. GFP_ATOMIC 플래그는 높은 우선순위를 제공, sleep하지 않도록 하기 때문에 인터럽트 핸들러에서 이용하기 좋다. GFP_KERNEL은 프로세스 컨텍스트와 같이 sleep할 수 있고 스핀락을 을 이용하지 않는 코드에서 이용하기 좋다.
High memory에서 할당하고자 한다면 alloc_pages()
를 이용한다. 이 함수는 논리 주소의 포인터가 아닌 페이지 구조체를 반환한다. High memory는 매핑될 수 없고 페이지 구조체를 통해서만 접근할 수 있다. 실제 포인터를 알고싶다면 kmap()
을 이용해 커널의 논리주소공간에 매핑할 수 있다.
만약 가상으로만 연속적이어도 괜찮다면 vmalloc()
을 이용한다. 이는 user-space에서의 할당과 유사하다.
만약 많은 큰 데이터 구조체를 생성하고 삭제해야한다면 slab cache를 고려해보아라. Slab layer는 프로세서별 객체 캐시(free list)를 관리하며, 이를 통해 객체 할당 비할당 성능을 크게 증가시켰다. 남은 메모리를 할당하는 대신 slab layer는 이미 할당된 객체의 캐시를 저장하고 필요하면 캐시에서 할당되어있던 객체를 반환한다.