2025, Oct 17 03:17

Как устроен байткод CPython: опкод + аргумент по одному байту

Поясняем, почему байткод CPython фиксирован: 2 байта — опкод и аргумент. На примерах dis, co_code и CACHE покажем, что псевдоинструкции в байткод не попадают.

Когда вы впервые заглядываете в байткод CPython, легко подумать, что инструкции занимают по одному байту. Вы выводите co_code, видите поток вроде 97 00 7c 00 64 01… — и кажется, что опкоды упакованы подряд. Однако документация утверждает, что каждая инструкция занимает 2 байта. Где прячется лишний байт и бывают ли инструкции переменной длины?

Воссоздаём путаницу

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

import dis
import sys


def probe_func(a):
    if a > 0:
        return a + 1
    return 0

print(sys.version)
print(probe_func.__code__.co_code.hex(' '))
dis.dis(probe_func)

Дизассемблер показывает смещения 0, 2, 4, … и читаемые опкоды, такие как RESUME, LOAD_FAST, LOAD_CONST, COMPARE_OP, POP_JUMP_IF_FALSE, BINARY_OP, RETURN_VALUE, RETURN_CONST. Но гекс-дамп на первый взгляд выглядит так, будто на каждую инструкцию приходится один байт.

Что на самом деле закодировано

CPython использует инструкции фиксированной ширины. Каждая инструкция — два байта: всегда байт опкода, за которым следует байт аргумента. Опкод — это операция, аргумент — её операнд. Если операции аргумент не нужен, байт операнда равен 0x00. Это совпадает с тем, что видно в dis, где колонка «offset» идёт с шагом два.

Проверить отдельные байты можно, сопоставив их через dis.opname.

print(dis.opname[0x97])
print(dis.opname[0x7C])
print(dis.opname[0x64])

Это выведет RESUME, LOAD_FAST, LOAD_CONST, подтверждая, что первый байт в каждой паре — опкод. Второй байт в паре — аргумент, и когда он не используется, это 00.

Есть и деталь реализации, заметная по смещениям в дизассемблированном коде: иногда появляются промежутки больше 2 из‑за скрытой инструкции CACHE. Если передать show_caches=True в dis.dis, эти операции CACHE будут видны в выводе.

Откуда берётся впечатление «больше одного байта»

Заблуждение часто возникает, когда поток читают по одному байту. Если же идти по co_code парами, структура становится очевидной.

import itertools

pairs = itertools.batched(probe_func.__code__.co_code, 2)
for opc, arg in pairs:
    name = dis.opname[opc] if opc < len(dis.opname) else hex(opc)
    print(f"{name} {arg:02x}")

Вывод чётко совпадает с dis: каждый опкод сопровождается своим однобайтовым аргументом — даже если этот аргумент фактически не используется.

Об опкодах выше 255 и псевдоинструкциях

Можно заметить, что в dis.opname больше 256 записей. Это не означает, что интерпретатор выдаёт многобайтовые опкоды. Лишние элементы соответствуют псевдоинструкциям, которые компилятор применяет на ранних этапах и удаляет или заменяет до формирования итогового байткода. В co_code вы их не встретите и в выводе dis они не появляются. Они существуют лишь в промежуточном представлении, используемом компилятором. Контекст их применения см. в https://github.com/python/cpython/blob/main/Python/flowgraph.c и в разделе «Pseudo-instructions» документации модуля dis.

Замечание в документации, которое ставит точку

Изменено в версии 3.6: каждая инструкция занимает 2 байта. Ранее количество байтов зависело от инструкции.

Ключевой момент в том, что сейчас CPython кодирует инструкции фиксированными парами байтов: [opcode][argument]. Интерпретатору не нужно угадывать длину инструкции по первому байту — за ним всегда следует ровно один байт аргумента.

Уточнённая ментальная модель и практическая проверка

Правильная картина простая: сырой поток байткода — это последовательность пар «опкод + аргумент». Дизассемблеры печатают человекочитаемые названия и операнды; колонка «offset» — это байтовое смещение, и оно увеличивается на два на каждую инструкцию. Скрытые записи CACHE возможны и отображаются при show_caches=True.

dis.dis(probe_func, show_caches=True)

Если хотите сами проверить содержимое co_code, проходите по байтам по два за раз и переводите опкоды через dis.opname, как показано выше. Так уходит иллюзия «однобайтных» инструкций, и всё ровно совпадает с дизассемблированием.

Почему это важно инженерам

Если вы пишете инструменты для анализа или трансформации байткода Python или разбираетесь с переходами и смещениями, предположение «по одному байту на инструкцию» заведёт не туда. Воспринимайте поток как пары фиксированной длины — так смещения, цели переходов и границы инструкций будут согласованы с dis и с документированным форматом CPython. Это также снимает путаницу вокруг псевдоинструкций, которые никогда не попадают в финальный байткод.

Выводы

В CPython инструкции не переменной длины. Каждая инструкция — два байта: один байт опкода и один байт аргумента. Для операций без операндов аргумент равен 0x00. Видимые «пропуски» в смещениях объясняются записями CACHE, которые можно показать с помощью show_caches=True. Имена за пределами диапазона 0–255 в dis.opname относятся к псевдоинструкциям, используемым только на этапе компиляции, — перед формированием co_code они удаляются или заменяются. Анализируя co_code, всегда двигайтесь шагом в два байта и сопоставляйте первый байт через dis.opname, чтобы корректно интерпретировать поток.

Статья основана на вопросе на StackOverflow от Petras Purlys и ответе Grismar.