Кодирование команд и форматы

Урок показывает, как ассемблерная команда упаковывается в конкретные биты машинного кода.

Кодирование команды — правило, по которому мнемоника и операнды превращаются в фиксированный набор бит: поле кода операции (opcode) плюс поля операндов.

Зачем разбираться в кодировании

Команда в памяти — это просто число. Чтобы процессор её понял, биты разбиты на поля по строгому формату. Понимание форматов снимает магию: вы увидите, что ADD R1, R2, R3 и набор битов 00000000010000110000100000100000 — одно и то же, и сможете «прочитать» машинный код руками.

Форматы команд (на примере MIPS-подобной ISA)

В классической RISC-архитектуре все команды — 32 бита, но поля разбиты по-разному в зависимости от типа:

R-тип (регистр-регистр), напр. ADD R1, R2, R3:
 ┌──────┬─────┬─────┬─────┬─────┬──────┐
 │opcode│ rs  │ rt  │ rd  │shamt│funct │
 │ 6 бит│5 бит│5 бит│5 бит│5 бит│6 бит │   = 32 бита
 └──────┴─────┴─────┴─────┴─────┴──────┘

I-тип (с непосредственным операндом), напр. ADDI R1, R2, 100:
 ┌──────┬─────┬─────┬────────────────┐
 │opcode│ rs  │ rt  │  immediate     │
 │ 6 бит│5 бит│5 бит│   16 бит        │   = 32 бита
 └──────┴─────┴─────┴────────────────┘

J-тип (переход), напр. JUMP addr:
 ┌──────┬───────────────────────────┐
 │opcode│      address               │
 │ 6 бит│       26 бит               │   = 32 бита
 └──────┴───────────────────────────┘

Как работает под капотом: кодируем команду

Соберём 32-битный код команды ADD R1, R2, R3 из полей и распечатаем биты. opcode=0, funct=32 — стандартные значения ADD в MIPS:

def encode_R(opcode, rs, rt, rd, shamt, funct):
    # упаковываем поля в 32-битное число, сдвигая по позициям
    code = (opcode << 26) | (rs << 21) | (rt << 16) \
         | (rd << 11) | (shamt << 6) | funct
    return code

# ADD R1, R2, R3 -> rd=1, rs=2, rt=3
code = encode_R(opcode=0, rs=2, rt=3, rd=1, shamt=0, funct=32)
print("машинный код:", format(code, "032b"))
print("в hex:        0x{:08X}".format(code))

Вывод:

машинный код: 00000000010000110000100000100000
в hex:        0x00430820

Декодируем обратно

Процессор делает обратное: берёт 32 бита и «нарезает» их на поля масками и сдвигами. Это и есть фаза decode:

def decode_R(code):
    opcode = (code >> 26) & 0x3F     # старшие 6 бит
    rs     = (code >> 21) & 0x1F     # 5 бит
    rt     = (code >> 16) & 0x1F
    rd     = (code >> 11) & 0x1F
    shamt  = (code >> 6)  & 0x1F
    funct  = code & 0x3F             # младшие 6 бит
    return opcode, rs, rt, rd, shamt, funct

fields = decode_R(0x00430820)
print("opcode={} rs={} rt={} rd={} shamt={} funct={}".format(*fields))
print("=> ADD R{}, R{}, R{}".format(fields[3], fields[1], fields[2]))

Вывод:

opcode=0 rs=2 rt=3 rd=1 shamt=0 funct=32
=> ADD R1, R2, R3

Почему фиксированная длина — это удобно

Все команды по 32 бита: процессор всегда знает, где кончается одна команда и начинается следующая (PC += 4). Это критично для конвейера и предвыборки. В CISC длина переменная, поэтому декодер должен сначала понять длину — отдельная морока.

Глубже в тему

Зачем вообще проектировщику ISA так тщательно раскладывать биты по полям? Главная причина — скорость декодирования. Когда поля регистров (rs, rt, rd) находятся всегда на одних и тех же битовых позициях независимо от типа команды, аппаратура может начать читать регистровый файл параллельно с определением, что это за команда. Это не случайность, а сознательное решение архитекторов MIPS: даже если в I-типе поле на месте rd не используется, регистры всё равно «вытягиваются» заранее, и если они не понадобятся, результат просто отбрасывается. Такая избыточная, но регулярная разметка экономит драгоценные доли такта на критическом пути конвейера.

Поучителен вопрос: почему immediate в I-типе всего 16 бит, а адрес в J-типе — целых 26? Это прямое следствие компромисса между выразительностью и размером команды. Все 32 бита уже распределены, и каждое поле борется за место. Шестнадцати бит хватает для большинства констант и смещений в реальном коде (циклы, доступ к локальным переменным), а если нужна большая константа, её собирают из двух команд (LUI загружает старшие 16 бит, затем ORI добавляет младшие). Для переходов же важна дальность, поэтому J-тип жертвует всем ради широкого поля адреса. При этом 26 бит адресуют не байты, а слова: реальный адрес получается сдвигом влево на 2, что даёт дальность перехода в 256 МБ — этого с запасом хватает внутри одной программы.

Кодирование команд тесно связано с расширением знака (sign extension). Когда 16-битное immediate участвует в арифметике над 32-битным регистром, его старший бит «размножается» влево, чтобы отрицательные числа остались отрицательными. Если этого не сделать, константа -1 (записанная как 0xFFFF) превратится в 65535 вместо -1. Именно поэтому в реальных ISA различают команды со знаковым и беззнаковым immediate — на уровне кодирования они почти одинаковы, но аппаратура по-разному дополняет константу до полной ширины. Это тонкость, на которой спотыкаются и студенты, и авторы эмуляторов.

Современные архитектуры добавляют ещё один слой — сжатые наборы команд. ARM Thumb и расширение RISC-V «C» вводят 16-битные кодировки для самых частых операций, чтобы уменьшить размер программы (это критично для встраиваемых систем и для попадания горячего кода в кэш). Получается своего рода компромисс с CISC: фиксированная длина частично уступает место смеси 16- и 32-битных команд. Декодер при этом смотрит на пару младших бит, чтобы понять длину текущей команды. Это показывает, что разметка битов — не застывшая догма, а живой инженерный инструмент: меняя раскладку полей, проектировщик балансирует между плотностью кода, скоростью декодирования и числом доступных регистров. Умение «читать» машинный код руками, как в этом уроке, остаётся базовым навыком при отладке, реверс-инжиниринге и написании компиляторов.

Частые ошибки

  • Путать opcode и funct. В R-типе MIPS общий opcode=0, а конкретную операцию задаёт поле funct.
  • Перепутать порядок rs/rt/rd. В машинном коде порядок полей не совпадает с порядком в ассемблере (rd пишется первым в мнемонике, но не в битах).
  • Забывать про маски при декодировании. Сдвиг без маски & 0x1F захватит лишние биты.

Итог

  • Команда — это число, разбитое на поля: opcode + операнды по строгому формату.
  • Форматы R/I/J отличаются разбиением битов под регистры, константы и адреса.
  • Кодирование — сдвиги и OR; декодирование — сдвиги и маски; фиксированная длина упрощает всё.
Проверьте себя
1. Что задаёт поле opcode в команде?
AАдрес в памяти
BКод операции — какую команду выполнять
CНомер регистра результата
DТактовую частоту
2. Почему RISC-команды делают фиксированной длины (например, 32 бита)?
AЧтобы экономить память
BПроцессор всегда знает границы команд (PC += 4), что упрощает выборку и конвейер
CЧтобы поддерживать больше регистров
DТак быстрее считает АЛУ
3. Как процессор извлекает поля из 32-битной команды в фазе decode?
AДелением на 32
BСдвигами вправо и применением битовых масок
CПоиском в памяти
DЧерез АЛУ-сложение