Всем привет, это снова stalker320, и я решил поделиться некоторой информацией, которую долго не переварить и только в конце 2022 года я смог понять управление памятью и как работают указатели. Если есть какие-то поправки, можете написать в комментарии, так как я и сам хотел бы лучше понять эту часть.
Размерность в Си
Итак, перед тем, как рассказывать про байты, я приведу один точный пример, чтобы разубедить всех, что программирование работает с битами.
Итак, если размер int
= 4 байта, short int
= 2 байта, сhar
= 1 байт, то какой размер у булевой переменной? Ответ: булевой переменной в принципе не существует без подключения стандартных библиотек. А при подключении библиотеки stdbool.h
, размер bool
= 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).
Если говорить о размерах, то лучше сразу вывести размеры различных типов:
#include <stdio.h>
#include <stdint.h>
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 // Размеры указателей верны для 8-байтного процессора.
// sizeof uint16_t*: 8
// sizeof uint32_t*: 8
// sizeof uint64_t*: 8
// sizeof void*: 8
// sizeof uintptr_t: 8 // Этот два типа представляет собой указатель, как число.
// sizeof intptr_t: 8 // Да, В него можно преобразовать указатель. В этом даже фишка
Если у вас ничего не щёлкнуло в голове, то поясню - все указатели имеют одинаковый размер, а значит их можно безболезненно преобразовывать между собой. void* void_ptr = (void*) int_ptr;
Использовать аккуратно.
Структуры(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
Если поля структуры размещаются последовательно, то поля объединений начинаются из одной точки и имеют размер наибольшего элемента. И если привести одну структуру к другой, даже если они имеют одинаковый размер, невозможно, то 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); // Так используется в объявлении функций.
Указатели
Итак, дорогие читатели-потенциальные Си программисты, вы должны были понять, что размер указателя одинаковый в пределах программы на одном компьютере. Однако как их использовать? Некоторые даже видели запись, когда изучали какой-то код вроде *x++
. Что тут происходит? Как такое возможно? Указатель - это число с размером разрядности компьютера. Но даже если размер не совпадает с разрядностью, размер указателя точно будет совпадать с размером uintptr_t
.
В любом случае, нам потребуется подключить 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; // обращаемся к значению и увеличиваем на один.
// *(px++) (Или *px++) - это получить значение и сместить указатель на байт.
// Именно в этом порядке.
// * - Уменьшает количество звёзд после типа на 1:
// тип *pvalue = int
}
// Вывод:
// start_ a: 2
// inc_ a: 3
А теперь перепишем его так, чтобы a
был изначально указателем:
#include <stdlib.h>
#include <stdio.h>
// Объявления. Можно вынести в main.h
void increace_value(int* pvalue);
int main(int argc, char** argv);
// Реализации
int main(int argc, char** argv) {
int* a = (int*) malloc(sizeof(int)); // Выделяем байты по размеру числа.
*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
Массивы в Си
Массивы в Си - это переменные, которым выделено N размеров типа данных. Массив указателей можно назвать списком из python. Правда отсутствие записи информации о типах данных различных элементов делает его сложным для обработки, хотя это решается структурами. В любом случае вернёмся к массивам и коду:
#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;
}
Вот и вся суть массива. Не злоупотребляйте, либо злоупотребляйте хотя бы в меру. Иначе вылетит stack trace.
Заключение
Управление памятью одновременно невероятно сложно и невероятно просто. Одни могут и не понять его, другие поймут с ходу. Всё зависит от представления об оперативной памяти. Надеюсь мои объяснения смогли не понимающим дать это самое понимание, а понимающим укрепить свои познания. Благодарю за прочтение.