Operating Systems : Three Easy Pieces를 보고 번역 및 정리한 내용들입니다.
다른 현재 OS들처럼 리눅스 가상 주소 공간도 유저 부분과 커널 부분으로 이루어져있다. 문맥 전환이 일어날 때 현재 실행되고 있는 주소 공간의 유저 부분은 바뀌고, 커널 부분은 프로세스들 사이에서도 동일하게 유지된다. 유저 모드에서 돌아가는 프로그램은 커널의 가상 페이지에 접근할 수 없으며, 커널에 트랩을 발생시켜 특권 모드로 전환할 때에만 해당 메모리는 접근될 수 있다.
32비트 리눅스에서 주소 공간의 유저/커널 부분의 분리는 주소 0xC0000000, 또는 전체 주소 공간의 3/4 지점에서 일어난다. 따라서 0에서 0xBFFFFFFF까지는 유저 가상 주소를 위해 쓰이고, 나머지는 커널의 가상 주소 공간이다. 64비트 리눅스는 비슷하기는 하지만, 조금 다른 지점에서 분할된다.
리눅스에서 재밌는 점은 두 종류의 커널 가상 주소를 포함한다는 것이다. 하나는 커널 논리 주소(kernel logical address)이이다. 이 종류의 메모리를 더 얻기 위해 커널 코드는 그냥 kmalloc
을 호출하기만 하면 된다. 페이지 테이블, 프로세스 별 커널 스택 등 대부분의 커널 자료 구조들은 여기에 있으며, 시스템 내의 대부분의 다른 메모리와는 달리 커널 논리 메모리는 디스크로 스왑되지 않는다.
커널 논리 주소와 물리 메모리와의 연결 관계도 눈여겨 볼 만하다. 구체적으로, 커널 논리 주소와 물리 메모리의 첫 번째 부분 사이에는 직접 사상이 있는데, 커널의 논리적 주소 0xC0000000는 물리 주소 0x0000000로 변환되고, 0xC0000FFF는 0x00000FFF로 변환된다. 이 직접 상이 갖는 함의는 두 가지이다. 우선, 커널 논리 주소와 물리 주소 사이의 변환은 간단하다. 그래서 이 주소들은 마치 물리 주소인 것처럼 다뤄진다. 두 번째로, 메모리 청크가 커널 논리 주소 공간에서 연속적이면 이는 물리공간에서도 마찬가지로 연속적이다. 이는 커널의 주소 공간의 일부에 할당된 메모리들이 (DMA를 통한 I/O 전속 작업과 같이) 정상적인 작동을 위해서는 연속된 물리 메모리를 필요로하는 연산들을 수행하는 데에 알맞게 만든다.
커널 주소의 다른 타입은 커널 가상 주소(kernel virtual address)다. 이 타입의 메모리를 얻으려면 커널 코드는 vmalloc
을 호출해야하는데, 이는 원하는 사이즈의, 가상적으로 연속된 공간을 할당하고 포인터를 반환한다. 커널 논리 메모리와 달리, 커널 가상 메모리는 보통 (물리 메모리 상에서는) 연속적이지 않다. 각 커널 가상 페이지는 불연속적인 물리 메모리에 매핑되지만, 그 메모리들은 할당하기에 더 쉽고, 그렇기에 큰 연속 물리 메모리 청크를 찾는 일이 어려운, 큰 버퍼를 위해 사용된다.
32비트 리눅스에서 커널 가상 주소를 쓰는 이유중 하나는, 이것이 커널이 대략 1기가가 넘는 메모리를 다룰 수 있게 하기 때문이다. 예전에 기계들은 이것보다 훨씬 적은 메모리를 가졌기 때문에 1GB가 넘는 접근을 가능케 하는 것이 이슈가 되지는 않았다. 하지만 기술이 발전하면서 커널은 더 많은 양의 메모리를 사용할 필요가 있게 됐다. 이렇게 커널 가상 메모리와 물리 메모리가 일대일 매핑이 되지는 않는다는 사실이 이를 가능케만든다. 하지만 64비트 리눅스로 옮겨가면, 이 문제는 덜 급한 문제가 된다. 64비트 리눅스에서는 커널이 가상 메모리의 마지막 1GB에만 국한되지는 않기 때문이다.
x86은 각 프로세스에 하나의, 하드웨어로 관리되는 멀티 레벨 페이지 테이블 구조를 제공한다. OS가 메모리에 매핑을 설정하고, 페이지 디렉토리의 시작 부분에 특권 레지스터를 가리키면, 나머지는 하드웨어가 모두 처리한다. OS는 프로세스 생성, 삭제, 문맥 전환 등의 각 경우마다 올바른 페이지 테이블이 하드웨어 MMU에 의해 변환을 수행하는 데 사용되고 있는 지 확인한다.
VAX/VMS 시스템에서와 같이 32비트 주소 공간은 오랫동안 사용되어 왔지만, 이는 이제 한계를 맞이하게 됐다. 가상 메모리는 시스템을 프로그래밍하기 쉽게 했지만, 현대 시스템들이 더 큰 메모리를 사용하게 되면서 32비트 주소는 이제 그 각각을 참조하기에 충분하지 않게 됐다. 64비트의 주소 공간이 필요해지는 이유다.
64 비트 주소로의 변화는 x86 페이지 테이블 구조에 영향을 준다. x86이 멀티 레벨 페이지 테이블을 쓰기 때문에 현재의 64비트 시스템은 4레벨 테이블을 사용하는데, 가상 주소 공간을 위해 64비트를 전부 쓰고 있지는 않고 하위 48비트만 쓰인다. 가상 주소의 상위 16 비트는 사용되지 않고 있고, 하위 12비트는 오프셋으로 쓰이며, 중간의 36비트가 변환되는 부분이다.
P1 부분은 최상위 페이지 디렉토리의 인덱스로 쓰인다. 주소 변환은 페이지 테이블의 실제 페이지를 P4로 인덱싱할 때까지 일어나고, 그 결과 원하던 PTE를 얻을 수 있게 된다. 시스템 메모리가 점점 더 커지면, 이 주소 공간의 상위 16비트도 사용되어 5 레벨, 6레벨의 페이지 테이블 구조를 사용하게 될 것이다. 특정한 데이터가 메모리의 어디에 위치해있는지를 찾기 위해 여섯 단계의 주소 변환이 필요해지는 것이다.
인텔 x86은 표준적인 4KB의 페이지 뿐만 아니라 여러 페이지 사이즈의 사용도 허용한다. 최근 설계는 2MB에서 1GB 페이지도 지원하는데, 리눅스는 응용 프로그램들이 이렇게 큰 페이지들을 활용할 수 있도록 진화해왔다.
큰 페이지를 사용하는 것에는 많은 이점들이 있다. VAX/VMS 시스템에서 본 것처럼, 이는 페이지 테이블에서 필요한 매핑의 수를 줄인다. 페이지가 커지면 매핑의 수는 줄어든다. 하지만 적은 페이지 테이블 엔트리가 큰 페이지를 쓰는 주된 이유인 것은 아니고, TLB의 동작과, 관련된 성능의 증가가 큰 크기의 페이지를 쓰는 주된 이유다.
프로세스가 많은 양의 메모리를 쓸 때, 이는 TLB를 빠르게 변환들로 채운다. 만약 그 변환들이 4KB 페이지들을 위한 것이라면, 전체 메모리 중 TLB 미스를 일으키지 않고 접근될 수 있다. 결과적으로, 수 기가 바이트 메모리의 기계에서 실행되는 현대의 큰 메모리 워크로드에 대해서, 이는 과한 성능적 비용을 필요로 한다. 최근 연구들에 따르면 몇몇의 응용 프로그램들은 전체 사이클 중 10%를 TLB 미스를 처리하는 데에만 사용하기도 한다.
큰 페이지는 적은 수의 TLB 슬롯들을 사용함으로써 프로세스가 TLB 미스 없이 넓은 범위의 메모리에 접근할 수 있게 해준다. 다른 이점들도 있다. 큰 페이지를 사용하면 TLB 미스를 처리하는 경로가 짧아져 TLB 미스가 일어났을 때 이를 더 빠르게 처리할 수 있다. 추가적으로 할당도 더 빨라질 수 있다.
리눅스의 큰 페이지 지원이 어떻게 점차 발전했는지를 살펴보는 것도 흥미롭다. 처음에 리눅스 개발자들은이것이 성능 요구가 엄격한 큰 데이터베이스와 같은 일부 응용 프로그램에만 중요할 것이라 생각했기에, 해당 응용 프로그램들이 명시적으로 더 큰 페이지의 메모리 할당을 요청하도록 만들었다. 하지만 이런 방식으로는 대부분의 응용 프로그램은 영향을 받지 않을 것이고, 이를 필요로 하는 소수의 응용 프로그램들도 이 인터페이스를 사용하도록 변경되어야 한다.
최근에는 더 나은 TLB 동작에 대한 수요가 여러 응용 프로그램들 사이에서 흔해지면서, 리눅스 개발자들은 투명 거대 페이지(transparent huge page, THP)에 대한 지원을 추가했다. 이 기능이 활성화되면, OS는 어플리케이션의 수정 없이 자동으로 큰 페이지들을 할당할 기회를 찾는다.
물론 큰 페이지를 사용하는 데에도 비용은 있다. 가장 큰 잠재적인 비용은 내부 단편화로, 메모리를 크지만, 적게 사용되는 페이지들로 채우는 것이다. 스와핑도 거대 페이지와는 잘 작동하지 않는데, 이는 거대 페이지가 종종 I/O 작업을 증폭시키기도 하기 때문이다. 할당의 오버헤드도 어떤 경우에는 나빠질 수 있다.
하지만 전체적으로 하나는 분명하다. 오랫동안 시스템들에 충분했던 4KB 페이지 사이즈는 더 이상 예전만큼의 보편적인 해결책이 되지 못한다. 메모리의 사이즈가 커짐에 따라, 큰 페이지와 그 외 다른 솔루션들이 필요해지는 이유다.
영구 스토리지에 접근하는 비용을 줄이기 위해 대부분의 시스템은 적극적인 캐싱 서브 시스템으로 자주 쓰이는 데이터를 메모리에다가 보존한다.
리눅스 페이지 캐시는 세 종류의 소스로부터의 페이지들을 메모리에 담는다.
이것들은 해당 데이터가 필요할 때 빠르게 검색될 수 있도록 페이지 캐시 해시 테이블에 담긴다.
페이지 캐시는 엔트리의 수정 여부를 추적한다. 수정된 데이터는 백그라운드 스레드에 의해 주기적으로 배킹 스토어에 쓰여, 수정된 데이터가 결국에는 영구 저장소에 쓰임을 보장한다. 이 백그라운드 동작은 특정 기간을 두고 일어나거나,너무 많은 페이지들이 수정된 상태로 있는 경우 일어난다.
시스템에 메모리가 부족한 경우, 리눅스는 어떤 페이지를 메모리로부터 쫓아내서 공간을 확보할지를 결정하기 위해 이중 큐(2Q) 교체의 수정된 형태를 사용한다.
이전에 표준 LRU 교체가 효과적이기는 하지만, 흔히 쓰이는 특정 접근 패턴의 경우에 대해서는 몹시 떨어지는 성능을 보임을 본 적이 있다. 2Q 교체 알고리즘의 리눅스 버전은 이 문제를 두 리스트에 메모리를 나누는 방식으로 해결한다. 처음으로 접근할 때 페이지는 한 큐(A1
, 또는 inactive list)에 들어가고, 재참조될 때 이 페이지는 다른 큐(Aq
, active list)로 승격된다. 교체가 일어나야 하는 경우, 교체 대상 후보는 inactive list에서 뽑힌다. 리눅스는 주기적으로 페이지를 active list에서 inactive list로 옮겨 active list를 전체 페이지 캐시 크기의 2/3 정도로 유지한다.
이 리스트들은 LRU의 순서에 따라 관리되는데, 앞선 장에서 보았던 것처럼 완전히 LRU에 맞도록 사용하는 것은 비용이 너무 많이 들기 때문에 많은 OS들은 근사 LRU를 사용한다.이 2Q 접근법은 LRU와 비슷하게 작동하지만, inactive list에 대한 순환적인 접근을 제한함으로써 순환적인 대용량 접근을 효과적으로 처리한다. 페이지들은 메모리에서 쫓겨나기 전에는 절대 재참조되지 않기 때문에, active list에 있는 어떤 다른 유용한 페이지도 플러시하지 않는다.
가장 중대한 위협은 버퍼 오버플로우(buffer overflow) 공격으로, 이는 일반적인 유저 프로그램이나 커널 그 자체에 대한 공격이 될 수 있다. 이 공격의 기본 아이디어는 공격자가 임의의 데이터를 타겟의 주소 공간에 주입시킬 수 있게 하는, 시스템의 버그를 찾는 것이다. 그런 취약점들은 개발자들이 입력이 너무 지나치게 크지는 않을 것이라 가정하고 해당 입력을 버퍼에 복사하기 때문에 발생한다. 입력이 너무 커서 버퍼를 초과하고 타겟의 메모리를 덮어쓰는 것이다.
많은 경우 이런 오버플로가 그렇게 심각한 문제가 되지는 않는다. 하지만 악의를 가진 프로그래머들은 버퍼를 넘는 입력으로 대상 시스템에 특정 코드를 주입해, 해당 시스템이 공격자들이 원하는 행동을 할 수 있게 만들 수 있다. 네트워크로 연결된 유저 프로그램에서 이 공격이 성공하면, 공격자는 임의의 연산을 실행하거나 할 수 있다. 특히 OS에 대한 공격은 특권 레벨을 상승시켜 원래는 허용되지 않았을 자원들에 대한 접근을 가능하게 만들 수도 있다.
오버플로우에 대한 가장 간단한 방어법은 주소 공간의 특정 영역에 있는 코드의 실행을 막는 것이다. AMD가 도입한 NX 비트가 그 예로, 이는 해당 비트가 1인 PTE의 페이지에서 코드 실행을 막는다.
하지만 공격자들은 주입된 코드가 공격자에 의해 추가되지 않더라도 임의의 코드 시퀀스가 실행되게 만들 수도 있다. 바로 return-oriented programming(ROP)라 불리는 이 공격법이다. 프로그램들, 특히 풍부한 C 라이브러리에 링크된 C 프로그램들의 주소 공간에는 많은 코드 비트(gadgets)들이 있다. 공격자들은 스택을 덮어 써, 현재 실행되고 있는 함수의 반환 주소를 바꿈으로써 원하는 악의적 명령을 실행하도록 한다. 여러 가젯들을 묶고, 이들이 다음 가젯으로 리턴하게 만듦으로써 공격자는 임의의 코드를 실행할 수 있다.
ROP를 막으려면, 주소 공간 레이아웃 무작위화(address space layout randomization, ALSR) 기법을 사용하면되는데, 이는 코드, 스택, 힙을 가상 주소 공간의 고정된 위치에 두지 않고 무작위화해, 이런 종류의 공격들을 구현하는 데에 필요한 복잡한 코드 시퀀스를 만드는 것을 어렵게 만든다.
ASLR은 유저 레벨 프로그램들을 위한 유용한 방어 수단이어서, 커널 주소 공간 레이아웃 무작위화(kernel address space layout randomization, KASLR)라고 하는 기능으로 커널에 통합되기도 했다.
커널이 처리해야 할 다른 중대한 문제들에는 다음의 두 가지도 있다. 첫 번째는 멜트다운(meltdown), 다른 하나는 스펙터(spectre)라 불린다. 이 중 더 문제가 되는 것은 스펙터다.
이 공격들에 쓰인 취약점은 현대 시스템의 CPU들이 성능 향상을 위해서 온갖 기술들을 다 쓴다는 데에서 시작한다. 그 기술 중에는 speculative execution이라 불리는 게 있는데, 이는 CPU가 어떤 명령이 곧 실행될 것인지를 떠올리고 미리 실행하게 하는 것이다. 만약 해당 추측이 맞아 떨어지면 프로그램은 더 빠르게 실행될 것이고, 그렇지 않으면 해당 행동을 취소하고 재실행한다.
이 speculation의 문제는 프로세서 캐시나 분기 예측기 등, 시스템의 여러 곳에 실행 흔적들을 남겨두곤 한다는 것이다. 이러한 상태들은 MMU에 의해 보호될 것이라 생각했던 것들을 포함한 메모리들의 내용까지도 취약하게 만든다.
커널 보호를 향상시키는 한 방법은 각 유저 프로세스에서 커널 주소 공간을 최대한 없애고, 대부분의 데이터를 위해서는 분리된 커널 페이지 테이블을 사용하는 것이다. 이걸 커널 페이지 테이블 고립(kernel page table isolation)이라 부른다. 커널의 코드와 자료 구조들을 각 프로세스에 매핑하지 않고, 최소한만을 거기에 두는 것이다. 커널로의 전환이 일어날 때에는 커널 페이지 테이블의 전환이 일어나며, 이렇게 함으로써 보안이 향상되고 공격 벡터도 피할 수 있다. 다만 비용이 없는 것은 아닌데, 페이지 테이블을 전환하는 작업은 비싸기 때문에, 커널 페이지를 전환하는 것 또한 성능적 비용을 가진다.
KPTI가 모든 보안 문제를 해결하는 것도 아니다. 이는 잘해야 일부를 해결할 뿐이다. 다른 간단한 해결법으로는 speculation 기능을 끄는 것이 있지만, 이는 시스템을 수 천배는 느리게 만들기 때문에 선택할 만한 것이 되지는 못한다.
잘 보고 갑니다~