Указатель - про указатель в языке СИ





Здравствуйте.


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


Для лучшего понимания, вначале мы разберёмся с «обычными» переменными (впрочем указатель, это тоже обычная переменная, но пока мы условно разделим эти понятия).


Переменная


Итак, у нас есть переменные char, uint16_t, uint32_t, и прочие. Всё это «типизированные» переменные, то есть переменные хранящие определённый тип данных. Переменная char (8 бит) хранит однобайтовое число/символ, uint16_t (16 бит) хранит двухбайтовое число, и uint32_t (32 бита) хранит четырёхбайтовое число.


Теперь разберёмся что значит «переменная хранит» и как это вообще выглядит внутри «железа». Напомню, что бы мы не делали в компьютере или микроконтроллере, мы всего лишь оперируем значениями в ячейках памяти (ну или регистрами в случае с микроконтроллером).

Предположим что мы объявили и инициализировали (то есть записали в них значения) две переменные…

char sim = 'a';
uint16_t digit = 2300;

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

Представим себе небольшой кусочек памяти компьютера где-то ближе к началу…


Клетки это ячейки памяти, а цифры это номера ячеек, то есть адреса. В каждой ячейке может храниться один байт данных (8 бит). Когда мы хотим обратится к тем или иным данным находящимся в памяти, мы обращаемся к ним по нужным нам адресам.

Но вот вопрос, откуда же мы знаем эти самые, нужные нам адреса, а ответ очень прост. Когда мы создали переменную char c именем sim, компилятор выделил для этой переменной одну ячейку (char у нас однобайтный) памяти, например ячейку 5676, после чего он ассоциировал имя sim с адресом этой ячейкой, а само имя уничтожил. То есть имена переменных это просто метки для адресов в памяти, которые нужны компилятору на определённом этапе компиляции. И теперь программа знает что когда происходит обращение к имени sim, это значит что нужно обратится к содержимому ячейки 5676.

После того как мы инициализировали переменную значением 'a', это значение записалось в эту ячейку.

И стало так…



Когда же мы создали переменную uint16_t, компилятор посмотрел на её тип, понял что она двухбайтовая, и соответственно выделил под неё две ячейки — 5677 и 5678. И так же как и в первом случае, он ассоциировал эти две ячейки с именем digit. То есть обращаясь к имени digit, мы обращаемся к тому что хранится в ячейках с адресами 5677 и 5678. Ну и соответственно при инициализации, в эти ячейки записалось число 2300



Если мы захотим получить не содержимое ячейки, а её адрес, то для этого есть специальная конструкция, но об этом позже.


Я специально подчеркнул две фразы ибо в них кроется ключевой смысл отличающий «обычную» переменную от указателя, поэтому повторю — когда мы обращаемся к имени «обычной» переменной, мы обращаемся непосредственно к содержимому ячейки/ячеек. То есть мы оперируем именно данными, хранящимися в этой ячейке/ячейках.


Указатель


Наконец пришло время дать определение указателю.

Указатель это переменная, которая хранит в себе не какие-то данные (как это делает «обычная» переменная), а адрес какой-либо ячейки памяти, то есть указывает на какую-либо ячейку памяти.


Указатель объявляется так же как и «обычная» переменная, с той лишь разницей, что перед именем ставиться звёздочка…

char *ptr = NULL;

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

И да, звездочку можно ставить как угодно…

char *ptr = NULL;
char * ptr = NULL;
char* ptr = NULL;



Теперь увеличим наш кусочек памяти на две ячейки, чтобы было удобнее…



… и рассмотрим что же произойдёт внутри системы после объявления указателя.


Компилятор выделил в памяти четыре ячейки для указателя, например 5681, 5682, 5683 и 5684 (см. ниже).

Размер указателя в современных компьютерах бывает либо 32-ух битный (4 байта), либо 64-ёх битный (8 байт), так как он должен хранить в себе какой-то адрес памяти, к которому мы будем обращаться через этот указатель.

Таким образом в первом случае мы можем адресовать (обратится по адресу) до 4Гб, а во втором свыше восемнадцати квинтиллионов байт . Если бы указатель был меньшей разрядности, например 16-ти битный, то не смог бы хранить в себе адреса выше 65535.

Размер указателя связан отчасти с разрядностью ОС, отчасти с шиной данных, отчасти от режима компилятора (эмуляция 32-ух битных программ на 64-ёх битных системах), и ещё чёрт знает от чего, нам это совершенно не важно.


И так же как и в случае с «обычными» переменными, компилятор ассоциировал имя ptr с этими четырьмя ячейками, и произошла инициализация указателя нулём.
что такое NULL
NULL это дефайн из хедера стандартной библиотеки stdio…

#define NULL    0


В результате мы создали указатель, который хранит адрес нулевой ячейки памяти, то есть указывает на нулевую ячейку памяти…



Главное отличие указателя от «обычной» переменной: если бы ptr был «обычной» переменной и мы бы решили к ней обратится (например прочитать), то нам бы вернулось значение 0. С указателем же всё по другому: если бы мы сейчас обратились по имени ptr, то программа бы заглянула в эти четыре ячейки, увидела бы там адрес 0, и полезла бы в ячейку 0, то есть в нулевой адрес памяти.

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

char *ptr;


Тогда в ячейках 5681, 5682, 5683 и 5684 скорее всего оказался бы какой-то «мусор» (какие-то бессмысленные цифры), и если бы мы в дальнейшем забыли присвоить указателю какой-то конкретный, нужный нам, адрес, и потом обратились бы к этому указателю, то скорее всего «мусор» оказался бы каким-то адресом, и мы сами того не зная случайно что-то сделали с хранящимися по этому адресу данными. Во что бы это вылилось неизвестно, скорее всего программа не упала бы сразу, а накуролесила страшных делов в процессе работы. Поэтому пока мы не присвоили указателю какого-то конкретного адреса, мы его «занулили» для собственной безопасности.


Итак, прежде чем двигаться дальше подобьём итоги: указатель это 32-ух битная (или 64-ёх битная) переменная, которая хранит в себе не данные, а адрес какой-то одной ячейки памяти.



Типы

Теперь разберёмся с типами, на которые указывает указатель. Сейчас мы создали указатель с типом char потому что будем присваивать ему адрес переменной с типом char.

Важно! Мы должны присваивать указателю адрес переменной того же типа что и сам указатель (ниже объясню почему). То есть мы не можем нашему указателю присвоить адрес переменной digit, компилятор на это изрыгнёт предупреждение. Для переменной digit нужен указатель с соответствующим типом uint16_t, и тогда всё будет окей…

uint16_t *ptr = NULL;
ptr = &digit;


То же самое касается и других типов переменных. Например для типа float будет так…

float my_float = 34.0;
float *ptrf = NULL;
ptrf = &my_float;


Ниже мы ещё вернёмся к переменной digit и другим типам данных.



Присваивание адреса указателю и «взятие адреса» обычной переменной

Далее давайте присвоим нашему указателю конкретный адрес, на который он будет указывать.

Мы хотим сделать так чтобы наш указатель указывал на ячейку памяти, в которой храниться значение переменной sim, то есть на ячейку в которой лежит символ 'a'. Вопрос в том как это сделать — мы же не можем просто взять и присвоить указателю значение переменной, то есть сделать так…

char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;

ptr = sim;

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

Нам нужно узнать адрес ячейки в которой лежит символ 'a', и записать его в указатель (присвоить указателю). Делается это очень просто, надо перед именем переменной добавить амперсанд (&), то есть сделать так…

ptr = ∼

Эта операция называется "взятие адреса". Выше я писал — «Если мы захотим получить не содержимое ячейки, а её адрес, то для этого есть специальная конструкция», это оно и есть. Таким образом мы можем получить адрес любой переменной.

То есть наша программа будет выглядеть так…

char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;

ptr = ∼

Теперь наш указатель хранит адрес ячейки (5676), в которой храниться символ 'a', то есть указывает на неё. В железе это выглядит так…



Если добавим вот такой вывод на печать…

printf("Var sim %c\n", sim);
printf("Adr sim %p\n", &sim);
printf("Ptr sim %p\n", ptr);

… то получим искомые данные…


Переменная sim хранит символ 'a', Adr это её адрес, ну и указатель указывает на тот же адрес (0x7ffecc3133ed это то, что на схеме выше обозначено как 5676).

Здесь, и ниже, на картинках, у меня 64-ёх битный указатель — не обращайте на это внимание. Просто я поленился рисовать восемь клеточек на схемах выше.

Вот тоже самое, только выполнено на микроконтроллере stm32...



Здесь указатель 32-ух битный.



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

Теперь разберёмся с ещё одной важной вещью. Выше я писал что при работе с указателем звёздочка выступает в двух ипостасях, с первой мы познакомились, это объявление указателя, а вторая это получение данных из ячейки на которую указывает указатель, или запись данных в эту ячейку. Это называется «разыменование указателя».

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

Для примера создадим ещё одну переменную типа char и с помощью "разыменования указателя" запишем в неё значение, которое храниться в ячейке на которую указывает указатель ptr, то есть символ 'a'

char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;

ptr = ∼

char sim2; // новая переменная

sim2 = *ptr; // записываем в новую переменную символ 'a' с помощью "разыменования указателя"

printf("Var sim2 %c\n", sim2);


Результат будет таков…


Мы прочитали значение из ячейки на которую указывает указатель, и записали его в переменную 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, но значение в этой ячейке изменилось. То есть изменилось значение переменной sim.

Можем в функциях printf() заменить разыменованный указатель (*ptr) на имя переменной…

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.

Результат…


Видно что указатель указывает на три разных адреса трёх наших переменных. Заодно видим что переменные расположились в памяти друг за дружкой.


Ну и конечно можем для каждой переменной создать свой указатель…

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);





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



Теперь давайте разберёмся с переменной digit. Поскольку эта переменная 16-ти битная, соответственно и указатель на неё должен иметь 16-ти битный тип, то есть такой…

uint16_t digit = 2300;
uint16_t *ptr16 = NULL;	
	
ptr16 = &digit;

Создали указатель и присвоили ему адрес переменной digit.

Здесь стоит заострить внимание читателя. Как я уже говорил выше, сам указатель либо 32-ух битный, либо 64-ёх битный (это для нас не имеет никакого значения), но вот тип данных на которые он указывает, может быть различный, и это очень важно. Поэтому когда мы при объявлении указателя прописываем тип, этот тип относится именно к типу данных на которые будет указывать указатель.

В прошлый раз мы создавали указатель с типом char так как он указывал на однобайтовую переменную sim. Теперь же мы создали указатель на двухбайтовую переменную и поэтому объявили указатель с соответствующим типом.

В памяти получилась следующая картина (рисунок я оставил прежний чтоб не перерисовывать)



Теперь указатель хранит адрес первой ячейки 16-ти битной переменной (стрелочкой указывает на неё), а поскольку при объявлении указателя мы сообщили компилятору что указатель будет указывать на 16-ти битный тип, то программа знает что при обращении к указателю нужно прочитать ячейку на которую он указывает, и следующую за ней ячейку, то есть две ячейки — 5677 и 5678…



Таким образом, благодаря типу прописанному при объявлении указателя, программа знает какое количество ячеек нужно прочитать при обращении к этому указателю.


Повторим:

Если нужен указатель который будет в дальнейшем указывать на однобайтовую переменную char или uint8_t, тогда создаём указатель с соответствующим типом…

char *ptr = NULL;

или

uint8_t *ptr = NULL;

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


Если нужен указатель который будет в дальнейшем указывать на двухбайтовую переменную uint16_t, тогда прописываем двухбайтовый тип…

uint16_t *ptr16 = NULL;

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


Если нужен указатель который будет в дальнейшем указывать на четырёхбайтовую переменную uint32_t, тогда прописываем четырёхбайтовый тип…

uint32_t *ptr32 = NULL;

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


Если нужен указатель который будет в дальнейшем указывать на переменную float, тогда прописываем тип float…

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, то есть получаем доступ к значению хранящемуся в ячейках, прибавляем к этому значению единицу — *ptr_var + 1 (2300 + 1), и опять же с помощью разыменования записываем в ячейки новое значение — *ptr_var = *ptr_var + 1. Теперь переменная digit хранит значение 2301. Следовательно в обоих функциях на печать выводится одно и то же значение.


Основную функцию мы можем немного упростить, сделав её такой…

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);
}

Результат мы получим тот же, что и в предыдущем примере.

Здесь мы не стали объявлять указатель и присваивать ему адрес переменной, а просто воспользовались операцией «взятие адреса» и передали этот адрес в функцию.

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


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



Массив

Все программисты используют в своих программах массивы, но не все знают, что массив, а точнее имя массива это указатель указывающий на первый элемент этого массива. При этом объявляется он без всяких звёздочек. То есть в чём то он похож на «обычную» переменную. Если быть ещё более точным, то массив можно представить себе как набор однотипных переменных, расположенных в памяти друг за дружкой, а каждая из этих «переменных» является элементом массива.

Значение в квадратных скобочках говорит о том, сколько элементов содержится в этом массиве, а тип говорит о том, какого размера элементы этого массива, то есть сколько ячеек памяти занимает один элемент. Для примера возьмём такой массив…

char array[4] = {'D','i','m','a'};


Массив из четырёх элементов. Каждый элемент занимает в памяти одну ячейку (об этом говорит тип char). В каждый из элементов мы записали по одному символу, то есть инициализировали весь массив конкретными значениями.

Чтобы вывести массив на печать делаем так…

printf("array %s\n", array);


Получаем…



Здесь всё выглядит так, как будто мы обратились к «обычной» переменной и вывели её на печать. Тем не менее легко доказать что array всё таки указатель. Достаточно изменить форматирующий символ «s» на «p»…

printf("array %p\n", array);


И мы получим адрес…



Если же мы сделаем разыменование array

printf("array %c\n", *array);


То получим первый элемент массива…


Что доказывает сказанное выше — имя массива это указатель на первый элемент этого массива.


То же самое мы получим если добавим к имени индекс нулевого элемента массива…

printf("array %c\n", array[0]);



В памяти это представляется следующим образом…


Имя array указывает на первый элемент массива (ячейка 5676), а следом идут остальные три элемента.

Чтобы нам было удобно обращаться к отдельным элементам этого массива компилятор любезно присвоил элементам индексы, начиная с нулевого. То есть ячейка 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

uint16_t array16[2] = {456, 789};


В таком массиве каждый элемент занимает две ячейки памяти…


Имя массива указывает на первую ячейку памяти первого элемента, а сами элементы хранят значения которые мы записали туда при инициализации массива.

Здесь индексы опять же присваиваются элементам массива. Индекс [0] отвечает за ячейки 5676 и 5677, а индекс [1] за ячейки 5678 и 5679. То есть индекс перескакивает через одну ячейку так как благодаря указанному типу uint16_t программа знает что каждый элемент массива занимает две ячейки памяти.

Чтоб проверить как работает индексация мы сначала прочитаем что храниться в элементах массива, а следом запишем в них число 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]);
	}
}


Получим что ожидали…



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



Как и в случае с «обычной» переменной, мы можем к элементу массива применить операцию «взятия адреса»…

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), а в последней строчки мы прибавили единицу к адресу первой ячейки первого элемента. Таким образом мы предполагаем что получим адрес второй ячейки первого элемента, то есть адрес 5677.

А теперь смотрим что получилось на самом деле…


В первых двух строках мы получили что хотели (как и в предыдущем примере), а в третьей строке мы вроде как должны были получить ...e85 (адрес второй ячейки первого элемента), но наши надежды не оправдались, мы получили адрес второго элемента. Как же так, в чём ошибка? А ошибки никакой и нет, программа всё сделала правильно.

Как я уже говорил выше, адресная арифметика жёстко привязана к типу указателя, поэтому когда мы прибавили к указателю единицу он увеличивается не на 1, а на размер элемента массива. То есть наша конструкция выглядела как «плюс один элемент». Тип массива у нас uint16_t, значит размер элемента два байта, поэтому программа увеличила адрес на 2, и поэтому мы получили адрес первой ячейки второго элемента, а не то, что предполагали. А если бы мы применили эту конструкцию ко второму элементу, то ещё и вылетели бы за границы массива.

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


Вот если мы будем работать с массивом типа char (или uint8_t), тогда адресная арифметика будет работать как обычная. Размер элемента один байт, значит и адрес будет увеличиваться на единицу.

Пример…

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
  • 26848
Поддержать автора


Telegram-чат istarik

Задать вопрос по статье
Telegram-канал istarik

Известит Вас о новых публикациях






Комментарии (0)

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