들어가며
오늘은 TLB(Translation Lookaside Buffer), 그리고 컨텍스트 스위칭(Context Switch) 과정에서 발생하는 TLB Flush 최적화에 대한 주제로 글을 작성했습니다.
애플리케이션의 성능을 이야기할 때 흔히 DB 쿼리, API 응답 속도 등을 떠올리지만, 그 아래에는 운영체제와 하드웨어가 벌이는 수많은 최적화 노력이 숨어있습니다. 오늘은 그중 하나인 TLB 최적화의 여정을 리눅스 커널 코드와 인텔 CPU 아키텍처 문서를 통해 함께 따라가 보겠습니다.
😤 이론 배경: 가상 메모리와 TLB
본격적인 이야기에 앞서, 몇 가지 핵심 개념을 가볍게 짚고 넘어가겠습니다.
가상 메모리(Virtual Memory)와 페이징(Paging)
현대 운영체제는 각 프로세스에게 독립적인 메모리 공간을 제공하기 위해 가상 메모리라는 개념을 사용합니다. 개발자가 코드에서 다루는 0x12345678 같은 주소는 실제 물리 메모리(RAM) 주소가 아닌 가상 주소입니다. 운영체제는 이 가상 주소를 실제 물리 주소로 변환(매핑)해주죠.
이때 메모리를 일정한 크기로 잘라서 관리하는데, 이 단위를 페이지(Page)라고 합니다. 즉, 운영체제는 'A 프로세스의 1번 가상 페이지는 물리 메모리의 100번 프레임에 있다'와 같은 정보를 페이지 테이블에 기록해 둡니다.
CPU의 MMU(Memory Management Unit)는 이 페이지 테이블을 참조하여 주소 변환을 수행합니다.
TLB: 주소 변환을 위한 고속 캐시
그런데 생각해보면, 메모리에 접근할 때마다 매번 페이지 테이블을 뒤지는 것은 비효율적입니다. 페이지 테이블 자체도 메모리에 있기 때문에, 주소 변환을 위해 추가적인 메모리 접근이 여러 번 발생할 수 있습니다.
이 속도를 높이기 위해 MMU 내부에 작고 빠른 캐시를 두었는데, 이것이 바로 TLB(Translation Lookaside Buffer) 입니다. TLB는 최근에 변환된 가상 주소-물리 주소 매핑 정보를 저장합니다.
메모리 접근 과정을 요약하면 다음과 같습니다 :
1. CPU: 특정 가상 주소의 데이터 요청
2. MMU
- TLB 확인 (Cache Hit): TLB에 매핑 정보가 있으면, 즉시 물리 주소로 변환하여 메모리에 접근. (매우 빠름)
- 페이지 테이블 확인 (Cache Miss): TLB에 정보가 없으면, 페이지 테이블을 뒤져서 매핑 정보를 찾음. (느림)
- 페이지 폴트 (Page Fault): 페이지 테이블에도 정보가 없다면(물리 메모리에 없음), 디스크에서 해당 데이터를 메모리로 가져온 후 주소 변환을 재시도. (매우 매우 느림)
TLB는 주소 변환의 성능을 좌우하는 핵심 장치인 셈입니다.
TLB의 구조와 컨텍스트 스위칭의 문제
TLB의 각 엔트리(Entry)는 대략 아래와 같은 정보를 담고 있습니다.

여기서 주목할 것은 PCID입니다. 잠시 후 자세히 다룰 이 값은 "이 TLB 엔트리가 어떤 프로세스(문맥)의 것인가?"를 구분하는 태그 역할을 합니다.
과거에는 이 태그가 없었습니다. 어떤 일이 벌어졌을까요?
프로세스 A에서 프로세스 B로 컨텍스트 스위칭이 일어나면, TLB에 캐싱된 주소 정보는 이제 쓸모가 없어집니다. 프로세스 B의 가상 주소 0x1000은 프로세스 A의 0x1000과 전혀 다른 물리 주소를 가리키기 때문이죠.
따라서, 과거에는 컨텍스트 스위칭이 발생할 때마다 TLB의 모든 내용을 비워버렸습니다 (TLB Flush). 이는 마치 작업을 바꿀 때마다 단기 기억을 모두 지우는 것과 같습니다. 새로 시작하는 프로세스 B는 모든 메모리 접근에 대해 TLB 미스를 겪게 되어 성능 저하가 불가피했습니다.
최적화의 열쇠: ASID와 PCID
이 비효율을 해결하기 위해 "태그" 개념이 도입되었습니다.
ASID (Address Space ID): 운영체제(리눅스) 수준에서 각 프로세스의 메모리 공간(mm_struct)을 구별하기 위해 부여하는 ID입니다.
PCID (Process-Context ID): 하드웨어(Intel CPU) 수준에서 ASID와 유사한 개념을 구현한 것입니다. TLB 엔트리에 이 PCID 태그를 붙여서, 어떤 프로세스에 속한 매핑 정보인지를 명시합니다.
이제 컨텍스트 스위칭이 일어나도 TLB를 전부 비울 필요가 없습니다. 그냥 "지금부터는 PCID가 11인 엔트리만 사용해!"라고 CPU에게 알려주기만 하면 됩니다. 덕분에 다른 프로세스의 TLB 엔트리는 그대로 유지되고, 나중에 그 프로세스로 다시 돌아왔을 때 캐시를 재사용할 수 있게 되었습니다.
x86_64 아키텍처에서 CR3 레지스터는 현재 활성화된 페이지 테이블의 시작 주소를 가리키는 중요한 역할을 합니다. 그리고 바로 이 CR3 레지스터에 PCID 값을 함께 담아, 컨텍스트 스위칭과 TLB 제어를 동시에 수행합니다.

환경
- 아키텍쳐 : x86_64
- os : 리눅스 커널 v5.15
🎢 리눅스 코드와 하드웨어의 만남
이제 리눅스 커널 코드를 통해 이 과정이 실제로 어떻게 일어나는지 추적해 봅시다.
컨텍스트 스위칭이 발생하면 context_switch() 함수가 호출되고, 내부적으로 메모리 컨텍스트를 전환하는 switch_mm_irqs_off() 함수가 실행됩니다.
void switch_mm_irqs_off(struct mm_struct *prev, struct mm_struct *next,
struct task_struct *tsk)
{
// 다음에 실행될 프로세스(next)에 적합한 ASID를 선택하고,
// TLB flush가 필요한지 여부(need_flush)를 결정한다.
choose_new_asid(next, next_tlb_gen, &new_asid, &need_flush);
// flush가 필요하다고 판단되면
if (need_flush) {
// load_new_mm_cr3 함수를 호출하며 flush가 필요하다고 알림 (true)
load_new_mm_cr3(next->pgd, new_asid, true);
}
}
static void load_new_mm_cr3(pgd_t *pgdir, u16 new_asid, bool need_flush)
{
unsigned long new_mm_cr3;
// flush가 필요한가?
if (need_flush) {
invalidate_user_asid(new_asid);
// CR3 레지스터 값을 'flush를 유발하는 방식'으로 생성
new_mm_cr3 = build_cr3(pgdir, new_asid);
} else {
// CR3 레지스터 값을 'flush를 유발하지 않는 방식'으로 생성
new_mm_cr3 = build_cr3_noflush(pgdir, new_asid);
}
// 최종적으로 계산된 값을 CR3 레지스터에 쓴다.
write_cr3(new_mm_cr3);
}
코드를 따라가 보니 load_new_mm_cr3 함수가 핵심입니다. need_flush 플래그 값에 따라 build_cr3 또는 build_cr3_noflush 함수를 호출하여 CR3에 쓸 값을 만듭니다.
그런데 이상합니다. write_cr3 함수의 내부를 보면, 명시적으로 "TLB를 플러시하라"는 명령은 보이지 않습니다.
static inline void write_cr3(unsigned long x)
{
// 인라인 어셈블리를 통해 CR3 레지스터에 값을 쓰는 것이 전부다.
asm volatile("mov %0,%%cr3": : "r" (val) : "memory");
}
그저 mov 명령어로 CR3 레지스터에 값을 쓰는 것뿐입니다. 어떻게 이것만으로 TLB Flush가 제어될까요?
비밀은 하드웨어에 있습니다
Intel 64 and IA-32 Architectures Software Developer Manuals 문서 Volume 3A, Chapter 5.10.4.1 을 보게 되면, TLB Invalidate 와 관련된 내용이 존재합니다.
MOV to CR3. The behavior of the instruction depends on the value of CR4.PCIDE:
— If CR4.PCIDE = 0, the instruction invalidates all TLB entries associated with PCID 000H except those for global pages. It also invalidates all entries in all paging-structure caches associated with PCID 000H.
— If CR4.PCIDE = 1 and bit 63 of the instruction’s source operand is 0, the instruction invalidates all TLB entries associated with the PCID specified in bits 11:0 of the instruction’s source operand except those for global pages. It also invalidates all entries in all paging-structure caches associated with that PCID. It is not required to invalidate entries in the TLBs and paging-structure caches that are associated with other PCIDs.
— If CR4.PCIDE = 1 and bit 63 of the instruction’s source operand is 1, the instruction is not required to invalidate any TLB entries or entries in paging-structure caches.
요약해보자면,
CR4.PCIDE = 0
(PCID 기능 비활성화) CR3에 값을 쓰면, PCID가 0인 모든 TLB 엔트리를 무효화합니다. (구식 전역 플러시 방식)
CR4.PCIDE = 1: (PCID 기능 활성화)
If bit 63 of the source operand is 0: CR3에 담긴 PCID (비트 11:0)와 일치하는 모든 TLB 엔트리를 무효화합니다. (선택적 플러시)
If bit 63 of the source operand is 1: TLB 엔트리를 무효화하지 않습니다. (플러시 안 함)
CR4.PCIDE 값 같은 경우 리눅스 시작시 1로 세팅이 됩니다.
소스코드
static void setup_pcid(void)
{
...
if (boot_cpu_has(X86_FEATURE_PGE)) {
cr4_set_bits(X86_CR4_PCIDE);
}
}
load_new_mm_cr3 함수에서 need_flush가 true이면, build_cr3 함수는 CR3에 쓸 값의 63번 비트를 0으로 설정합니다.
반대로 need_flush가 false이면, build_cr3_noflush 함수는 63번 비트를 1로 설정합니다.
결국 커널은 명시적인 flush 명령을 내리는 것이 아니라, CR3 레지스터에 값을 쓰는 행위에 따른 하드웨어 동작을 활용하여 TLB를 제어하고 있었던 것입니다.
한눈에 보는 과정
이 복잡한 과정을 그림으로 정리해 보겠습니다.
[컨텍스트 스위치 발생: Process A -> Process B]
|
V
[choose_new_asid()]
- Process B를 위한 ASID/PCID 할당
- 이 PCID가 재사용되어 이전 내용이 더럽혀졌나? (need_flush 결정)
|
+----------------------+
| |
(need_flush == true) (need_flush == false)
| |
V V
[build_cr3()] [build_cr3_noflush()]
- CR3 값 생성 - CR3 값 생성
- (bit 63 = 0) - (bit 63 = 1)
| |
V V
[write_cr3(new_value)] [write_cr3(new_value)]
| |
V V
[하드웨어 동작] [하드웨어 동작]
- MOV to CR3 - MOV to CR3
- CR4.PCIDE=1, bit63=0 - CR4.PCIDE=1, bit63=1
- "해당 PCID의 TLB - "TLB 플러시 안 함!"
엔트리를 플러시!"
마치며
오늘은 TLB Flush 최적화라는 주제를 통해 소프트웨어(리눅스 커널)와 하드웨어(CPU)가 어떻게 긴밀하게 협력하는지 살펴보았습니다.
- 하드웨어의 PCID 기능으로 TLB 엔트리에 태그를 달아 불필요한 전체 Flush를 방지
- 리눅스 커널은
mov to cr3명령어를 활용하여, 상황에 따라 선택적 Flush 또는 No-Flush를 수행
아쉬운 점은 load_new_mm_cr3 함수의 invalidate_user_asid(new_asid) 에서 해당 asid 의 tlbstate 값에 비트마킹을 해두는 부분이 있는데, 그 부분은 어디 쓰이는지 알아내지 못했습니다. 혹시 아시는 분이 계시다면 댓글로 달아주신다면 감사하겠습니다.
긴 글 읽어주셔서 감사합니다.
Reference
https://github.com/torvalds/linux/blob/v5.15/arch/x86/mm/tlb.c
https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
https://github.com/torvalds/linux/blob/v5.15/arch/x86/mm/init.c
'CS > Linux' 카테고리의 다른 글
| [Linux] epoll의 내부 동작 방식 (1) | 2025.06.01 |
|---|---|
| [Linux] recv()는 어떻게 잠들고 깨어날까? (0) | 2025.05.23 |
| [Linux] 네트워크 커널 스택: 리눅스 커널 네트워크 패킷 수신 여정 #3 (0) | 2025.05.22 |
| [Linux] Ring Buffer 에서 NAPI: 리눅스 커널 네트워크 패킷 수신 여정 #2 (0) | 2025.05.21 |
| [Linux] NIC에서 링 버퍼까지: 리눅스 커널 네트워크 패킷 수신 여정 #1 (0) | 2025.05.20 |