Skip to content
العودة إلى المدوّنة
دروس تعليمية

ترتيب البايتات: لماذا تُقرأ نفس البايتات رقمين مختلفين

نفس البايتات 12 34 56 78 تُقرأ 0x12345678 أو 0x78563412 حسب من يقرأها. ترتيب البايتات في JavaScript وPython وGo وPNG وGZIP بقياسات فعلية عبر الويب.

13 دقائق للقراءة

ترتيب البايتات: لماذا تُقرأ نفس البايتات رقمين مختلفين

أربعة بايتات في الذاكرة: 12 34 56 78. اقرأها بثلاث واجهات JavaScript مختلفة فيعود إليك رقمان مختلفان. هذا هو ترتيب البايتات (endianness)، وهذه هي المشكلة كلها في جدول واحد.

القراءةالناتج
new DataView(buf).getUint32(0)0x12345678
new DataView(buf).getUint32(0, true)0x78563412
new Uint32Array(buf)[0]0x78563412

لا شيء معطوب ولا شيء يرمي استثناءً. كل استدعاء يطبّق قاعدة مختلفة عن أيّ طرفَي العدد متعدد البايتات يأتي أولاً.

والسؤال المفيد ليس «ما ترتيب بايتات جهازي؟» بل «تحت أي عُرف كُتبت هذه البايتات؟». ملف PNG على قرصك هو big-endian، وملف GZIP المجاور له هو little-endian. ولا صوت لمعالجك في أيٍّ من الحالتين.

كل ما يلي مقيس على Node v26.7.0 وPython 3.14.6 تحت macOS darwin arm64، حيث يُرجع os.endianness() القيمة LE ويُرجع sys.byteorder القيمة little.

1. ما الذي يعنيه big-endian وlittle-endian فعلياً

خذ القيمة 0x12345678 بعرض 32 بت. إنها أربعة بايتات: 12 هو الأعلى قيمةً و78 هو الأدنى. وترتيب البايتات يقرّر أيّها يحلّ في العنوان الأدنى.

الترتيبالعنوان 0العنوان 1العنوان 2العنوان 3
Big-endian12345678
Little-endian78563412

يخزّن big-endian الطرف الكبير أولاً، بالترتيب نفسه الذي تكتب به الرقم على الورق. ويخزّن little-endian الطرف الصغير أولاً. ولا يمسّ أيٌّ منهما البتات داخل البايت الواحد: فـ0x12 يبقى 0x12 في الترتيبين. البايتات الكاملة وحدها هي التي تتحرّك.

إن كانت الخطوة من الست عشري إلى الثنائي هي الجزء غير المستقرّ عندك، فإن محوّل الأنظمة العددية يفرد كل بايت بالثنائي إلى جانب صورته الست عشرية، ويتناول دليل التحويل بين الثنائي والست عشري والثماني التدوين نفسه.

1.1 لماذا يوجد اثنان منهما أصلاً

الانقسام تاريخي لا مبدئي. فـbig-endian يُقرأ بالطريقة التي يكتب بها الناس الأرقام، وصار عُرف بروتوكولات الشبكة في وقت مبكر بما يكفي ليرسخ. أما little-endian فانتصر في جانب المعالجات لأن x86 يستعمله ولأن ARM يجعله الافتراضي. وحجّة الكفاءة التي ستجدها مكرّرة في كل مقال تقريباً عن الموضوع مقيسة في القسم 8، وتبيّن هناك أنها تساوي نحو 2%.

2. ترتيب البايتات صفة للصيغة، لا للمنصّة

ترتيب البايتات يحدّده من كتب البايتات، لا الجهاز الذي يقرأها. والشروح القصيرة تقفز عن هذه النقطة عادةً.

والبرهان يحتاج حاسوباً محمولاً واحداً وملفّين. على جهاز arm64 نفسه، وداخل العملية نفسها، يحتاج هذان الملفان فكّ ترميز متعاكسَين.

2.1 صيغة PNG هي big-endian

يشترط RFC 2083 أن تكون الأعداد الصحيحة متعددة البايتات بترتيب بايتات الشبكة، فكل طول وعرض وارتفاع داخل ملف PNG هو big-endian. وتخطيط الترويسة ثابت: ثمانية بايتات توقيع، ثم أربعة بايتات لطول الكتلة، ثم أربعة محارف لنوع الكتلة، ثم العرض والارتفاع.

const fs = require('node:fs');
const png = fs.readFileSync('public/og/base-converter.png');

png.subarray(0, 8).toString('hex'); // '89504e470d0a1a0a' — PNG signature
png.readUInt32BE(8);                // 13   — IHDR chunk length
png.readUInt32BE(16);               // 1200 — image width
png.readUInt32BE(20);               // 630  — image height

png.readUInt32LE(16);               // the same four bytes, read the wrong way
الحقلالبايتاتBig-endianLittle-endian
طول كتلة IHDR00 00 00 0d13218,103,808
عرض الصورة00 00 04 b012002,953,052,160

2.2 صيغة GZIP هي little-endian

يقولها RFC 1952 §2.3.1 صراحةً: البايت الأدنى قيمةً أولاً. فآخر أربعة بايتات في مجرى gzip هي ISIZE، أي الحجم قبل الضغط. اضغط 300 بايت من الحرف A وتحقّق بنفسك:

python3 -c "import gzip,sys; sys.stdout.buffer.write(gzip.compress(b'A'*300))" > a.gz
const gz = fs.readFileSync('a.gz');

gz.subarray(-4).toString('hex');   // '2c010000'
gz.readUInt32LE(gz.length - 4);    // 300 — correct
gz.readUInt32BE(gz.length - 4);    // the same four bytes, read the wrong way
الحقلالبايتاتLittle-endianBig-endian
حقل ISIZE في الذيل2c 01 00 00300738,263,040

الجهاز نفسه والعملية نفسها، وقراءة أولية واحدة بأربعة بايتات في الحالتين. وإذا كانت قاعدتك العملية هي «جهازي little-endian إذن أقرأ little-endian»، فأحد هذين الملفين سيُفكّ إلى خردة. الصيغة هي التي تقرّر في كل مرة.

3. JavaScript: واجهتان، وافتراضان متعاكسان

الطريقتان المتاحتان للنظر إلى ArrayBuffer تختلفان إحداهما عن الأخرى افتراضياً، ومن هنا يأتي أكثر ما يربك القارئ في المتصفّح وNode.

3.1 ترتيب بايتات DataView: الوسيط الثالث هو الذي يقرّر

تأخذ توابع DataView راية littleEndian اختيارية كوسيط أخير. أهملها فتحصل على big-endian. والاستدعاءان setUint32(0, x) وsetUint32(0, x, false) استدعاء واحد.

const buf = new ArrayBuffer(4);
new Uint8Array(buf).set([0x12, 0x34, 0x56, 0x78]);
const dv = new DataView(buf);

dv.getUint32(0).toString(16);       // '12345678'  — big-endian, the default
dv.getUint32(0, true).toString(16); // '78563412'  — littleEndian: true

والكتابة تتصرّف بالمثل في الاتجاه المعاكس:

const hex = (b) => [...new Uint8Array(b)].map((x) => x.toString(16).padStart(2, '0')).join(' ');

dv.setUint32(0, 0x12345678);
hex(buf); // '12 34 56 78'

dv.setUint32(0, 0x12345678, true);
hex(buf); // '78 56 34 12'

3.2 TypedArray يتبع المنصّة، ولا حيلة لك في ذلك

تستعمل Uint32Array وInt16Array وFloat64Array وبقيّة العائلة ما يستعمله المعالج. لا وسيط ولا راية في الباني. وعلى جهاز arm64 هذا يعني little-endian، وهو النقيض التام لافتراضي DataView.

new Uint32Array(buf)[0] = 0x12345678;
hex(buf); // '78 56 34 12'

فـArrayBuffer واحد مقروء عبر new DataView(buf).getUint32(0) وعبر new Uint32Array(buf)[0] يعطي 0x12345678 و0x78563412. وكلاهما صحيح. إنهما يجيبان عن سؤالين مختلفين.

والمناظير ذات البايت الواحد محصّنة، لأن ترتيب البايتات لا وجود له إلا في الوحدات الأعرض من بايت. فـUint8Array وInt8Array لا تحتاجان راية أبداً. وسّع خطوة واحدة فيعود:

const two = new ArrayBuffer(2);
new Uint16Array(two)[0] = 0x00ff;
hex(two); // 'ff 00'

3.3 Buffer في Node: سمِّ الترتيب داخل اسم التابع

يتخطّى Buffer الافتراضيات كلياً ويضع الترتيب في اسم التابع، ولهذا يكون كود Node عادةً أسهل ما يُراجَع.

const b = Buffer.from([0x12, 0x34, 0x56, 0x78]);

b.readUInt32BE(0).toString(16); // '12345678'
b.readUInt32LE(0).toString(16); // '78563412'

b.swap32().toString('hex');     // '78563412' — mutates b in place

يعكس swap32() كل مجموعة من أربعة بايتات ويُرجع الـBuffer نفسه لا نسخةً عنه. مفيد حين تكون لديك مصفوفة كاملة من الأعداد بالترتيب الخاطئ، وخطِر إن نسيت أن الـBuffer مشترك.

4. struct في Python: خمس بادئات، وما تكلّفه @ فعلاً

4.1 < > ! = @: بادئات ترتيب البايتات الخمس في struct pack

حزم 0x12345678 كعدد صحيح غير مُشار بعرض 32 بت، سطر واحد لكل بادئة:

import struct

struct.pack('<I', 0x12345678).hex(' ')  # '78 56 34 12'  little-endian
struct.pack('>I', 0x12345678).hex(' ')  # '12 34 56 78'  big-endian
struct.pack('!I', 0x12345678).hex(' ')  # '12 34 56 78'  network order
struct.pack('=I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, standard sizes
struct.pack('@I', 0x12345678).hex(' ')  # '78 56 34 12'  native order, native alignment

تنتج ! و> بايتات متطابقة لأن ترتيب بايتات الشبكة هو big-endian. وفكّ الحزم يعكس ذلك، وint.from_bytes يعطي الزوج نفسه:

hex(struct.unpack('>I', b'\x12\x34\x56\x78')[0])    # '0x12345678'
hex(struct.unpack('<I', b'\x12\x34\x56\x78')[0])    # '0x78563412'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'big'))     # '0x12345678'
hex(int.from_bytes(b'\x12\x34\x56\x78', 'little'))  # '0x78563412'

4.2 @ و= يختلفان في الحشو، لا في ترتيب البايتات

كلاهما يتبع المنصّة، فعلى هذا الجهاز يكتب كلاهما بترتيب little-endian. الفرق في المحاذاة، وهي تغيّر حجم بنيتك:

struct.calcsize('@ci')  # 8
struct.calcsize('=ci')  # 5
struct.calcsize('<ci')  # 5

محرف char يتلوه int يساوي خمسة بايتات من البيانات. وتحت @، وهي الافتراضي حين لا تكتب أي بادئة إطلاقاً، تُدخل Python ثلاثة بايتات حشو ليبدأ int على حدّ أربعة بايتات. وتحت = أو أي بادئة ترتيب صريحة، يختفي الحشو.

هذه هي الآلية وراء علّة تبدو مستحيلة عند قراءتها: أحدهم يضيف < ليصلح مشكلة في ترتيب البايتات فيتغيّر طول السجل من تحته. لا شيء في ترتيب البايتات سبّب ذلك. الابتعاد عن @ أطفأ المحاذاة الأصلية أيضاً، وبصمت.

5. ترتيب بايتات الشبكة، وكيف تكتبه اللغات الأخرى

ترتيب بايتات الشبكة هو big-endian. فترويسات TCP وUDP وIP كلها تحمل حقولها متعددة البايتات بهذا الشكل، وهو ما يعود إلى زمن كان فيه عتاد big-endian شائعاً بما يكفي ليضطر أحدهم إلى اختيار جانب. وتعرض لغة C التحويل عبر htons وhtonl وntohs وntohl: من المضيف إلى الشبكة والعكس، للأعداد القصيرة والطويلة. على مضيف little-endian تبدّل هذه الدوال البايتات، وعلى مضيف big-endian لا تفعل شيئاً، ولهذا يعمل الكود الذي يُغفلها عملاً سليماً حتى يلتقي بجهاز مختلف.

وتسلك Go المسلك المعاكس وترفض أن يكون لها افتراضي أصلاً:

import "encoding/binary"

v := binary.BigEndian.Uint32(b)
binary.LittleEndian.PutUint32(b, v)

فـbinary.BigEndian وbinary.LittleEndian قيمتان تسمّيهما في موضع الاستدعاء. لا مسار يعتمد على المنصّة، ولا راية اختيارية تُنسى. ومراجعة ترتيب البايتات في Go تصير قراءة معرّف لا أكثر.

والانضباط نفسه ينطبق على بيانات الفاصلة الثابتة. فعيّنة Q15 أو Q31 ليست إلا عدداً صحيحاً بعرض 16 أو 32 بت بمجرد أن تغادر كودك، فترث سؤال ترتيب البايتات مع كل ما عداه؛ ويُظهر محوّل تنسيق Q العدد الصحيح الكامن خلف الكسر، وهذا العدد هو ما يُرتَّب.

6. للأعداد ذات الفاصلة العائمة ترتيب بايتات أيضاً

العدد العائم ليس استثناءً. يحدّد IEEE 754 نمط البتات، ثم تُرصَف البايتات الأربعة أو الثمانية نفسها بأي ترتيب تطلبه الصيغة.

struct.pack('>f', 1.0).hex(' ')  # '3f 80 00 00'
struct.pack('<f', 1.0).hex(' ')  # '00 00 80 3f'
struct.pack('>d', 0.1).hex(' ')  # '3f b9 99 99 99 99 99 9a'
struct.pack('<d', 0.1).hex(' ')  # '9a 99 99 99 99 99 b9 3f'

يجيب محوّل IEEE 754 عن النصف الأول من السؤال: اكتب 3.14159 مع اختيار FP32 فتحصل على 0x40490FD0. ويجيب هذا المقال عن النصف الثاني، أي بأي ترتيب تصل تلك البايتات الأربعة إلى الملف. الأداة تعطيك القيمة، وترتيب البايتات يعطيك الرصف.

وسطر double 0.1 هو أيضاً سبب سوء سلوك 0.1 + 0.2. فبايتات 99 المتكرّرة تلك تمدّد ثنائي لا ينتهي أبداً، وهو ما يفكّكه دليل دقة الفاصلة العائمة.

6.1 القيمة float 1.0 هي 3f 80 00 00، أو 00 00 80 3f

القيمة 1.0 بصيغة FP32 كاشف ممتاز لأن نمط بايتاتها مائل بشدّة. فـbig-endian يكتبها 3f 80 00 00، وlittle-endian يكتبها 00 00 80 3f. أفرغ محتوى صيغة ثنائية غير مألوفة، وجِد حقلاً تعرف أن قيمته 1.0، فيخبرك الصفران الملتصقان بأحد الطرفين عند أي نهاية أنت. وينطبق الأمر على double كذلك، حيث تتجمّع ستة بايتات صفرية للقيمة نفسها على جانب واحد.

7. ترتيب البايتات في ترميزات النص: علامة BOM مجرّد إعلان

يتكوّن UTF-16 وUTF-32 من وحدات متعددة البايتات، فيصطدمان بالمشكلة التي يصفها هذا المقال تماماً. وجوابهما أن يدَعا الملف يعلن ترتيبه بنفسه عبر علامة ترتيب البايتات:

Buffer.from('\uFEFF', 'utf16le').toString('hex'); // 'fffe' — U+FEFF is the BOM
Buffer.from('A', 'utf16le').toString('hex');      // '4100'

وجود fffe في المقدمة يعني little-endian، وfeff يعني big-endian. فالـBOM أشهر مثال على صيغة تعلن ترتيب بايتاتها بدل أن تفترضه. والقصة كاملةً، بما فيها سبب استغناء UTF-8 عن الـBOM وما تكلّفك العلامة حين تظهر دون دعوة، في دليل ترميز UTF-8 وUTF-16 ويونيكود.

8. هل little-endian أسرع؟ ما تقوله 40 مليون تكرار

ادّعاء أن little-endian أكفأ يظهر في ملخّصات محرّكات البحث وفي معظم المقالات التمهيدية عن الموضوع. وهو ادّعاء قابل للاختبار. أربعون مليون تكرار لقراءة واحدة بعرض 32 بت على جهاز arm64 هذا:

المسارنانوثانية لكل عملية
DataView.getUint32(4, true) (little-endian)4.4267
DataView.getUint32(4) (big-endian)4.5200
Buffer.readUInt32LE(4)1.5173
Buffer.readUInt32BE(4)5.0893
النسبةالمعامل
DataView: big-endian مقابل little-endian1.021×
Buffer: big-endian مقابل little-endian3.354×

النسبتان تقيسان شيئين مختلفين، والاكتفاء بذكر الثانية وحدها هو الخطأ نفسه الذي تقع فيه تلك المقالات.

زوج DataView هو القياس الأمين لكلفة ترتيب البايتات. فالاستدعاءان يُترجمان إلى المسار المُضمَّن نفسه في V8، غير أن استدعاء big-endian يحمل تعليمة ARM إضافية واحدة هي REV لتبديل البايتات. هذه هي الـ1.021×، أي نحو 2%. صغيرة، لكنها ليست صفراً، فلا تقرّبها إلى «مجانية».

أما زوج Buffer فيقيس شيئاً آخر تماماً. فلدى V8 مسار سريع مخصّص لـreadUInt32LE لا يناله readUInt32BE، ومن ثمّ فإن الـ3.354× فرقٌ في التنفيذ داخل بيئة تشغيل واحدة، لا ثمن تبديل البايتات على المعالج. والاستشهاد به دليلاً على بطء big-endian خطأ. غيّر بيئة التشغيل يتغيّر الرقم معها.

على العتاد الحديث، تحويل ترتيب البايتات أرخص من أن يكون له مكان في نقاش تصميم صيغة. حجّة الكفاءة التاريخية جاءت من عصر سبق وجود تعليمات تبديل مخصّصة. اختر الترتيب الذي يستعمله بروتوكولك أو يستعمله جيرانك سلفاً.

9. كيف تميّز علّة ترتيب البايتات من علّة عادية

«كيف أعرف إن كان جهازي big-endian أم little-endian؟» جوابه os.endianness() في Node وsys.byteorder في Python، سطر واحد لكلٍّ منهما، ومحرّكات البحث تطبع الجواب فوق النتائج أصلاً. لكنه السؤال الخطأ في معظم جلسات التنقيح الحقيقية: فحين تحلّل ملفاً أو رزمة، الصيغة هي التي تقرّر ولا شأن لمعالجك بالأمر.

الأنفع أن تعرف كيف يبدو العَرَض حين يظهر.

9.1 عَرَضان: الرقم السخيف والرقم المضروب في 256

العَرَض الصاخب سهل. اقرأ عرض الـPNG من القسم 2 بالطريقة الخاطئة فتحصل على 2,953,052,160 لصورة عرضها 1200 بكسل. وأي حقل يُفترض أن يكون عدداً متواضعاً ثم يعود بالمليارات هو عدد صحيح بعرض 32 بت مقلوب حتى يثبت العكس.

أما العَرَض الهادئ فهو الباهظ. البايتات 00 00 01 00 تُقرأ 256 بترتيب big-endian و65,536 بترتيب little-endian. وكلا الرقمين يبدو حجم مخزن مؤقت معقولاً. لا شيء يرمي استثناءً، ولا تنطلق أي تأكيدة، والقيمة خاطئة بمعامل 256. علل كهذه تنجو من مراجعة الكود لأن الرقم الظاهر على الشاشة يبدو معقولاً. وهي من عائلة علامة BOM في UTF-8 التي تكسر تحليل JSON: تفصيلة غير مرئية على مستوى البايت، وواجهة خطأ مضلّلة تماماً، ويشرحها دليل استكشاف أخطاء تحليل JSON الناتجة عن BOM في UTF-8.

وثمة قاعدتان تقريبيتان تختصران الطريق. القيم الصغيرة التي تسبقها ثلاثة بايتات صفرية هي التي تنقلب بهدوء، لأن القراءتين تبقيان ضمن المدى. وإذا أنتج عكس البايتات يدوياً رقماً معقولاً، فقد وجدت جوابك دون أن تلمس منقّحاً.

9.2 الترتيب الذي تفحص به الأمور

  1. افحص مواصفة الصيغة أولاً. يقول RFC 2083 إن PNG هو big-endian، ويقول RFC 1952 §2.3.1 إن GZIP هو little-endian. وما يفعله جهازك لا صلة له بأيٍّ منهما.
  2. وافحص افتراضي القارئ ثانياً. DataView.getUint32(0) هو big-endian، وUint32Array بترتيب المنصّة، وstruct.pack('@I', ...) بترتيب المنصّة، وbinary.BigEndian.Uint32 هو ما يقوله اسمه. ومعظم علل ترتيب البايتات وسيط ثالث ناقص أو بادئة ناقصة، لا سوء فهم عميق.
  3. واشتبه في المنصّة أخيراً. فهي تهمّ حين تكتب ملفاً بـUint32Array أو بـ@ ثم ترسله إلى معمارية مختلفة، وتهمّ حين تقارن تفريغ ذاكرة بمواصفة. ولا تكاد تهمّ أبداً حين تقرأ صيغة محدّدة جيداً أخبرتك سلفاً بالترتيب الذي تستعمله.

الأسئلة الشائعة

هل big-endian أفضل أم little-endian؟

لا big-endian أفضل ولا little-endian. الصحيح لأي صيغة هو ما تنصّ عليه تلك الصيغة، ونادراً ما يكون الاختيار بيدك. أما على صعيد الأداء، فقراءة DataView بترتيب big-endian قيست عند 1.021× من نظيرتها little-endian على هذا الجهاز، أي نحو 2%، وهو أصغر بكثير من أن يقود أي قرار تصميمي.

كيف أعرف إن كان جهازي big-endian أم little-endian؟

يُرجع os.endianness() في Node القيمة LE هنا، ويُرجع sys.byteorder في Python القيمة little. وكلاهما سطر واحد. لكن السؤال أقل أهمية ممّا يبدو: فحين تحلّل ملفاً أو رزمة، الصيغة هي التي تملي ترتيب البايتات ولا رأي لمعالجك.

DataView وUint32Array يعطيان رقمين مختلفين من المخزن نفسه. أهذه علّة؟

لا، هذا سلوك موثَّق في DataView. فـDataView.getUint32(0) افتراضه big-endian، بينما يتبع Uint32Array المنصّة دائماً، وهي little-endian على x86 وApple Silicon. البايتات نفسها، وعُرفان مختلفان. مرّر true وسيطاً ثالثاً فيتفق معه DataView.

لماذا تغيّر حجم تخطيط struct لديّ حين أضفت <؟

لأنك ابتعدت عن @، وهي الافتراضي الذي يحشو من أجل المحاذاة الأصلية. فـstruct.calcsize('@ci') تساوي 8، بينما struct.calcsize('=ci') وstruct.calcsize('<ci') تساويان 5. بايتات الحشو الثلاثة التي كانت قبل int رحلت مع المحاذاة الأصلية.

هل ترتيب بايتات الشبكة big-endian أم little-endian؟

إنه big-endian. هذا هو العُرف الذي تستعمله ترويسات TCP/IP، ولهذا وُجدت htons وhtonl في C. وفي Python تنتج البادئتان ! و> بايتات متطابقة: فـstruct.pack('!I', 0x12345678) وstruct.pack('>I', 0x12345678) كلاهما يعطي 12 34 56 78.

هل يؤثّر ترتيب البايتات في UTF-8؟

لا. UTF-8 مجرى بايتات، وكل نقطة ترميز تُكتب تسلسلاً مرتّباً من بايتات مفردة، فلا تبقى وحدة متعددة البايتات لإعادة ترتيبها. أما UTF-16 وUTF-32 فتعانيان من تلك المشكلة فعلاً، ولهذا بالضبط تحملان علامة BOM، كما جاء في القسم 7.

هل تحتاج المصفوفات ذات البايت الواحد إلى أي معالجة لترتيب البايتات؟

لا. ترتيب البايتات لا وجود له إلا في الوحدات الأعرض من بايت واحد، فـUint8Array وInt8Array وكائنات bytes في Python محصّنة كلها. وسّع خطوة واحدة فيعود فوراً: new Uint16Array(two)[0] = 0x00ff يستقرّ في الذاكرة على صورة ff 00 على هذا الجهاز.

الوسوم: endianness byte-order binary-data file-formats cross-platform

مقالات ذات صلة

عرض جميع المقالات