7. Цикл исполнения инструкции. Fetch, Decode, Execute

В предыдущей главе 6. Анатомия CPU. Datapath, Control Unit и Register File мы изучили устройство центрального процессора в статике: познакомились с регистровым файлом, вычислительным трактом данных (Datapath) и дирижером оркестра — устройством управления (Control Unit).

Теперь пришло время вдохнуть в этот кремниевый кристалл жизнь и посмотреть на процессор в динамике.

С момента нажатия кнопки питания на сервере и до выключения питания процессор занят ровно одним делом. Он выполняет один и тот же монотонный, математически строгий алгоритм, повторяющийся такт за тактом со скоростью несколько миллиардов раз в секунду:
$$\text{Fetch (Выборка)} \longrightarrow \text{Decode (Декодирование)} \longrightarrow \text{Execute (Выполнение)}$$

Этот неумолимый кремниевый пульс называется циклом исполнения инструкции (Instruction Execution Cycle). Давайте разберем каждый его этап под микроскопом, заглянем в физику задержек и выясним, как высокоуровневый рантайм Go умудряется взаимодействовать с этим аппаратным циклом на лету.


1. Fetch (Выборка инструкции)

Процессор не обладает человеческим сознанием и не может «охватить взглядом» вашу программу на Go целиком. Для него не существует ни пакетов, ни структур, ни модулей. В каждый отдельный миг времени кругозор ядра сужен до одной-единственной машинной команды, на которую указывает его главный ориентир.

В архитектуре x86-64 этот ориентир хранится в регистре RIP (64-bit Instruction Pointer), а в ARM64 — в регистре PC (Program Counter).

flowchart LR
    RIP["Счетчик команд (RIP)"] --> Bus["Шина адреса"]
    Bus --> L1i["Кэш инструкций L1i<br>(~1 ns, SRAM)"]
    L1i -- "Промах (Miss)" --> L2["Кэш L2 / L3"]
    L2 -- "Промах (Miss)" --> RAM["Оперативная память (RAM)<br>(~60 ns, DRAM)"]

    L1i -- "Попадание (Hit)" --> IR["Instruction Register (IR)<br>Сырые байты команды"]

    RIP -.->|"Аппаратный инкремент (+ len)"| RIP_Next["Следующий RIP"]

    classDef fast fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc;
    classDef slow fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
    classDef core fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
    class L1i,IR fast;
    class L2,RAM slow;
    class RIP,Bus,RIP_Next core;
  

На фазе Fetch происходит следующая цепочка физических событий:

  1. Control Unit выставляет число из регистра RIP на шину адреса памяти.
  2. Процессор пытается прочитать инструкцию. Первым делом запрос попадает в выделенный аппаратный кэш инструкций первого уровня (L1i). Он построен на сверхбыстрых SRAM-триггерах и отдает данные за несколько тактов CPU (обычно 3–5, около 1 нс; в конвейере эта задержка частично скрывается).
  3. Прочитанные байты машинного кода (например, шестнадцатеричная тройка 48 01 D8) защелкиваются во внутреннем буфере ядра — Instruction Register (IR) (так устроена учебная модель; в реальных x86-ядрах отдельного архитектурного регистра IR нет, а байты проходят через буфер выборки и декодер).
  4. Аппаратный сумматор инкрементирует значение RIP, прибавляя к нему длину прочитанной инструкции, чтобы подготовить ядро к следующему такту.

2. Decode (Декодирование)

Сырые байты загружены в Instruction Register, но для вычислительного блока (Datapath) они пока остаются слепым набором битов. Набор байтов необходимо расшифровать и превратить в скоординированные электрические сигналы управления.

Эту задачу решает Instruction Decoder (Декодер инструкций) — сложнейшая комбинационная схема внутри Control Unit.

На этапе Decode:

  1. Декодер анализирует код операции (Opcode). Увидев байты 48 01 D8, декодер распознает:
    • Префикс 48 — операция над 64-битными регистрами;
    • Опкод 01 — операция сложения (ADD);
    • Байт D8 — операнды RAX и RBX (сложение ADD RAX, RBX).
  2. Декодер определяет, какие именно регистры нужны для вычислений, и подает управляющий сигнал Read на соответствующие порты регистрового файла (Register File).
  3. Настраиваются аппаратные мультиплексоры (MUX), коммутирующие шины между регистрами и входами ALU.
  4. На управляющие входы ALU подается бинарный код команды ADD.

3. Execute (Выполнение)

Все подготовительные цепи скоммутированы, операнды выставлены на входные шины. Наступает момент физического совершения полезной работы.

На этапе Execute:

  1. Вычисление: Арифметико-логическое устройство (ALU) выполняет команду. Например, при сложении операндов RAX и RBX кремниевые сумматоры Carry-Lookahead прогоняют биты через каскады вентилей за доли наносекунды.
  2. Обновление флагов: По результату операции ALU выставляет признаки в регистре состояния RFLAGS (Zero Flag ZF — равен ли результат нулю, Carry Flag CF — произошел ли перенос, Overflow Flag OF — случилось ли знаковое переполнение).
  3. Запись результата (Writeback): Полученная сумма по шине данных направляется обратно в регистровый файл и по сигналу Write Enable защелкивается в целевом регистре RAX.
  4. Ветвления и прыжки: Если выполнялась инструкция ветвления (например, безусловный JMP, условный переход или вызов функции CALL), то на этапе Execute результат вычисления адреса принудительно перезаписывает счетчик RIP. Линейный ход программы нарушается, и следующий цикл Fetch начнется с нового адреса в памяти.
flowchart TD
    Start["Начало такта"] --> Fetch["1. Fetch (Выборка)<br>Чтение байтов по адресу RIP<br>Инкремент RIP = RIP + len"]
    Fetch --> Decode["2. Decode (Декодирование)<br>Анализ Opcode (напр. ADD RAX, RBX)<br>Подача сигналов Read на регистры"]
    Decode --> Execute["3. Execute (Выполнение)<br>ALU вычисляет результат<br>Обновление флагов RFLAGS"]
    Execute --> WB["4. Writeback (Запись)<br>Защелкивание ответа в регистр RAX<br>или запись в кэш данных L1d"]
    WB --> Jump{"Инструкция перехода<br>(JMP, CALL, RET)?"}

    Jump -->|"Да: ветвление"| UpdateRIP["Принудительная перезапись RIP<br>новым целевым адресом"]
    Jump -->|"Нет: линейный код"| Start
    UpdateRIP --> Start

    classDef step fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
    classDef branch fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
    class Fetch,Decode,Execute,WB step;
    class Jump,UpdateRIP branch;
  

Mechanical Sympathy: Go Runtime и цикл процессора

Понимание непрерывного цикла «Fetch-Decode-Execute» позволяет блестяще ответить на один из самых классических вопросов для Senior Go-разработчиков: как устроена невытесняющая и вытесняющая многозадачность?

sequenceDiagram
    autonumber
    participant CPU as Ядро CPU (Fetch-Decode-Execute)
    participant Spin as Горутина с for {}
    participant Sysmon as Фоновый поток sysmon
    participant Kernel as Ядро Linux
    participant Runtime as Рантайм Go (sighandler)

    CPU->>Spin: Непрерывное исполнение цикла: JMP -> JMP -> JMP
    Note over Sysmon: Горутина крутится > 10 мс без прерываний!
    Sysmon->>Kernel: tgkill(tid, SIGURG)
    Kernel->>CPU: Доставка сигнала (Signal Delivery)
    Note over CPU: Разрыв цикла Fetch-Decode-Execute!<br>Сохранение RIP в стек
    CPU->>Runtime: Переход в обработчик runtime.sighandler
    Runtime->>Runtime: Эмуляция вызова: прерванный RIP на стек,<br>новый RIP = runtime.asyncPreempt
    Runtime-->>CPU: Возврат из сигнала
    CPU->>Runtime: Исполнение asyncPreempt: сохранение регистров,<br>вытеснение горутины в очередь
    CPU->>CPU: Планировщик переключается на функцию main() -> "Готово!"
  

Именно так глубокое понимание аппаратного цикла инструкций и механизма сигналов превращает загадочную магию рантайма Go в абсолютно прозрачный инженерный механизм.


Итог

  1. Fetch (Выборка): Ядро считывает байты машинной команды по адресу из регистра RIP (или PC), загружает их в регистр инструкции IR и инкрементирует счетчик адреса. Промахи в кэше инструкций L1i приводят к простоям конвейера на сотни тактов.
  2. Decode (Декодирование): Комбинационный дешифратор команд разбирает опкод (например, 48 01 D8), подготавливает входы ALU и выставляет сигналы Read на регистрах операндов. В архитектуре x86 переменная длина команд (1–15 байт) сильно усложняет декодер по сравнению с фиксированным 4-байтным форматом ARM64.
  3. Execute (Выполнение) и Writeback: ALU физически перемалывает биты, выставляет флаги состояний RFLAGS и сохраняет ответ обратно в регистровый файл по сигналу Write Enable.
  4. Инструкции переходов и ветвления (if/else, JMP, CALL, RET): Ломают последовательный шаг $RIP = RIP + \text{len}$ и принудительно загружают в счетчик команд новый адрес в памяти.
  5. Асинхронное вытеснение в Go (1.14+): В Linux плотный бесконечный цикл for {} прерывают, послав потоку сигнал SIGURG. (На Windows используются SuspendThread/SetThreadContext; есть и отладочная настройка GODEBUG=asyncpreemptoff=1, отключающая асинхронное вытеснение; синхронное вытеснение через проверку stack guard в прологах функций работает всегда.)

Мы разобрались, как процессор читает, декодирует и выполняет машинные команды. Но кто определяет сам словарь этих команд? Откуда компилятор Go знает, что сложение 64-битных регистров — это именно байты 48 01 D8, а не какие-то другие?

Ответ на этот вопрос дает великий архитектурный контракт между создателями кремния и разработчиками компиляторов. В следующей статье мы изучим: 8. ISA. Интерфейс между железом и софтом.