Linux下的Cache和TLB刷新
作者:David S. Miller davem@redhat.com
本文描述了Linux VM子系统调用的缓存/TLB刷新接口。它枚举了每个接口,描述了其预期目的以及在调用接口后预期的副作用。
下面描述的副作用是针对单处理器实现的,并且是在单个处理器上发生的。SMP情况是一个简单的扩展,只需将定义扩展到系统中的所有处理器上。不要因此认为SMP缓存/TLB刷新必须如此低效,事实上在这个领域有许多优化的可能。例如,如果可以证明用户地址空间从未在某个CPU上执行过(参见mm_cpumask()),则在该CPU上不需要为该地址空间执行刷新。
首先是TLB刷新接口,因为它们最简单。在Linux下,“TLB”被抽象为CPU用于缓存从软件页表获取的虚拟地址到物理地址的转换。这意味着如果软件页表发生变化,TLB缓存中可能存在过期的转换。因此,当发生软件页表更改时,内核将在页表更改发生后调用以下任一刷新方法:
void flush_tlb_all(void)
- 最严重的刷新。运行此接口后,任何先前的页表修改都将对CPU可见。
- 通常在更改内核页表时调用,因为这样的转换在“全局”范围内。
void flush_tlb_mm(struct mm_struct *mm)
- 此接口从TLB中刷新整个用户地址空间。运行后,此接口必须确保地址空间“mm”的任何先前页表修改对CPU可见。也就是说,运行后,对于“mm”,TLB中将没有条目。
- 此接口用于处理整个地址空间的页表操作,例如在fork和exec期间发生的操作。
void flush_tlb_range(struct vm_area_struct *vma, unsigned long start, unsigned long end)
- 在这里,我们从TLB中刷新特定范围的(用户)虚拟地址转换。运行后,此接口必须确保地址空间“vma->vm_mm”中范围为“start”到“end-1”的任何先前页表修改对CPU可见。也就是说,运行后,“start”到“end-1”范围内的虚拟地址对应的TLB中将没有条目。
- “vma”是用于该区域的后备存储。主要用于munmap()类型的操作。
void flush_tlb_page(struct vm_area_struct *vma, unsigned long addr)
- 这次我们需要从TLB中移除大小为PAGE_SIZE的转换。'vma'是Linux用于跟踪进程的mmap'd区域的后备结构,地址空间可通过vma->vm_mm获得。还可以测试(vma->vm_flags & VM_EXEC)以查看此区域是否可执行(因此可能在分离TLB类型的设置中位于“指令TLB”中)。
- 运行后,此接口必须确保地址空间“vma->vm_mm”中用户虚拟地址“addr”的任何先前页表修改对CPU可见。也就是说,运行后,“addr”对应的TLB中将没有条目。
- 这主要用于故障处理。
void update_mmu_cache_range(struct vm_fault *vmf, struct vm_area_struct *vma, unsigned long address, pte_t *ptep, unsigned int nr)
- 在每次页面故障结束时,调用此例程,告知体系结构特定代码现在在地址空间“vma->vm_mm”中的虚拟地址“address”处存在“nr”个连续页面的软件页表转换。
- 这个例程也在其他一些地方调用,传递了一个空的“vmf”。
- 端口可以以任何方式使用此信息。例如,它可以使用此事件来预先加载软件管理的TLB配置的TLB转换。sparc64端口目前正在这样做。
接下来是缓存刷新接口。一般来说,当Linux将现有的虚拟地址到物理地址的映射更改为新值时,序列将采用以下形式之一:
- flush_cache_mm(mm);
change_all_page_tables_of(mm);
flush_tlb_mm(mm); - flush_cache_range(vma, start, end);
change_range_of_page_tables(mm, start, end);
flush_tlb_range(vma, start, end); - flush_cache_page(vma, addr, pfn);
set_pte(pte_pointer, new_pte_val);
flush_tlb_page(vma, addr);
缓存级别的刷新总是首先进行,因为这使我们能够正确处理那些在虚拟地址从缓存中刷新时需要存在虚拟地址到物理地址转换的系统。HyperSparc CPU就是这样一个具有这种属性的CPU。
以下的缓存刷新例程只需要处理缓存刷新到特定CPU所需的程度。大多数情况下,这些例程必须为必须在虚拟地址到物理地址转换更改或删除时刷新的具有虚拟索引缓存的CPU实现。因此,例如,IA32处理器的物理索引物理标记缓存无需实现这些接口,因为这些缓存是完全同步的,不依赖于转换信息。
以下是每个例程:
void flush_cache_mm(struct mm_struct *mm)
该接口从缓存中刷新整个用户地址空间。也就是说,在运行之后,将不会有与'mm'相关的缓存行。
该接口用于处理整个地址空间的页表操作,比如在进程退出和执行期间发生的情况。void flush_cache_dup_mm(struct mm_struct *mm)
该接口从缓存中刷新整个用户地址空间。也就是说,在运行之后,将不会有与'mm'相关的缓存行。
该接口用于处理整个地址空间的页表操作,比如在进程复制期间发生的情况。
与flush_cache_mm
相比,这个选项是为了允许对VIPT缓存进行一些优化。void flush_cache_range(struct vm_area_struct *vma, unsigned long start, unsigned long end)
在这里,我们从缓存中刷新特定范围的(用户)虚拟地址。运行之后,对于范围在'start'到'end-1'之间的'vma->vm_mm'的虚拟地址,缓存中将不再有条目。
“vma”是用于该区域的后备存储。主要用于munmap()类型的操作。
提供此接口是希望端口可以找到一种适当高效的方法,以便一次性从缓存中移除多个页面大小的区域,而不是让内核为每个可能被修改的条目调用flush_cache_page(见下文)。void flush_cache_page(struct vm_area_struct *vma, unsigned long addr, unsigned long pfn)
这次我们需要从缓存中移除一个PAGE_SIZE大小的范围。'vma'是Linux用于跟踪进程mmap'd区域的后备结构,地址空间可以通过vma->vm_mm获得。还可以测试(vma->vm_flags & VM_EXEC)以查看此区域是否可执行(因此可能存在于“Harvard”类型的缓存布局中的“指令缓存”中)。
'pfn'表示'addr'转换为的物理页帧(将此值左移PAGE_SHIFT以获得物理地址)。应该从缓存中移除的是这种映射。
运行之后,对于'vma->vm_mm'的虚拟地址'addr'转换为'pfn',缓存中将不再有条目。
这主要用于错误处理。void flush_cache_kmaps(void)
如果平台使用高内存,则只需实现此例程。它将在所有kmaps失效之前调用。
运行之后,内核虚拟地址范围PKMAP_ADDR(0)到PKMAP_ADDR(LAST_PKMAP)中将不再有缓存条目。
此路由应在asm/highmem.h中实现。void flush_cache_vmap(unsigned long start, unsigned long end) void flush_cache_vunmap(unsigned long start, unsigned long end)
在这两个接口中,我们从缓存中刷新特定范围的(内核)虚拟地址。运行之后,内核地址空间中'start'到'end-1'的虚拟地址将不再有缓存条目。
这两个例程中的第一个在vmap_range()安装了页表条目后调用。第二个在vunmap_range()删除页表条目之前调用。
存在另一整个类别的CPU缓存问题,目前需要一整套不同的接口来正确处理。最大的问题是处理处理器数据缓存中的虚拟别名。
你的端口是否容易受到D-cache的虚拟别名影响?如果你的D-cache是虚拟索引的,大小大于PAGE_SIZE,并且不会阻止同一物理地址的多个缓存行同时存在,那么你就有这个问题。
如果你的D-cache有这个问题,首先要正确定义asm/shmparam.h中的SHMLBA,它应该基本上是你的虚拟地址D-cache的大小(或者如果大小是可变的,则是最大可能的大小)。这个设置将强制SYSv IPC层只允许用户进程在这个值的倍数处mmap共享内存。
注意
这并不修复共享的mmap,请查看sparc64端口以了解解决这个问题的一种方法(特别是SPARC_FLAG_MMAPSHARED)。
接下来,你必须解决所有其他情况下的D-cache别名问题。请记住,对于映射到某个用户地址空间的给定页面,总会至少有一个更多的映射,即内核在从PAGE_OFFSET开始的线性映射中的映射。因此,一旦第一个用户将给定的物理页面映射到其地址空间中,暗示着D-cache别名问题有可能存在,因为内核已经在其虚拟地址中映射了这个页面。
void copy_user_page(void *to, void *from, unsigned long addr, struct page *page) void clear_user_page(void *to, unsigned long addr, struct page *page)
这两个例程在用户匿名或COW页面中存储数据。它允许端口有效地避免用户空间和内核之间的D-cache别名问题。
例如,端口可以在复制过程中临时将'from'和'to'映射到内核虚拟地址。这两个页面的虚拟地址被选择为内核加载/存储指令恰好发生在与页面的用户映射相同“颜色”的虚拟地址上。例如,sparc64就使用了这种技术。
'addr'参数告诉了用户最终将该页面映射到的虚拟地址,'page'参数给出了目标的struct page的指针。
如果D-cache别名不是问题,这两个例程可以直接调用memcpy/memset并且不做其他操作。void flush_dcache_folio(struct folio *folio)
当内核写入页缓存页和/或高内存中的页面时,必须调用这些例程。
注意
这个例程只需要对可能被映射到用户进程地址空间的页缓存页调用。例如,处理页缓存中的vfs符号链接的VFS层代码根本不需要调用这个接口。
“内核写入页缓存页”这个短语的意思是,具体来说,内核执行存储指令,污染了该页在内核虚拟映射中的数据。重要的是在这里进行刷新以处理D-cache别名,以确保这些内核存储对该页的用户空间映射可见。
推论情况同样重要,如果有用户共享+可写映射这些文件,我们必须确保内核对这些页面的读取将看到用户执行的最近存储。
如果D-cache别名不是问题,这个例程可以在该架构上简单地定义为nop。
在folio->flags中有一个位被设置为“架构私有”。内核保证,对于页缓存页,当这样的页面首次进入页缓存时,它将清除这个位。
这使得这些接口可以更有效地实现。它允许“推迟”(也许是无限期地)实际的刷新,如果当前没有用户进程映射这个页面。查看sparc64的flush_dcache_folio和update_mmu_cache_range实现,可以了解如何处理这个问题的示例。
重要的是,如果你推迟了刷新,实际的刷新发生在与执行页面存储相同的CPU上。再次查看sparc64,了解如何处理这个问题的示例。void copy_to_user_page(struct vm_area_struct *vma, struct page *page, unsigned long user_vaddr, void *dst, void *src, int len) void copy_from_user_page(struct vm_area_struct *vma, struct page *page, unsigned long user_vaddr, void *dst, void *src, int len)
当内核需要在任意用户页面中复制数据时(例如用于ptrace()),它将使用这两个例程。
这里应该发生任何必要的缓存刷新或其他一致性操作。如果处理器的指令缓存不会窥探CPU存储,那么很可能需要刷新指令缓存以供copy_to_user_page()使用。void flush_anon_page(struct vm_area_struct *vma, struct page *page, unsigned long vmaddr)
当内核需要访问匿名页面的内容时,它调用这个函数(目前只有get_user_pages())。注意:flush_dcache_folio()故意不适用于匿名页面。默认实现是nop(对于所有一致的架构应该保持不变)。对于不一致的架构,它应该刷新vmaddr处页面的缓存。void flush_icache_range(unsigned long start, unsigned long end)
当内核存储到将要执行的地址时(例如加载模块时),将调用这个函数。
如果icache不窥探存储,那么这个例程将需要刷新它。void flush_icache_page(struct vm_area_struct *vma, struct page *page)
flush_icache_page的所有功能都可以在flush_dcache_folio和update_mmu_cache_range中实现。希望将来能完全删除这个接口。
最后一类API是用于内核内部故意别名的地址范围进行I/O。这样的别名是通过使用vmap/vmalloc API设置的。由于内核I/O经由物理页面进行,I/O子系统假设用户映射和内核偏移映射是唯一的别名。这对于vmap别名来说是不正确的,因此内核中的任何尝试对vmap区域进行I/O的操作都必须手动管理一致性。在进行I/O之前必须刷新vmap范围,并在I/O返回后使其无效。
void flush_kernel_vmap_range(void *vaddr, int size)
刷新vmap区域中给定虚拟地址范围的内核缓存。这是为了确保内核在vmap范围中修改的任何数据对物理页面可见。设计是使这个区域可以安全地进行I/O。请注意,这个API不会刷新该区域的偏移映射别名。void invalidate_kernel_vmap_range(void *vaddr, int size)
使给定虚拟地址范围中的vmap区域的缓存无效,这样处理器在进行I/O读取物理页面时不会通过推测性地读取数据使缓存变得陈旧。这仅对读取数据到vmap区域是必要的。
以上是关于Linux下的Cache和TLB刷新的详细说明。