Базовый CS с нуля
Игрушечный CPU
Ты изучил инструкции, цикл выборки–декодирования–исполнения, регистры и машинный код как отдельные идеи. Пришло время собрать их вместе и посмотреть, как они работают как единая система.
В этом уроке ты построишь мысленную модель полного — пусть и крошечного — CPU. У него два регистра, система из четырёх инструкций и небольшая память. Затем ты проследишь через него настоящую программу: загрузить два числа из памяти, сложить их, сохранить результат. Каждый шаг выборки, декодирования и исполнения показан явно. К концу CPU перестанет быть чёрным ящиком. Это будет машина, которую можно запустить в уме.
После этого урока ты сможешь описать компоненты минимального CPU (регистры, СК, память, АЛУ), читать и писать программы на игрушечной системе команд и пошагово трассировать цикл выборки–декодирования–исполнения для небольшой полной программы.
Компоненты игрушечного CPU. Наш игрушечный CPU содержит следующее:
- Два регистра общего назначения: R0 и R1. Каждый хранит одно 8-битное значение (0–255).
- Счётчик команд (СК): хранит адрес памяти следующей инструкции для выборки. Тоже 8-битный, поэтому может адресовать ячейки памяти 0–255.
- Память: 256 ячеек, каждая хранит одно 8-битное значение. Адреса 0–7 будут хранить нашу программу (инструкции). Адреса 200, 201 и 202 — данные (входные числа и результат).
- Арифметико-логическое устройство (АЛУ): схема, которая складывает два 8-битных значения и сохраняет результат в регистре.
Игрушечный CPU не умеет много — он создан, чтобы продемонстрировать основы, а не быть практичным. Реальные CPU имеют 16–31 регистр общего назначения, могут адресовать гигабайты памяти и системы команд с сотнями опкодов. Но принципы те же.
Игрушечная система команд. Наш CPU понимает ровно четыре инструкции. Каждая инструкция занимает ровно два байта: байт инструкции и байт операнда. СК продвигается на 2 после каждой не-JUMP инструкции.
Структура байта инструкции (байт 0):
| Биты 7–6 | Бит 5 | Биты 4–0 | Смысл |
|---|---|---|---|
| опкод | регистр | 0 (не используется) | опкод выбирает операцию; бит 5 выбирает регистр (0 = R0, 1 = R1) |
Байт операнда (байт 1): 8-битное значение (0–255) — адрес памяти для LOAD/STORE/JUMP, или 0x00 (не используется) для ADD.
Четыре опкода:
| Опкод (биты 7–6) | Бит 5 | Байт 1 | Название | Смысл |
|---|---|---|---|---|
00 | рег | адрес (0–255) | LOAD Ra, addr | Загрузить память[addr] в Ra |
01 | рег | адрес (0–255) | STORE addr, Ra | Сохранить Ra в память[addr] |
10 | 0 | 0x00 (не используется) | ADD R0, R1 | R0 ← R0 + R1. Бит 5 и байт 1 игнорируются. |
11 | 0 | адрес (0–255) | JUMP addr | Установить СК на addr. Бит 5 игнорируется. |
Полный разбор на уровне битов для четырёх инструкций нашей программы:
LOAD R0, 200:
Байт 0 = 00_0_00000 = 0x00 (опкод 00 = LOAD, бит 5 = 0 → R0)
Байт 1 = 11001000 = 0xC8 (десятичное 200 — адрес памяти)
LOAD R1, 201:
Байт 0 = 00_1_00000 = 0x20 (опкод 00 = LOAD, бит 5 = 1 → R1)
Байт 1 = 11001001 = 0xC9 (десятичное 201)
ADD R0, R1:
Байт 0 = 10_0_00000 = 0x80 (опкод 10 = ADD; бит 5 и байт 1 не используются)
Байт 1 = 00000000 = 0x00 (не используется, всегда 0)
STORE 202, R0:
Байт 0 = 01_0_00000 = 0x40 (опкод 01 = STORE, бит 5 = 0 → R0)
Байт 1 = 11001010 = 0xCA (десятичное 202 — адрес назначения)Поскольку байт 1 — это полное 8-битное поле, он может адресовать любую из 256 ячеек памяти — в том числе ячейки 200, 201 и 202. Вся наша программа укладывается в этот диапазон.
Почему это работает
Почему только четыре инструкции? В реальных системах команд сотни опкодов. Но для демонстрации всего существенного достаточно горстки: перемещение данных (LOAD/STORE), вычисление (ADD) и управление потоком (JUMP). Каждая более сложная инструкция — это комбинация или расширение этих идей. Курс nand2tetris использует столь же минимальную систему команд (называемую «Hack») для построения полностью рабочего компьютера с нуля; весь язык умещается в 16 бит.
Начальное состояние памяти. Перед запуском программы ОС загружает байты инструкций в память, начиная с адреса 0, и помещает входные данные по адресам 200 и 201. СК устанавливается на 0.
Каждая инструкция занимает 2 байта, поэтому четыре инструкции занимают адреса 0–7:
Адрес Содержимое Смысл
0 0x00 LOAD R0, 200 — байт 0 (байт инструкции)
1 0xC8 ← байт 1 (операнд: адрес 200)
2 0x20 LOAD R1, 201 — байт 0
3 0xC9 ← байт 1 (операнд: адрес 201)
4 0x80 ADD R0, R1 — байт 0
5 0x00 ← байт 1 (не используется)
6 0x40 STORE 202, R0 — байт 0
7 0xCA ← байт 1 (операнд: адрес 202)
...
200 25 ← первое входное число
201 17 ← второе входное число
202 0 ← слот для результата (пусто)Регистры до выполнения: R0 = 0, R1 = 0, СК = 0.
Для читаемости трассировка ниже обозначает каждую инструкцию мнемоникой, а не парой сырых байтов.
Счётчик команд управляет всем. Прежде чем подробно трассировать каждый цикл, обрати внимание на ключевую роль СК:
- СК начинается с 0 — значит, первая выборка начинается по адресу 0.
- После каждой не-JUMP инструкции СК увеличивается на 2 (поскольку каждая инструкция в нашем игрушечном CPU занимает 2 байта).
- Если программа содержала бы JUMP, СК устанавливался бы на адрес перехода вместо этого.
СК — это курсор, проходящий через последовательность инструкций. Именно он — единственная причина, по которой CPU знает, какая инструкция «следующая».
Полная трассировка программы из четырёх инструкций.
Отслеживаем: СК, R0, R1 и содержимое адреса памяти 202 после каждого цикла.
Цикл 1
Состояние до: СК = 0, R0 = 0, R1 = 0, память[202] = 0.
- Выборка: читаем 2 байта начиная с СК = 0. Байты =
0x00 0xC8. Инструкция =LOAD R0, 200. - Декодирование: биты 7–6 байта 0 =
00→ LOAD. Бит 5 = 0 → регистр-назначение R0. Байт 1 = 0xC8 = 200 → поле адреса = 200. - Исполнение: читаем память[200]. Результат = 25. Записываем 25 в R0.
- Продвижение СК: СК = 0 + 2 = 2.
Состояние после: СК = 2, R0 = 25, R1 = 0, память[202] = 0.
Цикл 2
Состояние до: СК = 2, R0 = 25, R1 = 0, память[202] = 0.
- Выборка: читаем 2 байта начиная с СК = 2. Байты =
0x20 0xC9. Инструкция =LOAD R1, 201. - Декодирование: биты 7–6 байта 0 =
00→ LOAD. Бит 5 = 1 → регистр-назначение R1. Байт 1 = 0xC9 = 201 → поле адреса = 201. - Исполнение: читаем память[201]. Результат = 17. Записываем 17 в R1.
- Продвижение СК: СК = 2 + 2 = 4.
Состояние после: СК = 4, R0 = 25, R1 = 17, память[202] = 0.
Цикл 3
Состояние до: СК = 4, R0 = 25, R1 = 17, память[202] = 0.
- Выборка: читаем 2 байта начиная с СК = 4. Байты =
0x80 0x00. Инструкция =ADD R0, R1. - Декодирование: биты 7–6 байта 0 =
10→ ADD. Операнда-адреса нет. Источники = R0, R1. Назначение = R0. - Исполнение: АЛУ вычисляет R0 + R1 = 25 + 17 = 42. Записываем 42 в R0.
- Продвижение СК: СК = 4 + 2 = 6.
Состояние после: СК = 6, R0 = 42, R1 = 17, память[202] = 0.
Цикл 4
Состояние до: СК = 6, R0 = 42, R1 = 17, память[202] = 0.
- Выборка: читаем 2 байта начиная с СК = 6. Байты =
0x40 0xCA. Инструкция =STORE 202, R0. - Декодирование: биты 7–6 байта 0 =
01→ STORE. Бит 5 = 0 → регистр-источник R0. Байт 1 = 0xCA = 202 → адрес-назначение = 202. - Исполнение: записываем значение R0 (42) в память[202].
- Продвижение СК: СК = 6 + 2 = 8.
Состояние после: СК = 8, R0 = 42, R1 = 17, память[202] = 42.
Программа завершена. Адрес памяти 202 теперь хранит 42 — сумму двух входных чисел (25 + 17). CPU попытается выбрать инструкцию по адресу 8, но нашей программе не нужна пятая инструкция, поэтому в реальной системе ОС завершила бы процесс до достижения адреса 8.
Всё вычисление потребовало ровно четыре цикла выборки–декодирования–исполнения. Каждый цикл имел одну и ту же трёхшаговую структуру. CPU никогда не нужно было «понимать», что именно он вычисляет — он просто выполнял свой цикл.
Граничные случаи
Что происходит, когда СК выходит за конец программы? В нашем примере СК достигнет адреса 8 и попытается выбрать байты, которые там находятся, — мусор или, возможно, код другой программы. Реальные ОС предотвращают это: двоичный файл программы содержит метаданные, сообщающие ОС, где заканчивается программа (или есть явная инструкция HALT, или системный вызов для завершения). ОС настраивает защиту памяти так, чтобы программа не могла выполняться за пределами своей области. На практике последняя инструкция почти каждой программы — вызов системного вызова «exit», который корректно завершает процесс.
В нашем игрушечном CPU каждая инструкция занимает 2 байта. После выполнения инструкции, начинающейся по адресу СК = 4 (не JUMP), каким становится СК?
До цикла 3 в нашей трассировке R0 = 25, R1 = 17. После выполнения ADD R0, R1 что хранит R0?
После цикла 4 (STORE 202, R0) в нашей трассировке что хранит адрес памяти 202?
Сколько циклов выборки–декодирования–исполнения потребовалось нашей программе из 4 инструкций для вычисления 25 + 17 и сохранения результата?
В начале цикла 1 СК = 0. После завершения всех 4 циклов СК = ?
В трассировке игрушечного CPU какова роль инструкции ADD по сравнению с инструкциями LOAD?
Минимальный CPU требует: регистров (быстрое рабочее хранилище — R0, R1), счётчика команд (регистра, хранящего адрес следующей инструкции), памяти (байтовых ячеек для инструкций и данных) и АЛУ (схемы, вычисляющей арифметику). Поведение CPU полностью описывается циклом выборки–декодирования–исполнения: выбрать байты инструкции по СК, декодировать опкод и операнды, исполнить соответствующее действие, продвинуть СК. В нашей игрушечной системе команд каждая инструкция занимает 2 байта — байт инструкции (опкод + выбор регистра) и байт операнда (8-битный адрес или значение) — поэтому СК продвигается на 2 за цикл и все 256 адресов памяти достижимы. Программа из четырёх инструкций (LOAD, LOAD, ADD, STORE) выполняется за четыре цикла и вычисляет сумму без того, чтобы CPU когда-либо «знал», что он складывает — он просто выполнял свой цикл. Каждая более крупная программа — какой бы сложной она ни была — это тот же цикл, работающий дольше, на большем количестве инструкций, с большим регистровым файлом и большей памятью. Механизм никогда не меняется.