进程地址空间
概述
Windows进程地址空间和Linux一样采用虚拟地址空间,每个进程有独立的虚拟地址空间,由Windows内核和硬件MMU联合管理- 核心区别在于:
Windows使用PE(Portable Executable)格式代替ELF- 使用
HeapAlloc/VirtualAlloc代替ptmalloc/brk,且32位和64位的地址空间布局差异显著
32-Windows(4GB 总虚拟地址空间)
- 开启
/3GB启动参数 +/LARGEADDRESSAWARE链接选项后- 用户空间可扩至
3GB(0x00000000~0xBFFFFFFF),内核压缩到1GB
- 用户空间可扩至
| 区域 | 地址范围 | 大小 | 说明 |
NULL 禁区 |
0x00000000~0x0000FFFF |
64KB |
不可访问,捕捉空指针解引用 |
| 用户空间 | 0x00010000~0x7FFEFFFF |
2GB |
用户程序可用 |
64KB 隔离带 |
0x7FFF0000~0x7FFFFFFF |
64KB |
用户/内核之间缓冲区 |
| 内核空间 | 0x80000000~0xFFFFFFFF |
2GB |
Ring 0,用户态不可访问 |
64-Windows(理论 256TB 可用虚拟地址空间)
64-Windows用户空间大小(128TB)和64-Linux相同,都源于x86-64 CPU的48位虚拟地址限制
| 区域 | 地址范围 | 大小 | 说明 |
| NULL 禁区 | 0x0000000000000000~0x000000000000FFFF |
64KB |
不可访问 |
| 用户空间 | 0x0000000000010000~0x00007FFFFFFFFFFF |
128TB |
用户程序可用 |
| 规范地址空洞 | 0x0000800000000000~0xFFFF7FFFFFFFFFFF |
不可访问 | x86-64 硬件限制,地址必须符合规范形式 |
| 内核空间 | 0xFFFF800000000000~0xFFFFFFFFFFFFFFFF |
128TB |
Ring 0,用户态不可访问 |
内核空间
- 内核空间是什么
Windows内核空间是进程虚拟地址空间的高地址区域,用户态(Ring 3)无法直接访问。与Linux相同,隔离机制依赖页表中的Supervisor位:- 内核页表项
Supervisor位为1,用户态访问触发访问违规(EXCEPTION_ACCESS_VIOLATION),最终表现为应用崩溃而非系统崩溃
- 里面住着什么
Windows内核(ntoskrnl.exe)的代码和数据:进程管理、内存管理器(Mm)、对象管理器、I/O管理器HAL(Hardware Abstraction Layer,hal.dll):硬件抽象层,屏蔽不同硬件差异- 内核驱动(
*.sys):网卡、磁盘、文件系统驱动加载于此 - 每线程内核栈:线程进入内核态时使用,默认大小
12KB(32位)/24KB(64位),大幅高于Linux的8KB/16KB - 非分页池(
Non-Paged Pool):必须常驻物理内存的内核数据,不可换出到页文件 - 分页池(
Paged Pool):可换出到页文件的内核数据,类似Linux的vmalloc区域 - 系统
PTE区域:内核动态映射用
- 进入内核的唯一合法途径
- 用户代码不能直接跳入内核,只能通过三条受控的门:
- 系统调用(
syscall指令,Win32 层通过ntdll.dll封装,最终调用NtXxx系列函数) - 硬件中断(
IRQ,网卡、键盘、定时器等设备触发) - 异常(缺页、访问违规、除零等
CPU检测到的错误) Windows特有:- 用户态通过
ntdll.dll→syscall进入内核,中间多一层Win32 API(kernel32.dll)→Native API(ntdll.dll)的包装
- 内核崩溃
vs用户崩溃
| 用户程序崩溃 | 内核/驱动崩溃 | |
| 表现 | 弹出"程序已停止工作",WER 收集崩溃转储 |
蓝屏死机(BSOD),系统完全停机 |
| 影响范围 | 只杀死当前进程 | 所有进程死亡,系统重启 |
| 转储文件 | AppCrashView 可查 |
%SystemRoot%\MEMORY.DMP |
栈
- 栈和
Linux机制相同,由CPU的rsp/rbp寄存器管理,LIFO结构,分配一条sub rsp, N指令完成,速度极快 - 关键区别:栈大小
Linux默认栈8MB,进程启动时由OS保留Windows默认栈1MB(由 PE 头SizeOfStackReserve字段指定),可通过链接器/STACK选项或CreateThread参数修改Windows栈采用按需提交机制:1MB是"保留"大小,实际只提交少量页,通过Guard Page机制按需扩展;用完才触发EXCEPTION_STACK_OVERFLOW
Windows特有的Guard Page机制:- 栈底部维护一个
Guard Page - 每次栈增长触碰
Guard Page时,OS自动将其提交为普通页,并在更低地址设置新的Guard Page - 当
Guard Page被耗尽且无法扩展时,抛出STATUS_STACK_OVERFLOW异常
- 栈底部维护一个
- 同样的陷阱:
- 返回局部变量的地址,函数返回后栈帧逻辑销毁(
rsp移回),指针变成悬空指针
- 返回局部变量的地址,函数返回后栈帧逻辑销毁(
|
1 2 3 4 5 |
// 危险:返回局部变量指针 int* bad() { int x = 42; return &x; // x 在栈上,函数返回后随时被覆盖 } |
堆
Windows堆由 堆管理器(ntdll.dll中的RtlHeap)管理,不存在Linux的brk/sbrk概念,全部通过VirtualAlloc向OS申请内存Windows内存的三种状态(Linux没有这个区分):Free:未分配,不占物理内存,也不占页文件Reserved:已预留虚拟地址范围,但不占物理内存(VirtualAlloc+MEM_RESERVE)Committed:已提交,建立了物理内存或页文件的对应关系(VirtualAlloc+MEM_COMMIT)
- 分配路径:
|
1 2 3 4 5 6 7 8 |
new / malloc ↓ HeapAlloc() ← 小块(< ~512KB),走堆管理器,有空闲链表复用 ↓ 堆耗尽时 VirtualAlloc(MEM_COMMIT) ← 向 OS 申请新的提交页 大块直接走: VirtualAlloc(MEM_RESERVE | MEM_COMMIT) ← 大块(通常 >= 512KB),绕过堆管理器 |
- 对应关系
Linux |
Windows |
malloc/free |
HeapAlloc/HeapFree |
new/delete |
new / delete(内部调用 HeapAlloc) |
mmap(MAP_ANON) |
VirtualAlloc(MEM_COMMIT) |
munmap |
VirtualFree(MEM_RELEASE) |
brk/sbrk |
无对应,Windows 不使用 |
- 同样的四种经典问题:
- 内存泄漏:
new没有对应delete double free:delete同一块两次- 悬空指针:
delete后继续访问 - 堆溢出:越界写破坏相邻块头部,下次
HeapAlloc/HeapFree时崩溃
- 内存泄漏:
bss段
- 和
Linux相同:存放未初始化(或初始值为0)的全局变量和静态变量 Windows PE格式的处理方式略有不同:PE中.bss节通常被 合并进.data节,通过节头的SizeOfRawData(文件中实际大小)和VirtualSize(内存中大小)之差来表示未初始化部分VirtualSize > SizeOfRawData的差额由PE加载器自动填零,效果和Linux的.bss完全相同- 也可能单独存在
.bss节,SizeOfRawData = 0,节头只记录VirtualSize
- 同样的结论:
int arr[1000000]不会让可执行文件变大4MB,运行时由加载器或OS填零
|
1 2 3 |
int g2; // .bss 或 .data 末尾填零区域 int arr[1000]; // 同上,文件不增大 4000 字节 int g3 = 0; // 同上,0 值等价于未初始化 |
data段
- 存放有初始值的全局变量和静态变量,和
Linux完全一致 - 初始值存在
PE文件中,加载时由PE加载器原样复制到内存 - 页权限:可读 + 可写,不可执行
|
1 2 3 |
int g = 42; // .data,PE 文件中存着 0x0000002A static int s = 1; // .data,函数内 static 也在这里 char buf[] = "hello"; // .data,数组本身可读写 |
- 和
Linux的唯一区别:PE节名通常就叫.data,与Linux ELF相同Windows不区分.data和.bss节的写保护,两者都是RW权限
rdata段
- 等价于
Linux的.rodataWindows PE格式中只读数据存放在.rdata节(Read-only DATA),Linux ELF中叫.rodata,概念完全相同
OS在页表层面设置写保护,写入触发EXCEPTION_ACCESS_VIOLATION(Windows)vsSIGSEGV(Linux)- 主要内容:
- 字符串字面量:
const char* p = "hello"中"hello"在.rdata,p本身在.data,p[0] = 'H'直接崩溃 vtable:和Linux相同,所有同类对象共享,存放在.rdata防止被篡改const全局变量switch跳转表- 导入表(
Import Address Table,IAT)也可能存于此段
- 字符串字面量:
Windows特有内容- 调试目录(
Debug Directory):指向.pdb调试符号文件路径 - 异常表(
Exception Directory):__try/__except结构化异常处理(SEH)的unwind信息,x64下存放RUNTIME_FUNCTION数组
- 调试目录(
text段
- 存放编译后的机器指令,和
Linux完全相同,权限"可读 + 可执行,不可写" - 主要内容:
- 每个函数的机器码顺序排列
- 普通函数调用:编译器将目标地址硬编码进
call指令(或通过IAT间接调用DLL函数) - 虚函数调用:和 Linux 相同,通过
vptr→vtable(.rdata)→ 函数地址(.text),多了两次内存读取 - 纯虚函数槽位指向
_purecall()(MSVC运行时),调用即abort(),等价于Linux的__cxa_pure_virtual
Windows特有:DLL调用的间接跳转- 调用
MessageBoxA等DLL函数时,.text中生成call [IAT_slot] IAT_slot在.rdata或.idata中,存放DLL函数真实地址DLL加载时由加载器填入实际地址,运行时通过这个间接表调用,这就是Windows DLL调用比直接调用多一次内存读取的原因
- 调用
可执行文件(PE 格式)
概述
Windows使用PE(Portable Executable)格式,对应Linux的ELF格式PE文件包括.exe、.dll、.sys(驱动)等
图示
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
PE 文件布局(磁盘): ┌──────────────────────────────────┐ │ DOS Header(64 字节) │ MZ 签名,历史遗留 │ DOS Stub(小段 DOS 程序) │ "This program cannot be run..." ├──────────────────────────────────┤ │ PE Signature(4 字节) │ 50 45 00 00 = "PE\0\0" ├──────────────────────────────────┤ │ COFF Header(20 字节) │ 机器类型、节数量、时间戳 ├──────────────────────────────────┤ │ Optional Header(96/112字节) │ 入口点、基址、节对齐、数据目录 ├──────────────────────────────────┤ │ 节头表(Section Table) │ 每节 40 字节描述符 ├──────────────────────────────────┤ │ .text 节 │ 机器码 │ .rdata 节 │ 只读数据、vtable、IAT │ .data 节 │ 已初始化全局/静态变量 │ .bss 节(可选,常并入 .data) │ 未初始化数据 │ .pdata 节 │ x64 异常处理表 │ .rsrc 节 │ 资源(图标、对话框、版本信息) │ .reloc 节 │ 重定位信息(ASLR 需要) └──────────────────────────────────┘ |
PE Header
概述
PE Header是可执行文件最开头的元数据区域,是整个文件的"目录",Windows加载器(ntdll!LdrLoadDll)靠它完成加载- 与
ELF header的对应关系:
| ELF | PE | 作用 |
Magic 7F 45 4C 46 |
Magic 4D 5A(MZ)+ 50 45 00 00(PE) |
文件类型识别 |
e_machine |
Machine(COFF Header) |
目标架构 |
e_entry |
AddressOfEntryPoint(Optional Header) |
程序入口 |
e_phoff |
节头表中 LOAD 段信息 |
加载信息 |
e_shoff |
节头表偏移(PointerToSectionHeaders) |
节描述 |
ELF Class(32/64位) |
Magic[4] = PE32 / PE32+ |
位宽 |
DOS Header(历史遗留,20字节关键字段)
OS读取前2字节验证MZ魔数,然后跳到e_lfanew指向的位置读取PE签名
|
1 2 |
偏移 0x00:e_magic = 4D 5A("MZ",Mark Zbikowski,DOS 时代设计者名字缩写) 偏移 0x3C:e_lfanew = PE 签名在文件中的偏移(指向真正的 PE header) |
COFF Header(20字节)
| 字段 | 大小 | 含义 |
Machine |
2字节 |
0x8664 = x86-64,0x014C = x86-32,0xAA64 = ARM64 |
NumberOfSections |
2字节 |
节的数量 |
TimeDateStamp |
4字节 |
编译时间戳(Unix 时间) |
SizeOfOptionalHeader |
2字节 |
Optional Header 大小 |
Characteristics |
2字节 |
文件属性标志(是否 DLL、是否可重定位等) |
Optional Header(最重要的导航字段)
| 字段 | 含义 |
Magic |
0x010B = PE32(32位),0x020B = PE32+(64位) |
AddressOfEntryPoint |
程序入口点 RVA(相对虚拟地址),指向 mainCRTStartup |
ImageBase |
加载基址,默认 .exe = 0x400000,.dll = 0x10000000;ASLR 开启后运行时随机化 |
SectionAlignment |
内存中节的对齐粒度,通常 0x1000(4KB,一页) |
FileAlignment |
文件中节的对齐粒度,通常 0x200(512字节) |
SizeOfImage |
加载到内存后的总大小 |
SizeOfHeaders |
所有头部(DOS + PE + 节头表)的总大小 |
SizeOfStackReserve |
主线程栈预留大小,默认 1MB(0x100000) |
SizeOfStackCommit |
主线程栈初始提交大小,默认 4KB(0x1000) |
SizeOfHeapReserve |
默认堆预留大小,默认 1MB |
DataDirectory[16] |
16个数据目录,指向导入表、导出表、异常表、重定位表等 |
关键 DataDirectory 条目
| 索引 | 名称 | 对应 Linux ELF |
0 |
Export Directory |
.dynsym(导出符号) |
1 |
Import Directory |
NEEDED + PLT/GOT(导入依赖) |
2 |
Resource Directory |
无直接对应(嵌入资源) |
3 |
Exception Directory |
.eh_frame(异常处理) |
5 |
Relocation Directory |
.rel.text(重定位) |
12 |
IAT Directory |
PLT/GOT(函数地址表) |
两张表的职责分工
- 节头表(
Section Table)- 描述每个节在文件中的偏移、在内存中的
RVA、大小、权限(类似ELF的节头表SHT+ 程序头表PHT的结合)
- 描述每个节在文件中的偏移、在内存中的
DataDirectory:- 指向各种特殊数据(导入、导出、异常表等),是 PE 特有的机制,
ELF用专用节名(如.plt、.got)来承担类似职责
- 指向各种特殊数据(导入、导出、异常表等),是 PE 特有的机制,
PE 加载过程(对应 ELF 加载)
Linux的ASLR修正在动态链接器ld.so中完成,Windows在ntdll!LdrLoadDll中完成,流程相似
|
1 2 3 4 5 6 7 8 9 10 11 |
① 读 DOS header,验证 MZ 魔数,跳到 e_lfanew ② 读 PE 签名,验证 "PE\0\0" ③ 读 COFF header,确认 Machine 类型和位宽 ④ 读 Optional Header,获取 ImageBase、入口点、节对齐 ⑤ 按节头表,将各节 mmap 到内存(对应 ELF 的 LOAD 段处理) .text → PAGE_EXECUTE_READ .rdata → PAGE_READONLY .data → PAGE_READWRITE ⑥ 处理重定位(.reloc 节),修正 ASLR 后的地址差 ⑦ 处理导入表(IAT),加载依赖 DLL,填入函数地址 ⑧ 跳转到 AddressOfEntryPoint 执行 CRT 启动代码,最终调用 main() |
一些问题
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Dump分析:调试方法与实践,空指针访问03/15
- ♥ 深度探索C++对象模型一02/09
- ♥ STL_slist08/28
- ♥ C++_静态类型、动态类型03/26
- ♥ C++数据库_Sqlite306/23
- ♥ C++20_第一篇06/30