Foundationপ্রথম নীতি থেকে
LEVEL 1লেসন ১৪/১৪কঠিন১ ঘণ্টা ৫ মিনিট

Serialization ও Binary Format — In-memory থেকে Byte পর্যন্ত

Serialization and Binary Formats

In-memory object-এর pointer শুধু নিজের process-এর মধ্যেই অর্থবহ, তাই disk বা network-এ পাঠাতে হলে সেটাকে একটা flat, self-contained byte sequence-এ রূপান্তর করতে হয় — আর সেই রূপান্তরের নিয়ম না মানলে padding garbage leak, endianness bug, আর ভাঙা backward compatibility অপেক্ষা করছে।

এই লেসন শেষে আপনি পারবেন

  • Serialization কী সমস্যা সমাধান করে এবং কেন raw pointer/memory address কখনো serialize করা যায় না তা ব্যাখ্যা করতে পারবেন
  • C struct-এ compiler কীভাবে padding বসায় তা ব্যাখ্যা করতে পারবেন এবং field reorder করে memory বাঁচাতে পারবেন
  • memcpy দিয়ে struct সরাসরি serialize করার বিপদ (uninitialized padding, endianness mismatch, ABI mismatch) চিনতে পারবেন
  • fixed binary, self-describing binary (protobuf/MessagePack), আর text (JSON) format-এর মধ্যে speed/size/readability/evolution trade-off তুলনা করতে পারবেন
  • schema evolution/versioning-এর মৌলিক কৌশলগুলো (append-only, tag-based, reserved field) ব্যাখ্যা করতে পারবেন
  • হাতে একটা length-prefixed binary serializer/deserializer লিখে integer, float, আর string-সহ একটা record round-trip করাতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আপনার প্রোগ্রামে একটা struct আছে — একটা user record, তিনটা field: id, score, name। মেমরিতে এটা কয়েকটা byte-এর একটা block, একটা নির্দিষ্ট address-এ। এখন এই record-টাকে একটা ফাইলে সেভ করতে হবে, অথবা network-এর ওপার দিয়ে আরেকটা মেশিনে পাঠাতে হবে। প্রশ্ন হলো — কীভাবে?

সরল উত্তর মনে হতে পারে: “মেমরির যে byte-গুলোতে struct-টা আছে, সেগুলো হুবহু কপি করে দিন।” এটাই memcpy বা সরাসরি fwrite(&record, ...) — আর এই লেসনে আমরা দেখব এই সরল উত্তরটা কেন প্রায়ই ভুল, এবং কখনো কখনো একটা real security vulnerability।

সমস্যাটার মূলে একটা গভীর সত্য আছে, যেটা এই পুরো module-এর থিমের সরাসরি সম্প্রসারণ। যদি struct-এ কোনো pointer/reference থাকে (যেমন একটা linked list-এর next field, বা একটা string-এর জন্য heap-এ allocated buffer-এর address), সেই pointer-টা একটা নির্দিষ্ট virtual address — যেটা শুধু বর্তমান process-এর জন্যই অর্থবহ। একই প্রোগ্রাম দ্বিতীয়বার চালালে ASLR (address space layout randomization)-এর কারণে সেই address বদলে যায়; অন্য একটা মেশিনে তো সেটা সম্পূর্ণ অর্থহীন — সেখানে সেই address-এ হয়তো ভিন্ন কোনো প্রোগ্রামের ভিন্ন কোনো ডেটা আছে, অথবা কিছুই নেই।

তাই memory-তে থাকা একটা structure-কে disk-এ বা network-এ পাঠানোর আগে সেটাকে এমন একটা রূপে আনতে হয় যেটা স্বয়ংসম্পূর্ণ — কোনো external pointer-নির্ভরতা ছাড়াই, শুধু byte-এর একটা linear sequence, যেটা থেকে মূল structure-টা আবার হুবহু পুনর্গঠন করা যায়। এই রূপান্তরের নামই serialization (“flatten করা”), আর বিপরীত দিকের রূপান্তরের নাম deserialization

এই module-এর শেষ লেসনে আমরা আগের প্রতিটা লেসনের টুকরো একসাথে জড়ো করব — integer representation, IEEE 754 float, UTF-8 string, আর গত লেসনের endianness — সবগুলো মিলে একটাই প্রশ্নের উত্তর দেবে: একটা in-memory object-কে flat byte-এ রূপান্তর করে আবার হুবহু ফেরত পাওয়া যায় কীভাবে, নির্ভরযোগ্যভাবে, বিভিন্ন মেশিনের মধ্যে?

মূল ধারণা

Serialization মানে কী

Serialization: একটা in-memory data structure-কে একটা linear byte sequence-এ রূপান্তর করা, যেটা disk-এ লেখা যায় বা network-এ পাঠানো যায়।

Deserialization: সেই byte sequence থেকে মূল data structure পুনর্গঠন করা — একই process-এ, একই মেশিনে, বা সম্পূর্ণ ভিন্ন কোথাও।

এই দুইটা ফাংশন একে অপরের সাথে একটা contract-এ আবদ্ধ — একটা নির্দিষ্ট নিয়ম (schema/format) মেনে লেখা হয়েছে ধরে নিয়েই deserializer সেই নিয়ম মেনে পড়ে। এই নিয়মটাই “format”।

Struct padding — recap ও সম্প্রসারণ

bits-bytes-words লেসনে আমরা সংক্ষেপে দেখেছিলাম CPU সাধারণত একটা n-byte type-কে n-এর গুণিতক address-এ রাখতে “পছন্দ” করে — এটাকে বলে alignment। এখানে আমরা এর পুরো প্রভাবটা দেখব।

#include \<stdio.h>
#include \<stddef.h>

struct A {
    char a;    // 1 byte
    int  b;    // 4 byte, 4-byte alignment দরকার
    char c;    // 1 byte
};

int main(void) {
    printf("sizeof(struct A) = %zu\n", sizeof(struct A));
    printf("  offsetof(a) = %zu\n", offsetof(struct A, a));
    printf("  offsetof(b) = %zu\n", offsetof(struct A, b));
    printf("  offsetof(c) = %zu\n", offsetof(struct A, c));
    return 0;
}

x86-64-এ (gcc, default alignment) output:

sizeof(struct A) = 12
  offsetof(a) = 0
  offsetof(b) = 4
  offsetof(c) = 8

a (1 byte) address 0-এ বসে। b (4 byte int)-কে address 4-এর গুণিতকে বসাতে হবে বলে compiler 1, 2, 3 address-এ ৩ byte padding ঢুকিয়ে দেয় — programmer কিছু লেখেনি, compiler নিজেই এই byte-গুলো “ফাঁকা” রেখে দেয়। b শেষ হয় address 8-এ। c (1 byte) address 8-এ বসে, শেষ হয় 9-এ। কিন্তু পুরো struct-এর size 12 কেন, 9 না?

কারণ struct-এর নিজেরও একটা alignment requirement আছে — এটা তার সবচেয়ে বেশি alignment-দরকার member-এর (এখানে int, ৪-byte) সমান। যদি এই struct-টার একটা array বানানো হয় (struct A arr[10]), প্রতিটা পরের element-ও ৪-byte boundary-তে align থাকতে হবে — তাই sizeof সবসময় সেই alignment-এর গুণিতকে round up হয়। ফলে address 9, 10, 11-এও আরও ৩ byte padding, আর মোট size 12

মোট padding: ৬ byte, দরকারি ডেটা মাত্র ৬ byte (1+4+1) — অর্ধেক জায়গা নষ্ট।

Field reorder করে padding কমানো — একটা বাস্তব দক্ষতা

struct B {
    int  b;    // 4 byte — সবচেয়ে বড় type আগে
    char a;    // 1 byte
    char c;    // 1 byte
};
sizeof(struct B) = 8
  offsetof(b) = 0
  offsetof(a) = 4
  offsetof(c) = 5

b address 0-এ (এমনিতেই aligned), a address 4-এ, c address 5-এ — তারপর মাত্র 2 byte padding (address 6-7) struct-এর নিজস্ব ৪-byte alignment পূরণ করতে। মোট size 8, আগেরটার চেয়ে ৩৩% কম — শুধু field-এর ক্রম বদলে, কোনো data হারানো ছাড়াই।

Padding-এর existence-ই বলে দেয় “struct-এর raw byte” আর “struct-এ থাকা প্রকৃত ডেটা” এক জিনিস না — কিছু byte নিছক filler, যেগুলোর মান compiler কখনো নির্ধারণ করে না। এই পার্থক্যটাই পরের section-এর কেন্দ্রীয় সমস্যা।

ভেতরে কী ঘটছে

যেখানে naive serialization ভেঙে পড়ে

১. Padding byte-এ থাকে random garbage — আর সেটা leak হতে পারে

struct A তৈরি হওয়ার সময় compiler a, b, c-এর জন্য জায়গা বরাদ্দ করে, কিন্তু padding byte-গুলোর (address 1-3, 9-11) মান নিয়ে কোনো commitment দেয় না — সেগুলোতে যা আগে সেই memory-তে ছিল তাই থেকে যায়। Stack-এ allocate হওয়া struct-এর ক্ষেত্রে এটা প্রায়ই আগের কোনো function call-এর local variable-এর অবশিষ্টাংশ, বা এমনকি অন্য কোনো sensitive ডেটা (password, key, অন্য request-এর data) হতে পারে।

struct packet {
    char  type;      // 1 byte
    int   payload;   // 4 byte — 3 byte padding এর আগে
};

void send_packet(int sock, char type, int payload) {
    struct packet p;
    p.type = type;
    p.payload = payload;
    // p-এর ৩টা padding byte কখনো set করা হয়নি!
    send(sock, &p, sizeof(p), 0);   // ❌ ওই ৩টা garbage byte-ও পাঠানো হচ্ছে
}

send() পুরো sizeof(p) byte পাঠায় — যার মধ্যে ৩টা byte কখনো programmer সেট করেনি। সেই byte-গুলোতে যা ছিল (আগের কোনো function-এর stack data, সম্ভবত অন্য user-এর sensitive তথ্য) হুবহু network-এ চলে যায়।

২. Endianness mismatch — গত লেসনের সরাসরি ধারাবাহিকতা

struct packet-এর payload field মেশিনের native byte order-এ থাকে। এই raw byte যদি একটা ভিন্ন-endianness মেশিনে পড়া হয় (বা network-এ পাঠানো হয় বিনা conversion-এ), payload-এর মান সম্পূর্ণ ভুল হয়ে যাবে — এটা আমরা গত লেসনে বিস্তারিত দেখেছি। memcpy-ভিত্তিক serialization এই সমস্যাটাকে আরও খারাপ করে, কারণ পুরো struct-টাই এক ধাক্কায় copy হয় — প্রতিটা multi-byte field-ই আলাদাভাবে ঝুঁকিতে থাকে, আর কোনোটাতেই explicit conversion হয় না।

৩. Struct layout নিজেই portable না — শুধু byte order না, পুরো layout

Padding-এর নিয়ম architecture-নির্ভর (৩২-বিট বনাম ৬৪-বিট আলাদা alignment চাইতে পারে), compiler-নির্ভর (#pragma pack, __attribute__((packed)) দিয়ে বদলানো যায়), এমনকি compiler flag-নির্ভর। তার মানে — একই C source code দুইটা ভিন্ন compiler দিয়ে কম্পাইল করলে, বা একই compiler-এর ভিন্ন version দিয়ে, একই struct-এর আলাদা sizeof এবং আলাদা byte layout হতে পারে। এই সমস্যাটা শুধু ভিন্ন মেশিনের মধ্যে না, এমনকি একই মেশিনে দুইটা ভিন্নভাবে কম্পাইল করা প্রোগ্রামের মধ্যেও ঘটতে পারে — এটাই ABI (Application Binary Interface) mismatch-এর একটা ক্লাসিক উৎস।

৪. Shallow copy — pointer field-কে “value” ভেবে ভুল করা

আরেকটা সূক্ষ্ম আর অত্যন্ত সাধারণ ভুল: struct-এ যদি একটা char *name (string-এর জন্য heap-allocated pointer) থাকে, memcpy সেই pointer-এর মান (একটা address) কপি করে, string-এর বিষয়বস্তু না:

struct BadRecord {
    uint32_t id;
    char    *name;   // heap-এ allocated string-এর দিকে pointer
};

struct BadRecord r = { .id = 1, .name = strdup("Alice") };
send(sock, &r, sizeof(r), 0);   // ❌ শুধু pointer-এর সংখ্যাটাই পাঠানো হলো!

sizeof(r) এখানে শুধু 12 বা 16 byte (একটা uint32_t + একটা pointer, প্ল্যাটফর্মভেদে) — "Alice" স্ট্রিং-এর 5 byte কোথাও নেই এই buffer-এ। Receiver যা পায় তা হলো sender-এর heap-এর একটা random-দেখতে address — যেটা তার নিজের address space-এ অর্থহীন, ঠিক “check” section-এর Q1-এ আলোচিত linked-list next pointer-এর মতোই। এই bug টা প্রায়ই ধরা পড়ে দেরিতে, কারণ compile করে, চলে, crash করে না — শুধু ভুল (বা garbage) ডেটা পাঠায়। সঠিক সমাধান সবসময় একই: pointer না, pointer-এর গন্তব্যে থাকা প্রকৃত ডেটা (এখানে string-এর byte-গুলো, length-prefix সহ) explicit ভাবে serialize করা — যেটা “example” section-এ আমরা করেছি।

fwrite(&record, ...) আসলে কী কী ঝুঁকি বহন করে
  1. struct Record record = {...}উৎস কোডে একটা সম্পূর্ণ, অর্থবহ object — এখানে সব ঠিক আছে মনে হয়
  2. compiler layout সিদ্ধান্তpadding কোথায়, কত byte — compiler/flag/architecture-নির্ভর, invisible
  3. padding byte initialize?না — সেগুলোতে stack/heap-এর পুরনো ডেটা থেকে যায়
  4. multi-byte field byte orderCPU-র native order — গত লেসনের সমস্যা, এখানে conversion নেই
  5. fwrite(&record, sizeof(record), 1, f)raw memory হুবহু কপি — padding-সহ, endianness-সহ
  6. ফাইলে যা গেলgarbage byte + wrong-endian byte + এই মেশিনের নির্দিষ্ট layout — কিছুই portable না
  7. অন্য মেশিনে পড়া হলেভুল sizeof, ভুল offset, ভুল byte order — silent corruption, কোনো error ছাড়াই

সংক্ষেপে: memcpy/fwrite দিয়ে সরাসরি struct dump করা একবার লিখলেই মনে হয় “কাজ করছে” — কারণ আপনি একই মেশিনে লিখছেন আর পড়ছেন, তাই padding garbage আর byte order দুটোই কাকতালীয়ভাবে মিলে যায়। সমস্যাটা প্রকাশ পায় শুধু তখনই যখন ভিন্ন কোনো প্রেক্ষাপটে (ভিন্ন মেশিন, ভিন্ন compiler, ভবিষ্যতের একটা compiler upgrade, বা আক্রমণকারীর network sniffing) সেই ফাইল/packet পড়া হয় — ঠিক যেভাবে আগের লেসনে little-endian-থেকে-little-endian টেস্টিং একটা htonl bug আড়াল করে রাখে।

উদাহরণ

একটা সম্পূর্ণ ডিজাইন — ধাপে ধাপে

ধরুন আমাদের একটা record serialize করতে হবে: {id: u32, score: f64, name: string} — একজন খেলোয়াড়ের ID, স্কোর, নাম।

বিপজ্জনক উপায় (যা করা উচিত না)

struct Player {
    uint32_t id;
    double   score;
    char     name[32];   // fixed-size buffer — নাম ৩২ byte-এর বেশি হলে কী হবে?
};
fwrite(&player, sizeof(player), 1, f);

সমস্যা: fixed-size name[32] — ছোট নামে জায়গা নষ্ট, বড় নামে (Unicode/UTF-8-এ বাংলা নাম আরও দ্রুত এই সীমা ছাড়ায়, কারণ প্রতিটা অক্ষর একাধিক byte) truncation বা buffer overflow। প্লাস padding, endianness — আগের section-এর সব সমস্যা।

ডিজাইন করা বাইনারি লেআউট

এর বদলে আমরা প্রতিটা field হাতে-কলমে, explicit byte order-এ প্যাক করব — কোনো compiler-নির্ধারিত layout-এর উপর নির্ভর না করে:

OffsetLengthFieldফরম্যাট
04 byteiduint32, little-endian
48 bytescoreIEEE 754 double, little-endian
124 bytename_lenuint32, little-endian — নামের byte length
16name_len bytenameUTF-8 encoded bytes, কোনো null terminator ছাড়া

মোট size = 16 + name_len — কোনো fixed limit নেই, কোনো padding নেই, প্রতিটা byte-এর অর্থ স্পষ্টভাবে সংজ্ঞায়িত।

খেয়াল করুন এখানে আগের প্রতিটা লেসনের একটা টুকরো ব্যবহার হচ্ছে:

  • id — unsigned integer representation লেসনের সরাসরি প্রয়োগ
  • score — IEEE 754 লেসনের bit layout (sign, exponent, mantissa) অবিকৃতভাবে byte-এ বসছে
  • byte order — গত লেসনের endianness সিদ্ধান্ত (এখানে little-endian বেছে নেওয়া হয়েছে, কারণ আজকের বেশিরভাগ endpoint-ই little-endian)
  • name — Unicode/UTF-8 লেসনের encoding নিয়ম, প্রতিটা অক্ষর ১-৪ byte-এ রূপান্তরিত

কেন name_len দরকার — length-prefixing

String-এর length আগে থেকে জানা নেই (নাম ছোট বা বড় হতে পারে), তাই deserializer-কে জানাতে হবে ঠিক কতগুলো byte পড়তে হবে। দুইটা সাধারণ কৌশল:

  1. Length-prefix (আমরা যেটা বেছেছি) — string-এর আগে তার byte length লিখে দেওয়া। Reader প্রথমে length পড়ে, তারপর ঠিক ততগুলো byte পড়ে।
  2. Delimiter/terminator — C string-এর মতো একটা বিশেষ byte (\0) দিয়ে শেষ চিহ্নিত করা। সমস্যা: যদি string-এর মধ্যেই সেই byte থাকতে পারে (যেমন raw binary data), delimiter পদ্ধতি ব্যর্থ হয়। UTF-8 টেক্সটে \0 কখনো আসে না (ASCII কম্প্যাটিবিলিটির কারণে), তাই টেক্সটের জন্য এটা প্রযোজ্য, কিন্তু arbitrary binary data-র জন্য নয়।

Length-prefix পদ্ধতি বেশি সাধারণ আর নির্ভরযোগ্য — এটাই বেশিরভাগ বাইনারি ফরম্যাট (protobuf, MessagePack, এমনকি নেটওয়ার্ক প্রোটোকলের অনেক অংশ) ব্যবহার করে।

ফরম্যাট ডিজাইনের বিস্তৃত জগৎ

আমাদের ডিজাইন করা লেআউট একটা fixed binary format-এর উদাহরণ। কিন্তু এটা একমাত্র বিকল্প না — তিনটা মূল category আছে:

Fixed binary (আমাদের ডিজাইন):
  [04 bytes id][08 bytes score][04 bytes len][N bytes utf8 name]
  → কোনো field name নেই, কোনো type marker নেই — schema শুধু কোডেই থাকে

Self-describing binary (protobuf-স্টাইল, tag-length-value):
  [tag=1,type=varint][id][tag=2,type=64bit][score][tag=3,type=len-delim][len][name]
  → প্রতিটা field-এর সাথে তার নিজের identity বহন করে

Text (JSON):
  {"id": 42, "score": 98.5, "name": "কম্পিউটার"}
  → সম্পূর্ণ human-readable, field name literally লেখা আছে
তিনটা ফরম্যাট category — একই {id, score, name} record, তিনভাবে এনকোড করা।
বৈশিষ্ট্যFixed binarySelf-describing binary (protobuf/MessagePack)Text (JSON)
গতি (encode/decode)সবচেয়ে দ্রুতদ্রুতসবচেয়ে ধীর
আকারসবচেয়ে ছোটছোট-মাঝারিসবচেয়ে বড়
মানুষ পড়তে পারে?নানা (টুল ছাড়া)হ্যাঁ
Schema evolutionভঙ্গুর — যেকোনো পরিবর্তনে ভাঙেভালো — নতুন optional field যোগ করা যায়সবচেয়ে নমনীয় — arbitrary key
প্রয়োজনসব সংস্করণে schema মিলতেই হবেfield tag number-এর disciplineকিছুই না — key না পেলে default
উদাহরণ ব্যবহারgame save file, memory-mapped I/O, custom protocolgRPC, Kafka message, Cap’n ProtoREST API, config file, log

আমাদের {id, score, name} record-এর আকার তিন ফরম্যাটে আনুমানিক তুলনা (name = "কম্পিউটার", যেটার UTF-8 byte length আমরা “check” section-এ হিসাব করব):

ফরম্যাটআনুমানিক byte
Fixed binary~৪৩
Protobuf (tag overhead সহ)~৪৫–৫০
JSON (UTF-8, keys-সহ)~৭০–৮০

ছোট record-এ পার্থক্য নগণ্য মনে হতে পারে, কিন্তু লক্ষ লক্ষ record (একটা database export, একটা high-throughput message queue) স্কেলে এই পার্থক্যটাই storage/bandwidth cost-এ বাস্তব প্রভাব ফেলে।

Self-describing format-এর একটা কৌশল — varint

Protobuf-এর মতো self-describing format ছোট সংখ্যাকে সবসময় fixed ৪ বা ৮ byte দিয়ে না প্যাক করে variable-length integer (varint) ব্যবহার করে — যাতে ছোট মান (যেমন id = 1) মাত্র ১ byte-এ আঁটে, ফিক্সড ৪ byte অপচয় না করে।

মূল কৌশল: প্রতিটা byte-এর ৭টা bit আসল সংখ্যার অংশ বহন করে, বাকি ১টা bit (সবচেয়ে গুরুত্বপূর্ণ bit, MSB) হলো একটা continuation flag1 মানে “আরও byte আসছে”, 0 মানে “এটাই শেষ byte”:

সংখ্যা 1  (৭ bit-এই আঁটে):
  0000 0001                          → ১ byte: 0x01

সংখ্যা 300 (৭ bit-এ আঁটে না, ২ byte লাগে):
  300 বাইনারিতে: 1 0010 1100
  নিচের ৭ bit:   010 1100 → continuation bit ১ (আরও আছে) → 1010 1100 = 0xAC
  বাকি bit:      10 (শুধু ২ bit) → continuation bit ০ (শেষ) → 0000 0010 = 0x02
  encoded:       0xAC 0x02   (২ byte)

এই কারণেই একটা protobuf message-এ ছোট field id/ছোট সংখ্যার জন্য প্রায় কোনো overhead-ই লাগে না, কিন্তু বড় সংখ্যার জন্য যতটা byte দরকার ততটাই ব্যবহার হয় — একটা fixed ৪-byte int সবসময় ৪ byte নেয়, চাই মান 1 হোক বা 1,000,000,000। Varint-এর খরচ: decode করতে প্রতিটা byte-এর continuation bit check করে loop চালাতে হয়, তাই fixed-width read-এর চেয়ে সামান্য বেশি CPU লাগে — এটাই “self-describing, compact” বনাম “fixed, সরল ও দ্রুততম” trade-off-এর আরেকটা রূপ।

নিজে চালিয়ে দেখুন

EXPERIMENT

Padding নিজে চোখে দেখুন — sizeof আর offsetof

C (gcc/clang), যেকোনো OS· ১০ মিনিট
#include \<stdio.h>
#include \<stddef.h>
#include \<stdint.h>

struct Unordered {
    char     a;
    uint64_t b;
    char     c;
    uint32_t d;
    char     e;
};

struct Ordered {
    uint64_t b;
    uint32_t d;
    char     a;
    char     c;
    char     e;
};

int main(void) {
    printf("struct Unordered:\n");
    printf("  sizeof = %zu\n", sizeof(struct Unordered));
    printf("  offsets: a=%zu b=%zu c=%zu d=%zu e=%zu\n",
        offsetof(struct Unordered, a), offsetof(struct Unordered, b),
        offsetof(struct Unordered, c), offsetof(struct Unordered, d),
        offsetof(struct Unordered, e));

    printf("\nstruct Ordered:\n");
    printf("  sizeof = %zu\n", sizeof(struct Ordered));
    printf("  offsets: b=%zu d=%zu a=%zu c=%zu e=%zu\n",
        offsetof(struct Ordered, b), offsetof(struct Ordered, d),
        offsetof(struct Ordered, a), offsetof(struct Ordered, c),
        offsetof(struct Ordered, e));

    double saved_percent = 100.0 * (sizeof(struct Unordered) - sizeof(struct Ordered))
                            / sizeof(struct Unordered);
    printf("\nসাশ্রয়: %.1f%%\n", saved_percent);

    return 0;
}
gcc -O0 -o padding_demo padding_demo.c && ./padding_demo

x86-64-এ (gcc, default alignment) প্রত্যাশিত output:

struct Unordered:
  sizeof = 32
  offsets: a=0 b=8 c=16 d=20 e=24

struct Ordered:
  sizeof = 16
  offsets: b=0 d=8 a=12 c=13 e=14

সাশ্রয়: 50.0%

ব্যাখ্যা — Unordered: a (address 0) এর পরে b (uint64_t, ৮-byte alignment দরকার)-কে address 8-এ যেতে ৭ byte padding লাগে। c (address 16) এর পরে d (uint32_t, ৪-byte alignment)-কে address 20-এ যেতে ৩ byte padding (address 17-19)। e (address 24) এর পরে struct-এর নিজস্ব ৮-byte alignment (সবচেয়ে বড় member uint64_t অনুযায়ী) পূরণ করতে আরও padding, মোট 32-এ পৌঁছায়।

ব্যাখ্যা — Ordered: সবচেয়ে বড় type (uint64_t) প্রথমে বসানোয় কোনো internal padding লাগে না প্রথম দুই field-এ। তিনটা char একসাথে শেষে বসায় মাত্র সামান্য trailing padding। ফলাফল: ঠিক অর্ধেক জায়গা।

নিজে করুন: field-এর ক্রম আরও কয়েকভাবে সাজিয়ে দেখুন sizeof কীভাবে বদলায় — এটা একটা ভালো intuition তৈরি করে যেটা যেকোনো systems-level কোড review-তে কাজে লাগবে।

এটা কী প্রমাণ করে

Struct-এর padding কোনো তাত্ত্বিক দাবি না — কম্পাইলার সত্যিই byte বরাদ্দ করে যেগুলো কোনো field-এর অংশ না, আর field-এর ক্রম বদলালে সেই বরাদ্দ পরিমাপযোগ্যভাবে বদলায়।

EXPERIMENT

Naive memcpy বনাম designed format — একটা raw comparison

Python 3· ১৫ মিনিট

Python-এ C-এর মতো raw struct packing simulate করে দেখি কীভাবে padding “অদৃশ্য” জায়গা দখল করে, আর তুলনা করি আমাদের designed length-prefixed format-এর সাথে:

import struct

# ── সিমুলেট করা "naive" C-struct-স্টাইল layout ──────────────
# struct { uint32_t id; char pad[4]; double score; char name[16]; }
# (double-কে ৮-byte align করতে uint32_t-এর পরে ৪ byte padding দরকার,
#  আর name একটা fixed ১৬-byte buffer বলে ধরে নিচ্ছি — বাস্তব বাগ demonstrate করতে)

def naive_pack(id_: int, score: float, name: str) -> bytes:
    name_bytes = name.encode('utf-8')
    if len(name_bytes) > 16:
        raise ValueError("naive format-এ ১৬ byte-এর বেশি নাম আঁটে না!")
    padded_name = name_bytes.ljust(16, b'\x00')
    return struct.pack('\<I4xd16s', id_, score, padded_name)
    #                    ^^^^^^^^^^ explicit padding দেখানো হচ্ছে (4x)

def designed_pack(id_: int, score: float, name: str) -> bytes:
    name_bytes = name.encode('utf-8')
    header = struct.pack('\<Id', id_, score)
    length_prefix = struct.pack('\<I', len(name_bytes))
    return header + length_prefix + name_bytes


# ── তুলনা ────────────────────────────────────────────────
tests = [
    (1, 98.5, "Alice"),
    (2, 76.25, "কম্পিউটার"),     # বাংলা — UTF-8-এ multi-byte
]

for id_, score, name in tests:
    naive = naive_pack(id_, score, name)
    designed = designed_pack(id_, score, name)
    print(f"name={name!r}")
    print(f"  naive   : {len(naive):3} byte  {naive.hex(' ')}")
    print(f"  designed: {len(designed):3} byte  {designed.hex(' ')}")
    print()

প্রত্যাশিত output:

name='Alice'
  naive   :  32 byte  01000000 00000000 00405f40 00000000 416c6963 65000000 00000000 00000000
  designed:  17 byte  01000000 00000000 00405f40 05000000 416c696365

name='কম্পিউটার'
  designed:  43 byte  02000000 0000... (id, score, তারপর 04 00 00 00 এর বদলে name_len=27, তারপর 27 byte UTF-8)

(দ্রষ্টব্য: বাংলা নামের জন্য naive_pack আসলে ValueError তুলবে — কারণ "কম্পিউটার"-এর UTF-8 encoding ২৭ byte, যেটা ১৬-byte fixed buffer-এ আঁটে না। এটাই fixed-size buffer-এর বাস্তব সীমাবদ্ধতা, বিশেষ করে বহু-বাইট script-এ।)

দুইটা পর্যবেক্ষণ:

  1. naive format-এ সবসময় ৩২ byte, নাম ছোট হোক ("Alice", মাত্র ৫ byte দরকার) বা লম্বা — বাকি জায়গা \x00 দিয়ে ভরাট, অপচয়। designed format-এ শুধু যতটুকু দরকার ততটুকুই — "Alice"-এর জন্য মাত্র 17 byte।
  2. naive format একটা hard limit চাপিয়ে দেয় (১৬ byte-এর বেশি নাম আঁটে না) — যেটা বাংলা, বা যেকোনো non-ASCII script-এ দ্রুত সমস্যা তৈরি করে, কারণ UTF-8-এ একটা “অক্ষর” (grapheme) কয়েক byte নিতে পারে। designed format-এ কোনো limit নেই — length প্রথমে ঘোষিত, তারপর ঠিক ততটুকু byte পড়া হয়।
এটা কী প্রমাণ করে

একই record দুইভাবে serialize করলে — raw struct-style বনাম designed binary format — byte-for-byte পার্থক্য স্পষ্ট দেখা যায়, এবং designed format-এর deterministic, portable আকার প্রমাণ হয়।

নিজে বানান

BUILD IT

নিজের বাইনারি serializer/deserializer লিখুন — মডিউলের সমন্বয় প্রকল্প

Python (C-তেও করা যায়) · ●●●○○
  1. একটা serialize_record(id, score, name) ফাংশন লিখুন যা {id: u32, score: f64, name: string}-কে bytes-এ রূপান্তর করে, "example" section-এর লেআউট অনুসরণ করে
  2. একটা deserialize_record(data: bytes) ফাংশন লিখুন যা সেই bytes থেকে (id, score, name) ফেরত দেয়
  3. যাচাই করুন deserialize_record(serialize_record(id, score, name)) == (id, score, name) সব input-এ
  4. ইংরেজি আর বাংলা দুই ধরনের নাম দিয়ে পরীক্ষা করুন — UTF-8-এর ভিন্ন byte-length handle হচ্ছে কি না
  5. একটা list of records serialize করে একটা "mini file format" বানান — একটা record-count header যোগ করে

আমরা “example” section-এর লেআউট হুবহু বাস্তবায়ন করব, কিন্তু এবার raw byte-level অপারেশন দিয়ে (কোনো struct.pack জাদু ছাড়া) — যাতে প্রতিটা ধাপ ঠিক কী করছে সেটা সম্পূর্ণ স্পষ্ট থাকে।

import struct


def serialize_record(id_: int, score: float, name: str) -> bytes:
    """{id: u32, score: f64, name: string} → bytes
    Layout: [4B id LE][8B score LE f64][4B name_len LE][name_len B utf8]
    """
    if not (0 \<= id_ \< 2**32):
        raise ValueError("id একটা u32-এ আঁটতে হবে")

    id_bytes = id_.to_bytes(4, byteorder='little', signed=False)

    # IEEE 754 double — struct module ব্যবহার করছি, কিন্তু আগের লেসনের
    # জ্ঞান দিয়ে আমরা জানি এটা আসলে sign+exponent+mantissa-কে
    # ৮টা byte-এ প্যাক করছে, little-endian ক্রমে
    score_bytes = struct.pack('\<d', score)

    name_bytes = name.encode('utf-8')
    name_len_bytes = len(name_bytes).to_bytes(4, byteorder='little', signed=False)

    return id_bytes + score_bytes + name_len_bytes + name_bytes


def deserialize_record(data: bytes) -> tuple[int, float, str]:
    """bytes → (id, score, name) — serialize_record-এর ঠিক বিপরীত"""
    if len(data) \< 16:
        raise ValueError("record অন্তত ১৬ byte হতে হবে (header)")

    id_ = int.from_bytes(data[0:4], byteorder='little', signed=False)
    score = struct.unpack('\<d', data[4:12])[0]
    name_len = int.from_bytes(data[12:16], byteorder='little', signed=False)

    if len(data) \< 16 + name_len:
        raise ValueError(f"name-এর জন্য {name_len} byte দরকার, কিন্তু data-তে যথেষ্ট নেই")

    name = data[16:16 + name_len].decode('utf-8')
    return (id_, score, name)


# ── যাচাই: round-trip সব ধরনের input-এ ─────────────────────
test_records = [
    (1, 98.5, "Alice"),
    (2, 76.25, "Bob"),
    (3, 100.0, "কম্পিউটার বিজ্ঞান"),   # বাংলা, multi-byte UTF-8
    (4, 0.0, ""),                        # খালি নাম — edge case
    (5, -3.14159, "Zero-Cool"),          # negative float
]

for id_, score, name in test_records:
    packed = serialize_record(id_, score, name)
    id2, score2, name2 = deserialize_record(packed)

    assert id_ == id2, f"id ভাঙল: {id_} != {id2}"
    assert score == score2, f"score ভাঙল: {score} != {score2}"
    assert name == name2, f"name ভাঙল: {name!r} != {name2!r}"

    print(f"✓ id={id_:2} score={score:9.4f} name={name!r:22}{len(packed):3} byte")

print("\nসব record সফলভাবে round-trip হলো।")

প্রত্যাশিত output:

✓ id= 1 score=  98.5000 name='Alice'               →  21 byte
✓ id= 2 score=  76.2500 name='Bob'                  →  19 byte
✓ id= 3 score= 100.0000 name='কম্পিউটার বিজ্ঞান'    →  16+N byte  (N = UTF-8 byte length)
✓ id= 4 score=   0.0000 name=''                     →  16 byte
✓ id= 5 score=  -3.1416 name='Zero-Cool'             →  25 byte

সব record সফলভাবে round-trip হলো।

সম্প্রসারণ — একটা mini “file format” (একাধিক record)

একটা single record যথেষ্ট শক্তিশালী না — বাস্তব ফাইল ফরম্যাটে অনেক record থাকে। একটা record-count header যোগ করে একটা সম্পূর্ণ “file” বানানো যায়:

MAGIC = b'PREC'   # "Player RECord" — নিজস্ব magic bytes, ফাইলের পরিচয়

def serialize_file(records: list[tuple[int, float, str]]) -> bytes:
    out = MAGIC
    out += len(records).to_bytes(4, 'little')
    for id_, score, name in records:
        out += serialize_record(id_, score, name)
    return out

def deserialize_file(data: bytes) -> list[tuple[int, float, str]]:
    if data[0:4] != MAGIC:
        raise ValueError("এটা একটা বৈধ PREC ফাইল না — magic bytes মেলেনি")
    count = int.from_bytes(data[4:8], 'little')

    records = []
    offset = 8
    for _ in range(count):
        # প্রথমে name_len পড়ে বুঝতে হবে এই record কত byte
        name_len = int.from_bytes(data[offset+12:offset+16], 'little')
        record_size = 16 + name_len
        records.append(deserialize_record(data[offset:offset + record_size]))
        offset += record_size

    return records


packed_file = serialize_file(test_records)
recovered = deserialize_file(packed_file)
assert recovered == test_records
print(f"\n{len(test_records)}টা record, মোট {len(packed_file)} byte-এর ফাইল, সবগুলো সঠিকভাবে ফেরত এলো।")

লক্ষ্য করুন — MAGIC byte-গুলো গত লেসনের TIFF II/MM কৌশলের ঠিক একই idea: ফাইল নিজেই তার পরিচয় ঘোষণা করছে, যাতে একটা reader ভুল ফরম্যাটের ফাইল খুলে বিভ্রান্ত না হয়ে সরাসরি error দিতে পারে।

নিজে বাড়ান:

  1. একটা version byte যোগ করুন MAGIC-এর ঠিক পরে, আর deserialize_file-কে version অনুযায়ী ভিন্নভাবে parse করান — এটাই schema evolution-এর সবচেয়ে সরল রূপ
  2. একটা checksum (যেমন সহজ sum-of-bytes, বা zlib.crc32) যোগ করুন ফাইলের শেষে, আর যাচাই করুন data corrupt হলে সেটা ধরা পড়ছে কি না
  3. তুলনা করুন আপনার format-এর আকার আর json.dumps(records).encode('utf-8')-এর আকার — কতটা পার্থক্য?
  4. C-তে একই serializer লিখুন, memcpy/raw pointer arithmetic দিয়ে — Python-এর automatic bounds-checking ছাড়া bug লেখা কতটা সহজ সেটা অনুভব করুন

C-তে serialize_record-এর একটা সম্ভাব্য কাঠামো (শুরু করার জন্য):

#include \<stdint.h>
#include \<string.h>
#include \<stdlib.h>

// buffer-এ কতটুকু জায়গা লাগবে আগেই হিসাব করা জরুরি —
// Python-এ list/bytes নিজে growable, C-তে caller-কে নিজে ভাবতে হয়
size_t record_size(const char *name) {
    return 4 + 8 + 4 + strlen(name);   // id + score + name_len + name bytes
}

uint8_t *serialize_record_c(uint32_t id, double score, const char *name, size_t *out_len) {
    size_t name_len = strlen(name);
    size_t total = 4 + 8 + 4 + name_len;
    uint8_t *buf = malloc(total);      // caller-কে free() করতে হবে — leak-এর ঝুঁকি!
    if (!buf) return NULL;

    size_t offset = 0;
    memcpy(buf + offset, &id, 4);       offset += 4;   // ⚠ এখানেও native byte order-এর ঝুঁকি,
    memcpy(buf + offset, &score, 8);    offset += 8;   //   htole32/htole64 ব্যবহার করা উচিত ছিল
    uint32_t nlen = (uint32_t)name_len;
    memcpy(buf + offset, &nlen, 4);     offset += 4;
    memcpy(buf + offset, name, name_len);

    *out_len = total;
    return buf;
}

লক্ষ্য করুন কোড-টা ইচ্ছাকৃতভাবে অসম্পূর্ণ রাখা হয়েছে — এখানে memcpy(buf + offset, &id, 4) লেখাটা আসলে গত লেসনের সেই একই bug reintroduce করছে (native byte order, কোনো explicit htole32 conversion ছাড়া)। এটাই আসল শিক্ষা: C-তে raw pointer/memcpy দিয়ে serialization লেখা সহজে ভুল হয় — বাউন্ডস-চেকিং নেই (buf + offset কখনো ভুল হিসাব হলে buffer overflow), memory leak-এর ঝুঁকি (malloc করা memory কে free করতে হবে, caller ভুলে গেলে leak), আর endianness bug আবার ফিরে আসার সুযোগ। এই ফাংশনটাকে সঠিক করুন — প্রতিটা memcpy-কে htole32/htole64-দিয়ে wrap করে, আর একটা matching free_record_buffer() লিখে।

বাস্তব সিস্টেমে

বাস্তব সিস্টেমে serialization

Protocol Buffers (gRPC-এর ভিত্তি, Google)। Tag-length-value (TLV) wire format — প্রতিটা field-এর একটা numbered “tag” থাকে (.proto ফাইলে ডেভেলপার নিজে সংজ্ঞায়িত করেন)। Reader যদি কোনো tag না চেনে (পুরনো code, নতুন field), সেটা নিঃশব্দে skip করে — এটাই forward compatibility-র চাবিকাঠি। Field type varint (variable-length integer) encoding ব্যবহার করে ছোট সংখ্যাকে কম byte-এ প্যাক করে।

MessagePack। “Binary JSON” — একই data model (object, array, string, number) কিন্তু compact binary encoding-এ। Redis-এর কিছু ecosystem tool, Fluentd log forwarding, MessagePack-RPC-তে ব্যবহৃত হয়। JSON-এর নমনীয়তা রাখে, কিন্তু আকার ছোট আর parse দ্রুত।

JSON REST API। প্রায় প্রতিটা আধুনিক web API — কারণ human-readable, debug করা সহজ (browser DevTools-এ সরাসরি পড়া যায়), আর প্রতিটা ভাষায় parser আছে। খরচ: আকার বড়, parsing ধীর (যদিও simdjson-এর মতো আধুনিক parser SIMD ব্যবহার করে এই gap অনেকটা কমিয়েছে)।

Cap’n Proto / FlatBuffers। একধাপ এগিয়ে — এই ফরম্যাটগুলো ডিজাইন করা হয়েছে যাতে কোনো parsing step-ই না লাগে। Wire format-ই সরাসরি সেই language-এর memory layout, তাই deserialization মানে শুধু byte buffer-এর উপর একটা pointer/accessor বসানো — কোনো copy, কোনো decode loop নেই। Game engine আর high-frequency trading-এ latency-critical জায়গায় ব্যবহৃত হয়।

Apache Avro (Hadoop ecosystem)। একটা ভিন্ন দর্শন — Avro data-এর সাথে কোনো tag/field-name বহন করে না, বরং schema সম্পূর্ণ আলাদাভাবে সংরক্ষণ করে (প্রায়ই একটা schema registry-তে), আর reader/writer উভয়েই সেই schema আগে থেকে জানে। এতে data আরও compact হয় (protobuf-এর tag overhead-ও নেই), কিন্তু data নিজে থেকে self-describing না — schema ছাড়া raw byte সম্পূর্ণ অর্থহীন। Big data pipeline-এ (Kafka topic-এ কোটি কোটি ছোট record) এই compactness গুরুত্বপূর্ণ, কারণ schema একবার সংরক্ষণ হলেই চলে, প্রতিটা record-এ বারবার বহন করতে হয় না।

Java-এর built-in Serializable ঐতিহাসিকভাবে জনপ্রিয় ছিল, কিন্তু একটা কুখ্যাত security সমস্যার কেন্দ্র — untrusted byte stream deserialize করার সময় object reconstruction-এর মধ্যেই attacker-নিয়ন্ত্রিত কোড চালানো সম্ভব হয়েছিল (“gadget chain” আক্রমণ, Apache Commons Collections library-কে কেন্দ্র করে ২০১৫ সালে ব্যাপকভাবে exploit হওয়া একটা প্যাটার্ন)। এটাই আমাদের এই লেসনের সতর্কতাকে আরও এক ধাপ এগিয়ে নিয়ে যায় — শুধু padding garbage-ই বিপজ্জনক না, untrusted serialized data-কে deserialize করাই নিজে একটা attack surface, যদি deserializer-কে blindly বিশ্বাস করে arbitrary object পুনর্গঠন করতে দেওয়া হয়। Level 10 (Security)-এ আমরা এই class-এর vulnerability বিস্তারিত দেখব।

Python-এর pickle নিজস্ব Python object-কে দ্রুত serialize করার সবচেয়ে সহজ উপায়, কিন্তু Python ডকুমেন্টেশন স্পষ্টভাবে সতর্ক করে: কখনো untrusted উৎস থেকে আসা pickle data unpickle করবেন না — কারণ unpickling প্রক্রিয়াটা নিজেই arbitrary code execute করতে পারে ডিজাইন অনুযায়ী। এটা Java deserialization সমস্যার সরাসরি সমান্তরাল।

Game save file format। ঐতিহাসিকভাবে অনেক game সরাসরি in-memory struct dump করে save file বানাত (এই লেসনের “বিপজ্জনক উপায়”)। ফলাফল: game-এর একটা patch যেটা কোনো struct-এ নতুন field যোগ করে, প্রায়ই পুরনো save file আর load করতে পারে না — কারণ format-এ কোনো versioning ছিল না। আধুনিক game engine (Unity, Unreal) এখন structured serialization (প্রায়ই JSON বা একটা custom TLV format) ব্যবহার করে ঠিক এই সমস্যা এড়াতে।

Database-এর on-disk row format। PostgreSQL-এর heap tuple format একটা sophisticated designed binary layout — fixed-offset field, একটা null bitmap (কোন column NULL তা track করতে, প্রতিটার জন্য পূর্ণ byte খরচ না করে), আর variable-length field-এর জন্য TOAST নামের একটা আলাদা out-of-line storage কৌশল। Level 8 (Databases)-এ আমরা এটা বিস্তারিত দেখব — এখানে যা শিখলাম (padding, alignment, length-prefixing) তার একটা industrial-strength সম্প্রসারণ।

যে ভুলগুলো সবাই করে

“memcpy দিয়ে একটা struct copy করলেই সেটা 'serialize' করা হয়ে যায়।”

না — এটাই এই লেসনের কেন্দ্রীয় সতর্কতা। memcpy/fwrite(&struct, ...) struct-এর raw memory representation copy করে, যেটাতে থাকে:

  • Padding byte — যেগুলোর মান undefined, প্রায়ই আগের কোনো ডেটার অবশিষ্টাংশ (EtherLeak-এর মতো info leak-এর ঝুঁকি)
  • Native byte order — যেটা অন্য মেশিনে ভুলভাবে interpret হতে পারে
  • Compiler/architecture-নির্দিষ্ট layout — যেটা portable না, এমনকি ভিন্ন compiler flag-এও বদলে যেতে পারে
  • Pointer, যদি struct-এ থাকে — সেটা সম্পূর্ণ অর্থহীন অন্য কোনো প্রেক্ষাপটে

প্রকৃত serialization মানে প্রতিটা field সচেতনভাবে, নির্দিষ্ট একটা contract (byte order, encoding, length-prefix) মেনে byte-এ রূপান্তর করা — কোনো compiler-নির্ধারিত internal layout-এর উপর নির্ভর না করে।

“JSON সবসময় binary format-এর চেয়ে ধীর ও অদক্ষ, তাই production-এ কখনো ব্যবহার করা উচিত না।”

ভুল সরলীকরণ। JSON নিঃসন্দেহে fixed/self-describing binary format-এর চেয়ে বড় আর (সাধারণত) ধীরে parse হয় — কিন্তু এই খরচটা প্রায়ই গুরুত্বপূর্ণ না

একটা REST API-তে network round-trip latency (কয়েক দশ মিলিসেকেন্ড) সাধারণত JSON parsing-এর খরচকে (কয়েক মাইক্রোসেকেন্ড থেকে মিলিসেকেন্ড) বহুগুণে ছাড়িয়ে যায় — তাই সেখানে JSON-এর readability আর tooling-এর সুবিধা (browser dev tools, curl | jq, সহজ debugging) প্রায়ই বাস্তব gain, আর সেই performance loss গুরুত্বহীন। আধুনিক parser (simdjson, orjson) SIMD instruction ব্যবহার করে JSON parsing-কে আরও দ্রুত করেছে।

Binary format-এর দিকে যাওয়া উচিত যখন measurement দেখায় serialization/deserialization প্রকৃতপক্ষে একটা bottleneck — high-frequency trading, লক্ষ লক্ষ message/সেকেন্ড-এর message queue, অথবা GPU/network-bound game state sync-এর মতো জায়গায়। এটা asymptotic-notation লেসনে দেখা “প্রথমে সরল লিখুন, measure করুন, তারপর optimize করুন” নীতিরই আরেকটা প্রয়োগ।

“Protobuf/binary format ব্যবহার করলেই backward compatibility 'ফ্রি' পাওয়া যায়।”

না — format নিজে শুধু compatibility সম্ভব করে, গ্যারান্টি দেয় না। এখনও discipline মানতে হয়:

  • কখনো একটা field-এর tag number reuse করবেন না, এমনকি field মুছে ফেললেও — পুরনো data-তে সেই tag-এ যা লেখা ছিল, নতুন সংজ্ঞায় সেটা ভুলভাবে interpret হতে পারে
  • নতুন field সবসময় optional হতে হবে (বা একটা sensible default থাকতে হবে) — নাহলে পুরনো data (যেখানে সেই field নেই) নতুন কোড দিয়ে পড়া যাবে না
  • কখনো একটা field-এর type বদলাবেন না (int32 থেকে string-এ) — wire format ভিন্ন, পুরনো reader crash করবে বা ভুল পড়বে
  • Field মুছে ফেললে সেই tag number “reserved” ঘোষণা করে রাখুন — protobuf-এ literally reserved keyword আছে এই ভুল রুখতে

Format একটা কাঠামো দেয় (tag-based, skippable unknown field) যেটার উপর compatibility বানানো সম্ভব — কিন্তু নিয়ম না মানলে সেই সম্ভাবনা কাজে লাগে না। এই ভুলগুলো real production system-এ regularly ঘটে, বিশেষ করে দ্রুত-চলা team-এ যেখানে schema review কড়াকড়ি না।

“'Serialize করা' মানেই একটা string/text বানানো।”

না। Serialization মানে “একটা linear byte sequence বানানো” — সেই byte sequence টেক্সট (human-readable) হতে পারে, বা raw binary (non-printable byte-সহ) হতে পারে। দুটোই সমানভাবে “serialization”।

এই misconception-টা সম্ভবত JavaScript-এর JSON.stringify() নামের কারণে এসেছে — নামের মধ্যেই “string” শব্দটা আছে, যেটা মানুষকে ভাবায় serialization মানেই stringification। কিন্তু JSON তো শুধু serialization-এর একটা পছন্দ (টেক্সট-ভিত্তিক); protobuf, MessagePack, আমাদের designed binary format — এসবও সমানভাবে বৈধ serialization, শুধু output human-readable টেক্সট না।

নিয়ম: serialization-এর প্রশ্ন দুইটা স্বাধীন সিদ্ধান্ত — (১) কী এনকোড করছি (schema/structure), আর (২) কোন byte representation-এ (text vs binary, fixed vs self-describing)। “String বানানো” এই দুইটার মধ্যে একটা মাত্র সম্ভাব্য উত্তর।

বুঝেছেন কি না দেখুন

1

একটা struct Node { int value; struct Node *next; } (linked list-এর node) সরাসরি memcpy করে network-এর ওপারে পাঠালে কী সমস্যা হবে? পুরো chain (next-এর মাধ্যমে যুক্ত সব node) কি এভাবে পাঠানো সম্ভব?

যুক্তি

একটা single node সরাসরি পাঠানো নিরর্থক, আর পুরো chain-ও এভাবে সম্ভব না।

next field একটা pointer — একটা virtual memory address, যেটা শুধু sender process-এর নিজের address space-এর মধ্যেই অর্থবহ। এই address raw byte হিসেবে receiver-কে পাঠালে receiver-এর কাছে সেটা একটা “সংখ্যা” মাত্র — receiver-এর নিজের address space-এ সেই address-এ হয়তো সম্পূর্ণ ভিন্ন কিছু আছে (অন্য কোনো variable, বা কিছুই না, বা invalid memory যেটাতে access করলে segmentation fault)।

এমনকি একই মেশিনে, প্রোগ্রামটা দ্বিতীয়বার চালালেও একই address কাজ করবে না — ASLR (Address Space Layout Randomization) প্রতিবার প্রোগ্রামের memory layout randomize করে, নিরাপত্তার জন্য (buffer overflow আক্রমণ কঠিন করতে)।

পুরো chain পাঠানোর সঠিক উপায়: pointer-ভিত্তিক structure-কে traverse করে প্রতিটা node-এর value সংগ্রহ করে একটা linear sequence বানাতে হবে — pointer বাদ দিয়ে, শুধু data:

def serialize_list(head):
    values = []
    node = head
    while node is not None:
        values.append(node.value)
        node = node.next
    # শুধু values-এর একটা list serialize করি, কোনো pointer ছাড়াই
    return serialize_int_array(values)

Receiver পাশে deserialize করার সময় নতুন করে node তৈরি করে, নিজস্ব memory address দিয়ে, আর সেই নতুন address দিয়েই নিজের next link বসায় — সংখ্যাগুলো (value) একই থাকে, কিন্তু structure পুনর্গঠিত হয় প্রতিটা প্রান্তে স্বাধীনভাবে, pointer কখনো wire-এ যায় না।

সাধারণ নীতি: serialization-এ কখনো raw pointer/memory address পাঠানো হয় না — যা পাঠানো হয় তা হলো সম্পর্ক (কোনটা কোনটার সাথে যুক্ত, index বা nesting দিয়ে প্রকাশ করা) এবং মান (আসল data), pointer নিজে না।

2

নিচের struct-টার sizeof x86-64-এ (৮-byte alignment পর্যন্ত সমর্থন করা typical alignment rule সহ) কত হবে হিসাব করুন, তারপর field reorder করে সর্বনিম্ন সম্ভব সাইজে আনুন।

struct Original {
    char     flag;      // 1 byte
    double   value;      // 8 byte
    short    count;       // 2 byte
    int      id;           // 4 byte
};
প্রয়োগ

মূল struct-এর হিসাব:

  • flag (1 byte) → address 0
  • value (double, ৮-byte alignment দরকার) → পরবর্তী ৮-এর গুণিতক address 8, তাই address 1-7 (৭ byte) padding
  • value শেষ হয় address 16-এ
  • count (short, ২-byte alignment) → address 16 ইতিমধ্যে ২-এর গুণিতক, তাই কোনো padding লাগে না, address 16-17
  • id (int, ৪-byte alignment) → পরবর্তী ৪-এর গুণিতক address 20, address 18-19 (২ byte) padding
  • id শেষ হয় address 24-এ
  • Struct-এর নিজস্ব alignment = সবচেয়ে বড় member-এর alignment = ৮ (double-এর জন্য) → total size ৮-এর গুণিতকে round up, 24 ইতিমধ্যে ৮-এর গুণিতক

sizeof(struct Original) = 24

মোট padding: 7 + 2 = 9 byte। দরকারি data: 1+8+2+4 = 15 byte। মোট 24-এর মধ্যে ৯ byte (৩৭.৫%) নষ্ট।

Reorder — বড় থেকে ছোট:

struct Reordered {
    double value;   // 8 byte, address 0
    int    id;      // 4 byte, address 8
    short  count;   // 2 byte, address 12
    char   flag;    // 1 byte, address 14
};
  • value → address 0-7, কোনো padding লাগে না (৮-এর গুণিতক থেকেই শুরু)
  • id → address 8, ইতিমধ্যে ৪-এর গুণিতক, কোনো padding নেই, শেষ 12
  • count → address 12, ইতিমধ্যে ২-এর গুণিতক, কোনো padding নেই, শেষ 14
  • flag → address 14, শেষ 15
  • Struct alignment ৮ (এখনও double আছে) → 15-কে ৮-এর গুণিতকে round up করলে 16

sizeof(struct Reordered) = 16

24 থেকে 16৩৩% সাশ্রয়, কোনো data না হারিয়ে। মূল ২৪ byte-এর মধ্যে মাত্র ১ byte trailing padding (address 15) অবশিষ্ট থাকে, যেটা এড়ানো অসম্ভব কারণ struct-এর নিজস্ব ৮-byte alignment মানতেই হবে।

3

EtherLeak-এর মতো একটা “padding information disclosure” bug ঠিক কীভাবে ঘটে ব্যাখ্যা করুন — মেমরি allocation থেকে শুরু করে network-এ ডেটা যাওয়া পর্যন্ত পুরো chain দেখান। আর বলুন কেন এই bug টেস্টিং-এ প্রায়ই ধরা পড়ে না।

যুক্তি

Chain-টা ধাপে ধাপে:

১. একটা struct/buffer allocate করা হয় (stack-এ local variable, বা heap-এ malloc) — এই মেমরি শূন্য দিয়ে initialize হয় না by default (calloc ছাড়া) — যা আগে সেই memory address-এ ছিল (আগের কোনো function call-এর data, আগের কোনো heap allocation-এর অবশিষ্টাংশ) তাই থেকে যায়।

২. প্রোগ্রামার struct-এর কিছু field explicit ভাবে set করে (যেমন p.type = 5;), কিন্তু padding byte (compiler-এর বসানো filler, প্রোগ্রামারের কাছে অদৃশ্য) কখনো touch করে না।

৩. পুরো struct/buffer একবারে send()/fwrite()/memcpy() দিয়ে পাঠানো হয় — sizeof(struct) byte, যার মধ্যে padding byte-ও আছে।

৪. Receiver (বা network-এ বসে থাকা আক্রমণকারী, packet capture করে) সেই raw byte পায় — যার মধ্যে “legitimate” field আর “leaked padding garbage” মেশানো, কোনো marker ছাড়াই আলাদা করা যায় না বাইরে থেকে।

৫. যদি এই প্যাটার্নটা বারবার ঘটে (প্রতিটা নতুন packet-এ নতুন, ভিন্ন padding garbage — কারণ প্রতিবার memory-র ভিন্ন অংশ reuse হচ্ছে), আক্রমণকারী অনেক packet জড়ো করে ধীরে ধীরে kernel/heap memory-র বড় অংশ পুনর্গঠন করতে পারে — সেই মেমরিতে যদি sensitive তথ্য (আগের কোনো connection-এর data, key material) থেকে থাকে, সেটাও leak হয়।

কেন টেস্টিং-এ ধরা পড়ে না:

  • Functional test সাধারণত শুধু যা প্রোগ্রামার ইচ্ছাকৃতভাবে set করেছে সেই field-গুলো check করে (assert p.type == 5) — padding byte-এর মান test-এর আওতার বাইরেই থাকে, কারণ কেউ padding byte নিয়ে assertion লেখে না
  • Memory-তে প্রায়ই কাকতালীয়ভাবে শূন্য থাকে (fresh process, প্রথমবার allocate হওয়া page প্রায়ই OS দ্বারা আগে থেকে zero করা — নিরাপত্তার জন্যই OS এটা করে নতুন page-এর জন্য), তাই development/test পরিবেশে padding-এ প্রায়ই 0x00 দেখা যায় — কোনো visible symptom নেই
  • সমস্যাটা প্রকাশ পায় শুধু নির্দিষ্ট memory reuse pattern-এ (আগের allocation-এর “নোংরা” মেমরি reuse হলে) — production-এর দীর্ঘ-চলা process-এ এটা টেস্ট পরিবেশের চেয়ে অনেক বেশি সম্ভাবনা, কারণ memory বহুবার allocate-free-reallocate হয়েছে

প্রতিকার: allocation-এর পরপরই পুরো buffer memset(&buf, 0, sizeof(buf)) দিয়ে শূন্য করা, অথবা (আরও ভালো ও নির্ভরযোগ্য) কখনো raw struct সরাসরি না পাঠিয়ে field-by-field explicit serialization ব্যবহার করা — যেখানে “padding” ধারণাটাই অস্তিত্বহীন, কারণ প্রতিটা byte সচেতনভাবে লেখা।

4

আপনি একটা তারবিহীন IoT sensor network-এর জন্য একটা compact binary protocol ডিজাইন করছেন, যেখানে ভবিষ্যতে নতুন sensor type/field যোগ হতে থাকবে, কিন্তু পুরনো firmware-ওয়ালা device (যেগুলো আপডেট করা কঠিন, physically দূরে বসানো) নতুন format-ও অন্তত আংশিকভাবে বুঝতে পারতে হবে। কী schema evolution কৌশল বেছে নেবেন?

ডিজাইন

এটা একটা classical forward/backward compatibility সমস্যা — protobuf-এর সমাধান করা ঠিক এই সমস্যাই, কিন্তু IoT-তে bandwidth/power constraint-ও যোগ হয়।

সিদ্ধান্ত ১ — Tag-length-value (TLV) কাঠামো বেছে নিন, fixed positional layout না।

প্রতিটা field-কে [tag: 1 byte][length: 1 byte][value: length byte] হিসেবে এনকোড করুন, positional (nth byte = nth field) ধরে না নিয়ে। এভাবে —

  • পুরনো firmware একটা অচেনা tag পেলে তার length পড়ে সেটা skip করতে পারে, বাকি message পার্স করা চালিয়ে যেতে পারে — সম্পূর্ণ message বাতিল করতে হয় না
  • নতুন field যোগ করা মানে শুধু একটা নতুন tag ব্যবহার করা — পুরনো tag-গুলোর অবস্থান/অর্থ অপরিবর্তিত থাকে

সিদ্ধান্ত ২ — একটা version/capability byte প্রতিটা message-এর শুরুতে।

Message-এর প্রথম byte-ই ঘোষণা করুক protocol version। Receiver (device বা central hub) version দেখে জানতে পারে কী কী tag আশা করা উচিত — আর version না চিনলেও, TLV কাঠামো থাকায় অন্তত পরিচিত tag-গুলো পড়তে পারবে।

সিদ্ধান্ত ৩ — tag number কখনো reuse করবেন না, deprecated tag “reserved” রাখুন।

একটা tag একবার একটা অর্থে ব্যবহৃত হলে, সেই sensor type বাতিল হয়ে গেলেও সেই tag number ভবিষ্যতে অন্য কোনো field-এর জন্য পুনর্ব্যবহার করবেন না — পুরনো device (যদি কখনো সেই পুরনো tag পাঠায়, firmware আপডেট না হওয়ায়) নতুন hub-কে বিভ্রান্ত করতে পারে যদি hub সেই tag-কে নতুন অর্থে ধরে নেয়।

সিদ্ধান্ত ৪ — bandwidth-সচেতন হলে varint/compact encoding বিবেচনা করুন, কিন্তু simplicity-র সাথে balance করুন।

IoT device-এ CPU/power সীমিত, তাই protobuf-এর মতো জটিল varint parsing হয়তো ভারী। একটা সরল compromise: fixed-size tag/length field (যেমন tag = 1 byte, length = 1 byte, তাই field ২৫৫ byte পর্যন্ত) — parsing logic খুবই সরল (কোনো variable-length integer decode লাগে না), অথচ TLV-এর extensibility সুবিধা বজায় থাকে।

যা এড়াবেন: positional fixed-offset format (আমাদের “বিপজ্জনক উপায়”-এর মতো) — কারণ সেখানে নতুন field মানেই সব offset শিফট হয়ে যাওয়া, পুরনো firmware সম্পূর্ণ ভুল জায়গা থেকে data পড়বে, কোনো graceful degradation ছাড়াই। TLV-তে “অচেনা tag skip করো” এই একটা সাধারণ নিয়মই পুরনো device-কে আংশিকভাবে হলেও কার্যকর রাখে — এটাই মূল trade-off যেটা এই design সমাধান করছে।

5

আমাদের designed binary format-এ ({id: u32, score: f64, name: string}, “example” section-এর লেআউট) একটা record-এর name = "কম্পিউটার" হলে সম্পূর্ণ record-এর মোট byte size কত হবে? UTF-8 encoding-এর জ্ঞান ব্যবহার করে হিসাব দেখান।

প্রয়োগ

আমাদের লেআউট: [4B id][8B score][4B name_len][name_len B utf8 name], তাই fixed header সবসময় 4 + 8 + 4 = 16 byte, আর তার সাথে যোগ হয় name-এর UTF-8 byte length।

"কম্পিউটার"-এর UTF-8 byte length বের করা:

Unicode/UTF-8 লেসনে আমরা শিখেছি একটা “visual অক্ষর” (grapheme) আর একটা Unicode code point এক জিনিস না — বাংলা conjunct/matra-তে একাধিক code point মিলে একটা visual glyph তৈরি হতে পারে। "কম্পিউটার" শব্দটা ভাঙলে code point-গুলো:

ক  (U+0995)
ম  (U+09AE)
্  (U+09CD  — hasant/virama)
প  (U+09AA)
ি  (U+09BF  — vowel sign I)
উ  (U+0989)
ট  (U+099F)
া  (U+09BE  — vowel sign AA)
র  (U+09B0)

৯টা code point। প্রতিটাই Bengali Unicode block-এ (U+0980U+09FF), আর UTF-8 encoding-এর নিয়ম অনুযায়ী U+0800U+FFFF রেঞ্জের প্রতিটা code point ৩ byte নেয় (Unicode/UTF-8 লেসনে দেখা UTF-8-এর length-prefix bit pattern অনুযায়ী)।

9 code point×3 byte/code point=27 byte9 \text{ code point} \times 3 \text{ byte/code point} = 27 \text{ byte}

সম্পূর্ণ record-এর সাইজ:

4id+8score+4name_len+27name UTF-8=43 byte\underbrace{4}_{\text{id}} + \underbrace{8}_{\text{score}} + \underbrace{4}_{\text{name\_len}} + \underbrace{27}_{\text{name UTF-8}} = 43 \text{ byte}

উত্তর: ৪৩ byte।

এই হিসাবটা এই পুরো module-এর সংশ্লেষণ — id (unsigned integer লেসন), score (IEEE 754 লেসন), byte order (endianness লেসন), আর name-এর দৈর্ঘ্য (Unicode/UTF-8 লেসন) — প্রতিটা আগের লেসনের একটা নির্দিষ্ট সিদ্ধান্ত এই একটা সংখ্যায় (৪৩) মিশে আছে। যদি নামটা ইংরেজি হতো (যেমন "Computer", ৮টা ASCII অক্ষর, প্রতিটা ১ byte), মোট size হতো 16 + 8 = 24 byte — একই “৮-৯ অক্ষরের শব্দ” হলেও script-ভেদে byte size প্রায় দ্বিগুণ পার্থক্য, যেটা UTF-8-এর variable-length প্রকৃতির একটা সরাসরি ফলাফল।

এরপর কী

Level 1 শেষ

পিছনে তাকান। চৌদ্দটা লেসনে আমরা একটাই প্রশ্নের চারপাশে ঘুরেছি: একটা bit pattern-এর অর্থ কী, আর সেই অর্থ কোথা থেকে আসে?

Binary, decimal, hexadecimal দিয়ে শুরু হয়েছিল — একটা সংখ্যা লেখার একাধিক সমতুল্য উপায়। Bits, bytes, words দিয়ে আমরা দেখলাম hardware কোন granularity-তে মেমরি সংগঠিত করে। Unsigned integer দিয়ে raw bit pattern-কে প্রথমবার “সংখ্যা” বানালাম, sign-magnitude, one’s complement, two’s complement দিয়ে negative সংখ্যা প্রকাশের প্রতিযোগিতা দেখলাম আর কেন two’s complement জিতেছে বুঝলাম, আর overflow দিয়ে দেখলাম সেই representation-এর সীমা কোথায় ভেঙে পড়ে। Fixed-point বনাম floating-point, তারপর IEEE 754-এর sign-exponent-mantissa দিয়ে fractional সংখ্যাকে bit-এ বন্দী করলাম, আর floating-point error, NaN, Inf, denormal দিয়ে সেই বন্দিত্বের খরচ দেখলাম — কেন 0.1 + 0.2 ≠ 0.3

তারপর সংখ্যা থেকে text-এ সরে গেলাম। ASCII থেকে Unicode-এর code point পর্যন্ত, UTF-8/16/32-এর ভিন্ন trade-off, আর বাংলা-সহ non-Latin script-এর encoding আর grapheme cluster-এর জটিলতা। শেষ দুই লেসনে আমরা দেখলাম একটা multi-byte value কোন ক্রমে মেমরিতে সাজে (endianness), আর কীভাবে একটা সম্পূর্ণ in-memory structure — integer, float, text সব মিলিয়ে — একটা flat, portable byte sequence-এ রূপান্তরিত হয় (serialization), যেখানে আজকের লেসনে আমরা দেখলাম কেন এই রূপান্তরটা সাবধানে না করলে security bug আর broken compatibility জন্ম নেয়।

এই সবকিছুরই একটা মাত্র থিম ছিল: bit pattern নিজে নিরর্থক। 01000001 কিছু বলে না — এটা 65 (unsigned int), A (ASCII), একটা negative সংখ্যার অংশ, একটা float-এর mantissa-র টুকরো, বা কিছুই না (padding garbage) হতে পারে, যতক্ষণ না একটা convention (encoding, byte order, schema) সেই bit-কে অর্থ দেয়। এই module জুড়ে আমরা বারবার একই প্যাটার্ন দেখেছি — representation আর interpretation দুইটা আলাদা স্তর, আর যেকোনো bug-এর মূলে প্রায়ই এই দুই স্তরের মধ্যে একটা ভুল ধারণা।

এতক্ষণ যা শিখলাম তা bit-এর অর্থ নিয়ে — মেমরিতে বসে থাকা প্যাটার্নকে কীভাবে সংখ্যা, অক্ষর, বা structure হিসেবে পড়তে হয়। এখন থেকে প্রশ্নটা বদলে যায়: সেই bit pattern নিজে কীভাবে অস্তিত্বে আসে?

পরের module — Digital Logic ও Computer Organization — শুরু হবে এই প্রশ্ন দিয়ে:

কয়েক কোটি on/off switch দিয়ে “যোগ করা” কীভাবে সম্ভব হয়?

সেখানে abstraction-এর একদম নিচে নামা হবে। একটা transistor একটা switch — বিদ্যুৎ পাস করে অথবা করে না, আর সেই দুই অবস্থাই আমাদের 0 আর 1-এর ভৌত ভিত্তি। কয়েকটা transistor মিলে CMOS দিয়ে NOT, NAND, NOR gate তৈরি হয়। Gate-গুলো মিলে Boolean algebra (Level 0-তে যেটা আমরা বিশুদ্ধ গণিত হিসেবে শিখেছিলাম) বাস্তবে সিলিকনে খোদাই হয়ে যায়। তারপর gate থেকে adder, multiplexer, ALU — আর আমরা প্রথমবার দেখব 01000001 + 00000001-এর মতো একটা অপারেশন কেন সঠিক উত্তর দেয়, শুধু tautologically না বরং electron আর voltage-এর স্তরে।

এই module-এ শেখা প্রতিটা bit pattern — একটা int, একটা float, একটা UTF-8 byte, এই লেসনের একটা serialized record — সবকিছুই শেষ পর্যন্ত এই একই জিনিসে দাঁড়িয়ে আছে: লক্ষ লক্ষ transistor, on অথবা off, একটা নির্দিষ্ট মুহূর্তে। পরের module সেই ভৌত ভিত্তিটা দেখাবে — আর তারপর, ধাপে ধাপে, সেই ভিত্তির উপর কীভাবে একটা সম্পূর্ণ CPU দাঁড়িয়ে যায়।

প্রশ্নটা এবার bit-এর অর্থ নিয়ে না — bit নিজে কীভাবে তৈরি হয়, সেটা নিয়ে।

আরও পড়ুন

  • Protocol Buffers — Encoding — Google · Tag-length-value wire format আর schema evolution-এর নিয়মগুলোর প্রামাণ্য বর্ণনা
  • MessagePack Specification · Self-describing binary format-এর আরেকটা জনপ্রিয় বাস্তবায়ন — JSON-এর বাইনারি বিকল্প
  • EtherLeak: Ethernet frame padding information leakage — @stake Inc. (Ofir Arkin, Josh Anderson) · ২০০৩ সালের বাস্তব vulnerability — frame padding-এ zero-না-করা মেমরি leak করে remote information disclosure
  • The Chromium Projects — Deserialization Vulnerabilities · কেন untrusted binary/serialized ডেটা deserialize করা একটা attack surface — Level 10 (Security)-এর পূর্বসূরি