Адреса и указатели это база работы с памятью, без которой нельзя объяснить динамическую память и динамические массивы.
Указатели также связаны с тем, что мы уже рассматривали при изучении функций - передачей данных по значению, по указателю и по ссылке.
Эта тема может быть сложной для новичков, но в контексте работы с памятью это необходимые знания.
Шестнадцатеричная система счисления
Перед изучением адресов нужно разобраться с 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.