image

«Язык Си как острая бритва: с его помощью можно сделать изящное произведение искусства или кровавое месиво.»— Керниган


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


Небольшое отступление про типы и переменные в C


Переменная в Си представляет собой непрерывный кусок памяти (как и в ассемблере), размер которого зависит от типа переменной. Так же, от типа зависит то, как процессор будет работать с данными лежащими в этой памяти (например знаковая или беззнаковая арифметика будет применяться к этим данным). Но вне зависимости от типа, любая переменная Си в своей сути — всего лишь набор последовательных байт памяти.

	char - 1 байт
	short - 2 байта
	int - 4 байта (2 байта на 16 и некторых 32 битных архитектурах; исторически со времен Ритчи и Томпсона- 2 байта)
	long - 4 байта (8 байт на некторых 64 битных архитектурах)


Как видим — бардак. Один и тот же код может скомпилится на разных архитектурах по разному.
Поэтому, когда нам нужна именно 32 битная (или еще какая-нибудь конкретная) переменная…

	/* ...мы инклудим: */
	#include <stdint.h>
	
	/* и получаем вот такие замечательные типы: */
	
	int8_t a, int16_t b, int32_t c, int64_t d;        // Знаковые
	uint8_t e, uint16_t f, uint32_t g, uint64_t h;    // и беззнаковые


Указатели


Указатель это целочисленная беззнаковая переменная, предназначенная для хранения адреса объекта в памяти и обеспечении доступа к нему. Размер указателя обычно равен разрядности вычислительной машины. Для 32 битного указателя диапазон его возможных значений будет: {0x00000000… 0xFFFFFFFF}, для 64 битного {0x0000000000000000… 0xFFFFFFFFFFFFFFFF} соответственно.

Важно отметить нулевой указатель — NULL. При попытке доступа к нулевому адресу контроллер виртуальной памяти MMU сгенерирует аппаратное прерывание, которое будет перехвачено ядром, и провинившийся процесс будет жестоко убит на месте.

Объявление указателей

Указатели объявляются так же как и любые другие переменные, с той лишь разницей, что перед именем указателя ставится звездочка. При этом тип указанный при объявлении указателя является типом структуры данных на которую он указывает. Для удобства демонстрации работы указателей я буду использовать тип uint32_t. Но указатели могут указывать на абсолютно любой тип структур.

	#include <stdlib.h>
	#include <stdint.h>
	#include <stdio.h>
	
	int main() {
 	/* p - указатель на переменную типа int32_t,
		e - просто переменная, т.к. перед ней нет звездочки! */
		uint32_t *p, e; 


Чтение/запись значения указателей

Несмотря на то, что указатель по своей сути всего лишь uint32_t/uint64_t переменная, компилятор в целях нашей же безопасности, не позволит нам делать с ним все то что мы привыкли делать с обычными переменными. Так, например попытка умножить указатель на что либо вызовет ошибку компиляции. Так что же мы можем делать с указателями? В первую очередь, присваивать и читать их значения, и здесь все почти как с обычными переменными:

        /* присвоим указателю произвольное значение */
		p = 5;
		
		/* теперь указатель p содержит адрес 0x00000005, в чем можем легко убедиться */
		printf("pointer p = %08X\n", p);


Если мы скомпилируем такой код, то скорее всего получим предупреждение компилятора, вроде такого:
	warning: incompatible integer to pointer conversion assigning to 'int *' from 'int'
Дело в том, что мы пытаемся присвоить указателю произвольное числовое значение, что вызывает у него вполне обоснованные подозрения в том что мы что-то перепутали.
Для того, чтобы развеять его сомнения, нам следует явно указать, что наше 5 — это не просто 5, а адрес переменной типа int. Для этого нам нужно сделать явное приведение типа.

Явное приведение типов в си производится указанием типа в круглых скобках перед значением. Неявное — осуществляется автоматически, там где это возможно. Например когда мы передаем char переменную в функцию принимающую int преобразование char к int произойдет автоматически. В случае же попытки передать int в функцию принимающую char компилятор сгенерирует предупреждение, поскольку в int при этом будет отброшены все байты кроме младшего.

    /* явно приведем тип нашей пятерки (int) к типу "указатель на int" */
	p = (int *)5;


Теперь наш код абсолютно валиден как с точки зрения компилятора, так и с точки зрения того кому доведется его читать. Однако, если мы обратимся к памяти по адресу 5, MMU обнаружит попытку доступа к непринадлежащей нам памяти, и у нас возникнет runtime error. Поэтому лучше попросим стандартную функцию malloc() выделить нам кусочек памяти, достаточный для размещения в нем одной int32_t переменной:

	p = malloc(4); /* выделим участок памяти в 4 байта и сохраним его адрес в p */
	
	/* и распечатаем полученный нами адрес */
	printf("pointer p = %08X\n", p);


Работа с указуемым объектом (Разименовывание указателя)

До сих пор мы так и не увидели в чем же заключаются преимущества указателей перед обычными беззнакоывми переменными.
Для хранения адреса нам запросто бы хватило обычной uint32_t переменной. Но прелесть указателей заключается в том, что он позволяет нам обратиться к адресуемому объекту. Для того чтобы сказать компилятору что мы хотим работать не с самим указателем, а с тем на что он указывает, перед именем указателя нужно поставить звездочку. Это называется разименовыванием указателя, и это третья разрешенная операция для указателей (после операций чтения и записи). Для начала, посмотрим что лежит в выделенной нам памяти:

	printf("*p = %08X\n", *p);

Скорее всего вы увидете нули, однако malloc() нам этого не гарантирует, и в выделенной памяти можно найти все что угодно. Нас это не должно смущать — неписаное правило программистов Си говорит о том, что память обнуляет тот, кто ее выделил. Следование ему избавляет от неприятных сюрпризов в рантайме.
Давайте теперь запишем какое нибудь значение в выделенное нами место:

	*p = 0x1234;

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

	printf("*p = %08X\n", *p);

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


Массивы и указатели


В си массив представляет собой кусок памяти размер которого равен произведению рамзера элемента на количество элементов в массиве. Так, массив:

	uint32_t m[2];
	m[0] = 0x01234567;
	m[1] = 0x89ABCDEF;

Будет занимать в памяти sizeof(unsigned int) * 2 = 4 * 2 = 8 байт, и после выполнения вышеприведенного кода будет выглядеть в памяти следующим образом:

      adderss   value
	[base+0000]  67
	[base+0001]  45
	[base+0002]  23
	[base+0003]  01
	[base+0004]  EF
	[base+0005]  CD
	[base+0006]  AB
	[base+0007]  89


Если вышеприведенная информация не вызывает у вас никаих затруднений в понимании (см. byte ordering), значит пришло время раскрыть одну тайну:
Массив в Си это просто указатель

Когда мы обращаемся к n-ному элементу массива компьютер умножает индекс (n) на размер одного элемнта массива — таким образом он получает смещение начала индексируемого элемента от начала массива. Затем компьютер складывает это смещение с указателем на массив и таким образом получает адрес индексируемого элемента в памяти. Далее этот адрес разименовывается и мы работаем уже с самим элементом!

Например, для доступа к m[1] в вышеприведенном примере, компьютер умножит sizeof(unsigned int) на 1 и получит смещение: 4*1 = 4. Далее он прибавит это смещение к базе: base+0004 и получит адрес 1го элемента массива (не путать с нулевым элементом!). Это происходит всякий раз, когда вы обращаетесь к элементу массива.

Приведенный ниже код призван продемонстрировать то, что массивы в Си являются указателями, а операция индексирования [] в си определена для указателей вообще:

	unsigned int *k;
	k = m; /* присваивание значения указателя (массива, что одно и то же) новому указателю */
	
	/* сейчас мы пишем все в тот же массив m! */
	k[0] = 0x00000000;
	k[1] = 0x11111111;


Как можно заметить, массивы в Си устроены очень просто, и в их основе лежат указатели.

Арифметика указателей



Как уже было ранее сказано, с указателями мы можем делать три вещи:
  • читать
  • писать
  • разименовывать

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

	/* Сейчас k еще указывает на m, что эквивалентно указанию на m[0] */
	
	k = k + 1; /* сдвинем k на 1 элемент вперед */


Теперь k указывает на m[1]! Это означает то, что если мы сделаем

printf("k[0] = %08X\n", k[0]);

То на экране появится 11111111, а не 00000000. Указатель сместился вперед на один элемент и первый элемент массива m остался позади, а второй стал для него первым. В прочем, мы все еще можем получить доступ к первому элементу массива m через указатель k:

printf("k[-1] = %08X\n", k[-1]);

Забавно, не правда ли? Ладно, поигрались и хватит :) Давайте вернем наш указатель назад.

	/* Сейчас k указывает на m[1], заставим его снова указывать на m[0] */
	
	k = k - 1; /* просто сдвинем k на 1 элемент назад */
	
	/* проверочка! */
	printf("k[0] = %08X\n", k[0]);


Теперь все вернулось на первоначальное место и k[0] снова равен 0x00000000.

А что если мы сейчас просто разименуем указатель k?

	printf("*k = %08X\n", *k);


Если вы выполните этот пример, то увидите, что *k и k[0] это одно и то же!
(Индексация это вычисление смещения до элемента, сложение смещения с указателем и разименовывание. Смещение равно 0, остается лишь разименовывание!)

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

Если вам необходимо сместить какой либо указатель на n байт, например указатель на int на 5 байт, то вы столкнетесь с проблемой: прибавляя к такому указателю 1 вы фактически будете прибавлять к нему 4, прибавляя 2 — 8, ит.д. Для того чтобы это сделать необходимо временно преобразовать тип указателя к указателю на char, и только тогда прибвлять к нему 5.

Еще один пример: индексация массива без помощи квадратных скобок. К примеру, хотим распечатать значение i-того элемента массива k:

	int i;
	...
	printf("k[i] = %08X\n", i, *(k + i));


Здесь мы вручную складываем указатель со смещением и разименовываем получившийся указатель.

Далее нами будут рассмотрены указатели на структуры (struct) и функции, и здесь cледует заметить, что прибавление (или вычитание) единицы к указателю на структуру прибавит к нему размер этой структуры. То есть, инкрементируя/декрементируя указатель на структуру мы перемещаемся вперед-назад по массиву структур. Аналогично ведут и себя указатели на функции.

Структуры Cи (struct)


Структуры Cи — аналог записей в паскале или объектов в ООП языках. Отличие от объектов заключается в том, что функции работающие со структурой описываются отдельно от нее. Структура в своей реализации очень похожа на массив. Разница лишь в том, что составные элементы структуры (поля или свойства) могут иметь разный размер, в то время как базовой идеей массива является то что все элементы одинаковы (а как иначе организовать простое индексирование массива?). Но доступ к полям структуры осуществляется не по индексу, а по имени. Опишем новую структуру в глобальной области, где нибудь перед функцией main():

	struct xx {
		uint8_t a;
		uint8_t b;
		uint32_t c;
	};


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

	/* Объявим указатель на нашу структуру */
	struct xx *xx_ptr;
	
	/* и выделим под нее участок памяти */
	xx_ptr = malloc(sizeof(struct xx));


Доступ к элементам структуры по указателю осуществляется с помощью оператора ->:

	/* Запишем что нибудь в нашу структуру */
	xx_ptr->a = 0xAA;
	xx_ptr->b = 0xBB;
	xx_ptr->c = 0x12345678;


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

	/* Заведем еще один - байтовый указатель, возьмем адрес нашей структуры
	и пройдемся по ней, распечатывая каждый байт */
	
	uint8_t *byte_ptr;
	byte_ptr = (uint8_t *) xx_ptr;
	
	int i;
	while(i < sizeof(struct xx)) {
		printf("[%04d] %02X\n", i, byte_ptr[i]);
		++i;
	}


В результате, работы вышеприведенного фрагмента мы увидим что память отведенная под нашу структуру имеет следующий вид:
	offset value
	[0000]  AA    <- a
	[0001]  BB    <- b
	[0002]  00    <- {!padding}
	[0003]  00    <- {!padding}
	[0004]  78    <- c
	[0005]  56       |
	[0006]  34       |
	[0007]  12    <--+


Стоп, скажете вы. Почему между полями b и с возник разрыв в 2 байта? Ответ на этот вопрост состоит в том, что современным процессорам гораздо удобнее работать с т.н. выровненными данными (Расставим точки над структурами C/C++). И данный padding в 2 байта заложенный компилятором позволит вашему процессору гораздо быстрее работать с такой слегка раздутой структурой. В то же время, если на первом месте стоит не скорость а память и вы имеете over9000 таких структур, то значительный кусок памяти будет пропадать вот на таких padding'ах. Для того, чтобы заставить компилятор паковать все именно так как вы написали (а это порой бывает очень важно, например в драйверах аппаратных устройств), в GCC существует аттрибут packed:

	struct xx {
		char a;
		char b;
		unsigned int c;
	} __attribute__ ((__packed__));


В других компиляторах данный атрибут выставляется с помощью директивы компилятора #pragma pack.

Если вы пересоберете пример с этим аттрибутом, то увидите что картина в памяти поменялась:

	offset value
	[0000]  AA    <- a
	[0001]  BB    <- b
	[0002]  78    <- c
	[0003]  56       |
	[0004]  34       |
	[0005]  12    <--+


Указатели на функции


Указатели на функции декларируются несколько замысловато:

	int (*func_ptr)(int a, int b);

Выше приведен указатель на функцию принимающую 2 int аргумента и возвращающую int.

— Заверните два!
— Легко!

	int (*func_ptr)(int (*func)(int), int k);

Указатель на функцию возвращающую int и принимающую (функцию принимающую int и возвращающую int) и int.

Вот такой он простой, наш Cи!

А вот вам массив из ста указателей на функцию:

	int (*func_ptr[100])(int a, int b);

Спросите и кому такое может понадобиться? — На самом деле очень полезная штука. Лично я применял такой массив при создании виртуальной машины. Работает куда быстрей чем switch диспетчер. Думаю и вам пригодится.

Инициализировать указатель на функцию очень просто, достаточно просто присвоить ему желаемую функцию, например так:
	void (*func_ptr)();

	func_ptr  = main; /* теперь func_ptr указывает на main */
	
	func_ptr(); /* вызовем main() через указатель */


Указатели на указатели


Указатели могут указывать на что угодно, что есть в памяти. Раз они сами находятся в ней, логично что и на себя. Зачем? Все очень просто — вот устройство двумерного массива:
	ptr -----> [ptr, ptr, ptr, ...]
				 |    |    |   ...
				[a]  [a]  [a]
				[r]  [r]  [r]
				[r]  [r]  [r]
				[a]  [a]  [a]
				[y]  [y]  [y]
				[1]  [2]  [3]
				...  ...  ...  ...

То есть — двумерный массив в Си это массив указателей на массивы данных. Указатель указывает на массив указателей, каждый из которых указывает на свою колонку двумерной матрицы. Когда просто работаешь с массивом об этом лучше не думать, чтобы не впасть в долгие и непродуктивные размышления о природе бытия :D.

Указатели на указатели настолько распространены, что встречаются даже в обычной unix main():

	int main(int argc, char **argv) {

Здесь argv — указатель на массив указателей на строки аргументов командной строки.

Принцип таких многоуровневых указателей очень прост. Назовем количество звездочек при объявлении указателя, его уровнем. То есть:
	char *p1;		/* первый уровень */
	char **p2;		/* второй уровень */
	char ***p3;		/* третий уровень */


Тогда для доступа к конечному объекту на который указывает указатель нужно понизить его уровень до нуля. Это делается при помощи звездочек при использовании:

	*p1 = 'A';
	**p2 - 'B';
	***p3 = 'C';


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

Операция взятия адреса


Создавать новые оъекты это хорошо, но что, если мы хотим получить адрес уже имеющегося объекта, например, параметра функции, локальной или глобальной переменной? Это возможно, и си предоставляет нам для этих целей оператор & (амперсанд) позволяющий получить адрес чего угодно, если это имеет адрес. Нельзя например взять адрес литеральной константы &5 и нельзя взять адрес результата выражения &(2+2) так как константа нигде не хранится; а результат выражения находится в регистре процессора, у которого как известно нет адреса, ит.п. Далее я приведу сжатый пример того как можно брать адрес различных объектов.

	struct x {
		int a;
	};
	
	/* Глобальные переменные */
	struct x x1;
	struct x *xp;
	int k;
	char c[100];
	
	int main() {
		/* указатели на разные типы */
		struct x *x_ptr;
		struct x **x_ptr_ptr;
		int *int_ptr;
		char *char_ptr;
		
		xp = malloc(sizeof(struct x));
		
		x_ptr = &x1;		/* берем адрес глобальной структуры */
		x_ptr = xp;		/* берем адрес структуры, лежащий в другом указателе */
		x_ptr_ptr = &xp;	/* берем адрес глобального указателя. адрес. указателя. берем :) */
		int_ptr = &x1.a;   	/* берем адрес поля глобальной структуры */
		int_ptr = &xp->a;	/* берем адрес поля структуры на которую указывает глобальный указатель */
		int_ptr = &k;   	/* берем адрес глобальной переменной */
		char_ptr = &c;		/* берем адрес глобального массива */
		char_ptr = c;		/* и так тоже можно, т.к. массив это указатель */
		char_ptr = &c[50];	/* берем адрес элемента массива с индексом 50  */

		return 0;
	}


Void — указатели


Язык Си не позволяет пользователю создавать void переменные (зачем?), зато позволяет создавать указатель на void. За таким указателем не закреплен конкретный тип, но каждый раз перед разименовыванием такого указателя вам придется приводить его к желаемому типу вручную. А как иначе компилятор узнает что вы хотите разименовать и как это сделать?

Функция malloc() определена как void *malloc(int size); что позволяет присваивать результат ее работы любому типу указателя без каких бы то ни было предупреждений со стороны компилятора.

Константа NULL тоже имеет тип void:
	#define NULL ((void *)0x0)


Заключение


Указатели — мощнейший инструмент, предоставляемый языком Си. Сочетание их практически безграничных возможностей с простотой внутренней организации программ Си сделало этот язык столь незаменимым и нестареющим. Думаю, заканчивать эту тему следует словами Кернигана, с которых она и началась.

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