View Categories

Адреса и указатели

В C/C++ возможна работа с памятью и адресами напрямую из программы, что позволяет писать очень эффективные и быстрые штуки. В языках более высокого уровня такой возможности нет - там всё устроено более абстрактно и безопасно. На Си можно очень просто сломать программу, неаккуратно работая с памятью, либо допустить уязвимость, которой может воспользоваться хакер.

Адрес #

Если проводить аналогию, то память - это многоквартирный дом, одна квартира - это один байт, каждая квартира имеет свой номер (адрес) по порядку. В зависимости от своего размера, переменная может занимать как одну квартиру, так и несколько соседних. При создании переменной выделяется необходимая память - переменная "заселяется" в квартиры и получает адрес в памяти - адрес первой квартиры, то есть первого байта из занимаемых переменной. По этому адресу можно прочитать данные, зная их размер, то есть тип. В программе мы об этом не задумываемся и просто используем имя переменной - компилятор сделает всю работу сам.

Максимальный адрес определяется количеством квартир объёмом оперативной памяти в байтах, так что если мы захотим адрес где-то сохранить - нужен соответствующий тип данных. Размер адреса зависит от платформы, например на МК с небольшим количеством памяти (AVR) это 2 байта, на тех же ESP8266/ESP32 - 4 байта.

Указатель #

Указатель (pointer) - специальная переменная, которая хранит адрес в памяти. Синтаксис создания указателя: тип* имя, также возможны комбинации с отступами тип *имя, тип * имя, тип*имя - это всё одно и то же. Компилятор должен знать, какие данные расположены по этому адресу, чтобы корректно к ним обращаться. Например int* - это указатель на адрес, по которому лежат данные типа int.

Вес самого указателя не зависит от типа данных, на который он указывает, а зависит от платформы, как было написано выше. Таким образом например sizeof(char*) равен sizeof(long*). Размер целочисленного типа данных size_t совпадает с размером указателя на текущей платформе

В отличие от ссылки, указатель может быть не инициализирован, а также может менять своё значение (адрес) в процессе работы программы. Указатели могут приравниваться к другим указателям такого же типа (как переменные), значение указателя - адрес, он и передаётся в этом случае:

int* ptr;   // указатель на int
int* ptr2;

ptr = ptr2; // ptr теперь указывает туда же, куда ptr2

Несколько указателей #

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

int *a, *b, *c;

Адрес переменной #

Чтобы получить адрес переменной, используется оператор взятия адреса &, который пишется перед её именем. Оператор вернёт адрес в формате указателя на тип этой переменной, например:

int a;
&a;     // имеет тип int* - указатель на int
//(int)&a - численно равно адресу в памяти, байт

&имя - это адрес в памяти переменной с именем "имя"

Соответственно теперь можно создать указатель и записать в него этот адрес:

int a;
int* ptr = &a;  // ptr указывает на a

int b;
ptr = &b;       // ptr теперь указывает на b

int val = (int)ptr;
// здесь val численно равен адресу в памяти, например 2143

Указатель можно получить и из ссылки на переменную:

int a;
int& ref = a;       // ссылка на a
int* ptr = &ref;    // указатель на a

Доступ по указателю #

Чтобы обратиться к данным, на которые указывает указатель, нужно использовать оператор косвенного доступа - *. Он пишется перед именем указателя и по сути возвращает ссылку на переменную этого типа, по которой можно писать и читать сами данные, как если бы это была обычная переменная. Этот процесс называется разыменованием указателя, т.е. превращением его в ссылку.

*указатель - это значение, которое находится в памяти по адресу, записанному в "указатель"

int a = 0;
int* ptr = &a;      // ptr указывает на a

*ptr = 123;         // меняем a по указателю ptr
// здесь a равно 123

int b = *ptr;       // читаем a в переменную b
// здесь b тоже равно 123

int& ref = *ptr;    // ссылка на a, получена из указателя
ref = 456;          // меняем a по ссылке
// здесь a равно 456

int* ptr2;
ptr2 = &ref;        // новый указатель на a, получен из ссылки

int* ptr3 = ptr;    // новый указатель на a, получен из указателя

Указатели и ссылки связаны друг с другом и могут "конвертироваться" друг в друга

Указатель на адрес #

Синтаксис языка не запрещает присвоение указателю любого адреса напрямую числом, для этого число нужно вручную привести его к типу указателя:

int* ptr = (int*)12345;
*ptr = 123;
// это сломает программу =)

Делать так не нужно, но нужно понимать, как оно работает.

Нулевой указатель #

Какое значение имеет глобально созданный указатель? Нулевое, как и глобальная переменная, по сути - нулевой адрес в памяти. Иногда бывает удобно присваивать нулевой адрес для дальнейшей проверки в программе - корректный это указатель или нет: нулевой указатель будет false при проверке в условии, а ненулевой - true, как и обычное число:

// проверяем, корректный ли указатель
if (ptr) {
    *ptr = 123;
}

Разыменование нулевого указателя приведёт к неопределённому поведению - скорее всего сломает программу

Указателю можно присваивать только адрес такого же типа, как и он сам, поэтому нужно указать нулевой адрес и вручную привети тип:

//int* ptr = 0;     // ошибка, 0 это int, а не int*
int* ptr = (int*)0;

Чтобы удобно присваивать указателям нулевой адрес, есть специальное ключевое слово - nullptr (нулевой указатель). Это нулевой указатель универсального типа, его можно присвоить любому указателю без преобразования:

int* ptr1 = nullptr;
float* ptr2 = nullptr;

//*ptr1 = 123;  // катастрофа

В языке Си для этого используется константа NULL, в C++ же есть nullptr - константа особого типа данных. Не используйте NULL в cpp проекте!

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

void foo(int* ptr);         // #1

int a;
foo(&a);        // вызов #1
foo(nullptr);   // вызов #1
void foo(int* ptr);         // #1
void foo(nullptr_t ptr);    // #2

int a;
foo(&a);        // вызов #1
foo(nullptr);   // вызов #2

"Висячий" указатель #

Может возникнуть ситуация, в которой переменная, на которую указывал указатель, уже удалена, а сам указатель - нет:

int* ptr;

{
    int a;
    ptr = &a;
}

// ...

*ptr = 123;     // катастрофа

Доступ к такому указателю может привести к неопределённому поведению, например в памяти по этому адресу появилась уже другая переменная, а мы туда запишем значение и сломаем программу. Если указатель "живёт" дольше, чем переменная, и может принимать разные адреса для работы, то обычно принято "сбрасывать" его в nullptr при удалении самой переменной - для дальнейшей проверки "доступности" указателя. Исправим предыдущий абстрактный пример:

int* ptr = nullptr;

{
    int a;
    ptr = &a;
    // ...
    ptr = nullptr;
}

// ...

if (ptr) *ptr = 123;    // запись только по валидному указателю

Константы #

Указатель на константу #

Можно создать указатель, по которому нельзя изменить сами данные:

int a;
const int* ptr = &a;    // ptr указывает на a

//*ptr = 123;   // ошибка компиляции

int b;
ptr = &b;       // ptr указывает на b

Const указатель #

Можно создать константный указатель, в который нельзя записать другой адрес. Как и const переменная, такой указатель должен быть инициализирован. Синтаксис следующий:

int a;
//int* const ptr;       // ошибка компиляции, не инициализирован
int* const ptr = &a;    // ptr указывает на a

*ptr = 123;     // меняем a

int b;
//ptr = &b;     // ошибка компиляции

Const const указатель #

Также можно соединить эти варианты и сделать указатель, у которого нельзя менять ни данные, ни сам указатель:

int a;
const int* const ptr = &a;

//*ptr = 123;   // ошибка компиляции

int b;
//ptr = &b;     // ошибка компиляции

const T* - указатель на константу, а T* const - константный указатель, константа типа "указатель на T"

Отбрасывание const #

C/C++ очень гибкий язык и позволяет делать страшные вещи - например проигнорировать const, но это придётся сделать вручную:

const int a = 123;

const int* ptr = &a;
//*ptr = 123;           // ошибка, менять константу нельзя

int* ptr2 = &a;         // ошибка, неконстантный указатель

// а так ошибки не будет
int* ptr3 = (int*)&a;   // привели вручную к неконстантному типу
*ptr3 = 456;
// но программа скорее всего сломается

Такая возможность есть, но использовать её не нужно. Но можно встретить в чужом коде и понимать, что происходит.

Другие особенности #

Инкремент и декремент #

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

uint8_t arr[] = {1, 2, 3, 4};
uint8_t* p = arr;

// инкрементировать ДАННЫЕ по указателю
(*p)++;     // arr == {2, 2, 3, 4}
++(*p);     // arr == {3, 2, 3, 4}

// инкрементировать УКАЗАТЕЛЬ (адрес) и получить доступ к данным
*p++;   // == *(p++) - доступ к *p, прошлому адресу
*++p;   // == *(++p) - доступ к *(p + 1), по следующему адресу

Инициализация #

Как и локальная переменная, локально созданный указатель имеет по сути случайное значение, случайный адрес. Запись по такому указателю сломает программу или приведёт к её непредсказуемому поведению:

{
    int* ptr;
    *ptr = 123;     // катастрофа
}

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

Указатель на указатель #

Указатель может указывать на другой указатель, синтаксис будет такой:

int a;
int* ptr = &a;      // указывает на переменную a
int** pptr = &ptr;  // указывает на указатель ptr

**pptr = 123;       // запись в переменную a по указателю на указатель
// здесь a равно 123

int b;
*pptr = &b;         // ptr теперь указывает на переменную b
*ptr = 456;         // запись в переменную b по указателю
// здесь b равно 456

**pptr = 789;       // запись в переменную b по указателю на указатель
// здесь b равно 789

Другой тип #

Указатель не может принять адрес данных другого типа - будет ошибка компиляции:

int a;
//long* ptr = &a;   // ошибка компиляции

В то же время тип можно привести вручную, что позволяет обращаться к памяти очень гибко, полностью в ручном режиме:

char a;
long* ptr = (long*)&a;

*ptr = 123; // это сломает программу =)
// так как мы запишем тип long, который не поместится в 1 байт
// компилятор не сможет это предотвратить и лишние байты также запишутся в память

Указатель на void #

Как говорилось выше, указатель принимает только адрес своего типа. Здесь особую роль играет тип void - указатель этого типа может принять адрес любого типа без дополнительного преобразования. А вот разыменовать указатель на void нельзя - его сначала нужно привести к конкретному типу:

int a;
void* ptr = &a;

//*ptr = 123;   // ошибка компиляции

int* iptr = (int*)ptr;
*iptr = 123;
// здесь a равна 123

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

Трюки #

Доступ к байтам #

При помощи доступа по указателю можно прочитать отдельно байты другой переменной - это будет быстрее математических операций. Указатель хранит в себе число и ведёт себя как обычная переменная - если прибавить к нему число - адрес сместится. Причём сместится на размер типа данных, на который он указывает:

uint32_t var = 0xabcdef12;

uint8_t* bytes = (uint8_t*)&var;
*(bytes + 0);   // == 0x12
*(bytes + 1);   // == 0xEF
*(bytes + 2);   // == 0xCD
*(bytes + 3);   // == 0xAB

uint16_t* bytes16 = (uint16_t*)&var;
*(bytes16 + 0); // == 0xEF12
*(bytes16 + 1); // == 0xABCD

Вот здесь как раз играет роль endianness - порядок байтов зависит от конкретной платформы.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх