Ассемблер

Предпосылки: Что такое программирование (программа, процессор, память, ввод/вывод).

Что такое программирование | Переменные и типы

Процессор понимает только числа. Инструкция «сложи два числа» для него — последовательность чисел вроде 0010 0001. Первые программисты записывали именно такие коды, сверяясь с таблицами: «код 0010 означает сложение, 0001 — номер регистра». На программе из десяти строк это терпимо. На программе из ста — мучительно: одна перепутанная цифра, и программа делает не то, а найти ошибку в стене чисел почти невозможно.

Люди решили: пусть каждая числовая инструкция получит короткое читаемое имя. 0010 стало ADD, 0001 стало MOV, переход к другой инструкции — JMP (от английского jump — прыжок). Набор таких имён и правил называется языком ассемблера, а ассемблер (assembler) переводит этот текст обратно в числа для процессора. На следующем уровне абстракции этот подход развилось в идею переменных: дать имя не машинной команде, а хранилищу для данных.

Название assembler приходит от assemble — «собирать»: этот инструмент собирает понятный человеку текст в машинный код.

Как устроена машина на уровне ассемблера

В предыдущей заметке мы описали компьютер тремя частями: процессор, память, ввод/вывод. Для ассемблера нужны две детали, которые мы тогда опустили.

Адреса. Память — не просто «место для данных», а пронумерованный ряд ячеек. У каждой ячейки есть номер — адрес. Чтобы прочитать или записать данные, процессор указывает адрес: «возьми число из ячейки 200» или «положи результат в ячейку 208». В ассемблере программист работает с этими адресами напрямую.

В этой заметке для удобства примем: одно целое значение занимает 4 байта. Тогда между соседними элементами массива будет разница 4 (200 204).

Почему память считают байтами и почему размер числа в других случаях может отличаться, см. двоичная система и байты и целые числа.

Регистры. Процессор не может складывать числа прямо в памяти — он сначала копирует их к себе, во внутреннее хранилище. Это хранилище состоит из нескольких ячеек, которые называются регистрами. Регистров мало (в простых процессорах — от 4 до 16), зато доступ к ним мгновенный. Типичные имена: EAX, EBX, ECX, EDX (E — Extended, расширение до 32 бит; A — Accumulator, B — Base, C — Counter, D — Data — исторические роли, закрепившиеся в названиях).

Память                     Процессор
(тысячи ячеек)             (несколько регистров)
 
[200] = 1500               EAX = ?
[204] = 300                EBX = ?
[208] = ?                  ECX = ?
...                        EDX = ?

Программа на ассемблере — это последовательность команд, каждая из которых говорит процессору: откуда взять данные, что с ними сделать, куда положить результат.

Пять команд, из которых строится всё

Ассемблер конкретного процессора может содержать сотни команд, но почти любая программа строится из нескольких базовых:

MOV (move — переместить) — копирует число из одного места в другое. Из памяти в регистр, из регистра в память, из регистра в регистр:

MOV EAX, [200]    ; скопировать число из ячейки 200 в регистр EAX
MOV [208], EAX    ; скопировать число из EAX в ячейку 208

ADD (add — сложить) — прибавляет число к содержимому регистра:

ADD EAX, EBX      ; EAX = EAX + EBX

SUB (subtract — вычесть) — обратная операция: вычитает число из регистра:

SUB ECX, 1        ; ECX = ECX - 1

CMP (compare — сравнить) — сравнивает два числа и запоминает результат (больше, меньше, равно). Само по себе ничего не делает — только готовит информацию для следующей команды.

JMP (jump — прыгнуть) — перейти к другой инструкции вместо следующей по порядку. Процессор обычно выполняет команды одну за другой, сверху вниз. JMP ломает этот порядок: он говорит «перейди вот туда». Куда именно — указывает метка: имя с двоеточием, которое программист ставит перед нужной строкой. Например, start: — это метка, а JMP start — команда «прыгни к строке с меткой start».

Существуют условные варианты: JNE (jump if not equal — прыгнуть, если не равно), JL (jump if less — если меньше), JE (jump if equal — если равно). Они прыгают только когда выполнено условие, заданное предыдущей командой CMP. Именно через них программа принимает решения и повторяет действия: CMP сравнивает два числа, а условный прыжок решает, что делать дальше.

Программа: сложить пять чисел

Задача: в памяти лежат пять чисел (по адресам 200, 204, 208, 212, 216). Нужно сложить их и записать результат в ячейку 220.

 1  MOV EAX, 0        ; EAX = 0 (здесь будем накапливать сумму)
 2  MOV EBX, 200      ; EBX = 200 (адрес первого числа)
 3  MOV ECX, 5        ; ECX = 5 (сколько чисел осталось)
 4
 5  loop:
 6    ADD EAX, [EBX]  ; прибавить число по адресу EBX к сумме
 7    ADD EBX, 4      ; сдвинуть адрес на 4 байта (в этой модели размер числа)
 8    SUB ECX, 1      ; уменьшить счётчик на 1
 9    CMP ECX, 0      ; счётчик дошёл до нуля?
10    JNE loop        ; если нет — вернуться к строке 5
11
12  MOV [220], EAX    ; записать сумму в ячейку 220

Двенадцать строк. Задача тривиальная — сложить пять чисел. Но программист должен:

  • помнить, что EAX — сумма, EBX — текущий адрес, ECX — счётчик;
  • вручную считать адреса (200, 204, 208…);
  • следить, что JNE прыгает именно на loop, а не на другую строку;
    • знать, что в этой простой модели одно целое значение занимает 4 байта, поэтому адрес сдвигается на 4.

Почему это больно

Ассемблер решил одну проблему: вместо чисел программист пишет читаемые имена. MOV EAX, [200] понятнее, чем 0010 0001 0000 1100 1000. Но все остальные проблемы остались.

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

Регистров мало, а задач много. Четыре регистра при сложении пяти чисел — терпимо. Но при расчёте зарплаты (оклад, налог, бонус, стаж, надбавка) регистров не хватает, приходится постоянно сохранять значения в память и загружать обратно. Программист должен помнить, что в какой ячейке лежит.

Ошибки невидимы. Процессор не знает, что ячейка 200 хранит зарплату, а ячейка 204 — налоговую ставку. Если программист перепутает адреса — программа молча посчитает мусор. Никакого сообщения об ошибке не будет.

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

Люди задались вопросом: что, если машина сама будет следить за адресами, а программист будет работать с именами? Вместо MOV EAX, [200] написать salary = 1500 — и пусть специальная программа сама решит, в какую ячейку положить число и какой регистр использовать. Это привело к идее переменных.

Sources

Реальные наборы команд процессоров (ARM, x86) и почему у разных архитектур (наборов инструкций конкретного процессора) разный ассемблер — в ISA.

  • Petzold, C., 2000, Code: The Hidden Language of Computer Hardware and Software. Microsoft Press.
  • Hyde, R., 2010, The Art of Assembly Language. No Starch Press.

Что такое программирование | Переменные и типы