Задача — сохранить десяток чисел и перебрать их по порядку. В языках высокого уровня это пара строк. В ассемблере нужно понять, где эти числа живут в памяти, как до них добраться и не выйти за границы.
Информация, которая содержится в этой статье, пригодится тем, кто изучает архитектуру процессора, пишет низкоуровневый код или хочет разобраться, что происходит «под капотом» любой программы.
По данным Stack Overflow Developer Survey, ассемблер остаётся в топ-20 языков, которые используют разработчики встроенных систем и системного программного обеспечения — то есть там, где работа с железом ведётся напрямую.
Что такое массив в ассемблере
Массив — это несколько ячеек памяти, расположенных подряд. Каждая ячейка хранит один элемент: байт, слово (2 байта), двойное слово (4 байта) или четвёрное (8 байт). Разрыва между элементами нет — они идут один за другим без промежутков, если только вы сами их не задали.
Ключевая особенность — никакого встроенного типа «массив» не существует. Процессор видит просто участок памяти. Интерпретация этого участка — задача программиста: он решает, где начинается структура, как велик каждый элемент и сколько их всего.
В ассемблере нет автоматической проверки границ. Вышли за пределы объявленного участка — читаете или пишете «чужую» память. Это источник большинства трудноуловимых багов.
Как устроена память под массив
Представьте ленту адресов: 1000, 1001, 1002… Если вы объявили массив из пяти байт начиная с адреса 1000, то элемент с индексом 0 лежит по адресу 1000, с индексом 1 — по 1001, и так далее. Для слов (2 байта) шаг будет уже 2: элемент 0 → адрес 1000, элемент 1 → адрес 1002.
Адрес любого элемента вычисляется по формуле:
адрес = базовый_адрес + индекс × размер_элемента
Это и есть основа всего, что дальше. Регистр с базовым адресом плюс смещение — стандартная схема адресации в большинстве архитектур.
Массивы в ассемблере: объявление и типы
Объявление происходит в секции данных. Синтаксис зависит от архитектуры (x86, x86-64, ARM) и ассемблера (NASM, MASM, GAS). Логика везде одна: директива типа + начальные значения или зарезервированное место.
Основные директивы в NASM для x86-64:
- DB (define byte) — массив байт (1 байт на элемент).
- DW (define word) — массив слов (2 байта на элемент).
- DD (define dword) — массив двойных слов (4 байта).
- DQ (define qword) — массив четверных слов (8 байт).
- RESB / RESW / RESD / RESQ — резервирование без инициализации.
Пример объявления в секции .data:

Для неинициализированных данных используют секцию .bss:

Если нужно хранить строку символов — это тоже байтовый массив. Строки в ассемблере заканчиваются нулевым байтом (0) по соглашению Си или явно задаются с длиной.
Код объявления полностью определяет поведение: одна и та же область памяти может быть прочитана как массив байт или как массив слов — всё зависит от того, каким регистром и с каким шагом вы её обходите.
Как создать массив в ассемблере
Создание массива — это три шага:
- Объявить данные.
- Загрузить базовый адрес в регистр.
- Работать с элементами через смещение или индекс.
Разберём оба варианта.
Статический массив
Задаётся при компиляции. Размер фиксирован и менять его в процессе выполнения программы нельзя. Это самый распространённый вариант для простых задач.
Полный пример на NASM x86-64 — объявление и чтение первого элемента:

Шаг равен размеру элемента: для dd это 4 байта. Для dw — 2, для db — 1.
Динамический массив
Если размер заранее неизвестен, память выделяют через системный вызов (Linux: sys_brk или mmap) или получают через стек. В стеке всё проще: смещаете указатель стека (RSP) на нужный размер и получаете область под данные.
Пример: массив из 10 двойных слов на стеке:

После работы с таким массивом обязательно восстановите RSP: add rsp, 40. Иначе при возврате из функции процессор прочитает «мусор» вместо адреса возврата.
Стековый массив живёт только пока активен текущий кадр вызова. Возвращать указатель на него из функции нельзя — по завершении функции эта область будет перезаписана.
Обход и обработка элементов
Перебирать элементы вручную — плохая идея при любом массиве длиннее трёх позиций. Для этого есть цикл. В ассемблере цикл реализуется через счётчик и условный или безусловный переход.
Цикл с индексом
Стандартная схема: в один регистр помещают счётчик, в другой — базовый адрес. На каждом шаге читают или записывают элемент и смещают указатель на размер элемента.
Пример: сумма всех элементов массива dword в NASM x86-64:

Строка [rsi + ecx*4] — индексная адресация: процессор сам умножает индекс на масштаб (1, 2, 4 или 8) и добавляет к базовому адресу. Это эффективнее, чем считать смещение вручную.
Аналогичным образом строится цикл для копирования, поиска максимума, сортировки. Логика всегда одна: управляете счётчиком, читаете или изменяете нужный элемент, переходите к следующей итерации.
Используйте команду LEA вместо MOV для загрузки адреса — она не обращается к памяти, а просто вычисляет адрес. Быстрее и не трогает флаги.
Поиск и сравнение элементов в массиве
Найти нужное значение в массиве — одна из самых частых задач при написании низкоуровневого кода. В ассемблере линейный поиск строится по той же схеме, что и обычный обход: загружаете базовый адрес, запускаете цикл, на каждом шаге сравниваете текущий элемент с эталоном командой CMP. Как только условие выполнилось — выходите из цикла через условный переход: JE, JNE, JL или JG в зависимости от задачи.
Команда CMP не меняет операнды — она только устанавливает флаги процессора. Дальше работает переход: JE сработает, если значения равны; JL — если первый операнд меньше второго для знаковых чисел. Это принципиально важно, когда сравниваете беззнаковые величины: для них нужны JB и JA, а не JL и JG. Путаница здесь не даёт сообщения об ошибке, но поиск будет находить совсем не то, что ожидается.
Нужно найти максимум или минимум? Логика немного меняется. Перед циклом кладёте в регистр первый элемент как текущий «рекордсмен». На каждой итерации сравниваете его с очередным элементом и при необходимости обновляете. После последней итерации в регистре остаётся нужное значение. Такой подход не требует дополнительной памяти — всё хранится в регистрах на протяжении всей работы цикла.
Отдельный случай — поиск в заранее отсортированном массиве. Там оправдан бинарный поиск: делите диапазон индексов пополам, сравниваете средний элемент с целевым и сужаете область поиска. В ассемблере это потребует нескольких дополнительных регистров под левую и правую границу, но выигрыш по скорости при больших наборах данных существенный. Для массива из тысячи элементов линейный поиск сделает до 1000 сравнений, бинарный — не более десяти.
Передача массива в подпрограмму
Когда программа растёт, повторяющийся код выносят в подпрограммы. С массивами в ассемблере это требует аккуратности: сам массив в стек не кладут — передают адрес его начала и количество элементов. Подпрограмма работает с переданным указателем, не зная и не заботясь о том, где именно в памяти расположены данные.
Соглашения о передаче аргументов
В 64-битных системах Linux (System V AMD64 ABI) первые шесть целочисленных аргументов передаются через регистры: RDI, RSI, RDX, RCX, R8, R9. Если вызываете свою подпрограмму и хотите передать ей массив, кладёте базовый адрес в RDI, длину — в RSI.
Внутри подпрограммы работаете с RDI как с базой, с RSI как со счётчиком границы. В Windows x64 первые четыре аргумента передаются через RCX, RDX, R8, R9 — соглашение другое, и при портировании кода это первое место, где возникают трудноуловимые ошибки.
Сохранение регистров при вызове
Не все регистры сохраняются автоматически при вызове функции. По System V ABI регистры RBX, RBP, R12–R15 обязан сохранить вызываемый, если собирается их использовать. Регистры RAX, RCX, RDX, RSI, RDI, R8–R11 считаются «мусорными» — их может испортить любая вызванная подпрограмма.
Если в вашем цикле обхода задействованы именно эти регистры, перед вызовом вложенной функции сохраняйте их на стек через PUSH и восстанавливайте через POP после возврата. Пропустите этот шаг — базовый адрес или счётчик окажется перезаписан, и следующий элемент прочитается из случайного места в памяти.
Возврат результата из подпрограммы
Подпрограмма возвращает одно значение через RAX (или EAX для 32-битных операций). Если результат — одиночное число: сумма, максимум, найденный элемент — просто пишете его в RAX перед RET.
Нужно вернуть несколько значений или изменённый массив? Работайте через переданный указатель: подпрограмма пишет результаты прямо в память по адресу, который ей передали в RDI. Это эффективнее, чем копировать данные — массив в памяти остаётся на месте, меняется только его содержимое.
Типичные ошибки при работе с массивами
Большинство ошибок при обработке массивов в ассемблере повторяются из проекта в проект. Знание типичных проблем экономит часы отладки.
1. Выход за границы. Счётчик дошёл до len, но вы проверяете jge после add — последний раз обращаетесь к несуществующему элементу. Всегда проверяйте условие перед телом цикла.
2. Неверный шаг. Работаете с массивом слов (dw), а шаг задаёте 4 вместо 2. Результат — читаете каждый второй реальный элемент плюс половину соседнего.
3. Загрязнение регистра. Вызвали подпрограмму внутри цикла, которая испортила регистр-счётчик или базовый адрес. Сохраняйте нужные регистры на стек перед вызовом.
4. Забытый нулевой байт. При работе со строками как байтовыми массивами не добавили завершающий ноль. Функции, ожидающие C-строку, выйдут за пределы.
5. Несовпадение размера директивы и шага. Объявили массив через dd, а обходите с шагом 2. Компилятор не предупредит — код просто работает неправильно.
6. Переполнение счётчика. Используете 8-битный регистр (AL) как счётчик для массива длиннее 255 элементов. Счётчик переполняется и цикл начинается заново.
Для отладки используйте GDB или SASM (Simple ASM IDE). Установите точку останова на начало цикла и пошагово проверяйте значения регистров и адреса памяти.
Быстрая сводка по директивам объявления и правильным шагам:
| Директива | Размер элемента | Шаг адресации | Примеры регистров |
|---|---|---|---|
| DB | 1 байт | ×1 | [rsi + rcx*1] |
| DW | 2 байта | ×2 | [rsi + rcx*2] |
| DD | 4 байта | ×4 | [rsi + rcx*4] |
| DQ | 8 байт | ×8 | [rsi + rcx*8] |
Подытожим: что важно помнить
Массив в ассемблере — это участок смежных ячеек памяти, с которым вы работаете напрямую. Нет абстракций, нет автоматических проверок. Вы сами выбираете директиву, задаёте размер элемента и следите за тем, чтобы индекс не вышел за пределы.
Объявление — через директивы DB/DW/DD/DQ для статики или через манипуляции со стеком для временных структур. Обработка данных сводится к циклу: базовый адрес в регистре, счётчик в другом, шаг равен размеру элемента. Всё остальное — вариации этой схемы.
Ошибки здесь обходятся дорого: неверный шаг или выход за границы не дают ни ошибки компилятора, ни предупреждения. Код просто работает неправильно или падает. Поэтому стоит заранее зафиксировать в комментариях: размер массива, тип элементов и максимальный допустимый индекс.
Понимание того, как строятся данные в памяти на уровне ассемблера, напрямую помогает писать более предсказуемый и быстрый код на C, C++ и Rust: именно там компилятор генерирует похожие конструкции автоматически.
Вам нужна биржа фриланса для новичков или требуются разработчики сайтов?


Комментарии