ce吧 关注:195,751贴子:3,798,599

【瞎写】讲讲如何用 Modern C++ 撸一套内存读写函数 (0)

只看楼主收藏回复

首先,此贴不讨论任何网络游戏WG的内容,所以请不要问我某某游戏如何过XX,如何写XX,XX哪里不对,以及XX为什么不起作用等类似问题,此贴所述的知识和技术请自行使用单机游戏尝试,例如:植物大战僵尸。
其次,本人早已不再进行任何WG相关的研究与开发,最早接触WG开发只是因为兴趣,并不曾以此牟利,现在做正规软件开发,所以不参与任何具体到某游戏中XX姿势的讨论,最多只讲与其相关的原理性知识,另外如果有C++和WIN32相关的问题我很乐意解答。
最后,此贴所述为C++和内存相关的知识,适合对二者都有一定了解的人阅读,对其中一些基本概念我不会深入讲解,所以没有相关基础的同学请选择性阅读,另外本贴纯属一时兴起,随便写写,言辞不加修饰,思路未做整理,不接受任何鸡蛋里挑骨头的行为。
以下是正文:
此贴诞生的原因要从我在本吧看到的一份代码截图说起:

这份代码如果要看懂需要这么几个基本条件:
1. 了解C/C++基本语法
2. 理解C/C++指针的概念
3. 理解数据在内存中的存储形式
4. 理解C/C++对象内存布局
我在此对第234点做简单的解释:
在现代操作系统中,因为内存分页机制的存在,程序所使用的内存空间是线性的,对于一个32位的应用程序,其地址空间范围为0-0xFFFFFFFF,大小共4GB(即2^32字节)
在C/C++语言中对于整型变量和字符型是直接以数据的常规来存储的,整形的100在内存中就是100,而对于浮点型数据float、double、long double而言,他们的存储形式是由编译器决定的,目前最常用的标准是IEEE754标准,此标准规定的浮点数在内存中的存储形式如下图:

关于IEEE754标准的详细信息请自行网络搜索或看标准Paper,这里只需要知道,这两种基本数据类型在内存中的存储方式是不同的,所以一切妄图通过以整型格式去读取浮点型格式数据的行为,最终只会得到无意义的数值,相反同理。举例比如:
int a = 10;
float b = *(float*)&a;
执行完毕后,b的值将是根据浮点数标准相关规定得出的无意义的值,因此对于一个值,我们必要根据其本身的数据类型,使用对应类型的指针来进行存取操作(此例为int型,float同理):
int a = 10;
int b = *(int*)&a;
懂一些编程的人会说:你这是脱裤子放屁,为什么不直接 int b = a 呢?
因为此代码中有a的定义,编译器可以直接通过变量名字a来寻址到a的内存,而在编写WG时,我们不可能去到游戏的上下文中进行开发,除非你有游戏的源代码(有源代码谁还写WG了)。
因此你所知的a仅能通过一个表示其地址的数值来体现,比如0x12345678,假定此为a的地址,那么对a的读取操作为 int b = *(int*)0x12345678,写入操作为 *(int*)0x12345678 = 20。
以上是第23点的简单讲解,再给完全没理解指针的同学补充一些:
指针自身的类型就是指针类型,不属于整型或浮点型或字符型或其他任何型。
指针在32位进程中的大小为4字节,和int/long类型的大小一致,而且指针在内存中的存储形式也和int/long相同,因此可以相互转化,但这种转化不是自动的(隐式的),需要使用强制转换,C风格的强制转换只有一种,形式即(type)object。
类似0x1234567或者100的这种直接写在源代码里的数值,他有个名字叫字面量(字面值)(0x开头的为16进制字面值),默认的不超过4字节范围的字面量其类型为int,与指针类型不同,因此若要将0x12345678作为指针使用,需要使用强制类型转换,即(int*)0x12345678,此即为一个指向int型数据的指针。对于访问一个指针所指数据的操作,使用*操作符,即解引用,*(int*)0x12345678这个表达式的结果即为10。
&为取地址操作符,使用形式为&object,此表达式结果为指向该变量的指针,类型为指向object的类型的指针,比如:float x = 3.14,那么&x的结果就是一个float*类型的指针,其指向数据为x存储的数据,对其解引用 *&x 结果就是3.14。
另外指向不同类型的指针本身的类型也不相同,因此也需要强制转换,对于上面最开始的例子,(float*)&a就体现了这点,&a的类型是int*,要将其当作float*来使用就需要这样的强制转换。
对于第4点,需要理解C/C++对象的内存模型,最基础的来说就是struct(结构体),举例以下结构体定义:
struct S {
int a;
int b;
};
这是一个最简单的例子,类型为S的结构体包含2个成员a和b,因为是包含关系,所以其大小为所有成员大小的和(此处不考虑内存对齐),因此S的大小是4+4=8字节。
又因为一个结构体的存储方式是连续且有序的,所以在S所在的8字节中,前4字节是a的数据,后4字节是b的数据,其内存结构图如下:

对结构体的访问最基本的做法是 . 操作符,即成员访问操作符,比如访问S的成员b:
S s;
s.b = 42;
首先定义了一个S类型的对象s,然后使用.操作符访问S的b成员(不是s的b成员,只有类型才拥有成员,s是对象),并将其赋值为42,这是最基本最常用的操作方式。
接下来考虑取s的地址,保存在类型为S*的指针p里,并通过p将a也赋值为42:
S* p = &s;
p->a = 42;
这里遇到了一个新的操作符 -> ,其名也为成员访问操作符,行为也和.一致,只不过他作用的对象是指针类型,因此 s.a 和 p->a 是结果是一致的。
继续我们再来看一种访问b的方式:
int i = (int)p;
*(int*)(i + 4) = 58;
先来看第一句,(int)p 即将指向s的指针p强制转换为int型,上文说过指针和整数类型不同但数据存储方式相同,并且上文我们也做过将int型转为int*型指针的操作了,此处可以反过来将S*类型的指针转为int型。
记住凡是指针都可以转换为int型,不论其是指向int型或是指向float型亦或是指向S型的,经过强制转换后,我们得到了一个数值意义上的地址,保存在i中。
第二局代码稍微复杂,我们一点点来看,首先i+4是什么,i 是对象 s 的地址,前文也讲了结构体和成员是包含关系,所以S大小为8字节,前4字节是a,后4字节是b。
那么这样一个表达式 i+0,其结果便是a的地址,又因为a是整数型大小4字节,所以i往后数4个字节i+4便是b的地址。
拿到了b的地址,但因为其是由i+4运算来的int型数据,如果我们要访问b的话,就要先转化为int*类型的指针,即(int*)(i+4),用*解引用,*(int*)(i+4)这条表达式即是b,最后对其赋值58。
以上概念对于从未接触过相关内容的同学来说可能有些难于理解,对此我爱莫能助,内存和指针的本质需要时间来消化,只能说多看书多写代码。


1楼2018-01-27 05:14回复
    说起来为什么一个图片会扯出来这么多内容,本来我是不打算将这些基础到不能再基础的概念的,但是想到如果连这些都不懂,那连图中的那第一行注释为“触发”代码都看不懂,所以便还是讲了些,大概明白点也好继续看下去。
    继续要讲的是基址和偏移的本质,我是非常有想找到我N年前录得视频,就不用再把这里废话一遍了,但是我找不到了。
    基址是啥?他就是个地址,可以转换成指针来进行存取操作,那为啥不叫地址呢,因为它通常和偏移一同使用,偏移是基于此地址的,所以叫“基”址。
    而且它是每次运行都不变的,因为他是个全局变量或者static变量(单例模式),在游戏开发中多为后者,这个static变量是指针,指向某个类或结构的唯一实例。
    偏移是啥?他就上面最后一例代码*(int*)(i+4)中的4。
    程序设计语言,不论是面向过程还是面向对象,都具备定义和操作数据结构的能力,上面的S就是最简单的数据结构,现在来把他和它的成员变一下名字:
    struct soldier {
    int hp;
    int mp;
    };
    感觉是不是微妙了一些呢,这不就是游戏中常见的概念吗?一个“战士”拥有最基本的“血量”和“蓝量”两个属性,这就是一个结构,那之前对于这个结构的操作,都可以理解为WG对游戏内数值的修改行为。
    一般情况下我们会通过逆向来找出“基址”,当然也可以使用别人找到共享出来的,假设我们通过调试器找到了某个游戏中角色的基址为0x1234,并且通过观察内存数据发现了偏移0处为hp,偏移4处为mp。
    那么我们对其hp和mp的修改就可以这样操作(注意我们是没有结构体的定义的,我们只知道基址和偏移):
    *(int*)(*(int*)0x1234 + 0) = 999999;
    *(int*)(*(int*)0x1234 + 4) = 999999;
    是不是很像图片里的代码了,只是比图里的少了几层而已。但是跟之前的例子对比,又有些不太一样,如果按之前的例子写,那应该是 *(int*)(0x1234 + 4) = 999999 啊,为什么会多了一次*(int*)呢?这并不是写错了,而是因为我上文说的单例模式,也就是基址存在的本质原因。
    何为单例模式,它是一种设计模式,作用是限制一个结构只能定义一个对象,考虑游戏中角色的概念,通常角色只有一个,就是你所操作的那一个,所以角色是非常适合使用单例模式的结构。
    对于常规的结构比如前文的S,我们可以:
    S s1;
    S s2;
    这是完全没有问题的,他们是类型相同的两个不同对象。
    而对于单例结构,只允许定义(实例化)一个对象,它定义对象的方式是通过定义一个静态的实例指针,并保存在结构中,暴露接口给外部访问,而其构造函数设为私有,因此外部不可以访问其构造函数,也就无法定义一个实例(超纲,不懂啥是构造函数的自行搜索或者跳过这块)。
    而所谓基址,其实就是这个结构中保存的指针的地址,这个static指针和全局对象拥有相同的特性,即他是硬编码在二进制文件中的,体现在汇编中就是一个立即数,是由程序本身的加载的基地址加上其在.data节中的rva所得到的(听不懂没关系,就知道他是不变的就行了)。
    因此0x1234其实是这个指针的地址,指针保存的值才是我们角色的地址,所以要先对这个指针的地址进行一次解引用取值操作,得到角色的地址,就可以代入之前的例子了。
    搞清楚了基址和偏移的本质,再来看看多级偏移,上个例子我们只有一个偏移量,要么是4要么是0,那是因为这个角色的结构太简单了,而一个好玩的游戏,人物的属性必然不会只有血和蓝,起码他还得有技能吧。
    那么看这样一个结构:
    struct skill {
    char name[10];
    int damage;
    };
    这是一个简单的技能结构,他有两个成员,一个是技能的名字,一个是技能的伤害。
    我们来定义一个技能
    skill taunt;
    taunt.name = "嘲讽"; //这里不能这么用,对字符串的操作不可以直接赋值,暂且不讲,就当成可以这么用
    taunt.damage = 800;
    以上定义了一个名为嘲讽的技能,它对敌人造成800点伤害,但是这个技能暂时不属于任何人。
    再改一下角色的定义:
    struct soldier {
    int hp;
    int mp;
    skill* skl;
    };
    然后做这样一个操作:
    soldier s;
    s.hp = 1000;
    s.mp = 400;
    s.skl = &taunt;
    这样我们通过给角色结构的成员中加入了一个技能的指针,使我们的战士得到了一个嘲讽的技能,为什么用指针而不直接在结构中定义技能呢,这是游戏设计的问题,这里不详细讨论。
    总之无论是指针还是直接定义,都有读写它的办法,多级偏移正是由于使用指针的设计方式而出现的。
    接下来写代码来修改我们角色嘲讽技能的攻击力(你可以先不往下看,自己尝试写一下):
    *(int*)(*(int*)(*(int*)0x1234 + 8) + 10) = 999999;
    emmm...这样看起来和图片里的更像了,来看一步步分析。
    *(int*)0x1234 按上文所述是拿到人物的地址,+8是跳过hp和mp成员计算出嘲讽技能指针的地址,因为计算地址要int型,所以算完后还要将其转为指针,再对其解引用,*(int*)(*(int*)0x1234 + 8) 即是嘲讽技能的地址。
    此时我们拿到的是另一个结构的地址,如果你真理解了上面的内容,那你应当知道+10是何意:为的是跳过技能名字的数据,即10个char,每个char是一个字节,因此+10(此处依然不考虑对齐,有好奇心的同学自行搜索)。
    跳过10个字节后 *(int*)(*(int*)0x1234 + 8) + 10 便是damage成员的地址了,damage是int型数据所以将其转换为int*进行访问,*(int*)(*(int*)(*(int*)0x1234 + 8) + 10) = 999999 自然便是修改其为999999。
    我不会再写例子还原图片中的5级偏移,因为原理和2级偏移是一样的,你可以想象一下,有一个地牢、地图里有个人、人身上有个袋子、袋子里有很多小袋子、某个小袋子是装战利品的、战利品有自己的属性(名称、价值、描述、重量、是否可出售等等),那如果你要修改这个物品的价值,那你起码需要几次偏移呢?
    我再结合之前的某个东西提个问题并做出解答,建议你先自己做出来再看答案:如果技能结构的伤害成员是float类型,你该怎么修改他的值呢?答案在文章末尾。


    5楼2018-01-27 05:23
    回复
      最后说说文章一开始给的代码截图,为什么我要贴这张图,如果你了解结构化编程和代码复用的思想,那你也许一开始就会发现,图中对多级偏移的内存访问操作大多是重复性操作,而这种操作是可以建立一个函数,通过将基址和偏移作为参数传入,让函数代为处理这一过程,从而达到简化代码量和增强可读性的目的。
      想象一下这只是5个偏移便已如此冗长,那假若有20个偏移呢?总归是写得出来的,也能很好的工作,但请允许我把写出这种代码的人叫做“愚夫”,“愚夫”大概更适合打劳工做苦力,因为他们很“享受”重复劳动的过程。
      我写此文的目的就是不想在有人成为“愚夫”。
      写到这里已经花了三个小时了,我发现以上内容和此贴标题并没有什么卵关系,一切都因为我想简单讲下基本概念,结果发现这些概念并不“简单”,或者说讲起来不那么“简单”。
      而我也暂时不想继续往下写了,所以这篇文章就到此为止吧,我在标题后面加了个(0),就当作正篇的铺垫吧。
      下次要讲的真的就是 Modern C++ 的东西了,何为现代C++,可以理解为充分使用了C++11/14/17标准,以及遵循近年来C++设计理念的C++代码,也许还会用到元编程的技巧,一切走一步看一步,旨在尝试使用现代C++写出一个亲和WIN32和R3底层操作的lib(虽然已经有了,但要享受造轮子的过程)。
      下一篇的内容绝对不亲民,有关C++的部分需要较强的C++功底,至少也是通读了《C++ Primer》一书,并且对OOP和GP思想都有一定理解的同学才能看懂,而WIN32相关的只是对内存的一些介绍,主要是进程内存隔离,可能会扯到进程的概念和CPU内存分页机制,但都不会太深入,毕竟这篇文章目的也仅仅是撸个读写内存的类或者一套函数而已。
      再往后的打算是讲下撸个HookEngine,初步打算支持的Hook方式:Inline Hook、VEH Hook(硬件断点)、VEH Hook(int3)、VEH Hook(Page Fault),内容会涉及CPU异常的概念,Windows对异常的处理方式,用户态异常的分发过程,如何利用异常做Hook,以及如何通过Hook异常分发过程来代替 VEH 做 Hook。
      如果你发现本文有哪里驴唇不对马嘴甚至话说到一半,请不要惊讶,因为我懒得review。
      答案:(float*)(*(int*)(*(int*)0x1234 + 8) + 10) = 999999;
      如果你写出 (float*)(*(float*)(*(float*)0x1234 + 8) + 10) = 999999 这样的代码,那说明你还得重新看下我讲解“指针类型和整数类型的异同以及为何能相互转化”的部分。
      配合这段话:在一次次偏移的过程中,你所读取的是一个指针变量,我们需要的是将指针和偏移做算术运算,指针在内存中的储存形式和整数型相同,所以就用int*型的指针来解引用,而不是float*。
      用后者你只会的到无意义的数值,把无意义的数值当作地址来访问数据多半会产生 access violation,结果就是程序崩溃。最后一次读取使用float*的原因是,这次读取的不在是一个指针了,而是一个float型数据,所以必要使用float*,而不能是其他指针。
      一句话总结便是:最后一次解引用的指针类型取决于你所操作的数据的类型,前面N次解引用使用int*是为了指针运算。


      6楼2018-01-27 05:26
      回复
        大佬出现了小快打死
         ✎﹏﹏  滑稽树上滑稽果 滑稽树下你和我 滑稽树前做游戏 滑稽树后做交易
           --来自助手版贴吧客户端


        IP属地:河南来自Android客户端7楼2018-01-27 07:08
        回复
          太难了大佬从新建dll一步步开始吧


          IP属地:广西8楼2018-01-27 08:10
          回复
            你的视频,叫什么名字


            来自Android客户端9楼2018-01-27 08:52
            回复
              顶 虽然看不懂


              IP属地:湖南来自iPhone客户端10楼2018-01-27 09:32
              回复
                不知道,对比易语言学习c+j,就是一顿复制粘贴,先把支持库对着来


                IP属地:广西来自Android客户端11楼2018-01-27 10:21
                回复
                  膜拜大佬!


                  来自Android客户端13楼2018-01-27 11:08
                  回复
                    我想知道那个大佬发的图片 我那天写出来 立马就有这图片发布 挺厉害的


                    14楼2018-01-27 11:12
                    收起回复
                      哇塞,真大佬,


                      IP属地:广东来自Android客户端15楼2018-01-27 11:25
                      回复
                        好难啊。看不懂


                        IP属地:广东来自Android客户端16楼2018-01-27 11:29
                        回复
                          路过帮顶


                          IP属地:浙江来自iPhone客户端17楼2018-01-27 11:54
                          回复
                            我觉得我还是手动吧


                            IP属地:四川来自Android客户端18楼2018-01-27 12:50
                            回复