Базовый CS с нуля
Идея ассемблера
В предыдущем разделе ты узнал, что CPU выполняет машинный код: необработанные битовые паттерны, хранящиеся в памяти. Ты даже вручную декодировал 16-битную инструкцию — разбивая её на биты опкода, биты регистра и непосредственное значение. Это сработало, но было медленно и мучительно. Представь, что нужно написать программу из тысяч инструкций, каждая из которых — строка нулей и единиц, без меток, без имён, без указаний на то, что делает каждая последовательность. Один неверный бит незаметно даёт неправильный опкод. Завтра это не прочесть.
Именно с такой ситуацией столкнулись программисты в конце 1940-х годов. Их решение было простым: дать каждой инструкции короткое, запоминающееся сокращение — мнемонику — и написать программу, которая преобразует эти сокращения в нужные CPU битовые паттерны. Эта программа-конвертер называется ассемблером, а её входной язык — языком ассемблера. Идея ассемблера — самая первая ступенька на лестнице от голого железа до программ, которые ты пишешь сегодня.
После этого урока ты сможешь объяснить, что такое мнемоника, описать связь один-к-одному между инструкциями ассемблера и машинными инструкциями, определить, что делает программа- ассемблер, и объяснить, как метки позволяют писать инструкции перехода без ручного вычисления адресов в памяти.
Мнемоники: именование битовых паттернов. Слово мнемоника означает средство запоминания — короткое, человекочитаемое имя, которое легче запомнить, чем то сырое значение, которое оно обозначает. В языке ассемблера каждому опкоду инструкции присваивается мнемоника, говорящая о том, что делает инструкция:
| Мнемоника | Что делает |
|---|---|
LOAD | Читает значение из памяти в регистр |
STORE | Записывает значение регистра в память |
ADD | Складывает значения двух регистров |
SUB | Вычитает значение одного регистра из другого |
JUMP | Устанавливает счётчик команд на новый адрес |
HALT | Останавливает CPU |
Ты пишешь мнемонику в исходном файле ассемблера как текст. Ассемблер находит её в таблице и заменяет соответствующим битовым паттерном. Больше ничего не меняется — битовый паттерн это именно тот опкод, который CPU в любом случае потребовал бы. Мнемоника — просто человекочитаемое имя для числа.
Соответствие один к одному. Ключевой факт о языке ассемблера: каждая инструкция ассемблера отображается на ровно одну машинную инструкцию, а каждая машинная инструкция может быть записана ровно как одна инструкция ассемблера. Никакого сжатия, никакого объединения нескольких инструкций в одну, никакого расширения одной инструкции во многие. Ассемблер просто подставляет: текст мнемоники → битовый паттерн, имя регистра → номер регистра, десятичная константа → двоичные биты.
Это принципиальное отличие ассемблера от всех других языков, с которыми ты встретишься.
Когда ты пишешь x = a + b на Python или TypeScript, среда выполнения или компилятор может
породить десятки машинных инструкций для этой одной строки. Когда ты пишешь ADD R0, R1
на ассемблере, ты получаешь ровно одну машинную инструкцию — опкод ADD и закодированные
номера регистров.
Ассемблер — это просто машинный код, написанный читаемыми именами вместо сырых битов.
Что ассемблер на самом деле делает. Ассемблер — это сама по себе программа. Исторически одна из первых когда-либо написанных программ: как только появился один ассемблер, его можно было использовать для написания следующего. Его задача — прочитать исходный файл на ассемблере и создать бинарный файл с машинным кодом. Процесс состоит из двух частей:
Часть 1 — Трансляция. Для каждой инструкции ассемблера программа-ассемблер:
- Читает мнемонику (например,
LOAD). - Находит её в таблице и получает биты опкода (например,
00для LOAD в нашем игрушечном CPU). - Читает каждый операнд (имя регистра или константу) и преобразует его в битовое представление.
- Объединяет эти битовые поля в полную двоичную инструкцию.
- Записывает двоичные байты в выходной файл.
Часть 2 — Разрешение символов. Практические программы на ассемблере используют
метки — именованные маркеры адресов памяти. Вместо JUMP 84 ты пишешь JUMP loop_start,
где loop_start — метка, размещённая перед инструкцией, к которой нужно перейти. Ассемблер
записывает адрес каждой метки в таблицу символов, а затем заменяет каждую ссылку на
метку соответствующим числовым адресом. Тебе не нужно считать адреса вручную — ассемблер
считает их за тебя.
Обе части выполняются за один или два прохода по исходному файлу. Результат — бинарный файл, готовый для выполнения на CPU.
Почему это работает
Почему ассемблер появился так рано, раньше любого другого инструмента высокого уровня? Потому что сам ассемблер — очень маленькая и прямолинейная программа. Трансляция чисто механическая: заменить этот текст тем битовым паттерном. Никакого сложного анализа, никакой оптимизации. Это означало, что программисты в конце 1940-х и начале 1950-х годов могли написать первый ассемблер вручную в машинном коде (мучительно, но один раз), а затем сразу использовать его для написания всего остального на ассемблере. Это был тот самый бутстрап, который сделал возможным создание всех дальнейших инструментов.
Ассемблер зависит от конкретного CPU. Поскольку мнемоники ассемблера — просто имена для опкодов конкретного CPU, каждое семейство CPU имеет свой собственный язык ассемблера. Мнемоники для процессоров x86-64 (используемых в большинстве настольных компьютеров и ноутбуков) отличаются от мнемоник для процессоров ARM (используемых в телефонах и Mac с Apple Silicon), потому что у этих CPU разные системы команд с разными битовыми кодировками.
Программу на ассемблере, написанную для x86-64, нельзя транслировать и запустить на CPU ARM без переписывания — общего словаря не существует. Ассемблер находится ровно на один тонкий уровень выше железа, и этот уровень так же специфичен для CPU, как и сам машинный код.
Именно эта зависимость от CPU и является мотивацией для следующего шага: языков высокого уровня, которые переносимы между семействами CPU. Но об этом — в следующем уроке.
Граничные случаи
Псевдоинструкции и директивы ассемблера. Некоторые ассемблеры добавляют небольшой слой
удобства поверх обычной трансляции: псевдоинструкции — мнемоники ассемблера, которые не
отображаются на одну реальную инструкцию, а раскрываются в короткую последовательность
реальных инструкций. Например, в MIPS-ассемблере MOVE Rd, Rs — псевдоинструкция,
раскрывающаяся в ADDU Rd, Rs, R0 (прибавление нуля для копирования значения; используется
ADDU, а не ADD, чтобы избежать ловушки переполнения). Директивы
ассемблера — команды для самой программы-ассемблера (а не для CPU): .data отмечает
начало секции данных, .byte 42 резервирует один байт со значением 42, .global main
экспортирует метку для компоновщика. Директивы не производят машинный код напрямую; они
управляют тем, как ассемблер компонует бинарный файл.
Пошаговая трансляция ассемблером программы из четырёх инструкций.
Вот небольшая программа на ассемблере, которая загружает два числа из памяти, складывает их и сохраняет результат. Используется тот же игрушечный CPU из предыдущего раздела (2-байтовые инструкции, 4 опкода).
Исходный код на ассемблере:
LOAD R0, 200 ; загрузить первое число в R0
LOAD R1, 201 ; загрузить второе число в R1
ADD R0, R1 ; R0 = R0 + R1
STORE 202, R0 ; сохранить результат по адресу 202Ассемблер обрабатывает каждую строку:
| Строка | Мнемоника | Биты опкода (7–6) | Бит рег. (5) | Байт операнда | Двоичная инструкция |
|---|---|---|---|---|---|
| 1 | LOAD R0, 200 | 00 | 0 (R0) | 11001000 (=200) | 00000000 11001000 |
| 2 | LOAD R1, 201 | 00 | 1 (R1) | 11001001 (=201) | 00100000 11001001 |
| 3 | ADD R0, R1 | 10 | 0 | 00000000 (не используется) | 10000000 00000000 |
| 4 | STORE 202, R0 | 01 | 0 (R0) | 11001010 (=202) | 01000000 11001010 |
Ассемблер записывает 8 байтов в выходной бинарный файл:
0x00 0xC8 0x20 0xC9 0x80 0x00 0x40 0xCA
Это именно те байты, которые раньше пришлось бы вычислять вручную. Ассемблер вычислил их за миллисекунды из человекочитаемого исходного текста.
Обрати внимание: четыре строки ассемблера породили четыре машинные инструкции — один к одному.
Язык ассемблера имеет соответствие один к одному с машинным кодом. Сколько машинных инструкций производит одна инструкция ассемблера? Введи число.
Ассемблер преобразует текст мнемоники ADD в соответствующий битовый паттерн опкода. Видит ли CPU когда-либо текст 'ADD'? Введи 1 (да) или 0 (нет).
Метка в ассемблере — это именованный маркер адреса в памяти. Что ассемблер хранит в таблице символов для каждой метки? Введи 1 (адрес помеченного места) или 2 (текст мнемоники инструкции по этому адресу).
Можно ли транслировать программу на ассемблере x86-64 и запустить её напрямую на CPU ARM без каких-либо изменений? Введи 1 (да) или 0 (нет).
В разобранном примере программа на ассемблере из четырёх инструкций породила сколько байтов в выходном бинарном файле? (Каждая инструкция занимает 2 байта.)
Какова связь между мнемоникой языка ассемблера и машинной инструкцией?
Язык ассемблера — это текстовое представление машинного кода, в котором каждая
инструкция CPU записывается как короткая человекочитаемая мнемоника (например, LOAD,
ADD или JUMP), а не как сырые биты. Связь — один к одному: каждая инструкция
ассемблера переводится ровно в одну машинную инструкцию, и наоборот. Программа, выполняющая
эту трансляцию, называется ассемблером: она читает исходный текст ассемблера, находит
в таблице соответствующие биты опкода для каждой мнемоники, преобразует имена регистров и
числовые константы в их двоичные формы, разрешает метки (именованные маркеры адресов)
через таблицу символов и записывает полученные байты в выходной бинарный файл. Ассемблер
зависит от конкретного CPU — каждое семейство CPU имеет свой язык ассемблера со своими
мнемониками, — потому что мнемоники — просто имена для битовых паттернов опкодов этого CPU.
Ассемблер был первым инструментом, сделавшим программирование терпимо продуктивным, и
по-прежнему остаётся тончайшим возможным слоем над голым железом.