System call — দেয়ালের দরজাটা ভেতর থেকে খোলা
System Calls
গত লেসনে দেখলাম kernel আর user space-এর মাঝে একটা hardware দেয়াল আছে, আর তাতে তিনটা দরজা। এই লেসনে প্রথম দরজাটা — system call — খুলে ভেতরটা দেখব। একটা `syscall` instruction থেকে শুরু করে kernel-এর handler পর্যন্ত প্রতিটা ধাপ, কেন argument গুলো ঠিক ওই register-এ, ৪০০ ন্যানোসেকেন্ড কোথায় যায়, আর `errno` আসলে কোথা থেকে জন্মায়। শেষে libc-কে সম্পূর্ণ বাদ দিয়ে খালি হাতে তিনটা syscall করা একটা প্রোগ্রাম বানাব।
আগে এটা বুঝি
আপনি জীবনে যত C প্রোগ্রাম লিখেছেন, তার প্রায় সবগুলোতে এই লাইনটা আছে:
printf("Hello\n");প্রশ্ন: এই লাইনটা কীভাবে আপনার টার্মিনালে অক্ষর আনে?
printf একটা ফাংশন। ফাংশন মানে একটা call instruction, একটা
নতুন stack frame, কিছু কাজ, একটা ret। কিন্তু “কিছু কাজ”-এর
ভেতরে কোথাও তো টার্মিনাল-এর hardware-এ byte পাঠাতেই হবে। আর
গত লেসনে আমরা প্রমাণ করেছি — user mode থেকে hardware ছোঁয়া
যায় না। outb চালালে SIGSEGV।
তাহলে?
তাহলে printf কোথাও গিয়ে নিজে থামে আর kernel-কে ডাকে।
সেই ডাকটার নাম system call। আর ডাকার পদ্ধতিটা একটাই instruction:
syscallএকটা মাত্র instruction যা CPU-কে বলে: “আমি স্বেচ্ছায় আমার সব ক্ষমতা ছেড়ে দিচ্ছি, তুমি ring 0-তে যাও, আর kernel যে জায়গাটা আগে থেকে ঠিক করে রেখেছে সেখানে লাফাও।”
লক্ষ্য করুন এটা কতটা অস্বাভাবিক। সাধারণ প্রোগ্রামিং-এ আপনি ফাংশনের ঠিকানা জানেন এবং সেখানে লাফান। এখানে আপনি ঠিকানা জানেনও না, জানার দরকারও নেই, এবং জানলেও সেখানে লাফাতে পারতেন না। আপনি শুধু ঠক ঠক করেন। দরজা কোথায়, কে খুলবে, কী হবে — সব kernel আগেই বুট-টাইমে ঠিক করে রেখেছে।
এই লেসনটা সেই ঠক ঠক-এর পুরো যান্ত্রিকতা নিয়ে। আর শেষে আমরা
printf তো দূরের কথা, পুরো libc বাদ দিয়ে একটা প্রোগ্রাম
বানাব যা খালি হাতে kernel-এর দরজায় গিয়ে দাঁড়ায়।
মূল ধারণা
System call কী, আর কী নয়
একটা system call হলো একটা function call নয় — যদিও C-তে সেটা দেখতে ঠিক function call-এর মতো:
ssize_t n = write(1, "hi\n", 3);পার্থক্যগুলো মৌলিক:
| সাধারণ function call | System call | |
|---|---|---|
| গন্তব্য | আপনার নির্ধারিত address | kernel-এর নির্ধারিত address |
| Privilege | ring 3 থেকে ring 3 | ring 3 থেকে ring 0 |
| Stack | একই stack | kernel stack-এ switch |
| Argument | rdi, rsi, rdx, rcx, r8, r9 | rdi, rsi, rdx, r10, r8, r9 |
| চিহ্নিতকরণ | address দিয়ে | সংখ্যা দিয়ে |
| খরচ | ~১ ns | ~৫০–৫০০ ns |
| ব্যর্থতা | আপনার নিজের চুক্তি | ঋণাত্মক মান = -errno |
সবচেয়ে গুরুত্বপূর্ণ পার্থক্যটা তৃতীয় সারি নয়, পঞ্চম সারি: system call-কে চিহ্নিত করা হয় একটা সংখ্যা দিয়ে, address দিয়ে নয়।
কেন? কারণ address দিলে user program যেকোনো kernel address দিতে পারত — অর্থাৎ kernel-এর মাঝখানে লাফিয়ে permission check এড়িয়ে যেতে পারত। সংখ্যা দিলে kernel সেটাকে একটা টেবিলে bounds-check করে দেখে নিতে পারে। এই একটা নকশাগত সিদ্ধান্তই পুরো দরজাটাকে নিরাপদ করে।
x86-64-এ syscall instruction ঠিক কী করে
AMD-র ম্যানুয়াল থেকে syscall-এর আচরণ, ধাপে ধাপে:
syscall চালালে CPU যা করে:
1. RCX ← RIP পরের instruction-এর ঠিকানা RCX-এ জমা
2. R11 ← RFLAGS বর্তমান flag গুলো R11-এ জমা
3. RFLAGS ← RFLAGS & ~IA32_FMASK
→ IF bit মুছে যায়, অর্থাৎ interrupt বন্ধ
4. CS ← IA32_STAR[47:32] kernel code segment
SS ← IA32_STAR[47:32] + 8 kernel stack segment
CPL ← 0 ← এখানেই ring বদলাল
5. RIP ← IA32_LSTAR kernel-এর entry point
6. RSP ← অপরিবর্তিত! ← এখনো user-এর stack pointer
এখন CPU ring 0-তে, kernel-এর কোড চালাচ্ছে,
কিন্তু হাতে এখনো user-এর stack — এটাই প্রথম বিপদ।ছয় নম্বর পয়েন্টটা ভালো করে দেখুন। syscall stack বদলায় না।
Interrupt-এর সময় hardware TSS থেকে RSP0 লোড করে দেয়, কিন্তু
syscall-এ সেটা হয় না — গতির জন্য।
ফলে kernel-এর entry code-এর প্রথম কাজই হলো নিজে হাতে stack
বদলানো, আর সেটা করতে গেলে একটা register লাগে, কিন্তু কোনো
register নষ্ট করা যাবে না কারণ সবগুলোতে user-এর ডেটা।
এই মুরগি-ডিম সমস্যার সমাধানই হলো swapgs — একটু পরে দেখব।
Linux x86-64 syscall ABI — আর rcx-এর রহস্য
চুক্তিটা এই:
| জিনিস | Register |
|---|---|
| syscall number | rax |
| argument ১ | rdi |
| argument ২ | rsi |
| argument ৩ | rdx |
| argument ৪ | r10 |
| argument ৫ | r8 |
| argument ৬ | r9 |
| return value | rax |
| নষ্ট হয়ে যায় | rcx, r11 |
এখন C-র System V calling convention-এর সাথে মিলিয়ে দেখুন (Level 3-এর assembly module-এ আমরা এটা বিস্তারিত করেছি):
C function : rdi, rsi, rdx, rcx, r8, r9
syscall : rdi, rsi, rdx, r10, r8, r9
↑
শুধু এই একটা আলাদাকেন? উপরের syscall instruction-এর ধাপ ১-এ ফিরে যান:
RCX ← RIP
syscall instruction নিজেই rcx-এ return address লিখে দেয়।
আপনি সেখানে argument রাখলে সেটা syscall চালানোর
সাথে সাথেই মুছে যেত — আর kernel পেত একটা random return
address।
তাই ABI-র নকশাকারদের একটা বিকল্প register বাছতে হয়েছিল,
আর তারা r10 বেছেছেন (r10 C-তে caller-saved scratch, কেউ
সেটার উপর নির্ভর করে না)।
r11 একইভাবে হারায় কারণ RFLAGS সেখানে জমা হয়।
ARM64 — একই ধারণা, পরিচ্ছন্ন বাস্তবায়ন
// write(1, msg, 13) on ARM64 Linux
mov x0, #1 // fd
adr x1, msg // buffer
mov x2, #13 // count
mov x8, #64 // __NR_write
svc #0 // supervisor call → EL1
| x86-64 | ARM64 | |
|---|---|---|
| Instruction | syscall | svc #0 |
| Number | rax | x8 |
| Args ১–৬ | rdi rsi rdx r10 r8 r9 | x0 x1 x2 x3 x4 x5 |
| Return | rax | x0 |
| Return address | rcx (GPR নষ্ট হয়) | ELR_EL1 (আলাদা register) |
| Saved flags | r11 (GPR নষ্ট হয়) | SPSR_EL1 |
| Entry point | IA32_LSTAR MSR | VBAR_EL1 vector table |
| Privilege | ring 3 → ring 0 | EL0 → EL1 |
লক্ষ্য করুন svc #0-এর #0 টা আসলে একটা ১৬-bit immediate যা
Linux উপেক্ষা করে (কিছু OS সেটা দিয়ে syscall শ্রেণি
আলাদা করে)। এটা historical: OS চাইলে এখানে number-টাই
বসাতে পারত, কিন্তু তাহলে syscall number runtime-এ পরিবর্তনযোগ্য
হতো না।
Syscall number — একটা টেবিল, তিনটা ভিন্ন জগৎ
x86-64-এ syscall number আসে
arch/x86/entry/syscalls/syscall_64.tbl থেকে:
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
...
59 64 execve sys_execve
60 common exit sys_exit
231 common exit_group sys_exit_group
...
425 common io_uring_setup sys_io_uring_setup
453 common map_shadow_stack sys_map_shadow_stackLinux 6.6-এ x86-64-এ ৪৫০-এর বেশি syscall আছে। কিন্তু এই সংখ্যাগুলো architecture-ভেদে সম্পূর্ণ আলাদা:
| syscall | i386 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|---|
read | 3 | 0 | 63 | 63 |
write | 4 | 1 | 64 | 64 |
open | 5 | 2 | নেই | নেই |
openat | 295 | 257 | 56 | 56 |
exit | 1 | 60 | 93 | 93 |
exit_group | 252 | 231 | 94 | 94 |
দুটো জিনিস লক্ষণীয়।
এক: ARM64 আর RISC-V-এ open নেই। শুধু openat আছে।
কারণ ARM64 তুলনামূলক নতুন (২০১২), তাই Linux সেই সুযোগে
“generic syscall ABI” নামে একটা পরিষ্কার তালিকা দিয়ে শুরু
করেছে, যেখানে পুরনো/অপ্রয়োজনীয় syscall গুলো বাদ। openat
থাকলে open অপ্রয়োজনীয় — openat(AT_FDCWD, path, ...)
হুবহু একই কাজ করে।
দুই: i386 আর x86-64-এর সংখ্যা মেলে না মোটেও। তাই একটা
৩২-bit binary ৬৪-bit kernel-এ চালালে kernel-কে জানতে হয়
কোন টেবিল দেখবে — সেটা সে বোঝে কোন entry point দিয়ে
এসেছে তা থেকে (entry_SYSCALL_64 বনাম entry_INT80_compat)।
ভেতরে কী ঘটছে
write(1, "hi\n", 3) — পুরো যাত্রাপথ
এবার একটা write কলের প্রতিটা স্তর, আপনার C কোড থেকে
disk/terminal পর্যন্ত:
- আপনার কোডwrite(1, "hi\n", 3) — একটা সাধারণ C function call
- glibc wrappersysdeps/unix/sysv/linux/write.c — errno হ্যান্ডলিং যোগ করে
- syscall instructionrax=1, rdi=1, rsi=buf, rdx=3 — তারপর syscall
- ⎯⎯ ring 3 | ring 0 সীমানা ⎯⎯hardware: CPL←0, RIP←IA32_LSTAR
- entry_SYSCALL_64arch/x86/entry/entry_64.S — swapgs, CR3, stack switch
- do_syscall_64()arch/x86/entry/common.c — bounds check + dispatch
- sys_call_table[1]__x64_sys_write — pt_regs থেকে argument বের করা
- ksys_write()fs/read_write.c — fd বৈধ? fdget_pos()
- vfs_write()fs/read_write.c — permission check, LSM hook
- file->f_op->write_iter()VFS-এর indirect call — লেসন ১৯
- tty_write() / ext4_file_write_iter()যে device সেই অনুযায়ী
- ↩ ফেরত: rax = লেখা byte সংখ্যাsyscall_exit_to_user_mode → sysret
উপরের দিক থেকে নিচে যেতে যেতে খেয়াল করুন কোথায় সীমানাটা।
তার উপরে সবকিছু আপনার process-এর, ring 3-এ, আপনার
debugger দেখতে পায়। তার নিচে সবকিছু kernel-এর, ring 0-তে,
gdb সেখানে ঢুকতে পারে না (perf আর ftrace পারে — লেসন ৩০)।
Kernel entry — প্রথম ২০টা instruction
arch/x86/entry/entry_64.S-এর entry_SYSCALL_64, সরলীকৃত
কিন্তু বাস্তব ক্রম অনুযায়ী:
SYM_CODE_START(entry_SYSCALL_64)
swapgs
/* এখন GS_BASE kernel-এর per-CPU এলাকার দিকে তাক করা */
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
/* user-এর RSP নিরাপদে জমা রাখলাম */
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
/* KPTI: user page table → kernel page table */
movq PER_CPU_VAR(pcpu_hot + X86_top_of_stack), %rsp
/* এখন kernel stack-এ */
pushq $__USER_DS /* SS */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* RSP */
pushq %r11 /* RFLAGS — syscall এটা এখানে রেখেছিল */
pushq $__USER_CS /* CS */
pushq %rcx /* RIP — syscall এটা এখানে রেখেছিল */
pushq %rax /* syscall number */
PUSH_AND_CLEAR_REGS rax=$-ENOSYS
/* বাকি ১৫টা GPR stack-এ, আর অব্যবহৃতগুলো শূন্য করে দেওয়া */
movq %rsp, %rdi /* struct pt_regs * */
movslq %eax, %rsi /* syscall number */
call do_syscall_64
...তিনটা লাইন বিশেষভাবে গুরুত্বপূর্ণ।
swapgs — মুরগি-ডিম সমস্যার সমাধান। Kernel-এ ঢুকে
প্রথমেই per-CPU ডেটা দরকার (কোন CPU? কার stack?), কিন্তু সেটা
পড়তে একটা pointer লাগে, আর pointer রাখার জায়গা register,
আর সব register-এ user-এর ডেটা যা নষ্ট করা যাবে না।
সমাধান: GS_BASE নামে একটা MSR-এ kernel per-CPU এলাকার ঠিকানা
রাখা থাকে, আর swapgs সেটাকে user-এর GS_BASE-এর সাথে
অদলবদল করে। একটা instruction, কোনো GPR ছোঁয় না।
swapgs ভুল সময়ে চালানো একটা ঐতিহাসিক নিরাপত্তা গর্তের
উৎস — CVE-2019-1125 (“SWAPGS attack”) ঠিক এই instruction-এর
speculative আচরণকে কাজে লাগিয়েছিল।
PUSH_AND_CLEAR_REGS — শুধু জমা নয়, মোছাও। Register জমা
রাখা স্পষ্ট। কিন্তু শূন্য করাটা কেন? কারণ Spectre।
User যদি একটা register-এ একটা kernel address রেখে দেয় আর
kernel সেটা speculatively ব্যবহার করে ফেলে, তথ্য ফাঁস হতে
পারে। তাই যেসব register এই syscall-এ ব্যবহার হবে না,
সেগুলো শূন্য করে দেওয়া হয়। এটা Spectre-পরবর্তী যুগে যোগ
হয়েছে, আগে ছিল না।
SWITCH_TO_KERNEL_CR3 — KPTI-র দাম, ঠিক এখানে। গত
লেসনের সেই cr3 লেখাটা এখানেই ঘটছে, প্রতিটা syscall-এ।
Dispatch — টেবিল দেখে handler খোঁজা
arch/x86/entry/common.c:
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = sys_call_table[unr](regs);
return true;
}
return false;
}চারটা লাইন, চারটা গুরুত্বপূর্ণ ধারণা:
১. unsigned int unr = nr; — signed থেকে unsigned-এ রূপান্তর।
এতে ঋণাত্মক number স্বয়ংক্রিয়ভাবে বিশাল ধনাত্মক হয়ে যায় আর
একটাই তুলনায় ধরা পড়ে। ক্লাসিক কৌশল, আর এখানে নিরাপত্তার
জন্য অপরিহার্য — signed তুলনা রাখলে nr = -1 টেবিলের আগের
memory পড়ত।
২. unr < NR_syscalls — bounds check। এটাই সেই যাচাই যা
“সংখ্যা দিয়ে চিহ্নিত করা”-কে নিরাপদ করে।
৩. array_index_nospec() — Spectre v1 প্রশমন। Bounds check
থাকা সত্ত্বেও CPU speculatively টেবিলের বাইরে পড়তে পারে।
এই macro একটা branchless mask তৈরি করে যা speculation-এর
সময়েও index-কে সীমার ভেতরে জোর করে রাখে।
৪. sys_call_table[unr](regs) — লক্ষ্য করুন handler
একটাই argument নেয়: struct pt_regs *। ৪.১৭-এর আগে
handler গুলো সরাসরি (fd, buf, count) নিত। বদলানো হয়েছে
Spectre-এর কারণে: এখন argument গুলো pt_regs থেকে kernel
নিজে বের করে নেয়, তাই register-এ থাকা আবর্জনা speculatively
argument হিসেবে ব্যবহৃত হতে পারে না।
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf, size_t, count) লেখা হলে macro টা তিনটা জিনিস তৈরি করে:
__x64_sys_write(struct pt_regs *regs) (টেবিলে যেটা বসে),
__se_sys_write (sign-extension wrapper), আর
__do_sys_write (আসল কোড)।
ফেরার পথ — sysret না iret
syscall-এর উল্টোটা sysret: RIP ← RCX, RFLAGS ← R11,
CPL ← 3। দ্রুত, কারণ কিছুই memory থেকে পড়তে হয় না।
কিন্তু kernel সবসময় sysret ব্যবহার করতে পারে না:
| অবস্থা | কেন sysret নয় | তখন কী |
|---|---|---|
ptrace register বদলেছে | RCX/R11 আর বৈধ নয় | iret |
sigreturn থেকে ফিরছে | পুরো context restore করতে হবে | iret |
| RIP non-canonical | sysret #GP তুলবে ring 0-তে | iret |
| segment register বদলানো | sysret সেগুলো restore করে না | iret |
| সাধারণ syscall | — | sysret |
তৃতীয় সারিটাই গত লেসনে উল্লেখ করা CVE-2012-0217।
Intel-এর sysret canonical check করে privilege বদলানোর
পরে, তাই #GP উঠত ring 0-তে user-এর stack নিয়ে। AMD আগে
check করে, তাই আক্রান্ত ছিল না। Linux এখন RCX canonical কি না
নিজেই যাচাই করে, না হলে iret পথে যায়।
ফেরার মান — errno আসলে কোথা থেকে আসে
Kernel-এর চুক্তি অত্যন্ত সরল:
সফল হলে
rax-এ ফলাফল। ব্যর্থ হলেrax-এ-errno।
কোনো আলাদা “errno” চ্যানেল নেই, কোনো out-parameter নেই। একটাই register, দুটো অর্থ।
কীভাবে আলাদা করা যায়? Linux-এ MAX_ERRNO হলো 4095।
তাই নিয়মটা:
ret যদি [-4095, -1] সীমার ভেতরে হয় → এটা একটা error, errno = -ret
অন্যথায় → এটা একটা বৈধ ফলাফলinclude/linux/err.h-এ এটাই লেখা আছে:
#define MAX_ERRNO 4095
#define IS_ERR_VALUE(x) unlikely((unsigned long)(void *)(x) >= (unsigned long)-MAX_ERRNO)আর glibc-র wrapper এই যাচাইটা করে (sysdeps/unix/sysv/linux/x86_64/sysdep.h):
long ret = raw_syscall(...);
if ((unsigned long)ret >= (unsigned long)-4095) {
errno = -ret; /* আসলে *__errno_location() */
return -1;
}
return ret;লক্ষ্য করুন (unsigned long) cast-টা। -4095 কে unsigned
হিসেবে দেখলে সেটা 0xFFFFFFFFFFFFF001 — একটা বিশাল সংখ্যা।
তাই ”≥ -4095 (unsigned)” মানে কার্যত “সবচেয়ে উপরের ৪০৯৫টা
মান”। একটাই তুলনায় পুরো range।
৪০০ ন্যানোসেকেন্ড কোথায় যায়
গত লেসনে আমরা একটা getpid() syscall মেপে পেয়েছিলাম প্রায়
৪০০ ns। getpid-এর আসল কাজ হলো একটা struct থেকে একটা int
পড়া — ৫ cycle-এরও কম। বাকিটা কোথায়?
৩ GHz-এ ৪০০ ns ≈ ১২০০ cycle। একটা KPTI ও IBRS চালু Skylake-শ্রেণির মেশিনে আনুমানিক ভাগ:
| ধাপ | আনুমানিক cycle | কেন |
|---|---|---|
syscall instruction | ২৫–৫০ | MSR পড়া, CPL বদল, pipeline serialize |
swapgs + user RSP জমা | ১০–২০ | MSR swap |
| KPTI: CR3 → kernel PGD | ১০০–২৫০ | PCID থাকলে কম, না থাকলে TLB flush |
| Spectre: IBRS/STIBP লেখা | ১০০–৩০০ | wrmsr, খুব ব্যয়বহুল instruction |
PUSH_AND_CLEAR_REGS | ২০–৪০ | ১৫টা push + বাকিগুলো শূন্য করা |
do_syscall_64 dispatch | ১০–৩০ | bounds check, indirect call (retpoline) |
আসল handler (getpid) | ~৫ | এটাই একমাত্র “কাজ” |
syscall_exit_to_user_mode | ৩০–১০০ | signal pending? resched দরকার? audit? |
| KPTI: CR3 → user PGD | ১০০–২৫০ | ফেরার পথে আবার |
swapgs + sysret | ২০–৪০ | |
| পরোক্ষ: ঠান্ডা cache, TLB, BTB | ১০০–৩০০ | ফিরে এসে আপনার কোড ধীর চলে |
| মোট | ৫২০–১৩৮৫ | ≈ ১৭০–৪৬০ ns @ ৩ GHz |
যে শিক্ষাটা এখান থেকে নেওয়ার: ৪০০ ns-এর মধ্যে ০.৪% আসল কাজ। বাকি ৯৯.৬% হলো দেয়াল পার হওয়ার খরচ — আর তার একটা বড় অংশ (KPTI + Spectre ≈ ৪০০–৮০০ cycle) ২০১৮-র পরে যোগ হয়েছে।
নিজের মেশিনে প্রশমনের অংশটা মাপতে পারেন:
# বর্তমান অবস্থা
grep . /sys/devices/system/cpu/vulnerabilities/* | head
# আর তারপর একটা VM-এ mitigations=off দিয়ে boot করে
# একই benchmark চালিয়ে তুলনা করুনএই ৪০০ ns-ই ব্যাখ্যা করে কেন আধুনিক system design এত
জোরালোভাবে syscall কমানোর দিকে ঝুঁকেছে: buffered I/O
(stdio), writev দিয়ে batching, epoll (লেসন ২০),
আর সবচেয়ে চরমভাবে io_uring (লেসন ২১), যেখানে হাজারটা
I/O অনুরোধ শূন্যটা syscall-এ জমা দেওয়া যায়।
উদাহরণ
একই কাজ, তিনটা স্তরে
আমরা একই জিনিস — টার্মিনালে hi লেখা — তিনটা আলাদা
বিমূর্ততার স্তরে করব, আর প্রতিটার খরচ দেখব।
স্তর ১ — printf
#include <stdio.h>
int main(void) { printf("hi\n"); return 0; }printf কী করে: format string parse করে, একটা internal
buffer-এ লেখে, আর buffer পূর্ণ না হলে বা line-buffered mode-এ
newline না এলে কিছুই লেখে না। শেষে exit-এর সময়
_IO_cleanup buffer flush করে।
অর্থাৎ printf("hi\n") টার্মিনালে (line-buffered) একটা
write করে, কিন্তু ফাইলে redirect করলে (fully buffered,
৪০৯৬ byte) প্রোগ্রাম শেষ না হওয়া পর্যন্ত কিছুই লেখে না।
./a.out # সাথে সাথে "hi"
./a.out > out.txt # প্রোগ্রাম শেষ হলে তবে লেখা হয়এই আচরণটা বহু বিভ্রান্তির উৎস — বিশেষত crash-এর সময় “শেষ printf টা কোথায় গেল?”-এর উত্তর: buffer-এ ছিল, flush হয়নি।
স্তর ২ — write
#include <unistd.h>
int main(void) { write(1, "hi\n", 3); return 0; }কোনো buffer নেই, কোনো parsing নেই। glibc-র wrapper শুধু
register সাজায়, syscall চালায়, আর ফেরার মান দেখে
errno সেট করে।
স্তর ৩ — কাঁচা syscall
int main(void) {
__asm__ volatile (
"movq $1, %%rax\n\t" /* __NR_write */
"movq $1, %%rdi\n\t" /* fd = 1 */
"leaq msg(%%rip), %%rsi\n\t"
"movq $3, %%rdx\n\t" /* count */
"syscall\n\t"
: : : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory"
);
return 0;
}
__asm__(".section .rodata\nmsg: .ascii \"hi\\n\"\n.text");কোনো libc নেই এই পথে। কিন্তু লক্ষ্য করুন clobber list-এ
rcx আর r11 আছে — কারণ syscall সেগুলো নষ্ট করবে, আর
compiler-কে সেটা না জানালে সে সেখানে কিছু রেখে দিতে পারে
এবং পরে ভুল মান পড়তে পারে। এই একটা ভুলে জীবনের সবচেয়ে
দুর্বোধ্য bug হয়।
তিনটার তুলনা
gcc -O2 -o v1 v1.c && strace -c ./v1 2>&1 | tail -5
| সংস্করণ | মোট syscall | binary আকার (dynamic) | কখন লেখা হয় |
|---|---|---|---|
printf | ~২৭ | ১৬ KB | buffer flush-এ |
write | ~২৫ | ১৬ KB | সাথে সাথে |
কাঁচা syscall | ~২৫ | ১৬ KB | সাথে সাথে |
“সবগুলোতেই ~২৫?” — হ্যাঁ, কারণ dynamic linking নিজেই ২০+
syscall খরচ করে (ld.so চালু হওয়া, libc.so খোঁজা, mmap
করা)। আপনার নিজের কোডের syscall মাত্র ১–২টা।
Static করলে ছবিটা পাল্টে যায়:
gcc -O2 -static -o v2 v2.c && strace -c ./v2 2>&1 | tail -5তখন ~৭টা। আর -nostdlib দিয়ে সম্পূর্ণ freestanding করলে
ঠিক যতগুলো আপনি লিখেছেন ততগুলো। সেটাই এই লেসনের
build section-এ বানাব।
নিজে চালিয়ে দেখুন
Hello World আসলে কয়টা syscall করে
cat > hello.c <<'EOF'
#include <stdio.h>
int main(void) { printf("Hello, syscall!\n"); return 0; }
EOF
gcc -O2 -o hello-dyn hello.c
gcc -O2 -static -o hello-static hello.c
echo "=== dynamic ==="
strace -c ./hello-dyn 2>&1 | tail -20
echo "=== static ==="
strace -c ./hello-static 2>&1 | tail -20Dynamic-এর সাধারণ ফলাফল (Ubuntu 24.04, glibc 2.39):
Hello, syscall!
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 2 read
0.00 0.000000 0 1 write
0.00 0.000000 0 2 close
0.00 0.000000 0 4 mmap
0.00 0.000000 0 3 mprotect
0.00 0.000000 0 1 munmap
0.00 0.000000 0 3 brk
0.00 0.000000 0 1 1 access
0.00 0.000000 0 1 execve
0.00 0.000000 0 2 1 arch_prctl
0.00 0.000000 0 1 set_tid_address
0.00 0.000000 0 1 set_robust_list
0.00 0.000000 0 1 prlimit64
0.00 0.000000 0 1 rseq
0.00 0.000000 0 2 newfstatat
0.00 0.000000 0 1 openat
0.00 0.000000 0 1 getrandom
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 28 2 totalStatic-এর:
Hello, syscall!
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 write
0.00 0.000000 0 2 brk
0.00 0.000000 0 1 execve
0.00 0.000000 0 2 1 arch_prctl
0.00 0.000000 0 1 set_tid_address
0.00 0.000000 0 1 set_robust_list
0.00 0.000000 0 1 prlimit64
0.00 0.000000 0 1 rseq
0.00 0.000000 0 1 getrandom
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 11 1 total২৮ বনাম ১১। এখন প্রতিটা syscall ব্যাখ্যা করা যাক। পুরো তালিকা দেখুন:
strace -f ./hello-dynআপনি যা দেখবেন, ব্যাখ্যা সহ:
| syscall | কে করছে | কেন |
|---|---|---|
execve | shell | প্রোগ্রাম চালু — এটা main-এর আগে |
brk(NULL) | ld.so | heap-এর বর্তমান শেষ জানা |
access("/etc/ld.so.preload") | ld.so | preload আছে কি না (ENOENT স্বাভাবিক) |
openat("/etc/ld.so.cache") | ld.so | library খোঁজার cache |
newfstatat + mmap | ld.so | cache ফাইল map করা |
openat("libc.so.6") | ld.so | libc খোঁজা |
read (৮৩২ byte) | ld.so | ELF header পড়া |
mmap ×৪ | ld.so | libc-র segment গুলো map করা |
mprotect ×৩ | ld.so | relocation-এর পরে RELRO জোরদার |
arch_prctl(ARCH_SET_FS) | ld.so | TLS (thread-local storage) বসানো |
set_tid_address, set_robust_list, rseq | libc | thread infrastructure |
getrandom | libc | stack canary + malloc-এর জন্য |
prlimit64 | libc | stack size জানা |
brk ×২ | libc | printf-এর buffer-এর জন্য heap |
write(1, "Hello, syscall!\n", 16) | আপনার কোড | এই একটাই আপনার |
exit_group(0) | libc | (strace -c এটা গোনে না) |
২৮টার মধ্যে ১টা আপনার। এটাই dynamic linking-এর দাম, আর এটাই কারণ container image-এ static binary জনপ্রিয় (Go-র default), আর কেন startup latency নিয়ে চিন্তিত সিস্টেম (FaaS, CLI tool) static করে।
একটা মজার যাচাই — getrandom কেন?
strace -e trace=getrandom ./hello-dyngetrandom("\x9f\x2a\xd1\x8e...", 8, GRND_NONBLOCK) = 8৮ byte — এটা stack canary। প্রতিটা function-এর stack frame-এ একটা random মান বসিয়ে return-এর আগে যাচাই করা হয়, যাতে buffer overflow ধরা পড়ে। Level 10-এ আমরা এটা বিস্তারিত দেখব, আর দেখব কীভাবে attacker এটা bypass করার চেষ্টা করে।
আপনার লেখা এক লাইনের প্রোগ্রামের বেশিরভাগ syscall আপনার নয় — সেগুলো dynamic linker আর libc-র startup খরচ। বিমূর্ততার দাম পরিমাপযোগ্য।
libc বাদ দিয়ে কাঁচা syscall — আর clobber ভুলে গেলে কী হয়
প্রথমে একটা সঠিক syscall wrapper:
/* rawsys.c — libc-র syscall() ছাড়া, নিজের হাতে */
#include <stdio.h>
static inline long sys3(long n, long a, long b, long c) {
long ret;
__asm__ volatile (
"syscall"
: "=a" (ret) /* out: rax */
: "a" (n), "D" (a), "S" (b), "d" (c) /* in: rax,rdi,rsi,rdx */
: "rcx", "r11", "memory" /* syscall এগুলো নষ্ট করে */
);
return ret;
}
static inline long sys6(long n, long a, long b, long c,
long d, long e, long f) {
long ret;
register long r10 __asm__("r10") = d;
register long r8 __asm__("r8") = e;
register long r9 __asm__("r9") = f;
__asm__ volatile (
"syscall"
: "=a" (ret)
: "a" (n), "D" (a), "S" (b), "d" (c),
"r" (r10), "r" (r8), "r" (r9)
: "rcx", "r11", "memory"
);
return ret;
}
#define SYS_write 1
#define SYS_getpid 39
#define SYS_openat 257
int main(void) {
long n = sys3(SYS_write, 1, (long)"কাঁচা syscall কাজ করছে\n", 34);
printf("write ফেরত দিল: %ld\n", n);
printf("getpid (কাঁচা): %ld\n", sys3(SYS_getpid, 0, 0, 0));
/* ইচ্ছাকৃত ব্যর্থতা — এমন ফাইল নেই */
long fd = sys6(SYS_openat, -100 /*AT_FDCWD*/,
(long)"/nonexistent-file-xyz", 0, 0, 0, 0);
printf("openat ফেরত দিল: %ld\n", fd);
if (fd >= -4095 && fd < 0)
printf(" → এটা একটা error, errno হবে %ld (ENOENT=2)\n", -fd);
return 0;
}gcc -O2 -o rawsys rawsys.c && ./rawsysকাঁচা syscall কাজ করছে
write ফেরত দিল: 34
getpid (কাঁচা): 48213
openat ফেরত দিল: -2
→ এটা একটা error, errno হবে 2 (ENOENT=2)-2 লক্ষ্য করুন। libc হলে এটা -1 ফেরত দিত আর errno
২-তে সেট করত। কাঁচা syscall সেই রূপান্তরটা করে না — kernel-এর
আসল চুক্তিটা আপনি সরাসরি দেখছেন।
এবার clobber ভুলে যাওয়ার পরীক্ষা। নিচেরটা ভুল কোড:
/* broken.c — ইচ্ছাকৃতভাবে ভুল: clobber list-এ rcx/r11 নেই */
static inline long bad_sys3(long n, long a, long b, long c) {
long ret;
__asm__ volatile ("syscall"
: "=a"(ret)
: "a"(n), "D"(a), "S"(b), "d"(c)
: "memory"); /* ← "rcx", "r11" নেই! */
return ret;
}
int main(void) {
long guard = 0x1234567890ABCDEFL;
for (int i = 0; i < 3; i++)
bad_sys3(1, 1, (long)".", 1);
bad_sys3(1, 1, "\n", 1);
if (guard != 0x1234567890ABCDEFL) {
bad_sys3(1, 1, (long)"guard নষ্ট হয়েছে!\n", 25);
return 1;
}
bad_sys3(1, 1, (long)"guard ঠিক আছে (এইবার ভাগ্য ভালো)\n", 44);
return 0;
}gcc -O0 -o broken0 broken.c && ./broken0
gcc -O2 -o broken2 broken.c && ./broken2
gcc -O3 -funroll-loops -o broken3 broken.c && ./broken3-O0-তে সম্ভবত কাজ করবে (compiler কিছুই register-এ রাখে না)।
-O2 বা -O3-এ compiler guard-কে rcx-এ রাখার সিদ্ধান্ত
নিতে পারে — আর তখন প্রথম syscall-এই সেটা নষ্ট।
$ ./broken0
...guard ঠিক আছে (এইবার ভাগ্য ভালো)
$ ./broken2
...guard নষ্ট হয়েছে!কোন register কোথায় গেল তা দেখুন:
objdump -d --no-show-raw-insn broken2 | sed -n '/<main>:/,/ret/p'syscall শুধু একটা instruction — libc কোনো জাদু করে না, শুধু register সাজায় আর errno হ্যান্ডল করে। আর compiler-কে clobber না জানালে যে bug হয় সেটা নীরব, অপ্রতিরোধ্য, আর optimization level-এর উপর নির্ভরশীল।
নিজে বানান
Freestanding — libc ছাড়া, `main` ছাড়া, তিনটা syscall
- নিজের `_start` লিখুন — কারণ libc ছাড়া কেউ main ডাকবে না
- syscall wrapper লিখুন সঠিক clobber list সহ
- তিনটা syscall করুন: write, nanosleep, exit_group
- -nostdlib -nostartfiles -static দিয়ে compile করে আকার তুলনা করুন
- strace দিয়ে প্রমাণ করুন ঠিক তিনটাই হয়েছে, একটাও বেশি নয়
libc ছাড়া প্রোগ্রাম লেখার সময় তিনটা জিনিস আপনাকে নিজে করতে হয়, যেগুলো সাধারণত অদৃশ্য:
- Entry point। Kernel
execve-এর পরে ELF header-এরe_entry-তে লাফায়, আর সেটার প্রচলিত নাম_start।mainএকটা libc-র ধারণা মাত্র। - প্রোগ্রাম শেষ করা।
_startথেকেreturnকরা যায় না — ফেরার কোনো জায়গা নেই।exit_groupsyscall করতেই হবে, না করলে_start-এর পরের আবর্জনা byte গুলো execute হবে আরSIGSEGV/SIGILLহবে। memcpy,strlenইত্যাদি। কিছুই নেই। যা লাগবে নিজে লিখুন।
/* tiny.c — libc সম্পূর্ণ বাদ
*
* build: gcc -O2 -nostdlib -nostartfiles -static \
* -fno-stack-protector -fno-asynchronous-unwind-tables \
* -o tiny tiny.c
* run: ./tiny ; echo "exit code = $?"
* verify: strace ./tiny
*/
/* ── syscall number (x86-64) ───────────────────────────────── */
#define SYS_write 1
#define SYS_getpid 39
#define SYS_nanosleep 35
#define SYS_exit_group 231
/* ── কাঁচা syscall wrapper ─────────────────────────────────── */
static inline long sys1(long n, long a) {
long r;
__asm__ volatile ("syscall" : "=a"(r)
: "a"(n), "D"(a)
: "rcx", "r11", "memory");
return r;
}
static inline long sys2(long n, long a, long b) {
long r;
__asm__ volatile ("syscall" : "=a"(r)
: "a"(n), "D"(a), "S"(b)
: "rcx", "r11", "memory");
return r;
}
static inline long sys3(long n, long a, long b, long c) {
long r;
__asm__ volatile ("syscall" : "=a"(r)
: "a"(n), "D"(a), "S"(b), "d"(c)
: "rcx", "r11", "memory");
return r;
}
/* ── ন্যূনতম libc — যা লাগে শুধু ততটুকু ────────────────────── */
static unsigned long slen(const char *s) {
unsigned long n = 0;
while (s[n]) n++;
return n;
}
static void puts_raw(const char *s) {
sys3(SYS_write, 1, (long)s, (long)slen(s));
}
/* unsigned long → দশমিক string, উল্টো দিক থেকে ভরে */
static void put_ulong(unsigned long v) {
char buf[24];
int i = sizeof buf;
buf[--i] = '\n';
if (v == 0) buf[--i] = '0';
while (v) { buf[--i] = (char)('0' + v % 10); v /= 10; }
sys3(SYS_write, 1, (long)&buf[i], (long)(sizeof buf - i));
}
/* ── nanosleep-এর জন্য struct timespec, নিজেরা সংজ্ঞায়িত ──── */
struct ts { long sec; long nsec; };
/* ── entry point ───────────────────────────────────────────── */
void _start(void) {
/* syscall #1 — write */
puts_raw("freestanding: libc নেই, ld.so নেই, main নেই\n");
/* syscall #2 — getpid, আর ফলাফল ছাপতে আরো write */
long pid = sys1(SYS_getpid, 0);
puts_raw("আমার PID: ");
put_ulong((unsigned long)pid);
/* syscall #3 — nanosleep, ৫০ মিলিসেকেন্ড */
struct ts req = { 0, 50 * 1000 * 1000 };
long slept = sys2(SYS_nanosleep, (long)&req, 0);
puts_raw(slept == 0 ? "ঘুম শেষ\n" : "ঘুম ভেঙে গেল\n");
/* একটা ইচ্ছাকৃত error — errno চুক্তি দেখতে */
long bad = sys3(SYS_write, 999 /* অবৈধ fd */, (long)"x", 1);
puts_raw("অবৈধ fd-তে write ফেরত দিল: ");
if (bad < 0) { puts_raw("-"); put_ulong((unsigned long)(-bad)); }
else put_ulong((unsigned long)bad);
puts_raw(" (9 = EBADF)\n");
/* এখান থেকে return করা যাবে না — exit_group করতেই হবে */
sys1(SYS_exit_group, 42);
/* এই লাইনে কখনো পৌঁছাবে না */
__builtin_unreachable();
}gcc -O2 -nostdlib -nostartfiles -static \
-fno-stack-protector -fno-asynchronous-unwind-tables \
-o tiny tiny.c
./tiny ; echo "exit code = $?"freestanding: libc নেই, ld.so নেই, main নেই
আমার PID: 51894
ঘুম শেষ
অবৈধ fd-তে write ফেরত দিল: -9 (9 = EBADF)
exit code = 42এবার প্রমাণ করুন যে অতিরিক্ত একটাও syscall হয়নি:
strace ./tinyexecve("./tiny", ["./tiny"], 0x7ffd...) = 0
write(1, "freestanding: libc \340\246\250\340\247\207\340\246\207..."..., 62) = 62
getpid() = 51894
write(1, "\340\246\206\340\246\256\340\246\276\340\246\260 PID: ", 16) = 16
write(1, "51894\n", 6) = 6
nanosleep({tv_sec=0, tv_nsec=50000000}, NULL) = 0
write(1, "\340\246\230\340\247\201\340\246\256 \340\246\266\340\247\207\340\246\267\n", 16) = 16
write(999, "x", 1) = -1 EBADF (Bad file descriptor)
write(1, "\340\246\205\340\246\254\340\247\210\340\246\247 fd-\340\246\244\340\247\207..."..., 44) = 44
write(1, "-9", 2) = 2
write(1, " (9 = EBADF)\n", 14) = 14
exit_group(42) = ?
+++ exited with 42 +++একটাও mmap নেই, একটাও brk নেই, একটাও arch_prctl নেই।
execve-এর পরে ঠিক যা আপনি লিখেছেন তাই। এটাই সবচেয়ে
পরিষ্কার প্রমাণ যে বাকি ২৭টা syscall আপনার প্রোগ্রামের নয়,
runtime-এর।
আকার তুলনা:
gcc -O2 -o big hello.c # dynamic + libc
gcc -O2 -static -o bigger hello.c # static + libc
strip tiny big bigger
ls -l tiny big bigger | awk '{print $5, $9}'8664 tiny
14328 big
895440 bigger৮.৬ KB বনাম ৮৭৪ KB — ১০০ গুণেরও বেশি। আর tiny-র ৮.৬ KB-র মধ্যেও প্রকৃত কোড মাত্র কয়েকশো byte; বাকিটা ELF header, program header, section header আর alignment padding।
size tiny
# text data bss dec hex filename
# 445 0 0 445 1bd tiny৪৪৫ byte। এটাই আপনার পুরো প্রোগ্রাম।
নিজে বাড়ান:
argc/argvউদ্ধার করুন।_start-এ ঢোকার সময়rspতাক করে থাকেargc-এর দিকে, তারপরargv[0..argc-1], একটাNULL, তারপরenvp। একটা naked_startলিখে (__asm__(".globl _start\n_start:\n mov %rsp,%rdi\n call real_start")) argument গুলো ছাপুন। System V ABI-র §3.4.1 এই layout-টা সংজ্ঞায়িত করে।ENOENT-এর নাম ছাপুন। একটা ছোট টেবিল বানান (errno→ string) অন্তত ১০টা সাধারণ error-এর জন্য, আরstrerror-এর মতো একটা ফাংশন লিখুন।- নিজের
mallocলিখুন।SYS_brk(12) দিয়ে একটা bump allocator —brk(0)দিয়ে বর্তমান শেষ জেনে, তারপরbrk(cur + n)দিয়ে বাড়িয়ে। লেসন ১৪-এ আমরা এটাকে একটা পূর্ণাঙ্গ allocator-এ পরিণত করব। - ARM64-তে port করুন।
syscall→svc #0,rax→x8, আর number গুলো generic ABI-র (write=64, exit_group=94)। একটা Raspberry Pi বাqemu-aarch64থাকলে চালিয়ে দেখুন। vDSOব্যবহার করুন।getauxvalছাড়াই — stack থেকেenvp-এর পরে auxiliary vector পড়েAT_SYSINFO_EHDR(33) খুঁজুন, সেটা vDSO-র ELF base। তার symbol table parse করে__vdso_clock_gettimeবের করে সরাসরি ডাকুন। এটা করলে আপনি libc-র সবচেয়ে জটিল অংশটার পুনরাবৃত্তি করলেন।- সবচেয়ে ছোট করার প্রতিযোগিতা। নিজের linker script
লিখে, section merge করে,
-Wl,--build-id=none -Wl,-nদিয়ে binary-টা ১ KB-র নিচে নামান। “A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux” (Brian Raiter) এই খেলার প্রামাণ্য গ্রন্থ — সেখানে ৪৫ byte পর্যন্ত নামানো হয়েছে।
বাস্তব সিস্টেমে
Syscall যেখানে সিস্টেম ডিজাইনের কেন্দ্রবিন্দু
seccomp — syscall number-ই নিরাপত্তার একক। Linux-এর
seccomp-bpf আপনাকে একটা BPF program দিতে দেয় যা প্রতিটা
syscall-এর আগে চলে আর সিদ্ধান্ত নেয়: allow, deny (EPERM),
kill, বা trace।
# একটা চলমান container-এ কোন syscall গুলো ব্লক করা আছে দেখুন
docker run --rm alpine grep Seccomp /proc/self/status
# Seccomp: 2 (2 = filter mode চালু)Docker-এর default profile ৩০০+ syscall-এর মধ্যে ~৪৪টা ব্লক
করে — যেমন kexec_load (নতুন kernel বুট করা),
init_module (kernel module ঢোকানো), ptrace (আগে; এখন
শর্তসাপেক্ষে অনুমোদিত)।
Chrome-এর renderer process আরো কঠোর: সেখানে অনুমোদিত syscall-এর তালিকাটা কয়েক ডজনের। একটা renderer exploit তাই সরাসরি kernel-এ পৌঁছাতে পারে না।
gVisor — syscall-কে userspace-এ ধরা। Google-এর gVisor একটা “userspace kernel”: container-এর syscall গুলো আটকে নিয়ে নিজে বাস্তবায়ন করে, শুধু কয়েক ডজন syscall আসল kernel-এ পাঠায়। ফলে host kernel-এর attack surface ৩০০+ syscall থেকে নেমে ~৬০-এ আসে। দাম: syscall-ভারী workload-এ ২০–৫০% ধীর।
WSL — দুটো ভিন্ন উত্তর। WSL1 প্রতিটা Linux syscall-কে
Windows NT syscall-এ অনুবাদ করত, ঠিক এই লেসনের টেবিল
ব্যবহার করে। কাজ করত, কিন্তু semantic মেলানো
(বিশেষত fork আর filesystem) এত কঠিন ছিল যে Microsoft
হাল ছেড়ে WSL2-তে একটা আসল Linux kernel VM-এ চালানো শুরু করল।
Windows-এর অস্থিতিশীল ABI। Windows-এ syscall number
প্রতি build-এ বদলায় — NtCreateFile Windows 10-এ 0x55,
Windows 11-এ অন্য কিছু। তাই আপনাকে ntdll.dll দিয়ে যেতেই হয়।
Malware লেখকরা এই কারণে “direct syscall” কৌশল ব্যবহার করে
(EDR-এর ntdll hook এড়াতে), আর তার জন্য প্রতিটা Windows
সংস্করণের জন্য আলাদা number টেবিল রাখতে হয়।
Syscall গোনা = performance-এর প্রথম diagnostic। Production-এ কোনো সেবা ধীর হলে সবচেয়ে দ্রুত সূত্রটা:
perf stat -e raw_syscalls:sys_enter -p <pid> -- sleep 5
strace -c -f -p <pid> # সাবধান: strace প্রোগ্রামকে 10-100× ধীর করেসেকেন্ডে ১০ লক্ষ syscall মানে শুধু দেয়াল পার হতেই
৪০০ ms/s = ৪০% CPU। তখন উত্তর হয় batching, buffering,
sendmmsg/recvmmsg, বা io_uring।
Android-এ Binder — syscall-এর উপর একটা নতুন স্তর।
Android-এর প্রায় সব IPC যায় ioctl(binder_fd, ...) দিয়ে —
অর্থাৎ একটা মাত্র syscall number-এর ভেতরে একটা সম্পূর্ণ
আলাদা protocol। এটা একটা প্রচলিত pattern: নতুন syscall
যোগ করা কঠিন (ABI চিরস্থায়ী), তাই মানুষ ioctl-এর ভেতরে
নতুন interface ঢোকায়। io_uring-ও অনেকটা এই দর্শনে — মাত্র
তিনটা syscall, কিন্তু তার ভেতরে ৪০+ operation।
যে ভুলগুলো সবাই করে
“libc-র প্রতিটা function একটা syscall করে।”
বেশিরভাগই করে না। libc-র বড় অংশটা বিশুদ্ধ userspace কোড।
| libc function | syscall করে? |
|---|---|
strlen, memcpy, qsort | না, কখনোই |
abs, sin, atoi | না |
malloc(100) | সাধারণত না (আগের arena থেকে দেয়) |
malloc(1 MB) | হ্যাঁ — mmap |
printf | হয়তো — buffer পূর্ণ না হলে না |
getpid | হ্যাঁ (একসময় cache করা হতো, এখন না) |
clock_gettime | না — vDSO থেকে |
write | হ্যাঁ, সবসময় |
উল্টোটাও সত্য: একটা libc call অনেকগুলো syscall করতে পারে।
fopen করে openat + newfstatat + mmap (buffer-এর জন্য)।
getaddrinfo করতে পারে ১০+ syscall (/etc/nsswitch.conf পড়া,
/etc/hosts পড়া, DNS socket খোলা, sendto, recvfrom…)।
যাচাই করুন:
cat > t.c <<'EOF'
#include <string.h>
#include <stdlib.h>
int main(void){ char b[64]; strcpy(b,"x"); (void)strlen(b);
void *p=malloc(64); free(p); return 0; }
EOF
gcc -O0 -o t t.c && strace ./t 2>&1 | grep -cE "strlen|strcpy"
# 0 — কারণ সেগুলো কখনো kernel-এ যায় নামানসিক মডেলটা এই: libc হলো দুটো জিনিসের মিশ্রণ — (ক) syscall-এর পাতলা মোড়ক, আর (খ) একটা বড় utility library যার kernel-এর সাথে কোনো সম্পর্ক নেই। দুটোকে আলাদা করে ভাবলে performance নিয়ে অনেক বেশি পরিষ্কার যুক্তি করা যায়।
“syscall number সব প্ল্যাটফর্মে একই — `write` মানেই 1।”
write = 1 শুধু x86-64 Linux-এ।
| প্ল্যাটফর্ম | write |
|---|---|
| Linux x86-64 | 1 |
| Linux i386 | 4 |
| Linux ARM64 | 64 |
| Linux RISC-V | 64 |
| Linux MIPS o32 | 4004 |
| macOS x86-64 | 0x2000004 (উপরের bit গুলো “BSD শ্রেণি”) |
| FreeBSD | 4 |
এমনকি একই architecture-এ ABI ভেদে আলাদা — MIPS-এ o32, n32 আর n64 তিনটা আলাদা টেবিল।
ব্যবহারিক পরিণতি: যেকোনো কোড যা number hardcode করে সেটা অ-portable। তাই সবসময় লিখুন:
#include <sys/syscall.h> /* __NR_write সংজ্ঞায়িত করে */
syscall(__NR_write, 1, "hi\n", 3);__NR_write একটা compile-time constant যা আপনার
architecture-এর সঠিক মান দেয়। আমরা experiment-এ ইচ্ছাকৃতভাবে
1 লিখেছি বোঝানোর জন্য — production কোডে কখনো নয়।
আর মনে রাখুন macOS-এর ব্যাপারটা: সেখানে number স্থিতিশীলই নয়, তাই সঠিক number জানলেও সরাসরি syscall করা উচিত নয়।
“Kernel `errno` সেট করে দেয়।”
Kernel errno-র অস্তিত্ব সম্পর্কেই জানে না। errno একটা
libc-র আবিষ্কার।
Kernel যা করে: ব্যর্থতায় rax-এ -errno রাখে। ব্যস।
libc যা করে: সেই ঋণাত্মক মান দেখে, -1 ফেরত দেয়, আর
errno-তে ধনাত্মক মানটা রাখে।
আর errno কোনো সাধারণ global variable নয় — সেটা হলে
multithreaded প্রোগ্রামে অচল হতো (একটা thread-এর error
আরেকটার উপর লিখে দিত)। বাস্তবে errno.h-তে:
extern int *__errno_location(void);
#define errno (*__errno_location())__errno_location() thread-local storage থেকে ঠিকানা দেয়।
তাই প্রতিটা thread-এর নিজস্ব errno।
এই কারণেই আমাদের freestanding প্রোগ্রামে errno ব্যবহার
করা যেত না — TLS বসায় libc, আর আমরা libc বাদ দিয়েছি।
তিনটা ব্যবহারিক পরিণতি:
১. errno শুধু তখনই বৈধ যখন call ব্যর্থ হয়েছে। সফল
call errno মোছে না, তাই আগের error-এর মান থেকে যেতে পারে।
২. errno পরীক্ষা করার আগে অন্য কোনো library call করবেন না —
সেটা errno বদলে দিতে পারে।
if (write(fd, buf, n) < 0) {
fprintf(stderr, "ব্যর্থ\n"); /* ← এটা errno নষ্ট করতে পারে! */
perror("write"); /* এখন ভুল বার্তা */
}
/* সঠিক: int saved = errno; ... errno = saved; */৩. যেসব function errno সেট করে কিন্তু -1 ফেরত দেয় না
(getpriority, strtol) — সেগুলোর আগে errno = 0 করতে হয়।
“`strace` প্রোগ্রামের সব kernel-মিথস্ক্রিয়া দেখায়।”
তিনটা বড় ফাঁক আছে।
১. vDSO call অদৃশ্য। clock_gettime, gettimeofday,
getcpu, time — এগুলো vDSO-তে থাকলে kernel-এ ঢোকেই না,
তাই strace কিছুই দেখে না। একটা প্রোগ্রাম সেকেন্ডে ১০ লক্ষ
বার clock_gettime করতে পারে আর strace দেখাবে শূন্য।
২. Page fault অদৃশ্য। একটা mmap করা ফাইল পড়া মানে
kernel বহুবার ঢুকছে (প্রতিটা page-এ একবার), কিন্তু সেগুলো
syscall নয়, exception। strace-এ দেখা যাবে না।
/usr/bin/time -v ./prog 2>&1 | grep -i faults
perf stat -e page-faults,major-faults ./progলেসন ১৩-এ এটাই মূল বিষয়।
৩. io_uring জমা অদৃশ্য। io_uring-এ আপনি ring buffer-এ
লিখে দেন, আর kernel-এর poller thread সেটা তুলে নেয় — কোনো
syscall নেই। strace একটা ব্যস্ত io_uring server-কে
প্রায় নিষ্ক্রিয় দেখাবে (লেসন ২১)।
আর একটা গুরুতর সতর্কতা: strace ব্যবহার করে ptrace,
যা প্রতিটা syscall-এ দুইবার context switch ঘটায় (entry
আর exit-এ tracer-কে জাগানো)। ফলে প্রোগ্রাম ১০–১০০ গুণ ধীর
হতে পারে।
time ./hello-dyn # ~0.001s
time strace -o /dev/null ./hello-dyn # ~0.02sএর মানে strace timing-নির্ভর bug (race condition, timeout)
বদলে দিতে বা লুকিয়ে দিতে পারে। Production-এ latency
তদন্তে strace প্রায় সবসময় ভুল যন্ত্র।
সঠিক যন্ত্রগুলো (লেসন ৩০-এ বিস্তারিত):
| দরকার | যন্ত্র | কেন |
|---|---|---|
| syscall গোনা, কম overhead | perf stat -e raw_syscalls:* | sampling, ptrace নয় |
| argument সহ, production-এ | bpftrace / eBPF | kernel-এ চলে, context switch নেই |
| একটা নির্দিষ্ট syscall-এর latency | funclatency (bcc) | histogram |
| dev মেশিনে দ্রুত তদন্ত | strace | সহজ, কিন্তু ভারী |
বুঝেছেন কি না দেখুন
1Linux x86-64-এ syscall-এর চতুর্থ argument rcx-এ না রেখে
r10-এ রাখা হয়। শুধু “ABI এটাই বলে” না বলে, hardware থেকে
শুরু করে কারণটা ব্যুৎপন্ন করুন। আর ব্যাখ্যা করুন কেন ARM64-এ
এই সমস্যাটা নেই।
যুক্তি
rcx-এ না রেখে
r10-এ রাখা হয়। শুধু “ABI এটাই বলে” না বলে, hardware থেকে
শুরু করে কারণটা ব্যুৎপন্ন করুন। আর ব্যাখ্যা করুন কেন ARM64-এ
এই সমস্যাটা নেই।ধাপ ১ — syscall instruction কী করে তা দেখুন।
AMD64 ম্যানুয়াল (Vol 2, §6.1) অনুযায়ী syscall-এর
pseudo-code-এর প্রথম দুই লাইন:
RCX ← next_RIP (কোথায় ফিরতে হবে)
R11 ← RFLAGS (কী অবস্থায় ফিরতে হবে)ধাপ ২ — প্রশ্ন করুন: এটা ছাড়া উপায় ছিল কি?
Return address কোথাও তো রাখতেই হবে। বিকল্পগুলো:
| বিকল্প | সমস্যা |
|---|---|
| Stack-এ push | Stack access = memory access = ধীর, আর syscall-এর সময় RSP এখনো user-এর, যা অবিশ্বস্ত/invalid হতে পারে |
| একটা আলাদা MSR-এ | MSR লেখা খুব ধীর (~১০০ cycle), আর nested syscall-এ overwrite হয়ে যেত |
| একটা নতুন dedicated register | x86-64-এ নতুন architectural register যোগ করা মানে সব context-switch কোড বদলানো |
| একটা বিদ্যমান GPR | দ্রুত (register rename), কিন্তু সেই register-এর ডেটা হারায় |
AMD চতুর্থ বিকল্প বেছেছে — গতির জন্য। আর rcx বেছেছে কারণ
rcx ঐতিহাসিকভাবে “counter” register, C ABI-তে caller-saved।
ধাপ ৩ — এখন ABI-র সীমাবদ্ধতা।
C-র System V ABI (যা syscall-এর অনেক পরে নয়, কিন্তু
স্বাধীনভাবে) argument দেয়: rdi, rsi, rdx, rcx, r8, r9।
যদি Linux syscall-এও rcx ব্যবহার করত:
mov $257, %rax # openat
mov $-100, %rdi # AT_FDCWD
mov $path, %rsi
mov $2, %rdx # O_RDWR
mov $0644, %rcx # mode ← চতুর্থ argument
syscall # ← এই মুহূর্তে RCX ← RIP, mode হারিয়ে গেলKernel rcx-এ পেত return address, আর সেটাকে mode ভেবে
একটা ফাইল তৈরি করত 0x00007f8a3b2c1234 permission-এ।
ধাপ ৪ — কেন r10?
শর্তগুলো: (ক) C ABI-তে argument register নয় (নাহলে wrapper
লিখতে বাড়তি কাজ), (খ) callee-saved নয় (নাহলে kernel-কে
সংরক্ষণ করতে হতো), (গ) syscall নষ্ট করে না।
r10 তিনটাই মেটায়। r11ও মেটাত (খ) আর (ক), কিন্তু
syscall সেটা নষ্ট করে। তাই r10।
ফলে glibc-র wrapper-এ ঠিক একটা লাইন লাগে:
mov %rcx, %r10 /* C-র চতুর্থ arg → syscall-এর চতুর্থ arg */
syscallধাপ ৫ — ARM64-এ কেন সমস্যা নেই।
ARM64-এ svc return address রাখে ELR_EL1-এ, আর flags রাখে
SPSR_EL1-এ। দুটোই আলাদা system register, general-purpose
register নয়।
কেন ARM এমন করতে পারল? কারণ ARM64 (২০১১) নকশা করার সময়
তারা ৩১টা GPR রেখেছে (x86-64-এ ১৬), আর exception model-টা
শুরু থেকেই banked system register দিয়ে সাজিয়েছে। x86-এর
syscall (১৯৯৭) একটা ৩২-bit ISA-তে বসানো হয়েছিল যেখানে
মাত্র ৮টা GPR ছিল — নতুন register যোগ করার বিলাসিতা ছিল না।
ফল:
| x86-64 | ARM64 | |
|---|---|---|
| C args | rdi rsi rdx rcx r8 r9 | x0 x1 x2 x3 x4 x5 |
| syscall args | rdi rsi rdx r10 r8 r9 | x0 x1 x2 x3 x4 x5 |
| wrapper-এ বাড়তি | mov %rcx,%r10 | কিছুই না |
সাধারণ শিক্ষা: ABI-র “অদ্ভুত” নিয়মগুলো প্রায় সবসময় hardware-এর একটা সীমাবদ্ধতার ছাপ। Level 5-এ compiler backend লেখার সময় আমরা বারবার এই ধরনের সিদ্ধান্তের মুখোমুখি হব — register allocation-এ কোনটা “free” আর কোনটা “নষ্ট হবে” তা জানাই অর্ধেক কাজ।
2আপনি কাঁচা syscall করে rax-এ পেলেন -2। আরেকবার পেলেন
-4096। আরেকবার পেলেন 140737488347136। প্রতিটার অর্থ কী,
আর একটা সঠিক C wrapper কীভাবে তিনটাকে আলাদা করবে?
প্রয়োগ
rax-এ পেলেন -2। আরেকবার পেলেন
-4096। আরেকবার পেলেন 140737488347136। প্রতিটার অর্থ কী,
আর একটা সঠিক C wrapper কীভাবে তিনটাকে আলাদা করবে?নিয়মটা: [-4095, -1] সীমার ভেতরে হলে error, নাহলে বৈধ
ফলাফল।
| ফেরত মান | পরিসরে? | অর্থ |
|---|---|---|
-2 | হ্যাঁ | error, errno = 2 = ENOENT |
-4096 | না (সীমার বাইরে, কারণ MAX_ERRNO = 4095) | একটা বৈধ ফলাফল যার মান -4096 |
140737488347136 | না | বৈধ ফলাফল (0x7FFFFFFFF000 — একটা mmap-করা address) |
দ্বিতীয়টা কীভাবে সম্ভব? কিছু syscall ঋণাত্মক মান ফেরত
দিতে পারে বৈধভাবে — যেমন lseek একটা ফাইলে ঋণাত্মক offset-এ
(সাধারণত অসম্ভব), অথবা getpriority যা -20 থেকে 19
ফেরত দেয়। getpriority-র জন্যই libc আলাদা কৌশল নেয়:
errno = 0 করে call করে, তারপর errno দেখে।
সঠিক wrapper:
#include <errno.h>
#include <stdint.h>
static inline long syscall_ret(long r) {
/* unsigned তুলনা: -4095 → 0xFFFFFFFFFFFFF001, একটা বিশাল সংখ্যা।
তাই "উপরের ৪০৯৫টা মান" একটাই তুলনায় ধরা পড়ে। */
if ((unsigned long)r >= (unsigned long)-4095L) {
errno = (int)(-r);
return -1;
}
return r;
}কেন unsigned cast-টা জরুরি — সংখ্যায় দেখুন:
r | (unsigned long)r | >= 0xFFFFFFFFFFFFF001? |
|---|---|---|
-1 (EPERM) | 0xFFFFFFFFFFFFFFFF | হ্যাঁ → error |
-2 (ENOENT) | 0xFFFFFFFFFFFFFFFE | হ্যাঁ → error |
-4095 (EHWPOISON) | 0xFFFFFFFFFFFFF001 | হ্যাঁ → error |
-4096 | 0xFFFFFFFFFFFFF000 | না → বৈধ |
0 | 0x0 | না → বৈধ |
0x7FFFFFFFF000 | 0x7FFFFFFFF000 | না → বৈধ |
দুটো তুলনার (r < 0 && r >= -4095) বদলে একটা — এটা kernel
আর libc দুই জায়গাতেই hot path, তাই এই ধরনের সূক্ষ্মতা
গুরুত্বপূর্ণ।
mmap-এর বিশেষ ক্ষেত্র:
void *mmap_raw(size_t len) {
long r = sys6(SYS_mmap, 0, len, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
if ((unsigned long)r >= (unsigned long)-4095L) {
errno = (int)(-r);
return MAP_FAILED; /* (void *)-1, NULL নয়! */
}
return (void *)r;
}MAP_FAILED NULL নয় কারণ mmap(NULL, ..., MAP_FIXED) দিয়ে
সত্যিই address 0-তে map করা সম্ভব (যদিও
/proc/sys/vm/mmap_min_addr সাধারণত সেটা আটকায় — একটা
NULL-dereference exploit প্রতিরোধ)।
যেখানে এই বিবরণটা বাস্তবে কামড়ায়: Rust-এর libc crate,
Go-র syscall package, Zig-এর std.os — প্রত্যেকের নিজস্ব
errnoErr ফাংশন আছে যা ঠিক এই যাচাইটা করে। Go-তে সেটা
runtime/sys_linux_amd64.s-এ, আর সেখানে সীমাটা
0xfffffffffffff001-এর সাথে তুলনা হিসেবে লেখা।
Level 5-এ যখন আমরা একটা ভাষার runtime লিখব, এই সীমানাটা নিজেদের হাতে বাস্তবায়ন করতে হবে।
3একটা function call ~১ ns, একটা syscall ~৪০০ ns। ৪০০ গুণ
পার্থক্য। কিন্তু দুটোই তো “একটা জায়গায় লাফানো আর ফিরে আসা”।
পার্থক্যটা কোথা থেকে আসে, আর এর তিনটা নকশাগত পরিণতি কী?
যুক্তি
পার্থক্যের উৎস তিন শ্রেণির।
শ্রেণি ১ — hardware যা করতেই হয় (~১০০–২০০ cycle)
| কাজ | কেন অপরিহার্য |
|---|---|
| CPL 3 → 0 | নিরাপত্তার পুরো ভিত্তি |
| Pipeline serialization | speculative-ভাবে ring বদলানো যায় না |
| MSR পড়া (LSTAR, STAR, FMASK) | entry point কোথায় জানতে |
swapgs | per-CPU ডেটা পেতে |
| Stack switch | user stack অবিশ্বস্ত |
একটা call instruction-এ এর একটাও লাগে না — শুধু RIP push
আর jump, দুটোই out-of-order engine-এ প্রায় free।
শ্রেণি ২ — নিরাপত্তা প্রশমন (~২০০–৬০০ cycle)
| কাজ | কেন যোগ হলো |
|---|---|
| KPTI: CR3 ×২ | Meltdown (২০১৮) |
IBRS/STIBP wrmsr | Spectre v2 |
| Retpoline (indirect call) | Spectre v2 |
| Register clearing | speculative তথ্য ফাঁস রোধ |
array_index_nospec | Spectre v1 |
এই পুরো শ্রেণিটা ২০১৮-র আগে ছিল না। ২০১৭-তে একটা
getpid ছিল ~৭০ ns; আজ একই মেশিনে ~৪০০ ns। syscall পাঁচ গুণ
ধীর হয়েছে, আর তার কারণ কোনো OS পরিবর্তন নয় — CPU-র
speculative execution-এর নিরাপত্তা ঋণ শোধ করা।
শ্রেণি ৩ — পরোক্ষ, অদৃশ্য (~১০০–৩০০ cycle সমতুল্য)
Kernel-এর কোড আপনার cache-এর জায়গা দখল করে, TLB entry সরিয়ে দেয়, branch predictor-এর ইতিহাস দূষিত করে। ফেরার পরে আপনার নিজের কোড ধীরে চলে। এটা benchmark-এ syscall-এর খরচ হিসেবে দেখা যায় না, কিন্তু বাস্তব।
লেসন ৭-এ context switch-এ আমরা দেখব এই পরোক্ষ খরচই আসলে প্রধান, আর সেটা মাপার কৌশল শিখব।
তিনটা নকশাগত পরিণতি:
পরিণতি ১ — Batching সবসময় জেতে।
১০০০টা byte এক এক করে লেখা : ১০০০ × ৪০০ ns = ৪০০ μs
একবারে ১০০০ byte লেখা : ১ × ৪৫০ ns = ০.৪৫ μs
৯০০× দ্রুতএটাই stdio buffer-এর অস্তিত্বের কারণ, writev-এর কারণ,
sendmmsg-এর কারণ, আর ডাটাবেসে group commit-এর কারণ
(Level 8-এ ফিরব)।
পরিণতি ২ — যে তথ্য গোপন নয়, তা শেয়ার করে দাও।
vDSO ঠিক এটাই করে: বর্তমান সময় গোপন নয়, তাই একটা read-only page-এ রেখে দিলে syscall লাগে না। ৪০০ ns → ২০ ns।
একই দর্শনের সাধারণীকরণ: শেয়ার্ড মেমরি। লেসন ২৪-এ আমরা দেখব দুটো process কীভাবে setup-এর পরে শূন্যটা syscall-এ গিগাবাইট বিনিময় করে।
পরিণতি ৩ — Interface-টাকেই asynchronous করে দাও।
সবচেয়ে চরম উত্তর: syscall-এর জন্য অপেক্ষাই করবেন না।
io_uring-এ আপনি একটা shared ring buffer-এ কাজ লিখে রাখেন
আর kernel সেটা তুলে নেয়। IORING_SETUP_SQPOLL mode-এ
একটা kernel thread সারাক্ষণ ring টা দেখে, তাই
হাজারটা I/O করতে শূন্যটা syscall লাগে।
লেসন ২১-এ (ইতিমধ্যে লেখা) এটা বিস্তারিত আছে; লেসন ২০-এ
আমরা তার আগের প্রজন্ম epoll বানাব।
একটা সতর্কতা — অকাল optimization। ৪০০ ns শুনতে ভয়ংকর, কিন্তু সেকেন্ডে ১০০০টা syscall মানে ০.৪ ms/s = ০.০৪% CPU। তখন syscall কমানো সম্পূর্ণ অর্থহীন।
সিদ্ধান্তের নিয়ম: আগে মাপুন।
perf stat -e raw_syscalls:sys_enter -p <pid> -- sleep 10সেকেন্ডে ১০০,০০০-এর নিচে হলে syscall আপনার সমস্যা নয়। ১০ লক্ষের উপরে হলে সেটাই আপনার প্রধান সমস্যা। Level 11-এ আমরা এই ধরনের performance যুক্তি পদ্ধতিগতভাবে শিখব — Amdahl-এর সূত্র থেকে শুরু করে।
4আপনি একটা sandbox বানাচ্ছেন যেখানে অবিশ্বস্ত কোড চলবে।
আপনি seccomp-bpf দিয়ে syscall filter করবেন ঠিক করেছেন।
কোন কোন জিনিস আপনি filter করতে পারবেন আর কোনগুলো পারবেন না,
আর সেই সীমাটা আপনার নকশাকে কীভাবে বদলে দেবে?
ডিজাইন
seccomp-bpf যা দেখতে পায় — struct seccomp_data:
struct seccomp_data {
int nr; /* syscall number */
__u32 arch; /* AUDIT_ARCH_X86_64 ইত্যাদি */
__u64 instruction_pointer; /* কোথা থেকে ডাকা হলো */
__u64 args[6]; /* ছয়টা argument-এর কাঁচা মান */
};ব্যস। এটুকুই।
যা filter করা যায়:
| নীতি | কীভাবে |
|---|---|
“ptrace একেবারেই নিষিদ্ধ” | nr == __NR_ptrace → KILL |
“socket শুধু AF_UNIX” | nr == socket && args[0] != AF_UNIX → EPERM |
“open শুধু read-only” | args[1] & O_WRONLY চেক |
“mmap-এ PROT_EXEC নিষিদ্ধ” | args[2] & PROT_EXEC চেক |
| “৩২-bit ABI বন্ধ” | arch != AUDIT_ARCH_X86_64 → KILL |
“clone-এ নতুন namespace নয়” | args[0] & CLONE_NEWUSER চেক |
যা filter করা যায় না:
| চাওয়া নীতি | কেন অসম্ভব |
|---|---|
“শুধু /tmp-এ লিখতে পারবে” | path একটা pointer; filter dereference করতে পারে না |
| “শুধু 10.0.0.0/8-এ connect” | sockaddr একটা pointer |
| “সর্বোচ্চ ১০০ MB allocate” | filter stateless, গোনা যায় না |
| “এই fd-টা আসলে কোন ফাইল” | fd → file mapping kernel-এর ভেতরে |
কেন pointer পড়া নিষিদ্ধ — এটাই মূল অন্তর্দৃষ্টি।
ধরুন seccomp args[1]-এ থাকা path পড়তে পারত:
সময় Thread A Thread B
────────────────────────────────────────────────────────
t0 buf-এ "/tmp/safe" লেখে
t1 openat(AT_FDCWD, buf, O_RDWR)
t2 └→ seccomp filter চলে
t3 buf পড়ে: "/tmp/safe" ✓ allow
t4 buf-এ "/etc/shadow" লেখে
t5 └→ kernel buf পড়ে: "/etc/shadow"
t6 ফাইল খুলে গেলক্লাসিক TOCTOU। আর এটা প্রতিরোধ করা যায় না, কারণ filter আর kernel দুটো আলাদা সময়ে একই user memory পড়ছে, আর সেই memory-র মালিক attacker।
তাই Linux-এর নকশাগত সিদ্ধান্ত: filter শুধু register দেখবে, কারণ register copy করা হয়ে গেছে আর আর বদলানো যায় না।
নকশা কীভাবে বদলায় — চারটা ফলাফল:
১. Capability-ভিত্তিক নকশায় যান, path-ভিত্তিক নয়।
Sandbox-এ ঢোকার আগে যেসব ফাইল/socket দরকার সেগুলো খুলে
fd হিসেবে হাতে ধরিয়ে দিন, তারপর openat/socket সম্পূর্ণ
নিষিদ্ধ করুন।
int data_fd = open("/data", O_RDONLY|O_DIRECTORY);
apply_seccomp_filter(); /* openat, socket, execve সব KILL */
/* এখন sandbox শুধু openat(data_fd, "rel/path", ...) করতে পারত —
কিন্তু openat-ও বন্ধ, তাই শুধু আগে থেকে খোলা fd */এটাই capability security-র মূল ধারণা, আর OpenBSD-র
pledge/unveil আর Linux-এর Landlock LSM এই পথেই গেছে।
২. স্তরে স্তরে প্রতিরক্ষা।
| স্তর | কী নিয়ন্ত্রণ করে |
|---|---|
| seccomp | কোন syscall, কোন flag |
| namespace | কী দেখতে পাবে (লেসন ২৯) |
| cgroup | কতটা ব্যবহার করতে পারবে (লেসন ২৯) |
| Landlock / AppArmor | কোন path (kernel-এর ভেতরে, TOCTOU-মুক্ত) |
RLIMIT_* | fd সংখ্যা, CPU সময়, ফাইল আকার |
একটাও যথেষ্ট নয়; সবগুলো একসাথে।
৩. SECCOMP_RET_TRAP না KILL — সিদ্ধান্তটা ভাবুন।
| ফেরত | আচরণ | কখন |
|---|---|---|
KILL_PROCESS | সাথে সাথে মৃত্যু | সবচেয়ে নিরাপদ default |
ERRNO(EPERM) | syscall ব্যর্থ, প্রোগ্রাম চলে | যখন প্রোগ্রামের fallback আছে |
TRAP | SIGSYS, handler ধরতে পারে | debugging |
USER_NOTIF | একটা supervisor process সিদ্ধান্ত নেয় | gVisor-শৈলীর নমনীয় নীতি |
ERRNO বাছার বিপদ: প্রোগ্রাম error উপেক্ষা করে ভুল অবস্থায়
চলতে থাকতে পারে। KILL বাছার বিপদ: একটা নিরীহ নতুন libc
সংস্করণ একটা নতুন syscall ব্যবহার শুরু করলে সব ভেঙে পড়বে
(বাস্তবে এটা বহুবার ঘটেছে — clone3, rseq, getrandom
প্রতিটাই কোনো না কোনো sandbox ভেঙেছে)।
৪. Allowlist, denylist নয়। নতুন syscall প্রতি kernel release-এ যোগ হয়। Denylist মানে প্রতিটা নতুন syscall স্বয়ংক্রিয়ভাবে অনুমোদিত — অর্থাৎ আপনার নীতি সময়ের সাথে দুর্বল হতে থাকবে।
Level 10-এ আমরা একটা পূর্ণাঙ্গ seccomp filter লিখব (libseccomp আর কাঁচা BPF দুইভাবেই), আর Level 12-এ এটাকে namespace ও cgroup-এর সাথে জুড়ে একটা container runtime বানাব (লেসন ২৯-এ তার ভিত্তি)।
5একটা প্রোগ্রাম প্রতি সেকেন্ডে ৫০ লক্ষ syscall করছে বলে
perf দেখাচ্ছে, আর CPU ব্যবহার ১০০%। প্রোগ্রামটা একটা
log aggregator যা লাইন পড়ে, parse করে, আর একটা ফাইলে লেখে।
আপনার তদন্তের ধাপগুলো কী, আর সম্ভাব্য সমাধান কী কী?
প্রয়োগ
perf দেখাচ্ছে, আর CPU ব্যবহার ১০০%। প্রোগ্রামটা একটা
log aggregator যা লাইন পড়ে, parse করে, আর একটা ফাইলে লেখে।
আপনার তদন্তের ধাপগুলো কী, আর সম্ভাব্য সমাধান কী কী?ধাপ ০ — খরচটা হিসাব করুন।
৫০ লক্ষ × ৪০০ ns = ২ সেকেন্ড প্রতি সেকেন্ডে। অর্থাৎ ২টা CPU core শুধু দেয়াল পার হতেই খরচ হচ্ছে। এটা নিশ্চিতভাবে প্রধান সমস্যা।
ধাপ ১ — কোন syscall?
perf top -e raw_syscalls:sys_enter -p <pid>
# অথবা কম overhead-এ:
sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter /pid == '$PID'/
{ @[args->id] = count(); }
interval:s:5 { print(@); clear(@); exit(); }'সাধারণ ফলাফল:
@[1]: 2500000 /* write */
@[0]: 2400000 /* read */
@[228]: 100000 /* clock_gettime — vDSO নয় কেন? */ধাপ ২ — প্রতিটার কারণ নির্ণয়।
| যা দেখলাম | সম্ভাব্য কারণ | নিশ্চিত করার উপায় |
|---|---|---|
২৫ লক্ষ read | প্রতি লাইনে একটা read, unbuffered | strace -e read দেখুন count কত ছোট |
২৫ লক্ষ write | প্রতি লাইনে একটা write | একই |
clock_gettime syscall হচ্ছে | clocksource tsc নয় | cat /sys/devices/system/clocksource/clocksource0/current_clocksource |
তৃতীয়টা একটা চমৎকার বাস্তব ফাঁদ: যদি clocksource hpet বা
acpi_pm হয় (কিছু VM-এ ঘটে), তাহলে vDSO কাজ করে না আর
প্রতিটা clock_gettime একটা আসল syscall হয়ে যায়। শুধু
clocksource tsc-এ বদলালেই বড় লাভ।
ধাপ ৩ — সমাধান, কম খরচ থেকে বেশি খরচের ক্রমে।
| # | সমাধান | প্রত্যাশিত লাভ | খরচ |
|---|---|---|---|
| ১ | পড়ায় বড় buffer (৬৪ KB fread/BufferedReader) | read ৯৯% কমে | কোডে ৫ লাইন |
| ২ | লেখায় buffer + সময়মতো flush | write ৯৯% কমে | durability trade-off |
| ৩ | clocksource ঠিক করা | ১০ লক্ষ syscall যায় | config |
| ৪ | writev দিয়ে একাধিক লাইন একসাথে | memcpy বাঁচে | মাঝারি |
| ৫ | mmap করে ফাইল পড়া | read syscall শূন্য | page fault বাড়ে (লেসন ১৩) |
| ৬ | io_uring | সবকিছু async | বড় পুনর্লিখন (লেসন ২১) |
প্রায় সবসময় ১ আর ২-ই যথেষ্ট। ৬৪ KB buffer-এ ১০০ byte-এর লাইন মানে ৬৪০ লাইনে একটা syscall — ৫০ লক্ষ থেকে ৮ হাজারে নেমে এল, ৬০০ গুণ।
ধাপ ৪ — durability-র প্রশ্নটা এড়াবেন না।
Write buffer করা মানে crash-এ ডেটা হারানো। এটা একটা সচেতন সিদ্ধান্ত হতে হবে, দুর্ঘটনা নয়:
| চাহিদা | কৌশল |
|---|---|
| log হারালে চলবে | বড় buffer, periodic flush |
| সর্বোচ্চ ১ সেকেন্ড হারানো চলবে | প্রতি সেকেন্ডে fflush + fsync |
| কিছুই হারানো চলবে না | প্রতি রেকর্ডে fsync — এবং তখন syscall নয়, disk-ই bottleneck (~১ ms) |
লেসন ১৮-এ আমরা দেখব write করা মানেই durable নয়, আর
fsync-এর প্রকৃত চুক্তি কী। Level 8-এ ডাটাবেসের WAL এই
ঠিক এই ভারসাম্যটাই পেশাদারভাবে সমাধান করে (group commit)।
ধাপ ৫ — মেপে প্রমাণ করুন।
# আগে
perf stat -e raw_syscalls:sys_enter -p $PID -- sleep 10
# পরে — একই কমান্ড, তুলনা করুনআর সবচেয়ে গুরুত্বপূর্ণ: শেষ পর্যন্ত throughput মাপুন, syscall count নয়। syscall কমিয়ে throughput না বাড়লে bottleneck অন্য কোথাও ছিল — হয়তো parsing (CPU), হয়তো disk। Level 11-এ আমরা এই “কোথায় সময় যাচ্ছে” প্রশ্নটার পদ্ধতিগত উত্তর শিখব।
এরপর কী
পরের লেসন — Process-এর শরীর
এই লেসনে আমরা দরজাটা খুললাম আর দেখলাম ভেতরে কী আছে: একটা number, ছয়টা register, একটা টেবিল, আর ফেরার পথে একটা ঋণাত্মক সংখ্যা।
কিন্তু একটা প্রশ্ন এড়িয়ে গেছি। getpid() ডাকলে kernel
কোথা থেকে PID-টা পড়ল? write(1, ...)-এ 1 বলতে kernel
কোন ফাইল বুঝল? “আপনার process” বলতে kernel-এর কাছে ঠিক
কী আছে?
উত্তর: একটা struct। Linux-এ তার নাম task_struct, আর সে
প্রায় ৭ কিলোবাইট বড়। আপনার প্রোগ্রাম সম্পর্কে kernel
যা কিছু জানে — PID, parent, খোলা ফাইলের তালিকা, page table-এর
ঠিকানা, signal handler, CPU-তে কতক্ষণ চলেছে, কোন cgroup-এ
আছে — সবই সেখানে।
পরের লেসনে আমরা সেই struct-টা খুলে দেখব, আর পাশাপাশি
রাখব আপনার process-এর address space-টা: /proc/self/maps
আমরা গত লেসনে দেখেছি, এবার প্রতিটা লাইনকে ELF ফাইলের
segment-এর সাথে এক এক করে মেলাব।
আর দেখব process-এর সেই রহস্যময় অবস্থাটা — D state,
uninterruptible sleep — যেখানে আটকে যাওয়া process-কে
kill -9 দিয়েও মারা যায় না। কেন যায় না, আর সেটা আসলে
একটা bug নয় বরং একটা অনিবার্য পরিণতি — সেটাও বুঝব।
আরও পড়ুন
- Linux kernel source — arch/x86/entry/entry_64.S — Linux kernel developers · `entry_SYSCALL_64` — এই লেসনে বর্ণিত প্রতিটা ধাপ এখানে assembly-তে পড়া যায়, মন্তব্য সহ
- System V Application Binary Interface — AMD64 Architecture Processor Supplement — Matz, Hubička, Jaeger, Mitchell (eds.) · Appendix A.2-এ Linux syscall convention আনুষ্ঠানিকভাবে লেখা — `r10`-এর ব্যাখ্যা সহ
- AMD64 Architecture Programmer's Manual, Volume 2 — System Programming (§6.1 SYSCALL and SYSRET) — Advanced Micro Devices · `syscall` instruction-এর আনুষ্ঠানিক pseudo-code — RCX আর R11 কেন নষ্ট হয় তার প্রামাণ্য উৎস
- Anatomy of a system call, part 1 & 2 — David Drysdale (LWN.net) · kernel-এর ভেতরের পথটার সবচেয়ে পঠনযোগ্য বর্ণনা, `SYSCALL_DEFINEn` macro-র রহস্য ভাঙা সহ