Кратко об указателях в Си: присваивание, разыменование и перемещение по массивам
Время прочтения
6 мин
Просмотры 40K
Приветствую вас, дорогие читатели. В данной статье кратко описаны основные сведения об указателях в языке Си. Кроме основных операций с указателями (объявление, взятие адреса, разыменование) рассмотрены вопросы безопасности типов при работе с ними. К сожалению, в данной статье вы не найдёте информацию по операциям сравнений указателей. Однако, статья будет полезна новичкам, а также тем, кто работает с массивами. Все примеры в данной статье компилировались компилятором gcc (восьмой версии).
Введение
Указатель — переменная, которая хранит адрес сущностей (т.е. других переменных любого типа, будь то структура, или массив), и над которой возможно выполнять операцию разыменования (dereferencing). Адрес обычно выражен целым положительным числом. Диапазон адресов зависит от архитектуры компьютера. Указателю надо указать тип переменной, адрес которой он хранит, или же использовать ключевое слово void, для обозначения указателя, хранящего адрес чего-угодно (т.е. разрешён любой тип). Указатели объявляются как и обычные переменные, с той разницей, что имя типа переменной указателя имеет префикс, состоящий как минимум из одной звёздочки (*). Например:
int a = 12; /* usual variable */
int * ptr = &a; /* ptr-variable which contains address of variable a */
int **pptr = &ptr; /* ptr-variable which contains address of variable ptr */
int aval = **pptr; /* get value by adress which is contained in pptr. */
int aval2 = *ptr; /* get value of a by address (value of ptr) */
Количество звёздочек лишь указывает на длину цепочек хранимых адресов. Поскольку указатель также является переменной и имеет адрес, то его адрес также можно хранить в другом указателе. В выше приведённом примере адрес переменной a сохраняется в переменной-указателе ptr. Адрес же самой переменной ptr сохраняется в другом указателе pptr. Чтобы получить адрес переменной, перед её именем надо поставить знак амперсанда (&). Наконец, чтобы выполнить обратную операцию, т.е. получить значение (содержимое) по адресу, хранимому в указателе, имя указателя предваряется звёздочкой, почти как при объявлении. Почти, потому что одной звёздочки достаточно чтобы «распаковать» указатель. Поскольку pptr указывает по адресу на значение, хранимое в ptr, то необходимо два раза применить операцию разыменования.
Указатели в предыдущем примере хранят адрес переменной определённого типа. В случае, когда применяются указатели типа void (любого типа), то прежде чем распаковать значение по адресу, необходимо выполнить приведение к типизированному указателю. Следующий пример является версией предыдущего, но с использованием указателя любого типа.
int b = 0xff;
void *pb = &b;
void **ppb = &pb;
int bval1 = *((int *) pb);
int bval2 = *((int *) *ppb);
В данном примере адреса хранятся в указателе типа void. Перед получением значения по адресу, хранимым в pb, необходимо привести указатель pb к типу int*. Затем, воспользоваться стандартной операцией разыменования. Что касается указателя ppb, то он разыменовывается два раза. Первый раз до приведения к типу, для получения содержимого переменной pb, на которую он указывает. Второй раз — после приведения к типу int*.
Изменения значения переменной через указатель.
Так как указатель хранит адрес переменной, мы можем через адрес не только получить значение самой переменной, но также его изменить. Например:
char a = 'x';
char *pa = &a; /* save address of a into pa */
*pa = 'y'; /* change content of variable a */
printf("%cn", a); /* prints: y */
Как было сказано выше, указатели хранят адреса. Естественно, что адреса могут указывать не только на ячейки данных переменных в вашей программе, но и на другие вещи: адрес стека процедур, адрес начала сегмента кода, адрес какой-то процедуры ядра ОС, адрес в куче и т. д. Логично, что не все адреса можно использовать напрямую в программе, поскольку некоторые из них указывают на те участки памяти, которые нельзя изменять (доступ для чтения), или которые нельзя затирать. В случае, при обращении к участку, доступному только для чтения, при попытке изменить значение получим ошибку Segmentation Fault (SF).
Кроме того, в языке Си определён макрос с именем NULL, для обозначения указателя с нулевым адресом. Данный адрес обычно используется операционной системой для сигнала об ошибке при работе с памятью. При попытке что либо читать по этому адресу, программа может получить неопределённое поведение. Поэтому ни в коем случае не пытайтесь извлечь значение по пустому указателю.
И ещё, указатели могут указывать на один и тот же объект. Например:
int a = 123;
int *p1 = &a;
//Теперь p2 хранит тот же адрес, что и p1.
int *p2 = &a;
*p1 -= 3; // a = 123 - 3.
printf("*p2 = %dn", *p2); //Выведет 120
Этот простой пример показывает, что через адреса можно менять содержимое простых переменных, а также остальных указателей, ссылающихся на тоже самое. Таким образом, указатель p2 как бы является псевдонимом (alias) для p1.
Передача параметров через указатели.
Параметры функций могут быть указателями. В случае вызова таких функций, они копируют значения аргументов в свои параметры как обычно. Единственное отличие здесь в том, что они копируют адреса, содержащиеся в указателях параметрах. И с помощью полученных адресов, можно изменять объекты, на которые указывают параметры. Ниже приведена стандартная процедура обмена значений между двумя целочисленными переменными.
int swap(int *a, int *b){
if(a == NULL || b == NULL)
return -1;
int temp = *a;
*a = *b;
*b = temp;
return 0;
}
Здесь переменные а и b меняются своими значениями друг с другом (при условии, что параметры содержат не нулевой адрес). Отметим ещё раз, что мы можем изменить содержимое, указываемое по параметру-указателю методов. И, конечно, мы можем стереть данный адрес, присвоив параметру новое значение.
Проверка типов и массивы
Как было сказано, указатели хранят адреса переменных. Несмотря на указание типа для переменной указателя, это не мешает присвоить ему адрес переменной другого типа, если вы компилируете БЕЗ флагов. Например, следующий код не скомпилируется, если вы включили флаги -Werror -Wall
.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv){
int *ptr = NULL;
float a = 23.2;
ptr = &a;
printf("%.1fn", *ptr);
return 0;
}
Конечно, компилятор gcc и без -Wall
заметит недопустимую операцию в 7 строке кода. Флаг -Wall
покажет все предупреждения компилятора. Главный флаг -Werror
не позволит компилировать код, если есть предупреждения.
Что же касается массивов, то для массива не нужно предварять имя переменной амперсандом, поскольку компилятор автоматически при присваивании адреса массива присвоит адрес первого его элемента в указатель. Для многомерных массивов потребуются указатели на массивы, а не массивы указателей. Первые имеют форму объявления вида int (*arr)[]
, а вторые вида int *arr[]
. В квадратных скобках обязательно нужно указать размер массива. Для трёхмерных массивов потребуется уже две пары скобок, например int (*arr)[2][2]
. Для четырёхмерных — три и так далее.
// В ПУСТОМ теле метода main.
int A[2] = {40, 20};
// A -> (int *) ptr to A[0] element, &A -> (int (*)[]) -> ptr to whole Array.
int *ptr = A;
printf("ptr -> A[1] = %dn", *(ptr + 1)); // A[1] => 20.
//Illegal usage of A.
// int a_2 = ++A; //expected lvalue.
//But with ptr you can do this.
int b_2 = *++ptr; //Now ptr contains address of A[1]. (b_2 = A[1]);
int (*ptr2)[2] = &A; //ptr to array, not to literal element.
//*ptr2 => get array.
//**ptr2 => get first element of array.
//*ptr2 + 1 => get address of second element of array.
printf("ptr2 -> A[1] = %dn", *( *ptr2 + 1) );
int M[2][2] = { {1, 2} , {3, 4} };
// (*mp)[k] => (*mp)[k] => mp[0][k].
int (*mp)[2] = M; //again you must not add '&' to variable M.
printf("M[0][0] = %dn", **mp);//get array and extract it first element
printf("M[1][0] = %dn", **(mp + 1));//move to the address of second element
printf("M[1][1] = %dn", *( *(mp + 1) + 1));
В выше приведённом коде даны примеры для работы с массивами (одномерными и двумерными). В квадратных скобках указывается размер последнего измерения. Важно помнить, что первое разыменование приводит вас ко всему массиву (т. е. к типу int *
). А второе разыменование распаковывает элемент данного массива. В случае одномерного массива, у нас всего одна ячейка, и указатель ссылается на неё. В случае двумерного массива, у нас две ячейки — массивы, а указатель ссылается на первую. Для перемещения на второй массив, достаточно прибавить единицу к адресу, хранимому в переменной mp, например, так mp + 1
. Чтобы получить первый элемент второго массива, надо два раза распаковать указатель с соответствующим адресом массива, т.е. **(mp + 1)
.
Постоянные (const) и указатели.
Напомним, чтобы сделать переменную с постоянным, фиксированным значением, надо добавить ключевое слово const перед её именем (до имени типа или после). Например:
const int i1 = 10;
int const i2 = 222;
// Warning: variable e3 is unitialized. With -Werror it won't be compiled.
// (Внимание: переменной e3 не присвоено значение. С флагом gcc -Werror
// данный код не скомпилируется).
// const int e3;
Для объявления указателя на постоянное значение, ключевое слово const должно быть ПЕРЕД звёздочкой.
int A[2] = {100, 200};
const int *a0 = A;
printf("content of a0 = %dn", *a0);
//*a0 *= 10; //error: cannot change constant value.
a0 = (A + 1); // A[1]
printf("content of a0 = %dn", *a0); //prints: A[1]
В примере выше была создана переменная-указатель, ссылающееся на постоянное значение. Слово const перед звёздочкой указывает, что нельзя менять содержимое напрямую (путём разыменования, обращения к ячейке). Но сама переменная указатель постоянной не является. А значит, ей можно присвоить новый адрес. Например, адрес следующей ячейки в массиве.
Чтобы запретить менять адрес (значение переменной) указателя, надо добавить слово const ПОСЛЕ звёздочки. Кроме того, можно добавить ключевые слова const перед и после '*'
, чтобы сделать переменную фиксированной ещё сильнее, например так:
// Переменная с постоянным адресом и постоянным содержимым.
const int *const ptr = A; // constant address with constant content
// Переменная с постоянным адресом (содержимое можно менять)
int *const ptr2 = A; // constant address only.
// Переменная с постоянным содержимым, но с изменяемым адресом (значение справа)
const int *ptr3 = A; // constant content only (can change address (rvalue))
Операции с указателями
Последнее обновление: 23.09.2017
Указатели поддерживают ряд операций: присваивание, получение адреса указателя, получение значения по указателю,
некоторые арифметические операции и операции сравнения.
Присваивание
Указателю можно присвоить либо адрес объекта того же типа, либо значение другого указателя.
Присвоение указателю адреса уже рассматривалось в прошлой теме. Для получения адреса объекта используется операция &:
int a = 10; int *pa = &a; // указатель pa хранит адрес переменной a
При этом указатель и переменная должны иметь один и тот же тип, в данном случае это тип int.
Присвоение указателю другого указателя:
#include <iostream> using std::cout; using std::endl; int main() { int a = 10; int b = 2; int *pa = &a; int *pb = &b; cout << "Variable a: address=" << pa << "t value=" << *pa << endl; cout << "Variable b: address=" << pb << "t value=" << *pb << endl; pa = pb; // теперь указатель pa хранит адрес переменной b cout << "Variable b: address=" << pa << "t value=" << *pa << endl; return 0; }
Когда указателю присваивается другой указатель, то фактически первый указатель начинает также указывать на тот же адрес, на который указывает второй указатель.
Нулевые указатели
Нулевой указатель (null pointer) — это указатель, который не указывает ни на какой объект. Если мы не хотим, чтобы указатель указывал на какой-то конкретный адрес, то можно присвоить ему условное нулевое значение.
Для создания нулевого указателя можно применять различные способы:
int *p1 = nullptr; int *p2 = NULL; int *p3 = 0;
Ссылки на указатели
Так как ссылка не является объектом, то нельзя определить указатель на ссылку, однако можно определить ссылку на указатель. Через подобную ссылку можно изменять
значение, на которое указывает указатель или изменять адрес самого указателя:
#include <iostream> int main() { int a = 10; int b = 6; int *p = 0; // указатель int *&pRef = p; // ссылка на указатель pRef = &a; // через ссылку указателю p присваивается адрес переменной a std::cout << "p value=" << *p << std::endl; // 10 *pRef = 70; // изменяем значение по адресу, на который указывает указатель std::cout << "a value=" << a << std::endl; // 70 pRef = &b; // изменяем адрес, на который указывает указатель std::cout << "p value=" << *p << std::endl; // 6 return 0; }
Разыменование указателя
Операция разыменования указателя представляет выражение в виде *имя_указателя
. Эта операция позволяет получить объект по адресу, который хранится в указателе.
#include <iostream> using std::cout; using std::endl; int main() { int a = 10; int *pa = &a; int *pb = pa; *pa = 25; cout << "Value on pointer pa: " << *pa << endl; // 25 cout << "Value on pointer pb: " << *pb << endl; // 25 cout << "Value of variable a: " << a << endl; // 25 return 0; }
Через выражение *pa
мы можем получить значение по адресу, который хранится в указателе pa
, а через выражение типа
*pa = значение
вложить по этому адресу новое значение.
И так как в данном случае указатель pa
указывает на переменную a
, то при изменении значения по адресу, на который указывает указатель, также изменится и значение
переменной a
.
Адрес указателя
Указатель хранит адрес переменной, и по этому адресу мы можем получить значение этой переменной. Но кроме того, указатель, как и любая переменная, сам имеет адрес, по которому он располагается в памяти.
Этот адрес можно получить также через операцию &:
int a = 10; int *pa = &a; std::cout << "address of pointer=" << &pa << std::endl; // адрес указателя std::cout << "address stored in pointer=" << pa << std::endl; // адрес, который хранится в указателе - адрес переменной a std::cout << "value on pointer=" << *pa << std::endl; // значение по адресу в указателе - значение переменной a
Операции сравнения
К указателям могут применяться операции сравнения >, >=,
<, <=,==, !=. Операции сравнения применяются только к
указателям одного типа и к значениям NULL и nullptr. Для сравнения используются номера адресов:
#include <iostream> using std::cout; using std::endl; int main() { int a = 10; int b = 20; int *pa = &a; int *pb = &b; if(pa > pb) cout << "pa (" << pa << ") is greater than pb ("<< pb << ")" << endl; else cout << "pa (" << pa << ") is less or equal pb ("<< pb << ")" << endl; return 0; }
Консольный вывод в моем случае:
pa (0x60fe94) is greater than pb (0x60fe90)
Приведение типов
Иногда требуется присвоить указателю одного типа значение указателя другого типа. В этом случае следует выполнить операцию приведения типов с помощью операции (тип_указателя *)
:
#include <iostream> int main() { char c = 'N'; char *pc = &c; int *pd = (int *)pc; void *pv = (void*)pc; std::cout << "pv=" << pv << std::endl; std::cout << "pd=" << pd << std::endl; return 0; }
Для преобразования указателя к другому типу в скобках перед указателем ставится тип, к которому надо преобразовать. Причем
если мы не можем просто создать объект, например, переменную типа void, то для указателя это вполне будет работать. То есть можно создать указатель типа void.
Кроме того, следует отметить, что указатель на тип char (char *pc = &c
) при выводе на консоль система интерпретирует как строку:
std::cout << "pc=" << pc << std::endl;
Поэтому если мы все-таки хотим вывести на консоль адрес, который хранится в указателе типа char, то это указатель надо преобразовать к другому типу, например, к void* или к int*.
DaviDOS1999 0 / 0 / 0 Регистрация: 02.11.2018 Сообщений: 28 |
||||
1 |
||||
Изменение значения переменной через указатель19.12.2018, 11:30. Показов 13756. Ответов 4 Метки нет (Все метки)
Дана задача:с помощью указателя увеличить значение переменной b в 3 раза, поменять местами a и c.
Со второй частью задания справился, а вот с первой не могу справится.
__________________
0 |
JohnBlack123 215 / 162 / 52 Регистрация: 09.12.2017 Сообщений: 520 |
||||
19.12.2018, 11:54 |
2 |
|||
С помощью операции разыменования.
Добавлено через 33 секунды
1 |
DaviDOS1999 0 / 0 / 0 Регистрация: 02.11.2018 Сообщений: 28 |
||||
19.12.2018, 11:55 [ТС] |
3 |
|||
Использовал операцию разыменования, теперь сломались ключевые команды cin и cout
0 |
JohnBlack123 215 / 162 / 52 Регистрация: 09.12.2017 Сообщений: 520 |
||||||||
19.12.2018, 12:09 |
4 |
|||||||
Потому что для cout нужно использовать << Добавлено через 4 минуты
То получите в результате a : 2, b : 2.
Теперь вывод будет : a : 2, b : 3.
1 |
zss Модератор 12641 / 10135 / 6102 Регистрация: 18.12.2011 Сообщений: 27,170 |
||||
19.12.2018, 15:01 |
5 |
|||
Решение
1 |
Всем привет! В этом уроке мы разберём то, что очень тесно связано с памятью в компьютере. С помощью этого можно улучшать работу своей программы. Как вы догадались с названия урока, это — указатели.
Адрес переменной в C++
Поголовно у каждой переменной имеется свой индивидуальный адрес. Адрес переменной — это путь, по которому находится значение самой переменной. Он записывается в шестнадцатеричном виде. Так, компьютер может записать переменную, как в такой адрес 0x155,
так и в такой 0x212
.
Давайте приведем аналогию с круизным лайнером. В нем, как и в отеле, имеются номера. Вот, например, при покупке номера вам могут дать номер — 0x155
(да, мы понимаем, что не в одном лайнере или отеле не станут записывать номера в шестнадцатеричном виде, но давайте все таки немного отвлечемся). А друг может оказаться в номере 0x212
— так и с переменными, они могут получить разный путь. И только сам компьютер при создании переменной знает, где она находится.
Переменные, которые вы создаете в программе, по её завершению автоматически удаляются, чтобы не нагружать операционную память вашего компьютера.
Пример удаления переменных
В играх присутствует хорошая графика, различные спецэффекты. Например, тот же дым. Все это — переменная (может не одна!), которой в будущем придётся уничтожиться навсегда. А вот, если бы она не удалилась, то она бы своей фоновой работой понемножку нагружала бы наш компьютер.
Поэтому в C/C++ присутствует возможность обратиться к переменной, и, если требует ситуация, удалить и создать её вовсе в другом участке программы, когда это, конечно, нам будет нужно.
Что такое указатели в C++
Указатели — это с самого начала переменные, уже в которых хранится адрес других переменных.
Чтобы пользоваться указателями, вам нужно использовать два оператора:
*
— показывает значение переменной по заданному адресу (показывает, кто живет в этом номере). Если вы используете оператор*
, то вы занимаетесь операцией разыменование указателя.&
— показывает адрес переменной (говорит, по какому адресу проживает этот человек).
Как создать указатели в C++
Давайте посмотрим, какую конструкцию нужно использовать, чтобы создать указатели:
*<имя переменной> = &<имя другой переменной> |
Давайте подробно разберем, как эта она работает:
- В самом начале мы ставим оператор
*
(звездочку). Так мы говорим компилятору, что хотим использовать тип данных — указатель. - Дальше мы должны указать имя нашей переменной.
- После знака равно нам нужно передать указателю адрес какой-то переменной, что мы и делаем с помощью оператора
&
(амперсанд).
Чтобы передать адрес какой-то переменной, от одного указателя другому, нужно опускать оператор *
для одного указателя (от которого мы передаем второму):
int a = 15 + 5; int *ykazatel = &a; int *ykazatel_second = ykazatel; // присвоили адрес переменной a cout << *ykazatel_second; |
В нашем случае мы опустили оператор *
для ykazatel
.
Используем указатели на примере
Чтобы получше понять указатели, давайте их разберем на примере ниже.
Отец работает программистом в крупной компании, которая находится в 0x145
городе, и ему предложили поехать в командировку в 0x195
город, которую он так долго ждал. Он смог оповестить только своего сына о том, что уезжает. Поэтому сыну придется передать это маме самому.
Пример выше мы сейчас реализуем на C++ с помощью указателей.
#include <iostream> using namespace std; int main() { setlocale(0, «»); int dad_gorod; int *dad_son = &dad_gorod; int *mama = dad_son; system(«pause»); return 0; } |
Давайте подробно разберем код выше:
- В строке 8: создали переменную
dad_gorod
, в которой находится адрес (город, в который уехал отец). - В строке 9: создали указатель
dad_son
(это сын), он узнает имя города. - В строке 11: объявили указатель
mama
, которая уже от сына получает имя города.
Так сын (dad_son
) в нашем примере является указателем.
Часто у новичков есть некоторое недопонимание, как работает указатель. Они начинают путать операторы *
, &
.
Вам нужно помнить, что:
*
— используется, когда вам нужно значение переменной.&
— используется, когда вам понадобилось узнать адрес переменной.
Как передать указатели функциям
Вы, наверное, заметили, передавая функции аргумент и изменяя его в функции, переменная, значение которой мы передавали, никак не изменятся. И это понятно, потому что мы передаем значение одной переменной — другой переменной. Вот на примере ниже мы решили изменить значение передаваемой переменной:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <iostream> using namespace std; void func(int func_number) { func_number = 5; } int main() { setlocale(0, «»); int main_number = 10; cout << «Значение main_number до использования функции: « << main_number << endl; func(main_number); cout <<«А это значение main_number после использования функции: «<< main_number; system(«pause»); return 0; } |
Давайте запустим эту программу:
Значение main_number до использования функции: 10
А это значение main_number после использования функции: 10
Process returned 0 (0x0) execution time : 0.010 s
Press any key to continue.
Как видно выше, у нас ничего не получилось, что и сразу было понятно. А вот если нам по-настоящему нужно изменить значение переменной в функции, нам понадобится оперировать указателями.
Чтобы изменить значение переменной, нам понадобится:
- В аргументах функции создать указатель.
- Далее при вызове функции передать адрес переменной, которую мы собираемся изменить.
Вот и все! На примере ниже вы можете увидеть, как это реализовать:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <iostream> using namespace std; void func(int *func_number) { *func_number = 5; } int main() { setlocale(0, «»); int main_number = 10; func(&main_number); cout << main_number; system(«pause»); return 0; } |
Закрепляем указатели
Чтобы закрепить данный материал, мы предлагаем вам выполнить это задание:
- Создайте переменную и сразу же присвойте ей значение 5.
- Создайте указатель на эту переменную.
- Измените значение переменной с помощью указателя.
- Выведите значение переменной снова с помощью указателя.
Тест на тему «Указатели». Проверь себя!
Пожалуйста, подождите пока страница загрузится полностью.
Если эта надпись не исчезает долгое время, попробуйте обновить страницу. Этот тест использует javascript. Пожалуйста, влкючите javascript в вашем браузере.
If loading fails, click here to try again
Если ты полностью разобрался в данном материале, то попробуй пройти несложный тест, который сможет выявить твои слабые стороны.
В этом уроке мы разобрали, как нужно пользоваться указателями в C++. Если у вас есть вопрос, то напишите его в комментариях ниже. Удачи!
Здравствуйте.
Забегая вперёд скажу, указатель это очень
странный предмет
простая вещь, вообще в языке СИ нет ничего проще чем указатель, и в тоже время это пожалуй самый мощный инструмент, с помощью которого можно творить великие дела . Однако многие падаваны не понимают что это такое, поэтому я попробую внести свою лепту.
Для лучшего понимания, вначале мы разберёмся с «обычными» переменными (впрочем указатель, это тоже обычная переменная, но пока мы условно разделим эти понятия).
Переменная
Итак, у нас есть переменные 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 %cn", sim);
printf("Adr sim %pn", &sim);
printf("Ptr sim %pn", 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 %cn", sim2);
Результат будет таков…
Мы прочитали значение из ячейки на которую указывает указатель, и записали его в переменную sim2.
Разыменование указателя работает в обе стороны, то есть мы можем не только прочитать значение, но и записать в разыменованный указатель. То есть мы запишем новое значение в ячейку на которую указывает указатель.
Изменим наш пример…
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
printf("Var sim %cn", *ptr); // выводим старое значение (с помощью разыменования указателя)
*ptr = 'b'; // записываем новое значение в разыменованный указатель
printf("Var sim %cn", *ptr); // выводим новое значение
Смотрим что получилось…
В результате наш указатель будет по прежнему указывать на ту же ячейку 5676, но значение в этой ячейке изменилось. То есть изменилось значение переменной
sim
.
Можем в функциях
printf()
заменить разыменованный указатель (*ptr) на имя переменной…
char sim = 'a';
uint16_t digit = 2300;
char *ptr = NULL;
ptr = ∼
printf("Var sim %cn", sim); // выводим старое значение
*ptr = 'b'; // записываем новое значение в разыменованный указатель
printf("Var sim %cn", sim); // выводим новое значение
Результат прежний…
Как вы уже наверно начинаете понимать, указатель это весьма любопытный инструмент, и мы уже начали использовать его по разному, но погодите, дальше будет интересней.
Термин «разыменованный указатель» вовсе не означает что указатель куда-то пропадает из-за того что мы «лишили его имени» и теперь он где-то бродит безымянный и неприкаянный, нет, просто это такой не самый удачный термин, а указатель как был так остаётся указателем со своим именем.
Если хотим изменить адрес на который указывает указатель, тогда просто присваиваем указателю новый адрес. Для примера поочерёдно присвоим одному и тому же указателю адреса разных переменных…
char sim1 = 'a';
char sim2 = 'b';
char sim3 = 'c';
char *ptr = NULL;
ptr = &sim1;
printf("Var sim1 %c, Adr %pn", sim1, ptr); // адрес переменной sim1
ptr = &sim2;
printf("Var sim2 %c, Adr %pn", sim2, ptr); // адрес переменной sim2
ptr = &sim3;
printf("Var sim3 %c, Adr %pn", 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 %pn", sim1, ptr1); // адрес переменной sim
ptr2 = &sim2;
printf("Var sim2 %c, Adr %pn", sim2, ptr2); // адрес переменной sim2
ptr3 = &sim3;
printf("Var sim3 %c, Adr %pn", sim3, ptr3); // адрес переменной sim3
Поскольку в использовании звёздочки прослеживается некое противоречие (сначала она означает объявленный указатель, потом разыменованный), стоит повторить всё что касается этого вопроса для закрепления информации.
Первое. Когда мы объявляем указатель, мы ставим звёздочку — здесь всё просто и понятно.
Второе. При использовании указателя по ходу программы. Когда мы используем имя указателя без звёздочки, мы получаем адрес ячейки на которую он указывает…
ptr1 = &sim1;
printf("Adr %pn", ptr1); // адрес переменной sim1
Разумеется в дальнейшем мы будем использовать указатель без звёздочки не только для вывода адреса на печать.
Когда мы ставим звёздочку перед именем указателя, мы получаем содержимое ячейки на которую он указывает…
ptr1 = &sim1;
printf("Var %cn", *ptr1); // содержимое переменной sim1
Или делаем запись нового значения в ячейку на которую он указывает…
ptr1 = &sim1;
printf("Var %cn", *ptr1); // содержимое переменной sim1
*ptr1 = 'b'; // записываем новое значение
printf("Var %cn", *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 %dn", var);
}
int main(void)
{
uint16_t digit = 2300;
func_var(digit);
printf("digit %dn", 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 %dn", *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 %dn", 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 %dn", *ptr_var);
}
int main(void)
{
uint16_t digit = 2300;
func_var(&digit);
printf("digit %dn", digit);
}
Результат мы получим тот же, что и в предыдущем примере.
Здесь мы не стали объявлять указатель и присваивать ему адрес переменной, а просто воспользовались операцией «взятие адреса» и передали этот адрес в функцию.
Оба варианта идентичны по своему смыслу, однако я хотел показать, что можно передавать и указатель, и «голый» адрес.
Суть этих примеров с переменной
digit
заключалась в том, чтобы показать, что когда мы передаём переменную в какую-то функцию, то её изначальное значение не изменится, а если мы передаём указатель на эту переменную, то можем менять изначальное значение локальной переменной откуда угодно. Однако во всей красе возможности указателя раскрываются с другими типами данных, например с массивом.
Массив
Все программисты используют в своих программах массивы, но не все знают, что массив, а точнее имя массива это указатель указывающий на первый элемент этого массива. При этом объявляется он без всяких звёздочек. То есть в чём то он похож на «обычную» переменную. Если быть ещё более точным, то массив можно представить себе как набор однотипных переменных, расположенных в памяти друг за дружкой, а каждая из этих «переменных» является элементом массива.
Значение в квадратных скобочках говорит о том, сколько элементов содержится в этом массиве, а тип говорит о том, какого размера элементы этого массива, то есть сколько ячеек памяти занимает один элемент. Для примера возьмём такой массив…
char array[4] = {'D','i','m','a'};
Массив из четырёх элементов. Каждый элемент занимает в памяти одну ячейку (об этом говорит тип char). В каждый из элементов мы записали по одному символу, то есть инициализировали весь массив конкретными значениями.
Чтобы вывести массив на печать делаем так…
printf("array %sn", array);
Получаем…
Здесь всё выглядит так, как будто мы обратились к «обычной» переменной и вывели её на печать. Тем не менее легко доказать что
array
всё таки указатель. Достаточно изменить форматирующий символ «s» на «p»…
printf("array %pn", array);
И мы получим адрес…
Если же мы сделаем разыменование
array
…
printf("array %cn", *array);
То получим первый элемент массива…
Что доказывает сказанное выше — имя массива это указатель на первый элемент этого массива.
То же самое мы получим если добавим к имени индекс нулевого элемента массива…
printf("array %cn", array[0]);
В памяти это представляется следующим образом…
Имя
array
указывает на первый элемент массива (ячейка 5676), а следом идут остальные три элемента.
Чтобы нам было удобно обращаться к отдельным элементам этого массива компилятор любезно присвоил элементам индексы, начиная с нулевого. То есть ячейка 5676 получает индекс 0, ячейка 5677 получает индекс 1, ячейка 5678 получает индекс 2, и т.д. Важно помнить что отсчёт элементов ведётся от ноля.
На схеме индексов не видно, но программа знает какой ячейке присвоен какой индекс.
Квадратные скобки при использовании массива имеют двойное назначение. При объявлении массива в них указывается количество элементов, а в процессе работы индекс ячейки, то есть её порядковый номер в данном массиве.
Благодаря индексации мы легко и просто можем обращаться к любому элементу…
printf("array1 %cn", array[0]);
printf("array2 %cn", array[1]);
printf("array3 %cn", array[2]);
printf("array4 %cn", 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 %sn", 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 array16n");
for(uint8_t i = 0; i < 2; i++)
{
printf("array16[%d] %dn", i, array16[i]);
}
printf("nWrite array16n");
for(uint8_t i = 0; i < 2; i++)
{
array16[i] = 999;
printf("array16[%d] %dn", i, array16[i]);
}
}
Получим что ожидали…
Думаю понятно, что при использовании типа uint32_t, каждый элемент массива будет занимать четыре ячейки памяти, и соответственно каждый индекс отвечает за четыре ячейки.
Как и в случае с «обычной» переменной, мы можем к элементу массива применить операцию «взятия адреса»…
int main(void)
{
uint16_t array16[2] = {456, 789};
printf("Adr array16[0] %pn", &array16[0]);
printf("Adr array16[1] %pn", &array16[1]);
}
Видно что адрес второго элемента больше первого на два. То есть адрес первой ячейки первого элемента …374, а второй ячейки будет …375. То же самое со вторым элементом — адрес первой ячейки …376, а второй будет …377.
А теперь давайте зафиксируем мысль на этом последнем примере и перейдём к следующему, очень важному понятию в теме про указатели, к «адресной арифметике» или «арифметики с указателями».
Адресная арифметика
Оперируя указателями мы оперируем хранящимися в указателях адресами, а адреса в свою очередь это всего лишь цифры, а раз это цифры, то значит мы можем производить над ними арифметические действия. То есть если вычесть или прибавить к указателю какую-то цифру, то этот указатель будет указывать уже на другую ячейку памяти. Вроде бы всё просто, но здесь есть существенный нюанс — вся эта арифметика жёстко связана с типом указателя. Сейчас мы убедимся в этом воспользовавшись нашим последним примером.
Освежим в голове нашу схему…
И добавим в последний пример ещё одну строчку…
int main(void)
{
uint16_t array16[2] = {456, 789};
printf("Adr array16[0] %pn", &array16[0]);
printf("Adr array16[1] %pn", &array16[1]);
printf("Adr array16[0] %pn", &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] %pn", array);
printf("array[1] %pn", array + 1);
printf("array[2] %pn", array + 2);
printf("array[3] %pn", array + 3);
}
Результат…
Все адреса подряд.
Адресную арифметику удобно применять при парсинге строк. Например у нас есть массив со строкой (в языке СИ нету строк, есть только массивы), и нам нужно вывести на печать эту строку начиная с четвёртого символа, тогда делаем так…
int main(void)
{
char array[] = "istarik.ru";
printf("Full - %sn", array);
printf("Cut - %sn", 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 - %sn", src);
printf("Dst - %sn", 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("%sn", src);
clear_str(src);
printf("%sn", src);
}
Здесь я не буду ничего комментировать. В среде программистов бытует мнение, что указатель нельзя выучить, его можно только понять, как озарение. Сам через это проходил. Поэтому когда вы поймёте что происходит в этом примере, это будет означать что вы поняли указатель
Ну, а после понимания, всякие штуки типа указателя на указатель, и функции-указатели вы будете щёлкать как орешки.
Кстати, любопытная вещь — имя обычной функции, только без скобочек — это указатель на эту функцию, то есть адрес в памяти где расположена эта функция. Ради интереса можете добавить в последний пример строчку…
printf("F %pn", clear_str);
Это всё, всем спасибо
Телеграм-чат istarik
Телеграм-чат STM32
Указатели в Си
Указатель – переменная, в которой хранится адрес какого-либо объекта в памяти компьютера, например, другой переменной. Мы уже сталкивались раньше с адресами переменных, когда изучали функцию scanf.
Итак, пойдём по порядку. Объявление указателя.
Объявление указателя отличается от объявления переменной только добавлением символа * после названия типа. Примеры:
Листинг 1.
int * p_g; // указатель на переменную типа int double * p_f; // указатель на переменную типа double
Присвоить указателю какой-то адрес можно, используя оператор присваивания. Примеры:
Листинг 2.
int n = 100; double PI = 3.1415926; int * p_k; // указатель на переменную типа int double * p_pi; // указатель на переменную типа double p_k = &n; // получаем адрес переменной n и присваиваем его указателю p_k p_pi = &PI; // получаем адрес переменной PI и присваиваем его указателю p_pi
Для вывода значения указателя на экран нужно в функции printf использовать спецификатор %p.
Пример:
Листинг 3.
printf ("adres peremennoi PI %pn", p_pi);
Используя адрес переменной, который хранится в указателе, можно изменять значения этой переменной. Для этого используется операция разыменования *.
Вот посмотрите на пример:
Листинг 4.
#include <stdio.h> int main(void) { int a = 100; int * p_a = &a; // сохраняем в указатель адрес переменной a printf("a = %dn", a); // стандартный способ получить значение переменной a printf("a = %dn", *p_a); // получаем значение переменной a через указатель на неё // используя указатель p_a, записываем в переменную a другое значение *p_a = 50; printf("a = %dn", *p_a); return 0; }
Рис.1 Доступ к переменной через указатель
Итого, * применительно к указателям используется в двух случаях:
- при объявлении указателя, чтобы показать, что это указатель;
- если мы хотим обратиться к переменной, на которую указывает указатель.
Есть еще, так называемый, нулевой указатель NULL. Нулевой указатель не ссылается никуда. Он используется, чтобы обнулять указатели. Посмотрите на пример.
Листинг 5.
#include <stdio.h> int main(void) { int a = 100; int * p_a = &a; // сохраняем в указатель адрес переменной a printf("a = %dn", a); // стандартный способ получить значение переменной a printf("a = %dn", *p_a); // получаем значение переменной a через указатель на неё // используя указатель p_a, записываем в переменную a другое значение *p_a = 50; printf("a = %dn", *p_a); printf("%pn", p_a); p_a = NULL; printf("%pn", p_a); return 0; }
Рис.2 Обнуление указателя
Сохрани в закладки или поддержи проект.
Практика
Решите предложенные задачи:
Для удобства работы сразу переходите в полноэкранный режим
Дополнительные материалы
- пока нет