pl-sem/sem6
2023-08-18 01:08:53 +03:00
..
ast.c Initital commit 2023-08-18 01:08:53 +03:00
ast.h Initital commit 2023-08-18 01:08:53 +03:00
ast.o Initital commit 2023-08-18 01:08:53 +03:00
bad.c Initital commit 2023-08-18 01:08:53 +03:00
heap-0.c Initital commit 2023-08-18 01:08:53 +03:00
heap-0.exe Initital commit 2023-08-18 01:08:53 +03:00
heap-1.c Initital commit 2023-08-18 01:08:53 +03:00
heap-1.exe Initital commit 2023-08-18 01:08:53 +03:00
main.c Initital commit 2023-08-18 01:08:53 +03:00
main.o Initital commit 2023-08-18 01:08:53 +03:00
makefile Initital commit 2023-08-18 01:08:53 +03:00
parser Initital commit 2023-08-18 01:08:53 +03:00
README.org Initital commit 2023-08-18 01:08:53 +03:00
ring.h Initital commit 2023-08-18 01:08:53 +03:00
tokenizer.c Initital commit 2023-08-18 01:08:53 +03:00
tokenizer.h Initital commit 2023-08-18 01:08:53 +03:00
tokenizer.o Initital commit 2023-08-18 01:08:53 +03:00

Семинар 6: Аллокация памяти, арихитектура

Что такое куча

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

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

Данные в C могут быть выделены следующими способами:

  • Автоматически: локальные переменные функций создаются в момент их запуска и живут до их завершения. Правильно сказать: гарантированное время жизни переменных от запуска функции до её завершения. Зная, что компиляторы оптимизируют локальные переменные, мы больше не будем говорить, что они выделяются в стеке.
  • Глобальные переменные и локальные переменные функций, если локальные переменные помечены static. И те, и другие создаются в глобальной области данных.

Других гарантированно возможных способов выделить память в "голом" C нет.

Чтобы выделить память в стеке или в глобальной области данных необходимо знать, сколько именно памяти понадобится, уже в момент компиляции. Однако иногда мы не знаем этого заранее; например, когда принимаем по сети размер строки и саму строку; её нужно где-то выделить, а сколько под это выделять байт — неясно.

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

Функции malloc, calloc, realloc, free — это интерфейс к аллокатору из стандартной библиотеки C. С помощью вызова malloc можно выделить память, время жизни которой будет неопределённо долгим. Она может существовать до завершения работы программы, как глобальные переменные, или быть возвращена для переиспользования с помощью функции free.

Некоторые советы по работе с памятью:

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

Аллокатор блоков

Самый простой аллокатор возвращает блоки фиксированного размера. Их можно заполнять не полностью, остаток будет теряться. Помимо блоков нужно хранить карту их занятости; здесь она хранится в виде массива bool'ов, но для компактности можно было бы упаковывать булевы значения по восемь в каждый байт.

#define HEAP_BLOCKS 80
#define BLOCK_CAPACITY 1024

struct heap {
  struct block {
    char contents[BLOCK_CAPACITY];
  } blocks[HEAP_BLOCKS];
  bool is_occupied[HEAP_BLOCKS];
} global_heap = {0};

Задание 1 Изучите файл heap-0.c и реализуйте в нём недостающие функции для резервирования блока и для возвращения блока в пул доступных. Почему block_id содержит ссылку на struct heap?

Аллокатор множества блоков

В предыдущем аллокаторе есть несколько существенных недостатков:

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

Задание 2 Модифицируйте аллокатор так, чтобы он умел выделять несколько блоков подряд. См. файл heap-1.c. У блоков теперь будет статус с большей гранулярностью, нежели "занят — не занят". Придумайте тесты для вашего кода для проверки всех особых случаев.

enum block_status { BLK_FREE = 0, BLK_ONE, BLK_FIRST, BLK_CONT, BLK_LAST };

Блоки будут связываться в одну из следующих конфигураций:

BLK_FREE //  свободный блок
BLK_ONE //  единичный занятый блок.
// после него или свободный или занятый другими данными.

BLK_FIRST, BLK_CONT, BLK_CONT, BLK_LAST
// последовательность блоков с  началом, серединкой и концом.

BLK_FIRST, BLK_LAST  // то же, но без серединки.

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

Чего не хватает для эффективности?

Чтобы аллокатор стал более эффективным, можно отказаться от фиксированного размера блока, снабдив каждый из них заголовком и организовав в связный список. Затем из единой непрерывной кучи можно сделать несколько фрагментов и добавить довыделение памяти (а именно страниц от операционной системы) в случае, когда куча закончилась. Это и будет темой четвертой лабораторной работы.

Задание 3

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

Первый вариант

Первый вариант задания - это развитие принтера, который вы увидели на прошлом семинаре. Добавьте туда:

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

В качестве стартовой точки для выполнения этого задания можете использовать расширенный вариант принтера (ast.h, ast.c, main.c, makefile), в который уже включен базовый токенайзер (tokenizer.h, tokenizer.c), а также реализация стека и очереди для абстракнтых типов (ring.h).

Второй вариант

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

Изначальный код

У вас есть код внутри одной функции main, который реализует расширяемый массив (вектор). Изучите его.

/* bad.c */

#include <inttypes.h>
#include <malloc.h>
#include <stdio.h>

int main() {
  int64_t *array = malloc(sizeof(int64_t) * 5);
  // вместимость -- сколько памяти выделено
  size_t capacity = 5;
  // количество -- сколько памяти по факту используется из выделенной.
  size_t count = 0;
  // заполните массив квадратами чисел от 0 до 100
  // если не хватает места, расширяем в два раза
  for (size_t i = 0; i <= 100; i++) {
    if (count == capacity) {
      array = realloc(array, sizeof(int64_t) * capacity * 2);
      capacity = capacity * 2;
    }
    array[count++] = i * i;
  }

  for (size_t i = 0; i < 100; i++) {
    printf("%" PRId64 " ", array[i]);
  }
  return 0;
}

Расширяемый массив в отличие от обычного имеет нефиксированный размер, в конец такого массива можно добавлять элементы. Как это работает:

  • Мы выделяем память с запасом.
  • Мы храним два дополнительных числа: количество выделенных слотов под элементы и количество заполненных слотов в массиве.
  • Пока у нас хватает выделенных слотов, мы просто дописываем элементы в массив, увеличивая количество занятых слотов.
  • Если слотов перестало хватать, то увеличиваем количество слотов в 2 раза (или линейно на некоторый фиксированный размер). Для этого используем realloc чтобы скопировать выделенную память в расширенный участок.

Вопрос Прочитайте раздел man malloc про функцию realloc.

Задание (второй вариант)

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

  • Вектор должен быть реализован как непрозрачная структура данных.
  • Доступ к его элементам должен быть контролируем и осуществляться через getter и setter.
  • Постарайтесь максимально переиспользовать код и ничего не дублировать.
  • Вывод вектора реализуйте как отдельную функцию, которая принимает FILE*, в который нужно вывести его содержимое. Эту функцию можно также разбить на функцию foreach и принтер одного элемента.
  • Стремитесь сделать настолько маленькие функции, насколько возможно.

Ваша реализацяия как минимум должна позволять:

  • Получить доступ к любому элементу.
  • Получить информацию о текущей длинне массива и количестве выделенной памяти.
  • Добавить элемент в конец массива.
  • Добавить другой массив в конец массива (скопировать данные).
  • Изменить размер массива на заданный (при необхоимости выделить память).
  • Изменить размер массива на меньший с освобождением неиспользуемой памяти.
  • Освободить память.
  • Вывести массив в указанный поток вывода.

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

Прочитайте заметку [Правила хорошего стиля] — ваш код должен им соответствовать.