Register ও Calling Convention — System V AMD64 ABI
Registers and Calling Conventions
যে register-এ argument আসে, সেটা hardware-এর নিয়ম না — System V AMD64 ABI নামের একটা software চুক্তি, যা মেনে চললেই ভিন্ন compiler বা ভাষায় লেখা কোড একে অপরকে সঠিকভাবে call করতে পারে।
আগে এটা বুঝি
গত মডিউলের শেষ লেসনে (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, বাম থেকে ডানে, এই নির্দিষ্ট ক্রমে যায়:
এই ক্রমটা এতটাই মৌলিক যে 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 argument | Caller-saved |
rsi | ২য় integer/pointer argument | Caller-saved |
rdx | ৩য় argument, rax-এর সাথে ১২৮-বিট return-এর উপরের অংশ | Caller-saved |
rcx | ৪র্থ argument | Caller-saved |
r8 | ৫ম argument | Caller-saved |
r9 | ৬ষ্ঠ argument | Caller-saved |
rax | Return value, variadic call-এ SSE-count বহন করে (al) | Caller-saved |
r10, r11 | কোনো নির্দিষ্ট ভূমিকা নেই, শুধু scratch | Caller-saved |
rbx | কোনো নির্দিষ্ট ভূমিকা নেই | Callee-saved |
rbp | সাধারণত frame pointer (পরের লেসনে বিস্তারিত) | Callee-saved |
r12–r15 | কোনো নির্দিষ্ট ভূমিকা নেই | Callee-saved |
rsp | Stack pointer — সবসময় সংরক্ষিত থাকতে হয় (পরের লেসনে বিস্তারিত) | বিশেষ — সবসময় balance রাখতে হয় |
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-এর জন্য গণনা আলাদাভাবে চলে:
| Argument | Class | বরাদ্দকৃত 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
retcvtsi2sd 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;
}প্রথম ছয়টা (a–f) যথারীতি 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 sum7call 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-এর সম্পূর্ণ যাত্রা
- Caller: argument register-এ বসায়ABI ক্রম অনুযায়ী rdi, rsi, rdx, ... — অথবা xmm0, xmm1, ... ফ্লোটের জন্য
- Caller: প্রয়োজনীয় caller-saved register বাঁচায়call-এর পরও দরকার এমন মান আগে থেকে stack-এ push করা
- call target — return address push, jumpপরের লেসনে সম্পূর্ণ বিস্তারিত mechanics
- Callee: ব্যবহারের আগে callee-saved register বাঁচায়শুধু যেগুলো ভেতরে ব্যবহার করবে
- Callee: কাজ করে, return value rax/xmm0-এ রাখেABI-নির্ধারিত return-value register
- Callee: callee-saved register ফিরিয়ে দেয়, ret করেstack থেকে return address পড়ে PC-তে বসায়
- Caller: rax/xmm0 থেকে ফলাফল পড়ে, কাজ চালিয়ে যায়caller-saved register-গুলো এখন অবিশ্বস্ত ধরে নিতে হবে
নিজে চালিয়ে দেখুন
GDB দিয়ে real argument register verify করুন
// 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_demoGDB-এর ভেতরে:
(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 $raxsum6 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-এর পর সত্যিই বদলে যায়।
নিজে বানান
নিজে হাতে-লেখা assembly function — C থেকে call করুন
- একটা add5.s ফাইল লিখুন — একটা add5 নামের function যা পাঁচটা int argument নেয় (edi, esi, edx, ecx, r8d) আর তাদের যোগফল eax-এ রিটার্ন করে
- ফাংশনের উপরে .globl add5 আর .text directive যোগ করুন, যাতে C কোড থেকে এটা দেখা যায় (linker symbol হিসেবে)
- একটা caller.c লিখুন যেখানে extern int add5(int, int, int, int, int); ঘোষণা করে সেটাকে কল করুন printf দিয়ে ফলাফল দেখান
- gcc -c add5.s -o add5.o দিয়ে শুধু আপনার assembly ফাইল compile করুন, তারপর gcc caller.c add5.o -o prog দিয়ে দুটো একসাথে লিংক করুন
- ./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
retcaller.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।
বুঝেছেন কি না দেখুন
1System 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 যায় xmm0–xmm7-এ, একটা স্বতন্ত্র 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-এ যাবে তা বের করুন।
প্রয়োগ
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 ১ →edix(double) → SSE slot ১ →xmm0b(long) → INTEGER slot ২ →rsiy(double) → SSE slot ২ →xmm1c(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 করে)?
প্রয়োগ
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 করে)?প্রথম ছয়টা (a–f) 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?” এই প্রশ্নের একটা যুক্তিসঙ্গত উত্তর দিন।
ডিজাইন
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-এর মতো একটা ক্লাসিক নিরাপত্তা-দুর্বলতার ভিত্তি তৈরি করে।
আরও পড়ুন
- System V Application Binary Interface — AMD64 Architecture Processor Supplement · এই লেসনের কেন্দ্রীয় বিষয়ের আনুষ্ঠানিক spec — Chapter 3, বিশেষত argument classification আর register assignment
- Computer Systems: A Programmer's Perspective, §3.7 — Procedures — Randal E. Bryant, David R. O'Hallaron · x86-64 calling convention-এর ধাপে-ধাপে compiled-example-সহ ব্যাখ্যা
- Calling Conventions for different C++ compilers and operating systems — Agner Fog · System V ও Windows x64 ABI-র পাশাপাশি তুলনা, red zone ও stack alignment-এর বিস্তারিত
- Compiler Explorer (godbolt.org) · এই লেসনের experiment-এ real compiled output verify করার জন্য ব্যবহৃত