Адреса и указатели это база работы с памятью, без которой нельзя объяснить динамическую память и динамические массивы.

Указатели также связаны с тем, что мы уже рассматривали при изучении функций - передачей данных по значению, по указателю и по ссылке.

Эта тема может быть сложной для новичков, но в контексте работы с памятью это необходимые знания.

Шестнадцатеричная система счисления

Перед изучением адресов нужно разобраться с HEX представлением чисел.

HEX (Hexadecimal) - это обозначение шестнадцатеричной системы счисления.

В программировании мы чаще всего сталкиваемся с десятичной, шестнадцатеричной и двоичной системами счисления.

Для записи числа в шестнадцатеричной системе используются цифры от 0 до 9 и символы от A до F:

0 1 2 3 4 5 6 7 8 9 A B C D E F

После 9 идут A, B, C, D, E, F.

Вывод в HEX

Чтобы выводить числа в таком виде нам нужно переключить состояние потока вывода используя std::hex.

int a = 9;
std::cout << a << std::endl;
std::cout << std::hex << a << std::endl;
std::cout << std::dec;
std::cout << a << std::endl;
9
9
9

В консоли мы наблюдаем три числа 9, потому что 9 в десятичной записывается точно так же, как и шестнадцатеричной.

Ситуация изменится если мы выведем число 10.

int a = 10;
std::cout << a << std::endl;
std::cout << std::hex << a << std::endl;
std::cout << std::dec;
std::cout << a << std::endl;
10
a
10

Тут, если поток вывода переключен в HEX - числа будут выведены в шестнадцатеричной системе счисления.

Мы переключаем состояние потока вывода используя std::hex и std::dec. Как вы видите это можно сделать даже без вывода числа и переноса линии, как показано в конструкции std::cout << std::dec;.

После переключения поток продолжает выводить числа в выбранной системе счисления, пока его состояние не будет изменено снова.

Запись в коде в HEX

В C++ для обозначения шестнадцатеричного литерала используется префикс 0x.

int main() {
    int a = 10;
    int b = 0x10;
    std::cout << a << std::endl;
    std::cout << b << std::endl;
    std::cout << std::hex;
    std::cout << a << std::endl;
    std::cout << b << std::endl;
}
10
16
a
10

Тут числа 10 и 0x10 это совершенно разные значения, потому что первое записано в десятичной системе счисления, а второе - шестнадцатеричной.

При выводе в десятичной системе мы получаем 10 и 16.

После переключения потока вывода в HEX мы уже наблюдаем те же самые числа, но в шестнадцатеричной системе счисления.

Стоит отметить, что нет разницы как записывать числа - это дело контекста и удобства. В памяти они хранятся одинаково несмотря на то как они были записаны в коде.

Использование букв в записи допускается только если использован префикс 0x или 0X, регистр при этом не важен.

int main() {
    int a = 0xFF; // ok
    int b = 0XFF; // ok
    int c = 0Xff; // ok
    int d = FF; // identifier "FF" is undefined
}

Шестнадцатеричная система особенно удобна при работе с адресами, потому что позволяет компактно записывать большие числа.

Что такое адрес

Адрес - это число, обозначающее конкретный байт в памяти, где находятся данные.

Важно: адресуется именно байт, а не бит.

Один байт равен восьми битам.

У каждого байта есть свой адрес.

У отдельного бита собственного адреса нет.

У каждой переменной в памяти есть свой адрес, его можно получить используя знак амперсанда &.

int main() {
    int a = 5;
    std::cout << &a << std::endl;
    std::cout << (uint64_t)&a << std::endl;
    std::cout << std::hex << (uint64_t)&a << std::endl;
}
0000006D4AEFFB84
469408676740
6d4aeffb84

Здесь мы вывели одно и то же число в разных его представлениях.

0000006D4AEFFB84 - стандартный тип вывода при выводе указателя через std::cout. Выводится в шестнадцатеричной системе вне зависимости от текущего состояния.

469408676740 - тот же самый адрес, но в десятичной системе.

6d4aeffb84 - всё ещё адрес, только в шестнадцатеричной и выведенный, как обычное число типа uint64_t.

Конкретный адрес зависит от запуска программы.

Если вы запустите скомпилированную программу вне Visual Studio и во вкладке Memory View перейдёте по этому адресу, то вы там увидите число 5.

Адрес как число

С типами uint64_t, uint32_t, uintptr_t и подобными мы познакомились ранее, но до этого не применяли их на практике.

С ними тесно связана разработка, в первую очередь, external читов.

При использовании этого подхода мы работаем с адресами вне нашего приложения, следовательно, эти адреса нужно где-то хранить.

Для этого мы и используем эти типы, но надо быть аккуратнее.

int main() {
    int a = 0xA;
    std::cout << &a << std::endl;

    uintptr_t addressOfA_ptr = (uintptr_t)&a;
    uint64_t addressOfA_64 = (uint64_t)&a;
    uint32_t addressOfA_32 = (uint32_t)&a;

    std::cout << std::hex;
    std::cout << addressOfA_ptr << std::endl;
    std::cout << addressOfA_64 << std::endl;
    std::cout << addressOfA_32 << std::endl;

    return 0;
}
00000012C6FBF964
12c6fbf964
12c6fbf964
c6fbf964

При преобразовании адреса в число мы можем получить усечение из-за размера типа.

Это происходит при использования, в частности, типа uint32_t из-за его размера в 4 байта.

Для хранения указателей в Windows+x64 стоит использовать два типа uintptr_t и uint64_t, поскольку они имеют размер 8 байт.

В теории мы можем использовать тип unsigned long long, поскольку тип uint64_t определён в MSVC (Windows+x64) следующим образом typedef unsigned long long uint64_t;.

Но всё же из-за компактности записи и семантического значения типа стоит использовать именно указанные выше типы.

Тип uint32_t так же может быть использован, но только при промежуточных вычислениях (или в режиме совместимости с x32), где его размера достаточно.

Что такое указатель

Указатель - это переменная, которая хранит адрес другого объекта в памяти.

Указатели используются для:

  • работы с динамической памятью
  • передачи данных без копирования
  • работы со сложными структурами данных
  • реализации связанных списков, деревьев, графов
  • непосредственной работы с памятью.

Без указателей было бы сложно объяснять динамическую память, по этой причине эта тема находится именно здесь.

Такой тип представления адресов используется в internal реализациях читов, поскольку механика разыменовывания подразумевает обращение к памяти собственного адресного пространства.

Указатель имеет тип. Например, указатель на int:

int* ptr;

Аналогично существуют указатели на другие типы:

float* ptr;
double* ptr;

Если у нас есть переменная, то мы можем объявить указатель на неё с использованием &.

int a = 10;
int* ptr = &a;

std::cout << a << std::endl;
std::cout << &a << std::endl;
std::cout << ptr << std::endl;
10
0000006EA65DF7A4
0000006EA65DF7A4

Здесь:

  • int* - тип указателя;
  • ptr - имя указателя;
  • &a - адрес переменной a.

Нельзя просто записать значение a в int*:

int main() {
    int a = 10;
    //a value of type "int" cannot be used 
    //to initialize an entity of type "int *"
    int* ptr = a;
}

Потому что a имеет тип int, а ptr должен получить тип int*.

Разыменование указателя

Мы можем получать значение по указателю.

int a = 10;
int* ptr = &a;

std::cout << a << std::endl;
std::cout << *ptr << std::endl;
10
10

С помощью оператора * мы разыменовываем указатель и тем самым получаем значение по адресу &a.

Получение адреса и разыменование являются взаимосвязанными операциями:

Операция &a возвращает адрес a.

Операция *&a возвращает значение a.

То есть при инициализации int* ptr = &a; выражения &a и &*ptr будут выдавать один и тот же адрес.

Переназначение указателя

Указатель можно переназначить, чтобы он указывал на другой объект.

int main() {
    int a = 10;
    int b = 20;
    int* ptr = &a;
    std::cout << *ptr << std::endl;
    ptr = &b;
    std::cout << *ptr << std::endl;
    return 0;
}
10
20

Сначала ptr указывает на переменную a, затем на b.

Изменение значения через указатель

Через указатель можно взаимодействовать с объектом, на который он указывает.

Помимо чтения значения мы можем его изменять.

int main() {
    int a = 10;
    int* ptr = &a;
    std::cout << a << std::endl;
    *ptr += 1;
    std::cout << a << std::endl;
    return 0;
}

Результат:

10
11

Мы не обращались к a напрямую при изменении значения.

Вместо этого использовали указатель и его разыменование.

Это называется косвенным доступом.

int* и uintptr_t

Между int* (указателем на int) и uintptr_t есть важное различие.

int* ptr; это типизированный указатель. Мы знаем, что он указывает на int.

uintptr_t address; это числовое представление адреса.

Сам тип uintptr_t, как инструмент языка, не содержит в себе информации на какой тип данных этот адрес указывает.

Это приводит к важному различию.

Для int* компилятор знает размер объекта, на который указывает указатель, поэтому ptr + 1 сдвигает адрес на sizeof(int) байт. uintptr_t - обычное целое число, и address + 1 сдвинет адрес ровно на один байт.

Масштабирование на размер типа в этом случае нужно делать вручную.

Оба типа используются в разработке вне контекста читов, более того - они могут быть нужны одновременно в соседних участках кода для решения разных задач.

Размеры типов и хранение адресов

Предположим, у нас есть int a.

Можно представить адрес этой переменной в разных типах:

int main() {
    int a = 10;

    int a_IntPtr = (int)&a;
    long long a_LongLongPtr = (long long)&a;

    uint64_t a_uint64_t_Ptr = (uint64_t)&a;
    uintptr_t a_uintptr_t_Ptr = (uintptr_t)&a;
    int* aPtr = &a;

    std::cout << std::dec << "int\t" << sizeof(a_IntPtr) << "\t" << std::hex << a_IntPtr << std::endl;
    std::cout << std::dec << "long long\t" << sizeof(a_LongLongPtr) << "\t" << std::hex << a_LongLongPtr << std::endl;
    std::cout << std::dec << "uint64_t\t" << sizeof(a_uint64_t_Ptr) << "\t" << std::hex << a_uint64_t_Ptr << std::endl;
    std::cout << std::dec << "uintptr_t\t" << sizeof(a_uintptr_t_Ptr) << "\t" << std::hex << a_uintptr_t_Ptr << std::endl;
    std::cout << std::dec << "int*\t" << sizeof(aPtr) << "\t" << std::hex << aPtr << std::endl;

    return 0;
}
int     4       b90ffbf4
long long       8       79b90ffbf4
uint64_t        8       79b90ffbf4
uintptr_t       8       79b90ffbf4
int*    8       00000079B90FFBF4

Вариант с long long в теории будет работать, но при написании кода это хуже читается и тем более тип используется не по назначению - он не сообщает, что число является адресом.

Вариант с int не нужно использовать вообще, часть адреса 79 теряется из-за усечения, потому что указатель занимает 8 байт.

Для хранения адреса в числовом виде используются uintptr_t или, в соответствующем 64-битном контексте, uint64_t.

На Windows+x64 int занимает 4 байта, а указатель - 8 байт.

Поэтому при преобразовании адреса в int старшие 32 бита адреса будут потеряны.

Для типизированного доступа используется указатель int* ptr;

Таким образом, последние три варианта (uint64_t, uintptr_t и int*) являются правильными для тех случаев, для которых предназначены.

Арифметика указателей

Арифметика указателей - это правила выполнения операций сложения и вычитания с указателями, позволяющие перемещаться по памяти.

int main() {
    int a[5] = { 2, 8, 3, 7, 4 };
    int* ptr = a;
    std::cout << *ptr << std::endl;
    ptr = ptr + 1;
    std::cout << *ptr << std::endl;
    return 0;
}
2
8

Обратите внимание, что здесь не используется &, потому что имя массива в таком выражении распадается в указатель на его нулевой элемент.

Оформите подписку, чтобы иметь доступ ко всем публикациям без ограничений. Вы получите мгновенный доступ к полным версиям материалов, исходному коду и новым публикациям сразу после их выхода.

Я регулярно публикую материал и разборы ситуаций для различных игр, движков, а так же подходов разработки включая Internal, External и DMA.

Эта публикация содержит только часть статьи.

Полные уроки по C++, реверс-инжинирингу и разработке софта вы можете найти тут: