process마다 page-table root가 다르다면 kernel의 거대한 direct map도 process 수만큼 복사할까요? 그렇지 않습니다. root entry를 복사하는 것과 그 entry가 가리키는 하위 table을 새로 만드는 것은 다른 작업입니다. 여기에 PTI가 더해지면 한 process 안에서도 kernel 실행용 root와 user 실행용 root를 구별해야 합니다.
이 글에서는 upstream v6.18의 x86-64를 기준으로 root의 생성, 공유와 전환을 연결합니다. user page의 CoW와 kernel mapping의 공유를 한 종류의 'page table 복사'로 뭉뚱그리지 않는 것이 핵심입니다.
root entry 복사는 깊은 복사가 아닙니다
mm_struct.pgd는 kernel이 접근할 수 있는 root table pointer입니다. CPU의 CR3에는 그 root의 physical address와 architecture별 제어 정보가 들어갑니다. C pointer를 그대로 CR3에 넣는 모델이 아닙니다.
새 mm을 준비하는 mm_init()은 PGD를 할당합니다. x86의 pgd_alloc()와 pgd_ctor()는 kernel 범위의 root entry들을 swapper_pg_dir에서 초기화합니다. 복사하는 것은 정해진 개수의 pgd_t 값입니다. 값 안의 하위 table physical address가 같으므로 그 아래 kernel mapping은 공유됩니다.
KERNEL_PGD_BOUNDARY와 KERNEL_PGD_PTRS는 복사할 root 구간을 정합니다. 이 작업을 kernel data 전체 또는 모든 하위 table의 깊은 복사라고 부르면 메모리 비용과 갱신의 전파 방식을 모두 오해하게 됩니다. 4-level에서는 P4D가 folded되므로 실제 공유가 시작되는 table level도 5-level과 같은 이름으로 단순화할 수 없습니다.
반면 fork의 copy_page_range()는 VMA를 따라 필요한 user mapping을 복제하는 작업입니다. private writable data에 대해서는 CoW 관계와 write-protection, reference와 reverse mapping을 준비합니다. kernel direct map을 VMA마다 통째로 복사하는 함수가 아닙니다. kernel root 초기화와 user mapping 복제를 별도로 보아야 합니다.
fork와 exec는 다른 출발점을 가집니다
일반적인 fork는 부모의 mm을 토대로 새 mm과 VMA들을 준비합니다. CLONE_VM을 사용한 경로는 새 address space를 복제하지 않고 기존 mm을 공유할 수 있습니다. 새 mm을 만드는 경로에서는 kernel root 구간의 초기화와 user range 복제가 각각 필요합니다.
exec는 새 실행 이미지를 위한 mm을 준비하고 성공적으로 전환합니다. 원래 프로그램의 heap, stack과 모든 user PTE를 새 프로그램에 계속 물려주는 동작이 아닙니다. 그러나 새 프로그램도 같은 kernel을 호출하므로 kernel mapping의 기준은 유지되어야 합니다. root를 만드는 공통 경로가 kernel entry들을 초기화하는 이유입니다.
allocation 중에는 root뿐 아니라 configuration에 따른 하위 table의 preallocation이나 architecture context 준비가 실패할 수 있습니다. pgd_alloc()은 준비가 끝나지 않은 root를 다른 PGD 순회자가 완성된 것으로 보지 않도록 pgd_lock 아래 constructor와 관련 게시 작업을 수행합니다. 실패 label들은 앞서 준비한 자원을 역순으로 정리합니다. 일반적인 x86-64 설명에서 PAE prepopulation이 주인공은 아니어도, 함수 안의 다른 config 분기를 존재하지 않는 코드로 간주할 수는 없습니다.
아래는 official v6.18 pgd_alloc()의 전체 정의입니다. kernel 의존성이 있는 GPL code이며 각 파일의 원래 SPDX와 출처, code/GPL-2.0.txt를 함께 읽습니다.
// SPDX-License-Identifier: GPL-2.0
/*
* Official Linux v6.18: arch/x86/mm/pgtable.c
* Complete function; source lines 322-374.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
pgd_t *pgd_alloc(struct mm_struct *mm)
{
pgd_t *pgd;
pmd_t *u_pmds[MAX_PREALLOCATED_USER_PMDS];
pmd_t *pmds[PREALLOCATED_PMDS];
pgd = _pgd_alloc(mm);
if (pgd == NULL)
goto out;
mm->pgd = pgd;
if (sizeof(pmds) != 0 &&
preallocate_pmds(mm, pmds, PREALLOCATED_PMDS) != 0)
goto out_free_pgd;
if (sizeof(u_pmds) != 0 &&
preallocate_pmds(mm, u_pmds, PREALLOCATED_USER_PMDS) != 0)
goto out_free_pmds;
if (paravirt_pgd_alloc(mm) != 0)
goto out_free_user_pmds;
/*
* Make sure that pre-populating the pmds is atomic with
* respect to anything walking the pgd_list, so that they
* never see a partially populated pgd.
*/
spin_lock(&pgd_lock);
pgd_ctor(mm, pgd);
if (sizeof(pmds) != 0)
pgd_prepopulate_pmd(mm, pgd, pmds);
if (sizeof(u_pmds) != 0)
pgd_prepopulate_user_pmd(mm, pgd, u_pmds);
spin_unlock(&pgd_lock);
return pgd;
out_free_user_pmds:
if (sizeof(u_pmds) != 0)
free_pmds(mm, u_pmds, PREALLOCATED_USER_PMDS);
out_free_pmds:
if (sizeof(pmds) != 0)
free_pmds(mm, pmds, PREALLOCATED_PMDS);
out_free_pgd:
_pgd_free(mm, pgd);
out:
return NULL;
}
첫 _pgd_alloc() 실패는 바로 NULL 반환으로 이어집니다. 일반 PMD 준비 뒤 user-shadow용 PMD 준비가 실패하면 일반 PMD만 반환하고, paravirtual allocation이 실패하면 user PMD와 일반 PMD를 모두 반환한 뒤 root를 해제합니다. 각 preallocate_pmds()는 자기 내부에서 부분 성공한 allocation을 정리하는 계약을 가집니다. 이 함수의 label이 없는 초기화 단계를 임의로 정리한다고 상상하면 double free가 됩니다.
constructor도 전체를 보면 버전 차이를 확인할 수 있습니다. v6.18 pgd_ctor()는 PAE가 아니면 kernel entry를 clone하고 PGD list에 연결합니다. historical code의 CONFIG_PGTABLE_LEVELS·SHARED_KERNEL_PMD 조건식을 현행 함수의 조건식이라고 인용하지 않습니다.
// SPDX-License-Identifier: GPL-2.0
/*
* Official Linux v6.18: arch/x86/mm/pgtable.c
* Complete function; source lines 82-93.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static void pgd_ctor(struct mm_struct *mm, pgd_t *pgd)
{
/* PAE preallocates all its PMDs. No cloning needed. */
if (!IS_ENABLED(CONFIG_X86_PAE))
clone_pgd_range(pgd + KERNEL_PGD_BOUNDARY,
swapper_pg_dir + KERNEL_PGD_BOUNDARY,
KERNEL_PGD_PTRS);
/* List used to sync kernel mapping updates */
pgd_set_mm(pgd, mm);
pgd_list_add(pgd);
}
해제는 constructor의 list 관계와 paravirtual 상태도 정리해야 합니다. 이것은 user leaf를 unmap하는 작업과 별개입니다.
// SPDX-License-Identifier: GPL-2.0
/*
* Official Linux v6.18: arch/x86/mm/pgtable.c
* Complete function; source lines 376-382.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
void pgd_free(struct mm_struct *mm, pgd_t *pgd)
{
pgd_mop_up_pmds(mm, pgd);
pgd_dtor(pgd);
paravirt_pgd_free(mm, pgd);
_pgd_free(mm, pgd);
}
최초의 user process도 이 원칙을 벗어나지 않습니다. v6.18 init/main.c의 rest_init()은 user_mode_thread(kernel_init, ...)로 PID 1의 출발점을 만들고, 실행 이미지 선택은 run_init_process()를 거쳐 kernel_execve()로 이어집니다. 초기 kernel 실행 문맥으로 시작했다는 사실과 나중에 user 프로그램을 위한 mm을 구성한다는 사실은 모순되지 않습니다.
PTI는 user 실행용 kernel mapping을 줄입니다
PTI가 없을 때에는 한 root가 user와 kernel mapping을 모두 포함하고 permission으로 접근을 나눌 수 있습니다. PTI는 user mode에서 필요한 kernel mapping을 최소한으로 줄이는 추가 경계입니다. kernel에 들어오면 완전한 kernel mapping이 있는 root를 사용하고, user mode로 돌아갈 때에는 제한된 root로 전환합니다.
PTI용 PGD는 x86의 일반적인 4 KiB page 환경에서 order-1 allocation의 인접한 두 root page를 사용합니다. 이 배치는 root physical address의 해당 bit를 바꾸어 두 copy 사이를 전환하기 쉽게 합니다. '두 root'는 모든 user PTE까지 이중으로 복제한다는 뜻은 아닙니다. user 범위의 PGD entry가 하위 table들을 공유하므로 하나의 PTE와 accessed/dirty 상태를 관리합니다.

그림은 두 root와 하위 user table의 공유를 구분합니다. 실제 entry 수, PCID와 CPU별 entry-area 구성은 생략했습니다.
완전한 kernel root의 user 구간에는 top-level NX를 적용합니다. kernel에서 user로 돌아가면서 root 전환을 놓쳤을 때 user instruction 실행이 즉시 실패하도록 하는 방어입니다. 이를 user PTE가 모두 영구적으로 NX가 된다는 뜻으로 읽으면 안 됩니다. user 실행용 root가 가리키는 동일 하위 table에서도 상위 entry의 실행 제한은 다릅니다.
user root에도 kernel 진입과 복귀에 필요한 최소 entry code와 CPU 관련 자료는 남아야 합니다. 그 코드마저 없으면 root를 바꾸는 transition 자체를 수행할 수 없습니다. 따라서 'PTI이면 user root에 kernel 주소가 단 하나도 없다'는 설명도 부정확합니다.
두 root의 변경을 함께 유지합니다
새 user mapping이 새로운 top-level entry를 필요로 하면 kernel root에만 추가해서는 안 됩니다. 대응하는 user root entry도 함께 유지해야 합니다. 이때 kernel 쪽 user entry의 NX 조건과 user 쪽 entry의 속성도 맞아야 합니다. PTI 관련 set_pgd() 경로가 단순한 store보다 복잡한 이유입니다.
kernel root 구간을 복제하는 clone_pgd_range()도 runtime PTI가 활성화되어 있으면 대응 user-root 구간을 함께 복사합니다. 새 process가 가져야 할 최소 kernel entry mapping을 빠뜨리지 않기 위한 처리입니다. compile-time 지원 여부만으로 현재 PTI 활성화를 단정해서는 안 됩니다. boot parameter와 CPU feature 판단도 관계됩니다.
context switch의 switch_mm_irqs_off()는 다음 mm의 kernel 실행 context를 준비합니다. 그 함수가 반환하는 순간 곧바로 user instruction이 실행되는 것은 아닙니다. 실제 user 복귀 경로에서 PTI root를 바꿔야 합니다. syscall과 interrupt entry/exit의 root 전환, process 간 context switch는 서로 만나는 지점이 있지만 동일한 한 단계는 아닙니다.
CR3 write를 '항상 모든 TLB를 flush하는 명령'으로 요약해서도 안 됩니다. PCID 지원과 generation, global translation 및 invalidation 범위에 따라 보존할 수 있는 entry가 달라집니다. PTI는 user/kernel PCID의 관리도 필요하게 합니다. 실제 switch 비용은 hardware와 활성 기능에 따른 결과이지 두 root라는 그림만으로 계산할 수 있는 상수가 아닙니다.
실제 clone의 copy 단위는 byte가 아니라 entry 개수입니다. 다음은 v6.18 clone_pgd_range()의 전체 정의입니다. PTI 지원 symbol도 현재는 CONFIG_MITIGATION_PAGE_TABLE_ISOLATION입니다.
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Official Linux v6.18: arch/x86/include/asm/pgtable.h
* Complete function; source lines 1465-1475.
* Body unchanged. Kernel dependencies and configuration are required.
* Source URL, SHA, commit, and license: provenance.json; GPL-2.0.txt.
*/
static inline void clone_pgd_range(pgd_t *dst, pgd_t *src, int count)
{
memcpy(dst, src, count * sizeof(pgd_t));
#ifdef CONFIG_MITIGATION_PAGE_TABLE_ISOLATION
if (!static_cpu_has(X86_FEATURE_PTI))
return;
/* Clone the user space pgd as well */
memcpy(kernel_to_user_pgdp(dst), kernel_to_user_pgdp(src),
count * sizeof(pgd_t));
#endif
}
이 helper는 memmove()가 아닙니다. source와 destination이 같은 root page 안에 있는 것 자체는 허용되지만 두 copy range는 겹치면 안 되고 page 경계를 넘어가서도 안 됩니다. count * sizeof(pgd_t)만큼 복사하므로 caller가 범위와 capacity를 확보해야 합니다. 일반적인 x86-64의 512-entry root에서 kernel half 256 entry를 복사한다면 8-byte entry 기준 2,048 byte입니다. 이것은 복사량의 예시이며 32-bit PAE 등 다른 layout의 boundary 상수를 그대로 대입하는 공식은 아닙니다.
공유되지 않은 root entry는 동기화가 필요합니다
이미 같은 하위 table을 가리키는 process들의 kernel mapping을 바꾸면 그 table의 갱신은 공유됩니다. 그러나 새 하위 table을 가리키는 root-level entry를 나중에 만들었다면 기존 process root에 그 pointer가 없을 수 있습니다. 공유가 시작되기 전의 연결부는 별도 문제입니다.
pgd_list는 이러한 kernel mapping 갱신을 process root들에 반영할 수 있도록 root들을 연결합니다. v6.18 x86의 arch_sync_kernel_mappings()와 sync_global_pgds()는 boot 뒤에도 고려해야 할 API입니다. 실제로 어느 level의 변경에서 필요한지는 ARCH_PAGE_TABLE_SYNC_MASK, 4/5-level 상태와 mapping 경로가 결정합니다. kernel 영역이 크다는 이유만으로 초기화 뒤 root 갱신은 절대 없다고 결론 내려서는 안 됩니다.
동기화는 이미 다른 하위 table이 존재해도 마음대로 갈아끼우는 동작이 아닙니다. 현재 구현은 양쪽 entry가 모두 존재하면 같은 하위 table을 가리키는지 검사하고, 빠진 쪽에 연결을 추가하는 규칙을 지킵니다. pgd_lock과 해당 mm의 page-table lock이 보호하는 관계도 함께 읽어야 합니다.
정리하면 process마다 root를 가진다는 사실, kernel 하위 table을 공유한다는 사실, PTI가 실행 mode별 root를 나눈다는 사실은 동시에 성립합니다. 세 층을 분리하면 fork, exec와 syscall이 같은 주소들을 서로 다르게 취급하는 이유를 설명할 수 있습니다.
참고 자료
- Lorenzo Stoakes, The Linux Memory Manager, Early Access 2025, Chapter 4의 Kernel PGD maintenance와 PTI 설명.
- Linux v6.18 x86 pgtable.c: PGD allocation과 constructor
- Linux v6.18 pgtable.h: clone_pgd_range와 PTI entry 처리
- Linux v6.18 PTI 문서
- Linux v6.18 init_64.c: kernel root 동기화
- Linux v6.18 tlb.c: context와 PCID 관리