Кодирование команд и форматы
Урок показывает, как ассемблерная команда упаковывается в конкретные биты машинного кода.
Кодирование команды — правило, по которому мнемоника и операнды превращаются в фиксированный набор бит: поле кода операции (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; декодирование — сдвиги и маски; фиксированная длина упрощает всё.