rvo算法C语言版,【瞎写】讲讲如何用 Modern C++ 撸一套内存读写函数 (1)

该楼层疑似违规已被系统折叠 隐藏此楼查看此楼

这篇是撸读写系列的第二篇,但不一定是最后一篇,只是短期内为最后一篇。

前文:https://tieba.baidu.com/p/5530062070

我假定你对C++非常了解,否则你可能看不懂,我会尽量用较基础的方式来一步步讲解,如果一开始就看不懂,请不要浪费时间了,做点别的吧。

以下是废话:

如何优化一段代码,如何重构一段代码,有关代码质量的东西都是有趣的,更有趣的地方在于,代码是用C++写的。

为什么我会这么说?因为C++是门“抽风”的语言,原谅我找不出其他词来形容他,只有对各种常用编程语言较为熟悉,并且深入学习过C++的人,才能体会这种“抽风”的感觉。

非要用什么通俗点的话来解释,大概就是:C++被弃不掉的历史包袱拖累,新进的标准是为过去的设计填坑,而且还在不断挖坑,导致这门语言的特性越来越多,多到没几个人敢说精通C++。

其次这些特性的实现都非常诡异,用其他语言可以简洁高效实现的代码,用C++写出来会显得非常复杂且别扭。

再其次近些年C++对于元编程的执念越来越深,本身的发展在向着越来越畸形的道路走去,至少给我一种破罐子破摔的感觉。

我为C++的元编程中毒者感到惋惜,虽然人有自己的选择,没有对错之分,但实是令人唏嘘。

如果上天给我重来一次的机会,我会说:我选择Rust!

废话就这些,以下是正文:

接上篇,此文是为了实现一套通用的、简洁高效的、现代的内存读写函数,我已经达到了这个目的,但我是来写文章的,不是发代码的,所以我会从C语言的实现慢慢转向C++的实现,毕竟C语言要好懂得多。

这是前文截图中的代码:

*(int*)(*(int*)(*(int*)(*(int*)(*(int*)(*(int*)(人偶基址) + 12176) + 2940) + 4) + 24) + 0) = 31;

对于这种代码,前文也已经吐槽过了,具体问题就是:重复代码过多,可读性低,可维护性低。

那么如何改良这种代码呢:

根据代码复用和模块化的编程思想,首先想到的就是函数,是的这必是要用函数来解决问题,但具体该怎么实现呢?

考虑如果实现了这个函数,与普通函数最大的区别是什么?

此函数参数数量不定。

偏移这个东西,根据数据结构的嵌套层数不同而不同,因此少的会遇到123456级偏移,多的将达十数级甚至数十级偏移,总之他是变化的,那么我们需要编写一种名为“可变参函数”的特殊函数来实现这一目的。

当下我能想到的可以正确运行、实现不复杂且使用简单的方式,有4种:

1. C语言 可变参函数

2. C++11 可变参模板

3. C++11 std::initializer_list

4. C++ 类的运算符重载

先从最基础的说起:

一、 C风格的可变参函数

为什么这里说“C风格”而不是前文的“C语言”,因为我们编写使用的是C++语言而不是C语言,关于这两者初学者多半是分不清的,因为C++语言兼容C99之前的C语言语法,而且一般初学C++时使用的也都是C遗留下来的语法,所以分不清是正常的,这不是关键,只要记住这是再写C++就可以了。

C风格可变参函数的一个典型实现就是非常常见的printf函数,这是C标准库预定义的一个函数,通过为其传入一个格式化字符串,来指示其输出对应格式的变量值。

下面看几种用法:

printf("Hello World"); // Hello World

printf("%s", "Hello World"); // Hello World

printf("%s %s", "Hello", "World"); // Hello World

printf("my name is %s", "VM-EXIT"); // my name is VM-EXIT

printf("i'm %d years old", 22); // i'm 22 years old

printf("%d-%d-%d-%d", 1, 2, 3, 4, 5); // 1-2-3-4-5

我假定你会正确使用printf,一个最基本的要求就是:

必要有一个格式化字符串,以及N个(N可以为0)额外参数,并且格式化字符串中的格式符数量要等于额外参数。

那么考虑以下两种代码:

printf("%d %d", 123);

printf("%d", 123, 456);

很明显他们都违背了基本要求,他们是错误的吗?

不是,因为可以编译通过,他们的行为要分开来讨论(这里要涉及一点栈和调用约定的相关知识,但我不打算对函数调用过程进行解释,请自行使用搜索引擎):

对于第一行代码,两个格式符,一个额外参数,这种情况下,printf内部解析格式化字符串,他了解到需要从栈上读取两个长度为4字节的数据,以整数型的方式对齐进行解释并打印输出。

但我们只传入了一个额外参数,但printf无法意识到这一点,在读取了第一个4字节之后,他会继续从读下4个字节,这就有问题了,因为我们只传入了一个参数,那他读取到的第二个4字节数据是什么呢?

是之前使用堆栈时存入的未知数据,其内容根据调用printf前的代码行为不同而不同,因此此数据没有意义,printf也会因此给你打印一个莫名其妙的数值,这种行为C++标准称为:Undefined Behavior (未定义行为)。

发生UB的多数情况,程序已经执行了错误的代码,但有时因为数据的随机性,并不会导致错误的发生,也就不会导致程序崩溃,而有时又会立即崩溃,如果你写了UB代码,在测试时又恰巧没发生错误,那你就倒霉了,因为他多半会在交给客户的时候崩溃,而你懵然不知!

那么对于UB为什么编译器不报错呢,答案编译器不知道,因为编译时是静态检测代码,而这种错误发生在运行时。

但是某些编译器,譬如VC,他会根据你给出的格式化字符串,判断格式符数量和额外参数是否相等,若不等则会发出一个警告,如果你习惯忽略警告,那你多半就歇菜了。

注:忽略警告是非常非常不好的行为,因为当编译器发出警告时,多半你的代码写的有问题了!这是新手常犯的错误。请记住不要忽略警告,要把他当成错误来对待!

第二种情况要简单不少,多的一个额外参数就放在栈上,一点影响都没有,但你也不要写这种愚蠢的代码,因为它不仅看上去很蠢:它会多消耗栈4个字节的空间,虽然这对于默认MB大小的栈来说是九牛一毛,但也改变不了它愚蠢的事实。

在实现读写用的可变参函数之前,先说说如何跨进程读写内存:

前文讲过的以指针形式来进行内存读写的操作仅限于同一进程上下文,也就是说你要么是自己写的程序,要么是将你写的DLL注入到目标进程中,对于WG编写肯定是第二种。

但这里我们不用DLL的形式,虽然DLL有诸多优点,但我旨在编写一个WIN32的hack库,将来可以应用在比如进程管理工具,代码注入工具,窗口检测工具等等,这些工具必然是不会首选以DLL的方式注入到其他进程工作的,所以这里选择EXE的跨进程内存读写方式。

具体操作VeryEasy,Windows系统提供了现成的WIN32API:ReadProcessMemory 和 WriteProcessMemory,对于这两个函数,我非常不想多说什么,所以我就不多说什么,不会用自行使用搜索引擎(也许以后的帖子我会单独讲进程、线程、内存的东西,到那时应该会具体说(吧))。

接下来拿读取内存举例,因为它比写内存能多体现一个问题:

int a = 0;

ReadProcessMemory(handle, (const void*)0x12345678, &a, sizeof(int), nullptr);

几个参数简单说下:

1. handle为目标进程句柄。

2. 0x12345678为要读取的数据在目标进程中的地址,强制转换为const void*是因为此函数要求此类型,事实上他用int或者__int64(64位程序中)也没什么影响。

3. &a是此参数要求一个缓冲区指针,既然要读取一个int数据,那显然保存此数据的缓冲区(变量),也应该是个int型。

4. sizeof(int)是此参数要求指定要读取数据的长度,你写4也没什么毛病,因为int大小就是4字节。那么为什么要有这么个参数,难道不能从上一个参数判断他是个int*从而读取4字节么?答案是不能,因为上一个参数类型是void*,表示它可以接纳任何数据类型的指针,这也是你可以选择读取成float还是int的一个关键原因。

5. 一个接受函数实际读取字节数的指针,这通常是在函数返回失败并且GetLastError指示“部分拷贝”时,用以考虑如何进行后续处理所需要的信息,而对于仅为了读写某些数据的情况下,此参数完全没有意义,直接传空指针,我们只需要判断函数的返回值来表明成功与失败(并且额外提一点,我不打算设计返回错误原因的相关功能,因为此库自用,我不需要这样的功能)。

正常来说可以直接拿此API来进行封装了,但我觉得它使用起来还是略显复杂,因此我们写一个wrapper函数:

bool read(uintptr_t address, size_t size, void* result) {

return ReadProcessMemory(hprocess_, reinterpret_cast(address), result, size, nullptr);

}

简单说说我都做了什么:

1. 返回值从BOOL类型换成C++标准定义的bool类型,对于一个“干净”的库来说,原因有两个:一是要尽量避免其他库定义的类型出现在你所定义的接口中,而BOOL是WIN32SDK定义的类型;二是WIN32SDK同时支持C和C++,C++是向下兼容C89的,所以微软将数据类型定义为C风格以同时支持两种语言,而C语言当时又恰巧没有布尔类型,只能就typedef了一个int当作布尔型来用了,这种将一个类型typedef后拿来当作另一种作用的行为是非常不推荐的,容易产生歧义,正规开发中绝对要避免这种情况(微软当年是不得已而为之,即使C99标准定义了_Bool类型,但为了保持旧代码的兼容性,也无法做出任何更改,这便是历史包袱)。

2. 将进程句柄移至类内,这样做的原因是此函数早晚要变成类的成员函数,因为我写的是C++的库,OOP的思想是最起码的,而且还能省个参数,岂不美哉?

3. 将要求的目标地址参数类型换成uintptr_t,这是C++标准库定义的一个专门用来保存指针的类型,其本质就是整数型,因为指针拿来当整数做算术运算是很常用的需求,所以出现这么个东西不足为奇,为什么不用直接用int呢,原因有两点:一是指针在32位进程中为4字节,在64位进程中为8字节,而int永远为4字节,所以如果编写64位程序,int将无法容纳指针数值,而uintptr_t会根据进程位数来对应到 unsigned int 和 unsigned __int64,所以最好选用uintptr_t,即使我们不一定要支持64位程序,也最好如此,方便以后移植,事实上WIN32SDK也定义了名为UINT_PTR的实现相同功能的类型,但根据上文的接口设计原则,不与采用;二是因为对于地址来说不存在负数形式,而int型是包括有符号数的,虽然在使用都是按无符号解释而不会有影响,但从逻辑合理性来说 unsigned int 要比 int 更好。另外为什么要用整数类型来替换指针类型呢,因为此库的设计目的便是操作其他进程,既然如此便不会出现地址是通过类似&xxx而体现出的指针形式,更多的是直接利用整数来指定地址,所以这样做能省去大量的强制类型转换操作。

4. 用C++标准库定义的size_t来替换WIN32SDK定义的SIZE_T,这两个作用跟uintptr_t和UINT_PTR类似,没什么好说的,换掉的原因依旧是遵循接口设计原则。

5. 省掉了"实际读取字节数"这个对此库无用的参数,也没什么好说的。

6. 将size和result调换了位置,这是因为一个良好的接口设计风格还要遵循一个原则,那就是作为输出使用的参数应在参数列表最后:此API将从目标进程读到的结果数据复制到调用者提供的缓冲区参数中,这当然是一个输出参数,所以理应放在最后。

经过这一层wrapper,来看下同之前那个例子的调用对比:

之前:ReadProcessMemory(handle, (const void*)0x12345678, &a, sizeof(int), nullptr);

现在:xxx.read(0x12345678, sizeof(int), &a);

嗯,看起来简洁多了,考虑能否再进一步?

这是相比上面那个货更符合直觉的写法:

int a = xxx.read(0x12345678);

这个看起来简直美滋滋啊,然而成品还是比上面的多了那么一丢丢东西,不过影响不大,具体是什么暂且不说,继续往下看。

首先还是说说要把之前的函数改成这个要处理几个问题:

1. 函数如何知道要读取的字节数量(即size)?

2. 函数应以何种类型作为返回值?

3. 返回值被结果占用,那么如何判断函数成功与否?

4. 函数返回值类型导致发生拷贝行为,由此造成的性能损失如何解决?

1和2可以合在一起来搞定,有两种解决方式,先说C风格的。

若要用C语言来实现这种操作那办法只有一个,那就是给每个类型单独写一个函数,比如:

int readInt(uintptr_t address) {

int result = 0;

ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(int), nullptr);

return result;

}

float readFloat(uintptr_t address) {

float result = 0;

ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(float), nullptr);

return result;

}

然后要做的就是针对不同类型调用相应的函数,emmm...看起来没什么不好的,用起来也没什么不好的,但是别忘了我们的初衷是什么,至少有一点是增加代码的复用性,那么看看这俩函数吧,这何尝不是一种重复劳动呢?对于更多的类型,难道我们都要分别写一个函数来支持吗?简直“愚夫”!最多还能用宏来生成一个函数的样板,为每个类型分别调用一次宏函数,以此来生成一个处理相应类型的函数,相较这种会减少很多工作量,但似乎还不是我们要的完美解决方案。

那么到底完美解决方案是什么?看看这两个函数的区别吧,他们差的仅仅是一个类型而已,这时聪明的人会想到,能不能定义一种特殊的参数,让这个参数来接收类型呢?

答案是能,这就是泛型编程所解决问题,不知何为泛型的同学请自行搜索,简单说就是类型是“可变”的。

C++通过模板技术来提供泛型支持,不知道模板为何物的去看《C++ Primer》,关于模板的基本操作我也不多说了,只讲讲模板支持此操作的根本原理,在此之前先看看如何利用泛型模板来实现我们的目的:

template

T read(uintptr_t address) {

T result = 0;

ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(T), nullptr);

return result;

}

这个语法简单地说就是通过定义了一个模板形参T,这个T可以根据调用者指定的类型来“动态变化”,因此只需这一个函数,便完成了对所有类型的支持,是不是很Nice啊。

至于这个“动态变化”啊,其实他原理就有点类似上面提到的用宏来为每个类型生成函数,不过这个过程由编译器代劳了,而且只有在你明确调用了一个类型的模板函数时,编译器才会为这个函数生成一个实例,如果你这样子调用的话:

int a = xxx.read(0x12345678);

float b = xxx.read(0x88888888);

那么编译器就会生成如下两个函数实例:

int read(uintptr_t address) {

int result = 0;

ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(int), nullptr);

return result;

}

float read(uintptr_t address) {

float result = 0;

ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(float), nullptr);

return result;

}

看的出来吧,和C风格的区别也就是函数名字相同了,这是并不是因为C++支持函数重载的原因,因为函数重载并不能支持通过返回值来区分两个函数。这俩个模板实例之所以能够被编译器正确区分并调用的原因就是:模板就是能通过返回值区分。是的,这是一句废话,不要再问我为什么,你姑且当作是编译器优待吧。

如此使用泛型特性之后,仅增加了一行代码便免去了大量重复的工作,这就是泛型给我们带来的好处,而这也不过是泛型的最基本应用。对于C++来说,模板本身就可以当作一门语言,他是图灵完备的,这点很强大,当然也容易深陷其中,我上文所说的元编程中毒者指的就是模板元编程中毒者,可歌可泣。

至此我们解决了4个问题中的前两个,接下来说说第三个问题:如何判断函数调用成功与否呢?

事实上函数返回值这个东西,可以说有点微妙,C语言允许调用者不接收返回值,而C++自然也兼容了这一点,所以许多初学者甚至部分工作了的同学,在个别情况下都会偷懒不检查返回值。

这种行为在你临时写测试用例的时候是自然是允许的,作为测试的代码无论写的多烂都无所谓,但如果你让这种行为出现在了产品里,那问题可就大了。

无论对于何人何库提供的接口,带有明确的表明操作成功与否的返回值都不应被忽略,不要因为要多写两句代码就偷懒不做。

如果你检测了函数返回值,那么当某一步调用失败时,你也会做出正确的处理:报错或者生成dump,并结束进程运行,至少你知道出错了就不该让代码继续执行下去。

而如果放任错误的代码继续执行,那将可能导致不可预料的后果,若你为服务器开发程序,此后果可能导致服务器集体宕机,更严重的甚至是数据丢失。

因此检测函数返回值是很有必要的,而作为一个健壮的库,无论调用者是否处理返回值,都理应提供这种机制,我个人是无论何种情况都会检查函数返回值的,这能节省很多无意义的debug人生(此处不考虑通过异常来报告错误的方式)。

于是只好解决这个问题:

首先考虑返回0值...emmm...如果要读的数据本来就是0呢,那不就产生歧义了?负值同理。

那其次考虑定义一个结构体?包含一个结果和一个表示成功或失败的布尔值?看起来没毛病,但是我给你写出来看看:

template

struct call_result {

T result;

bool success;

}

再稍微改下之前的模板函数定义:

template

call_result read(uintptr_t address) {

T result = 0;

bool success = ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(T), nullptr);

return { result, success };

}

到目前为止都没什么问题,继续看如何使用:

call_result r = xxx.read(0x12345678);

if (r.success) {

// 正常使用 r.result 比如:

std::cout << r.result << std::endl;

}

第一行代码看着就很麻烦是吧,要指定两次int,这不是多此一举么,还好从C++11标准开始我们有了auto,因此可以把第一行改为 auto r = xxx.read(0x12345678)。

到这里一切都很和谐,他的确就是如此和谐,你这么用完全是没有问题的,但是我们要写的是更 Modern 的 C++,而新的标准由提供比这更好的办法,继续往下看。

如果你有其他语言的编程经验的话,你可能会知道多返回值这个东西,C#、Swift、Rust等常见语言和大多数脚本语言都支持这项特性,一般是通过返回一种名为 tuple(元组) 的对象来实现的,从C++11标准开始,C++也提供了std::tuple。

元组本质就是一个能容纳各种类型的结构,这你可以不严谨的理解为不用事先定义的结构体,因此对比使用结构体来说,你可以省去定义call_result的操作,本着蚊子腿再小也是肉的态度,似乎元组是比结构体更好的解决方式。

来看如何利用 std::tuple 以及C++17提供的 Structured Binding 特性来实现我们的函数:

template

std::tuple read(uintptr_t address) {

T result = 0;

bool success = ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(T), nullptr);

return { result, success };

}

嗯,实现上的确省去了结构体的定义,来看使用:

auto [result, success] = xxx.read(0x12345678);

if (success) {

std::cout << result << std::endl;

}

emmm...真心是蚊子腿,并没有什么卵区别嘛!

所以说来说去最后也就是这样?这次不好意思,差不多只能这样了,大概也只能使用另外一个更具相性的东西来替代元组和结构体,虽然大部分情况下用起来还是差不多,这东西也就算最终解决方案了,事实上它也确实是为了这种使用场景而被创造出来的,这个货就是C++17标准新增的std::optional。

optional可以表示一个逻辑上的空值,所以用来做这种事情再合适不过了,来看看怎么写:

template

std::optional read(uintptr_t address) {

T result = 0;

if (ReadProcessMemory(hprocess_, reinterpret_cast(address), &result, sizeof(T), nullptr)) {

return result;

}

return std::nullopt;

}

实现上和tuple没什么区别,但std::nullopt是明确代表一个逻辑空值的,所以说此函数看起来更合理了。

在使用上与tuple只有略微的不同:

auto result = xxx.read(0x12345678);

if (result) {

std::cout << *result << std::endl;

std::cout << result.value() << std::endl;

}

此处的*不再是解引用运算符了,而是std::optional自定义的重载运算符,这个重载*运算符函数的行为和value()成员函数的行为是一致的,所以这两句代码都是打印读取内存的结果(关于运算符重载,不懂的同学自行看书)。

这里额外讲一个optional的特殊操作,value_or()成员函数,它的作用是你可以指定一个与返回类型相同的参数,当optional有效时,此函数返回其本来的结果,否则返回你指定的参数值,举个例子:

auto result = xxx.read(0x12345678);

std::cout << result.value_or(666) << std::endl;

此处我们假定0x12345678地址处的4字节数据为整数42,那么当read函数成功时,cout将打印42,失败时则打印666,此功能在某些情况下有些用处,可以看成是默认值的意思。

最后只剩第4点了:函数返回值类型导致发生拷贝行为,由此造成的性能损失如何解决?

关于这个东西,要明确一个概念:对于内置类型,int、float之属,拷贝的消耗也就是4个字节而已,这同指针和引用所需要的消耗是一致的;而对于更大的自定义类型,比如结构体和类,特别是容器类,其拷贝操作所消耗的时间和空间都是不可忽视的。

为了解决这个问题C++11标准增加了“右值引用”和“移动语义”,右值引用用以支持区分一个对象是“左值”还是“右值”,函数返回值是典型的右值,此处依旧不详述,依旧请自己看书。

若一个类定义了移动构造函数,那么该类型的对象在发生拷贝操作时源对象是一个右值的话,那么将使用移动构造函数代替拷贝构造函数,这就是“移动语义”。

这里我们简单的将右值看作为临时对象(不严谨,值类型的最新定义分为左值、右值、泛左值、纯右值和亡值,严格说纯右值才是临时对象),由于临时对象即将丧失其对资源的所有权,所以对于一次拷贝来说,更好的办法是转移资源所有权,而一次移动操作相比一次拷贝操作的消耗来说根本不值一提(具体的内容还是看书,太多了讲不过来)。

因此有了移动语义我们就不必过分担心大型数据结构的拷贝消耗,但这还不是最好的事情:现代C++编译器通常实现了分别名为“RVO(返回值优化)”和“NRVO(具名返回值优化)”的特性,这是一种编译器优化策略,不属于语言特性,但好在大部分编译器都支持,比如gcc、clang、vc,有了这一特性,我们甚至省去了移动操作,使得一次拷贝操作和直接构造没有任何区别了。

另外在最新的C++17标准中,新增了 Guaranteed Copy Elision (确保复制消除) 这一特性,使得纯右值到泛左值的赋值操作保证不发生复制或移动,而是如RVO一般直接构造。但可惜的时在我们这个例子中,返回的不是一个纯右值,而是一个左值,所以不能使用这种特性,但好在我们还有NRVO,他工作得很好。

我也是万万没想到写了这么多,才完成了第一部分的四分之一内容,不过这四分之一的内容大概要占整体工作的一大半,因为剩下的工作大概就是照例写出一个write函数、实现此部分可变参函数,以及实现和对比其余三种可变参函数了,由于语言的概念在这部分讲的都差不多了,所以剩下的部分写起来会很容易,但也都是后话了。

既然还是没有完结,那就再次把开头的话收回来:这必然不是最后一篇。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值