Skip to content

Latest commit

 

History

History
1132 lines (776 loc) · 72.9 KB

File metadata and controls

1132 lines (776 loc) · 72.9 KB

十一、内核内存管理

在 Linux 系统上,每个内存地址都是虚拟的。它们不直接指向内存中的任何地址。每当访问一个存储单元时,执行一种转换机制,以便匹配相应的物理存储器。

让我们从一个小故事开始,介绍虚拟记忆的概念。给定一个酒店,每个房间可以有一部电话,有一个私人号码。任何安装的电话,当然是属于酒店的。没有一个可以从酒店外直接加入。

如果你需要联系一个房间的居住者,比如说你的朋友,他一定给了你酒店的总机号码和他住的房间号码。一旦你打电话给总机,并给出你需要通话的人的房间号码,就在这时,接待员将你的电话重定向到房间真正的私人电话。只有接待员和房间居住者知道私人号码映射:

(switchboard number + room number) <=> private (real) phone number 

每当这个城市(或世界各地)的人想联系一个房间的居住者,他必须通过热线。他需要知道酒店正确的热线号码和房间号码。这样,switchboard number + room number =虚拟地址,而private phone number对应物理地址。与酒店相关的一些规则也适用于 Linux:

| 酒店 | Linux | | 你不能联系房间里没有私人电话的住户。甚至没有办法尝试这样做。你的电话会突然结束。 | 您不能访问地址空间中不存在的内存。这将导致分割错误。 | | 你不能联系一个不存在的居住者,或者酒店不知道他的入住,或者总机找不到他的信息。 | 如果您访问未映射的内存,中央处理器会引发页面错误,操作系统会处理它。 | | 你不能联系一个住在这里的人。 | 您无法访问释放的内存。可能已经分配给另一个进程了 | | 许多酒店可能有相同的品牌,但位于不同的地方,每个酒店都有不同的热线号码。如果你弄错了热线号码。 | 不同的进程可能在它们的地址空间中映射了相同的虚拟地址,但是指向另一个不同的物理地址。 | | 有一本书(或带有数据库的软件)保存着房间号码和私人电话号码之间的映射,接待员可以根据需要进行咨询。 | 虚拟地址通过页表映射到物理内存,页表由操作系统内核维护,并由处理器查询。 |

这就是虚拟地址在 Linux 系统上的工作方式。

在本章中,我们将讨论整个 Linux 内存管理系统,包括以下主题:

  • 存储器布局以及地址转换和内存管理单元
  • 内存分配机制(页面分配器、平板分配器、kmalloc 分配器等)
  • 输入输出内存访问
  • 将内核内存映射到用户空间并实现mmap()回调函数
  • 介绍 Linux 缓存系统
  • 介绍设备管理资源框架(devres)

系统内存布局-内核空间和用户空间

在本章中,诸如内核空间和用户空间这样的术语指的是它们的虚拟地址空间。在 Linux 系统上,每个进程都拥有一个虚拟地址空间。它是进程生命周期中的一种内存沙盒。在 32 位系统上,该地址空间的大小为 4 GB(即使在物理内存小于 4 GB 的系统上也是如此)。对于每个进程,4 GB 地址空间分为两部分:

  • 用户空间虚拟地址
  • 内核空间虚拟地址

分割的方式取决于一个特殊的内核配置选项CONFIG_PAGE_OFFSET,它定义了内核地址部分在进程地址空间中的起始位置。默认情况下,32 位系统的通用值为0xC0000000,但这可能会改变,恩智浦使用0x80000000的 i.MX6 系列处理器就是这种情况。全章默认考虑0xC0000000。这被称为 3G/1G 分割,其中用户空间被给予较低的 3 GB 虚拟地址空间,内核使用剩余的较高的 1 GB。典型进程的虚拟地址空间布局如下所示:

      .------------------------. 0xFFFFFFFF 
      |                        | (4 GB) 
      |    Kernel addresses    | 
      |                        | 
      |                        | 
      .------------------------.CONFIG_PAGE_OFFSET 
      |                        |(x86: 0xC0000000, ARM: 0x80000000) 
      |                        | 
      |                        | 
      |  User space addresses  | 
      |                        | 
      |                        | 
      |                        | 
      |                        | 
      '------------------------' 00000000 

内核和用户空间中使用的地址都是虚拟地址。不同的是,访问内核地址需要特权模式。特权模式具有扩展特权。当 CPU 运行用户空间端代码时,活动进程被说成是在用户模式下运行;当 CPU 运行内核空间端代码时,活动进程被称为在内核模式下运行。

Given an address (virtual of course), one can distinguish whether it is a kernel space or a user space address by using process layout shown above. Every address falling into 0-3 GB, comes from the user space; otherwise, it is from the kernel.

内核与每个进程共享其地址空间是有原因的:因为在给定时刻,每个进程都使用系统调用,这将涉及内核。将内核的虚拟内存地址映射到每个进程的虚拟地址空间允许我们避免在内核的每个入口(和出口)切换出内存地址空间的成本。这就是为什么内核地址空间被永久地映射在每个进程的顶部,以便通过系统调用加速内核访问。

内存管理单元将内存组织成固定大小的单元,称为页面。一个页面由 4,096 字节(4 KB)组成。即使这个大小在其他系统上可能有所不同,但在 ARM 和 x86 上是固定的,这是我们感兴趣的架构:

  • 内存页、虚拟页或简称页是用来指代固定长度的连续虚拟内存块的术语。同名page作为内核数据结构来表示内存页面。
  • 另一方面,帧(或页面帧)是指物理内存的固定长度连续块,操作系统在其上映射内存页面。每个页面框架都有一个编号,称为页面框架编号 ( PFN )。给定一个页面,使用page_to_pfnpfn_to_page宏可以很容易地得到它的 PFN,反之亦然,这将在接下来的章节中详细讨论。
  • 页表是内核和架构数据结构,用于存储虚拟地址和物理地址之间的映射。密钥对页面/框架描述了页面表中的单个条目。这代表一种映射。

由于一个内存页面被映射到一个页面框架,不言而喻,页面和页面框架具有相同的大小,在我们的例子中为 4 K。页面的大小通过PAGE_SIZE宏在内核中定义。

There are situations where one needs memory to be page-aligned. One says a memory is page-aligned if its address starts exactly at the beginning of a page. For example, on a 4 K page size system, 4,096, 20,480, and 409,600 are instances of page-aligned memory addresses. In other words, any memory whose address is a multiple of the system page size is said to be page-aligned.

内核地址–低内存和高内存的概念

Linux 内核有自己的虚拟地址空间,就像每个用户模式进程一样。内核的虚拟地址空间(在 3G/1G 分割中为 1 GB)分为两部分:

  • 低内存或低内存,即第一个 896 兆字节
  • 高内存或高内存,由前 128 兆字节表示
                                           Physical mem 
       Process address space    +------> +------------+ 
                                |        |  3200 M    | 
                                |        |            | 
    4 GB +---------------+ <-----+        |  HIGH MEM  | 
        |     128 MB    |                |            | 
        +---------------+ <---------+    |            | 
        +---------------+ <------+  |    |            |  
        |     896 MB    |        |  +--> +------------+          
    3 GB +---------------+ <--+   +-----> +------------+  
        |               |    |           |   896 MB   | LOW MEM 
        |     /////     |    +---------> +------------+ 
        |               |      
    0 GB +---------------+ 

内存不足

内核地址空间的第一个 896 兆字节构成了低内存区域。在引导的早期,内核永久地映射那些 896 兆字节。由该映射产生的地址称为逻辑地址。这些是虚拟地址,但可以通过减去固定偏移量转换为物理地址,因为映射是永久的,并且是预先已知的。低内存与物理地址的下限相匹配。可以将低内存定义为内核空间中存在逻辑地址的内存。大多数内核内存函数返回低内存。事实上,为了服务于不同的目的,内核内存被划分为一个区域。实际上,LOWMEM 的前 16 MB 是为 DMA 使用而保留的。由于硬件限制,内核不能将所有页面都视为相同。然后,我们可以在内核空间中识别三个不同的内存区域:

  • ZONE_DMA:包含 16 MB 以下的页面帧内存,预留给直接内存访问 ( DMA )
  • ZONE_NORMAL:包含 16 MB 以上 896 MB 以下的页面帧内存,正常使用
  • ZONE_HIGHMEM:包含 896 MB 及以上的内存页面帧

也就是说,在一个 512 兆字节的系统中,不会有ZONE_HIGHMEMZONE_DMA有 16 兆字节、ZONE_NORMAL有 496 兆字节。

Another definition of logical addresses: addresses in kernel space, mapped linearly on physical addresses, which can be converted into physical addresses just with an offset, or applying a bitmask. One can convert a physical address into a logical address using the __pa(address) macro, and then revert with the __va(address) macro.

高内存

内核地址空间的前 128 兆字节被称为高内存区域。它被内核用来临时映射 1 G 以上的物理内存,当需要访问 1 GB 以上(或者更准确地说,896 MB)的物理内存时,内核使用这 128 MB 创建到其虚拟地址空间的临时映射,从而达到能够访问所有物理页面的目标。可以将高内存定义为逻辑地址不存在,并且没有永久映射到内核地址空间的内存。896 兆以上的物理内存按需映射到 128 兆的高内存区域。

访问高内存的映射由内核动态创建,并在完成时销毁。这使得高内存访问速度变慢。也就是说,高内存的概念在 64 位系统上并不存在,这是由于巨大的地址范围(2 64 ),在这里 3G/1G 拆分不再有意义。

用户空间地址

在本节中,我们将通过流程来处理用户空间。每个进程在内核中都被表示为struct task_struct的一个实例(参见*include/linux/sched.h*,它表征并描述了一个进程。每个进程都有一个内存映射表,存储在一个类型为struct mm_struct的变量中(见*include/linux/mm_types.h*)。然后,您可以猜测每个task_struct中至少嵌入了一个mm_struct字段。下面一行是我们感兴趣的结构task_struct定义的一部分:

struct task_struct{ 
    [...] 
    struct mm_struct *mm, *active_mm; 
    [...] 
} 

内核全局变量current,指向当前进程。字段*mm,指向其内存映射表。根据定义,current->mm指向当前进程内存映射表。

现在让我们看看struct mm_struct是什么样子的:

struct mm_struct { 
        struct vm_area_struct *mmap; 
        struct rb_root mm_rb; 
        unsigned long mmap_base; 
        unsigned long task_size; 
        unsigned long highest_vm_end; 
        pgd_t * pgd; 
        atomic_t mm_users; 
        atomic_t mm_count; 
        atomic_long_t nr_ptes; 
#if CONFIG_PGTABLE_LEVELS > 2 
        atomic_long_t nr_pmds; 
#endif 
        int map_count; 
        spinlock_t page_table_lock; 
        struct rw_semaphore mmap_sem; 
        unsigned long hiwater_rss; 
        unsigned long hiwater_vm; 
        unsigned long total_vm; 
        unsigned long locked_vm; 
        unsigned long pinned_vm; 
        unsigned long data_vm; 
        unsigned long exec_vm; 
        unsigned long stack_vm; 
        unsigned long def_flags; 
        unsigned long start_code, end_code, start_data, end_data; 
        unsigned long start_brk, brk, start_stack; 
        unsigned long arg_start, arg_end, env_start, env_end; 

        /* Architecture-specific MM context */ 
        mm_context_t context; 

        unsigned long flags; 
        struct core_state *core_state; 
#ifdef CONFIG_MEMCG 
        /* 
         * "owner" points to a task that is regarded as the canonical 
         * user/owner of this mm. All of the following must be true in 
         * order for it to be changed: 
         * 
         * current == mm->owner 
         * current->mm != mm 
         * new_owner->mm == mm 
         * new_owner->alloc_lock is held 
         */ 
        struct task_struct __rcu *owner; 
#endif 
        struct user_namespace *user_ns; 
        /* store ref to file /proc/<pid>/exe symlink points to */ 
        struct file __rcu *exe_file; 
}; 

我故意删除了一些我们不感兴趣的领域。有一些字段我们将在后面讨论:pgd例如,它是指向进程的基(第一个入口)级1表(PGD)的指针,写在上下文切换时 CPU 的翻译表基地址中。总之,在继续之前,让我们看看进程地址空间的表示:

Process memory layout

从进程的角度来看,内存映射只不过是一组专用于连续虚拟地址范围的页表条目。那连续的虚拟地址范围叫做内存区,或者虚拟内存区 ( VMA )。每个内存映射由起始地址和长度、权限(例如程序是否可以从该内存中读取、写入或执行)以及相关资源(例如物理页面、交换页面、文件内容等)来描述。

A mm_struct有两种方式存储工艺区域(VMA):

  1. 在红黑树中,其根元素由字段mm_struct->mm_rb指向。
  2. 在链表中,第一个元素被字段mm_struct->mmap 指向。

虚拟存储区(VMA)

内核使用虚拟内存区域来跟踪进程内存映射,例如,一个进程的代码有一个 VMA,每种类型的数据有一个 VMA,每个不同的内存映射(如果有)有一个 VMA,等等。虚拟机管理程序是独立于处理器的结构,具有权限和访问控制标志。每个 VMA 都有一个起始地址和一个长度,它们的大小总是页面大小的倍数(PAGE_SIZE)。VMA 由许多页面组成,每个页面在页面表中都有一个条目。

Memory regions described by VMA are always virtually contiguous, not physically. One can check all VMAs associated with a process through the /proc/<pid>/maps file, or using the pmap command on a process ID.

Image source: http://duartes.org/gustavo/blog/post/how-the-kernel-manages-your-memory/

# cat /proc/1073/maps
00400000-00403000 r-xp 00000000 b3:04 6438 /usr/sbin/net-listener
00602000-00603000 rw-p 00002000 b3:04 6438 /usr/sbin/net-listener
00603000-00624000 rw-p 00000000 00:00 0 [heap]
7f0eebe4d000-7f0eebe54000 r-xp 00000000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eebe54000-7f0eec054000 ---p 00007000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eec054000-7f0eec055000 rw-p 00007000 b3:04 11717 /usr/lib/libffi.so.6.0.4
7f0eec055000-7f0eec069000 r-xp 00000000 b3:04 21629 /lib/libresolv-2.22.so
7f0eec069000-7f0eec268000 ---p 00014000 b3:04 21629 /lib/libresolv-2.22.so
[...]
7f0eee1e7000-7f0eee1e8000 rw-s 00000000 00:12 12532 /dev/shm/sem.thk-mcp-231016-sema
[...]

前面摘录中的每一行代表一个 VMA,字段映射如下模式:{address (start-end)} {permissions} {offset} {device (major:minor)} {inode} {pathname (image)}:

  • address:代表 VMA 的起止地址。
  • permissions:描述区域的访问权限:r(读)w(写)x(执行),包括p(如果映射是私有的)和s(对于共享映射)。
  • Offset : 在文件映射(mmap系统调用)的情况下,是文件中发生映射的偏移量。否则就是0了。
  • major:minor : 在文件映射的情况下,这些表示存储文件的设备(保存文件的设备)的主要和次要数量。
  • inode:从文件映射的情况下,映射文件的索引节点号。
  • pathname:这是映射文件的名称,否则留空。还有其他区域名,如[heap][stack][vdso],代表虚拟动态共享对象,这是内核映射到每个进程地址空间的共享库,以减少系统调用切换到内核模式时的性能损失。

分配给进程的每个页面都属于一个区域;因此,任何不在 VMA 的网页都不存在,也不能被该进程引用。

High memory is perfect for user space because user space's virtual address must be explicitly mapped. Thus, most high memory is consumed by user applications. __GFP_HIGHMEM and GFP_HIGHUSER are the flags for requesting the allocation of (potentially) high memory. Without these flags, all kernel allocations return only low memory. There is no way to allocate contiguous physical memory from user space in Linux.

可以使用find_vma功能找到对应于给定虚拟地址的 VMA。find_vmalinux/mm.h申报 :

* Look up the first VMA which satisfies  addr < vm_end,  NULL if none. */ 
extern struct vm_area_struct * find_vma(struct mm_struct * mm, unsigned long addr); 

这是一个例子:

struct vm_area_struct *vma = find_vma(task->mm, 0x13000); 
if (vma == NULL) /* Not found ? */ 
    return -EFAULT; 
if (0x13000 >= vma->vm_end) /* Beyond the end of returned VMA ? */ 
    return -EFAULT; 

内存映射的全过程可以通过读取文件:/proc/<PID>/map/proc/<PID>/smap/proc/<PID>/pagemap获得。

地址转换和内存管理单元

虚拟内存是一个概念,是赋予一个进程的幻觉,因此它认为自己拥有巨大的、几乎无限的内存,有时甚至超过了系统真正拥有的内存。每次访问一个存储单元时,由中央处理器进行从虚拟地址到物理地址的转换。这种机制被称为地址转换,由作为中央处理器一部分的内存管理单元(MMU )执行。

MMU 保护内存免受未经授权的访问。给定一个流程,任何需要访问的页面都必须存在于其中一个流程 VMA 中,因此必须存在于流程页面表中(每个流程都有自己的页面)。

内存是由固定大小的块组织的,命名为用于虚拟内存,用于物理内存,在我们的例子中大小为 4 KB。无论如何,您不需要猜测为其编写驱动的系统的页面大小。它是用内核中的PAGE_SIZE宏定义和访问的。因此请记住,页面大小是由硬件(中央处理器)决定的。考虑到 4 KB 页面大小的系统,字节 0 到 4095 属于页面 0,字节 4096-8191 属于页面 1,依此类推。

引入页表的概念来管理页和框架之间的映射。页面分布在表格上,因此每个 PTE 对应于页面和框架之间的映射。然后给每个进程一组页表来描述它的整个内存空间。

为了遍历页面,每个页面都被分配了一个索引(像一个数组),称为页码。说到陷害,就是 PFN **。**这样,虚拟内存地址由两部分组成:一个页码和一个偏移量。偏移量代表地址的 12 个较低有效位,而在 8 KB 页面大小的系统中,13 个较低有效位代表它:

Virtual address representation

操作系统或中央处理器如何知道哪个物理地址对应于给定的虚拟地址?他们使用页表作为翻译表,并且知道每个条目的索引是一个虚拟页码,值是 PFN。要访问给定虚拟内存的物理内存,操作系统首先提取偏移量、虚拟页码,然后遍历进程的页表,以便将虚拟页码与物理页匹配。一旦出现匹配,就可以将数据访问到该页面框架中:

Address translation

偏移量用于指向框架中的正确位置。页表不仅保存物理和虚拟页码之间的映射,还保存访问控制信息(读/写访问、特权等)。

Virtual to physical address translation

用于表示偏移的位数由内核宏PAGE_SHIFT定义。PAGE_SHIFT是向左移动一位以获得PAGE_SIZE值的位数。将虚拟地址转换为页码,将物理地址转换为页帧号,也是需要右移的位数。以下是内核源代码/include/asm-generic/page.h对这些宏的定义:

#define PAGE_SHIFT      12 
#ifdef __ASSEMBLY__ 
#define PAGE_SIZE       (1 << PAGE_SHIFT) 
#else 
#define PAGE_SIZE       (1UL << PAGE_SHIFT) 
#endif 

页表是部分解决方案。让我们看看为什么。大多数架构需要 32 位(4 字节)来表示一个 PTE。每个进程都有其私有的 3 GB 用户空间地址,我们需要 786,432 个条目来表征和覆盖一个进程地址空间。它表示每个进程花费了太多的物理内存,只是为了描述内存映射的特征。事实上,一个进程通常使用其虚拟地址空间的一小部分,但又是分散的。为了解决这个问题,引入了的概念。页表是按级别(页级)分级的。存储多级页表所需的空间仅取决于实际使用的虚拟地址空间,而不是与虚拟地址空间的最大大小成比例。这样,未使用的内存不再被表示,页表遍历时间减少。这样,级别 N 中的每个表条目将指向级别 N+1 的表中的一个条目。1 级是更高的级别。

Linux 使用四层分页模型:

  • 页面全局目录 ( PGD ):是第一级(一级)页面表。每个条目的类型在内核中都是pgd_t(通常是unsigned long),并指向第二级表中的一个条目。在内核中,结构tastk_struct表示一个进程的描述,该描述又有一个成员(mm)其类型为mm_struct,该成员表征并表示进程的内存空间。在mm_struct中,有一个特定于处理器的字段pgd,它是进程的 1 级(PGD)页表的第一个条目(条目 0)上的指针。每个进程只有一个 PGD,最多可包含 1024 个条目。
  • P 时代上层目录 ( PUD ):这仅存在于使用四层表的架构上。它代表间接的社会层次。
  • P 时代中间目录 ( PMD ):这是第三个间接层,只存在于使用四层表的架构上。
  • 页表 ( PTE ):树叶。它是pte_t的数组,每个条目指向物理页面。

All levels are not always used. The i.MX6's MMU only supports a 2 level page table (PGD and PTE), it is the case for almost all 32-bit CPUs) In this case, PUD and PMD are simply ignored.

Two-level tables overview

你可能会问 MMU 是如何知道进程页表的。很简单,MMU 不存储任何地址。相反,在中央处理器中有一个特殊的寄存器,称为页表基址寄存器 ( PTBR )或翻译表基址寄存器 0 ( TTBR0 ),它指向进程的 1 级(顶级)页表(PGD)的基址(入口 0)。正是struct mm_struct的场pdg指向的地方:current->mm.pgd == TTBR0

在上下文切换时(当一个新的进程被调度并被给予中央处理器时),内核立即配置内存管理单元,并用新进程的pgd更新 PTBR。现在,当给 MMU 一个虚拟地址时,它使用 PTBR 的内容来定位进程的 1 级页表(PGD),然后它使用从虚拟地址的最高有效位 ( MSBs )中提取的 1 级索引来找到适当的表条目,该表条目包含指向适当的 2 级页表的基址的指针。然后,从该基址开始,它使用二级索引来查找适当的条目,以此类推,直到到达 PTE。ARM 架构(在我们的例子中是 MX6)有一个两级页表。在这种情况下,2 级条目是一个 PTE,并指向物理页面(PFN)。在这个步骤中,只找到物理页面。为了访问页面中的确切内存位置,MMU 提取内存偏移量,也是虚拟地址的一部分,并指向物理页面中的相同偏移量。

当一个进程需要读取或写入一个内存位置(当然我们说的是虚拟内存)时,MMU 会对该进程的页表进行翻译,以找到正确的条目(PTE)。虚拟页号被提取(从虚拟地址中)并被处理器用作进程页表的索引,以检索其页表条目。如果在该偏移量处存在有效的页表条目,则处理器从该条目中获取页帧号。如果不是,这意味着该进程访问了其虚拟内存的未映射区域。然后会出现页面错误,操作系统应该会处理它。

在现实世界中,地址转换需要一次页表遍历,而且并不总是一次性操作。至少有和表级别一样多的内存访问。四级页表需要四次内存访问。换句话说,每次虚拟访问将导致五次物理内存访问。如果虚拟内存的访问速度比物理内存慢四倍,那么虚拟内存的概念将毫无用处。幸运的是,SoC 制造商努力寻找一个巧妙的技巧来解决这个性能问题:现代 CPU 使用一个名为翻译后备缓冲区 ( TLB )的小型关联和非常快速的内存,以便缓存最近访问的虚拟页面的 pte。

向上看,TLB

在 MMU 进行地址转换之前,还涉及到另一个步骤。因为有一个缓存用于最近访问的数据,所以也有一个缓存用于最近转换的地址。由于数据缓存加快了数据访问过程,TLB 加快了虚拟地址转换(是的,地址转换是一项棘手的任务。它是内容可寻址存储器,缩写为( CAM ,其中键是虚拟地址,值是物理地址。换句话说,TLB 是内存管理单元的缓存。每次访问内存时,内存管理单元首先检查 TLB 最近使用的页面,其中包含一些物理页面当前分配到的虚拟地址范围。

TLB 是如何运作的

在虚拟内存访问中,中央处理器遍历 TLB,试图找到正在访问的页面的虚拟页码。这一步叫做 TLB 查找。当找到一个 TLB 条目(匹配发生)时,有人说有一个 TLB 命中,而中央处理器只是继续运行,并使用在 TLB 条目中找到的 PFN 来计算目标物理地址。当 TLB 命中发生时,没有页面错误。可以看到,只要能在 TLB 找到翻译,虚拟内存的访问就会像物理访问一样快。如果没有找到 TLB 的条目(没有匹配发生),人们会说有一个 TLB 小姐

在 TLB 未命中事件中,有两种可能性,取决于处理器类型,TLB 未命中事件可以由软件处理,或者由硬件通过 MMU 处理:

  • 软件处理:CPU 发出 TLB 未命中中断,被操作系统捕捉到。然后,操作系统遍历进程的页表以找到正确的 PTE。如果有匹配且有效的条目,则中央处理器在 TLB 安装新的翻译。否则,将执行页面错误处理程序。
  • 硬件处理:在硬件中遍历进程的页表是由 CPU(实际上是 MMU)决定的。如果有匹配且有效的条目,则中央处理器在 TLB 中添加新的翻译。否则,中央处理器会引发页面错误中断,由操作系统处理。

在这两种情况下,页面错误处理程序是相同的:执行do_page_fault()函数,这是依赖于架构的。对于 ARM,do_page_faultarch/arm/mm/fault.c中定义:

MMU and TLB walkthrough process Page table and Page directory entries are architecture-dependent. It is up to the Operating system to ensure that the structure of the table corresponds to a structure recognized by the MMU. On the ARM processor, you must write the location of the translation table in CP15 (coprocessor 15) register c2, and then enable the caches and the MMU by writing to the CP15 register c1. Have a look at both http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0056d/BABHJIBH.htm and http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0433c/CIHFDBEJ.html for detailed information.

内存分配机制

让我们看看下图,它向我们展示了基于 Linux 的系统上存在的不同内存分配器,我们稍后将讨论它:

Inspired from: http://free-electrons.com/doc/training/linux-kernel/linux-kernel-slides.pdf.

Overview of kernel memory allocator

有一个分配机制来满足任何类型的内存请求。根据你需要什么样的记忆,你可以选择更接近你目标的记忆。主分配器是页面分配器,它只处理页面(页面是它能提供的最小内存单元)。然后是建立在页面分配器之上的 SLAB 分配器,从它那里获取页面并返回更小的内存实体(通过 SLAB 和缓存)。这是 kmalloc 分配器所依赖的分配器。

页面分配器

页面分配器是 Linux 系统上的低级分配器,是其他分配器所依赖的。系统的物理内存由固定大小的块(称为页面帧)组成。页面框架在内核中表示为struct page结构的一个实例。页面是操作系统在低级别上给予任何内存请求的最小内存单元。

页面分配应用编程接口

您将会理解内核页面分配器使用伙伴算法分配和解除分配页面块。页面被分配在大小为 2 的幂的块中(以便从伙伴算法中获得最佳效果)。这意味着它可以分配一块 1 页、2 页、4 页、8 页、16 页等等:

  1. alloc_pages(mask, order)分配 2 个顺序页面,并返回一个表示保留块的第一页的struct page实例。要仅分配一页,顺序应为 0。这就是alloc_page(mask)所做的:
struct page *alloc_pages(gfp_t mask, unsigned int order) 
#define alloc_page(gfp_mask) alloc_pages(gfp_mask, 0) 

__free_pages()用于释放alloc_pages()功能分配的内存。它将指向已分配页面的指针作为参数,其顺序与用于分配的顺序相同。

void __free_pages(struct page *page, unsigned int order); 
  1. 还有其他函数以同样的方式工作,但是它们返回的不是 struct page 的实例,而是保留块的地址(当然是虚拟的)。这些是__get_free_pages(mask, order)__get_free_page(mask):
unsigned long __get_free_pages(gfp_t mask, unsigned int order); 
unsigned long get_zeroed_page(gfp_t mask); 

free_pages()用于释放__get_free_pages()分配的页面。它采用代表已分配页面的开始区域的内核地址,以及顺序,顺序应该与用于分配的顺序相同:

free_pages(unsigned long addr, unsigned int order); 

在这两种情况下,mask指定请求的细节,即内存区域和分配器的行为。可用选项有:

  • GFP_USER,为用户分配内存。
  • GFP_KERNEL,内核分配的常用标志。
  • GFP_HIGHMEM向高 MEM 区请求内存。 *** GFP_ATOMIC,以不能休眠的原子方式分配内存。当需要从中断上下文中分配内存时使用。**

**使用GFP_HIGHMEM有警告,不宜与__get_free_pages()(或__get_free_page())一起使用。因为不能保证 HIGHMEM 内存是连续的,所以不能返回从该区域分配的内存的地址。在内存相关功能中,全局只允许GFP_*的一个子集:

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order) 
{ 
   struct page *page; 

   /* 
    * __get_free_pages() returns a 32-bit address, which cannot represent 
    * a highmem page 
    */ 
   VM_BUG_ON((gfp_mask & __GFP_HIGHMEM) != 0); 

   page = alloc_pages(gfp_mask, order); 
   if (!page) 
         return 0; 
   return (unsigned long) page_address(page); 
} 

The maximum number of pages one can allocate is 1024. It means that on a 4 Kb sized system, you can allocate up to 1024*4 Kb = 4 MB at most. It is the same for kmalloc.

转换函数

page_to_virt()函数用于将结构页(如alloc_pages()返回的)转换为内核地址。virt_to_page()获取一个内核虚拟地址,并返回其关联的结构页面实例(就像它是使用alloc_pages()函数分配的一样)。virt_to_page()page_to_virt()均在<asm/page.h>中定义:

struct page *virt_to_page(void *kaddr); 
void *page_to_virt(struct page *pg) 

page_address()可用于返回对应于结构页实例的起始地址(当然是逻辑地址)的虚拟地址:

void *page_address(const struct page *page) 

我们可以看到它是如何在get_zeroed_page()功能中使用的:

unsigned long get_zeroed_page(unsigned int gfp_mask) 
{ 
    struct page * page; 

    page = alloc_pages(gfp_mask, 0); 
    if (page) { 
        void *address = page_address(page); 
        clear_page(address); 
        return (unsigned long) address; 
    } 
    return 0; 
} 

__free_pages()free_pages()可以混合。它们之间的主要区别在于free_page()采用虚拟地址作为参数,而__free_page()采用struct page结构。

平板分配器

平板分配器是kmalloc()所依赖的。其主要目的是消除在小容量内存分配情况下由伙伴系统引起的内存(de)分配导致的碎片,并加快常用对象的内存分配。

伙伴算法

为了分配内存,请求的大小被舍入到 2 的幂,伙伴分配器搜索适当的列表。如果请求的列表中不存在条目,则来自下一个较高列表(具有两倍于前一个列表大小的块)的条目被分成两半(称为好友)。分配器使用前半部分,而另一半被添加到下一个列表中。这是一种递归方法,当伙伴分配器成功找到一个我们可以分割的块,或者达到块的最大大小并且没有可用的空闲块时,这种方法就会停止。

以下案例研究的灵感来源于http://烦躁不安. net/operating systems 1/4 _ allocation _ buddy _ system . html。例如,如果最小分配大小为 1 KB,内存大小为 1 MB,伙伴分配器将为 1 KB 孔创建一个空列表,为 2 KB 孔创建一个空列表,为 4 KB 孔、8 KB、16 KB、32 KB、64 KB、128 KB、256 KB、512 KB 创建一个空列表,为 1 MB 孔创建一个列表。除了只有一个孔的 1 MB 列表外,所有列表最初都是空的。

现在让我们想象一个场景,我们想要分配一个 70K 块。好友分配器将其向上舍入到 128K ,最终将 1 MB 分成两个 512K 块,然后是 256K ,最后是 128K ,然后将其中一个 128K 块分配给用户。以下是总结这种情况的方案:

Allocation using buddy algorithm

解除分配和分配一样快。下图总结了解除分配算法:

Deallocation using buddy algorithm

平板分配器之旅

在介绍 slab 分配器之前,让我们定义它使用的一些术语:

  • Slab :这是一个连续的物理内存,由几个页面帧组成。每个块被分成相同大小的相等块,用于存储特定类型的内核对象,如信息节点、互斥体等。每个板就是一个对象数组。
  • 缓存:由链表中的一个或多个片组成,它们在内核中表示为struct kmem_cache_t结构的实例。缓存只存储相同类型的对象(例如,只存储信息节点,或者只存储地址空间结构)

板坯可能处于以下状态之一:

  • :这是平板上所有物体(块)被标记为自由的地方
  • 部分:板中既有已用对象,也有自由对象
  • :平板上的所有物体都标记为已使用

内存分配器负责构建缓存。最初,每个板都标记为空。当一个(代码)为内核对象分配内存时,系统会在该类型对象的缓存中的部分/空闲块上寻找该对象的空闲位置。如果没有找到,系统会分配一个新的板并将其添加到缓存中。新对象从该板分配,该板标记为部分。当代码使用内存完成时(释放内存),对象只是以其初始化状态返回到 slab 缓存。

这就是为什么内核还提供帮助函数来获取清零的初始化内存,以摆脱之前的内容。slab 会记录正在使用的对象数量,这样当缓存中的所有 slab 都已满并且请求另一个对象时,slab 分配器就会负责添加新的 slab:

Slab cache overview

这有点像创建每个对象的分配器。系统为每种类型的对象分配一个缓存,只有相同类型的对象才能存储在一个缓存中(例如,只有task_struct结构)。

内核中有不同类型的 slab 分配器,这取决于是否需要紧凑性、缓存友好性或原始速度:

  • 尽可能紧凑的 SLOB
  • 尽可能缓存友好的 SLAB
  • SLUB ,相当简单,需要较少的指令成本计数

kmalloc 系列分配

kmalloc是一个内核内存分配函数,比如用户空间中的malloc()kmalloc返回的内存在物理内存和虚拟内存中是连续的:

kmalloc 分配器是内核中通用的、更高级别的内存分配器,它依赖于 SLAB 分配器。从 kmalloc 返回的内存有一个内核逻辑地址,因为它是从LOW_MEM区域分配的,除非指定HIGH_MEM。它在<linux/slab.h>中声明,这是在您的驱动中使用 kmalloc 时要包含的标题。以下是原型:

void *kmalloc(size_t size, int flags); 

size指定要分配的内存大小(以字节为单位)。flag确定内存应该如何分配以及分配到哪里。可用标志与页面分配器相同(GFP_KERNELGFP_ATOMICGFP_DMA等等)。

  • GFP_KERNEL:这是标准旗。我们不能在中断处理程序中使用这个标志,因为它的代码可能会休眠。它总是从LOM_MEM区返回内存(因此是一个逻辑地址)。

  • GFP_ATOMIC:这保证了分配的原子性。当我们处于中断上下文中时唯一使用的标志。请不要滥用这个,因为它使用了一个紧急内存池。

  • GFP_USER:这给用户空间进程分配内存。然后,内存与分配给内核的内存是不同的。

  • GFP_HIGHUSER:这将从HIGH_MEMORY区域分配内存

  • GFP_DMA:这是从DMA_ZONE分配内存。

成功分配内存后,kmalloc 会返回已分配区块的虚拟地址,保证物理上是连续的。出错时,返回NULL

Kmalloc 在分配小容量内存时依赖于 SLAB 缓存。在这种情况下,内核将分配的区域大小舍入到它可以容纳的最小 SLAB 缓存的大小。始终将其用作默认内存分配器。在本书使用的体系结构(ARM 和 x86)中,每次分配的最大大小为 4 MB,总分配为 128 MB。看看https://kai wantech . WordPress . com/2011/08/17/kmalloc-and-vmalloc-Linux-内核-内存-分配-api-limits/。

kfree功能用于释放 kmalloc 分配的内存。以下是kfree()的原型;

void kfree(const void *ptr) 

让我们看一个例子:

#include <linux/init.h> 
#include <linux/module.h> 
#include <linux/slab.h> 
#include <linux/mm.h> 

MODULE_LICENSE("GPL");  
MODULE_AUTHOR("John Madieu"); 

void *ptr;  

static int  
alloc_init(void)  
{ 
    size_t size = 1024; /* allocate 1024 bytes */  
    ptr = kmalloc(size,GFP_KERNEL);  
    if(!ptr) { 
        /* handle error */ 
        pr_err("memory allocation failed\n");  
        return -ENOMEM;  
    }  
    else  
        pr_info("Memory allocated successfully\n");  
    return 0; 
} 

static void alloc_exit(void) 
{ 
    kfree(ptr);  
    pr_info("Memory freed\n");  
}  

module_init(alloc_init);  
module_exit(alloc_exit); 

其他类似家庭的功能有:

void kzalloc(size_t size, gfp_t flags); 
void kzfree(const void *p); 
void *kcalloc(size_t n, size_t size, gfp_t flags); 
void *krealloc(const void *p, size_t new_size, gfp_t flags); 

krealloc()是用户空间realloc()功能的内核等价物。因为kmalloc()返回的内存保留了其先前版本的内容,如果它暴露在用户空间中,可能会有安全风险。要获取清零的 kmalloced 内存,应该使用kzallockzfree()kzalloc()的释放函数,而kcalloc()为一个数组分配内存,其参数nsize分别代表数组中元素的个数和元素的大小。

Since kmalloc() returns a memory area in the kernel permanent mapping (which mean physically contiguous), the memory address can be translated to a physical address using virt_to_phys(), or to a IO bus address using virt_to_bus(). These macros internally call either __pa() or __va()if necessary. The physical address (virt_to_phys(kmalloc'ed address)), downshifted by PAGE_SHIFT , will produce a PFN of the first page from which the chunk is allocated.

vmalloc 分配程序

vmalloc()是我们将在书中讨论的最后一个内核分配器。它只返回虚拟空间上连续的内存(不是物理上连续的):

返回的记忆总是来自HIGH_MEM区。返回的地址不能转换成物理地址或总线地址,因为不能断言内存是物理连续的。意思是vmalloc()返回的内存不能在微处理器外使用(不能轻易用于 DMA 目的)。使用vmalloc()为仅存在于软件中的大型(例如,用它来分配一页)序列分配内存是正确的,例如,网络缓冲区。需要注意的是vmalloc()kmalloc()或页面分配器函数慢,因为它必须检索内存,建立页面表,甚至重新映射到一个几乎连续的范围内,而kmalloc()从来不这样做。

在使用这个 vmalloc API 之前,您应该在代码中包含这个头:

#include <linux/vmalloc.h> 

以下是 vmalloc 系列原型:

void *vmalloc(unsigned long size); 
void *vzalloc(unsigned long size); 
void vfree( void *addr); 

size是需要分配的内存大小。成功分配内存后,它会返回已分配内存块的第一个字节的地址。出现故障时,它会返回一个NULLvfree功能,用于释放vmalloc()分配的内存。

下面是使用vmalloc的一个例子:

#include<linux/init.h> 
#include<linux/module.h> 
#include <linux/vmalloc.h> 

void *ptr; 
static int alloc_init(void) 
{ 
    unsigned long size = 8192; 
    ptr = vmalloc(size); 
    if(!ptr) 
    { 
        /* handle error */ 
        printk("memory allocation failed\n"); 
        return -ENOMEM; 
    } 
    else 
        pr_info("Memory allocated successfully\n"); 
    return 0; 

} 

static void my_vmalloc_exit(void) /* function called at the time of rmmod */ 
{ 
    vfree(ptr); //free the allocated memory 
    printk("Memory freed\n"); 
} 
module_init(my_vmalloc_init); 
module_exit(my_vmalloc_exit); 

MODULE_LICENSE("GPL"); 
MODULE_AUTHOR("john Madieu, [email protected]"); 

可以使用/proc/vmallocinfo显示系统上所有可用的内存。VMALLOC_STARTVMALLOC_END是界定 vmalloc 地址范围的两个符号。它们依赖于架构,并在<asm/pgtable.h>中定义。

幕后的进程内存分配

让我们把重点放在较低级别的分配器上,它分配内存页面。内核将报告帧页面(物理页面)的分配,直到真正需要时(当那些页面被实际访问时,通过读取或写入)。这种按需分配被称为惰性分配,消除了分配永远不会被使用的页面的风险。

每当请求页面时,只更新页面表,在大多数情况下,会创建一个新条目,这意味着只分配虚拟内存。只有当您访问页面时,才会引发名为页面故障的中断。该中断有一个专用的处理程序,称为页面错误处理程序,由 MMU 调用,以响应访问虚拟内存的尝试,但没有立即成功。

实际上,无论访问类型是什么(读、写、执行),如果页面表中的条目没有设置适当的权限位来允许这种类型的访问,就会引发页面错误中断。对该中断的响应属于以下三种方式之一:

  • 硬故障:页面没有驻留在任何地方(既不在物理内存中,也不在内存映射文件中),这意味着处理程序无法立即解决故障。处理程序将执行输入/输出操作,以便准备解决故障所需的物理页面,并可能暂停中断的进程,在系统工作解决问题时切换到另一个进程。
  • 软故障:页面驻留在内存的其他地方(在另一个进程的工作集中)。这意味着故障处理程序可以通过立即将一页物理内存附加到适当的页表条目、调整条目并恢复中断的指令来解决故障。
  • 故障无法解决:这将导致总线错误或 segv。SIGSEGV被发送到故障进程,杀死它(默认行为),除非已经为SIGSEV安装了信号处理器来改变默认行为。

内存映射通常从没有附加物理页面开始,通过定义虚拟地址范围而没有任何相关的物理内存。由于内核提供了一些标志来确定尝试的访问是否合法,并指定了页面错误处理程序的行为,因此在访问内存时,实际的物理内存将在稍后分配,以响应页面错误异常。因此,用户空间brk(), mmap()和类似的分配(虚拟)空间,但物理内存随后被附加。

A page fault occurring in the interrupt context causes a double fault interrupt, which usually panics the kernel (calling the panic() function) . It is the reason why memory allocated in the interrupt context is taken from a memory pool, which does not raise page fault interrupts. If an interrupt occurs when a double fault is being handled, a triple fault exception is generated, causing the CPU to shut down and the OS immediately reboots. This behavior is actually arc-dependent.

写时复制(CoW)案例

CoW(与fork()一起大量使用)是一个内核特性,它不会为两个或多个进程共享的数据分配多次内存,直到一个进程触及它(写入其中);在这种情况下,为其私有副本分配内存。下面显示了页面错误处理程序如何管理 CoW(单页案例研究):

  • 一个 PTE 被添加到进程页表中,并被标记为不可写。
  • 该映射将在进程 VMA 列表中创建一个 VMA。页面被添加到 VMA,VMA 被标记为可写。
  • 在页面访问时(第一次写入时),故障处理程序会注意到差异,这意味着:这是写入时拷贝。然后,它将分配一个物理页面,分配给上面添加的 PTE,更新 PTE 标志,刷新 TLB 条目,并执行do_wp_page()功能,该功能可以将内容从共享地址复制到新位置。

使用输入/输出内存与硬件对话

除了执行面向数据内存的操作,还可以执行输入/输出内存事务,与硬件对话。谈到访问设备的寄存器,内核根据系统架构提供了两种可能性:

  • 通过输入输出端口:这也叫端口输入输出 ( PIO )。寄存器可通过专用总线访问,访问这些寄存器需要特定的指令(一般是汇编程序中的inout)。x86 架构就是这种情况。
  • 内存映射输入输出 ( MMIO ):这是最常用最常用的方法。该器件的寄存器映射到存储器。只需读写特定地址,即可写入器件的寄存器。ARM 架构就是这种情况。

PIO 设备接入

在使用 PIO 的系统上,有两个不同的地址空间,一个用于内存,我们已经讨论过了,另一个用于输入/输出端口,称为端口地址空间,仅限 65,536 个端口。这是一种古老的方式,现在非常罕见。

内核导出一些函数(符号)来处理输入输出端口。在访问任何端口区域之前,我们必须首先通知内核,我们正在使用一系列使用request_region()函数的端口,该函数将在出错时返回NULL。一旦与该地区达成协议,人们必须称之为release_region()。这些都在linux/ioport.h中声明。他们的原型是:

struct resource *request_region(unsigned long start, 
                                 unsigned long len, char *name); 
void release_region(unsigned long start, unsigned long len); 

这些函数通知内核您打算从start开始使用/释放一个区域len端口。name参数应使用设备名称进行设置。它们的使用不是强制性的。这是一种礼貌,可以防止两个或多个驱动引用相同范围的端口。通过读取/proc/ioports文件的内容,可以显示系统上实际使用的端口信息。

一旦完成区域预留,就可以使用以下功能访问端口:

u8 inb(unsigned long addr) 
u16 inw(unsigned long addr) 
u32 inl(unsigned long addr) 

它们分别访问(读取)8 位、16 位或 32 位大小(宽)的端口,并具有以下功能:

void outb(u8 b, unsigned long addr) 
void outw(u16 b, unsigned long addr) 
void outl(u32 b, unsigned long addr) 

其将 8 位、16 位或 32 位大小的数据写入addr端口。

事实上,PIO 使用不同的指令集来访问输入/输出端口或 MMIO 是一个缺点,因为 PIO 需要比正常内存更多的指令来完成相同的任务。例如,1 位测试在 MMIO 只有一条指令,而 PIO 要求在测试该位之前将数据读入寄存器,这是一条以上的指令。

MMIO 设备访问

内存映射输入/输出驻留在与内存相同的地址空间。内核使用 RAM 通常使用的地址空间的一部分(实际上是HIGH_MEM)来映射设备寄存器,这样,输入/输出设备就发生了,而不是在该地址有真实的内存(即 RAM)。因此,与输入/输出设备通信变得像读写专用于该输入/输出设备的内存地址。

像 PIO 一样,还有 MMIO 函数,通知内核我们打算使用一个内存区域。请记住,这只是一个纯粹的预订。这些是request_mem_region()release_mem_region():

struct resource* request_mem_region(unsigned long start, 
                                    unsigned long len, char *name) 
void release_mem_region(unsigned long start, unsigned long len) 

这也是一种礼貌。

One can display memory regions actually in use on the system by reading the content of the /proc/iomem file.

在访问内存区域之前(以及在您成功请求之后),必须通过调用特殊的依赖于体系结构的函数(利用 MMU 构建页表,因此不能从中断处理程序中调用)将该区域映射到内核地址空间。它们是ioremap()iounmap(),也处理缓存一致性:

void __iomem *ioremap(unsigned long phys_add, unsigned long size) 
void iounmap(void __iomem *addr) 

ioremap()返回一个指向映射区域开始的__iomem void指针。不要被这样的指针所诱惑(通过读/写指针来获取/设置值)。内核提供访问内存的功能。这些是:

unsigned int ioread8(void __iomem *addr); 
unsigned int ioread16(void __iomem *addr); 
unsigned int ioread32(void __iomem *addr); 
void iowrite8(u8 value, void __iomem *addr); 
void iowrite16(u16 value, void __iomem *addr); 
void iowrite32(u32 value, void __iomem *addr); 

ioremap builds new page tables, just as vmalloc does. However, it does not actually allocate any memory but instead, returns a special virtual address that one can use to access the specified physical address range.

在 32 位系统上,MMIO 窃取物理内存地址空间来为内存映射的输入/输出设备创建映射是一个缺点,因为它阻止系统将窃取的内存用于通用内存。

我的饼干

__iomem是稀疏使用的内核 cookie,稀疏是内核用来发现可能的编码错误的语义检查器。为了利用稀疏提供的特性,应该在内核编译时启用它;如果没有,__iomem cookie 无论如何都会被忽略。

命令行中的C=1将为您启用稀疏,但是解析应该首先安装在您的系统上:

sudo apt-get install sparse  

例如,构建模块时,请使用:

make -C $KPATH M=$PWD C=1 modules

或者,如果 makefile 写得很好,只需键入:

make C=1  

下面显示了 __iomem 是如何在内核中定义的:

#define __iomem    __attribute__((noderef, address_space(2))) 

它防止我们有故障的驱动执行输入/输出内存访问。为所有输入/输出访问添加__iomem也是一种更加严格的方法。由于即使是输入/输出访问也是通过虚拟内存完成的(在带有内存管理单元的系统上),因此该 cookie 会阻止我们使用绝对物理地址,并要求我们使用ioremap(),这将返回一个标记有__iomem cookie 的虚拟地址:

void __iomem *ioremap(phys_addr_t offset, unsigned long size); 

所以我们可以使用专用功能,比如ioread23()iowrite32()。你可能想知道为什么不使用readl() / writel()功能。这些不推荐使用,因为它们不进行健全性检查,并且比只接受__iomem地址的ioreadX() / iowriteX()家族函数更不安全(不需要__iomem)。

此外,noderef是稀疏使用的属性,以确保程序员不会取消引用__iomem指针。即使它可以在某些架构上工作,也不鼓励您这样做。请改用特殊的ioreadX() / iowriteX()功能。它是可移植的,适用于所有架构。现在让我们看看当取消引用一个__iomem指针时,稀疏会如何警告我们:

#define BASE_ADDR 0x20E01F8 
void * _addrTX = ioremap(BASE_ADDR, 8); 

首先,稀疏并不高兴,因为类型初始值设定项错误:

 warning: incorrect type in initializer (different address spaces)
 expected void *_addrTX
 got void [noderef] <asn:2>* 

或者:

u32 __iomem* _addrTX = ioremap(BASE_ADDR, 8); 
*_addrTX = 0xAABBCCDD; /* bad. No dereference */ 
pr_info("%x\n", *_addrTX); /* bad. No dereference */ 

稀疏依旧不高兴:

Warning: dereference of noderef expression  

最后一个例子让稀疏很开心:

void __iomem* _addrTX = ioremap(BASE_ADDR, 8); 
iowrite32(0xAABBCCDD, _addrTX); 
pr_info("%x\n", ioread32(_addrTX)); 

你必须记住的两条规则是:

  • 无论是作为返回类型还是作为参数类型,总是在需要的地方使用__iomem,并使用稀疏来确保您这样做了
  • 不要取消引用__iomem指针;请改用专用功能

内存(重新)映射

内核内存有时需要重新映射,要么从内核到用户空间,要么从内核到内核空间。常见的用例是将内核内存重新映射到用户空间,但也有其他情况,例如需要访问高内存。

克马普

Linux 内核会将其 896 MB 的地址空间永久映射到较低的 896 MB 物理内存(低内存)。在 4 GB 系统上,内核只剩下 128 MB 来映射剩余的 3.2 GB 物理内存(高内存)。由于永久性的一对一映射,内核可以直接寻址低内存。当涉及到高内存(超过 896 MB 的内存)时,内核必须将所请求的高内存区域映射到其地址空间中,而前面提到的 128 MB 是专门为此保留的。用于执行此技巧的功能,kmap()kmap(),用于给定页面映射到内核地址空间。

void *kmap(struct page *page); 

page是指向要映射的struct page结构的指针。当分配高内存页时,它是不可直接寻址的。kmap()是将高内存临时映射到内核地址空间必须调用的函数。映射将持续到调用kunmap()为止:

void kunmap(struct page *page); 

我说的暂时,是指一旦不再需要,映射就应该被撤销。请记住,128 MB 不足以映射 3.2 GB。最佳编程实践是在不再需要时取消映射高内存映射。这就是为什么每次访问高内存页面时必须输入kmap() - kunmap()序列的原因。。

该功能适用于高内存和低内存。也就是说,如果页面结构驻留在低内存中,那么只返回页面的虚拟地址(因为低内存页面已经有了永久映射)。如果页面属于高内存,将在内核的页面表中创建一个永久映射,并返回地址:

void *kmap(struct page *page) 
{ 
   BUG_ON(in_interrupt()); 
   if (!PageHighMem(page)) 
         return page_address(page); 

   return kmap_high(page); 
} 

将内核内存映射到用户空间

映射物理地址是最有用的功能之一,尤其是在嵌入式系统中。有时您可能希望与用户空间共享部分内核内存。如前所述,在用户空间运行时,CPU 以非特权模式运行。要让一个进程访问内核内存区域,我们需要将该区域重新映射到进程地址空间。

使用 remap_pfn_range

remap_pfn_range()将物理内存(通过内核逻辑地址)映射到用户空间进程。它对于实现mmap()系统调用特别有用。

对一个文件(不管是不是设备文件)调用mmap()系统调用后,CPU 会切换到特权模式,运行相应的file_operations.mmap()内核函数,内核函数又会调用remap_pfn_range()。映射区域的内核 PTE 将被导出,并被赋予进程,当然,带有不同的保护标志。该进程的 VMA 列表更新为新的 VMA 条目(具有适当的属性),该条目将使用 PTE 来访问相同的内存。

因此,内核只是复制 pte,而不是通过复制来浪费内存。然而,内核和用户空间 PTE 具有不同的属性。remap_pfn_range()有以下原型:

int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr, 
             unsigned long pfn, unsigned long size, pgprot_t flags); 

成功的呼叫将返回0,失败时返回负错误代码。remap_pfn_range()的大多数参数是在调用mmap()方法时提供的。

  • vma:这是file_operations.mmap()调用情况下内核提供的虚拟内存区域。它对应于用户进程vma,映射应该在其中完成。
  • addr:这是 VMA 应该开始的用户虚拟地址(vma->vm_start),这将导致从addraddr + size之间的虚拟地址范围的映射。
  • pfn:表示要映射的内核内存区域的页面帧数。它对应于右移PAGE_SHIFT位的物理地址。应该考虑vma偏移(必须开始映射的对象的偏移)来产生 PFN。由于 VMA 结构的vm_pgoff字段包含页数形式的偏移值,因此这正是您需要的(通过PAGE_SHIFT左移)来提取字节形式的偏移:offset = vma->vm_pgoff << PAGE_SHIFT。最后,pfn = virt_to_phys(buffer + offset) >> PAGE_SHIFT
  • size:这是被重映射区域的尺寸,以字节为单位。
  • prot:这代表新 VMA 要求的保护。驱动可以修改默认值,但是应该使用在vma->vm_page_prot中找到的值作为使用或运算符的框架,因为它的一些位已经由用户空间设置。其中一些标志是:
    • VM_IO,指定设备的内存映射输入/输出
    • VM_DONTCOPY,告诉内核不要在 fork 上复制这个vma
    • VM_DONTEXPAND,防止vmamremap(2)膨胀
    • VM_DONTDUMP,防止vma包含在堆芯转储中

One may need to modify this value in order to disable caching if using this with I/O memory (vma->vm_page_prot = pgprot_noncached(vma->vm_page_prot);).

使用 io_remap_pfn_range

当涉及到将输入/输出内存映射到用户空间时,所讨论的remap_pfn_range()函数不再适用。在这种情况下,合适的函数是io_remap_pfn_range(),其参数是相同的。唯一改变的是 PFN 来自哪里。它的原型看起来像:

int io_remap_page_range(struct vm_area_struct *vma, 
                        unsigned long virt_addr, 
                        unsigned long phys_addr, 
                        unsigned long size, pgprot_t prot); 

当试图将输入/输出内存映射到用户空间时,没有必要使用ioremap()。- ioremap()用于内核目的(将输入/输出内存映射到内核地址空间),其中io_remap_pfn_range用于用户空间目的。

只需将您的真实物理输入/输出地址(通过PAGE_SHIFT降档以产生 PFN)直接传递给io_remap_pfn_range()。即使有些架构将io_remap_pfn_range()定义为remap_pfn_range(),但也有其他架构并非如此。出于可移植性的原因,您应该只在 PFN 参数指向内存的情况下使用remap_pfn_range(),在phys_addr指输入输出内存的情况下使用io_remap_pfn_range()

mmap 文件操作

内核mmap函数是struct file_operations结构的一部分,在用户执行系统调用mmap(2)时执行,用于将物理内存映射到用户虚拟地址。内核通过通常的指针解引用将对内存映射区域的任何访问转换为文件操作。甚至可以将设备物理内存直接映射到用户空间(参见/dev/mem)。本质上,写入内存就像写入文件一样。这只是一种更方便的称呼方式write()

通常,出于安全目的,用户空间进程不能直接访问设备内存。因此,用户空间进程使用mmap()系统调用,要求内核将设备映射到调用进程的虚拟地址空间。映射后,用户空间进程可以通过返回的地址直接写入设备内存。

mmap 系统调用声明如下:

 mmap (void *addr, size_t len, int prot, 
       int flags, int fd, ff_t offset); 

为了支持mmap(2),驱动应该已经定义了 mmap 文件操作(file_operations.mmap)。从内核方面来看,驱动文件操作结构(struct file_operations结构)中的 mmap 字段具有以下原型:

int (*mmap) (struct file *filp, struct vm_area_struct *vma); 

其中:

  • filp是指向驱动打开的设备文件的指针,该文件是 fd 参数转换的结果。
  • vma由内核分配并作为参数给出。它是指向用户进程的 vma 的指针,映射应该指向这个 VMA。为了理解内核如何创建新的 vma,让我们回忆一下mmap(2)系统调用的原型:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset); 

该函数的参数以某种方式影响 vma 的某些字段:

  • addr:是映射应该开始的用户空间的虚拟地址。对vma>vm_start有影响。如果指定了NULL(最便携的方式),自动确定正确的地址。
  • length:这规定了映射的长度,间接对vma->vm_end有影响。记住,vma的大小永远是PAGE_SIZE的倍数。换句话说,PAGE_SIZE永远是vma能拥有的最小尺寸。内核总是会改变vma的大小,所以它是PAGE_SIZE的倍数。
If length <= PAGE_SIZE 
    vma->vm_end - vma->vm_start == PAGE_SIZE. 
If PAGE_SIZE < length <= (N * PAGE_SIZE) 
             vma->vm_end - vma->vm_start == (N * PAGE_SIZE) 
  • prot:影响 VMA 的权限,司机可以在vma->vm_pro找到。如前所述,驱动可以更新这些值,但不能更改它们。
  • flags:这决定了驾驶员可以在vma->vm_flags中找到的映射类型。映射可以是私有的,也可以是共享的。
  • offset:指定映射区域内的偏移量,从而打乱vma->vm_pgoff的值。

在内核中实现 mmap

由于用户空间代码无法访问内核内存,mmap()函数的目的是导出一个或多个受保护的内核页表条目(对应于要映射的内存)并复制用户空间页表,删除内核标志保护,并设置允许用户访问与内核相同内存而无需特殊权限的权限标志。

编写 mmap 文件操作的步骤如下:

  1. 获取映射偏移量,并检查它是否超出我们的缓冲区大小:
unsigned long offset = vma->vm_pgoff << PAGE_SHIFT;  
if (offset >= buffer_size) 
         return -EINVAL; 
  1. 检查映射大小是否大于我们的缓冲区大小:
unsigned long size = vma->vm_end - vma->vm_start; 
if (size > (buffer_size - offset)) 
   return -EINVAL; 
  1. 获取对应于页面中offset位置所在的 PFN 的 PFN:
unsigned long pfn; 
/* we can use page_to_pfn on the struct page structure 
 * returned by virt_to_page 
 */ 
/* pfn = page_to_pfn (virt_to_page (buffer + offset)); */ 

/* Or make PAGE_SHIFT bits right-shift on the physical 
 * address returned by virt_to_phys 
 */       
pfn = virt_to_phys(buffer + offset) >> PAGE_SHIFT; 
  1. 设置适当的标志,无论输入/输出内存是否存在:

  2. 使用计算出的 PFN、大小和保护标志调用remap_pfn_range:

if (remap_pfn_range(vma, vma->vm_start, pfn, size, vma->vm_page_prot)) { 
   return -EAGAIN; 
} 
return 0; 
  1. 将您的 mmap 功能传递给struct file_operations结构:
static const struct file_operations my_fops = { 
   .owner = THIS_MODULE, 
   [...] 
   .mmap = my_mmap, 
   [...] 
}; 

Linux 缓存系统

缓存是一个过程,通过这个过程,频繁访问的或新写入的数据被从一个更小更快的内存中取出或写入,称为缓存

脏内存是数据备份(例如,文件备份)内存,其内容已被修改(通常在缓存中),但尚未写回磁盘。数据的缓存版本比磁盘上的版本新,这意味着两个版本不同步。将缓存数据写回到磁盘(回存)的机制称为写回。我们最终将更新磁盘版本,使两者同步。干净内存是文件备份内存,其中的内容与磁盘同步。

Linux 延迟写入操作是为了加快读取过程,并且通过仅在必要时写入数据来减少磁盘磨损。一个典型的例子是dd命令。它的完全执行并不意味着数据被写入目标设备;这就是为什么dd在大多数情况下都被一个sync命令所束缚。

什么是缓存?

高速缓存是一种临时的、小型的、快速的内存,用于保存较大且通常非常慢的内存中的数据副本,通常放置在一组工作数据集比其他数据集(例如硬盘、内存)被访问的频率高得多的系统中。

当第一次读取发生时,假设一个进程从较大且较慢的磁盘请求一些数据,所请求的数据被返回给该进程,并且被访问的数据的副本也被跟踪和缓存。任何后续读取都将从缓存中获取数据。任何数据修改都将应用于缓存,而不是主磁盘。然后,其内容已经被修改并且不同于(比)盘上版本的高速缓存区域将被标记为。当缓存满时,由于缓存数据被添加,新数据开始驱逐未被访问且闲置时间最长的数据,因此如果再次需要它,它将不得不再次从大/慢存储中取出。

中央处理器缓存–内存缓存

现代 CPU 上有三个高速缓冲存储器,按大小和访问速度排序:

  • 内存最少的 L1 缓存(通常在 1k 到 64k 之间)可由中央处理器在单个时钟周期内直接访问,这也使其成为最快的缓存。经常使用的东西在 L1,并留在 L1,直到其他一些东西的使用变得比现有的更频繁,L1 的空间更少。如果是这样的话,它将被移到更大的空间 L2。
  • L2 缓存是中间层,与处理器相邻的内存更大(高达几兆字节),可以在少量时钟周期内访问。这适用于将东西从 L2 移动到 L3。
  • L3 缓存甚至比 L1 和 L2 还慢,可能比主内存(RAM)快两倍。每个内核都可能有自己的 L1 和 L2 缓存;因此,它们都共享 L3 缓存。大小和速度是每个缓存级别之间变化的主要标准:L1 < L2 < L3。例如,原始存储器访问可能是 100 纳秒,而 L1 高速缓存访问可能是 0.5 纳秒。

A real-life example is how a library may put several copies of the most popular titles on display for easy and fast access, but have a large-scale archive with a far greater collection available, at the inconvenience of having to wait for a librarian to go get it for you. The display cases would be analogous to a cache, and the archive would be the large, slow memory.

中央处理器缓存解决的主要问题是延迟,这间接增加了吞吐量,因为访问未缓存的内存可能需要一段时间。

Linux 页面缓存–磁盘缓存

页面缓存,顾名思义,是内存中的页面缓存,包含最近访问的文件块。内存充当驻留在磁盘上的页面的缓存。换句话说,它是文件内容的内核缓存。缓存数据可以是常规文件系统文件、块设备文件或内存映射文件。每当调用read()操作时,内核首先检查数据是否驻留在页面缓存中,如果找到就立即返回。否则,将从磁盘读取数据。

If a process needs to write data without any caching involved, it has to use the O_SYNC flag, which guarantees the write() command will not return before all data has been transferred to the disk, or the O_DIRECT, flag, which only guarantees that no caching will be used for data transfer. That says, O_DIRECT actually depends on filesystem used and is not recommended.

专用缓存(用户空间缓存)

  • 网页浏览器缓存:将经常访问的网页和图片存储到磁盘上,而不是从网上获取。虽然对在线数据的第一次访问可能会持续数百毫秒以上,但第二次访问将在 10 毫秒内从缓存(在本例中为磁盘)中提取数据。
  • libc 或用户应用缓存:内存和磁盘缓存实现会尝试猜测你接下来需要使用什么,而浏览器缓存会保留一个本地副本,以防你需要再次使用。

为什么延迟将数据写入磁盘?

这主要有两个原因:

  • 更好地利用磁盘特性;这就是效率
  • 允许应用在写入后立即继续;这就是表演

例如,延迟磁盘访问并仅在数据达到一定大小时处理数据可能会提高磁盘性能,并降低 eMMC 损耗平衡(在嵌入式系统上)。每个块写入都被合并到一个连续的写入操作中。此外,写入的数据被缓存,允许进程立即返回,以便任何后续的读取都将从缓存中获取数据,从而产生更具响应性的程序。存储设备更喜欢少量的大型操作,而不是几个小型操作。

通过稍后报告永久存储上的写操作,我们可以消除这些磁盘带来的延迟问题,这些问题相对较慢。

写缓存策略

根据缓存策略,可以列举几个好处:

  • 减少数据访问延迟,从而提高应用性能
  • 延长储存寿命
  • 减少系统工作负荷
  • 降低数据丢失的风险

缓存算法通常属于以下三种不同策略之一:

  1. 直写 缓存,任何写操作都会自动更新内存缓存和永久存储。对于不能容忍数据丢失的应用,以及写入然后频繁重新读取数据的应用(因为数据存储在缓存中,导致低读取延迟),这种策略是首选的。

  2. 回写 缓存,类似于直写,不同的是它会立即使缓存无效(这对于系统来说也很昂贵,因为任何写入都会导致自动缓存无效)。主要的后果是,任何后续的读取都将从磁盘获取数据,这很慢,从而增加了延迟。它防止缓存被随后不会被读取的数据淹没。

  3. Linux 采用了第三种也是最后一种策略,称为回写缓存,它可以在每次发生变化时将数据写入缓存,而无需更新主内存中的相应位置。相反,页面缓存中相应的页面被标记为(该任务由 MMU 使用 TLB 完成),并被添加到所谓的列表中,由内核维护。数据仅在指定的时间间隔或特定条件下写入永久存储器中的相应位置。当页面中的数据与页面缓存中的数据一致时,内核会从列表中删除这些页面,并且它们不会被标记为脏。

  4. 在 Linux 系统上,可以从Dirty下的/proc/meminfo找到这个:

    cat /proc/meminfo | grep Dirty

冲洗器螺纹

回写缓存推迟页面缓存中的输入/输出数据操作。一组内核线程,称为冲洗线程,负责这一点。当满足以下任何一种情况时,就会发生脏页写回:

  1. 当空闲内存低于指定阈值以重新获得脏页消耗的内存时。
  2. 当脏数据持续到特定时期时。最早的数据被写回磁盘,以确保脏数据不会无限期地保持脏状态。
  3. 当用户进程调用sync()fsync()系统调用时。这是一个按需写回。

设备管理的资源–设备

Devres 是一个内核工具,通过自动释放驱动中分配的资源来帮助开发人员。它简化了init / probe / open功能中的错误处理。有了 devres,每个资源分配器都有自己的托管版本,负责为您释放资源。

This section heavily relies on the Documentation/driver-model/devres.txt file in the kernel source tree, which deals with devres API and lists supported functions along with their descriptions.

分配有资源管理功能的内存与设备相关联。devres 由与struct device相关联的任意大小的存储区域的链表组成。每个 devers 资源分配器将分配的资源插入列表中。该资源保持可用,直到被代码手动释放、设备从系统分离或驱动卸载。每个 devres 条目都与一个release函数相关联。有不同的方法来释放一个设备。无论如何,所有 devres 条目都会在驱动分离时释放。在释放时,调用相关的释放函数,然后释放 devres 条目。

以下是驱动可用的资源列表:

  • 私有数据结构的内存
  • 内部评级
  • 内存区域分配(request_mem_region())
  • 内存区域的输入/输出映射(ioremap())
  • 缓冲存储器(可能带有直接存储器存取映射)
  • 不同的框架数据结构:时钟、通用输入输出系统、脉宽调制、通用串行总线物理层、调节器、直接存储器存取等等

本章中讨论的几乎每个函数都有其托管版本。在大多数情况下,函数托管版本的名称是通过在原始函数名称前加上devm获得的。比如devm_kzalloc()就是kzalloc()的托管版。此外,参数保持不变,但向右移动,因为第一个参数是为其分配资源的结构设备。非托管版本的参数中已经给定了结构设备的函数有一个例外:

void *kmalloc(size_t size, gfp_t flags) 
void * devm_kmalloc(struct device *dev, size_t size, gfp_t gfp) 

当设备与系统分离或设备的驱动卸载时,该内存会自动释放。如果不再需要,可以用devm_kfree()释放内存。

老爷子道:

ret = request_irq(irq, my_isr, 0, my_name, my_data); 
if(ret) { 
    dev_err(dev, "Failed to register IRQ.\n"); 
    ret = -ENODEV; 
    goto failed_register_irq; /* Unroll */ 
} 

正确的方式:

ret = devm_request_irq(dev, irq, my_isr, 0, my_name, my_data); 
if(ret) { 
    dev_err(dev, "Failed to register IRQ.\n"); 
    return -ENODEV; /* Automatic unroll */ 
} 

摘要

这一章是最重要的一章。它揭开了内核中内存管理和分配的神秘面纱。每一个记忆的方面都被讨论和详细,以及 dvres 也被解释。简要讨论缓存机制是为了概述在输入/输出操作期间幕后发生了什么。这是介绍和理解下一章的坚实基础,下一章将讨论 DMA。**