程序地址空间

一、C语言中的程序地址空间

1.分区

一个运行中的 C 程序,其地址空间按功能划分为 7 个核心区域,从低地址到高地址依次排列,每个区域有明确的读写权限和用途:

区域名称 地址范围(示意) 核心功能 关键特性
代码段(Text) 低地址 存储程序的二进制可执行指令(main、函数等) 只读、可共享(多进程复用)
数据段(Data) 代码段后 存储已初始化的全局变量、静态变量(如int a = 10; 可读可写、程序运行全程存在
未初始化数据段(BSS) 数据段后 存储未初始化的全局变量、静态变量(如int b; 程序启动时自动初始化为 0、占用虚拟地址不占磁盘空间
堆(Heap) BSS 段后 动态内存分配区域(malloc/calloc/realloc申请的内存) 向上增长(地址升高)、手动管理
内存映射区 堆与栈之间 映射共享库、动态库(如libc.so)、文件等 灵活分配、按需映射
栈(Stack) 高地址 存储局部变量、函数参数、返回地址、寄存器上下文 向下增长(地址降低)、自动回收
环境 / 参数区 栈顶(更高地址) 存储命令行参数(argv)、环境表(envp/environ

2.核心区详解

代码段(Text)

存放编译后的机器指令,比如你写的printf()for循环对应的二进制代码。

权限为只读(防止程序意外修改自身指令),多个进程执行同一程序时,代码段可共享物理内存,节省资源。

数据段 + BSS 段

两者合称 "静态存储区",区别仅在于是否初始化。

BSS 段的优势:编译时仅记录变量名和大小,不占用可执行文件的磁盘空间,程序启动时由操作系统分配内存并置 0。

堆(Heap)

程序员手动管理的动态内存,通过malloc申请、free释放。

堆的增长方向是 "向上"(从低地址到高地址),大小受限于系统剩余物理内存。

常见问题:内存泄漏(忘记free)、野指针(free后未置 NULL)、双重释放(重复free)。

栈(Stack)

自动管理的临时内存,函数调用时分配栈帧(存储局部变量、参数、返回地址),函数返回时栈帧自动销毁。

栈的增长方向是 "向下"(从高地址到低地址),默认大小有限(Linux 下约 8MB),超出则触发 "栈溢出"(Stack Overflow)。

环境 / 参数区

存放main函数的argv(命令行参数)和envp(环境变量),本质是两个以NULL结尾的字符指针数组。

环境表(envp)的每个指针指向KEY=VALUE格式的字符串(如PATH=/usr/bin),与栈紧邻但地址更高。

3.验证地址空间布局

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>

// 全局变量(数据段/BSS段)
int global_init = 10;    // 数据段
int global_uninit;       // BSS段
static int static_init = 20; // 数据段
static int static_uninit;    // BSS段

void print_addr() {
    // 局部变量(栈)
    int local_var = 30;
    // 动态内存(堆)
    int *heap_var = malloc(sizeof(int));
    
    printf("=== 各区域地址(从低到高)===\n");
    printf("代码段(函数地址):%p\n", print_addr);
    printf("数据段(初始化全局变量):%p\n", &global_init);
    printf("数据段(初始化静态变量):%p\n", &static_init);
    printf("BSS段(未初始化全局变量):%p\n", &global_uninit);
    printf("BSS段(未初始化静态变量):%p\n", &static_uninit);
    printf("堆(malloc分配):%p\n", heap_var);
    printf("栈(局部变量):%p\n", &local_var);
    printf("环境表(environ):%p\n", environ); // 需声明extern char **environ;
    
    free(heap_var);
}

int main(int argc, char *argv[], char *envp[]) {
    print_addr();
    return 0;
}

输出结果:

bash 复制代码
=== 各区域地址(从低到高)===
代码段(函数地址):0x55f8b76a8660
数据段(初始化全局变量):0x55f8b78ac010
数据段(初始化静态变量):0x55f8b78ac014
BSS段(未初始化全局变量):0x55f8b78ac020
BSS段(未初始化静态变量):0x55f8b78ac024
堆(malloc分配):0x55f8b94092a0
栈(局部变量):0x7ffd7b8e7abc
环境表(environ):0x7ffd7b8e95d8

从输出可清晰看到:代码段 < 数据段 / BSS 段 < 堆 < 栈 < 环境表,符合经典布局顺序。


二、进程地址空间

但实际上,程序地址空间并不是真实的物理内存,而是"虚拟"内存

我们用一段代码验证:

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

int g_val = 0;

int main()
{
    pid_t id = fork();
    if(id < 0)
    {
        perror("fork");
        return 0;
    }
    else if(id == 0)
    {
        g_val = 100;
        printf("child[%d]:%d:%p\n", getpid(), g_val, &g_val);
    }
    else
    {
        printf("parent[%d]:%d:%p\n", getpid(), g_val, &g_val);
    }
    return 0;
}

输出:

bash 复制代码
child[2996]:100:0x80497d8
parent[2995]:0:0x80497d8

我们发现,父子进程输出地址是一样的,但是变量的内容不一样:

内容变量不一样,所以父子进程输出地址的变量绝对不是同一个变量

但地址值是一样的,说明绝对不是物理地址

在Linux环境中,这种地址叫做虚拟地址

OS将虚拟地址转化为物理地址,具体关系如下所示:

这张图描绘了父进程通过 fork() 创建子进程后的内存映射变化:

1.初始状态(fork () 刚完成时)

子进程会复制父进程的 task_structmm_struct,但页表和物理内存是共享的。

此时父子进程的虚拟地址(如 g_val)指向同一个物理内存页,页表被标记为只读

2.子进程尝试写入(触发写时复制)

当子进程对共享的物理页(如 g_val 所在的已初始化数据区)执行写入操作时,CPU 会检测到 "写只读页" 的异常。

内核会为该物理页创建一个副本,更新子进程的页表,使其指向新的物理页。

之后子进程的写入操作就只会作用于这个新副本,不会影响父进程的原数据。

相关推荐
苦藤新鸡2 小时前
51.课程表(拓扑排序)-leetcode207
数据结构·算法·leetcode·bfs
senijusene2 小时前
数据结构与算法:栈的基本概念,顺序栈与链式栈的详细实现
c语言·开发语言·算法·链表
naruto_lnq2 小时前
分布式日志系统实现
开发语言·c++·算法
啊我不会诶2 小时前
Codeforces Round 1071 (Div. 3) vp补题
开发语言·学习·算法
格林威2 小时前
Baumer相机金属弹簧圈数自动计数:用于来料快速检验的 6 个核心算法,附 OpenCV+Halcon 实战代码!
人工智能·数码相机·opencv·算法·计算机视觉·视觉检测·堡盟相机
188号安全攻城狮2 小时前
【PWN】HappyNewYearCTF_9_ret2syscall
linux·汇编·安全·网络安全·系统安全
一起努力啊~2 小时前
算法刷题--栈和队列
开发语言·算法
开开心心_Every2 小时前
Win10/Win11版本一键切换工具
linux·运维·服务器·edge·pdf·web3·共识算法