当前位置:   article > 正文

64/32位Linux系统的差异(地址空间布局,系统调用)对比分析_linux64内存布局

linux64内存布局

Ubuntu从17.10开始不再官方支持32位(i386)架构(严格的说是从18.04开始的,因为17.10不支持32位的PC版,但是支持32位的SERVER版,但是偶数稳定版确实是从18.04开始的),只支持64位(amd64)架构,这是因为随着时间的推移,64位系统变得越来越普遍,并且可以更好的利用现代计算机的硬件性能和内存容量,此外,随着软件的发展和更新,许多应用程序都不再提供32位版本,而只提供64位版本,这也促使ubuntu停止更新32位架构。尽管如此,ubuntu18.04仍然提供了32位版本的软件包和库,以便支持老旧系统和应用程序。镜像下载链接:

Index of /releases

地址类型

Linux系统有物理地址,虚拟地址,用户虚拟地址,总线地址,内核逻辑地址,内核虚拟地址之分,区别如下:

物理地址,把内存当成一个大的字节数组,物理地址是对数组中的每个字节进行编址得到的结果,物理地址有32位和64位的。

虚拟地址,在CPU使用MMU的体系架构上,CPU发出的地址需要经过MMU翻译后才能变成物理地址,CPU发出的这个未经MMU翻译的地址就是虚拟地址,虚拟地址包括用户虚拟地址,内核虚拟地址和内核逻辑地址。

用户虚拟地址,这是用户空间程序所能看到的地址,用户虚拟地址可以是32位的,也可以是64位的,取决于体系架构,Linux下每个进程都有自己的虚拟地址空间。

总线地址,该地址在外围总线和内存之间使用,通常总线地址与处理器使用的物理地址相同,但也不是必须的,如果体系结构提供了IOMMU单元,则它实现总线第和物理内存之间的重新映射,此时的总线地址就不等于物理地址了。

内核逻辑地址,内核逻辑地址组成了内核的常规地址空间,它是一种虚拟地址,在32位系统中,由于虚拟地址空间有限,内核逻辑地址只映射了部内存,剩下的物理内存作为高端内存映射到内核虚拟地址。在大多数体系结构中,内核逻辑地址和物理地址的区别,仅仅在于他们之间存在一个固定的偏移量。正因为内核逻辑地址和物理地址之间的转换非常简单,内核逻辑地址常常被是为物理地址,但本质上是有区别的。kmalloc和page_address(低端内存,高端内存PAGE一般是临时映射才有内核虚拟地址)返回的就是内核逻辑地址。

内核虚拟地址,内核虚拟地址和内核逻辑地址的相同之处在于,它们都将内核空间的地址映射到物理地址上,内核虚拟地址与物理地址的映射不必是线性的和一对一的,而这恰恰是内核逻辑地址的特点,所有的内核逻辑地址都是内核虚拟地址,但是许多的内核虚拟地址不是逻辑地址。比如,vmalloc分配的是内核虚拟地址,kmap映射高端内存,返回的也是一个内核虚拟地址。内核逻辑地址和物理地址之间可以通过page_to_pfn/pfn_to_page/__va/__pa等宏转化,但是内核虚拟地址不可以,因为它和物理地址没有线性关系,只能反查页表。

虚拟地址空间

内核的架构是由内核自身决定的,和发行版无关,之所以提到UBUNTU是因为后面的实验是在UBUNTU环境下进行的。Linux内核在 32位和64位架构上有着不同的地址空间范围,在32位架构中,地址空间一共32位,从[0x00000000,0xFFFFFFFF],总共是4GB,这个地址空间被分成用户空间和内核空间两个部分,不同的架构划分方式有所不同,通常是按照3G用户1G内核的分法。

相比之下,在64位架构中,虚拟地址空间范围为[0x0000000000000000,0xFFFFFFFFFFFFFFFF],这个范围如此之大,以至于当前没有任何应用需要这么大的空间,所以实际上这个地址空间并不会被占满,而是用户空间和内核空间分别占用一部分,剩下的保留不用。当前的主流实现是,64位地址空间,Linux64位操作系统仅使用低47位,高17位作为扩展(全0或者全1),所以实际用到的地址空间为[0x0000000000000000,0x00007FFFFFFFFFFF](用户态)和[0xFFFF800000000000, 0xFFFFFFFFFFFFFFFF](内核态)。其余地址空间都是保留不用的。如下图所示:

地址映射范围: 

实际运行用户态用例查看

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <unistd.h>
  4. int main(void)
  5. {
  6. while(1)
  7. {
  8. printf("%s line %d, helloworld.\n", __func__, __LINE__);
  9. sleep(1);
  10. }
  11. return 0;
  12. }

测试发现用户态地址空间确实和上面的分析吻合。

内核地址空间呢?内核地址空间无法通过pmap或者/proc/节点查看,不用急,我们还有/proc/kallsyms节点,它保留了内核中的符号信息,接取一小段分析一下,发现却是落到了上图中得内核地址空间范围内,但是似乎只局限在某个狭窄的区域,并没有从0xFFFF800000000000开始。

实际上,查看内核文档Documentation/x86/x86_64/mm.rst得知,内核代码段仅仅占用了内核空间的一小部分,剩下的部分也都各有所属,被内核的其它机制瓜分了,为了将来拓展内核功能,有些地址空间被设置为保留。

在4.14内核上的描述更简洁清晰一些:

图中可以看到,0xffffffff80000000开始的地址空间确实属于内核代码和数据的地盘,和上面分析/proc/kallsyms结论是吻合的。

物理地址空间

UBUNTU下,可以通过如下几个命令查看物理地址空间分布

  1. ls -l /sys/firmware/memmap/
  2. lsmem
  3. cat /proc/iomem

以lsmem输出为例,我的笔记本主内存有8G,从lsmem输出来看,被分成两段,第一段2.3G,物理地址范围在0x0000000000000000-0x000000008fffffff,另一段是5.8G,范围为0x0000000100000000-0x000000026fffffff。

并且0x000000008fffffff + 1 + (0x000000026fffffff-0x0000000100000000 + 1) = 0x200000000=8G.

完美吻合实际物理内存大小。

PAGE_OFFSET

物理地址与线性地址之间的位移量,在Linux代码中就叫做PAGE_OFFSET, 在32位的内核中,这个值一般是0xc0000000.PAGE_OFFSET 代表的是内核空间和用户空间对虚拟地址空间的划分,不同的体系结构定义和值都不同。比如在32位系统中3G-4G属于内核使用的内存空间,所以 PAGE_OFFSET = 0xC0000000。在X86-64架构下是ffff880000000000。内核程序可以可以访问从PAGE_OFFSET 之后的内存。

可以看到,在开启CONFIG_DYNAMIC_MEMORY_LAYOUT(通过CONFIG_RANDOMIZE_MEMORY控制)的情况下,PAGE_OFFSET的值是动态变化的,取自于page_offset_base变量。

我们可以将一个PAGE的物理地址和内核线性地址的偏移打印出来,如果不出意外,应该和PAGE_OFFSET相等。下图可以看到,计算得到的OFFSET和PAGE_OFFSET他们确实是一致的。

page offset 是线性地址和物理地支之间的转换因子

CONFIG_DYNAMIC_MEMORY_LAYOUT打开情况下:

上图中Physical Memory到Virtual Memory的映射叫做直接映射,它是将物理地址的0地址,直接映射到虚拟内存中的page_offset_base偏移处,通过程序反查内核页表,定位从page_offset_base开始的10个PAGE映射页面可以看出,物理PFN从0递增到9。

也就是说,PFN号为0的物理页面的内核逻辑地址,就是PAGE_OFFSET,有如下等式:

                         page_address(pfn_to_page(0)) == page_offset_base

但是经过测试,系统中NODE中实际可用的PFN从1号开始,似乎预留了物理页面的前4K。

如果不设置CONFIG_DYNAMIC_MEMORY_LAYOUT,则PAGE_OFFSET为内核设置的固定值:

DUMP系统的PAGE TABLE,起始于0xffff888000000000.

关于几个地址的随机化实现原理,可以参考这篇文章的分析:

Linux内核地址空间随机化ASLR的几种实现方法_papaofdoudou的博客-CSDN博客

struct page 数组起始地址vmemmap

vmemmap就是用来存放稀疏内存的page结构体的数据的虚拟地址空间,其地址空间并不存在于内核逻辑地址空间, 而是内核虚拟地址空间,所以vmemmap地址无法通过virt_to_page/page_to_pfn进行转换。        

vmemmap是struct page数组的起始地址,也就是说vmemmap代表的是pfn 0的struct page结构体。并且,通过HACK页表得知,vmemmap的映射为大页映射,也就是PMD 2M映射,如下图所示:

由于地址空间随机化的作用,每次重启系统,vmemmap的地址数值会发生变化,但是规律始终不变。

vmemmap所在的区域属于内核虚拟地址空间,而非内核逻辑地址空间,所以可以在需要的时候再做映射。这个特点非常重要,因为在默认情况下只有物理内存有对应的struct page映射,而对于外设内存,比如显存等等,是没有struct page对应的,所以内核中针对DMA CPU内存和设备内存的映射分成两套API,分别是map_sg和map_resource,设备内存由于没有struct page对应,只能被看成一种资源去做映射。这会在某些情况下带来一些麻烦,典型的是PCIE设备之间P2P访问,内核的P2P DMA实现调用的是map_sg之类的接口,但是map_sg接口需要存储有struct page对应,这种矛盾的解决,就是通过扩展vmemmap数组,将设备内存也纳入到vmemmap数组的管理,为了保证vmemmap开始的内核虚拟地址空间足够映射连同设备资源在内的所有系统物理内存,vmemmap区域的大小是按照MAX_PHYSICAL_BIT的大小确定的,关于这一点的分析,可以参考博客:

PCIE体系结构基础和Linux PCI设备注册过程的实现_linux驱动 pcie设备为何不需要注册-CSDN博客

vmemmap和CR3

从如下角度来看,vmmemap映射内存和页表映射内存很像,是同构的:

怎么理解这种同构性呢,尤其是,无论页表和struct page数组,都存在于其映射的物理内存中,并非是一个三方存储器。想通这个问题,可以参考:

1。购物中心的导航地图(你所处的位置在这里)。

2。在中国境内打开一张中国地图。

3。数学集合中的部分到整体的映射。

3。磁盘文件系统中的位图,1个BIT可以描述一个BLOCK,位图也可以存放于其映射的BLOCK中。

现在,应该想通了吧。

从内核镜像中分析内核符号

分析vmlinux和vmlinux.lds文件都可以得到关键的符号地址信息:

可以看到_stext起始地址为0xffffffff81000000,吻合图中的分布.

vmlinux.lds的定义导出关键符号表信息,比如_end.

也就是说,内核代码+数据+bss=0xffffffff8402c000-0xffffffff81000000=0x302c000.

32位系统安装

32位系统已经很少见了,便于分析,用QEMU-KVM安装一台ubuntu-16.04.3-desktop-i386.iso虚拟机。参考博客:

ubuntu18.04下pass-through直通realteck PCI设备到qemu-kvm虚拟机实践_papaofdoudou的博客-CSDN博客

  1. qemu-img create -f qcow2 i386.img 20G
  2. sudo virt-install --virt-type kvm --name i386 --ram 4096 --vcpus 4 --machine q35 --cdrom ~/Workspace/iso/ubuntu-16.04.3-desktop-i386.iso --disk i386.img --network network=default --graphics vnc,listen=0.0.0.0 --noautoconsole
  3. sudo virsh vncdisplay i386
  4. :1
  5. gvncviewer 127.0.0.1:1

安装后,对比HOST的系统信息输出,可以看到虚拟机系统确实是32位的。

32位用户地址空间布局

目前的主流高性能芯片已经普遍升级到64位,兼容运行32位应用程序,比如ARM支持PL0 32位应用,X86也支持X86_EMULATION配置支持32位应用程序,所以根据内核是否64位,布局分析包含两种情况。

32位内核运行32位应用:

32位内核地址空间布局:

CONFIG_PAGE_OFFSET定义了内核空间的实际起始地址:

以ARM为例,x86-32类似,32位的地址空间如下,PAGE_OFFSET为0xc0000000:

64位内核兼容模式运行32位应用:

  1. $ gcc -m32 main.c
  2. $ file a.out
  3. a.out: ELF 32-bit LSB shared object, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=c16494de74596c05b0429ac104597022e0817a8f, not stripped
  4. $ ./a.out &
  5. [1] 30455
  6. $ main line 8.
  7. main line 8.
  8. main line 8.
  9. main line 8.
  10. main line 8.
  11. main line 8.
  1. $ pmap 30455
  2. 30455: ./a.out
  3. 000000005662a000 4K r-x-- a.out
  4. 000000005662b000 4K r---- a.out
  5. 000000005662c000 4K rw--- a.out
  6. 0000000057e66000 136K rw--- [ anon ]
  7. 00000000f7d2a000 1876K r-x-- libc-2.27.so
  8. 00000000f7eff000 4K ----- libc-2.27.so
  9. 00000000f7f00000 8K r---- libc-2.27.so
  10. 00000000f7f02000 4K rw--- libc-2.27.so
  11. 00000000f7f03000 12K rw--- [ anon ]
  12. 00000000f7f20000 8K rw--- [ anon ]
  13. 00000000f7f22000 12K r---- [ anon ]
  14. 00000000f7f25000 8K r-x-- [ anon ]
  15. 00000000f7f27000 152K r-x-- ld-2.27.so
  16. 00000000f7f4d000 4K r---- ld-2.27.so
  17. 00000000f7f4e000 4K rw--- ld-2.27.so
  18. 00000000ff9a2000 132K rw--- [ stack ]
  19. total 2372K
  20. $

可以看到,兼容模式下的32位应用地址高32BIT全部为0,地址分配比较随意,毕竟内核64位的,32位触及不到,随便怎么分配也不影响64位内核的运行。X86在兼容模式下有一套独立的针对32位EMULATION模式的系统调用表和系统调用总入口,有独立的系统调用路径,在内核态,会通过compat_ptr补齐高位32个BIT地址为0,再进行寻址访问,32位应用的系统调用的接口通常带有compat_xxx字样特征。

不同宽度的虚拟地址的惯用映射方式:

SV48 用户态空间的虚拟地址上限在0x7fffffffffff,实际实现时,设置为0x7ffffffff000.并且保存在struct task_struct->mm->task_size变量中。见下图DUMP的结果:

而当32位应用运行在兼容模式时,struct task_struct->mm->task_size被设置为0xffffe000.

X86PC的实际物理地址宽度是多少呢?

服务器和个人PC绝大部分是X86架构,从代码中可以看到,包括INTEL,AMD,和海光三家。

CPU最多能访问的物理内存大小是由物理地址宽度决定的,那么这个宽度是多少呢?在LINUX内核中,可以通过boot_cpu_data.x86_phys_bits获取这个值:

可以看到,在我的电脑上,这个宽度是39,也就是128x4G=512G.

在某些服务器上,可寻址的物理地址更大一些:

不同架构上的物理地址宽度:

物理和虚拟地址宽度信息可以通过CPUID指令获得,测试代码如下:

  1. #include <stdio.h>
  2. #include <string.h>
  3. struct cpuinfo_x86 {
  4. unsigned char x86;
  5. unsigned char x86_vendor;
  6. unsigned char x86_model;
  7. unsigned char x86_stepping;
  8. int cpuid_level;
  9. char x86_vendor_id[16];
  10. char x86_model_id[64];
  11. int x86_cache_alignment;
  12. unsigned short x86_clflush_size;
  13. unsigned char x86_virt_bits;
  14. unsigned char x86_phys_bits;
  15. unsigned char x86_cache_bits;
  16. unsigned char x86_feature_hyper;
  17. };
  18. static inline void cpuid(unsigned int op, unsigned int *eax, unsigned int *ebx,
  19. unsigned int *ecx, unsigned int *edx)
  20. {
  21. *eax = op;
  22. *ecx = 0;
  23. asm volatile("cpuid"
  24. : "=a"(*eax),
  25. "=b"(*ebx),
  26. "=c"(*ecx),
  27. "=d"(*edx)
  28. : "0"(*eax), "2"(*ecx)
  29. : "memory");
  30. }
  31. unsigned int x86_family(unsigned int sig)
  32. {
  33. unsigned int x86;
  34. x86 = (sig >> 8) & 0xf;
  35. if (x86 == 0xf)
  36. x86 += (sig >> 20) & 0xff;
  37. return x86;
  38. }
  39. unsigned int x86_model(unsigned int sig)
  40. {
  41. unsigned int fam, model;
  42. fam = x86_family(sig);
  43. model = (sig >> 4) & 0xf;
  44. if (fam >= 0x6)
  45. model += ((sig >> 16) & 0xf) << 4;
  46. return model;
  47. }
  48. unsigned int x86_stepping(unsigned int sig)
  49. {
  50. return sig & 0xf;
  51. }
  52. static void get_model_name(struct cpuinfo_x86 *c)
  53. {
  54. unsigned int *v = (unsigned int *)c->x86_model_id;
  55. cpuid(0x80000002, &v[0], &v[1], &v[2], &v[3]);
  56. cpuid(0x80000003, &v[4], &v[5], &v[6], &v[7]);
  57. cpuid(0x80000004, &v[8], &v[9], &v[10], &v[11]);
  58. c->x86_model_id[48] = 0;
  59. }
  60. void cpu_detect(struct cpuinfo_x86 *c)
  61. {
  62. /* Get vendor name */
  63. memset(c->x86_vendor_id, 0, sizeof(c->x86_vendor_id));
  64. cpuid(0x00000000, (unsigned int *)&c->cpuid_level,
  65. (unsigned int *)&c->x86_vendor_id[0],
  66. (unsigned int *)&c->x86_vendor_id[8],
  67. (unsigned int *)&c->x86_vendor_id[4]);
  68. c->x86 = 4;
  69. /* Intel-defined flags: level 0x00000001 */
  70. if (c->cpuid_level >= 0x00000001) {
  71. unsigned int junk, tfms, cap0, misc;
  72. cpuid(0x00000001, &tfms, &misc, &junk, &cap0);
  73. c->x86 = x86_family(tfms);
  74. c->x86_model = x86_model(tfms);
  75. c->x86_stepping = x86_stepping(tfms);
  76. c->x86_feature_hyper = junk >> 31;
  77. if (cap0 & (1 << 19)) {
  78. c->x86_clflush_size = ((misc >> 8) & 0xff) * 8;
  79. c->x86_cache_alignment = c->x86_clflush_size;
  80. }
  81. }
  82. }
  83. void get_cpu_address_sizes(struct cpuinfo_x86 *c)
  84. {
  85. unsigned int eax, ebx, ecx, edx;
  86. cpuid(0x80000008, &eax, &ebx, &ecx, &edx);
  87. c->x86_virt_bits = (eax >> 8) & 0xff;
  88. c->x86_phys_bits = eax & 0xff;
  89. c->x86_cache_bits = c->x86_phys_bits;
  90. }
  91. int main(void)
  92. {
  93. unsigned int eax = 0;
  94. unsigned int ebx = 0;
  95. unsigned int ecx = 0;
  96. unsigned int edx = 0;
  97. struct cpuinfo_x86 _cpuinfo_x86;
  98. cpuid(0, &eax, &ebx, &ecx, &edx);
  99. printf("EBX ← %x (“Genu”)EDX ← %x (“ineI”) ECX ← %x (“ntel”)\n", ebx, edx, ecx);
  100. get_cpu_address_sizes(&_cpuinfo_x86);
  101. cpu_detect(&_cpuinfo_x86);
  102. get_model_name(&_cpuinfo_x86);
  103. printf("Address sizes: phys_bits = %d virt_bits = %d\n", _cpuinfo_x86.x86_phys_bits, _cpuinfo_x86.x86_virt_bits);
  104. printf("Vendor Id= %s\n", _cpuinfo_x86.x86_vendor_id);
  105. printf("cpuid level = %d\n", _cpuinfo_x86.cpuid_level);
  106. printf("CPU family = %d\n", _cpuinfo_x86.x86);
  107. printf("Model = %d\n", _cpuinfo_x86.x86_model);
  108. printf("Stepping = %d\n", _cpuinfo_x86.x86_stepping);
  109. printf("Model name = %s\n", _cpuinfo_x86.x86_model_id);
  110. printf("clflush_size = %d\n", _cpuinfo_x86.x86_clflush_size);
  111. printf("cache_alignment = %d\n", _cpuinfo_x86.x86_cache_alignment);
  112. printf("feature hyper = %s\n", _cpuinfo_x86.x86_feature_hyper ? "HyperVisor" : "Metal");
  113. return 0;
  114. }

或者直接执行CPUID指令

分析发现,无论上面的代码还是工具得到的虚拟地址均是48BIT.

而我们上面图中的虚拟地址是按照47BIT讲的,少掉的一个BIT在那儿呢? 

少掉的BIT47我们可以认为是标志位,而剩下的从BIT47到BIT63位可以认为是标志位的扩展。

所以说是48位没有问题,48位,正好可以构成四级页表,每级TABLE 9个BIT。4X9+12 = 48. 末级的最高位为1,这说明内核的映射范围对应的PGD index 最少是256,可见计算pgd index的时候,是将bit47计算在内的。并且有如下规律:

PTE Entry映射4K。

PMD Entry映射2M。

PUD Entry映射1G。

PGD/P4D Entry映射512G。

64系统对32位应用的支持

前不久ARM发布了ARMv9指令集,并基于此推出了Cortex-X2/A710/A510架构,这是10年前推出ARMv8之后的一次更大更新,全面迈向64位指令集。紧接着ARM官方声明中计划2023年ARM所有的大小核架构都将采用64位,32位指令届时会被淘汰。2023年淘汰32位 ARM将全面转向64位CPU架构,家都没了,那32位应用应该如何自处呢?其实X86架构已经给出了答案,目前主流的X86服务器和个人PC都已经迁移到了64位上,但是仍然兼容32位应用运行,兼容的方式有两种,一种是架构上支持用户级的32位运行模式,这种需要CPU硬件支持,另一种则是ILP32模式,后者仅需要软件支持。

ILP32模式的优势:

这里分析一下X86架构的支持方式。X86_64 linux支持32位应用需要打开如下配置:

  1. CONFIG_X86_64=y
  2. CONFIG_64BIT=y
  3. CONFIG_X86_X32=y
  4. CONFIG_COMPAT_32=y
  5. CONFIG_COMPAT=y
  6. CONFIG_IA32_EMULATION=y

注意CONFIG_X86_X32和CONFIG_X86_32差一个字母,但是意义差别巨大,后者表示处理器是32位处理器,非64位处理器。

打开后,内核会为32位应用开放3个专供32位应用进入的系统调用入口点:

unlocked_ioctl和compat_ioctl

字符设备驱动中支持两个IOCTL HANDLER,分别是unlocked_ioctl和compat_ioctl,

  • compat_ioctl:支持64bit的driver必须要实现ioctl,当有32bit的userspace application call 64bit kernel的IOCTL的时候,这个callback会被调用到。如果没有实现compat_ioctl,那么32位的用户程序在64位的kernel上执行ioctl时会返回错误:Not a typewriter 
  • 如果是64位的用户程序运行在64位的kernel上,调用的是unlocked_ioctl,如果是32位的APP运行在32位的kernel上,调用的也是unlocked_ioctl。

ARM实现ILP32,则更多的是CPU硬件的支持

32位和64位的系统调用对比

32位和64位的系统调用号顺序不同:

内核地址空间

每个进程的task_struct结构都有一个指针指向mm_struct结构, 从中可以找到相应的页面目录,但是,内核空间不属于任何一个特定的进程,所以单独设置了一个内核专用的mm_struct,称为init_mm,当然内核也没有代表它的task_struct结构,所以一般是直接访问init_mm.

内核地址空间是为内核而存在的,而不是为了用户进程,从CPU角度看,任何没有映射到内核地址空间的物理内存都是不存在的。内核掌握全部资源,如果进程通过系统调用申请内核服务,内核需要能够访问到这些资源,在大部分架构的内核实现中,系统调用的入口点并不会执行用户态页表到内核态页表的切换工作,而是将内核态页表寄生在每个进程的页表中,保持同样的映射在所有的进程中共享。内核只有一个映射,当内核端页面映射发生更改时,该更改将会在所有进程反映出来。

内核分配是动态的,但是地址空间不是,内核和用户空间分配的边界是PAGE_OFFSET定义的,这个裂口不能移动。

设想一个处理器运行多个进程的场景,如果一个进程在执行用户态,那么内核态一定是不活动的,如果进程中没有可用的内核映射,当用户态进程通过系统调用请求内核服务时,内核该如何找到它的上下文呢?解决方法就是在每个进程中映射内核。进程既可以运行在用户空间,也可以运行在内核空间,这取决于它在做什么,但是在任何时候,都是用户进程在运行,区别是它在运行用户代码还是内核代码。

至少在LINUX上,不认为有任何办法可以预测一个进程何时运行在内核态,何时运行在用户态,因为进程可以作任何可以作的事情。

如何判断一个进程是用户进程还是内核进程?

大多数进程在用户空间运行,并在运行内核代码时切换到内核空间,就像应用程序一样,然而,也有专门设计为完全在内核空间中运行的守护进程,这样的例子很多,比如ksoftirqd,khungtaskd 等等。如何判断一个进程有没有用户空间呢?下面推荐两种办法:

1.pmap #PID 会告诉你一个进程的用户空间分布情况,如果为0,说明它不是用户态进程:

2.ps -aux 命令,对应的VSZ字段如果为0,说明没有用户态虚拟内存,为内核线程。

IO地址和内存地址一致性

多数RISC处理器的IO地址和内存地址是统一编址的,开发人员可以像操作普通内存一样操纵IO口,但是X86不是这样的架构,X86的IO口必须通过专门的指令访问:

ioremap是通过填充MMU页表的方式进行映射的:

ioremap的记录是可以通过/proc/vmallocinfo获取到的:

地址空间末尾1个PAGE大小作为系统调用的返回错误码

内核中对错误码的处理逻辑

所有的驱动程序都运行在内核空间。内核空间虽然很大,但总是有限的。在这有限的空间中,其最后一个page是专门为错误码保留的,即内核用最后一页捕捉错误,一般人不可能用到内核空间最后一个page的指针。因此,在写设备驱动程序的过程中,涉及到的指针,必然有以下三种情况:有效指针;NULL,即空指针;错误指针,或者说无效指针。

而所谓的错误指针就是指其已经到达了最后一个page。比如,对于32位的 系统,内核空间最高地址为0xffffffff,那么最后一个page就是0xfffff000到 0xffffffff(假设4k一个page),这段地址是被保留的。如果你发现你的一个指针指向这个范围中的某个地址,那么你的代码肯定出错了。

IS_ERR()就是用来判断指针是否有错,如果指针并不是指向最后一个page,那么没有问题;如果指针指向了最后一个page,那么说明实际上这不是一个有效的指针,这个指针里保存的实际上是一种错误代码。而通常很常用的方法就是先用IS_ERR()来判断是否是错误,然后如果是,那么就调用PTR_ERR()来返回这个错误代码。

值得一提的是,由于IS_ERR只判断指针是否在最后一个PAGE与否,所以其返回值并不代表指针为是有效的,换一句话说,IS_ERR(NULL)返回为0,虽然探测通过,但是NULL指针是无法访问的,所以在应用中,经常用判断NULL和IS_ERR或在一起用,只有两个都部成立的时候,指针才是安全的。

如果系统调用发生错误,内核中返回的错误码,传递给用户态之后,用户态会将其赋值给errno,errno每个线程有一个,是线程安全的全局变量。而且系统调用的返回值,则统一设置为-1.

Q&A

同一个进程的所有线程的/proc/$PID/tasks/$TID/maps完全相同:

当然,因为同一个进程下所有的线程共享进程的mm_struct对象:


X86默认的四级页表和大页支持:

X86上使用大页步骤

挂在HUGEPAGE文件系统

  1. mkdir -p /mnt/huge
  2. mount none /mnt/huge -t hugetlbfs

配置系统允许的大页数目,否则申请不到大页

 /proc/sys/vm/nr_hugepages 给出了当前内核中配置的大页面的数目,也可以通过该文件配置大页面的数目,如:

echo 30 > /proc/sys/vm/nr_hugepages

编写代码,在/mnt/huge目录中创建文件,并MMAP,将会使用大页PAGE进行映射。

  1. #include <stdio.h>
  2. #include <fcntl.h>
  3. #include <sys/mman.h>
  4. #include <errno.h>
  5. #include <unistd.h>
  6. #include <string.h>
  7. #include <sys/wait.h>
  8. #include <stdlib.h>
  9. #define MAP_LENGTH (10*1024*1024)
  10. int main(void)
  11. {
  12. int fd;
  13. void *addr;
  14. /* create a file in hugetlb fs */
  15. fd = open("/mnt/huge/test", O_CREAT | O_RDWR);
  16. if (fd < 0) {
  17. perror("Erriii: ");
  18. return -1;
  19. }
  20. /* map the file into address space of current application process */
  21. addr = mmap(0, MAP_LENGTH, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
  22. if (addr == MAP_FAILED) {
  23. perror("Errddd: ");
  24. close(fd);
  25. unlink("/mnt/huge/test");
  26. return -1;
  27. }
  28. /* from now on, you can store application data on huage pages via addr */
  29. memset(addr, 0x5a, MAP_LENGTH);
  30. munmap(addr, MAP_LENGTH);
  31. fsync(fd);
  32. close(fd);
  33. //unlink("/mnt/huge/test");
  34. return 0;
  35. }

10M的0x5a数据

修改代码,在做完mmap后睡眠,查看/proc/meminfo中对应的HUGE PAGE信息,发现其总共30个,空闲25个,也就是分配了5个,PAGE SIZE为2M,一共10M,和我们的程序是完全吻合的。

如何验证大页表功能?

映射地址:

可以看到,对应的地址0x7fd9b8c00000是PSE页,映射了10M,是PMD级的大页映射,也就是2M页面的映射。连续影射了5个PMD大页。

PSE页要求虚拟地址和物理地址都是2M对齐

测试发现,即便没有执行mount hugetlbfs操作,内核态地址空间都有大页映射,而只有在hugetlbfs被ENABLE后,用户态空间才可以进行大页映射,不知道是否可以从代码中得到证明。

一个侧面的例证是,在运行上述测试过程中,如果遍历所有用户进程,打印其VMA的属性,发现只有测试用例中存在大页映射。

从上面可以看到,映射的大页都是PMD页级,也就是2M大小的大页,没有发现1G大小的大页。

X86_64 MMU映射

从CR3开始的中间级页表保存的都是物理地址和属性信息,MMU查表依赖于物理地址,而不是虚拟地址。软件维护页表时,需要通过pgd_page_vaddr/p4d_page_vaddr/pud_page_vaddr/pmd_page_vaddr/__va等转换为虚拟地址才可以访问。

P4D折叠到PGD中,实际页表只有四级,从下面的page fault输出的各级页表项可以看出来,P4D和PGD的页表项是相同的:

X86_64 48位地址空间分布情况

PAGE_OFFSET为0xffff9a4ec0000000,4级页表情况下,PGD的分布如下图所示:

计算方法:

PGD有9个BIT,512项,每项管39BIT的地址空间(48-9 = 39).

内核地址空间的共享

所有进程,包括内核进程,共享同样的内核地址空间,表现在页表上,PGD的内核空间条目部分是完全一致的:

可以通过对比不同进程页表的内核空间部分的页目录来验证:

进程创建时,内核部分的页表条目来自于swapper pg dir.

调用堆栈和路径如下:

在MIPS和Xtensa架构中,没有存储PGD的系统寄存器,页表维护是通过TLB 重填实现的,而且,内核空间中都存在一段KSEG的映射,它不需要MMU即可访问,所以也就避免了内核页目录拷贝这一过程,而其他普通架构则需要拷贝相同的PGD来透明的实现这一点。

以xtensa为例,其PGD仅仅分配一个空闲页面后,初始化为0即可,不需要拷贝内核页目录:

内核空间是长存的,而用户空间是暂时的,会随着进程退出消失的,用户空间就如同是内核空间的黄粱一梦,当梦醒来,发现身边留下的永远都是内核的东西,用户空间曾经发生的一切都不曾留下痕迹,就像从来没有发生过的一样。

正因为如此,内核空间才不可以依赖用户空间的资源,比如锁,缓存等等,避免用户空间进程的任何异常对内核空间产生负面影响。而用户空间则是严重依赖于内核的资源,比如申请缓存,请求均衡调度,休眠等等。

新创建的进程会从swapper_pg_dir(X86架构下是init_top_pgt)拷贝内核空间的页表PGD,保证所有进程在内核态时共享相同的内核地址空间。而用户空间由进程本身通过PAGE FAULT机制或者主动POPULATE建立映射,swapper_pg_dir的用户空间部分全部为0,这也是内核线程没有用户空间的原因。

页表消耗

Linux struct mm_struct结构中定义了pgtables_bytes字段存储用于建立页表的内存消耗:

内核通过如下几个函数在建立页表/删除页表时增加/减少pgtables_bytes的统计值:

  1. mm_pgtables_bytes_init
  2. mm_pgtables_bytes
  3. mm_inc_nr_ptes/mm_dec_nr_ptes
  4. mm_inc_nr_pmds/mm_dec_nr_pmds
  5. mm_inc_nr_puds/mm_dec_nr_puds

下面是一段计算页表消耗的算法:

  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #define PAGE_SIZE 0x1000
  4. #define DBG(fmt, ...) do { printf("%s line %d, "fmt, __func__, __LINE__, ##__VA_ARGS__); } while (0)
  5. #define assert(expr) \
  6. if (!(expr)) { \
  7. printf("Assertion failed! %s,%s,%s,line=%d\n",\
  8. #expr,__FILE__,__func__,__LINE__); \
  9. while(1); \
  10. }
  11. int caculate_page_table(unsigned long size, int entry_size, int level)
  12. {
  13. int i = 0;
  14. unsigned long tmp = size;
  15. unsigned long sum = 0;
  16. while(tmp && level) {
  17. if(tmp < PAGE_SIZE) {
  18. tmp = PAGE_SIZE;
  19. }
  20. tmp = (tmp / PAGE_SIZE) * entry_size;
  21. sum += tmp;
  22. DBG("level %d page table take size 0x%lx.\n", i,tmp);
  23. // if the last level of page table, satp,cr3,ttbr...
  24. if(tmp == entry_size)
  25. break;
  26. i ++;
  27. level --;
  28. }
  29. DBG("you use %d level page table and total cost 0x%lx.\n", i, sum);
  30. return sum;
  31. }
  32. int caculate_page_table_recur(unsigned long size, int entry_size)
  33. {
  34. int sum = 0, tmp;
  35. static int level = 0;
  36. //DBG("size = 0x%lx.\n", size);
  37. if (size == entry_size) {
  38. // aready added in last sum += tmp;
  39. DBG("mmu has %d page level.\n", level);
  40. return 0;
  41. }
  42. tmp = (size / PAGE_SIZE) * entry_size;
  43. sum += tmp;
  44. level ++;
  45. sum += caculate_page_table_recur(tmp, entry_size);
  46. DBG("sum = 0x%x.\n", sum);
  47. return sum;
  48. }
  49. int main(void)
  50. {
  51. unsigned long sz;
  52. int total1, total2;
  53. sz = 4UL*1024UL*1024UL*1024UL;
  54. #if 1
  55. caculate_page_table_recur(sz, 4);
  56. #else
  57. total1 = caculate_page_table(sz, 4, 1);
  58. total2 = caculate_page_table(sz, 4, 3);
  59. DBG("total2 - total1 = 0x%x.\n", total2-total1);
  60. #endif
  61. sz = 512UL*1024UL*1024UL*1024UL;
  62. caculate_page_table(sz, 8, 6);
  63. sz = 32*1024UL*1024UL*1024UL;
  64. caculate_page_table(sz, 8, 6);
  65. return 0;
  66. }

可以看到,对于4G空间的映射来说,10-10-12的二级映射比20-12的一级映射会多出4K PGD的映射大小。但是通常不可能映射满全部的4G空间,所以多级映射还是有优势的。

X86_64架构下的页表级数

Linux定义了CONFIG_PGTABLE_LEVELS配置页表级数,默认情况下,X86_64架构使用了4级页表:

ARM SMMU页表映射

内核代码放在物理内存什么位置?

从连接脚本看,_text, _stext为内核代码第一个指令字节地址:

察看内核运行时,这两个符号的地址:

根据page table看,代码段映射为PSE 2M大页:

用seqfile对地址0xffffffffaa000000反查页表,得到其大页PAGE  PFN:

可以看到反查得到的PFN在PMD表项,PMD表每个entry映射2M,再次根据/proc/iomem得到的的信息,确认内核代码段确实是放在了物理地址空间19aa00000-19b800e30,恰好和反查页表得到的吻合。

所以,这样看起来,代码段并没有放到物理内存的0地址,纠正了之前的认知。下图是另一次启动时代码段映射起始地址从0xffffffff81a00000开始时的DUMP,可以看到其开始阶段是连续6个2M的PMD大页映射。

虚拟地址空间如何释放?

虚拟地址空间分为内核空间和用户空间,用户空间不需要主动释放,当进程退出时,会自动销毁用户态的地址空间,所以像remap_pfn_range/remap_vmalloc_range这样的MAP接口,是没有对应的unmap接口的,而kmap,vmap,ioremap等等映射内核地址空间的API,都有对应的kunmap, vunmap, iounmap等实现。内核空间需要主动释放。

64位LINUX系统没有高端内存

Linux操作系统中将内存划分为不同的ZONE进行管理,常见的ZONE可以分为以下几种:

  1. ZONE_DMA:用于ISA设备的DMA操作,范围是0-16MB,只适用于X86架构。ARM架构没有这个区域。
  2. ZONE_DMA32:用于最低4GB的内存设备的访问,如只支持32位的DMA设备。
  3. ZONE_NORMAL:对于64位系统来说,这部分区域指的是4GB以后的物理内存,用于线性映射物理内存,若系统内存小于4GB,则没有这个内存管理区。对于32位系统来说,NORMAL区域指的是16M-896M这段的区间,内核空间只有1G大小,内核将0 ~ 896M的物理地址空间一对一映射到自己的线性地址空间中,这样它便可以随时访问ZONE_DMA和ZONE_NORMAL里的物理页面;剩下的128M内核地址空间不足以完全映射所有的ZONE_HIGHMEM,Linux采取了动态映射的方法,即按需的将ZONE_HIGHMEM里的物理页面映射到kernel space的最后128M线性地址空间里,使用完之后释放映射关系,以供其它物理页面映射。
  4. ZONE_HIGHMEM:用户管理高端内存,这些高端内存是不能线性映射到内核地址空间的,并且,由于64位LINUX操作系统寻址能力足够强,是没有这个内存管理区的,只有32位系统有高端内存区域,范围大约是>=~896M附近以上的区域。

  5. ZONE_DEVICE:Linux为每个物理页面都分配了一个struct page结构构成PAGE 数组,使用mem_map/vmemmem等记录其地址,这些struct page和物理页面是一一对应的。默认情况下,只有主内存享受拥有struct page对应的待遇,设备内存,比如PCIE的BAR内存空间是没有struct page对象对应的,而struct page是内和的基础设施和通用语言,内核中大部分的DMA操作API都是接收struct page引用来进行存储访问的,比如DMA操作中的dma_map_sg等,虽然也有类似于dma_map_resource等接口用来映射裸的PFN(没有struct page对应的设备内存地址),但是毕竟将设备内存纳入PAGE系统仍然有很大的通用价值。因此,内和设置了ZONE_DEVICE用来管理PCIE上的设备内存,并且为其扩展vmemmap/mem_map数组,增加这些设备内存页面的struct page描述。这样在同一个管理体系下,一个应用场景,比如双卡的P2P数据拷贝,就可以通过通用的dma操作进行数据拷贝了。

高端内存依赖于X86_32的定义:

64位地址空间如此之大,以至于不需要设计高端内存即可映射全部的物理内存,所以在64位LINUX系统上,是没有高端内存的,看si_meminfo函数的输出,如下面打印,高端内存大小全部为0。

64位系统模块的安装空间

64位系统下模块的安装空间和32位是不同的,在32位系统下,模块的安装空间好像是在VMALLOC的范围内,但是在64位系统下,模块的安装空间由[MODULES_VADDR, MODULES_END]定义,其范围在[0xffffffffa0000000, 0xffffffffff000000].


参考文章

KSM Demo 分析-CSDN博客

linux内核——内存管理之早期页表建立过程 - 知乎

Linux内核地址空间随机化ASLR的几种实现方法_papaofdoudou的博客-CSDN博客

内存管理特性分析(十二):大页(huge pages)技术分析 - 知乎

Linux ion&dma-buf&iommu&sglist&genpool的原理_papaofdoudou的博客-CSDN博客

内存管理特性分析(十二):大页(huge pages)技术分析 - 知乎

Linux hugepage 原理 - 知乎

CPUID获取本机CPU信息_ITLittleDragon的博客-CSDN博客

Linux 物理内存映射_linux page_offset_小立爱学习的博客-CSDN博客


结束

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/从前慢现在也慢/article/detail/106436
推荐阅读
相关标签
  

闽ICP备14008679号