Nikita Mandrykin

Практикуюсь в программировании. C • Python • Assembly • Linux

Назад

Массивы в NASM: Адресация памяти, Циклы и Эффективность

Словарь основных терминов

Термин Значение Пример
Массив Последовательность элементов одного типа в памяти [10, 20, 30, 40]
Метка Имя для адреса в памяти numbers:
Директива Команда ассемблеру (не процессору) db, dw, dd
Адресация Способ обращения к элементу [numbers + 4]
Индекс Номер элемента в массиве (начинается с 0) В [10, 20, 30] индекс 1 = 20
Смещение Расстояние в байтах от начала массива Для 2-го элемента dw: смещение = 2 байта
Буфер Временная область памяти для хранения данных buffer resb 1024

⏱️ 1. Первый массив за 2 минуты

Начнём с самого простого примера — массив из пяти чисел и вывод первого элемента:

section .data
    numbers db 10, 20, 30, 40, 50    ; Массив из 5 байтов
    
section .text
    global _start

_start:
    mov al, [numbers]                ; Загружаем первый элемент (10)
                                     ; al теперь содержит 10
    
    mov rax, 60                      ; Системный вызов exit
    xor rdi, rdi
    syscall

Что здесь происходит:

  • db (define byte) объявляет массив байтов
  • numbers — это метка, указывающая на начало массива
  • [numbers] — обращение к первому элементу через квадратные скобки

Запустите этот код — он успешно скомпилируется и выполнится. Теперь разберёмся, как это работает детально.


🤔 2. Зачем нужны массивы в ассемблере?

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

Взгляните на разницу между хранением отдельных переменных и использованием массива:

❌ Без массивов

ПРОБЛЕМЫ

Представьте, что нужно сохранить 100 чисел:

section .data
    num1 db 10
    num2 db 20
    num3 db 30
    ; ...ещё 97 переменных

ПОСЛЕДСТВИЯ

  • Невозможно обрабатывать в цикле
  • Огромный объём повторяющегося кода
  • Нельзя динамически выбрать элемент
  • Кошмар при масштабировании
✅ С массивами

РЕШЕНИЕ

Один массив вмещает всё:

section .data
    numbers db 10, 20, 30, ..., 100

ПРЕИМУЩЕСТВА

  1. Компактность: Одна строка вместо сотен
  2. Цикличность: Легко обходить for(i=0; i<100; i++)
  3. Индексация: Доступ к любому элементу через индекс
  4. Эффективность: Непрерывный блок в памяти

Реальные применения

Обработка строк

Строки — это массивы символов (байтов):

section .data
    message db "Hello, World!", 0    ; Массив символов с нуль-терминатором
Таблицы данных

Предвычисленные значения для быстрого доступа:

section .data
    ; Таблица квадратов чисел 0-9
    squares db 0, 1, 4, 9, 16, 25, 36, 49, 64, 81
Буферы ввода-вывода

Временное хранение данных:

section .bss
    buffer resb 1024    ; Резервируем 1024 байта под буфер

📋 3. Способы объявления массивов

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

Основные директивы

Директива Размер элемента Диапазон значений Применение
db 1 байт (8 бит) 0 до 255 (или -128 до 127) Символы, малые числа
dw 2 байта (16 бит) 0 до 65535 (или -32768 до 32767) Короткие числа, Unicode
dd 4 байта (32 бит) 0 до 4,294,967,295 Целые числа, адреса (32-bit)
dq 8 байтов (64 бит) 0 до 18,446,744,073,709,551,615 Большие числа, указатели

Практические примеры

Массив байтов (db)
section .data
    ; ASCII-коды символов
    chars db 'A', 'B', 'C', 0
    
    ; Малые числа
    ages db 25, 30, 18, 45
    
    ; Смешанное (осторожно!)
    mixed db 100, 'X', 200
Массив двойных слов (dd)
section .data
    ; Большие целые числа
    populations dd 1000000, 5000000
    
    ; Числа с плавающей точкой (32-bit float)
    temperatures dd 3.14, 2.71
    
    ; Можно смешивать форматы
    data dd 42, 0x2A, 0b101010  ; Всё равно 42

Специальные возможности

Повторяющиеся значения:

section .data
    zeros db 100 dup(0)        ; 100 нулевых байтов
    pattern dw 10 dup(0xFFFF)  ; 10 слов со значением 0xFFFF

Неинициализированные массивы:

section .bss
    buffer resb 512    ; Резервируем 512 байт (не инициализированы)
    matrix resw 100    ; Резервируем 100 слов (200 байт)
    bigdata resd 1000  ; Резервируем 1000 двойных слов (4000 байт)
⚠️ Важно: Разница между .data и .bss
  • .data — инициализированные данные, занимают место в исполняемом файле
  • .bss — неинициализированные данные, в файле только метаданные о размере
  • Используйте .bss для больших буферов, чтобы не раздувать размер программы. Эта и другие подобные ошибки разобраны в статье «Топ ошибок в NASM: Почему падает Segfault и неверные расчёты».

Профессиональный приём: Выравнивание (align)

Представьте, что память — это тетрадь в клеточку, где процессор «читает» по 8 клеточек за раз.

Если ваше число случайно начинается с середины “строки” и перелезает на следующую, процессору приходится делать два чтения вместо одного и склеивать половинки. Это не только медленно, но и опасно: некоторые современные инструкции (SSE/AVX) в такой ситуации просто аварийно завершают программу.

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

🐢 Медленно (Без выравнивания)
section .data
    flag  db 1         ; Адрес: 0x...00
    
    ; Следующий адрес: 0x...01 (нечетный!)
    
    value dq 100       ; Адрес: 0x...01
                       ; Занимает байты с 01 по 08.
                       ; Пересекает границу 8 байт!
🐇 Быстро (С align)
section .data
    flag  db 1         ; Адрес: 0x...00
    
    align 8            ; Вставляет 7 пустых байт (NOP)
                       ; Следующий адрес станет 0x...08
    
    value dq 100       ; Адрес: 0x...08
                       ; Идеально попадает в "сетку" памяти

🤔 А разве компилятор не делает это сам?

В языках высокого уровня (C, C++, Rust) компилятор автоматически добавляет отступы (padding) между переменными. Но NASM — это не компилятор. Он создаёт структуру памяти с точностью до байта именно так, как вы написали. Если вы не напишете align, данные будут лежать вплотную, даже если это вредит производительности. В ассемблере вся ответственность за память лежит на вас.

Золотое правило выравнивания

Ставьте align X перед данными размером X байт:

Тип данных Размер Директива
dw (word) 2 байта align 2
dd (dword) 4 байта align 4
dq (qword) 8 байт align 8
SSE / AVX 16/32 байта align 16 / align 32 (Обязательно!)

🗺️ 4. Как работает адресация массивов

Концепция: Массив как улица с домами

Представьте массив как улицу с пронумерованными домами:

🏠 Дом №0    🏠 Дом №1    🏠 Дом №2    🏠 Дом №3
   [10]         [20]         [30]         [40]
Адрес: 100   Адрес: 101   Адрес: 102   Адрес: 103
  • Метка массива (numbers) — это адрес первого дома (улица Пушкина, дом №0)
  • Каждый дом занимает определённую площадь:
    • db: 1 байт — маленький дом
    • dw: 2 байта — средний дом
    • dd: 4 байта — большой дом

Чтобы дойти до дома №3:

  • Если дома по 1 байту: идём на 3 × 1 = 3 байта вперёд
  • Если дома по 2 байта: идём на 3 × 2 = 6 байтов вперёд
  • Если дома по 4 байта: идём на 3 × 4 = 12 байтов вперёд

Формула адресации

Теперь та же идея в виде формулы:

$$Address = Base + (Index \times ElementSize)$$

Конкретный числовой пример:

section .data
    arr dw 10, 20, 30, 40    ; Массив начинается с адреса 0x1000

Если arr находится по адресу 0x1000 (база), то:

Элемент Индекс Вычисление Адрес Значение
arr[0] 0 0x1000 + (0 × 2) 0x1000 10
arr[1] 1 0x1000 + (1 × 2) 0x1002 20
arr[2] 2 0x1000 + (2 × 2) 0x1004 30
arr[3] 3 0x1000 + (3 × 2) 0x1006 40

Размер элемента dw = 2 байта, поэтому умножаем индекс на 2.

💡 Как узнать реальный адрес массива?

В коде вы никогда не пишете 0x1000 вручную. Адрес определяет операционная система при запуске. Чтобы получить его в регистр, используйте инструкцию LEA:

lea rsi, [arr]      ; Теперь RSI содержит реальный адрес (например, 0x402010)

Как увидеть адрес при отладке (GDB):

(gdb) print &arr
$1 = (<data variable, no debug info> *) 0x402010

🔍 Теория

ФОРМУЛА АДРЕСАЦИИ

Для массива dw (2-байтовые элементы):

element[3] → base_address + (3 × 2)

ПОЧЕМУ ТАК?

  • Индекс 0: base + 0×2 = base
  • Индекс 1: base + 1×2 = base+2
  • Индекс 3: base + 3×2 = base+6

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

⚙️ Практика в коде

РЕАЛИЗАЦИЯ

section .data
    arr dw 10, 20, 30, 40  ; 2-байтовые элементы

section .text
    mov rsi, arr       ; rsi = база массива
    mov rcx, 3         ; индекс элемента
    
    ; Вычисляем адрес: база + индекс×2
    mov ax, [rsi + rcx*2]  ; ax = arr[3] = 40

АВТОМАТИЧЕСКИЙ МАСШТАБ

NASM сам умножает rcx на 2, если указать *2.

Режимы адресации

NASM поддерживает несколько способов доступа к элементам:

Прямая адресация
section .data
    arr db 10, 20, 30

section .text
    ; Доступ через метку напрямую
    mov al, [arr]      ; arr[0]
    mov al, [arr+1]    ; arr[1]
    mov al, [arr+2]    ; arr[2]
Косвенная адресация
section .data
    arr db 10, 20, 30

section .text
    ; Доступ через регистр
    mov rsi, arr       ; rsi указывает на массив
    mov al, [rsi]      ; arr[0]
    mov al, [rsi+1]    ; arr[1]
    
    ; Индексация через регистр
    mov rcx, 2
    mov al, [rsi+rcx]  ; arr[rcx] = arr[2]

Масштабирование индекса

Для массивов с элементами больше 1 байта используйте множители *1, *2, *4, *8:

section .data
    bytes db 10, 20, 30, 40       ; 1 байт/элемент
    words dw 10, 20, 30, 40       ; 2 байта/элемент
    dwords dd 10, 20, 30, 40      ; 4 байта/элемент
    qwords dq 10, 20, 30, 40      ; 8 байтов/элемент

section .text
    mov rcx, 2                     ; Индекс = 2
    
    mov al, [bytes + rcx*1]        ; bytes[2] = 30
    mov ax, [words + rcx*2]        ; words[2] = 30
    mov eax, [dwords + rcx*4]      ; dwords[2] = 30
    mov rax, [qwords + rcx*8]      ; qwords[2] = 30
💡 Совет: Как запомнить множитель

Множитель = Размер элемента в байтах:

  • db*1 (1 байт)
  • dw*2 (2 байта)
  • dd*4 (4 байта)
  • dq*8 (8 байтов)

⚙️ 5. Типичные операции с массивами

Чтение элемента

По константному индексу
section .data
    numbers dd 100, 200, 300, 400

section .text
    ; Прямой доступ
    mov eax, [numbers]      ; eax = numbers[0] = 100
    mov eax, [numbers+4]    ; eax = numbers[1] = 200
    mov eax, [numbers+8]    ; eax = numbers[2] = 300
По переменному индексу
section .data
    numbers dd 100, 200, 300, 400

section .text
    ; Через регистр-индекс
    mov rcx, 2                  ; Индекс = 2
    mov eax, [numbers + rcx*4]  ; eax = numbers[2] = 300
    
    ; Или через указатель
    lea rsi, [numbers]
    mov eax, [rsi + rcx*4]

Запись элемента

section .data
    arr dd 10, 20, 30, 40

section .text
    ; Изменяем arr[1] на 999
    mov dword [arr+4], 999
    
    ; Через регистр
    mov rcx, 3
    mov dword [arr + rcx*4], 777    ; arr[3] = 777

Обход массива в цикле

Задача: Просуммировать все элементы массива.

Метод 1: Счётчик индекса

section .data
    arr dd 1, 2, 3, 4, 5
    len equ ($ - arr) / 4   ; Длина массива

section .text
    xor eax, eax            ; sum = 0
    xor rcx, rcx            ; i = 0

.loop:
    cmp rcx, len
    jge .done               ; if (i >= len) goto done
    
    add eax, [arr + rcx*4]  ; sum += arr[i]
    inc rcx                 ; i++
    jmp .loop

.done:
    ; eax содержит сумму (15)
💡 Альтернативный подход: использование указателя

Этот метод двигает указатель по массиву вместо индекса. Более эффективен, но менее интуитивен для новичков.

section .data
    arr dd 1, 2, 3, 4, 5
    len equ ($ - arr) / 4

section .text
    xor eax, eax            ; sum = 0
    lea rsi, [arr]          ; rsi = &arr[0]
    mov rcx, len            ; counter = len

.loop:
    add eax, [rsi]          ; sum += *rsi
    add rsi, 4              ; rsi++ (двигаем на 4 байта)
    dec rcx                 ; counter--
    jnz .loop               ; if (counter != 0) goto loop

    ; eax содержит сумму (15)

Когда использовать этот метод:

  • Когда индекс элемента не важен
  • Для последовательной обработки всех элементов
  • Когда нужна максимальная скорость
📖 Объяснение: Вычисление длины массива
len equ ($ - arr) / 4
  • $ — текущий адрес при ассемблировании (конец массива)
  • arr — адрес начала массива
  • $ - arr — размер массива в байтах
  • / 4 — делим на размер элемента, получаем количество элементов

Для dd используем /4, для dw/2, для db/1.

Поиск элемента

Задача: Найти индекс первого вхождения числа 30 в массиве.

section .data
    arr dd 10, 20, 30, 40, 30, 50
    len equ ($ - arr) / 4
    target dd 30

section .text
    xor rcx, rcx                ; i = 0
    mov r8d, -1                 ; found_index = -1 (не найдено)

.search_loop:
    cmp rcx, len
    jge .not_found
    
    mov eax, [arr + rcx*4]      ; eax = arr[i]
    cmp eax, [target]
    je .found                   ; if (eax == target) goto found
    
    inc rcx
    jmp .search_loop

.found:
    mov r8, rcx                 ; r8 = индекс найденного элемента
    jmp .end

.not_found:
    ; r8 уже содержит -1

.end:
    ; r8 содержит индекс (2) или -1

Копирование массива

section .data
    source dd 10, 20, 30, 40
    len equ ($ - source) / 4

section .bss
    dest resd 4                 ; Место для копии

section .text
    lea rsi, [source]           ; rsi = указатель на источник
    lea rdi, [dest]             ; rdi = указатель на назначение
    mov rcx, len                ; rcx = счётчик элементов

.copy_loop:
    mov eax, [rsi]              ; Читаем из source
    mov [rdi], eax              ; Пишем в dest
    
    add rsi, 4                  ; Переходим к следующему элементу
    add rdi, 4
    
    dec rcx
    jnz .copy_loop
    
    ; Альтернатива: использовать rep movsd
    ; mov rcx, len
    ; rep movsd                 ; Копирует rcx двойных слов из [rsi] в [rdi]
🚀 Оптимизация: Инструкция REP MOVSD
lea rsi, [source]
lea rdi, [dest]
mov rcx, len
rep movsd    ; Копирует rcx×4 байта из [rsi] в [rdi]

rep movsd — это специальная инструкция процессора, которая повторяет movsd (move double word) rcx раз. Она быстрее ручного цикла на современных процессорах.


📝 6. Практические примеры

Простой пример: Поиск максимума

Начнём с компактного примера без вывода на экран:

section .data
    numbers dd 45, 12, 89, 23, 67, 34, 91, 56
    len equ ($ - numbers) / 4

section .text
    global _start

_start:
    ; Находим максимум
    mov eax, [numbers]      ; max = numbers[0]
    mov rcx, 1              ; i = 1

.loop:
    cmp rcx, len
    jge .done
    
    mov edx, [numbers + rcx*4]  ; edx = numbers[i]
    cmp edx, eax
    jle .skip               ; if (edx <= max) skip
    
    mov eax, edx            ; max = edx

.skip:
    inc rcx
    jmp .loop

.done:
    ; eax содержит максимум (91)
    
    ; Выход с кодом = максимум (для демонстрации)
    mov rdi, rax            ; exit code
    mov rax, 60             ; sys_exit
    syscall

Что происходит:

  1. Загружаем первый элемент как начальный максимум
  2. Проходим по остальным элементам
  3. Если текущий элемент больше максимума — обновляем максимум
  4. После цикла в eax лежит максимальное значение

Проверка работы:

nasm -f elf64 max.asm -o max.o
ld max.o -o max
./max
echo $?    # Выведет 91 — код возврата программы

Усложнённый пример: Вывод максимума на экран

Теперь добавим форматированный вывод результата:

Основная логика находится в функции find_max (строка 30).
Функция print_number — дополнительная, можно пропустить при первом чтении.

section .data
    numbers dd 45, 12, 89, 23, 67, 34, 91, 56
    numbers_end:
    len equ (numbers_end - numbers) / 4
    
    msg_prefix db "Максимум: "
    msg_prefix_len equ $ - msg_prefix
    
    newline db 10

section .bss
    digit_buffer resb 20

section .text
    global _start

_start:
    ; Находим максимум
    call find_max           ; Результат в eax
    
    ; СОХРАНЯЕМ результат, так как syscall его испортит
    push rax
    
    ; Выводим префикс "Максимум: "
    mov rax, 1              ; sys_write
    mov rdi, 1              ; stdout
    mov rsi, msg_prefix
    mov rdx, msg_prefix_len
    syscall
    
    ; ВОССТАНАВЛИВАЕМ результат после syscall
    pop rax
    
    ; Преобразуем число в строку и выводим
    mov edi, eax            ; Копируем eax в edi
    call print_number
    
    ; Выводим перенос строки
    mov rax, 1
    mov rdi, 1
    mov rsi, newline
    mov rdx, 1
    syscall
    
    ; Выход
    mov rax, 60
    xor rdi, rdi
    syscall

; Функция: находит максимум в массиве numbers
; Возвращает: eax = максимальное значение
find_max:
    mov eax, [numbers]      ; max = numbers[0]
    mov ecx, 1              ; i = 1

.loop:
    cmp ecx, len            ; Сравниваем текущий индекс с длиной массива
    jge .done               ; if (i >= len) goto done
    
    mov edx, [numbers + rcx*4]  ; edx = numbers[i]
    cmp edx, eax
    jle .skip               ; if (edx <= max) skip
    
    mov eax, edx            ; max = edx

.skip:
    inc ecx
    jmp .loop

.done:
    ret

; Функция: выводит число на экран
; Параметры: rdi = число для вывода (беззнаковое)
print_number:
    push rbp
    mov rbp, rsp
    push rbx
    
    ; Преобразуем число в строку (в обратном порядке)
    lea rsi, [digit_buffer + 19]
    mov byte [rsi], 0
    dec rsi
    
    mov rax, rdi
    mov rbx, 10

.convert_loop:
    xor rdx, rdx
    div rbx
    add dl, '0'
    mov [rsi], dl
    dec rsi
    
    test rax, rax
    jnz .convert_loop
    
    ; Вывод строки
    inc rsi
    lea rdx, [digit_buffer + 19]
    sub rdx, rsi
    
    mov rax, 1
    mov rdi, 1
    syscall
    
    pop rbx
    pop rbp
    ret

Компиляция и запуск:

nasm -f elf64 max_print.asm -o max_print.o
ld max_print.o -o max_print
./max_print

Вывод:

Максимум: 91
🔍 Разбор функции print_number

Эта функция преобразует число в строку, разбивая его на цифры:

  1. Деление на 10: 91 ÷ 10 = 9 остаток 1
  2. Сохраняем ‘1’: преобразуем 1 в ASCII символ '1' (код 49)
  3. Повторяем: 9 ÷ 10 = 0 остаток 9 → сохраняем '9'
  4. Результат: строка "91"

Мы записываем цифры справа налево, поэтому начинаем с конца буфера.

❌ 7. Частые ошибки и их решения

При работе с массивами легко допустить ошибки, связанные с неправильной адресацией или несоответствием типов. Ниже рассмотрены самые типичные из них. Более подробный разбор и другие примеры вы найдёте в руководстве «Топ ошибок в NASM: Почему падает Segfault и неверные расчёты», а техники отладки описаны в статье «Отладка ASM в VS Code: Настройка GDB и визуальный интерфейс».

❌ Типичные ошибки

1. НЕПРАВИЛЬНЫЙ РАЗМЕР ЭЛЕМЕНТА

arr dw 10, 20, 30
mov ax, [arr + 2]    ; ❌ Получим arr[1], а не arr[2]!

ПРОБЛЕМА

Забыли умножить на размер элемента (2 байта для dw).


2. ВЫХОД ЗА ГРАНИЦЫ

arr db 1, 2, 3
mov al, [arr + 5]    ; ❌ Читаем случайные данные!

ПРОБЛЕМА

Массив из 3 элементов, обращаемся к 6-му байту.


3. НЕВЕРНЫЙ РЕГИСТР

arr dw 1000
mov al, [arr]        ; ❌ al — 1 байт, потеряем данные!

ПРОБЛЕМА

Используем 8-битный регистр для 16-битного значения.

✅ Правильные решения

1. ИСПОЛЬЗУЕМ МАСШТАБИРОВАНИЕ

arr dw 10, 20, 30
mov ax, [arr + 2*2]  ; ✅ arr[2] = 30
; или
mov rcx, 2
mov ax, [arr + rcx*2]

2. ПРОВЕРЯЕМ ГРАНИЦЫ

arr db 1, 2, 3
len equ $ - arr

mov rcx, 2           ; Индекс
cmp rcx, len
jge .out_of_bounds   ; ✅ Проверка перед доступом

mov al, [arr + rcx]

3. СООТВЕТСТВИЕ ТИПОВ

arr dw 1000
mov ax, [arr]        ; ✅ 16-битный регистр для dw
Директива Регистры
db al, bl, cl, dl
dw ax, bx, cx, dx
dd eax, ebx, ecx, edx
dq rax, rbx, rcx, rdx

Дополнительные подводные камни

4. Забыли про знаковость
section .data
    arr db -1, -2, -3    ; Интерпретируется как 255, 254, 253

section .text
    mov al, [arr]
    cmp al, 0
    jl .negative         ; ❌ Не сработает! al = 255 (беззнаковое)

Решение: Используйте знаковые сравнения или явное расширение знака.

⚠️ Отладка: Если вы случайно вышли за границы массива, программа упадёт с ошибкой Segmentation fault. Как найти точное место ошибки с помощью GDB, описано в статье «Топ ошибок в NASM: Почему падает Segfault и неверные расчёты».

5. Перепутали lea и mov
mov rsi, [arr]       ; Загружает ЗНАЧЕНИЕ первого элемента
lea rsi, [arr]       ; Загружает АДРЕС массива

Правило: lea для получения адреса, mov для получения значения.


✅ Заключение

Что вы освоили:

Объявление массивов с помощью db, dw, dd, dq
Адресацию через прямой доступ и индексные регистры
Масштабирование индексов для элементов разных размеров
Базовые операции: чтение, запись, обход, поиск, копирование
Практическое применение в полноценных программах


Шпаргалка по массивам

Операция Код Примечание
Объявить массив байтов arr db 1, 2, 3 1 байт/элемент
Объявить массив слов arr dw 100, 200 2 байта/элемент
Объявить массив двойных слов arr dd 1000, 2000 4 байта/элемент
Резервировать буфер buf resb 1024 В секции .bss
Длина массива len equ ($ - arr) / 4 Для dd делим на 4
Прочитать arr[0] mov eax, [arr] Первый элемент
Прочитать arr[i] (байты) mov al, [arr + rcx] rcx = индекс
Прочитать arr[i] (слова) mov ax, [arr + rcx*2] Масштаб ×2
Прочитать arr[i] (dword) mov eax, [arr + rcx*4] Масштаб ×4
Записать в arr[i] mov [arr + rcx*4], eax Для dd
Загрузить адрес массива lea rsi, [arr] rsi = &arr[0]
Цикл по массиву cmp rcx, lenjge .done Проверка границ
💜

Полезный материал?

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

Поддержать через CloudTips