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

Размерность в Си

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

Итак, если размер int = 4 байта, размер short int = 2 байта, размер сhar = 1 байт, то какой размер у булевой переменной? Ответ: булевой переменной в принципе не существует без подключения стандартных библиотек. А при подключении библиотеки stdbool.h, размер bool = 1 байт(Зависит от платформы и компилятора, используя компьютер с 32-битным процессором и компилятор gcc я заметил, что размер bool = 4 байта).

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

Для проверки этого можно использовать следующий код:

#include <stdbool.h>
#include <stdio.h>

int main(int argc, char** argv) {
  printf("%llu\n", sizeof(bool)); // sizeof возвращает размер в байтах. А в библиотеке
                                  // stdint существует соответсвующий символу тип, "uint8_t", где 8 - это количество бит. Но размер возвращает 1 байт.
  // %llu для вывода long long unsigned int, или, коротко, size_t.
  return 0;
}
// Вывод:
// sizeof bool: 1 // или что-то ещё.

Пояснение: булева переменная - это число, которое принимает либо 0(false), либо 1(true), нужен ровно 1 бит, 1/8 байта.

Если говорить о размерах, то лучше сразу вывести размеры различных типов:

#include <stdio.h>
#include <stdint.h>


// Windows 10, 64-разрядный процессор, компилятор GCC
int main(int argc, char** argv) {
  // вот размеры разных типов                         // Полные названия типов
  printf("sizeof int8_t: %llu\n", sizeof(int8_t));    // char
  printf("sizeof uint8_t: %llu\n", sizeof(uint8_t);   // unsigned char
  printf("sizeof int16_t: %llu\n", sizeof(int16_t);   // short int
  printf("sizeof uint16_t: %llu\n", sizeof(uint16_t); // unsigned short int
  printf("sizeof int32_t: %llu\n", sizeof(int32_t);   // int
  printf("sizeof uint32_t: %llu\n", sizeof(uint32_t); // unsigned int
  printf("sizeof int64_t: %llu\n", sizeof(int64_t);   // long long int
  printf("sizeof uint64_t: %llu\n", sizeof(uint64_t); // unsigned long long int
  
  // Но это не всё. Сразу отмечу ещё один момент, который меняет ВСЁ.  
  printf("sizeof uint8_t*: %llu\n", sizeof(uint8_t*);   // unsigned char*
  printf("sizeof uint16_t*: %llu\n", sizeof(uint16_t*); // unsigned short int*
  printf("sizeof uint32_t*: %llu\n", sizeof(uint32_t*); // unsigned int*
  printf("sizeof uint64_t*: %llu\n", sizeof(uint64_t*); // unsigned long long int*
  printf("sizeof void*: %llu\n", sizeof(void*);         // void*
      // В этом блоке размеры РАВНЫ.
      // Это потому что размер указателя диктуется РАЗРЯДНОСТЬЮ ПРОЦЕССОРА,
      // где 64-битные процессоры соответсвуют 8-байтным указателям,
      // в следствие чего меняется и максимальный размер оперативной памяти.

  // И раз зашла речь о типах данных, то в библиотеке stdint.h
  // содержится ещё несколько интересных типов
  printf("sizeof uintptr_t: %llu\n", sizeof(uintptr_t));
  printf("sizeof intptr_t: %llu\n", sizeof(uintptr_t));
  return 0;
}
// Вывод:
// sizeof int8_t: 1
// sizeof uint8_t: 1
// sizeof int16_t: 2
// sizeof uint16_t: 2
// sizeof int32_t: 4
// sizeof uint32_t: 4
// sizeof int64_t: 8
// sizeof uint64_t: 8
// sizeof uint8_t*: 8  // Размеры указателей верны для 64-битного процессора.
// sizeof uint16_t*: 8 // На других архитектурах возможны другие размеры.
// sizeof uint32_t*: 8 
// sizeof uint64_t*: 8 
// sizeof void*: 8     
// sizeof uintptr_t: 8 // Этот два типа представляет собой указатель, как число.
// sizeof intptr_t: 8  // Да, В него можно преобразовать указатель. В этом даже фишка

Если у вас ничего не щёлкнуло в голове, то поясню - все указатели имеют одинаковый размер, а значит их можно относительно безболезненно преобразовывать между собой (Что-то вроде union). unsigned int* uint_ptr = (unsigned int*) int_ptr; Использовать предельно аккуратно, так как при преобразовании указателя, данные внутри не преобразовываются: символ со знаком -0x7F равен символу без знака 0xFF из-за того, что самый большой бит ответственен за знак. А если мы преобразовываем указатель с размером типа 2 байта, в указатель с размером типа 1 байт, мы гарантированно получим только первую половину данных, если считать от адреса указателя.

UPD 29.01.2023: Пользователь @MiraclePtr поделился статьёй про синонимы.

Структуры(struct), объединения(union) и немного enum.

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

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

#include <stdio.h>

struct s_data {
  unsigned char type; // sizeof(char) = 1;
  int x, y, z; // (sizeof(int) = 4) + (sizeof(int) = 4) + (sizeof(int) = 4);
}; // суммарно 13

struct s_data_arr {
  unsigned char type; // sizeof(char) = 1;
  int values[3]; // sizeof(int) * 3 = 4 * 3;
}; // Суммарно 13

int main(int argc, char** argv) {
  printf("sizeof struct s_data: %llu\n", sizeof(struct s_data));
  printf("sizeof struct s_data_arr: %llu\n", sizeof(struct s_data_arr));
  printf("sizeof struct s_data*: %llu\n", sizeof(struct s_data*));
  return 0;
}
// Вывод
// sizeof struct s_data: 16     //(Всё дело в выравнивании по байтам.
// sizeof struct s_data_arr: 16 // Это будет представлено
// sizeof struct s_data*: 8     // так: 
                              // char, NULL_byte, NULL_byte, NULL_byte, int, int, int)
                              // Выравнивание по n sizeof(type), где type - тип,
                              // а n - положение на линейке оперативной памяти
                              // Ссылку на более подробное описание добавлю в конце
                              // статьи, так как сам только недавно прочитал.
// Пы. Сы. Зато в эти 3 байта можно вписать ещё переменных. Вроде такого:

struct s_data__ {
  unsigned char type; // sizeof(char) = 1;
  unsigned char chr; // sizeof(char) = 1;
  unsigned short int count; // sizeof(unsigned short int) = 2;
  int x, y, z; // (sizeof(int) = 4) + (sizeof(int) = 4) + (sizeof(int) = 4);
}; // суммарно 16. sizeof(s_data__) = 16

// P. P. S. Для того, чтобы получить 13-байтовую структуру, нам потребуется упаковать её, или перетасовать свойства:
#pragma pack(1) // выровнять по 1 байту
struct s_data_1 {
  unsigned char type; // sizeof(char) = 1;
  int values[3]; // sizeof(int) * 3 = 4 * 3;
} // фактически 13 байт.
# pragma pack(show)

Если поля структуры размещаются последовательно, то поля объединений начинаются из одной точки и имеют размер наибольшего элемента. И если привести одну структуру к другой, даже если они имеют одинаковый размер, невозможно, то union позволяет сотворить чудо. Меньше трёпа, больше кода:

#include <stdio.h>

struct s_data_xyz {
  unsigned char type; // sizeof(char) = 1;
  int x, y, z; // sizeof(int) = 4;
}; // суммарно 13, но 16, хотя это для нас не важно, доверимся компилятору.

struct s_data_arr {
  unsigned char type; // sizeof(char) = 1
  int values[3]; // sizeof(int) * 3 = 4 * 3
}; // Суммарно 13, но 16

union pos {
  unsigned char type;
  struct s_data_xyz as_xyz;
  struct s_data_arr as_arr;
};

int main(int argc, char** argv) {
  union pos p;
  printf("sizeof union pos: %llu\n", sizeof(union p));
  p.type = 0;
  p.as_xyz.x = 12;
  p.as_xyz.y = 3;
  p.as_xyz.z = 7;

  printf("p.type: %u\n", p.type);
  printf("p.as_xyz.type: %u\n", p.as_xyz.type);
  printf("p.as_arr.type: %u\n", p.as_arr.type);

  printf("arr elems:\n");
  for (int i = 0; i < 3; i++) {
    printf("%d: %d\n", i, p.as_arr.values[i]);
  }
  
  return 0;
}
// Вывод
// sizeof union pos: 16
// p.type: 0 // Указатели на один и тот же байт без указателей. Всё это.
// p.as_xyz.type: 0
// p.as_arr.type: 0
// arr elems:
// 0: 12
// 1: 3
// 2: 7

Рассказывать о перечислениях(enum) Нечего, потому что это массив чисел, который компилятор удобно подписал ключевыми словами. Не хуже справляется команда препроцессора #define.

В любом случае покажу на примере:

enum {
  ELEM_1, ELEM_2, ELEM_3, ELEM_MAX
}; // Всё int
enum Elems {
  ELEM_1, ELEM_2, ELEM_3, ELEM_MAX
}; // Всё Elems, который typedef int Elems;
// Работает только с int
typedef unsigned char Elems;
#define ELEM_1    ((Elems) 0x00) // Не уверен в том, что это не будет воспринято как препроцессорная функция
#define ELEM_2    ((Elems) 0x01)
#define ELEM_3    ((Elems) 0x02)
#define ELEM_MAX  ((Elems) 0x03)

void fn(Elems a) {} // Так используется в объявлении функций.

Указатели

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

Указатель - это число равное, или меньшее размером, чем разрядность процессора(Большее число просто не выйдет просчитать так же эффективно).

То есть на 64-битном процессоре это 8-байтовые указатели, или меньшие, 4-байтовые и 2-байтовые, на 32-битном - 4-байтовые, или меньшие, 2-байтовые. uintptr_t в свою очередь по размеру совпадает с максимальным допустимым размером указателя на процессоре.

Указатель представляет собой адрес переменной, структуры, константы и чего-либо другого в оперативной памяти. Почему в оперативной памяти, хотя есть куча(от английского heap, место в памяти, выделенное на время выполнения программы) и стек(от английского stack, модель данных, цепочка структур, которые указывают в простейшей реализации на себя и на следующий элемент)? Потому что есть куча и стек, которые существуют одновременно в оперативной памяти, как и многое другое, в том числе другие программы.

UPD 29.01.2023: на микроконтроллерах AVR указатели могут быть 24-битными(3 байта) и 16-битными(2 байта), как подсказал в комментариях пользователь @sun-ami.

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

При этом мы не сами выделяем память, а просим библиотеку попросить операционную систему, или другое устройство, ответственное за выделение и управление памятью, выделить нам память на нужное количество байтов, получая указатель в куске виртуальной памяти, выделенной операционной системой, для нашей программы. Так всё будет, вплоть до тех пор, пока разработчик не решит самостоятельно написать операционную систему, прописывая собственный stdlib.h, или не решит вести разработку под специфическое устройство, которое обладает специфическими ограничениями в части управления памятью и собственную библиотеку для управления памятью.

Для примера напишем функцию, которая принимает указатель на число и увеличивает значение данных по его адресу на 1 (В данном примере мы ещё не выделяем и не освобождаем память, а лишь передаём адреса):

#include <stdlib.h>
#include <stdio.h>

// Объявления
void increace_value(int* pvalue);
int main(int argc, char** argv);

// Реализации
int main(int argc, char** argv) {
  int a = 2;
  printf("start_\ta: %d\n", a);
  increace_value( // Вызываем функцию
    &a // Передаём АДРЕС переменной в функцию ( Увеличивает количество звёзд после типа на 1)
  );   // тип &a = int*, &&a = int**, &&&a = int*** и так далее
  printf("inc_\ta: %d\n", a);
  return 0;
}

void increace_value(
  int* pvalue // Прибавляем префикс p столько же раз, сколько есть звёздочка.
              // Так удобно воспринимать аргументы
) {
  *pvalue += 1; // обращаемся к значению по адресу и увеличиваем на один.
} // В свою очередь, если int*** val, то *val = int**, val** = int*, val*** = int

// Вывод:
// start_   a: 2
// inc_     a: 3

А теперь перепишем его так, чтобы a был изначально указателем:

#include <stdlib.h>
#include <stdio.h>

// Объявления. Можно вынести в main.h и подключать его с помощью #include
void increace_value(int* pvalue);
int main(int argc, char** argv);

// Реализации
int main(int argc, char** argv) {
  int* a = (int*) malloc(sizeof(int)); // Выделяем байты по размеру числа. *alloc функции возвращают адрес в оперативной памяти. 
  *a = 5; // задаём значение переменной по адресу, как в функции.
  printf("start_\ta: %d\n", *a); // выводим значение из адреса
  increace_value(a); // Передаём указатель
  printf("inc_\ta: %d\n", *a); // повторяем предыдущий вывод, уточняя, что выводим после увелечения значения
  free(a); // очищаем память
  return 0;
}

void increace_value(
  int* pvalue // Прибавляем префикс p столько же раз, сколько есть звёздочка.
              // Так удобно воспринимать аргументы
) {
  *pvalue += 1; // обращаемся к значению и увеличиваем на один.
}

// Вывод:
// start_  a: 5
// inc_    a: 6

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

Куча(heap) представляет собой все динамически(во время выполнения программы) выделяемые данные. (Каждый раз, когда мы используем *alloc функции, мы получаем адрес начала блока из области кучи, а также размеченную часть данных)

Стек в свою очередь можно сравнить со стопкой тарелок, или магазином от автомата, пистолета, или любого другого магазинного орудия. Если точнее, то стек - это модель памяти. В стек попадают все локальные переменные в самый конец, откуда первыми могут быть взяты(Принцип "Последний пришёл, первый ушёл", LIFO), созданные между { и }, инструкции между которыми представляют собой блок кода. Куча может в программе и не появиться (Это также зависит от платформы и компилятора), но стек обязательно появится, как минимум для хранения данных о пустом int main(). (Без данной функции мы не сможем создать исполняемый файл через компилятор, конечно мы не используем фреймворк, который имеет собственный main, как тот же winapi, который требует метод win_main в качестве начала программы)

Собственно очистка памяти нужна, чтобы: первое - убрать за собой. Если мы её не очищаем, то другие программы могут получить доступ к этим данным, когда будут объявляться, так как после завершения программы, куча теряет разметку и доступ к нему можно получить только в виде указателей, которые также теряются по выходу из ранее упомянутого блока, поэтому даже, если мы ничего не объявили после malloc нашей структуры, мы гарантированно найдём в ней какие-то байты.

Массивы в Си

Массивы в Си - это указатели на области памяти, которые имеют N размеров типа данных(N * sizeof(type)). Вот так выделяется память для массивов через malloc:

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char** argv) {
  int* iarr = (int*) malloc(sizeof(int) * 4)); // выделяем память для 4 элементов типа int
  // умножение дольше, чем сложение, поэтому неоптимально.
  for (int i = 0; i < 4; i++) {
    iarr[i] = i * 2; // записываем значения.
  }
  for (int i = 0; i < 4; i++) {
    printf("%d: %d\n", i, iarr[i]); // вывод
  }
  free(iarr); // очищаем память
  return 0;
}

Однако для создания массива лучше подойдёт функция calloc, которая принимает количество элементов и размер одного элемента:

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char** argv) {
/*
(тип*)calloc(кол-во элементов, размер одного элемента.);*/
  int* iarr = (int*) calloc(4, sizeof(int))); // выделяем память для 4 элементов типа int
  // вопрос умножения решается в стандартной библиотеке.
  for (int i = 0; i < 4; i++) {
    iarr[i] = i * 2; // записываем значения.
  }
  for (int i = 0; i < 4; i++) {
    printf("%d: %d\n", i, iarr[i]); // вывод
  }
  free(iarr); // очищаем память
  return 0;
}

А теперь попробуем перебрать массив в цикле while:

#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
int main() {
//(тип*)calloc(кол-во элементов, размер одного элемента.);
    int* iarr = (int*) calloc(8, sizeof(int)), * iterator = iarr;
    // выделяем память для 4 элементов типа int
    ptrdiff_t diff;
    // вопрос умножения решается в стандартной библиотеке.
    for (int i = 0; i < 8; i++) {
        iarr[i] = i; // записываем значения.
    }
    while ((diff = iterator - iarr) < 8) 
    {
        printf("%d\n", *iterator); // вывод 
        printf("ptr: 0x%p\n", iterator); // Интересный момент, массивы размером от 16 байт и больше, выравниваются в оперативной памяти по 16 байт, так как нумерация на моей системе 0, 4, 8, C, 0, 4, 8, C...(Последний 16-ричный разряд адреса)
        iterator++; // iterator инкрементить в последнем упоминании блока
    } 
    free(iarr); // очищаем память, удалять указатель iterator не требуется
    return 0;
}
// Вывод:
// 0
// ptr: 0x##############C0
// 1
// ptr: 0x##############C4
// 2
// ptr: 0x##############C8
// 3
// ptr: 0x##############CC
// 4
// ptr: 0x##############D0
// 5
// ptr: 0x##############D4
// 6
// ptr: 0x##############D8
// 7
// ptr: 0x##############DC

Инкремент увеличивает значение указателя не на 1, а на размер типа указателя(В нашем случае на 4, если записатьiteratorи iarr как char*, то мы будем увеличивать значение на размер 1 символа, на байт).

ptrdiff_t позволяет рассчитывать разницу(разность) между указателями. То есть, при вычитании указателя на массив из указателя на элемент массива, мы получаем номер элемента. На это указывают 8 выводов, от нулевого до седьмого.

Также поговорим немного про многомерные массивы, если точнее, то про массивы указателей. Они представляют собой массив, который содержит другой массив(повторить n - 1 раз, где n - мерность массива), который содержит значения. Каждый компилятор ограничивает как глубину рекурсии, так и глубину мерности массива по своему, обычно глубина мерности массива стоит на 12(чтобы вы понимали, это поставить [] 12 раз и везде указать размер после названия переменной). Сразу приведу пример

#include <stdio.h>

int main() {
  int arr[4][4][4]; /*трёхмерный массив четыре на четыре на четыре*/
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        arr[z][y][x] = z * 4 * 4 + y * 4 + x; // Всё сделано за нас, компилятор уже выделил память.
      }
    }
  }
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, arr[z][y][x]);
      }
    }
  }
  return 0;
}
// {0, 0, 0}: 0
// {1, 0, 0}: 1
// {2, 0, 0}: 2
// {3, 0, 0}: 3
// {0, 1, 0}: 4
// {1, 1, 0}: 5
// {2, 1, 0}: 6
// {3, 1, 0}: 7
// ...
// {0, 2, 3}: 56
// {1, 2, 3}: 57
// {2, 2, 3}: 58
// {3, 2, 3}: 59
// {0, 3, 3}: 60
// {1, 3, 3}: 61
// {2, 3, 3}: 62
// {3, 3, 3}: 63

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

#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>

int main() {
  int*** arr = (int***) calloc(4, sizeof(int**)); /*трёхмерный массив четыре на четыре на четыре*/
  for (int z = 0; z < 4; z++) {
    arr[z] = (int**) calloc(4, sizeof(int*));
    for (int y = 0; y < 4; y++) {
      arr[z][y] = (int*) calloc(4, sizeof(int));
      for (int x = 0; x < 4; x++) {
        arr[z][y][x] = z * 4 * 4 + y * 4 + x; // Наконец пишем значение
      }
    }
  }
  for (int z = 0; z < 4; z++) {
    for (int y = 0; y < 4; y++) {
      for (int x = 0; x < 4; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, arr[z][y][x]);
      }
    }
  }
  // А теперь очищаем память
  for (int z = 3; z >= 0; z--) {
    for (int y = 3; y >= 0; y--) {
      free(arr[z][y]);
    }
    free(arr[z]);
  }
  free(arr); // Долго... Слишком долго.
  return 0;
}
// Вывод будет как с константными размерами

Именно поэтому нужно знание хотя бы немного геометрии. Точнее умение считать площадь, объём и возможно гипер-объём(для 5-мерных массивов, но тут не сложно, идею можно по четырёхмерным продолжить).

#include <stdlib.h>
#include <stdio.h>

// препроцессорные команды. Они подменят текст ниже на значения. То есть вместо
// volume(WIDTH, HEIGHT, DEPTH) будет сразу 4 * 4 * 4
#define volume(w, h, d) w * h * d
#define WIDTH 4
#define HEIGHT 4
#define DEPTH 4
// x = 1 * слой
// y = длина w * слой
// z = площадь wh * слой
// w = объём dwh * слой
// и так далее, где слой - название переменной, которая отвечает за определённую координату
#define get_id_3d(x, y, z, width, height) (z * width * height + y * width + x)

int main() {
  int* array = (int*) calloc(volume(WIDTH, HEIGHT, DEPTH), sizeof(int));
  for (size_t i = 0; i < volume(WIDTH, HEIGHT, DEPTH); i++) {
    array[i] = i; // Просто поставим значение, равное i в iтый элемент.
  }

  for (size_t z = 0; z < DEPTH; z++) {
    for (size_t y = 0; y < HEIGHT; y++) {
      for (size_t x = 0; x < WIDTH; x++) {
        printf("{%d, %d, %d}: %d\n", x, y, z, array[get_id_3d(x, y, z, WIDTH, HEIGHT)]);
      }
    }
  }
  free(array);
  return 0;
}
// вывод повторяет предыдущие выводы многомерных массивов

Вот и вся суть массива. Не злоупотребляйте, либо злоупотребляйте хотя бы в меру. Иначе вылетит stack trace.

Вектор на Си и realloc.

Для наглядного примера управления памятью я предлагаю в качестве примера реализацию одномерного вектора. Если точнее, то целочисленного вектора, который будет держать обычный int, но это из-за того, что в Си ещё нет шаблонов и ООП. ООП можно создать, но это выглядит бесполезно, как минимум из-за того, что Си - это структурный, процедурный, или, по-современному, функциональный язык программирования. В любом случае начнём с того, что определим цель создания данной библиотеки(Что и зачем писать):

  • Допустим, мы хотим уметь держать динамически(чтобы можно было добавить сколько пожелаешь элементов, пока позволяет память в "куче") расширяемый список;

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

  • Он будет создаваться посредством передачи указателя на обыкновенную переменную функции (А не заставлять будущего пользователя самостоятельно выделять память для элемента и писать его свойства);

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

  • Также библиотека должна уметь копировать векторы.

Итак, цели определены, значит определим функции.

  • vcCreateVectori(vectori* pvector);

  • vcDestroyVectori(vectori vector);

  • vcPushBacki(vectori* pvector, int value);

  • vcReplacei(vectori vector, size_t id, int new_value);

  • vcDuplicatei(vectori vector, vectori* ptarget);

  • vcGeti(vectori vector, size_t id, int* ptarget);

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

// inc/vc/vectori.h
#ifndef VC_VECTORI_H
#define VC_VECTORI_H

#define OK 0 // объявляем нормальное выполнение программы.
#define ERR_CANNOT_ALLOC 1 // ОШИБКА! Не смогли выделить память под массив
#define ERR_OUT_OF_BOUNDS 2 // Ошибка! Вы просите изменить элемент вне вектора!
#define ERR_TARGET_IS_ALIAS_OF_SOURCE 3 // Ошибка! Вы дали синоним входного значения как выходное

#include <stdlib.h> // для size_t и malloc, calloc, realloc и free

typedef struct s_vectori {
  size_t length; // длина
  size_t last; // последний элемент
  int* parray; // Наш массив данных
} vectori;

int vcCreateVectori(vectori* pvector);
void vcDestroyVectori(vectori vector);
int vcPushBacki(vectori* pvector, int value); // vectori*, так как мы увеличиваем last
                                              // и length
int vcReplacei(vectori vector, size_t id, int new_value);
int vcDuplicatei(vectori vector, vectori* ptarget);
int vcGeti(vectori vector, size_t id, int* ptarget);

#endif//VC_VECTORI_H

Методы описаны. Там, где возвращаем int - возвращаем успешность выполнения.

// src/vc/vectori.c
#include <vc/vectori.h>
#include <string.h> // Здесь управление строками и в частности массивами


int vcCreateVectori(vectori* pvector) {
  pvector->length = 1;
  pvector->last = 0;
  pvector->parray = (int*) calloc(pvector->length, sizeof(int)); // выделяем память для массива данных
  if (pvector->parray == NULL) { // Если calloc вернул NULL, значит память не выделилась.
    return ERR_CANNOT_ALLOC; // Не удалось выделить место
  }
  return OK; // Возвращаем, что всё нормально
}
void vcDestroyVectori(vectori vector) {
  free(vector.parray); // Мы выделили память только для этого элемента. Остальное за разработчиком
}
int vcPushBacki(vectori* pvector, int value) {
  if (pvector->last + 1 == pvector->length) {
    pvector->length <<= 1; // побитовый сдвиг, чтобы удвоить размер
    pvector->parray = (int*) realloc(pvector->parray, pvector->length * sizeof(int));
    if (pvector->parray == NULL) {
      pvector->length >>= 1; // Возвращаем всё назад.
      return ERR_CANNOT_ALLOC;
    } // Не удалось выделить место
  }
  pvector->parray[pvector->last++] = value; // Наконец задаём значение
  return OK;
}
int vcReplacei(vectori vector, size_t id, int new_value) {
  if (id > vector.last) return ERR_OUT_OF_BOUNDS; // Желанное место вне вектора
  vector.parray[id] = new_value; // Мы передаём копию вектора, но указатель копируется тоже и сохраняет значение
  return OK;
}
int vcDuplicatei(vectori vector, vectori* ptarget) {
  if (vector.parray == ptarget->parray) return ERR_TARGET_IS_ALIAS_OF_SOURCE;
  ptarget->length = vector.length;
  ptarget->last = vector.last;
  ptarget->parray = (int*) calloc(vector.length, sizeof(int));
  memcpy(ptarget->parray, vector.parray, sizeof(int) * vector.length);
  if (ptarget->parray == NULL) {
    return ERR_CANNOT_ALLOC; // Не удалось выделить место
  }
  return OK;
}

int vcGeti(vectori vector, size_t id, int* ptarget) {
    if (id > vector.last) return ERR_OUT_OF_BOUNDS;
    *ptarget = vector.parray[id];
    return OK;
}

А теперь проведём тесты наших векторов.

#include <proj1/vectori.h>
#include <stdio.h>

int main() {
  // Инициализируем переменные
  vectori vec, copy;
  int buffer;
  // Создаём пустой вектор.
  vcCreateVectori(&vec); 
  // Вносим элементы в вектор. Можно сделать через цикл
  vcPushBacki(&vec, 3);  // for (int i = 3; i >= 0; i--) {
  vcPushBacki(&vec, 2);  //   vcPushBack(&vec, i);
  vcPushBacki(&vec, 1);  // }
  vcPushBacki(&vec, 0);
  printf("source\n");    // Вывод исходного
  for (unsigned int i = 0U; i < vec.last; i++) {
    // получить элемент и вывести в консоль
    vcGeti(vec, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  // Проверка дублирования
  vcDuplicatei(vec, &copy);
  vcPushBacki(&copy, 6); // добавляем в конец копии 6
  printf("copy\n"); // Вывод копии
  for (unsigned int i = 0U; i < copy.last; i++) {
    vcGeti(copy, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  printf("source again\n"); // Вывод исходного снова
  for (unsigned int i = 0U; i < vec.last; i++) {
    vcGeti(vec, i, &buffer);
    printf("%d: %d\n", i, buffer);
  }
  // Освобождаем память
  vcDestroyVectori(copy);
  vcDestroyVectori(vec);

  return OK;
}

// Вывод:
// source
// 0: 3
// 1: 2
// 2: 1
// 3: 0
// copy
// 0: 3
// 1: 2
// 2: 1
// 3: 0
// 4: 6
// source again
// 0: 3
// 1: 2
// 2: 1
// 3: 0

Заключение

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

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

Название книги, которую мне посоветовали в комментариях: "Программирование на языке Си" Подбельский В.В., Фомин С.С.

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


  1. lrrr11
    29.01.2023 15:31
    +1

    выделять память для массивов лучше с помощью calloc. И кто бы что ни говорил, но VLA - очень полезная фича.


    1. stalker320 Автор
      29.01.2023 15:34

      Точно, я про calloc забыл, если честно. Спасибо


  1. splasher
    29.01.2023 15:44

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

    Я бы читателям лучше по С любую книжку прочитать бы посоветовал, в которой будет написано, что указатели хранят АДРЕС переменной в памяти, и тогда вопросов про размер указателя, равенство размеров указателей и т.п. не возникло бы) PS. Сам на C не пишу, но стало интересно.


    1. stalker320 Автор
      29.01.2023 15:53
      -3

      Тогда посоветуй мне ещё что-нибудь про работу оперативной памяти. Мне, когда я заинтересовался управлением памятью адрес НИЧЕГО не говорил. Сколько бы я не читал, мне совершенно ничего не давало это утверждение. И я считаю, что программисту, который не закапывается в ассемблер данное утверждение НИЧЕГО не даёт. Так как адрес памяти мы получаем всё равно от операционной системы.


      1. NickDoom
        29.01.2023 16:39
        +2

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


        1. IkaR49
          29.01.2023 17:01
          +1

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


          1. stalker320 Автор
            29.01.2023 17:05

            Судя по тому, что я видел, когда прощупывал Vulkan, это делают достаточно часто, иначе бы для этого не выделили бы параметр почти во всех функциях.


            1. NickDoom
              29.01.2023 17:26
              +1

              Вроде ещё что-то было в библиотеке FFTW, кажется… как-то она хитро выравнивает при аллокации, чтобы быстрее работало… или дежа вю у меня уже…

              А в графических либах да, норма жизни — «аллокатить через меня, деаллокатить через меня».

              А в DOS4GW было «аллокатить в первом мегабайте, будем юзать для легаси-служб DOS и BIOS» :)


            1. Kyoki
              31.01.2023 08:57
              +1

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


      1. splasher
        29.01.2023 20:33
        +2

        Без обид, но лучше на Вы, лично не знакомы. Вообще это ни разу не про ассемблер, а про базовые принципы представления программы в памяти. То есть лично для меня это из области "что такое 32-битная или 64-битная ОС". Если пишете на языке достаточно низкого уровня (как С), то предполагается, что базовый курс по компьютерной архитектуре за плечами имеется. Материала в сети сейчас на эту тему масса, хотя для полного понимания я бы посоветовал действительно "закопаться" в ассемблер, в любом учебнике по ассемблеру разжеваны основы архитектуры, в том числе адресация. Я лично не совсем представляю себе программиста на С без знания основ ассемблера. Удачи в свершениях, ждем профессиональных статей на Хабре.


  1. stalker320 Автор
    29.01.2023 15:48
    -6

    Кстати, я всегда, на каждый пост, получаю от каких-то гениев, которые соизволили прочитать мои записи, несколько минусов за низкий технический уровень материала. Что им не нравится? Я множество раз перечитываю и думаю, что я не так сделал. Убрать приветствие в начале? Убрать заключение? Но так делают везде, где я читаю, значит не то. Тогда в чём проблема? Эти гении молчат как рыбы и не считают комментарии необходимыми. Или на хабре так принято?


    1. Cheater
      29.01.2023 15:56
      +9

      Убрать приветствие в начале? Убрать заключение?

      Возможно проблема в середине поста, а не в его начале или конце?))

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


      1. stalker320 Автор
        29.01.2023 16:11
        -3

        Всегда так. Пытаешься писать для себя - нет идей что написать в принципе. Пишешь для других, тебе каждый грех будут припоминать. Указываешь, что пишешь не для гуру Си, они тебе будут тыкать тысячами материалов, говоря о том, что всё уже давно написано. Только когда спросишь, где были эти чудо-материалы, когда сам пол интернета прогуглил в их поиске, находя только призрачные объяснения, которые больше рассказывают о работе ПК, чем о программировании, неловко молчат в тряпочку. Посоветуй новичкам в комментариях материалы, которые стоит прочитать и которые они с ходу поймут! Может я улучшу статью за их счёт?


        1. Cheater
          29.01.2023 16:32
          +2

          сам пол интернета прогуглил в их поиске, находя только призрачные объяснения, которые больше рассказывают о работе ПК, чем о программировании

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

          Я учил по книге Подбельский, Фомин "Программирование на языке Си", полностью ей доволен, но это было почти 20 лет назад, с тех пор могло появиться что-то получше.


          1. stalker320 Автор
            29.01.2023 16:47

            Да не, лучше могло и не появиться. Спасибо за книгу!


    1. xxxphilinxxx
      29.01.2023 18:35
      +4

      Не минусовал и уж точно не гений, но выскажу предположения.

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

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

      Наконец, для кого вы писали? Из новичков имхо мало кому поможет, слишком обрывочные сведения и неясен порог входа, а ведь в обучении очень важны системность и последовательность. Более опытные не увидят ни новых фактов, ни приемов/практик. Получается, что у статьи толком нет аудитории, ее некому положительно оценить.

      Ну и несколько вещей, которые мне кольнули глаз:

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

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

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

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

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

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

      Указатель - это число с размером разрядности компьютера. Но даже если размер не совпадает с разрядностью, размер указателя точно будет совпадать с размером uintptr_t.

      Так размер совпадает или не совпадает с "разрядностью компьютера"?

      *pvalue += 1; // обращаемся к значению и увеличиваем на один.
      // *(px++) (Или *px++) - это получить значение и сместить указатель на байт.

      В отрыве от разбора операций и комбинаций (" px++", "*(++px)", "(*px)++" и т.д.) выглядит заклинанием.

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

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


      1. MiraclePtr
        29.01.2023 20:10
        +2

        Наверное, хотели сказать, что минимальная адресуамая единица - это байт, но сказали не это.

        Более того, на некоторых архитектурах минимальная адресуемая единица - это не байт, а машинное слово (2/4/8 байт в зависимости от архитектуры). Поэтому адресовать байты, если они не находится на границе слова, не выйдет, для процессора это будет недопустимая операция. Компилятор в итоге чтобы это обойти будет вынужден вместо одной простой инструкции сгенерировать сразу несколько, и это может ударить по производительности (например, если вы активно манипулируете packed-структурами с невыровнеными полями).


        1. stalker320 Автор
          30.01.2023 21:53

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


          1. fk0
            31.01.2023 01:39

            packed структуры шляпа потому, что указатели на члены такой структуры ничем не отличаются от прочих указателей, но при том обращение по таким указателям (не выравненным) может потребовать специальных машинных инструкций.

            Для тех кто любит говорить, что мол на x86/arm/etc... всё можно невыравненно, напоминаю, что векторные инструкции часто в таких случаях таки вызывают исключения, а их может компилятор вставить в совершенно безобидном месте просто для того чтоб быстрей скопировать данные (memcpy заинлайнился). Компилятор знает, например, что структура или int всегда выравненный и будет копировать его векторными инструкциями не глядя. В рантайме всё грохнется.


          1. BadHandycap
            31.01.2023 11:00
            +1

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


            1. stalker320 Автор
              31.01.2023 16:02

              Спасибо, учту. Думаю я даже могу найти применение этому теперь.


        1. fk0
          31.01.2023 01:40
          +1

          Минимально-адресуемая единица таки "char" -- по стандарту. Другое дело, что это у компилятора так, а у процессора может оказаться как вы описываете. А у некоторых CHAR_BIT просто равен 16 (DSP фирмы Texas Instruments).


      1. xxxphilinxxx
        31.01.2023 00:09
        +1

        @stalker320, пожалуйста, удалите упоминание меня из статьи :) ну или хотя бы давайте уберем или поправим те пару абзацев про стек и кучу, а то вы хоть и молодец, что трудитесь над исправлением/дополнением, но все же странных вещей написали.

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

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

        2. Про кучу и динамически выделяемые данные не очень понятно получилось. Данные-то вполне могут быть динамическими, но размещаться в стеке, а важно тут выделение области памяти: стек ее получает статически, однократно при запуске программы, а куча может меняться в процессе работы программы, сколько операционка позволяет процессу: нет в уже имеющейся памяти непрерывного куска нужного размера - запросили у операционки (на самом деле тут одновременно и сложнее, и проще, т.к. это виртуальная память) еще кусок памяти и продолжаем в ней сами себе выделять области - этим занимается рантайм языка. И наоборот, когда память освобождается, то она может вернуться операционке. За счет этих манипуляций выделение памяти в куче медленее, чем на стеке. "Куча может в программе и не появиться" - это все-таки контролируется вручную, т.к. такое ограничение могут накладывать целевые платформы - микроконтроллеры, например. Еще из важных отличий, что в куче можно хранить куда бОльшие объемы данных (стек же маленький) и ее можно шарить между потоками (у потоков собственные стеки).

        3. В очистке памяти вы описали затирание данных с точки зрения безопасности, а не возвращение в кучу, что, собственно, в контексте управления памятью обычно очисткой и называется. Вот вы запросили в куче память, получили указатель. Куча у себя отметила, что область с N по N' занята, ее больше нельзя использовать при последующих запросах. Когда эта область перестала быть вам нужна, куча-то все еще считает, что память занята. А если вы даже и указатель не сохранили, то получается классическая утечка памяти: область занята, но у нас практически нет возможности это понять и ее освободить для дальнейшего использования, она так и будет висеть мертвым грузом до завершения процесса. Вот для этого возвращения и нужна очистка через free: куча отмечает область свободной и может вам в дальнейшем снова выдать в ней место даже без обращения к операционке.

        Подобрал несколько хороших ссылок по теме
        https://habr.com/ru/post/270009/
        https://habr.com/ru/post/345766/
        https://habr.com/ru/post/489360/
        https://www.ibm.com/docs/ru/aix/7.1?topic=concepts-system-memory-allocation-using-malloc-subsystem


        1. stalker320 Автор
          31.01.2023 16:01

          1. То есть адрес указателя int* ptr; будет храниться в стеке, в то время, как в хипе будут храниться сами данные?


          1. xxxphilinxxx
            31.01.2023 17:11
            +1

            Именно. Если подробнее, то операции и их порядок примерно (я все же тут не эксперт) таковы:

            int (int n) {
            	int * ptr = malloc(sizeof(int));
            	*ptr = 1;
            	free(ptr);
            	return n+1;
            }
            
            1. На входе в контекст (грубо говоря, в функцию) в конце стека выделяется место под n, возвращаемый результат и ptr, как если бы все объявления переменных были смещены в начало функции и не имели инициализации значением. В аргумент же автоматически копируется значение из места вызова. До самого конца размер стека не меняется. И этот пункт стоит уточнить, поскольку аргументы функций (первые 4, кажется) и возвращаемые значения могут передаваться даже не через оперативку, а через регистры процессора.

            2. У кучи запрашивается непрерывный кусок памяти размера sizeof(int). Если в свободных областях есть такое место, то куча его отделит по размеру, пометит занятым и отдаст указатель на начало области. Если же сводного места нет вовсе, или есть, но оно фрагментировано, т.е. состоит из нескольких частей меньшего требуемого размера и не идущих подряд, то куча сообщает операционке, что увеличивает свой размер. Операционка в свою очередь проверит, не превышает ли процесс заданный лимит на память, и подыскивает в физической памяти кусок нужного размера. Тут надо отметить, что память процесса - виртуальная, "лоскутная". Операционка сшивает несколько разных областей памяти, которые могут быть даже на разных типах устройств, и создает новое адресное пространство, которые выглядит цельным и непрерывным для процесса. Куча поэтому просто сообщает операционке, что хочет вырасти на определенный размер, а операционка подшивает к виртуальной памяти еще один кусок физической. Получив еще памяти, куча теперь может выделить запрошенный через malloc кусок или бросить исключение, если не получилось.

            3. В стековую переменную ptr записывается адрес начала выделенной кучей области.

            4. Из стековой переменной ptr считывается хранящийся там адрес в куче.

            5. По считанному адресу в куче записывается интовая единица.

            6. Куча получает запрос на освобождение: она помечает у себя, что область, начинающаяся с ptr, теперь свободна и снова может быть использована. Если есть соседние свободные области, то куча их объединит в одну большую, а при определенных условиях может еще и память операционке вернуть (тут уже подробностей совсем не знаю). На каждый malloc со временной выделенной областью должен приходиться ровно один free с тем же адресом.

            7. По адресу возвращаемого результата на стеке записывается интовое n+1.

            8. На завершении контекста у n и ptr вызвался бы деструктор, не будь они базовыми типами.

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


            1. stalker320 Автор
              31.01.2023 19:12

              Благодарю.


  1. Sun-ami
    29.01.2023 16:11
    +4

    Размер указателей не всегда одинаковый — на некоторых платформах на размер указателей могут влиять модификаторы модели памяти и адресного пространства. В частности, на x86 в реальном режиме на размер указателей влияют модификаторы far и near, в зависимости от которых, а также от модели памяти по умолчанию, указатели могут быть 16-битными или 32-битными. Аналогично, в 32-разрядных программах на 64-разрядных x86-процессорах могут использоваться 64-разрядные указатели. А на платформах с Гарвардской архитектурой есть еще и отдельные типы указателей на память программ (в которой также могут храниться и константные данные), которые тоже могут быть разного размера. В частности, на микроконтроллерах AVR указатели на память программ могут быть 16-разрядными и 24-разрядными.


    1. stalker320 Автор
      29.01.2023 16:16

      Спасибо за уточнение. Но можно уточнить у вас, как у знающего?

      В частности на микроконтроллерах AVR указатели на память программ могут быть 16-разрядными и 24-разрядными.

      В пределах одной программы 16-разрядные и 24-разрядные одновременно? Или есть константный размер указателя, который определяется при запуске из операционной системы, 16 разрядов, или 24 разряда?


      1. Sun-ami
        29.01.2023 21:16

        Размер указателей и, соответственно, доступная область размещения в памяти программ по умолчанию определяется опцией компилятора «модель памяти». Эта опция может быть разной для разных объектных файлов в пределах одной программы. Также, размер указателей может быть переопределён для конкретной функции, константы, или указателя. Компилятор IAR использует для этого ключевые слова __flash (16-битный указатель на данные или размещение данных в сегменте, на который может указывать 16-битный указатель), __farflash (23-битный указатель на данные, или соответствующее размещение данных) и __farfunc (23-битный указатель на функцию, или соответствующее размещение функции). На практике это обычно не используется, но может быть использовано для ускорения работы программы, и уменьшения объёма памяти под указатели. Дополнительно, у IAR есть еще тип 24-битных указателей, который может указывать и на память данных, и на память программ — они объявляются с ключевым словом __generic.


    1. Rusrst
      29.01.2023 17:15
      +2

      Про разный размер указателей для разных типов тоже хотел указать, в sdcc man есть пояснения по этому поводу про memory map model


  1. NickDoom
    29.01.2023 16:36
    +5

    С одной стороны, молодому поколению надо регулярно напоминать, как ещё можно писать, кроме «скачай моднявенькие фреймворки и соедини блоки мышкой» (а сейчас уже скорее «спроси у ЧатГПТ»). А то «Блокнот» в 3.5 гигабайта весом, совершенно на серьёзных щах поданный почтеннейшей публике (и ведь кто-то им восхищается!) — это, извините, конец света, кровавый дождь, саранча и горящие собаки с неба.

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

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

    Разница malloc/calloc, кто для каких случаев лучше.

    Фрагментация кучи, реаллокация (не путать с :) ). Страницы и маппинг их на физическую память.

    Byte order — интеловский (3 2 1 0), мотороллерный (0 1 2 3), костыльный (2 3 1 0). Там, где упомянут union — нельзя обойти этот аспект вниманием.

    Короче, подсказал что в голову первое пришло («чем смог — помог, картошку сажайте сами» © анекдот), но поле тут непаханое, писать и писать.


    1. stalker320 Автор
      29.01.2023 16:59

      Кстати, что за "костыльный" Byte order и причём тут union? (Вроде это же больше со struct связано)


      1. NickDoom
        29.01.2023 17:19
        +2

        Где-то натыкался… Википедия только PDP-11 называет в этом качестве, но это явно был не он :)

        А с union всё просто — если надо структуру типа «четыре U32» по какой-то причине (обычно это скорость где-то в глубине цикла в цикле в цикле, что ж ещё…) интерпретировать как «шестнадцать U8», то просто pragma pack будет недостаточно — надо предусмотреть вариант кода для интеловской и мотороллерной эндианности, ну и как минимум выплюнуть ошибку в случае экзотики :)

        А, точно, как же я забыл pragma pack упомянуть сразу! Там, где речь идёт о структурах и объединениях и тема коснулась их расположения в памяти — это ж главное блюдо на всём обеде!

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

        UPD: «костыльных» (смешанных) может быть несколько, у PDP вроде был другой вариант.


        1. stalker320 Автор
          29.01.2023 17:34

          интеловский получается LITTLE_ENDIAN(..., 2^3, 2^2, 2^1, 2^0), а мотороллерный - BIG_ENDIAN(2^0, 2^1, 2^2, 2^3, ...), если я правильно понял, что ты имел в виду.

          Кстати, не в курсе про htonl()htons()ntohl() и ntohs() . Я просто видел упоминания вместе с byte order, но так и не понял зачем нужно и как использовать.


          1. BadHandycap
            29.01.2023 18:11
            +2

            В TCP/IP применяется порядок байтов big-endian. Вышеперечисленные функции нужны для того чтобы писать кросплатформенный код. На big-endian машинах они будут просто возвращать значение.


            1. stalker320 Автор
              29.01.2023 18:11

              А, понятно. Спасибо.


    1. fk0
      31.01.2023 01:35
      +1

      "костыльный" был на x51 помнится. причём там ещё и костыли в разные стороны, в зависимости от того в какой памяти лежало "длинное" число (в idata или xdata).


    1. firehacker
      01.02.2023 01:09
      +1

      нечто страшное, например, в случае сегментированной модели памяти (мало ли где на микроконтроллерах человек снова нарвётся на это наследие 8086-го).

      Мне постоянно кажется, что многие люди путают или не видят разницы между сегментами 8086 и сегментами 80286 или тем более 80386.

      А точнее сказать, между сегментами реального режима и сегментами защищённого режима.

      Сегменты защищённого режима это, это по-моему глубокому убеждению, технология с самым underrated потенциалом развития, которую толком по уму никто не использовал из-за желания обратной совместимости с одной стороны и отсутствия ЯВУ, который мог бы эксплуатировать идею сегментов максимально естественным образом. Доминировавшие на тот момент Си и Си++ были явно не теми языками из-за своей адресной арифметики.

      По сути появление сегментов защищённого режима можно сравнить с появлением идеи использовать файлы и файловые системы вместо просто записи данных куда-то в непрерывное линейное пространство диска (сделаем вид, что CHS-адресации никогда не существовало).

      И увы, человечество предпочло свернуть не туда, и теперь мы имеем атаки переполнения буфера, remote code execution и костыли в виде ASLR и DEP для борьбы с ним. Хотя могли бы иметь защиту от доступа за границу объекта/массива/буфера на аппаратном уровне. Ну и проблему с фрагментацией АП процесса, которая становится теперь головной болью прикладной программы, а не ОС.


  1. MiraclePtr
    29.01.2023 20:16

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

    С этим нужно действительно быть предельно аккуратным. Иначе можно очень легко нарушить правила алиасинга, что в итоге может привести к очень неожиданным спецэффектам (ибо это undefined behaviour в чистом виде).


    1. stalker320 Автор
      29.01.2023 20:45

      Учту при редактировании. Спасибо за ссылку


    1. stalker320 Автор
      29.01.2023 20:47

      И кстати, разве ссылка не на статью по C++? Или в C правила те же?


  1. IknowThatIknowNothing
    29.01.2023 21:31
    +1

    замечание: px++ увеличивает значение указателя не на байт, а на sizeof типа указателя, то есть если указатель был int*, то значение будет увеличено на sizeof(int)


    1. stalker320 Автор
      30.01.2023 21:25

      Благодарю


  1. fk0
    31.01.2023 01:31
    +2

    Многое в общем-то не соответствует действительности...

    размер bool = 1 байт

    Во-первых размеры bool, int и т.п. -- платформенно-зависимые. Есть где bool -- 4 байта как и int (MIPS).

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

    Нельзя. По-стандарту нельзя указатель на функцию приводить к void* и наоборот. На практике можно, но не всегда заработает. И нельзя сопоставлять указатели на разные объекты. Разность указателей можно считать только в пределах одного объекта (одного аллоцированного фрагмента памяти). Для большинства архитектур это не принципиально, у них "плоская" модель памяти, но не у всех.

    символ со знаком -0x7F равен символу без знака 0xFF

    Полная чушь, 0xff -- это -1 везде, где отрицательные числа представлены в дополнительном коде (на современных архитектурах -- везде).

    Размер же структуры - это сумма размеров её полей, выравненных по байтам.

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

    поля объединений начинаются из одной точки и имеют размер наибольшего элемента...

    Поле объединений имеют размер этих полей, и он разный для разных полей. Одинаковый они имеют только _Alignof().

    Рассказывать о перечислениях(enum) Нечего, потому что это массив чисел, который компилятор удобно подписал ключевыми словами. Не хуже справляется команда препроцессора #define.

    Это совершенно разные сущности. enum -- это способ определить константу типа int во время компиляции в языке C. Единственный. А #define -- подстановка текста. Во что она там ещё превратится -- вопрос. Как минимум типы могут получиться разные. И результат.

    Указатель - это число равное, или меньшее размером, чем разрядность процессора(Большее число просто не выйдет просчитать так же эффективно).

    Ну да, конечно. Особенно на 8-битных микроконтроллерах или даже на 16-битных (типа dsPIC), или даже на 16-битом x86 с сегментной адресацией...

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

    Совершенно не обязательно. У некоторых стек растёт вверх, а у некоторых куча и стек в разных сегментах и пересечься не смогут.

    Массивы в Си - это указатели на области памяти...

    Массивы -- это отдельный тип данных, а не указатели. Массивы лишь деградируют (decay) до указателей в определённых выражениях (но не во всех: операторов sizeof, typeof и & это не касается). И массив как тип данных имеет в первую очередь размер, что позволяет резервировать под него память. А указатели -- все на один размер.

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

    Многомерный массив НЕ ЯВЛЯЕТСЯ массивом указателей. Это справедливо только для "char *argv[]" из аргументов main, но строго говоря там не многомерный массив, а вполне себе одномерный массив указателей на строки. Вычисление адреса в многомерном массиве не задействует никакие указатели и зависит от размерностей (всех кроме последней) массива. Для массива указателей же как раз размерность не важна (почему в argv строк может быть сколько угодно и каждая любой длины).

    И да, массивы с динамической размерностью (variadic length arrays) как раз в C возможны (но не в C++). Сомнительная вещь, но что есть то есть.

    В остальном статья очень плоха. Не стоит о чём-то писать, о чём не имеете представления. И уж тем более не стоит читать российскую литературу. Керниган и Ритчи -- устарели. Возможно просто стоит изучить сайт cppreference.com (там есть раздел описывающий язык C). Если учить, то скорей Роб Пайк, "Практика программирования".

    PS: примеры на C откровенно плохие, начиная с не использования const, передачи структур то по-значению, то по-указателю, код в целом низкого качества. В случае с вектором лучше возвращать "ссылку" на элемент, чем его значение (тогда по ссылке его можно изменить). Кастить void* к указателю на конкретный тип и наоборот в C не нужно (в отличии от C++)...