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

Register ও Calling Convention — System V AMD64 ABI

Registers and Calling Conventions

যে register-এ argument আসে, সেটা hardware-এর নিয়ম না — System V AMD64 ABI নামের একটা software চুক্তি, যা মেনে চললেই ভিন্ন compiler বা ভাষায় লেখা কোড একে অপরকে সঠিকভাবে call করতে পারে।

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

  • System V AMD64 ABI-র প্রথম ছয়টা integer/pointer argument কোন কোন register-এ যায় (rdi, rsi, rdx, rcx, r8, r9 — ঠিক এই ক্রমে) মুখস্থ ও যেকোনো C function signature-এ প্রয়োগ করতে পারবেন
  • caller-saved (rax, rcx, rdx, rsi, rdi, r8-r11) আর callee-saved (rbx, rbp, r12-r15, rsp) register-এর সম্পূর্ণ তালিকা বলতে ও কোনো নির্দিষ্ট কোডে কোনটা কখন বাঁচাতে হবে সিদ্ধান্ত নিতে পারবেন
  • একটা real compiled C function-এর assembly output পড়ে ঠিক কোন argument কোন register-এ গেল, আর return value কোথায় গেল তা identify করতে পারবেন
  • calling convention কেন একটা pure software agreement, hardware-এনফোর্সড নিয়ম না — সেটা যুক্তি দিয়ে ব্যাখ্যা করতে পারবেন, আর এই কারণেই ভিন্ন compiler/ভাষার কোড কীভাবে একে অপরকে call করতে পারে তা বর্ণনা করতে পারবেন
  • floating-point argument (xmm0-xmm7) আর integer argument-এর জন্য দুইটা independent register-counter কীভাবে কাজ করে তা ব্যাখ্যা করতে পারবেন
  • variadic function call-এ (যেমন printf) %al register-এ vector-register-count কেন বসাতে হয় তার কারণ বলতে পারবেন

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

আগে এটা বুঝি

গত মডিউলের শেষ লেসনে (computer-architecture/registers-pc-flags) আমরা একটা প্রতিশ্রুতি রেখেছিলাম। একটা max ফাংশনের উদাহরণে দেখেছিলাম a আসে edi-তে, b আসে esi-তে, return value যায় eax-এ — আর তখনই বলা হয়েছিল, “hardware register-এর সংখ্যা আর width নির্দিষ্ট করে, তাদের ভূমিকা নির্দিষ্ট করে সফটওয়্যার-কনভেনশন… পুরো convention আর ABI-র বিস্তারিত নিয়ম assembly মডিউলে বিস্তারিত আসবে।” এই লেসনটাই সেই প্রতিশ্রুতি।

একটা সহজ প্রশ্ন দিয়ে শুরু করি। আপনি gcc-এ একটা C ফাংশন কম্পাইল করলেন। আপনার বন্ধু ঠিক একই ফাংশনের signature দেখে clang-এ (সম্পূর্ণ আলাদা compiler) একটা caller লিখলেন, যেটা সেই ফাংশনকে call করে। দুটো আলাদা compiler, হয়তো দুটো আলাদা optimization pipeline, ভিন্ন register-allocation সিদ্ধান্ত — তবু, লিংক করার পর, প্রোগ্রামটা সঠিকভাবে চলে। কীভাবে?

উত্তরটা হার্ডওয়্যারে নেই। CPU-র সিলিকনে কোথাও লেখা নেই যে “প্রথম argument অবশ্যই rdi-তে যাবে” — rdi একটা সাধারণ ৬৪-বিট general-purpose register, ঠিক rax বা r10-এর মতোই। CPU নিজে কখনো “argument” শব্দটাই জানে না; সে শুধু instruction execute করে। যা আসলে ঘটছে তা হলো — gcc আর clang দুটোই, স্বাধীনভাবে লেখা হলেও, একটা একই লিখিত নথি মেনে চলে যেটা বলে দেয় “প্রথম integer argument rdi-তে রাখবে।” এই নথির নাম System V AMD64 ABI (Application Binary Interface) — Linux, macOS, BSD-তে x86-64-র জন্য প্রমিত calling convention। এই লেসনে আমরা এই চুক্তিটা নিখুঁতভাবে দেখব: ঠিক কোন argument কোন register-এ যায়, return value কোথায় যায়, আর কোন register কার দায়িত্ব বাঁচানোর।

মূল ধারণা

ছয়টা register, একটা ক্রম — মুখস্থ রাখার মতো গুরুত্বপূর্ণ

System V AMD64 ABI বলে: একটা ফাংশনের প্রথম ছয়টা integer বা pointer argument, বাম থেকে ডানে, এই নির্দিষ্ট ক্রমে যায়:

rdirsirdxrcxr8r9\text{rdi} \to \text{rsi} \to \text{rdx} \to \text{rcx} \to \text{r8} \to \text{r9}

এই ক্রমটা এতটাই মৌলিক যে assembly পড়ার সময় এটা তাৎক্ষণিকভাবে চেনা উচিত — ঠিক যেমন if/while চিনতে ভাবতে হয় না। একটা জনপ্রিয় স্মৃতি-কৌশল (mnemonic) আছে: “Diane’s Silk Dress Cost $89 — D(i) S(i) D(x) C(x) 8 9 → rDI, rSI, rDX, rCX, r8, r9।

সপ্তম argument থেকে শুরু করে বাকি সব argument stack-এ যায় (এই লেসনের পরের লেসনে stack-এর সম্পূর্ণ বিস্তারিত আসবে — এখানে শুধু জেনে রাখুন এটা ঘটে)।

Return value যায় rax-এ (৬৪-বিটের কম হলে eax/ax/al — নিচের অংশ ব্যবহার হয়)। যদি return value ১২৮ বিট পর্যন্ত বড় হয় (যেমন একটা struct যাতে দুইটা long আছে), rax:rdx জোড়া ব্যবহার হয় — rax-এ নিচের ৬৪ বিট, rdx-এ উপরের ৬৪ বিট।

Floating-point argument সম্পূর্ণ আলাদা register-সেট ব্যবহার করেxmm0 থেকে xmm7, একই ক্রমে। Floating-point return value যায় xmm0-এ। গুরুত্বপূর্ণ একটা সূক্ষ্মতা এখানেই — integer আর floating-point argument-এর জন্য দুইটা সম্পূর্ণ স্বতন্ত্র counter থাকে, একটা counter-এর advancement আরেকটাকে প্রভাবিত করে না। এই প্রথম দেখাতে অদ্ভুত মনে হতে পারে, তাই একটা concrete উদাহরণ পরের সেকশনেই আসছে।

Registerভূমিকা (role)Save করার দায়িত্ব
rdi১ম integer/pointer argumentCaller-saved
rsi২য় integer/pointer argumentCaller-saved
rdx৩য় argument, rax-এর সাথে ১২৮-বিট return-এর উপরের অংশCaller-saved
rcx৪র্থ argumentCaller-saved
r8৫ম argumentCaller-saved
r9৬ষ্ঠ argumentCaller-saved
raxReturn value, variadic call-এ SSE-count বহন করে (al)Caller-saved
r10, r11কোনো নির্দিষ্ট ভূমিকা নেই, শুধু scratchCaller-saved
rbxকোনো নির্দিষ্ট ভূমিকা নেইCallee-saved
rbpসাধারণত frame pointer (পরের লেসনে বিস্তারিত)Callee-saved
r12r15কোনো নির্দিষ্ট ভূমিকা নেইCallee-saved
rspStack pointer — সবসময় সংরক্ষিত থাকতে হয় (পরের লেসনে বিস্তারিত)বিশেষ — সবসময় balance রাখতে হয়
System V AMD64 ABI-র সম্পূর্ণ integer/pointer register table — role, caller/callee দায়িত্ব-সহ।

Caller-saved বনাম callee-saved — পুরো নিয়মটা একনজরে

গত লেসনে এই ভাগটা প্রথম আভাস পেয়েছিলেন। এখানে সম্পূর্ণ নিয়মটা স্পষ্ট করে দেওয়া যাক, কারণ এটাই এই লেসনের সবচেয়ে বেশি ভুল-বোঝা অংশ।

Caller-saved (aka “volatile”)rax, rcx, rdx, rsi, rdi, r8, r9, r10, r11। একটা function call-এর পর এই register-গুলোর মান যেকোনো কিছু হতে পারে ধরে নিতে হবে। যদি caller-এর একটা মান এমন একটা register-এ থাকে, আর call-এর পরও সেই মানটা দরকার — caller-কেই call-এর আগে সেটা কোথাও (সাধারণত stack-এ) বাঁচিয়ে রাখতে হবে, নাহলে সেই মান হারিয়ে যাবে।

Callee-saved (aka “non-volatile”)rbx, rbp, r12, r13, r14, r15। একটা called function যদি এই register-গুলোর কোনোটা ব্যবহার করতে চায়, তাকে প্রথমে পুরোনো মান বাঁচিয়ে রাখতে হবে (সাধারণত stack-এ push করে), আর return করার আগে সেই পুরোনো মান ফিরিয়ে দিতে হবে (pop করে)। ফলাফল — caller নিশ্চিন্তে ধরে নিতে পারে, একটা call-এর পরেও এই register-গুলোর মান অপরিবর্তিত থাকবে, ভেতরে যতই জটিল কাজ হোক না কেন।

কম্পাইলার বাস্তবে কীভাবে caller-saved স্পিল করে — একটা concrete case

তত্ত্বটা concrete করতে একটা এমন ফাংশন দেখা যাক যেখানে caller-saved register বাঁচানো সত্যিই বাধ্যতামূলক:

int g(int x);
int h(int x);

int f(int a) {
    int r1 = g(a);      // g()-এর ভেতরে rdi/rax/rcx/... সব বদলে যেতে পারে
    int r2 = h(a);       // h()-কে call করার আগে a আবার দরকার — কিন্তু a আগে rdi-তে ছিল, g()-এর জন্যও rdi লেগেছিল
    return r1 + r2;
}

এখানে a দুইবার দরকার — একবার g(a)-তে, একবার h(a)-তে। কিন্তু g() নিজে rdi-সহ যেকোনো caller-saved register free ভাবে বদলে দিতে পারে (তার নিজের ভেতরের কাজে)। তাই compiler a-কে g()-এর call-এর আগে একটা callee-saved register-এ (অথবা stack-এ) সরিয়ে রাখবে, যাতে g() থেকে ফেরার পরও এটা অক্ষত থাকে:

f:
        push    %rbx              # rbx callee-saved — নিরাপদে ব্যবহারের আগে বাঁচানো (prologue-এর অংশ, পরের লেসনে বিস্তারিত)
        mov     %edi, %ebx         # a-কে rbx-এ সরিয়ে রাখা — g()-এর call rdi/rax/... বদলে দিলেও rbx অক্ষত থাকবে
        call    g                  # g(a) — এতে rdi, rax, rcx, rdx, rsi, r8-r11 যেকোনোটা বদলে যেতে পারে
        mov     %eax, %r12d        # r1 (g-এর return value) — এটাও একটা callee-saved register-এ (r12) বাঁচানো, h()-এর call-এর পরও দরকার
        mov     %ebx, %edi         # h(a)-এর জন্য a আবার rdi-তে — rbx থেকে, যেটা g()-এর call সত্ত্বেও অক্ষত ছিল
        call    h                  # h(a)
        add     %r12d, %eax        # r1 + r2 (h-এর return value, এখনও eax-এ)
        pop     %rbx               # rbx ফেরত দেওয়া, কারণ callee-saved হিসেবে ব্যবহার করেছিলাম
        ret

লক্ষ্য করুন — a কে caller-saved register-এ (rdi) না রেখে ইচ্ছাকৃতভাবে একটা callee-saved register-এ (rbx) সরানো হলো, ঠিক g()-এর call-এর কারণে সৃষ্ট বিপদ এড়াতে। এটাই বাস্তব কম্পাইলারের সবচেয়ে সাধারণ register-allocation কৌশলগুলোর একটা — cross-call-বেঁচে-থাকা দরকার এমন মান-গুলোকে callee-saved register-এ রাখা, প্রতিবার stack-এ push/pop এড়িয়ে।

কেন এই পুরো ব্যবস্থাটাই একটা software চুক্তি, hardware নিয়ম না

এখানেই এই লেসনের কেন্দ্রীয় দাবিটা আসে, আর এটা শুধু একটা তাত্ত্বিক মন্তব্য না — এর বাস্তব ফলাফল আছে। CPU জানেই না rdi মানে “প্রথম argument”। CPU শুধু জানে rdi নামের একটা ৬৪-বিট register আছে, আর কোনো instruction (mov, add, …) সেটাকে operand হিসেবে address করতে পারে। “প্রথম argument rdi-তে যাবে” — এই নিয়মটা সম্পূর্ণভাবে compiler-লেখকদের মধ্যে সম্মত একটা প্রোটোকল, যা একটা লিখিত ডকুমেন্টে (ABI spec) বিবৃত, কোনো circuit-এ এনফোর্স করা না।

এর মানে দাঁড়ায়: যদি একটা compiler bug এই convention ভাঙে — ধরুন ভুলবশত দ্বিতীয় argument rdi-তে রেখে দেয় (প্রথমটার জায়গায়) — hardware কোনো error দেবে না। প্রোগ্রামটা চলতে থাকবে, কিন্তু ভুল মান নিয়ে কাজ করবে, ভুল ফলাফল দেবে অথবা crash করবে — প্রায়ই এমন একটা জায়গায় যেটা মূল bug থেকে অনেক দূরে, debug করা কঠিন। ঠিক এই কারণেই ABI compliance এত গুরুত্বপূর্ণ — এটা কোনো “best practice” না, এটা একমাত্র উপায় যাতে স্বাধীনভাবে কম্পাইল করা কোড একসাথে কাজ করতে পারে।

আর ঠিক এই একই কারণে — এই একই চুক্তি মানলে — সম্পূর্ণ ভিন্ন ভাষায় লেখা কোডও একে অপরকে call করতে পারে। একটা Rust function extern "C" দিয়ে ঘোষিত হলে, সেটাও ঠিক এই একই ABI মেনে চলে; একটা Python C-extension, একটা Go-তে cgo দিয়ে লেখা wrapper — সবাই এই একই নথি মেনে চলে বলেই একে অপরের সাথে সরাসরি function call করতে পারে, কোনো translation layer ছাড়াই।

ভেতরে কী ঘটছে

দুইটা independent counter — integer আর floating-point আলাদা গণনা করে

আগের সেকশনে বলা হয়েছিল integer আর floating-point argument দুইটা আলাদা counter ব্যবহার করে। একটা concrete উদাহরণ দিয়ে এটা স্পষ্ট করা যাক:

double compute(int a, long b, double x, double y) {
    return x * a + y * b;
}

এখানে চারটা argument — দুইটা integer-class (a, b), দুইটা SSE-class (x, y)। ABI classification প্রতিটা argument-কে বাম থেকে ডানে দেখে তার class (INTEGER বা SSE) অনুযায়ী পরবর্তী উপলব্ধ register বরাদ্দ করে — কিন্তু দুইটা class-এর জন্য গণনা আলাদাভাবে চলে:

ArgumentClassবরাদ্দকৃত register
a (int)INTEGER slot ১edi (rdi-র নিচের ৩২ বিট)
b (long)INTEGER slot ২rsi (পূর্ণ ৬৪ বিট)
x (double)SSE slot ১xmm0
y (double)SSE slot ২xmm1

লক্ষ্য করুন — b কে rdx-এ পাঠানো হয়নি (যেটা হতো যদি সব argument একটাই ক্রমিক counter শেয়ার করত), বরং rsi-তে গেছে, কারণ INTEGER-class counter-এ এটাই দ্বিতীয় অবস্থান। একইভাবে x আর y rdi/rsi-র কোনো সম্পর্ক ছাড়াই সরাসরি xmm0/xmm1-এ গেছে। একটা representative -O2 compiled output:

compute:
        cvtsi2sd %edi, %xmm2      # xmm2 = (double)a  — edi থেকে int→double রূপান্তর
        mulsd    %xmm0, %xmm2     # xmm2 = x * a
        cvtsi2sd %rsi, %xmm0      # xmm0 = (double)b  — rsi (পূর্ণ ৬৪-বিট, কারণ b একটা long)
        mulsd    %xmm1, %xmm0     # xmm0 = y * b
        addsd    %xmm2, %xmm0     # xmm0 = (y*b) + (x*a)  — চূড়ান্ত return value
        ret

cvtsi2sd instruction-টা লক্ষ্য করুন — এর operand register-এর width (%edi বনাম %rsi) থেকেই assembler বুঝে নেয় ৩২-বিট না ৬৪-বিট integer-কে convert করতে হবে, কোনো আলাদা mnemonic ছাড়াই। আর শেষ ফলাফল xmm0-তেই থাকল — যেটা ঠিক double-এর জন্য return-value register।

ছোট struct — register-এই “প্যাক” হয়ে যেতে পারে

একটা স্বাভাবিক প্রশ্ন — একটা struct argument হলে কী হয়? পূর্ণ বিস্তারিত এই লেসনের সুযোগের বাইরে, কিন্তু মূল নিয়মটা জানা দরকার। ABI প্রতিটা struct-কে ৮-byte “eightbyte” খণ্ডে ভাগ করে দেখে — যদি সম্পূর্ণ struct ১৬ byte বা তার কম হয় আর তার প্রতিটা eightbyte-এ শুধু integer/pointer type থাকে (কোনো complex/large field না), পুরো struct-টাই সরাসরি এক বা দুইটা integer register-এ চলে যায়, memory স্পর্শ না করেই:

struct Point { int x, y; };            // ৮ byte মোট — একটা eightbyte
long dist2(struct Point p) {
    return (long)p.x * p.x + (long)p.y * p.y;
}
dist2:
        movd    %edi, %xmm0     # p সম্পূর্ণটাই rdi-তে এসেছে (x নিচের ৩২ বিট, y উপরের ৩২ বিট) — কোনো memory access নেই
        ...

p (৮ byte, দুইটা int) সরাসরি edi-র মধ্যেই প্যাক হয়ে এসেছে — যেন এটা একটা single long argument। কিন্তু struct ১৬ byte-এর বেশি হলে (বা কোনো field-এ array/বড় type থাকলে), ABI hardware-register-এ প্যাক করা ছেড়ে দেয় — পুরো struct memory-তে (সাধারণত caller-এর stack frame-এ) রাখা হয়, আর শুধু তার ঠিকানা (একটা সাধারণ pointer) integer-class register-এ পাঠানো হয়। এই সীমারেখাটাই (১৬ byte) কেন গুরুত্বপূর্ণ — একটা ছোট struct pass করা প্রায় বিনামূল্যে (register-এই), কিন্তু একটু বড় হলেই hidden pointer-indirection এর খরচ যোগ হয়ে যায়।

Stack alignment — একটা প্রয়োজনীয়তা যেটা SSE instruction থেকে আসে

ABI-র আরেকটা নিয়ম — call instruction execute হওয়ার ঠিক মুহূর্তে, rsp অবশ্যই ১৬-byte-এ aligned থাকতে হবে (অর্থাৎ rsp mod 16 == 0)। এটা কোনো নির্বিচার নিয়ম না — এর একটা সুনির্দিষ্ট hardware কারণ আছে। SSE-র কিছু instruction (যেমন movaps, movdqa — “aligned” load/store) শুধুমাত্র ১৬-byte-aligned মেমরি ঠিকানায় কাজ করে; unaligned ঠিকানায় ব্যবহার করলে CPU একটা #GP (general protection) fault তোলে। Compiler প্রায়ই local array/struct-কে এই দ্রুততর aligned instruction দিয়ে access করে, ধরে নিয়ে যে stack-এর alignment ABI অনুযায়ী নিশ্চিত। যদি এই চুক্তি ভাঙা হয় — কোনো এক ফাংশন ভুলভাবে rsp-কে ৮-এর গুণিতকে রেখে দেয় (১৬-এর না) — তাহলে অনেক পরের একটা সম্পূর্ণ ভিন্ন ফাংশনে, যেটা movaps ব্যবহার করে, হঠাৎ crash করবে। এটা calling convention ভাঙার একটা বাস্তব, প্রায়ই বিভ্রান্তিকর পরিণতির উদাহরণ — bug-এর কারণ আর তার লক্ষণ সম্পূর্ণ ভিন্ন জায়গায়।

call instruction নিজে ৮ byte push করে (return address), তাই একটা function-এর প্রথম instruction-এ ঢোকার মুহূর্তে rsp mod 16 == 8 (১৬ না, ইচ্ছাকৃতভাবে) — কারণ caller-এর call-এর ঠিক আগে rsp mod 16 == 0 ছিল। এই নির্দিষ্ট অফসেটটা মনে রাখা পরের লেসনে prologue বোঝার সময় গুরুত্বপূর্ণ হবে।

Variadic function — %al-এ SSE-count লুকিয়ে থাকা একটা ABI কৌশল

printf-এর মতো variadic function (যাদের argument সংখ্যা compile-time-এ নির্দিষ্ট না) কল করার সময় একটা বাড়তি নিয়ম আছে — যদি call-এ কোনো floating-point argument থাকে, %al (৮-বিট, rax-এর সবচেয়ে নিচের byte) register-এ ব্যবহৃত xmm register-এর সংখ্যা (০ থেকে ৮) বসাতে হয়, call execute হওয়ার ঠিক আগে:

printf("%d %f\n", 5, 3.14);
        movq    $1, %rdi         ; format string ঠিকানা (উদাহরণের জন্য সরলীকৃত)
        mov     $5, %esi         ; ১ম variadic arg — integer "%d" → esi
        movsd   .LC_314(%rip), %xmm0   ; ২য় variadic arg — "%f" → xmm0
        mov     $1, %al          ; ★ ব্যবহৃত xmm register সংখ্যা = ১
        call    printf

কেন এই নিয়ম দরকার? printf-এর মতো variadic function-কে internally জানতে হয় কয়টা xmm register-এ প্রকৃত argument আছে, কারণ va_list-এর মাধ্যমে সেগুলো memory-তে save করার সময় সব ৮টা xmm register save করা অপচয় — %al দিয়ে ঠিক কয়টা প্রয়োজন তা আগেই জানিয়ে দিলে callee শুধু প্রয়োজনীয়টুকুই save করে। এটা caller-saved/callee-saved-এর ঠিক একই “শুধু প্রয়োজনীয়টা কর” দর্শনের আরেকটা প্রকাশ, বিশেষভাবে variadic call-এর জন্য ডিজাইন করা।

উদাহরণ

সাতটা argument — যখন register শেষ হয়ে যায়

long sum7(long a, long b, long c, long d, long e, long f, long g) {
    return a + b + c + d + e + f + g;
}

প্রথম ছয়টা (af) যথারীতি rdi, rsi, rdx, rcx, r8, r9-এ যায়। সপ্তমটা (g) — register শেষ, তাই stack-এ যায়। Caller-এর দৃষ্টিকোণ থেকে:

        movq    $7, (%rsp)       # সপ্তম argument (g) — stack-এর top-এ রাখা
        mov     $6, %r9d         # f
        mov     $5, %r8d         # e
        mov     $4, %ecx         # d
        mov     $3, %edx         # c
        mov     $2, %esi         # b
        mov     $1, %edi         # a
        call    sum7

call execute হওয়ার সাথে সাথে return address আরও ৮ byte push হয় (পরের লেসনে বিস্তারিত), তাই callee-র prologue শেষ হওয়ার পর g-কে পাওয়া যায় 16(%rbp)-এ (৮ byte saved rbp + ৮ byte return address):

sum7:
        push    %rbp
        mov     %rsp, %rbp
        mov     16(%rbp), %rax   # g — সপ্তম argument, stack থেকে পড়া
        add     %rdi, %rax
        add     %rsi, %rax
        add     %rdx, %rax
        add     %rcx, %rax
        add     %r8, %rax
        add     %r9, %rax
        pop     %rbp
        ret

এই উদাহরণটা স্পষ্ট করে দেয় — “প্রথম ৬টা register-এ” নিয়মটা কোনো নির্বিচার সীমা না, বরং সরাসরি হার্ডওয়্যারে উপলব্ধ integer-class calling register-এর সংখ্যা (rdi, rsi, rdx, rcx, r8, r9 — ঠিক ৬টা) থেকে আসছে। সপ্তমটা থেকে আর কোনো “খালি” register নেই, তাই ABI-র একমাত্র বিকল্প stack ব্যবহার করা।

একটা function call-এর সম্পূর্ণ যাত্রা

একটা ABI-compliant function call — caller থেকে callee, আবার caller-এ ফেরা
  1. Caller: argument register-এ বসায়ABI ক্রম অনুযায়ী rdi, rsi, rdx, ... — অথবা xmm0, xmm1, ... ফ্লোটের জন্য
  2. Caller: প্রয়োজনীয় caller-saved register বাঁচায়call-এর পরও দরকার এমন মান আগে থেকে stack-এ push করা
  3. call target — return address push, jumpপরের লেসনে সম্পূর্ণ বিস্তারিত mechanics
  4. Callee: ব্যবহারের আগে callee-saved register বাঁচায়শুধু যেগুলো ভেতরে ব্যবহার করবে
  5. Callee: কাজ করে, return value rax/xmm0-এ রাখেABI-নির্ধারিত return-value register
  6. Callee: callee-saved register ফিরিয়ে দেয়, ret করেstack থেকে return address পড়ে PC-তে বসায়
  7. Caller: rax/xmm0 থেকে ফলাফল পড়ে, কাজ চালিয়ে যায়caller-saved register-গুলো এখন অবিশ্বস্ত ধরে নিতে হবে

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

EXPERIMENT

GDB দিয়ে real argument register verify করুন

Linux/WSL (GCC + GDB)· ২০ মিনিট
// abi_demo.c
#include <stdio.h>

long sum6(long a, long b, long c, long d, long e, long f) {
    return a + b + c + d + e + f;
}

int main() {
    long r = sum6(1, 2, 3, 4, 5, 6);
    printf("%ld\n", r);
    return 0;
}
gcc -O0 -g abi_demo.c -o abi_demo
gdb ./abi_demo

GDB-এর ভেতরে:

(gdb) break sum6
(gdb) run
(gdb) info registers rdi rsi rdx rcx r8 r9
(gdb) disassemble

যা লক্ষ্য করবেন: sum6 breakpoint-এ ঠোকার মুহূর্তে rdi=1, rsi=2, rdx=3, rcx=4, r8=5, r9=6 — ঠিক এই লেসনের নিয়ম অনুযায়ী, কোনো ব্যতিক্রম ছাড়াই।

দ্বিতীয় ধাপ — caller-saved register-এর অস্থিরতা যাচাই করুন:

(gdb) break main
(gdb) run
(gdb) next
(gdb) print $rax
(gdb) next    ; sum6 কল হয়ে ফিরে আসা পর্যন্ত step over করুন
(gdb) print $rax

sum6 call-এর আগে-পরে rax-এর মান তুলনা করুন — call-এর পর rax-এ return value (21) থাকবে, যা প্রমাণ করে rax caller-saved (call এটা পুরোপুরি বদলে দিতে “স্বাধীন”)।

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

এই লেসনের রেজিস্টার-ক্রম (rdi, rsi, rdx, rcx, r8, r9) তাত্ত্বিক না — প্রতিটা real gcc-compiled function call ঠিক এই ক্রমেই argument বসায়, আর caller-saved register call-এর পর সত্যিই বদলে যায়।

নিজে বানান

BUILD IT

নিজে হাতে-লেখা assembly function — C থেকে call করুন

x86-64 Assembly (AT&T) + C · ●●○○○
  1. একটা add5.s ফাইল লিখুন — একটা add5 নামের function যা পাঁচটা int argument নেয় (edi, esi, edx, ecx, r8d) আর তাদের যোগফল eax-এ রিটার্ন করে
  2. ফাংশনের উপরে .globl add5 আর .text directive যোগ করুন, যাতে C কোড থেকে এটা দেখা যায় (linker symbol হিসেবে)
  3. একটা caller.c লিখুন যেখানে extern int add5(int, int, int, int, int); ঘোষণা করে সেটাকে কল করুন printf দিয়ে ফলাফল দেখান
  4. gcc -c add5.s -o add5.o দিয়ে শুধু আপনার assembly ফাইল compile করুন, তারপর gcc caller.c add5.o -o prog দিয়ে দুটো একসাথে লিংক করুন
  5. ./prog চালিয়ে যাচাই করুন সঠিক যোগফল আসছে কি না — এটাই প্রমাণ যে আপনার হাতে-লেখা assembly ঠিক ABI মেনেছে, নাহলে C caller ভুল মান পেত

add5.s:

.globl add5
.text
add5:
    # ABI অনুযায়ী: a=edi, b=esi, c=edx, d=ecx, e=r8d
    mov  %edi, %eax
    add  %esi, %eax
    add  %edx, %eax
    add  %ecx, %eax
    add  %r8d, %eax
    ret

caller.c:

#include <stdio.h>
extern int add5(int, int, int, int, int);

int main(void) {
    printf("%d\n", add5(1, 2, 3, 4, 5));
    return 0;
}

আউটপুট 15 হওয়া উচিত। এখানে গুরুত্বপূর্ণ যা প্রমাণ হলো: আপনি কোনো C কোড লেখেননি add5-এর জন্য, তবু gcc-এ compile হওয়া caller.c আপনার হাতে-লেখা raw assembly-কে নির্ভুলভাবে call করতে পারল — কারণ শুধু দুটোই একই ABI মেনেছে। এটাই এই লেসনের কেন্দ্রীয় দাবিটার সরাসরি, হাতে-কলমে প্রমাণ।

নিজে বাড়ান: add5-কে বদলে দিন যাতে এটা rbx-ও ব্যবহার করে (একটা callee-saved register) কোনো intermediate calculation-এ, কিন্তু return করার আগে তার আগের মান restore করতে ভুলে যান ইচ্ছাকৃতভাবে। caller.c-তে add5 কল করার আগে-পরে কোনো local variable rbx-এ রাখা একটা compiler-generated কোডে (এটা নিয়ন্ত্রণ করা কঠিন high-level C থেকে, তাই এটা একটা চিন্তা-পরীক্ষা হিসেবেও করতে পারেন) কী রকম subtle bug ঘটতে পারে তা ব্যাখ্যা করুন এক অনুচ্ছেদে।

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

Calling convention যেখানে বাস্তব সিদ্ধান্ত নির্ধারণ করে

Foreign Function Interface (FFI) — বহু-ভাষার আন্তঃকার্যযোগ্যতার ভিত্তি। Python-এর ctypes, Rust-এর extern "C", Node.js-এর native addon — সবাই ঠিক এই System V AMD64 ABI মেনে চলে বলেই একটা C library-কে সরাসরি call করতে পারে, কোনো translation বা wrapper-layer ছাড়াই। এই লেসনের নিয়মগুলো literally সেই সেতু যা ভিন্ন ভাষার ইকোসিস্টেমকে একসাথে কাজ করতে দেয়।

JIT compiler-এর runtime call-back। V8 (Chrome/Node.js-এর JavaScript engine) বা JVM-এর JIT যখন machine code জেনারেট করে, সেই generated code-কেও মাঝে মাঝে C++ runtime function call করতে হয় (garbage collection trigger করা, ইত্যাদি) — এই call-গুলো ঠিক এই ABI মেনেই লেখা, নাহলে runtime crash করবে।

Windows x64 calling convention — একটা ভিন্ন চুক্তি, একই ধারণা। Windows-এর x64 ABI System V-র থেকে ভিন্ন — প্রথম চারটা integer argument যায় rcx, rdx, r8, r9-এ (rdi/rsi না!), আর caller-কে call-এর আগে একটা “shadow space” (৩২ byte) reserve করতে হয়। এটাই কারণ কেন cross-platform C library প্রায়ই #ifdef _WIN32 দিয়ে আলাদা calling-convention attribute ব্যবহার করে — একই hardware, দুইটা সম্পূর্ণ ভিন্ন software চুক্তি, এই লেসনের কেন্দ্রীয় দাবিরই আরেকটা প্রমাণ।

Debugger backtrace আর crash-dump বিশ্লেষণ। gdb-তে info args কমান্ড ঠিক এই ABI-নিয়ম ব্যবহার করে ফাংশনের argument register থেকে মান পড়ে দেখায় — এই লেসনের experiment-এই যা প্রত্যক্ষ করেছেন।

Signal handler আর red zone-এর সংঘর্ষ। x86-64-র “red zone” (leaf function-এর জন্য rsp-র নিচের ১২৮ byte, sub rsp ছাড়াই ব্যবহারযোগ্য — গত লেসনে সংক্ষেপে উল্লেখ হয়েছিল) একটা বাস্তব সমস্যা তৈরি করে signal handler-এ — যেহেতু signal handler asynchronously (যেকোনো মুহূর্তে, কোনো normal call ছাড়াই) invoke হয়, সেই মুহূর্তে চলমান ফাংশনের red zone-এর ডেটা signal handler-এর নিজের ব্যবহারে ওভাররাইট হয়ে যেতে পারে — তাই কার্নেল signal handler invoke করার সময় ABI অনুযায়ী red zone-এর নিচে আরও জায়গা রেখে দেয়।

Struct passing-এর জটিলতা। ছোট struct (১৬ byte-এর কম) কখনো কখনো সরাসরি register-এ pack করে পাঠানো হয় (উপরের রীতি অনুসরণ করেই), বড় struct memory-তে রেখে শুধু pointer পাঠানো হয় — এই classification নিয়মও একই ABI ডকুমেন্টের অংশ, যদিও সম্পূর্ণ বিস্তারিত এই লেসনের সুযোগের বাইরে।

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

“rdi-কে hardware বিশেষভাবে ট্রিট করে 'প্রথম argument register' হিসেবে — এটা একটা বিশেষ circuit।”

সম্পূর্ণ ভুল। rdi হার্ডওয়্যার-গঠনের দিক থেকে ঠিক অন্য যেকোনো general-purpose register-এর মতো — একই flip-flop, একই write-enable কাঠামো (Level 2-র শিক্ষা এখানেও প্রযোজ্য)। CPU-র কোনো circuit “জানে না” rdi-এ argument আছে কি না। “প্রথম argument rdi-তে” — এই নিয়মটা সম্পূর্ণভাবে compiler-এর মধ্যে সম্মত একটা convention, যা mov, call ইত্যাদি সাধারণ instruction ব্যবহার করেই বাস্তবায়িত হয়। Hardware শুধু জানে “এটা একটা register, এতে লেখো/পড়ো” — বাকি সবটাই সফটওয়্যার-স্তরের অর্থ।

“Calling convention CPU/ISA স্পেসিফিকেশনের একটা অংশ — যেমন opcode বা addressing mode।”

না, এবং এই পার্থক্যটা গুরুত্বপূর্ণ। ISA (Instruction Set Architecture) স্পেসিফিকেশন বলে দেয় কতগুলো register আছে, কী কী instruction আছে, কীভাবে তারা encode হয় — এটা hardware-vendor (Intel/AMD) প্রকাশ করে। Calling convention/ABI একটা সম্পূর্ণ ভিন্ন, পৃথক ডকুমেন্ট — OS ও compiler-লেখকদের মধ্যে সম্মত, hardware-vendor-এর সাথে সরাসরি সম্পর্কহীন। এই কারণেই একই x86-64 ISA-তে দুইটা ভিন্ন ABI চলতে পারে (System V লিনাক্সে, একটা ভিন্ন convention Windows-এ) — hardware একই থেকে যায়, শুধু সফটওয়্যার-চুক্তি বদলায়।

“Caller-saved register মানে caller-কে সবসময়, প্রতিটা call-এর আগে সেটা বাঁচাতে হয়।”

না — “caller-saved” মানে caller যদি সেই register-এর মান call-এর পরও দরকার মনে করে, তখনই তাকে বাঁচাতে হবে। যদি caller-এর সেই মুহূর্তে সেই register-এ এমন কোনো মান না থাকে যেটা call-এর পর দরকার (যেমন একটা temporary যা call-এর আগেই ব্যবহার শেষ হয়ে গেছে), caller কিচ্ছু বাঁচায় না — অপ্রয়োজনীয় push/pop এড়িয়ে যায়। এটাই আগের ইনসাইট-বক্সে আলোচিত optimization-এর মূল কথা — শুধু প্রয়োজনীয়টাই বাঁচানো, “সবসময় সব বাঁচানো” না।

“Floating-point argument-ও integer argument-এর মতো একই rdi/rsi/rdx... ক্রমে যায়, শুধু আলাদা register-এ কপি হয়।”

ভুল — এই লেসনের hood সেকশনের compute উদাহরণ ঠিক এই ভুল ধারণাটা ভাঙে। Integer/pointer argument আর floating-point argument-এর জন্য দুইটা সম্পূর্ণ স্বতন্ত্র counter থাকে। একটা function-এ (int a, double x, int b) থাকলে — a যায় INTEGER slot ১-এ (edi), x যায় SSE slot ১-এ (xmm0), b যায় INTEGER slot ২-এ (esi) — x-এর কারণে b কোনোভাবে edx-এ ঠেলে যায় না। দুই counter সম্পূর্ণ independent।

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

1

System V AMD64 ABI-তে প্রথম ছয়টা integer/pointer argument কোন কোন register-এ, ঠিক কোন ক্রমে যায়? Return value কোথায় যায়? Floating-point argument কী ভিন্ন register ব্যবহার করে?

স্মরণ

ক্রম: rdi, rsi, rdx, rcx, r8, r9 (মনে রাখার কৌশল: “Diane’s Silk Dress Cost $89”)। Return value যায় rax-এ (ছোট type হলে eax/ax/al)। Floating-point argument যায় xmm0xmm7-এ, একটা স্বতন্ত্র counter দিয়ে; floating-point return value যায় xmm0-এ।

2

কেন বলা হয় calling convention “hardware-enforced না, pure software agreement”? যদি একটা compiler এই convention ভুল implement করে (যেমন argument ভুল register-এ রাখে), তাহলে ঠিক কী ঘটবে — hardware কি কোনো error দেবে?

যুক্তি

Hardware জানেই না কোন register “argument” হিসেবে কী ভূমিকায় ব্যবহৃত হচ্ছে — সে শুধু register হিসেবে ট্রিট করে, তার ISA-নির্ধারিত semantics অনুযায়ী (mov, add যা করে করে)। “প্রথম argument rdi-তে” এই অর্থটা সম্পূর্ণভাবে compiler/programmer-এর মাথায়, hardware-এ কোনো check নেই।

যদি একটা compiler bug থাকে যা convention ভাঙে — hardware কোনো error দেবে না। প্রোগ্রাম চলতেই থাকবে, কিন্তু callee ভুল register থেকে ভুল মান পড়বে — ফলে হয় ভুল ফলাফল আসবে (silent, প্রায়ই ধরা কঠিন), অথবা একটা অসম্পর্কিত register-এ garbage থাকায় পরে কোথাও crash করবে — যেই মুহূর্ত crash হয় সেটা bug-এর প্রকৃত উৎস থেকে অনেক দূরে হতে পারে, যা এই ধরনের bug debug করা বিশেষভাবে কঠিন করে তোলে।

3

একটা ফাংশন void f(int a, double x, long b, double y, int c) — পাঁচটা argument, integer/pointer আর floating-point মিশ্রিত। প্রতিটা argument ঠিক কোন register-এ যাবে তা বের করুন।

প্রয়োগ

দুইটা independent counter ট্র্যাক করতে হবে — INTEGER আর SSE, বাম থেকে ডানে প্রতিটা argument দেখে তার class অনুযায়ী পরের স্লট বরাদ্দ করে:

  • a (int) → INTEGER slot ১ → edi
  • x (double) → SSE slot ১ → xmm0
  • b (long) → INTEGER slot ২ → rsi
  • y (double) → SSE slot ২ → xmm1
  • c (int) → INTEGER slot ৩ → edx

লক্ষ্য করুন — x আর y-র উপস্থিতি a, b, c-র register বরাদ্দে কোনো প্রভাব ফেলেনি; তারা যথাক্রমে edi, rsi, edx-এই গেছে, ঠিক যেন floating-point argument-গুলো নেই — এটাই দুই-counter নিয়মের প্রত্যক্ষ ফলাফল।

4

একটা ফাংশন long f(long a, long b, long c, long d, long e, long f, long g, long h) — আটটা integer argument। কোনটা কোনটা register-এ, কোনটা stack-এ যাবে, আর stack-এ থাকলে callee-র prologue-এর পর ঠিক কোন offset-এ পাওয়া যাবে (ধরে নিন prologue শুধু push %rbp; mov %rsp, %rbp করে)?

প্রয়োগ

প্রথম ছয়টা (af) register-এ: rdi, rsi, rdx, rcx, r8, r9। বাকি দুইটা (g, h) stack-এ, বাম থেকে ডানে ক্রম অনুযায়ী নিচের ঠিকানা থেকে উপরের দিকে সাজানো — অর্থাৎ g prologue-এর পর 16(%rbp)-এ (সরাসরি সপ্তম argument-এর জন্য), আর h তার পরের ৮ byte-এ, 24(%rbp)-এ।

গণনা: call push করে ৮ byte return address (তাই 8(%rbp)… আসলে (%rbp)-এ সরাসরি saved rbp, 8(%rbp)-এ return address, 16(%rbp)-এ প্রথম stack argument g, 24(%rbp)-এ পরেরটা h) — প্রতিটা পরের stack argument আগেরটার চেয়ে ৮ byte উপরে।

5

একজন সহপাঠী প্রশ্ন করছেন — “যদি calling convention শুধু একটা software চুক্তি, hardware কিছুই এনফোর্স করে না — তাহলে CPU designer-রা কেন rdi/rsi-কে বিশেষ কোনো hardware সুবিধা দেয় না, যেমন argument-এর জন্য fast-access করার একটা আলাদা circuit?” এই প্রশ্নের একটা যুক্তিসঙ্গত উত্তর দিন।

ডিজাইন

মূল কারণ হলো — যদি hardware rdi-কে বিশেষভাবে “argument register” হিসেবে ট্রিট করত, তাহলে calling convention আর সফটওয়্যার-স্তরের নমনীয় থাকত না, বরং হার্ডওয়্যার-স্তরে স্থায়ীভাবে বেঁধে ফেলা হতো। এতে দুইটা বড় সমস্যা হতো:

(১) ভিন্ন ABI সমর্থন করা যেত না। এই লেসনের realworld সেকশনেই দেখেছেন — Windows x64 ABI আর System V ABI একই x86-64 hardware-এ ভিন্ন নিয়ম ব্যবহার করে (rcx বনাম rdi প্রথম argument)। যদি hardware rdi-কে বিশেষভাবে এনফোর্স করত, Windows-এর ভিন্ন convention চালানোই অসম্ভব হয়ে যেত একই CPU-তে।

(২) Non-argument ব্যবহারের নমনীয়তা হারাত। একটা ফাংশনের ভেতরে, argument-হ্যান্ডলিং শেষ হওয়ার পর, rdi একটা সম্পূর্ণ সাধারণ register হিসেবে যেকোনো কাজে (loop counter, temporary) ব্যবহার করা যায় — যদি hardware এটাকে বিশেষ ট্রিট করত, এই পুনর্ব্যবহার সীমিত বা জটিল হতো।

মূল অন্তর্দৃষ্টি: hardware সবচেয়ে বেশি সাধারণ (general), নমনীয় building block সরবরাহ করে (Level 2-র মূল থিম); ঠিক কোন register কোন ভূমিকা পালন করবে — সেই সিদ্ধান্তটা সফটওয়্যার-স্তরে রাখলেই বিভিন্ন OS, ভাষা, আর ব্যবহারের ক্ষেত্রে নমনীয়ভাবে খাপ খাওয়ানো যায়, একই hardware-এর উপরেই।

এরপর কী

পরের প্রশ্ন — argument register থেকে stack-এ কী থাকে

এই লেসনে আমরা দেখেছি argument আর return value কোন register-এ যায়, আর কোন register কে বাঁচায়। কিন্তু বেশ কয়েকবার একটা জিনিস আভাসেই রয়ে গেছে — call execute হলে ঠিক কী ঘটে stack-এ? push %rbp; mov %rsp, %rbp — এই দুইটা instruction বারবার দেখা গেছে উদাহরণে, কিন্তু কেন ঠিক এই দুইটা, এই ক্রমে? আর ret কীভাবে ঠিক জানে কোথায় ফিরে যেতে হবে?

পরের লেসনে আমরা stack-কে একটা মেমরি-অঞ্চল হিসেবে গভীরভাবে দেখব — কেন এটা নিচের দিকে বাড়ে (পাল্টা-স্বজ্ঞাত কিন্তু সুনির্দিষ্ট কারণসহ), call/ret-এর প্রকৃত mechanics, prologue/epilogue-এর প্রতিটা instruction কেন সেখানে আছে, আর একটা সম্পূর্ণ concrete stack frame diagram — সবশেষে দেখব কীভাবে এই ঠিক এই কাঠামোটাই buffer overflow-এর মতো একটা ক্লাসিক নিরাপত্তা-দুর্বলতার ভিত্তি তৈরি করে।

আরও পড়ুন