2025, Oct 17 03:33

CPython बाइटकोड में हर निर्देश 2 बाइट: व्याख्या और डिसअसेंबली

CPython बाइटकोड को सही ढंग से समझें: हर निर्देश 2 बाइट (opcode+argument)। dis में ऑफ़सेट व CACHE पढ़ें, co_code जांचें और pseudo‑instructions का अर्थ जानें।

जब आप पहली बार 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 निर्देश होते हैं। dis.dis को show_caches=True देने पर ये 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 और dis मॉड्यूल के दस्तावेज़ में “Pseudo-instructions” अनुभाग।

दस्तावेज़ी टिप्पणी जो बात साफ़ कर देती है

संस्करण 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 से देखा जा सकता है। dis.opname में 0–255 सीमा से परे वाले नाम केवल संकलन के दौरान उपयोग होने वाले छद्म-निर्देशों के लिए हैं और co_code बनने से पहले हटा दिए जाते हैं या बदले जाते हैं। co_code देखते समय हमेशा दो-दो बाइट के चरणों में पढ़ें और स्ट्रीम को सही तरह समझने के लिए पहली बाइट को dis.opname से मैप करें।

यह लेख StackOverflow के एक प्रश्न (द्वारा Petras Purlys) और Grismar के उत्तर पर आधारित है।