CPP应用-外挂开发
- 开发一个外挂程序的步骤
- 外挂界面
- 事件处理
- 监听外挂界面上面的按钮事件等
- 跨进程访问
- 用于控制实际的应用
外挂界面
- Windows平台的桌面开发
- 使用C++开发
- MFC、Qt
- 使用C#开发
- WinForm、WPF
- 使用C++开发
- 本节使用最古老的MFC,不用引入其他外部的框架
- VS要先安装好MFC组件
- 创建MFC项目
- 打开vs新建项目->visual C++ -> MFC应用程序 ->选择路径、命名->下一步
- 应用程序类型选择:基于对话框 ,然后点击完成即可
- 此时点击f5运行程序就出现一个简单的弹框界面应用程序
- 搭建界面
- 找到界面(就相当于iOS的xib界面)
- 顶部工具栏->视图->其他窗口->资源视图->项目名文件夹下->dialog->IDD_项目名大写_DIALOG双击打开
- 修改左上角的logo
- 右击项目文件->打开文件夹->res->换掉里面的ico图片就行了
- 修改窗口标题
- 右击界面->属性->外观->caption(输入标题即可)
- 增删界面控件
- 删除:点击控件,点击delete即可
- 添加:
- 视图->工具箱->里面有很多控件
- 拖拽到界面即可
- 右击控件属性,选择外观,可以设置样式
- 监听事件(相当于iOS中的xib脱线成方法)
- 事件注册 – 手动
- 给控件设置唯一ID
- 右击控件属性->杂项->ID->右边重命名(ID_COURSE)
- 点击原来项目->头文件->Resource.h
- 在这文件中可以看到一堆宏定义,其中就有(ID_COURSE)
- 声明、实现事件函数
- 凡是DIALOG界面的控件,绑定代码都在源文件的:项目名Dlg.cpp中写
- 首先在:”项目名Dlg.h“ 中新增函数声明
- 在”项目名Dlg.cpp“中新增函数实现
-
将控件与事件函数通过控件ID绑定
//消息映射开始 BEGIN_MESSAGE_MAP(CPVZCheaperDlg, CDialogEx) ON_WM_SYSCOMMAND() ON_WM_PAINT() ON_WM_QUERYDRAGICON() //ON_BN_CLICKED:按钮单击,参数:id按钮的,对应的函数 ON_BN_CLICKED(ID_COURSE, CPVZCheaperDlg::OnBnClickedCourse) //消息隐射结束 END_MESSAGE_MAP()
- 如下图:
- 给控件设置唯一ID
- 事件注册 – 自动(也可以直接双击控件)
- 给控件设置唯一ID(同手动)
- 声明、实现事件函数/将控件与事件函数通过控件ID绑定–自动方式生成
- 右击控件-> 添加事件处理程序->选择消息类型(点击、长按等)/修改函数处理程序名称->点击添加编辑即可 (直接双击控件等价于当前所有操作)
- 注意:手动监听控件的第2、3步,都在这里自动实现了
- 事件注册 – 手动
- 找到界面(就相当于iOS的xib界面)
- 绑定成员变量(相当于iOS中XIB中的控件脱线成成员变量)
- 绑定成员变量 – 手动
- 给控件设置唯一ID(同监听事件)
- 声明成员变量
- 项目名Dlg.h中声明一个成员变量
CButton m_kill;
- 项目名Dlg.h中声明一个成员变量
- 将成员变量与控件通过ID绑定
-
项目名Dlg.cpp中DoDataExchange函数中进行绑定
DDX_Control(pDX, IDC_KILL, m_kill);
-
- 绑定成员变量 – 自动
- 给控件设置唯一ID(同手动)
- 右击控件->添加变量->设置控件的属性(名称、权限等)->点击下一步即可(手动绑定成员变量的第2、3步,都在这里自动实现了)
- 绑定成员变量 – 手动
- MFC开发中的打印
-
TRACE函数:类似于C语言的printf,只能在DEBUG调试模式下看到打印信息(F5启动)
TRACE("age is %d\n",20);
-
AfxMessageBox函数(弹框提示)
CString str; str.Format(CString("age is %d"),20); AfxMessageBox(str);
-
MessageBox函数:只在CWnd的子类中使用,功能比AfxMessageBox多
CString str; str.Format(CString("age is %d"),20); MessageBox(str); MessageBox(str,CString("错误"),MB_YESNO|MB_ICONERROR);
-
自定义log宏,简化打印
# define log(fmt,...)\ CString str; \ str.Format(CString(fmt),_VA_ARGS_);\ AfxMessageBox(str); //使用 log("age is %d\n",20);
-
- 常用方法
-
打开URL
ShellExecute(NULL, CString("open"), CString("www.baidu.com"), NULL, NULL, SW_SHOWNORMAL);
-
单选框的状态读取和修改
-
方法1:
//读取,参数为单选框控件的ID BOOL state = IsDlgButtonChecked(IDC_CHOSE1) //修改 CheckDlgButton(IDC_CHOSE1, true);
-
方法2;
CButton *button = (CButton *)GetDlgItem(IDC_CHOSE1); //读取 BOOL state = button->GetCheck(); //修改 button->SetCheck(true);
-
方法3:
- 将按钮设置为成员变量,然后通过成员变量获取/设置
//读取 BOOL state = this->m_kill.GetCheck(); //修改 this->m_kill.SetCheck(true);
-
-
软件破解
- 请查看汇编部分最后一节:软件破解
植物大战僵尸外挂
- 外挂常用工具:
- Cheat Engine
- 安装:解压之后双击文件夹中的Cheat Engine.exe运行
- 作用:
- 点击左上角的电脑+放大镜图标,会显示当前PC机的所有应用进程
- 选择一个应用进程,点击打开
- 右边的输入框Hex是用于搜索的值(还有其他的筛选条件等),点击扫描,就可以搜索出来当前进程中那个地址内存存储到了当前值。
- 主要作用就是监控某一个进程的内存,根据某个索引找到对应进程中的内存地址,然后双击相应的内存地址就可以修改内存中的数据。
- 找到内存地址了,就可以修改内存的数据,达到破解的目的。
- Cheat Engine
- 外挂的本质
- 常见的外挂功能有2种做法
- 修改内存中的数据
- 数据的话,变量地址值可以是固定的,也可以是变化的
- 如果是全局变量,那么他的地址值就是固定的,这就比较容易找
- 如果是局部变量,那么他的地址值就是变化的,这就比较难找(通过基地址+偏移量定位)
- 修改内存中的代码
- 比较容易一些,因为代码段的内存地址在进程虚拟内存中是固定的。每局代码的内存地址在exe文件中都已经固定了。
- 修改内存中的数据
- 其实数据和代码并没有本质区别, 在内存中都是0和1
- 常见的外挂功能有2种做法
植物大战僵尸详细破解步骤
- 工具
- OD(OllyDbg)
- 用来剖析exe文件,形成汇编
- CE(Cheat Engine)
- 用来定位想要破解的东西的内存地址
- 植物大战僵尸程序exe
- 要破解的对象
- VS
- 通过MFC来编写外挂Windows应用程序界面
- 通过Windows API来跨进程修改目标应用程序的内存地址
- OD(OllyDbg)
- 实现功能:
- 外挂界面监控游戏的打开关闭
- 无限阳光
- 秒杀僵尸
- 破解步骤(这里只讲解秒杀僵尸、外挂界面监控)
- 获取减少僵尸生命值的汇编代码对应的内存地址
- 通过CE定位,获取僵尸生命值的内存地址
- 打开CE运行起来
- 打开植物大战僵尸游戏
- 点击CE左上角(放大镜+电脑)选择植物大战僵尸应用进程
- 切换至游戏,等待僵尸出来
- 切换CE条件选择“未知的初始值”,首次扫描
- 切换至游戏,然后在切换至CE,条件选择“未变化的值”,再次扫描
- 切换游戏,用豌豆打僵尸一枪,切换至CE,条件选择“减少的数值”,再次扫描
- 循环6,7,就可以定位到僵尸生命值那个内存地址了
- 双击左边的地址,在下面的内存浏览(双击数值)就能修改这些值
- 在内存浏览中右击地址,选择”找出是什么改写了这个地址“
- 切换到游戏,一旦打中僵尸,就可以看到是那句汇编代码起到了这个作用
- 根据这个汇编代码的地址值,比如:
00531319
- 根据CE定位的内存地址,在OD中寻找相应的汇编代码
- 运行OD,将植物大战僵尸exe拖入OD
- control+G搜索这个地址值,就能定位到那句汇编代码
-
前两步定位的结果如下:
//1. CE中根据内存地址00531319定位到的汇编代码 00531313 - 89 44 24 1C - mov [esp+1C],eax 00531317 - 8B C5 - mov eax,ebp 00531319 - 89 BD C8000000 - mov [ebp+000000C8],edi << 0053131F - E8 ECC3FFFF - call PlantsVsZombies.exe+12D710 00531324 - 8B D8 - mov ebx,eax //2. 通过00531319在OD中 搜索定位到的代码 //地址 16进制源码 对应的汇编代码 //这一句是sub,是每打一次,僵尸的生命值减少 0053130F |. 2B7C24 20 sub edi,dword ptr ss:[esp+0x20] 00531313 |. 894424 1C mov dword ptr ss:[esp+0x1C],eax ; kernel32.BaseThreadInitThunk 00531317 |. 8BC5 mov eax,ebp //定位:这一句是赋值僵尸的生命值 00531319 |. 89BD C8000000 mov dword ptr ss:[ebp+0xC8],edi //3. 因此分析,只要将地址0053130F是这个的汇编代码换成如下即可 // sub edi,edi //4. 在OD中双击这句汇编,然后修改为sub edi,edi,然后这段汇编变成如下: 0053130F 2BFF sub edi,edi 00531311 90 nop 00531312 90 nop 00531313 |. 894424 1C mov dword ptr ss:[esp+0x1C],eax ; kernel32.BaseThreadInitThunk 00531317 |. 8BC5 mov eax,ebp 00531319 |. 89BD C8000000 mov dword ptr ss:[ebp+0xC8],edi //因此分析,只要将内存中的源码由原来的2B7C2420改为2BFF9090即可
- 通过CE定位,获取僵尸生命值的内存地址
- 通过VS编写windows 外挂程序
-
下面是需要写的代码
PVZCheaperDlg.cpp: 实现文件 //指向对话框的指针 static CPVZCheaperDlg *g_dlg = NULL; // 用来监控的线程 static HANDLE g_monitorThead = NULL; //植物大战僵尸的进程句柄 static HANDLE g_process = NULL; /* 写内存 */ void WriteMemory(void *value, DWORD valueSize, ...) { if (value == NULL || valueSize == 0 || g_process == NULL) return; DWORD tempValue = 0; va_list addresses; va_start(addresses, valueSize); DWORD offset = 0; DWORD lastAddress = 0; //根据0x6A9EC0, 0x320, 0x8, 0x0, 0x8, 0x144, 0x2c, 0x5560, -1这些数据遍历便宜找到阳光地址值 while ((offset = va_arg(addresses, DWORD)) != -1) { //加上偏移量 lastAddress = tempValue + offset; //取地址对应的值 ReadProcessMemory(g_process, (LPCVOID)lastAddress, &tempValue, sizeof(DWORD), NULL); } va_end(addresses); //写入到内存,参数:进程句柄、要写入到的内存地址、值、值有多大 //往哪个应用进程的内存中写入哪些数据 WriteProcessMemory(g_process, (LPVOID)lastAddress, value, valueSize, NULL); } /* value: 要写入数据的内存地址 valueSize:数据有多大 address:内存地址 */ void WriteMemory(void *value, DWORD valueSize, DWORD address) { WriteMemory(value, valueSize, address, -1); } //用来监控线程 DWORD WINAPI monitorThreadFunc(LPVOID lpThreadParameter) { while (true) { //1. 监控植物大战僵尸是否打开、关闭 //该函数用户发现当前桌面是否有某个窗口,参数有2个,类名、窗口名 /* 如何知道某个窗口的类名和窗口名呢? 通过Spy++ VS->工具->spy++->点击“望远镜+文本”图标->鼠标按住查找程序工具右边的靶子,然后移动鼠标到你 想要知道的窗口即可以找到类和窗口名称 */ HWND window = FindWindow(CString("MainWindow"),CString("植物大战僵尸中文版")); if (!window) { //没有打开植物大战僵尸窗口 //2个勾选按钮失效,而且取消勾选 g_dlg->m_kill.EnableWindow(false); g_dlg->m_sun.EnableWindow(false); g_dlg->m_kill.SetCheck(false); g_dlg->m_sun.SetCheck(false); } else if(!g_process)//只需要获取一次植物大战僵尸的进程句柄 { // 获取植物大战僵尸的进程句柄 //进程id DWORD pid = NULL; //通过一个窗口获取当前进程的id GetWindowThreadProcessId(window, &pid); //根据id获取进程的句柄(用于操作进程) g_process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid); //打开了植物大战僵尸窗口 //2个勾选按钮生效 g_dlg->m_kill.EnableWindow(true); g_dlg->m_sun.EnableWindow(true); } // 每隔1s修改一次阳光值,并且是在勾选无限阳光的基础下才可以 if (g_dlg->m_sun.GetCheck()) { /* 1. 阳光值是一个数据段,如果这个数据段的变量是全局变量,那么整个内存中的内存地址是不变的。但是,如果是局部变量,每次重新分配,地址值不一样,所以无法固定 2. 如何才能定位到阳光值呢? 3. 我们不能定位到阳光值,但是我们可以定位到阳光对象,然后通过对象的偏移量就可以找到阳光值 就是这个 0x6A9EC0, 0x320, 0x8, 0x0, 0x8, 0x144, 0x2c, 0x5560, -1 0x6A9EC0是基址 -1代表结束 其他的是偏移量 根据基址0x6A9EC0取值,然后加上偏移量0x320,取值,在加偏移量0x8,。。。,直到遇到-1停止,就是阳光的地址值 */ DWORD value = 9990; WriteMemory(&value, sizeof(value), 0x6A9EC0, 0x320, 0x8, 0x0, 0x8, 0x144, 0x2c, 0x5560, -1); } Sleep(1000); } return 0; } // CPVZCheaperDlg 消息处理程序 //当界面初始化完毕时回调用这个方法相当于OC中的ViewDidload BOOL CPVZCheaperDlg::OnInitDialog() { CDialogEx::OnInitDialog(); //系统默认的。。。。略 // TODO: 在此添加额外的初始化代码 //保存当前对话框 g_dlg = this; //创建一个子线程用于:1. 间隔性的自动修改阳光值,使阳光值永远用不完 2.实时监控植物大战僵尸的程序开关状态 //HANDLE:句柄 //设置成全局变量 //HANDLE monitorThead = CreateThread(NULL,0,monitorThreadFunc,NULL,0,NULL); g_monitorThead = CreateThread(NULL, 0, monitorThreadFunc, NULL, 0, NULL); //终止线程 //TerminateThread(g_monitorThead, 0); return TRUE; // 除非将焦点设置到控件,否则返回 TRUE } //点击打开一个网页 void CPVZCheaperDlg::OnBnClickedCourse() { /*TRACE("======"); CString str; str.Format(CString("age is %d"),20); AfxMessageBox(str);*/ ShellExecute(NULL, CString("open"), CString("www.baidu.com"), NULL, NULL, SW_SHOWNORMAL); //BOOL state = this->m_kill.GetCheck(); //this->m_kill.SetCheck(true); } //点击是否需要无限阳光 void CPVZCheaperDlg::OnBnClickedCheck1() { // TODO: 在此添加控件通知处理程序代码 //获取到当前按钮的选择状态 //IsDlgButtonChecked(IDC_CHOSE1) if (IsDlgButtonChecked(IDC_CHOSE1)){ //勾选 //AfxMessageBox(CString("需要无限阳光")); }else{ //没有勾选 //AfxMessageBox(CString("不需要无限阳光")); } } //点击是否需要秒杀僵尸 void CPVZCheaperDlg::OnBnClickedKill() { // TODO: 在此添加控件通知处理程序代码 //将植物大战僵尸的汇编代码改掉 /* 问题来了,我需要知道植物大战僵尸的打击僵尸的汇编代码的内存地址 这里注意了,每一个平台的应用程序,他的代码段运行到操作系统时,他的内存地址是固定不变的。在PE文件 里面就已经固定下来了 1. 通过Cheat Engine找到僵尸的生命值 2. 通过OD减少僵尸生命值的汇编代码地址 游戏之前的: 地址 16进制 对应的汇编代码(每一句二进制对应一句汇编代码) 0053130F 2B7C2420 sub edi,dword ptr ss:[esp+0x20] //秒杀替换 0053130F 2BFF9090 sub edi,edi 修改方法1: 直接将整句汇编代码换掉:即2B7C2420换成 2BFF9090即可 */ //修改方法 DWORD address = 0x53130F; if (this->m_kill.GetCheck()) { //勾选 //AfxMessageBox(CString("需要秒杀僵尸")); //修改方法 BYTE value[] = { 0x2B, 0xFF, 0x90, 0x90 }; WriteMemory(value, sizeof(value), address); } else { //没有勾选 //AfxMessageBox(CString("不需要秒杀僵尸")); BYTE value[] = { 0x2B, 0x7C, 0x24, 0x20 }; WriteMemory(value, sizeof(value), address); } }
-
- 获取减少僵尸生命值的汇编代码对应的内存地址
外挂疑惑
- 我们通过CE、OD找到了相应的汇编代码的内存地址,然后通过VS编写C++代码往这个内存地址中写二进制代码从而替换掉原来的汇编代码,达到外挂实现的目的
- 那么问题来了,这个内存地址是固定不变的吗? 每次运行都是这个内存地址? 在不同的电脑上也是这个地址? 但是是固定的
- 每一个可执行文件在编译成PE文件之后,他们的内存地址就已经固定下来了。
- 因此,不同的应用进程,可以有相同的内存地址:
- 应用程序的指针访问的是虚拟地址,所以两个不同的进程,可以访问相同的地址,但是每个进程的这个虚拟地址,被操作系统放在不同的物理地址上,互相没有任何关系。
- MMU把相同的虚拟地址映射到不同的物理地址
- 在windows系统中你所看到的所有地址都是虚拟地址。每个进程都有完全独立的4GB虚拟地址空间,A进程的0x00300000被映射到物理页面1上,B进程的0x00300000被映射到物理页面2上。虽然他们的虚拟地址相同,但是被映射的物理页面时完全不同的。
进程地址空间与虚拟存储空间
早期的内存分配机制
- 在早期的计算机中,要运行一个程序,会把这些程序全都装入内存,程序都是直接运行在内存上的,也就是说程序中访问的内存地址都是实际的物理内存地址。当计算机同时运行多个程序时,必须保证这些程序用到的内存总量要小于计算机实际物理内存的大小。
- 那当程序同时运行多个程序时,操作系统是如何为这些程序分配内存 的呢?下面通过实例来说明当时的内存分配方法:
-
某台计算机总的内存大小是 128M ,现在同时运行两个程序 A 和 B , A 需占用内存 10M , B 需占用内存 110 。计算机在给程序分配内存时会采取这样的方法:先将内存中的前 10M 分配给程序 A ,接着再从内存中剩余的 118M 中划分出 110M 分配给程序 B 。这种分配方法可以保证程序 A 和程序 B 都能运行,但是这种简单的内存分配策略问题很多。
-
- 这种分配造成的问题如下:
- 问题 1 : 进程地址空间不隔离。由于程序都是直接访问物理内存,所以恶意程序可以随意修改别的进程的内存数据,以达到破坏的目的。有些非恶意的,但是有 bug 的程序也可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。这种情况对用户来说是无法容忍的,因为用户希望使用计算机的时候,其中一个任务失败了,至少不能影响其它的任务。
- 问题 2 : 内存使用效率低。在 A 和 B 都运行的情况下,如果用户又运行了程序 C,而程序 C 需要 20M 大小的内存才能运行,而此时系统只剩下 8M 的空间可供使用,所以此时系统必须在已运行的程序中选择一个将该程序的数据暂时拷贝到硬盘上,释放出部分空间来供程序 C 使用,然后再将程序 C 的数据全部装入内存中运行。可以想象得到,在这个过程中,有大量的数据在装入装出,导致效率十分低下。
- 问题 3 : 程序运行的地址不确定。当内存中的剩余空间可以满足程序 C 的要求后,操作系统会在剩余空间中随机分配一段连续的 20M 大小的空间给程序 C 使用,因为是随机分配的,所以程序运行的地址是不确定的。
分段
- 为了解决上述问题,人们想到了一种变通的方法,就是增加一个中间层,利用一种间接的地址访问方法访问物理内存。按照这种方法,程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。
- 当创建一个进程时,操作系统会为该进程分配一个 4GB 大小的虚拟进程地址空间。
- 之所以是 4GB ,是因为在 32 位的操作系统中,一个指针长度是 4 字节,而 4 字节指针的寻址能力是从 0x00000000~0xFFFFFFFF,最大值 0xFFFFFFFF 表示的即为 4GB 大小的容量。
- 与虚拟地址空间相对的,还有一个物理地址空间,这个地址空间对应的是真实的物理内存。
- 如果你的计算机上安装了 512M 大小的内存,那么这个物理地址空间表示的范围是 0x00000000~0x1FFFFFFF 。当操作系统做虚拟地址到物理地址映射时,只能映射到这一范围,操作系统也只会映射到这一范围。
- 当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。
- 那是不是这4GB的虚拟地址空间应用程序可以随意使用呢?很遗憾,在 Windows 系统下,这个虚拟地址空间被分成了4部分: NULL 指针区、用户区、 64KB 禁入区、内核区。
- NULL指针区 (0x00000000~0x0000FFFF): 如果进程中的一个线程试图操作这个分区中的数据,CPU就会引发非法访问。他的作用是,调用 malloc 等内存分配函数时,如果无法找到足够的内存空间,它将返回 NULL。而不进行安全性检查。它只是假设地址分配成功,并开始访问内存地址 0x00000000(NULL)。由于禁止访问内存的这个分区,因此会发生非法访问现象,并终止这个进程的运行。
- 用户模式分区 ( 0x00010000~0xBFFEFFFF):这个分区中存放进程的私有地址空间。一个进程无法以任何方式访问另外一个进程驻留在这个分区中的数据(相同 exe,通过 copy-on-write 来完成地址隔离)。(在windows中,所有 .exe 和动态链接库都载入到这一区域。系统同时会把该进程可以访问的所有内存映射文件映射到这一分区)。
- 隔离区 (0xBFFF0000~0xBFFFFFFF):这个分区禁止进入。任何试图访问这个内存分区的操作都是违规的。微软保留这块分区的目的是为了简化操作系统的现实。
- 内核区 (0xC0000000~0xFFFFFFFF):这个分区存放操作系统驻留的代码。线程调度、内存管理、文件系统支持、网络支持和所有设备驱动程序代码都在这个分区加载。这个分区被所有进程共享。
- 应用程序能使用的只是用户区而已,大约 2GB 左右 ( 最大可以调整到 3GB) 。内核区为 2GB ,内核区保存的是系统线程调度、内存管理、设备驱动等数据,这部分数据供所有的进程共享,但应用程序是不能直接访问的。
- 人们之所以要创建一个虚拟地址空间,目的是为了解决进程地址空间隔离的问题。 但程序要想执行,必须运行在真实的内存上,所以,必须在虚拟地址与物理地址间建立一种映射关系。这样,通过映射机制,当程序访问虚拟地址空间上的某个地址值时,就相当于访问了物理地址空间中的另一个值。人们想到了一种分段(Sagmentation)的方法,它的思想是在虚拟地址空间和物理地址空间之间做一一映射。比如说虚拟地址空间中某个 10M 大小的空间映射到物理地址空间中某个 10M 大小的空间。这种思想理解起来并不难,操作系统保证不同进程的地址空间被映射到物理地址空间中不同的区域上,这样每个进程最终访问到的。
- 物理地址空间都是彼此分开的。通过这种方式,就实现了进程间的地址隔离。
-
还是以实例说明,假设有两个进程 A 和 B ,进程 A 所需内存大小为 10M ,其虚拟地址空间分布在 0x00000000 到 0x00A00000 ,进程 B 所需内存为 100M ,其虚拟地址空间分布为 0x00000000 到 0x06400000 。那么按照分段的映射方法,进程 A 在物理内存上映射区域为 0x00100000 到 0x00B00000 ,,进程 B 在物理内存上映射区域为0x00C00000 到 0x07000000 。于是进程 A 和进程 B 分别被映射到了不同的内存区间,彼此互不重叠,实现了地址隔离。从应用程序的角度看来,进程 A 的地址空间就是分布在 0x00000000 到 0x00A00000 ,在做开发时,开发人员只需访问这段区间上的地址即可。应用程序并不关心进程 A 究竟被映射到物理内存的那块区域上了,所以程序的运行地址也就是相当于说是确定的了。 下图显示的是分段方式的内存映射方法:
-
- 这种分段的映射方法虽然解决了上述中的问题一和问题三,但并没能解决问题二,即内存的使用效率问题。
- 在分段的映射方法中,每次换入换出内存的都是整个程序, 这样会造成大量的磁盘访问操作,导致效率低下。所以这种映射方法还是稍显粗糙,粒度比较大。实际上,程序的运行有局部性特点,在某个时间段内,程序只是访问程序的一小部分数据,也就是说,程序的大部分数据在一个时间段内都不会被用到。基于这种情况,人们想到了粒度更小的内存分割和映射方法,这种方法就是分页 (Paging)。
分页
- 分页的基本方法是,将地址空间分成许多的页。每页的大小由 CPU 决定,然后由操作系统选择页的大小。目前 Inter 系列的 CPU 支持 4KB 或 4MB 的页大小,而 PC上目前都选择使用 4KB 。按这种选择, 4GB 虚拟地址空间共可以分成 1048576 页, 512M 的物理内存可以分为 131072 个页。显然虚拟空间的页数要比物理空间的页数多得多.
- 在分段的方法中,每次程序运行时总是把程序全部装入内存,而分页的方法则有所不同。
- 分页的思想是程序运行时用到哪页就为哪页分配内存,没用到的页暂时保留在硬盘上。
- 当用到这些页时再在物理地址空间中为这些页分配内存,然后建立虚拟地址空间中的页和刚分配的物理内存页间的映射。
- 下面通过介绍一个可执行文件的装载过程来说明分页机制的实现方法。
- 一个可执行文件 (PE 文件 ) 其实就是一些编译链接好的数据和指令的集合,它也会被分成很多页,在 PE 文件执行的过程中,它往内存中装载的单位就是页。
- 当一个 PE 文件被执行时,操作系统会先为该程序创建一个 4GB 的进程虚拟地址空间。
- 前面介绍过,虚拟地址空间只是一个中间层而已,它的功能是利用一种映射机制将虚拟地址空间映射到物理地址空间,所以,创建 4GB 虚拟地址空间其实并不是要真的创建空间,只是要创建那种映射机制所需要的数据结构而已,这种数据结构就是页目和页表。
- 当创建完虚拟地址空间所需要的数据结构后,进程开始读取 PE 文件的第一页。
- 在PE 文件的第一页包含了 PE 文件头和段表等信息,进程根据文件头和段表等信息,将 PE 文件中所有的段一一映射到虚拟地址空间中相应的页 (PE 文件中的段的长度都是页长的整数倍 ) 。这时 PE 文件的真正指令和数据还没有被装入内存中,操作系统只是据 PE 文件的头部等信息建立了 PE 文件和进程虚拟地址空间中页的映射关系而已。
- 当 CPU 要访问程序中用到的某个虚拟地址时,当 CPU 发现该地址并没有相相关联的物理地址时, CPU 认为该虚拟地址所在的页面是个空页面, CPU 会认为这是个页错误 (Page Fault) , CPU 也就知道了操作系统还未给该 PE 页面分配内存,CPU 会将控制权交还给操作系统。操作系统于是为该 PE 页面在物理空间中分配一个页面,然后再将这个物理页面与虚拟空间中的虚拟页面映射起来,然后将控制权再还给进程,进程从刚才发生页错误的位置重新开始执行。
- 由于此时已为 PE 文件的那个页面分配了内存,所以就不会发生页错误了。
- 随着程序的执行,页错误会不断地产生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。
- 分页方法的核心思想就是当可执行文件执行到第 x 页时,就为第 x 页分配一个内存页 y ,然后再将这个内存页添加到进程虚拟地址空间的映射表中 , 这个映射表就相当于一个 y=f(x) 函数。应用程序通过这个映射表就可以访问到 x 页关联的 y 页了。
逻辑地址、线性地址、物理地址和虚拟地址的区别
- 逻辑地址(Logical Address)
- 是指由程式产生的和段相关的偏移地址部分。例如,你在进行 C 语言指针编程中,能读取指针变量本身值( &操作 ),实际上这个值就是逻辑地址,他是相对于你当前进程数据段的地址,不和绝对物理地址相干。只有在 Intel 实模式下,逻辑地址才和物理地址相等(因为实模式没有分段或分页机制,cpu不进行自动地址转换);逻辑也就是在Intel保护模式下程式执行代码段限长内的偏移地址(假定代码段、数据段如果完全相同)。应用程式员仅需和逻辑地址打交道,而分段和分页机制对你来说是完全透明的,仅由系统编程人员涉及。应用程式员虽然自己能直接操作内存,那也只能在操作系统给你分配的内存段操作。
- 线性地址(Linear Address)
- 是逻辑地址到物理地址变换之间的中间层。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址。如果启用了分页机制,那么线性地址能再经变换以产生一个物理地址。若没有启用分页机制,那么线性地址直接就是物理地址。Intel 80386 的线性地址空间容量为 4G(2的32次方即32根地址总线寻址)。
- 物理地址(Physical Address)
- 是指出目前 CPU 外部地址总线上的寻址物理内存的地址信号,是地址变换的最终结果地址。如果启用了分页机制,那么线性地址会使用页目录和页表中的项变换成物理地址。如果没有启用分页机制,那么线性地址就直接成为物理地址了。
- 虚拟内存(Virtual Memory)
- 是指计算机呈现出要比实际拥有的内存大得多的内存量。
- 因此他允许程式员编制并运行比实际系统拥有的内存大得多的程式。这使得许多大型项目也能够在具有有限内存资源的系统上实现。
- 一个非常恰当的比喻是:你不必非常长的轨道就能让一列火车从上海开到北京。你只需要足够长的铁轨(比如说3公里)就能完成这个任务。采取的方法是把后面的铁轨即时铺到火车的前面,只要你的操作足够快并能满足需求,列车就能象在一条完整的轨道上运行。这也就是虚拟内存管理需要完成的任务。
- 在 Linux0.11 内核中,给每个程式(进程)都划分了总容量为 64MB 的虚拟内存空间。因此程式的逻辑地址范围是 0x0000000 到 0x4000000。有时我们也把逻辑地址称为 虚拟地址。因为和虚拟内存空间的概念类似,逻辑地址也是和实际物理内存容量无关的。逻辑地址和物理地址的“差距”是 0xC0000000,是由于虚拟地址->线性地址->物理地址映射正好差这个值。这个值是由操作系统指定的。机理逻辑地址(或称为虚拟地址)到线性地址是由CPU的段机制自动转换的。如果没有开启分页管理,则线性地址就是物理地址。如果开启了分页管理,那么系统程式需要参和线性地址到物理地址的转换过程。具体是通过设置页目录表和页表项进行的。
总结
- 操作系统在管理内存时,每个进程都有一个独立的进程地址空间,进程地址空间的地址为虚拟地址,对于32位操作系统,该虚拟地址空间为2^32=4GB。
- 进程在执行的时候,看到和使用的内存地址都是虚拟地址,而操作系统通过MMU部件将进程使用的虚拟地址转换为物理地址。
- 进程地址空间中分为各个不同的部分:
- 由于系统内核中有些代码、数据是所有进程所公用的,所以所有进程的进程地址空间中有一个专门的区域存放公共的内核代码和数据,该区域内的内容相同,且该虚拟内存映射到同一个物理内存区域。
- 进程在执行的时候,需要维护进程相关的数据结构,比如页表、task和mm结构、内核栈等,这些数据结构是进程独立的,各个进程之间可能不同。这些数据结构在进程虚拟地址空间中一个专门的区域中。
- 进程在进行函数调用的时候,需要使用栈,于是进程地址空间中存在一个专门的虚拟内存区域维护用户栈。
- 进程在进行动态内存分配的时候,需要使用堆,于是进程地址空间中存在一个专门的虚拟内存区域维护堆。
- 进程中未初始化的数据在 .bss 段
- 进程中初始化的数据在 .data 段
- 进程代码在 .text 段
- 进程执行的时候可能会调用共享库,在进程地址空间中有一个共享库的存储器映射区域,这个是进程独立的,因为每个进程可能调用不同的共享库。