Как работает процессор: фон Нейман, регистры, ALU, fetch-decode-execute
В предыдущей статье трека мы собрали из логических вентилей сумматор и триггер и увидели,
что из AND, OR и NOT можно построить схему, которая складывает числа и запоминает
бит (Булева логика и вентили). Но сумматор
сам по себе — это не компьютер. Он умеет ровно одно и делает это, пока на входах держат
напряжение. Чтобы получить машину, которая читает программу и выполняет тысячи разных
операций в нужном порядке, между вентилями и кодом нужен ещё один слой — процессор.
Процессор — это самая недопонятая деталь всего стека. Сверху его видят как «то, что исполняет мой код», снизу — как «набор транзисторов». Между этими двумя описаниями лежит удивительно простая идея, которую в 1945 году записал Джон фон Нейман и которая до сих пор лежит в основе почти каждого чипа: и программа, и данные — это просто числа в одной памяти, а процессор в цикле достаёт очередное число-команду и выполняет его. Разберём эту идею по слоям: сначала общая схема машины, потом её части (регистры, АЛУ, устройство управления), потом как команда превращается в биты, и наконец сам пульс — цикл fetch-decode-execute. В конце честно посмотрим, где эта модель протекает, потому что настоящий Core i9 отличается от неё примерно как реактивный самолёт от бумажного.
Модель фон Неймана: одна память на всё
До фон Неймана программу часто задавали физически — перекоммутацией проводов (ENIAC «программировали» неделями, перетыкая кабели). Прорыв был концептуальный: если команды закодировать теми же числами, что и данные, и положить в ту же память, то программу можно загрузить как данные, менять на лету и хранить рядом с тем, над чем она работает. Это принцип хранимой программы, и вся современная вычислительная техника — его прямое следствие.
Из этого принципа вырастает классическая схема из пяти блоков.
- Память (RAM) — единое адресное пространство пронумерованных ячеек. В одних лежат команды, в других данные, и физически они неотличимы: и то и другое — байты. Подробно про устройство и скорость памяти — в следующей статье (Иерархия памяти).
- АЛУ (ALU) — арифметико-логическое устройство, тот самый сумматор из прошлой статьи,
доросший до вычитания,
AND/OR/XOR, сдвигов и сравнений. - Устройство управления (CU) — дирижёр: читает очередную команду, понимает, что она значит, и раздаёт остальным блокам управляющие сигналы в правильном порядке.
- Регистры — крошечная сверхбыстрая память внутри процессора, где хранятся числа, с которыми он работает прямо сейчас.
- Ввод-вывод — связь с внешним миром (клавиатура, экран, диск, сеть).
Соединяют всё это шины — пучки проводов: по шине адреса процессор говорит «дай мне ячейку номер N», по шине данных число едет туда-обратно, по шине управления идут сигналы «читаю / пишу». АЛУ и CU вместе с регистрами и образуют процессор (CPU); память и устройства — снаружи.
У единой памяти есть оборотная сторона, которую фон Нейман создал и которую его именем же и называют — бутылочное горлышко фон Неймана: команды и данные ходят по одной шине, и процессор часто простаивает, ожидая, пока из памяти приедет очередное слово. Вся иерархия кэшей (статья 06) существует именно чтобы это горлышко расширить. Альтернатива — гарвардская архитектура с раздельными памятью и шинами для команд и данных — живёт внутри микроконтроллеров и в кэшах реальных CPU (раздельные L1i и L1d), но снаружи программист почти всегда видит фон-неймановскую картину.
Регистры: рабочий стол процессора
Регистр — это несколько триггеров, хранящих слово (обычно 32 или 64 бита). Он находится буквально внутри ядра, поэтому доступ к нему занимает часть одного такта — на порядки быстрее любой памяти. Аналогия: регистры — это то, что лежит у вас в руках, RAM — шкаф в этой же комнате, диск — склад на другом конце города. Работать можно только с тем, что в руках; всё остальное надо сначала принести.
Регистров мало (в x86-64 — шестнадцать 64-битных регистров общего назначения) и они бывают двух сортов: общего назначения, куда программа кладёт любые числа, и специальные, у каждого из которых своя роль в работе самого процессора:
| Регистр | Назначение | Что было бы без него |
|---|---|---|
| PC / IP (счётчик команд) | адрес следующей команды | процессор не знал бы, что выполнять дальше |
| IR (регистр команды) | текущая команда, только что прочитанная из памяти | нечего было бы декодировать |
| SP (указатель стека) | вершина стека вызовов | не работали бы вызовы функций и локальные переменные |
| FLAGS (регистр флагов) | результаты сравнений: ноль, перенос, знак, переполнение | не было бы if и циклов |
| ACC / R0…Rn | операнды и результаты вычислений | АЛУ негде было бы взять числа |
Ключевые — PC и IR. PC хранит адрес следующей команды и после каждой команды увеличивается (или прыгает при переходе); IR держит команду, которую процессор разбирает прямо сейчас. Эти два регистра и есть материальное воплощение идеи «хранимой программы»: PC листает память как книгу, IR — открытая страница.
FLAGS заслуживает отдельного слова: именно он делает компьютер способным ветвиться.
АЛУ, вычисляя a - b, попутно выставляет флаг «ноль» (если равны) и флаг «знак» (если
результат отрицателен). Команда условного перехода потом просто смотрит на эти флаги и решает,
менять PC или нет. Весь if, весь while, вся логика вашей программы на нижнем уровне —
это «вычесть, посмотреть на флаг, при нужном значении прыгнуть».
АЛУ: где происходит арифметика
Арифметико-логическое устройство — комбинационная схема (без памяти) с двумя входами для
операндов, входом кода операции («что делать») и выходом результата плюс флагами. Внутри —
сумматор, схемы побитовых AND/OR/XOR/NOT, сдвигатель (barrel shifter) и компаратор.
Умножение и деление в простых процессорах делаются серией сложений и сдвигов; в современных —
отдельными аппаратными блоками.
Важная деталь, которая многих удивляет: вычитание, сравнение и логика ветвления — это одна
и та же операция. Процессор вычитает через сложение с дополнительным кодом
(a - b = a + (~b + 1)), а сравнение a == b — это то же вычитание, у которого нас интересует
не результат, а только флаг «ноль». Поэтому у АЛУ нет отдельной «схемы сравнения»: она
переиспользует сумматор. Как именно отрицательные числа помещаются в биты через дополнительный
код — разбирается в статье
Представление данных.
Как команда становится числами
Раз команды хранятся в памяти как числа, у каждой должна быть кодировка. Формат команды делит биты слова на поля: опкод (код операции — что делать) и операнды (над чем). Устройство управления, получив команду в IR, первым делом смотрит на опкод и по нему понимает, как читать остальные биты.
Возьмём игрушечную систему команд. Команда ADD R1, R2 («сложи R1 и R2, результат в R1»)
кодируется так: 4 бита опкода 0010, 3 бита приёмника 001 (это R1), 3 бита источника 010
(R2), остальное не используется. А LOADI R1, 7 («положи число 7 прямо в R1») — тот же
16-битный узор, но опкод 0001, и оставшиеся биты читаются уже не как номер регистра, а как
непосредственное значение (immediate) 7. Один и тот же набор нулей и единиц значит
разное — всё решает опкод.
Набор всех допустимых команд и правил их кодирования называется архитектурой системы команд (ISA) — x86-64, ARM, RISC-V. ISA — это контракт между железом и всем софтом: пока процессор честно исполняет команды по правилам ISA, ему всё равно, кто их сгенерировал — ассемблер, компилятор C или JIT виртуальной машины. Как из вашего кода на языке высокого уровня получается этот поток команд, подробно разбирает статья От кода к исполнению.
Люди не пишут опкоды в двоичном виде — для этого есть ассемблер: мнемоники ADD, LOAD,
JMP один-к-одному соответствуют машинным командам. Вот наша программа «7 + 3» на ассемблере
игрушечной машины:
LOADI R1, 7 ; R1 <- 7 (опкод 0001)
LOADI R2, 3 ; R2 <- 3
ADD R1, R2 ; R1 <- R1 + R2 = 10 (опкод 0010)
STORE R1, 0x40 ; память[0x40] <- R1 (записали результат)
HALT ; стоп
Пять строк, пять команд, пять чисел в памяти. Теперь посмотрим, как процессор их исполняет.
Цикл выборка-декодирование-исполнение
Вся работа процессора — это один цикл, повторяющийся миллиарды раз в секунду. У него три фазы, давшие название: fetch → decode → execute (выборка → декодирование → исполнение).
Разберём фазы на нашей команде ADD R1, R2 (пусть её адрес в памяти — 0x02):
- Выборка. CU выставляет содержимое PC (
0x02) на шину адреса и сигнал «чтение». Память отдаёт по шине данных 16-битное слово команды; оно попадает в IR. Сразу жеPC := PC + 1— процессор уже смотрит на следующую команду. - Декодирование. CU читает опкод из IR (
0010), понимает «это сложение регистр+регистр», и вытаскивает номера регистров: приёмник001(R1), источник010(R2). Значения R1 и R2 подаются на входы АЛУ. - Исполнение. АЛУ складывает
7 + 3 = 10, результат10записывается обратно в R1, попутно выставляются флаги (не ноль, нет переноса). Цикл начинается заново — с новым PC.
То же самое как протокол между блоками внутри процессора — кто кому что передаёт за один оборот цикла:
Обратите внимание: переход (JMP, ветвление) — это не какая-то особая магия, а просто
запись нового значения в PC. Команда JMP 0x10 в фазе исполнения кладёт 0x10 в PC — и
следующая выборка пойдёт уже оттуда. Условный переход делает то же самое, но лишь если нужный
флаг выставлен. Циклы, if, вызовы функций — всё это в конечном счёте манипуляции с PC.
Такт: пульс машины
Цикл синхронизируется тактовым генератором — кварцем, который выдаёт прямоугольные импульсы с постоянной частотой. Частота 3 ГГц означает 3 миллиарда тактов в секунду; на каждом такте по всему чипу защёлкиваются регистры и схемы делают следующий шаг. Такт нужен, чтобы сигналы успели «устояться»: комбинационная логика (тот же сумматор) даёт правильный ответ не мгновенно — сигнал распространяется через вентили за конечное время, и такт должен быть длиннее самого медленного пути. Отсюда прямая связь: короче такт (выше частота) — быстрее работа, но меньше времени на устаканивание, больше тепла и жёстче предел. Именно поэтому гонка гигагерц остановилась около 4–5 ГГц ещё в середине 2000-х, и производители стали наращивать не частоту, а число ядер — к этому мы вернёмся.
Наивно кажется, что одна команда = один такт. В простых процессорах это близко к правде, но уже здесь видно, что одна команда требует нескольких шагов (выборка, декодирование, несколько под-шагов исполнения), а значит — нескольких тактов. Как процессоры научились «прятать» эти такты, показывает следующий раздел.
Где эта модель протекает
Всё описанное — честная и работающая модель, по которой построены микроконтроллеры и по которой стоит думать о процессоре. Но CPU в вашем ноутбуке отличается от неё радикально, и знать об этих отличиях важно: они объясняют, почему одинаковый по числу операций код может работать в разы медленнее, и откуда взялись целые классы уязвимостей.
Конвейер (pipeline)
Пока команда исполняется, устройство выборки простаивает — и наоборот. Расточительство. Решение позаимствовано у конвейера Форда: разбить обработку команды на стадии и запустить их внахлёст, чтобы на каждой стадии одновременно находилась своя команда.
Классический RISC-конвейер имеет 5 стадий (IF, ID, EX, MEM, WB); каждая команда всё так же
проходит их за 5 тактов, но в установившемся режиме на каждом такте завершается одна
команда — пятикратный рост пропускной способности без роста частоты. Плата — сложность:
если команда зависит от результата предыдущей, которого ещё нет (hazard), конвейер
приходится тормозить. А главная беда — ветвления: пока не вычислен условный переход,
неизвестно, какую команду выбирать следующей. Отсюда предсказание переходов (branch
prediction): процессор угадывает исход if и начинает исполнять предполагаемую ветку
заранее (спекулятивно). Угадал — выигрыш; ошибся — конвейер сбрасывается, десятки тактов
впустую. Вот почему предсказуемые ветки (отсортированные данные) исполняются быстрее
непредсказуемых при том же числе операций.
Суперскалярность и внеочередное исполнение
Дальше — больше. Суперскалярный процессор имеет несколько АЛУ и завершает по несколько команд за такт. Внеочередное исполнение (out-of-order) позволяет, наткнувшись на команду, ждущую данные из памяти, не стоять, а выполнить следующие независимые команды, пока данные едут, — а результаты потом «собрать» в правильном порядке. Снаружи ISA-контракт соблюдён (программа видит последовательное исполнение), внутри — управляемый хаос из сотен команд «в полёте».
Именно спекулятивное и внеочередное исполнение породили в 2018 году Spectre и Meltdown: процессор спекулятивно читал данные, к которым у программы не было прав, отбрасывал результат при откате — но следы оставались в кэше и вычитывались по таймингам. Абстракция «команды исполняются строго по одной и по порядку» протекла на уровне безопасности целой индустрии (подробнее об этом классе атак — в статье Основы безопасности).
Кэши и несколько ядер
Горлышко фон Неймана никуда не делось: обращение в RAM — это сотни тактов, вечность для процессора на 3 ГГц. Между ядром и памятью стоят несколько уровней кэша (L1/L2/L3), и реальная скорость программы часто определяется не числом команд, а тем, попадают ли данные в кэш. Это тема следующей статьи. А поскольку частоту дальше не поднять, производительность растёт вширь — несколькими ядрами, каждое из которых крутит свой цикл fetch-decode-execute. Заставить их работать вместе, не мешая друг другу, — отдельная и трудная задача (Многозадачность и параллелизм). А переключением между программами на одном ядре и обработкой прерываний от устройств заведует операционная система (Что делает ОС): само прерывание на уровне железа — это сигнал, который заставляет процессор бросить текущий цикл, сохранить PC и прыгнуть на код-обработчик.
Немного истории — как усложнялась одна идея
Сквозь все эти слои сложности неизменной остаётся суть 1945 года: достать команду по PC, разобрать, выполнить, повторить. Конвейеры, спекуляция и кэши — это способы делать этот цикл быстрее, не меняя его смысла. Понимаете базовый цикл — понимаете, что оптимизируют все остальные ухищрения.
Типичные заблуждения
- «Процессор понимает мой код на Python/Java». Нет. Он понимает только машинные команды своей ISA. Всё остальное — работа компиляторов и интерпретаторов (статья 07).
- «Больше гигагерц = быстрее». Только при прочих равных. Число команд за такт (IPC), кэши, число ядер и предсказуемость кода часто важнее частоты; 3-гигагерцевый чип легко обгоняет 4-гигагерцевый.
- «Одна команда — один такт». Уже в простой модели команда занимает несколько тактов; конвейер лишь создаёт иллюзию «команда за такт» на пропускной способности.
- «Регистров можно нарожать сколько угодно». Их мало и это часть ISA: адрес регистра кодируется в команде, больше регистров — длиннее команды, дороже декодер. Компилятор ведёт борьбу за то, чтобы горячие переменные жили в регистрах, а не в памяти.
Мини-итог
Процессор — это машина, реализующая одну простую идею фон Неймана: программа и данные лежат
числами в общей памяти, а специальный регистр PC указывает на следующую команду. Внутри —
регистры (быстрый рабочий стол), АЛУ (арифметика и логика через один сумматор) и устройство
управления (дирижёр). Работа сводится к бесконечному циклу выборка → декодирование →
исполнение, синхронизированному тактовым генератором; ветвления, циклы и вызовы — это
манипуляции с PC, а if держится на флагах АЛУ. Реальные процессоры прячут за этой моделью
конвейеры, спекуляцию, внеочередное исполнение, кэши и множество ядер — и знать, где эта
абстракция протекает, полезно и для производительности, и для безопасности. Но ядро всего
стека — тот самый трёхтактный цикл, к которому в конце концов сводится любая программа.
Что дальше
Мы несколько раз упёрлись в то, что процессор быстр, а память медленна, и что между ними стоят кэши. Следующая статья — про это устройство целиком: почему память образует иерархию от регистров до диска, и как знание этой иерархии радикально ускоряет код.
Иерархия памяти: регистры, кэш, RAM, диск и почему это важно