Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ৩/২৯কঠিন১ ঘণ্টা ১৫ মিনিট

System call — দেয়ালের দরজাটা ভেতর থেকে খোলা

System Calls

গত লেসনে দেখলাম kernel আর user space-এর মাঝে একটা hardware দেয়াল আছে, আর তাতে তিনটা দরজা। এই লেসনে প্রথম দরজাটা — system call — খুলে ভেতরটা দেখব। একটা `syscall` instruction থেকে শুরু করে kernel-এর handler পর্যন্ত প্রতিটা ধাপ, কেন argument গুলো ঠিক ওই register-এ, ৪০০ ন্যানোসেকেন্ড কোথায় যায়, আর `errno` আসলে কোথা থেকে জন্মায়। শেষে libc-কে সম্পূর্ণ বাদ দিয়ে খালি হাতে তিনটা syscall করা একটা প্রোগ্রাম বানাব।

এই লেসন শেষে আপনি পারবেন

  • x86-64-এর `syscall` instruction আর ARM64-এর `svc` ঠিক কোন hardware অবস্থা বদলায় তা ধাপে ধাপে বর্ণনা করতে পারবেন, আর বলতে পারবেন কেন পুরনো `int 0x80` এর চেয়ে এটা দ্রুত
  • Linux x86-64 syscall ABI মুখস্থ নয়, ব্যুৎপন্ন করতে পারবেন — কেন number `rax`-এ, কেন চতুর্থ argument `rcx` নয় বরং `r10`-এ
  • `IA32_LSTAR` MSR থেকে শুরু করে `entry_SYSCALL_64` → `do_syscall_64` → `sys_call_table` → handler → `sysret` — পুরো পথটা কোন kernel ফাইলে কোন ফাংশন তা সহ বলতে পারবেন
  • গত লেসনে মাপা ~৪০০ ন্যানোসেকেন্ড ঠিক কোন কোন ধাপে খরচ হয় তার একটা যুক্তিসঙ্গত ভাঙানি দিতে পারবেন, আর KPTI ও Spectre প্রশমনের অংশ আলাদা করতে পারবেন
  • কেন kernel ঋণাত্মক মান ফেরত দেয় আর `errno` আসলে কে সেট করে — সেই চুক্তিটা ব্যাখ্যা করে নিজে একটা সঠিক wrapper লিখতে পারবেন
  • libc সম্পূর্ণ বাদ দিয়ে শুধু inline assembly দিয়ে syscall করে একটা চলমান freestanding প্রোগ্রাম বানাতে পারবেন, আর `strace` দিয়ে প্রমাণ করতে পারবেন সেটা ঠিক কয়টা 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 callSystem call
গন্তব্যআপনার নির্ধারিত addresskernel-এর নির্ধারিত address
Privilegering 3 থেকে ring 3ring 3 থেকে ring 0
Stackএকই stackkernel stack-এ switch
Argumentrdi, rsi, rdx, rcx, r8, r9rdi, 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` instruction একটাই, কিন্তু এর ভেতরে ছয়টা আলাদা hardware কাজ ঘটে — সবই atomically।

ছয় নম্বর পয়েন্টটা ভালো করে দেখুন। syscall stack বদলায় না। Interrupt-এর সময় hardware TSS থেকে RSP0 লোড করে দেয়, কিন্তু syscall-এ সেটা হয় না — গতির জন্য।

ফলে kernel-এর entry code-এর প্রথম কাজই হলো নিজে হাতে stack বদলানো, আর সেটা করতে গেলে একটা register লাগে, কিন্তু কোনো register নষ্ট করা যাবে না কারণ সবগুলোতে user-এর ডেটা। এই মুরগি-ডিম সমস্যার সমাধানই হলো swapgs — একটু পরে দেখব।

Linux x86-64 syscall ABI — আর rcx-এর রহস্য

চুক্তিটা এই:

জিনিসRegister
syscall numberrax
argument ১rdi
argument ২rsi
argument ৩rdx
argument ৪r10
argument ৫r8
argument ৬r9
return valuerax
নষ্ট হয়ে যায়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-64ARM64
Instructionsyscallsvc #0
Numberraxx8
Args ১–৬rdi rsi rdx r10 r8 r9x0 x1 x2 x3 x4 x5
Returnraxx0
Return addressrcx (GPR নষ্ট হয়)ELR_EL1 (আলাদা register)
Saved flagsr11 (GPR নষ্ট হয়)SPSR_EL1
Entry pointIA32_LSTAR MSRVBAR_EL1 vector table
Privilegering 3 → ring 0EL0 → 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_stack

Linux 6.6-এ x86-64-এ ৪৫০-এর বেশি syscall আছে। কিন্তু এই সংখ্যাগুলো architecture-ভেদে সম্পূর্ণ আলাদা:

syscalli386x86-64ARM64RISC-V
read306363
write416464
open52নেইনেই
openat2952575656
exit1609393
exit_group2522319494

দুটো জিনিস লক্ষণীয়।

এক: 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. আপনার কোডwrite(1, "hi\n", 3) — একটা সাধারণ C function call
  2. glibc wrappersysdeps/unix/sysv/linux/write.c — errno হ্যান্ডলিং যোগ করে
  3. syscall instructionrax=1, rdi=1, rsi=buf, rdx=3 — তারপর syscall
  4. ⎯⎯ ring 3 | ring 0 সীমানা ⎯⎯hardware: CPL←0, RIP←IA32_LSTAR
  5. entry_SYSCALL_64arch/x86/entry/entry_64.S — swapgs, CR3, stack switch
  6. do_syscall_64()arch/x86/entry/common.c — bounds check + dispatch
  7. sys_call_table[1]__x64_sys_write — pt_regs থেকে argument বের করা
  8. ksys_write()fs/read_write.c — fd বৈধ? fdget_pos()
  9. vfs_write()fs/read_write.c — permission check, LSM hook
  10. file->f_op->write_iter()VFS-এর indirect call — লেসন ১৯
  11. tty_write() / ext4_file_write_iter()যে device সেই অনুযায়ী
  12. ↩ ফেরত: 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-canonicalsysret #GP তুলবে ring 0-তেiret
segment register বদলানোsysret সেগুলো restore করে নাiret
সাধারণ syscallsysret

তৃতীয় সারিটাই গত লেসনে উল্লেখ করা 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
সংস্করণমোট syscallbinary আকার (dynamic)কখন লেখা হয়
printf~২৭১৬ KBbuffer 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-এ বানাব।

নিজে চালিয়ে দেখুন

EXPERIMENT

Hello World আসলে কয়টা syscall করে

Linux, gcc, strace· ২০ মিনিট
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 -20

Dynamic-এর সাধারণ ফলাফল (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 total

Static-এর:

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কে করছেকেন
execveshellপ্রোগ্রাম চালু — এটা main-এর আগে
brk(NULL)ld.soheap-এর বর্তমান শেষ জানা
access("/etc/ld.so.preload")ld.sopreload আছে কি না (ENOENT স্বাভাবিক)
openat("/etc/ld.so.cache")ld.solibrary খোঁজার cache
newfstatat + mmapld.socache ফাইল map করা
openat("libc.so.6")ld.solibc খোঁজা
read (৮৩২ byte)ld.soELF header পড়া
mmap ×৪ld.solibc-র segment গুলো map করা
mprotect ×৩ld.sorelocation-এর পরে RELRO জোরদার
arch_prctl(ARCH_SET_FS)ld.soTLS (thread-local storage) বসানো
set_tid_address, set_robust_list, rseqlibcthread infrastructure
getrandomlibcstack canary + malloc-এর জন্য
prlimit64libcstack size জানা
brk ×২libcprintf-এর 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-dyn
getrandom("\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 খরচ। বিমূর্ততার দাম পরিমাপযোগ্য।

EXPERIMENT

libc বাদ দিয়ে কাঁচা syscall — আর clobber ভুলে গেলে কী হয়

Linux x86-64, gcc· ২৫ মিনিট

প্রথমে একটা সঠিক 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-এর উপর নির্ভরশীল।

নিজে বানান

BUILD IT

Freestanding — libc ছাড়া, `main` ছাড়া, তিনটা syscall

C · ●●●○○
  1. নিজের `_start` লিখুন — কারণ libc ছাড়া কেউ main ডাকবে না
  2. syscall wrapper লিখুন সঠিক clobber list সহ
  3. তিনটা syscall করুন: write, nanosleep, exit_group
  4. -nostdlib -nostartfiles -static দিয়ে compile করে আকার তুলনা করুন
  5. strace দিয়ে প্রমাণ করুন ঠিক তিনটাই হয়েছে, একটাও বেশি নয়

libc ছাড়া প্রোগ্রাম লেখার সময় তিনটা জিনিস আপনাকে নিজে করতে হয়, যেগুলো সাধারণত অদৃশ্য:

  1. Entry point। Kernel execve-এর পরে ELF header-এর e_entry-তে লাফায়, আর সেটার প্রচলিত নাম _startmain একটা libc-র ধারণা মাত্র।
  2. প্রোগ্রাম শেষ করা। _start থেকে return করা যায় না — ফেরার কোনো জায়গা নেই। exit_group syscall করতেই হবে, না করলে _start-এর পরের আবর্জনা byte গুলো execute হবে আর SIGSEGV/SIGILL হবে।
  3. 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 ./tiny
execve("./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। এটাই আপনার পুরো প্রোগ্রাম।

নিজে বাড়ান:

  1. 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-টা সংজ্ঞায়িত করে।
  2. ENOENT-এর নাম ছাপুন। একটা ছোট টেবিল বানান (errno → string) অন্তত ১০টা সাধারণ error-এর জন্য, আর strerror-এর মতো একটা ফাংশন লিখুন।
  3. নিজের malloc লিখুন। SYS_brk (12) দিয়ে একটা bump allocator — brk(0) দিয়ে বর্তমান শেষ জেনে, তারপর brk(cur + n) দিয়ে বাড়িয়ে। লেসন ১৪-এ আমরা এটাকে একটা পূর্ণাঙ্গ allocator-এ পরিণত করব।
  4. ARM64-তে port করুন। syscallsvc #0, raxx8, আর number গুলো generic ABI-র (write=64, exit_group=94)। একটা Raspberry Pi বা qemu-aarch64 থাকলে চালিয়ে দেখুন।
  5. vDSO ব্যবহার করুন। getauxval ছাড়াই — stack থেকে envp-এর পরে auxiliary vector পড়ে AT_SYSINFO_EHDR (33) খুঁজুন, সেটা vDSO-র ELF base। তার symbol table parse করে __vdso_clock_gettime বের করে সরাসরি ডাকুন। এটা করলে আপনি libc-র সবচেয়ে জটিল অংশটার পুনরাবৃত্তি করলেন।
  6. সবচেয়ে ছোট করার প্রতিযোগিতা। নিজের 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 functionsyscall করে?
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-641
Linux i3864
Linux ARM6464
Linux RISC-V64
Linux MIPS o324004
macOS x86-640x2000004 (উপরের bit গুলো “BSD শ্রেণি”)
FreeBSD4

এমনকি একই 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 গোনা, কম overheadperf stat -e raw_syscalls:*sampling, ptrace নয়
argument সহ, production-এbpftrace / eBPFkernel-এ চলে, context switch নেই
একটা নির্দিষ্ট syscall-এর latencyfunclatency (bcc)histogram
dev মেশিনে দ্রুত তদন্তstraceসহজ, কিন্তু ভারী

বুঝেছেন কি না দেখুন

1

Linux x86-64-এ syscall-এর চতুর্থ argument rcx-এ না রেখে r10-এ রাখা হয়। শুধু “ABI এটাই বলে” না বলে, hardware থেকে শুরু করে কারণটা ব্যুৎপন্ন করুন। আর ব্যাখ্যা করুন কেন ARM64-এ এই সমস্যাটা নেই।

যুক্তি

ধাপ ১ — syscall instruction কী করে তা দেখুন।

AMD64 ম্যানুয়াল (Vol 2, §6.1) অনুযায়ী syscall-এর pseudo-code-এর প্রথম দুই লাইন:

RCX ← next_RIP        (কোথায় ফিরতে হবে)
R11 ← RFLAGS          (কী অবস্থায় ফিরতে হবে)

ধাপ ২ — প্রশ্ন করুন: এটা ছাড়া উপায় ছিল কি?

Return address কোথাও তো রাখতেই হবে। বিকল্পগুলো:

বিকল্পসমস্যা
Stack-এ pushStack access = memory access = ধীর, আর syscall-এর সময় RSP এখনো user-এর, যা অবিশ্বস্ত/invalid হতে পারে
একটা আলাদা MSR-এMSR লেখা খুব ধীর (~১০০ cycle), আর nested syscall-এ overwrite হয়ে যেত
একটা নতুন dedicated registerx86-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-64ARM64
C argsrdi rsi rdx rcx r8 r9x0 x1 x2 x3 x4 x5
syscall argsrdi rsi rdx r10 r8 r9x0 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 কীভাবে তিনটাকে আলাদা করবে?

প্রয়োগ

নিয়মটা: [-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
-40960xFFFFFFFFFFFFF000না → বৈধ
00x0না → বৈধ
0x7FFFFFFFF0000x7FFFFFFFF000না → বৈধ

দুটো তুলনার (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 serializationspeculative-ভাবে ring বদলানো যায় না
MSR পড়া (LSTAR, STAR, FMASK)entry point কোথায় জানতে
swapgsper-CPU ডেটা পেতে
Stack switchuser stack অবিশ্বস্ত

একটা call instruction-এ এর একটাও লাগে না — শুধু RIP push আর jump, দুটোই out-of-order engine-এ প্রায় free।

শ্রেণি ২ — নিরাপত্তা প্রশমন (~২০০–৬০০ cycle)

কাজকেন যোগ হলো
KPTI: CR3 ×২Meltdown (২০১৮)
IBRS/STIBP wrmsrSpectre v2
Retpoline (indirect call)Spectre v2
Register clearingspeculative তথ্য ফাঁস রোধ
array_index_nospecSpectre 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_ptraceKILL
socket শুধু AF_UNIXnr == socket && args[0] != AF_UNIXEPERM
open শুধু read-only”args[1] & O_WRONLY চেক
mmap-এ PROT_EXEC নিষিদ্ধ”args[2] & PROT_EXEC চেক
“৩২-bit ABI বন্ধ”arch != AUDIT_ARCH_X86_64KILL
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 আছে
TRAPSIGSYS, 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 করে, আর একটা ফাইলে লেখে। আপনার তদন্তের ধাপগুলো কী, আর সম্ভাব্য সমাধান কী কী?

প্রয়োগ

ধাপ ০ — খরচটা হিসাব করুন।

৫০ লক্ষ × ৪০০ 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, unbufferedstrace -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 + সময়মতো flushwrite ৯৯% কমে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 নয় বরং একটা অনিবার্য পরিণতি — সেটাও বুঝব।

আরও পড়ুন