Указатель - про указатель в языке СИ
Здравствуйте.
Забегая вперёд скажу, указатель это очень
Для лучшего понимания, вначале мы разберёмся с «обычными» переменными (впрочем указатель, это тоже обычная переменная, но пока мы условно разделим эти понятия).
Переменная
Итак, у нас есть переменные char, uint16_t, uint32_t, и прочие. Всё это «типизированные» переменные, то есть переменные хранящие определённый тип данных. Переменная char (8 бит) хранит однобайтовое число/символ, uint16_t (16 бит) хранит двухбайтовое число, и uint32_t (32 бита) хранит четырёхбайтовое число.
Теперь разберёмся что значит «переменная хранит» и как это вообще выглядит внутри «железа». Напомню, что бы мы не делали в компьютере или микроконтроллере, мы всего лишь оперируем значениями в ячейках памяти (ну или регистрами в случае с микроконтроллером).
Предположим что мы объявили и инициализировали (то есть записали в них значения) две переменные…
char sim = 'a';
uint16_t digit = 2300;
Что это за переменные, глобальные или нет значения не имеет, пускай будут глобальными.
Представим себе небольшой кусочек памяти компьютера где-то ближе к началу…
Клетки это ячейки памяти, а цифры это номера ячеек, то есть адреса. В каждой ячейке может храниться один байт данных (8 бит). Когда мы хотим обратится к тем или иным данным находящимся в памяти, мы обращаемся к ним по нужным нам адресам.
Но вот вопрос, откуда же мы знаем эти самые, нужные нам адреса, а ответ очень прост. Когда мы создали переменную
После того как мы инициализировали переменную значением
И стало так…
Когда же мы создали переменную
Если мы захотим получить не содержимое ячейки, а её адрес, то для этого есть специальная конструкция, но об этом позже.
Я специально подчеркнул две фразы ибо в них кроется ключевой смысл отличающий «обычную» переменную от указателя, поэтому повторю —
Указатель
Наконец пришло время дать определение указателю.
Указатель это переменная, которая хранит в себе не какие-то данные (как это делает «обычная» переменная), а адрес какой-либо ячейки памяти, то есть
Указатель объявляется так же как и «обычная» переменная, с той лишь разницей, что перед именем ставиться звёздочка…
char *ptr = NULL;
Тут стоит отметить, что при использовании указателя, звёздочка выступает в двух ипостасях, первая это как сейчас, при объявлении, а про вторую мы узнаем позже.
И да, звездочку можно ставить как угодно…
char *ptr = NULL;
char * ptr = NULL;
char* ptr = NULL;
Теперь увеличим наш кусочек памяти на две ячейки, чтобы было удобнее…
… и рассмотрим что же произойдёт внутри системы после объявления указателя.
Компилятор выделил в памяти четыре ячейки для указателя, например 5681, 5682, 5683 и 5684 (см. ниже).
Размер указателя в современных компьютерах бывает либо 32-ух битный (4 байта), либо 64-ёх битный (8 байт), так как он должен хранить в себе какой-то адрес памяти, к которому мы будем обращаться через этот указатель.
Таким образом в первом случае мы можем адресовать
Размер указателя связан отчасти с разрядностью ОС, отчасти с шиной данных, отчасти от режима компилятора (эмуляция 32-ух битных программ на 64-ёх битных системах), и ещё чёрт знает от чего, нам это совершенно не важно.
И так же как и в случае с «обычными» переменными, компилятор ассоциировал имя
что такое NULL
В результате мы создали указатель, который хранит адрес нулевой ячейки памяти, то есть
Главное отличие указателя от «обычной» переменной: если бы
Зачем же мы инициализировали наш указатель нулём, ведь обращение к нулевому адресу привело бы к мгновенному падению программы? Всё очень просто, давайте представим что мы объявили указатель без инициализации...
char *ptr;
Тогда в ячейках 5681, 5682, 5683 и 5684 скорее всего оказался бы какой-то «мусор» (какие-то бессмысленные цифры), и если бы мы в дальнейшем забыли присвоить указателю какой-то конкретный, нужный нам, адрес, и потом обратились бы к этому указателю, то скорее всего «мусор» оказался бы каким-то адресом, и мы сами того не зная случайно что-то сделали с хранящимися по этому адресу данными. Во что бы это вылилось неизвестно, скорее всего программа не упала бы сразу, а накуролесила страшных делов в процессе работы. Поэтому пока мы не присвоили указателю какого-то конкретного адреса, мы его «занулили» для собственной безопасности.
Итак, прежде чем двигаться дальше подобьём итоги: указатель это 32-ух битная (или 64-ёх битная) переменная, которая хранит в себе не данные, а адрес какой-то одной ячейки памяти.
Типы
Теперь разберёмся с типами, на которые указывает указатель. Сейчас мы создали указатель с типом
Важно! Мы должны присваивать указателю адрес переменной того же типа что и сам указатель (ниже объясню почему). То есть мы не можем нашему указателю присвоить адрес переменной
uint16_t *ptr = NULL;
ptr = &digit;
То же самое касается и других типов переменных. Например для типа
float my_float = 34.0;
float *ptrf = NULL;
ptrf = &my_float;
Ниже мы ещё вернёмся к переменной
Присваивание адреса указателю и «взятие адреса» обычной переменной
Далее давайте присвоим нашему указателю конкретный адрес, на который он будет указывать.
Мы хотим сделать так чтобы наш указатель указывал на ячейку памяти, в которой храниться значение переменной
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = sim;
Указатель должен хранить адрес, а мы пытаемся запихать в него символ
Нам нужно узнать адрес ячейки в которой лежит символ
ptr = ∼
Эта операция называется "
То есть наша программа будет выглядеть так…
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
Теперь наш указатель хранит адрес ячейки (5676), в которой храниться символ
Если добавим вот такой вывод на печать…
printf("Var sim %c\n", sim);
printf("Adr sim %p\n", &sim);
printf("Ptr sim %p\n", ptr);
… то получим искомые данные…
Переменная
Здесь, и ниже, на картинках, у меня 64-ёх битный указатель — не обращайте на это внимание. Просто я поленился рисовать восемь клеточек на схемах выше.
Вот тоже самое, только выполнено на микроконтроллере stm32...
Здесь указатель 32-ух битный.
Разыменования указателя
Теперь разберёмся с ещё одной важной вещью. Выше я писал что при работе с указателем звёздочка выступает в двух ипостасях, с первой мы познакомились, это объявление указателя, а вторая это получение данных из ячейки на которую указывает указатель, или запись данных в эту ячейку. Это называется «разыменование указателя».
По сути это действо обратно «взятию адреса» обычной переменной, только вместо амперсанда используется звёздочка, а вместо имени переменной, имя указателя.
Для примера создадим ещё одну переменную типа
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
char sim2; // новая переменная
sim2 = *ptr; // записываем в новую переменную символ 'a' с помощью "разыменования указателя"
printf("Var sim2 %c\n", sim2);
Результат будет таков…
Мы прочитали значение из ячейки на которую указывает указатель, и записали его в переменную
Разыменование указателя работает в обе стороны, то есть мы можем не только прочитать значение, но и записать в разыменованный указатель. То есть мы запишем новое значение в ячейку на которую указывает указатель.
Изменим наш пример…
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
printf("Var sim %c\n", *ptr); // выводим старое значение (с помощью разыменования указателя)
*ptr = 'b'; // записываем новое значение в разыменованный указатель
printf("Var sim %c\n", *ptr); // выводим новое значение
Смотрим что получилось…
В результате наш указатель будет по прежнему указывать на ту же ячейку 5676, но значение в этой ячейке изменилось. То есть изменилось значение переменной
Можем в функциях
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
printf("Var sim %c\n", sim); // выводим старое значение
*ptr = 'b'; // записываем новое значение в разыменованный указатель
printf("Var sim %c\n", sim); // выводим новое значение
Результат прежний…
Термин «разыменованный указатель» вовсе не означает что указатель куда-то пропадает из-за того что мы «лишили его имени» и теперь он где-то бродит безымянный и неприкаянный, нет, просто это такой не самый удачный термин, а указатель как был так остаётся указателем со своим именем.
Если хотим изменить адрес на который указывает указатель, тогда просто присваиваем указателю новый адрес. Для примера поочерёдно присвоим одному и тому же указателю адреса разных переменных…
char sim1 = 'a';
char sim2 = 'b';
char sim3 = 'c';
char *ptr = NULL;
ptr = &sim1;
printf("Var sim1 %c, Adr %p\n", sim1, ptr); // адрес переменной sim1
ptr = &sim2;
printf("Var sim2 %c, Adr %p\n", sim2, ptr); // адрес переменной sim2
ptr = &sim3;
printf("Var sim3 %c, Adr %p\n", sim3, ptr); // адрес переменной sim3
Сколько раз хотим столько раз и меняем адреса. Разумеется типы всех переменных должны быть
Результат…
Видно что указатель указывает на три разных адреса трёх наших переменных. Заодно видим что переменные расположились в памяти друг за дружкой.
Ну и конечно можем для каждой переменной создать свой указатель…
char sim1 = 'a';
char sim2 = 'b';
char sim3 = 'c';
char *ptr1 = NULL;
char *ptr2 = NULL;
char *ptr3 = NULL;
ptr1 = &sim1;
printf("Var sim1 %c, Adr %p\n", sim1, ptr1); // адрес переменной sim
ptr2 = &sim2;
printf("Var sim2 %c, Adr %p\n", sim2, ptr2); // адрес переменной sim2
ptr3 = &sim3;
printf("Var sim3 %c, Adr %p\n", sim3, ptr3); // адрес переменной sim3
Поскольку в использовании звёздочки прослеживается некое противоречие (сначала она означает объявленный указатель, потом разыменованный), стоит повторить всё что касается этого вопроса для закрепления информации.
Первое. Когда мы объявляем указатель, мы ставим звёздочку — здесь всё просто и понятно.
Второе. При использовании указателя по ходу программы. Когда мы используем имя указателя без звёздочки, мы получаем адрес ячейки на которую он указывает…
ptr1 = &sim1;
printf("Adr %p\n", ptr1); // адрес переменной sim1
Когда мы ставим звёздочку перед именем указателя, мы получаем содержимое ячейки на которую он указывает…
ptr1 = &sim1;
printf("Var %c\n", *ptr1); // содержимое переменной sim1
Или делаем запись нового значения в ячейку на которую он указывает…
ptr1 = &sim1;
printf("Var %c\n", *ptr1); // содержимое переменной sim1
*ptr1 = 'b'; // записываем новое значение
printf("Var %c\n", *ptr1);
С этой звёздочкой у людей частенько возникают трудности из-за неправильного использования, так что будьте внимательны.
Теперь давайте разберёмся с переменной
uint16_t digit = 2300;
uint16_t *ptr16 = NULL;
ptr16 = &digit;
Создали указатель и присвоили ему адрес переменной
Здесь стоит заострить внимание читателя. Как я уже говорил выше, сам указатель либо 32-ух битный, либо 64-ёх битный (это для нас не имеет никакого значения), но вот тип данных на которые он указывает, может быть различный, и это очень важно. Поэтому когда мы при объявлении указателя прописываем тип, этот тип относится именно к типу данных на которые будет указывать указатель.
В прошлый раз мы создавали указатель с типом
В памяти получилась следующая картина (рисунок я оставил прежний чтоб не перерисовывать)…
Теперь указатель хранит адрес первой ячейки 16-ти битной переменной (стрелочкой указывает на неё), а поскольку при объявлении указателя мы сообщили компилятору что указатель будет указывать на 16-ти битный тип, то программа знает что при обращении к указателю нужно прочитать ячейку на которую он указывает, и следующую за ней ячейку, то есть две ячейки — 5677 и 5678…
Повторим:
Если нужен указатель который будет в дальнейшем указывать на однобайтовую переменную
char *ptr = NULL;
или
uint8_t *ptr = NULL;
Программа будет знать что при обращении к этому указателю нужно прочитать только одну ячейку, на которую он указывает.
Если нужен указатель который будет в дальнейшем указывать на двухбайтовую переменную
uint16_t *ptr16 = NULL;
Программа будет знать что при обращении к этому указателю нужно прочитать ячейку, на которую он указывает, и следующую за ней.
Если нужен указатель который будет в дальнейшем указывать на четырёхбайтовую переменную
uint32_t *ptr32 = NULL;
Программа будет знать что при обращении к этому указателю нужно прочитать ячейку, на которую он указывает, и ещё три следующие за ней.
Если нужен указатель который будет в дальнейшем указывать на переменную
float *ptr_f = NULL;
Программа будет знать что при обращении к этому указателю нужно прочитать ячейку, на которую он указывает, и ещё три следующие за ней (тип float занимает четыре байта).
Теперь когда мы немного познакомились с указателем, давайте посмотрим как он работает на практике. Создадим простейшую программу…
#include <stdio.h>
#include <stdint.h>
void func_var(uint16_t var)
{
var++;
printf("var %d\n", var);
}
int main(void)
{
uint16_t digit = 2300;
func_var(digit);
printf("digit %d\n", digit);
}
Результат работы будет таков…
Мы передали переменную digit в функцию func_var(), увеличили на единичку и вывели на печать. Потом в основной функции тоже вывели на печать эту переменную, и разумеется получили результат без увеличения. Это произошло потому, что когда мы передавали переменную в функцию, мы её как-бы скопировали в другую переменную, объявленную в аргументе (uint16_t var). Переменная var в функции func_var() увеличилась, а оригинал как был так и остался равен 2300.
А сейчас изменим нашу программу вот так…
void func_var(uint16_t *ptr_var)
{
*ptr_var = *ptr_var + 1;
printf("var %d\n", *ptr_var);
}
int main(void)
{
uint16_t digit = 2300;
uint16_t *ptr_digit = NULL; // объявили указатель
ptr_digit = &digit; // присвоили указателю адрес переменной digit
func_var(ptr_digit); // передали указатель (хранящий адрес переменной digit) в функцию
printf("digit %d\n", digit);
}
Результат получим иной…
В обоих случаях значение увеличилось на единицу.
В основной функции мы объявили указатель, присвоили ему адрес переменной digit, и передали этот указатель в функцию func_var(). Аргументом этой функции мы объявили указатель (ptr_var) в который при передаче записался адрес переменной digit. Это значит, что теперь указатель ptr_var так же как и указатель ptr_digit указывает на адрес переменной digit, и следовательно манипулируя указателем ptr_var мы можем изменить значение этой переменной.
В функции мы разыменовываем ptr_var, то есть получаем доступ к значению хранящемуся в ячейках, прибавляем к этому значению единицу —
Основную функцию мы можем немного упростить, сделав её такой…
void func_var(uint16_t *ptr_var)
{
*ptr_var = *ptr_var + 1;
printf("var %d\n", *ptr_var);
}
int main(void)
{
uint16_t digit = 2300;
func_var(&digit);
printf("digit %d\n", digit);
}
Результат мы получим тот же, что и в предыдущем примере.
Здесь мы не стали объявлять указатель и присваивать ему адрес переменной, а просто воспользовались операцией «взятие адреса» и передали этот адрес в функцию.
Оба варианта идентичны по своему смыслу, однако я хотел показать, что можно передавать и указатель, и «голый» адрес.
Суть этих примеров с переменной
Массив
Все программисты используют в своих программах массивы, но не все знают, что массив, а точнее имя массива это указатель указывающий на первый элемент этого массива. При этом объявляется он без всяких звёздочек. То есть в чём то он похож на «обычную» переменную. Если быть ещё более точным, то массив можно представить себе как набор однотипных переменных, расположенных в памяти друг за дружкой, а каждая из этих «переменных» является элементом массива.
Значение в квадратных скобочках говорит о том, сколько элементов содержится в этом массиве, а тип говорит о том, какого размера элементы этого массива, то есть сколько ячеек памяти занимает один элемент. Для примера возьмём такой массив…
char array[4] = {'D','i','m','a'};
Массив из четырёх элементов. Каждый элемент занимает в памяти одну ячейку (об этом говорит тип char). В каждый из элементов мы записали по одному символу, то есть инициализировали весь массив конкретными значениями.
Чтобы вывести массив на печать делаем так…
printf("array %s\n", array);
Получаем…
Здесь всё выглядит так, как будто мы обратились к «обычной» переменной и вывели её на печать. Тем не менее легко доказать что
printf("array %p\n", array);
И мы получим адрес…
Если же мы сделаем разыменование
printf("array %c\n", *array);
То получим первый элемент массива…
Что доказывает сказанное выше — имя массива это указатель на первый элемент этого массива.
То же самое мы получим если добавим к имени индекс нулевого элемента массива…
printf("array %c\n", array[0]);
В памяти это представляется следующим образом…
Имя
Чтобы нам было удобно обращаться к отдельным элементам этого массива компилятор любезно присвоил элементам индексы, начиная с нулевого. То есть ячейка 5676 получает индекс 0, ячейка 5677 получает индекс 1, ячейка 5678 получает индекс 2, и т.д. Важно помнить что отсчёт элементов ведётся от ноля.
На схеме индексов не видно, но программа знает какой ячейке присвоен какой индекс.
Квадратные скобки при использовании массива имеют двойное назначение. При объявлении массива в них указывается количество элементов, а в процессе работы индекс ячейки, то есть её порядковый номер в данном массиве.
Благодаря индексации мы легко и просто можем обращаться к любому элементу…
printf("array1 %c\n", array[0]);
printf("array2 %c\n", array[1]);
printf("array3 %c\n", array[2]);
printf("array4 %c\n", array[3]);
Чаще всего индексацию используют в циклах для записи в массив новых значений…
int main(void)
{
char array[4] = {'D','i','m','a'};
for(uint8_t i = 0; i < 4; i++)
{
array[i] = 'Z';
}
printf("array %s\n", array);
}
Переменная «i» приращивается в цикле и выступает в роли индекса элемента массива. Таким образом мы заполним все элементы символом «Z»…
Теперь создадим массив из двух элементов с типом
uint16_t array16[2] = {456, 789};
В таком массиве каждый элемент занимает две ячейки памяти…
Имя массива указывает на первую ячейку памяти первого элемента, а сами элементы хранят значения которые мы записали туда при инициализации массива.
Здесь индексы опять же присваиваются элементам массива. Индекс [0] отвечает за ячейки 5676 и 5677, а индекс [1] за ячейки 5678 и 5679. То есть индекс перескакивает через одну ячейку так как благодаря указанному типу
Чтоб проверить как работает индексация мы сначала прочитаем что храниться в элементах массива, а следом запишем в них число 999…
int main(void)
{
uint16_t array16[2] = {456, 789};
printf("Read array16\n");
for(uint8_t i = 0; i < 2; i++)
{
printf("array16[%d] %d\n", i, array16[i]);
}
printf("\nWrite array16\n");
for(uint8_t i = 0; i < 2; i++)
{
array16[i] = 999;
printf("array16[%d] %d\n", i, array16[i]);
}
}
Получим что ожидали…
Думаю понятно, что при использовании типа
Как и в случае с «обычной» переменной, мы можем к элементу массива применить операцию «взятия адреса»…
int main(void)
{
uint16_t array16[2] = {456, 789};
printf("Adr array16[0] %p\n", &array16[0]);
printf("Adr array16[1] %p\n", &array16[1]);
}
Видно что адрес второго элемента больше первого на два. То есть адрес первой ячейки первого элемента ...374, а второй ячейки будет ...375. То же самое со вторым элементом — адрес первой ячейки ...376, а второй будет ...377.
А теперь давайте зафиксируем мысль на этом последнем примере и перейдём к следующему, очень важному понятию в теме про указатели, к «адресной арифметике» или «арифметики с указателями».
Адресная арифметика
Оперируя указателями мы оперируем хранящимися в указателях адресами, а адреса в свою очередь это всего лишь цифры, а раз это цифры, то значит мы можем производить над ними арифметические действия. То есть если вычесть или прибавить к указателю какую-то цифру, то этот указатель будет указывать уже на другую ячейку памяти. Вроде бы всё просто, но здесь есть существенный нюанс — вся эта арифметика жёстко связана с типом указателя. Сейчас мы убедимся в этом воспользовавшись нашим последним примером.
Освежим в голове нашу схему…
И добавим в последний пример ещё одну строчку…
int main(void)
{
uint16_t array16[2] = {456, 789};
printf("Adr array16[0] %p\n", &array16[0]);
printf("Adr array16[1] %p\n", &array16[1]);
printf("Adr array16[0] %p\n", &array16[0] + 1);
}
Как мы помним первые две строчки напечатают адреса первых ячеек первого и второго элемента массива (5676 и 5678), а в последней строчки мы прибавили единицу к адресу первой ячейки первого элемента. Таким образом мы предполагаем что получим адрес второй ячейки первого элемента, то есть адрес
А теперь смотрим что получилось на самом деле…
В первых двух строках мы получили что хотели (как и в предыдущем примере), а в третьей строке мы вроде как должны были получить ...e85 (адрес второй ячейки первого элемента), но наши надежды не оправдались, мы получили адрес второго элемента. Как же так, в чём ошибка? А ошибки никакой и нет, программа всё сделала правильно.
Как я уже говорил выше, адресная арифметика жёстко привязана к типу указателя, поэтому когда мы прибавили к указателю единицу он увеличивается не на 1, а на размер элемента массива. То есть наша конструкция выглядела как «плюс один элемент». Тип массива у нас uint16_t, значит размер элемента два байта, поэтому программа увеличила адрес на 2, и поэтому мы получили адрес первой ячейки второго элемента, а не то, что предполагали. А если бы мы применили эту конструкцию ко второму элементу, то ещё и вылетели бы за границы массива.
Этот нюанс нужно хорошенько запомнить, ибо многим начинающим программистам он стоил немалого количества вырванных волос и сломанных клавиатур .
Вот если мы будем работать с массивом типа
Пример…
int main(void)
{
char array[4] = {'D','i','m','a'};
printf("array[0] %p\n", array);
printf("array[1] %p\n", array + 1);
printf("array[2] %p\n", array + 2);
printf("array[3] %p\n", array + 3);
}
Результат…
Все адреса подряд.
Адресную арифметику удобно применять при парсинге строк. Например у нас есть массив со строкой (в языке СИ нету строк, есть только массивы), и нам нужно вывести на печать эту строку начиная с четвёртого символа, тогда делаем так…
int main(void)
{
char array[] = "istarik.ru";
printf("Full - %s\n", array);
printf("Cut - %s\n", array + 3);
}
Отрезали три первых символа.
Или допустим мы хотим перегнать из одного массива в другой строку начиная с четвертого символа…
int main(void)
{
char src[] = "istarik.ru"; // массив источник
char dst[8] = {0,}; // массив приёмник
char *p = NULL; // создаём указатель
p = src + 3; // присваиваем новому указателю адрес массива-источника начиная с четвёртой ячейки
for(uint8_t i = 0; i < 8; i++)
{
dst[i] = *p; // разыменовываем указатель и записываем значение в элемент массива-приёмника
p++; // увеличиваем адрес на единицу
}
printf("Src - %s\n", src);
printf("Dst - %s\n", dst);
}
Все действия я прокомментировал.
И вот вам ещё один пример демонстрирующий крутость указателя. В этой программе мы легко и непринуждённо уберём все нижние подчёркивания и запятые из строки…
void clear_str(char *src)
{
char *dst = NULL;
dst = src;
for(; *src != 0; src++)
{
if(*src == '_' || *src == ',') continue;
*dst = *src;
dst++;
}
*dst = 0;
}
int main(void)
{
char src[] = "i_s,t_a,r_i,k.r_u";
printf("%s\n", src);
clear_str(src);
printf("%s\n", src);
}
Здесь я не буду ничего комментировать. В среде программистов бытует мнение, что указатель нельзя выучить, его можно только понять, как озарение. Сам через это проходил. Поэтому когда вы поймёте что происходит в этом примере, это будет означать что вы поняли указатель
Ну, а после понимания, всякие штуки типа указателя на указатель, и функции-указатели вы будете щёлкать как орешки.
Кстати, любопытная вещь — имя обычной функции, только без скобочек — это указатель на эту функцию, то есть адрес в памяти где расположена эта функция. Ради интереса можете добавить в последний пример строчку...
printf("F %p\n", clear_str);
Это всё, всем спасибо
Телеграм-чат istarik
Телеграм-чат STM32
- 0
- stD
30780
Поддержать автора
Комментарии (0)