【Linux进程概念】环境变量与虚拟地址空间深度解析本文系统梳理环境变量与虚拟地址空间的核心知识点。重点解答两个问题为什么执行ls不用带路径而执行自己写的hello必须带./为什么fork之后父子进程同一个地址可以有不同的值一、环境变量1.1 从一个问题开始指令为什么不用带路径在 Linux 终端里我们输入ls、pwd、cd就能直接执行从来不用写/bin/ls。但自己写了一个hello.c编译出hello后却必须写./hello才能运行。同样都是可执行程序为什么系统命令可以直接敲名字我们自己写的程序就得带路径再进一步想如果系统命令也可以不带路径那它到底去哪里找这个可执行文件答案就藏在环境变量PATH里。1.2 从 PATH 引出环境变量当我们输入一个命令不带路径时Shell 会去PATH这个环境变量所指定的目录列表中依次查找同名的可执行文件。查看 PATHecho$PATH典型输出/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin:/home/whb/code/code/merge_class/lesson15它是一串用冒号:分隔的目录列表。Shell 会按顺序在这些目录里找ls找到/usr/bin/ls就执行它。而当前目录.通常不在 PATH 中所以自己写的hello必须显式写./hello告诉 Shell “就在当前目录找”。那 PATH 到底是什么它为什么能被 Shell 读到这就引出了环境变量的概念。1.3 基本概念环境变量Environment Variables是操作系统中用来指定运行环境的一些参数通常具有全局属性可以被子进程继承。从开发角度操作系统对外表现为一个整体但会暴露部分接口供上层使用这部分接口叫系统调用。系统调用功能基础、对用户要求高有心的开发者会对其进行封装形成库方便二次开发。环境变量就是操作系统运行环境的一部分。1.4 常见环境变量变量含义PATH指定命令的搜索路径HOME指定用户的主工作目录登录 Linux 时默认目录SHELL当前 Shell通常为/bin/bashUSER当前登录用户PWD当前工作目录LANG语言与字符编码LD_LIBRARY_PATH动态链接库额外搜索路径1.5 终端实录echo $HOME 与 env先看echo $HOME[whbbite-alicloud ~]$echo$HOME/home/whb说明当前用户whb的主工作目录是/home/whb。再看env它会输出当前 Shell 的所有环境变量环境变量值示例含义解析XDG_SESSION_ID14403当前图形/登录会话的唯一标识 ID用于 XDG 规范下的会话管理HOSTNAMEbite-alicloud当前主机名TERMxterm当前终端类型决定终端支持的颜色、光标移动等能力SHELL/bin/bash当前用户使用的 Shell 程序路径HISTSIZE1000历史命令记录条数上限Shell 最多保存 1000 条历史命令SSH_CLIENT113.132.220.172 12810 22当前 SSH 客户端的 IP、客户端端口、服务器端口SSH_TTY/dev/pts/0当前 SSH 会话使用的伪终端设备文件USERwhb当前登录用户名LD_LIBRARY_PATH/home/whb/.VimForCpp/vim/bundle/YCM.so/el7.x86_64动态链接库.so的额外搜索路径程序运行时优先在此查找库LS_COLORSrs0:di01;34:ln01;36:...ls命令显示不同文件类型时的颜色配置目录、链接、可执行文件等MAIL/var/spool/mail/whb当前用户的邮件存放路径PATH/usr/local/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/home/whb/.local/bin:/home/whb/bin:/home/whb/code/code/merge_class/lesson15命令搜索路径Shell 按顺序在这些目录中查找可执行文件PWD/home/whb当前工作目录Print Working DirectoryLANGen_US.UTF-8当前语言与字符编码设置HISTCONTROLignorededups历史命令记录控制ignorededups表示不记录重复命令SHLVL1当前 Shell 嵌套层级1 表示第一层 ShellHOME/home/whb当前用户的主工作目录LOGNAMEwhb当前登录名通常与 USER 相同SSH_CONNECTION113.132.220.172 12810 172.28.71.17 22SSH 连接的完整信息客户端 IP、客户端端口、服务器 IP、服务器端口LESSOPEN||/usr/bin/lesspipe.sh %sless命令的预处理脚本用于在查看文件前自动解压/格式化XDG_RUNTIME_DIR/run/user/1000当前用户的运行时目录用于存放临时 socket、锁文件等_/usr/bin/env上一条执行的命令路径Shell 自动记录OLDPWD/home/whb/code/code/merge_class/lesson15上一次的工作目录cd -会回到这里补充PATH的作用——命令搜索路径Shell 会到 PATH 指定的目录中查找可执行文件HOME与~的关系——~会被 Shell 展开为$HOME。1.6 和环境变量相关的命令命令作用echo显示某个环境变量值export设置一个新的环境变量env显示所有环境变量unset清除环境变量set显示本地定义的 shell 变量和环境变量1.7 环境变量的组织方式每个程序都会收到一张环境表它是一个字符指针数组每个指针指向一个以\0结尾的环境字符串最后以 NULL 结尾。environ → [ HOME/home/whb\0 ] [ PATH/usr/local/bin:...\0 ] [ SHELL/bin/bash\0 ] [ USERwhb\0 ] [ NULL ]1.8 通过代码获取环境变量方式一命令行第三个参数intmain(intargc,char*argv[],char*env[]){for(inti0;env[i];i)printf(%s\n,env[i]);return0;}方式二通过第三方变量environexternchar**environ;intmain(){for(inti0;environ[i];i)printf(%s\n,environ[i]);return0;}environ由 libc 定义指向环境变量表使用时需extern声明。1.9 通过系统调用获取或设置环境变量printf(%s\n,getenv(PATH));// 获取setenv(MYENV,helloworld,1);// 设置1.10 环境变量的全局属性环境变量可以被子进程继承。示例exportMYENVhelloworld再次运行程序即可读到MYENV。1.11 实验详解实验一只进行MYENVhelloworld不调用export结果程序查不到MYENVgetenv(MYENV)返回 NULL。原因MYENVhelloworld只是在当前 Shell 中定义了一个普通局部变量Shell 变量只存在于当前 Shell 进程的变量表中不会写入环境变量表。子进程只能继承父进程的环境变量表environ无法继承普通 Shell 变量。只有export后才能被子进程继承。实验二export MYENVhelloworld后再运行结果程序输出helloworld。原因export将MYENV提升为环境变量写入当前 Shell 的环境变量表。fork exec创建并加载子进程时环境变量表被完整继承给子进程。实验三修改~/.bash_profile和~/.bashrc操作# 编辑 ~/.bashrc 或 ~/.bash_profile添加exportMYENVhelloworldsource~/.bashrc结果新打开的 Shell 及其所有子进程都能读到MYENV。区别~/.bash_profile登录 Shell 启动时执行一次。~/.bashrc每次打开交互式非登录 Shell 时执行。通常建议把环境变量放在~/.bashrc中或在~/.bash_profile中 source~/.bashrc。实验四测试 PATH结果系统命令如ls、pwd直接输入名字就能执行。自己编译的hello必须用./hello才能执行。原因Shell 执行命令时会到PATH所指定的目录列表中依次查找同名可执行文件。当前目录.通常不在 PATH 中所以自己编译的程序必须显式写./hello。不用带路径直接运行的方法把程序所在目录加入 PATHexport PATH$PATH:hello程序所在路径把程序拷贝到 PATH 已包含的目录如/usr/local/bin。使用别名alias hello/路径/hello。实验五测试 HOME结果root 用户echo $HOME通常输出/root。普通用户echo $HOME通常输出/home/用户名。cd ~; pwd与 HOME 的关系~是 Shell 的元字符会被展开为$HOME的值。cd ~等价于cd $HOME进入用户主工作目录。二、程序地址空间2.1 研究平台kernel 2.6.3232 位平台2.2 经典空间布局高地址 内核空间 1G 3G~4G 共享区 栈 ↓ 向下增长 堆 ↑ 向上增长 未初始化数据 初始化数据 正文代码 低地址2.3 验证代码intg_unval;intg_val100;intmain(intargc,char*argv[],char*env[]){constchar*strhelloworld;printf(code addr: %p\n,main);printf(init global addr: %p\n,g_val);printf(uninit global addr: %p\n,g_unval);staticinttest10;char*heap_mem(char*)malloc(10);char*heap_mem1(char*)malloc(10);char*heap_mem2(char*)malloc(10);char*heap_mem3(char*)malloc(10);printf(heap addr: %p\n,heap_mem);printf(heap addr: %p\n,heap_mem1);printf(heap addr: %p\n,heap_mem2);printf(heap addr: %p\n,heap_mem3);printf(test static addr: %p\n,test);printf(stack addr: %p\n,heap_mem);printf(stack addr: %p\n,heap_mem1);printf(stack addr: %p\n,heap_mem2);printf(stack addr: %p\n,heap_mem3);printf(read only string addr: %p\n,str);for(inti0;iargc;i)printf(argv[%d]: %p\n,i,argv[i]);for(inti0;env[i];i)printf(env[%d]: %p\n,i,env[i]);return0;}2.4 典型输出code addr: 0x40055d ← 代码段地址最低 init global addr: 0x601034 ← 已初始化全局变量 uninit global addr: 0x601040 ← 未初始化全局变量 heap addr: 0x1791010 ← 堆地址逐次增大 heap addr: 0x1791030 heap addr: 0x1791050 heap addr: 0x1791070 test static addr: 0x601038 ← 静态变量位于数据段 stack addr: 0x7ffd0f9a4368 ← 栈地址逐次减小 stack addr: 0x7ffd0f9a4360 stack addr: 0x7ffd0f9a4358 stack addr: 0x7ffd0f9a4350 read only string addr: 0x400800 ← 只读字符串位于代码段附近 argv[0]: 0x7ffd0f9a4811 ← 命令行参数位于栈区上方 env[0]: 0x7ffd0f9a4819 ← 环境变量位于栈区上方结论地址从低到高依次为代码段 → 已初始化数据段 → 未初始化数据段 → 堆 → 共享区 → 栈 → 命令行参数/环境变量。堆向上增长栈向下增长。三、虚拟地址空间重点3.1 现象父子进程同一地址不同值看这段经典代码intg_val0;intmain(){pid_tidfork();if(id0){g_val100;printf(child[%d]: %d : %p\n,getpid(),g_val,g_val);}else{sleep(3);printf(parent[%d]: %d : %p\n,getpid(),g_val,g_val);}sleep(1);return0;}输出child[3046]: 100 : 0x80497e8 parent[3045]: 0 : 0x80497e8疑问同一个地址0x80497e8为什么子进程读到 100父进程读到 03.2 核心结论这个地址不是物理地址而是虚拟地址在 Linux 下我们 C/C 程序中看到的所有地址全部都是虚拟地址物理地址由 OS 统一管理用户不可见。3.3 为什么同一虚拟地址可以映射到不同物理地址关键机制页表 写时拷贝Copy-On-Write1fork 之后父子进程共享物理页fork()创建子进程时内核会为子进程复制一份父进程的mm_struct地址空间描述和页表。此时父子进程的虚拟地址相同页表项也暂时指向同一块物理内存。这块物理内存被标记为只读。2子进程修改数据触发写时拷贝当子进程执行g_val 100时CPU 发现该页是只读的触发缺页异常。内核介入为子进程新分配一块物理页。把原物理页的内容拷贝到新页。修改子进程页表让虚拟地址0x80497e8映射到新的物理页。恢复该页为可写子进程继续执行写入 100。而父进程的页表仍然指向原来的物理页内容还是 0。3图解父进程页表 子进程页表 虚拟地址 0x80497e8 虚拟地址 0x80497e8 │ │ ▼ ▼ 物理页 A值为 0 ←─── fork 时共享 ───→ 物理页 A只读 │ 子进程写入时触发写时拷贝 │ ▼ 物理页 B值为 100因此虚拟地址相同因为子进程复制了父进程的地址空间布局。物理地址不同写时拷贝后子进程映射到新物理页。值不同父进程读原页0子进程读新页100。3.4 页表到底是什么——虚拟地址 → 物理地址的翻译官前面说“虚拟地址相同、物理地址不同”靠的就是页表。页表是 OS 为每个进程维护的一张映射表CPU 中的MMU内存管理单元负责查表把虚拟地址翻译成物理地址。1页表项里有什么页表的每一个表项PTEPage Table Entry通常包含三部分核心内容内容说明物理页帧号PFN虚拟页对应的物理内存页的起始地址或页号权限位Protection Bits读/写/执行权限比如 R只读、W可写、X可执行状态位Status Bits比如 Present是否在内存、Dirty是否被写过、Accessed是否被访问过等用一句更直白的话说一个页表项回答三个问题这个虚拟地址对应物理内存的哪一页允许对它做什么操作读、写、执行它当前的状态是什么在不在内存、是否被修改过2为什么常量无法修改——权限位在起作用看这段代码constchar*strhelloworld;*strH;// 崩溃Segmentation fault字符串helloworld存放在代码段/只读数据段它所在的虚拟页在页表中的权限位被标记为“只读”。当程序试图通过*str H写入时CPU 查页表发现该页权限是只读。写入操作触发保护异常Protection Fault。内核收到异常向进程发送SIGSEGV段错误信号。进程默认行为是终止并打印Segmentation fault。所以常量不能修改不是编译器“拦着你”而是页表权限位 CPU 硬件检查 内核异常处理共同作用的结果。这是一种硬件级别的保护比编译器检查更底层、更可靠。同理代码段也是只读的试图修改函数机器码同样会段错误。3fork 时页表是怎么拷贝的fork()创建子进程时内核会拷贝父进程的 mm_struct子进程获得一份独立的地址空间描述虚拟地址布局完全一样。拷贝父进程的页表子进程得到一张和父进程内容相同的页表。把父子进程的页表项都标记为只读原本可写的页现在两个进程的页表里都变成只读。物理页不拷贝父子进程的页表项暂时指向同一块物理内存。这就是“拷贝页表不拷贝物理页”。4写时拷贝Copy-On-Write的完整过程当子进程或父进程试图写入某个共享页时CPU 查页表发现该页是只读但这次是写操作。触发缺页异常Page Fault内核介入。内核新分配一块物理页把原页内容拷贝过去。修改写操作那一方的页表项物理页帧号指向新页权限位恢复为可写。另一方的页表项保持不变仍指向原物理页权限仍为只读直到它也要写时再触发一次 COW。回到用户态重新执行那条写指令这次写入成功。5回到父子进程同地址不同值的例子intg_val0;intmain(){pid_tidfork();if(id0){g_val100;printf(child[%d]: %d : %p\n,getpid(),g_val,g_val);}else{sleep(3);printf(parent[%d]: %d : %p\n,getpid(),g_val,g_val);}sleep(1);return0;}输出child[3046]: 100 : 0x80497e8 parent[3045]: 0 : 0x80497e8结合页表和写时拷贝解释如下阶段父进程页表子进程页表物理页fork 后0x80497e8 → 物理页 A只读0x80497e8 → 物理页 A只读A 内容为 0子进程写 g_val1000x80497e8 → 物理页 A只读0x80497e8 → 物理页 B可写A0B100父进程读 g_val0x80497e8 → 物理页 A读到 0——子进程读 g_val—0x80497e8 → 物理页 B读到 100—虚拟地址相同都是 0x80497e8但经过各自的页表映射到了不同的物理页所以值不同。6一图总结父进程 子进程 虚拟地址 0x80497e8 虚拟地址 0x80497e8 │ │ ▼ ▼ 父进程页表 子进程页表 ┌─────────────┐ ┌─────────────┐ │ PFN: 页 A │ │ PFN: 页 B │ │ 权限: 只读 │ │ 权限: 可写 │ └─────────────┘ └─────────────┘ │ │ ▼ ▼ 物理页 A0 物理页 B1003.5 分页 虚拟地址空间示意图task_struct → mm_struct → 页表 → 物理内存每个进程有独立的mm_struct描述整个用户空间。mm_struct中的mmap指向 VMA 链表mm_rb指向红黑树。每个 VMA 由vm_area_struct描述记录虚拟区间的起始、结束、标志位等。页表负责将虚拟地址映射到物理地址。3.6 虚拟内存管理描述进程地址空间的结构体是mm_struct内存描述符。每个进程只有一个 mm_struct。task_struct 中有指向 mm_struct 的指针。structtask_struct{structmm_struct*mm;// 用户进程指向用户空间内核线程为 NULLstructmm_struct*active_mm;// 内核线程使用};mm_struct 所在的文件是mm_types.h。structmm_struct{structvm_area_struct*mmap;// 指向虚拟区间VMA链表structrb_rootmm_rb;// 红黑树unsignedlongtask_size;// 虚拟地址空间大小unsignedlongstart_code,end_code,start_data,end_data;unsignedlongstart_brk,brk,start_stack;unsignedlongarg_start,arg_end,env_start,env_end;// ...};虚拟空间的组织方式虚拟区较少时单链表由mmap指向。虚拟区较多时红黑树由mm_rb指向。vm_area_structVMA表示一个独立的虚拟内存区域。一个进程使用多个 vm_area_struct 表示不同类型的虚拟内存区域。structvm_area_struct{unsignedlongvm_start;// 虚存区起始unsignedlongvm_end;// 虚存区结束structvm_area_struct*vm_next,*vm_prev;structrb_nodevm_rb;// 红黑树中的位置structmm_struct*vm_mm;// 所属 mm_structpgprot_tvm_page_prot;unsignedlongvm_flags;// ...structfile*vm_file;// 映射的文件void*vm_private_data;// ...};3.7 更深层的意义进程独立性每个进程有独立的mm_struct和页表互不干扰。内存保护页表由 OS 维护用户无法直接访问物理内存。延迟分配malloc时只在虚拟地址空间申请真正访问才分配物理页。解耦进程管理与内存管理解耦物理内存可任意位置加载。3.8 为什么需要虚拟地址空间如果程序直接操作物理内存会有什么问题问题说明安全风险每个进程可访问任意内存空间木马病毒可随意修改内存地址不确定程序运行时需搬到内存直接使用物理地址无法确定内存使用位置效率低下直接使用物理内存进程作为整体操作内存不够时需整体拷贝到磁盘虚拟地址空间 分页机制解决了这些问题地址空间和页表由 OS 创建并维护所有映射访问都在 OS 监管之下。保护物理内存中的合法数据各进程及内核数据。物理内存可任意位置加载数据进程管理与内存管理解耦。new/malloc时在地址空间申请物理内存可延迟分配。真正访问时才申请内存、构建页表映射延迟分配用户零感知。3.9 页表相关的重要结论页表是每个进程私有的子进程 fork 时拷贝一份之后各自独立修改。页表项三要素物理页帧号、权限位、状态位。权限位是硬件保护的基础常量、代码段只读靠的就是页表权限位 CPU 检查 内核异常。写时拷贝拷贝页表不拷贝物理页写时才拷贝物理页并修改页表权限。虚拟地址相同 ≠ 物理地址相同页表不同映射结果就不同。四、总结知识点核心内容环境变量引入指令可直接执行 → PATH 搜索路径 → 环境变量环境变量全局属性、可继承、export与不export的区别常见变量解析XDG_SESSION_ID、HOSTNAME、TERM、SHELL、HISTSIZE、SSH_CLIENT、SSH_TTY、USER、LD_LIBRARY_PATH、LS_COLORS、MAIL、PATH、PWD、LANG、HISTCONTROL、SHLVL、HOME、LOGNAME、SSH_CONNECTION、LESSOPEN、XDG_RUNTIME_DIR、_、OLDPWD环境变量实验不 export 子进程读不到export 后可继承PATH 决定命令搜索HOME 与~关系程序地址空间代码/数据/堆/栈分布堆向上、栈向下虚拟地址用户看到的所有地址都是虚拟地址页表每个进程私有页表项含物理页帧号、权限位、状态位MMU 查表翻译常量无法修改常量在只读页页表权限位为只读写入触发保护异常内核发送 SIGSEGV父子进程同地址不同值fork 拷贝页表并标记只读写时拷贝新物理页并修改页表虚拟地址相同、物理地址不同虚拟内存管理mm_struct、vm_area_struct、页表映射为什么需要虚拟地址空间安全、地址确定、效率、解耦、延迟分配一句话总结页表是虚拟地址到物理地址的翻译官页表项里的物理页帧号、权限位、状态位决定了地址映射和访问权限。常量不能改是因为它所在页的权限位是只读父子进程同地址不同值是因为 fork 拷贝页表后写时拷贝各自的页表最终指向了不同的物理页。这正是虚拟地址空间最精妙的设计之一。