Control Flow ইন অ্যাসেম্বলি — cmp, test, আর conditional jump-এর পরিবার
Control Flow in Assembly
কোনো CPU-তে if instruction নেই — শুধু cmp/test দিয়ে flags সেট করা আর conditional jump দিয়ে সেই flags পড়ে সিদ্ধান্ত নেওয়া; if/while/for সবই এই একই দুইটা প্রাথমিক উপাদানের ভিন্ন বিন্যাস মাত্র।
আগে এটা বুঝি
গত দুই লেসনে আমরা দেখেছি একটা ফাংশনের ভেতরে-বাইরে যাওয়ার mechanics — argument কোথায় যায়, call/ret কীভাবে কাজ করে, stack frame কেমন দেখায়। কিন্তু একটা ফাংশনের ভেতরে, যেখানে আসল যুক্তি (logic) থাকে — if, while, for — এসব এখনও এক ধরনের রহস্য। C কোডে এই তিনটা keyword literally আছে; assembly-তে এদের কোনো ছাপই দেখা যায় না।
এর কারণ সহজ, কিন্তু প্রথমবার শুনলে চমকে দেওয়ার মতো: কোনো CPU-তে if নামের কোনো instruction নেই। না while, না for। যা আছে তা হলো মাত্র দুইটা প্রাথমিক উপাদান — একটা comparison (যা flags সেট করে) আর একটা conditional jump (যা সেই flags পড়ে হয় জাম্প করে, নয় করে না)। প্রতিটা if, প্রতিটা loop, প্রতিটা switch — সবকিছুই এই দুইটা উপাদানের বিভিন্ন বিন্যাস, compiler-এর তৈরি label আর jump-এর একটা কাঠামো।
এই লেসনে আমরা ঠিক দেখব কীভাবে। গত মডিউলের registers-pc-flags লেসনের flags register, আর তারও আগের digital-logic/combinational-subtractors-comparators লেসনের ZF/CF/SF/OF সার্কিট — এই দুটোই এখন একসাথে কাজে লাগবে, একটা নতুন প্রশ্নের উত্তরে: branch নেব, নাকি নেব না?
মূল ধারণা
cmp — subtract যার ফলাফল ফেলে দেওয়া হয়
cmp a, b (AT&T syntax — মনে রাখুন, source আগে, তাই এটা গণনা করে b - a) হুবহু একটা sub instruction-এর মতোই ALU-তে চলে — একই subtractor circuit, একই flags আপডেট (digital-logic/combinational-subtractors-comparators লেসনের সেই circuit, হুবহু) — শুধু একটা পার্থক্য: গণনার ফলাফলটা কোথাও লেখা হয় না, ফেলে দেওয়া হয়। শুধু flags থেকে যায়।
sub b, a ≡ a ← a - b (ফলাফল রাখে, flags-ও আপডেট করে)
cmp b, a ≡ a - b গণনা করে, ফলাফল ফেলে দেয়, শুধু flags রাখেকেন এই পার্থক্য দরকার? কারণ প্রায়ই আমরা a-এর আসল মান পরেও দরকার হবে — শুধু a আর b-র সম্পর্ক (a \< b? a == b?) জানতে চাই, a-কে নষ্ট না করে। cmp ঠিক এই প্রয়োজন মেটায় — subtraction-এর “পার্শ্ব-ফলাফল” (flags) নেয়, মূল ফলাফল ফেলে দেয়।
test — AND যার ফলাফল ফেলে দেওয়া হয়
test a, b একই ধারণা, কিন্তু subtraction-এর বদলে bitwise AND দিয়ে:
and b, a ≡ a ← a AND b (ফলাফল রাখে, flags আপডেট করে)
test b, a ≡ a AND b গণনা করে, ফলাফল ফেলে দেয়, শুধু flags রাখেসবচেয়ে সাধারণ ব্যবহার — test reg, reg (একই register নিজের সাথে):
test %eax, %eaxX AND X সবসময় হুবহু X-ই ফেরত দেয় (bit-by-bit — প্রতিটা bit নিজের সাথে AND করলে নিজেই থাকে)। তাই test %eax, %eax-এর flags ঠিক তাই বলে যা %eax-এর নিজস্ব মান বলে — বিশেষত, ZF = 1 হয় ঠিক তখনই যখন %eax = 0 (কারণ X AND X = 0 ⟺ X = 0), আর SF সরাসরি %eax-এর sign bit দেখায়। এটাই “এই register কি শূন্য?” প্রশ্নের সবচেয়ে সস্তা, প্রচলিত compiler-idiom — cmp $0, %eax লেখাও সম্ভব ছিল (একই ফলাফল দিত), কিন্তু test reg, reg ছোট এনকোডিং আর কোনো literal constant ($0) ছাড়াই কাজ সারে।
Conditional jump-এর পুরো পরিবার
cmp বা test-এর পর flags সেট হয়ে গেলে, একটা conditional jump (Jcc — “jump if condition”) সেই flags পড়ে সিদ্ধান্ত নেয়। পুরো পরিবারটা দুই ভাগে ভাগ করা যায়:
| উদ্দেশ্য | Signed jump | Unsigned jump | Flag শর্ত |
|---|---|---|---|
| সমান | je / jz | je / jz | ZF = 1 |
| অসমান | jne / jnz | jne / jnz | ZF = 0 |
| ছোট (less) | jl / jnge | jb / jnae / jc | signed: SF ≠ OF • unsigned: CF = 1 |
| ছোট-বা-সমান | jle / jng | jbe / jna | signed: ZF=1 বা SF≠OF • unsigned: ZF=1 বা CF=1 |
| বড় (greater) | jg / jnle | ja / jnbe | signed: ZF=0 এবং SF=OF • unsigned: ZF=0 এবং CF=0 |
| বড়-বা-সমান | jge / jnl | jae / jnb / jnc | signed: SF = OF • unsigned: CF = 0 |
কেন signed আর unsigned-এর জন্য আলাদা jump লাগে — যদিও cmp হুবহু একই
এটাই এই লেসনের সবচেয়ে গুরুত্বপূর্ণ, প্রায়ই বিভ্রান্তিকর প্রশ্ন — আর computer-representation/signed-integers আর digital-logic/combinational-subtractors-comparators লেসনের সরাসরি callback দিয়েই এর উত্তর আসে।
cmp %ebx, %eax (গণনা করে eax - ebx) একটাই subtraction — একই bit-pattern, একই circuit, একই flags সেট হয়। CPU জানে না আপনি eax/ebx-কে signed না unsigned হিসেবে দেখতে চান — সাবট্র্যাকশনের সময় এই তথ্যটা কোথাও নেই, signed-integers লেসনের মূল শিক্ষা মনে করুন: একই bit pattern-কে দুইভাবে পড়া যায়, hardware শুধু bit নিয়ে কাজ করে।
পার্থক্যটা তাই cmp-এ নেই — jump instruction কোন flag পড়ছে তাতেই। digital-logic/combinational-subtractors-comparators লেসনে ঠিক এই নিয়ম derive করা হয়েছিল:
একই subtraction, একই ZF/CF/SF/OF আউটপুট — কিন্তু jl (signed less) SF⊕OF পড়ে, jb (unsigned below) সরাসরি CF পড়ে। এই দুইটা bit-combination ভিন্ন জিনিস নির্দেশ করে, তাই ভিন্ন jump instruction লাগে — CPU এক subtraction থেকে দুই ধরনের প্রশ্নের উত্তর একসাথে বের করে রাখে (ZF/CF/SF/OF-এর পূর্ণ সেট), আর কোন jump ব্যবহার হচ্ছে সেটাই ঠিক করে কোন প্রশ্নটা “জিজ্ঞাসা করা হলো”।
ভেতরে কী ঘটছে
Jcc-এর ভেতরে — একটা ছোট boolean সার্কিট, flags-এর উপর
প্রতিটা Jcc instruction hardware-স্তরে আসলে একটা ছোট combinational circuit — flags register-এর নির্দিষ্ট bit-গুলো ইনপুট নিয়ে একটা single boolean বের করে “জাম্প নেব কি না”:
jl (signed less) → branch_taken = SF XOR OF
jb (unsigned below) → branch_taken = CF
je (equal) → branch_taken = ZF
jg (signed greater) → branch_taken = (NOT ZF) AND (SF XOR OF ইনভার্স, অর্থাৎ SF == OF)এই circuit-টা control unit-এর একটা অংশ — decode ধাপে instruction-টা “কন্ডিশনাল ব্র্যাঞ্চ” হিসেবে চেনা যায়, তারপর ঠিক কোন flag-combination পড়তে হবে তা opcode-এর মধ্যেই এনকোড করা থাকে (Jcc-এর বিভিন্ন variant আলাদা opcode, 0F 8x রেঞ্জে x86-64-এ)। এই boolean-এর ফলাফলই আগের লেসনের সেই PC-selecting MUX-কে নিয়ন্ত্রণ করে (registers-pc-flags-এর hood সেকশনের সেই MUX ডায়াগ্রাম মনে করুন) — true হলে PC ← target, false হলে PC ← PC + length।
Compiler-এর loop-পুনর্গঠন — কেন body-প্রথম, test-পরে
একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ compiler-optimization এই লেসনের example সেকশনে দেখবেন — একটা source-level while loop (condition আর body-সহ) কম্পাইল হয়ে প্রায়ই এই কাঠামো নেয়:
jmp .L_test ; প্রথমে সরাসরি test-এ যাও
.L_body:
...body...
.L_test:
cmp ...
jCC .L_body ; শর্ত সত্য হলে আবার body-তে“স্বাভাবিক” (naive) অনুবাদ হতো test-প্রথমে, তারপর conditionally body স্কিপ করা, তারপর body-শেষে unconditionally test-এ ফিরে যাওয়া — প্রতিটা iteration-এ দুইটা jump (একটা body স্কিপ করতে, একটা test-এ ফিরতে)। কিন্তু compiler-এর ব্যবহৃত কাঠামোয় প্রতিটা iteration-এ লাগে মাত্র একটা conditional jump (test থেকে body-তে ফিরে যাওয়ার জন্য) — শুরুতে একটা মাত্র বাড়তি unconditional jump (jmp .L_test), যেটা loop-এ মোটে একবারই ঘটে। যেহেতু loop সাধারণত অনেকবার iterate করে (common case), প্রতি-iteration এক jump কমানো একটা বাস্তব performance জয় — এটাই compiler কেন প্রায় সবসময় এই “test-at-bottom” রূপান্তর করে, উদাহরণ সেকশনে হুবহু এই প্যাটার্নই দেখবেন।
উদাহরণ
If/else — সবচেয়ে সরল রূপ
int classify(int x) {
if (x > 0) {
return 1;
} else {
return -1;
}
}classify:
cmp $0, %edi # x - 0 গণনা করে flags সেট করে
jg .L_positive # SF=OF এবং ZF=0 হলে (x > 0), জাম্প
mov $-1, %eax # else শাখা — fall-through (জাম্প না হলে)
jmp .L_end
.L_positive:
mov $1, %eax # if শাখা
.L_end:
retলক্ষ্য করুন কাঠামোটা — cmp দিয়ে flags সেট, jg (signed greater) দিয়ে branch, “then” branch-এর জন্য একটা label, “else” শাখা fall-through হিসেবে (কোনো label লাগে না, স্বাভাবিক sequential execution), আর else-এর শেষে একটা jmp দিয়ে then-শাখার কোড এড়িয়ে .L_end-এ চলে যাওয়া (নাহলে else-এর execution then-এর কোডেও পড়ে যেত)।
While loop — test-at-bottom কাঠামো
int sum_to_n(int n) {
int total = 0;
int i = 1;
while (i <= n) {
total += i;
i++;
}
return total;
}sum_to_n:
mov $0, %eax # total = 0 (eax-এই থাকবে, return-এর জন্য প্রস্তুত)
mov $1, %ecx # i = 1
jmp .L_test # আগে test-এ — hood সেকশনের ব্যাখ্যা অনুযায়ী
.L_body:
add %ecx, %eax # total += i
add $1, %ecx # i++
.L_test:
cmp %edi, %ecx # i - n গণনা করে flags সেট করে
jle .L_body # i <= n হলে (signed), আবার body-তে
ret # loop শেষ — total (eax-এ) returnমাত্র একটা conditional jump (jle) — প্রতিটা iteration-এ ঠিক এই একটাই jump execute হয়, hood সেকশনে ব্যাখ্যা করা optimization অনুযায়ী।
For loop — while-এরই একটা সিনট্যাক্টিক রূপ
int factorial(int n) {
int result = 1;
for (int i = 1; i <= n; i++) {
result *= i;
}
return result;
}factorial:
mov $1, %eax # result = 1
mov $1, %ecx # i = 1 (for-এর init অংশ)
jmp .L_test
.L_body:
imul %ecx, %eax # result *= i
add $1, %ecx # i++ (for-এর increment অংশ)
.L_test:
cmp %edi, %ecx # i - n
jle .L_body # for-এর condition অংশ
retএই assembly-টা হুবহু আগের while-loop-এর কাঠামো — শুধু C source-এ init/condition/increment তিনটা আলাদা জায়গায় লেখা হয়েছিল, compiled output-এ তারা যথাক্রমে ঠিক সেই একই তিনটা জায়গায় বসেছে (init prologue-এর পরে, condition .L_test-এ, increment body-র শেষে)। এটাই স্পষ্ট প্রমাণ — for আসলে while-এরই “syntactic sugar”, hardware-স্তরে দুটোর মধ্যে কোনো পার্থক্যই নেই।
test reg, reg ইডিয়ম বাস্তব কম্পাইলার-আউটপুটে
int is_nonzero(int x) {
return x != 0;
}is_nonzero:
test %edi, %edi # edi এর সাথে edi-র AND — শূন্য কি না চেক
setne %al # ZF=0 হলে (edi ≠ 0), al = 1, নাহলে al = 0
movzbl %al, %eax # al-কে zero-extend করে পূর্ণ eax বানানো
retsetne (Jcc-পরিবারেরই একটা আত্মীয় — jump-এর বদলে সরাসরি একটা byte-এ ০/১ বসায়, flags থেকে) আর movzbl (byte থেকে long-এ zero-extend) — একসাথে branch-ছাড়াই একটা boolean মান তৈরি করার প্রচলিত প্যাটার্ন, test reg, reg-এর সরাসরি ব্যবহার।
Short-circuit evaluation — &&-এর দ্বিতীয় অংশ মূল্যায়নই না হওয়া
int safe_check(int *p) {
if (p != 0 && *p > 0) {
return 1;
}
return 0;
}safe_check:
test %rdi, %rdi # p == NULL চেক (0-র সাথে তুলনা, test দিয়ে)
je .L_false # p == NULL হলে সরাসরি false — *p একদম স্পর্শই করা হয়নি
mov (%rdi), %eax # শুধু এই পথে পৌঁছালেই *p পড়া হয় — p নিশ্চিতভাবে non-NULL
cmp $0, %eax
jle .L_false
mov $1, %eax
ret
.L_false:
mov $0, %eax
retলক্ষ্য করুন — mov (%rdi), %eax (যেটা *p পড়ে) instruction-টা শুধুমাত্র .L_false-এ jump না-হওয়া পথে execute হয়, অর্থাৎ শুধু তখনই যখন p != 0 ইতিমধ্যে নিশ্চিত। এটা নিছক একটা optimization না — C-র ভাষাগত নিয়ম (&&-এর short-circuit semantics) এই নির্দিষ্ট jump-কাঠামো ছাড়া বাস্তবায়নই করা যেত না। যদি compiler দুইটা condition আগেই আলাদাভাবে গণনা করে রাখত (branch ছাড়া), p == NULL-এর ক্ষেত্রে *p পড়তে গিয়ে একটা null-pointer-dereference crash হতো — ঠিক সেই bug-টাই এই jump-কাঠামো এড়িয়ে যায়।
Switch statement — অনেক case হলে jump table
int day_type(int day) {
switch (day) {
case 0: return 100;
case 1: return 101;
case 2: return 102;
case 3: return 103;
default: return -1;
}
}অল্প কয়েকটা case হলে compiler সাধারণত chained cmp/je-ই ব্যবহার করে (এই লেসনের if/else প্যাটার্নের সম্প্রসারণ মাত্র)। কিন্তু case-সংখ্যা বেশি আর ক্রমিক (0, 1, 2, 3, …) হলে, compiler প্রায়ই একটা jump table ব্যবহার করে:
day_type:
cmp $3, %edi # সীমার বাইরে কি না — default-এর জন্য একটামাত্র bounds-check
ja .L_default
mov %edi, %eax
lea .L_table(%rip), %rdx
movslq (%rdx,%rax,4), %rax # table[day] থেকে সরাসরি target-এর offset পড়া
add %rdx, %rax
jmp *%rax # ★ indirect jump — target একটা register থেকে, কোনো label সরাসরি লেখা নেই
.L_case0:
mov $100, %eax
ret
.L_case1:
mov $101, %eax
ret
.L_case2:
mov $102, %eax
ret
.L_case3:
mov $103, %eax
ret
.L_default:
mov $-1, %eax
ret
.L_table:
.long .L_case0 - .L_table
.long .L_case1 - .L_table
.long .L_case2 - .L_table
.long .L_case3 - .L_tablejmp *%rax — একটা indirect jump, target সরাসরি assembly-তে লেখা কোনো label না, বরং একটা register-এ থাকা মান (যেটা runtime-এ table থেকে পড়া হয়েছে)। এটা এই লেসনের বাকি সব jump (je, jl, jmp .L_body) থেকে গুণগতভাবে ভিন্ন — সেগুলোর target compile-time-এ স্থির, jmp *%rax-এর target শুধু runtime-এই জানা যায়। day-এর মান সরাসরি table-এর index হিসেবে ব্যবহৃত হচ্ছে — n-টা cmp/je চেইনের O(n) তুলনার বদলে, একটামাত্র bounds-check (cmp $3, %edi; ja) আর একটা array-lookup দিয়ে সরাসরি সঠিক case-এ পৌঁছানো, O(1)।
নিজে চালিয়ে দেখুন
Godbolt-এ -O0 বনাম -O2 control-flow shape তুলনা করুন
sum_to_n ফাংশনটা (এই লেসনের example সেকশন থেকে) godbolt.org-এ পেস্ট করুন, x86-64 gcc target।
প্রথমে -O0 দিয়ে কম্পাইল করুন। লক্ষ্য করুন — test-প্রথমে-না, বরং প্রতিটা iteration-এ দুইটা jump ব্যবহার হচ্ছে (একটা condition-fail হলে loop-এর বাইরে যেতে, একটা body-শেষে test-এ ফিরতে) — এটাই hood সেকশনে উল্লেখিত “naive” রূপান্তর, optimization ছাড়া compiler এটাই generate করে (readability/debuggability অগ্রাধিকার পায়, performance না)।
gcc -O0 -S sum_to_n.c -o sum_to_n_O0.sতারপর -O2 দিয়ে। লক্ষ্য করুন এবার test-at-bottom কাঠামো আসবে — একটামাত্র jmp শুরুতে, তারপর প্রতি-iteration একটাই conditional jump, ঠিক এই লেসনের উদাহরণের মতো।
gcc -O2 -S sum_to_n.c -o sum_to_n_O2.s
diff sum_to_n_O0.s sum_to_n_O2.sনিজে যাচাই করুন: classify ফাংশনটাও পেস্ট করুন, cmp-এর ঠিক পরের jump instruction-টা (jg) নোট করুন — Intel SDM-এর Jcc টেবিলে (রেফারেন্স দেখুন) গিয়ে যাচাই করুন এই instruction সত্যিই ZF=0 AND SF=OF পড়ে, এই লেসনের দাবি অনুযায়ী।
এই লেসনের if/while/for প্যাটার্ন তাত্ত্বিক না — real gcc/clang output ঠিক এই কাঠামোই তৈরি করে, আর hood সেকশনের 'test-at-bottom' optimization সত্যিই -O0-এ অনুপস্থিত, -O2-এ উপস্থিত।
নিজে বানান
দুই দিকে — লেখা আর reverse-engineer করা
- একটা count_down.s লিখুন যা raw assembly-তে (কোনো C ছাড়াই) 5 থেকে 1 পর্যন্ত সংখ্যা print করে — একটা loop, একটা label, একটা conditional jump ব্যবহার করে, printf-কে call করে প্রতিটা iteration-এ
- এটা assemble ও link করুন (printf ব্যবহারের জন্য gcc দিয়ে লিংক করা সহজ হবে, শুধু as দিয়ে না), চালিয়ে যাচাই করুন আউটপুট সঠিক
- এরপর নিচের mystery snippet-টা (কোনো comment ছাড়া দেওয়া) হাতে trace করুন — প্রতিটা label আর jump-এর মানে বের করে এর সমতুল্য C কোড (if/while/for কোনটা মানানসই) লিখে ফেলুন
- আপনার reconstruction যাচাই করতে সেই C কোডটা কম্পাইল করে দেখুন উৎপন্ন assembly এই mystery snippet-এর কাঠামোর সাথে মেলে কি না
Mystery snippet (কোনো ব্যাখ্যা ছাড়া — নিজে বের করুন):
mystery:
mov $0, %eax
mov $0, %ecx
jmp .L_test
.L_body:
mov %ecx, %edx
sub %edi, %edx
test %edx, %edx
jns .L_skip
neg %edx
.L_skip:
add %edx, %eax
add $1, %ecx
.L_test:
cmp %edi, %ecx
jl .L_body
ret(ইঙ্গিত: %edi একটা input parameter (অপরিবর্তিত থাকে পুরো ফাংশনে), %eax accumulator, %ecx loop-counter, %edx প্রতি-iteration একটা temporary — তিনটা আলাদা ভূমিকায় তিনটা আলাদা register, ঠিক বাস্তব কম্পাইলার-আউটপুটের মতো। খেয়াল করুন এখানে দুইটা নেস্টেড control structure আছে — একটা loop-এর ভেতরে একটা conditional, আর loop-টা নিজেই এই লেসনের test-at-bottom কাঠামো অনুসরণ করছে। jns মানে “jump if not sign” — SF=0 হলে জাম্প করে, অর্থাৎ non-negative হলে।)
নিজে বাড়ান: আপনার reconstruction লেখার পর, ভাবুন — এই snippet-টা কি একটা while লিখলে, না একটা for লিখলে বেশি স্বাভাবিক আউটপুট হতো? এই লেসনের উদাহরণ সেকশনের দাবি অনুযায়ী (“for আসলে while-এর syntactic sugar”) — দুটো লিখেই compile করে assembly তুলনা করুন, প্রত্যাশিতভাবে প্রায় অভিন্ন হওয়া উচিত।
বাস্তব সিস্টেমে
Control flow যেখানে বাস্তব সিদ্ধান্ত নির্ধারণ করে
Branch prediction — এই instruction-গুলোর উপরই টিকে থাকা একটা hardware-optimization। আগের মডিউলে (computer-architecture) branch prediction বিস্তারিত এসেছে — সেই পুরো mechanism-টাই ঠিক এই Jcc instruction-গুলোর ভবিষ্যৎ ফলাফল (নেবে/নেবে না) আগে থেকে অনুমান করার চেষ্টা। এই লেসনের sum_to_n-এর মতো loop-এ, jle .L_body প্রায় প্রতিবার “নেবে” (শুধু শেষবার “নেবে না”) — branch predictor এই প্যাটার্ন দ্রুত শিখে ফেলে, misprediction penalty প্রায় সবসময় এড়িয়ে যায়।
Malware/reverse-engineering বিশ্লেষণ — ঠিক এই লেসনের skill। একটা malicious binary-র logic বোঝার সবচেয়ে মৌলিক ধাপ হলো ঠিক এই লেসনের BuildIt exercise-এর মতো — label আর jump-এর কাঠামো পড়ে সমতুল্য high-level control flow পুনর্গঠন করা, কোনো source code ছাড়াই।
Short-circuit evaluation (&&, ||) — conditional jump দিয়ে বাস্তবায়িত। if (p != NULL && p->value > 0) কম্পাইল হলে, প্রথম condition (p != NULL) মিথ্যা হলে দ্বিতীয় condition-টা মূল্যায়নই করা হয় না — সরাসরি একটা conditional jump দ্বিতীয় cmp এড়িয়ে যায়। এটা নিছক একটা optimization না, C-র semantics-এরই অংশ (দ্বিতীয় condition-এ p->value access করলে null-pointer-dereference হতো যদি p == NULL), আর এই লেসনের jump-কাঠামো ঠিক এই semantics বাস্তবায়নের উপায়।
Switch statement — মাঝে মাঝে jump table হয়ে যায়। অনেকগুলো case থাকলে, compiler প্রায়ই একটা chained cmp/je সিরিজের বদলে একটা jump table (memory-তে code-address-এর একটা array) আর একটা indirect jump (jmp *table(,%eax,8)-জাতীয়) ব্যবহার করে — O(n) তুলনার বদলে O(1)-এ সরাসরি সঠিক case-এ লাফ দেয়। এটা এখনও “conditional jump”-এরই একটা আত্মীয়, শুধু condition গণনা করার বদলে সরাসরি index হিসেবে ব্যবহার করে।
Debug (-O0) বনাম optimized (-O2) বিল্ড — ভিন্ন কাঠামো, একই logic। এই লেসনের experiment-এ দেখেছেন — -O0-এ প্রতি-iteration দুইটা jump, -O2-এ একটা। এই পার্থক্যটা জানা না থাকলে debugger-এ optimized বিল্ডের control flow “অদ্ভুত” বা “ভাঙা” মনে হতে পারে — আসলে এটা compiler-এর সচেতন পুনর্গঠন, bug না।
ARM64/RISC-V-তে তুলনা — registers-pc-flags লেসনের callback। RISC-V-তে blt/bge-এর মতো instruction সরাসরি দুইটা register তুলনা করে branch নেয় (কোনো আলাদা cmp লাগে না) — x86-64-র দুই-instruction (cmp + Jcc) প্যাটার্নের বিপরীতে RISC-V-র এক-instruction প্যাটার্ন। ARM64 cmp+conditional-branch (x86-64-র মতোই) এবং cbz/cbnz (সরাসরি zero-check, test+je-এর মতো একক instruction-এ) দুটোই রাখে।
যে ভুলগুলো সবাই করে
“CPU-তে একটা বিশেষ 'if' instruction আছে, উচ্চস্তরের ভাষার if-এর সরাসরি প্রতিরূপ।”
সম্পূর্ণ ভুল, আর এটাই এই লেসনের কেন্দ্রীয় সংশোধন। কোনো ISA-তে if নামের instruction নেই। যা আছে তা মাত্র দুইটা প্রাথমিক উপাদান — comparison (flags সেট করে) আর conditional jump (flags পড়ে জাম্প করে বা করে না)। if, while, for, switch — সবই compiler-এর তৈরি label/jump-কাঠামোর ভিন্ন বিন্যাস, কোনো নতুন hardware primitive না।
“cmp আর sub সম্পূর্ণ ভিন্ন instruction, ভিন্ন circuit ব্যবহার করে।”
ভুল। cmp হুবহু sub-এর একই subtractor circuit ব্যবহার করে, একই flags তৈরি করে — একমাত্র পার্থক্য destination-এ ফলাফল লেখা হয় কি না। hood সেকশনে দেখেছেন এটা কোনো নতুন hardware না, শুধু control-signal-স্তরে “write করো না” একটা ছোট পরিবর্তন।
“যেহেতু cmp signed/unsigned নির্বিশেষে হুবহু একই কাজ করে, jl আর jb-ও একই আচরণ করা উচিত — একই flags পড়ে।”
এটাই ঠিক এই লেসনের সবচেয়ে গুরুত্বপূর্ণ, সরাসরি লক্ষ্যবস্তু ভুল ধারণা। cmp সত্যিই signed/unsigned নির্বিশেষে একই bit-level subtraction করে আর একই পূর্ণ flags-সেট (ZF, CF, SF, OF) তৈরি করে — কিন্তু jl (signed) SF⊕OF পড়ে, jb (unsigned) সরাসরি CF পড়ে। এই দুইটা ভিন্ন flag-combination, তাই একই bit pattern-এর comparison-এ তারা ভিন্ন উত্তর দিতে পারে (এই লেসনের danger-callout-এর A=1000, B=0001 উদাহরণ ঠিক এটাই দেখিয়েছে) — কারণ signed আর unsigned আসলে ভিন্ন প্রশ্ন, একই raw bit-তুলনা না।
“test reg, reg একটা বিশেষ 'zero-check circuit' ব্যবহার করে, যা register সরাসরি শূন্যের সাথে তুলনা করে।”
না — test reg, reg আসলে একটা সাধারণ bitwise AND, register-কে নিজের সাথে AND করা। এতে কোনো “শূন্যের সাথে তুলনা”-র জন্য বিশেষ circuit নেই — শুধু গাণিতিক সত্য ব্যবহার করা হচ্ছে যে X AND X = X, তাই X AND X-এর ফলাফল শূন্য হয় ঠিক তখনই যখন X নিজেই শূন্য। ফলাফল ফেলে দেওয়া হয়, শুধু flags (বিশেষত ZF) থেকে যায়, যেটা তখন je/jne-এর মতো যেকোনো সাধারণ conditional jump দিয়ে পড়া যায়।
বুঝেছেন কি না দেখুন
1signed comparison-এর জন্য “ছোট” (less than) বোঝাতে কোন jump instruction ব্যবহার হয়, আর unsigned-এর জন্য কোনটা? প্রতিটা কোন flag(গুলো) পড়ে?
স্মরণ
Signed: jl (বা jnge) — SF ≠ OF (অর্থাৎ SF ⊕ OF = 1) হলে জাম্প নেয়। Unsigned: jb (বা jnae/jc) — CF = 1 হলে জাম্প নেয়। দুটোই একই cmp-এর flags পড়ে, কিন্তু ভিন্ন combination — কারণ তারা ভিন্ন প্রশ্নের উত্তর দিচ্ছে (signed বনাম unsigned interpretation)।
2test %eax, %eax কেন cmp $0, %eax-এর একটা সাধারণ বিকল্প (এবং সাধারণত compiler-এর পছন্দ)? এই দুইটার মধ্যে flags-এর ফলাফল কি হুবহু একই, নাকি কোথাও ভিন্ন হতে পারে?
যুক্তি
test %eax, %eax কেন cmp $0, %eax-এর একটা সাধারণ বিকল্প (এবং সাধারণত compiler-এর পছন্দ)? এই দুইটার মধ্যে flags-এর ফলাফল কি হুবহু একই, নাকি কোথাও ভিন্ন হতে পারে?test %eax, %eax গণনা করে eax AND eax (= eax), cmp $0, %eax গণনা করে eax - 0 (= eax) — দুটোই একই মান “গণনা” করে ফেলে দেয় (eax নিজেই), তাই ZF (ফলাফল শূন্য কি না) আর SF (sign bit) দুই ক্ষেত্রেই হুবহু একই।
পার্থক্য CF আর OF-এ — and/test সবসময় CF=0, OF=0 সেট করে (bitwise operation-এ overflow/carry-র ধারণাই প্রযোজ্য না), কিন্তু sub/cmp $0,...-এ CF/OF subtraction-এর প্রকৃত ফলাফল অনুযায়ী সেট হয় (যদিও eax - 0-এ বাস্তবে CF সবসময় ০ হবে, OF সবসময় ০ হবে, কারণ ০ দিয়ে বিয়োগে কখনো borrow/overflow ঘটে না — তাই এই নির্দিষ্ট ক্ষেত্রে ফলাফল একই)। মূল কারণ compiler test পছন্দ করে — এনকোডিং ছোট ($0-এর মতো কোনো immediate constant এনকোড করার দরকার নেই) আর zero-check-এর জন্য শুধু ZF/SF-ই যথেষ্ট, তাই ব্যবহারিকভাবে সম্পূর্ণ সমতুল্য।
3নিচের C কোডটা assembly-তে trace করুন — label/jump-সহ সম্পূর্ণ কাঠামো লিখুন (AT&T syntax, x86-64), যেখানে n আসে edi-তে।
int sign(int n) {
if (n \< 0) return -1;
if (n > 0) return 1;
return 0;
}
প্রয়োগ
int sign(int n) {
if (n \< 0) return -1;
if (n > 0) return 1;
return 0;
}sign:
cmp $0, %edi
jl .L_neg # n \< 0 (signed) হলে
jg .L_pos # n > 0 (signed) হলে
mov $0, %eax # fall-through — বাকি একমাত্র সম্ভাবনা n == 0
ret
.L_neg:
mov $-1, %eax
ret
.L_pos:
mov $1, %eax
retলক্ষ্য করুন — মাত্র একটাই cmp যথেষ্ট, কারণ n-এর মান একবার stack/register-এ আছে বলে দুইটা ভিন্ন condition (jl, jg) একই flags-সেট থেকে পড়া যাচ্ছে, দ্বিতীয়বার তুলনা করার দরকার নেই — এটাই flags-latch-হয়ে-থাকার আরেকটা ব্যবহারিক সুবিধা।
4এই লেসনের sum_to_n while-loop উদাহরণে, যদি i-কে unsigned int হিসেবে ঘোষণা করা হতো (int-এর বদলে), assembly-তে ঠিক কোন একটামাত্র instruction বদলে যেত, আর কেন?
প্রয়োগ
sum_to_n while-loop উদাহরণে, যদি i-কে unsigned int হিসেবে ঘোষণা করা হতো (int-এর বদলে), assembly-তে ঠিক কোন একটামাত্র instruction বদলে যেত, আর কেন?শুধু jle (signed less-or-equal) বদলে jbe (unsigned below-or-equal) হয়ে যেত — বাকি সবকিছু (register allocation, label কাঠামো, cmp নিজে) অপরিবর্তিত থাকত। কারণ i <= n-এর তুলনা এখন unsigned interpretation-এ হওয়া উচিত, তাই compiler CF-ভিত্তিক jump ব্যবহার করবে signed SF⊕OF-ভিত্তিক jump-এর বদলে — ঠিক এই লেসনের কেন্দ্রীয় দাবি অনুযায়ী, একই cmp, ভিন্ন flag-পাঠ।
5একজন সহপাঠী প্রস্তাব করছেন: “compiler সবসময় test-at-top (naive) কাঠামো ব্যবহার করলেই তো সহজ — কেন test-at-bottom-এর মতো একটা বাড়তি জটিল রূপান্তর করার দরকার, যেখানে সুবিধাটা মাত্র ‘এক জাম্প কম প্রতি-iteration’?” এই যুক্তিটা মূল্যায়ন করুন।
ডিজাইন
সহপাঠীর প্রশ্নটা ন্যায্য, কিন্তু “মাত্র এক জাম্প কম” ছোট শোনালেও বাস্তবে এর প্রভাব উল্লেখযোগ্য, দুইটা কারণে:
(১) Loop প্রায়ই বহুবার iterate করে। যদি একটা loop গড়ে ১০০ বার চলে, “প্রতি-iteration এক জাম্প কম” মানে সেই একটা loop-এই ~১০০টা কম jump execute হওয়া — একটা প্রোগ্রামে হাজার হাজার loop থাকলে এই সাশ্রয় সামগ্রিকভাবে উল্লেখযোগ্য হয়ে ওঠে (asymptotic notation লেসনের ভাষায় বললে — per-iteration cost-এর একটা constant-factor সাশ্রয়, কিন্তু iteration সংখ্যার সাথে গুণিত হয়ে মোট প্রভাব বড় হয়ে যায়)।
(২) branch prediction-এর সাথে সরাসরি সম্পর্ক। এই লেসনের realworld সেকশনে উল্লেখিত branch predictor loop-এর “সাধারণত নেয়” জাম্পে ভালো কাজ করে, কিন্তু প্রতিটা বাড়তি jump instruction (এমনকি ভালোভাবে predicted হলেও) নিজেই একটা instruction — decode/fetch bandwidth ব্যবহার করে, code-এর আকার বাড়ায় (I-cache-এ বেশি জায়গা লাগে)। “এক জাম্প কম” মানে শুধু runtime cycle সাশ্রয় না, বরং সামগ্রিক code-density আর I-cache আচরণেও একটা ছোট কিন্তু বাস্তব উন্নতি।
মূল অন্তর্দৃষ্টি: compiler optimization প্রায়ই এমন সব ছোট, “নগণ্য মনে হওয়া” রূপান্তর নিয়ে গঠিত, যেগুলো একত্রে (আর বহু-iteration/বহু-call-এর প্রেক্ষাপটে গুণিত হয়ে) বাস্তব performance পার্থক্য তৈরি করে — কোনো একটা একক পরিবর্তন বিপ্লবী মনে না হলেও।
এরপর কী
পরের প্রশ্ন — memory-তে ঠিকানা কীভাবে গণনা হয়
চার-পাঁচ-ছয় নম্বর এই তিনটা লেসন মিলিয়ে আমরা এখন জানি — argument কোথায় যায় (calling convention), function call-এর mechanics (stack frame), আর একটা ফাংশনের ভেতরের logic কীভাবে assembly-তে রূপ নেয় (control flow)। কিন্তু একটা গুরুত্বপূর্ণ প্রশ্ন এখনও অস্পর্শিত — যখন কোডে একটা array-এর i-তম উপাদান access হয় (arr[i]), বা একটা struct-এর কোনো field (p->value), সেই ঠিকানাটা ঠিক কীভাবে গণনা হয় assembly-তে?
পরের লেসনগুলোতে (এই মডিউলের অন্য অংশ) addressing mode আর pointer arithmetic-এর বিস্তারিত আসবে — base + index*scale + displacement-এর মতো জটিল ঠিকানা-গণনা একটা single instruction-এই কীভাবে এনকোড হয়, আর কেন এই ক্ষমতাটা x86-64-এ বিশেষভাবে সমৃদ্ধ।
আরও পড়ুন
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1 §3.4.3 — EFLAGS and Jcc Instructions · প্রতিটা Jcc condition-এর নির্ভুল flag-সংজ্ঞা — এই লেসনের টেবিলের প্রাথমিক উৎস
- Computer Systems: A Programmer's Perspective, §3.6 — Control — Randal E. Bryant, David R. O'Hallaron · if/while/for-এর compiled assembly pattern-এর প্রামাণ্য textbook আলোচনা
- Compiler Explorer (godbolt.org) · এই লেসনের experiment-এ -O0 বনাম -O2 control-flow shape তুলনার জন্য ব্যবহৃত