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

C থেকে assembly — compiler, x86-64 আর ARM-এর তুলনা

From C to Assembly — The Compilation Pipeline, Reading Compiler Output, -O0 vs -O2 on RISC-V, x86-64 and ARM64, Recognisable Optimisations, Code Size, UB and volatile

Taught in Bangla, this lesson follows C through preprocess, compile, assemble, link and load, and shows three ways to see compiler output: gcc -S, objdump -d and Compiler Explorer. It gives reading guides for x86-64 and ARM64, compares five small functions on RISC-V, x86-64 and ARM64 at -O0 and -O2, spots nine common optimisations, builds a tiny compiler, compares code size, and covers undefined behaviour, volatile and inline assembly.

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

  • একটি C লাইন কীভাবে assembly, object file, linked program হয়ে CPU-তে চলে — প্রতিটি ধাপে কী তৈরি হয়
  • gcc/clang, objdump আর Compiler Explorer দিয়ে compiler-এর আউটপুট বের করা ও পড়া — x86-64 আর ARM64-এর পড়ার গাইডসহ
  • পাঁচটি ছোট C ফাংশন তিন ISA-তে -O0 আর -O2-এ — কী বদলায়, কেন বদলায়
  • Constant folding থেকে cmov পর্যন্ত নয়টি optimization listing দেখে চেনা
  • Code size-এর তুলনা, undefined behaviour-এর সাথে optimizer-এর সম্পর্ক, volatile আর inline assembly-র জায়গা

১ · Compiler: C আর ISA-র মাঝখানের সেতু

পর্ব ২৫ থেকে ৩০ পর্যন্ত আপনি দেখেছেন ISA কী, RISC-V-এর instruction কেমন, সেগুলো কীভাবে ৩২ বিটে encode হয়, শাখা-লুপ আর function call assembly-তে কেমন দেখায়। কিন্তু বাস্তবে খুব কম মানুষ সরাসরি assembly লেখেন। লেখেন C, Rust, Go বা অন্য কোনো ভাষায় — আর সেই লেখাকে ISA-র ভাষায় অনুবাদ করে compiler. এই পর্বে মডিউল ৫ শেষ করছি সেই অনুবাদটি খুলে দেখে: আসল compiler আসলে কী লেখে, আর কেন।

একটি রেস্তোরাঁর কথা ভাবুন। অতিথি মেনু দেখে বলেন “এক প্লেট কাচ্চি” — এটি C কোড, কী চাই তার বর্ণনা। রান্নাঘরের প্রধান বাবুর্চি সেটিকে ভেঙে দেন রান্নাঘরের নির্দিষ্ট কাজে: কোন চুলা, কোন হাঁড়ি, কত মিনিট — এটি assembly. একই অর্ডার দুটি আলাদা রান্নাঘরে (একটিতে গ্যাসের চুলা, আরেকটিতে মাটির চুলা) আলাদা ধাপে রান্না হয়, কিন্তু প্লেটে আসে একই কাচ্চি। ঠিক তেমনি একই C কোড RISC-V, x86-64 আর ARM64-এ আলাদা instruction পায়, ফল একই থাকে। আর অভিজ্ঞ বাবুর্চি (-O2) নতুন বাবুর্চির (-O0) চেয়ে কম ধাপে, কম হাঁটাহাঁটিতে একই রান্না সারেন।

C course-এর টুলচেইন পর্বে চার ধাপের কারখানা দেখেছেন। এখানে আমরা প্রতিটি ধাপ দেখব আর্কিটেকচারের চোখে — কোন ধাপের আউটপুট কোন ISA-র ওপর নির্ভর করে:

ধাপযে টুলযা তৈরি হয়ISA কোথায়?
Preprocesspreprocessor (cpp, gcc -E).i — #include আর macro বসানো Cপ্রায় নেই; শুধু __x86_64__, __riscv-এর মতো macro ঠিক হয়
Compilecompiler-এর মূল অংশ (cc1, clang).s — assembly লেখাএখানেই প্রথম: register, instruction, calling convention বাছাই
Assembleassembler (as).o — object file: machine code + symbol table + relocation-এর তালিকাপর্ব ২৭-এর encoding: প্রতিটি instruction বিটে
Linklinker (ld)executable (ELF, PE বা Mach-O)relocation পূরণ: ISA-নির্দিষ্ট নিয়মে ঠিকানা বসানো
LoadOS-এর loader (Linux-এ execve + dynamic linker)মেমরিতে প্রসেস, PC = entry pointCPU সরাসরি instruction fetch শুরু করে

নিচের stepper-এ একটিমাত্র C লাইন — int r = add(40, 2); — পুরো পথ পাড়ি দিচ্ছে। add আরেকটি ফাইলে লেখা, তাই linker-কে দরকার হবে। Listing-গুলো clang (LLVM trunk) -O2 --target=riscv32 -march=rv32im দিয়ে সত্যিই তৈরি করে যাচাই করা; শুধু link-এর পরের ঠিকানাগুলো (ধাপ ৫) হাতে হিসাব করা।

  1. Cmain.cঅর্ডার
  2. Preprocessmain.iউপকরণ সাজানো
  3. Compilemain.sISA এখানে
  4. Assemblemain.oবিট + ফাঁকা
  5. Linkprogঠিকানা বসানো
  6. Load + চালানোa0 = 84CPU
main.cadd() আছে অন্য ফাইল add.c-তে
int add(int a, int b);      /* শুধু ঘোষণা */

int main(void) {
    int r = add(40, 2);
    return r * 2;
}
main.igcc -E: #include নেই, তাই প্রায় একই
# 1 "main.c"
int add(int a, int b);

int main(void) {
    int r = add(40, 2);
    return r * 2;
}
main.sclang -O2 -S, RV32IM (directive বাদ)
main:
    addi  sp, sp, -16
    sw    ra, 12(sp)        # ফেরার ঠিকানা বাঁচানো
    li    a0, 40           # ১ম argument
    li    a1, 2            # ২য় argument
    call  add
    slli  a0, a0, 1         # r * 2 → বাঁয়ে ১ shift
    lw    ra, 12(sp)
    addi  sp, sp, 16
    ret
main.ollvm-objdump -dr (সংক্ষেপে)
 8: 02800513   li    a0, 40
 c: 00200593   li    a1, 2
10: 00000097   auipc ra, 0
               R_RISCV_CALL_PLT  add
14: 000080e7   jalr  ra, 0(ra)
18: 00151513   slli  a0, a0, 1
add কোথায় জানা নেই → offset 0, সাথে একটি relocation-নোট
progধরা যাক main @ 0x10000, add @ 0x10028
10010: 00000097   auipc ra, 0          # ra = 0x10010
10014: 018080e7   jalr  ra, 24(ra)     # 0x10010 + 24 = 0x10028
10018: 00151513   slli  a0, a0, 1
  …
10028: 00a58533   add   a0, a1, a0     # add() — add.o থেকে
1002c: 00008067   ret
CPUloader PC বসাল, তারপর fetch-decode-execute
li  a0, 40      a0 = 40
li  a1, 2       a1 = 2
jalr            PC = 0x10028, ra = 0x10018
add             a0 = 2 + 40 = 42
ret             PC = ra = 0x10018
slli            a0 = 42 << 1 = 84   # main ফেরত দেয় 84
  1. C — কী চাই। main জানে শুধু add-এর ঘোষণা (prototype): দুটি int নেয়, একটি int দেয়। কোথায় আছে, কত বাইট — কিছুই জানে না। এখানে কোনো ISA নেই; এই ফাইল যেকোনো CPU-র জন্য compile করা যায়।
  2. Preprocess — উপকরণ সাজানো। এই ফাইলে #include বা macro নেই, তাই main.i প্রায় অপরিবর্তিত; শুধু comment মুছে যায় আর # 1 "main.c" ধরনের লাইন-চিহ্ন বসে, যাতে পরে error-এ সঠিক লাইন নম্বর দেখানো যায়।
  3. Compile — ISA প্রথম দেখা দিল। এখন সিদ্ধান্তগুলো RISC-V-নির্দিষ্ট: argument যায় a0, a1-এ (পর্ব ৩০-এর calling convention), ফল আসে a0-তে, ra বাঁচাতে stack-এ ১৬ বাইট। আর r * 2 গুণ নয়, slli a0, a0, 1 — এটাই strength reduction. call আসলে একটি pseudo-instruction, assembler একে দুটি আসল instruction-এ ভাঙবে।
  4. Assemble — বিট, কিন্তু একটি ফাঁকা। প্রতিটি লাইন ৩২-বিট শব্দে encode হলো, যেমন li a0, 40 = addi a0, zero, 40 = 0x02800513. কিন্তু add কোথায় তা assembler জানে না, তাই auipc ra, 0 আর jalr ra, 0(ra)-এ offset 0 বসিয়ে একটি relocation-নোট রাখল: “এখানে add-এর PC-relative দূরত্ব বসাতে হবে”। x86-64-এ ঠিক একই জিনিস দেখতে e8 00 00 00 00 আর R_X86_64_PLT32 add-0x4.
  5. Link — ঠিকানা বসানো। ধরা যাক linker main-কে 0x10000-এ আর add-কে ঠিক পরে 0x10028-এ বসাল (main-এর আকার 0x28 বাইট)। দূরত্ব = 0x10028 − 0x10010 = 0x18 = 24. auipc-এর ওপরের ২০ বিট 0-ই থাকে, jalr-এর ১২-বিট immediate-এ 24 বসে: 0x000080e7 থেকে 0x018080e7. (বাস্তব RISC-V linker দূরত্ব ছোট দেখলে linker relaxation করে জোড়াটিকে একটিমাত্র jal-এ ছোট করতে পারে।)
  6. Load আর চালানো। OS-এর loader (বা microcontroller-এ bootloader) ফাইলটি মেমরিতে এনে PC-তে entry point বসায়। এরপর আর কোনো সফটওয়্যার অনুবাদক নেই — CPU পর্ব ১-এর fetch-decode-execute চক্রে চালায়: a0 = 42, ফেরার পর shift করে 84. Linking আর loading-এর OS-দিক (dynamic linking, PLT/GOT, ASLR) পাবেন OS course-এর মেমরির ভিত্তি পর্বে আর প্রসেস খোলার দিক fork-exec পর্বে।
মূল ধারণা

Preprocess পর্যন্ত কোড প্রায় ISA-মুক্ত; compile ধাপেই ISA ঢোকে — register, instruction আর ABI বাছাই। Assembler সেটিকে বিটে রূপ দেয়, linker ISA-র নিয়মে ঠিকানার ফাঁকা পূরণ করে। তাই একই main.c থেকে তিন ISA-র জন্য তিনটি আলাদা .s, .o আর executable হয় — একটির executable আরেকটিতে চলে না।

২ · Compiler-এর লেখা দেখার তিন উপায়

Compiler কী লিখল, তা দেখার জন্য তিনটি টুল জানলেই যথেষ্ট। প্রতিটি আলাদা প্রশ্নের উত্তর দেয়।

যা জানতে চানকমান্ডমন্তব্য
Compiler কী assembly লিখলgcc -O2 -S f.c → f.sডিফল্ট AT&T লেখা; -masm=intel দিলে Intel লেখা; -fverbose-asm দিলে C ভ্যারিয়েবলের নাম comment-এ
অন্য ISA-র জন্যriscv64-linux-gnu-gcc -S, aarch64-linux-gnu-gcc -S, অথবা clang --target=riscv32 -march=rv32im -SClang একটি binary দিয়েই বহু ISA-র জন্য assembly লিখতে পারে (cross-compile)
Object/executable-এর ভেতরে আসল বাইটobjdump -d prog (-M intel, -r দিলে relocation)বাঁয়ে ঠিকানা, মাঝে hex বাইট, ডানে instruction — উপরের stepper-এর ধাপ ৪-এ দেখেছেন
C আর তিন ISA পাশাপাশি, লাইনে লাইনে রং মিলিয়েgodbolt.org (Compiler Explorer)শত শত compiler version, x86-64/ARM64/RISC-V সব; ব্রাউজারেই চলে

Compiler Explorer এই পর্বের সবচেয়ে কাজের টুল। Matt Godbolt ২০১২ সালের দিকে এটি বানাতে শুরু করেন, এখন পৃথিবীজুড়ে compiler-প্রকৌশলীরাও এটি দিয়ে bug রিপোর্ট করেন। ব্যবহার: বাঁয়ে C কোড, ডানে একটি compiler বেছে নিন (যেমন “x86-64 gcc”, “ARM64 gcc”, “RISC-V (32-bits) gcc”), option-এ -O2 লিখুন। আরও দুটি panel খুলে তিন ISA পাশাপাশি রাখা যায়; একই রঙের লাইন মানে একই C লাইন থেকে এসেছে। এই পাতার runnable কোড Compiler Explorer-এ চলে না — সেটি আলাদা ওয়েবসাইট — কিন্তু এই পর্বের প্রতিটি listing সেখানে বসিয়ে নিজে মিলিয়ে নিতে পারেন।

একটি সতর্কতা পুরো পর্বের জন্য: compiler-এর আউটপুট version, flag আর OS-ভেদে বদলায়। এই পর্বের প্রতিটি listing “উদাহরণ” — Wandbox-এ ২০২৬ সালের অক্টোবরে থাকা gcc 17 (experimental) আর clang (LLVM trunk) দিয়ে তৈরি করে directive আর comment বাদ দিয়ে সংক্ষেপ করা। আপনার compiler-এ register-এর নাম, ক্রম বা দু-একটি instruction আলাদা হলে অবাক হবেন না; দেখার বিষয় ধরনটা।

এবার উল্টো দিক থেকে দেখা যাক: compile হয়ে যাওয়া একটি ফাংশনের বাইটগুলোই প্রোগ্রামের ভেতর থেকে পড়ি। নিচের প্রোগ্রামে একই add দুবার লেখা — একটি পুরো ফাইলের মতো -O0-এ, আরেকটি GCC-র optimize("O2") attribute দিয়ে -O2-এ। Function pointer-কে unsigned char *-এ cast করে বাইট ছাপা হচ্ছে:

#include <stdio.h>

/* Same C body, two optimisation levels in one program (GCC function attribute) */
__attribute__((noinline)) int add_O0(int a, int b) { return a + b; }
__attribute__((noinline, optimize("O2"))) int add_O2(int a, int b) { return a + b; }

/* Print the machine-code bytes of a function, stopping at the first 0xc3 (x86 ret).
   Reading code through a data pointer is implementation-defined: it works with
   GCC on this x86-64 Linux setup, but is not portable C. */
static void dump(const char *name, int (*f)(int, int)) {
    const unsigned char *p = (const unsigned char *)f;
    printf("%s:", name);
    for (int i = 0; i < 32; i++) {
        printf(" %02x", p[i]);
        if (p[i] == 0xc3) { printf("   <- %d bytes", i + 1); break; }
    }
    printf("\n");
}

int main(void) {
    printf("add_O0(40, 2) = %d, add_O2(40, 2) = %d\n", add_O0(40, 2), add_O2(40, 2));
    dump("add_O0", add_O0);
    dump("add_O2", add_O2);
    return 0;
}

যাচাইয়ের সময় আসল আউটপুট ছিল:

add_O0(40, 2) = 42, add_O2(40, 2) = 42
add_O0: 55 48 89 e5 89 7d fc 89 75 f8 8b 55 fc 8b 45 f8 01 d0 5d c3   <- 20 bytes
add_O2: 8d 04 37 c3   <- 4 bytes

দুটির ফল একই (42), কিন্তু একটি ২০ বাইট, আরেকটি ৪ বাইট। -O2-এর বাইট পড়া যাক: 8d 04 37 = lea eax, [rdi+rsi] (8d হলো LEA-এর opcode, 04 আর 37 বলে “ফল eax-এ, ঠিকানা = rdi + rsi×1”), আর c3 = ret. অর্থাৎ যোগটি করা হলো ঠিকানা-হিসাবের circuit দিয়ে, কোনো মেমরি না ছুঁয়ে। -O0-এর বাইট: 55 push rbp · 48 89 e5 mov rbp, rsp · 89 7d fc আর 89 75 f8 — দুই argument stack-এ লেখা · 8b 55 fc আর 8b 45 f8 — আবার stack থেকে পড়া · 01 d0 add eax, edx · 5d pop rbp · c3 ret. লক্ষ করুন x86-এর instruction ১, ২ বা ৩ বাইটের — দৈর্ঘ্য একরকম নয়। Function pointer থেকে data pointer-এ cast করা ISO C-তে নির্দিষ্ট নয়, এখানে শুধু এই x86-64 Linux সেটআপে চলে; আর runner-এর compiler বদলালে বাইটও বদলাতে পারে।

৩ · x86-64 আর ARM64 পড়ার গাইড

RISC-V আপনি পর্ব ২৬–৩০-এ শিখেছেন। এবার বাকি দুটি ISA পড়ার মতো যতটুকু দরকার, শুধু ততটুকু। x86-64-এর register, flag আর stack frame-এর বিস্তারিত পরিচয় আছে C course-এর C আর assembly পর্বে; এখানে তুলনার দিকটাই মূল।

x86-64: একই register-এর চার নাম

x86 ১৬-বিট 8086 (১৯৭৮) থেকে ৩২-বিট হয়ে ৬৪-বিটে বড় হয়েছে, আর প্রতিটি ধাপে পুরোনো নামগুলো রয়ে গেছে। তাই একটি register-এর বিভিন্ন অংশের আলাদা নাম:

৬৪ বিটনিচের ৩২নিচের ১৬নিচের ৮System V ABI-তে কাজ
raxeaxaxalreturn value
rdi, rsi, rdx, rcxedi, esi, edx, ecxdi…dil…১ম–৪র্থ argument (৫ম–৬ষ্ঠ: r8, r9)
r8–r15r8d–r15dr8w…r8b…সাধারণ কাজ
rsp, rbpesp, ebp——stack pointer, frame pointer

একটি গুরুত্বপূর্ণ নিয়ম: ৩২-বিট নামে লিখলে (যেমন mov eax, 5) CPU ওপরের ৩২ বিট শূন্য করে দেয় (zero extension), কিন্তু ১৬ বা ৮ বিটে লিখলে ওপরের বাকি বিট অপরিবর্তিত থাকে। এজন্যই C-র int নিয়ে কাজ করা কোডে আপনি বেশিরভাগ সময় eax, edi দেখবেন, আর pointer নিয়ে rdi, rax.

AT&T বনাম Intel লেখা

একই x86 instruction দুই ভাবে লেখা হয়। AT&T syntax GNU টুল (gcc, objdump)-এর ডিফল্ট; Intel syntax Intel-এর manual, Windows-এর টুল আর এই পর্বের listing-এ। মূল পার্থক্য operand-এর ক্রম:

বিষয়Intel (-masm=intel)AT&T (ডিফল্ট)
ক্রমmov eax, edi — গন্তব্য আগেmovl %edi, %eax — উৎস আগে
Register ও ধ্রুবকeax, 5%eax, $5
আকারDWORD PTR [rdi+4]নামের শেষে অক্ষর: b ৮, w ১৬, l ৩২, q ৬৪ বিট
মেমরি[rdi+rsi*4+8]8(%rdi,%rsi,4)

সবচেয়ে বেশি দেখা x86-64 instruction: mov (কপি; মেমরি থেকে পড়া বা লেখাও এটিই — x86-এ আলাদা load/store নেই), lea (ঠিকানা হিসাব, মেমরি না ছুঁয়ে — গুণ-যোগের কৌশল), add/sub/imul (প্রায়ই একটি operand সরাসরি মেমরি থেকে — CISC-এর চিহ্ন), cmp/test (শুধু flag বসায়), jcc (je, jne, jl, jg, ja… flag দেখে লাফ), call/ret (ফেরার ঠিকানা stack-এ রাখে আর তোলে — RISC-V-এর মতো ra register নয়), push/pop. বিরল কিন্তু চিনে রাখার মতো: xor eax, eax = “eax = 0” (ছোট instruction), cdqe/movsx = sign extension, cmovcc = শর্তসাপেক্ষ কপি।

ARM64 (AArch64): RISC-V-এর কাছাকাছি, কিন্তু flag আছে

ARM64-এ ৩১টি সাধারণ register: ৬৪-বিট নামে x0–x30, তাদের নিচের ৩২ বিট w0–w30. Argument x0–x7-এ, ফল x0-তে, ফেরার ঠিকানা x30 (link register)-এ, x29 frame pointer. xzr/wzr সবসময় শূন্য — RISC-V-এর x0-এর মতো। সব instruction ৪ বাইট, মেমরি ছোঁয় শুধু load/store — RISC-এর মূল নিয়ম। কিন্তু RISC-V-এর সাথে পার্থক্য: ARM64-এ status flag (NZCV) আছে।

কাজRISC-VARM64x86-64 (Intel)
যোগadd a0, a1, a0add w0, w1, w0add eax, edx (২ operand)
মেমরি থেকে পড়াlw a0, 4(a1)ldr w0, [x1, #4]mov eax, DWORD PTR [rsi+4]
মেমরিতে লেখাsw a0, 4(a1)str w0, [x1, #4]mov DWORD PTR [rsi+4], eax
তুলনা করে লাফblt a0, a1, L (এক instruction)cmp w0, w1 → b.lt Lcmp eax, edx → jl L
Function call / ফেরাcall f (auipc + jalr, বা jal ra, f) / retbl f / retcall f / ret (stack-এ)
শর্তসাপেক্ষ বাছাইমূল ISA-তে নেই (সাধারণত শাখা দিয়ে করা হয়)csel w0, w0, w1, gtcmovge eax, esi

ARM64-এর কয়েকটি বৈশিষ্ট্য listing-এ প্রায়ই দেখবেন: ldp/stp একসাথে দুটি register পড়ে বা লেখে; [x8], #4 মানে “x8 থেকে পড়া, তারপর x8-এ ৪ যোগ” (post-index addressing — পর্ব ২৮-এর addressing mode-এর ARM রূপ); add w0, w0, w10, lsr #31-এর মতো একটি operand-কে আগে shift করে নেওয়া; আর madd = গুণ করে যোগ, এক instruction-এ।

৪ · পাঁচটি ফাংশন, তিন ISA, দুই optimization স্তর

এবার মূল পরীক্ষা। পাঁচটি ছোট C ফাংশন — যোগ, array-র যোগফল, switch, recursion আর struct — compile করা হয়েছে তিন ISA-তে, optimization স্তর -O0 আর -O2-এ। x86-64-এর listing gcc 17 (-masm=intel), RISC-V (rv32im) আর ARM64-এর listing clang (LLVM trunk) — সবই Wandbox-এ তৈরি, directive বাদ দিয়ে।

int add(int a, int b) { return a + b; }

int sum(const int *a, int n) {
    int s = 0;
    for (int i = 0; i < n; i++)
        s += a[i];
    return s;
}

int fee(int k) {                     /* k = 0..4 → নির্দিষ্ট ফি, নইলে 0 */
    switch (k) {
    case 0: return 10;  case 1: return 25;  case 2: return 40;
    case 3: return 70;  case 4: return 95;  default: return 0;
    }
}

int fact(int n) { if (n <= 1) return 1; return n * fact(n - 1); }

struct item { char tag; int qty; int price; };
int total(const struct item *p) { return p->qty * p->price; }

(ক) add — -O0-এ stack-এর ঘুরপথ

# RISC-V rv32im, clang -O0 (13টি)      # ARM64, clang -O0 (8টি)
add:                                    add:
    addi  sp, sp, -16                       sub  sp, sp, #16
    sw    ra, 12(sp)                        str  w0, [sp, #12]
    sw    s0, 8(sp)                         str  w1, [sp, #8]
    addi  s0, sp, 16                        ldr  w8, [sp, #12]
    sw    a0, 4(sp)      # a → stack        ldr  w9, [sp, #8]
    sw    a1, 0(sp)      # b → stack        add  w0, w8, w9
    lw    a0, 4(sp)      # আবার পড়া        add  sp, sp, #16
    lw    a1, 0(sp)                         ret
    add   a0, a0, a1
    lw    ra, 12(sp)                    # x86-64, gcc -O0 (9টি)
    lw    s0, 8(sp)                     add:
    addi  sp, sp, 16                        push rbp
    ret                                     mov  rbp, rsp
                                            mov  DWORD PTR [rbp-4], edi
                                            mov  DWORD PTR [rbp-8], esi
                                            mov  edx, DWORD PTR [rbp-4]
                                            mov  eax, DWORD PTR [rbp-8]
                                            add  eax, edx
                                            pop  rbp
                                            ret
# RISC-V rv32im, clang -O2
add:
    add   a0, a1, a0
    ret

# ARM64, clang -O2
add:
    add   w0, w1, w0
    ret

# x86-64, gcc -O2
add:
    lea   eax, [rdi+rsi]      # x86-এর add দুই operand-এর; lea দুটি রেখে তৃতীয়টিতে লেখে
    ret

-O0-এ তিন ISA-ই একই কাজ করছে: argument register থেকে stack-এ লেখা, তারপর সঙ্গে সঙ্গে আবার পড়া। এটা বোকামি মনে হলেও উদ্দেশ্যমূলক — debugger-এ print a লিখলে মানটি stack-এর একটি নির্দিষ্ট ঘরে খুঁজে পাওয়া যায়, আর প্রতিটি C লাইনের জন্য আলাদা instruction থাকে বলে breakpoint সঠিক লাইনে থামে। -O2-এ তিনটিই দুই instruction. x86-এর add দুই operand-এর (add eax, edx মানে eax = eax + edx), তাই ফল তৃতীয় register-এ চাইলে compiler lea ব্যবহার করে — RISC-V আর ARM64-এর তিন-operand add-এর কাজটি।

(খ) sum — লুপ থেকে কী কী হারায়

নিচের stepper-এ একই লুপ RISC-V-তে -O0 থেকে -O2. Instruction-গুলো হুবহু clang-এর আউটপুট (comment আমাদের)।

  1. -O0 শুরুstack-এ ৪টিa, n, s, i
  2. -O0 লুপ১৭টি/ঘুর৭ load, ২ store
  3. -O2 pointeri উধাওশেষ-ঠিকানা
  4. -O2 লুপ৪টি/ঘুর১ load
  5. হিসাব17n+18 → 4n+6n = 100
-O0 · prologueসব ভ্যারিয়েবলের জন্য stack-এ ঘর
sum:
    addi  sp, sp, -32
    sw    ra, 28(sp)
    sw    s0, 24(sp)
    addi  s0, sp, 32
    sw    a0, 20(sp)     # a  → sp+20
    sw    a1, 16(sp)     # n  → sp+16
    li    a0, 0
    sw    a0, 12(sp)     # s = 0 → sp+12
    sw    a0, 8(sp)      # i = 0 → sp+8
    j     .LBB1_1
-O0 · লুপপ্রতি ঘুরে সব কিছু মেমরি থেকে
.LBB1_1: lw a0, 8(sp)       # i
         lw a1, 16(sp)      # n
         bge a0, a1, .LBB1_4
         j   .LBB1_2
.LBB1_2: lw a0, 20(sp)      # a
         lw a1, 8(sp)       # i আবার
         slli a1, a1, 2     # i * 4
         add a0, a0, a1     # &a[i]
         lw a1, 0(a0)       # a[i]
         lw a0, 12(sp)      # s
         add a0, a0, a1
         sw a0, 12(sp)      # s ফেরত
         j   .LBB1_3
.LBB1_3: lw a0, 8(sp)
         addi a0, a0, 1
         sw a0, 8(sp)       # i++
         j   .LBB1_1
-O2 · লুপের আগেi-এর বদলে pointer আর শেষ-ঠিকানা
sum:
    blez  a1, .LBB1_4      # n ≤ 0 হলে সরাসরি 0
    mv    a2, a0           # a2 = p = a
    li    a0, 0            # s থাকবে a0-তে
    slli  a1, a1, 2
    add   a1, a2, a1       # a1 = end = a + 4n
-O2 · লুপ৪টি instruction, ১টি load
.LBB1_2:
    lw    a3, 0(a2)        # *p
    addi  a2, a2, 4        # p++  (i*4-এর গুণ নেই)
    add   a0, a3, a0       # s += *p, register-এ
    bne   a2, a1, .LBB1_2  # p ≠ end ?
    ret
Dynamic instruction countn = 100
-O0: 10 (শুরু) + 17 × n + 3 (শেষ পরীক্ষা) + 5 (ফেরা)
   = 17n + 18 = 1718
-O2: 5 (শুরু) + 4 × n + 1 (ret)
   = 4n + 6   = 406
অনুপাত ≈ 4.2 ; লুপে মেমরি-access: 9n বনাম n
  1. -O0: চারটি ভ্যারিয়েবল, চারটি stack-ঘর। Argument a আর n register-এ এসেছিল, তবু সঙ্গে সঙ্গে stack-এ লেখা হলো; s আর i-ও stack-এ। প্রতিটি C ভ্যারিয়েবলের একটি স্থায়ী মেমরি-ঠিকানা — debugger-এর জন্য আদর্শ।
  2. -O0 লুপ: ১৭টি instruction, যার ৯টি মেমরি ছোঁয়। শর্ত পরীক্ষায় i আর n পড়া, দেহে a, i, a[i], s পড়া আর s লেখা, বৃদ্ধিতে i পড়া-লেখা — মোট ৭টি load আর ২টি store, প্রতি ঘুরে। সাথে তিনটি অপ্রয়োজনীয় j: -O0 প্রতিটি C অংশকে আলাদা block রাখে।
  3. -O2: i নিজেই উধাও। Compiler দেখল i শুধু ঠিকানা a + 4i বানাতে লাগে। তাই একটি pointer a2 রাখল যা প্রতি ঘুরে ৪ বাড়বে, আর লুপের শেষ চিনবে শেষ-ঠিকানা a1 = a + 4n দিয়ে। একে বলে induction variable elimination; i * 4 গুণের বদলে + 4 হলো strength reduction.
  4. -O2 লুপ: ৪টি instruction, ১টি load. s থাকে a0-তে (যেটি শেষে ফেরত দেওয়ার register-ও), তাই লুপে store নেই। মেমরি থেকে আসে শুধু যা আসতেই হবে — a[i]. এটাই register allocation-এর ফল: compiler ঠিক করল কোন ভ্যারিয়েবল কোন register-এ থাকবে।
  5. গুনে দেখা। n = ১০০ হলে -O0 চালায় ১৭ × ১০০ + ১৮ = ১৭১৮টি instruction, -O2 চালায় ৪ × ১০০ + ৬ = ৪০৬টি — প্রায় ৪.২ গুণ কম; লুপের মেমরি-access ৯০০ থেকে ১০০. পর্ব ৩-এর CPU time = IC × CPI × T সূত্রে IC কমল, আর কম load মানে CPI-ও সাধারণত কমে।

একই -O2 লুপ বাকি দুই ISA-তে প্রায় হুবহু একই আকৃতির:

# x86-64, gcc -O2                       # ARM64, clang -O2 -fno-vectorize
.L5:                                    .LBB1_2:
    add  eax, DWORD PTR [rdi]   # load+add      ldr  w10, [x8], #4   # load, তারপর x8 += 4
    add  rdi, 4                                  subs x9, x9, #1      # বাকি গোনা
    cmp  rdi, rdx                                add  w0, w10, w0
    jne  .L5                                     b.ne .LBB1_2

x86-এ load আর যোগ এক instruction-এ (add eax, [rdi]) — CISC-এর মেমরি-operand. ARM64-এ post-index load একসাথে pointer-ও বাড়ায়, আর লুপ গোনে নিচের দিকে একটি counter দিয়ে। তবে সাবধান: clang ARM64-এ সাধারণ -O2-তে (-fno-vectorize ছাড়া) এই ফাংশন প্রায় ষাট instruction-এর হয়ে যায় — ldp q4, q5 দিয়ে একবারে ৮টি int পড়ে আর add v0.4s দিয়ে চারটি করে যোগ করে। একে বলে vectorization, যার hardware-দিক আসবে পর্ব ৪২-এ (SIMD). GCC ১২ থেকে -O2-তে শুধু খুব সস্তা ক্ষেত্রে vectorize করে, তাই এখানে gcc-র x86 লুপ সাধারণই রইল; -O3-তে gcc-ও paddd দিয়ে vectorize করল।

(গ) switch — তুলনার শিকল থেকে টেবিল

# x86-64, gcc -O0 (সংক্ষেপে): একটার পর একটা তুলনা
    cmp  DWORD PTR [rbp-4], 4
    je   .L8
    cmp  DWORD PTR [rbp-4], 4
    jg   .L9
    cmp  DWORD PTR [rbp-4], 3
    ...                                 # মোট ৩৩টি instruction

# RISC-V, clang -O2: সীমা পরীক্ষা, তারপর বাইট-টেবিল থেকে পড়া
fee:
    li    a1, 4
    bltu  a1, a0, .LBB2_2               # unsigned তুলনা: k < 0 বা k > 4 দুটোই ধরে
    lui   a1, %hi(.Lswitch.table.fee)
    addi  a1, a1, %lo(.Lswitch.table.fee)
    add   a0, a1, a0
    lbu   a0, 0(a0)                     # টেবিল: 10, 25, 40, 70, 95 (প্রতিটি ১ বাইট)
    ret
.LBB2_2:
    li    a0, 0
    ret

# ARM64, clang -O2                       # x86-64, gcc -O2
    cmp   w0, #4                            xor  eax, eax
    b.hi  .LBB2_2                           cmp  edi, 4
    adrp  x8, .Lswitch.table.fee            ja   .L8
    add   x8, x8, :lo12:.Lswitch.table.fee  mov  edi, edi
    ldrb  w0, [x8, w0, uxtw]                mov  eax, DWORD PTR CSWTCH.3[0+rdi*4]
    ret                                 .L8: ret

পর্ব ২৯-এ দেখেছেন, ঘন case-এর switch jump table-এ যায় — clang -O0-তে RISC-V-এ সত্যিই jr a0 দিয়ে jump table বানিয়েছিল। কিন্তু -O2 এক ধাপ এগোল: প্রতিটি case যেহেতু শুধু একটি ধ্রুবক ফেরত দেয়, লাফানোরই দরকার নেই — মানগুলোই একটি টেবিলে রেখে সরাসরি পড়া (switch-to-lookup-table). RISC-V আর ARM64 মানগুলো এক বাইটে (০–২৫৫) আঁটে দেখে ১ বাইটের টেবিল নিল (lbu, ldrb), gcc নিল ৪ বাইটের। আর একটি কৌশল লক্ষ করুন: k < 0 || k > 4 দুটি তুলনা না করে একটি unsigned তুলনা — ঋণাত্মক k unsigned-এ বিশাল সংখ্যা, তাই 4-এর চেয়ে বড় (পর্ব ০৫-এর two's complement).

(ঘ) fact — recursion থেকে লুপ

# x86-64, gcc -O0: সত্যিকারের recursion
    mov   eax, DWORD PTR [rbp-4]
    sub   eax, 1
    mov   edi, eax
    call  fact                      # নিজেকে ডাকা — প্রতি ডাকে নতুন stack frame
    imul  eax, DWORD PTR [rbp-4]

# x86-64, gcc -O2                    # RISC-V rv32im, clang -O2
fact:                                fact:
    mov   eax, 1                         li    a2, 2
    cmp   edi, 1                         bge   a0, a2, .LBB3_2
    jle   .L11                           li    a0, 1
.L12:                                    ret
    imul  eax, edi                   .LBB3_2:
    sub   edi, 1                         mv    a1, a0
    cmp   edi, 1                         li    a0, 1
    jne   .L12                       .LBB3_3:
.L11:                                    mv    a3, a1
    ret                                  mul   a0, a1, a0
                                         addi  a1, a1, -1
# ARM64, clang -O2 -fno-vectorize        bltu  a2, a3, .LBB3_3
.LBB3_3:                                 ret
    mul   w0, w8, w0
    cmp   w8, #2
    sub   w8, w8, #1
    b.hi  .LBB3_3

n * fact(n - 1) সরাসরি tail call নয় — ফেরার পর একটি গুণ বাকি থাকে। তবু দুই compiler-ই গুণের সাহচর্য (associativity) ব্যবহার করে একটি “accumulator” বানাল: ফল = n × (n−1) × … × 2, লুপে। কোনো call নেই, stack frame নেই, তাই বড় n-এও stack overflow নেই (যদিও ১৩! int-এ উপচে পড়ে — সেটা অন্য সমস্যা)। আরেকটি ISA-শিক্ষা: শুধু -march=rv32i দিলে RISC-V-এ mul-ই নেই (সেটি M extension-এ), তাই clang সেখানে প্রতিটি গুণের জন্য library ফাংশন __mulsi3 ডাকল — base ISA ছোট রাখার দাম।

(ঙ) struct — offset আর padding

# RISC-V rv32im, clang     # ARM64, clang                 # x86-64, gcc
total:                     total:                         total:
    lw   a1, 4(a0)             ldp  w8, w9, [x0, #4]          mov  eax, DWORD PTR [rdi+4]
    lw   a0, 8(a0)             mul  w0, w9, w8                imul eax, DWORD PTR [rdi+8]
    mul  a0, a0, a1            ret                            ret
    ret

qty offset 4-এ, price 8-এ — যদিও tag মাত্র ১ বাইট। কারণ int-কে ৪-এর গুণিতক ঠিকানায় রাখতে হয় (alignment), তাই tag-এর পরে ৩ বাইট padding বসে (পর্ব ০৬)। Struct-এর field access তাই assembly-তে স্রেফ base + ধ্রুবক offset — পর্ব ২৮-এর base+offset addressing. ARM64 পাশাপাশি দুটি field একটি ldp-তে পড়ল; x86 দ্বিতীয় field-কে সরাসরি imul-এর মেমরি-operand বানাল। -O0-এ x86 gcc pointer p-কেও stack-এ রেখে প্রতিটি field-এর আগে আবার পড়েছিল — দুবার।

৫ · -O0 বনাম -O2: আসলে কী বদলায়

পাঁচটি উদাহরণ থেকে একটি তালিকা দাঁড়ায়। এগুলো চিনতে পারলে যেকোনো listing পড়া অনেক সহজ হয়:

🐢 -O0 (debug-এর জন্য)

প্রতিটি ভ্যারিয়েবল stack-এ নিজের ঘরে; প্রতিটি ব্যবহারে load, প্রতিটি বদলে store. প্রতিটি C লাইন আলাদা instruction-গুচ্ছ, ক্রম অক্ষরে অক্ষরে; frame pointer সবসময়। দ্রুত compile হয়, debugger-এ সব দেখা যায়।

ধীর, বড়, কিন্তু স্বচ্ছ

⚡ -O2 (প্রকাশের জন্য)

ভ্যারিয়েবল register-এ; অপ্রয়োজনীয় load-store, লাফ আর frame বাদ; গুণ-ভাগ shift/lea-তে; ছোট ফাংশন inline; লুপের বাইরে সরানো যায় এমন হিসাব বাইরে; recursion লুপে; tail call হয় jmp; কখনো vectorize.

দ্রুত, ছোট, কিন্তু debugger-এ “optimized out”

-O2 আর -O0-এর মাঝে আছে -O1 (হালকা), -Og (debug-বান্ধব হালকা optimization), ওপরে -O3 (আরও আগ্রাসী: বেশি vectorization আর unrolling, code বড় হতে পারে), আর -Os/-Oz (আকার ছোট রাখা — microcontroller-এর flash মেমরির জন্য)।

Optimizer-এর প্রভাব চোখে দেখার একটি সহজ পরীক্ষা: একই যোগফলের লুপ দুইভাবে — একটিতে সাধারণ accumulator, আরেকটিতে volatile accumulator. volatile compiler-কে বলে “এর প্রতিটি পড়া-লেখা সত্যিই মেমরিতে করতে হবে”, তাই সেই লুপ register-এ বা vector-এ যেতে পারে না। Runner ফাইলটিকে -O0-এ compile করে, তাই ফাইলের মাথায় #pragma GCC optimize("O2") দিয়ে আমরা -O2 চাইছি:

#include <stdio.h>
#include <time.h>
#pragma GCC optimize("O2")   /* optimise this file even though the runner compiles at -O0 */
#define N 4096
#define REPS 20000
static int a[N];
/* noipa: GCC may not analyse across these calls, so every call really runs its loop */
__attribute__((noipa)) int sum_plain(void) {
    int s = 0;
    for (int i = 0; i < N; i++) s += a[i];
    return s;
}
__attribute__((noipa)) int sum_volatile(void) {
    volatile int s = 0;           /* every s += ... must load and store memory */
    for (int i = 0; i < N; i++) s += a[i];
    return s;
}
static double now(void) {
    struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
    return (double)t.tv_sec + (double)t.tv_nsec * 1e-9;
}
int main(void) {
    for (int i = 0; i < N; i++) a[i] = i % 7;
    long r1 = 0, r2 = 0;
    double t0 = now();
    for (int k = 0; k < REPS; k++) r1 += sum_plain();
    double t1 = now();
    for (int k = 0; k < REPS; k++) r2 += sum_volatile();
    double t2 = now();
    printf("same result: %s (%ld)\n", r1 == r2 ? "yes" : "NO", r1);
    printf("plain    : %.0f ms\nvolatile : %.0f ms\n", (t1 - t0) * 1e3, (t2 - t1) * 1e3);
    printf("volatile is about %.1fx slower\n", (t2 - t1) / (t1 - t0));
    return 0;
}

যাচাইয়ের তিনটি run-এ পাওয়া গেছে same result: yes (245700000), আর সময় plain : 16 ms বনাম volatile : 90 ms (about 5.5x slower), পরের বার 11 ms বনাম 114 ms (10.1x), আর চূড়ান্ত যাচাইয়ে 17 ms বনাম 56 ms (3.2x). ফল প্রতিবারই এক — দুটি ফাংশন গাণিতিকভাবে একই কাজ করে। কিন্তু গতি আলাদা, আর অনুপাতটি প্রতি run-এ, প্রতি মেশিনে বদলায় — একটি cloud সার্ভারের x86-64 CPU অন্য প্রোগ্রামের সাথে ভাগাভাগি হয়, তাই সংখ্যা নয়, অনুপাতের মাপ দেখুন। কেন এত পার্থক্য? একই ফাংশন gcc -O2-এ compile করে দেখা গেছে: sum_plain-এর লুপ paddd xmm0, XMMWORD PTR [rax] — একবারে চারটি int যোগ (লুপের দৈর্ঘ্য 4096 আগেই জানা, তাই -O2-এর সস্তা vectorization খাটল)। sum_volatile-এর লুপে প্রতিটি উপাদানের জন্য stack-এ রাখা s মেমরি থেকে পড়া, যোগ, আবার মেমরিতে লেখা হয়, এবং পরের যোগ আগেরটির লেখা শেষ হওয়ার অপেক্ষায় থাকে।

৬ · Listing দেখে optimization চেনা — নয়টি পরিচিত মুখ

প্রতিটি optimization-এর জন্য একটি ছোট C উদাহরণ আর -O2-এ যা দেখা গেছে (x86-64 gcc 17; যেখানে বলা আছে RISC-V বা ARM64 clang). Compiler-প্রকৌশলের বইয়ে এগুলোর প্রতিটির আলাদা অধ্যায় আছে; আমাদের লক্ষ্য শুধু চিনতে পারা।

OptimizationC-O2-এ যা দেখবেন
Constant foldingreturn 60 * 60 * 24;mov eax, 86400 — গুণ compile-এর সময়েই শেষ। RISC-V-এ ধ্রুবক বড় বলে দুই instruction: lui a0, 21 + addi a0, a0, 384 (21 × 4096 + 384 = 86400)
Common subexpression elimination(a+b)*(a+b) + (a+b)add edi, esi একবারই; তারপর imul eax, edi, add eax, edi. Clang RISC-V আরও এগিয়ে s × (s + 1) বানাল: add, addi a1, a0, 1, mul
Loop-invariant code motionfor (…) v[i] = v[i] * (a * b);imul edx, ecx (a × b) লুপের আগে একবার; লুপে শুধু imul eax, edx
Strength reductionx * 8, x * 3, x / 4lea eax, [rdi+rdi*2] (×3), ×8 lea-র scale-এর ভেতরেই; signed x / 4 → lea eax, [rdi+3], cmovns, sar eax, 2 (ঋণাত্মকে আগে ৩ যোগ — পর্ব ২০, ২২)
Induction-variable eliminationfor (i…) s += a[i];লুপে i নেই; add rdi, 4 আর শেষ-ঠিকানার সাথে cmp (অংশ ৪খ)
Loop unrollingএকই লুপ, -O2 -funroll-loopsলুপের দেহ কয়েকবার কপি; sum-এর ১৫ লাইনের listing (label-সহ) কয়েক গুণ লম্বা হয়ে যায়। ঘোরার সংখ্যা কমে, শাখা কমে, কোড বড় হয়
Inliningছোট static ফাংশন ডাকাcall নেই; ফাংশনের দেহ ডাকার জায়গায় বসে, তারপর আশপাশের কোডের সাথে মিলে আরও optimize হয় (নিচের stepper)
Dead code eliminationint unused = x * 37; if (x > x) return 99; return x + 1;শুধু lea eax, [rdi+1] — অব্যবহৃত গুণ আর কখনো-সত্য-নয় এমন শাখা উধাও
Branchless cmovreturn a > b ? a : b;x86: cmp esi, edi, mov eax, edi, cmovge eax, esi; ARM64: csel w0, w0, w1, gt; RISC-V (মূল ISA-তে cmov নেই): blt a1, a0, … + mv

শাখাহীন (branchless) কোড কেন ভালো হতে পারে? পর্ব ৩৭-এ দেখবেন, শাখার দিক ভুল অনুমান করলে আধুনিক pipeline-কে প্রায় ১০–২০ cycle-এর কাজ ফেলে দিতে হয়। cmov/csel শাখা না রেখে দুটি মানের একটি বেছে নেয় — অনুমানের ঝুঁকিই নেই, তবে দুটি মানই আগে হিসাব করতে হয়। তাই compiler শুধু সস্তা ক্ষেত্রেই এটি বেছে নেয়। আর tail call: int tc(int x) { return g(x + 1); } -O2-এ add edi, 1 আর jmp g (RISC-V-এ tail g, ARM64-এ b g) — call-ret জোড়ার বদলে একটি লাফ, কারণ g-এর ret সরাসরি tc-কে যে ফাংশন ডেকেছিল, সেখানে ফিরলেই চলে।

এবার একটি listing-এ একসাথে দুটি optimization চিনি। C কোড: static int area(int w, int h) { return w * h; } আর int cost(int w, int h) { return area(w, h) * 8 + w * 3; }. gcc -O2-এর x86-64 আউটপুট w = 5, h = 7 দিয়ে চালাই:

1cost:2    imul  esi, edi3    lea   eax, [rdi+rdi*2]4    lea   eax, [rax+rsi*8]5    ret
Register-এর মান (w = 5, h = 7)
edi (w)
esi (h)
eax
  1. প্রথমে খুঁজুন কী নেই। C-তে cost ডাকে area-কে, কিন্তু listing-এ কোনো call area নেই — চার instruction-এর পুরো ফাংশন। এটাই inlining-এর প্রথম চিহ্ন। শুরুতে System V নিয়মে w আসে edi-তে (5), h আসে esi-তে (7).
  2. imul esi, edi — area-র দেহ এখানে। esi = 7 × 5 = 35. ফাংশন ডাকার খরচ (argument বসানো, call, ret, stack ঠিক রাখা) বাদ গেল, আর ফলটি সরাসরি পরের হিসাবে যাবে। h আর লাগবে না বলে তার register-টাই ফলের জন্য ব্যবহার হলো।
  3. lea eax, [rdi+rdi*2] — w * 3 গুণ ছাড়া। ঠিকানা-হিসাবের circuit দিয়ে w + 2w = 5 + 10 = 15. imul-এর বদলে lea — strength reduction; বহু x86 CPU-তে সরল lea ১ cycle-এ শেষ হয়, যেখানে imul-এ সাধারণত ৩ cycle.
  4. lea eax, [rax+rsi*8] — × 8 আর যোগ একসাথে। area × 8 আলাদা গুণ বা shift-ও নয়: lea-র scale factor (১, ২, ৪ বা ৮) দিয়েই হলো, সাথে যোগ। eax = 15 + 35 × 8 = 15 + 280 = 295.
  5. ফল যাচাই। C অনুযায়ী area(5, 7) × 8 + 5 × 3 = 280 + 15 = 295 — মিলেছে। চার instruction, একটিমাত্র গুণ। Clang RISC-V একই ফাংশনকে বীজগণিত দিয়ে আরও বদলাল: 8wh + 3w = w × (8h + 3) → slli a1, a1, 3, addi a1, a1, 3, mul a0, a1, a0. দুই compiler, দুই কৌশল, একই অর্থ।

৭ · নিজের হাতে একটি ছোট্ট compiler

Compiler-এর ভেতরে তিনটি বড় কাজ: লেখা পড়ে একটি গাছ (syntax tree) বানানো, গাছ থেকে instruction লেখা (code generation), আর সেই instruction-কে ছোট-দ্রুত করা (optimization). নিচের JavaScript প্রোগ্রাম তিনটিই করে, একটি ছোট ভাষার জন্য: d = (a + b) * 4 - c. ভ্যারিয়েবলগুলো মেমরিতে থাকে gp থেকে offset-এ (-O0-এর মতো), register বরাদ্দ হয় সরল নিয়মে — পরের খালি t register, আর ফল বাঁয়ের operand-এর register-এ। তারপর একটি peephole optimization — একটি ছোট “জানালা” দিয়ে পাশাপাশি দুটি instruction দেখে সস্তা বিকল্প বসানো। শেষে একটি ছোট RV32 interpreter দুটি সংস্করণই চালিয়ে ফল মেলায়।

// Tiny compiler: "d = (a + b) * 4 - c" -> RV32 assembly, then run it on a tiny RV32 interpreter
const src = "d = (a + b) * 4 - c", OFF = { a: 0, b: 4, c: 8, d: 12 };   // variables live in memory at gp + offset
const tk = src.match(/\w+|[()+\-*=]/g); let p = 0; const peek = () => tk[p], eat = () => tk[p++];
function prim() { if (peek() === "(") { eat(); const e = expr(); eat(); return e; } const t = eat(); return /^\d+$/.test(t) ? { num: +t } : { v: t }; }
function term() { let l = prim(); while (peek() === "*") { eat(); l = { op: "*", l, r: prim() }; } return l; }
function expr() { let l = term(); while (peek() === "+" || peek() === "-") { const op = eat(); l = { op, l, r: term() }; } return l; }
const dest = eat(); eat(); const tree = expr();
// Code generation, naive register allocation: next free t-register; a result reuses its left operand's register
let next = 0; const code = [];
function gen(n) {
  if (n.num !== undefined) { code.push(`li   t${next}, ${n.num}`); return next++; }
  if (n.v) { code.push(`lw   t${next}, ${OFF[n.v]}(gp)`); return next++; }
  const x = gen(n.l), y = gen(n.r);
  code.push(`${{ "+": "add", "-": "sub", "*": "mul" }[n.op]}  t${x}, t${x}, t${y}`); next = x + 1; return x;
}
code.push(`sw   t${gen(tree)}, ${OFF[dest]}(gp)`);
const opt = [];   // Peephole: "li tY, 2^k" + "mul tX, tX, tY" -> "slli tX, tX, k"  (mul needs the M extension; slli is RV32I)
for (let i = 0; i < code.length; i++) {
  const li = code[i].match(/^li\s+(t\d), (\d+)$/), mu = (code[i + 1] || "").match(/^mul\s+(t\d), (t\d), (t\d)$/);
  const k = li ? Math.log2(+li[2]) : -1;
  if (li && mu && mu[3] === li[1] && Number.isInteger(k)) { opt.push(`slli ${mu[1]}, ${mu[2]}, ${k}`); i++; } else opt.push(code[i]);
}
function run(prog, m) {                          // m = memory words [a, b, c, d], gp = 0
  const r = {};
  for (const line of prog) {
    const [op, x, y, z] = line.split(/[\s,()]+/).filter(Boolean);
    if (op === "li") r[x] = +y; else if (op === "lw") r[x] = m[+y / 4]; else if (op === "sw") m[+y / 4] = r[x];
    else if (op === "slli") r[x] = (r[y] << +z) | 0;
    else r[x] = { add: (u, v) => (u + v) | 0, sub: (u, v) => (u - v) | 0, mul: Math.imul }[op](r[y], r[z]);
  }
  return m[3];
}
console.log(`naive (${code.length} instructions):\n  ${code.join("\n  ")}\nafter peephole (${opt.length} instructions):\n  ${opt.join("\n  ")}`);
for (const [a, b, c] of [[7, 5, 6], [-3, 1, 2]])
  console.log(`a=${a} b=${b} c=${c}: naive d=${run(code, [a, b, c, 0])}, optimised d=${run(opt, [a, b, c, 0])}, JS says ${(a + b) * 4 - c}`);

আসল আউটপুট:

naive (8 instructions):
  lw   t0, 0(gp)
  lw   t1, 4(gp)
  add  t0, t0, t1
  li   t1, 4
  mul  t0, t0, t1
  lw   t1, 8(gp)
  sub  t0, t0, t1
  sw   t0, 12(gp)
after peephole (7 instructions):
  lw   t0, 0(gp)
  lw   t1, 4(gp)
  add  t0, t0, t1
  slli t0, t0, 2
  lw   t1, 8(gp)
  sub  t0, t0, t1
  sw   t0, 12(gp)
a=7 b=5 c=6: naive d=42, optimised d=42, JS says 42
a=-3 b=1 c=2: naive d=-10, optimised d=-10, JS says -10

গাছটি ছিল sub(mul(add(a, b), 4), c); code generator গাছের বাঁ দিক থেকে নিচে নেমে প্রতিটি পাতার জন্য একটি lw বা li লিখল, প্রতিটি গিঁটের জন্য একটি হিসাব। পুরো কাজে মাত্র দুটি register লাগল (t0, t1), কারণ প্রতিটি ফল বাঁয়ের register-এ বসে আর ডানেরটি মুক্ত হয়। Peephole ধাপ li t1, 4 + mul জোড়াকে slli t0, t0, 2-এ বদলাল: একটি instruction কম, আর mul না থাকায় কোডটি এখন খাঁটি RV32I — M extension ছাড়া CPU-তেও চলবে। ঋণাত্মক a = −3 দিয়েও ফল মিলল (−10), কারণ two's complement-এ বাঁয়ে shift মানে ২-এর ঘাত দিয়ে গুণ, overflow না হলে চিহ্নসহ ঠিক থাকে (পর্ব ০৫)।

এই খেলনা compiler আর gcc-র মধ্যে দূরত্ব বিশাল, কিন্তু কাঠামো একই। আসল compiler গাছ থেকে প্রথমে একটি মাঝামাঝি ভাষায় (IR) যায়, সেখানে শত শত optimization pass চালায়, তারপর প্রতিটি ISA-র জন্য আলাদা back-end-এ instruction বাছে। আর register allocation সেখানে একটি কঠিন সমস্যা: RISC-V-এ ৩১টি ব্যবহারযোগ্য register, x86-64-এ ১৬টি সাধারণ register; একসাথে বেঁচে থাকা মান তার বেশি হলে কিছু মানকে stack-এ সরিয়ে রাখতে হয় — একে বলে spilling, আর তখন -O2 কোডেও load-store ফিরে আসে। কম register-এর ISA-তে spill বেশি হয় — RISC নকশায় ৩২টি register রাখার এটি একটি বড় কারণ।

আরও গভীরে: GCC আর LLVM দুটোই মাঝের ভাষাকে SSA (static single assignment) রূপে রাখে, যেখানে প্রতিটি মান ঠিক একবার লেখা হয়; constant propagation, dead code elimination আর induction variable-এর বিশ্লেষণ এতে অনেক সহজ হয়। Register allocation-এর ধ্রুপদী পদ্ধতি graph coloring (১৯৮০-এর দশকের শুরুতে IBM-এ Gregory Chaitin-দের কাজ), যেখানে একসাথে বেঁচে থাকা দুটি মান একই রং (register) পেতে পারে না; LLVM এখন একটি greedy allocator ব্যবহার করে। এর বাইরে আছে profile-guided optimization (PGO — আসল চালানোর পরিসংখ্যান দিয়ে সিদ্ধান্ত), link-time optimization (LTO — ভিন্ন ফাইলের ফাংশনও inline), আর গবেষণায় superoptimizer, যা ছোট কোডের জন্য সম্ভাব্য সব instruction-ক্রম খুঁজে সবচেয়ে ছোটটি বের করে।

৮ · Instruction সংখ্যা আর code size — তিন ISA পাশাপাশি

RISC বনাম CISC-এর পুরোনো তর্কের একটি অংশ (পর্ব ২৫): CISC-এ এক instruction বেশি কাজ করে, তাই instruction কম লাগে; RISC-এর instruction সরল, তাই বেশি লাগে কিন্তু প্রতিটি দ্রুত। আসল compiler-এর আউটপুট দিয়ে মাপা যাক। পাঁচটি ফাংশন -O2-এ (RISC-V আর ARM64 clang, ARM64-এ -fno-vectorize যাতে তুলনা ন্যায্য থাকে; x86-64 gcc), বাইট মাপা হয়েছে object file-এর symbol size থেকে (nm -S):

ফাংশনRV32IM
instr / বাইট
RV32IMC
বাইট
ARM64
instr / বাইট
x86-64
instr / বাইট
add2 / 842 / 82 / 4
sum12 / 482812 / 4812 / 35
fee9 / 36268 / 326 / 17
fact11 / 442811 / 448 / 28
total4 / 16103 / 123 / 8
মোট38 / 1529636 / 14431 / 92

কী শিখলাম (সতর্কভাবে — পাঁচটি ছোট ফাংশন কোনো benchmark নয়, আর এখানে দুটি ভিন্ন compiler মেশানো):

  • Instruction সংখ্যায় পার্থক্য কম: ৩৮, ৩৬, ৩১. x86-এর মেমরি-operand (add eax, [rdi], imul eax, [rdi+8]) কয়েকটি instruction বাঁচায়, ARM64-এর ldp আর post-index-ও কিছু বাঁচায়।
  • বাইটে পার্থক্য বেশি: RV32IM আর ARM64-এর সব instruction ৪ বাইট, তাই বাইট = ৪ × instruction. x86-এর গড় এখানে তিন বাইটের কম — ret ১ বাইট, lea eax, [rdi+rsi] ৩ বাইট। (x86-এর বাইটে loop-এর আগে alignment-এর জন্য বসানো কিছু ফাঁকা বাইটও ধরা আছে।)
  • RISC-V-এর C extension: compressed instruction-এ বহুল ব্যবহৃত instruction-এর ১৬-বিট রূপ (add a0, a0, a1 = 952e, ret = 8082). একই কোড ১৫২ থেকে ৯৬ বাইট — প্রায় ৩৭% ছোট, x86-এর কাছাকাছি। RISC-V-এর spec নিজে সাধারণত ২৫–৩০% কমার কথা বলে; ছোট উদাহরণে বেশি দেখাতেই পারে।

Code size কেন গুরুত্বপূর্ণ? Code density বেশি হলে instruction cache-এ বেশি কোড ধরে (পর্ব ৪৪–৪৭), আর microcontroller-এ flash মেমরিই খরচের বড় অংশ। তবে মনে রাখুন, আসল গতি ঠিক করে instruction সংখ্যা × CPI × clock period (পর্ব ৩) — x86-এর জটিল instruction decoder ভেতরে ছোট ছোট micro-operation-এ ভাঙে, তাই “কম instruction” স্বয়ংক্রিয়ভাবে “দ্রুত” নয়।

Runner-এর shell-এ কোন gcc আছে, আর প্রতিটি -O স্তরে কতগুলো optimization flag চালু হয়, নিজে দেখুন:

# Which gcc does this runner's shell have, and how many optimisation passes does each -O level switch on?
if command -v gcc >/dev/null; then
    gcc --version | head -1
    for o in -O0 -O1 -O2 -O3 -Os; do
        printf '%-4s : %3s optimiser flags enabled\n' "$o" "$(gcc -Q --help=optimizers $o 2>/dev/null | grep -c '\[enabled\]')"
    done
    # same tiny function, count real instructions (lines that are not directives or labels)
    for o in -O0 -O2; do
        n=$(echo 'int add(int a, int b) { return a + b; }' | gcc $o -S -x c - -o - | grep -c -E '^\s+[a-z]')
        echo "add() at $o: $n instructions"
    done
    echo 'int add(int a, int b) { return a + b; }' | gcc -O2 -S -masm=intel -x c - -o - | grep -E '^\s+[a-z]'
else
    echo "gcc নেই"
fi

যাচাইয়ের সময় আউটপুট:

gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
-O0  :  52 optimiser flags enabled
-O1  :  92 optimiser flags enabled
-O2  : 137 optimiser flags enabled
-O3  : 150 optimiser flags enabled
-Os  : 129 optimiser flags enabled
add() at -O0: 10 instructions
add() at -O2: 3 instructions
	endbr64
	lea	eax, [rdi+rsi]
	ret

এই shell-এর gcc হলো Ubuntu-র gcc 13.3 — C-র runnable block যে gcc 17 (experimental) দিয়ে চলে, তার থেকে আলাদা; তাই উপরের বাইট-প্রোগ্রামের সাথে এখানে একটি পার্থক্য: প্রথমে endbr64. Ubuntu তার gcc-তে ডিফল্টভাবে -fcf-protection চালু রাখে, যা প্রতিটি ফাংশনের শুরুতে এই ৪-বাইটের “এখানে লাফানো বৈধ” চিহ্ন বসায় (Intel CET-এর indirect branch tracking; পুরোনো CPU-তে এটি nop). -O0-তেও ৫২টি flag “enabled” দেখাচ্ছে, কারণ তালিকায় এমন অনেক flag আছে যা আসলে optimization নয় (যেমন exception-এর জন্য unwind টেবিল রাখা); -O1 থেকে -O2-তে ৪৫টির মতো নতুন pass যোগ হয়, -O3 যোগ করে মূলত আরও vectorization আর loop রূপান্তর। Version বদলালে এই সংখ্যাগুলোও বদলায়।

৯ · Undefined behaviour, volatile আর inline assembly

Optimizer ধরে নেয় UB কখনো ঘটে না

পর্ব ০৫-এ দেখেছেন, C-তে signed int-এর overflow undefined behaviour — hardware-এ wrap-around হলেও ভাষা কোনো ফল নির্দিষ্ট করে না। Optimizer এর সুযোগ নেয়: “overflow হতেই পারে না” ধরে নিয়ে কোড ছোট করে। তিনটি বাস্তব আউটপুট (gcc 17 -O2, x86-64):

# int grows(int x) { return x + 1 > x; }        → সবসময় 1 !
grows:
    mov   eax, 1
    ret

# unsigned ugrows(unsigned x) { return x + 1 > x; }   → সত্যিকারের তুলনা
ugrows:
    xor   eax, eax
    cmp   edi, -1           # x == 0xFFFFFFFF হলে x + 1 = 0, তাই মিথ্যা
    setne al
    ret

# int first(int *p) { int v = *p; if (p == 0) return -1; return v; }
first:
    mov   eax, DWORD PTR [rdi]     # null পরীক্ষা পুরোপুরি উধাও
    ret

প্রথম ক্ষেত্রে x = INT_MAX হলে hardware-এ x + 1 ঋণাত্মক হতো, কিন্তু ভাষার চোখে সেটা UB, তাই compiler উত্তর সবসময় 1 ধরে নিল (লক্ষ করার মতো: gcc এটি -O0-তেও করেছে, কারণ এই সরলীকরণ front-end-এই হয়)। unsigned-এ wrap-around ভাষায় নির্দিষ্ট, তাই সেখানে সত্যিকারের তুলনা রইল। তৃতীয় ক্ষেত্রে *p আগেই পড়া হয়েছে; p null হলে সেটাই UB, তাই “p == 0” পরীক্ষা অর্থহীন ধরে compiler মুছে দিল। এই ধরনের অপসারণ একসময় Linux kernel-এ একটি নিরাপত্তা-ত্রুটির জন্ম দিয়েছিল (ভাবনার প্রশ্ন ৪)। প্রতিরক্ষা: -fsanitize=undefined (UBSan) দিয়ে পরীক্ষা, ইচ্ছাকৃত wrap-এর জন্য unsigned বা __builtin_add_overflow, আর নিরাপত্তা-সংবেদনশীল কোডে -fwrapv-এর মতো flag. C-র দিক থেকে বিস্তারিত: Undefined behavior ও নিরাপদ কোডিং।

volatile: যখন মেমরি আসলে একটি যন্ত্র

Microcontroller-এ একটি UART (serial port) ধরা যাক, যার status register মেমরি-ঠিকানা 0x10000004-এ বসানো (memory-mapped I/O, পর্ব ৫১)। বিট ০ = “পাঠানোর জন্য প্রস্তুত”। Hardware নিজে এই বিট বদলায় — প্রোগ্রাম নয়।

# #define UART_STATUS (*(volatile unsigned *)0x10000004u)
# void wait_ready(void) { while ((UART_STATUS & 1u) == 0) { } }
wait_ready:
    lui   a0, 65536             # a0 = 0x10000000
.LBB3_1:
    lw    a1, 4(a0)             # প্রতি ঘুরে নতুন করে পড়া
    andi  a1, a1, 1
    beqz  a1, .LBB3_1
    ret

# একই কোড, শুধু volatile ছাড়া:
wait_ready_nv:
    lui   a0, 65536
    lbu   a0, 4(a0)             # মাত্র একবার পড়া!
    andi  a0, a0, 1
    bnez  a0, .LBB4_2
.LBB4_1:
    j     .LBB4_1               # বিট 0 হলে চিরকালের লুপ
.LBB4_2:
    ret

volatile ছাড়া compiler-এর যুক্তি নিখুঁত: লুপের ভেতরে কেউ ঠিকানাটিতে লেখে না, তাই মান বদলাতে পারে না, তাই একবার পড়লেই যথেষ্ট — আর বিট ০ থাকলে লুপ চিরকাল। এমনকি পুরো শব্দের বদলে শুধু একটি বাইট পড়ল (lbu), যা অনেক যন্ত্র-register-এ নিষিদ্ধ। volatile দিলে প্রতিটি পড়া সত্যিকারের lw, পূর্ণ আকারে, ক্রম মেনে। Embedded কোডে তাই যন্ত্রের register, interrupt handler-এর সাথে ভাগ করা flag আর setjmp-এর পরের local ভ্যারিয়েবলে volatile লাগে। কিন্তু volatile lock নয়, memory barrier-ও বসায় না — একাধিক core-এর সমন্বয়ে লাগে atomic আর fence (পর্ব ৫৩)। এর বাস্তব প্রয়োগ পর্ব ৫৬-এ (embedded সিস্টেম) আর C course-এর embedded C পর্বে।

Inline assembly — কদাচিৎ, সাবধানে

GCC আর Clang-এ __asm__("...") দিয়ে C-র ভেতরে সরাসরি instruction লেখা যায় (inline assembly), সাথে কোন C ভ্যারিয়েবল কোন register-এ যাবে তার “constraint”. কখন দরকার? CPU-র এমন বিশেষ instruction, যার C-রূপ নেই: RISC-V-এ csrr দিয়ে control register পড়া, fence, wfi (interrupt-এর অপেক্ষায় ঘুম), context switch-এর কোড, boot-এর প্রথম লাইনগুলো। কিন্তু উপরের নয়টি optimization দেখার পর বুঝতেই পারছেন, সাধারণ হিসাবে compiler আপনার হাতের লেখার চেয়ে ভালো বা সমান — আর inline asm-এর ভেতরে compiler কিছু optimize করে না, constraint ভুল হলে নিঃশব্দে অন্য ভ্যারিয়েবল নষ্ট হয়, কোডটি এক ISA-তে বাঁধা পড়ে। তাই আগে খুঁজুন compiler-এর builtin বা intrinsic (__builtin_popcount, __rdtsc(), SIMD intrinsic). একটি x86 উদাহরণ আর তার ফাঁদগুলো আছে C আর assembly পর্বে।

১০ · AI-এর যুগে — assembly ব্যাখ্যায় AI, আর যাচাইয়ের অভ্যাস

Assembly-র ব্যাখ্যা চাওয়া — এই কাজে AI বেশ ভালো, বিশেষ করে লম্বা listing-এ কোন অংশ কোন C লাইনের, তা দেখাতে। কিন্তু assembly এমন জায়গা যেখানে একটি ভুল register বা ভুল operand-ক্রম পুরো অর্থ উল্টে দেয়, আর AI-এর ভুলগুলোর একটি পরিচিত ধরন আছে:

AI-এর সাধারণ ভুলকীভাবে ধরবেন
AT&T আর Intel মিশিয়ে mov eax, edi-কে “eax থেকে edi-তে” বলা% চিহ্ন আছে কি না দেখুন; Compiler Explorer-এ দুই syntax পাল্টে দেখুন
MIPS-এর নিয়ম RISC-V-তে চাপানো ($t0, branch delay slot, মাত্র চারটি argument register $a0–$a3)RISC-V-এ delay slot নেই, register a0–a7; ISA manual মিলিয়ে নিন
“-O2 এই কোডই লিখবে” বলে একটি বানানো listing দেওয়ানিজে Compiler Explorer-এ একই version আর flag দিয়ে চালান
instruction সংখ্যা বা বাইট “অনুমানে” বলাobjdump -d বা nm -S দিয়ে গুনুন
UB-র কারণে মুছে যাওয়া কোডকে “compiler bug” বলাUBSan দিয়ে চালান; C standard-এর নিয়ম দেখুন
volatile-কে thread-safety-র সমাধান বলাAtomic, mutex, fence — এগুলোই সমাধান (পর্ব ৫৩)

AI-কে listing ব্যাখ্যা করতে দিলে compiler, version, ISA আর flag স্পষ্ট বলুন, আর তাকে দিয়ে যাচাইযোগ্য দাবি লেখান:

নিচের listing টি clang -O2 দিয়ে RISC-V rv32im-এর জন্য তৈরি (এটা x86 নয়, তাই AT&T/Intel প্রশ্ন নেই)। C কোডও দিলাম। [C কোড] [assembly listing] ১. প্রতিটি instruction কোন C অংশ থেকে এসেছে, একটি টেবিলে দাও। ২. কোন কোন optimization (inlining, strength reduction, induction variable elimination, loop-invariant code motion ইত্যাদি) চিনতে পারছ, প্রতিটির জন্য ঠিক কোন instruction প্রমাণ — সেটা বলো। ৩. n = 4 আর a = {1, 2, 3, 4} দিয়ে প্রতিটি register-এর মান ধাপে ধাপে লেখো, যাতে আমি হাতে মিলিয়ে দেখতে পারি। ৪. কোনো কিছু নিশ্চিত না হলে "নিশ্চিত নই" লেখো, অনুমান কোরো না।

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

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

  • Compilation-এর ধাপ আর প্রতিটির আউটপুট? — Preprocess (.i) → compile (.s) → assemble (.o: machine code + symbol + relocation) → link (executable) → load (প্রসেস)। ISA প্রথম আসে compile ধাপে।
  • -O0-তে কেন সব ভ্যারিয়েবল stack-এ? — Debugger যাতে প্রতিটি ভ্যারিয়েবলকে একটি নির্দিষ্ট ঠিকানায় পায় আর প্রতিটি C লাইনে থামতে পারে।
  • x86-64 gcc -O2-এ int add(int a, int b)? — lea eax, [rdi+rsi]; ret — বাইটে 8d 04 37 c3.
  • Strength reduction-এর দুটি উদাহরণ? — x * 8 → slli/shl বা lea-র scale; লুপে a[i]-এর i * 4 → pointer-এ প্রতি ঘুরে + 4.
  • volatile কী নিশ্চিত করে, কী করে না? — প্রতিটি access সত্যিই মেমরিতে, বাদ বা একত্র না করে; কিন্তু atomicity বা core-এর মধ্যে ক্রম নয়।

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

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

প্র ০১RISC-এর জন্ম আর compiler — কেন বলা হয় RISC আসলে compiler-প্রকৌশলীদের আবিষ্কার?

১৯৭০-এর দশকের CPU-নকশার প্রবণতা ছিল instruction-কে উচ্চ-স্তরের ভাষার কাছাকাছি আনা — DEC-এর VAX-এ বহুপদী (polynomial) মূল্যায়ন বা string-কপির মতো একটি instruction, বহু addressing mode, microcode-এ লেখা জটিল কাজ। ধারণা ছিল: hardware যত ভাষার কাছে, compiler তত সহজ, প্রোগ্রাম তত ছোট।

কিন্তু যাঁরা compiler লিখতেন, তাঁরা উল্টো জিনিস দেখলেন। IBM-এ John Cocke-এর দল ১৯৭০-এর দশকের মাঝামাঝি 801 প্রকল্পে মেপে দেখল, compiler-এর তৈরি কোডে জটিল instruction-গুলো খুব কমই আসে; বেশিরভাগ সময় চলে load, store, add, branch-এর মতো সরল কাজ। জটিল instruction সঠিকভাবে ব্যবহার করা compiler-এর পক্ষে কঠিন, আর অনেক সময় কয়েকটি সরল instruction দিয়ে একই কাজ দ্রুততর হয়। ১৯৮০-এর দশকের শুরুতে Berkeley-তে David Patterson-এর RISC I আর Stanford-এ John Hennessy-র MIPS এই চিন্তাকে চিপে রূপ দিল: সরল, একই দৈর্ঘ্যের instruction, শুধু load/store মেমরি ছোঁয়, অনেক register — আর জটিলতার দায়িত্ব compiler-এর।

এই পর্বের listing-গুলোতে সেই সিদ্ধান্তের ছাপ স্পষ্ট। RISC-V-এর ৩১টি ব্যবহারযোগ্য register থাকায় বড় লুপেও spill কম হয় (sum-এর ছোট -O2 লুপে কোনো spill-ই নেই); সমান দৈর্ঘ্যের instruction-এ বাইট গোনা সহজ, decode সহজ। আর x86? Intel-AMD-এর আধুনিক CPU ভেতরে x86 instruction-কে RISC-ধাঁচের micro-operation-এ ভেঙে চালায় — অর্থাৎ বাইরে CISC-এর পোশাক, ভেতরে RISC-এর ইঞ্জিন। আবার x86-এর ঘন encoding আর মেমরি-operand এই পর্বের code size টেবিলে একটি সত্যিকারের সুবিধা দেখাল, যা RISC-V পরে C extension দিয়ে পূরণ করেছে।

তাই প্রশ্নটা “RISC না CISC” নয়, বরং “hardware আর compiler-এর মধ্যে কাজ কীভাবে ভাগ হবে”। Compiler যা ভালো পারে (register বাছাই, instruction-ক্রম সাজানো, ধ্রুবক হিসাব), hardware-কে তা করতে হয় না; hardware যা ভালো পারে (চলার সময়ের তথ্য দেখে branch অনুমান, out-of-order চালানো), compiler-কে তা আগে থেকে জানতে হয় না।

মূল উপলব্ধি: ISA হলো compiler আর hardware-এর চুক্তি; ভালো ISA সেটাই যার নিয়ম compiler সহজে কাজে লাগাতে পারে আর hardware দ্রুত চালাতে পারে।

প্র ০২-O2 এত ভালো হলে debug build কেন -O0-তে হয়? “optimized out” আসলে কী, আর -Og কী সমাধান দেয়?

Debugger কাজ করে compiler-এর রেখে যাওয়া একটি মানচিত্র দিয়ে — Linux-এ DWARF ফরম্যাটের debug তথ্য (-g). সেই মানচিত্র বলে: এই instruction-টি C-র কোন লাইনের, আর এই মুহূর্তে ভ্যারিয়েবল s কোথায় আছে — কোন register-এ, না stack-এর কোন ঘরে। -O0-তে উত্তরটি সরল: প্রতিটি ভ্যারিয়েবল সারাজীবন stack-এর একটি নির্দিষ্ট ঘরে, প্রতিটি C লাইনের instruction পাশাপাশি আর ক্রম মেনে। আপনি যেকোনো লাইনে থামতে পারেন, যেকোনো ভ্যারিয়েবল দেখতে বা বদলাতে পারেন।

-O2-এ মানচিত্র জটিল হয়ে যায়। অংশ ৪-এর sum-এ i ভ্যারিয়েবলটি আর নেই — তার জায়গায় একটি pointer. তাই debugger-এ print i লিখলে দেখবেন <optimized out>: compiler সৎভাবে বলছে, “এই মান আর কোথাও রাখা নেই।” কোনো ভ্যারিয়েবল এক সময় register-এ, পরে অন্য register-এ, আবার কখনো কোথাও না। Inline হওয়া ফাংশনে breakpoint দিলে সেটি দশ জায়গায় ছড়ানো থাকতে পারে; instruction scheduling-এর কারণে “পরের লাইন” চাপলে কার্সর আগে-পিছে লাফায়; আর optimizer যে লুপকে ধ্রুবক বানিয়ে দিয়েছে, তার ভেতরে থামার জায়গাই নেই।

এ কারণেই প্রচলিত নিয়ম: উন্নয়নের সময় -O0 -g, প্রকাশের সময় -O2 (প্রায়ই -g-সহ, যাতে crash রিপোর্ট পড়া যায়)। কিন্তু -O0 এত ধীর যে কিছু প্রোগ্রাম (গেম, বড় সিমুলেশন) debug করাই কঠিন হয়ে পড়ে। তাই GCC ৪.৮ (২০১৩) থেকে আছে -Og: যেসব optimization debug-অভিজ্ঞতা নষ্ট করে না সেগুলো চালু, বাকিগুলো বন্ধ। Clang-এর -Og এখনো মূলত -O1-এর মতো আচরণ করে বলে জানা যায়।

আরেকটি গুরুত্বপূর্ণ বিষয়: যে বাগ শুধু -O2-তে দেখা দেয়, সেটি প্রায়ই compiler-এর দোষ নয় — কোডে লুকিয়ে থাকা undefined behaviour বা volatile-এর অভাব, যা -O0-এর “সব মেমরিতে” আচরণে চাপা পড়ে ছিল। তাই শুধু -O0-তে পরীক্ষা করা কোড পুরোপুরি পরীক্ষিত নয়।

মূল উপলব্ধি: Optimization আর debuggability পরস্পরবিরোধী — একটি বাড়ালে অন্যটি কমে; পেশাদাররা দুই ধরনের build-ই রাখেন আর দুটিতেই পরীক্ষা চালান।

প্র ০৩ARM-এর Thumb থেকে RISC-V-এর C extension — code density নিয়ে এত মাথাব্যথা কেন, আর ARM64 কেন সেই পথে যায়নি?

১৯৯০-এর দশকের শুরুতে ARM-এর ৩২-বিট instruction ছিল সরল আর দ্রুত, কিন্তু মোবাইল ফোনের মতো যন্ত্রে মেমরি ছিল দামি আর ছোট। প্রতিটি instruction ৪ বাইট হওয়ায় একই প্রোগ্রাম x86 বা ৮/১৬-বিট chip-এর চেয়ে বেশি জায়গা নিত। ARM-এর সমাধান ছিল Thumb (১৯৯৪ সালের দিকে ARM7TDMI core-এ): বহুল ব্যবহৃত instruction-এর একটি ১৬-বিট রূপ। সেই সময়ের বহু ARM-ভিত্তিক মোবাইল ফোনে এটি ব্যবহার হতো। পরে Thumb-2 (২০০৩ সালের দিকে) ১৬ আর ৩২ বিট মিশিয়ে প্রায় পূর্ণ ক্ষমতা দিল; আজও Cortex-M microcontroller-গুলো শুধু Thumb চালায়।

RISC-V একই শিক্ষা থেকে শুরুতেই “C” (compressed) extension রাখল: সাধারণ instruction-এর ১৬-বিট রূপ, যা ৩২-বিট instruction-এর সাথে যেকোনো জায়গায় মিশে থাকতে পারে। এই পর্বের টেবিলে পাঁচটি ফাংশন ১৫২ থেকে ৯৬ বাইটে নামল। Linux চালানোর মতো প্রায় সব RISC-V chip-ই C extension সমর্থন করে, আর বড় Linux distribution-গুলোও এটি ধরে নিয়ে build করে।

কেন এত গুরুত্ব? প্রথমত, microcontroller-এ flash মেমরিই chip-এর বড় অংশ আর খরচ; কোড ছোট মানে ছোট, সস্তা chip. দ্বিতীয়ত, বড় CPU-তেও instruction cache সীমিত (প্রায়ই ৩২–৬৪ KB); ঘন কোডে একই cache-এ বেশি কোড ধরে, miss কমে। তৃতীয়ত, মেমরি থেকে instruction আনার bandwidth আর শক্তিও কমে।

তাহলে ARM64 (২০১১ সালে ঘোষিত ARMv8-A) কেন Thumb বাদ দিল? বড় core-এর জন্য ARM-এর যুক্তি ছিল: সব instruction ৪ বাইট হলে একসাথে অনেকগুলো decode করা সহজ — প্রতিটি instruction কোথায় শুরু, তা আগে থেকেই জানা। ভিন্ন দৈর্ঘ্য মানে প্রতিটির শুরু খুঁজতে আগেরটি আংশিক decode করতে হয়, যা x86 decoder-এর একটি বড় জটিলতা। ARM64 তাই code size-এর ক্ষতি পূরণ করল ঘন instruction দিয়ে (ldp/stp, shift-করা operand, madd, post-index). এই পর্বের টেবিলে ARM64-এর instruction সংখ্যা তাই RISC-V-এর চেয়ে কিছুটা কম।

মূল উপলব্ধি: Code density একটি trade-off — ছোট কোড বনাম সহজ, চওড়া decoder; microcontroller আর বড় server CPU-র উত্তর তাই আলাদা হতেই পারে।

প্র ০৪Optimizer একটি null পরীক্ষা মুছে দিল আর kernel-এ নিরাপত্তা-ত্রুটি হলো — দোষ compiler-এর, না প্রোগ্রামারের?

২০০৯ সালে Linux kernel-এর একটি নেটওয়ার্ক ড্রাইভারে (tun) এমন একটি কোড পাওয়া যায়: প্রথমে একটি pointer-এর ভেতরের field পড়া হচ্ছে, তার পরের লাইনে পরীক্ষা হচ্ছে pointer-টি null কি না — অংশ ৯-এর first() ফাংশনের মতোই। GCC যুক্তি দিল: pointer আগেই dereference হয়েছে, null হলে সেটাই undefined behaviour, তাই এখানে pointer null হতে পারে না — পরীক্ষাটি অপ্রয়োজনীয়, মুছে ফেলা যায়। সাধারণ প্রোগ্রামে null dereference মানে crash, কারণ ঠিকানা 0-তে কিছু map করা থাকে না। কিন্তু kernel-এ, কিছু পরিস্থিতিতে আক্রমণকারী user প্রোগ্রাম ঠিকানা 0-তে নিজের ডেটা বসাতে পারত; তখন crash না হয়ে kernel আক্রমণকারীর ডেটা পড়ত, আর যে পরীক্ষা বাঁচাতে পারত সেটি নেই। ত্রুটিটি CVE-2009-1897 নামে নথিভুক্ত।

দোষ কার? C standard-এর দিক থেকে compiler সম্পূর্ণ নিয়ম মেনেছে — UB থাকলে compiler-এর কোনো দায় নেই। প্রোগ্রামারের ভুল ছিল পরীক্ষার আগে dereference করা। কিন্তু বাস্তব প্রকৌশলের দিক থেকে দুই পক্ষই শিখেছে। Linux kernel এখন -fno-delete-null-pointer-checks flag দিয়ে build হয়, যাতে compiler এমন পরীক্ষা না মোছে; একইভাবে -fno-strict-overflow দিয়ে signed overflow-কে wrap ধরে নেওয়া হয়। আর ঠিকানা 0-এর আশপাশে user প্রোগ্রামের map করা সাধারণত নিষিদ্ধ (mmap_min_addr সেটিং)।

এই ঘটনা একটি বড় বিতর্কও উসকে দেয়: compiler কি UB-কে “অনুমতি” হিসেবে দেখবে, নাকি সতর্কভাবে? Compiler-প্রকৌশলীদের যুক্তি, এই অনুমানগুলোই লুপ vectorize বা induction variable সরলীকরণের মতো বড় optimization সম্ভব করে — অংশ ৪-এর মতো লুপে int i-কে pointer-এ বদলানোর সময়ও compiler প্রায়ই “i overflow করবে না” অনুমানটি কাজে লাগায়, বিশেষ করে ৬৪-বিট মেশিনে, যেখানে ৩২-বিট i থেকে ৬৪-বিট ঠিকানা বানাতে হয়। সমালোচকদের যুক্তি, নিরাপত্তা-সংবেদনশীল কোডে এই ঝুঁকি অগ্রহণযোগ্য। ফলাফল হিসেবে এসেছে UBSan-এর মতো টুল, -fwrapv-এর মতো flag, আর Rust-এর মতো ভাষা যেখানে সাধারণ (safe) কোডে UB ঘটানোই সম্ভব নয়।

মূল উপলব্ধি: Compiler আপনার ইচ্ছা নয়, ভাষার নিয়ম অনুসরণ করে; নিয়মের ফাঁক বোঝা আর টুল দিয়ে ধরা — দুটোই প্রোগ্রামারের দায়িত্ব।

প্র ০৫Compiler যদি এত ভালো হয়, তাহলে আজও কেউ হাতে assembly লেখেন কেন? কোথায় লেখেন?

সাধারণ কোডে আজকের compiler বেশিরভাগ মানুষের চেয়ে ভালো assembly লেখে — অংশ ৬-এর lea কৌশল, clang-এর w × (8h + 3) বীজগণিত, বা ARM64-এ vectorize করা লুপ একজন মানুষ হাজার লাইনে ধারাবাহিকভাবে করতে পারবেন না। তবু কিছু জায়গায় হাতের assembly আজও টিকে আছে।

প্রথম — যা C-তে বলাই যায় না। CPU চালু হওয়ার পরের প্রথম instruction (stack তখনো তৈরি নয়, তাই C চলবে না), context switch-এ সব register বাঁচানো আর ফেরানো, interrupt vector, privileged register (RISC-V-এর CSR, x86-এর control register) পড়া-লেখা। প্রতিটি OS kernel আর firmware-এ এমন কয়েকশো বা কয়েক হাজার লাইন assembly থাকে।

দ্বিতীয় — গতির শেষ বিন্দু। ভিডিও decoder-এর মতো প্রকল্প (যেমন FFmpeg বা AV1 decoder dav1d) প্রতিটি ISA-র SIMD-এর জন্য হাতে লেখা assembly ব্যবহার করে, কারণ এখানে কয়েক শতাংশ গতি মানে কোটি কোটি যন্ত্রে ব্যাটারি আর বিদ্যুৎ। Compiler-এর auto-vectorizer এসব জটিল লুপে প্রায়ই পিছিয়ে থাকে।

তৃতীয় — নিরাপত্তা। Cryptography-র কোডে সময়ের পার্থক্য থেকেই গোপন চাবি ফাঁস হতে পারে (timing side channel). গোপন ডেটার ওপর নির্ভর করে কোনো branch বা মেমরি-ঠিকানা থাকা চলবে না। কিন্তু compiler “সমান-সময়” নিশ্চিত করার প্রতিশ্রুতি দেয় না — অংশ ৬-এর মতো সে শাখা যোগ বা বাদ দিতে পারে। তাই OpenSSL-এর মতো library-র গুরুত্বপূর্ণ অংশ প্রায়ই assembly-তে বা assembly তৈরির script-এ লেখা।

আর সবচেয়ে বড় ব্যবহার আসলে পড়া, লেখা নয়। Performance engineer listing পড়ে দেখেন লুপ vectorize হলো কি না, spill আছে কি না; নিরাপত্তা গবেষক binary পড়ে দুর্বলতা খোঁজেন; compiler-প্রকৌশলী আর embedded প্রকৌশলী নিশ্চিত হন যে যন্ত্রের register সত্যিই পড়া হচ্ছে। এই পর্বের দক্ষতাগুলো ঠিক এর জন্য।

মূল উপলব্ধি: Assembly লেখা আজ বিশেষজ্ঞের কাজ, কিন্তু assembly পড়া প্রতিটি systems প্রকৌশলীর মৌলিক দক্ষতা — compiler-কে বিশ্বাস করার আগে যাচাই করার উপায়।

অনুশীলন

পরীক্ষার ধাঁচে: listing পড়ে C লেখা, -O2-এর আচরণ অনুমান, আর instruction গোনা। আগে নিজে চেষ্টা করুন।

  1. Listing থেকে C লিখুন: নিচের কোড clang -O2 RISC-V rv32im-এর আউটপুট (f(const int *a, int n)). C ফাংশনটি লিখুন, আর srli a3, a3, 31 লাইনের অর্থ ব্যাখ্যা করুন।
    f:
        blez  a1, .LBB0_4
        mv    a2, a0
        li    a0, 0
        slli  a1, a1, 2
        add   a1, a2, a1
    .LBB0_2:
        lw    a3, 0(a2)
        addi  a2, a2, 4
        srli  a3, a3, 31
        add   a0, a3, a0
        bne   a2, a1, .LBB0_2
        ret
    .LBB0_4:
        li    a0, 0
        ret

    কাঠামো অংশ ৪-এর sum-এর মতোই: n ≤ 0 হলে 0; নইলে pointer a2 থেকে শেষ-ঠিকানা a + 4n পর্যন্ত প্রতিটি int পড়া। পার্থক্য একটি লাইনে: পড়া মান a3-কে ৩১ ঘর ডানে logical shift. ৩২-বিট two's complement-এ তখন শুধু চিহ্ন-বিট থাকে — ঋণাত্মক হলে 1, নইলে 0. তাই a0 += (a[i] < 0). ফাংশনটি ঋণাত্মক সংখ্যা গোনে:

    int f(const int *a, int n) {
        int c = 0;
        for (int i = 0; i < n; i++)
            if (a[i] < 0) c++;
        return c;
    }

    C-তে if থাকলেও listing-এ কোনো শর্তের শাখা নেই — compiler তুলনাকে বিট-কৌশলে বদলে শাখাহীন করেছে। gcc x86-64-এ একই জিনিস shr eax, 31 আর ARM64-এ add w0, w0, w10, lsr #31.

  2. -O2 কী করবে, অনুমান করুন: int k(int x) { return (x * 8) + (x * 2) - 60 * 60; } — x86-64 gcc -O2-এ কোনো imul থাকবে কি? কতগুলো instruction আশা করেন? RISC-V-এ কী হতে পারে?

    প্রথমে compiler যা নিশ্চিতভাবে করবে: 60 * 60 → 3600 (constant folding), আর 8x + 2x → 10x (বীজগণিত)। ফাংশনটি তাই 10x − 3600. ১০ দিয়ে গুণ imul ছাড়াই করা যায়। gcc 17 -O2-এর আসল আউটপুট:

    lea  eax, [rdi-1800+rdi*4]    ; eax = 5x - 1800
    add  eax, eax                 ; eax = 10x - 3600
    ret

    তিনটি instruction, কোনো imul নেই: compiler 10x − 3600-কে লিখল 2 × (5x − 1800), আর 5x − 1800 একটি lea-তে (base + index×4 + displacement). Clang RISC-V একই ধারণা অন্যভাবে: (x − 360) × 10 = (x − 360)·8 + (x − 360)·2 → addi a0, a0, -360, slli a1, a0, 1, slli a0, a0, 3, add a0, a0, a1, ret. ARM64 clang বেছে নিল madd (গুণ আর যোগ এক instruction-এ)। হুবহু instruction অনুমান করা কঠিন; পরীক্ষায় যা প্রত্যাশিত তা হলো: ধ্রুবক ভাঁজ হবে, গুণ shift/lea-তে যাবে বা একটি গুণে নামবে, instruction ৩–৫টি।

  3. Instruction গুনুন: অংশ ৪খ-এর RISC-V listing ধরে n = 10 হলে sum() -O0 আর -O2-এ মোট কতগুলো instruction চালায়? n = 0 হলে? কতগুলো load আর store?

    -O0: শুরু (prologue থেকে j পর্যন্ত) ১০টি; প্রতি ঘুরে শর্ত ৪টি (lw, lw, bge, j) + দেহ ৯টি + বৃদ্ধি ৪টি = ১৭টি; শেষবার শর্ত পরীক্ষা ৩টি (bge লাফ দেয়, j চলে না); ফেরা ৫টি (lw a0, lw ra, lw s0, addi, ret). মোট 17n + 18. n = 10 → 188; n = 0 → 18.

    -O2: n > 0 হলে শুরু ৫টি (blez, mv, li, slli, add), প্রতি ঘুরে ৪টি, শেষে ret ১টি: 4n + 6. n = 10 → 46. n = 0 হলে blez লাফ দেয়, তারপর li, ret: 3.

    মেমরি: -O0-তে প্রতি ঘুরে ৭ load + ২ store; শুরুতে ৬টি store (ra, s0, a, n, s, i), শেষবার শর্তে ২ load, ফেরায় ৩ load. n = 10: load = 70 + 2 + 3 = 75, store = 20 + 6 = 26. -O2-তে load = n = 10, store = 0. অনুপাত (188 ÷ 46 ≈ 4.1) n বাড়লে 17/4 ≈ 4.25-এর দিকে যায়।

  4. Struct, offset আর char-এর চিহ্ন: struct rec { char c; short h; int i; char d; }; — প্রতিটি field-এর offset আর sizeof বলুন (৪-বাইট int alignment ধরে)। return p->d; RISC-V আর x86-64-এ কোন load instruction-এ হবে, আর কেন দুটি আলাদা?

    c offset 0 (১ বাইট); short h-কে ২-এর গুণিতকে বসতে হয় → offset 2 (মাঝে ১ বাইট padding); int i → offset 4; char d → offset 8; struct-এর মোট আকারকে সবচেয়ে বড় alignment (৪)-এর গুণিতক হতে হয়, তাই 9 থেকে বেড়ে sizeof = 12. Compiler-এর আউটপুট মিলেছে: sizeof(struct rec) ফেরত দেওয়া ফাংশন → li a0, 12 আর mov eax, 12; p->h → lh a0, 2(a0) আর movsx eax, WORD PTR [rdi+2].

    p->d: RISC-V clang দিল lbu a0, 8(a0) (zero-extend), x86-64 gcc দিল movsx eax, BYTE PTR [rdi+8] (sign-extend). কারণ ABI: x86-64 System V-এ সাধারণ char signed, কিন্তু RISC-V আর ARM64-এর Linux/ELF ABI-তে char unsigned. একই C কোড, ১২৭-এর বেশি মানের বাইটে দুই ISA-তে ভিন্ন ফল! বহনযোগ্য কোডে তাই signed char বা unsigned char (বা int8_t/uint8_t) স্পষ্ট লিখুন।

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

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

ক্রমএকটি C প্রোগ্রাম চলা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📦 Preprocess — #include আর macro বসিয়ে .i
  2. 📐 Compile — নির্দিষ্ট ISA-র assembly, .s
  3. 🔢 Assemble — machine code আর relocation-সহ .o
  4. 🔗 Link — relocation পূরণ করে executable
  5. ⚡ Load — মেমরিতে এনে PC-তে entry point বসানো

x86-64-এ gcc -O2 int add(int a, int b) { return a + b; }-এর জন্য lea eax, [rdi+rsi] লেখে। add-এর বদলে lea কেন?

  • lea মেমরি থেকে a আর b পড়ে আনে
  • x86-এর add দুই-operand-এর; lea দুটি register যোগ করে তৃতীয়টিতে রাখতে পারে, একটি mov বাঁচে
  • add overflow-তে exception দেয়, lea দেয় না
  • lea দিয়ে floating-point যোগ দ্রুত হয়

শুধু -march=rv32i (M extension ছাড়া) দিয়ে int mul2(int q, int p) { return q * p; } compile করলে clang কী লেখে?

  • একটি mul a0, a0, a1
  • Compile error: গুণ সম্ভব নয়
  • Library ফাংশন __mulsi3-এ call বা tail call
  • একটিমাত্র slli

gcc -O2 int grows(int x) { return x + 1 > x; }-কে mov eax, 1 আর ret-এ বদলে দেয়। কারণ কী?

  • Signed overflow undefined behaviour, তাই compiler ধরে নেয় x + 1 সবসময় x-এর চেয়ে বড়
  • এটি gcc-র একটি পরিচিত bug
  • x86-এ int যোগ কখনো wrap করে না
  • x-এর মান compile-এর সময়েই জানা, তাই constant folding

একটি microcontroller-এ while ((STATUS & 1) == 0) { } লুপ -O0-তে ঠিক চলে, কিন্তু -O2-তে চিরকাল আটকে থাকে। STATUS একটি যন্ত্র-register-এর ঠিকানা। সবচেয়ে সম্ভাব্য সমাধান কী?

  • সবসময় -O0 দিয়ে build করা
  • লুপের চারপাশে একটি mutex lock দেওয়া
  • লুপের ভেতরে একটি অর্থহীন হিসাব বসানো
  • STATUS-এর pointer-কে volatile হিসেবে ঘোষণা করা

AT&T syntax-এ লেখা movl %edi, %eax-এর অর্থ কী?

  • eax-এর মান edi-তে কপি
  • edi-এর ৩২-বিট মান eax-এ কপি
  • edi-তে রাখা ঠিকানা থেকে মেমরি পড়ে eax-এ
  • ৬৪-বিট rdi থেকে rax-এ কপি

int tc(int x) { return g(x + 1); } -O2-এ x86-64-এ add edi, 1 আর jmp g হয়। call g + ret-এর বদলে jmp কেন বৈধ?

  • g inline হয়ে গেছে, তাই কোনো call লাগে না
  • jmp ফেরার ঠিকানা stack-এ রাখে
  • g ফেরার পর tc-এর আর কোনো কাজ নেই, তাই g-এর ret সরাসরি tc-এর caller-এ ফিরতে পারে
  • x86-এ call আর jmp সবসময় সমান

শব্দকোষ

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

মনে রাখুন

  1. Preprocess → compile → assemble → link → load; ISA ঢোকে compile-এ, object file-এ থাকে machine code + relocation, linker ঠিকানা বসায়।
  2. দেখার টুল: gcc -S (-masm=intel), objdump -d, আর তিন ISA পাশাপাশি দেখতে Compiler Explorer; আউটপুট version-ভেদে বদলায়।
  3. -O0 = সব ভ্যারিয়েবল stack-এ (debug-এর জন্য); -O2 = register, কম load, strength reduction, induction variable elimination, inline, recursion থেকে লুপ, tail call, কখনো vectorization.
  4. x86-64-এ instruction ১–১৫ বাইট আর মেমরি-operand; ARM64 আর RV32-এ ৪ বাইট, RVC-তে ২ — instruction সংখ্যা আর code size আলাদা প্রশ্ন।
  5. Optimizer ধরে নেয় UB ঘটে না; যন্ত্র-register-এ volatile; inline assembly শুধু যেখানে C-তে বলা অসম্ভব।

পরের পর্বে: মডিউল ৫-এ software-এর দিক থেকে ISA দেখা শেষ। এবার CPU-র ভেতরে ঢুকব — একটি RISC-V instruction কীভাবে register file, ALU আর মেমরির মধ্য দিয়ে এক clock cycle-এ পুরো পথ পার হয়: single-cycle datapath.

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

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