Оглавление
Весь код можно найти в этом репозитории
В третьей статье пойдет речь уже о готовом аллокаторе который вполне пригоден для распределения памяти. Он полностью переписан, но идея та же самая, неявный список свободных блоков с граничными тегами сверху и снизу, но с массивом списков свободных блоков. Т.е. по сути с бинами.
Бины реализованы как массив размером 256 указателей на двусвязные списки. Блоки хранятся в бине индекс которого равен размеру блока если блок <= 255 байт, остальные блоки лежат в бине с индексом 255 и отсортированы по размеру. В бинах с меньшими индексами свободный блок просто добавляется в начало списка со сложностью O(1). Так же поиск бина получается быстрым так как доступ по индексу в массиве так же имеет сложность O(1), это оптимизирует работу с блоками небольшого размера. Для поиска больших блоков придется довольствоваться поиском в отсортированном по размеру списке.
Вот общая схема:

Индекс бина вычисляется следующим образом:
static size_t bin_index_from_size(size_t size){ if (size >= kHugeBlockMinSize) { return kHugeBinIndex; } return size;}
Связи хранятся в так называемом payload, т.е. в пространстве которое будет использовано если блок занят, но если блок свободен, то мы можем использовать его как захотим. Следует отметить что это обязывает нас сделать минимальный размер блока 16 байт, или sizeof(void*) * 2

Это обеспечивается простым набором функций в стиле С. Все функции высокого уровня работают только с указателями на payload блока. Функции высокого уровня это функции которые работают с блоками, а не с сырой памятью. Это важно!
Ниже функции работающие с сырой памятью. Именно они делают из нее блоки.
/** * Memory block related stuff */// Возвращает указатель типа size_t* на память на которую укзывает __p// нужно для чтения и записи служебной информации хранящейся в граничных тегахstatic size_t *mem_block_size_t_ptr(void *_p){ return reinterpret_cast<size_t *>(_p);}// Возвращает размер блока. Предполагается что __p указывает на хедер// или футер блокаstatic size_t mem_block_get_size(void *_p){ return *mem_block_size_t_ptr(_p) & ~0x01;}// Возвращает указатель типа char* на память на которую укзывает __p// нужно для арифметических операций с указателями, что бы шаг был размером в байтstatic char *mem_block_char_ptr(void *_p){ return reinterpret_cast<char *>(_p);}// Возвращает указатель на payload из указателя на заголовок блока// Все функции высокого уровня работают с указателями на payloadstatic char *mem_block_user_ptr(void *_p){ return mem_block_char_ptr(_p) + kHeaderSize;}// Упаковывает вместе размер блока и бит состояния. Сободен\занятstatic void mem_block_pack(void *_p, size_t _sz, size_t _st){ *mem_block_size_t_ptr(_p) = _sz | _st;}// получает указатель на заголовок блока из указателя на payloadstatic char *mem_block_header(void *_p){ return mem_block_char_ptr(_p) - kHeaderSize;}// Возвращает размер блока из указателя на payloadstatic size_t mem_block_size(void *_p){ return mem_block_get_size(mem_block_header(_p));}// Возвращает указатель на эпилог блока из указателя на payloadstatic char *mem_block_footer(void *_p){ return mem_block_char_ptr(_p) + mem_block_size(_p);}// Читает занят ли блок из указателя на один из граничных теговstatic size_t mem_block_get_alloc(void *p){ return (*mem_block_size_t_ptr(p) & 0x01);}// Проверяет аллоцирован ли блок из указателя на payloadstatic bool mem_block_is_allocated(void *p){ return mem_block_get_alloc(mem_block_header(p)) == kBlockAllocated;}static bool mem_block_is_free(void *p){ return !mem_block_is_allocated(p);}// Кладет служебную информацию в заголовок блока. __p указатель на payloadstatic void mem_block_put_to_header(void *_p, size_t _sz, size_t state){ mem_block_pack(mem_block_header(_p), _sz, state);}// Кладет служебную информацию в футер блока. __p указатель на payloadstatic void mem_block_put_to_footer(void *_p, size_t _sz, size_t state){ mem_block_pack(mem_block_footer(_p), _sz, state);}// Возвращает следующий блок из неявного списка. Т.е. следующий смежный блок// возвращается указатель на payload. __p указатель на payloadstatic void *mem_block_next(void *_p){ return mem_block_char_ptr(_p) + mem_block_size(_p) + kOverheadSize;}// Тривиальное заполнение оверхеда. __p указатель на payload// Возвращается указатель на payloadstatic inline void *mem_block_init_block(void *_p, size_t _sz, size_t state){// сначала хедер mem_block_put_to_header(_p, _sz, state);// потом футер mem_block_put_to_footer(_p, _sz, state); return _p;}// Возвращает предыдущий блок из неявного списка. Т.е. предыдущий смежный блок// возвращается указатель на payload// __p указатель на payloadstatic void *mem_block_prev(void *_p){ char *header = mem_block_char_ptr(mem_block_header(_p)); char *prev_footer = header - kFooterSize; size_t prev_size = mem_block_get_size(prev_footer); return prev_footer - prev_size;}// Размер блока с оверхедом, т.е. размерами заголовка и футера// ptr указатель на payloadstatic inline size_t mem_block_size_with_overhead(void *ptr){ return mem_block_size(ptr) + kOverheadSize;}
Начнем с рассмотрения того как аллокатор инициализирует себя в начале. Он кладет единственный большой свободный блок в бин с индексом 255.
int mem_initialize(void *base, size_t size){ if (base && size > 0 && (size % 2 == 0)) { size_t binsSize = kBinCount * sizeof(void *); if (size > binsSize) { gMemStart = mem_block_char_ptr(base);// Если определен этот макрос то резервируем первые sizeof(void*) * 256 // байт для наших бинов иначе используем стековый массив #if BINS_ARE_IN_HEAP gMemStart = mem_block_char_ptr(base) + binsSize; size -= binsSize; gBinList = reinterpret_cast<ListHead **>(base); ALOGD("gBinList %p binsSize %zu gMemStart %p", gBinList, binsSize, gMemStart);#endif// выставляем все бины в nullptr memset(gBinList, 0, binsSize);// Первый и последний блоки являются служебными и всегда аллоцированы// это сделано для упрощения слияния mem_block_pack(gMemStart, kOverheadSize, kBlockAllocated); mem_block_pack(gMemStart + kHeaderSize + kOverheadSize, kOverheadSize, kBlockAllocated);// Уменьшаем размер кучи на на// 5 оверхедов это оверхеды служебных блоков в начале и в конце // и их размеры равные оверхеду блока и пятый это оверхед самого свободного// блока size_t heapSize = size - (kOverheadSize * 5); void *heap = mem_block_next(mem_block_user_ptr(gMemStart)); mem_block_init_block(heap, heapSize, kBlockFree);// Последний блок так же является служебным и всегда аллоцирован gMemEnd = mem_block_char_ptr(mem_block_next(heap)) - kHeaderSize; mem_block_pack(gMemEnd, kOverheadSize, kBlockAllocated); mem_block_pack(gMemEnd + kOverheadSize + kHeaderSize, kOverheadSize, kBlockAllocated); auto firstBlock = mem_block_list_head(heap);// После инициализации служебных блоков кладем в бин единственный свободный блок bin_insert(firstBlock); return 0; } } else { ALOGE("Could not initialize memory with params base %p size %zu", base, size); } ALOGD("gMemStart %p gMemEnd %p size %td", gMemStart, gMemEnd, mem_block_char_ptr(gMemEnd) - mem_block_char_ptr(gMemStart)); return EINVAL;}
Перейдем теперь к выделению свободного блока и потихоньку перейдем к бинам.
void *mem_malloc(size_t size){ void *block = nullptr; if (gMemStart) { if (size > 0) { // Вернет минимальный размер блока sizeof(void*) * 2 без учета // оверхеда. Он будет учтен в другом месте, а именно в mem_block_place size_t aligned_size = mem_block_aligned_size(size); // Проверяем бины, если там есть свободный блок возвращаем его auto memoryBlock = bin_find_free_block(aligned_size); if (memoryBlock) { block = memoryBlock; // удаляем найденный блок из бина bin_erase(block); // распределяем блок отрезая от него кусок если нужно block = mem_block_place(block, aligned_size); // Если мы отрежем от блока кусок, то сделаем это с начала. Тогда // получается что если блок большой и мы взяли только его часть // то следующий смежный блок должен быть свободен auto next = mem_block_next(block); if (next < gMemEnd && next > gMemStart && mem_block_is_free(next)) { auto nextBlock = mem_block_list_head(next); // если это так то кладем его в бин bin_insert(nextBlock); } } } else { ALOGE("Could not allocate block with size %zu", size); errno = EINVAL; } } else { errno = EINVAL; ALOGE("Not initialized"); } return block;}
Для понимания того как работает mem_malloc нам нужно разобраться с несколькими функциями:
mem_block_place, bin_find_free_block и bin_insert
Давайте начнем с mem_block_place она самая сложная, хотя на первый взгляд может показаться тривиальной.
static void *mem_block_place(void *block, size_t sz){// выясняем размер текущего блока. Напоминаю он сводоен раз мы тут size_t cur_size = mem_block_size(block);// нам нужно понимать сколько останется от блока если мы попробуем// отрезать от него sz байт size_t remain = cur_size - sz;// Если это минимальный размер блока, т.е. 2 оверхеда потому что payload блока// может иметь минимальный размер оверхед + ему нужен его собственный оверхед// и того sizeof(void*) * 4// то отрезаем от начала блока sz байт меняя имеющийся заголовок if (remain >= kOverheadSize * 2) {// принимая в расчет что мы создадим новый футер и новый хедер// футер для нового блока и хедер для оставшегося куска памяти remain -= kOverheadSize; // создаем новый блок mem_block_put_to_header(block, sz, kBlockAllocated); mem_block_put_to_footer(block, sz, kBlockAllocated); // готово // заполняем оверхед следующего блока новым размером и уходим auto next = mem_block_next(block); mem_block_put_to_header(next, remain, kBlockFree); mem_block_put_to_footer(next, remain, kBlockFree); return block; }// если remain меньше чем sizeof(void*) * 4 даже на 1 байт// мы пренебрегаем этим и распределяем весь блок mem_block_put_to_header(block, cur_size, kBlockAllocated); mem_block_put_to_footer(block, cur_size, kBlockAllocated); return block;}
Причина по которой aligned_size не учитывает оверхед заключается в том что сам свободный блок уже его имеет и как следует из реализации mem_block_place мы либо отрезаем достаточно что бы создать новый оверхед либо отдаем имеющийся блок даже если он больше на
(sizeof(void*) * 4) — 1, т.е. на 63 байта
Сами свободные блоки лежат в двусвязном списке который реализован в коде в виде следующей структуры:
struct ListHead{ ListHead *next = nullptr; ListHead *prev = nullptr;};
Так же вспомогательная функция которая умеет создавать из блока памяти объект типа ListHead*
static inline ListHead *mem_block_list_head(void *ptr){ auto head = reinterpret_cast<ListHead *>(ptr); head->next = nullptr; head->prev = nullptr; return head;}
Как упоминалось ранее более высоко уровневые функции которые работают не с сырой памятью, а с блоками всегда принимают указатель на payload. Получается что в payload свободного блока помещаются как минимум две ноды списка. Я решил пожертвовать дополнительной памятью что бы реализовать удаление за O(1).
static ListHead *list_erase(ListHead *head){ auto prev = head->prev; auto next = head->next; if (prev) { prev->next = next; } if (next) { next->prev = prev; } head->next = nullptr; head->prev = nullptr; return next;}static void bin_erase(void *block){ auto *head = reinterpret_cast<ListHead *>(block); size_t index = bin_index_from_size(mem_block_size(block)); list_erase(head); if (gBinList[index] == head) { gBinList[index] = head->next; } head->next = nullptr; head->prev = nullptr;}
Это необходимо делать каждый раз при слиянии блоков. Но об этом позже. Сейчас рассмотрим поиск свободного блока:
static ListHead *bin_find(size_t index, size_t size){ auto block = gBinList[index]; while (block) { if (mem_block_size(block) >= size) { break; } block = block->next; } return block;}static ListHead *bin_find_free_block(size_t size){// Получаем индекс бина из размера блока size_t index = bin_index_from_size(size);// Получаем бин из массива за O(1) auto block = gBinList[index];// Далее if (block) { if (index == kHugeBinIndex) {// если блок большой ищем в большом бине// операция может быть O(n) в худшем случае, если мы ищем очень большой блок// который будет последним в списке block = bin_find(index, size); } } else {// иначе перебираем все имеющиеся. Напоминаю что в маленьких бинах// лежат блоки одинакового размера, так что нам подойдет первый// попавшийся с нужным размером// наминально операция линейная, но фактически идет перебор всего 256// элементов и по факту первый же бин который != nullptr даст нам список за// O(1) for (size_t i = index; i <= kHugeBinIndex && !block; ++i) { block = bin_find(i, size); } } return block;}
Рассмотрим теперь вставку в бин
// Тривиальная вставка в начало спискаtemplate<typename _Node>_Node *free_list_prepend(_Node *head, _Node *block){ block->next = nullptr; if (!head) { return block; } if (head != block) { block->next = head; head->prev = block; } return block;}// Вставка в отсортированный список без оптимизации случая вставки в середину// за отсутствием просто такого кейса в кодеtemplate<typename _Node, typename _Cmp>_Node *__free_list_insert_sorted(_Node *head, _Node *block, _Cmp cmp){ if (!head) { head = block; head->next = nullptr; return head; } auto cursor = head; if (cmp(block, cursor)) { return free_list_prepend(cursor, head); } while (cursor->next && !cmp(block, cursor->next)) { cursor = cursor->next; } block->next = cursor->next; cursor->next = block; block->prev = cursor; if (cursor->next) { cursor->next->prev = block; } return head;}// Вызывает функцию выше с предикатом в виде лямбдыtemplate<typename _Node>_Node *free_list_insert_sorted_by_size(_Node *head, _Node *block){ block->next = nullptr; if (!head) { return block; } if (head != block) { auto cmp = [](_Node *first, _Node *second) { return mem_block_size(first) <= mem_block_size(second); }; return __free_list_insert_sorted<_Node, decltype(cmp)>(head, block, cmp); } return block;}// Собственно вставкаtemplate<typename _Node>_Node *bin_insert(_Node *block){ size_t index = mem_block_size(block); if (index < kHugeBinIndex) { gBinList[index] = free_list_prepend(gBinList[index], block); } else { gBinList[kHugeBinIndex] = free_list_insert_sorted_by_size(gBinList[kHugeBinIndex], block); } return block;}
Теперь перейдем к освобождению памяти, там видеть операции со списком за O(1) особенно радостно.
// удаляет из бинов смежные блоки перед слиянием если они свободныstatic void *mem_block_erase_merge(void *block){ auto current = mem_block_prev(block); if (mem_block_is_free(current)) { bin_erase(current); /* O(1) */ } current = mem_block_next(block); if (mem_block_is_free(current)) { bin_erase(current); /* O(1) */ }// эту функцию рассмотрим отдельно return mem_block_merge(block);}// получается амортизированно O(1) со слиянием!void mem_free(void *ptr){ if (ptr != nullptr && mem_block_is_allocated(ptr)) { // берем размер что бы проинициализировать блок снова // как свободный size_t size = mem_block_size(ptr); mem_block_init_block(ptr, size, kBlockFree); // готово сливаем со смежными свободными блоками предварительно // удалив их из бинов ptr = mem_block_erase_merge(ptr); // вставляем блок в соответствующий бин bin_insert(mem_block_list_head(ptr)); } else { ALOGE("%s(): Invalid pointer (%p)\n", __func__, ptr); }}
Теперь рассмотрим функцию слияния:
static void *mem_block_merge(void *ptr){// берем следуюий и предыдущий смежные блоки auto next = mem_block_next(ptr); auto prev = mem_block_prev(ptr);// выясняем аллоцированы ли они bool next_allocated = mem_block_is_allocated(next); bool prev_allocated = mem_block_is_allocated(prev);// запоминаем размер переданного блока, он еще пригодится ... size_t size = mem_block_size(ptr); if (prev_allocated && next_allocated) { /* соседи аллоцированы, нечего сливать */ } else if (prev_allocated && !next_allocated) {// сливаем со следующим, т.е. наращиваем размер блоку ptr с конца// уитываем освободившееся место из за отсутствия старого футера блока // ptr и хедера блока с которым мы сливаемся size += mem_block_size(next) + kOverheadSize; mem_block_put_to_header(ptr, size, kBlockFree); void *footer = mem_block_footer(ptr); mem_block_pack(footer, size, kBlockFree); } else if (!prev_allocated && next_allocated) {// тут зеркальный случай, только наращивем блок ptr к предыдущему блоку// в остальном все в точности тоже самое void *footer = mem_block_footer(ptr);// больше нет хедера ptr и футера предыдущего блока, футер ptr теперь футер// предыдущего блока size += mem_block_size(prev) + kOverheadSize; mem_block_put_to_header(prev, size, kBlockFree); mem_block_pack(footer, size, kBlockFree); return prev; } else if (!prev_allocated && !next_allocated) {// сливаемся с обоими, тоже ничего сложного. Все тоже самое что выше// только остаются хедер предыдущего блока и футер следующего void *header = mem_block_header(prev); void *footer = mem_block_footer(next);// учитываем что освобождаются футер предыдущего, весь оверхед блока ptr// и хедер следующего, т.е. в сумме, по размеру 2 полных оверхеда size += mem_block_size(prev) + mem_block_size(next) + kOverheadSize * 2; mem_block_pack(header, size, kBlockFree); mem_block_pack(footer, size, kBlockFree); return prev; } return ptr;}
Таким образом уменьшается фрагментация памяти и упрощается реализация, не нужна реализация отдельного алгоритма как политики слияния блоков.
Так же есть функции mem_realloc и mem_calloc, их реализация тривиальна:
void *mem_calloc(size_t num, size_t size){ size_t count = size * num; void *p = mem_malloc(count); if (p != nullptr) { memset(p, 0, count); } return p;}void *mem_realloc(void *p, size_t new_sz){ if (!p) { return mem_malloc(new_sz); } auto block = mem_malloc(new_sz); if (block) { memmove(block, p, min(new_sz, mem_block_size(p))); mem_free(p); } return block;}
На этот раз я реализовал возможности какой никакой диагностики помимо дампа как в предыдущих статьях. Теперь можно хоть как-то проверить целостность кучи:
bool mem_check_block(void *p){ if (p) { // проверяем выравнивание, если оно сломано то это не наш указатель if ((reinterpret_cast<size_t>(p) % kPointerSize) == 0) { // хедер и футер должны быть равны друг другу по содержанию if (*mem_block_header(p) == *mem_block_footer(p)) { // На этом диагностика заканчивается :-) return true; } else { ALOGE("Bad block. Header and footer are not the same"); } } else { ALOGE("Bad block (%p). The address is not aligned by %zu", p, kPointerSize); } } else { ALOGE("Bad block (%p)", p); } return false;}bool mem_check(bool verbose){ char buffer[kMaxMessageLen]; if (gMemStart != nullptr && gMemEnd != nullptr) { for (void *cur_blk = mem_block_user_ptr(gMemStart); cur_blk <= mem_block_user_ptr(gMemEnd); cur_blk = mem_block_next(cur_blk)) { if (verbose) { mem_print_block_to_str(cur_blk, buffer); if (!mem_check_block(cur_blk)) { ALOGD("block %s BAD", buffer); return false; } ALOGD("%s OK", buffer); } } } return true;}
Вот пример использования:
#include <iostream>#include <memory>#include <vector>#include <string>#include <string.h>#include "memory.h"#define LOG_TAG "main"#include "logging.h"#define PAGE_SIZE 4096#define HEAP_SIZE (4096 + 1000000 + 2048)#define ITEMS_NUMBER 4static char *char_alloc(size_t size) __attribute__((unused));static char *char_alloc(size_t size){ auto p = mem_malloc(size); //std::cout << __func__ << ": p = " << std::hex << p << std::endl; return reinterpret_cast<char *>( p);}int main(){ std::unique_ptr<char[]> heap(new char[HEAP_SIZE]); std::vector<char *> vptr; vptr.reserve(ITEMS_NUMBER); auto p = heap.get(); *p = 'a'; mem_initialize(heap.get(), HEAP_SIZE); dump_mem(); dump_bins(); for (int i = 1; i < ITEMS_NUMBER; ++i) { vptr[i] = char_alloc(i); } mem_check(true); for (int i = 1; i < ITEMS_NUMBER; ++i) { if ((i % 2) == 0) { mem_free(vptr[i]); } } dump_mem(); dump_bins(); mem_check(true); for (int i = 0; i < ITEMS_NUMBER; ++i) { if ((i % 2) != 0) { mem_free(vptr[i]); } } dump_mem(); dump_bins(); mem_check(true); return 0;}
вывод:
D:\osdev\small_allocator\cmake-build-debug\malloc.exememory: *************************MEMORY DUMP*************************memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocatedmemory: block address 000001ce788200a8 size 1006064 size with overhead 1006080 state freememory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocatedmemory: **********************END OF MEMORY DUMP**********************memory: total memory 1006096 bytesmemory: total memory with overhead 1006144 bytesmemory: total total_blocks count 3memory: total allocated blocks 2memory: total free blocks 1memory: block 000001ce788200a8 size 1006064 size with overhead 1006080 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000memory: Total free blocks in all bins 1memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OKmemory: block (000001ce788200a8) header (000001ce788200a0) [16:1] footer (000001ce788200b8) [16:1] OKmemory: block (000001ce788200c8) header (000001ce788200c0) [16:1] footer (000001ce788200d8) [16:1] OKmemory: block (000001ce788200e8) header (000001ce788200e0) [16:1] footer (000001ce788200f8) [16:1] OKmemory: block (000001ce78820108) header (000001ce78820100) [1005968:0] footer (000001ce78915a98) [1005968:0] OKmemory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OKmemory: *************************MEMORY DUMP*************************memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocatedmemory: block address 000001ce788200a8 size 16 size with overhead 32 state allocatedmemory: block address 000001ce788200c8 size 16 size with overhead 32 state freememory: block address 000001ce788200e8 size 16 size with overhead 32 state allocatedmemory: block address 000001ce78820108 size 1005968 size with overhead 1005984 state freememory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocatedmemory: **********************END OF MEMORY DUMP**********************memory: total memory 1006048 bytesmemory: total memory with overhead 1006144 bytesmemory: total total_blocks count 6memory: total allocated blocks 4memory: total free blocks 2memory: block 000001ce788200c8 size 16 size with overhead 32 mem bin[16] prev addr 0000000000000000 next addr 0000000000000000memory: block 000001ce78820108 size 1005968 size with overhead 1005984 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000memory: Total free blocks in all bins 2memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OKmemory: block (000001ce788200a8) header (000001ce788200a0) [16:1] footer (000001ce788200b8) [16:1] OKmemory: block (000001ce788200c8) header (000001ce788200c0) [16:0] footer (000001ce788200d8) [16:0] OKmemory: block (000001ce788200e8) header (000001ce788200e0) [16:1] footer (000001ce788200f8) [16:1] OKmemory: block (000001ce78820108) header (000001ce78820100) [1005968:0] footer (000001ce78915a98) [1005968:0] OKmemory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OKmemory: *************************MEMORY DUMP*************************memory: service block address 000001ce78820088 size 16 size with overhead 32 state allocatedmemory: block address 000001ce788200a8 size 1006064 size with overhead 1006080 state freememory: service block address 000001ce78915aa8 size 16 size with overhead 32 state allocatedmemory: **********************END OF MEMORY DUMP**********************memory: total memory 1006096 bytesmemory: total memory with overhead 1006144 bytesmemory: total total_blocks count 3memory: total allocated blocks 2memory: total free blocks 1memory: block 000001ce788200a8 size 1006064 size with overhead 1006080 mem bin[255] prev addr 0000000000000000 next addr 0000000000000000memory: Total free blocks in all bins 1memory: block (000001ce78820088) header (000001ce78820080) [16:1] footer (000001ce78820098) [16:1] OKmemory: block (000001ce788200a8) header (000001ce788200a0) [1006064:0] footer (000001ce78915a98) [1006064:0] OKmemory: block (000001ce78915aa8) header (000001ce78915aa0) [16:1] footer (000001ce78915ab8) [16:1] OKProcess finished with exit code 0
Ну вот и все! Важная часть библиотеки написана!
До новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1045692/