হাতে-লেখা অ্যাসেম্বলি প্রোগ্রাম
Hand-written Assembly Programs
libc ছাড়াই, খালি হাতে x86-64 অ্যাসেম্বলিতে strlen, memcpy, আর fibonacci লেখা — নিজের _start থেকে শুরু করে সরাসরি syscall দিয়ে exit পর্যন্ত, আর gdb দিয়ে প্রতিটা ফলাফল যাচাই করা।
কেন এই প্রজেক্ট
Stack frame আর control flow লেসনে আমরা দেখেছি ফাংশন-কল, লুপ, শর্ত — এগুলো অ্যাসেম্বলিতে ঠিক কী রূপ নেয়। কিন্তু এখন পর্যন্ত যা লিখেছি তার প্রায় সবকিছুর পিছনে একটা লুকানো সহায়ক ছিল — libc। _start, স্ট্যাক সেটআপ, exit() কল — এসব libc-র crt0 স্টার্টআপ কোড নিঃশব্দে সামলে দেয়।
এই প্রজেক্টে সেই সহায়ক সরিয়ে নেওয়া হচ্ছে। কোনো libc নেই, কোনো crt0 নেই, কোনো printf নেই। প্রোগ্রাম শুরু হয় সরাসরি কার্নেলের হাতে ছেড়ে দেওয়া একটা _start লেবেল থেকে, আর শেষ হয় সরাসরি একটা syscall দিয়ে। মাঝখানে যা কিছু দরকার — string-এর length বের করা, মেমরি কপি করা, সংখ্যা গণনা করা — সব হাতে লিখতে হবে, কারণ libc-র strlen()/memcpy() এখানে নেই।
এটাই আসল শিক্ষা: strlen, memcpy এই নামগুলো ম্যাজিক ফাংশন না — এগুলো C স্ট্যান্ডার্ড লাইব্রেরির লেখকরা যে সাধারণ, ছোট্ট লুপগুলো লিখেছেন, সেটাই। একবার নিজে হাতে লিখলে, compiler output পড়ার সময় বা gdb-তে libc-র ভিতরে স্টেপ করার সময় আর কিছুই রহস্যময় মনে হবে না।
উদাহরণ — প্রত্যাশিত আউটপুট
তিনটা প্রোগ্রাম একসাথে assemble ও link করে চালালে (নিচে বিস্তারিত), টার্মিনালে দেখা উচিত:
$ ./strlen_test
length = 13
$ ./memcpy_test
copied 5 bytes: Hello
$ ./fib_test
fib(10) = 55
কোনো printf নেই — প্রতিটা লাইন সরাসরি write syscall দিয়ে ছাপা হচ্ছে, সংখ্যাগুলো নিজের-লেখা itoa-র মাধ্যমে ASCII-তে রূপান্তরিত হয়ে।
গোড়ার কথা — লিবসি ছাড়া _start
libc-সহ একটা প্রোগ্রামে main() আসলে সরাসরি এন্ট্রি-পয়েন্ট না। Linker আসল এন্ট্রি-পয়েন্ট বসায় _start-এ, যেটা crt0 থেকে আসে, আর crt0 স্ট্যাক আর্গুমেন্ট (argc/argv/envp) সাজিয়ে তারপর main() কল করে, আর main() রিটার্ন করলে exit() কল করে প্রসেস বন্ধ করে।
আমরা যেহেতু libc লিংক করছি না, _start নিজেই আমাদের লিখতে হবে, আর প্রোগ্রাম শেষে নিজে থেকে exit syscall কল করতে হবে — নাহলে CPU _start-এর পরের মেমরিতে গিয়ে র্যান্ডম বাইটকে ইন্সট্রাকশন হিসেবে চালানোর চেষ্টা করবে আর সেগফল্ট করবে। Linux x86-64-এ syscall কনভেনশন:
| রেজিস্টার | কাজ |
|---|---|
rax | syscall নম্বর |
rdi, rsi, rdx, r10, r8, r9 | আর্গুমেন্ট (ক্রমান্বয়ে) |
syscall ইন্সট্রাকশন | কার্নেলে ট্র্যাপ করা |
rax (রিটার্নের পর) | ফলাফল |
আমাদের দরকার শুধু দুইটা syscall — write (নম্বর ১) আর exit (নম্বর ৬০)। একটা ন্যূনতম “কিছু না করে exit” প্রোগ্রাম:
.global _start
.text
_start:
mov $60, %eax # syscall নম্বর: exit
xor %edi, %edi # exit code 0
syscall
Assemble ও link (কোনো libc ছাড়া, তাই -nostdlib আর নিজের _start linker-কে এন্ট্রি-পয়েন্ট হিসেবে দেওয়া):
$ as -o exit_test.o exit_test.s
$ ld -o exit_test exit_test.o
$ ./exit_test
$ echo $?
0
ld-কে আলাদা করে -nostdlib বলার দরকার নেই কারণ আমরা কোনো C লাইব্রেরি অবজেক্ট ফাইল দিচ্ছিই না — শুধু আমাদের .o। যদি gcc দিয়ে link করেন (assemble+link এক কমান্ডে করতে সুবিধাজনক), তাহলে -nostdlib -static দিতে হবে যাতে gcc নিজে থেকে crt0/libc জুড়ে না দেয়:
$ gcc -nostdlib -static -o exit_test exit_test.s
এই কঙ্কাল প্রতিটা প্রোগ্রামের ভিত্তি — নিচের তিনটা রুটিনই এর ভিতরে বসবে।
ধাপে ধাপে
১. strlen — null terminator পর্যন্ত গোনা
C-স্টাইল string null-terminated — শেষে একটা 0x00 বাইট দিয়ে দৈর্ঘ্য বোঝানো হয়, কোনো আলাদা length ফিল্ড নেই। তাই strlen মানে হলো বাইট-বাই-বাইট এগিয়ে গিয়ে 0x00 না পাওয়া পর্যন্ত গোনা:
# rdi = string-এর ঠিকানা (ইনপুট)
# রিটার্ন: rax = length (null বাদে)
my_strlen:
xor %rax, %rax # length = 0
.strlen_loop:
cmpb $0, (%rdi, %rax) # str[length] == 0 ?
je .strlen_done
inc %rax
jmp .strlen_loop
.strlen_done:
ret
cmpb $0, (%rdi, %rax) একটা indexed addressing mode — rdi + rax ঠিকানায় থাকা বাইটকে 0-র সাথে তুলনা করছে, কোনো আলাদা “current pointer” রেজিস্টার না রেখেই। ফাংশনটা caller-এর কোনো রেজিস্টার নষ্ট করে না (rax তো রিটার্ন ভ্যালুই), তাই callee-saved রেজিস্টার বাঁচানোর push/pop-ও লাগে না।
পুরো টেস্ট প্রোগ্রাম — একটা static string নিয়ে my_strlen কল করে, ফলাফল সংখ্যায় প্রিন্ট করা:
.global _start
.text
_start:
lea msg(%rip), %rdi
call my_strlen # rax = length
# rax-কে ASCII-তে রূপান্তর করে stdout-এ লেখা
mov %rax, %rdi
call print_number
mov $60, %eax
xor %edi, %edi
syscall
my_strlen:
xor %rax, %rax
.strlen_loop:
cmpb $0, (%rdi, %rax)
je .strlen_done
inc %rax
jmp .strlen_loop
.strlen_done:
ret
# rdi = অ-ঋণাত্মক সংখ্যা → stdout-এ দশমিক প্রিন্ট, শেষে নিউলাইন
print_number:
push %rbx
push %r12
mov %rdi, %rax
lea buf(%rip), %rbx
add $20, %rbx # বাফারের শেষ থেকে পিছনে ভরব
movb $10, (%rbx) # নিউলাইন
mov %rbx, %r12
.pn_loop:
xor %rdx, %rdx
mov $10, %rcx
div %rcx # rax = rax/10, rdx = rax%10
add $'0', %dl
dec %r12
mov %dl, (%r12)
test %rax, %rax
jnz .pn_loop
# write(1, r12, buf+21 - r12)
lea buf(%rip), %rax
add $21, %rax
sub %r12, %rax # length
mov $1, %rdi
mov %r12, %rsi
mov %rax, %rdx
mov $1, %eax
syscall
pop %r12
pop %rbx
ret
.data
msg: .asciz "Hello, assembly!"
.bss
buf: .skip 21
লক্ষ্যণীয় কয়েকটা জিনিস:
lea msg(%rip), %rdi— RIP-relative addressing, position-independent code-এর জন্য দরকারি (এই মডিউলেরই “Position independent code” টপিকের সরাসরি প্রয়োগ)।div %rcxঅবশ্যইrdx:rax-কে ডিভিডেন্ড ধরে, তাই ডিভিশনের আগেrdxশূন্য করা বাধ্যতামূলক — না করলে ভুল ফলাফল বা#DE(divide error) exception আসবে।- সংখ্যাকে ASCII বানানোর সময় পিছন থেকে বাফার ভরা হচ্ছে কারণ digit-গুলো উল্টো ক্রমে বের হয় (একক ঘর আগে, তারপর দশক…)।
push %rbx/push %r12— callee-saved রেজিস্টার ব্যবহারের আগে সংরক্ষণ করা, ফাংশনের শেষে ফেরত দেওয়া। System V ABI-তেrbx,r12–r15callee-saved; ব্যবহার করলে caller-এর মান অক্ষত রাখার দায়িত্ব callee-র।
Assemble ও লিংক:
$ as -o strlen_test.o strlen_test.s
$ ld -o strlen_test strlen_test.o
$ ./strlen_test
16
(“Hello, assembly!” ১৬ অক্ষর — গুনে মিলিয়ে নিন।)
২. memcpy — বাইট-বাই-বাইট কপি
# rdi = dest, rsi = src, rdx = n (বাইট সংখ্যা)
# রিটার্ন: rax = dest (memcpy-র C সিগনেচার অনুসরণ করে)
my_memcpy:
mov %rdi, %rax # ফেরত দেওয়ার জন্য dest সংরক্ষণ
xor %rcx, %rcx # i = 0
.memcpy_loop:
cmp %rdx, %rcx
je .memcpy_done
mov (%rsi, %rcx), %r8b # tmp = src[i]
mov %r8b, (%rdi, %rcx) # dest[i] = tmp
inc %rcx
jmp .memcpy_loop
.memcpy_done:
ret
n == 0 হলে লুপ একবারও না চলে সাথে সাথে রিটার্ন করে — এটা একটা edge case যেটা mentally trace করে দেখা উচিত: cmp %rdx, %rcx প্রথমবারেই 0 == 0 পেয়ে সরাসরি .memcpy_done-এ যাবে, mov/inc একবারও চলবে না। এই memcpy ওভারল্যাপিং বাফার সামলায় না — সত্যিকারের libc memcpy তা করে না এমন প্রতিশ্রুতি দেয়ও না (এই কাজের জন্য memmove আলাদা ফাংশন, যেটা দিক ঠিক করে কপি করে)।
টেস্ট প্রোগ্রাম — "Hello" (৫ বাইট, null বাদে) কপি করে সরাসরি stdout-এ লেখা:
.global _start
.text
_start:
lea src(%rip), %rsi
lea dst(%rip), %rdi
mov $5, %rdx
call my_memcpy # rax = dst
mov $1, %rax # write
mov $1, %rdi # fd = stdout
lea dst(%rip), %rsi
mov $5, %rdx
syscall
mov $60, %eax
xor %edi, %edi
syscall
my_memcpy:
mov %rdi, %rax
xor %rcx, %rcx
.memcpy_loop:
cmp %rdx, %rcx
je .memcpy_done
mov (%rsi, %rcx), %r8b
mov %r8b, (%rdi, %rcx)
inc %rcx
jmp .memcpy_loop
.memcpy_done:
ret
.data
src: .ascii "Hello"
.bss
dst: .skip 5
$ as -o memcpy_test.o memcpy_test.s
$ ld -o memcpy_test memcpy_test.o
$ ./memcpy_test
Hello
৩. fibonacci — কেন iterative, recursive না
Recursive fibonacci (fib(n) = fib(n-1) + fib(n-2)) দেখতে সবচেয়ে “স্বাভাবিক” মনে হয়, কিন্তু এর কল-ট্রি প্রতিটা স্তরে দ্বিগুণ হয় — fib(n) গণনা করতে প্রায় O(2^n) ফাংশন-কল লাগে, কারণ fib(n-2) বহুবার আলাদা আলাদা শাখা থেকে recompute হয় (Mathematics module-এর induction লেসনে recursion-এর এই খরচ নিয়ে আলোচনা আছে)। fib(40) ইতিমধ্যে কয়েক বিলিয়ন কল, স্ট্যাকের গভীরতাও n-এর সমানুপাতিক।
Iterative সংস্করণ শুধু আগের দুইটা মান মনে রাখে আর O(n) সময়ে, constant স্ট্যাক গভীরতায় চলে — কোনো ফাংশন-কল ওভারহেডই নেই:
# rdi = n
# রিটার্ন: rax = fib(n) (fib(0)=0, fib(1)=1, fib(2)=1, ...)
my_fib:
cmp $0, %rdi
jne .fib_base_check
xor %rax, %rax # fib(0) = 0
ret
.fib_base_check:
cmp $1, %rdi
jne .fib_loop_setup
mov $1, %rax # fib(1) = 1
ret
.fib_loop_setup:
xor %rax, %rax # a = fib(0) = 0
mov $1, %rbx # b = fib(1) = 1
mov $1, %rcx # i = 1
.fib_loop:
cmp %rdi, %rcx
je .fib_loop_done
lea (%rax, %rbx), %rdx # tmp = a + b
mov %rbx, %rax # a = b
mov %rdx, %rbx # b = tmp
inc %rcx
jmp .fib_loop
.fib_loop_done:
mov %rbx, %rax # rax = b = fib(n)
ret
lea (%rax, %rbx), %rdx একটা প্রচলিত ট্রিক — lea আসলে ঠিকানা গণনার ইন্সট্রাকশন, কিন্তু add-এর মতোই ব্যবহার করা যায় (rdx = rax + rbx) কারণ এতে flags পরিবর্তন হয় না ও কোনো মেমরি অ্যাক্সেস লাগে না — এক ইন্সট্রাকশনে যোগফল বের করে একটা তৃতীয় রেজিস্টারে বসিয়ে দেওয়া যায়, add করলে যেমন একটা অপারেন্ড ওভাররাইট হতো তা এড়িয়ে।
টেস্ট প্রোগ্রাম — fib(10) গণনা করে প্রিন্ট (আগের print_number রুটিন পুনর্ব্যবহার করে):
.global _start
.text
_start:
mov $10, %rdi
call my_fib # rax = fib(10)
mov %rax, %rdi
call print_number
mov $60, %eax
xor %edi, %edi
syscall
my_fib:
cmp $0, %rdi
jne .fib_base_check
xor %rax, %rax
ret
.fib_base_check:
cmp $1, %rdi
jne .fib_loop_setup
mov $1, %rax
ret
.fib_loop_setup:
xor %rax, %rax
mov $1, %rbx
mov $1, %rcx
.fib_loop:
cmp %rdi, %rcx
je .fib_loop_done
lea (%rax, %rbx), %rdx
mov %rbx, %rax
mov %rdx, %rbx
inc %rcx
jmp .fib_loop
.fib_loop_done:
mov %rbx, %rax
ret
print_number:
push %rbx
push %r12
mov %rdi, %rax
lea buf(%rip), %rbx
add $20, %rbx
movb $10, (%rbx)
mov %rbx, %r12
.pn_loop:
xor %rdx, %rdx
mov $10, %rcx
div %rcx
add $'0', %dl
dec %r12
mov %dl, (%r12)
test %rax, %rax
jnz .pn_loop
lea buf(%rip), %rax
add $21, %rax
sub %r12, %rax
mov $1, %rdi
mov %r12, %rsi
mov %rax, %rdx
mov $1, %eax
syscall
pop %r12
pop %rbx
ret
.bss
buf: .skip 21
$ as -o fib_test.o fib_test.s
$ ld -o fib_test fib_test.o
$ ./fib_test
55
(fib(10) = 55 — হাতে গুনেও মিলিয়ে নেওয়া যায়: 0,1,1,2,3,5,8,13,21,34,55।)
যাচাই করার কৌশল — কোনো C টেস্ট হার্নেস ছাড়া
libc নেই মানে assert() বা কোনো unit-test ফ্রেমওয়ার্কও নেই। তিনটা স্বাধীন উপায়ে সঠিকতা যাচাই করা যায়:
- stdout-এ ফলাফল লিখে চোখে মেলানো — উপরের প্রতিটা প্রোগ্রামই এটাই করছে।
writesyscall সরাসরি কার্নেলে যায়, কোনো buffering লেয়ার নেই যা লুকিয়ে রাখতে পারে ফলাফল ভুল হলে। - Exit code দিয়ে boolean যাচাই — যদি ফলাফল সংখ্যায় প্রিন্ট করতে না চান,
mov %rax, %ediকরে সরাসরিexitকল করলে শেলেecho $?দিয়ে ফলাফল দেখা যায় (তবে ০-২৫৫ রেঞ্জেই সীমাবদ্ধ, তাই বড় সংখ্যার জন্য কাজে আসে না)। - gdb দিয়ে instruction-level পরিদর্শন (এই মডিউলেরই “gdb/lldb দিয়ে instruction-level debugging” লেসনের সরাসরি প্রয়োগ) — ফাংশন কল হওয়ার ঠিক পরে breakpoint বসিয়ে রেজিস্টার দেখা:
$ gdb ./strlen_test
(gdb) break *_start+9 # call my_strlen-এর ঠিক পরের ইন্সট্রাকশনে
(gdb) run
(gdb) print/d $rax
$1 = 16
print/d $rax রেজিস্টারের মান দশমিকে দেখায় — এটা মিলে গেলে ফাংশনটা সঠিক বলে নিশ্চিত হওয়া যায়, প্রোগ্রাম পুরো শেষ হওয়ার অপেক্ষা না করেই। memcpy-র জন্য memory ইন্সপেক্ট করা যায়:
(gdb) break *_start+20
(gdb) run
(gdb) x/5c dst
0x...: 72 'H' 101 'e' 108 'l' 108 'l' 111 'o'
x/5c dst মানে — dst ঠিকানা থেকে ৫টা character (byte) হিসেবে মেমরি dump করা। এই টেকনিকটাই বড় প্রোগ্রামে বাগ খোঁজার আসল হাতিয়ার — কোথায় প্রত্যাশিত মান আর বাস্তব মান আলাদা হয়ে গেল, সেটা breakpoint দিয়ে binary-search করে বের করা।
নিজেকে চ্যালেঞ্জ করুন
strcmpলিখুন — দুইটা string তুলনা করে-1/0/1ফেরত দিন, প্রথম অমিল বাইটে থেমেmemmoveলিখুন — ওভারল্যাপিং বাফার সামলানোর জন্যdest > srcহলে পিছন থেকে সামনে কপি করুন, নাহলে সামনে থেকে পিছনে (ওভাররাইট এড়াতে)- Recursive fibonacci-ও লিখে সময় মাপুন —
fib(30)iterative আর recursive দুইভাবে চালিয়েtimeকমান্ড দিয়ে পার্থক্য দেখুন; recursive সংস্করণে stack frame push/pop-এর প্যাটার্নটাও gdb-তেbt(backtrace) দিয়ে দেখুন - Command-line argument পড়ুন —
_start-এ স্ট্যাকের উপর সরাসরিargc/argvবসানো থাকে ((%rsp)= argc,8(%rsp)= argv[0]…); সেখান থেকে সরাসরি পড়ে একটা argument-এর length প্রিন্ট করুন - একটা সাধারণ Makefile লিখুন যা তিনটা প্রোগ্রামই assemble+link করে, যাতে
make && make testদিয়ে সবকটা এক কমান্ডে চলে ও ফলাফল যাচাই হয়
এটা যেখানে গিয়ে মিশবে
| এখানে যা শিখলেন | পরে কোথায় লাগবে |
|---|---|
libc ছাড়া raw _start, syscall-ভিত্তিক exit | Level 4 — OS module-এর “system calls — mechanism ও cost” লেসন, Unix Tools from Scratch প্রজেক্ট |
Callee-saved রেজিস্টার সংরক্ষণ (push/pop) | এই মডিউলেরই বাকি প্রজেক্ট ও Level 4-এর calling convention আলোচনা |
| Iterative বনাম recursive খরচ | Level 6 — Algorithms module-এর recurrence relations, dynamic programming |
| RIP-relative addressing | এই মডিউলের “Position independent code” টপিক, Level 4-এর shared library loading |
| gdb দিয়ে register/memory পরিদর্শন | এই মডিউলেরই Toy Assembler ও Compiler Output Autopsy প্রজেক্ট, Level 4-এর kernel debugging |