Foundationপ্রথম নীতি থেকে
LEVEL 3কঠিন~৮ ঘণ্টাx86-64 Assembly

হাতে-লেখা অ্যাসেম্বলি প্রোগ্রাম

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 কনভেনশন:

রেজিস্টারকাজ
raxsyscall নম্বর
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, r12r15 callee-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 ফ্রেমওয়ার্কও নেই। তিনটা স্বাধীন উপায়ে সঠিকতা যাচাই করা যায়:

  1. stdout-এ ফলাফল লিখে চোখে মেলানো — উপরের প্রতিটা প্রোগ্রামই এটাই করছে। write syscall সরাসরি কার্নেলে যায়, কোনো buffering লেয়ার নেই যা লুকিয়ে রাখতে পারে ফলাফল ভুল হলে।
  2. Exit code দিয়ে boolean যাচাই — যদি ফলাফল সংখ্যায় প্রিন্ট করতে না চান, mov %rax, %edi করে সরাসরি exit কল করলে শেলে echo $? দিয়ে ফলাফল দেখা যায় (তবে ০-২৫৫ রেঞ্জেই সীমাবদ্ধ, তাই বড় সংখ্যার জন্য কাজে আসে না)।
  3. 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 করে বের করা।

নিজেকে চ্যালেঞ্জ করুন

  1. strcmp লিখুন — দুইটা string তুলনা করে -1/0/1 ফেরত দিন, প্রথম অমিল বাইটে থেমে
  2. memmove লিখুন — ওভারল্যাপিং বাফার সামলানোর জন্য dest > src হলে পিছন থেকে সামনে কপি করুন, নাহলে সামনে থেকে পিছনে (ওভাররাইট এড়াতে)
  3. Recursive fibonacci-ও লিখে সময় মাপুনfib(30) iterative আর recursive দুইভাবে চালিয়ে time কমান্ড দিয়ে পার্থক্য দেখুন; recursive সংস্করণে stack frame push/pop-এর প্যাটার্নটাও gdb-তে bt (backtrace) দিয়ে দেখুন
  4. Command-line argument পড়ুন_start-এ স্ট্যাকের উপর সরাসরি argc/argv বসানো থাকে ((%rsp) = argc, 8(%rsp) = argv[0]…); সেখান থেকে সরাসরি পড়ে একটা argument-এর length প্রিন্ট করুন
  5. একটা সাধারণ Makefile লিখুন যা তিনটা প্রোগ্রামই assemble+link করে, যাতে make && make test দিয়ে সবকটা এক কমান্ডে চলে ও ফলাফল যাচাই হয়

এটা যেখানে গিয়ে মিশবে

এখানে যা শিখলেনপরে কোথায় লাগবে
libc ছাড়া raw _start, syscall-ভিত্তিক exitLevel 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