Virtual address에서 PFN을 조사할 때 필요한 두 index

maps에서 주소 범위를 찾았다고 해서 그 범위의 모든 page가 현재 RAM에 존재하는 것은 아닙니다. Virtual page의 현재 상태를 조사하는 pagemap과 physical page의 metadata를 조사하는 global 파일을 연결해야 합니다.

Linux v6.12의 pagemap 문서를 기준으로 설명하겠습니다. 이 인터페이스에는 권한과 보안상 제한이 적용되며, 더 새로운 kernel에서는 bit의 의미가 추가될 수 있습니다.

첫 offset은 virtual address로 계산합니다

/proc/PID/pagemap에는 virtual page 하나당 64-bit entry가 있습니다. 주소를 base page 크기로 나눈 virtual page index에 8을 곱한 것이 해당 entry의 file offset입니다. 예를 들어 page size가 4 KiB일 때 주소 0x6000의 index는 6이고 file offset은 48 bytes입니다.

Entry를 읽은 뒤 먼저 present와 swapped 상태를 구분해야 합니다. Present일 때의 낮은 bit를 PFN으로 해석하는 것과, swapped일 때 swap type 및 offset으로 해석하는 것은 다른 경우입니다. 두 상태가 모두 없을 때 낮은 값을 physical page 번호처럼 사용하는 것은 잘못입니다.

Bit 범위 Linux v6.12에서의 해석
0-54 present이면 PFN입니다. swapped이면 0-4가 swap type, 5-54가 swap offset입니다.
55, 56 각각 soft-dirty, exclusive입니다.
57 userfaultfd write-protected 표시입니다.
58-60 이 version에서는 0으로 예약되어 있습니다.
61 file page 또는 shared anonymous page 표시입니다.
62, 63 각각 swapped, present입니다.

Linux v6.12 문서에서 bit 63은 present, bit 62는 swapped, bit 61은 file page 또는 shared anonymous 관련 표시입니다. Bit 55는 soft-dirty, bit 56은 exclusive, bit 57은 userfaultfd write protection과 연결됩니다. Exclusive의 의미에는 large allocation 등에 관한 세부 조건이 있으므로 단순한 process 개수의 대용으로 읽지 않으셔야 합니다.

PFN이 0이라고 항상 unmapped는 아닙니다

PFN 노출에는 capability 제한이 적용됩니다. 정보에 접근할 권한이 없는 경우 PFN field가 0으로 가려질 수 있습니다. Present bit를 무시한 채 PFN이 0이니 memory가 없다고 판단하면, 권한 제한을 mapping 부재로 오인하게 됩니다.

반대로 0이라는 숫자 자체만으로 가려졌다고 확정할 수도 없습니다. 실제 PFN 0이라는 가능성과 인터페이스의 권한 조건을 함께 고려해야 합니다. 진단 도구는 이런 경우 판정할 수 없다는 사실을 표현해야지, 그럴듯한 physical address를 임의로 만들면 안 됩니다.

두 번째 offset은 PFN으로 계산합니다

/proc/kpagecount와 /proc/kpageflags는 virtual address별 파일이 아닙니다. PFN을 index로 사용하고 역시 entry당 8 bytes입니다. 따라서 유효하게 얻은 PFN에 8을 곱해 접근합니다.

kpagecount는 mapping count에 관한 정보를 제공합니다. kpageflags는 KPF_*로 정의되는 physical-page 관련 상태와 가공된 표시를 제공합니다. 이것을 CPU의 raw PTE flags와 같다고 설명하면 안 됩니다. UAPI에 공개된 flag와 내부 header의 값도 구분해야 합니다.

THP와 같은 larger allocation에서는 kernel 설정에 따라 개별 page의 정확한 mapping count 대신 allocation 전체에서 계산한 평균이 제공될 수 있습니다. kpagecount를 process 개수나 reference count와 동일시하지 않으셔야 합니다. Read offset과 길이는 8 bytes 단위를 맞추며, 짧은 read를 정상 entry로 처리하지 않습니다.

Pagemap을 읽은 뒤 global 파일을 읽는 사이 page가 migration되거나 mapping이 바뀔 수 있습니다. Buffer 객체를 살아 있게 유지하는 것만으로 physical page를 pin하거나 두 파일의 조회를 atomic하게 만드는 것은 아닙니다. 이 경로에서 얻은 결과는 관찰 시점에 영향을 받습니다.

자신의 buffer 한 곳을 읽는 예제

아래 Python 예제는 두 index를 구분하기 위한 실습입니다. Linux에서 실행하며, 자신의 buffer 하나를 조회하고 권한 제한이 있으면 physical-page 조회를 생략합니다. Global 설정이나 다른 process의 memory를 변경하지 않습니다.

전체 예제 파일

pagemap_probe.py

"""Read one pagemap entry on Linux; this is not an atomic memory snapshot."""

import ctypes
import os
import sys

ENTRY_BYTES = 8
PFN_MASK = (1 << 55) - 1


def read_u64(path, index):
    if index < 0:
        raise ValueError("negative entry index")
    with open(path, "rb", buffering=0) as stream:
        stream.seek(index * ENTRY_BYTES)
        raw = stream.read(ENTRY_BYTES)
    if len(raw) != ENTRY_BYTES:
        raise EOFError("incomplete 64-bit entry")
    return int.from_bytes(raw, sys.byteorder)


def main():
    page_size = os.sysconf("SC_PAGE_SIZE")
    buffer = ctypes.create_string_buffer(page_size)
    buffer[0] = b"A"
    address = ctypes.addressof(buffer)
    entry = read_u64("/proc/self/pagemap", address // page_size)
    present = bool(entry & (1 << 63))
    swapped = bool(entry & (1 << 62))
    print(f"address={address:#x} present={present} swapped={swapped}")
    print(f"soft_dirty={bool(entry & (1 << 55))}")
    print(f"exclusive={bool(entry & (1 << 56))}")
    if swapped:
        print(f"swap_type={entry & 31} swap_offset={(entry >> 5) & ((1 << 50) - 1)}")
        return
    if not present:
        return
    pfn = entry & PFN_MASK
    if pfn == 0:
        print("PFN is zero: it may be hidden; no physical-page lookup attempted.")
        return
    print(f"pfn={pfn}")
    for path in ("/proc/kpagecount", "/proc/kpageflags"):
        try:
            value = read_u64(path, pfn)
        except (OSError, EOFError) as error:
            print(f"{path}: unavailable ({error})")
        else:
            print(f"{path}: {value:#x}")


if __name__ == "__main__":
    main()

Code는 다음 네 단계로 구성됩니다. Buffer를 만들고 한 byte를 쓴 뒤, 그 주소의 virtual page index로 pagemap entry를 읽습니다. Present와 swapped를 구분한 다음, 노출된 PFN이 유효할 때만 global 파일의 같은 PFN index를 읽습니다.

이 예제는 전체 page table을 덤프하거나 모든 flag를 해석하는 도구가 아닙니다. 실제 조사 범위를 늘릴 때는 maps로 mapping된 범위부터 찾고, 충분한 크기의 정확한 read, 읽는 중의 상태 변화와 version별 bit 정의를 처리해야 합니다. PFN을 얻었다는 사실도 그 memory에 임의로 접근할 권한을 얻었다는 뜻은 아닙니다.

Anonymous와 file mapping을 단계별로 비교합니다

다음 예제는 위의 pagemap_probe.py와 같은 directory에 두고 Linux에서 python3 pagemap_walk.py로 실행합니다. 자체 생성한 한 page 크기의 anonymous mapping과 임시 file만 사용합니다. 두 파일의 전체 code를 함께 제공했으므로 생략된 helper를 따로 구현하실 필요는 없습니다.

전체 비교 예제

pagemap_walk.py

"""Observe two mappings on Linux; flag definitions are pinned to Linux v6.12."""

import ctypes
import mmap
import os
import sys
import tempfile

from pagemap_probe import PFN_MASK, read_u64

KPAGE_FLAGS = (
    ("LOCKED", 0), ("REFERENCED", 2), ("UPTODATE", 3),
    ("DIRTY", 4), ("LRU", 5), ("ACTIVE", 6), ("WRITEBACK", 8),
    ("MMAP", 11), ("SWAPBACKED", 14), ("NOPAGE", 20),
)


def decode_entry(entry):
    result = {
        "present": bool(entry & (1 << 63)),
        "swapped": bool(entry & (1 << 62)),
        "file_or_shared_anon": bool(entry & (1 << 61)),
        "soft_dirty": bool(entry & (1 << 55)),
        "exclusive": bool(entry & (1 << 56)),
        "uffd_wp": bool(entry & (1 << 57)),
    }
    if result["swapped"]:
        result["swap_type"] = entry & 31
        result["swap_offset"] = (entry & PFN_MASK) >> 5
    elif result["present"]:
        result["pfn"] = entry & PFN_MASK
    return result


def decode_kpageflags(value):
    return [name for name, bit in KPAGE_FLAGS if value & (1 << bit)]


def mapping_address(area):
    # Release the exported buffer before the mmap context tries to unmap it.
    exported = ctypes.c_char.from_buffer(area)
    address = ctypes.addressof(exported)
    del exported
    return address


def observe(label, area, page_size):
    address = mapping_address(area)
    entry = read_u64("/proc/self/pagemap", address // page_size)
    fields = decode_entry(entry)
    print(f"\n{label}: address={address:#x} raw={entry:#018x}")
    print(" ".join(f"{name}={value}" for name, value in fields.items()))
    if not fields["present"] or fields["swapped"]:
        return
    pfn = fields["pfn"]
    if not pfn:
        print("PFN zero: possibly hidden; physical-page lookup skipped.")
        return
    for path in ("/proc/kpagecount", "/proc/kpageflags"):
        try:
            value = read_u64(path, pfn)
        except (OSError, EOFError) as error:
            print(f"{path}: unavailable ({error})")
        else:
            if path.endswith("kpageflags"):
                names = ",".join(decode_kpageflags(value)) or "none selected"
                print(f"kpageflags={value:#x}: {names}")
            else:
                print(f"kpagecount={value}")


def run():
    page_size = os.sysconf("SC_PAGE_SIZE")
    protection = mmap.PROT_READ | mmap.PROT_WRITE
    with mmap.mmap(
        -1, page_size, flags=mmap.MAP_PRIVATE | mmap.MAP_ANONYMOUS,
        prot=protection,
    ) as anonymous:
        observe("anonymous: before access", anonymous, page_size)
        anonymous[0] = ord("A")
        observe("anonymous: after write", anonymous, page_size)

    with tempfile.TemporaryFile() as backing:
        backing.truncate(page_size)
        backing.write(b"F")
        backing.flush()
        with mmap.mmap(
            backing.fileno(), page_size, flags=mmap.MAP_SHARED,
            prot=protection,
        ) as shared:
            observe("file: before access", shared, page_size)
            print(f"first byte={shared[0]:#x}")
            observe("file: after read", shared, page_size)
            shared[0] = ord("W")
            observe("file: after shared write", shared, page_size)
            shared.flush()


def main():
    if not sys.platform.startswith("linux"):
        raise SystemExit("This example requires Linux procfs.")
    try:
        run()
    except (OSError, EOFError) as error:
        raise SystemExit(f"Observation could not be completed: {error}") from error


if __name__ == "__main__":
    main()

Anonymous mapping은 접근 전과 첫 write 후를 비교합니다. File mapping은 접근 전, 첫 read 후, MAP_SHARED write 후를 비교합니다. 첫 read는 한 byte로 제한했으며, file 끝을 넘어 문자열을 탐색하지 않습니다. File은 mapping 전에 한 page 크기로 만들고, 모든 mapping과 file은 context manager를 벗어날 때 닫습니다. Shared write는 해당 임시 file에만 영향을 줍니다.

아직 접근하지 않은 anonymous mapping에는 보통 present PTE가 없습니다. 다만 이 예제는 각 단계의 상태를 관찰할 뿐 결과를 고정하지 않습니다. File data가 이미 page cache에 있더라도 해당 virtual address의 PTE 설치 여부는 별도로 확인해야 합니다. Background writeback이나 reclaim이 개입할 수 있으므로 write 직후 DIRTY가 반드시 보인다고 단정하지 않습니다. 마지막 flush() 또한 그 이전의 여러 조회를 atomic snapshot으로 바꾸지는 않습니다.

선택해서 표시한 physical-page flag는 다음과 같습니다. Bit 번호는 UAPI의 KPF_* 값이며, pagemap entry의 bit 번호와는 별개입니다.

Flag Bit 확인하려는 상태
LOCKED 0 page lock 상태입니다. mlock()으로 고정했다는 뜻은 아닙니다.
REFERENCED 2 참조 상태를 나타내는 page flag입니다.
UPTODATE 3 page data가 uptodate로 표시되어 있습니다.
DIRTY, WRITEBACK 4, 8 각각 dirty 상태와 진행 중인 writeback입니다. 서로 배타적이지 않습니다.
LRU, ACTIVE 5, 6 LRU 소속과 active 상태 표시입니다.
MMAP 11 userspace mapping에 관한 physical-page 표시입니다.
SWAPBACKED 14 swap-backed 성격입니다. 현재 swapped 상태라는 뜻은 아닙니다.
NOPAGE 20 해당 PFN에 유효한 page가 없는 경우의 표시입니다.

출력 중 file_or_shared_anon=True는 file만을 가리키지 않습니다. Shared anonymous page도 포함합니다. Soft-dirty는 physical-page의 DIRTY와 다른 계층의 표시이며, mapping 생성 직후부터 표시될 수도 있습니다. 이 실습에서는 soft-dirty를 초기화하기 위한 clear_refs write를 하지 않습니다.

PFN이 가려진 환경에서는 pagemap의 공개 flag까지만 비교하고, kpageflags와 kpagecount 조회는 건너뜁니다. 예제를 실행하기 위해 관리자 권한을 부여하실 필요는 없습니다. 모든 output은 실행한 환경의 관찰값이며, 특정 flag 조합을 미리 측정한 결과처럼 제시하지 않습니다.

참고 자료