初识Linux · 地址空间

目录

前言:

代码现象

快速理解该现象

理解部分细节问题

[细节1 拷贝和独立性](#细节1 拷贝和独立性)

[细节2 如何理解地址空间](#细节2 如何理解地址空间)

[细节3 为什么存在地址空间](#细节3 为什么存在地址空间)

[细节4 如何进一步理解页表和写时拷贝](#细节4 如何进一步理解页表和写时拷贝)


前言:

本文介绍的是有关地址空间,咱们的介绍的大体思路是,先直接看现象,通过现象我们引入地址空间的概念,然后是简单的介绍地址空间,如何理解地址空间等,在往后的学习中,我们大概会介绍地址空间3 - 4次,因为地址空间有很多内容一次性还介绍不完,并且在本文中,我们能够理解之前颠覆代码三观的函数------fork(),现在就进入正题。


代码现象

目前我们对于地址空间没有明确的概念,所以先来看这样一段代码:

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include<sys/types.h>

int g_val =100;
int main()
{
        pid_t id = fork();
        if(id == 0)
        {
                int count = 1;
                while(count)
                {

                printf("g_val is %d,&g_val = %p\n",g_val,&g_val);
                sleep(1);
                if(count == 5)
                        g_val = 200;
                count++;
                }

        }
        else
        {
                while(1)
                {
                printf("g_val is %d,&g_val = %p\n",g_val,&g_val);
                sleep(1);
                }
        }

        return 0;
}

代码的意思是,我们创建一个父进程之后,在父进程里面创建一个子进程,子进程要完成的工作是打印g_val和它的地址,当count到5的时候就修改g_val,但是后续还是要一直打印,父进程要做的工作就是一直打印g_val和g_val的地址。

现象如下:

打印5秒之后,g_val的值如愿以偿的被修改了,此时让父进程打印的时候,我们发现一个怪事,打印的时候为什么父进程中的g_val没有变化呢?我们在进程部分知道父进程的数据是和子进程共享的,但是此时,父进程的数据被子进程修改了,父进程居然无动于衷?

现在的现象就是:一个变量,地址没有变化,但是拥有两个不同的值。

我们一会儿要理解的就是该现象,该现象理解了之后,我们同时就能理解fork函数的返回值是怎么回事了。


快速理解该现象

我们如果想要快速理解该现象,就需要引入两个概念,一个是物理内存,一个是虚拟内存

物理内存很好理解,即我们平常买的,比如三星SSD 990Pro内存条,就是物理内存,数据是实打实的在里面加载了的,但是虚拟内存是怎么回事呢?

我们平常写下的对某个对象取地址,本质上都是在虚拟内存层面上进行操作,并不是在物理内存上,那么上面代码写的,&g_val本质上就是个虚拟地址!

那么虚拟地址(内存)如何和物理地址(内存)进行联系呢?

这里就需要引出地址空间的概念了,这是地址空间的形象图,我们在语言学习阶段,最多涉及到的只有malloc空间在堆上,局部变量在栈上等概念,我们没有系统的学习,这里我们会深入一点点,为什么存在地址空间?地址空间是什么?有什么用?这是我们需要知道的。

我们最开始的问题是,虚拟内存如何和物理内存进行联系的,这个过程有地址空间的一份功劳,我们从名字来看,地址空间,地址,空间,容易想到这是一块空间,空间里面充满了地址这种描述,实际上确实是这样的,地址空间在源码中的名字叫做mm_struct,更深层次的我们不追究,而我们最开始说的,task_struct,mm_struct是嵌套在里面的。

通过我们刚才的描述,mm_struct里面充满了地址,那么是谁的地址呢?

由图我们知道,由堆区的地址,栈的地址,初始化数据的地址等,但是同时,不是所有的地址我们都是可以访问的,像内核空间的地址,我们知道,但是是无法访问的。一个空间里面充满了地址,可以用什么变量表示呢?结构体对吧!

所以地址空间本质上就是结构体,进程通过地址空间的所有地址来找到物理内存中对应的数据。

那么问题又来了,里面不是都存的虚拟地址吗,怎么通过虚拟地址来找物理地址呢?

这时候,页表就该引入了。

页表,最直观的就是:

最最最简单的页表就是这样,每一行的元素存在映射关系,既然是提到了映射关系,相信不少同学都明白了,映射嘛,左边是虚拟地址,右边是物理地址,这就是OS通过页表,地址空间等,链接了虚拟内存和物理内存的方法。

当然了,页表还有许多没有介绍的,在后续文章中会介绍的。

现在就得出结论:虚拟内存和物理内存的联系是通过页表,地址空间,从地址空间得到虚拟内存,在页表存在虚拟内存和物理内存的映射的关系来找到对应的数据,这是OS中找数据的方法。

现在只是知道了找数据,但是我们不清楚了找数据之后,为什么同样的地址会有两份结果,那么虚拟地址都是一样的,映射关系可以不一样吗?

大体图就是这样,父进程和子进程得到的虚拟地址是一样的,但是呢,当子进程对父进程中的某个数据发生了修改,此时就会发生写时拷贝,即在物理内存层面拷贝一个该数据,子进程指向该数据即可。

以上是对文章开头代码的简单理解。


理解部分细节问题

细节1 拷贝和独立性

我们重新理解一下,什么是进程的独立性?

从上文代码可以看出来,我们虽然在父进程里面创建了一个子进程,但是数据在物理内存中却不是同一个,通过写时拷贝再次印证了进程的独立性问题。

既然子进程修改父进程中的数据,会导致发生写时拷贝,那么在物理内存层面,为什么不全部拷贝一份呢?

在C++的类和对象中,存在按需实例化的语法,在OS层面上也是同理,从按需实例化的角度来看,子进程需要使用到父进程中的什么数据,如果发生了改变才会有写时拷贝,从地址空间的角度来看,地址空间的内核部分,是用户层面无法调用的,所以没有必要发生拷贝复制,所以按需实例化的方式,成功的可以节省一定的空间。

细节2 如何理解地址空间

在小学的时候,我们和同桌不妨都有过三八线的经历,这个动作的本质就是在划分区域,划分区域涉及到的对象有自己拥有的区域,桌子这一整个对象,所以这个动作可以分为,A拥有自己的空间,B拥有自己的空间,所有空间加起来就是桌面的大小。

那么我们不妨将桌面的整个空间理解为OS内核,里面存在的所有地址空间,都是一个一个的结构体,那么为了区域划分,结构体里面肯定是需要不同的变量来表示区域的开始 结束的,在地址空间这里,我们不妨简单看一下源码:

cpp 复制代码
struct mm_struct {
	struct vm_area_struct * mmap;		/* list of VMAs,指向线性区对象的链表头部 */
	struct rb_root mm_rb;                   /* 指向线性区对象的红黑树*/
	struct vm_area_struct * mmap_cache;	/* last find_vma result 指向最近找到的虚拟区间 */
#ifdef CONFIG_MMU 
 
/*用来在进程地址空间中搜索有效的进程地址空间的函数*/
 
	unsigned long (*get_unmapped_area) (struct file *filp,
				unsigned long addr, unsigned long len,
				unsigned long pgoff, unsigned long flags);
/*释放线性区的调用方法*/
 void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
#endif
	unsigned long mmap_base;		/* base of mmap area ,内存映射区的基地址*/
	unsigned long task_size;		/* size of task vm space */
	unsigned long cached_hole_size; 	/* if non-zero, the largest hole below free_area_cache */
	unsigned long free_area_cache;		/* first hole of size cached_hole_size or larger */
	pgd_t * pgd;                            /* 页表目录指针*/
	atomic_t mm_users;			/* How many users with user space?,共享进程的个数 */
	atomic_t mm_count;			/* How many references to "struct mm_struct" (users count as 1),主使用计数器,采用引用计数,描述有多少指针指向当前的mm_struct */
	int map_count;				/* number of VMAs ,线性区个数*/
	struct rw_semaphore mmap_sem;
	spinlock_t page_table_lock;		/* Protects page tables and some counters,保护页表和引用计数的锁 (使用的自旋锁)*/
 
	struct list_head mmlist;		/* List of maybe swapped mm's.	These are globally strung
						 * together off init_mm.mmlist, and are protected
						 * by mmlist_lock
						 */
 
 
	unsigned long hiwater_rss;	/* High-watermark of RSS usage,进程拥有的最大页表数目 */
	unsigned long hiwater_vm;	/* High-water virtual memory usage ,进程线性区的最大页表数目*/
 
	unsigned long total_vm, locked_vm, shared_vm, exec_vm;
	unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
	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;  /*命令行参数的起始地址和尾地址,环境变量的起始地址和尾地址*/
 
	unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
 
	/*
	 * Special counters, in some configurations protected by the
	 * page_table_lock, in other configurations by being atomic.
	 */
	struct mm_rss_stat rss_stat;
 
	struct linux_binfmt *binfmt;
 
	cpumask_t cpu_vm_mask;
 
	/* Architecture-specific MM context */
	mm_context_t context;
 
	/* Swap token stuff */
	/*
	 * Last value of global fault stamp as seen by this process.
	 * In other words, this value gives an indication of how long
	 * it has been since this task got the token.
	 * Look at mm/thrash.c
	 */
	unsigned int faultstamp;
	unsigned int token_priority;
	unsigned int last_interval;
 
	unsigned long flags; /* Must use atomic bitops to access the bits */
 
	struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
	spinlock_t		ioctx_lock;
	struct hlist_head	ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
	/*
	 * "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 *owner;
#endif
 
#ifdef CONFIG_PROC_FS
	/* store ref to file /proc/<pid>/exe symlink points to */
	struct file *exe_file;
	unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
	struct mmu_notifier_mm *mmu_notifier_mm;
#endif
};

里面存在的字段,如start_code等,都是地址空间里面不同区域的划分。

这里我们可以得出结论,地址空间本质就是内核中的一个一个的结构体,每个进程都拥有自己的地址空间

细节3 为什么存在地址空间

结合细节2,我们以大富翁的概念来结束进程的理解,大富翁的总资产是10亿,对于不同的孩子,他都是说我有10亿,你们看着要就行,但是大富翁肯定是会给儿女不同的零花钱什么的。这里面,大富翁就是OS,地址空间就是零花钱,不同的儿女对应不同的进程。

那么为什么存在地址空间呢?它存在的意义是什么?

从内存层面上来讲,如何程序直接在物理内存上开辟空间,那必然是杂乱无章,因为哪里有空间就开在哪里,操作系统管理起来就十分麻烦,这是无序的,有了地址空间这个结构,地址空间里面存储的都是进程里面的地址信息,那么集合管理在一个结构体里面,从有序的角度去看待地址,而非在物理内存里面无序的查找。所以地址空间可以让无需变成有序。

从管理内存和进程的角度来看,地址空间的存在可以让进程管理模块和进程管理模块解耦,如果没有地址空间,那么进程是直接链接在物理内存上的,那么进程里面申请了一个变量,在物理内存上就一定会申请空间,势必空间会不太够用,但是有地址空间,即便申请了,但是没有用,页表那里甚至可以先不映射,如果使用了,再映射即可。所以管理进程和内存,可以通过页表来解耦,而不是直接让进程和内存完全绑定在一起。

从安全问题上来看,我们之前写代码的时候,如果出现了非法请求,比如野指针访问,进程就会被直接杀死,这是因为地址空间已经划分好了空间,如果访问的地址超过了这个空间,就是非法访问,OS层面检测出越界,肯定就直接杀死该进程了。这是一种对物理内存的保护,再比如,一对父子,子如果直接拥有钱,自由支配,不免的会买不利于成长的玩具,但是父如果作为中间商,对钱进行管理,子想要买,必须通过父这一层,此时,子想买扑克牌,父制止,这个就是对非法请求的拦截,也是保护了整个内存的运作。

细节4 如何进一步理解页表和写时拷贝

我们看一段简单的代码:

cpp 复制代码
const char* str = "abcdefg";
str = 'A';

为什么str不能被修改呢?我们知道它是const类型的。

但是为什么const类型就不能被修改呢?因为在页表里面还存在数据的权限,rwx。

对于str来说,是只读的,所以对应的权限是r,代码一般也是只读的。所以即便页表对应到了该数据,但是页表中记录的权限仍然是r,没有w,那么它就无法写。

对于页表中是如何映射的我们先不管,里面涉及到了cpu中的CR3和MMU,后面详细介绍。

当OS在页表中查找数据时,如果没有该数据,就会发生缺页中断,如果数据需要写时拷贝,就会发生写时拷贝,对于数据,如果上面的两种情况都不满足,才会引入异常。

这里对于虚拟地址也带一嘴,虚拟地址哪里来的呢?为什么地址空间一来就有地址呢?这是因为程序本身就有地址,我们使用指令:objdump -S 可执行文件名

cpp 复制代码
0000000000001000 <_init>:
    1000:	f3 0f 1e fa          	endbr64 
    1004:	48 83 ec 08          	sub    $0x8,%rsp
    1008:	48 8b 05 d9 2f 00 00 	mov    0x2fd9(%rip),%rax        # 3fe8 <__gmon_start__@Base>
    100f:	48 85 c0             	test   %rax,%rax
    1012:	74 02                	je     1016 <_init+0x16>
    1014:	ff d0                	call   *%rax
    1016:	48 83 c4 08          	add    $0x8,%rsp
    101a:	c3                   	ret    

Disassembly of section .plt:

0000000000001020 <.plt>:
    1020:	ff 35 8a 2f 00 00    	push   0x2f8a(%rip)        # 3fb0 <_GLOBAL_OFFSET_TABLE_+0x8>
    1026:	f2 ff 25 8b 2f 00 00 	bnd jmp *0x2f8b(%rip)        # 3fb8 <_GLOBAL_OFFSET_TABLE_+0x10>
    102d:	0f 1f 00             	nopl   (%rax)
    1030:	f3 0f 1e fa          	endbr64 
    1034:	68 00 00 00 00       	push   $0x0
    1039:	f2 e9 e1 ff ff ff    	bnd jmp 1020 <_init+0x20>
    103f:	90                   	nop
    1040:	f3 0f 1e fa          	endbr64 
    1044:	68 01 00 00 00       	push   $0x1
    1049:	f2 e9 d1 ff ff ff    	bnd jmp 1020 <_init+0x20>
    104f:	90                   	nop
    1050:	f3 0f 1e fa          	endbr64 
    1054:	68 02 00 00 00       	push   $0x2
    1059:	f2 e9 c1 ff ff ff    	bnd jmp 1020 <_init+0x20>
    105f:	90                   	nop

Disassembly of section .plt.got:

0000000000001060 <__cxa_finalize@plt>:
    1060:	f3 0f 1e fa          	endbr64 
    1064:	f2 ff 25 8d 2f 00 00 	bnd jmp *0x2f8d(%rip)        # 3ff8 <__cxa_finalize@GLIBC_2.2.5>
    106b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

Disassembly of section .plt.sec:

0000000000001070 <printf@plt>:
    1070:	f3 0f 1e fa          	endbr64 
    1074:	f2 ff 25 45 2f 00 00 	bnd jmp *0x2f45(%rip)        # 3fc0 <printf@GLIBC_2.2.5>
    107b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

0000000000001080 <sleep@plt>:
    1080:	f3 0f 1e fa          	endbr64 
    1084:	f2 ff 25 3d 2f 00 00 	bnd jmp *0x2f3d(%rip)        # 3fc8 <sleep@GLIBC_2.2.5>
    108b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

0000000000001090 <fork@plt>:
    1090:	f3 0f 1e fa          	endbr64 
    1094:	f2 ff 25 35 2f 00 00 	bnd jmp *0x2f35(%rip)        # 3fd0 <fork@GLIBC_2.2.5>
    109b:	0f 1f 44 00 00       	nopl   0x0(%rax,%rax,1)

Disassembly of section .text:

就可以看到对应的程序地址了,当然,了解即可。

地址空间到这里就粗略的收场了,后面会介绍的。

再回归到最开始的问题,为什么fork的返回值会有"两个",你理解了吗?


感谢阅读!

相关推荐
IC 见路不走几秒前
LeetCode 第91题:解码方法
linux·运维·服务器
翻滚吧键盘13 分钟前
查看linux中steam游戏的兼容性
linux·运维·游戏
小能喵18 分钟前
Kali Linux Wifi 伪造热点
linux·安全·kali·kali linux
汀沿河32 分钟前
8.1 prefix Tunning与Prompt Tunning模型微调方法
linux·运维·服务器·人工智能
zly35001 小时前
centos7 ping127.0.0.1不通
linux·运维·服务器
小哥山水之间1 小时前
基于dropbear实现嵌入式系统ssh服务端与客户端完整交互
linux
ldj20202 小时前
2025 Centos 安装PostgreSQL
linux·postgresql·centos
翻滚吧键盘2 小时前
opensuse tumbleweed上安装显卡驱动
linux
凌肖战2 小时前
力扣网编程55题:跳跃游戏之逆向思维
算法·leetcode
cui_win3 小时前
【内存】Linux 内核优化实战 - net.ipv4.tcp_tw_reuse
linux·网络·tcp/ip