среда, 10 июля 2013 г.

Что такое указатели и как с ними работать

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

Очевидно стоит, так как мы ступили на нелегкий путь программирования во враждебной, практически лишенной всяческой программной оболочки (если не считать BIOS и GRUB) среде. И здесь указатели обретают несколько более важное значение, чем при прикладном программировании, где они умирают, благодаря разнообразным надстройкам и фреймворкам, скрывающим от программиста работу с динамической памятью. И уж никто из прикладников почти не задумывается о том чтобы работать с памятью напрямую.

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


1. Три сущности одного указателя


Если обратится к Википедии:
Указатель (англ. pointer) — переменная, диапазон значений которой состоит из адресов ячеек памяти или специального значения — нулевого адреса. Последнее используется для указания того, что в данный момент там ничего не записано.
 Теперь рассмотрим вот такую схемку


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

Таким образом одному указателю соответствуют три значения
  • Собственный адрес, по которому он расположен в памяти;
  • Адрес размещения в памяти данных, на которые он ссылается;
  • Сами данные, которые процессор отыщет, воспользовавшись указателем.
Разрядность указателя равна разрядности адресной шины процессора, по которой он обращается к памяти. В 16-разрядных системах указатели были 16-разрядными, в 32-разрядных они уже соответственно 32-битные. Ну а в 64-разрядных, разумеется 64-разрядные.

Представим себе теперь, что изображенный на рисунке указатель описан в 32-разрядной системе и ссылается он на 32-разрядные данные, то есть на языке C его описание таково

unsigned int *ptr;

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

unsigned int a = (unsigned int) ptr;

В результате такого присваивания (с преобразованием типа) переменная a примет значение 0x01F00004, ведь переменная-указатель ptr хранит именно это значение.

Тогда какой результат осядет в b после такого присваивания

unsigned int b = *ptr;

Звездочка перед именем указателя означает операцию разыменования, то есть получение значения, лежащего по хранимому в указателе адресу, а значит b будет равно 0x00010AB3. Дальше - больше, кто сказал что мы можем достать из памяти только это значение? Рассмотрим такие конструкции

unsigned int c = *(ptr + 1);
unsigned int d = *(ptr + 2);
unsigned int e = *(ptr - 1);

Сответственно, с примет значение 0x00000003, d станет равной 0x10000000, ну а e будет хранить 0x00000080. 

Обратите внимание, что сдвигаясь относительно указателя на 1 позицию, фактический адрес данных увеличивается на 4 - из-за того что указатель ссылается на 4-байтные данные типа unsigned int. Если указатель будет иметь другой тип, то преобразование адреса будет произведено компилятором автоматически. Обыкновенные массивы устроены именно так, а поэтому справедливо написать

unsigned int c = ptr[1];
unsigned int d = ptr[2];
unsigned int e = ptr[-1]; /* Да-да-да, отрицательный индекс массива!*/

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

Ну и наконец предскажем результат операции

unsigned int f = (unsigned int) &ptr;

В результате в переменной f окажется число 0x01EFFFFA - адрес размещение самого указателя в памяти. Знак & ("амперсанд") называется в языке C оператором взятия адреса, и возвращает он как раз таки указатель. Поэтому в данном примере понадобилось преобразование типа. И теперь, хоть и f не объявлена как указатель, она де-факто стала указателем на указатель ptr, ибо хранит адрес его размещения в памяти.

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

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

ptr = (unsigned int*) 0x01F0000C;

В указателе изменится хранимый им адрес на 0x01F0000C, ссылаться он уже будет на число 0x00000001, а его собственный адрес при этом не поменяется, останется равным 0x01EFFFFA, как как ptr уже продекларирован нами в коде.

 

 2. Обобщенный указатель void*


Обобщенный указатель применяется для корректного приведения одного типа указателя к другому. Рассмотрим такой код

unsigned int  a[1];
unsigned char b; 
................................
unsigned char byte_form_data_array(void* ptr, int index)
{
   unsigned char* bytes_array  = (unsigned char*) ptr;

   return bytes_array[index];
................................

a[0] = 0x001243F5;
b = bytes_from_data_array(a, 2);

путем такого ухищрения мы можем получить третий по счету байт в переменной a, которая есть массив из одного (!) элемента, равного 0x001243F5. Передав её в функцию через обобщеннй указатель ptr мы спокойно тарнсформируем это единственное значение в массив из четырех байт и получаем на выходе в b значение 0x12.

Заключение


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