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

C থেকে assembly — stack frame আর calling convention-এর ভেতরে

From C to Assembly — Registers, Stack Frames & Calling Conventions

Taught in Bangla, this lesson shows what the CPU actually runs: x86-64 registers and instruction families, reading gcc -S output line by line, the stack, prologue and epilogue of a function call, System V and Windows calling conventions, what -O2 changes, and why stack buffer overflows reach the return address.

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

  • x86-64-এর প্রধান register, flag আর ছয় পরিবারের instruction চেনা; Intel আর AT&T লেখার ধরন আলাদা করা
  • gcc -S -O0-এর আউটপুট পড়ে বলতে পারা — কোন C লাইন কোন instruction হয়েছে
  • ফাংশন কলের প্রতিটি ধাপ: prologue, local ভ্যারিয়েবল, return address, epilogue
  • System V আর Windows x64 calling convention-এর পার্থক্য, আর ARM64-এর সাথে তুলনা
  • Optimizer কী বদলায়, buffer overflow কেন return address পর্যন্ত পৌঁছায়, আর কোন প্রতিরক্ষা তা ঠেকায়

১ · CPU-র চোখে আপনার C কোড — কেন assembly পড়া শিখবেন

পর্ব ০৩-এ দেখেছিলেন, compiler আপনার .c ফাইলকে প্রথমে assembly-তে অনুবাদ করে, তারপর assembler সেটিকে machine code বানায়। তখন assembly-কে একটি মাঝের ধাপ হিসেবে দেখে আমরা এগিয়ে গিয়েছিলাম। এই পর্বে সেই ধাপেই থামব, আর ফাইলটি খুলে পড়ব।

প্রথম সত্যটি সহজ: CPU কোনো ভ্যারিয়েবল, লুপ বা ফাংশন চেনে না। সে চেনে কয়েকশো খুব ছোট নির্দেশ — “এই ঘর থেকে ওই ঘরে ৪ বাইট আনো”, “দুটি সংখ্যা যোগ করো”, “ফল শূন্য হলে অমুক জায়গায় লাফ দাও।” for, if, ফাংশন কল — সবকিছুই compiler এই সরল নির্দেশের সারিতে ভেঙে দেয়। এই নির্দেশের তালিকাকে বলে CPU-র instruction set; ল্যাপটপ আর সার্ভারে সাধারণত x86-64, আর ফোন ও Apple Silicon Mac-এ ARM64.

একটি গার্মেন্টস কারখানার কথা ভাবুন। ডিজাইনার বলেন, “একটি শার্ট বানাও।” কিন্তু সেলাই মেশিনের অপারেটরকে দিতে হয় ছোট ছোট কাজ: “এই কাপড়টি টেবিলে আনো”, “এই দুই টুকরো সেলাই করো।” C হলো ডিজাইনারের বাক্য, assembly হলো অপারেটরের কাজের তালিকা। আর অপারেটরের হাতের কাছের ছোট টেবিলটি হলো register — অল্প জায়গা, কিন্তু সবচেয়ে দ্রুত।

তাহলে assembly পড়তে শিখবেন কেন? কারণ তাতে এমন অনেক প্রশ্নের উত্তর নিজের চোখে দেখা যায়:

  • ফাংশন ডাকলে আসলে কী হয়? কোথায় argument যায়, ফল কোথা থেকে ফেরে।
  • Local ভ্যারিয়েবল কেন হারিয়ে যায়? পর্ব ১৪ আর ১৮-তে শুনেছেন “frame মুছে যায়” — এবার দেখবেন কোন instruction সেটা করে।
  • রিকার্শন কেন মেমরি খায়? পর্ব ১৫-এর প্রতিটি frame কত বাইট, তা গুনে দেখা যায়।
  • Optimization কেন undefined behavior-ওয়ালা কোডের আচরণ বদলায়? কারণ -O2-এ compiler কোডের গঠনই বদলে দেয়।
  • Buffer overflow কীভাবে প্রোগ্রামের নিয়ন্ত্রণ কেড়ে নিতে পারে? কারণ ফেরার ঠিকানা ডেটার ঠিক পাশেই থাকে।

Assembly দেখার তিনটি সহজ উপায় আছে। কোনোটিই মুখস্থ করার দরকার নেই:

gcc -S -O0 -masm=intel area.c        # area.s ফাইলে Intel ধরনের assembly
objdump -d -M intel area             # তৈরি প্রোগ্রাম থেকে উল্টো দিকে assembly (disassembly)
# আর ব্রাউজারে: godbolt.org — বাঁয়ে C, ডানে assembly, রঙ দিয়ে লাইন মেলানো

শেষেরটি, Compiler Explorer (godbolt.org), শেখার জন্য সবচেয়ে ভালো। আর objdump তৈরি হয়ে যাওয়া প্রোগ্রাম থেকে instruction ফিরিয়ে আনে — একে বলে disassembly.

এই পর্বের সব listing কোথা থেকে: প্রতিটি assembly listing Wandbox-এর gcc-head (GCC 17, experimental, অক্টোবর ২০২৬) দিয়ে Linux x86-64-এর জন্য তৈরি করে আসল আউটপুট থেকে নেওয়া। প্রতিটির ওপর flag লেখা আছে। পড়ার সুবিধার জন্য .p2align-এর মতো directive আর নামের উদ্ধৃতিচিহ্ন বাদ দেওয়া হয়েছে; #-এর পরের বাংলা মন্তব্য আমাদের যোগ করা। অন্য compiler বা version-এ খুঁটিনাটি একটু আলাদা হতে পারে, মূল কাঠামো একই থাকে।

২ · CPU-র ভেতরের ঘর — register, rip আর flag

মেমরি (RAM) বিশাল, কিন্তু CPU-র তুলনায় ধীর। তাই CPU-র ভেতরে আছে অল্প কয়েকটি খুব দ্রুত ঘর — register. x86-64-এ সাধারণ কাজের জন্য ১৬টি ৬৪-বিট register আছে, আর বেশিরভাগ হিসাব এখানেই হয়।

Register৩২-বিট নামসাধারণ ভূমিকা (System V, Linux/macOS)
raxeaxফাংশনের ফল ফেরত দেওয়ার ঘর; হিসাবের কাজেও
rdi, rsi, rdx, rcx, r8, r9edi, esi…প্রথম ছয়টি পূর্ণসংখ্যা বা pointer argument — ঠিক এই ক্রমে
rbx, r12–r15ebx…দীর্ঘ সময় মান রাখার ঘর; ফাংশন ব্যবহার করলে আগের মান ফিরিয়ে দিতে বাধ্য
rsp—Stack pointer: stack-এর বর্তমান চূড়ার ঠিকানা
rbp—Frame pointer: বর্তমান ফাংশনের frame-এর ভিত্তি; local ভ্যারিয়েবল এর সাপেক্ষে খোঁজা হয়
rip—Instruction pointer: পরের কোন instruction চলবে তার ঠিকানা
rflags—Flag-এর বিট: শেষ হিসাবের ফল শূন্য কি না, ঋণাত্মক কি না, overflow হলো কি না

একটি খুঁটিনাটি পরে বারবার দেখবেন: eax হলো rax-এর নিচের ৩২ বিট। C-এর int ৪ বাইট, তাই int নিয়ে কাজ হলে listing-এ eax, edi, esi দেখবেন; pointer ৮ বাইট, তাই সেখানে পুরো rdi, rax. আর x86-64-এর একটি নিয়ম: ৩২-বিট নামে কিছু লিখলে ওপরের ৩২ বিট নিজে থেকেই শূন্য হয়ে যায় — অর্থাৎ একটি zero extension.

Flag — তুলনার ফল কোথায় থাকে

CPU-তে “a < b হলে লাফ দাও” বলে কোনো একক নির্দেশ নেই। কাজটি হয় দুই ধাপে। প্রথমে cmp a, b ভেতরে ভেতরে a − b হিসাব করে, ফল ফেলে দেয়, শুধু কয়েকটি flag বিট বসায়:

ZF — Zero flag

ফল শূন্য হলে ১. অর্থাৎ a আর b সমান। je (jump if equal) এটাই দেখে।

SF — Sign flag

ফলের সবচেয়ে উঁচু বিট। পর্ব ০৫-এর two's complement-এ এটাই ঋণাত্মক চিহ্ন।

CF — Carry flag

Unsigned হিসাবে ধার বা হাতে-রাখা হলে ১. jb (jump if below) এটা দেখে।

OF — Overflow flag

Signed হিসাবে overflow হলে ১. jl (jump if less) দেখে SF আর OF দুটোই।

মূল ধারণা

মেমরিতে int আর unsigned একই বিট — পার্থক্য কোথাও লেখা থাকে না। পার্থক্যটা দেখা যায় কোন jump instruction বেছে নেওয়া হলো তাতে: signed তুলনায় compiler বসায় jl/jg, unsigned তুলনায় jb/ja. পর্ব ০৮-এ signed আর unsigned মেশালে যে অদ্ভুত ফল দেখেছিলেন, তার আসল জায়গা এখানেই — compiler একটি jump বেছে নেয়, আর সেটি আপনার অনুমানের উল্টো হলে ফলও উল্টো।

৩ · Instruction-এর ছয় পরিবার — আর লেখার দুই ধরন

x86-64-এ হাজারখানেক instruction আছে, কিন্তু সাধারণ C কোডের listing পড়তে মোটামুটি ছয় পরিবার চিনলেই চলে। প্রতিটি instruction-এর একটি ছোট নাম থাকে (mov, add), তারপর এক বা দুটি operand:

পরিবারউদাহরণ (Intel)অর্থ
ডেটা আনা-নেওয়াmov eax, DWORD PTR [rbp-4]মেমরির rbp-4 ঠিকানা থেকে ৪ বাইট eax-এ আনো
হিসাবadd, sub, imul, xor, sarযোগ, বিয়োগ, signed গুণ, XOR, ডানে arithmetic shift
ঠিকানার হিসাবlea rdx, [0+rax*4]“ঠিকানা” হিসাব করো, কিন্তু মেমরিতে যেও না — ফল rax*4
তুলনা ও লাফcmp, test, jmp, jl, jneflag বসাও; শর্ত মিললে অন্য লাইনে লাফ দাও
ফাংশনcall area, retফেরার ঠিকানা stack-এ রেখে লাফ; stack থেকে ঠিকানা তুলে ফেরা
Stackpush rbp, pop rbprsp ৮ কমিয়ে মান রাখো; মান তুলে rsp ৮ বাড়াও

lea নিয়ে একটু থামা দরকার, কারণ নতুনরা এখানেই সবচেয়ে বেশি বিভ্রান্ত হন। নামটি “load effective address”, কিন্তু সে মেমরিতে যায়ই না — শুধু বন্ধনীর ভেতরের হিসাবটি করে। [0+rax*4] মানে i * 4: int ৪ বাইটের, তাই a[i] আছে শুরু থেকে i*4 বাইট দূরে। পর্ব ১৮-এর pointer arithmetic মেশিনে ঠিক এই রূপ নেয়। Compiler চালাকি করে lea দিয়ে সাধারণ যোগ-গুণও করে, যেমন lea eax, [rdi+rsi] মানে eax = rdi + rsi.

একই instruction, দুই রকম বানান

x86 assembly লেখার দুটি প্রচলিত ধরন আছে। Linux-এর GNU টুল ডিফল্টভাবে লেখে AT&T syntax; Intel-এর নিজের ম্যানুয়াল আর Windows-এর টুল লেখে Intel syntax. ভেতরের machine code হুবহু এক — শুধু লেখার রীতি আলাদা। নিচে একই area ফাংশন দুই ভাবে, gcc-head -O0 থেকে:

area:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, DWORD PTR [rbp-8]
        pop     rbp
        ret
area:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    %edi, -4(%rbp)
        movl    %esi, -8(%rbp)
        movl    -4(%rbp), %eax
        imull   -8(%rbp), %eax
        popq    %rbp
        ret
বিষয়IntelAT&T
Operand-এর ক্রমগন্তব্য আগে: mov eax, 5উৎস আগে: movl $5, %eax
Register ও সংখ্যাখালি নাম: rbp, 5%rbp, $5
আকারDWORD PTR (৪ বাইট), QWORD PTR (৮ বাইট)নামের শেষে অক্ষর: movl, movq
মেমরি[rbp-4]-4(%rbp)

এই পর্বে আমরা Intel ধরন ব্যবহার করব, কারণ mov গন্তব্য, উৎস পড়তে C-এর গন্তব্য = উৎস-এর মতো লাগে। Godbolt-এ এটিই ডিফল্ট; নিজের কম্পিউটারে gcc -masm=intel আর objdump -M intel দিলেই পাবেন।

পর্ব ০৫-এর সাথে মিল: assembler-এর listing-এ প্রতিটি instruction-এর পাশে তার আসল বাইটও দেখা যায়। mov edx, 5050 হয়েছে BA BA 13 00 00 — প্রথম বাইট BA নির্দেশটির কোড, বাকি চার বাইট 5050 = 0x000013BA, কিন্তু উল্টো ক্রমে: little-endian. আর mov eax, -1 হয়েছে B8 FF FF FF FF — two's complement-এ −1 মানে সব বিট ১। পর্ব ০৫-এর দুটি ধারণাই এখানে হাতেনাতে দেখা যাচ্ছে।

৪ · প্রথম অনুবাদ পড়া — area, একটি লুপ আর if/else

-O0 মানে optimization বন্ধ। তখন compiler প্রায় অক্ষরে অক্ষরে অনুবাদ করে: প্রতিটি ভ্যারিয়েবলের জন্য মেমরিতে একটি ঘর, প্রতিটি ব্যবহারে সেখান থেকে আনা, প্রতিটি পরিবর্তনে সেখানে রাখা। ধীর, কিন্তু পড়তে সবচেয়ে সহজ — আর debugger-এর জন্য আদর্শ। শুরু করি সবচেয়ে ছোট ফাংশন দিয়ে:

# int area(int w, int h) { return w * h; }
area:
        push    rbp                       # caller-এর rbp বাঁচিয়ে রাখো
        mov     rbp, rsp                  # এই ফাংশনের frame-এর ভিত্তি
        mov     DWORD PTR [rbp-4], edi    # w (এসেছে edi-তে) মেমরিতে রাখো
        mov     DWORD PTR [rbp-8], esi    # h (এসেছে esi-তে) মেমরিতে রাখো
        mov     eax, DWORD PTR [rbp-4]    # eax = w
        imul    eax, DWORD PTR [rbp-8]    # eax = eax * h
        pop     rbp                       # caller-এর rbp ফিরিয়ে দাও
        ret                               # ফল eax-এ, caller-এর কাছে ফেরো

w আর h এসেছে register-এ — edi আর esi-তে, কারণ calling convention তাই বলে (অংশ ৭)। -O0-এ compiler সেগুলোকে আগে মেমরিতে, rbp-এর ৪ আর ৮ বাইট নিচে রাখে, তারপর আবার আনে, গুণ করে, ফল রাখে eax-এ। কোনো return ভ্যারিয়েবল নেই; ফল eax-এ থাকাটাই “ফেরত দেওয়া”।

লুপ: একটি array-র যোগফল

# int sum(const int *a, int n) { int s = 0;
#     for (int i = 0; i < n; i++) s += a[i];  return s; }
sum:
        push    rbp
        mov     rbp, rsp
        mov     QWORD PTR [rbp-24], rdi   # a (pointer, ৮ বাইট)
        mov     DWORD PTR [rbp-28], esi   # n
        mov     DWORD PTR [rbp-4], 0      # s = 0
        mov     DWORD PTR [rbp-8], 0      # i = 0
        jmp     .L4                       # আগে শর্ত যাচাই করতে নিচে যাও
.L5:                                      # লুপের দেহ
        mov     eax, DWORD PTR [rbp-8]    # eax = i
        cdqe                              # int i-কে ৬৪-বিটে বাড়াও (চিহ্নসহ)
        lea     rdx, [0+rax*4]            # rdx = i * 4 (বাইটে দূরত্ব)
        mov     rax, QWORD PTR [rbp-24]   # rax = a
        add     rax, rdx                  # rax = &a[i]
        mov     eax, DWORD PTR [rax]      # eax = a[i]
        add     DWORD PTR [rbp-4], eax    # s += a[i]
        add     DWORD PTR [rbp-8], 1      # i++
.L4:                                      # শর্ত
        mov     eax, DWORD PTR [rbp-8]
        cmp     eax, DWORD PTR [rbp-28]   # i আর n তুলনা
        jl      .L5                       # i < n (signed) হলে আবার দেহে
        mov     eax, DWORD PTR [rbp-4]    # ফল = s
        pop     rbp
        ret

তিনটি জিনিস খেয়াল করুন। এক, for-এর শর্ত লুপের নিচে বসেছে, আর শুরুতে একটি jmp সেখানে নিয়ে যায়। দুই, cdqe নির্দেশটি int i-কে ৬৪-বিটে বাড়ায়, চিহ্ন রেখে: এটি একটি sign extension, কারণ ঠিকানার হিসাব ৬৪-বিটে হয়। তিন, তুলনার পরে jl — signed “less than”. i আর n দুটোকেই unsigned করে godbolt-এ দেখুন: jl বদলে হবে jb, আর cdqe উধাও হবে। অংশ ২-এর মূল ধারণা হাতেনাতে।

if/else: শর্ত মানে উল্টো লাফ

# int sign(int x) { if (x < 0) return -1; else return 1; }
sign:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        cmp     DWORD PTR [rbp-4], 0      # x - 0 হিসাব করে flag বসাও
        jns     .L8                       # sign flag নেই (x ≥ 0) হলে else-এ লাফ
        mov     eax, -1                   # then অংশ
        jmp     .L9                       # else টপকে যাও
.L8:
        mov     eax, 1                    # else অংশ
.L9:
        pop     rbp
        ret

if (x < 0) লেখা হয়েছিল, কিন্তু listing-এ লাফটি হলো উল্টো শর্তে — “ঋণাত্মক নয় হলে else-এ যাও।” এটাই সাধারণ নিয়ম: শর্ত সত্য হলে কোড সোজা নিচে নামে, মিথ্যা হলে লাফ দিয়ে then অংশ টপকে যায়। পর্ব ১১-এর প্রতিটি if, পর্ব ১২-এর প্রতিটি লুপ মেশিনে শুধু cmp আর কয়েকটি লাফ।

৫ · Stack, prologue আর epilogue — একটি ফাংশন কল ধাপে ধাপে

পর্ব ১৩-এ call stack-কে দেখেছিলেন একটি স্তূপ হিসেবে। মেশিনের স্তরে stack হলো মেমরির একটি সাধারণ অংশ, আর তার চূড়ার ঠিকানা থাকে rsp-এ। একটি অদ্ভুত কিন্তু গুরুত্বপূর্ণ তথ্য: x86-64-এ stack নিচের দিকে বাড়ে। push করলে rsp ৮ কমে, তারপর নতুন ঠিকানায় মান লেখা হয়; pop করলে মান পড়ে rsp ৮ বাড়ে।

প্রায় প্রতিটি -O0 ফাংশন একই ছাঁচে শুরু ও শেষ হয়। শুরুর অংশকে বলে prologue, শেষের অংশকে epilogue:

Prologue

push rbp — caller-এর frame-এর ভিত্তি বাঁচাও।
mov rbp, rsp — এখান থেকে আমার frame শুরু।
sub rsp, N — local ভ্যারিয়েবলের জন্য N বাইট জায়গা নাও।

Epilogue

leave — একসাথে mov rsp, rbp আর pop rbp: আমার জায়গা ছেড়ে দাও, caller-এর ভিত্তি ফিরিয়ে দাও।
ret — stack থেকে ফেরার ঠিকানা তুলে সেখানে লাফ দাও।

এবার main যখন area(6, 7) ডাকে, তখন কী ঘটে, ধাপে ধাপে দেখুন। ঠিকানাগুলো বোঝার জন্য বানানো ছোট উদাহরণ — আসল ঠিকানা অনেক বড়, আর ASLR-এর কারণে প্রতিবার আলাদা।

1main:   ...2        mov  esi, 73        mov  edi, 64        call area5        mov  DWORD PTR [rbp-4], eax67area:8        push rbp9        mov  rbp, rsp10        mov  DWORD PTR [rbp-4], edi11        mov  DWORD PTR [rbp-8], esi12        mov  eax, DWORD PTR [rbp-4]13        imul eax, DWORD PTR [rbp-8]14        pop  rbp15        ret
Register
edi / esi
eax
rsp
rbp
Stack (ওপরে = বড় ঠিকানা)
0x7ffc · main-এর a
0x7fe8
0x7fe0
0x7fdc · w
0x7fd8 · h
  1. Caller argument সাজায়। main দ্বিতীয় argument 7 রাখে esi-তে, প্রথমটি 6 রাখে edi-তে। কোনো stack লাগল না — প্রথম ছয়টি পূর্ণসংখ্যা argument register-এই যায়। main-এর নিজের frame আছে 0x8000 থেকে 0x7ff0 পর্যন্ত।
  2. call area — দুটি কাজ একসাথে। প্রথমে পরের লাইনের (লাইন ৫) ঠিকানাটি stack-এ push করে: rsp 0x7ff0 থেকে কমে 0x7fe8. তারপর rip-কে area-র প্রথম লাইনে বসায়। এই ঠিকানাটিই return address — area-র কাজ শেষে কোথায় ফিরতে হবে।
  3. Prologue, ধাপ ১: push rbp. main-এর rbp (0x8000) stack-এ রাখা হলো, rsp হলো 0x7fe0. পরে ফেরার সময় main যেন তার নিজের ভিত্তি অক্ষত পায়।
  4. Prologue, ধাপ ২: mov rbp, rsp. এখন rbp = 0x7fe0 — area-র frame-এর ভিত্তি। area-র সব local এর সাপেক্ষে খোঁজা হবে। খেয়াল করুন, sub rsp, N নেই! কারণটি পরের ধাপে।
  5. Argument মেমরিতে। w গেল [rbp-4] = 0x7fdc-তে, h গেল [rbp-8] = 0x7fd8-তে — rsp-এর নিচে। area অন্য কোনো ফাংশন ডাকে না, তাই System V-এর নিয়মে সে rsp-এর নিচের ১২৮ বাইট (red zone) rsp না সরিয়েই ব্যবহার করতে পারে।
  6. দেহ: হিসাব। eax = 6, তারপর imul: eax = 6 × 7 = 42. ফলটি eax-এ রইল — calling convention অনুযায়ী এটাই ফেরত দেওয়ার জায়গা।
  7. Epilogue: pop rbp. stack থেকে 0x8000 তুলে rbp-তে, rsp আবার 0x7fe8. main-এর frame-এর ভিত্তি অক্ষত ফিরে এলো। area-র ঘরগুলোতে 6 আর 7 পড়ে আছে, কিন্তু এখন সেগুলো “মুক্ত” — যে কেউ লিখে দিতে পারে।
  8. ret. stack-এর চূড়া থেকে ফেরার ঠিকানা তুলে rip-এ বসাল, rsp হলো 0x7ff0 — call-এর ঠিক আগের মতো। area-র পুরো frame এখন অতীত।
  9. Caller ফল তুলে নেয়। main লাইন ৫-এ eax-এর 42 রাখল নিজের local a-তে ([rbp-4] = 0x7ffc). rsp, rbp সব call-এর আগের মতো — এই “আগের মতো ফিরিয়ে দেওয়া”-ই ফাংশন কলের চুক্তি।

এই পুরো ঘটনা নিজের চোখে দেখার যন্ত্র হলো পর্ব ৩১-এর gdb. সেখানে next দিয়ে C-এর লাইন ধরে এগিয়েছিলেন; এখানে লাগবে instruction ধরে এগোনোর কমান্ড:

$ gcc -g -O0 area.c -o area
$ gdb ./area
(gdb) set disassembly-flavor intel    # Intel ধরনে দেখাও
(gdb) break area
(gdb) run
(gdb) disassemble                     # পুরো ফাংশন; => চিহ্ন দেখায় এখন কোন instruction
(gdb) info registers rdi rsi rsp rbp  # register-এর মান
(gdb) stepi                           # মাত্র এক instruction এগোও (সংক্ষেপে si)
(gdb) x/4gx $rsp                      # stack-এর চূড়া থেকে ৪টি ৮-বাইট ঘর hex-এ
(gdb) backtrace                       # কে কাকে ডেকেছে — frame-এর তালিকা

৬ · Frame-এর পর frame — main → f → g, রিকার্শন আর local-এর ঠিকানা

একটি ফাংশন আরেকটিকে ডাকলে নতুন frame পুরোনোটির নিচে বসে। ফাংশন ফিরলে rsp আবার ওপরে ওঠে, আর ছেড়ে আসা জায়গাটি খালি হয় — কিন্তু পুরোনো বাইট সেখানে পড়েই থাকে, যতক্ষণ না কেউ ওপরে লেখে। নিচের সরলীকৃত ছবিতে main → f → g, তারপর f → h দেখুন:

1void g(void) {2    int y = 2;3}4void h(void) {5    int z = 3;6}7void f(void) {8    int x = 1;9    g();10    h();11}12int main(void) {13    int m = 0;14    f();15    return m;16}
Stack (ওপরে = বড় ঠিকানা, নিচে বাড়ে)
0x8000 · main
0x7ff0 · f
0x7fd0 · g / h
rsp
সক্রিয় frame
  1. শুধু main. main-এর frame 0x8000-এর নিচে, তাতে m = 0. rsp = 0x7ff0, অর্থাৎ main-এর জায়গার শেষ সীমা।
  2. main ডাকল f. call ফেরার ঠিকানা রাখল, f-এর prologue নিজের frame বানাল, x = 1. rsp নেমে এলো 0x7fd0-তে। ঠিকানা কমছে — stack নিচে বাড়ছে।
  3. f ডাকল g — সবচেয়ে গভীর বিন্দু। g-এর frame আরও নিচে, y = 2, rsp = 0x7fb0. এখন ৩টি frame একসাথে মেমরিতে। backtrace চালালে ঠিক এই তিনটিই দেখতেন: g, f, main.
  4. g ফিরল। epilogue আর ret-এর পরে rsp আবার 0x7fd0. g-এর জায়গা এখন মুক্ত — কিন্তু 2 লেখাটি কেউ মোছেনি, শুধু “এটি আর কারো নয়” হয়ে গেছে।
  5. f ডাকল h — একই জায়গা আবার। h-এর frame ঠিক সেই ঠিকানাতেই বসল, যেখানে একটু আগে g ছিল। z = 3 লেখা হলো পুরোনো y-এর ঘরে। কেউ যদি g-এর y-এর ঠিকানা ধরে রাখত, এখন সেখানে পেত 3 — বা পরে যা খুশি।
  6. h ফিরল। জায়গাটি আবার মুক্ত, এবার 3 পড়ে আছে। একই মেমরি একের পর এক ফাংশন ভাগাভাগি করে নিচ্ছে — stack এত দ্রুত আর সস্তা হওয়ার কারণ এটাই।
  7. f ফিরল main-এ। rsp আবার 0x7ff0. ঠিক যে উল্টো ক্রমে frame তৈরি হয়েছিল, সেই উল্টো ক্রমেই মুক্ত হলো — একে বলে stack-এর unwinding.
  8. main ফেরত দিল m. পুরো যাত্রায় কোনো free লাগেনি, কোনো হিসাব রাখতে হয়নি — শুধু rsp ওঠানামা। Heap-এ এই হিসাব কে রাখে, তা দেখবেন পর্ব ৪৭-এ।

রিকার্শনের প্রতিটি ডাক = একটি নতুন frame

পর্ব ১৫-এর fact(5) মনে করুন। assembly-র চোখে প্রতিটি ডাক একটি পূর্ণ call: নতুন ফেরার ঠিকানা, নতুন সংরক্ষিত rbp, নতুন local-এর জায়গা। নিচের প্রোগ্রামটি গুনে দেখায় একসাথে সর্বোচ্চ কতগুলো frame stack-এ ছিল, আর gcc-head -O0-তে সেটি কত বাইট:

#include <stdio.h>

int depth = 0, max_depth = 0;

int fact(int n) {
    depth++;                                /* new frame */
    if (depth > max_depth) max_depth = depth;
    int r = (n <= 1) ? 1 : n * fact(n - 1);
    printf("%*sframe %d: n=%d returns %d\n", (depth - 1) * 2, "", depth, n, r);
    depth--;                                /* frame removed */
    return r;
}

int main(void) {
    int answer = fact(5);
    printf("fact(5) = %d\n", answer);
    printf("max frames on stack = %d\n", max_depth);
    printf("at gcc -O0 this fact frame = 48 bytes -> %d bytes\n", max_depth * 48);
    return 0;
}

আউটপুটে প্রথম লাইন frame 5: n=1 — সবচেয়ে গভীর ডাকটিই সবার আগে শেষ হয়, তারপর এক এক করে ওপরের frame. সর্বোচ্চ ৫টি frame. ৪৮ বাইট সংখ্যাটি আন্দাজ নয়: এই fact-এর -O0 listing-এ prologue হলো push rbp (৮ বাইট), sub rsp, 32 (৩২ বাইট), আর call-এর রাখা ফেরার ঠিকানা (৮ বাইট) — মোট ৪৮। (৮ বাইটের local-এর জন্য ৩২ — rsp-কে ১৬-এর গুণিতকে রাখার নিয়ম, অংশ ৭.) fact(100000) হলে লাগত প্রায় ৪.৮ MB — Linux-এর সাধারণ ৮ MB stack-এর অর্ধেকের বেশি, Windows-এর সাধারণ ১ MB-এর অনেক বেশি। অন্য compiler বা -O2-এ সংখ্যাটি আলাদা; -O2-এ GCC এই সরল fact-কে লুপেই বদলে ফেলে।

Local-এর ঠিকানা ফেরত দেওয়া — compiler নিজেই কী করে

উপরের stepper-এর ধাপ ৫ ব্যাখ্যা করে কেন পর্ব ১৮-এর dangling pointer এত বিপজ্জনক: ফাংশন ফেরার পরে তার local-এর ঘর পরের কলের দখলে চলে যায়। মজার ব্যাপার হলো, আধুনিক GCC এই ভুল দেখলে শুধু সতর্কই করে না, ইচ্ছা করে ঠিকানার বদলে 0 ফেরত দেয়:

prog.c:3:12: warning: function returns address of local variable [-Wreturn-local-addr]
    3 |     return &y;
g:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 2      # y = 2
        mov     eax, 0                    # ঠিকানা নয় — NULL ফেরত!
        pop     rbp
        ret

অর্থাৎ *p পড়তে গেলে প্রোগ্রাম সাধারণত সাথে সাথে crash করবে — “চুপচাপ ভুল মান”-এর চেয়ে অনেক ভালো। কিন্তু pointer-টি একটু ঘুরিয়ে ফেরত দিলে compiler সবসময় ধরতে পারে না, আর তখন ঠিক stepper-এর ধাপ ৫ ঘটে। এটি undefined behavior; এখানে compiler সাহায্যকারী কিছু বেছে নিয়েছে, সবখানে নেবে এমন নিশ্চয়তা নেই।

৭ · Calling convention — কে কোন register-এ কী রাখবে, তার চুক্তি

আপনার main.c compile হলো GCC দিয়ে, আর printf আছে বহু বছর আগে অন্য কারো compile করা C library-তে। তবু দুটো একসাথে কাজ করে। কীভাবে? কারণ দুই পক্ষই একটি লিখিত চুক্তি মানে: argument কোথায় যাবে, ফল কোথায় আসবে, কোন register নষ্ট করা যাবে, stack কেমন থাকবে। এই চুক্তির নাম calling convention, আর এটি বড় চুক্তি ABI-র একটি অংশ।

Metro rail-এর কথা ভাবুন। যে কোম্পানিই ট্রেন বানাক, প্ল্যাটফর্মের উচ্চতা, দরজার জায়গা আর ট্র্যাকের মাপ একই হতে হবে — নইলে ট্রেন স্টেশনে থামবে, কিন্তু কেউ উঠতে পারবে না। Calling convention হলো ফাংশনগুলোর “প্ল্যাটফর্মের মাপ”।

বিষয়System V AMD64 (Linux, macOS, BSD)Windows x64ARM64 (AAPCS64)
পূর্ণসংখ্যা/pointer argumentrdi, rsi, rdx, rcx, r8, r9 (৬টি)rcx, rdx, r8, r9 (৪টি)x0–x7 (৮টি)
দশমিক (double) argumentxmm0–xmm7xmm0–xmm3 (একই ৪টি জায়গা ভাগ করে)v0–v7
ফল ফেরতrax (দশমিক হলে xmm0)rax (দশমিক হলে xmm0)x0 (দশমিক হলে v0)
ফেরার ঠিকানাcall stack-এ রাখেcall stack-এ রাখেbl রাখে register x30-এ (link register)
Callee-saved (ফাংশনকে আগের মান ফিরিয়ে দিতে হবে)rbx, rbp, r12–r15rbx, rbp, rdi, rsi, r12–r15, xmm6–xmm15x19–x28 (আর frame pointer x29)
Stack-এর বিশেষ নিয়ম১২৮ বাইটের red zonecaller ৩২ বাইটের shadow space রাখে; red zone নেইsp সবসময় ১৬-এর গুণিতক

Caller-saved বনাম callee-saved

ধরুন f একটি হিসাবের মাঝপথের মান rcx-এ রেখে g-কে ডাকল। g কি rcx ব্যবহার করতে পারবে? চুক্তি বলে: System V-এ rcx হলো caller-saved — g যা খুশি লিখতে পারে, তাই মানটি বাঁচানোর দায় f-এর (কলের আগে stack-এ রেখে দেওয়া)। কিন্তু rbx হলো callee-saved — g ব্যবহার করতে চাইলে শুরুতে পুরোনো মান push করবে, শেষে pop করে ফিরিয়ে দেবে। rbp-ও এই দলে; এজন্যই প্রতিটি prologue-এ push rbp আর epilogue-এ pop rbp.

১৬ বাইটের নিয়ম, red zone আর printf-এর আগে eax

  • Stack alignment: System V-এ যেকোনো call-এর ঠিক আগে rsp ১৬-এর গুণিতক হতে হবে। call ৮ বাইটের ঠিকানা push করে, তাই ফাংশনে ঢোকার মুহূর্তে rsp থাকে ১৬-এর গুণিতক থেকে ৮ কম। এজন্যই অংশ ৮-এর -O2 main-এ দেখবেন sub rsp, 8 — কোনো local নেই, শুধু হিসাব মেলানোর জন্য। কারণ: SSE-এর কিছু instruction ১৬ বাইটে সাজানো মেমরি চায়, না পেলে crash.
  • Red zone: যে ফাংশন আর কাউকে ডাকে না (leaf function), সে rsp-এর নিচের ১২৮ বাইট rsp না সরিয়েই ব্যবহার করতে পারে — অংশ ৫-এর area ঠিক তাই করেছে। OS-ও কথা দেয়, signal handler চালানোর সময় এই জায়গা নষ্ট করবে না। Linux kernel নিজে অবশ্য -mno-red-zone দিয়ে compile হয়, কারণ সেখানে interrupt যেকোনো মুহূর্তে stack ব্যবহার করতে পারে।
  • Variadic কল: printf-এর মতো ফাংশন ডাকার আগে System V-এ al-এ বলে দিতে হয় কতগুলো দশমিক argument vector register-এ গেছে। এজন্য listing-এ call printf-এর ঠিক আগে mov eax, 0 বা xor eax, eax দেখবেন।

Windows কেন আলাদা, আর ARM64 কেমন

Windows x64-এ প্রথম চারটি argument register-এ যায়, কিন্তু caller তবুও stack-এ ৩২ বাইট (চারটি ৮-বাইট ঘর) ফাঁকা রাখে — shadow space. ডাকা ফাংশন চাইলে register-এর argument সেখানে নামিয়ে রাখতে পারে। ফলে একই C কোড Linux আর Windows-এ ভিন্ন assembly হয়, আর এক OS-এর জন্য লেখা হাতের assembly অন্য OS-এ বিনা পরিবর্তনে চলে না।

ফোন আর Apple Silicon-এর ARM64-এ নিয়ম আবার আলাদা, তবে ধারণা একই। godbolt-এ compiler হিসেবে “ARM64 gcc” বেছে -O2 দিলে area হয় মাত্র দুই লাইন:

area:
        mul     w0, w0, w1     # w0 = w0 * w1 (w = x-register-এর নিচের ৩২ বিট)
        ret                    # x30-এর ঠিকানায় ফেরো

যে ফাংশন অন্যকে ডাকে, ARM64-এ তার prologue সাধারণত stp x29, x30, [sp, -16]! — frame pointer আর link register একসাথে stack-এ। কারণ নিজের bl x30-কে মুছে দেবে। আর Apple-এর ARM64 platform-এ variadic argument সবসময় stack-এ যায়, register-এ নয়।

৮ · -O2 কী করে — একই কোড, সম্পূর্ণ অন্য চেহারা

এবার চালানো যায় এমন একটি প্রোগ্রাম নিই, আর তার main-এর assembly দুই optimization স্তরে পাশাপাশি রাখি:

#include <stdio.h>

int area(int w, int h) {
    return w * h;
}

int sum_to(int n) {
    int s = 0;
    for (int i = 1; i <= n; i++)
        s += i;
    return s;
}

int main(void) {
    int a = area(6, 7);
    int t = sum_to(100);
    printf("area = %d, sum = %d\n", a, t);
    return 0;
}

আউটপুট area = 42, sum = 5050 — -O0 বা -O2, যেভাবেই compile করুন। আপনার দিক থেকে প্রোগ্রামের অর্থ এক। কিন্তু ভেতরের কাজের পরিমাণ একেবারে আলাদা: -O0-তে দুটি ফাংশন কল আর ১০০ বার ঘোরা একটি লুপ; -O2-তে কোনো কল নেই, কোনো লুপ নেই — শুধু দুটি সংখ্যা বসিয়ে printf. নিচের দুটি tab দেখুন।

main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16                   # a আর t-এর জায়গা
        mov     esi, 7
        mov     edi, 6
        call    area                      # সত্যিকারের কল
        mov     DWORD PTR [rbp-4], eax    # a = ফল
        mov     edi, 100
        call    sum_to                    # ভেতরে ১০০ বার ঘোরা লুপ
        mov     DWORD PTR [rbp-8], eax    # t = ফল
        mov     edx, DWORD PTR [rbp-8]    # printf-এর ৩য় argument
        mov     eax, DWORD PTR [rbp-4]
        mov     esi, eax                  # ২য় argument
        mov     edi, OFFSET FLAT:.LC0     # ১ম argument: format string
        mov     eax, 0                    # variadic: vector register ০টি
        call    printf
        mov     eax, 0                    # return 0
        leave
        ret
main:
        sub     rsp, 8                    # শুধু ১৬ বাইটের হিসাব মেলাতে
        mov     edx, 5050                 # sum_to(100) — আগেই হিসাব করা!
        mov     esi, 42                   # area(6, 7)  — আগেই হিসাব করা!
        xor     eax, eax                  # eax = 0 (mov-এর চেয়ে ছোট নির্দেশ)
        mov     edi, OFFSET FLAT:.LC0
        call    printf
        xor     eax, eax                  # return 0
        add     rsp, 8
        ret

এখানে দুটি optimization একসাথে কাজ করেছে। প্রথমে inlining: compiler area আর sum_to-এর দেহ main-এর ভেতরে বসিয়ে দিল। তারপর constant folding: সব মান যেহেতু আগে থেকেই জানা, হিসাবটাই compile-এর সময় সেরে ফেলল। আর rbp দিয়ে frame বানানোর প্রথাও উধাও — -O2-তে GCC rbp-কে সাধারণ register হিসেবে ব্যবহার করে। নিচের stepper-এ পুরো রূপান্তরটি ধাপে ধাপে দেখুন:

  1. C কোডsum_to(100)আপনার লেখা
  2. -O0মেমরিতে সবঅক্ষরে অক্ষরে
  3. -O2 লুপregister-এপ্রতি ঘুরে ২টি
  4. -O2 main5050লুপই নেই
  5. -O3 arraypadddএকসাথে ৪টি
sum_to.cযা লিখেছেন
int sum_to(int n) {
    int s = 0;
    for (int i = 1; i <= n; i++)
        s += i;
    return s;
}
gcc-head -O0লুপের দেহ, সংক্ষেপে
.L12:
        mov  eax, DWORD PTR [rbp-8]    # i মেমরি থেকে
        add  DWORD PTR [rbp-4], eax    # s += i, মেমরিতে
        add  DWORD PTR [rbp-8], 1      # i++, মেমরিতে
.L11:
        mov  eax, DWORD PTR [rbp-8]
        cmp  eax, DWORD PTR [rbp-20]   # i <= n ?
        jle  .L12
gcc-head -O2sum_to একা, লুপের অংশ
.L13:
        lea  edx, [rdx+1+rax*2]      # s += i + (i+1)
        add  eax, 2                    # i += 2
        cmp  eax, ecx
        jne  .L13
.L11:
        mov  eax, edx                  # ফল = s
        ret
gcc-head -O2main-এর ভেতরে sum_to(100)
main:
        sub  rsp, 8
        mov  edx, 5050
        mov  esi, 42
        xor  eax, eax
        mov  edi, OFFSET FLAT:.LC0
        call printf
gcc-head -O3sum(a, n) — array যোগের মূল লুপ
.L6:
        movdqu  xmm2, XMMWORD PTR [rax]   # ১৬ বাইট = ৪টি int একসাথে
        add     rax, 16
        paddd   xmm0, xmm2              # ৪টি যোগ এক নির্দেশে
        cmp     rax, rdx
        jne     .L6
  1. শুরু: একটি সাধারণ লুপ। ১ থেকে n পর্যন্ত যোগ। আমরা জানি উত্তর n(n+1)/2, কিন্তু কোডে লেখা আছে ধাপে ধাপে যোগ।
  2. -O0: সবকিছু মেমরিতে। প্রতি ঘুরে i মেমরি থেকে আনা, s-এ মেমরিতেই যোগ, i মেমরিতেই বাড়ানো। n = 100 হলে ১০০ বার এই সাত লাইন। Debugger-এর জন্য চমৎকার — প্রতিটি ভ্যারিয়েবল সবসময় মেমরিতে খুঁজে পাওয়া যায়।
  3. -O2, ফাংশন একা: register আর unrolling. s থাকে edx-এ, i থাকে eax-এ — মেমরিতে আর যাওয়াই হয় না। আর প্রতি ঘুরে দুটি সংখ্যা যোগ হয়: lea edx, [rdx+1+rax*2] মানে s + 2i + 1 = s + i + (i+1). এটি loop unrolling — ঘোরার সংখ্যা অর্ধেক। (বিজোড় n-এর জন্য শুরুতে আলাদা একটি ধাপ আছে, এখানে দেখানো হয়নি।)
  4. -O2, main-এ: লুপই নেই। main যখন sum_to(100) ডাকে, n আগে থেকেই জানা। Compiler ফাংশনটি inline করে, তারপর পুরো লুপ compile-এর সময়ই হিসাব করে রেখে দেয়: mov edx, 5050. চালানোর সময় একটি যোগও হয় না। আপনার চালাকি করে n(n+1)/2 লেখার দরকারই ছিল না।
  5. -O3: একসাথে ৪টি। একটি array-র যোগফলের লুপ (sum) -O3-তে SIMD নির্দেশ পায়: movdqu ১৬ বাইট বা ৪টি int একবারে আনে, paddd চারটি যোগ এক নির্দেশে করে। একে বলে vectorization; শেষে বাকি ১–৩টি উপাদান আলাদা করে যোগ হয়। কোন লুপ vectorize হলো, তা জানার উপায় পর্ব ৫৫-এ দেখবেন।

Optimizer আর undefined behavior

পর্ব ০৩-এ শুনেছিলেন, UB-ওয়ালা কোড -O0-তে “ঠিক” চলে, -O2-তে অদ্ভুত আচরণ করে। কারণ -O2-তে compiler ধরে নেয় আপনার কোডে UB নেই, আর সেই ভরসায় লুপ বদলায়, শর্ত বাদ দেয়। একটি signed overflow বা array-র সীমার বাইরে পড়া থাকলে সেই অনুমান ভেঙে পড়ে — পর্ব ৪৮-এ বাস্তব উদাহরণ দেখবেন।

একটি সুন্দর উদাহরণ two's complement থেকে: অংশ ৪-এর sign ফাংশন -O2-তে কোনো লাফ ছাড়াই হয়ে যায়:

sign:
        mov     eax, edi
        sar     eax, 31       # চিহ্নের বিট সব জায়গায় ছড়াও: x < 0 হলে -1 (সব ১), নইলে 0
        or      eax, 1        # -1 | 1 = -1,  0 | 1 = 1
        ret

volatile — optimizer-কে থামানোর একমাত্র কথা

পর্ব ৪৫-এ দেখেছিলেন, volatile বলে “এই মান পেছন থেকে বদলাতে পারে।” এখন তার অর্থ assembly-র ভাষায় বলা যায়: optimizer উপরের মতো মান register-এ রেখে দিতে বা হিসাব আগেই সেরে ফেলতে পারবে না — প্রতিটি পড়া ও লেখা সত্যিকারের mov হয়ে মেমরিতে যাবে, আর বাদ পড়বে না। Memory-mapped I/O-তে, যেখানে একটি “মেমরি ঠিকানা” আসলে কোনো যন্ত্রের রেজিস্টার, এটাই দরকার — পর্ব ৫৪-এ হাতেনাতে দেখবেন। কিন্তু মনে রাখুন, volatile কোনো lock বা memory barrier নির্দেশ বসায় না; thread-এর সমস্যা এটি সারায় না।

Inline assembly — খুব কম, খুব সাবধানে

কখনো কখনো C থেকে সরাসরি একটি বিশেষ instruction চালানো দরকার হয় — যেমন CPU-র cycle গোনার rdtsc. GCC আর Clang এর জন্য দেয় inline assembly (GCC extended asm). একটি সরল উদাহরণ, দুটি সংখ্যা যোগ:

long add_asm(long a, long b) {
    long r = a;
    __asm__("addq %1, %0"      /* AT&T: r = r + b              */
            : "+r"(r)          /* output: r পড়াও হবে, লেখাও হবে */
            : "r"(b)           /* input: b যেকোনো register-এ     */
            : "cc");           /* flag বদলায় — compiler-কে জানাও */
    return r;
}

এখানে instruction-এর লেখা AT&T ধরনে, কারণ GCC ডিফল্টভাবে assembly AT&T-তেই লেখে। ফাঁদগুলো বড়: এই কোড MSVC-তে চলবে না (x64 MSVC inline asm সমর্থনই করে না), ARM-এ চলবে না, আর constraint ("+r", "cc") ভুল দিলে compiler আপনার অজান্তে অন্য ভ্যারিয়েবলের register নষ্ট করবে। তাই আগে খুঁজুন compiler-এর তৈরি builtin বা intrinsic আছে কি না — যেমন rdtsc-এর জন্য __rdtsc(). সেগুলো compiler বোঝে, আর ভুলের সুযোগ কম।

৯ · Stack buffer overflow — return address কেন ঝুঁকিতে, আর প্রতিরক্ষা

এখন আপনি জানেন stack-এ কী কী পাশাপাশি থাকে: local array, সংরক্ষিত rbp, আর ফেরার ঠিকানা। array ছোট ঠিকানায়, ফেরার ঠিকানা বড় ঠিকানায়। কিন্তু strcpy বা লুপ array-তে লেখে ছোট থেকে বড় ঠিকানার দিকে। অর্থাৎ array-র সীমা পেরিয়ে লিখতে থাকলে লেখাটি ঠিক return address-এর দিকেই এগোয়। পর্ব ১৯ আর ৪৮-এর buffer overflow এই কারণেই এত গুরুতর: ফেরার ঠিকানা বদলে গেলে ret প্রোগ্রামকে অন্য কোথাও নিয়ে যায়।

এই অংশের উদ্দেশ্য সম্পূর্ণ প্রতিরক্ষামূলক: বোঝা যে কেন bounds check আর hardening flag জরুরি। নিচের ফাংশনটি -fstack-protector-strong দিয়ে compile করা; listing-টি আসল:

# void greet(const char *name) { char buf[16]; strcpy(buf, name); puts(buf); }
greet:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 48
        mov     QWORD PTR [rbp-40], rdi   # name
        mov     rax, QWORD PTR fs:40      # গোপন canary মান আনো
        mov     QWORD PTR [rbp-8], rax    # buf আর ফেরার ঠিকানার মাঝে রাখো
        xor     eax, eax                  # register থেকে মানটি মুছে দাও
        ...                               # strcpy(buf = [rbp-32], name); puts(buf)
        mov     rax, QWORD PTR [rbp-8]    # ফেরার আগে canary আবার পড়ো
        sub     rax, QWORD PTR fs:40      # আসলটির সাথে মেলাও
        je      .L5                       # মিললে স্বাভাবিকভাবে ফেরো
        call    __stack_chk_fail          # না মিললে: প্রোগ্রাম বন্ধ
.L5:
        leave
        ret
1void greet(const char *name) {2    char buf[16];  /* + canary */3    strcpy(buf, name);4    puts(buf);5}   /* canary যাচাই, তারপর ret */
greet-এর frame (ওপরে = বড় ঠিকানা)
[rbp+8] ফেরার ঠিকানা
[rbp] সংরক্ষিত rbp
[rbp-8] canary
[rbp-16] ৮ বাইট ফাঁকা
[rbp-32] buf[16]
  1. Prologue-এ canary বসল। প্রোগ্রাম শুরুর সময় একটি এলোমেলো গোপন মান তৈরি হয়ে fs:40-এ থাকে। greet সেটি কপি করে রাখল buf আর ফেরার ঠিকানার মাঝখানে, [rbp-8]-এ। খেয়াল করুন: buf ছোট ঠিকানায় (rbp-32), ফেরার ঠিকানা বড় ঠিকানায় (rbp+8).
  2. স্বাভাবিক ইনপুট। "Dhaka" মানে ৫টি অক্ষর আর শেষের শূন্য বাইট — মোট ৬, ১৬-এর মধ্যে দিব্যি এঁটে যায়। puts ছাপে Dhaka, ফাংশন স্বাভাবিকভাবে ফেরে। কোনো সমস্যা নেই।
  3. লম্বা ইনপুট — সীমা পার। এবার ধরুন কেউ ৪৮টি A পাঠাল। strcpy জানে না buf কত বড়; সে শূন্য বাইট না পাওয়া পর্যন্ত লিখতেই থাকে। প্রথম ১৬টি buf ভরাল, পরের ৮টি ফাঁকা জায়গা — লেখা এগোচ্ছে বড় ঠিকানার দিকে।
  4. Canary-তে আঘাত। পরের ৮টি A পড়ল canary-র ওপর। ফেরার ঠিকানায় পৌঁছাতে হলে আগে canary পেরোতেই হবে — এটাই নকশার মূল চালাকি। গোপন মানটি না জানলে সেটি অক্ষত রেখে পেরোনো যায় না। (glibc-এ canary-র একটি বাইট ইচ্ছা করে 0 রাখা হয়, যাতে string-এর ফাংশন সেটি হুবহু লিখতেই না পারে।)
  5. Return address নষ্ট। শেষ ১৬টি A সংরক্ষিত rbp আর ফেরার ঠিকানা ঢেকে দিল। Canary না থাকলে ret এখন লাফ দিত 0x4141414141414141-এ (A = 0x41). Canary ছাড়া একই প্রোগ্রাম চালিয়ে আমরা পেয়েছি Segmentation fault. আক্রমণকারীর হাতে এই বাইটগুলো এলোমেলো না হয়ে একটি বেছে নেওয়া ঠিকানা হলে — প্রোগ্রামের নিয়ন্ত্রণ তার হাতে। ঠিক এজন্যই এটি নিরাপত্তার সবচেয়ে পুরোনো সমস্যাগুলোর একটি।
  6. Epilogue-এ ধরা পড়ল। ret-এর আগে greet canary আবার পড়ে আসলটির সাথে মেলায়। মিলছে না, তাই __stack_chk_fail প্রোগ্রাম সাথে সাথে বন্ধ করে দেয়, আর দেখায় *** stack smashing detected ***: terminated. নষ্ট ঠিকানায় লাফ দেওয়ার সুযোগই আসে না। Crash খারাপ, কিন্তু নিয়ন্ত্রণ হারানোর চেয়ে ভালো।

প্রতিরক্ষার স্তর

Stack canary

Canary ফেরার আগে যাচাই করে। -fstack-protector-strong; অনেক Linux distribution-এর GCC-তে এটি ডিফল্ট। Wandbox-এর gcc-head-এ ডিফল্ট নয় — উপরের listing তাই flag দিয়ে তৈরি।

NX / DEP

NX stack-এর মেমরিকে “এখানে কোড চালানো যাবে না” চিহ্ন দেয়। ফলে buffer-এ বাইট ঢুকিয়ে সেগুলো চালানো যায় না।

ASLR

ASLR প্রতিবার stack, heap, library-র ঠিকানা এলোমেলো করে। কোথায় লাফ দিতে হবে, আক্রমণকারী আগে থেকে জানে না।

Shadow stack (CET)

নতুন Intel ও AMD প্রসেসরে CPU নিজেই ফেরার ঠিকানার একটি আলাদা, সুরক্ষিত কপি রাখে — shadow stack. ret-এর সময় দুটি না মিললে CPU থামিয়ে দেয়। GCC-তে -fcf-protection.

এগুলো সবই সিটবেল্ট — বাগ সারায় না, ক্ষতি কমায়। Canary শুধু ফেরার ঠিকানার আশেপাশের লেখা ধরে; একই frame-এর অন্য ভ্যারিয়েবল নষ্ট হলে সে কিছুই জানে না। প্রথম দেয়াল আপনার কোড: strcpy-এর বদলে দৈর্ঘ্য-যাচাই করা ফাংশন, প্রতিটি লেখার আগে সীমা যাচাই, আর পরীক্ষার সময় -fsanitize=address. পুরো তালিকা পর্ব ৪৮-এ।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা কোডিং ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • Stack frame-এ কী কী থাকে? ফেরার ঠিকানা, caller-এর সংরক্ষিত rbp, local ভ্যারিয়েবল, আর দরকার হলে register-এ না আঁটা argument ও সংরক্ষিত register.
  • Linux x86-64-এ প্রথম argument কোথায় যায়, ফল কোথায় আসে? প্রথম ছয়টি পূর্ণসংখ্যা argument rdi, rsi, rdx, rcx, r8, r9-এ; ফল rax-এ।
  • call আর ret কী করে? call পরের instruction-এর ঠিকানা push করে লাফ দেয়; ret সেই ঠিকানা pop করে সেখানে ফেরে।
  • Stack কোন দিকে বাড়ে, আর কেন local-এর ঠিকানা ফেরত দেওয়া ভুল? x86-64-এ নিচের (ছোট ঠিকানার) দিকে; ফাংশন ফিরলে তার frame পরের কলে পুনর্ব্যবহার হয়।
  • Stack canary কী? local buffer আর ফেরার ঠিকানার মাঝে রাখা গোপন মান, যা ফেরার আগে মেলানো হয়; বদলে গেলে প্রোগ্রাম বন্ধ।

অন্য ভাষায় একই প্রশ্ন: C++-এও stack frame আর calling convention হুবহু এই রকম, সাথে this pointer (System V-এ rdi-তে) — C++ কোর্সের lifetime ও dangling পর্ব দেখুন। Java আর Python প্রথমে bytecode বানায়: “Java কীভাবে চলে” আর “Python কীভাবে চলে” পর্বে তুলনা আছে।

১০ · AI-এর যুগে — assembly পড়তে AI, আর তার ফাঁদ

Godbolt-এর আউটপুট AI-কে দিয়ে “লাইন ধরে বুঝিয়ে দাও” বলা assembly শেখার দ্রুত একটি পথ। কিন্তু assembly-তে একটি অক্ষরের ভুলেও অর্থ উল্টে যায়, আর AI-এর ভুল সহজে চোখে পড়ে না।

AI-এর ভুলকেন বিপজ্জনককীভাবে ধরবেন
Linux-এর জন্য হাতের asm-এ Windows-এর register ক্রম (rcx, rdx…) বা উল্টোটাargument ভুল register-এ — compile হয়, ফল আবর্জনাঅংশ ৭-এর টেবিল মিলিয়ে দেখুন কোন OS, কোন ABI
Inline asm-এ "cc" বা "memory" clobber, বা output constraint বাদcompiler জানে না register/মেমরি বদলেছে; -O2-তে অন্য ভ্যারিয়েবল নষ্ট-O0 আর -O2 দুটোতেই চালিয়ে ফল মেলান
Intel আর AT&T-এর operand ক্রম গুলিয়ে ব্যাখ্যাsub বা cmp-এ কে কার থেকে বিয়োগ, উল্টে যায়listing-এ % চিহ্ন আছে কি না দেখুন; থাকলে AT&T
Callee-saved register (rbx, r12) ব্যবহার করে ফিরিয়ে না দেওয়াcaller-এর মান চুপচাপ নষ্ট — বাগটি দেখা দেয় অন্য কোথাওprologue-এ push আর epilogue-এ pop জোড়া মেলান
C-কে হাতে “optimize” করার পরামর্শ — i*2-এর বদলে i<<1, লুপের বদলে সূত্রcompiler আগেই তা করে; কোড শুধু কঠিন হয়, কখনো UB-ও ঢোকেgodbolt-এ দুই সংস্করণের -O2 listing তুলনা করুন

AI-কে assembly বোঝাতে বলার সময় তিনটি জিনিস নির্দিষ্ট করুন — compiler ও flag, platform, আর আপনি কী যাচাই করতে চান। আর তাকে বাধ্য করুন অনিশ্চিত জায়গা আলাদা করে বলতে:

নিচে একটি C ফাংশন আর তার assembly দিলাম। assembly তৈরি হয়েছে x86-64 Linux-এ gcc -O2 -masm=intel দিয়ে (System V ABI). [C কোড] [assembly listing] ১. প্রতিটি instruction পাশে সহজ বাংলায় ব্যাখ্যা দাও, আর বলো কোন C লাইন থেকে এসেছে। ২. কোন register-এ কোন argument এসেছে, ফল কোথায় যাচ্ছে — calling convention মিলিয়ে বলো। ৩. কোন optimization হয়েছে (inlining, constant folding, unrolling, vectorization) নাম ধরে বলো। ৪. যে লাইনের ব্যাপারে তুমি নিশ্চিত নও, সেগুলো আলাদা করে "অনিশ্চিত" লিখে চিহ্নিত করো — অনুমান করে ব্যাখ্যা দিও না।

AI-এর দেওয়া assembly বা ব্যাখ্যা হাতে পেলে — চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১C-কে প্রায়ই “portable assembly” বলা হয়। কথাটি কোথা থেকে এলো, আর আজও কতটা সত্য?

গল্পটা শুরু Bell Labs-এ। ১৯৬৯ সালে Ken Thompson Unix-এর প্রথম সংস্করণ লেখেন একটি PDP-7 কম্পিউটারের assembly-তে। পরে Unix সরানো হয় PDP-11-এ — আবার assembly-তে। সমস্যাটা স্পষ্ট: প্রতিটি নতুন মেশিনে পুরো অপারেটিং সিস্টেম নতুন করে লিখতে হয়। এই সময়ে Dennis Ritchie Thompson-এর B ভাষা থেকে তৈরি করেন C (১৯৭২)। C-তে ছিল এমন জিনিস, যা মেশিনের সাথে প্রায় এক-এক মেলে: বাইট আর word-এর মাপের টাইপ, pointer মানে সরাসরি ঠিকানা, ++ আর --, struct মানে মেমরিতে পাশাপাশি সাজানো ঘর।

১৯৭৩ সালে Unix-এর kernel প্রায় পুরোটা C-তে নতুন করে লেখা হয়। তখন অপারেটিং সিস্টেম মানেই assembly, তাই এটি ছিল যুগান্তকারী। পরে Unix অন্য মেশিনে নিতে হলে মূলত একটি নতুন C compiler আর অল্প কিছু মেশিন-নির্ভর অংশ লিখলেই চলত। “Portable assembly” নামের উৎস এখানেই — assembly-র মতো মেশিনের কাছাকাছি, কিন্তু এক মেশিন থেকে আরেক মেশিনে নেওয়া যায়।

আজ কতটা সত্য? অর্ধেক। এই পর্বের -O0 listing দেখলে কথাটি পুরো সত্য মনে হয় — প্রতিটি C লাইনের একটি সরল অনুবাদ। কিন্তু -O2 listing দেখলে বোঝেন, আধুনিক C আর “লাইন ধরে assembly” নয়। C স্ট্যান্ডার্ড একটি বিমূর্ত মেশিনের কথা বলে; compiler সেই নিয়ম মেনে যা খুশি রূপান্তর করতে পারে — দুটি কল আর একটি লুপকে দুটি ধ্রুবকে নামিয়ে আনা, লুপকে SIMD বানানো। আর undefined behavior-এর নিয়মের কারণে “মেশিনে তো এটাই হবে” ভেবে লেখা কোড (যেমন signed overflow-এ wrap হবে ধরে নেওয়া) আসলে ভুল। এ নিয়ে একটি পরিচিত প্রবন্ধের শিরোনামই হলো “C Is Not a Low-level Language” (David Chisnall, ২০১৮)।

মূল উপলব্ধি: C আপনাকে মেশিনের কাছাকাছি নিয়ে যায়, কিন্তু মেশিনের সাথে সরাসরি কথা বলায় না — মাঝখানে একটি চুক্তি (স্ট্যান্ডার্ড) আর একজন খুব চালাক অনুবাদক (optimizer) আছে। assembly পড়ে আপনি সেই অনুবাদকের সিদ্ধান্ত দেখতে পান; চুক্তি মেনে লেখা কোডই সেই সিদ্ধান্তে নিরাপদ।

প্র ০২একই x86-64 CPU-তে Linux আর Windows-এর calling convention আলাদা কেন? একটি “সঠিক” convention কি থাকতে পারত না?

x86-64 নামের ৬৪-বিট সম্প্রসারণটি Intel নয়, AMD তৈরি করে। নকশা প্রকাশিত হয় ২০০০ সালের দিকে, আর প্রথম প্রসেসর Opteron বাজারে আসে ২০০৩ সালে। পুরোনো ৩২-বিট x86-এ register ছিল মাত্র ৮টি, তাই argument প্রায় সবসময় stack-এ যেত — ধীর। নতুন নকশায় register হলো ১৬টি, আর সেটিকে কাজে লাগাতে নতুন calling convention দরকার হলো।

Linux আর অন্য Unix-জাতীয় সিস্টেমের জন্য নিয়মটি লেখা হয় AMD আর GCC/Linux-এর ডেভেলপারদের যৌথ কাজে — আজকের “System V AMD64 ABI”. প্রায় একই সময়ে Microsoft নিজের Windows-এর জন্য আলাদা নিয়ম ঠিক করে। কারণগুলো ছিল প্রকৌশলগত আর ঐতিহাসিক: Windows-এর debugger, exception ব্যবস্থা (stack unwinding-এর টেবিল) আর বিদ্যমান টুল নিজেদের প্রয়োজনমতো নকশা করা ছিল। চারটি register argument আর shadow space ব্যবস্থায় variadic ফাংশন আর debugging সহজ হয়; System V-এর ছয়টি register আর red zone ছোট ফাংশনে একটু বেশি গতি দেয়।

কোনটি “সঠিক”? কোনোটিই নয় — দুটোই যুক্তিসঙ্গত trade-off. বেশি register argument মানে stack কম, কিন্তু callee-saved register কম থাকলে বড় ফাংশনে বেশি push/pop. Red zone ছোট leaf ফাংশনে কয়েকটি instruction বাঁচায়, কিন্তু kernel-এর মতো জায়গায় ব্যবহারই করা যায় না। আসল সমস্যা “কোনটি ভালো” নয়, বরং “দুটো আছে”। এজন্য cross-platform প্রোগ্রামে হাতের assembly দুবার লিখতে হয়, আর Wine-এর মতো প্রকল্পকে (Linux-এ Windows প্রোগ্রাম চালানো) দুই ABI-র মাঝে অনুবাদ করতে হয়।

মূল উপলব্ধি: ABI হলো সামাজিক চুক্তি, প্রাকৃতিক নিয়ম নয়। একবার লক্ষ লক্ষ প্রোগ্রাম একটি চুক্তিতে compile হয়ে গেলে তা বদলানো প্রায় অসম্ভব — তাই প্রথম দিনের নকশার সিদ্ধান্ত কয়েক দশক টিকে থাকে।

প্র ০৩Stack buffer overflow-এর বিপদ মানুষ কবে থেকে জানে, আর প্রতিরক্ষাগুলো কীভাবে এলো?

১৯৮৮ — Morris worm. নভেম্বর ১৯৮৮-এ Robert Tappan Morris নামের এক ছাত্রের লেখা একটি প্রোগ্রাম তখনকার ইন্টারনেটে নিজে নিজে ছড়িয়ে পড়ে, আর হাজার হাজার কম্পিউটার ধীর বা অচল করে দেয়। ছড়ানোর কয়েকটি পথের একটি ছিল Unix-এর fingerd সার্ভিস, যা ইনপুট পড়ত gets() দিয়ে — যে ফাংশন buffer-এর আকারই জানে না। এই পর্বের stepper-এর ধাপ ৫ ঠিক সেই ঘটনার মূল কৌশল: লম্বা ইনপুট stack-এ ফেরার ঠিকানা পর্যন্ত পৌঁছায়। এই ঘটনার পরে ইন্টারনেটের নিরাপত্তা-ঘটনা সামলাতে CERT নামের সমন্বয় কেন্দ্র তৈরি হয়।

১৯৯৬ — একটি প্রবন্ধ। “Aleph One” ছদ্মনামে Elias Levy, Phrack পত্রিকায় “Smashing the Stack for Fun and Profit” প্রকাশ করেন। এরপর বিষয়টি নিরাপত্তা জগতের সাধারণ জ্ঞানে পরিণত হয়, আর প্রতিরক্ষার গবেষণাও জোর পায়।

প্রতিরক্ষার ঢেউ। ১৯৯৮ সালে StackGuard প্রকল্প stack canary-র ধারণা নিয়ে আসে — এই পর্বে দেখা fs:40-এর মান। পরে GCC-তে এটি -fstack-protector হিসেবে আসে, আর আজ অনেক Linux distribution-এ -strong সংস্করণটি ডিফল্ট। এরপর আসে NX (stack-এ কোড চালানো নিষেধ) আর ASLR (ঠিকানা এলোমেলো করা)। প্রতিটি নতুন দেয়ালের জবাবে গবেষকেরা দেখান প্রোগ্রামের ভেতরে আগে থেকে থাকা কোডের টুকরো জুড়ে অপব্যবহার সম্ভব; তার জবাবে এসেছে control-flow integrity আর হার্ডওয়্যার shadow stack — Intel-এর CET প্রযুক্তি ২০২০ সাল থেকে বাজারে আসা প্রসেসরে।

এই ইতিহাস থেকে একটি প্রকৌশল শিক্ষা আসে: প্রতিটি প্রতিরক্ষা একটি নির্দিষ্ট পথ বন্ধ করে, বাগটি নয়। তাই আধুনিক নিয়ম হলো “গভীরতায় প্রতিরক্ষা” — নিরাপদ ফাংশন, sanitizer দিয়ে পরীক্ষা, hardening flag, OS-এর সুরক্ষা, সব একসাথে। আর দীর্ঘমেয়াদে memory-safe ভাষার দিকে যাওয়ার আলোচনাও এখান থেকেই (পর্ব ৪৮)।

মূল উপলব্ধি: ফেরার ঠিকানা ডেটার পাশে রাখা ১৯৭০-এর দশকের একটি সরল, দক্ষ নকশা-সিদ্ধান্ত। তার নিরাপত্তা-মূল্য দিতে হচ্ছে কয়েক দশক ধরে। Stack frame বোঝা মানে এই দীর্ঘ লড়াইটি বোঝা — আর নিজের কোডে সেই পুরোনো ভুলটি না করা।

প্র ০৪Compiler-এর তৈরি assembly-কে কি অন্ধভাবে বিশ্বাস করা যায়? Ken Thompson-এর “Reflections on Trusting Trust” এখানে কী শেখায়?

১৯৮৩ সালে Turing Award গ্রহণের সময় Ken Thompson একটি ছোট কিন্তু বিখ্যাত ভাষণ দেন, যা ১৯৮৪ সালে “Reflections on Trusting Trust” নামে প্রকাশিত হয়। তিনি দেখান: একটি C compiler-এর ভেতরে এমন কোড রাখা সম্ভব, যা login প্রোগ্রাম compile করার সময় চুপিচুপি একটি গোপন দরজা বসিয়ে দেয়, আর compiler নিজেকে compile করার সময় সেই কৌশলটিও নতুন compiler-এ কপি করে দেয়। তারপর compiler-এর source থেকে সেই কোড মুছে ফেললেও তৈরি হওয়া প্রতিটি নতুন compiler-এ কৌশলটি থেকে যায়। Source পড়ে কিছুই ধরা যায় না।

এই পর্বের সাথে সম্পর্কটা সরাসরি। আপনি C লেখেন, কিন্তু CPU চালায় compiler-এর তৈরি instruction. Thompson-এর যুক্তি বলে, শেষ পর্যন্ত সত্য আছে শুধু তৈরি হওয়া binary-তে। এজন্যই নিরাপত্তা গবেষকেরা disassembly পড়েন — objdump বা আরও বড় টুল দিয়ে। Source আর binary মিলছে কি না যাচাই করার জন্য আজ আছে reproducible build (একই source থেকে সবাই হুবহু একই binary পাবে) আর “diverse double-compiling”-এর মতো কৌশল, যেখানে দুটি স্বাধীন compiler দিয়ে তৈরি ফল মিলিয়ে দেখা হয়।

বিদ্বেষপূর্ণ compiler ছাড়াও সাধারণ বাগ আছে: GCC আর Clang-এও মাঝে মাঝে miscompilation (ভুল কোড তৈরি) ধরা পড়ে, যেমন Csmith প্রকল্প এলোমেলো C প্রোগ্রাম দিয়ে বহু এমন বাগ খুঁজেছে। তবু “compiler-এর বাগ” সন্দেহ করার আগে নিজের কোডে undefined behavior আছে কি না দেখুন। বহু ক্ষেত্রে -O2-এর “অদ্ভুত” আচরণ আসলে কোডের UB, compiler-এর নয়।

মূল উপলব্ধি: বিশ্বাস একটি শিকল — আপনার কোড, compiler, library, build সিস্টেম, এমনকি AI-এর দেওয়া কোড। assembly পড়তে পারা মানে এই শিকলের শেষ কড়াটি নিজের চোখে যাচাই করার ক্ষমতা রাখা।

প্র ০৫আজকের compiler কেন সাধারণত হাতে লেখা assembly-র চেয়ে ভালো কোড বানায়? আর কখন মানুষ এখনো হাতে লেখে?

১৯৭০-৮০-এর দশকে দ্রুত প্রোগ্রাম মানেই ছিল হাতে লেখা assembly. আজ চিত্র উল্টো, কয়েকটি কারণে:

  • CPU অনেক জটিল হয়েছে। আধুনিক প্রসেসর একসাথে কয়েকটি instruction চালায়, ক্রম বদলে চালায়, শাখা অনুমান করে, cache-এর আচরণে গতি বহুগুণ বদলায়। কোন instruction-এর ক্রম কোন প্রসেসরে দ্রুত — এই জ্ঞান compiler-এর ভেতরে মডেল হিসেবে রাখা থাকে, প্রতিটি প্রসেসর পরিবারের জন্য আলাদা।
  • Compiler ক্লান্ত হয় না। হাজার ফাংশনের প্রতিটিতে register বণ্টন, inlining-এর সিদ্ধান্ত, লুপ রূপান্তর — মানুষ দুই-একটি ফাংশনে এটা ভালো করতে পারেন, পুরো প্রোগ্রামে নয়।
  • রক্ষণাবেক্ষণ আর portability. হাতের assembly এক CPU, এক ABI-তে বাঁধা। নতুন প্রসেসর এলে C শুধু আবার compile করলেই চলে; assembly নতুন করে লিখতে হয়।

তবু কোথায় মানুষ জেতে? মূলত SIMD-ভারী, খুব নির্দিষ্ট লুপে — ভিডিও codec, ক্রিপ্টোগ্রাফি, ছবি প্রক্রিয়া। যেমন AV1 ভিডিও decoder dav1d বা FFmpeg-এর বহু অংশ এখনো হাতে লেখা assembly-তে, কারণ সেখানে প্রতিটি শতাংশ গতি লাখো ডিভাইসে ব্যাটারি বাঁচায়, আর auto-vectorizer সব সময় সেরা সাজানোটা খুঁজে পায় না। মাঝামাঝি পথ হলো intrinsics — C থেকে ডাকা ফাংশন, যার প্রতিটি একটি নির্দিষ্ট SIMD instruction (যেমন _mm_add_epi32, যা paddd-এর সমান)। এতে instruction বাছাই আপনার, register বণ্টন compiler-এর। আর ক্রিপ্টোগ্রাফিতে আরেকটি কারণ আছে: সময় যেন গোপন তথ্যের ওপর নির্ভর না করে (constant-time কোড), এমন নিশ্চয়তা optimizer সবসময় দেয় না।

বাস্তব নিয়ম: আগে পরিষ্কার C লিখুন, -O2 দিন, তারপর profiler দিয়ে মাপুন (পর্ব ৫৫)। শুধু যে লুপে সত্যিই সময় যায়, সেখানে godbolt খুলে দেখুন compiler কী করেছে — প্রায়ই সামান্য C পরিবর্তনেই (যেমন restrict, পর্ব ৪৫) vectorization চালু হয়ে যায়।

মূল উপলব্ধি: আজ assembly জানার মূল্য লেখায় নয়, পড়ায় — compiler কী করল তা যাচাই করা, আর কোথায় তাকে একটু সাহায্য দরকার তা বোঝা।

অনুশীলন

এগুলোর বেশিরভাগে কোড লিখতে হবে না — দেখা, গোনা আর ব্যাখ্যা করাই আসল কাজ।

  1. Godbolt-এ তিন রূপ: godbolt.org-এ opt.c পেস্ট করুন, compiler “x86-64 gcc”. option ঘরে প্রথমে -O0, তারপর -O2 দিন। এরপর compiler বদলে “ARM64 gcc” করুন। তিনটিতে area ফাংশন কত লাইন, আর argument কোন register-এ আসছে?

    x86-64 -O0: প্রায় আট লাইন — prologue, দুটি argument মেমরিতে রাখা, আবার আনা, imul, pop rbp, ret. Argument edi আর esi-তে।

    x86-64 -O2: তিন লাইন — mov eax, edi, imul eax, esi, ret. মেমরিতে যাওয়াই নেই, frame-ও নেই।

    ARM64 -O2: দুই লাইন — mul w0, w0, w1, ret. Argument আসে w0 আর w1-এ (অর্থাৎ x0, x1-এর নিচের ৩২ বিট), ফলও যায় w0-তে — তাই আলাদা mov লাগে না। (Compiler-এর version অনুযায়ী লাইনের সংখ্যা সামান্য বদলাতে পারে।)

  2. Frame-এর ছবি আঁকুন: পর্ব ১৫-এর সরল fact (শুধু n নিয়ে, কোনো ছাপা ছাড়া) gcc-head -O0-তে compile করলে prologue হয় push rbp; mov rbp, rsp; sub rsp, 16. main যখন fact(3) ডাকে, আর call-এর ঠিক আগে rsp = 0x8000 — fact(1) চলার সময় rsp কত হবে? প্রতিটি frame-এ কী কী আছে লিখুন।

    প্রতিটি fact frame = ফেরার ঠিকানা (৮) + সংরক্ষিত rbp (৮) + sub rsp, 16 (১৬) = ৩২ বাইট (0x20).

    • fact(3): ফেরার ঠিকানা 0x7ff8-এ, সংরক্ষিত rbp 0x7ff0-এ (rbp = 0x7ff0), n = 3 থাকে [rbp-4] = 0x7fec-এ; rsp = 0x7fe0.
    • fact(2): আরও ৩২ বাইট নিচে, rsp = 0x7fc0.
    • fact(1): rsp = 0x7fa0. এখানে base case, তাই আর কোনো frame নয়।

    মোট ৩টি frame, ৯৬ বাইট। এখন বুঝতে পারছেন, কেন খুব গভীর রিকার্শন stack শেষ করে ফেলে।

  3. Listing থেকে উল্টো দিকে C: নিচের -O2 listing কোন C ফাংশন থেকে এসেছে বলে মনে হয়? test esi, esi / jle .L6 / লুপে add eax, DWORD PTR [rdi], add rdi, 4, cmp rdi, rdx, jne / .L6: xor eax, eax. (ইঙ্গিত: অংশ ৪-এর ফাংশনগুলো দেখুন।)

    এটি sum(const int *a, int n)-এর -O2 রূপ। test esi, esi / jle: n ≤ 0 হলে সরাসরি .L6-এ গিয়ে 0 ফেরত (xor eax, eax হলো eax = 0). লুপে আর i নেই — compiler pointer-টিকেই (rdi) প্রতি ঘুরে ৪ বাইট এগিয়ে নেয়, আর শেষ ঠিকানার (rdx = a + n×4) সাথে তুলনা করে। অর্থাৎ a[i]-এর লুপকে compiler নিজেই *p++-ধরনের pointer লুপে বদলেছে — পর্ব ১৮-এর দুটি রূপ মেশিনে একই।

  4. AI অডিট: যেকোনো AI-কে বলুন, “x86-64-এর জন্য দুটি long যোগ করার একটি assembly ফাংশন লেখো, যা C থেকে ডাকা যাবে।” তারপর অংশ ১০-এর চেকলিস্ট দিয়ে যাচাই করুন — কোন OS-এর ABI ধরে লিখেছে, syntax কোনটি, আর আপনার কম্পিউটারে সেটি ঠিক কাজ করবে কি না।

    সাধারণত যা দেখা যায়:

    • AI প্রায়ই কোনো OS না জানিয়ে ধরে নেয় System V (mov rax, rdi; add rax, rsi; ret). Windows-এ এই ফাংশন argument পাবে rcx আর rdx-এ, ফলে ফল হবে আবর্জনা — অথচ compile আর link ঠিকঠাক হবে।
    • উত্তরে Intel syntax লিখে GNU assembler-এ চালাতে বলে, কিন্তু .intel_syntax noprefix লিখতে ভুলে যায় — তখন assembler error দেয়।
    • বেশি register লাগলে rbx বা r12 ব্যবহার করে push/pop ছাড়াই — callee-saved নিয়ম ভাঙে, বাগটি দেখা দেয় caller-এর ভেতরে।

    ভালো সমাধান প্রায়ই সবচেয়ে সহজটি: assembly নয়, সাধারণ C-তে return a + b; লিখে -O2 দিন — compiler ঠিক ওই দুটি instruction-ই বানাবে, প্রতিটি platform-এর ABI মেনে।

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

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

ক্রমx86-64 Linux-এ -O0-তে একটি ফাংশন কলের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Caller argument রাখে edi, esi-তে
  2. call ফেরার ঠিকানা stack-এ push করে লাফ দেয়
  3. Prologue: push rbp, mov rbp, rsp
  4. দেহের হিসাব শেষে ফল থাকে eax-এ
  5. Epilogue: pop rbp বা leave
  6. ret ফেরার ঠিকানা pop করে caller-এ ফেরে

x86-64 Linux-এ (System V ABI) int f(int a, int b) ডাকলে a কোন register-এ যায়?

  • eax
  • edi
  • ecx
  • stack-এ, [rsp+8]-এ

x86-64-এর call instruction ঠিক কী করে?

  • শুধু ফাংশনের ঠিকানায় লাফ দেয়
  • rbp stack-এ রাখে আর নতুন frame বানায়
  • পরের instruction-এর ঠিকানা stack-এ push করে, তারপর ফাংশনে লাফ দেয়
  • Argument-গুলো register থেকে stack-এ কপি করে

gcc -O0 listing-এ mov DWORD PTR [rbp-8], 0 সাধারণত কী বোঝায়?

  • একটি ৪-বাইটের local ভ্যারিয়েবলে (যেমন int i) 0 বসানো
  • একটি global ভ্যারিয়েবল শূন্য করা
  • ফেরার ঠিকানা মুছে ফেলা
  • rbp register-এ 0 বসানো

Windows x64 calling convention সম্পর্কে কোনটি ঠিক?

  • প্রথম ছয়টি argument যায় rdi, rsi, rdx, rcx, r8, r9-এ
  • Leaf ফাংশন ১২৮ বাইটের red zone ব্যবহার করতে পারে
  • ফল ফেরত আসে rcx-এ
  • প্রথম চারটি argument যায় rcx, rdx, r8, r9-এ, আর caller ৩২ বাইটের shadow space রাখে

-fstack-protector-strong দিয়ে compile করা ফাংশনে canary মান কখন যাচাই হয়?

  • array-তে প্রতিটি লেখার পরপরই
  • ফাংশন থেকে ফেরার ঠিক আগে, epilogue-এ
  • Compile-এর সময়, compiler নিজেই
  • শুধু প্রোগ্রাম শুরুর সময় একবার

area(6, 7) আর ১ থেকে ১০০-এর যোগফলের লুপ ডাকা একটি main, gcc -O2-তে mov esi, 42 আর mov edx, 5050 হয়ে যায়, কোনো কল বা লুপ থাকে না। এখানে প্রধানত কোন দুটি optimization কাজ করেছে?

  • Vectorization আর stack canary
  • শুধু loop unrolling আর dead code removal
  • Inlining আর constant folding
  • Red zone আর tail call

শব্দকোষ

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

মনে রাখুন

  1. CPU বোঝে শুধু সরল instruction — mov, add, lea, cmp + jump, call/ret, push/pop; প্রতিটি if আর লুপ আসলে তুলনা আর লাফ।
  2. Linux x86-64-এ argument যায় rdi, rsi, rdx, rcx, r8, r9-এ, ফল আসে rax-এ; Windows-এ rcx, rdx, r8, r9 + shadow space; ARM64-এ x0–x7.
  3. call ফেরার ঠিকানা stack-এ রাখে; prologue (push rbp; mov rbp, rsp; sub rsp, N) frame বানায়; local থাকে [rbp-N]-এ; epilogue (leave; ret) সব ফিরিয়ে দেয়।
  4. Stack নিচে বাড়ে, ফেরার পরে frame পুনর্ব্যবহার হয় — তাই local-এর ঠিকানা ফেরত দেওয়া ভুল, আর গভীর রিকার্শন stack শেষ করে।
  5. -O2 কোডের রূপ বদলায় (inlining, constant folding, unrolling, SIMD); buffer overflow ফেরার ঠিকানা পর্যন্ত পৌঁছাতে পারে — canary, NX, ASLR, shadow stack ক্ষতি কমায়, কিন্তু প্রথম দেয়াল সীমা-যাচাই করা কোড।

পরের পর্বে: আজ দেখলেন stack কত সহজে নিজের হিসাব রাখে — শুধু rsp ওঠানামা। Heap-এর জীবন এত সহজ নয়। পর্ব ৪৭-এ নিজের হাতে একটি ছোট malloc আর free বানিয়ে দেখবেন, memory allocator ভেতরে কী হিসাব রাখে।

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

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