C уже более 50 лет, но он по-прежнему популярен. Это просто, быстро и мощно. Операционная система Linux, рабочий стол GNOME, компиляторы для C++, FORTRAN и интерпретаторы для Python, Ruby и JVM написаны на C.

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

Если вы планируете работать специалистом по безопасности (аналитик вредоносных программ, пентестер, реверс-инженер), разработчиком встраиваемых систем, разработчиком ядра Linux, разработчиком графического или игрового движка и т. д., тогда C — ваш компаньон.

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

В качестве примера мы будем работать со следующим фрагментом кода:

//main.c
#include <stdio.h>
int global = 156;

int main()
{
  int a = 1;
  int b = 2;
  int c = 3;
  int *d = &a;
  printf("a points to %p that contains %d\n", &a, a);
  printf("b points to %p that contains %d\n", &b, b);
  printf("c points to %p that contains %d\n", &c, c);
  printf("d points to %p that contains %d\n", d, *d);
  printf("global points to %p that contains %d\n", &global, global);

  return 0;
}

Не волнуйтесь, если вы не знакомы с синтаксисом, мы обсудим это позже.

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

gcc -o example main.c

На выходе будет двоичный пример

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

  • Куча
  • куча
  • BSS (сегмент неинициализированных данных)
  • Данные (инициализированный сегмент данных)
  • Текстовый или кодовый сегмент

.text или сегмент кода

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

Вы можете проверить этот раздел вашего исполняемого двоичного файла с помощью команды «objdump -h ‹binary file›», и вы получите что-то вроде этого:

кроме того, вы можете просмотреть содержимое раздела с помощью «objdump -sj .text ‹binary file›»

Если мы разберем основную функцию двоичного файла example , мы увидим те же инструкции, которые мы получили из раздела .text на изображении выше (проверьте адреса в левой части оба изображения):

Сегменты .bss и .data:

.bss содержит все глобальные и статические переменные, которые инициализированы значением 0 или не инициализированы явно,

.data содержит все глобальные, статические, константные и внешние переменные, которые уже инициализированы.

//example snippet
int global = 1;   //<- global

void func ()
{
    static int i = 5;  //<- static   
}

Используя objdump, давайте посмотрим раздел .data нашего скомпилированного примера:

По адресу 4018 мы видим 9c000000, что является ничем иным, как нашей глобальнойпеременной из исходного примера

9cиз шестнадцатеричного в десятичное равно 156

.data и .bss с доступом на запись/чтение (WA=write/read), используя другой инструмент — readelf, мы можем проверить, что:

readelf -S example

куча

используется для хранения всех локальных переменных, аргументов и адресов возврата, переданных функциям. Это структура LIFO, расположенная по старшим адресам памяти программ:

Стек «растет» до младших адресов (на архитектуре x86) и примыкает к куче и растет друг к другу, как на картинке. Когда вы вызываете функцию из другой функции → стек будет расти

и уменьшается в размере по мере завершения функции и возвращается обратно

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

#include <stdio.h>
int global = 156;  //<- stored in .data

void func()
{
    int a = 1;  //<- stored on stack
    int b = 2;  //<- stored on stack
}

int main()
{
  func();
    return 0;
}

мы можем проверить это с помощью gdb:

По адресам +4 и +11 мы видим, как a(a=1) и b(b= 2) хранятся в стеке с инструкциями:

 movl   $0x1,-0x4(%rbp)
 movl   $0x2,-0x8(%rbp)

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

Затем мы можем проверить адрес rbp со смещениями -0x4 и -0x8, так как локальные переменные хранились именно по этим смещениям:

В этом примере a и b существуют только на время вызова func().

куча

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

Когда часть памяти не нужна, ее следует освободить с помощью вызова функции free, а память, которую она использовала внутри кучи, освобождается.

Со всей этой информацией давайте рассмотрим пример с самого начала:

//main.c
#include <stdio.h>
int global = 156;

int main()
{
  int a = 1;
  int b = 2;
  int c = 3;
  int *d = &a;
  // %p prints pointer, %d prints decimal
  printf("a points to %p that contains %d\n", &a, a);
  printf("b points to %p that contains %d\n", &b, b);
  printf("c points to %p that contains %d\n", &c, c);
  printf("d points to %p that contains %d\n", d, *d);
  printf("global points to %p that contains %d\n", &global, global);

  return 0;
}

Мы уже знаем, что a, b и c — это переменные, хранящиеся в стеке.

Далее идет новый синтаксис int *d =

int *d — это объявление «указатель на int». А указатель — это переменная, в которой хранится адрес памяти.

& — это унарный оператор, который дает адрес переменной, к которой он применяется, в примере: &a — он дает адрес переменной a

поэтому d теперь имеет адрес a, и мы можем убедиться в этом, скомпилировав и запустив пример:

ПРИМЕЧАНИЕ: вы можете заметить, что адреса, на которые указывают переменные, уменьшаются по мере того, как запас увеличивается до более низких адресов.

мы можем объявить указатели на:

char* p;
short* p;
int *p; 
long *p;
float* p;
double* p;
void* p;
...

void* p — особый, и мы можем попытаться привести его к любому из упомянутых выше, но это будет для другого поста 😎

также вы можете объявить указатель на структуру;

Чтобы получить содержимое указателя, существует другая операция, называемая разыменованием указателя, которая выполняется с использованием следующего синтаксиса *d:

int a = 5;
int *d = &a; // d points to a
printf("%d\n", *d); // dereferencing d pointer and prints its content
// output will be 5

Теперь давайте посмотрим на пример, где указатели могут нам помочь.

#include <stdio.h>

void func(int a)
{
  a += 1;
  printf("%d\n", a);
}

int main()
{
  int a = 1;
  func(a);
  printf("%d\n", a);

  return 0;
}

Мы передаем переменную в func и увеличиваем ее там, но на самом деле в основной функции она не обновляется, на выходе будет

┌──(kali㉿kali)-[~/Workdir]
└─$ ./test2 
2
1

Коротко говоря, переменная a, переданная как значение, копируется в функцию func, а локальная функция a in func фактически обновляется.

Чтобы исправить это, мы можем вернуть обновленную переменную из func или передать как ссылку, используя указатели:

#include <stdio.h>

void func(int *a)
{
    if (a == NULL)  // we have to check pointers
      return;       // otherwise there is a chance we may dereference
    *a += 1;        // a null pointer and that will crash the program
    printf("%d\n", *a);
}

int main()
{
    int a = 1;
    func(&a);
    printf("%d\n", a);

    return 0;
}

теперь вывод:

Еще одно важное замечание об указателях — они необходимы для динамического выделения памяти.

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

Мы хотели бы иметь буфер памяти, но не знаем, сколько памяти нам нужно — это задание на динамическое выделение памяти

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

Чтобы передать массив другой функции — используйте указатели

Пример:

//test2.c
#include <stdio.h>
#include <malloc.h>

int main( void )
{
    int on_stack = 5;
    char *string;

    // Allocate space for a path name
    string = (char*) malloc( (size_t)128 );

    if( string == NULL )
      printf( "Insufficient memory available\n" );
    else
    {
      printf( "Stack variable : %p \n", &on_stack );
      printf( "Memory space allocated for path name: %p \n", string );
      free( string );
      printf( "Memory freed\n" );
    }
    return 0;
}

Чтобы выделить память в куче, используйте malloc. Не забудьте освободить его с помощью вызова функции free, иначе вы получите утечку памяти!

//test3.c  but with memory leak
#include <stdio.h>
#include <malloc.h>

int main( void )
{
    int on_stack = 5;
    char *string;

    // Allocate space for a path name
    string = (char*) malloc( (size_t)128 );

    if( string == NULL )
      printf( "Insufficient memory available\n" );
    else
    {
      printf( "Stack variable : %p \n", &on_stack );
      printf( "Memory space allocated for path name: %p \n", string );
      // free( string );
      printf( "Memory freed\n" );
    }
    return 0;
}

Подведем итог:

  • Память стека используется для хранения аргументов функции и локальных переменных, а память кучи используется для хранения объектов.
  • Стек не является гибким, размер выделенной памяти нельзя изменить, и мы не можем выделять память во время выполнения, в отличие от кучи, где мы можем выделять память динамически.
  • Указатели имеют решающее значение для динамического распределения памяти и для «передачи по ссылке».

Это еще не все!!! Мы только начали 😁

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

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

Это может быть полезно для перебора непрерывной памяти, такой как массив:

#include <stdio.h>

int main () 
{

   int  arr[] = {1, 2, 3, 4, 5};
   int  i, *ptr;
   size_t len = sizeof(arr) / sizeof(int);

   ptr = arr;
 
   for ( i = 0; i < len;i++) {

      printf("Address of var[%d] = %x\n", i, ptr );
      printf("Value of var[%d] = %d\n", i, *ptr );

      /* move to the next location by incrementing ptr*/
      ptr++;
   }
 
   return 0;
}

ptr — указатель на int, размер int — 4 байта в C, поэтому каждое приращение добавляет 4 байта к адресу, который ptr содержит как значение:

без приращения -› d4ed5ea0

первое приращение -> d4ed5ea4

второй шаг -> d4ed5ea8

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

Мы уже обсуждали, что код каждой функции находится в памяти… да, вы понимаете, о чем я, каждая функция имеет адрес, как и все остальные переменные в программе 😏.

и мы можем получить его, просто написав имя функции!

Прекрасными примерами этого являются qsort и signal.

Давайте посмотрим на объявление функции qsort:

void qsort(void *base, size_t nitems, size_t size, int (*compar)(const void *, const void*))

Последний аргумент — это указатель на функцию:

int (*compar)(const void *, const void*)

compar — это указатель на функцию, которая возвращает int и имеет два аргумента const, указывающие на void. Вот золотое правило чтения указателя C на объявление функции

Как это используется? Легко, вы определяете свой собственный компаратор двух объектов и просто передаете его в qsort 🧑‍💻

//test2.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>

typedef struct 
{
   char name[50];
   int age;
}
person;

int comparePersonAge(const void *p1, const void *p2)
{
   // New Syntax
   // when pointer point to struct 
   // to access its member we need to use ->
   if(((person*)p1)->age < ((person*)p2)->age)
   {
      return 0;
   }
   return 1;
}

int main() 
{
   int i;
   person person_arr[3];
   strcpy(person_arr[0].name, "Alice");
   person_arr[0].age= 22;
   strcpy(person_arr[1].name, "Bob");
   person_arr[1].age= 45;
   strcpy(person_arr[2].name, "Eve");
   person_arr[2].age= 35;
 
   qsort((void*)person_arr, 3, sizeof(person_arr[0]), comparePersonAge);
   for(i = 0; i<3; i++) {
      printf("%s age is %d\n", person_arr[i].name, person_arr[i].age);
   }
}

Примеры из жизни

Пример арифметики указателя

https://android.googlesource.com/platform/system/keymaster/+/refs/heads/nougat-iot-release/key_blob_utils/software_keyblobs.cpp#171

Пример передачи по ссылке

https://android.googlesource.com/platform/system/keymaster/+/refs/heads/nougat-iot-release/key_blob_utils/software_keyblobs.cpp#250

Пример использования указателя на указатель

https://boringssl.googlesource.com/boringssl/+/refs/heads/master/ssl/internal.h#294

Что дальше?

На этом все, надеюсь вам понравилась эта статья 😊

Не стесняйтесь задавать вопросы в комментариях или в Твиттере