程序地址空间(基于c++和linxu的一些个人笔记

程序地址空间(基于c++和linxu的一些个人笔记)

我们之前讲C语言的时候,老师给大家画过这样的空间布局图:

复制代码
┌─────────────────┐ 高地址
│    内核空间 1G   │
├─────────────────┤
│       栈        │ ↓ 向下增长
├─────────────────┤
│       ↓         │
│                 │
│       ↑         │
├─────────────────┤
│    共享库/mmap   │
├─────────────────┤
│       堆        │ ↑ 向上增长
├─────────────────┤
│  未初始化数据BSS │
├─────────────────┤
│   初始化数据段   │
├─────────────────┤
│     代码段       │
└─────────────────┘ 低地址(用户空间 3G)

可是我们对他并不理解!可以先对其进行各区域验证:


为什么字符串常量是只读的

在 C/C++ 中,字符串字面量(如 "hello")存储在只读内存区域,尝试修改会导致段错误。

原因:

  • 编译器优化:相同字符串只存一份,节省内存
  • 安全考虑:防止意外修改导致程序崩溃

存储位置: 通常放在代码段(.text 或 .rodata),这些段是只读的

c 复制代码
#include <stdio.h>
#include <string.h>

int main() {
    // 下面是错误的
    char *str1 = "hello";
    str1[0] = 'J';           // 崩溃!字符串常量不可修改
    printf("%s\n", str1);

    // 方法:拷贝到可修改内存
    char *str2 = strdup("hello");
    str2[0] = 'J';           // 可以!
    printf("%s\n", str2);    // 输出 "Jello"
    free(str2);

    return 0;
}

C/C++ 变量存储位置表

内存区域 存储内容 示例 特点
内核空间 系统调用、内核数据 - 用户态不可直接访问
命令行/环境变量 程序参数、环境变量 argc, argv, environ 程序启动时传入
栈区 局部变量函数参数返回地址 int x = 10; void func(int a) 自动分配释放,向下增长(LIFO)
共享库 动态链接库的代码和共享内存 mmap(), .so文件 多进程可共享
堆区 动态分配的内存 malloc(), new 手动管理,向上增长,容易碎片化
BSS段 未初始化全局变量和初始化为0的静态变量 int global; static char buf[1024]; 程序加载时自动清零
数据段 已初始化全局变量和静态变量(非零值) int count = 10; static int x = 5; 占用可执行文件空间
代码段 可执行代码和字符串常量 int main() {...}, "MAX = 100" 只读可执行
变量类型 声明方式 存储位置 生命周期 可见性
全局变量 static int x = 10;(函数外) 数据段(已初始化)或 BSS(未初始化) 整个程序运行期间 当前文件内
静态局部变量 static int y = 20;(函数内) 数据段或 BSS 整个程序运行期间 仅函数内
普通全局变量 int z = 30;(函数外) 数据段或 BSS 整个程序运行期间 全局可见(可用extern跨文件访问)
局部变量 int a;(函数内) 函数调用期间 仅函数内

关键点:

  • 静态变量(无论局部还是全局)都存储在相同的内存区域(数据段或BSS)
  • 区别在于可见性(作用域),而不是存储位置
  • 静态局部变量只在函数内可见,但生命周期是整个程序

那程序地址空间真的存在吗?

不是内存,应该叫做虚拟地址空间。其地址是虚拟地址,不是真正的物理地址。

怎么证明: 父子进程修改一个变量有写时拷贝,结果两个的数据内容不一样但是地址一样,如果这是物理内存的话,明显不符合。所以只能是虚拟的。但是它具体是什么都不知道,C/C++ 里用到的都是虚拟地址,不是实际的内存地址。


关键解释

虚拟地址空间: 每个进程都有自己的虚拟地址空间(如32位系统下的4GB空间)

这是操作系统提供的抽象:

  • 进程"看到"的是连续的地址空间
  • 实际上这些地址被映射到物理内存的不同位置,甚至可能部分在磁盘上(页面交换)
  • 不是物理排布:物理内存不可能为每个进程都分配完整的地址空间
  • 例如:多个4GB进程不可能同时完全装入2GB物理内存

操作系统使用分页/分段技术,按需将虚拟地址映射到物理内存。

概念 vs 语言: 地址空间是计算机科学的概念,不是编程语言特有的概念。它是操作系统和硬件协作提供的抽象层。


虚拟地址与进程地址空间

大富翁例子

虚拟地址空间相当于大饼:每个进程都以为自己有4GB的物理内存,每个进程都以为自己在独占。

那操作系统给进程画了大饼,也要把大饼管理起来,就又到了先描述、再组织

  • struct饼:时间,内容......;然后组织起来

所以为了能把虚拟地址空间管理起来,虚拟地址空间就是一个数据结构 ,在Linux里叫 struct mm_struct

所以在我们创建进程的时候,要给进程画一个 mm_struct 这个虚拟地址(大饼)。task_struct 也是结构体变量,里面有指针指向对方。


虚拟地址空间怎么实现?

我们看起来有代码区,堆,栈......

它是一个从0到全的一共2的32次方个地址;同时它也划分了很多区域。

那这些区域要怎么理解?小女孩在桌子上画线本质就是区域划分,那怎么用计算机量化这个区域划分?

小女孩区域分化,用计算机量化一下!

c 复制代码
struct Desktop {
    int zs_start;          // 张三区域开始
    int zs_end;            // 张三区域结束
    int ls_start;          // 李四区域开始
    int ls_end;            // 李四区域结束
};

struct Desktop area = {0, 49, 50, 99};

所以区域划分只需要定好区域的开始和结束就可以; 就上面的每一个刻度就是地址,比如0处,1处等等;虽然地址空间没说明地址,但是只要知道可以使用的区域就可以。

桌子就是地址空间,宽度是2的32次方,刻度是地址空间上的地址,一张桌子上的小朋友先不管,每一个小朋友都有自己的区域。所以结构体大饼要有什么属性?

本质:是一个数据结构!!

c 复制代码
struct mm_struct {
    long code_start;       // 代码段开始
    long code_end;         // 代码段结束
    long init_start;       // 初始化数据开始
    long init_end;         // 初始化数据结束
    long uninit_start;     // 未初始化数据开始
    long uninit_end;       // 未初始化数据结束
    long heap_start;       // 堆开始
    long heap_end;         // 堆结束
    long stack_start;      // 栈开始
    long stack_end;        // 栈结束
    // ......
    pgd_t *pgd;            // 页表根指针
    struct vm_area_struct *mmap;  // VMA链表
};

就是每一个区域的开始虚拟地址和结束虚拟地址;调整区域就是修改开始和结束。

所以地址空间本质上就是一个数据结构,PCB里有一个指针会指向自己进程的地址空间。

它这个空间是可变的,比如你写的代码占100字节,那它就开辟一百字节的代码区就可以,与此同时,物理空间也开辟同样大小的,然后通过页表映射;而怎么改变区域大小,就是调整区域划分。


mm_struct 的初始化

1. 开辟空间

2. 初始化的值从哪里来?

地址空间是个对象,但是对象要被初始化,那它怎么初始化?相当一部分就是程序加载到内存的时候,在加载的时候来的。

程序在磁盘上是 ELF 格式的可执行文件,里面已经记录了:

  • 代码段多大、从哪开始
  • 数据段多大、从哪开始
  • 入口地址是什么

加载程序时,操作系统读取这些信息,填到 mm_struct 里。


一个进程一个虚拟地址空间,一个进程一套页表

虚拟地址空间对应的宽度是1字节;32位下有2的32次方个地址就是4GB,64位下有2的64次方个地址,宽度是1字节嘛,就这样乘。

用户空间3GB,然后内核空间1GB。

页表的作用

在这里说一个情况,你在程序里定义一个全局变量,然后在内存(物理地址)上就有了这个变量,在地址空间上也要对应有一个全局变量,这个全局变量的虚拟起始地址;与此同时,每个进程创建的时候要构建一个页表,一个进程一个地址空间,一个进程一个页表,页表的作用是:

复制代码
页表
┌──────────┬──────────┐
│ 0x111111 │ 0x112233 │
├──────────┼──────────┤
│   ...    │   ...    │
└──────────┴──────────┘

它就长这样,然后左侧是变量的虚拟地址,右侧是变量的物理地址;当我们进程要访问虚拟地址,操作系统就查表来找到内存地址来访问,所以页表是做内存映射的。

我们在语言层用到的都是虚拟地址,我的代码里每一个变量都有地址,包括代码本身都有地址,都是虚拟地址,然后通过映射找到内存地址。

地址的理解

但是刚刚我们知道,程序地址空间的宽度是1字节,那四个字节代表是有四个地址,那么我们得到的是哪个地址,是四个地址里最小的地址;那一个地址只能访问一个字节,所以有了类型,所以有了起始地址加偏移量就可以访问到完整地址。


fork 与写时拷贝

子进程的很多东西都是包括 task_struct 拷贝父进程的,然后改一改;子进程也要有自己的虚拟地址空间;也有自己对应的页表,因为子进程有自己的代码;那子进程的页表和虚拟地址空间都拷贝父进程。

但是页表拷贝就是浅拷贝了,所以为什么打印的地址是一样的,而且为什么全局变量被父子共享;代码也共享了;

要是子进程要修改全局变量,操作系统就会在内存开辟一个新空间然后把变量复制过去,子进程的映射也改变到新创建的变量,就可以看起来是一个地址(虚拟地址)但是值不同,因为真实的物理地址不一样;这就是写时拷贝。

与此同时用户只能看到虚拟地址看不到物理地址。


那为什么要有虚拟地址空间?

1. 将地址从无序变有序

物理内存可能是碎片化的、散落各处的。虚拟地址空间让每个进程看到的是连续、统一的地址布局。

2. 权限保护

页表当中还有权限,如果你要对一个代码区写入但是没权限,去查页表,操作系统就不会让你完成,就对代码进行保护。即地址转化过程中,可以对你的地址和操作进行合法性判定。

几个问题:

a. 什么是野指针

c 复制代码
char *str = "helloworld";
*str = 'H';  // 崩溃!

a. 野指针其实就是已经被释放的空间你去访问,然后查页表要去访问,结果操作系统为了保护代码,可能会直接把你的进程挂掉,导致程序崩溃;不一定程序奔溃,做个了解。

b. 这个叫字符串常量,是不可被修改的;那会发生什么,可以编译过,运行的时候崩掉了,字符串在字符常量区,它的权限是只读,没权限,所以页表转换失败。

3. 缺页中断(按需加载)

还没讲,大概意思就是物理空间如果需要的很大,可以先加载一点然后映射到页表,用的时候动态加载。

不需要一次性把整个程序加载到内存,用到哪页加载哪页,节省内存。

4. 让进程管理和内存管理进行一定程度的解耦合

进程只管虚拟地址,内存管理只管物理页的分配回收,两边通过页表连接,互不干扰。


澄清一些问题!!!

1. 我么可以不加载对应的代码和数据,只有 task_struct,mm_struct...,页表;

可以。创建进程时先建好"骨架",代码和数据可以按需加载(Demand Paging)。

2. 创建进程,先有 PCB 和 mm_struct...才会有代码;

对。顺序是:分配 task_struct → 分配 mm_struct → 建立页表 → 映射代码段/数据段 → 按需加载。

3. 如何理解进程挂起?

进程挂起就是先找到对应的进程,把页表标记为"不在内存",把物理页内容换出到磁盘(swap区)。这个时候虚拟地址还在,mm_struct 还在,只是物理页暂时不在内存里。恢复时再换回来。


堆区不止一个地址

堆区不止一个吧?不止一个虚拟地址吧?因为我需要不断地去 new 啊,然后它每次之间都是离散的呀?

没事的,mm_struct 里维护了一个 vm_area_struct 链表,有指向下一个节点的指针,然后分很多堆区也没事,一份堆区对应一个 vm_area_structvm_area_struct 里有这个堆区的开始和结束地址。

c 复制代码
struct vm_area_struct {
    unsigned long vm_start;      // 区域起始地址
    unsigned long vm_end;        // 区域结束地址
    struct vm_area_struct *vm_next;  // 下一个VMA
    unsigned long vm_flags;      // 权限标志
    // ...
};

就是一个地址区域相当于一个节点,整个是一个链表,然后节点里有指向下一个节点的指针。

复制代码
mm_struct
    └── mmap ──► [VMA: 代码段] ──► [VMA: 数据段] ──► [VMA: 堆] ──► [VMA: 栈] ──► NULL

总结

  1. 虚拟地址空间是操作系统给进程制造的"幻觉",让每个进程以为自己独占一大片连续内存。

  2. 内核用 mm_struct 描述虚拟地址空间,记录各区域的起止地址。

  3. vm_area_struct 链表管理各个区域(代码段、数据段、堆、栈......)。

  4. 页表负责把虚拟地址翻译成物理地址,同时做权限检查。

  5. 这套机制实现了:

    • 进程隔离(互不干扰)
    • 简化编程(不用关心物理地址)
    • 灵活分配(虚拟连续,物理可以不连续)
    • 写时拷贝(fork 高效)
    • 权限保护(防止非法访问)
    • 按需加载(节省内存)
相关推荐
Yweir1 小时前
Linux性能监控的工具集和分析命令工具
java·linux·jvm
Tandy12356_1 小时前
手写TCP/IP协议栈——无回报ARP包生成
c语言·c++·tcp/ip·计算机网络
Dxxyyyy1 小时前
零基础学JAVA--Day41(IO文件流+IO流原理+InputStream+OutputStream)
java·开发语言·python
XH-hui1 小时前
【打靶日记】群内靶机Monkey
linux·网络安全
赖small强1 小时前
【Linux C/C++开发】C语言函数深度技术指南 (Deep Dive into C Functions)
linux·c语言·函数指针·stack frame
独自破碎E1 小时前
力场重叠问题
java·开发语言·算法
XH-hui1 小时前
【打靶日记】群内靶机Alluser
linux·网络安全
jiuweiC1 小时前
python 虚拟环境-windows
开发语言·windows·python
前端世界1 小时前
C 语言项目实践:用指针实现一个“班级成绩智能分析器”
c语言·开发语言