সরাসরি পর্বে যান
পর্ব ৩০ · ৬২-এর মধ্যে · মডিউল ৫

Function call আর stack — calling convention, stack frame আর recursion

Procedure Calls and the Stack — jal/jalr, the RISC-V Calling Convention, Stack Frames, Recursion, Tail Calls, x86-64/ARM64 Conventions and Stack Security

Taught in Bangla, this lesson breaks a function call into six jobs and shows how RISC-V does them with jal and jalr. It covers the calling convention's register table, caller-saved versus callee-saved registers, stack-frame layout with addresses, prologue and epilogue for non-leaf functions, recursive factorial and fibonacci traced frame by frame, extra arguments, structs, varargs and tail calls, x86-64, Windows x64 and ARM64 conventions, and stack protection, with a stack simulator.

এই পর্বে যা শিখবেন

  • একটি function call-এর ছয়টি কাজ, আর RISC-V-তে jal/jalr দিয়ে সেগুলো কীভাবে হয়
  • RISC-V calling convention-এর register টেবিল, আর caller-saved বনাম callee-saved ভাগের কারণ
  • Stack frame-এর নকশা ঠিকানাসহ আঁকা, আর non-leaf function-এর prologue-epilogue লেখা
  • Recursive factorial আর fibonacci assembly-তে — fact(3)-এর frame ধরে ধরে trace
  • x86-64, Windows x64 আর ARM64-এর convention-এর তুলনা, আর stack-এর নিরাপত্তা-প্রতিরক্ষা

১ · একটি function call-এর ছয়টি কাজ

C-তে y = sq(x); লেখা খুব সহজ। কিন্তু CPU-র কাছে “function” বলে কিছু নেই — আছে শুধু register, মেমরি আর একটি program counter (PC), যা বলে দেয় পরের instruction কোথায়। তাহলে একটি function ডাকা আর সেখান থেকে ঠিক জায়গায় ফিরে আসা machine-এর ভাষায় আসলে কী? এই পর্বের পুরো গল্পটা এই প্রশ্নের উত্তর।

একটি ব্যস্ত রেস্তোরাঁর কথা ভাবুন। ওয়েটার (যিনি ডাকছেন — caller) একটি অর্ডার-স্লিপ রান্নাঘরে দেন: “দুই প্লেট কাচ্চি, টেবিল ৭।” রাঁধুনি (যাঁকে ডাকা হলো — callee) স্লিপ পড়েন (argument), নিজের হাঁড়ি-বাটি নেন (local), রান্না করেন, তারপর প্লেট রাখেন নির্দিষ্ট কাউন্টারে (return value). “টেবিল ৭” লেখাটি না থাকলে খাবার কোথায় যাবে, কেউ জানত না — এটাই return address. আর একটি অলিখিত নিয়ম আছে: রাঁধুনি অন্যের সাজিয়ে রাখা মসলার তাক ব্যবহার করতে পারেন, কিন্তু কাজ শেষে আগের মতো সাজিয়ে রাখতে হবে। কোন জিনিস “যে কেউ এলোমেলো করতে পারে” আর কোনটি “আগের মতো ফেরত দিতে হবে” — এই ভাগাভাগিই calling convention.

Patterson আর Hennessy তাঁদের Computer Organization and Design বইয়ে একটি procedure (function) call-কে ছয়টি ধাপে ভেঙেছেন। বিশ্ববিদ্যালয় আর চাকরির লিখিত পরীক্ষায় এই তালিকা প্রায়ই চাওয়া হয়:

ধাপকী করতে হয়RISC-V-তে কে, কীভাবে
১Argument এমন জায়গায় রাখা, যেখান থেকে function পড়তে পারেCaller: a0–a7 register-এ (বেশি হলে stack-এ)
২Function-এর কাছে নিয়ন্ত্রণ পাঠানোCaller: jal ra, f — লাফ, আর ফেরার ঠিকানা ra-তে
৩Function-এর দরকারি জায়গা নেওয়াCallee: stack-এ frame (addi sp, sp, -16), register সংরক্ষণ
৪আসল কাজটি করাCallee: function-এর শরীর (body)
৫ফল এমন জায়গায় রাখা, যেখান থেকে caller পড়তে পারেCallee: a0 (আর দরকারে a1)
৬যেখান থেকে ডাকা হয়েছিল, সেখানে ফেরাCallee: register ফিরিয়ে দিয়ে ret

ছয় নম্বর ধাপটিই সবচেয়ে মজার। একটি function প্রোগ্রামের অনেক জায়গা থেকে ডাকা হতে পারে — printf একটি প্রোগ্রামে হয়তো দুশো জায়গা থেকে ডাকা হয়। তাই ফেরার ঠিকানা function-এর ভেতরে স্থির করে লেখা যায় না; প্রতিটি call-এর সময় caller-কেই তা জানিয়ে দিতে হয়। শুরুর দিকের কিছু কম্পিউটারে ফেরার ঠিকানা function-এর কোডের ভেতরেই একটি নির্দিষ্ট ঘরে লিখে রাখা হতো, ফলে একটি function নিজেকে ডাকতে (recursion) পারত না — দ্বিতীয় call প্রথমটির ঠিকানা মুছে দিত। Stack সেই সমস্যার স্থায়ী সমাধান, যা আমরা অংশ ৪-এ দেখব।

মূল ধারণা

Hardware function চেনে না। Hardware দেয় শুধু দুটি সুবিধা: “লাফ দেওয়া, আর কোথা থেকে লাফ দেওয়া হলো তা মনে রাখা” (jal), আর “register-এ রাখা ঠিকানায় লাফ দেওয়া” (jalr). বাকি সব — কোন register-এ argument, কে কোন register রক্ষা করবে, stack কীভাবে সাজানো — software-এর একটি লিখিত চুক্তি, যার নাম calling convention. চুক্তিটি ABI-এর অংশ, আর RISC-V-এর জন্য তা লেখা আছে “RISC-V ELF psABI” নথিতে।

২ · jal আর jalr — ডাকা আর ফেরা

পর্ব ২৯-এ শাখা আর লুপ লিখতে আপনি jal আর jalr-এর প্রাথমিক রূপ দেখেছেন। এবার এদের আসল কাজ — function call. নিয়ম দুটি এক লাইনে:

jal rd, label: rd ← PC + 4; PC ← PC + offset
jalr rd, imm(rs1): t ← PC + 4; PC ← (rs1 + imm) & ~1; rd ← t

jal (jump and link) একটি J-type instruction (encoding পর্ব ২৭-এ): এর offset ২০ বিটের, ২ byte-এর এককে, তাই নিজের অবস্থান থেকে প্রায় ±১ MiB দূর পর্যন্ত লাফ দেওয়া যায়। “Link” মানে ফেরার ঠিকানা রেখে দেওয়া — PC + 4, অর্থাৎ jal-এর ঠিক পরের instruction. Function call-এ rd হিসেবে প্রায় সবসময় ra (x1) ব্যবহার হয়। jalr একটি I-type instruction: লাফের ঠিকানা আসে একটি register থেকে, তাই যেকোনো জায়গায় যাওয়া যায় — function থেকে ফেরা, function pointer দিয়ে ডাকা, বা অনেক দূরের function. ঠিকানার শেষ বিটটি শূন্য করা হয় (& ~1).

Assembler কয়েকটি pseudo-instruction দেয়, যেগুলো আসলে এই দুটিরই ছদ্মবেশ:

যা লেখেনআসলে যা হয়কাজ
retjalr x0, 0(ra)ফেরা; ফেরার ঠিকানা কোথাও রাখার দরকার নেই, তাই rd = x0
j labeljal x0, labelশুধু লাফ, ফেরার ইচ্ছা নেই
jr rsjalr x0, 0(rs)register-এর ঠিকানায় লাফ
call fauipc ra, … + jalr ra, …(ra)দূরের function ডাকা; কাছে হলে linker একে একটি jal ra, f-এ ছোট করতে পারে
tail fauipc + jalr x0, … (একটি temporary register দিয়ে)Tail call — ফিরে না এসে লাফ (অংশ ৬)

খেয়াল করুন, ret-এ rd = x0. x0-তে লেখা সবসময় হারিয়ে যায়, তাই “ফেরার সময় আবার ফেরার ঠিকানা রাখা”-র কোনো ফল থাকে না। এবার একটি leaf function — যে নিজে আর কাউকে ডাকে না — ডাকা আর তার ফেরা ধাপে ধাপে দেখুন। প্রতিটি ধাপে দাগ দেওয়া instruction-টি এইমাত্র চলেছে:

100001000 main: addi a0, zero, 7200001004       jal  ra, sq     # call300001008       addi a1, a0, 0  # mv a1, a040000100c       ...5600002000 sq:   mul  a0, a0, a0700002004       jalr zero, 0(ra) # ret
Register (sp = 0x7ffffff0, বদলায় না)
PC (পরের)
ra
a0
a1
  1. Argument রাখা। addi a0, zero, 7 চলল, তাই a0 = ৭. এটাই ছয় ধাপের প্রথমটি: argument এমন জায়গায় রাখা, যেখানে sq খুঁজবে। ra-তে এখন যে মান (0x00000f40), সেটি main-কে যে ডেকেছিল তার ফেরার ঠিকানা — পরের ধাপে তা মুছে যাবে।
  2. jal ra, sq — ডাকা। jal-এর নিজের ঠিকানা 0x1004, তাই ra = 0x1004 + 4 = 0x1008 — ঠিক পরের instruction. একই সাথে PC = sq-এর ঠিকানা 0x2000. একটি instruction-এই দুটি কাজ: লাফ আর মনে রাখা। লক্ষ করুন, main-এর নিজের পুরোনো ra (0x0f40) এখন হারিয়ে গেছে — main যদি পরে নিজে ফিরতে চায়, তাকে আগেই সেটি stack-এ রেখে দিতে হতো।
  3. sq-এর কাজ। mul a0, a0, a0: a0 = ৭ × ৭ = ৪৯. ফলও a0-তে — argument আর return value একই register-এ, যা convention-এর নিয়ম। sq আর কাউকে ডাকে না (leaf), তাই ra অক্ষত আছে আর stack ছুঁতেই হলো না; sp একই রইল। (mul RV32I-এর নয়, M extension-এর instruction.)
  4. jalr zero, 0(ra) — ফেরা। নতুন PC = ra + 0 = 0x1008. rd = x0, তাই “PC + 4” কোথাও রাখা হলো না। sq জানেও না কে তাকে ডেকেছিল — সে শুধু ra-কে বিশ্বাস করেছে। অন্য কোনো জায়গা থেকে ডাকলে ra-তে অন্য ঠিকানা থাকত, আর sq সেখানেই ফিরত।
  5. main আবার চলছে। 0x1008-এর addi a1, a0, 0 চলল: a1 = ৪৯. পুরো call-এ খরচ মাত্র দুটি বাড়তি instruction (jal আর ret) আর শূন্য মেমরি-কাজ — leaf function সস্তা হওয়ার কারণ এটাই।

এখন সমস্যাটি স্পষ্ট: sq যদি নিজে আরেকটি function ডাকত, তার jal ra-কে নতুন ঠিকানা দিয়ে মুছে দিত, আর sq আর কখনো main-এ ফিরতে পারত না। তাই যে function অন্যকে ডাকে — non-leaf function — সে শুরুতেই নিজের ra মেমরিতে (stack-এ) রেখে দেয়, আর ফেরার আগে ফিরিয়ে আনে। কোন register-এর ক্ষেত্রে এমন সাবধানতা কার দায়িত্ব, সেটাই পরের অংশের বিষয়।

MIPS থেকে এলে সাবধান। পুরোনো বইয়ে MIPS-এর jal ফেরার ঠিকানা রাখে নির্দিষ্ট register $ra ($31)-এ, আর ফেরে jr $ra দিয়ে। RISC-V-এর jal-এ যেকোনো rd দেওয়া যায় — সাধারণত ra (x1), আর বিকল্প link register হিসেবে x5 (t0). register-এর নামও আলাদা: MIPS-এর $a0–$a3 (চারটি argument) বনাম RISC-V-এর a0–a7 (আটটি)। AI প্রায়ই দুটি মিশিয়ে ফেলে।

৩ · Calling convention — কোন register কার দায়িত্বে

RV32I-এর ৩২টি integer register-এর প্রতিটির একটি ABI নাম আছে (পূর্ণ নামের টেবিল পর্ব ২৬-এ)। Function call-এর দিক থেকে প্রশ্ন একটাই: একটি call-এর পরে এই register-এর মান কি আগের মতো থাকবে? RISC-V psABI-এর উত্তর:

RegisterABI নামভূমিকাCall পেরিয়ে টেকে?কে রক্ষা করে
x0zeroসবসময় 0——
x1raReturn addressনাCaller (non-leaf function নিজের ra stack-এ রাখে)
x2spStack pointerহ্যাঁCallee (ফেরার সময় ঠিক আগের মান)
x3gpGlobal pointer—সাধারণ কোড ছোঁয় না
x4tpThread pointer—সাধারণ কোড ছোঁয় না
x5–x7t0–t2TemporaryনাCaller
x8s0 / fpSaved register / frame pointerহ্যাঁCallee
x9s1Saved registerহ্যাঁCallee
x10–x11a0–a1Argument আর return valueনাCaller
x12–x17a2–a7ArgumentনাCaller
x18–x27s2–s11Saved registerহ্যাঁCallee
x28–x31t3–t6TemporaryনাCaller

দুটি শব্দ পরিষ্কার করে নিই। Caller-saved (একে “volatile” বা “call-clobbered”-ও বলে) register মানে: callee এটি যা খুশি বদলাতে পারে, ফেরত দেওয়ার দায় নেই। Caller-এর যদি call-এর পরে মানটি দরকার হয়, সে নিজে call-এর আগে কোথাও রেখে দেবে। Callee-saved (“non-volatile”, “call-preserved”) register মানে: callee এটি ব্যবহার করতে চাইলে আগে পুরোনো মান stack-এ রাখবে আর ফেরার আগে ফিরিয়ে দেবে — caller নিশ্চিন্তে ধরে নিতে পারে, call-এর পরে মান অক্ষত।

Floating-point register (F/D extension থাকলে) একই নকশা মানে: fa0–fa7 argument, ft0–ft11 temporary, fs0–fs11 callee-saved. আর অনেক সময় বলা হয় “ra caller-saved” — এর মানে, jal নিজেই ra বদলায়, তাই যার পুরোনো ra দরকার (অর্থাৎ non-leaf function নিজে), তাকেই সেটি রাখতে হবে।

কেন দুই ভাগে ভাগ — একটি হিসাব

ধরুন একটি function F-এর একটি লুপ ১০০ বার G-কে ডাকে, আর পুরো লুপ জুড়ে F-এর তিনটি মান (যেমন সূচক, যোগফল, একটি pointer) বাঁচিয়ে রাখা দরকার। G একটি ছোট leaf function, যে নিজের কাজে কয়েকটি register ব্যবহার করে।

  • সব register caller-saved হলে: প্রতিটি call-এর আগে F তিনটি মান stack-এ লিখবে, পরে তিনটি পড়বে। মোট ১০০ × (৩ + ৩) = ৬০০টি মেমরি-কাজ — অথচ G হয়তো ওই register-গুলো ছুঁতই না।
  • সব register callee-saved হলে: G যে register-ই ব্যবহার করুক, প্রতিটি call-এ তা সংরক্ষণ আর ফেরত দেবে — ধরা যাক ৩টি, তাহলেও ১০০ × ৬ = ৬০০; আর যে G কিছুই বাঁচিয়ে রাখার দরকার বোঝে না, সেও অকারণে খরচ করবে।
  • ভাগ থাকলে: F তিনটি মান s1, s2, s3-এ রাখে, আর prologue-এ একবার সেগুলোর পুরোনো মান সংরক্ষণ করে, epilogue-এ একবার ফেরত দেয়: মোট ৬টি মেমরি-কাজ. G নিজের কাজ t আর a register-এ সারে: ০টি. হিসাব ৬০০ থেকে নেমে এলো ৬-এ।

এই হিসাবের পেছনের পর্যবেক্ষণ দুটি: (১) চলার সময় যত call হয়, তার বড় অংশ যায় leaf function-এ, যাদের স্বল্পায়ু মানের জন্য “বিনা খরচের” temporary register দরকার; (২) দীর্ঘায়ু মান (যা অনেকগুলো call পেরিয়ে বাঁচে) একবার সংরক্ষণ করা প্রতিবার সংরক্ষণের চেয়ে সস্তা। তাই compiler-এর register allocation নিয়ম সহজ: call পেরিয়ে যে মান বাঁচবে, সে পায় একটি s register; বাকিরা পায় t বা a register. কতগুলো কোন ভাগে থাকবে, তা একটি নকশা-সিদ্ধান্ত — RISC-V ১২টি s register রেখেছে, MIPS-এর পুরোনো o32 convention-এ ছিল ৮টি ($s0–$s7).

নিচের stepper-এ একটি caller একটি মান t0-তে আর আরেকটি s1-এ রেখে একটি function ডাকছে। Callee দুটিই ব্যবহার করে — কিন্তু নিয়ম মেনে। দেখুন call-এর পরে কোনটি টিকল:

1caller: addi t0, zero, 10   # li t0, 102        addi s1, zero, 20   # li s1, 203        jal  ra, work4        add  a0, t0, s1     # expects 30?56work:   addi sp, sp, -167        sw   s1, 12(sp)     # save s1 first8        addi t0, zero, 99   # t0: free to use9        addi s1, zero, 77   # s1: saved, so OK10        lw   s1, 12(sp)     # restore s111        addi sp, sp, 1612        jalr zero, 0(ra)    # ret
Register আর stack
t0 (caller-saved)
s1 (callee-saved)
sp
[0x7fffffec]
a0
  1. Caller দুটি মান রাখল। t0 = ১০ আর s1 = ২০. দুটিই সে call-এর পরে ব্যবহার করতে চায় (লাইন ৪). কিন্তু t0 একটি caller-saved register — এখানেই লুকানো বাগ।
  2. Call. jal ra, work: ra-তে লাইন ৪-এর ঠিকানা, PC = work. Caller t0 কোথাও সংরক্ষণ করেনি।
  3. Callee নিজের frame নিল আর s1 সংরক্ষণ করল। sp = 0x7ffffff0 − 16 = 0x7fffffe0. work জানে সে s1 ব্যবহার করবে, আর s1 callee-saved — তাই আগে পুরোনো মান ২০ রাখল ঠিকানা sp + 12 = 0x7fffffec-এ। t0-এর জন্য এমন কিছু করার দায় তার নেই।
  4. Callee দুটি register-ই নিজের কাজে লাগাল। t0 = ৯৯ আর s1 = ৭৭. দুটিই নিয়মসম্মত: t0 যে কেউ নষ্ট করতে পারে, আর s1-এর পুরোনো মান তো নিরাপদে stack-এ আছে।
  5. Epilogue আর ফেরা। lw s1, 12(sp) s1-কে ২০-এ ফিরিয়ে দিল, sp আবার 0x7ffffff0, তারপর ret. লক্ষ করুন, 0x7fffffec-এ ২০ এখনো লেখা আছে, কিন্তু সেটি এখন sp-এর নিচে — “মুক্ত” জায়গা, পরের যেকোনো call তা মুছে দেবে।
  6. ফল ভুল — দোষ caller-এর। a0 = t0 + s1 = ৯৯ + ২০ = ১১৯, caller আশা করেছিল ৩০. s1 টিকেছে, কারণ callee নিয়ম মেনেছে; t0 টেকেনি, আর নিয়ম অনুযায়ী টেকার কথাও ছিল না। সমাধান দুটি: মানটি শুরু থেকেই একটি s register-এ রাখা (তখন caller-এর নিজের prologue-এ সেই s register সংরক্ষণ করতে হবে), অথবা call-এর আগে t0 stack-এ রেখে পরে পড়ে নেওয়া।

এই ধরনের বাগ ভয়ংকর, কারণ প্রায়ই কাকতালীয়ভাবে কাজ করে। work যদি কোনো দিন t0 না ছুঁত, ফল ঠিক ৩০ আসত — যতক্ষণ না কেউ work-এর ভেতরে একটি লাইন যোগ করে। হাতে assembly লেখার সময় প্রতিটি jal-এর পরে নিজেকে জিজ্ঞেস করুন: “এর পরে কোন t বা a register-এর পুরোনো মান আমি পড়ছি?” উত্তর “কোনোটি” হলে সেখানেই ভুল।

Leaf বনাম non-leaf

🍃 Leaf function

কাউকে ডাকে না। ra অক্ষত থাকে, তাই সংরক্ষণ লাগে না। শুধু t আর a register দিয়ে কাজ সারতে পারলে stack-ই ছোঁয় না — কোনো frame নেই, ফেরা শুধু ret.

উদাহরণ: sq, strlen-ধরনের ছোট function

🌳 Non-leaf function

অন্তত একজনকে ডাকে। তাই prologue-এ ra (আর যে s register ব্যবহার করবে সেগুলো) stack-এ রাখে; call পেরিয়ে যে মান বাঁচাতে হবে, তা s register বা frame-এ রাখে।

উদাহরণ: main, recursive fact

৪ · Stack আর stack frame — ঠিকানাসহ ছবি

Register মাত্র ৩২টি, কিন্তু function-এর ডাক এক ডজন স্তর গভীর হতে পারে, আর প্রতিটির নিজের ra, নিজের local, নিজের সংরক্ষিত register আছে। এগুলো রাখার জায়গা হলো মেমরির একটি অংশ, যার নাম stack. এটি একটি LIFO (last in, first out) কাঠামো — রান্নাঘরে ধোয়া প্লেটের স্তূপের মতো: সবচেয়ে পরে রাখা প্লেটটিই প্রথমে তোলা হয়। Function call-এর ধরনও ঠিক তাই: যে function সবচেয়ে পরে ডাকা হয়েছে, সে-ই সবচেয়ে আগে ফেরে।

মেমরির মানচিত্রে stack কোথায়

একটি চলমান প্রোগ্রামের (process) ঠিকানা-জগৎ মোটামুটি চার ভাগে সাজানো থাকে — বিস্তারিত দেখুন OS কোর্সের প্রসেস পর্বে আর C-এর দিক থেকে C কোর্সের scope ও storage পর্বে। নিচের ছবিটি একটি সাধারণ Linux-ধাঁচের বিন্যাস; আসল ঠিকানা OS, ISA আর ASLR-এর ওপর নির্ভর করে।

অঞ্চল (উঁচু ঠিকানা ওপরে)কী থাকেকীভাবে বাড়ে
Stackপ্রতিটি চলমান call-এর frame: ra, saved register, localনিচের দিকে (ঠিকানা কমে) ↓
(ফাঁকা জায়গা; shared library, mmap)——
Heapmalloc/new দিয়ে নেওয়া মেমরিওপরের দিকে ↑
Data / BSSglobal আর static চলকস্থির আকার
Textmachine code (instruction), সাধারণত শুধু পড়া যায়স্থির আকার

RISC-V psABI-তে stack নিচের দিকে বাড়ে (grows downward): জায়গা নিতে sp থেকে বিয়োগ, ছাড়তে যোগ। sp সবসময় stack-এর বর্তমান “শেষ ব্যবহৃত” byte-কে দেখায়। আর একটি কঠোর নিয়ম: sp সবসময় ১৬ byte-এ aligned থাকবে (RV32I আর RV64I দুটিতেই; শুধু ছোট embedded “E” রূপের ilp32e ABI-তে ৪ byte). কারণ: ৮ বা ১৬ byte-এর ডেটা (double, long long, vector) stack-এ রাখলে তা স্বাভাবিকভাবে aligned থাকে, আর caller-callee দুজনেই একই অনুমান করতে পারে। তাই প্রতিটি stack frame-এর আকার ১৬-এর গুণিতকে গোল (round up) করা হয়।

একটি stack frame-এর ভেতরে

ধরুন একটি non-leaf function, যে s0 আর s1 ব্যবহার করে, দুটি int local রাখে, আর নিজে এমন একটি function ডাকে যার ১০টি argument (তাই দুটি argument stack-এ যায় — অংশ ৬)। Caller-এর sp ছিল 0x7fffffe0. Callee-র prologue শেষে frame এমন:

ঠিকানাsp-আপেক্ষিকfp-আপেক্ষিককী আছে
0x7fffffe0 ও ওপরে32(sp) …0(fp) …Caller-এর frame (তার পাঠানো stack-argument থাকলে ঠিক এখানে)
0x7fffffdc28(sp)−4(fp)Saved ra
0x7fffffd824(sp)−8(fp)Saved s0 (caller-এর frame pointer)
0x7fffffd420(sp)−12(fp)Saved s1
0x7fffffd016(sp)−16(fp)Local int x
0x7fffffcc12(sp)−20(fp)Local int y
0x7fffffc88(sp)−24(fp)Padding (১৬-এর গুণিতক করতে)
0x7fffffc44(sp)−28(fp)Outgoing argument ১০
0x7fffffc0 ← sp0(sp)−32(fp)Outgoing argument ৯

হিসাব: ra ৪ + s0 ৪ + s1 ৪ + দুটি local ৮ + outgoing argument ৮ = ২৮ byte, ১৬-এর পরের গুণিতক ৩২; তাই ৪ byte padding. Frame-এর ভেতরের ক্রম (ra সবার ওপরে, outgoing argument সবার নিচে, sp-তে) psABI বাধ্যতামূলক করে না — শুধু stack-argument যে caller-এর sp থেকে শুরু হবে আর alignment ১৬ — এটুকুই চুক্তি। বাকিটা compiler-এর পছন্দ; GCC আর Clang সাধারণত ra আর s0-কে frame-এর মাথায় রাখে।

Frame pointer (s0, যাকে fp-ও বলা হয়) হলো একটি স্থির খুঁটি: prologue-এ addi s0, sp, 32 করলে s0 থাকে frame-এর মাথায় (caller-এর sp-তে)। function-এর মাঝখানে sp বদলালেও (যেমন C-এর variable-length array বা alloca) local খুঁজে পাওয়া যায় s0 থেকে। আর debugger বা profiler saved-s0-এর শিকল ধরে ধরে পুরো call-chain হাঁটতে পারে। তবে frame pointer ঐচ্ছিক: optimization চালু থাকলে GCC সাধারণত -fomit-frame-pointer করে s0-কে আরেকটি সাধারণ saved register বানিয়ে ফেলে, আর debugger তখন compiler-এর লেখা unwind তথ্য (DWARF CFI) পড়ে। Profiling সহজ করতে কিছু Linux distribution সম্প্রতি আবার frame pointer চালু রেখে package build করছে।

Prologue আর epilogue — একটি non-leaf function

এবার পুরো একটি উদাহরণ হাতে লিখি: int sum_sq(int a, int b) { return sq(a) + sq(b); }. প্রথম call-এর সময় b বাঁচিয়ে রাখতে হবে, আর দ্বিতীয় call-এর সময় sq(a)-এর ফল — দুটিই call পেরোনো মান, তাই s register-এ যাবে। ra-ও রাখতে হবে, কারণ sum_sq নিজে ডাকছে। মোট ৩টি word = ১২ byte → frame ১৬ byte.

sq:                         # leaf: no frame at all
    mul  a0, a0, a0         # (M extension)
    ret

sum_sq:                     # a in a0, b in a1
    addi sp, sp, -16        # --- prologue: take a 16-byte frame
    sw   ra, 12(sp)         #     we call others -> save ra
    sw   s0, 8(sp)          #     we will use s0 and s1 -> save the caller's values
    sw   s1, 4(sp)
    mv   s1, a1             # keep b alive across the first call
    call sq                 # a0 = sq(a)
    mv   s0, a0             # keep sq(a) alive across the second call
    mv   a0, s1
    call sq                 # a0 = sq(b)
    add  a0, s0, a0         # return value in a0
    lw   s1, 4(sp)          # --- epilogue: undo the prologue in reverse
    lw   s0, 8(sp)
    lw   ra, 12(sp)
    addi sp, sp, 16
    ret

Prologue আর epilogue আয়নার মতো: prologue sp কমায় আর register রাখে; epilogue উল্টো ক্রমে register ফেরায় আর sp বাড়ায়। মাঝে যা-ই হোক, ফেরার মুহূর্তে sp, s0, s1 হুবহু আগের মতো — caller-এর কাছে মনে হবে কিছুই বদলায়নি, শুধু a0-তে ফল এসেছে। বিকল্প নকশাও সম্ভব: b-কে s1-এ না রেখে সরাসরি frame-এ (sw a1, 0(sp)) রেখে পরে পড়া — একই সংখ্যক মেমরি-কাজ। কোনটি ভালো, compiler আশপাশের কোড দেখে ঠিক করে।

নিচের visualiser আরেকটি non-leaf function-এর prologue আর epilogue চালায় — এবার s0-কে frame pointer হিসেবে, আর দুটি local সরাসরি frame-এ। প্রতিটি অবস্থায় মেমরির প্রতিটি word ঠিকানাসহ ছাপে:

// Stack-frame visualiser for one non-leaf RV32 function:
//   int f(int a, int b) { int buf[2] = {a, b}; return twice(buf[0]) + buf[1]; }
const hex = v => "0x" + (v >>> 0).toString(16).padStart(8, "0");
const x = { zero: 0, ra: 0x00010040, sp: 0x7fffffe0, s0: 0x7ffffff0, a0: 7, a1: 5 };   // at entry to f
const mem = new Map([[0x7fffffec, [0x00010100, "caller: saved ra"]], [0x7fffffe8, [0x80000000, "caller: saved s0"]],
                     [0x7fffffe4, [42, "caller: local"]], [0x7fffffe0, [0, "caller: local"]]]);
const NAME = { "ra,12": "saved ra", "s0,8": "saved s0 (caller fp)", "a0,-16": "buf[0] = a", "a1,-12": "buf[1] = b" };
function exec(line) {
  const [op, rd, off, base] = line.split(/[\s,()]+/);
  if (op === "addi") x[rd] = x[off] + +base;                          // addi rd, rs, imm
  if (op === "sw") mem.set(x[base] + +off, [x[rd], NAME[rd + "," + off]]);
  if (op === "lw") x[rd] = mem.get(x[base] + +off)[0];
  if (op === "call") x.ra = 0x00010218;                               // callee's jal overwrites ra
}
function show(title, lines, rows = true) {
  lines.forEach(exec);
  console.log(`--- ${title} ---   sp=${hex(x.sp)}  s0(fp)=${hex(x.s0)}  ra=${hex(x.ra)}`);
  for (let a = 0x7fffffec; rows && a >= 0x7fffffd0; a -= 4) {
    const [v, what] = mem.get(a) ?? [null, "(never written)"];
    const live = a >= x.sp ? "" : "   [below sp: free]", tag = a === x.sp ? " <- sp" : "";
    console.log(`${hex(a)}  ${v === null ? "     ?    " : hex(v)}  ${what}${tag}${live}`);
  }
}
show("1. at entry (before prologue)", []);
show("2. after prologue", ["addi sp,sp,-16", "sw ra,12(sp)", "sw s0,8(sp)", "addi s0,sp,16",
                           "sw a0,-16(s0)", "sw a1,-12(s0)"]);
show("3. after call twice (ra clobbered, frame intact)", ["call twice"], false);
show("4. after epilogue (ret jumps to ra)", ["lw ra,12(sp)", "lw s0,8(sp)", "addi sp,sp,16"]);
console.log(`frame size = 16 bytes (multiple of 16: ${16 % 16 === 0}); return to ${hex(x.ra)}`);

আউটপুট (নির্ধারিত — প্রতিবার একই):

--- 1. at entry (before prologue) ---   sp=0x7fffffe0  s0(fp)=0x7ffffff0  ra=0x00010040
0x7fffffec  0x00010100  caller: saved ra
0x7fffffe8  0x80000000  caller: saved s0
0x7fffffe4  0x0000002a  caller: local
0x7fffffe0  0x00000000  caller: local <- sp
0x7fffffdc       ?      (never written)   [below sp: free]
0x7fffffd8       ?      (never written)   [below sp: free]
0x7fffffd4       ?      (never written)   [below sp: free]
0x7fffffd0       ?      (never written)   [below sp: free]
--- 2. after prologue ---   sp=0x7fffffd0  s0(fp)=0x7fffffe0  ra=0x00010040
0x7fffffec  0x00010100  caller: saved ra
0x7fffffe8  0x80000000  caller: saved s0
0x7fffffe4  0x0000002a  caller: local
0x7fffffe0  0x00000000  caller: local
0x7fffffdc  0x00010040  saved ra
0x7fffffd8  0x7ffffff0  saved s0 (caller fp)
0x7fffffd4  0x00000005  buf[1] = b
0x7fffffd0  0x00000007  buf[0] = a <- sp
--- 3. after call twice (ra clobbered, frame intact) ---   sp=0x7fffffd0  s0(fp)=0x7fffffe0  ra=0x00010218
--- 4. after epilogue (ret jumps to ra) ---   sp=0x7fffffe0  s0(fp)=0x7ffffff0  ra=0x00010040
0x7fffffec  0x00010100  caller: saved ra
0x7fffffe8  0x80000000  caller: saved s0
0x7fffffe4  0x0000002a  caller: local
0x7fffffe0  0x00000000  caller: local <- sp
0x7fffffdc  0x00010040  saved ra   [below sp: free]
0x7fffffd8  0x7ffffff0  saved s0 (caller fp)   [below sp: free]
0x7fffffd4  0x00000005  buf[1] = b   [below sp: free]
0x7fffffd0  0x00000007  buf[0] = a   [below sp: free]
frame size = 16 bytes (multiple of 16: true); return to 0x00010040

অবস্থা ১: sp = 0x7fffffe0 — caller-এর frame-এর তলা; তার নিচের চারটি word এখনো কারও নয় ([below sp: free]). অবস্থা ২: addi sp,sp,-16-এর পর sp = 0x7fffffd0, আর নতুন চারটি word-এ বসেছে saved ra (0x00010040 — caller-এ ফেরার ঠিকানা), saved s0 (0x7ffffff0 — caller-এর frame pointer), আর দুই local (৫ আর ৭)। addi s0,sp,16 নতুন frame pointer বানাল: s0 = 0x7fffffe0, অর্থাৎ caller-এর sp, তাই locals হলো −16(s0) আর −12(s0). অবস্থা ৩: twice-কে ডাকতেই ra হলো 0x00010218 — পুরোনো ra register থেকে মুছে গেল, কিন্তু stack-এ অক্ষত। অবস্থা ৪: epilogue ra = 0x00010040 আর s0 = 0x7ffffff0 ফিরিয়ে আনল, sp আবার 0x7fffffe0. মজার বিষয়: পুরোনো frame-এর চারটি মান মেমরিতে এখনো লেখা আছে, শুধু sp-এর নিচে পড়ে গেছে। “ফেরত দেওয়া” মানে মুছে ফেলা নয় — শুধু “এই জায়গা এখন মুক্ত” ঘোষণা। এজন্যই C-তে local-এর ঠিকানা function থেকে ফেরত দেওয়া মারাত্মক ভুল: সেই ঠিকানা পরের যেকোনো call-এর frame মুছে দেবে।

৫ · Recursion — factorial আর fibonacci assembly-তে

Recursion-এ hardware-এর জন্য নতুন কিছু নেই — একটি function নিজেকে ডাকছে মানে আরেকটি সাধারণ call, শুধু callee-র নাম আর caller-এর নাম এক। আর প্রতিটি call নিজের frame পায় বলে প্রতিটি “n” আলাদা আলাদা জায়গায় থাকে, কেউ কাউকে মোছে না। C-এর দিক থেকে ধারণাটি দেখুন C কোর্সের recursion পর্বে। পরীক্ষার সবচেয়ে চেনা উদাহরণ factorial:

int fact(int n) { if (n < 2) return 1; return n * fact(n - 1); }
00010000 main: addi a0, zero, 3     # li a0, 3
00010004       jal  ra, fact         # fact(3)
00010008       ebreak                # stop here
0001000c fact: addi sp, sp, -16      # frame: ra at 12(sp), n at 8(sp)
00010010       sw   ra, 12(sp)
00010014       sw   a0, 8(sp)        # save n: we need it after the call
00010018       addi t0, zero, 2      # li t0, 2
0001001c       bge  a0, t0, rec      # n >= 2 -> recursive case
00010020       addi a0, zero, 1      # base case (n < 2): return 1
00010024       addi sp, sp, 16
00010028       jalr zero, 0(ra)      # ret (ra was never changed here)
0001002c rec:  addi a0, a0, -1       # argument n - 1
00010030       jal  ra, fact         # a0 = fact(n - 1)
00010034       lw   t1, 8(sp)        # reload OUR n from OUR frame
00010038       mul  a0, t1, a0       # n * fact(n - 1)
0001003c       lw   ra, 12(sp)       # our return address
00010040       addi sp, sp, 16
00010044       jalr zero, 0(ra)      # ret

কেন n-কে stack-এ রাখতে হলো? কারণ a0 একটি caller-saved register, আর jal ra, fact-এর পরে a0-তে থাকবে fact(n − 1)-এর ফল — আমাদের n হারিয়ে যাবে। বিকল্প হতো n-কে s0-এ রাখা; তখন s0-এর পুরোনো মান সংরক্ষণ করতে হতো — একই খরচ। এবার fact(3)-এর পুরো জীবন frame ধরে ধরে দেখুন — বিশ্ববিদ্যালয় আর চাকরির পরীক্ষায় এই trace প্রায়ই চাওয়া হয়:

শুরুmain fact(3) ডাকতে যাচ্ছে
0x7ffffff0  ── main-এর এলাকা ──    ← sp
            (নিচে সব মুক্ত)
a0 = 3
fact(3)-এর framedepth ১
0x7ffffff0  ── main-এর এলাকা ──
0x7fffffec  ra = 0x00010008   (main-এ ফেরা)
0x7fffffe8  n  = 3
0x7fffffe4  (ফাঁকা)
0x7fffffe0  (ফাঁকা)              ← sp
3 ≥ 2 → rec: a0 = 2, jal
fact(2)-এর framedepth ২
0x7fffffec  ra = 0x00010008   n = 3      fact(3)
0x7fffffdc  ra = 0x00010034   n = 2      fact(2)
0x7fffffd0  ← sp
2 ≥ 2 → rec: a0 = 1, jal
fact(1)-এর framedepth ৩ — সবচেয়ে গভীর
0x7fffffec  ra = 0x00010008   n = 3      fact(3)
0x7fffffdc  ra = 0x00010034   n = 2      fact(2)
0x7fffffcc  ra = 0x00010034   n = 1      fact(1)
0x7fffffc0  ← sp   (মোট 48 byte ব্যবহৃত)
1 < 2 → base case: a0 = 1
fact(1) ফিরলa0 = 1
0x7fffffec  ra = 0x00010008   n = 3      fact(3)
0x7fffffdc  ra = 0x00010034   n = 2      fact(2)
0x7fffffd0  ← sp   (fact(1)-এর frame মুক্ত)
PC = 0x00010034: lw t1 = 2; a0 = 2 × 1 = 2
fact(2) ফিরলa0 = 2
0x7fffffec  ra = 0x00010008   n = 3      fact(3)
0x7fffffe0  ← sp
PC = 0x00010034: lw t1 = 3; a0 = 3 × 2 = 6
fact(3) ফিরলmain-এ, a0 = 6
0x7ffffff0  ── main-এর এলাকা ──    ← sp
PC = 0x00010008 (main), a0 = 6
call ৩টি, সর্বোচ্চ depth ৩, stack ব্যবহার 48 byte
  1. শুরু। sp = 0x7ffffff0, a0 = ৩. main এখন jal ra, fact চালাবে, ফলে ra = 0x00010008 (main-এর পরের instruction).
  2. fact(3) frame নিল। sp = 0x7ffffff0 − 16 = 0x7fffffe0. 12(sp) = 0x7fffffec-এ ra = 0x00010008, 8(sp) = 0x7fffffe8-এ n = ৩. ৩ ≥ ২, তাই rec শাখা: a0 = ২, তারপর jal ra, fact — ra এখন 0x00010034.
  3. fact(2) frame নিল। sp = 0x7fffffd0. এর saved ra = 0x00010034 — main নয়, fact-এরই ভেতরের ঠিকানা (jal-এর পরের lw). n = ২ রাখা হলো 0x7fffffd8-এ। ২ ≥ ২, তাই আবার rec: a0 = ১, jal.
  4. fact(1) — সবচেয়ে গভীর বিন্দু। sp = 0x7fffffc0; তিনটি frame, ৩ × ১৬ = ৪৮ byte ব্যবহৃত। এর saved ra-ও 0x00010034, n = ১ (0x7fffffc8-এ). ১ < ২ — base case: a0 = ১, sp = 0x7fffffd0, ret. এখানেই recursion থামে; base case না থাকলে frame জমতেই থাকত।
  5. fact(1) ফিরল fact(2)-তে। ret গেল ra = 0x00010034-এ। fact(2) নিজের frame থেকে n পড়ে: lw t1, 8(sp) = ২. a0 = ২ × ১ = ২. তারপর নিজের ra (0x00010034) পড়ে, sp = 0x7fffffe0, ret.
  6. fact(2) ফিরল fact(3)-তে। আবার 0x00010034-এ, কিন্তু এবার sp = 0x7fffffe0, তাই lw t1, 8(sp) পড়ে fact(3)-এর n = ৩. a0 = ৩ × ২ = ৬. একই instruction, একই ঠিকানা, কিন্তু sp আলাদা বলে আলাদা frame — recursion-এর পুরো রহস্য এই এক লাইনে।
  7. fact(3) ফিরল main-এ। saved ra = 0x00010008 পড়ে sp = 0x7ffffff0, ret. a0 = ৬. হিসাব: ৩টি call, সর্বোচ্চ গভীরতা ৩, stack-এর সর্বোচ্চ ব্যবহার ৩ × ১৬ = ৪৮ byte, সবচেয়ে নিচে sp = 0x7fffffc0. sp ঠিক শুরুর মানে ফিরেছে — callee-saved নিয়ম রক্ষা হয়েছে।

নিচের simulator একটি ছোট RV32I interpreter — register, sp, sw/lw দিয়ে আসল stack মেমরি, আর jal/jalr. এটি উপরের fact-এর কোডই চালায় (n = ৫), আর fibonacci-র একটি সংস্করণ, যেখানে fib(n − 1)-এর ফল দ্বিতীয় call পেরিয়ে বাঁচাতে callee-saved s0 ব্যবহার হয়েছে। প্রতিটি call আর return-এ sp আর frame-এর ভেতরের মান ছাপে:

// Tiny RV32I (+ mul from the M extension) interpreter with a real stack in memory.
const REG = { zero: 0, ra: 1, sp: 2, t0: 5, t1: 6, s0: 8, a0: 10 }, SP0 = 0x7ffffff0, r = t => REG[t];
const hex = v => "0x" + (v >>> 0).toString(16).padStart(8, "0");
const FACT = `fact: addi sp,sp,-16; sw ra,12(sp); sw a0,8(sp); addi t0,zero,2; bge a0,t0,rec
  addi a0,zero,1; addi sp,sp,16; jalr zero,0(ra)
  rec: addi a0,a0,-1; jal ra,fact; lw t1,8(sp); mul a0,t1,a0; lw ra,12(sp); addi sp,sp,16; jalr zero,0(ra)`;
const FIB = `fib: addi sp,sp,-16; sw ra,12(sp); sw s0,8(sp); sw a0,4(sp); addi t0,zero,2; blt a0,t0,done
  addi a0,a0,-1; jal ra,fib; addi s0,a0,0; lw a0,4(sp); addi a0,a0,-2; jal ra,fib; add a0,s0,a0
  done: lw s0,8(sp); lw ra,12(sp); addi sp,sp,16; jalr zero,0(ra)`;
function run(src, entry, n, slots, trace) {
  const code = [], lab = {}, x = new Array(32).fill(0), mem = new Map(), ld = a => mem.get(a) ?? 0;
  for (const s of `main: addi a0,zero,${n}; jal ra,${entry}; ebreak; ${src}`.split(/[;\n]/)) {
    const m = s.trim().match(/^(?:(\w+):\s*)?(.*)$/);
    if (m[1]) lab[m[1]] = 0x10000 + 4 * code.length; code.push(m[2].split(/[\s,()]+/));
  }
  let pc = 0x10000, minSp = SP0, depth = 0, maxDepth = 0, calls = 0, steps = 0; x[2] = SP0;
  for (;;) {
    const [op, a, b, c] = code[(pc - 0x10000) / 4], pad = "  ".repeat(depth); let next = pc + 4;
    if (op === "ebreak") break; steps++;
    if (op === "addi") x[r(a)] = (x[r(b)] + +c) | 0;
    else if (op === "add") x[r(a)] = (x[r(b)] + x[r(c)]) | 0;
    else if (op === "mul") x[r(a)] = Math.imul(x[r(b)], x[r(c)]);
    else if (op === "sw") mem.set(x[r(c)] + +b, x[r(a)]);               // sw rs2, imm(rs1)
    else if (op === "lw") x[r(a)] = ld(x[r(c)] + +b);
    else if (op === "bge" || op === "blt") { if ((x[r(a)] >= x[r(b)]) === (op === "bge")) next = lab[c]; }
    else if (op === "jal") { x[r(a)] = pc + 4; next = lab[b]; calls++; maxDepth = Math.max(maxDepth, ++depth);
      if (trace) console.log(`${pad}call ${b}(${x[10]})  sp=${hex(x[2])}  ra=${hex(pc + 4)}`); }
    else if (op === "jalr") { next = (x[r(c)] + +b) & ~1; x[r(a)] = pc + 4; const f = x[2] - 16; depth--;
      if (trace) console.log(`${pad.slice(2)}ret  a0=${x[10]}  sp=${hex(x[2])}  popped ` + Object.entries(slots)
        .map(([o, nm]) => nm + "=" + (nm === "ra" ? hex(ld(f + +o)) : ld(f + +o))).join(" ")); }
    x[0] = 0; pc = next; minSp = Math.min(minSp, x[2]); }
  console.log(`${entry}(${n}) = ${x[10]} | calls=${calls}, max depth=${maxDepth}, stack used=${SP0 - minSp} bytes, lowest sp=${hex(minSp)}, instructions=${steps}`);
}
run(FACT, "fact", 5, { 12: "ra", 8: "n" }, true);
run(FIB, "fib", 6, { 12: "ra", 8: "s0", 4: "n" }, false);   // true = print all 25 calls

আউটপুট (নির্ধারিত — প্রতিবার একই):

call fact(5)  sp=0x7ffffff0  ra=0x00010008
  call fact(4)  sp=0x7fffffe0  ra=0x00010034
    call fact(3)  sp=0x7fffffd0  ra=0x00010034
      call fact(2)  sp=0x7fffffc0  ra=0x00010034
        call fact(1)  sp=0x7fffffb0  ra=0x00010034
        ret  a0=1  sp=0x7fffffb0  popped n=1 ra=0x00010034
      ret  a0=2  sp=0x7fffffc0  popped n=2 ra=0x00010034
    ret  a0=6  sp=0x7fffffd0  popped n=3 ra=0x00010034
  ret  a0=24  sp=0x7fffffe0  popped n=4 ra=0x00010034
ret  a0=120  sp=0x7ffffff0  popped n=5 ra=0x00010008
fact(5) = 120 | calls=5, max depth=5, stack used=80 bytes, lowest sp=0x7fffffa0, instructions=58
fib(6) = 8 | calls=25, max depth=6, stack used=96 bytes, lowest sp=0x7fffff90, instructions=336

প্রতিটি নতুন call-এ sp ঠিক ১৬ কমেছে (0x7ffffff0 → …e0 → …d0 → …c0 → …b0), আর fact(1)-এর frame নেওয়ার পরে সবচেয়ে নিচে 0x7fffffa0 — মোট ৮০ byte = ৫ frame × ১৬. ফেরার পথে প্রতিটি “popped” লাইন দেখায় সেই frame-এ কী ছিল: ভেতরের চারটি frame-এর ra একই 0x00010034 (fact-এর ভেতরের jal-এর পরের ঠিকানা), শুধু বাইরেরটির 0x00010008 (main). n-এর মান ১, ২, ৩, ৪, ৫ — প্রতিটি নিজের frame-এ, তাই a0 ক্রমে ১ → ২ → ৬ → ২৪ → ১২০. Fibonacci-তে call অনেক বেশি — fib(6)-এর জন্য ২৫টি, কারণ প্রতিটি fib(n) দুটি call করে আর একই মান বারবার হিসাব হয়; সাধারণভাবে fib(n)-এর call-সংখ্যা ২·F(n + 1) − ১ (F(7) = ১৩, তাই ২৫)। কিন্তু stack-এর গভীরতা মাত্র ৬ (fib(6) → fib(5) → … → fib(1)), ৯৬ byte: কারণ একটি শাখা শেষ হলে তার frame খুলে যায়, আর পরের শাখা একই জায়গা আবার ব্যবহার করে। সময় বাড়ে call-সংখ্যার সাথে, stack বাড়ে গভীরতার সাথে — দুটি আলাদা জিনিস। (instruction-সংখ্যা ৫৮ আর ৩৩৬ — main-এর দুটিসহ, ebreak বাদে।) শেষ লাইনের false বদলে true করলে fib-এর ২৫টি call-ই দেখবেন।

Stack-এর গভীরতা আর stack overflow

Recursion-এর stack-খরচের সূত্র সরল: সর্বোচ্চ stack ব্যবহার = সর্বোচ্চ গভীরতা × প্রতিটি frame-এর আকার (frame-এর আকার ভিন্ন হলে প্রতিটির যোগফল)। fact(n)-এর গভীরতা n, তাই ১৬-byte frame-এ fact(১০০০০) লাগবে ১,৬০,০০০ byte. Stack-এর একটি সীমা আছে; সীমা পেরোলে stack overflow — Linux-এ সাধারণত stack-এর নিচে রাখা একটি “guard page” ছুঁয়ে ফেলায় page fault, আর OS প্রোগ্রামটিকে SIGSEGV দিয়ে বন্ধ করে দেয় (C-এর দিক থেকে দেখুন C কোর্সের stack overflow অংশ)। বাস্তবের একটি compiled প্রোগ্রামে frame কত বড়, চলুন মেপে দেখি। নিচের C প্রোগ্রাম পরপর recursive call-এ একটি local-এর ঠিকানা কতটা সরে, তা ছাপে (পূর্ণ ঠিকানা নয় — সেগুলো ASLR-এর কারণে প্রতিবার বদলায়), আর দেখায় যে pointer দিয়ে callee caller-এর frame-এ লিখতে পারে:

#include <stdio.h>
#include <stdint.h>

static intptr_t where[6];            /* address of 'local' in each recursive call */

__attribute__((noinline)) static int depth(int n) {
    volatile int local = n;          /* lives in THIS call's stack frame */
    where[n] = (intptr_t)&local;
    if (n == 5) return local;
    return depth(n + 1) + local;     /* work after the call: not a tail call */
}

__attribute__((noinline)) static void add_bonus(int *p) {
    volatile int mine = 0;           /* the callee's own local */
    *p += 100;                       /* writes into the CALLER's frame */
    printf("callee: p is %s my own local\n", (intptr_t)p > (intptr_t)&mine ? "ABOVE" : "below");
}

int main(void) {
    printf("depth(0) returned %d\n", depth(0));
    for (int i = 1; i < 6; i++)
        printf("call %d -> call %d: &local moved down by %ld bytes\n",
               i - 1, i, (long)(where[i - 1] - where[i]));
    int marks = 70;
    add_bonus(&marks);
    printf("main: marks = %d after add_bonus(&marks)\n", marks);
    return 0;
}

আউটপুট (Wandbox-এর gcc, x86-64, optimization ছাড়া):

depth(0) returned 15
call 0 -> call 1: &local moved down by 48 bytes
call 1 -> call 2: &local moved down by 48 bytes
call 2 -> call 3: &local moved down by 48 bytes
call 3 -> call 4: &local moved down by 48 bytes
call 4 -> call 5: &local moved down by 48 bytes
callee: p is ABOVE my own local
main: marks = 170 after add_bonus(&marks)

প্রতিটি পরের call-এ &local ঠিক ৪৮ byte নিচে — stack নিচের দিকে বাড়ছে, আর depth()-এর প্রতিটি frame ৪৮ byte. এই compiler-এর তৈরি assembly দেখলে হিসাব মেলে: call ৮ byte-এর return address push করে, prologue-এর pushq %rbp আরও ৮, আর subq $32, %rsp local-এর জন্য ৩২ — মোট ৪৮, যা ১৬-এর গুণিতক। একই কোড -O2-এ চালালে পার্থক্য ছিল ৩২ — frame-এর আকার compiler আর flag-এর সিদ্ধান্ত, ISA-র নয়; কিন্তু একই build-এ প্রতিবার হুবহু এক। ১৫ = ০ + ১ + ২ + ৩ + ৪ + ৫. শেষ দুই লাইন: add_bonus পেল main-এর local marks-এর ঠিকানা, যা তার নিজের local-এর ওপরে (উঁচু ঠিকানায়) — কারণ main-এর frame আগে তৈরি, stack তারপর নিচে নেমেছে। *p += 100 সরাসরি main-এর frame-এ লিখল, তাই marks = ১৭০. C-এর “pass by pointer” machine-এর স্তরে ঠিক এটাই: অন্য frame-এর একটি ঠিকানা হাতে পাওয়া।

এবার জানি stack-এর সীমা কত। Linux-এ main thread-এর stack-এর সীমা shell-এর ulimit -s দিয়ে দেখা যায় (KiB এককে)। কোর্সের runner-এ চালিয়ে দেখুন:

# How big may the main thread's stack grow here? (ulimit is a bash builtin)
soft=$(ulimit -s 2>/dev/null || echo "?")
hard=$(ulimit -Hs 2>/dev/null || echo "?")
echo "stack limit in KiB: soft=$soft hard=$hard"
if [ -r /proc/self/limits ]; then
  grep -i "max stack size" /proc/self/limits
else
  echo "/proc/self/limits not available"
fi
case "$soft" in
  ''|*[!0-9]*) echo "no numeric limit, skipping the estimate" ;;
  *) echo "48-byte frames that fit: about $(( soft * 1024 / 48 ))"
     echo "1 KiB frames that fit:   about $(( soft ))" ;;
esac

আউটপুট (কোর্সের runner, একটি cloud সার্ভারের x86-64 Linux container):

stack limit in KiB: soft=8192 hard=unlimited
Max stack size            8388608              unlimited            bytes
48-byte frames that fit: about 174762
1 KiB frames that fit:   about 8192

Soft সীমা ৮১৯২ KiB = ৮ MiB — Linux-এ main thread-এর জন্য বহুল প্রচলিত ডিফল্ট; hard সীমা unlimited, অর্থাৎ ব্যবহারকারী চাইলে soft সীমা বাড়াতে পারেন (ulimit -s 65536). /proc/self/limits একই কথা byte-এ বলছে: 8388608. উপরের C প্রোগ্রামের ৪৮-byte frame-এ মোটামুটি ১,৭৪,৭৬২ স্তর পর্যন্ত recursion সম্ভব; কিন্তু কোনো function-এ ১ KiB-এর একটি local array থাকলে মাত্র প্রায় ৮,১৯২ স্তর। অন্য সিস্টেমে সংখ্যা আলাদা: Windows-এ main thread-এর ডিফল্ট stack সাধারণত ১ MiB, আর ছোট microcontroller-এ পুরো RAM-ই হয়তো কয়েক KiB — সেখানে recursion প্রায়ই নিষিদ্ধ।

প্রতিটি thread-এর আলাদা stack কেন? একটি প্রসেসের সব thread একই code, global ডেটা আর heap ভাগ করে নেয়, কিন্তু প্রতিটি thread নিজের মতো স্বাধীনভাবে function ডাকে আর ফেরে। একটি stack মানে একটি sp আর একটি LIFO ক্রম — দুটি thread একই stack-এ frame জমালে একজনের ret অন্যজনের frame খুলে ফেলত। তাই OS বা thread library প্রতিটি নতুন thread-এর জন্য আলাদা stack এলাকা দেয় (glibc-তে সাধারণত main-এর সীমার সমান আকারের, pthread_attr_setstacksize দিয়ে বদলানো যায়), আর context switch-এর সময় sp-সহ সব register বদলায়। বিস্তারিত দেখুন OS কোর্সের thread পর্বে। RISC-V-এর tp register প্রতিটি thread-এর নিজস্ব ডেটা (thread-local storage) খুঁজে পেতে এই কারণেই রাখা।

৬ · আটটির বেশি argument, struct, varargs আর tail call

আটটির বেশি argument

a0–a7 ভরে গেলে বাকি argument যায় stack-এ। নিয়মটি হলো: call-এর মুহূর্তে caller-এর sp থেকে শুরু করে — নবম argument 0(sp)-এ, দশম 4(sp)-এ (RV32-এ প্রতিটি int ৪ byte), এভাবে ওপরের দিকে। Caller এই জায়গা নিজের frame-এর তলায় আগেই রেখে দেয় (অংশ ৪-এর টেবিলের “outgoing argument”), আর callee নিজের frame নেওয়ার পরে সেগুলো পড়ে নিজের frame-এর ওপারে: নিজের frame ৩২ byte হলে নবম argument 32(sp)-এ। বাস্তবে আটটির বেশি argument বিরল, আর প্রতিটি বাড়তি argument মানে একটি মেমরি-লেখা ও মেমরি-পড়া; এজন্য অনেক argument লাগলে সেগুলো একটি struct-এ জড়ো করে pointer পাঠানো প্রচলিত কৌশল।

Struct আর বড় return value

RISC-V psABI-র নিয়ম মোটামুটি এমন (বিস্তারিত নিয়মে বেশ কিছু ব্যতিক্রম আছে, যেমন float-সদস্যের struct — আসল নথি দেখুন): যে struct দুই word (২ × XLEN) পর্যন্ত বড়, তা register-এ যায় — এক বা দুটি a register-এ, register ফুরিয়ে গেলে অংশত stack-এ। এর চেয়ে বড় struct-এর জন্য caller একটি কপি বানিয়ে তার ঠিকানা পাঠায় (pass by reference). Return value-ও একই ধাঁচে: দুই word পর্যন্ত a0/a1-এ; আরও বড় হলে caller ফলের জন্য জায়গা নিয়ে তার ঠিকানা একটি লুকানো প্রথম argument হিসেবে a0-তে দেয়, আর callee সেখানে লেখে। RV32-এ ৬৪-বিটের long long বা double (soft-float) এক জোড়া register নেয়: a0 নিচের word, a1 ওপরের word.

Varargs — printf-এর মতো function

printf(const char *fmt, ...)-এর মতো variadic function-এ callee আগে জানে না কয়টি argument এসেছে। RISC-V-এ variadic argument-ও সাধারণ integer register-এ (a register-এ) আসে, তবে কিছু বিশেষ নিয়মসহ (যেমন RV32-এ ৮-byte-এ aligned মান একটি জোড়-বিজোড় register-জোড়ায়)। Callee-র prologue সাধারণত বাকি a register-গুলো stack-এ লিখে ফেলে, যাতে stack-এ রাখা argument-এর ঠিক পাশে একটি অবিচ্ছিন্ন সারি তৈরি হয়; তারপর va_arg সেই মেমরি-সারি ধরে একটি একটি করে পড়ে। কয়টি আর কোন ধরনের argument, তা জানার একমাত্র উপায় format string — তাই printf("%d %d", x) লিখলে দ্বিতীয় %d যা-ই পড়ে, তা আবর্জনা। অন্য ABI-তে variadic নিয়ম আলাদা হতে পারে (যেমন Apple-এর ARM64-এ variadic argument সবসময় stack-এ)।

Tail call — jal-এর বদলে j

একটি function-এর শেষ কাজ যদি অন্য একটি function ডেকে তার ফলই ফেরত দেওয়া হয় — return g(x + 1); — তাহলে নিজের frame রেখে অপেক্ষা করার কিছু নেই। g-এর ফল সরাসরি আমাদের caller-এর কাছে গেলেই চলে। তাই compiler আমাদের frame খুলে ফেলে, ra যেমন আছে তেমন রেখে (ra-তে তো আমাদের caller-এর ঠিকানাই আছে), jal-এর বদলে শুধু লাফ দেয় — j g বা tail g. g যখন ret করবে, সোজা আমাদের caller-এ ফিরবে। একে বলে tail call optimization. Wandbox-এর gcc (x86-64) -O2-এ এই দুটি function যা বানাল, আর RISC-V-এ একই জিনিস কেমন দেখায়:

/* C */
int f(int x) { return g(x + 1); }     /* tail call      */
int h(int x) { return g(x) + 1; }     /* NOT a tail call */

# x86-64, gcc -O2              # RV32 equivalent
f:  addl  $1, %edi             f:  addi a0, a0, 1
    jmp   g        # tail call      tail g          # jump, ra untouched
h:  subq  $8, %rsp # align 16  h:  addi sp, sp, -16
    call  g                         sw   ra, 12(sp)
    addq  $8, %rsp                  call g
    addl  $1, %eax                  lw   ra, 12(sp)
    ret                             addi a0, a0, 1
                                    addi sp, sp, 16
                                    ret

f-এ কোনো frame নেই, ফেরার ঠিকানাও রাখা হয়নি — একটি যোগ আর একটি লাফ। h-এ g-এর পরে আরও কাজ (+১) আছে, তাই tail call সম্ভব নয়: পুরো call-return লাগল। x86-64-এর subq $8, %rsp-টি মজার — কোনো local রাখার জন্য নয়, শুধু alignment-এর জন্য (অংশ ৭). Recursion-এ tail call আরও বড় উপকার করে: fact_acc(n, acc) { return n < 2 ? acc : fact_acc(n − 1, n * acc); }-এর মতো tail-recursive function stack-এ একটিও নতুন frame না নিয়ে একটি লুপ হয়ে যায়। এমনকি আমাদের সাধারণ fact-কেও gcc -O2 (এই পর্ব লেখার সময় Wandbox-এ পরীক্ষা করা) পুরোপুরি একটি গুণের লুপে বদলে ফেলেছে — কোনো call-ই নেই। তবে C standard tail call-এর কোনো নিশ্চয়তা দেয় না; optimization বন্ধ থাকলে (-O0) বা function-এর local-এর ঠিকানা বাইরে গেলে তা হয় না। Scheme-এর মতো কিছু ভাষা নিয়মেই tail call বাধ্যতামূলক করে; আর Clang-এ [[clang::musttail]] নামে একটি extension আছে।

৭ · অন্য ISA-র convention — x86-64, Windows x64 আর ARM64

চিন্তা সব জায়গায় একই — argument register, caller/callee-saved ভাগ, নিচে-নামা stack, ১৬-byte alignment. পার্থক্য register-এর নাম-সংখ্যায় আর ফেরার ঠিকানা কোথায় থাকে তাতে। নিচের টেবিল মূল কথাগুলো ধরে; প্রতিটি ABI-তে আরও অনেক খুঁটিনাটি নিয়ম আছে (float, struct, variadic), যার জন্য আসল নথি দেখুন। C থেকে কোন compiler কী বানায়, তা বিস্তারিত পর্ব ৩১-এ।

বিষয়RISC-V (psABI)x86-64 System V (Linux, macOS)Windows x64ARM64 (AAPCS64)
Integer argumenta0–a7 (৮টি)rdi, rsi, rdx, rcx, r8, r9 (৬টি)rcx, rdx, r8, r9 (৪টি)x0–x7 (৮টি)
Return valuea0 (a1)rax (rdx)raxx0 (x1)
ফেরার ঠিকানাra register-এ (jal)stack-এ (call push করে)stack-এ (call)lr = x30 register-এ (bl)
Callee-saveds0–s11, sprbx, rbp, r12–r15rbx, rbp, rdi, rsi, r12–r15, xmm6–xmm15x19–x28, x29 (fp), sp
Frame pointers0 (ঐচ্ছিক)rbp (ঐচ্ছিক)rbp (ঐচ্ছিক)x29 (প্রায়ই রাখা হয়)
বিশেষ নিয়ম—Red zone: rsp-এর নিচে ১২৮ byteShadow space: ৩২ byte, caller দেয়—
Stack alignment১৬ bytecall-এর আগে ১৬ bytecall-এর আগে ১৬ bytesp সবসময় ১৬ byte

x86-64-এ return address stack-এ। x86-এর call instruction নিজেই ফেরার ঠিকানা (৮ byte) stack-এ push করে, আর ret তা pop করে লাফ দেয়; push/pop instruction-ও আছে যা rsp বদলায় আর একই সাথে মেমরিতে লেখে বা পড়ে। ফলে leaf function-এও প্রতিটি call-এ একটি মেমরি-লেখা আর একটি মেমরি-পড়া হয় — RISC-V বা ARM-এর leaf function-এ যা লাগে না। আর alignment-এর নিয়মটি “call-এর আগে rsp ১৬-এর গুণিতক” বলে, callee-তে ঢোকার মুহূর্তে rsp থাকে ১৬-এর গুণিতক + ৮ (return address-এর জন্য)। এজন্যই উপরের h-এ gcc subq $8, %rsp লিখেছিল: আরেকটি call-এর আগে আবার ১৬-এ মেলাতে।

Red zone (System V): leaf function rsp না কমিয়েই rsp-এর নিচের ১২৮ byte নিজের local-এর জন্য ব্যবহার করতে পারে — চুক্তি হলো, signal handler বা interrupt এই এলাকা মুছবে না। এতে ছোট leaf function-এ sp বদলানোর দুটি instruction বাঁচে। Linux kernel নিজের কোডে red zone বন্ধ রাখে (-mno-red-zone), কারণ সেখানে interrupt একই stack ব্যবহার করে। Shadow space (Windows x64; একে home space-ও বলে): caller সবসময় stack-এ ৩২ byte জায়গা রেখে দেয়, যেখানে callee চাইলে চারটি register-argument লিখে রাখতে পারে — debugging আর variadic function সহজ হয়। RISC-V-এ এর কোনোটিই নেই।

ARM64 নকশায় RISC-V-এর সবচেয়ে কাছে: bl f ফেরার ঠিকানা রাখে link register x30 (lr)-এ, ret সেখানে ফেরে, আর non-leaf function prologue-এ সাধারণত stp x29, x30, [sp, #-16]! দিয়ে frame pointer আর lr একসাথে stack-এ রাখে। x9–x15 temporary (caller-saved), x19–x28 callee-saved. Apple-এর platform-এর ABI কিছু জায়গায় আলাদা (যেমন x18 সংরক্ষিত)। ৩২-বিটের পুরোনো ARM (AAPCS32)-এ argument ছিল r0–r3, lr = r14.

৮ · Stack আর নিরাপত্তা — return address রক্ষা করা

Stack frame-এ দুটি ভিন্ন জাতের জিনিস পাশাপাশি থাকে: প্রোগ্রামের ডেটা (local array, যেমন একটি নামের buffer) আর প্রোগ্রামের নিয়ন্ত্রণ-তথ্য (saved ra — কোথায় ফিরতে হবে)। আর একটি সূক্ষ্ম জ্যামিতি আছে: stack নিচের দিকে বাড়ে, কিন্তু একটি array-র উপাদান থাকে ওপরের দিকে (buf[0], buf[1], … ক্রমে বড় ঠিকানায়)। ফলে কোনো কোড যদি একটি local array-তে তার আকারের চেয়ে বেশি byte লেখে, বাড়তি byte গিয়ে পড়ে array-র ওপরে — যেখানে সাধারণত saved register আর saved return address থাকে। একে বলে stack buffer overflow.

ঠিকানা (উঁচু ওপরে)কী আছেসীমার বাইরে লিখলে
উঁচুSaved return addressফেরার ঠিকানা নষ্ট — ret ভুল জায়গায় যায়
↓(Canary থাকলে এখানে)Canary বদলে যায় — ফেরার আগে ধরা পড়ে
↓Saved s0 / অন্য localঅন্য চলক নষ্ট
নিচুchar buf[16] (buf[0] সবচেয়ে নিচে)লেখা ওপরের দিকে গড়ায় ↑

সবচেয়ে নিরীহ ফল হলো crash — ret একটি অর্থহীন ঠিকানায় লাফ দেয় আর OS প্রোগ্রাম বন্ধ করে। কিন্তু input যদি বাইরের কারও নিয়ন্ত্রণে থাকে (নেটওয়ার্ক থেকে আসা ডেটা, একটি ফাইল), সে saved return address-এর জায়গায় নিজের পছন্দের মান বসিয়ে প্রোগ্রামের নিয়ন্ত্রণ নিতে পারে। ১৯৮৮ সালের Morris worm-এর ছড়ানোর একটি পথ ছিল ঠিক এমন একটি ত্রুটি — Unix-এর fingerd সার্ভিস gets() দিয়ে সীমাহীনভাবে input পড়ত। কয়েক দশক ধরে এই এক শ্রেণির বাগ বহু গুরুতর নিরাপত্তা-ঘটনার কারণ হয়েছে। এখানে আমরা শুধু ধারণা আর প্রতিরক্ষা দেখব; আক্রমণের কৌশল এই কোর্সের বিষয় নয়।

প্রতিরক্ষার স্তরগুলো

🐤 Stack canary

Compiler prologue-এ local আর saved return address-এর মাঝে একটি গোপন, এলোমেলো মান (canary) বসায়, আর epilogue-এ ret-এর আগে যাচাই করে। overflow ওপরে গড়ালে আগে canary নষ্ট হয় — প্রোগ্রাম তখনই নিজেকে থামিয়ে দেয় (glibc-তে “stack smashing detected”). GCC/Clang: -fstack-protector-strong; অনেক Linux distribution এটি ডিফল্ট চালু রাখে। নামটি খনিতে বিষাক্ত গ্যাস টের পেতে ক্যানারি পাখি রাখার পুরোনো প্রথা থেকে।

ধরে: return-এর আগে

🚫 Non-executable stack (NX)

Page table-এর একটি বিট (x86-64-এ NX/XD, ARM-এ XN; RISC-V-এর page table entry-তে আলাদা R, W, X বিট) বলে দেয় কোন page থেকে instruction চালানো যাবে। Stack আর heap লেখা যায় কিন্তু চালানো যায় না (W^X নীতি) — তাই stack-এ ঢোকানো ডেটা code হিসেবে চলে না। Page table-এর hardware পর্ব ৫০-এ।

থামায়: stack-এ code চালানো

🎲 ASLR

ASLR প্রতিবার প্রোগ্রাম চালানোর সময় stack, heap, library আর (PIE হলে) প্রোগ্রামের code এলোমেলো ঠিকানায় বসায়। আক্রমণকারী জানে না কোথায় লাফ দিতে হবে। এজন্যই উপরের C প্রোগ্রামে পূর্ণ ঠিকানা নয়, শুধু পার্থক্য ছাপা হয়েছিল।

কঠিন করে: ঠিকানা অনুমান

🛡️ Shadow stack ও PAC

Shadow stack-এ hardware প্রতিটি call-এ return address-এর একটি আলাদা, সুরক্ষিত কপি রাখে; ret-এর সময় দুটি না মিললে exception. Intel CET (প্রায় ২০২০ সালের CPU থেকে) আর সাম্প্রতিক AMD CPU-তে আছে; RISC-V-এ Zicfiss extension (২০২৪ সালে অনুমোদিত)। ARM-এর Pointer Authentication (PAC) (Armv8.3-A) lr-এ একটি cryptographic স্বাক্ষর বসায় (paciasp) আর ফেরার আগে যাচাই করে (autiasp).

রক্ষা করে: return address নিজেই

এগুলো সব “দ্বিতীয় দেয়াল”। প্রথম আর সবচেয়ে কার্যকর দেয়াল হলো বাগটিই না রাখা: input-এর আকার যাচাই, আর এমন API ব্যবহার যা buffer-এর আকার জানে — bounds checking. নিচে ঝুঁকিপূর্ণ আর নিরাপদ রূপ পাশাপাশি (শুধু উদাহরণ; চালানোর জন্য নয়)। বিস্তারিত দেখুন C কোর্সের undefined behavior ও নিরাপদ কোডিং পর্বে আর OS-এর দিক থেকে OS নিরাপত্তা পর্বে।

char name[16];

/* risky: no idea how big 'name' is */
strcpy(name, input);                  /* overflows if input has 16+ chars */
gets(name);                           /* removed from C11 -- never use    */

/* safer: the size travels with the buffer */
snprintf(name, sizeof name, "%s", input);   /* truncates, always ends with '\0' */
fgets(name, sizeof name, stdin);            /* reads at most 15 chars + '\0'   */
if (strlen(input) >= sizeof name) { /* reject the input explicitly */ }

আরও কয়েকটি অভ্যাস: compiler warning চালু রাখা (-Wall -Wextra), পরীক্ষার সময় AddressSanitizer (-fsanitize=address) — যা সীমার বাইরের প্রতিটি লেখা হাতেনাতে ধরে, -D_FORTIFY_SOURCE=2-এর মতো library-যাচাই, আর যেখানে সম্ভব Rust-এর মতো memory-safe ভাষা। বড় সফটওয়্যার কোম্পানি আর সরকারি নিরাপত্তা সংস্থাগুলোও সাম্প্রতিক বছরগুলোতে memory-safe ভাষার দিকে যাওয়ার পরামর্শ দিচ্ছে।

আরও গভীরে: CPU-র branch predictor (পর্ব ৩৭) ফেরার ঠিকানা আগেভাগে অনুমান করতে একটি ছোট hardware stack রাখে — return address stack (RAS): প্রতিটি call-এ push, প্রতিটি ret-এ pop. RISC-V spec এজন্য একটি ইঙ্গিত-নিয়ম দিয়েছে: rd যদি x1 বা x5 হয়, jal/jalr-কে “call” ধরে push; আর rd = x0 ও rs1 = x1/x5 হলে “return” ধরে pop — তাই ret লিখতে ra ব্যবহার শুধু রীতি নয়, পারফরম্যান্সেরও প্রশ্ন। Shadow stack ধারণাগতভাবে এই RAS-এরই নিরাপত্তা-সংস্করণ, তবে তা থাকে মেমরিতে, বিশেষ page-এ, যা সাধারণ store দিয়ে লেখা যায় না। আরেকটি পুরোনো পথ ছিল register window: Berkeley RISC আর SPARC প্রতিটি call-এ register-এর একটি নতুন “জানালা” দিত, যাতে সাধারণ call-এ stack-এ কিছু লিখতে না হয় — ভাবনার প্রশ্ন ২-এ এর ভালো-মন্দ।

৯ · AI-এর যুগে calling convention আর stack

AI টুল assembly-তে একটি recursive function দ্রুত লিখে দিতে পারে, কিন্তু এই বিষয়ে তার ভুলগুলো খুব চেনা — আর বেশিরভাগই এমন, যা কোড চালানো পর্যন্ত ধরা পড়ে না, বা কাকতালীয়ভাবে চলে যায়:

AI-এর প্রচলিত ভুলঠিক কথাকীভাবে যাচাই
MIPS আর RISC-V মিশিয়ে ফেলা ($ra, jr $ra, $a0–$a3)RISC-V-এ ra, ret, a0–a7RARS-এর মতো RISC-V simulator-এ assemble করে
Non-leaf function-এ ra সংরক্ষণ বাদযে-ই jal করে, তার নিজের ra stack-এ রাখতে হয়প্রতিটি jal-এর আগে ra কোথাও রাখা আছে কি না দেখা
Call-এর পরে t বা a register-এর পুরোনো মান ধরে নেওয়াএগুলো caller-saved — call-এর পরে আবর্জনা ধরে নিনCallee-তে ইচ্ছা করে সব t register নষ্ট করে চালানো
Frame ১২ বা ২০ byte (১৬-এর গুণিতক নয়)RV32I/RV64I-এ sp সবসময় ১৬-byte alignedপ্রতিটি addi sp-এর সংখ্যা ১৬ দিয়ে ভাগ করা
Epilogue-এ sp বা offset ভুল (prologue-এর সাথে অমিল)Epilogue হুবহু উল্টো: একই offset, একই আকারTrace করে ফেরার মুহূর্তে sp শুরুর মানের সমান কি না
x86-64-এ argument ক্রম ভুল, বা Windows আর Linux মেশানোSysV: rdi, rsi, rdx, rcx, r8, r9; Windows: rcx, rdx, r8, r9Compiler Explorer-এ একই C দুই ABI-তে compile করে
Stack overflow-এর গভীরতা বা frame-আকারের নির্দিষ্ট সংখ্যা বানিয়ে বলাCompiler, flag আর OS-এর সীমার ওপর নির্ভর করেঠিকানার পার্থক্য মেপে আর ulimit -s দেখে

AI-কে assembly লিখতে বললে ISA, ABI আর যাচাইয়ের উপায় স্পষ্ট বলে দিন, আর trace চেয়ে নিন:

আমি RISC-V RV32IM assembly শিখছি (RISC-V psABI, ilp32; MIPS নয়)। ১. int sum(int n) { if (n == 0) return 0; return n + sum(n - 1); } — এর recursive assembly লেখো। Prologue-এ কোন register কেন সংরক্ষণ করছ, প্রতিটি লাইনে comment দাও। Frame-এর আকার ১৬-এর গুণিতক রাখো। ২. শুরুতে sp = 0x7ffffff0 ধরে sum(3)-এর জন্য প্রতিটি call আর return-এ sp-এর মান আর প্রতিটি frame-এ কী লেখা আছে, একটি টেবিলে দেখাও। ৩. কোন register caller-saved আর কোনটি callee-saved, তোমার কোডে কোথায় কোন নিয়ম কাজে লাগল, আলাদা করে বলো। ৪. আমি কোডটি RARS simulator-এ চালিয়ে মেলাব — তাই শুধু RV32IM-এর আসল instruction আর প্রচলিত pseudo-instruction (li, mv, call, ret) ব্যবহার করো।

AI-এর উত্তর হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Function call-এর ছয় ধাপ কী কী? — Argument রাখা, নিয়ন্ত্রণ পাঠানো, জায়গা নেওয়া, কাজ করা, ফল রাখা, ফিরে আসা।
  • jal ra, f কী করে? ঠিকানা 0x400 হলে ra কত? — ra = PC + 4 = 0x404, তারপর PC = f.
  • Caller-saved আর callee-saved-এর পার্থক্য, আর কেন দুটিই রাখা হয়? — Caller-saved (t, a) callee নষ্ট করতে পারে; callee-saved (s, sp) ফেরত দিতে হয়; leaf function সস্তা রাখতে আর দীর্ঘায়ু মান একবারে সংরক্ষণ করতে।
  • fact(3)-এর stack trace আঁকুন (১৬-byte frame, sp = 0x7ffffff0) — frame 0x7fffffe0, 0x7fffffd0, 0x7fffffc0; ফেরার সময় a0 = ১, ২, ৬; সর্বোচ্চ ৪৮ byte.
  • Stack canary কীভাবে কাজ করে? — Local আর return address-এর মাঝে একটি গোপন মান; ফেরার আগে বদলে গেলে প্রোগ্রাম থামে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলো আপনাকে instruction মুখস্থ করা থেকে নকশার কারণ বোঝার দিকে নিয়ে যাবে।

প্র ০১Register-গুলোকে caller-saved আর callee-saved-এ ঠিক কতটা করে ভাগ করা উচিত — আর এই সংখ্যা কে, কীভাবে ঠিক করে?

এর কোনো গাণিতিকভাবে “সঠিক” উত্তর নেই; এটি মাপা তথ্যের ওপর দাঁড়ানো একটি প্রকৌশল-সিদ্ধান্ত। দুই দিকের টানাপোড়েন অংশ ৩-এ দেখেছেন: temporary register বেশি থাকলে leaf function-গুলো stack না ছুঁয়েই অনেক কাজ সারতে পারে; saved register বেশি থাকলে non-leaf function-গুলো বেশি মান call পেরিয়ে register-এ রাখতে পারে। কোনটি বেশি জরুরি, তা নির্ভর করে বাস্তব প্রোগ্রাম কেমন তার ওপর — কত শতাংশ call leaf-এ যায়, একটি সাধারণ function-এ কয়টি মান call পেরিয়ে বাঁচে।

ISA-র নকশাকারীরা তাই benchmark-এর কোড compile করে গোনেন: বিভিন্ন ভাগে মোট কতগুলো save/restore instruction চলে। MIPS-এর পুরোনো o32 convention-এ ৮টি saved register ছিল ($s0–$s7), আর argument মাত্র ৪টি — ১৯৮০-এর দশকের প্রোগ্রাম আর compiler-এর হিসাবে। RISC-V-এর নকশাকারীরা (Berkeley, ২০১০-এর দশক) argument ৮টি আর saved register ১২টি রাখলেন — আধুনিক compiler বেশি inlining করে, ফলে function-গুলো বড় আর বেশি মান একসাথে বাঁচিয়ে রাখে। ARM64-এ ১০টি callee-saved (x19–x28), x86-64 System V-তে মাত্র ৬টি (rbx, rbp, r12–r15) — কারণ x86-64-এর মোট register-ই ১৬টি। Windows x64-এ rdi আর rsi-ও callee-saved, যা System V-তে argument register — একই hardware, দুটি OS-এর আলাদা সিদ্ধান্ত।

একটি মজার দিক হলো, এই ভাগ hardware-এ কোথাও লেখা নেই। CPU-র কাছে s3 আর t3 একই রকম register. চুক্তিটি বদলাতে চাইলে নতুন chip লাগে না, লাগে নতুন compiler আর সব library নতুন করে build করা — যা আসলে আরও কঠিন, কারণ পুরোনো binary-গুলো তখন আর একসাথে কাজ করবে না। এজন্যই একবার ABI ঠিক হয়ে গেলে তা দশকের পর দশক টিকে থাকে।

মূল উপলব্ধি: Calling convention একটি সামাজিক চুক্তি, hardware নয় — তাই এর সংখ্যাগুলো আসে বাস্তব কোডের পরিসংখ্যান থেকে, আর একবার ছড়িয়ে পড়লে বদলানো প্রায় অসম্ভব। ভালো ABI নকশা মানে ভবিষ্যতের compiler কেমন হবে, তা আগে থেকে আন্দাজ করা।

প্র ০২Berkeley RISC আর SPARC “register window” দিয়ে call-এর মেমরি-খরচ প্রায় শূন্যে নামিয়েছিল। তাহলে RISC-V বা ARM64 এই কৌশল নিল না কেন?

১৯৮০-এর দশকের শুরুতে David Patterson-এর দল Berkeley-তে RISC I আর RISC II বানানোর সময় দেখলেন, সাধারণ প্রোগ্রামে function call খুব ঘনঘন, আর প্রতিটি call-এ register মেমরিতে রাখা-ফেরানো বড় খরচ। তাঁদের সমাধান: chip-এ অনেক বেশি physical register, যেগুলো কয়েকটি “জানালায়” ভাগ করা। প্রতিটি call-এ CPU শুধু একটি pointer সরিয়ে নতুন জানালা দেয়; caller-এর “output” register আর callee-র “input” register একই physical register, তাই argument পাঠাতে কোনো কপিও লাগে না। Sun-এর SPARC (১৯৮৭) এই নকশা বাণিজ্যিকভাবে নেয়।

সমস্যা দেখা দিল পরে। প্রথমত, জানালা সীমিত (প্রায়ই ৮টি)। গভীর recursion-এ জানালা ফুরিয়ে গেলে একটি trap হয়, আর OS পুরোনো জানালা মেমরিতে লেখে (window overflow) — তখন খরচ সাধারণ stack-এর চেয়েও বেশি। দ্বিতীয়ত, context switch-এ সব জানালার register সংরক্ষণ করতে হয়, যা multitasking-এ ভারী। তৃতীয়ত, বিপুল register file chip-এর জায়গা আর শক্তি খায়, আর পরের প্রজন্মের out-of-order নকশায় (register renaming, পর্ব ৪০) জানালার হিসাব ঢোকানো জটিল। আর সবচেয়ে বড় কথা: compiler ততদিনে অনেক ভালো হয়ে গেছে — inlining আর ভালো register allocation দিয়ে অনেক call-ই আর হয় না, বা leaf function stack ছোঁয়ই না।

Intel-এর Itanium একটি নিজস্ব রূপ (register stack engine) চেষ্টা করেছিল, যা পেছনে নিজে থেকে register মেমরিতে লিখত; কিন্তু Itanium বাণিজ্যিকভাবে সফল হয়নি। RISC-V-এর নকশাকারীদের অনেকেই Berkeley-রই মানুষ — নিজেদের পুরোনো ধারণা জেনেবুঝে বাদ দিয়ে তাঁরা সরল নকশা বেছে নিয়েছেন: একটি সমতল register file আর software-এর calling convention.

মূল উপলব্ধি: একটি hardware কৌশল তার সময়ের software-এর দুর্বলতা পূরণ করতে পারে; software (compiler) উন্নত হলে সেই কৌশল বোঝা হয়ে দাঁড়ায়। ISA নকশায় তাই প্রশ্ন শুধু “এটা কি দ্রুত?” নয়, “২০ বছর পরের compiler আর chip-এ এটা কি বাধা হবে?”

প্র ০৩অনেক নিরাপত্তা-সংবেদনশীল embedded কোডে recursion নিষিদ্ধ। Recursion কি তাহলে খারাপ? আর tail call কেন এই বিতর্কে গুরুত্বপূর্ণ?

Recursion খারাপ নয় — গাছ (tree), ভাষার parser বা divide-and-conquer অ্যালগরিদম recursion-এ সবচেয়ে স্বাভাবিক আর পড়তে সহজ। সমস্যা একটাই: stack-এর ব্যবহার input-এর ওপর নির্ভর করে, আর সেটি আগে থেকে সীমা টানা কঠিন। একটি desktop প্রোগ্রামে ৮ MiB stack আর virtual memory থাকে, overflow হলে crash হয়ে নতুন করে চালানো যায়। কিন্তু একটি গাড়ির brake controller বা পেসমেকারের microcontroller-এ হয়তো কয়েক KiB RAM, কোনো virtual memory বা guard page নেই — stack নিচে নেমে নীরবে অন্য ডেটা মুছে দিতে পারে, আর ফল হতে পারে প্রাণঘাতী।

এজন্য মোটরগাড়ি শিল্পের বহুল ব্যবহৃত MISRA C নির্দেশিকায় একটি নিয়ম আছে যে function নিজেকে প্রত্যক্ষ বা পরোক্ষভাবে ডাকবে না। NASA JPL-এর Gerard Holzmann ২০০৬ সালে নিরাপত্তা-সংবেদনশীল কোডের জন্য দশটি নিয়মের যে তালিকা (“Power of Ten”) প্রকাশ করেন, তার প্রথমটিতেই recursion বাদ দিতে বলা হয়েছে। উদ্দেশ্য একটাই: static analysis tool যেন call graph দেখে প্রমাণ করতে পারে, সবচেয়ে খারাপ ক্ষেত্রে stack কত byte লাগবে। Recursion থাকলে call graph-এ চক্র তৈরি হয়, আর সেই প্রমাণ আর সহজ থাকে না।

Tail call এখানে একটি সেতু। একটি tail-recursive function compiler-এর হাতে লুপ হয়ে যেতে পারে, যার stack-খরচ স্থির। Scheme-এর মতো ভাষা নিয়মেই বলে দেয় tail call-এ stack বাড়বে না, তাই সেখানে লুপের বদলে recursion লেখাই রীতি। কিন্তু C আর C++ এমন নিশ্চয়তা দেয় না — -O2-এ হলেও -O0-এ হয় না, আর একটি local-এর ঠিকানা বাইরে গেলেই optimization ভেঙে যায়। তাই নিরাপত্তার যুক্তিতে “compiler নিশ্চয়ই করবে” ধরে নেওয়া চলে না। মজার একটি তথ্য: প্রোগ্রামারদের সবচেয়ে বড় প্রশ্নোত্তর-সাইটের নাম “Stack Overflow” — এই বাগটি এতটাই চেনা।

মূল উপলব্ধি: প্রশ্নটি “recursion ভালো না খারাপ” নয়, “এই পরিবেশে stack-এর সবচেয়ে খারাপ ক্ষেত্র কি প্রমাণ করা যায়?” Desktop-এ সুন্দর কোড, embedded-এ প্রমাণযোগ্য কোড — একই কৌশলের দাম পরিবেশভেদে আলাদা।

প্র ০৪Return address stack-এ রাখা কি একটি নকশাগত ভুল ছিল? Stack-নিরাপত্তার ইতিহাস থেকে আমরা কী শিখি?

সময়ের প্রেক্ষাপটে এটি ভুল ছিল না — ডেটা আর নিয়ন্ত্রণ-তথ্য একই stack-এ রাখা সহজ, দ্রুত, আর recursion-এর জন্য নিখুঁত। কিন্তু এর একটি নিরাপত্তা-মূল্য আছে, যা ইন্টারনেটের যুগে স্পষ্ট হলো। ১৯৮৮ সালের Morris worm-এর ছড়ানোর একটি পথ ছিল একটি stack buffer overflow, আর ১৯৯০-এর দশকে এই ধরনের ত্রুটি সফটওয়্যার-নিরাপত্তার সবচেয়ে বড় সমস্যাগুলোর একটি হয়ে ওঠে।

প্রতিরক্ষার ইতিহাসটি একটি “ইঁদুর-বিড়াল” খেলার মতো, আর প্রতিটি ধাপ শিক্ষণীয়। ১৯৯৮ সালের দিকে StackGuard গবেষণা-প্রকল্প stack canary-র ধারণা আনে; পরে তা GCC-র -fstack-protector হয়ে প্রায় সব Linux সিস্টেমে ছড়ায়। তারপর hardware সাহায্য করল: AMD64-এর NX বিট আর Windows-এর DEP stack থেকে code চালানো বন্ধ করল। আক্রমণকারীরা তখন নতুন code না ঢুকিয়ে প্রোগ্রামের ভেতরের বিদ্যমান code-এর টুকরো জোড়া দেওয়ার কৌশলে গেল — যাকে code-reuse আক্রমণ বলে। এর উত্তরে এলো ASLR (ঠিকানা লুকানো), তারপর control-flow integrity, আর শেষে hardware-এর shadow stack (Intel CET) আর ARM-এর pointer authentication — যা সরাসরি return address-কেই রক্ষা করে। RISC-V-ও ২০২৪ সালে Zicfiss (shadow stack) আর Zicfilp (landing pad) extension অনুমোদন করেছে।

লক্ষ করুন, প্রতিটি ধাপে প্রতিরক্ষা মূল নকশা বদলায়নি — return address এখনো stack-এ — বরং একটার পর একটা স্তর যোগ করেছে (defense in depth). কারণ ABI বদলানো মানে কোটি কোটি প্রোগ্রাম ভেঙে ফেলা। আর এই সব hardware-সমাধানের পরেও সবচেয়ে কার্যকর প্রতিরক্ষা সবচেয়ে পুরোনোটি: buffer-এর সীমা যাচাই করা, আর memory-safe ভাষা ব্যবহার করা।

মূল উপলব্ধি: যেখানে ডেটা আর নিয়ন্ত্রণ পাশাপাশি থাকে, সেখানে ডেটার একটি ভুল নিয়ন্ত্রণের ভুলে পরিণত হতে পারে। নিরাপত্তা-নকশার মূল কথা হলো এই দুটিকে আলাদা রাখা — shadow stack ঠিক সেই চিন্তারই hardware-রূপ।

প্র ০৫x86 ফেরার ঠিকানা stack-এ push করে, আর RISC-V ও ARM রাখে একটি register-এ (ra, lr). এই পার্থক্যের পেছনে কী যুক্তি, আর আজকের CPU-তে এর প্রভাব কতটা?

পার্থক্যটি দুই নকশা-দর্শনের। Intel-এর 8086 (১৯৭৮) একটি CISC ধাঁচের প্রসেসর, যেখানে একটি instruction-এ বেশি কাজ করানো স্বাভাবিক ছিল: call একাই sp কমায়, মেমরিতে লেখে আর লাফ দেয়; ret একাই পড়ে, sp বাড়ায় আর লাফ দেয়। সেই সময় register কম ছিল (৮টি সাধারণ register), তাই একটি register-কে স্থায়ীভাবে link register বানানো দামি হতো। আর x86-64 পিছনের দিকের সামঞ্জস্য (backward compatibility) রাখতে এই আচরণ রেখে দিয়েছে।

RISC নকশা (MIPS, ARM, পরে RISC-V) “একটি instruction, একটি সরল কাজ” নীতি নেয়: jal শুধু একটি register-এ লেখে আর লাফ দেয়, মেমরি ছোঁয় না। এতে instruction সরল থাকে আর pipeline-এ সহজে চলে (পর্ব ৩৫)। সবচেয়ে বড় লাভ leaf function-এ — প্রোগ্রামের বেশিরভাগ call যেখানে যায় — সেখানে ফেরার ঠিকানা কখনো মেমরিতেই যায় না। Non-leaf function-এ অবশ্য ra নিজেই stack-এ রাখতে হয়, তখন খরচ x86-এর কাছাকাছি।

আজকের বাস্তবতায় পার্থক্য অনেকটা কমে গেছে। আধুনিক x86 core-এ একটি বিশেষ hardware (stack engine) push/pop/call/ret-এর sp-হিসাব আলাদাভাবে সামলায়, আর মেমরি-লেখাগুলো সাধারণত L1 cache-এই শেষ হয় (stack-এর locality খুব ভালো, পর্ব ৪৪)। দুই ধরনের CPU-ই return address stack দিয়ে ফেরার ঠিকানা আগে থেকে অনুমান করে। তবু x86-এর নকশার দুটি ছাপ রয়ে গেছে, যা এই পর্বে দেখেছেন: callee-তে ঢোকার মুহূর্তে rsp ১৬-এর গুণিতক নয় (৮ বেশি), তাই compiler বাড়তি sub $8 লেখে; আর red zone-এর মতো কৌশল লাগে leaf function-এর খরচ কমাতে। নিরাপত্তার দিক থেকে দুটি নকশাই শেষ পর্যন্ত ra/return address stack-এ রাখে, তাই দুই জায়গাতেই shadow stack বা PAC দরকার হয়েছে।

মূল উপলব্ধি: ISA-র একটি ছোট সিদ্ধান্ত — “ফেরার ঠিকানা কোথায়” — alignment-নিয়ম, ABI-র কৌশল আর নিরাপত্তা-hardware পর্যন্ত ছড়িয়ে পড়ে। আধুনিক microarchitecture অনেক পার্থক্য মুছে দিতে পারে, কিন্তু ISA-র চুক্তি যা বলে, software-কে তা মানতেই হয়।

অনুশীলন

পরীক্ষার ধাঁচের চারটি সমস্যা। আগে খাতায় নিজে করুন, তারপর উত্তর মেলান। সব সংখ্যা ১৬-byte frame আর RV32 (৪-byte word) ধরে।

  1. Prologue-epilogue লিখুন: একটি non-leaf function g, যে s0 আর s1 ব্যবহার করে আর একটি local int arr[2] stack-এ রাখে। g-এর prologue আর epilogue লিখুন, frame-এর আকার আর প্রতিটি জিনিসের offset দেখান।

    দরকার: ra (৪, কারণ non-leaf) + s0 (৪) + s1 (৪) + arr (২ × ৪ = ৮) = ২০ byte. ১৬-এর পরের গুণিতক ৩২, তাই ১২ byte padding. একটি বৈধ বিন্যাস: ra 28(sp), s0 24(sp), s1 20(sp), arr[1] 16(sp), arr[0] 12(sp), 0–11 padding.

    g:  addi sp, sp, -32     # prologue
        sw   ra, 28(sp)
        sw   s0, 24(sp)
        sw   s1, 20(sp)
        ...                  # body: arr[0] = 12(sp), arr[1] = 16(sp)
        lw   s1, 20(sp)      # epilogue: reverse order
        lw   s0, 24(sp)
        lw   ra, 28(sp)
        addi sp, sp, 32
        ret

    যাচাইয়ের তিনটি প্রশ্ন: (১) prologue-এর −32 আর epilogue-এর +32 মেলে? (২) প্রতিটি sw-এর offset-এ ঠিক একই lw আছে? (৩) ৩২ ÷ ১৬ পূর্ণসংখ্যা? তিনটিই হ্যাঁ। ভেতরের ক্রম আলাদা হলেও উত্তর ঠিক, যদি offset-গুলো একে অপরের ওপর না পড়ে আর frame-এর ভেতরেই থাকে।

  2. Recursive call trace করুন: int sum(int n) { if (n == 0) return 0; return n + sum(n - 1); }. প্রতিটি call ১৬-byte frame নেয় (ra আর n রাখে), main sum(3) ডাকার সময় sp = 0x7ffffff0. প্রতিটি call-এ ঢোকার সময়ের sp, সবচেয়ে নিচের sp, stack-এর সর্বোচ্চ ব্যবহার আর প্রতিটি return value লিখুন।

    sum(3), sum(2), sum(1), sum(0) — চারটি call (base case n == 0-তেও একটি frame, কারণ আমাদের নকশায় প্রতিটি call frame নেয়)।

    Callঢোকার সময় spFrame-এর পরে spReturn value
    sum(3)0x7ffffff00x7fffffe03 + 3 = 6
    sum(2)0x7fffffe00x7fffffd02 + 1 = 3
    sum(1)0x7fffffd00x7fffffc01 + 0 = 1
    sum(0)0x7fffffc00x7fffffb00 (base case)

    সবচেয়ে নিচে sp = 0x7fffffb0, সর্বোচ্চ ব্যবহার ৪ × ১৬ = ৬৪ byte. ফেরার ক্রম উল্টো: আগে sum(0) ফেরে ০, তারপর ১, ৩, আর শেষে main পায় ৬; প্রতিটি return-এর পরে sp এক ধাপ (১৬) ওপরে ওঠে, আর শেষে আবার 0x7ffffff0. সাধারণভাবে sum(n)-এর stack-খরচ (n + ১) × ১৬ byte.

  3. Frame-এর আকার গুনুন: একটি non-leaf function h তিনটি callee-saved register (s0, s1, s2) ব্যবহার করে, local হিসেবে একটি int x আর একটি int arr[4] রাখে, আর এমন একটি function ডাকে যার ১০টি int argument. (ক) h-এর frame কত byte? (খ) দুটি stack-argument কোথায় থাকবে? (গ) h যদি নিজেকে recursively ১০০০ স্তর গভীরে ডাকে, শুধু h-এর frame-গুলোর জন্য কত stack লাগবে?

    (ক) ra ৪ + s0–s2 ১২ + x ৪ + arr ১৬ + outgoing argument (৯ আর ১০ নম্বর) ৮ = ৪৪ byte → ১৬-এর গুণিতকে গোল করে ৪৮ byte (৪ byte padding).

    (খ) Call-এর মুহূর্তে h-এর sp থেকে: নবম argument 0(sp)-এ, দশম 4(sp)-এ — অর্থাৎ h-এর frame-এর একেবারে তলায়। Callee নিজের frame (ধরা যাক F byte) নেওয়ার পরে এগুলো পড়বে F(sp) আর F+4(sp) থেকে।

    (গ) ১০০০ × ৪৮ = ৪৮,০০০ byte (প্রায় ৪৭ KiB) — ৮ MiB-এর Linux stack-এ সহজে আঁটে, কিন্তু ১৬ KiB RAM-এর একটি microcontroller-এ অসম্ভব।

  4. Output অনুমান করুন আর কোন register সংরক্ষণ দরকার বলুন:
    main: li   t1, 5
          li   s2, 8
          jal  ra, g
          add  a0, t1, s2      # a0 = ?
          ...
    g:    li   t1, 100
          li   s2, 1
          ret
    (ক) add-এর পরে a0 কত? (খ) কে কোন নিয়ম ভেঙেছে? (গ) g-কে কি ra সংরক্ষণ করতে হবে? main-কে?

    (ক) g দুটি register-ই বদলে দেয়, তাই a0 = ১০০ + ১ = ১০১ (main আশা করেছিল ১৩)।

    (খ) দুটি ভুল, দুই পক্ষের। g s2 বদলেছে কিন্তু সংরক্ষণ করেনি — s2 callee-saved, তাই g-এর prologue-এ sw s2, …(sp) আর ফেরার আগে lw লাগত (তখন g-এর একটি ১৬-byte frame লাগবে)। main t1-এর মান call পেরিয়ে আশা করেছে — t1 caller-saved, তাই g-এর t1 বদলানো সম্পূর্ণ বৈধ। main-এর উচিত মানটি একটি s register-এ রাখা (আর নিজের prologue-এ সেটি সংরক্ষণ করা), অথবা call-এর আগে t1 stack-এ রেখে পরে পড়া। ঠিক করার পরে a0 = ৫ + ৮ = ১৩.

    (গ) g একটি leaf function — কাউকে ডাকে না, তাই ra-তে হাত পড়ে না; সংরক্ষণ লাগে না। main কিন্তু g-কে ডাকে, আর main নিজেও কোথাও ফেরে (C runtime-এর কাছে), তাই main-কে নিজের ra prologue-এ stack-এ রাখতে হবে — উপরের কোডে সেটিও বাদ পড়েছে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমRISC-V-তে একটি non-leaf function f ডাকা থেকে ফেরা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Caller argument রাখে a0–a7-এ
  2. Caller চালায় jal ra, f
  3. f-এর prologue: addi sp, sp, -16, তারপর ra আর s register stack-এ
  4. f-এর কাজ চলে, ফল যায় a0-তে
  5. f-এর epilogue: register ফেরত, তারপর addi sp, sp, 16
  6. ret (jalr x0, 0(ra)) — caller-এ ফেরা

RISC-V-তে ঠিকানা 0x00400100-এ রাখা jal ra, func চলার পরে ra-তে কী থাকবে?

  • 0x00400100
  • 0x00400104
  • 0x00400108
  • func-এর ঠিকানা

RISC-V calling convention (psABI) অনুযায়ী কোন register-গুলোর মান একটি function-কে ফেরার আগে অবশ্যই আগের মতো ফিরিয়ে দিতে হয়?

  • t0–t6
  • a0–a7
  • s0–s11 আর sp
  • শুধু ra

RV32-এ একটি function-এর ৯টি int argument. RISC-V psABI অনুযায়ী নবম argument কোথায় যায়?

  • Call-এর মুহূর্তে caller-এর stack-এ, 0(sp)-এ
  • a8 register-এ
  • t0 register-এ
  • Heap-এ

একটি RV32 non-leaf function ra আর তিনটি s register সংরক্ষণ করে, আর ৮ byte local রাখে। sp ১৬-byte aligned রাখতে হলে frame-এর আকার কত?

  • ১৬ byte
  • ২৪ byte
  • ৩২ byte
  • ৪৮ byte

fib(n) = fib(n − 1) + fib(n − 2), যেখানে n < 2 হলে সরাসরি n ফেরত দেয় — এই সরল recursive সংস্করণে fib(5) হিসাব করতে fib মোট কতবার ডাকা হয় (প্রথম call-সহ)?

  • ৫
  • ৮
  • ৯
  • ১৫

GCC-র -fstack-protector যে stack canary বসায়, তার মূল কাজ কী?

  • Stack-এর page থেকে code চালানো বন্ধ করা
  • Function ফেরার আগে ধরা যে local buffer উপচে saved return address-এর দিকে লিখেছে কি না
  • প্রতিবার stack-এর ঠিকানা এলোমেলো করা
  • প্রতিটি array লেখার আগে সীমা যাচাই করা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. ডাকা আর ফেরা: jal ra, f → ra = PC + 4, PC = f; ret = jalr x0, 0(ra). ছয় ধাপ: argument রাখা, লাফ, জায়গা নেওয়া, কাজ, ফল রাখা, ফেরা।
  2. Calling convention: argument a0–a7, ফল a0–a1; t0–t6 আর a0–a7 caller-saved; s0–s11 আর sp callee-saved; non-leaf function নিজের ra stack-এ রাখে। ভাগের কারণ: leaf function বিনা খরচে, দীর্ঘায়ু মান একবারে সংরক্ষণ।
  3. Stack frame: stack নিচে নামে, sp ১৬-byte aligned; frame-এ saved ra, saved s register, local, outgoing argument (নবমটি 0(sp)-এ); s0/fp ঐচ্ছিক। Prologue addi sp, sp, -N + sw, epilogue হুবহু উল্টো।
  4. Recursion: প্রতিটি call নতুন frame; fact(3)-এ তিনটি ১৬-byte frame, সর্বোচ্চ ৪৮ byte; stack-খরচ = গভীরতা × frame, সময় = call-সংখ্যা (fib(6)-এ ২৫ call কিন্তু গভীরতা ৬)। Tail call (j/tail) frame-ই নেয় না।
  5. অন্য ISA আর নিরাপত্তা: x86-64 SysV rdi, rsi, rdx, rcx, r8, r9 আর call-এ stack-এ return address, red zone; Windows x64 rcx, rdx, r8, r9 আর ৩২-byte shadow space; ARM64 x0–x7, lr. প্রতিরক্ষা: canary, NX, ASLR, shadow stack/PAC, আর সবার আগে সীমা-যাচাই।

পরের পর্বে: এতক্ষণ assembly আমরা হাতে লিখেছি। পর্ব ৩১-এ দেখব আসল compiler একই C কোড থেকে কী বানায় — -O0 আর -O2-এর পার্থক্য, আর একই function RISC-V, x86-64 আর ARM-এ পাশাপাশি।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান