-
-
[原创]新手向,从调试器视角拆解一个helloworld进程的内存地图
-
发表于: 2025-10-21 18:27 591
-
先说一点题外话,最近写一个C++的数组封装的时候写到这样的一个函数,功能是尾删,执行删除操作时除了逻辑上的长度-1,还进行了析构。这个析构,它仅仅析构了一个元素,如果T是基础类型比如int这样,那其实不太有影响,但是如果T是封装类型,像是Array底下的Array的话,那这时析构就很有用了,而且是必须的。就以Array套Array的例子说,大Array析构了尾巴的一个小Array,通常来讲,如果不管他,小Array的数值应该仍存在内存当中,但此时管理权已从小Array交给大Array,而在大Array中,对象从逻辑上来说已经不存在了,因此这个位置可以由大Array随意分配,因此这里即使内存里数据仍在,也不再视为有小Array的存在,说小Array被析构了。
template<typename T>
void Array<T>::remove_back(){
if(length == 0){
return;
}
data[length - 1].~T(); //析构关键语句
length --;
}
再说一个最近碰到的例子,是一个加载dll的程序,里面有这么一个API:
hDll = LoadLibraryA(DEF_DLL_NAME)
是一个将dll加载到内存的API,会返回当前dll的句柄。为什么说返回句柄就是加载到内存呢,Windows加载dll,首先来说,dll也是个可执行文件,刚开始存在磁盘或者哪里,调用时,加载到内存的虚拟地址,而返回句柄就是加载到内存的具体位置的一个反馈,因此说,返回句柄就是加载内存。
有点怪,句柄就给人一种上面小Array的指针的感觉。而实际上,句柄可以说是操作系统的一种“指针”,只是抽象层面不太一样。
之前我只知道一个可执行文件加载时的过程大致是 磁盘文件 -> 创建进程 -> 加载到虚拟内存 -> (动态链接) -> 执行,但具体的,分层面的东西我将在本篇进行个人学习的经验分享:
学习导入表的时候就知道,有时候dll之间会发生重定位,但exe通常不会发生这种情况,是因为exe在不同的进程。我对进程的理解就是操作系统对内存的一种逻辑的分段管理,包括往上了说,分页、虚拟内存等,往下了说,线程等。个人对分页机制的理解就是把虚拟地址转换为物理地址的过程,虚拟地址由程序在编译链接中形成,而分页机制就是能够将这些虚拟地址转化为硬件能够对应的物理地址上,分页机制里一个重要的东西:页表,就是一个翻译表一样的,用于虚拟地址到物理地址的转换。
这里不具体谈分页机制,主要想讲一下进程方面的。
随便写一个hello world程序并动态调试它,Alt+M打开memory map窗口查看进程。

动态调试看到的是程序运行时进程中各部分的内存映射,从所有者(owner)栏可看到除helloworld.exe以外还有apphelp.dll、ntdll.dll、KernelBase.dll、Kernel32.dll模块,每个模块都有自己的PE头、各种节区(text、data等节区)等,接下来为了弄清楚单个文件磁盘和内存的映射关系,用hex查看器打开查看磁盘内容(或者直接用PE查看器),只分析exe文件。
经整理,这个例子里用到的helloworld.exe的文件结构大概这样:
| 区域 | RVA地址范围(size) | 内存地址范围(size) |
| PE头 | 0x0000 ~ 0x02AC(02AC) | 7E0000 ~ 7E1000(1000) |
| text节区 | 0x1000 ~ 0x1E3F0(1D3F0) | 7E1000 ~ 7FF000(1E000) |
| rdata节区 | 0x1F000 ~ 0x2A9F0(B9F0) | 7FF000 ~ 80B000(C000) |
| data节区 | 0x2B000 ~ 0x2BFF0(FF0) | 80B000 ~ 80D000(2000) |
| fptable节区 | 0x2D000 ~ 0x2D1F0(1F0) | 80D000 ~ 80E000(1000) |
| reloc节区 | 0x2E000 ~ 0x2FBF0(1BF0) | 80E000 ~ 810000(2000) |
这里能够直接的看到各地址的范围和所占空间大小,以及他们的对齐粒度,可以看见,在磁盘中,每个区域的地址之间有“空洞”(如PE头的末尾02AC并不是text节区的开头1000)。而在内存中,文件中的每个区域(PE头,节区等)被对齐,但文件或者说模块间又存在“空洞”。拿图中810000 ~ 845000地址区域举例来说,调试器没有显示,说明该段地址空间未被映射到当前进程或者处于完全空闲状态,也就是模块间看到的空洞。
说完单个文件映射,进程的大致映射理论框架大致为:
0x00000000 - 0x0000FFFF: 空指针保护区 (固定)
0x00010000 - 0x7FFDFFFF: 用户模式区 (主要区域)
0x7FFE0000 - 0x7FFE0FFF: 共享用户数据页 (固定)
0x7FFE1000 - 0x7FFE1FFF: 进程环境块PEB (固定)
0x7FFE2000 - 0x7FFE2FFF: 线程环境块TEB (固定)
0x7FFD0000 - 0x7FFDFFFF: 主线程栈 (通常在这个区域)
0x7FFF0000 - 0x7FFFFFFF: 系统保留区 (固定)
0x80000000 - 0xFFFFFFFF: 内核模式区 (固定)
这里的地址是虚拟地址,而调试时看到的是ASLR后的随机地址,有一点就是,随机化后的地址的布局并不原始的虚拟地址布局,但随机化地址将保留基本分区概念。可以关闭ASLR后重新查看:

这个图片是win10操作环境下的进程布局,和经典的还是有点差异,虽然说,操作系统通过进程来管理程序,但是进程也不是像数组一样单个单个的,而是有自己独立的区域和公用部分(如空指针保护区、系统级dll等),但是管理上就有一点像上面那个大Array套小Array的方法。
虽然进程布局在实际情况中并不那么固定,但是通过type栏可以大致清楚各个模块的定位:
Map - 映射,内存映射文件或共享内存,可以对应磁盘文件,但也能是纯内存共享,涉及进程间通信和文件IO。个人理解是进程间的公用部分,从这里调试就可能看到与其他进程共享的数据。
Priv - 进程私有的内存区域,包含堆、栈、动态分配内存、线程的TSL数据,这些区域其他进程无法访问,且生命周期与进程有关,通常是动态创建。
Imag - 从磁盘文件映射到内存的区域,类似于上面分析的exe区域,在磁盘中能找到相应文件,一般都是exe、dll这一类的。
接下来说线程,线程和进程类似,这个例子里比较简单,只有一个主线程,每个线程仅有一个栈,所以可以通过栈来寻找线程,在图中标记有stack of main thread的地方就是主线程。线程之间共享全局变量、堆内存、代码段、文件句柄等,通过读写共享的内存区域来进行通信。
平时调试说的挂起就是暂停进程或者线程的执行,调试器单步执行也可以说是一直挂起不是吗……
一点个人学习笔记,边写边学了……欢迎交流、指正。
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!