Inline Assembly — C-এর ভেতর থেকে সরাসরি Instruction
Inline Assembly
গত লেসনে আমরা দেখেছি কম্পাইলার প্রায় সবসময় হাতে-লেখা assembly-র চেয়ে ভালো কোড বানায়। কিন্তু কিছু CPU instruction-এর কোনো C প্রতিশব্দই নেই — rdtsc, cpuid, লক-করা atomic operation। তখনই GCC/Clang-এর extended inline assembly দরকার — asm volatile("..." : outputs : inputs : clobbers)। এই লেসনে চারটা অংশ, volatile-এর আসল প্রয়োজন, আর সবচেয়ে গুরুত্বপূর্ণ — কেন এটা প্রায় সবসময় এড়ানো উচিত, শুধু genuine edge case-এই ব্যবহার করা উচিত।
আগে এটা বুঝি
গত লেসনে আমরা দেখলাম কম্পাইলার কতটা যত্ন নিয়ে register allocate করে, instruction fuse করে, branch এড়ায় — প্রায় সবসময় একজন মানুষের হাতে-লেখা কোডের চেয়ে ভালো। তাহলে প্রশ্ন: কখনো কি নিজে হাতে assembly লেখার সত্যিকারের দরকার পড়ে?
একটা নির্দিষ্ট পরিস্থিতিতে — হ্যাঁ। ধরুন আপনি জানতে চান একটা
function কল করতে ঠিক কত CPU cycle লাগে। x86-64-এ এর জন্য একটা
instruction আছে — rdtsc (read time-stamp counter) — যেটা CPU-এর
নিজস্ব cycle counter পড়ে আনে। কিন্তু C-তে কোনো rdtsc() ফাংশন
নেই। <time.h>-এর কোনো ফাংশন এটা দেয় না — সেগুলো wall-clock সময়
মাপে, cycle না।
uint64_t cycles = ???; // কীভাবে rdtsc চালাবেন?এই ফাঁকটাই এই লেসনের বিষয়। GCC/Clang একটা মেকানিজম দেয় — inline assembly — যা দিয়ে C কোডের মাঝখানে সরাসরি raw machine instruction বসানো যায়, compiler-এর সাথে register/memory শেয়ার করেই।
static inline uint64_t rdtsc(void) {
uint32_t lo, hi;
asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi<<32) | lo;
}এই লেসনের কাজ এই একটা লাইনের প্রতিটা অংশ — asm, volatile,
"rdtsc", "=a"(lo), "=d"(hi) — সম্পূর্ণভাবে বোঝা। আর সমান
গুরুত্বপূর্ণ, শেষে সৎভাবে স্বীকার করা: এটা একটা শেষ অস্ত্র,
প্রতিদিনের হাতিয়ার না।
মূল ধারণা
Extended asm-এর চারটা অংশ
GCC/Clang-এর “extended” inline assembly সিনট্যাক্স (সাধারণ, খালি
asm("...")-এর চেয়ে অনেক বেশি শক্তিশালী) চারটা কোলন-বিভক্ত অংশে
গঠিত:
asm volatile(
"assembly template"
: output operands
: input operands
: clobber list
);একটা ন্যূনতম, শেখার জন্য বানানো উদাহরণ দিয়ে শুরু করি — বাস্তবে কেউ এভাবে যোগ করবে না (কম্পাইলার নিজেই এটা optimal করে), কিন্তু এটা সিনট্যাক্স স্পষ্ট দেখায়:
int add_asm(int a, int b) {
int result;
asm("movl %1, %0\n\t"
"addl %2, %0"
: "=r"(result)
: "r"(a), "r"(b));
return result;
}অংশ ১ — Template
"movl %1, %0\n\t"
"addl %2, %0"আক্ষরিক assembly instruction, একটা string literal হিসেবে। %0,
%1, %2 — এগুলো operand নম্বর, output থেকে শুরু করে বাম
থেকে ডানে গোনা: %0 = প্রথম output (result), %1 = প্রথম
input (a), %2 = দ্বিতীয় input (b)। কম্পাইলার এই নম্বরগুলোকে
actual register-এ replace করে দেয় compile-time-এ।
\n\t মাল্টি-লাইন template-এ প্রতিটা instruction আলাদা লাইনে,
সঠিকভাবে indent করে বসাতে — assembler-এর জন্য syntax পরিষ্কার
রাখতে।
অংশ ২ — Output operands
: "=r"(result)= মানে write-only — এই operand-এ asm ব্লক একটা মান লিখবে,
আগের মান পড়বে না। r মানে “যেকোনো general-purpose register” —
compiler নিজে বেছে নেবে কোনটা, আপনাকে নির্দিষ্ট করতে হবে না।
(result) বলছে এই constraint কোন C variable-এর সাথে যুক্ত।
অংশ ৩ — Input operands
: "r"(a), "r"(b)দুইটা input, দুটোই “যেকোনো register”-এ থাকবে বলে চিহ্নিত। কম্পাইলার
a আর b-এর বর্তমান মান কোনো register-এ রাখবে (আগে থেকেই থাকতে
পারে, নাহলে move করে দেবে), আর template-এ %1/%2 সেই register-এর
নাম দিয়ে প্রতিস্থাপিত হবে।
অংশ ৪ — Clobber list
এই উদাহরণে খালি (নেই)। Clobber list বলে দেয় এই asm block আর কী কী নষ্ট করে যা output/input-এ তালিকাভুক্ত না — condition flags, অন্য কোনো register, বা memory। নিচে বিস্তারিত।
এখন real উদাহরণ — rdtsc
#include <stdint.h>
static inline uint64_t rdtsc(void) {
uint32_t lo, hi;
asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi<<32) | lo;
}- Template: শুধু
"rdtsc"— কোনো operand reference নেই, কারণrdtscনিজেই নির্দিষ্ট register ব্যবহার করে (ISA-তেই লেখা আছে)। - Output:
"=a"(lo),"=d"(hi)—aআরdহলো specific register constraint, “যেকোনো register” (r) না।aমানে%eax/%al/%rax(context অনুযায়ী),dমানে%edx। এটা বাধ্যতামূলক এখানে, কারণrdtscISA অনুযায়ী সবসময় নিম্ন ৩২ বিট%eax-এ, উচ্চ ৩২ বিট%edx-এ লেখে — এটা compiler-এর পছন্দের বিষয় না, hardware-এর নিয়ম। - Input: নেই।
- Clobber: এখানেও explicit কিছু নেই, কারণ
%eax/%edx-এর পরিবর্তন ইতিমধ্যে output constraint-এই ঘোষিত। volatile: আবশ্যক — নিচে ব্যাখ্যা।
ভেতরে কী ঘটছে
Constraint letter-গুলো — একটা সংক্ষিপ্ত রেফারেন্স
| Constraint | অর্থ |
|---|---|
r | যেকোনো general-purpose register |
m | একটা memory operand |
i | একটা compile-time constant (immediate) |
g | “general” — register, memory, বা immediate, compiler-এর পছন্দ |
a, b, c, d | নির্দিষ্ট register — %rax, %rbx, %rcx, %rdx |
S, D | নির্দিষ্ট register — %rsi, %rdi |
0–9 | আরেকটা operand-এর মতো একই register ব্যবহার করো (“matching constraint”) |
প্রিফিক্স:
| প্রিফিক্স | অর্থ |
|---|---|
= | Write-only output — আগের মান গুরুত্বপূর্ণ না |
+ | Read-write — asm block পড়বে ও লিখবে দুটোই |
& | Early-clobber — এই output-এ লেখা হয় সব input পড়ার আগে, তাই compiler input-এর জন্য একই register পুনরায় ব্যবহার করবে না |
volatile কেন — dead code elimination-এর ঝুঁকি
গত লেসনে আমরা দেখেছি -O2 সেই কোড সরিয়ে দেয় যার ফলাফল ব্যবহৃত
হয় না (dead code elimination)। Inline asm-এর ক্ষেত্রেও এই একই
নিয়ম প্রযোজ্য — যদি output ব্যবহৃত না হয়, আর volatile না
থাকে, কম্পাইলার পুরো asm block-ই মুছে দিতে পারে।
static inline uint64_t rdtsc_risky(void) {
uint32_t lo, hi;
asm("rdtsc" : "=a"(lo), "=d"(hi)); // volatile নেই!
return ((uint64_t)hi<<32) | lo;
}
void benchmark_wrong(void) {
rdtsc_risky(); // ফলাফল ব্যবহার হচ্ছে না
do_work();
rdtsc_risky(); // এটাও না
}কম্পাইলারের দৃষ্টিতে rdtsc_risky()-এর কোনো “observable effect”
নেই যদি রিটার্ন মান ব্যবহৃত না হয় — এটা একটা pure computation মনে
করে সম্পূর্ণ সরিয়ে দিতে পারে, বা দুইটা কলকে একটায় “merge” করতে পারে
(যেহেতু কম্পাইলার জানে না rdtsc আসলে সময়ের সাথে পরিবর্তনশীল একটা
hardware state পড়ছে — এটা তার কাছে যেকোনো আর পাঁচটা deterministic
function-এর মতোই দেখায়)।
volatile কম্পাইলারকে বলে: এই asm block-এর একটা side effect
আছে যা তুমি বুঝতে পারবে না — একে মুছো না, পুনরায় সাজিয়ো না, দুইবার
ডাকা দুইটা কলকে এক ধরে নিও না।
Clobber list — “cc” আর “memory”
দুইটা বিশেষ clobber আছে যা register নাম না:
"cc" — condition code flags (ZF, CF, SF, OF ইত্যাদি) বদলে
গেছে। যদি asm block-এ কোনো add, cmp, বা flags-বদলানো instruction
থাকে, এটা লেখা আবশ্যক — নাহলে কম্পাইলার হয়তো এই asm block-এর
আগে গণনা করা কোনো flags-ভিত্তিক conditional (যেমন একটা
jl) এই asm-এর পরে ব্যবহার করার চেষ্টা করবে, ধরে নিয়ে flags
এখনো বৈধ — যা মিথ্যা।
"memory" — এই asm block memory পড়তে/লিখতে পারে এমন কোনোভাবে
যা কম্পাইলার ট্র্যাক করতে পারছে না। এটা একটা সম্পূর্ণ memory
barrier হিসেবে কাজ করে — কম্পাইলার এই asm-এর আগে-পরে কোনো memory
access reorder করবে না, আর কোনো মান register-এ “cache” করে রাখবে
না যা আসলে এই asm-এর দ্বারা memory-তে বদলে যেতে পারে।
static inline void atomic_increment(volatile int *counter) {
asm volatile("lock incl %0" : "+m"(*counter) : : "memory");
}"+m"(*counter) — read-write memory operand (আগের মান পড়ে,
increment করে, আবার লেখে)। lock prefix পুরো read-modify-write-কে
atomic করে (অন্য CPU core একই সময়ে একই cache line-এ হস্তক্ষেপ
করতে পারবে না)। "memory" clobber নিশ্চিত করে — এই increment-এর
আগে-পরে কম্পাইলার অন্য কোনো memory access-কে এই instruction-এর
ওপার দিয়ে সরাবে না, যা multi-threaded কোডে সঠিকতার জন্য জরুরি।
উদাহরণ
দ্বিতীয় real উদাহরণ — cpuid
cpuid — CPU-এর ক্ষমতা query করার instruction (কোন feature আছে,
কোন vendor, কত core)। ইনপুট %eax-এ (কখনো %ecx-এও, sub-leaf-এর
জন্য), আউটপুট চারটা register জুড়ে:
static inline void cpuid(int leaf, int *eax, int *ebx, int *ecx, int *edx) {
asm volatile("cpuid"
: "=a"(*eax), "=b"(*ebx), "=c"(*ecx), "=d"(*edx)
: "a"(leaf));
}চারটা output, একটা input — সবই নির্দিষ্ট register constraint
(a,b,c,d), কারণ cpuid-এর semantics ISA-তে নির্দিষ্ট, কোনো
“compiler-এর choice” নেই। "a"(leaf) input হিসেবে %eax-এ leaf
নম্বর বসায়; একই সময়ে "=a"(*eax) output-ও %eax-এ — এটা বৈধ,
কারণ instruction চলে যাওয়ার পর %eax-এর মান বদলে যায় (input হিসেবে
পড়ার পর, output হিসেবে নতুন মান)।
নিজে চালিয়ে দেখুন
volatile না থাকলে asm সত্যিই মুছে যায় কি না — সরাসরি দেখুন
// rdtsc_test.c
#include <stdint.h>
static inline uint64_t rdtsc_no_volatile(void) {
uint32_t lo, hi;
asm("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi<<32) | lo;
}
int main(void) {
rdtsc_no_volatile();
return 0;
}gcc -O2 -c rdtsc_test.c -o rdtsc_test.o
objdump -d rdtsc_test.o | grep -i rdtsc-O2-এ প্রায়ই কোনো আউটপুট আসবে না — rdtsc instruction সম্পূর্ণ
অদৃশ্য, কারণ রিটার্ন মান কোথাও ব্যবহৃত হয়নি। এবার asm → asm volatile বদলে আবার কম্পাইল করুন — এবার rdtsc instruction-টা
main-এর disassembly-তে থাকা উচিত, ব্যবহার না হলেও।
যদি প্রথম সংস্করণেও rdtsc দেখা যায় — চিন্তার কিছু নেই,
compiler version/optimization heuristic ভেদে এই আচরণ পুরোপুরি
guaranteed না (GCC ডকুমেন্টেশনও বলে non-volatile asm-এর আচরণ
“undefined” এই অর্থে যে compiler-নির্ভর) — এটাই বরং লেসনের সতর্কতা
আরও জোরালো করে: এটা ভাগ্যের ওপর ছাড়া ঠিক না, volatile স্পষ্টভাবে
লেখাই একমাত্র নির্ভরযোগ্য পথ।
asm volatile ছাড়া, unused output-সহ একটা inline asm block optimizer-এ সত্যিই অদৃশ্য হয়ে যেতে পারে — এটা তাত্ত্বিক সতর্কতা না, পর্যবেক্ষণযোগ্য বাস্তবতা।
rdtsc দিয়ে সত্যিই একটা function-call-এর cycle count মাপুন
#include <stdint.h>
#include <stdio.h>
static inline uint64_t rdtsc(void) {
uint32_t lo, hi;
asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
return ((uint64_t)hi<<32) | lo;
}
int main(void) {
volatile int x = 0;
uint64_t start = rdtsc();
for (int i = 0; i \< 1000; i++) x += i;
uint64_t end = rdtsc();
printf("1000 iteration-এ cycle: %llu\n", (unsigned long long)(end - start));
return 0;
}gcc -O2 rdtsc_bench.c -o rdtsc_bench
./rdtsc_benchকয়েকবার চালান — সংখ্যাটা প্রতিবার সামান্য বদলাবে (OS scheduling noise, cache state, CPU frequency scaling)। এটাই বাস্তব benchmarking-এর প্রথম শিক্ষা — একবার মাপা যথেষ্ট না, বহুবার মেপে median/minimum নেওয়াই নির্ভরযোগ্য (Level ১১-এর measurement methodology-র প্রিভিউ)।
Inline asm শুধু তত্ত্ব না — একটা বাস্তব, কার্যকর micro-benchmarking টুল বানানো যায় মাত্র কয়েক লাইনে।
নিজে বানান
একটা Cycle-Accurate Micro-benchmark টুল
- rdtsc()-কে একটা reusable inline function হিসেবে একটা header ফাইলে রাখুন
- দুইটা ভিন্ন ছোট function বাছুন যাদের গতি তুলনা করতে চান (যেমন একটা array sum লুপ বনাম আরেকটা variant)
- প্রতিটাকে বহুবার (যেমন ১০০০ বার) চালিয়ে প্রতিবারের cycle count রেকর্ড করুন
- Minimum ও median cycle count রিপোর্ট করুন — গড় না, কারণ outlier (OS interrupt) গড়কে বিকৃত করে
- যাচাই করুন volatile বাদ দিলে ফলাফল কীভাবে অবাস্তব/অসামঞ্জস্যপূর্ণ হয়ে যায় (আগের experiment-এর প্রমাণ পুনরায় প্রয়োগ করুন)
- আপনার rdtsc-ভিত্তিক ফলাফলের সাথে gcc-র __builtin_ia32_rdtsc()-এর ফলাফল তুলনা করুন (যদি আপনার compiler সমর্থন করে) — একটা compiler intrinsic একই কাজ করে কি না দেখুন
শেষ ধাপটা ইচ্ছাকৃতভাবে যোগ করা — __builtin_ia32_rdtsc() একটা
GCC/Clang built-in যা ঠিক এই লেসনের inline asm-এর মতোই rdtsc
instruction emit করে, কিন্তু কোনো raw asm string ছাড়াই, কম্পাইলারের
নিজস্ব semantics-বোঝা মেকানিজম দিয়ে। এই তুলনাটাই পরের (“realworld”)
অংশের মূল বার্তার প্রথম হাতে-কলমে প্রমাণ — অনেক ক্ষেত্রে raw inline
asm-এর বিকল্প ইতিমধ্যেই বিদ্যমান, শুধু খুঁজে বের করতে হয়।
বাস্তব সিস্টেমে
Inline assembly বাস্তব কোডে
Linux kernel-এর ঐতিহাসিক ব্যবহার। Linux kernel-এর প্রথম দিকের
কোডে spinlock, atomic counter, per-CPU variable access — সবই
হাতে-লেখা inline asm দিয়ে হতো, কারণ C-তে এসবের কোনো portable উপায়
ছিল না। আজও kernel-এ কিছু জায়গায় inline asm আছে (বিশেষত architecture-নির্দিষ্ট
low-level কোডে, arch/x86/-এ), কিন্তু আধুনিক kernel ক্রমশ generic
atomic builtin আর architecture-abstraction layer-এর দিকে সরে
গেছে — direct inline asm দিন দিন কমছে।
glibc-র string function-এর ইতিহাস। memcpy, strlen-এর মতো
hot-path function ঐতিহাসিকভাবে হাতে-লেখা assembly-তে (আলাদা .S
ফাইলে, inline না) লেখা হতো — প্রতিটা CPU generation-এর জন্য আলাদা
সংস্করণ (SSE, AVX, AVX-512), runtime-এ ifunc mechanism দিয়ে
সেরাটা বেছে নেওয়া হতো। এটা inline asm না হলেও একই মূলনীতি —
compiler generic কোড generate করতে পারে না বলে হাতে-অপ্টিমাইজড
পথ দরকার।
<immintrin.h> — আধুনিক বিকল্প, SIMD-এর জন্য। computer-architecture
module-এর SIMD লেসনের vaddps-জাতীয় instruction ব্যবহার করতে আজ
কেউ raw inline asm লেখে না — Intel intrinsics ব্যবহার করে:
#include <immintrin.h>
__m256 sum = _mm256_add_ps(a, b); // vaddps, কিন্তু compiler-বোধগম্য type-সহকম্পাইলার জানে _mm256_add_ps-এর সম্পূর্ণ semantics — register
allocation, instruction scheduling, এমনকি আরও ভালো instruction-এ
রূপান্তর (constant-folding) — সবকিছু করতে পারে, যা raw asm-এ অসম্ভব
(কম্পাইলারের কাছে raw asm একটা black box, ভেতরে কী হচ্ছে তা
বুঝতে পারে না)।
Rust-এর asm! ম্যাক্রো — একই প্যাটার্ন, ভিন্ন ভাষা। Rust
১.৫৯ (২০২২) থেকে stable asm! macro-তে ঠিক এই লেসনের ধারণাগুলোরই
প্রতিধ্বনি — named operand, in/out/lateout (early-clobber-এর
Rust-সংস্করণ), clobber annotation। ভাষা ভিন্ন, সমস্যা আর সমাধানের
কাঠামো একই — inline asm একটা সাধারণ, ভাষা-নিরপেক্ষ প্যাটার্ন।
Memory fence intrinsics — raw asm-এর প্রয়োজনই ফুরিয়েছে। আগে
mfence/lfence/sfence instruction inline asm দিয়ে ব্যবহার
করতে হতো memory-ordering নিয়ন্ত্রণে। আজ _mm_mfence(),
_mm_lfence(), _mm_sfence() (<immintrin.h>) বা <stdatomic.h>-এর
atomic_thread_fence একই কাজ portable, compiler-বোধগম্যভাবে করে।
CPU feature detection — intrinsic-ভিত্তিক বিকল্প। এই লেসনের
cpuid উদাহরণ আজও বাস্তব কোডে (compiler-এর নিজস্ব __builtin_cpu_supports()
বা MSVC-র __cpuid() intrinsic না থাকলে) সরাসরি ব্যবহৃত হয় — কিন্তু
GCC/Clang-এর নিজস্ব built-in থাকায় raw inline asm-এর প্রয়োজন এখন
কমই পড়ে।
Sanitizer আর optimizer-এর সাথে ঘর্ষণ। যেহেতু কম্পাইলার inline
asm-এর ভেতরে কী হচ্ছে “দেখতে” পারে না, একটা asm block পুরো
আশেপাশের function-এর optimization সুযোগ কমিয়ে দেয় — compiler
রক্ষণশীলভাবে ধরে নেয় “যেকোনো কিছু হতে পারে” (বিশেষত "memory"
clobber-সহ)। AddressSanitizer/ThreadSanitizer-এর মতো টুলও raw
asm-এর ভেতরের memory access ট্র্যাক করতে পারে না — এই কারণে বড়
codebase-এ inline asm ন্যূনতম রাখার আরেকটা বাস্তব কারণ, নিছক
“portability” ছাড়াও।
যে ভুলগুলো সবাই করে
“প্রকৃত systems programmer-রা প্রচুর inline assembly ব্যবহার করে — এটাই 'আসল' low-level কাজ।”
আজকের বাস্তবতায় এটা বেশিরভাগ ক্ষেত্রেই মিথ্যা। বড় production codebase (Linux kernel, glibc, Chromium, PostgreSQL) ঘেঁটে দেখলে inline asm-এর occurrence সংখ্যা আশ্চর্যজনকভাবে কম — সাধারণত পুরো codebase-এর একটা ক্ষুদ্র ভগ্নাংশ, প্রায় সবসময় খুব নির্দিষ্ট, মন্তব্য-সহ, সতর্কতার সাথে লেখা জায়গায় সীমাবদ্ধ (boot code, context switch, নির্দিষ্ট hardware register access)। আধুনিক “systems programming”-এর বেশিরভাগই আসলে compiler intrinsics, careful data layout, cache-conscious algorithm design, আর profiling — raw asm লেখা না। গত লেসনের driving question-ই এর প্রমাণ — কম্পাইলার কী বানায় তা পড়তে পারা অনেক বেশি দরকারি দক্ষতা, নিজে লেখা থেকে।
“Inline assembly সবসময় দ্রুততর, কারণ এটা 'সরাসরি hardware-এ কথা বলে'।”
সাধারণত সত্যি না। একটা raw inline asm block কম্পাইলারের কাছে একটা black box — এর ভেতরের register ব্যবহার, latency, dependency কিছুই কম্পাইলার বিশ্লেষণ করতে পারে না, তাই এর আশেপাশের কোড optimize করার ক্ষমতা কমে যায় (register allocator রক্ষণশীল হতে বাধ্য হয়, instruction scheduler asm block-এর চারপাশে reorder করতে পারে না)। গত লেসনে দেখেছি কম্পাইলার নিজেই instruction fusion, branch elimination-এর মতো কৌশল প্রয়োগ করে যা মানুষ প্রায়ই মিস করে। একটা ভালোভাবে-লেখা C function, ভালো optimizer দিয়ে কম্পাইল করলে, প্রায়ই একটা naive হাতে-লেখা asm-এর চেয়ে দ্রুততর — শুধু সেই একটা instruction-ই ছাড়া যার C equivalent নেই।
“SIMD কোড লিখতে হলে inline assembly লাগবেই।”
আজ প্রায় কখনোই না। <immintrin.h> (x86) বা ARM NEON-এর intrinsic
header-গুলো প্রতিটা SIMD instruction-এর জন্য একটা টাইপ-নিরাপদ C
function দেয় (_mm256_add_ps, vaddq_f32), যা কম্পাইলার সম্পূর্ণ
বোঝে — register allocate করতে পারে, constant-fold করতে পারে,
এমনকি ভুল intrinsic কল টাইপ-চেক করে ধরতে পারে। ১৯৯০-এর দশকে যখন
MMX/SSE নতুন ছিল, intrinsics পরিপক্ব ছিল না, তখন raw inline asm-ই
একমাত্র উপায় ছিল — কিন্তু সেই যুগ বহু আগেই শেষ।
“clobber list বাদ দেওয়া একটা ছোটখাটো ভুল — সবচেয়ে খারাপ ক্ষেত্রে সামান্য ধীর কোড হবে।”
বিপজ্জনকভাবে ভুল। একটা ভুল বা অসম্পূর্ণ clobber list silent
miscompilation ঘটাতে পারে — কম্পাইলার এমন একটা মান “cache” করে
রাখতে পারে যা আসলে আপনার asm block বদলে দিয়েছে, বা একটা register
পুনরায় ব্যবহার করতে পারে যা আসলে এখনো asm block-এর ভেতরে ব্যবহৃত
হচ্ছে। এই বাগ প্রায়ই শুধু নির্দিষ্ট optimization level-এ (-O0-এ
লুকানো, -O2-এ প্রকাশিত) বা নির্দিষ্ট surrounding code-এ (inline
হওয়ার পর ভিন্ন register চাপে) দেখা যায় — যা এটাকে খুঁজে বের করা
বিশেষভাবে কঠিন করে তোলে, “আমার মেশিনে কাজ করে” ধরনের বাগের একটা
ক্লাসিক উৎস।
বুঝেছেন কি না দেখুন
1asm volatile(“cpuid” : “=a”(*eax), “=b”(*ebx), “=c”(*ecx), “=d”(*edx) : “a”(leaf))-এ কয়টা output operand, কয়টা input operand, আর কয়টা clobber আছে?স্মরণ
চারটা output ("=a"(*eax), "=b"(*ebx), "=c"(*ecx),
"=d"(*edx)), একটা input ("a"(leaf)), কোনো explicit
clobber নেই (চতুর্থ কোলনের পরে কিছুই লেখা হয়নি — এখানে খালি,
তালিকাটাই অনুপস্থিত)। লক্ষ্য করুন %eax output ও input দুটোতেই
ব্যবহৃত হচ্ছে — এটা বৈধ, কারণ cpuid চালানোর আগে input হিসেবে
পড়া হয়, চালানোর পরে output হিসেবে নতুন মান বসে; কম্পাইলার এই
ক্রম বোঝে constraint-এর syntax থেকেই।
2নিচের inline asm-এ কী ভুল আছে?
int square(int x) {
asm("imull %0, %0" : "+r"(x));
return x;
}
যুক্তি
int square(int x) {
asm("imull %0, %0" : "+r"(x));
return x;
}সিনট্যাক্স নিজেই আসলে বৈধ এবং কাজ করবে — "+r"(x) একটা read-write
constraint, তাই x-এর বর্তমান মান আগে register-এ বসে, imull %0, %0 সেটাকে নিজের সাথে গুণ করে (self-multiply), আর ফলাফল আবার
সেই একই register-এ, যা x-এ ফেরত যায়।
তবে বাস্তব সমস্যা দুইটা:
১. volatile নেই — এই function-এর রিটার্ন মান যদি কোথাও
ব্যবহৃত না হয়, পুরো asm block dead-code হিসেবে মুছে যেতে পারে
(যদিও এখানে output ব্যবহৃত হচ্ছেই ধরে নেওয়া যাচ্ছে, তবু
defensive practice হিসেবে volatile লেখা উচিত)।
২. "cc" clobber নেই — imull condition flags বদলে দেয়
(overflow flag বিশেষভাবে)। যদি এই asm-এর ঠিক পরেই কোনো
flags-নির্ভর কোড থাকে (compiler যদি কোনোভাবে flags পুনঃব্যবহারের
চেষ্টা করে — বাস্তবে বিরল কিন্তু তাত্ত্বিকভাবে সম্ভব একটা
ভবিষ্যতের compiler optimization-এ), "cc" না থাকলে ভুল ধারণা
হতে পারে।
আরও গুরুত্বপূর্ণ শিক্ষা: এই পুরো function-টাই অপ্রয়োজনীয় —
return x * x; লিখলে কম্পাইলার নিজেই optimal imull (বা আরও ভালো
কিছু) generate করবে। এটা এই লেসনের কেন্দ্রীয় সতর্কতার একটা উদাহরণ:
যেখানে C-এর সরাসরি equivalent আছে, সেখানে inline asm লেখার কোনো
কারণ নেই।
3একটা memory-mapped I/O register পড়তে হবে ঠিকানা 0x1000-এ, যার মান বারবার পড়লে বদলে যেতে পারে (hardware sensor), আর কম্পাইলার যেন এই read-কে কখনো cache/eliminate/reorder না করে। কোন clobber (বা constraint কৌশল) ব্যবহার করবেন, আর কেন?প্রয়োগ
static inline uint32_t read_sensor(volatile uint32_t *addr) {
uint32_t val;
asm volatile("movl (%1), %0" : "=r"(val) : "r"(addr) : "memory");
return val;
}volatile — নিশ্চিত করে এই asm block কখনো dead-code হিসেবে
মুছে যাবে না, আর দুইটা পরপর কল কখনো একটায় merge হবে না (প্রতিটা
read-ই একটা আলাদা, প্রকৃত hardware access হিসেবে গণ্য হবে)।
"memory" clobber — কম্পাইলারকে বলে এই asm-এর আশেপাশে কোনো
memory access reorder না করতে, আর কোনো পূর্বে-পড়া মান “cache”
করে না রাখতে যা আসলে বদলে যেতে পারে। যদিও এই নির্দিষ্ট function
নিজে অন্য কোনো memory touch করছে না, "memory" একটা রক্ষণশীল,
নিরাপদ পছন্দ hardware I/O-এর মতো “compiler-এর জানার বাইরের” side
effect-এর ক্ষেত্রে — এটাই compiler-কে বলার একমাত্র উপায় “এই
মুহূর্তে memory সম্পর্কে তোমার সব অনুমান বাতিল করো।”
অতিরিক্ত বাস্তব বিবেচনা: C-তে যদি addr-কে ইতিমধ্যেই
volatile uint32_t * টাইপ করা হয় (এই উদাহরণে করা হয়েছে), তাহলে
আসলে সাধারণ C dereference-ই (*addr) volatile semantics দিয়ে
কম্পাইলারকে একই নিশ্চয়তা দিতে পারত, কোনো inline asm ছাড়াই — এই
নির্দিষ্ট উদাহরণটা inline asm-এর প্রয়োজনীয়তা প্রদর্শনের জন্য
সরলীকৃত, বাস্তবে volatile-qualified pointer-ই যথেষ্ট হতো।
4নিচের কোডে কী ভুল হতে পারে — বিশেষত inlining-এর পরে?
int f(int x) {
int tmp;
asm("movl %1, %0" : "=r"(tmp) : "r"(x));
asm("addl $1, %0" : "+r"(tmp));
return tmp;
}
প্রয়োগ
int f(int x) {
int tmp;
asm("movl %1, %0" : "=r"(tmp) : "r"(x));
asm("addl $1, %0" : "+r"(tmp));
return tmp;
}এই কোড আসলে সঠিক — দুইটা আলাদা asm statement, প্রতিটার নিজস্ব
সঠিক constraint (প্রথমটা tmp-তে x-এর মান কপি করছে, দ্বিতীয়টা
"+r" দিয়ে read-modify-write করে ১ যোগ করছে)। কম্পাইলার দুইটা
statement-এর মধ্যে tmp-এর জন্য একটাই consistent register বেছে
নেবে, কারণ constraint সিস্টেম নিজেই এই সম্পর্ক ট্র্যাক করে — কোনো
manual register নাম hardcode করা হয়নি।
যা সত্যিই সতর্কতার বিষয় (যদিও এখানে ঠিক আছে): যদি কেউ ভুল
করে "a" বা "d"-এর মতো hardcoded specific register constraint
ব্যবহার করত (এই উদাহরণে করেনি, "r" ব্যবহার করেছে — generic),
তাহলে function inline হওয়ার পরে caller-এর context-এ সেই নির্দিষ্ট
register ইতিমধ্যে অন্য কোনো জীবিত মান ধরে থাকতে পারত — যদি সেই
constraint clobber list-এ বা properly output/input হিসেবে ঘোষিত
না থাকে, একটা silent conflict ঘটতে পারে। এই কারণেই generic "r"
constraint (compiler-কে register বেছে নিতে দেওয়া) নির্দিষ্ট register
constraint-এর চেয়ে সাধারণত নিরাপদ — যেখানেই ISA বাধ্য না করে
(rdtsc/cpuid-এর মতো), generic constraint-ই পছন্দনীয়।
5আপনার টিমের একজন জুনিয়র ডেভেলপার একটা hot loop-এ কর্মক্ষমতা বাড়াতে rdtsc-স্টাইলে একটা inline asm ব্লক দিয়ে “integer square root” হাতে লিখতে চাইছে, দাবি করছে এটা C-এর sqrt()-এর চেয়ে দ্রুত হবে। এই সিদ্ধান্তের আগে আপনি কী কী প্রশ্ন করবেন, আর কেন?ডিজাইন
প্রশ্ন ১ — এর জন্য কি ইতিমধ্যে একটা compiler intrinsic/builtin
আছে? x86-এ integer/float square root-এর জন্য sqrtss/sqrtsd
instruction আছে, আর GCC/Clang-এর __builtin_sqrt বা সরাসরি
<math.h>-এর sqrt() প্রায়ই ইতিমধ্যেই এই instruction-এই compile
হয় (বিশেষত -ffast-math বা উপযুক্ত target flag দিলে)। যদি তাই
হয়, raw inline asm লেখা সম্পূর্ণ অপ্রয়োজনীয় — কম্পাইলার নিজেই
optimal কোড দিচ্ছে।
প্রশ্ন ২ — measurement আছে? “দ্রুত হবে” একটা দাবি, প্রমাণ না।
গত লেসনের একটা কেন্দ্রীয় শিক্ষা — কম্পাইলার প্রায়ই মানুষের চেয়ে
ভালো optimize করে। আগে -O2/-O3-এ generated assembly (Compiler
Explorer দিয়ে) দেখা উচিত — যদি ইতিমধ্যেই optimal instruction ব্যবহৃত
হচ্ছে, hand-written asm কোনো লাভ দেবে না, শুধু maintainability
কমাবে।
প্রশ্ন ৩ — এই asm block portable? x86-64-এর জন্য লেখা inline
asm ARM-এ কাজ করবে না — অথচ sqrt() প্রতিটা architecture-এ কাজ
করে। যদি প্রজেক্ট একাধিক architecture সমর্থন করে, inline asm একটা
maintenance burden আর platform-নির্দিষ্ট branch তৈরি করে।
প্রশ্ন ৪ — সঠিকতা যাচাই কীভাবে হবে? Compiler-বোঝা কোডের জন্য sanitizer, static analyzer কাজ করে; raw inline asm-এর ভেতরের বাগ এসব টুল ধরতে পারে না (realworld অংশের সতর্কতা)।
সিদ্ধান্তের কাঠামো: শুধু “দ্রুত হবে মনে হচ্ছে” যথেষ্ট কারণ না — প্রথমে measure করা, তারপর compiler-এর existing output পরীক্ষা করা, তারপরই (আর প্রায় কখনোই না) inline asm বিবেচনা করা — এই লেসনের misconception অংশের প্রতিটা পয়েন্টই এই সিদ্ধান্তে প্রযোজ্য।
এরপর কী
এই তিনটা লেসন (addressing, compiler output, inline assembly) একসাথে মডিউলের driving question-এর সম্পূর্ণ উত্তর দিয়েছে — কম্পাইলার কী বানায়, কেন বানায়, আর কবে (খুব কম ক্ষেত্রে) নিজে হাতে হস্তক্ষেপ করার দরকার পড়ে।
মডিউলের বাকি topic-গুলো (position-independent code বিস্তারিতভাবে,
ELF format ও sections, gdb/lldb দিয়ে instruction-level debugging)
এই ভিত্তির উপর দাঁড়িয়ে বাকি প্রশ্নগুলোর উত্তর দেবে — একটা প্রোগ্রাম
কম্পাইল হওয়ার পর ফাইল হিসেবে কেমন দেখতে হয়, লোড হওয়ার সময় কী ঘটে,
আর একটা চলমান প্রোগ্রামকে instruction-level-এ কীভাবে পর্যবেক্ষণ
করা যায়। এই লেসনের rdtsc/cpuid-এর মতো inline asm-এর প্রতিটা
ব্যবহার আসলে সেই “চলমান প্রোগ্রাম কী দেখছে, কী জানে” প্রশ্নেরই
একটা ছোট্ট, concrete জানালা ছিল — module-এর বাকি অংশ সেই জানালাটাই
আরও বড় করবে।
আরও পড়ুন
- How to Use Inline Assembly Language in C Code — GCC Manual · Extended asm syntax-এর প্রামাণ্য, সম্পূর্ণ ডকুমেন্টেশন — constraint letters, volatile-এর নিয়ম, সব এখানে
- Intel Intrinsics Guide · আধুনিক বিকল্প — কম্পাইলার-বোধগম্য SIMD/special-instruction intrinsics
- Intel 64 and IA-32 Architectures Software Developer's Manual — RDTSC, CPUID — Intel Corporation · এই লেসনের উদাহরণ instruction দুটোর প্রামাণ্য semantics