3 पॉइंट द्वारा GN⁺ 2024-04-09 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • C में लिखी Hello World की एक लाइन भी स्क्रीन पर दिखने से पहले क्रमशः compiled executable, C standard library, system call, kernel और terminal से होकर गुजरती है
  • gcc hello.c -o hello से बना आउटपुट एक ELF 64-bit x86-64 executable file है, और ELF header के entry point 0x1060 पर _start code पहले execute होता है
  • यूज़र द्वारा लिखा गया main() सीधे start नहीं होता; यह _start और __libc_start_main से होकर जाता है, और printf("Hello World!\n") optimization के कारण एक सरल puts() call बन जाता है
  • string .rodata के 0x2004 में byte sequence के रूप में store होती है, और C string length information के बजाय NULL terminator से अपना अंत तय करती है
  • वास्तविक output path libc buffering और locking, write या writev system call, Linux kernel, pseudo-terminal और terminal emulator rendering तक जाता है, और execution environment के अनुसार बदल सकता है

C Hello World से शुरुआत

  • उदाहरण program C में लिखा गया निम्न code है
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • यह Python के print('Hello World!') जैसा ही result देता है, लेकिन C program interpreter से सीधे run नहीं होता; पहले इसे compile करना पड़ता है
gcc hello.c -o hello
./hello
  • execution result इस प्रकार है
Hello World!
  • C या assembly की basic knowledge हो तो flow को follow करना आसान रहेगा

executable file की असल पहचान

  • file hello के result में मुख्य बात ELF executable, x86-64 है
    • ELF executable file Linux में executable program format है
    • x86-64 का मतलब 64-bit x86 processor के लिए machine-code program है
  • readelf -h hello से ELF header देखने पर Entry point address: 0x1060 दिखता है
    • यह address वह जगह है जहां program load होने के बाद CPU execution शुरू करता है

_start और C library entry

  • objdump -D hello से disassemble करने पर 0x1060 location पर _start होता है
  • _start यूज़र द्वारा सीधे लिखा गया code नहीं है, बल्कि compiler, अधिक सटीक रूप से linker, द्वारा automatically जोड़ा गया code है
  • यह code initialization करने के बाद निम्न call execute करता है
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • यह function program के अंदर सीधे define नहीं है, बल्कि standard C library की ओर मौजूद है
  • readelf -d hello के dynamic section में libc.so.6 dependency दिखाई देती है
Shared library: [libc.so.6]
  • libc.so.6 system की standard C library है, और Linux की .so file में Windows की .dll की तरह कई programs द्वारा share किया जा सकने वाला code होता है
  • C library command-line arguments और environment variables की processing जैसी initialization संभालती है, main() call करती है और फिर उसके return value से program को exit कराती है

main() में वास्तव में क्या होता है

  • disassembly result में main() 0x1149 पर है
  • main() का flow इस प्रकार है
    • stack frame set करता है
    • function call arguments तैयार करता है
    • Hello World output function call करता है
    • stack frame clean करता है
    • exit code 0 के साथ return करता है
  • मुख्य हिस्सा string address को argument के रूप में तैयार कर puts@plt call करना है
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • source code में printf() था, लेकिन compiler ने optimize करके इसे puts() में बदल दिया
    • printf() formatted output capability वाला complex function है
    • उदाहरण में variable insertion जैसी formatting capability का इस्तेमाल नहीं है, इसलिए इसे अधिक simple puts() से replace कर दिया जाता है
    • puts() string के बाद खुद newline जोड़ता है, इसलिए original string का \n भी हटा दिया जाता है

string कैसे store होती है

  • string .rodata section के 0x2004 address पर है
  • उस location के bytes इस प्रकार हैं
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • इस byte sequence को "Hello World!" और अंत के 0x00 के रूप में interpret किया जाता है
  • 0x00 NULL terminator है, जो C string का अंत दिखाता है
  • C string अपने साथ length information नहीं रखती, इसलिए string लेने वाला function NULL terminator मिलने तक एक-एक byte process करता है
  • अगर strings के बीच NULL terminator न हो, तो C function कई strings को जोड़कर process कर सकता है या unauthorized memory पढ़कर Segmentation Fault के साथ exit हो सकता है

Glibc में puts() का path

  • puts@plt अंततः standard library की तरफ जाता है
  • Glibc में puts() _IO_puts से जुड़ता है
  • _IO_puts निम्न काम करता है
    • string length निकालता है
    • stdout output stream के लिए lock हासिल करता है
    • conditions check करता है और _IO_sputn call करता है
    • newline character output करता है
    • lock release करता है और output किए गए characters की संख्या return करता है
  • Glibc की internal implementation बड़ी और complex है, इसलिए आगे छोटे C library musl libc के flow को देखा गया है

musl libc में output नीचे जाने की प्रक्रिया

  • musl का puts() stdout lock हासिल करता है, fputs() और putc_unlocked('\n', stdout) call करता है, फिर lock release करता है
  • fputs() string length निकालता है और fwrite() call करता है
  • fwrite() फिर lock हासिल करता है और __fwritex() call करता है
  • __fwritex() buffer state check करता है, और जरूरत होने पर output stream के write function pointer को call करता है
  • stdout को fd = 1 के रूप में define किया गया है, और write function initially __stdout_write पर set होता है
  • __stdout_write() TIOCGWINSZ ioctl execute करने के बाद __stdio_write() call करता है
  • __stdio_write() SYS_writev से system call execute करता है

system call और kernel

  • सिर्फ C library hardware से सीधे communicate नहीं कर सकती; hardware access operating system kernel संभालता है
  • output request अंततः operating system से text को output stream में लिखने की request करने वाले system call पर खत्म होती है
  • सामान्य output write system call से होता है, और musl कई buffers को array के रूप में लिखने के लिए writev का इस्तेमाल करता है
  • musl का x86-64 system call implementation arguments की संख्या के अनुसार __syscall0 से __syscall6 तक विभाजित है
  • हर function arguments को CPU registers में set करता है और syscall instruction execute करता है
    • control kernel को चला जाता है
    • kernel registers के parameters पढ़ता है और requested system call execute करता है

kernel के बाद स्क्रीन पर दिखने तक

  • Linux kernel write system call receive करके open file या stream में data लिखता है
  • write system call file descriptor, लिखे जाने वाला buffer, और लिखे जाने वाले bytes की संख्या को arguments के रूप में लेता है
  • example environment में hello program GNOME terminal emulator में run होता है, और stdout /dev/pts/0 pseudo-terminal से connected होता है
  • kernel Hello World message को buffer में store करता है, और terminal emulator इसे पढ़कर screen पर display करता है
  • terminal emulator text को frame के रूप में render करता है, और X server या compositor उसे दूसरे app screens के साथ composite करने के बाद kernel के जरिए display पर दिखाता है
  • execution environment के अनुसार इसके बाद का path बदल सकता है
    • remote login में kernel text को sshd को भेजता है, और sshd encrypted packet के रूप में उसे फिर kernel को देता है ताकि internet पर भेजा जा सके
    • physical terminal और serial-to-USB adapter इस्तेमाल करने पर kernel text को USB packet के रूप में भेजता है
    • framebuffer console में kernel text को frame के रूप में render करके display पर output करता है

छोटे से output में जुड़ी complexity

  • Hello World message भेजना किसी एक program में हुआ सिर्फ एक system call है
  • आधुनिक software और hardware इतनी complex और finely layered संरचना से बने हैं कि छोटी action को भी पूरी तरह trace करना मुश्किल हो जाता है
  • इस explanation में बहुत सारी details, exceptions और kernel internal operations को छोड़कर सिर्फ main flow follow किया गया है

1 टिप्पणियां

 
GN⁺ 2024-04-09
Hacker News की राय
  • मैंने बोरियत में macOS पर Rust से कुछ ऐसा ही करके देखा था, और #![no_std], #![no_main], तथा WRITE/EXIT system call सीधे इस्तेमाल करने वाला “Hello, world!” भी Ghidra में देखने पर किसी भी हालत में लगभग 16KB का ही था
    इसे और घटाने के लिए code golf किया जा सकता है, लेकिन लगता है कि शायद कोई यह पहले ही करके document भी कर चुका होगा

    • Windows पर इसी तरह का प्रोग्राम 3072 bytes का था, और इसे rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main से compile किया गया था
      kernel32 के ExitProcess, GetStdHandle, WriteFile को सीधे call किया गया था, और hello world होने की वजह से panic handler को बस जैसे-तैसे रखा गया था। executable के अंदर अभी भी काफ़ी padding है, इसलिए size बढ़ाए बिना और चीज़ें डाली जा सकती हैं, और इससे भी ज़्यादा “criminal” तरीकों से इसे घटाया भी जा सकता है, लेकिन उसका बहुत मतलब नहीं दिखता
      संदर्भ के लिए, संबंधित PDB debug database का आकार 208,896 bytes था
    • अगर इसे सबसे छोटा बनाना हो, तो main को पूरी तरह छोड़कर _start का इस्तेमाल करना होगा, और section alignment न करने के लिए linker flags भी देने होंगे
      https://darkcoding.net/software/a-very-small-rust-binary-ind... में देखें, तो इस तरीके से आसानी से 500 bytes के आसपास पहुँचा जा सकता है
    • Code golf मज़ेदार है, लेकिन आजकल page size कितनी है, यह भी देखना चाहिए
      अगर भाषा में stack है, तो executable अंततः कम-से-कम दो pages, यानी read-only/read-write pages, में लोड होने की संभावना काफ़ी ज़्यादा है
    • min-sized-rust project में Rust binary size घटाने के लिए बहुत सी optimizations अच्छी तरह संकलित हैं
      याद पड़ता है कि सारी optimizations लगाने पर hello world आख़िर में लगभग 8KB का रह गया था: https://github.com/johnthagen/min-sized-rust
    • XNU एक page से छोटे Mach-O को load नहीं करता, इसलिए उस platform पर अफ़सोस की बात है कि छोटे binary के साथ खेलने की ज़्यादा गुंजाइश नहीं है
  • Musl ने छोड़ा हुआ एक और rabbit hole भी है। Linux में system functions को call करना सिर्फ़ syscall को सीधे इस्तेमाल करना ही नहीं है
    इससे ज़्यादा “सभ्य” तरीका vDSO को call करना है। यह एक जादुई-सी छोटी library है जिसे kernel address space में अपने-आप map कर देता है, इसलिए kernel system calls चलाने के लिए सबसे उपयुक्त code दे सकता है
    कुछ system calls user space में ही चल सकते हैं, जिससे syscall की ज़रूरत ही न रहे, और पहले vDSO कभी int 0x80 या sysenter जैसे kernel call mechanisms में से किसी एक को चुनता भी था
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • सिर्फ़ 32-bit x86 में ही vDSO में एक सामान्य fast system call shim मौजूद है
      x86-64 में standard system call तरीका SYSCALL instruction है, और vDSO में सिर्फ़ time-related functions और SGX से जुड़े कुछ functions होते हैं
  • अलग-अलग भाषाओं के “Hello World” प्रोग्रामों के overhead की तुलना करने वाला यह लेख भी देखने लायक है: https://drewdevault.com/2020/01/04/Slow.html
    इसका follow-up लेख: https://drewdevault.com/2020/01/08/Re-Slow.html
    Linux पर सबसे छोटा प्रोग्राम बनाने वाला एक legendary लेख भी है। वह प्रोग्राम बस status code 42 के साथ exit करता है: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    उसी साइट पर सबसे छोटा “Hello World” प्रोग्राम भी मिल जाएगा

  • यह लेख असल में dynamic linker की भूमिका को लगभग छोड़ ही देता है, जबकि उसे प्रोग्राम का वास्तविक entry point भी कहा जा सकता है
    अगर यह नज़रिया दिलचस्प लगे, तो https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2... देखें

  • अगर आप DOS के शौकीन हैं, तो DOS पर assembly/machine code में लिखा “hello, world” 23 bytes तक सिमट सकता था: https://github.com/susam/hello
    इन 23 bytes में से 15 bytes तो डॉलर चिन्ह पर खत्म होने वाली string ही ले लेती है, इसलिए असली machine code सिर्फ़ x86 instructions के चार commands, यानी 8 bytes, का होता है

  • लेख अच्छा था, लेकिन मुझे लगता है कि इसमें दो चीज़ें और होनी चाहिए थीं। या तो optimization और inlining को बंद किया जाता, जिनकी वजह से printf बदलकर puts हो जाता है, या फिर शुरू से ही puts को सीधे इस्तेमाल करने वाला hello world लिखा जाता
    साथ ही compilation process को preprocessing, compilation, assembly, और linking इन चार चरणों में बाँटना, या cc में --save-temps लगाकर बनने वाली files को समझाना भी अच्छा रहता। pipeline को सीधे देखने पर जो हिस्सा जादू जैसा लगता है, वह काफ़ी कम हो जाता है

  • यूनिवर्सिटी के systems programming क्लास का एक पसंदीदा असाइनमेंट याद आ गया: “C++ hello world का एक टुकड़ा दिया जाएगा, और संभव हो तो सबसे छोटा compiled binary जमा करो।”
    readelf और objdump जैसे tools से program को देखना, layers और compiler optimizations को एक-एक करके हटाना, और फिर भी “hello world” प्रिंट करने वाला सबसे छोटा binary बनाने की कोशिश—यह याद अब भी बनी हुई है।
    जाहिर है, खोजने पर पता चला कि छात्रों से कहीं बेहतर काम किसी ने पहले ही कर रखा था: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • यह बात कि वह टुकड़ा C++ था, क्या सच में मायने रखती है?
      सीधे hello world प्रिंट करने वाला सबसे छोटा binary बना कर यह दावा तो किया ही जा सकता है कि वह अर्थ की दृष्टि से equivalent है। String data शामिल करने पर भी लगता है x86 instructions की करीब दस लाइनें काफी होंगी।
    • अगर यह इतना पसंदीदा असाइनमेंट है, तो यह सोचकर हैरानी होती है कि “hello world” के अलावा दूसरे programs के लिए भी सबसे छोटा संभव binary बनाने वाले लोग ज्यादा क्यों नहीं दिखते।
      व्यक्तिगत रूप से, मुझे अपनी मशीन पर जगह बचाना अच्छा लगता है, इसलिए यह मजेदार लगता है, लेकिन आजकल 10MiB, 20MiB, 50MiB, 100MiB से भी बड़े programs बहुत लिखे जा रहे हैं। कुछ commercial environment में commercial purpose से बने होते हैं, लेकिन कई programs ऐसे भी होते हैं जो सिर्फ मजे के लिए लिखे जाते हैं। क्या छोटे programs लिखने का आनंद अब नहीं रहा?
  • “आधी रात हो गई है, अब सोना चाहिए” जैसी ending उलटे इस लेख के लिए एकदम परफेक्ट समापन थी

  • दुर्भाग्य से, कई “hello world” deep dives की तरह यह लेख भी write system call पर रुक जाता है और बाकी हिस्से को काफी सरसरी ढंग से पार कर देता है।
    system call तक की कहानी मूल रूप से function calls की एक chain ही है, जहाँ printf, puts को बुलाता है, puts, write को, और char const* पास करते हुए थोड़ा bookkeeping करता है—मेरे लिए यह सबसे दिलचस्प हिस्सा नहीं है।
    असली दिलचस्पी और complexity system call के बाद शुरू होती है। Kernel process के stdout को terminal emulator की input से जोड़ता है, और terminal font rendering library और GPU driver की मदद से framebuffer तैयार करता है। Character bytes के मुताबिक font outlines disk से पढ़े जाते हैं, viewport के हिसाब से फिट किए जाते हैं, scaling, kerning और font metrics लागू होते हैं, और फिर GPU rasterization और anti-aliasing करता है।
    इसके बाद window manager terminal window frame और desktop को composite करता है, और अगर transparency या frosted-glass effects हों तो वे shaders से संभाले जाते हैं। तैयार framebuffer को monitor resolution और color depth के अनुसार HDMI या DisplayPort signals में packetize किया जाता है, फिर cable और display input circuitry से गुजरकर pixel addressing signals में बदला जाता है। LCD, OLED, plasma, CRT—हर display technology का refresh तरीका अलग होता है, और उदाहरण के लिए 3840×2400 WRGB OLED को लगभग 3.686 करोड़ subpixels संभालने पड़ते हैं।
    यह सब 60Hz पर एक frame time यानी 16.67ms के भीतर हो जाता है।

    • व्याख्या अच्छी है, लेकिन आखिरकार यह भी मानव visual system पर आकर रुकती है, और वही हिस्सा फिर से सच में दिलचस्प और जटिल है :)
      https://en.wikipedia.org/wiki/Visual_system
    • अगर इस तरह की गहराई वाली पड़ताल पसंद है, तो Gynvael Coldwind का Windows पर Python hello world कैसे चलता है, इस पर लिखा गया लेख भी पसंद आ सकता है।
      इसमें CPython internals, Windows conhost, font rasterization, GPU rendering आदि शामिल हैं: https://gynvael.coldwind.pl/?id=754
    • इसका ज्यादातर हिस्सा program खुद से संबंधित नहीं है। उदाहरण के लिए, अगर output को /dev/null में pipe कर दिया जाए, तो यह सब नहीं होगा।
    • _start से पहले होने वाली बातें भी इसमें नहीं हैं। जैसे Linux में process का जन्म कैसे होता है, खासकर कुछ अजीब-सा execve, program का memory में load होना, binfmt_* और ताकतवर binfmt_misc, relocations, exception handling frames, sections, पूरा ELF loader, और malloc सहित operating system resources का allocation वगैरह।
  • “Python के विपरीत, इस program को चलाने के लिए interpreter को call नहीं किया जा सकता” यह बात पूरी तरह सही नहीं है।
    tcc -run hello.c इस्तेमाल करें तो यह किया जा सकता है। सख्ती से कहें तो यह interpreter नहीं, बल्कि in-memory compiler के ज्यादा करीब है।
    और अगर थोड़ा अतिरिक्त geek points चाहिए, तो program से “Hello world” की जगह “Hellorld” कहलवाया जा सकता है।