অ্যাসেম্বলার, লিংকার, লোডার — সোর্স কোড থেকে চলমান প্রোগ্রাম পর্যন্ত
Assembler, Linker, and Loader
Assembly module শেখার লক্ষ্য assembly লেখা না — লক্ষ্য compiler কী বানাচ্ছে তা পড়তে পারা। এই লেসন সেই পুরো যাত্রাটা ম্যাপ করে: .c থেকে .s (compiler), .s থেকে .o (assembler), একাধিক .o আর library থেকে একটা executable (linker), আর সেই executable-কে চলমান process বানানো (loader) — প্রতিটা ধাপ বাস্তব gcc/as/ld কমান্ড দিয়ে হাতে-কলমে ট্রেস করা।
আগে এটা বুঝি
গত লেসনের শেষে (computer-architecture/dma-and-io) একটা প্রতিশ্রুতি দেওয়া হয়েছিল। CPU Architecture module-এর সতেরোটা লেসন জুড়ে আমরা দেখেছি CPU কীভাবে কাজ করে — pipeline, cache, branch prediction, out-of-order execution, DMA। কিন্তু একটা প্রশ্ন এখনো সরাসরি উত্তর পায়নি: এই সব জেনে, এখন আমি নিজের চোখে দেখব কীভাবে আমার লেখা কোড ঠিক কোন instruction-এ পরিণত হয়?
সেই প্রশ্নটাই এই module-এর driving question:
আমার C function compile হয়ে ঠিক কোন instruction-গুলো হলো, আর কেন?
শুরুতেই একটা জিনিস স্পষ্ট করা দরকার, কারণ এটা এই পুরো module-এর প্রত্যাশা নির্ধারণ করে। Assembly শেখার উদ্দেশ্য assembly-তে software লেখা না। ২০২৬ সালে দাঁড়িয়ে, প্রায় কেউই production software হাতে assembly-তে লেখে না — কম্পাইলার optimization-এ এত ভালো হয়ে গেছে যে হাতে-লেখা assembly প্রায় সবসময় হয় সমান দ্রুত, নয়তো ধীর, তার উপর ভয়াবহ maintenance-খরচসহ। তাহলে এই পুরো module কেন?
উদ্দেশ্য তিনটা, আর তিনটাই বাস্তব, দৈনন্দিন কাজে লাগে:
১. Compiler কী বানাচ্ছে সেটা পড়তে পারা। -O2 কি সত্যিই আপনার loop vectorize করেছে? একটা function কি inline হয়ে গেছে, নাকি এখনও আলাদা call থেকে যাচ্ছে? এই প্রশ্নের উত্তর শুধু generated assembly পড়েই পাওয়া যায়।
২. Debugger-এ কী দেখছি বুঝতে পারা। gdb-তে একটা crash-এর পর যখন disas কমান্ড চালান, বা একটা segfault-এর backtrace-এ raw instruction address দেখেন — সেটা পড়তে না পারলে debugging একটা অন্ধকার ঘরে হাতড়ানো।
৩. Performance আর security-র প্রশ্নে নিচে নামা। কেন এই একটা loop এত ধীর অথচ আরেকটা প্রায় একই কোড দ্রুত — এই প্রশ্নের উত্তর প্রায়ই assembly-স্তরে লুকানো (cache miss pattern, branch misprediction, SIMD ব্যবহার হচ্ছে কি না)। আর একটা buffer overflow ঠিক কীভাবে stack-এর return address বদলে দেয় — সেটা বোঝাও assembly-স্তরের জ্ঞান দাবি করে (Level 10-এর security module এই থ্রেড ধরে এগোবে)।
এই module তাই আপনাকে assembly-তে fluent প্রোগ্রামার বানানোর চেষ্টা করবে না — বরং একজন দক্ষ পাঠক বানাবে, যে কম্পাইলার-জেনারেটেড কোড দেখে ভয় পায় না, বরং সেটাকে একটা পরিচিত ভাষা হিসেবে পড়তে পারে।
আর সেই পড়া শুরু হওয়ার আগে, প্রথমেই জানা দরকার — assembly text আসলে আসে কোত্থেকে, আর সেটা থেকে একটা চলমান প্রোগ্রাম হয়ে ওঠার পথে ঠিক কোন কোন hand বদলায়? এই লেসন সেই সম্পূর্ণ পাইপলাইনটা ম্যাপ করবে — আর প্রতিটা ধাপ শুধু বর্ণনা না, বাস্তব কমান্ড দিয়ে হাতে-কলমে চালিয়ে দেখাবে।
মূল ধারণা
চারটা ধাপ, চারটা ভিন্ন প্রোগ্রাম
একটা .c ফাইল থেকে একটা চলমান প্রোগ্রাম হয়ে ওঠার পথে চারটা সম্পূর্ণ আলাদা প্রোগ্রাম কাজ করে — প্রতিটার নিজস্ব input, নিজস্ব output, নিজস্ব দায়িত্ব। gcc হ্যালো.c -o hello লিখলে মনে হতে পারে একটাই কমান্ড একটাই কাজ করছে, কিন্তু আসলে gcc একটা driver program — সে ভেতরে এই চারটা ধাপ (আসলে প্রথম তিনটা; loader চলে runtime-এ) ক্রমান্বয়ে চালায়, প্রতিটাকে আলাদা প্রোগ্রাম হিসেবে invoke করে।
- Source code (.c)মানুষের পড়ার জন্য — high-level syntax, variable name, control flow
- Compiler (cc1).c → .s — high-level কোডকে target ISA-র mnemonic assembly-তে অনুবাদ করে
- Assembly (.s)মানুষের পড়ার জন্য এখনও — mnemonic টেক্সট (mov, add, jmp...), কিন্তু এখন target-ISA-নির্দিষ্ট
- Assembler (as).s → .o — প্রতিটা mnemonic-কে actual বিট-প্যাটার্নে (machine code) রূপান্তর করে
- Object file (.o)বাইনারি — machine code + symbol table + unresolved relocation, এখনও চলার-যোগ্য না
- Linker (ld)একাধিক .o + library → একটা executable/.so — সব external reference resolve করে
- Executable (.exe / a.out / ELF)বাইনারি, ডিস্কে বসে আছে — এখনও চলছে না, শুধু চলার-যোগ্য
- Loader (OS)Executable-কে একটা নতুন process-এর address space-এ map করে, entry point-এ execution শুরু করে
- চলমান processএখন সত্যিই CPU-তে চলছে — fetch-decode-execute cycle সক্রিয়
মূল অন্তর্দৃষ্টিটা এখানেই — প্রতিটা তীর একটা আলাদা প্রোগ্রামের কাজ, আর প্রতিটা ধাপ পরের ধাপের জন্য একটা নির্দিষ্ট, ভিন্ন ফরম্যাটের output তৈরি করে। gcc শব্দটা একটা প্রোগ্রামের নাম মনে হলেও, বাস্তবে এটা একটা orchestrator — ভেতরে cc1 (compiler proper), as (assembler), ld (linker) — তিনটা সম্পূর্ণ আলাদা বাইনারি ক্রমান্বয়ে চালায়, আপনার থেকে এই জটিলতা লুকিয়ে।
ধাপ ০ — Preprocessor: টেক্সট প্রতিস্থাপন, এখনও কোনো “বোঝা” নেই
Compiler চালু হওয়ার আগেই একটা আরও সরল ধাপ ঘটে — preprocessor (cpp)। এর কাজ সম্পূর্ণভাবে টেক্সট-স্তরের, কোনো ভাষার semantics বোঝার দরকার নেই — #include ফাইলের বিষয়বস্তু সরাসরি বসিয়ে দেওয়া, #define ম্যাক্রো টেক্সট-প্রতিস্থাপন করা, #ifdef ব্লক রাখা/বাদ দেওয়া।
#define SQUARE(x) ((x) * (x))
int y = SQUARE(5);gcc -E tiny.c -o tiny.itiny.i-তে দেখবেন SQUARE(5) আক্ষরিকভাবে ((5) * (5))-এ পরিণত হয়ে গেছে — কোনো “function call” ধারণা এখানে নেই, নিছক টেক্সট বসানো। এই কারণেই preprocessor-কে compiler-এর অংশ ধরা হয় না, এটা তার আগের, স্বতন্ত্র একটা যান্ত্রিক ধাপ — compiler proper (cc1) যখন কাজ শুরু করে, ততক্ষণে সব #include/#define ইতিমধ্যেই সম্প্রসারিত, তার সামনে শুধু বিশুদ্ধ C syntax।
ধাপ ১ — Compiler: .c থেকে .s
Compiler-এর কাজ high-level source code পড়ে target ISA-র জন্য assembly mnemonic টেক্সট তৈরি করা। ভেতরে এটা নিজেই একটা multi-stage pipeline (lexer → parser → AST → intermediate representation → optimization → code generation) — এই পুরো ভেতরের গল্পটা compilers module-এর বিষয় (Level 5, পরে আসবে); এই লেসনে আমরা compiler-কে একটা “black box” হিসেবে দেখব যেটা .c খেয়ে .s উগরে দেয়।
গুরুত্বপূর্ণ বিষয়টা হলো — compiler-এর output এখনও মানুষের পড়ার জন্য টেক্সট, machine code না। gcc -S flag কম্পাইলারকে বলে “শুধু এই ধাপ পর্যন্তই যাও, থেমে যাও” — output একটা .s ফাইল, plain text, যেটা আপনি একটা text editor-এ খুলে পড়তে পারেন।
gcc -S -O0 square.c -o square.sধাপ ২ — Assembler: .s থেকে .o
Assembler-এর কাজ সম্পূর্ণ ভিন্ন ধরনের — এটা compiler না, এটা optimization করে না, কোনো high-level সিদ্ধান্তও নেয় না। এর কাজ যান্ত্রিক, প্রায় এক-থেকে-এক: প্রতিটা mnemonic লাইন (movl, addq, jmp…) পড়ে, ISA-র official encoding rule (গত module-এর instruction-encoding লেসনের বিষয়, মনে করুন RV32I-র R-type/I-type ফিল্ড লেআউট) অনুযায়ী ঠিক সেই bit-প্যাটার্ন তৈরি করে।
as square.s -o square.o
# অথবা gcc দিয়েই, একই কাজ:
gcc -c square.s -o square.o-c flag মানে “শুধু compile+assemble করো, link করো না” — এটাই gcc-কে বলে থামতে assembler ধাপের পরে, linker চালানোর আগে।
Object file (.o)-এ ঠিক কী আছে
Object file একটা বাইনারি ফরম্যাট (Linux-এ ELF — Executable and Linkable Format) — এলোমেলো বাইট না, একটা সুনির্দিষ্ট, sectioned কাঠামো। চারটা অংশ সবচেয়ে গুরুত্বপূর্ণ:
| Section | কী আছে |
|---|---|
.text | Machine code — actual instruction-এর বিট-প্যাটার্ন |
.data | Initialized global/static variable-এর প্রাথমিক মান |
.bss | Uninitialized global/static variable — শুধু আকার রেকর্ড করা, কোনো actual বাইট ডিস্কে সংরক্ষিত না (সব শূন্য ধরে নেওয়া হয়, তাই জায়গা বাঁচে) |
.symtab | Symbol table — প্রতিটা function/global variable-এর নাম, এই ফাইলে সংজ্ঞায়িত (defined) নাকি অন্য কোথাও থেকে আসবে (undefined) |
এই চারটার সাথে থাকে relocation entries (.rela.text ইত্যাদি) — এমন জায়গার একটা তালিকা যেখানে machine code-এ একটা address বসানো দরকার ছিল, কিন্তু assembler তখনও জানত না ঠিক কোন address, কারণ সেই symbol হয়তো অন্য একটা .o ফাইলে সংজ্ঞায়িত, যেটা এখনও যোগ হয়নি।
ধাপ ৩ — Linker: একাধিক .o + library → একটা executable
Linker-এর কাজ symbol resolution — প্রতিটা object file-এ যেসব “undefined” symbol আছে (যেমন printf, বা অন্য একটা .c ফাইলে সংজ্ঞায়িত কোনো function), সেগুলোর প্রকৃত ঠিকানা খুঁজে বের করে সেই relocation entry-গুলো পূরণ করা। একাধিক .o ফাইল আর প্রয়োজনীয় library একসাথে জুড়ে, সব ঠিকানা চূড়ান্ত করে, একটা একক executable (বা shared library) তৈরি করাই এই ধাপের ফলাফল।
gcc main.o square.o -o prog
# ভেতরে আসলে চলে (সরলীকৃত):
ld -o prog /usr/lib/crt1.o main.o square.o -lcStatic linking বনাম dynamic linking — একটা মৌলিক সিদ্ধান্ত। একটা external function (যেমন printf, libc-তে সংজ্ঞায়িত) resolve করার দুইটা সম্পূর্ণ ভিন্ন উপায় আছে:
| Static linking | Dynamic linking | |
|---|---|---|
| Library code কোথায় শেষ হয় | সরাসরি executable-এর ভেতরে কপি হয়ে যায় | Executable-এ শুধু “libc.so লাগবে” — একটা রেফারেন্স থাকে |
| Executable-এর আকার | বড় (সব library code embedded) | ছোট (library আলাদা ফাইল) |
| Runtime dependency | নেই — সম্পূর্ণ self-contained | আছে — libc.so.6 রানটাইমে থাকতেই হবে |
| একাধিক process একই library ব্যবহার করলে | প্রতিটার নিজস্ব কপি (memory-তে ডুপ্লিকেট) | সবাই একই shared library file-কে memory-তে share করে (OS-এর page cache-এর কল্যাণে) |
| Library আপডেট হলে | পুরো প্রোগ্রাম পুনরায় link করতে হয় | শুধু .so ফাইল বদলালেই যথেষ্ট, প্রোগ্রাম অপরিবর্তিত থাকতে পারে |
Static library (.a) — linker যেভাবে বেছে বেছে symbol নেয়
একটা static library (.a ফাইল, “archive”) আসলে জটিল কিছু না — এটা নিছক কয়েকটা .o ফাইলের একটা bundle, সাথে একটা index (কোন symbol কোন member .o-তে আছে তার একটা লুকআপ টেবিল)। ar কমান্ড দিয়ে বানানো হয়:
ar rcs libmathlib.a add.o multiply.o is_prime.oকিন্তু একটা সূক্ষ্ম আচরণ আছে যা নতুন প্রোগ্রামারদের প্রায়ই বিভ্রান্ত করে — linker একটা static library থেকে শুধু সেই member .o-গুলোই নেয় যেগুলোর কোনো symbol এই মুহূর্তে “দরকার” (কোনো আগের .o-তে undefined হিসেবে চিহ্নিত)। এটা সরাসরি .o ফাইল link করার থেকে ভিন্ন — একটা .o ফাইল সরাসরি দিলে সেটার সবকিছু সবসময় final executable-এ যায়, কিন্তু library-র ভেতরের একটা member .o শুধুই তখন টানা হয় যদি তার ভেতরের কোনো symbol আসলেই দরকার হয়।
ধাপ ৪ — Loader: executable থেকে চলমান process
Loader হলো OS-এর একটা অংশ — এটা কোনো আলাদা কমান্ড-লাইন প্রোগ্রাম না, execve() syscall-এর ভেতরে চলে (fork/exec-এর সেই exec, যা Level 4-এ বিস্তারিতভাবে আসবে)। যখন আপনি ./prog টাইপ করেন, শেল একটা execve("./prog", ...) syscall করে, আর সেখান থেকে loader-এর কাজ শুরু:
১. Executable ফাইলটা (ELF ফরম্যাট) পড়ে, তার header দেখে বোঝে কোন কোন section কোথায় memory-তে map করতে হবে।
২. একটা সম্পূর্ণ নতুন virtual address space তৈরি করে এই process-এর জন্য (Level 4-এর virtual memory লেসনগুলোর বিষয়)।
৩. .text, .data, .bss section-গুলো সেই নতুন address space-এ যথাযথ জায়গায় map করে (প্রায়ই lazily — শুধু প্রয়োজনের সময়, page fault দিয়ে, demand paging নামের একটা কৌশল, Level 4-এর বিষয়)।
৪. যদি executable dynamic linking ব্যবহার করে, একটা dynamic linker/loader (Linux-এ ld.so) চালু করে যেটা প্রয়োজনীয় .so ফাইলগুলো খুঁজে memory-তে map করে, বাকি symbol resolution সম্পন্ন করে।
৫. Stack, initial register (PC/RIP-কে entry point-এ সেট করা — গত module-এর registers-pc-flags লেসনের সেই PC) প্রস্তুত করে CPU-কে বলে “এখান থেকে শুরু করো।”
এই পুরো ধাপটার বিস্তারিত mechanism (virtual memory, page table, address translation) Level 4 — Operating Systems-এর বিষয়; এখানে আমরা শুধু এইটুকু বুঝেই এগোব যে executable ফাইল আর চলমান process এক জিনিস না — মাঝখানে একটা সক্রিয়, OS-চালিত রূপান্তর আছে, যেটাই loader-এর কাজ।
ভেতরে কী ঘটছে
পুরো toolchain একসাথে — gcc আসলে একটা orchestrator
gcc file.c -o prog লিখলে, ভেতরে প্রায় এই ক্রমেই ঘটনা ঘটে (সরলীকৃত, কিন্তু বাস্তবতার কাছাকাছি):
gcc file.c -o prog
│
├─▶ preprocessor (cpp) file.c → file.i (#include, #define সম্প্রসারণ)
├─▶ compiler proper (cc1) file.i → file.s (উচ্চ-স্তরের কোড → assembly mnemonic)
├─▶ assembler (as) file.s → file.o (mnemonic → machine code বিট)
└─▶ linker (ld) file.o + libs → prog (symbol resolve, একত্র করা)প্রতিটা ধাপ - দিয়ে শুরু হওয়া একটা flag দিয়ে থামিয়ে দেওয়া যায় — এটা নিজেই একটা দরকারি ডিবাগিং কৌশল, “ঠিক কোন ধাপে সমস্যা” তা আলাদা করতে:
| Flag | থামে কোথায় | Output |
|---|---|---|
-E | Preprocessor-এর পরেই | .i (preprocessed source, এখনও C) |
-S | Compiler-এর পরে | .s (assembly, plain text) |
-c | Assembler-এর পরে | .o (object file, বাইনারি) |
| (কিছুই না) | Linker-এর পরেও | executable (বাইনারি, চলার-যোগ্য) |
Symbol table বাস্তবে কেমন দেখতে — nm
nm কমান্ড একটা object file বা executable-এর symbol table পড়ে দেখায়। প্রতিটা symbol-এর পাশে একটা অক্ষর তার ধরন বলে — T মানে defined function (.text-এ), U মানে undefined (এই ফাইলে সংজ্ঞায়িত না, অন্য কোথাও থেকে আসবে বলে আশা করা হচ্ছে)।
nm square.o0000000000000000 T squaresquare এখানে T (defined, .text-এ) — কারণ এই ফাইলেই তার সংজ্ঞা আছে। এখন একটা ফাইল দেখি যেটা square আর printf দুটোই ব্যবহার করে কিন্তু নিজে সংজ্ঞায়িত করে না:
nm main.o0000000000000000 T main
U printf
U squaremain নিজেই defined (এই ফাইলে সংজ্ঞায়িত), কিন্তু printf আর square দুটোই U — undefined। এই মুহূর্তে main.o-এর ভেতরে যেখানে call square instruction আছে, সেখানে target address আসলে একটা placeholder, একটা প্রকৃত সংখ্যা না।
Relocation entries — objdump -r
objdump -r দেখায় ঠিক কোন কোন জায়গায় “পরে ঠিক করো” চিহ্ন বসানো আছে:
objdump -r main.oRELOCATION RECORDS FOR [.text]:
OFFSET TYPE VALUE
0000000000000015 R_X86_64_PLT32 square-0x4
0000000000000025 R_X86_64_PLT32 printf-0x4দুইটা লাইন — দুইটা জায়গায় (byte offset 0x15 আর 0x25) linker-কে বলা আছে “এখানে square-এর, আর এখানে printf-এর প্রকৃত ঠিকানা বসাও।” এটাই সেই “অসম্পূর্ণতা”-র বাস্তব, দেখার-যোগ্য প্রমাণ — object file সত্যিই একটা অসম্পূর্ণ পাজল-এর টুকরো, শুধু একটা তাত্ত্বিক বর্ণনা না।
Link হওয়ার পর — কী বদলায়
gcc main.o square.o -o prog
nm prog | grep -E "main|square|printf"0000000000001139 T main
0000000000001129 T square
U printfলক্ষ করুন — square এখন T (defined) হয়ে গেছে prog-এর symbol table-এ, কারণ square.o linking-এ যোগ হয়েছিল, তার সংজ্ঞা পাওয়া গেছে, ঠিকানা চূড়ান্ত হয়েছে। কিন্তু printf তখনও U — কেন? কারণ printf dynamically link হয়েছে (libc.so-তে থাকে, static ভাবে embedded হয়নি) — তার প্রকৃত ঠিকানা এখনও ঠিক হয়নি, সেটা ঠিক হবে loader/dynamic-linker চলার সময়। ldd prog দিয়ে dynamic dependency দেখা যায়:
ldd proglinux-vdso.so.1 => (0x00007fff...)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)libc.so.6 — সেই dynamic dependency, যেটা loader runtime-এ resolve করবে। এই মুহূর্তে আমরা directly module 4-এর ভূমিতে পা রেখেছি — dynamic linker (ld-linux-x86-64.so.2) নিজেই আরেকটা প্রোগ্রাম, যেটা loader দ্বারা প্রথমেই চালু করা হয়, তারপর সে বাকি resolve করে।
Symbol binding — nm-এর অক্ষরগুলো ঠিক কী বলে
nm output-এ T/U ছাড়াও আরও কয়েকটা অক্ষর দেখা যায় — প্রতিটাই symbol-এর একটা নির্দিষ্ট binding ও section নির্দেশ করে:
| অক্ষর | অর্থ |
|---|---|
T | Defined, .text section-এ, global (অন্য ফাইল থেকে দৃশ্যমান) |
t | Defined, .text-এ, কিন্তু local (শুধু এই ফাইলের ভেতরেই দৃশ্যমান — C-তে static function) |
U | Undefined — এই ফাইলে সংজ্ঞা নেই, অন্য কোথাও থেকে আসবে |
D / d | Defined, .data section-এ (initialized global/static variable), global/local |
B / b | Defined, .bss section-এ (uninitialized global/static variable) |
W | Weak symbol — defined, কিন্তু linker-কে অনুমতি দেয় অন্য কোনো non-weak definition থাকলে সেটাকে প্রাধান্য দিতে (একাধিক library-তে একই নামের “default” fallback থাকলে conflict এড়াতে ব্যবহৃত) |
Section header table — readelf -S দিয়ে সম্পূর্ণ কাঠামো দেখা
objdump/nm প্রতিটা আলাদা তথ্য আলাদাভাবে দেখায়; readelf -S পুরো ELF ফাইলের section header table-টাই একবারে দেখিয়ে দেয় — কোন section কোথায়, কত বড়, কোন ফ্ল্যাগ নিয়ে:
readelf -S square.oSection Headers:
[Nr] Name Type Address Size
[ 0] NULL 0000000000000000 00000000
[ 1] .text PROGBITS 0000000000000000 00000010
[ 2] .data PROGBITS 0000000000000000 00000000
[ 3] .bss NOBITS 0000000000000000 00000000
[ 4] .symtab SYMTAB 0000000000000000 00000048
[ 5] .strtab STRTAB 0000000000000000 0000000a
[ 6] .rela.text RELA 0000000000000000 00000018.text-এর Size কলামে 0x10 (১৬ byte) — ঠিক আগে objdump -d-এ দেখা square function-এর ১৬ byte-এর machine code-এর সাথে মেলে। .bss-এর Type NOBITS — মানে ডিস্কে কোনো actual বাইট সংরক্ষিত না এই section-এর জন্য, শুধু “এত জায়গা লাগবে, সব শূন্যে শুরু করো” এই তথ্যটুকু রেকর্ড করা — এই লেসনের আগের টেবিলেই যা বলা হয়েছিল, এখানে সরাসরি readelf-এর আউটপুটে প্রমাণিত।
PLT/GOT — lazy binding-এর প্রকৃত mechanism
উপরে প্রতিশ্রুতি দেওয়া হয়েছিল “runtime-এ প্রথমবার call হওয়ার মুহূর্তে resolve” ব্যাপারটা ঠিক কীভাবে কাজ করে তা দেখানোর। প্রক্রিয়াটা দুটো ছোট টেবিলের সমন্বয়ে হয় — PLT (Procedure Linkage Table, কোড) আর GOT (Global Offset Table, ডেটা — ঠিকানার একটা array)।
prog-এ printf কল করলে, machine code সরাসরি printf-এর আসল ঠিকানায় jump করে না — সে jump করে একটা ছোট, স্থানীয় PLT stub-এ:
\<printf@plt>:
jmp *GOT[printf] ; GOT-এ যা লেখা আছে, সেখানে jump করোপ্রথমবার printf call হলে — GOT[printf]-এ তখনও প্রকৃত ঠিকানা নেই, বরং সেটা আবার PLT-এরই আরেকটা অংশে ফিরিয়ে দেয়, যেটা dynamic linker-কে (ld.so) ডেকে বলে “এখনই printf-এর প্রকৃত ঠিকানা খুঁজে বের করো।” Dynamic linker libc.so.6-এ printf খুঁজে পায়, তার ঠিকানা GOT[printf]-এ লিখে ফেলে (patch করে), তারপর সেই ঠিকানাতেই jump করে।
দ্বিতীয়বার থেকে — GOT[printf]-এ এখন প্রকৃত ঠিকানা বসানো আছে, তাই PLT stub সরাসরি এক-ধাপেই printf-এ jump করে, dynamic-linker-এর কোনো জড়িত থাকা লাগে না।
প্রথম printf() কল:
call printf@plt ──▶ jmp *GOT[printf] ──▶ (GOT-এ এখনো resolver-এর ঠিকানা)
│
▼
dynamic linker (ld.so) চালু হয়
libc.so.6-এ printf খোঁজে
GOT[printf] ← printf-এর প্রকৃত ঠিকানা (patch)
│
▼
printf-এ jump করে, execute করে
দ্বিতীয় printf() কল (আর পরের সব):
call printf@plt ──▶ jmp *GOT[printf] ──▶ সরাসরি printf-এ jump (dynamic linker লাগেনি)এই কৌশলটার নাম lazy binding — যেসব function কখনোই call হয় না (অনেক বড় library-তে সাধারণ), তাদের জন্য কখনোই resolution খরচ হয় না; শুধু যেগুলো সত্যিই ব্যবহৃত হয়, শুধু তাদেরই, আর শুধু একবারই। এটা আগের module-এর memory-hierarchy লেসনের সেই “শুধু যা লাগে, শুধু তখনই” নীতিরই (demand paging-এর অনুরূপ) একটা linking-স্তরের প্রতিফলন।
Section বনাম Segment — linker আর loader দুই ভিন্ন দৃষ্টিভঙ্গিতে একই ELF ফাইল পড়ে
এতক্ষণ যা দেখেছি (.text, .data, .symtab…) — এগুলো section — সূক্ষ্ম-দানার (fine-grained), linker-এর জন্য দরকারি তথ্য (কোন symbol কোথায়, কোন relocation কোথায়)। কিন্তু ELF ফাইলে আরেকটা সম্পূর্ণ আলাদা টেবিল আছে — program header table — যেটা loader-এর জন্য, sections-এর চেয়ে অনেক মোটা-দানার (coarse-grained)। readelf -l (lowercase L) সেটা দেখায়:
readelf -l progProgram Headers:
Type Offset VirtAddr FileSiz MemSiz Flg
PHDR 0x000040 0x0000000000000040 0x0002d8 0x0002d8 R
INTERP 0x000318 0x0000000000000318 0x00001c 0x00001c R
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x000638 0x000638 R
LOAD 0x001000 0x0000000000001000 0x000189 0x000189 R E
LOAD 0x002000 0x0000000000002000 0x0000f0 0x0000f0 R
LOAD 0x002df0 0x0000000000003df0 0x000228 0x000230 RW
DYNAMIC 0x002e00 0x0000000000003e00 0x0001d0 0x0001d0 RWলক্ষ করুন loader-এর কোনো আগ্রহই নেই কোনটা .text না .rodata না .symtab — সে শুধু জানতে চায় কতগুলো contiguous byte-range memory-তে map করতে হবে, কোন permission নিয়ে (R = read-only, R E = read+execute, RW = read+write)। এই কারণেই একাধিক section (যেমন .text আর .rodata, দুটোই read-only) প্রায়ই একটাই LOAD segment-এ একসাথে গুটিয়ে দেওয়া হয় — loader-এর জন্য একটাই mmap()-এর মতো কাজ যথেষ্ট, sections-এর সূক্ষ্ম বিভাজন তার কোনো কাজে লাগে না।
উদাহরণ
সম্পূর্ণ ট্রেস — দুইটা ফাইল থেকে একটা চলমান প্রোগ্রাম
সব ধাপ এক জায়গায় জুড়ে দেখি — একটা বাস্তব, ছোট, দুই-ফাইলের C প্রোগ্রাম, শুরু থেকে শেষ।
square.c — একটা সরল ফাংশন, আলাদা ফাইলে:
int square(int x) {
return x * x;
}main.c — square কল করে, আর libc-র printf ব্যবহার করে:
#include \<stdio.h>
int square(int x); // forward declaration -- এই ফাইলে সংজ্ঞা নেই
int main(void) {
int result = square(7);
printf("7 squared is %d\n", result);
return 0;
}ধাপ ১ — square.c-কে assembly-তে কম্পাইল করা (-O0, কোনো optimization ছাড়া, যাতে output সরাসরি পড়া যায়):
gcc -S -O0 square.c -o square.s
cat square.sOutput-এর প্রাসঙ্গিক অংশ (x86-64, AT&T syntax — পরের লেসনের বিষয়, এখানে শুধু “এটাই সেই মানুষের-পড়ার-যোগ্য টেক্সট” বোঝার জন্য):
square:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
imull -4(%rbp), %eax
popq %rbp
retএই টেক্সট মানুষের পড়ার জন্য — কোনো বাইনারি বিট এখনও তৈরি হয়নি, শুধু mnemonic। লক্ষ করুন imull — x * x-এর জন্য signed multiply instruction, arithmetic ধাপগুলো পরের দুই লেসনে বিস্তারিত আসবে।
ধাপ ২ — assembly-কে object file-এ পরিণত করা:
gcc -c square.s -o square.oএখন square.o একটা বাইনারি ফাইল — file square.o চালালে দেখাবে ELF 64-bit relocatable, x86-64। এটা এখনও চলার-যোগ্য না, শুধু একটা “টুকরো।”
ধাপ ৩ — machine code সত্যিই বাইট, তা যাচাই:
objdump -d square.osquare.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 \<square>:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: 89 7d fc mov %edi,-0x4(%rbp)
7: 8b 45 fc mov -0x4(%rbp),%eax
a: 0f af 45 fc imul -0x4(%rbp),%eax
e: 5d pop %rbp
f: c3 retবাম কলামে raw hex byte — এটাই আসল machine code, CPU যা সত্যিই fetch করবে। ডান কলামে objdump সেই বাইটকে আবার mnemonic-এ ফিরিয়ে দেখাচ্ছে (একটা disassembler) — সুবিধার জন্য, কিন্তু CPU-র কাছে গুরুত্বপূর্ণ শুধু বাম কলাম।
ধাপ ৪ — main.c-কে একই ধাপে নিয়ে যাওয়া, আর অসম্পূর্ণতা দেখা:
gcc -c main.c -o main.o
nm main.o0000000000000000 T main
U printf
U squaremain সংজ্ঞায়িত, কিন্তু square আর printf — দুটোই এখনও U। এই মুহূর্তে main.o-এর ভেতরে call square instruction আছে ঠিকই, কিন্তু তার target address একটা placeholder।
ধাপ ৫ — লিংক করা:
gcc main.o square.o -o progএই একটা কমান্ড ভেতরে ld চালায় — দুইটা .o ফাইল নেয়, square symbol square.o-তে খুঁজে পায় (সেটার প্রকৃত ঠিকানা main.o-এর relocation-এ বসিয়ে দেয়), আর printf-এর জন্য একটা dynamic-linking রেফারেন্স রেখে দেয় (কারণ libc সাধারণত dynamically link হয় ডিফল্টে)।
ধাপ ৬ — চালানো:
./prog7 squared is 49এই একটা লাইন output-এর পেছনে ঠিক ছয়টা ধাপ, তিনটা আলাদা প্রোগ্রাম (cc1, as, ld), আর একটা OS mechanism (loader/execve) কাজ করেছে — প্রতিটা তার নিজের নির্দিষ্ট, সংকীর্ণ দায়িত্ব পালন করে।
নিজে চালিয়ে দেখুন
নিজে পুরো pipeline চালিয়ে প্রতিটা ধাপের output verify করুন
উপরের square.c/main.c উদাহরণ নিজে টাইপ করে চালান, প্রতিটা ধাপে যাচাই করুন।
# ধাপ ১ -- দুইটা ফাইল তৈরি করুন (উপরের কোড কপি করে)
gcc -S -O0 square.c -o square.s
cat square.s # পড়ুন -- এটা প্লেইন টেক্সট
# ধাপ ২ -- object file বানান, আর দেখুন এটা বাইনারি
gcc -c square.s -o square.o
file square.o # "ELF 64-bit relocatable" দেখাবে
# ধাপ ৩ -- symbol table দেখুন, undefined symbol চিহ্নিত করুন
gcc -c main.c -o main.o
nm main.o # square আর printf দুটোই U (undefined)
# ধাপ ৪ -- relocation entry দেখুন (কোথায় "পরে ঠিক করো" লেখা আছে)
objdump -r main.o
# ধাপ ৫ -- link করুন, আবার symbol table দেখুন
gcc main.o square.o -o prog
nm prog | grep -E "main|square|printf" # square এখন T, printf এখনও U (dynamic)
# ধাপ ৬ -- dynamic dependency দেখুন
ldd prog
# ধাপ ৭ -- চালান
./progobject file সত্যিই অসম্পূর্ণ থাকে (undefined symbol, relocation entry) linking-এর আগে, আর linking সত্যিই সেই ফাঁকগুলো পূরণ করে -- এটা একটা তাত্ত্বিক দাবি না, সরাসরি nm/objdump দিয়ে পর্যবেক্ষণযোগ্য।
নিজে বানান
Static বনাম Dynamic linking — নিজের চোখে size ও dependency তুলনা
- একটা ছোট library তৈরি করুন -- ২-৩টা সাধারণ ফাংশন (যেমন add, multiply, is_prime) একটা mathlib.c ফাইলে
- mathlib.c-কে object file-এ কম্পাইল করুন, তারপর ar rcs libmathlib.a mathlib.o দিয়ে একটা static library আর্কাইভ বানান
- একটা main.c লিখুন যেটা এই library-র ফাংশন ব্যবহার করে, তারপর gcc main.c -L. -lmathlib -static -o prog_static দিয়ে static-linked বানান
- একই main.c-কে এবার gcc -shared -fPIC দিয়ে একটা libmathlib.so বানিয়ে dynamic-linked bind করুন (prog_dynamic)
- ls -la দিয়ে দুইটা executable-এর আকার তুলনা করুন, আর ldd দিয়ে dependency তুলনা করুন
- libmathlib.so সরিয়ে ফেলে (mv) দেখান prog_dynamic চলতে ব্যর্থ হয় (dependency missing) কিন্তু prog_static ঠিকই চলে
মূল লক্ষ্য — static আর dynamic linking-এর ট্রেড-অফ শুধু পড়ে না, নিজের চোখে পরিমাপ করে বোঝা।
# mathlib.c
cat > mathlib.c \<\< 'EOF'
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
EOF
# static library বানানো
gcc -c mathlib.c -o mathlib.o
ar rcs libmathlib.a mathlib.o
# shared library বানানো (-fPIC জরুরি -- position independent code,
# এই module-এরই একটা পরবর্তী topic, shared library-র জন্য অপরিহার্য)
gcc -shared -fPIC mathlib.c -o libmathlib.so
# main.c লিখুন যা add()/multiply() ডাকে, তারপর:
gcc main.c -L. -lmathlib -static -o prog_static
gcc main.c -L. -lmathlib -o prog_dynamic
ls -la prog_static prog_dynamic
ldd prog_static # "not a dynamic executable" দেখাবে
ldd prog_dynamic # libmathlib.so-র উপর নির্ভরতা দেখাবে
# এখন dependency সরিয়ে ফেলুন
mv libmathlib.so /tmp/
./prog_static # ঠিকঠাক চলবে -- সব library-code ভেতরেই embedded
./prog_dynamic # ব্যর্থ হবে -- "error while loading shared libraries"
mv /tmp/libmathlib.so . # ফেরত আনুনপ্রত্যাশিত ফলাফল — prog_static উল্লেখযোগ্যভাবে বড় (কয়েক মেগাবাইট, কারণ পুরো libc-র প্রয়োজনীয় অংশও embedded), prog_dynamic ছোট (কয়েক কিলোবাইট) কিন্তু runtime dependency-র উপর নির্ভরশীল।
নিজে বাড়ান:
nm prog_static | wc -lবনামnm prog_dynamic | wc -lতুলনা করুন — static সংস্করণে libc-র বহু অতিরিক্ত symbol ঢুকে যাওয়ায় সংখ্যাটা অনেক বড় হবেobjdump -r prog_staticবনামobjdump -r prog_dynamicচালিয়ে দেখুন কোনটাতে এখনও unresolved relocation বাকি আছেar t libmathlib.aদিয়ে archive-এর ভেতরের member তালিকা দেখুন — এটাই সেই “কয়েকটা .o-এর bundle” ধারণার সরাসরি প্রমাণstrace -e trace=openat ./prog_dynamic(Linux) চালিয়ে দেখুন dynamic linker ঠিক কোন কোন ফাইল খোলার চেষ্টা করছে libmathlib.so খুঁজতে — dynamic loading বাস্তবে ঠিক কী করে তার একটা প্রত্যক্ষ প্রমাণ
বাস্তব সিস্টেমে
যেখানে এই pipeline প্রতিদিন কাজ করে
Compiler Explorer (godbolt.org)। যেকোনো ভাষার কোড লিখে সরাসরি assembly output দেখার সবচেয়ে জনপ্রিয় টুল — এই লেসনের ধাপ ১ (compiler → assembly) লাইভ, ইন্টারেক্টিভভাবে দেখায়, যেকোনো ISA আর optimization level-এ। পরের দুই লেসনের experiment-গুলোতেও এই টুল কাজে লাগবে।
glibc — প্রায় প্রতিটা Linux প্রোগ্রামের dynamic dependency। libc.so.6 — GNU C Library-র shared object — প্রায় প্রতিটা dynamically-linked Linux প্রোগ্রামের ldd output-এ দেখা যায়। একই library-র একটা কপি memory-তে থেকে হাজার হাজার প্রোগ্রাম একসাথে share করে — dynamic linking-এর সবচেয়ে বড় বাস্তব সুবিধা।
Windows DLL, macOS dylib — একই ধারণা, ভিন্ন নাম, ভিন্ন ফাইল-ফরম্যাট। Windows-এ .dll (Dynamic Link Library), macOS-এ .dylib — দুটোই ঠিক dynamic linking-এর একই সমস্যার সমাধান, কিন্তু প্রতিটা OS-এর executable/object ফরম্যাটই আলাদা:
| OS | Executable ফরম্যাট | Object ফাইল | Shared library |
|---|---|---|---|
| Linux | ELF | .o | .so |
| Windows | PE (Portable Executable) | .obj | .dll |
| macOS | Mach-O | .o | .dylib |
তিনটাই আলাদা বাইট-ফরম্যাট, কিন্তু ধারণাগতভাবে হুবহু এই লেসনের বর্ণিত কাঠামো — section/segment বিভাজন, symbol table, relocation entry, সবই তিনটাতেই কোনো না কোনো রূপে বিদ্যমান, শুধু নাম আর বাইট-লেআউট আলাদা। .exe-এর “missing DLL” error বার্তা এই একই symbol-resolution ব্যর্থতার Windows-সংস্করণ — undefined reference error-এরই একটা ভিন্ন-ভাষার প্রতিধ্বনি।
Go আর Rust — ডিফল্টে static linking। আধুনিক ভাষাগুলো প্রায়ই ডিফল্টে static linking বেছে নেয় (Go বিশেষভাবে) — deployment সরল করার জন্য (শুধু একটা ফাইল কপি করলেই চলে, কোনো “libc.so.6 কোথায়?” সমস্যা নেই), dynamic linking-এর মেমরি-শেয়ারিং সুবিধা ত্যাগ করে। এটা একটা সচেতন ট্রেড-অফ, ঠিক এই লেসনের static/dynamic টেবিলেরই একটা বাস্তব প্রয়োগ।
LD_PRELOAD — dynamic linking-কে হুক করা। যেহেতু dynamic symbol resolution runtime-এ ঘটে, একটা পরিবেশ-ভেরিয়েবল (LD_PRELOAD=mylib.so ./prog) দিয়ে নিজের একটা library আগে থেকে load করানো যায়, যা আসল library-র function override করে দেয় — malloc-debugging টুল (যেমন valgrind-এর কিছু মোড), profiling, বা mock-testing এই কৌশল ব্যবহার করে।
Link-Time Optimization (LTO)। আধুনিক কম্পাইলার (gcc -flto, clang -flto) linker-কেই optimization-এর সুযোগ দেয় — কারণ linker-ই একমাত্র জায়গা যেখানে পুরো প্রোগ্রামের সব .o ফাইল একসাথে দৃশ্যমান, তাই cross-file inlining আর dead-code elimination সম্ভব যা কম্পাইলার একটা ফাইল দেখে করতে পারত না।
RELRO/GOT hardening — নিরাপত্তা যোগসূত্র। Dynamic linking-এর সেই “runtime-এ ঠিকানা লেখা” প্রক্রিয়া (Global Offset Table, GOT) একটা আক্রমণের লক্ষ্যবস্তু হতে পারে (GOT overwrite attack) — যদি একজন attacker সেই টেবিলে লিখতে পারে, সে function call-কে নিজের কোডে redirect করতে পারে। আধুনিক Linux binary-তে RELRO (RELocation Read-Only) এই টেবিলকে link-এর পর read-only করে দেয় — Level 10-এর security module-এ এই mitigation বিস্তারিত আসবে।
Kernel module loading। Linux kernel module (.ko ফাইল) নিজেই একটা relocatable object file, আর insmod/modprobe কার্যত একটা mini-linker/loader হিসেবে কাজ করে — kernel-এর নিজস্ব symbol table-এর বিপরীতে module-এর undefined symbol resolve করে।
Rust crate compilation — .rlib, static library-রই একটা আধুনিক রূপ। Rust-এ প্রতিটা compiled crate ডিফল্টে একটা .rlib ফাইলে পরিণত হয় — কাঠামোগতভাবে এই লেসনের .a static archive-এরই একটা আধুনিক সংস্করণ (ভেতরে object code + metadata, dependency crate-গুলোর সাথে ঠিক এই লেসনের “linker বেছে বেছে দরকারি member টানে” নীতিতেই final binary-তে জোড়া লাগে)।
ccache/distributed build system — object file-এর স্থিতিশীলতার উপর নির্ভরশীল। ccache-এর মতো টুল একই .c ফাইল বারবার কম্পাইল না করে আগের .o output cache করে রাখে (input হ্যাশ মিললে সরাসরি cached .o ফেরত দেয়) — এটা সম্ভব শুধু কারণ compiler-থেকে-.o় রূপান্তর deterministic (একই input সবসময় একই output), আর .o নিজেই একটা independent, well-defined ইউনিট — এই লেসনের পাইপলাইন-বিভাজনের একটা প্রত্যক্ষ, বাস্তব প্রকৌশল সুবিধা।
যে ভুলগুলো সবাই করে
“Assembly module শেখার মানে আমাকে এখন থেকে হাতে assembly লিখতে জানতে হবে -- এটাই একজন 'real' systems programmer হওয়ার শর্ত।”
এই লেসনের শুরুতেই যা স্পষ্ট করা হয়েছিল, সেটাই আবার — উদ্দেশ্য পড়া, লেখা না।
আধুনিক কম্পাইলার এত পরিণত যে হাতে-লেখা assembly প্রায় সবসময় হয় সমান, নয়তো ধীর — বিনিময়ে maintenance-এর দুঃস্বপ্ন (portability নেই, প্রতিটা ISA-র জন্য আলাদা কোড, readability কম)। বাস্তব systems programmer-রা assembly পড়ে — compiler output audit করতে, debugger-এ crash trace করতে, একটা নির্দিষ্ট hot loop কেন ধীর তা বুঝতে — কিন্তু খুব কম ক্ষেত্রেই নতুন করে লেখে (device driver-এর কিছু নির্দিষ্ট অংশ, bootloader, বা compiler নিজে ছাড়া)।
এই module সেই পড়ার দক্ষতা তৈরি করবে, একজন assembly-programmer বানানোর লক্ষ্যে না।
“gcc file.c -o prog একটাই, অবিভাজ্য ধাপ -- ভেতরে কী হচ্ছে সেটা জানার দরকার নেই।”
এই লেসনের কেন্দ্রীয় প্রতিষেধকই এই ভুল ধারণার — gcc একটা driver, ভেতরে চারটা আলাদা প্রোগ্রাম (cpp, cc1, as, ld) ক্রমান্বয়ে চালায়।
এই বিভাজন জানাটা নিছক তাত্ত্বিক কৌতূহল না — বাস্তব ডিবাগিং-এ প্রতিনিয়ত কাজে লাগে। “Undefined reference” error মানে linker ব্যর্থ হয়েছে (symbol resolution সমস্যা, missing .o/library)। “Syntax error” মানে compiler ব্যর্থ হয়েছে (আপনার .c কোডে সমস্যা)। এই দুই ধরনের error সম্পূর্ণ ভিন্ন কারণ থেকে আসে, আর কোন ধাপে সমস্যা তা জানলেই ঠিক জায়গায় খোঁজা যায় — -c (শুধু compile) দিয়ে চালিয়ে যদি সফল হয়, সমস্যাটা নিশ্চিতভাবে linking-এ, compiling-এ না।
“একটা object file (.o) already চলার-যোগ্য machine code -- শুধু একাধিক ফাইল 'জোড়া' লাগানো বাকি, ঠিকানা তো ঠিকই আছে।”
এই লেসনের hood সেকশনেই সরাসরি প্রমাণ দেখানো হয়েছে এর বিপরীত — nm main.o এ U printf, U square দেখায় (undefined), আর objdump -r main.o দেখায় সুনির্দিষ্ট relocation entry — জায়গাগুলো যেখানে প্রকৃত ঠিকানা এখনও লেখা হয়নি, শুধু একটা placeholder।
Object file আসলে একটা “অসম্পূর্ণ পাজল-টুকরো” — নিজের ভেতরে সংজ্ঞায়িত সবকিছুর জন্য চূড়ান্ত (তার নিজের function-গুলোর ঠিকানা ঠিক), কিন্তু বাইরের যেকোনো কিছুর জন্য (অন্য ফাইলের function, library function) শুধু “এটার নাম লাগবে, ঠিকানা পরে বসাও” চিহ্ন রেখে দেয়। Linker-ই সেই “পরে” ধাপ।
“Static linking সবসময় dynamic linking-এর চেয়ে খারাপ (বা উল্টো) -- একটা সবসময় সঠিক পছন্দ।”
এই লেসনের static/dynamic টেবিলে দেখানো ট্রেড-অফগুলো প্রেক্ষাপট-নির্ভর, কোনোটাই সার্বজনীনভাবে “ভালো” না।
Static linking জেতে যখন deployment সরলতা গুরুত্বপূর্ণ (একটা single self-contained binary, container image-এ কম layer, “dependency hell” এড়ানো) — এই কারণেই Go আর অনেক modern CLI টুল ডিফল্টে static। Dynamic linking জেতে যখন memory/disk শেয়ারিং গুরুত্বপূর্ণ (হাজার হাজার প্রোগ্রাম একই libc শেয়ার করছে এমন একটা সাধারণ Linux desktop/server-এ), বা যখন library আপডেট (নিরাপত্তা প্যাচ) পুরো সিস্টেম পুনরায় কম্পাইল না করেই ছড়িয়ে দেওয়া দরকার।
বাস্তব সিদ্ধান্ত নির্ভর করে deployment প্রসঙ্গের উপর — এটাই এই module জুড়ে বারবার ফিরে আসা একটা থিম হবে (আগের module-এও port-mapped বনাম memory-mapped I/O ঠিক এই একই ধরনের “প্রেক্ষাপট-নির্ভর, কোনোটাই সার্বজনীন সেরা না” প্যাটার্নের উদাহরণ ছিল)।
বুঝেছেন কি না দেখুন
1কম্পাইলার (ধাপ ১) একটা .c ফাইল কম্পাইল করার সময়ই কেন একটা external function call (যেমন printf)-এর প্রকৃত ঠিকানা সরাসরি assembly-তে লিখে দিতে পারে না? সমস্যাটা ঠিক কোথায়?
যুক্তি
.c ফাইল কম্পাইল করার সময়ই কেন একটা external function call (যেমন printf)-এর প্রকৃত ঠিকানা সরাসরি assembly-তে লিখে দিতে পারে না? সমস্যাটা ঠিক কোথায়?কম্পাইলার যখন main.c কম্পাইল করছে, তার কাছে শুধু সেই একটা ফাইল দৃশ্যমান — printf কোথায় সংজ্ঞায়িত (কোন library, কোন ঠিকানায়) তার কোনো ধারণা তার নেই, আর থাকার কথাও না। এমনকি একই প্রোগ্রামের অন্য একটা .c ফাইলে সংজ্ঞায়িত square-এর ঠিকানাও কম্পাইলার জানে না, কারণ সে একবারে শুধু একটা translation unit (একটা .c ফাইল) দেখে।
আরও গভীরভাবে — প্রকৃত ঠিকানা নির্ভর করে চূড়ান্ত executable-এ প্রতিটা অংশ কোথায় বসবে তার উপর, যেটা শুধুমাত্র তখনই জানা যায় যখন সব .o ফাইল আর library একসাথে জোড়া হচ্ছে — অর্থাৎ linking-এর সময়। Dynamic linking-এর ক্ষেত্রে সমস্যা আরও গভীর — চূড়ান্ত ঠিকানা তখনও অজানা থাকে, কারণ সেটা নির্ভর করে runtime-এ shared library কোথায় memory-তে load হচ্ছে তার উপর (ASLR-এর কারণে প্রতিবার আলাদা হতে পারে — Level 10-এর security module-এর একটা বিষয়)।
তাই কম্পাইলার একটা বাস্তবসম্মত কাজ করে — placeholder রেখে দেয়, আর একটা relocation entry-তে লিখে রাখে “এই symbol-এর ঠিকানা পরে বসাতে হবে” — সেই “পরে”-টা linker (link time) অথবা loader (load/run time) সম্পন্ন করে।
2আপনি gcc main.o -o prog চালালেন (square.o যোগ না করেই) আর একটা error পেলেন:
main.o: In function `main':
main.c:(.text+0x15): undefined reference to `square'
কোন ধাপ ব্যর্থ হয়েছে, ঠিক কেন, আর সমাধান কী?
প্রয়োগ
gcc main.o -o prog চালালেন (square.o যোগ না করেই) আর একটা error পেলেন:main.o: In function `main':
main.c:(.text+0x15): undefined reference to `square'কোন ধাপ ব্যর্থ হয়েছে: এটা একটা linker error, compiler বা assembler-এর না — লক্ষ্য করুন main.o ইতিমধ্যেই একটা valid object file হিসেবে বিদ্যমান (compile+assemble ধাপ সফলভাবে শেষ হয়েছে), সমস্যাটা এসেছে linking-এর সময়।
ঠিক কেন: main.o-এর symbol table-এ square একটা U (undefined) entry হিসেবে আছে — main.c-তে শুধু square-এর একটা forward declaration ছিল, প্রকৃত সংজ্ঞা square.c-তে। Linker-কে main.o main.o (ভুলবশত square.o বাদ দিয়ে) দেওয়া হয়েছিল, তাই linker-এর কাছে square-এর সংজ্ঞা কোথাও খুঁজে পাওয়ার উপায় ছিল না — সে সব ইনপুট .o ফাইল আর library স্ক্যান করে, কিন্তু square কোথাও সংজ্ঞায়িত পায়নি।
সমাধান: square.o-কে link কমান্ডে অন্তর্ভুক্ত করা — gcc main.o square.o -o prog। এখন linker square.o-এর symbol table স্ক্যান করে square-কে T (defined) হিসেবে খুঁজে পাবে, আর main.o-এর সেই relocation entry-তে সঠিক ঠিকানা বসিয়ে দেবে।
এই error message নিজেই এই লেসনের একটা মূল বার্তার সরাসরি প্রমাণ — object file সত্যিই অসম্পূর্ণ থাকে, আর linker-ই সেই অসম্পূর্ণতা পূরণ করার দায়িত্বপ্রাপ্ত।
3nm prog (link হওয়ার পরের executable) চালালে square এখন T (defined) দেখায়, কিন্তু printf তখনও U (undefined) দেখায় — যদিও লিংকিং সফলভাবে সম্পন্ন হয়েছে, প্রোগ্রাম ঠিকঠাক চলছে। এই পার্থক্যটা কেন?
যুক্তি
nm prog (link হওয়ার পরের executable) চালালে square এখন T (defined) দেখায়, কিন্তু printf তখনও U (undefined) দেখায় — যদিও লিংকিং সফলভাবে সম্পন্ন হয়েছে, প্রোগ্রাম ঠিকঠাক চলছে। এই পার্থক্যটা কেন?পার্থক্যটা আসে square আর printf কীভাবে link হয়েছে তার ভিন্নতা থেকে — একটা static-এর মতো (একই প্রোগ্রামের অংশ, সরাসরি link করা হয়েছে), আরেকটা dynamic (libc-তে সংজ্ঞায়িত, dynamically link)।
square square.o-তে সংজ্ঞায়িত ছিল, আর সেই .o ফাইলটা সরাসরি link কমান্ডে দেওয়া হয়েছিল — তাই linker সরাসরি সেই সংজ্ঞা পেয়ে গেছে, square-এর প্রকৃত ঠিকানা prog-এর .text section-এই স্থায়ীভাবে বসে গেছে। Link time-এই সম্পূর্ণভাবে resolve হয়ে গেছে — তাই T।
printf libc-তে সংজ্ঞায়িত, আর ডিফল্টে gcc libc-কে dynamically link করে (ldd prog-এ libc.so.6 দেখা গিয়েছিল)। এর মানে printf-এর কোড prog-এর ভেতরে embedded না — শুধু “runtime-এ libc.so.6 থেকে printf লাগবে” এই তথ্যটুকু prog-এ রেকর্ড করা আছে। প্রকৃত ঠিকানা তখনও অজানা, কারণ সেটা নির্ভর করে libc.so.6 runtime-এ ঠিক কোথায় memory-তে map হচ্ছে তার উপর — যা loader/dynamic-linker (ld-linux-x86-64.so.2) প্রোগ্রাম চালু হওয়ার সময় নির্ধারণ করে, প্রায়ই প্রথমবার আসলেই printf কল হওয়ার মুহূর্তে (lazy binding, PLT/GOT mechanism দিয়ে)।
তাই prog-এর symbol table-এও printf U থেকে যায় — এটা bug না, এটা dynamic linking-এর প্রত্যাশিত, সঠিক আচরণ। nm শুধু static resolution-ই দেখায়; dynamic resolution runtime-এর একটা পৃথক, পরবর্তী ধাপ।
4আপনি একটা কোম্পানির জন্য ডজনখানেক ছোট microservice ডিজাইন করছেন, সবগুলো C-তে লেখা, সবগুলো একই কমন library (একটা logging/metrics library) ব্যবহার করে, আর সবগুলো আলাদা Docker container-এ deploy হবে (প্রতিটা container নিজের মতো self-contained, একটা container-এর ভেতরের ফাইল আরেকটাতে দেখা যায় না)। Static নাকি dynamic linking বেছে নেবেন commong library-র জন্য, আর কেন?
ডিজাইন
এই প্রেক্ষাপটে static linking-ই যুক্তিসঙ্গত পছন্দ, যদিও সাধারণভাবে dynamic linking-এর একটা বড় সুবিধা (memory-তে shared library-র একটাই কপি, একাধিক process জুড়ে) — সেই সুবিধাটাই এখানে কার্যত অকেজো হয়ে গেছে প্রেক্ষাপটের কারণে।
কেন dynamic linking-এর মূল সুবিধা এখানে খাটে না: dynamic linking-এর memory-শেয়ারিং সুবিধা কাজ করে যখন একই OS-এর একাধিক process একই shared library file ব্যবহার করে (OS-এর page cache-এর মাধ্যমে)। কিন্তু এখানে প্রতিটা microservice আলাদা container-এ চলছে — প্রতিটা container-এর নিজস্ব, বিচ্ছিন্ন filesystem view (namespace isolation-এর কল্যাণে, যা Level 12-এর virtualization module-এর বিষয়)। তার মানে প্রতিটা container-এর ভেতরেই libmylogging.so-র নিজস্ব কপি থাকতে হবে যদি dynamic linking ব্যবহার করা হয় — memory-শেয়ারিং সুবিধা বাস্তবে হারিয়ে গেছে (যদি না containerগুলো একই base image layer শেয়ার করে, যেটা একটা ভিন্ন optimization)।
Static linking-এর সুবিধা এখানে জেতে: প্রতিটা microservice binary সম্পূর্ণ self-contained — deploy করতে শুধু একটা ফাইল কপি করলেই চলে, “container-এর ভেতরে সঠিক .so ভার্সন আছে কিনা” এই dependency-হেল সম্পূর্ণ এড়ানো যায়। এটা ঠিক এই কারণেই আধুনিক container-native ভাষা (Go বিশেষভাবে) ডিফল্টে static linking বেছে নেয় — “distroless” বা “scratch” Docker image (প্রায় খালি, শুধু নিজের binary) সম্ভব হয় শুধু যখন static linking ব্যবহার হয়।
একটা বাস্তব ট্রেড-অফ যা মেনে নিতে হবে: যদি logging library-তে একটা নিরাপত্তা bug ধরা পড়ে, static-linked পদ্ধতিতে প্রতিটা microservice আলাদাভাবে পুনরায় কম্পাইল আর redeploy করতে হবে — dynamic linking হলে শুধু shared .so আপডেট করলেই যথেষ্ট হতো (যদি container-গুলো সত্যিই একই host-level library শেয়ার করত)। বাস্তবে অনেক container-native টিম এই ট্রেড-অফ মেনে নেয়, আর automated CI/CD pipeline দিয়ে দ্রুত পুনরায়-বিল্ড/redeploy করে ক্ষতিপূরণ দেয়।
5নিচের চারটা ধাপ — compiler, assembler, linker, loader — প্রতিটার input আর output ফরম্যাট এক লাইনে লিখুন।
স্মরণ
| ধাপ | Input | Output |
|---|---|---|
| Compiler | .c (high-level source, মানুষের পড়ার জন্য) | .s (assembly mnemonic, target-ISA-নির্দিষ্ট, এখনও মানুষের পড়ার জন্য) |
| Assembler | .s (mnemonic টেক্সট) | .o (object file — বাইনারি machine code + symbol table + relocation, অসম্পূর্ণ) |
| Linker | একাধিক .o + library (.a/.so) | executable বা .so (বাইনারি, সব internal ঠিকানা চূড়ান্ত, dynamic dependency থাকতে পারে) |
| Loader | executable ফাইল (ডিস্কে বসা) | একটা চলমান process (memory-তে map করা, entry point-এ execution শুরু) |
চারটাই ভিন্ন প্রোগ্রাম/mechanism, প্রতিটার input ঠিক আগের ধাপের output — একটা কঠোরভাবে ক্রমিক pipeline, প্রতিটা ধাপ পরের ধাপের জন্য একটা নির্দিষ্ট, সুচিহ্নিত ফরম্যাট তৈরি করে।
6একটা প্রোগ্রাম চলাকালীন printf তিনবার call হয় — একবার প্রোগ্রামের শুরুতে, তারপর একটা loop-এর ভেতরে আরও দুইবার। PLT/GOT lazy binding mechanism অনুযায়ী, এই তিনটা call-এর মধ্যে কোনটাতে dynamic linker (ld.so) সক্রিয়ভাবে কাজ করবে, আর কোনটাতে করবে না?
প্রয়োগ
printf তিনবার call হয় — একবার প্রোগ্রামের শুরুতে, তারপর একটা loop-এর ভেতরে আরও দুইবার। PLT/GOT lazy binding mechanism অনুযায়ী, এই তিনটা call-এর মধ্যে কোনটাতে dynamic linker (ld.so) সক্রিয়ভাবে কাজ করবে, আর কোনটাতে করবে না?শুধু প্রথম call-টায় dynamic linker সক্রিয়ভাবে কাজ করবে — বাকি দুইটায় করবে না।
প্রথম call: printf@plt স্টাব চালু হয়, GOT[printf]-এ তখনও printf-এর প্রকৃত ঠিকানা নেই (শুরুতে সেটা resolver স্টাবের দিকে নির্দেশ করে)। তাই control চলে যায় dynamic linker-এর কাছে — সে libc.so.6-এ printf-এর সংজ্ঞা খুঁজে বের করে, GOT[printf]-এ সেই প্রকৃত ঠিকানা লিখে দেয় (patch করে), তারপর printf-এ jump করে execute করে। এই ধাপটা তুলনামূলকভাবে ধীর — একটা symbol lookup, memory write জড়িত।
দ্বিতীয় ও তৃতীয় call (loop-এর ভেতরে): এতক্ষণে GOT[printf]-এ প্রকৃত ঠিকানা ইতিমধ্যেই বসানো আছে (প্রথম call-এর সময় patch হয়ে গেছে)। তাই printf@plt স্টাব চালু হয়ে সরাসরি GOT[printf]-এর মান পড়ে সেখানেই jump করে — dynamic linker-এর কোনো অংশগ্রহণ লাগে না, প্রায় একটা সাধারণ indirect function call-এর মতোই দ্রুত।
এই আচরণটাই lazy binding-এর মূল লক্ষ্য অর্জন করে — resolution-খরচ একবারই হয় (প্রথম ব্যবহারে), তারপর বাকি সব call প্রায় বিনামূল্যে। যদি printf লুপে হাজারবার call হতো, শুধু প্রথমটাই ধীর হতো, বাকি ৯৯৯টা প্রায় zero-overhead indirect jump।
এরপর কী
পরের লেসন — x86-64 Syntax
এই লেসনের example section-এ square.c কম্পাইল করে যে assembly পেয়েছিলাম, সেটা এভাবে লেখা ছিল:
movl %edi, -4(%rbp)
imull -4(%rbp), %eax% চিহ্ন, register নাম আগে-source-পরে-destination — এই মুহূর্তে হয়তো এটা স্বাভাবিক মনে হয়েছে, কিন্তু আসলে এটা একটা নির্দিষ্ট, historically-motivated syntax convention — যাকে বলে AT&T syntax। ঠিক এই একই instruction অন্য একটা সম্পূর্ণ ভিন্ন, সমানভাবে বৈধ টেক্সট-আকারে লেখা যায়:
mov dword ptr [rbp-4], edi
imul eax, dword ptr [rbp-4]দুটোই ঠিক একই machine code বর্ণনা করছে — শুধু textual convention ভিন্ন। এই দুটো দুনিয়ার একসাথে বাস করা — একদিকে GNU টুলচেইন (gcc, as, gdb, objdump — সবই ডিফল্টে AT&T), অন্যদিকে Intel-এর নিজস্ব ডকুমেন্টেশন আর NASM/MASM (Intel syntax) — পরের লেসনের বিষয়। সেখানে আমরা এই দুই syntax পাশাপাশি শিখব, কেন এই বিভাজন হলো তার ইতিহাস দেখব, আর x86-64-র পূর্ণ general-purpose register set (৬৪/৩২/১৬/৮-বিট sub-register সহ) আর মূল instruction category (mov, arithmetic, comparison, jump) হাতে-কলমে দেখব।
আরও পড়ুন
- Linkers and Loaders — John R. Levine · এই বিষয়ের একমাত্র সম্পূর্ণ, প্রামাণ্য বই — object file format থেকে dynamic linking পর্যন্ত সব বিস্তারিত
- Computer Systems: A Programmer's Perspective, Chapter 7 — Linking — Randal E. Bryant, David R. O'Hallaron · Symbol resolution, relocation, static/dynamic linking-এর টেক্সটবুক-মানের ব্যাখ্যা, বাস্তব ELF উদাহরণসহ
- GNU Binutils Documentation (as, ld, objdump, nm) · এই লেসনের সব কমান্ডের প্রামাণ্য রেফারেন্স
- Executable and Linkable Format (ELF) Specification · Object file আর executable-এর section/symbol-table বিন্যাসের আনুষ্ঠানিক সংজ্ঞা