Почему это важно?
В контексте разработки читов преобладают две основные архитектуры x64 и ARM и соответствующие им операционные системы Windows и Android.
Большинство игр и их целевой аудитории сосредоточено именно в этих направлениях.
Как и в комбинации Windows+x64 так и в Android+ARM размер указателя sizeof(int*) == 8 то есть 8 байт.
Стоит отметить, что размер указателя не зависит от типа, на который он указывает, как кто-то мог бы подумать.
Размер указателя определяется реализацией языка, которая следует выбранному ABI.
ABI (Application Binary Interface) - это набор правил, по которым определяется соглашение о вызовах, разметка объектов в памяти и прочие вещи, которые должны взаимодействовать друг с другом на низком уровне.
Фактически размер указателя определяется именно ABI, а не операционной системой или архитектурой - но я допускаю использование со своей стороны следующей формулировки:
Размер указателя фактически гарантируется связкой Windows+x64, потому что это уже устоявшаяся реальность, которая не меняется уже десяток лет и в случайный момент времени ваш процессор или операционная система не могут поменять размеры типов.
Нужно отметить, что в высокоуровневой разработке на плюсах, при учёте всех абстракций, вопрос такого рода встаёт не часто.
Этот момент начинает иметь значение при низкоуровневой разработке при взаимодействии с памятью - и то, не во всех случаях.
Internal читы
Когда мы пишем internal-софт мы не кастуем указатели к целочисленным типам за отсутствием необходимости, потому что логика происходит внутри адресного пространства другого процесса и нам достаточно использовать соответствующие типы, например тот же самый int*.
External читы
При external разработке мы работаем с адресами и указателями из памяти другого процесса, тут неизбежно придётся читать значения адресов из памяти.
В этот момент как раз таки и встаёт этот вопрос о типе, который должен быть для этого использован.
Забегая вперёд, можно сказать что для этого все используют типы uint64_t, uintptr_t (и в некоторых случаях uint32_t), о чём мы ещё поговорим.
Основные причины
Разница в размере указателей
Размер указателя в современных решениях был выбран не случайно.
До появления процессоров с архитектурой x64 была архитектура x32 с размером указателя 4 байта.
Вы до сих пор можете скомпилировать 32-х разрядное приложение, запустить его в режиме совместимости и получить следующий результат
int main() {
std::cout << sizeof(int*) << std::endl;
}
4
Это накладывает ограничение на максимальный объём адресуемой памяти, то есть при 2^32 мы получаем 4 294 967 296 байт - примерно 4 гигабайта.
Очевидно, что в текущих реалиях это уже для телефона становится маленьким числом в плане оперативной памяти.
По этой причине эта архитектура была улучшена до x64 и в этом случае максимальный объем оперативной памяти 2^64 и это получается 18 446 744 073 709 551 616 байт, то есть 16 EiB, однако реальные процессоры и ОС используют меньшее количество адресных бит.
Да, это число ещё не скоро станет узким горлышком.
Недостаточность размера указателя
Исходя из того, что размер указателя в этом контексте составляет 8 байт, то есть он с большой вероятностью будет превышать диапазон допустимых значений 0x00000000 - 0xFFFFFFFF.
Что может быть подтверждено следующим кодом
int a = 1;
int* p = &a;
std::cout << p << std::endl;
000000A55130F8E0
Допустим нам повезло и это просто стечение обстоятельств.
Давайте сделаем подобное действие это десять раз.
for (size_t i = 0; i < 10; i++)
{
int* p = new int;
std::cout << p << std::endl;
delete p;
}
00000211AAD61470
00000211AAD62F40
00000211AAD62EB0
00000211AAD62F40
00000211AAD62FA0
00000211AAD62E00
00000211AAD62E20
00000211AAD62E30
00000211AAD62EE0
00000211AAD62F20
Как мы видим что в рамках разных запусков, что в рамках выделения памяти внутри одного процесса мы получаем адреса, которые превышают максимально возможное значение.
Что же будет если мы попытаемся записать эти данные, очевидно превышающие по объёму 4 байта в эти самые 4 байта типа int?
int a = 1;
int* p = &a;
uint64_t ptrValue8Bytes = (uint64_t)p;
int ptrValue4Bytes = (int)p;
std::cout << std::hex;
std::cout << p << std::endl;
std::cout << ptrValue8Bytes << std::endl;
std::cout << ptrValue4Bytes << std::endl;
0000004A678FFD30
4a678ffd30
678ffd30
Тут я объявляю переменную a, от типа которой ничего не зависит.
Далее указатель p на её адрес.
Потом я кастую этот адрес к типу int приводя его в числовое значение, а так же к типу uint64_t.
Переключаю состояние потока вывода в hex, чтобы числовые значения у нас отображались в шестнадцатеричной системе, вывожу значение указателя p, а так же содержимое переменных ptrValue4Bytes и ptrValue8Bytes.
Что мы видим?
Значение указателя 0000004A678FFD30 и эквивалентное ему значение 4a678ffd30, что является содержимым переменной uint64_t ptrValue8Bytes с размером 8 байт.
Помимо этого мы наблюдаем значение переменной int ptrValue4Bytes - число 678ffd30, которое является результатом преобразования адреса занимающего более чем 4 байта в тип с размером 4 байта.
То есть происходит усечение с отбрасыванием старших бит.
Это поведение ожидаемо и очевидно для Windows+x64.
Мощность множеств
Если подходить к этому вопросу с точки зрения комбинаторики, то всё так же суть остаётся очевидной.
У нас есть множество всех возможных значений 2^64 для 8 байт и множество всех возможных значений 2^32 для 4 байт в рамках одного значения адреса типа uint64_t.
Обозначим множество как , а подмножество как , где .
Каст указателя uint64_t к int это, по сути, отображение:
которое не является взаимно-однозначным и вот почему.
Поскольку:
то, по принципу Дирихле, такое отображение не может быть инъективным.
Инъективность требует, чтобы для любых:
выполнялось:
но при:
это невозможно - элементов в области определения стало больше, чем возможных значений, а значит хотя бы два разных элемента обязаны отобразиться в один и тот же элемент .
Это фундаментальное ограничение, вытекающее из теории множеств и теории информации - нельзя взаимно-однозначно закодировать большее множество в меньшее без потерь.
Специфические модели указателей
Часть битов указателя в определённых случаях может быть зарезервирована под дополнительные данные, что допускается за счёт отсутствия запрета в стандарте C++.
Например в технологии CHERI.
Обычно указатель - это просто адрес, но в случае CHERI указатель в своём object representation содержит не только адрес в памяти (value representation), но и дополнительные данные, которые позволяют процессору контролировать использование указателя.
В этом случае, если мы грубо будем интерпретировать такой указатель как обычное целочисленное, положительное число мы рискуем получить невалидное значение, даже если размеры типов будут идентичны.
Но дело в том, что это не касается Windows+x64, потому что для MSVC/Windows+x64 value representation эквивалентно object representation - все биты участвуют в хранении значения.
Когда это всё таки возможно
Давайте подумаем - когда же всё таки такое возможно?
Случайность
Даже если в теории у вас получится так что все адреса в программе будут при запуске выделены в диапазон 0x00000000 - 0xFFFFFFFF никто не даст вам гарантию, что при следующем они будут выделены в этом же диапазоне и вы не получите какую-либо ошибку.
Но встаёт вопрос - а может ли вообще так произойти?
Как мы видели выше - этого не происходит, кроме этого за этим стоят механизмы ASLR и прочие технические особенности.
То есть на случайность в этом случае рассчитывать не стоит.
Задуманная реализация
Вы можете спроектировать свою реализацию программы так, что у вас значения указателей будут каким-то образом сжиматься или разбиваться на два и хранится в нескольких экземплярах типа int, но это не будет поводом
для обоснования использования типа int для хранения указателя в Windows+x64 в общем случае.
Никто не будет так делать в основной реализации функционала и код так не пишут.
Режим совместимости x86
Как я уже говорил, мы можем скомпилировать код в режиме совместимости с x32 - WOW64, Windows 32-bit On Windows 64-bit.
int a = 1;
int* p = &a;
uint64_t ptrValue8Bytes = (uint64_t)p;
int ptrValue4Bytes = (int)p;
std::cout << std::hex;
std::cout << p << std::endl;
std::cout << ptrValue8Bytes << std::endl;
std::cout << ptrValue4Bytes << std::endl;
009FFA84
9ffa84
9ffa84
В этом случае все адрес может быть записан в тип int размером 4 байта.
Но большая часть рынка это Windows+x64 и Android+ARM, а не Windows+x32.
Следовательно возможность есть, но вам всё равно придётся использовать операционную систему и режим, в котором int занимает 4 байта, а указатель может быть 8 байт.
Другие ABI, архитектуры, операционные системы
Стандарт C++ не гарантирует размер указателя или размер типа int.
Размер типа int и большинства других типов зависит от ABI.
Один из примеров различия в размере типов между ABI это тип long на Windows x64 и Linux x64.
На Linux x64 sizeof(long) это 8 байт, а на Windows x64 sizeof(long) это 4 байта.
Но на тип int это не влияет - его размер составляет 4 байта в обоих случаях.
Теоретически может существовать какое-то ABI, ОС и компиляторы, где для хранения int будет выделено 8 байт, но такого на данный момент не существует и скорее всего - примеры не скоро появятся.
Следует ли из этого, что можно в типе int гарантированно хранить указатель в общем случае на Windows+x64?
Конечно нет, к ситуации Windows+x64 это не имеет никакого отношения.
Фактическое положение дел
Теоретически - да, вы можете использовать этот тип, как было указано для случаев выше.
Практически - нет, вы не будете использовать этот тип, потому что вы не находитесь ни в одном из вышеперечисленных случаев.
Общий контекст - использование C++ в разработке софта для игр, которые работают на Windows+x64 или Android+ARM и у вас не получится в здравом уме писать код используя для хранения указателя тип int с размером 4 байта.
Что использовать, если int не подходит
Для хранения адресов из игр или приложений в Windows+x64 нужно использовать соответствующие типы, которые зависят от того - запущено ли приложение в режиме совместимости с x32.
uint32_t
Этот тип предназначен именно для хранения адреса приложений скомпилированных в режиме, где размер указателя составляет 4 байта, самый очевидный пример - игра AssaultCube.
uint64_t / uintptr_t
Эти два типа являются основными по следующим причинам
uintptr_tизначально был создан для обеспечения кроссплатформенности и переносимости кода между архитектурами/операционными системами.uint64_tимеет размер 8 байт и в случае Windows+x64 с размером указателя в 8 байт и отсутствием различия в репрезентациях представления - может быть использован для описанной задачи.
Если мы посмотрим на то, как определены эти типы, то мы увидим следующее
typedef unsigned __int64 uintptr_t;
typedef unsigned long long uint64_t;
__int64 и long long в MSVC - это разные типы языка, хотя на платформе Windows+x64 они имеют одинаковый размер и одинаковое представление.
__int64 это расширение Microsoft, появившееся до стандартизации long long в C++.
Когда в языке появился стандартный тип long long, Microsoft не стали делать его псевдонимом __int64.
Вместо этого компилятор поддерживает оба типа.
Исторически Windows SDK определял типы через расширения компилятора - __int64, а потом появился <cstdint>, который определил фиксированные типы через стандартные типы.
Получилось, что разные заголовки используют разные базовые типы.
Однако с точки зрения языка это не обязан быть один и тот же тип, но в контексте Windows+x64 это не имеет значения.
Какой тип использую я?
Я предпочитаю явно указывать размер адреса и использовать uint32_t или uint64_t в зависимости от ситуации.
В случае Windows+x64 и разработке читов, очевидно, нет смысла использовать uintptr_t потому что написанный код пишется для конкретной игры, которая запускается на конкретной операционной системе, выполняющейся на конкретной архитектуре с конкретным ABI, который не меняется в случайный момент времени.
По моим наблюдениям - люди на форумах так же используют оба этих типа.
Пример использования типа uint64_t можно наблюдать в этой ситуации
uint64_t networkable_key(uint64_t pointer)
{
uint64_t value = memory_interface::read<uint64_t>(pointer + 0x18);
uint32_t* data = (uint32_t*)&value;
uint32_t count = 2;
do
{
uint32_t x = *data;
uint32_t y = (x << 21) | (x >> 11);
uint32_t result = (y ^ 0xDA053B90) - 0x4D1B2722;
*data = result;
data++;
--count;
} while (count);
return Il2cppGetHandle(value);
}
Стоит отметить, что в этой ситуации тип uint64_t используется вместе с типом uint32_t, потому что исходное значение является 64х-битным объектом, но алгоритм обработки работает поблочно по 32 бита.
То есть данные читаются по указателю uint64_t pointer, а уже потом из-за алгоритмической задачи появляется необходимость использовать 32х-битный тип.
По моему мнению, в этой ситуации (и большинстве других) использование типа uintptr_t меньше вписывалось бы в стилистику кода с точки зрения семантики, чем использование типа uint64_t.
Тем не менее ситуации с uintptr_t так же присутствуют, несмотря на то, что тут записывается не адрес, а смещение
constexpr uintptr_t GameManager = 0xD4A3048;
constexpr uintptr_t StaticFields = 0xb8;
constexpr uintptr_t Instance = 0x40;
constexpr uintptr_t PrefabPoolCollection = 0x18;
constexpr uintptr_t Dictionary = 0x10;
constexpr uintptr_t Stack = 0x28;
constexpr uintptr_t Size = 0x18;
Заключение
Смело используйте int*, uint64_t и uintptr_t для хранения адресов памяти в Windows+x64.