160 67 4MB
Hungarian Pages 610 Year 2012
Alkalmazott informatika sorozat
Asztalos Márk Bányász Gábor Levendovszky Tihamér
Második, átdolgozott kiadás
Asztalos Márk Bányász Gábor Levendovszky Tihamér
programozás
Asztalos Márk — Bányász Gábor — Levendovszky Tihamér
Linux programozás Második, átdolgozott kiadás
Asztalos Márk Bányász Gábor Levendovszky Tihamér
Lin ux programozás Második, átdolgozott kiadás
2012
Linux programozás
Második, átdolgozott kiadás Asztalos Márk, Bányász Gábor, Levendovszky Tihamér Alkalmazott informatika sorozat Budapesti Műszaki és Gazdaságtudományi Egyetem Villamosmérnöki és Informatikai Kar Automatizálási és Alkalmazott Informatikai Tanszék Alkalmazott Informatika Csoport C Asztalos Márk, Bányász Gábor, Levendovszky Tihamér, 2012. Sorozatszerkesztő: Charaf Hassan Lektor: Völgyesi Péter
ISBN 978-963-9863-29-3 ISSN 1785-363X
Minden jog fenntartva. Jelen könyvet, illetve annak részeit a kiadó engedélye nélkül tilos reprodukálni, adatrögzítő rendszerben tárolni, bármilyen formában vagy eszközzel elektronikus úton vagy más módon közölni.
■ Az 1795-ben alapított Magyar Könyvkiadók és Egyesülésének a tagja ■ 2060 Bicske, Diófa u. 3. ■ Tel.: 36-22-565-310 ■ Fax: 36-22-565-311 ■ www.szak.hu ■ e-mail: [email protected] ■ http://www.facebook.com/szakkiado ■ Kiadóvezető: Kis Ádám, e-mail• [email protected] ■ Főszerkesztő: Kis Balázs, e-mail: [email protected] SZAK Kiadó Kft. Könyvterjszők
Tartalomjegyzék Előszó a második kiadáshoz ....................................... xv 1. Bevezetés .......................................................... 1 1.1. A Linux .................................................................................................1 1.2. A szabad szoftver és a Linux története ........................................ 2 1.2.1. FSF ................................................................................................ 2 1.2.2. GPL ................................................................................................ 3 1.2.3. GNU ............................................................................................... 4 1.2.4. Linux-kernel.................................................................................. 4 1.2.5. A Linux rendszer .......................................................................... 6 1.2.6. Linux-disztribúciók ....................................................................... 6 1.3. Információforrások ...........................................................................8
2. Betekintés a Linux-kernelbe ................................. 11 2.1. A Linux-kernel felépítése ..............................................................11 2.2. A Linux elindulása ..........................................................................13 2.3. Processzek .........................................................................................14 2.3.1. A Linux-processzekhez kapcsolódó információk ....................... 15 2.3.2. A processz állapotai .................................................................... 17 2.3.3. Azonosítók ................................................................................... 18 2.3.4. Processzek létrehozása és terminálása ..................................... 19 2.3.5. A programok futtatása ............................................................... 20 2.3.6. Ütemezés ..................................................................................... 21 2.3.6.1. Klasszikus ütemezés .......................................................... 21 2.3.6.2. Az O(1) ütemezés ................................................................ 23 2.3.6.3. Teljesen igazságos ütemező ............................................... 24 2.3.6.4. Multiprocesszoros ütemezés .............................................. 25 2.3.7. Megszakításkezelés .................................................................... 25 2.3.8. Valósidejűség .............................................................................. 26 2.3.9. Idő és időzítők ............................................................................. 26 2.4. Memóriakezelés ...............................................................................27 2.4.1. A virtuálismemória-kezelés........................................................ 27 2.4.2. Lapozás ........................................................................................ 28
Tartalomjegyzék
2.4.3. A lapozás implementációja a Linuxon ....................................... 29 2.4.4. Igény szerinti lapozás ................................................................. 31 2.4.5. Lapcsere ...................................................................................... 32 2.4.6. Megosztott virtuális memória .................................................... 34 2.4.7. Másolás íráskor (COW technika) ............................................... 34 2.4.8. A hozzáférés vezérlése ................................................................ 35 2.4.9. A lapkezelés gyorsítása .............................................................. 35 2.5. A virtuális állományrendszer .......................................................36 2.5.1. Az állományabsztrakció.............................................................. 36 2.5.2. Speciális állományok .................................................................. 38 2.5.2.1. Eszközállományok .............................................................. 38 2.5.2.2. Könyvtár ............................................................................. 40 2.5.2.3. Szimbolikus hivatkozás...................................................... 40 2.5.2.4. Csővezeték .......................................................................... 40 2.5.2.5. Socket .................................................................................. 41 2.5.3. Az inode ....................................................................................... 41 2.5.4. Az állományleírók ....................................................................... 44 2.6. A Linux programozási felülete .....................................................44
3. Programkönyvtárak készítése ............................... 47 3.1 . Statikus programkönyvtárak ......................................................47 3.2. Megosztott programkönyvtárak ..................................................55 3.2.1. Megosztott programkönyvtár készítése ..................................... 56 3.2.2. Megosztott programkönyvtárak használata ............................. 60 3.2.3. Megosztott programkönyvtárak dinamikus betöltése .............. 63 3.3. Megosztott könyvtárak C++ nyelven ..........................................69 3.3.1. Programkönyvtárbeli C++-osztályok használata ...................... 69 3.3.2. C++-objektumok dinamikus betöltése programkönyvtárból ................................................................... 72 3.4. A megosztott könyvtárak működési mechanizmusai .............77 3.4.1. A betöltött program .................................................................... 78 3.4.2. Statikus könyvtárat tartalmazó program linkelése és betöltése ...................................................................................... 80 3.4.3. Megosztott könyvtár linkelése és betöltése ............................... 83 3.4.3.1. A címtartomány kezelése ................................................... 83 3.4.3.2. A megosztott könyvtárak megvalósításának alapkoncepciói..................................................................... 85 3.4.3.3. A megosztott könyvtárakkal kapcsolatos linkelés és betöltés.............................................................. 87 3.4.3.4. Dinamikusan linkelt megosztott könyvtár linkelése és betöltése .......................................................... 89 3.4.4. A programkönyvtárak használatának optimalizálása ............. 89
vi
Tartalomjegyzék
4. Állomány- és I/O kezelés ..................................... 95 4.1. Egyszerű állománykezelés ............................................................95 4.1.1. Az állományleíró ......................................................................... 96 4.1.2. Állományok megnyitása ............................................................. 97 4.1.3. Állományok bezárása.................................................................. 98 4.1.4. Írás, olvasás és pozicionálás az állományban ........................... 99 4.1.5. Részleges és teljes olvasás ........................................................ 101 4.1.6. Az írásművelet finomhangolása ............................................... 103 4.1.7. Állományok rövidítése .............................................................. 106 4.1.8. Állományok átirányítása .......................................................... 106 4.2. Inode-információk ........................................................................ 108 4.2.1. Inode-információk lekérdezése ................................................. 109 4.2.2. Jogok lekérdezése ..................................................................... 110 4.2.3. Jogok állítása ............................................................................ 111 4.2.4. Tulajdonos és csoport beállítása .............................................. 112 4.2.5. Az időbélyeg beállítása ............................................................. 112 4.3. További állományműveletek...................................................... 113 4.3.1. Eszközállományok és pipe bejegyzések létrehozása ............... 113 4.3.2. Merev hivatkozás létrehozása .................................................. 114 4.3.3. Szimbolikus hivatkozás létrehozása ........................................ 115 4.3.4. Állományok törlése ................................................................... 116 4.3.5. Állományok átnevezése ............................................................ 116 4.4. Könyvtárműveletek ..................................................................... 117 4.5. Csővezetékek ................................................................................. 120 4.5.1. Névtelen csővezetékek .............................................................. 121 4.5.2. Megnevezett csővezetékek........................................................ 123 4.6. Blokkolt és nem blokkolt I/O ..................................................... 126 4.7. A multiplexelt I/O módszerei ..................................................... 129 4.7.1. Multiplexelés a select() függvénnyel ........................................ 129 4.7.2. Multiplexelés a poll() függvénnyel ........................................... 134 4.7.3. A multiplexelési módszerek összehasonlítása......................... 139 4.8. Állományok leképezése a memóriába...................................... 139 4.9. Állományzárolás ........................................................................... 143 4.9.1. Zárolóállományok ..................................................................... 144 4.9.2. Rekordzárolás ........................................................................... 145 4.9.3. Kötelező zárolás ........................................................................ 148 4.10. Kapcsolat a magas szintű állománykezeléssel .................... 149 4.11. Soros kommunikáció ................................................................. 150 4.11.1. Kanonikus feldolgozás ............................................................ 151 4.11.2. Nem kanonikus feldolgozás .................................................... 154
vii
Tartalomjegyzék
5. Párhuzamos programozás .................................. 157 5.1. Processzek ...................................................................................... 157 5.2. Processzek közötti kommunikáció (IPC)................................ 165 5.2.1. Szemaforok ................................................................................ 166 5.2.2. Üzenetsorok .............................................................................. 178 5.2.3. Megosztott memória ................................................................. 185 5.3. Processzek a Linux rendszerben .............................................. 189 5.3.1. Feladatvezérlés ......................................................................... 191 5.3.2. Démonok.................................................................................... 193 5.3.3. Programok indítása shellből..................................................... 197 5.3.4. Jogosultságok ............................................................................ 198 5.3.5. Felhasználói nevek és csoportnevek ........................................ 200 5.4. Szálak .............................................................................................. 202 5.4.1. Szálak létrehozása .................................................................... 203 5.4.2. Szálak létrehozása C++ nyelven .............................................. 207 5.4.3. Szálak attribútumai ................................................................. 210 5.4.4. Szálbiztos függvények .............................................................. 212 5.4.5. Szál leállítása ............................................................................ 217 5.4.6. Szálak és a fork/exec hívások .................................................. 220 5.5. POSIX-szinkronizáció.................................................................. 221 5.5.1. Kölcsönös kizárás (mutex) ........................................................ 221 5.5.2. Feltételes változók .................................................................... 227 5.5.3. Szemaforok ................................................................................ 233 5.5.4. Spinlock ..................................................................................... 236 5.5.5. További lehetőségek: POSIX megosztott memória és üzenetsorok ............................................................................... 238 5.6. Jelzések ........................................................................................... 238 5.6.1. A jelzésküldés és -fogadás folyamata....................................... 239 5.6.2. Jelzések megvalósítása............................................................. 245 5.6.3. A jelzéskezelő és a főprogram egymásra hatása ..................... 247 5.6.4. Jelzések és a többszálú processz .............................................. 250 5.6.5. Jelzések programozása ............................................................. 250 5.6.5.1. Jelzések küldése ............................................................... 251 5.6.5.2. Jelzések letiltása és engedélyezése. Függőben lévő jelzések ..................................................... 253 5.6.5.3. A jelzések kezelése ........................................................... 254 5.6.5.4. Szinkron jelzéskezelés ...................................................... 258 5.6.6. A SIGCHLD jelzés .................................................................... 262
viii
Tartalomjegyzék
6. Hálózati kommunikáció ..................................... 265 6.1. A socket ........................................................................................... 265 6.2. Az összeköttetés-alapú kommunikáció ................................... 267 6.2.1. A kapcsolat felépítése ............................................................... 268 6.2.2. A socket címhez kötése ............................................................. 268 6.2.3. Várakozás a kapcsolódásra ...................................................... 269 6.2.4. Kapcsolódás a szerverhez ......................................................... 269 6.2.5. A kommunikáció ....................................................................... 270 6.2.6. A kapcsolat bontása .................................................................. 270 6.2.7. További kapcsolatok kezelése a szerverben ............................ 271 6.3. Az összeköttetés nélküli kommunikáció ................................. 272 6.3.1. A kommunikáció ....................................................................... 273 6.3.2. A connect() használata .............................................................. 274 6.3.3. A socket lezárása ...................................................................... 275 6.4. Unix domain socket ..................................................................... 275 6.4.1. Unix domain socket címek ....................................................... 275 6.4.2. Unix domain socket adatfolyam szerveralkalmazás .............. 276 6.4.3. Unix domain socket adatfolyam kliensalkalmazás................. 278 6.4.4. Unix domain socket datagram kommunikáció........................ 280 6.4.5. Névtelen Unix domain socket .................................................. 280 6.4.6. A Linux absztrakt névtere ....................................................... 280 6.5. IP ...................................................................................................... 282 6.5.1. Röviden az IP-hálózatokról ...................................................... 282 6.5.2. Az IP protokoll rétegződése ...................................................... 284 6.5.3. IPv4-es címzés ........................................................................... 285 6.5.4. IPv4-es címosztályok ................................................................ 286 6.5.5. IPv4-es speciális címek ............................................................. 287 6.5.6. IPv6-os címzés ........................................................................... 288 6.5.7. Portok ........................................................................................ 289 6.5.8. A hardverfüggő különbségek feloldása .................................... 290 6.5.9. A socketcím megadása .............................................................. 290 6.5.10. Lokális cím megadása ............................................................ 293 6.5.11. Név- és címfeloldás ................................................................. 294 6.5.11.1. A getaddrinfo() függvény................................................ 294 6.5.11.2. A getnameinfo() függvény............................................... 298 6.5.12. Összeköttetés-alapú kommunikáció ...................................... 302 6.5.12.1. TCP kliens-szerver példa ............................................... 304 6.5.12.2. TCP szerver alkalmazás ................................................ 308 6.5.12.3. TCP-kliensalkalmazás ................................................... 315 6.5.13. Összeköttetés nélküli kommunikáció .................................... 317 6.5.13.1. UDP-kommunikáció-példa ............................................. 317 6.5.13.2. Többes küldés ................................................................. 320
ix
Tartalomjegyzék
6.6. Socketbeállítások ......................................................................... 326 6.7. Segédprogramok........................................................................... 330 6.8. Távoli eljáráshívás ....................................................................... 331 6.8.1. Az RPC-modell .......................................................................... 332 6.8.2. Verziók és számok..................................................................... 332 6.8.3. Portmap ..................................................................................... 333 6.8.4. Szállítás ..................................................................................... 333 6.8.5. XDR ........................................................................................... 333 6.8.6. rpcinfo ........................................................................................ 334 6.8.7. rpcgen ........................................................................................ 334 6.8.8. Helyi eljárás átalakítása távoli eljárássá ................................ 335
7. Fejlesztés a Linux-kernelben .............................. 339 7.1. Verziófüggőség .............................................................................. 340 7.2. A kernel- és az alkalmazásfejlesztés eltérései ....................... 341 7.2.1. Felhasználói üzemmód — kernelüzemmód ............................. 342 7.3. Kernelmodulok ............................................................................. 343 7.3.1. Hello modul világ ...................................................................... 344 7.3.2. Fordítás ..................................................................................... 346 7.3.3. A modulok betöltése és eltávolítása ......................................... 347 7.3.3.1. insmod/rmmod................................................................. 347 7.3.3.2. modprobe ........................................................................... 347 7.3.4. Egymásra épülő modulok ......................................................... 348 7.4. Paraméterátadás a modulok számára ..................................... 351 7.5. Karakteres eszközvezérlő ........................................................... 353 7.5.1. Fő- és mellékazonosító (major és minor number) ................... 354 7.5.2. Az eszközállományok dinamikus létrehozása ......................... 355 7.5.3. Állományműveletek .................................................................. 356 7.5.4. Használatszámláló.................................................................... 357 7.5.5. „Hello világ” driver ................................................................... 358 7.5.6. Az open és a release függvények............................................... 362 7.5.7. A mellékazonosító (minor number) használata ...................... 363 7.5.8. Az ioctl() implementációja ........................................................ 366 7.6. A /proc állományrendszer ........................................................... 370 7.7. A hibakeresés módszerei ............................................................ 375 7.7.1. A printk() használata ................................................................ 375 7.7.2. A /proc használata ................................................................... 376 7.7.3. Kernelopciók ............................................................................. 377 7.7.4. Az Oops üzenet.......................................................................... 378 7.7.4.1. Az „Oops” üzenet értelmezése kernel esetében............... 379 7.7.4.2. Az „Oops” üzenet értelmezése kernelmodul esetében............................................................................. 380 x
Tartalomjegyzék
7.7.5. Magic SysRq .............................................................................. 381 7.7.6. A gdb program használata ....................................................... 382 7.7.7. A kgdb használata .................................................................... 382 7.7.8. További hibakeresési módszerek ............................................. 383 7.8. Memóriakezelés a kernelben ..................................................... 384 7.8.1. Címtípusok ................................................................................ 384 7.8.2. Memóriaallokáció ...................................................................... 384 7.9. A párhuzamosság kezelése ......................................................... 386 7.9.1. Atomi műveletek ....................................................................... 387 7.9.2. Ciklikus zárolás (spinlock) ....................................................... 389 7.9.3. Szemafor (semaphore) .............................................................. 391 7.9.4. Mutex ......................................................................................... 392 7.9.5. Olvasó/író ciklikus zárolás (spinlock) és szemafor (semaphore) ............................................................................... 393 7.9.6. A nagy kernelzárolás ................................................................ 394 7.10. I/O műveletek blokkolása ......................................................... 395 7.10.1. Elaltatás .................................................................................. 396 7.10.2. Felébresztés............................................................................. 397 7.10.3. Példa ........................................................................................ 398 7.11. A select() és a poll() támogatása .............................................. 403 7.12. Az mmap támogatása ................................................................. 404 7.13. I/O portok kezelése..................................................................... 407 7.14. I/O memória kezelése................................................................. 408 7.15. Megszakításkezelés .................................................................... 410 7.15.1. Megszakítások megosztása .................................................... 412 7.15.2. A megszakításkezelő függvények megkötései ....................... 412 7.15.3. A megszakítás tiltása és engedélyezése ................................ 412 7.15.4. A szoftvermegszakítás ............................................................ 413 7.15.5. A BH-mechanizmus ................................................................ 414 7.15.5.1. A kisfeladat (tasklet) ...................................................... 414 7.15.5.2. Munkasor ........................................................................ 417 7.16. A kernelszálak ............................................................................. 421 7.17. Várakozás ..................................................................................... 423 7.17.1. Rövid várakozások .................................................................. 423 7.17.2. Hosszú várakozás ................................................................... 424 7.18. Időzítők ......................................................................................... 425 7.19. Eszközvezérlő modell ................................................................ 426 7.19.1. A busz ...................................................................................... 426 7.19.2. Eszköz- és eszközvezérlő lista ................................................ 428 7.19.3. sysfs.......................................................................................... 429 7.19.4. Buszattribútumok exportálása .............................................. 429
xi
Tartalomjegyzék
7.19.5. Az eszközvezérlő ..................................................................... 430 7.19.6. Eszközvezérlő attribútumok exportálása .............................. 433 7.19.7. Az eszköz ................................................................................. 433 7.19.8. Az eszköz attribútumainak exportálása ................................ 435 7.19.9. Példa ........................................................................................ 436 7.20. További információk ................................................................. 438
8. A Qt keretrendszer programozása ........................ 439 8.1. Az X Window rendszer................................................................. 439 8.1.1. Az X Window rendszer felépítése ............................................. 439 8.1.2. X Windows kliensalkalmazások ............................................... 440 8.1.3. Asztali környezet ...................................................................... 441 8.2. Fejlesztés Qt alatt ......................................................................... 442 8.2.1. Hello Világ!................................................................................ 443 8.2.2. Projektállományok .................................................................... 447 8.2.3. A QObject szolgáltatásai........................................................... 448 8.2.4. A QtCore modul ......................................................................... 449 8.3. A Qt eseménykezelés-modellje .................................................. 450 8.3.1. Szignálok létrehozása ............................................................... 452 8.3.2. Szlotfüggvények létrehozása .................................................... 454 8.3.3. Szignálok és szlotok összekapcsolása ...................................... 455 8.3.4. Szlot az átmeneti objektumokban ............................................ 457 8.3.5. A Meta Object Compiler ........................................................... 458 8.4. Ablakok és vezérlők ..................................................................... 459 8.4.1. Dialógusablakok készítése ....................................................... 460 8.4.2. A Qt vezérlőkészlete ................................................................. 466 8.4.3. Saját alkalmazásablakok ......................................................... 467 8.4.4. A főablak programozása ........................................................... 469 8.4.5. Lokalizáció ................................................................................ 479 8.4.6. Saját vezérlők készítése............................................................ 483 8.5. A dokumentum/nézet architektúra .......................................... 489 8.5.1. Az alkalmazás szerepe.............................................................. 491 8.5.2. A dokumentumosztály .............................................................. 493 8.5.3. A nézetosztályok ....................................................................... 497 8.5.4. További osztályok ..................................................................... 503 8.6. További technológiák .................................................................. 509 8.6.1. Többszálú alkalmazásfejlesztés ............................................... 509 8.6.2. Adatbáziskezelés ....................................................................... 516 8.6.3. Hálózati kommunikáció............................................................ 521 8.7. Összefoglalás ................................................................................. 530
xii
Tartalomjegyzék
A függelék: Fejlesztőeszközök ................................. 533 A.1. Szövegszerkesztők ....................................................................... 533 A.1.1. Emacs ........................................................................................ 533 A.1.2. vi (vim) ...................................................................................... 534 A.1.3. nano (pico) ................................................................................ 534 A.1.4. joe .............................................................................................. 534 A.1.5. mc .............................................................................................. 534 A.1.6. Grafikus szövegszerkesztők ..................................................... 535 A.2. Fordítók ......................................................................................... 535 A.2.1. GNU Compiler Collection ........................................................ 536 A.2.1. gcc .............................................................................................. 536 A.2.3. LLVM ........................................................................................ 540 A.3. Make ................................................................................................ 541 A.3.1. Megjegyzések ............................................................................ 542 A.3.2. Explicit szabályok .................................................................... 542 A.3.3. Hamis tárgy .............................................................................. 544 A.3.4. Változódefiníciók ...................................................................... 545 A.3.5. A változó értékadásának speciális esetei ................................ 546 A.3.6. Többsoros változók definiálása ................................................ 547 A.3.7. A változó hivatkozásának speciális esetei .............................. 547 A.3.8. Automatikus változók .............................................................. 548 A.3.9. Többszörös cél ........................................................................... 549 A.3.10. Mintaszabályok ...................................................................... 550 A.3.11. Klasszikus ragozási szabályok .............................................. 551 A.3.12. Implicit szabályok .................................................................. 552 A.3.13. Speciális tárgyak .................................................................... 553 A.3.14. Direktívák ............................................................................... 554 A.4. Make alternatívák........................................................................ 554 A.4.1. Autotools ................................................................................... 555 A.4.2. CMake ....................................................................................... 555 A.4.3. qmake ........................................................................................ 555 A.4.4. SCons ........................................................................................ 556 A.5. IDE................................................................................................... 556
B függelék: Hibakeresés ......................................... 557 B.1. gdb ................................................................................................... 557 B.1.1. Példa a gdb használatára ........................................................ 558 B.1.2. A gdb leggyakrabban használt parancsai ............................... 561 B.1.3. A gdb indítása .......................................................................... 561 B.1.4. Töréspontok: breakpoint, watchpoint, catchpoint................... 562 B.1.5. Data Display Debugger (DDD) ................................................ 566 B.1.6. Az IDE-k beépített hibakeresője ............................................. 567 xiii
Tartalomjegyzék
B.2. Memóriakezelési hibák .............................................................. 568 B.2.1. Malloc hibakeresők .................................................................. 569 B.2.1.1. Memóriaterület túlírása .................................................. 570 B.2.1.2. Eléírás .............................................................................. 571 B.2.1.3. Felszabadított terület használata ................................... 571 B.2.1.4. Memóriaszivárgás ............................................................ 571 B.2.1.5. A malloc hibakeresők korlátai ........................................ 571 B.2.2. Electric Fence ............................................................................ 572 B.2.2.1. Az Electric Fence használata ........................................... 572 B.2.2.2. A Memory Alignment kapcsoló ........................................ 574 B.2.2.3. Az eléírás .......................................................................... 574 B.2.2.4. Az Electric Fence további lehetőségei ............................. 575 B.2.2.5. Erőforrásigények.............................................................. 575 B.2.3. DUMA ....................................................................................... 576 B.3. Valgrind ......................................................................................... 576 B.3.1. Memcheck.................................................................................. 577 B.3.1.1. A memcheck modul működése ......................................... 578 B.3.2. Helgrind .................................................................................... 580 B.4. Rendszerhívások monitorozása: strace .................................. 582 B.5. Könyvtárfüggvényhívások monitorozása: ltrace ................. 582 B.6. További hasznos segédeszközök .............................................. 582
Tárgymutató ........................................................ 585 Irodalomjegyzék ................................................... 593
xiv
Előszó a második kiadáshoz Könyvünk első kiadása óta a Linux fejlődése töretlen. A beágyazott szoftverrendszerek egyre nagyobb térhódításának köszönhetően a Linux népszerűsége is egyre nő, ezen belül számos mobileszköz – köztük okostelefonok – operációs rendszerévé vált. Ezzel együtt az operációs rendszer programozási felülete is sokat fejlődött. Ezért döntöttünk úgy, hogy a könyv első kiadása alapos átdolgozásra, illetve kiegészítésre szorul. Munkánk során csaknem minden fejezetet átírtunk, aktuálissá tettünk, a kernel programozásával kapcsolatos részt teljesen újraírtuk. A Linux a közelmúltban volt húszéves, az interneten nagyon sok cikk, példaprogram és közösségi oldalak állnak rendelkezésre. A Linux nagyon sokban követi a POSIX-szabványt, amely számos további dokumentációforrást jelent. Így véleményünk szerint egy Linuxról szóló könyv akkor a leghasznosabb, ha rendszerezi a programozáshoz szükséges ismereteket, a rendszer működését mutatja be, és a programozás logikája vezeti a tárgyalást. Könyvünkben így próbálunk hathatós segítséget nyújtani: az olvasót bevezetjük a rendszer működésébe, nemcsak a mit? és hogyan? kérdésekre adunk választ programrészletekkel illusztrálva, hanem a miért? kérdésre fektetjük a hangsúlyt, és arra építve mutatjuk be a többit. A legtöbb esetben egy példa motiválja a bemutatandó megoldást, amelyet külön kiemelünk. Feladat Készítsünk megosztott könyvtárat a kerekítést végző függvényünkkel.
A különösen fontos következtetéseket, jó tanácsokat „útmutatókban” összegezzük. Útmutató Ha a programozás számunkra több mint kész példakódok összefésülése, majd azok próbálkozással történő kijavítása, és időt szánunk a működés megértésére, sokkal bonyolultabb hibákat sokkal előbb észreveszünk, — ez képessé tesz minket Linux alatti szoftver tervezésére is.
Az első hat fejezet a Linux rendszer C nyelven hívható alapfunkcióit tárgyalja, egy fejezet a kernelmodulok készítéséről szól, míg az utolsó fejezet az XWindow rendszer programozását mutatja be C++ nyelven Qt-környezetben. A könyv alapját a Budapesti Műszaki és Gazdaságtudományi Egyetem Villamosmérnöki és Informatikai Karán választható „Linux-programozás” tantárgy elő-
Előszó a második kiadáshoz
adásai és laborfoglalkozásai képezik. A könyv első fejezetei azt feltételezik, hogy az olvasó tisztában van a C és C++ nyelvek alapjaival, az alapvető adatstruktúrákkal, és rendelkezik az elemi programozástechnikai ismeretekkel, amelyek például az [1,2,3] irodalmi hivatkozásnak megfelelő könyv feldolgozásának eredményeként szerezhetők meg. Olyan olvasókra is számítunk, akik Linux alatt próbálnak először komolyabban programozni, ezért a fontosabb fejlesztőeszközöket és magát az operációs rendszert teljesen az alapoktól tárgyaljuk, és a függvényhívásokat olyan részletességgel mutatjuk be, hogy azokat közvetlenül fel lehessen használni. Ahol csak tehettük, az egyes funkciókat egyszerű példákkal illusztráltuk. Reményeink szerint azok, akik feldolgozzák a könyv első hat fejezetét, képesek lesznek arra, hogy önállóan megtalálják és értelmezzék azokat a további Linuxszal kapcsolatos információkat, amelyekre szükségük van a fejlesztés során. Ebben a kiadásban különösen a szoftvertervezőket próbáljuk segíteni: bemutatjuk, hogy a Linux által biztosított megoldások milyen logikát követnek, melyek a járható utak, és mik ezek előnyei/hátrányai, valamint melyik módszer mikor hatékony. Azok számára, akik ipari projektet indítanának, bemutatunk néhány olyan eszközt (grafikus fejlesztői környezet – „A” függelék, a memóriaszivárgást felderítő programok, hibakeresők – „B” függelék), amelyek nélkül hoszszabb programok írása nehézkes és hosszan tartó volna. A grafikus fejlesztés bemutatásakor döntenünk kellett, hiszen a számos eszközkészlet mindegyikét nem mutathatjuk be. Választásunk a Qt-re esett mivel elterjedt mind asztali, mind beágyazott környezetben, valamint jól strukturált fejlesztői eszközkészlet. Úgy gondoljuk, hogy a grafikus felhasználói felület programozása és tervezése ma már kiforrottnak mondható, így ha valaki egy környezetet megismer, gyakorlatát minimális változtatásokkal más rendszerekben is alkalmazni tudja. A Qt alatti programozással foglalkozó részek haladó C++-programozói szintet feltételeznek. A C++ nyelv szükséges elemeit a [2] irodalmi hivatkozás első 12 fejezete, illetve a [3] hivatkozás ismerteti. A könyvet tankönyvként használók számára a következő fejezetek feldolgozását javasoljuk: ●
Linuxot használó C kurzus: „A” függelék, (bevezetés a nyelvi elemekbe, szabványos könyvtári függvények, például [1] alapján): 4, 5, 6.
●
Operációs rendszerek alapjai kurzus gyakorlati illusztrálása: 1–5.
●
Linux-programozási kurzus: „A” függelék , „B” függelék, 1–8.
Továbbra is igyekeztünk, hogy az egyes fejezetek a lehetőségekhez mérten és a téma jellegétől függően önálló egészet alkossanak, esetleges rövidebb ismétlések árán is. Az itt leírt tananyag számonkérése a téma jellege miatt különösen nehézkes. Ezt megkönnyítendő a számon kérhető fogalmakra, valamint a fontosabb mondanivalóra vastag betűkkel hívtuk fel a figyelmet. A folyó szövegben
xvi
Előszó a második kiadáshoz
gyakran hivatkozunk a programkódban szereplő változókra, konstansokra, makrókra stb. Ezeket dőlt betűkkel szedtük az elkülöníthetőség miatt. A programrészleteket, a parancssorba beviendő parancsokat és szkripteket szürke háttér jelzi. A szerzők törekedtek arra, hogy a könyvben szereplő kódrészek elektronikusan is hozzáférhetők legyenek, ezek a példák az alábbi oldalon érhetők el: http://szak.hu/linux. Jelen munkában felhasználtuk a Linux saját dokumentációit, így az info és a man oldalakat, a POSIX-szabványokat. Az egyes forrásokat a könyv jellege és a tárgyalás folyamatossága miatt nem jelöltük külön, az irodalomjegyzékben összegeztük őket. Az egyes új fogalmaknak magyar megfelelői mellett kerek zárójelben közöljük azok angol megfelelőit a további tájékozódás megkönnyítéséhez. Elsőként szeretnénk megköszönni Völgyesi Péternek különösen gondos lektori munkáját és értékes tanácsait. Köszönjük Lattman Zsolt, Szilvási Sándor, Horváth Péter és Babják Benjamin visszajelzéseit és a kézirat egyes részeinek átolvasását. Köszönjük továbbá Laczkó Krisztina olvasószerkesztői munkáját, amely jelentősen növelte a kézirat szövegének igényességét és érthetőségét, valamint Mamira Györgynek a kézirat tördelését. Köszönetünket szeretnénk kifejezni Szilvási Sándornak a fedélborító külső megjelenésének az elkészítéséért, az Institute for Software Integrated Systems kutatóintézetének (Vanderbilt Egyetem, Nashville, Tennessee, USA), valamint az Automatizálási és Alkalmazott Informatikai Tanszék (Budapesti Műszaki és Gazdaságtudományi Egyetem) Alkalmazott Informatika Csoportjának, hallgatóinknak és a SZAK Kiadó munkatársainak. Végül pedig továbbra is bízunk abban, hogy ez a könyv sokak számára lesz megbízható segítség tanulmányaik és munkáik során, és reményeink szerint akad néhány olyan olvasó, aki szabadidejét nem kímélő, lelkes tagja lesz a szabad szoftverek önkéntes fejlesztőgárdájának.
Budapest, 2012. október A szerzők
xvii
ELSŐ FEJEZET
Bevezetés
1.1. A Linux A Linux szónak több jelentése is van. Műszaki értelemben pontos definíciója a következő: A Linux szabadon terjeszthető, Unix1 szerű operációsrendszer kernel. -
-
A legtöbb ember a Linux szó hallatán azonban a Linux-kernelen alapuló teljes operációs rendszerre gondol. Így általában az alábbiakat értjük rajta: A Linux szabadon terjeszthető, Unix-szerű operációs rendszer, amely tartalmazza a kernelt, a rendszereszközöket, a programokat és a teljes fejlesztői környezetet. A továbbiakban mi is a második jelentését vesszük alapul, vagyis a Linuxot mint operációs rendszert mutatjuk be. A Linux kiváló, ingyenes platformot ad a programok fejlesztéséhez. Az alapvető fejlesztőeszközök a rendszer részét képezik. Unix-szerűségéből adódóan programjainkat könnyen átvihetjük majdnem minden Unix- és Unix-szerű rendszerre. További előnyei a következők: •
A teljes operációs rendszer forráskódja szabadon hozzáférhető, használható, vizsgálható és szükség esetén módosítható.
•
Ebbe a körbe beletartozik a kernel is, így komolyabb, a kernel módosítását igénylő problémák megoldására is lehetőségünk nyílik.
A Unix egy 1960-as években keletkezett többfelhasználós operációs rendszer, amelynek nagy részét C nyelven írták az AT&T cégnél. Évtizedekig a legnépszerűbb operációs rendszerek egyike volt. A System V az AT&T által fejlesztett Unix alapverziójának a neve. A másik jelentős változat a kaliforniai Berkeley Egyetemhez kötődik, ez a Berkeley Software Distribution, röviden BSD.
1. fejezet: Bevezetés
•
A Linux fejlesztése nem profitorientált fejlesztők kezében van, így fejlődésekor csak műszaki szempontok döntenek, marketinghatások nem befolyásolják.
•
A Linux-felhasználók és -fejlesztők tábora széles és lelkes. Ennek következtében az interneten nagy mennyiségű segítség és dokumentáció található.
Az előnyei mellett természetesen meg kell említenünk a hátrányait is. A decentralizált fejlesztés és a marketinghatások hiányából adódóan a Linux nem rendelkezik olyan egységes, felhasználóbarát kezelői felülettel, mint a versenytársai, beleértve a fejlesztői eszközök felületét is. Ennek ellensúlyozására az egyes disztribúciók készítői többnyire törekednek arra, hogy a kezükből kiadott rendszer egységes, jól használható felületet nyújtson. Ám a disztribúciók mennyisége ugyanakkor megnehezítheti a fejlesztők dolgát, ha minden rendszeren támogatni szeretnék a programcsomagjukat. 2 Mindezek figyelembevételével azonban a Linux így is kiváló lehetőségeket nyújt a fejlesztésekhez, elsősorban a grafikus felhasználói felülettel nem rendelkező programok területén, de hathatós támogatást biztosít grafikus kliensalkalmazások számára is. A Linux rendszerek használata a beágyazott eszközök területén a legjelentősebb. Számos olyan eszközt találhatunk manapság az otthonokban, amelyekről sokszor nem is tudjuk, hogy rajtuk egy Linux rendszer teszi a háttérben a dolgát (például otthoni router, DVD felvevő/lejátszó, fényképezőgépek stb.). A beágyazott alkalmazások egy külön csoportját alkotják a mobiltelefonok. A Linux rendszer számos mobiltelefon operációs rendszerének is az alapja. Ezek egy része lényegében csak a kernelt és a főbb fejlesztői könyvtárakat használja (például az Android), de találhatunk olyat is, amelyik szinte egy teljes linuxos számítógépnek felel meg.
1.2. A szabad szoftver és a Linux története 1.2.1. FSF A számítástechnika hajnalán a cégek kis jelentőséget tulajdonítottak a szoftvereknek. Elsősorban a hardvert akarták eladni, a szoftvereket csak járulékosan adták hozzá, üzleti jelentőséget nem tulajdonítottak neki. Ez azt eredményezte, hogy a forráskódok, az algoritmusok szabadon terjedhettek.
2
2
A különböző disztribúciók okozta problémában segítséget nyújt a később említett LSBprojekt.
1.2. A szabad szoftver és a Linux története
Ám ez az időszak nem tartott sokáig, a gyártók hamar rájöttek a szoftverben rejlő üzleti lehetőségekre, és ezzel beköszöntött a zárt forráskódú programok korszaka. Ez lényegében azt jelenti, hogy a szoftverek forráskódját, mint szellemi termékeket, a cégek levédik, és üzleti titokként szigorúan őrzik. Ezek a változások nem nyerték el az akkoriban a Massachusettsi Műszaki Egyetemen (Massachusetts Institute of Technology, MIT) dolgozó Richard Stallman tetszését. Így megalapította a Free Software Foundation (FSF) elnevezésű szervezetet a massachusettsi Cambridge-ben. Az FSF célja a szabadon terjeszthető szoftverek fejlesztése lett.
1.2.2. GPL Az FSF nevében a „free" szabadságot jelent, nem ingyenességet. Stallman hite szerint a szoftvernek és a hozzátartozó dokumentációnak, forráskódnak szabadon hozzáférhetőnek és terjeszthetőnek kell lennie. Ennek elősegítésére megalkotta (némi segítséggel) a General Public License-t (GPL, magyarul: általános felhasználói licenc), amelyet 1989-től használtak. A GPL három fő irányelve a következő: 1. Mindenkinek, aki GPL-es szoftvert kap, megvan a joga arra, hogy ingyenesen továbbadja a forráskódját. (Leszámítva a terjesztési költségeket.) 2. Minden szoftver, amely GPL-es szoftverből származik, szintén GPL-es kell, hogy legyen. 3. A GPL-es szoftver birtokosának megvan a joga ahhoz, hogy a szoftvereit olyan feltételekkel terjessze, amelyek nem állnak konfliktusban a GPL-lel. A GPL egyik jellemzője, hogy nem nyilatkozik az árról. Vagyis a GPL-es szoftvertermékeinket szabadon értékesíthetjük. Egyetlen kikötés az, hogy a forráskód ingyenesen jár a szoftverhez. A vevő ezután azonban szabadon terjesztheti a programot és a forráskódját. Az internet elterjedésével ez azt eredményezte, hogy a GPL-es szoftvertermékek ára alacsony lett (sok esetben ingyenesek), de lehetőség nyílik ugyanakkor arra is, hogy a termékhez kapcsolódó szolgáltatásokat, támogatást térítés ellenében nyújtsák. A GPL licenc 2-es verziója 1991 nyarán született meg, és a szoftverek jelentős része ezt használja. Mivel a megkötések nagyon szigorúnak bizonyultak a fejlesztői könyvtárakkal kapcsolatban, ezért megszületett az LGPL licenc (eredetileg Library General Public License, később GNU Lesser General Public License) körülbelül a GPLv2-vel egy időben. Az LPGL első változata a 2-es verziószámot kapta.
3
1. fejezet: Bevezetés
Időközben azonban megjelentek olyan gyártók, akik „kreatívan" értelmezték a GPLv2 licencet. A szöveget betartották, ám az alapelvet megsértették. 3 A problémák orvoslására 2007 nyarán jelent meg a GPLv3. A GPLv3 fogadtatása azonban vegyes volt, ezért sokan maradtak a GPLv2-nél. A GPLv3-mal párhuzamosan egy új licenc is született reagálva a kor kihívásaira, nevezetesen a hálózaton futó alkalmazásokra, ilyenek például a webes alkalmazások. Ez az új licenc az AGPLv3 (Affero General Public License).
1.2.3. GNU Az FSF által támogatott legfőbb mozgalom a GNU's Not Unix (röviden GNU) projekt, amelynek az a célja, hogy szabadon terjeszthető Unix-szerű operációs rendszert hozzon létre. Ez a projekt nagyon sokat adott hozzá a Linux rendszerhez. Csak a legfontosabbakat említve: a C fejlesztői könyvtár, a GNU Compiler Collection, amely a legelterjedtebb fordító eszközcsomag, a GDB, amely a fő hibakereső program, továbbá számos, a rendszer alapjaként szolgáló segédprogram.
1.2.4. Linux-kernel A Linux-kernel története 1991-re nyúlik vissza. Linus Torvalds, a helsinki egyetem diákja ekkor kezdett bele a projektbe. Eredetileg az Andrew S. Tanenbaum által tanulmányi célokra készített Minix operációs rendszerét használta a gépén. A Minix az operációs rendszerek működését, felépítését volt hivatott bemutatni, ezért egyszerűnek, könnyen értelmezhetőnek kellett maradnia Emiatt nem tudta kielégíteni Linus Torvalds igényeit, aki ezért belevágott egy saját, Unix-szerű operációs rendszer fejlesztésébe. Eredetileg a Linux-kernelt gyenge licenccel látta el, amely csak annyi korlátozást tartalmazott, hogy a Linux-kernel nem használható fel üzleti célokra. Ám ezt rövidesen GPL-re cserélte. A GPL feltételei lehetővé tették más fejlesztőknek is, hogy csatlakozzanak a projekthez. 4 A MINIX közösség jelentős mértékben támogatta a munkát. Abban az időben nagy szükség volt egy szabad kernelre, mivel a GNU-projekt kernelrésze még nem készült el, a BSD rendszerrel kapcsolatban pedig jogi problémák merültek fel. A pereskedés közel két évig gátolta a szabad BSD-változatok fejlesztését. Ez a környezet nagyban hozzájárult a Linux megszületéséhez.
4
4
Egyes cégek, bár betartották a GPLv2 licencet, és kiadták az ez alá tartozó forráskódokat, a hardverben azonban olyan trükköket alkalmaztak, mint a digitális aláírást, amelylyel megakadályozták, hogy rajtuk kívül bárki más új szoftververziót fordíthasson és telepíthessen az eszközre. A szakzsargonban ez a módszer „tivoization" néven terjedt el, mivel a TiVo cég alkalmazta először. A Linux-kernel jelenleg a GPLv2 licencet használja.
1.2. A szabad szoftver és a Linux története
A Linux-kernel fejlődésének állomásai a következők: •
1991. szeptember: a Linux 0.01-es verziója megjelent az ftp.funet.fi szerveren, amely a finn egyetemi hálózat állományszervere.
•
1991. október: Linux 0.02.
•
1991. december: Linux 0.11 — az első önhordó Linux. Vagyis a Linuxkernel ettől kezdve fordítható a Linux rendszeren. A korábbi fejlesztések Minix alatt történtek.
•
1992. február: Linux 0.12 — az első GPL licences kernel.
•
1992. március: Linux 0.95 — az X Windows rendszert átültették Linuxra, így már grafikus felületet is kapott.
•
1994. március: Linux 1.0.0 — a gyors fejlődést látva hamarabb várta mindenki, de kellett még néhány fejlesztői verzió, mire elég éretté vált a rendszer. Ez a kernel is még csak egy processzort és csak i386os architektúrát támogatott.
•
1995. március: Linux 1.2.0 — a Linux megérkezett más architektúrákra is. Az Alpha, az SPARC és a MIPS rendszerek is bekerültek a támogatott platformok közé.
•
1996. június 9: Linux 2.0.0 — megjelent a többprocesszoros rendszerek támogatása (SMP).
•
1999. január 25: Linux 2.2.0 — javult az SMP támogatása, megjelent a támogatott rendszerek között az m68k és a PowerPC.
•
2001. január 4: Linux 2.4.0 — megjelent az ISA PnP-, az USB- és a PCkártya- (PC card) támogatás. Továbbá a támogatott architektúrák közé bekerült a PA-RISC. Ez a verzió abban is más a korábbiaknál, hogy a felgyorsult fejlődés hatására már a stabil változatnak is egyre több újdonságot kellett átvennie, ilyen például a Bluetooth, az LVM, a RAID, az ext3 állományrendszer.
•
2003. december 17: Linux 2.6.0 — megváltozott, felgyorsult a fejlesztési modell. Nincs külön stabil és fejlesztői vonal, hanem sokkal rövidebb ciklusokban ebbe a vonulatba kerül bele minden újítás. Emellett számos újítást és új architektúrák támogatását hozta ez a verzió, ezért ezek felsorolására jelen keretek közt nem vállalkozunk.
•
2011. július 22: Linux 3.0 — a Linux 20 éves évfordulója alkalmából jelent meg. A verzióváltás jelképes, mivel a fejlesztések folyamatosan belekerültek a 2.6.x kernelekbe. Így az új verzió technikai újítást nem hozott a 2.6.39-es verzióhoz képest. Megváltozott a verziószám felépítése, mivel a fejlesztési modell szükségtelenné tette a három szintet.
5
1. fejezet: Bevezetés
1.2.5. A Linux rendszer A Linux-projekt már a kezdetektől szorosan összefonódott a GNU-projekttel. A GNU-projekt forráskódjai fontosak voltak a Linux-közösség számára a rendszerük felépítéséhez. A rendszer további jelentős részletei a kaliforniai Berkley Egyetem nyílt Unix-forráskódjaiból, illetve az X konzorciumtól származnak. A különböző Unix-fajták egységesített programozói felületének létrehozására született meg a C nyelven definiált POSIX- (Portable Operating System Interface) szabvány — a szó végén az X a Unix világára utal —, amelyet a Linux implementálásakor is messzemenőkig figyelembe vettek. Szintén a Unix-fajták egységesítésére jött létre a SUS (Single UNIX Specification), amely egy szabványgyűjtemény. A SUS aktuális verzióinak a magját a POSIX-szabványok alkotják. A SUS definiálja a headerállományokat, a rendszerhívásokat, a könyvtári függvényeket és az alapvető segédprogramokat, amelyeket egy Unix rendszernek nyújtania kell. Emellett információkat tartalmaz a szabványok mögött álló megfontolásokról is. 6 A Linux hasonlóan más nyílt operációs rendszerekhez nem rendelkezik a SUS Unix tanúsítványával. Ennek oka részben a tanúsítvány megszerzésének költsége, másrészt a Linux gyors fejlődése, amely további extraköltséget jelentene a tanúsítvány megtartásához. Ugyanakkor a rendszer fejlesztői törekednek a SUS-szabványok teljesítésére. Ezen okokból a Linuxra a Unix-szerű (Unix-like) jelzőt használjuk.
1.2.6. Linux-disztribúciók Az első Linux-disztribúció (terjesztési csomag) megjelenése előtt, ha valaki Linux-használó akart lenni, akkor jól kellett ismernie a Unix rendszereket, a Linux felépítését, konfigurálását, elindulásának a folyamatát. Ennek az az oka, hogy a használónak kellett a komponensekből felépítenie a rendszert. Beláthatjuk, hogy ez jelentős akadályt jelentett a rendszer elterjedésében, hiszen a kezdőknek nem sok esélyük volt megismerni a rendszert. Ezért, amint a Linux rendszert a Linux-fejlesztőin kívül mások is elkezdték használni, me gj elentek az első disztribúciók. A korai disztribúciók többnyire lelkes egyének csomagjai voltak. Néhány a korai disztribúciókból: Boot-root, MCC Interim Linux, TAMU, SLS, Yggdrasil Linux/GNU/X. 5
6
6
Jelenleg a POSIX-szabvány az IEEE Std 1003.1-2008 szabványt jelenti, amelyet POSIX.12008-ként rövidítenek. A POSIX-implementációk eltérhetnek, ugyanis a szabvány csak felületet határoz meg, nem megvalósítást. Legegyszerűbben a man oldalak végén találjuk meg egy adott függvény POSIX-kompatibilitását. Jelenleg a SUS 3-as verziója a legelterjedtebb, amely a POSIX:2001-es szabványon alapul. Az a rendszer amelyik ezt teljesíti az jogosult a UNIX 03 címke használatára. A POSIX:2008 a SUSv4 alapját szolgáltatja.
1.2. A szabad szoftver és a Linux története
Az első jelentős disztribúció az 1993-ban megjelent Slackware volt, amelyet az SLS-disztribúcióból alakított ki Patrick Volkerding. A Slackware tekinthető az első olyan csomagnak, amelyet már komolyabb háttértudás nélkül is lehetett installálni és használni. Megszületése nagyban elősegítette a Linux terjedését, népszerűségének a növekedését.? A Slackware egyben az alapját szolgáltatta több későbbi rendszernek is, például a Red Hatnek és a SuSE-nek. A Slackware mellett azonban az SLS miatti elégedetlenség egy másik disztribúció megszületéséhez is hozzájárult. Ian Murdock elindította a Debian projektet (a disztribúció neve a felesége, Debra és saját keresztnevének összeolvasztása). A Debian szintén 1993-ban született, ám az első 1.x verzió csak 1996 nyarán jelent meg. A Debian számos további disztribúció alapjaként szolgált és szolgál mai is. A Red Hat Linux-disztribúció 1994 novemberében jelent meg az 1.0-s verzióval. Ez volt az első olyan disztribúció, amely az RPM csomagkezelőjét használta. Ez nagy előrelépést jelentett, ugyanis sokkal könnyebbé tette a szoftvercsomagok adminisztrálását, telepítését, frissítését. Ez is hozzájárult a Linux további elterjedéséhez, mert felhasználóbarát, teljes rendszert biztosított. Emellett a Red Hat cég olyan szintű támogatás nyújtott a termékéhez, amely lehetővé tette, hogy a cégek is komolyan fontolóra vegyék a rendszer használatát. 2003-ban a Red Hat Linux ebben a formájában megszűnt. Helyette a Red Hat Enterprise Linux (RHEL) rendszert ajánlja a cég vállalati környezetbe, illetve életre hívta és támogatja a Fedora Projectet, amely a Fedora disztribúciót tartja karban. Az üzleti világban az RHEL az egyik legnépszerűbb disztribúció. Napjaink egyik leggyakrabban használt Linux-disztribúciója a Debianalapú Ubuntu. A disztribúció egy dél-afrikai humanista filozófiáról kapta a nevét, amelynek lényege a mások iránti emberségesség: „azért vagyok az, aki vagyok, amiért mi mindannyian azok vagyunk, akik vagyunk". A rendszert a dél-afrikai Mark Shuttleworth alapította Canonical Ltd. fejleszti, amely a terméktámogatást pénzért árulja, ugyanakkor az operációs rendszer ingyenes. Az első verzió 2004 októberében jelent meg. Az eddig említett disztribúciókat számos újabb is követte, amelyek különböző célok mentén fejlődtek. Így lehetőségünk van kicsi és gyors, nagy és látványos, stabil és kevésbé aktuális vagy nem annyira stabil, de minden újdonságot tartalmazó rendszert is választani. A disztribúciók listája rendkívül hosszú, így nem is vállalkozunk a felsorolásukra. Az interneten megtalálhatjuk a nekünk legszimpatikusabbat. Ám ha magyar nyelvű és magyarok által gondozott disztribúciót szeretnénk, akkor erre is van lehetőségünk az UHU Linux- (http: / / uhulinux.hu) disztribúció révén. Az oktatási intézmények számára kifejlesztett SuliX (http: / / www.sulix.hu) szintén ebbe a kategóriába tartozik.
7
Jelen írás szerzője is a Slackware disztribúciója révén találkozott először a Linux rendszerrel 1993 végén. 7
1. fejezet: Bevezetés
A Linux-disztribúciók általában a fejlesztői könyvtárakat, a fordítókat, az értelmezőket, a parancsértelmezőket, az alkalmazásokat, a segédprogramokat, a konfigurációs és csomagkezelő eszközöket és még sok más komponenst is tartalmaznak a Linux-kernel mellett. Az alapelemek többnyire megegyeznek a disztribúciókban, csak eltérő verziókkal, illetve kisebb-nagyobb módosításokkal találkozhatunk. Az opcionális fejlesztői könyvtárak már nem minden diszt8 ribúcióban lelhetők fel, de forrásból lefordíthatjuk őket hozzájuk. Mivel a különböző Linux-disztribúciók nagymértékben eltérhetnek egymástól, ezért felhasználók és a fejlesztők számára jelentős problémát jelenthet a különbözőségek kezelése. Felismerve a problémát a Linux-disztribúciók készítői létrehozták az LSB (Linux Standard Base) projektet, amelynek célja a rendszerstruktúra egységesítése és szabványosítása. Ennek eredménye az is, hogy a különböző Linux rendszereken nagyjából egységes könyvtárstruktúrákkal találkozunk. Az LSB a korábban említett POSIX- és SUS-specifikációkat veszi alapul, illetve ezek mellett számos nyílt szabványra is épít. Fejlesztőként az LSB-projekt hatalmas segítséget nyújt nekünk, mivel így egységesen kezelhetjük a Linux rendszert, és a szoftvereink számos disztribúción lesznek működőképesek. A könyv tematikájának kialakításakor törekedtünk arra, hogy lehetőség szerint disztribúciófüggetlenek legyünk. Így a tárgyalt témakörök minden disztribúció esetén alkalmazhatók, illetve a fejlesztői eszközöket is úgy választottuk ki, hogy lehetőleg általánosan elérhetők legyenek. Ám a példaprogramok fordításakor időnként találkozhatunk azzal a problémával, hogy egyes disztribúciók esetében a könyvtárstruktúra eltérhet. Viszont az LSB-t követő disztribúcióknál ennek minimális az esélye.
1.3. Információforrások A Linux története során mindig is kötődött az internethez. Internetes közösség készítette, fejlesztette, terjesztette, így a dokumentációk nagy része is az interneten található. Ezért a legfőbb információforrásnak is az internetet tekinthetjük. A Linux-világ egyik klasszikus információforrása a Linux Documentation Project (Linux dokumentációs projekt, LDP). Az LDP elsődleges feladata magas szintű, ingyenes dokumentációk fejlesztése a GNU/Linux operációs rendszer számára. Céljuk minden Linuxszal kapcsolatos témakör lefedése a megfelelő dokumentumokkal. Bár sok dokumentum nem teljesen aktuális, ennek elle-
8
8
Gyakran elhangzik a kérdés: melyik a legjobb disztribúció? Szerintünk ilyen nincs, csak olyan, amely elég közel áll a felhasználó egyéni igényeihez, és már nem kell olyan sokat dolgoznia rajta, hogy teljesen a saját képére alakítsa. Ha rosszul választ kiindulási alapot, akkor több munkája lesz vele.
1.3. Információforrások
nére is az egyik legjelentősebb információforrásnak tekinthető. Az egyes Linux-disztribútorok sokszor nagyon komoly és jól használható dokumentációt készítenek. Ám ezek a dokumentációk többnyire az adott disztribúció használatáról, adminisztrálásáról szólnak. Szoftverfejlesztésről ritkán találunk dokumentációkat. Az LDP által elkészített dokumentumok a következő címen találhatók meg: http:/ / www.tldp.org / . A kézikönyvoldalak (manual page, rövidítve man page) a Unix klasszikus elektronikus dokumentációs formája, amelyet a Linux is örökölt. Ezek az oldalak eredetileg a man segédprogrammal jeleníthetők meg, de manapság HTML és egyéb formátumban is elérhető. Emiatt magyarul számos elnevezésük van, legtöbben az angol elnevezést használják, írott formában sokszor csak „kézikönyv" használatos. Az első kézikönyveket a Unix szerzői írták a hetvenes évek legelején. A tagolása egységes, a POSIX-szabvány is ezzel a felépítéssel írja le az egyes interfészeket, ezek a leírások kézikönyvek formájában is rendelkezésre állnak — sokszor kiegészítve az adott platformra jellemző további információval, esetleg eltérésekkel. Ez a mai napig az egyik legfontosabb dokumentáció. Az info formátumot a GNU vezette be, ez képes linkeket és egyéb formázásokat is kezelni. A HOWTO-k egy-egy konkrét probléma megoldását nyújtják. Számos formátumban (HTML, PostScript, PDF, egyszerű szöveg) elérhetők. Az útmutató (guide) kifejezés az LDP által készített könyveket jelöli. Ezek egy-egy témakör bővebb kifejtését tartalmazzák. Fejlesztőként a fejlesztői könyvtárak dokumentációinak vehetjük a legnagyobb hasznát. Minden fejlesztői könyvtár honlapján találunk minimálisan egy API-dokumentációt. Gyakran találkozhatunk azonban komoly leírásokkal, gyakorlópéldákkal is. Az LWN (http:/ / lwn.net) a Linux-világ hírlapja. Igyekszik összegyűjteni minden újdonságot a Linux világából. Az általános hírek mellett a Development rovatból tájékozódhatunk a legújabb fejlesztői hírekről, míg a Kernel rovat a kernelfejlesztés iránt érdeklődőknek szolgál friss információkkal. inter Ha a feladatunkkal elakadnánk, számos fórumot találhatunk az , ahol feltehetjük a kérdéseinket. Ráadásul sokszor az is előfordul, hogy mások már feltették a kérdést, és már meg is válaszolták. Így ha elakadunk, akkor gyakran a legnagyobb segítséget a webes keresők jelentik, amelyek megtalálják a kívánt fórumbejegyzéseket, sőt a kézikönyvek és a howtok között is képesek keresni.
9
MÁSODIK FEJEZET
Betekintés a Linuxkernelbe Ebben a fejezetben a Linux-kernel egyes részeivel ismerkedünk meg. A következő ismeretek nem elengedhetetlenek ahhoz, hogy a könyv további fejezeteiben található példaprogramokat közvetlenül felhasználjuk, a fejezetek megértéséhez azonban igen. Ezek a tudnivalók megvilágítják, hogy mi zajlik le a rendszer belső magjában a programunk futása közben, így hasznos háttérinformációt szolgáltathatnak a fejlesztők számára. Ez a fejezet tehát egyfajta áttekintést kíván nyújtani. A további, specializált részekben sokszor részletesebben tárgyaljuk az itt bemutatottakat.
2.1. A Linux-kernel felépítése Egy operációs rendszer magjának strukturális felépítésénél két alapvető szélsőséges választásunk van. Választhatunk a mikrokernel- és a monolitikuskernel-struktúra között. A lényegi különbség a kettő között az, hogy milyen szolgáltatásokat valósítanak meg a felhasználói címtérben, illetve a kernel címterében. A mikrokernel csak a legszükségesebb operációsrendszer-szolgáltatásokat futtatja a kernelben, míg a többit a rugalmasság érdekében a felhasználói címtartományban implementálja. Ezzel szemben a monolitikus kernelben a legtöbb szolgáltatás a kernel részeként fut a nagyobb teljesítmény érdekében. A kettő között félúton a hibrid kernel található. Vegytiszta megoldások egyre kevésbé vannak: a mikrokernel-architektúrákon a jobb teljesítmény miatt bizonyos alacsony szintű szolgáltatásokat áthelyeznek a kernelbe (pl. a grafikusvezérlők). A monolitikus kernel esetében részben a rugalmasság, részben a teljesítménymaximalizálás érdekében tesznek át funkciókat a felhasználói tartományba. A Linux a monolitikuskernel-megközelítéshez áll közelebb, a gyökerei oda nyúlnak vissza.
2. fejezet: Betekintés a Linux-kernelbe
A mikrokernel és monolitikus kernel kategóriától függetlenül a modern operációs rendszerek kernele dinamikusan betölthető modulokból épül fel, így használat közben az igényeknek megfelelően bővíthető vagy csökkenthető. A struktúrák alapjainak megismerése után nézzük meg, milyen részekre oszthatjuk fel a Linux-kernelt. (Ez a felosztás vázlatos, a könnyebb érthetőség érdekében nem tér ki a rendszer minden részletére.)
•
Felhasználói processzek
•
Fejlesztői könyvtárak (pl glibc)
•
Kernel Rendszerhívások
Processzkezelő Állományrendszerek
Ütemező Hálózati réteg
Memóriakezelő
Egyéb alrendszerek
)
IPC
Perifériák kezelése
Hardver 2.1. ábra. A kernel vázlatos felépítése
A felhasználói programok a rendszerhívásokon keresztül kérhetik a kerneltől a kívánt szolgáltatásokat. Ezeket a rendszerhívásokat a programok általában a rendszerkönyvtárak segítségével érik el. A fájlrendszerek magasabb szintű absztrakciót nyújtanak a perifériák kezelésére. Ennek az alrendszernek a segítségével kezelhetünk állományokat, könyvtárakat az egyes eszközökön (ilyen állományrendszerek az ext4, a proc, az MSDOS, az NTFS, az iso-9660, az NFS stb.), de a fájlkezeés műveleteivel férhetünk hozzá például a soros portokhoz is. A hálózati réteg a különböző hálózati protokollok implementációját tartalmazza (IPv4, IPv6 IPX, Ethernet stb.).
12
2.2. A Linux elindulása
A perifériakezelő alrendszer az eszközök alacsony szintű kezelését valósítja meg. Hozzátartozik a háttértárolók, az I/O eszközök, a soros/párhuzamos és az egyéb portok kezelése. A folyamatkezelő alrendszer több, a processzek kezelésével kapcsolatos funkciót valósít meg: •
A Linux többfeladatos (multitask) rendszer, ez azt jelenti, hogy több processzt futtat párhuzamosan (többprocesszoros rendszer) vagy kvázi párhuzamosan, váltogatva (egyprocesszoros rendszer). Azt, hogy az egyes processzek mikor jussanak a processzorhoz, az ütemező dönti el.
•
A processzeknek általában szükségük van arra, hogy egymással kommunikáljanak. Ezt az IPC- (Interprocess Communication, processzek közötti kommunikáció) alrendszer teszi lehetővé.
•
A fizikai memória kiosztását a processzek között a memóriakezelő alrendszer végzi el.
A következő fejezetekben főként a processzkezelő alrendszer egyes részeivel, az ütemezés működésével, illetve a memóriakezelés elméletével ismerkedünk meg. Az IPC-alrendszerre nem térünk ki; lehetőségeit, használatát később tárgyaljuk.
2.2. A Linux elindulása Egy operációs rendszer betöltődése, elindulása első pillantásra mindig kicsit rejtélyes dolognak tűnik. Általában ha egy, a háttértárolón lévő programot szeretnénk betölteni, lefuttatni, akkor beírjuk a nevét a parancsértelmezőbe, vagy rákattintunk az ikonjára, és az operációs rendszer elindítja. Ám hogyan történik mindez, amikor a gép indulásakor magát az operációs rendszert szeretnénk betölteni, elindítani? A Linux-kernel betöltését és elindítását az úgynevezett kernelbetöltő végzi el. Ilyen program a LILO (The Linux Loader), a LOADLIN, a Grub és még sorolhatnánk. A program betöltéséhez szükség van egy kis hardveres segítségre is. Általában a gép csak olvasható memóriájában van egy kis program (az x86-os architektúra esetében ennek a neve BIOS vagy (U)EFI), amely megtalálja, betölti és futtatja ezt a kernelbetöltőt. Vagyis összegezve: egy rövid, beégetett program lefut, és elindít egy valamivel nagyobb betöltőprogramot. Ez a betöltőprogram pedig elindít egy még nagyobb programot, nevezetesen az operációs rendszer kernelprogramját. 9
9
Lehetőség van arra, hogy a betöltőprogramokat még tovább láncoljuk. Ezáltal lehetővé válik a több operációs rendszert tartalmazó gépeken, hogy induláskor kiválaszthassuk a számunkra szükségeset. 13
2. fejezet: Betekintés a Linux-kernelbe
A kernel manapság általában tömörített formában van a lemezeken, és képes önmagát kitömöríteni. Így az első lépés a kernel kitömörítése, majd a kód feldolgozása a kitömörített kernel kezdőcímétől folytatódik. Ezt követi a hardver inicializálása (memóriakezelő, megszakítástáblák stb.), majd az első C-függvény (start_kernel()) meghívása. Ez a függvény, amely egyben a 0-s azonosítójú processz belépési pontja, inicializálja a kernel egyes részeit. Az inicializáció végén a 0-s processz elindít egy kernelszálat (a neve ínit), majd egy üresjárati ciklusba (idle loop) kezd, így a továbbiakban a 0-s processz szerepe már elhanyagolható. Az ínit kernelszálnak vagy processznek a processzazonosítója az 1. Ez a rendszer első igazi processze. Elvégez még néhány beállítást (elindítja a fájlrendszert szinkronizáló és lapcserekezelő folyamatokat, feléleszti a rendszerkonzolt, felcsatolja [mount] a gyökér-állományrendszert [root file system]), majd lefuttatja a rendszerinicializáló programot (nem keverendő a korábban említett processzel). Ez a program az adott disztribúciótól függően a követkevalamelyike: /etc/init, /bin/init, /sbin/init.10 Az ínit program az /etc/ izők ntab1 konfigurációs állomány segítségével új, immár felhasználói processzeket hoz létre, és ezek további új processzeket. Például a getty processz létrehozza a login processzt, amikor a felhasználó bejelentkezik. Ezek a processzek mind az ínit kernelszál leszármazottai.
2.3. Processzek A processz egy működés közbeni program. Ebből adódóan a programkódot és a hozzátartozó erőforrásokat tartalmazza. A processzek meghatározott feladatokat hajtanak végre az operációs rendszeren belül. A feladat leírása az a program, amely gépi kódú utasítások és adatok együtteséből áll. Ezeket a lemezeken tároljuk, így a program önmagában passzív entitás. Ezzel szemben a processz már dinamikus entitás. Folyamatosan változik, ahogy a processzor egymás után futtatja az egyes utasításokat. A program kódja és adatai mellett a processz tartalmazza a programszámlálót, a CPUregisztereket, továbbá a processz vermét, amely az átmeneti adatokat (függvényparaméterek, visszatérési címek, elmentett változók) tárolja.
10
11
Ha egyik helyen sem találja meg a rendszer az ínit programot, akkor megpróbálja feldolgozni az /etc/rc állományt, és elindít egy shellt, hogy a rendszergazda megjavíthassa a rendszert. A Linux rendszereken elterjedőben van az eseményalapú, upstart nevű ínit implementáció. Ez a megoldás a hagyományos System V ínit programhoz képest eltérő konfigurációs megoldásokat alkalmaz.
14
2.3. Processzek A Linux többfeladatos (multitask) operációs rendszer, a processzek saját jogokkal rendelkező elkülönített feladatok (task), amelyeket a Linux párhuzamosan futtat. Egy processz összeomlása nem okozza a rendszer más processzeinek az összeomlását. Minden különálló processz a saját virtuális címtartományában fut, és nem képes más processzekre hatni. Kivételt képeznek ez alól a biztonságos, kernel által kezelt mechanizmusok, amelyekkel a processzek magvalósíthatják az egymás közötti kommunikációt. Életciklusa során egy processz számos rendszererőforrást használhat (CPU, memória, állományok, fizikai eszközök stb.). A Linux feladata az, hogy ezeket a hozzáféréseket könyvelje, kezelje, és igazságosan elossza a konkuráló processzek között.
2.3.1. A Linux-processzekhez kapcsolódó információk A Linux minden processzhez hozzárendel egy leíró adatstruktúrát, 12 az ebben szereplő adatok jellemzik az adott processzt, és befolyásolják a működését. Ez a feladatokat leíró adatstruktúra nagy és komplex, ám felosztható néhány funkcionális területre:
1,
•
Állapot A processz a futása során a körülményektől függően különböző állapotokba kerülhet, ezeket az állapotokat a 2.3.2. alfejezetben tárgyaljuk.
•
Azonosítók A rendszerben minden processznek van egyedi azonosítója. Ezen túl minden processz rendelkezik felhasználói és csoportazonosítókkal. Ezek szabályozzák az állományokhoz és az eszközökhöz való hozzáférést a rendszerben. (Bővebben lásd a 2.3.3. alfejezetben.)
•
Kapcsolatok A Linuxban egyetlen processz sem független a többitől. Az ínit processzt leszámítva minden processznek van szülője. Az új processzek nem létrejönnek, hanem a korábbiakból másolódnak, klónozódnak. Minden processzleíró adatstruktúra tartalmaz hivatkozásokat a szülőprocesszre és a leszármazottakra. Ezt a kapcsolódási fát a pstree paranccsal nézhetjük meg.
•
Ütemezési információ Az ütemezőnek szüksége van bizonyos információkra: prioritás, statisztikai információk stb., hogy igazságosan dönthessen, hogy melyik processz kerüljön sorra. Az ütemezést a 2.3.6. alfejezet tárgyalja.
A leíró adatstruktúra típusa megtalálható a kernelforrásban. A neve task_struct, és az include/linux/sched.h állományban található.
15
2. fejezet: Betekintés a Linux-kernelbe
16
•
Memóriainformációk A processz memóriafoglalásával kapcsolatos információk (kód-, adat-, veremszegmensek) tartoznak ide.
•
Fájlrendszer A processzek megnyithatnak és bezárhatnak állományokat. A processzleíró adatstruktúra tartalmazza az összes megnyitott állomány leíróját, továbbá az aktuális, úgynevezett munkakönyvtárának (working directory) a mutatóját.
•
Processzek közötti kommunikáció A Linux támogatja a klasszikus Unix IPC-mechanizmusokat (jelzések, csővezetékek, szemaforok) és a System V IPC-mechanizmusokat is (megosztott memória, szemafor, üzenetsor). Egy kifejezetten a Unix operációs rendszerekre jellemző processzek közti aszinkron kommunikációs forma a jelzés (signal). A jelzést küldő processz nem várakozik a kézbesítésre, küldés után folytatja futását. Jelzés érkezésekor a fogadó processz normál futása megszakad, és vagy a processz által megadott, vagy az alapértelmezett jelzéskezelő függvény fut le. Ezek után a processz ott folytatja a futását, ahol abbahagyta. A futás megszakítását, a jelzéskezelő futtatását, majd a futás folytatását a kernel teljes mértékben kezeli. A jelzéshez tartozik egy egész szám, amely a jelzés kiváltásának okát adja meg. Ezt az egész számot szimbolikus konstanssal adjuk meg, értéke a signal.h állományban található. A szimbolikus konstansok SIG előtaggal kezdődnek. Például nullával való osztás esetén a kernel SIGFPE jelzést küld a műveletet futtató processznek, illetve akkor, ha egy processz futását azonnal meg szeretnénk szakítani, SIGKILL jelzést küldünk neki.
•
Idő és időzítők A kernel naplózza a processzek létrehozási és CPU-felhasználási idejét. A Linux támogatja továbbá az intervallumidőzítők használatát a processzekben, amelyeket beállítva jelzéseket kaphatunk bizonyos idő elteltével. Ezek lehetnek egyszeri vagy periodikusan ismétlődő értesítések. (Bővebben lásd a 2.3.8. alfejezetben.)
•
Processzorspecifikus adatok (Context) A folyamat a futása során használja a processzor regisztereit, a vermet stb. Ez a processz környezete, és amikor feladatváltásra kerül sor, ezeket az adatokat le kell menteni a processzt leíró adatstruktúrába. Amikor a processz újraindul, innen állítódnak vissza az adatok.
2.3. Processzek
2.3.2. A processz állapotai A futás során a processzek különböző állapotokba juthatnak. Az állapot függhet a processz aktuális teendőjétől és a külső hatásoktól. A processz aktuális állapotát a processzhez rendelt leíró struktúra állapotleíró változója tárolja. Linux alatt egy processz lehetséges állapotai a következők: •
A processz éppen fut az egyik processzoron. (Az állapotváltozó értéke:
RUNNING.) •
A processz futásra kész, de másik foglalja a processzort, ezért várakozik a listában. (Az állapotváltozó értéke ilyenkor is: RUNNING.)
•
A processz egy erőforrásra vagy eseményre várakozik. Ilyenkor attól függően kap értéket az állapotváltozó, hogy a várakozást megszakíthatja egy jelzés (INTERRUPTABLE), vagy sem (UNINTERUPTABLE). A jelzéssel nem megszakítható állapot egyik alesete az, amikor a kritikus, a folyamat leállását eredményező jelzések még megszakíthatják a várakozást (KILLABLE).
•
A processz felfüggesztve án, általában a SIGSTOP jelzés következtében. Ez az állapot hibajavításkor jellemző, de a terminálban futtatott folyamatnál a CTRL + Z billentyű kombinációval szintén elérhetjük. (Az állapotváltozó értéke: STOPPED.)
•
A processz kész az elmúlásra (már nem élő, de még nem halott: zombi), de valami oknál fogva még mindig foglalja a leíró adat struktúráját. (Az állapotváltozó értéke: ZOMBIE.)
Az állapotok kapcsolatait a 2.2. ábra szemlélteti:
Esemény bekövetkezik
Eseményre vár Futás
Létrehozás
vége
Jelzés
Jelzés
Megszűnik 2.2.
ábra.
A processz állapotatmenet - diagramja
17
2. fejezet: Betekintés a Linux-kernelbe
A processzeket a fenti állapotdiagram szerint az ütemező (scheduler) kezeli. Az ütemezési algoritmusokat és a processzekhez tartozó adatterületeket a 2.3.6. alfejezetben tárgyaljuk bővebben.
2.3.3. Azonosítók A Linux-kernel minden processzhez egy egyedi azonosítót rendel: pid (process ID). Később ezzel az azonosítószámmal hivatkozhatunk a processzre. Minden folyamat egy processzcsoport tagja, amelynek azonosítóját a pgid (process group ID) mező tartalmazza. Amikor a folyamat létrejön, a szülőfolyamat pgid azonosítóját örökli, ám ez később módosítható. A processzcsoport arra használható, hogy tagjainak egyszerre küldjünk jelzéseket, vagy valamelyik tagjának befejeződésére várakozzunk. A konvenció szerint a pgid azonosító számértéke a csoport első tagjának pid értékével egyezik meg. Új csoportot is úgy tudunk létrehozni, ha a pgid értékét a folyamat pid értékére állítjuk. Ekkor a folyamatunk a csoport vezetője lesz. A csoport vezetőjének szerepe annyiban speciális, hogy ha véget ér, akkor a csoport többi tagja egy SIGHUP jelzést kap. (Bővebben lásd az 5.6. Jelzések című alfejezetben.) A jelzés hatására a folyamatok dönthetnek arról, hogy leállnak-e (alapértelmezett), vagy folytatják a futásukat. Több processzcsoport összekombinálható egy munkamenetté (session). Minden folyamatnak a munkameneten belül azonos a sessionid értéke. Linux alatt a szálak speciális folyamatoknak számítanak. Ezért fontos a rendszer számára annak könyvelése, hogy mely szálak tartoznak egybe, egy folyamathoz. Az összetartozó szálakat a szálcsoportok tartalmazzák. A folyamat szálcsoport-azonosítója a tgid. A Linux, mint minden más Unix rendszer, felhasználói- és csoportazonosítókat használ az állományok hozzáférési jogosultságának az ellenőrzésére. A Linux rendszerben minden állománynak van tulajdonosa és jogosultsági beállításai. A legegyszerűbb jogok a read, a write és az execute. Ezeket rendeljük hozzá a felhasználók három osztályához: ezek a tulajdonos, a csoport és a rendszer többi felhasználója. A felhasználók mindhárom osztályára külön beállítható mindhárom jog. Természetesen ezek a jogok valójában nem a felhasználóra, hanem a felhasználó azonosítójával futó processzekre érvényesek. Ebből következően a processzek az alábbi azonosítókkal rendelkeznek:
18
•
uid, gid A felhasználó felhasználói és csoportazonosítói, amelyekkel a processz fut.
•
Effektív uid és gid Ezeket az azonosítókat használja a rendszer annak vizsgálatára, hogy a processz hozzáférhet-e az állományhoz. Különbözhetnek a valódi felhasználótól és csoporttól. Ezeket a programokat nevezzük setuidos,
2.3. Processzek
illetve csoportállítás esetén setgides programoknak. Segítségükkel korlátozott hozzáférést nyújthatunk a rendszer egyes védett, csak a rendszergazda számára hozzáférhető részeihez. •
Fájlrendszer-uid és gid Ezek normál esetben megegyeznek a valódi azonosítókkal, és a fájlrendszerhez való hozzáférési jogosultságokat szabályozzák. Elsősorban az NFS-fájlrendszereknél 13 van rájuk szükség, amikor a felhasználói üzemmódú NFS-szervernek különböző állományokhoz kell hozzáférnie az adott felhasználó nevében. Ebben az esetben csak a fájlrendszer-azonosítók változnak.
•
Mentett uid és gid Olyan programok használják, amelyek a processz azonosítóit rendszerhívások által megváltoztatják. A valódi uid és gid elmentésére szolgálnak, hogy később visszaállíthatók legyenek.
-
2.3.4. Processzek létrehozása és terminálása Új processzt egy korábbi processz lemásolásával hozhatunk létre. Ez a fork rendszerhívással valósítható meg. A másolat teljesen megegyezik az eredetivel, csak a processzazonosítóban különböznek. Az eredeti processzt szülőprocessznek, a másolatot, a leszármazottat gyerekprocessznek nevezzük. Természetesen, ha később az egyes processzek módosítanak a belső változóikon, akkor ez a másik processznél már nem érvényesül. A valóságban a gyerekfolyamat létrehozásakor nem történik tényleges másolás. Helyette a Linux a COW metódust használja (lásd később a 2.4.7. alfejezetben). Ha egy program egy másik programot akar futtatni, akkor sincs más út: a processznek le kell másolnia magát, és a gyerekprocessz tölti be a futtatandó másik programot. Ezért gyakori az a forgatókönyv, hogy az új folyamat létrehozása után egy rendszerhívással azonnal betöltünk egy új programot, és azt futtatjuk. Ilyenkor az eredeti folyamat memóriájának a lemásolása felesleges. Erre az esetre szolgál a vfork függvény, amelyben a memória másolása helyett a rendszer megosztja a memóriát a szülő és a gyerek között addig, amíg a gyerek betölt egy új programkódot. Erre az átmeneti időre a szülőfolyamat blokkolódik. Amióta a Linux a COW metódust használja a fork rendszerhívásnál, azóta a vfork előnye valójában már elhanyagolható, így nem jellemző a használata.
13
Az NFS (Network File System) elsősorban Unix rendszereknél használt hálózati fájlrendszer. 19
2. fejezet: Betekintés a Linux-kernelbe
A Linux mind a fork, mind a vfork rendszerhívást valójában a clone rendszerhívással valósítja meg. A clone létrehoz egy új folyamatot a szülőfolyamatból, és lehetővé teszi, hogy megadjunk, mely memóriaterületeken osztozzon a két folyamat. Mint látható, a fork és a vfork függvények valójában a clone specializált esetei. A clone teszi lehetővé a kernelszintű szálkezelés implementációját is Linux alatt. 14 A gyerekfolyamat a szülő teljes mása, csak a pid és a ppid értékekben tér el tőle. Ezen kívül nem öröklődnek még az állományzárolások és az aktuális jelzések. Ha a gyerekfolyamat a programkódjának a végére ért, vagy termináló jellegű jelzést kap, akkor ezt a tényt egy SIGCHLD jelzéssel jelzi a szülő számára, befejezi a futását, és zombi állapotba kerül. Addig zombi állapotban marad, amíg a szülőjének átadja az eredményét. Ameddig a szülő ezt nem veszi át tőle, addig a gyerekfolyamat zombi állapotban várakozik. Felvetődik a kérdés, mi történik akkor, ha a szülő fejezi be hamarabb a futását, és nem a gyerek. A gyerek ilyenkor árva lesz, és az ínit veszi át a szülő szerepét. Amikor a gyerekfolyamat végzett, akkor az ínit átveszi tőle a viszszatérési értékét, így teljesen megszűnhet.
2.3.5. A programok futtatása A Linuxban, mint a többi Unix rendszerben, a programokat és a parancsokat általában a parancsértelmező futtatja. A parancsértelmező egy felhasználói processz, amelynek a neve shell. Linuxos rendszereken több parancsértelmező közül választhatunk (sh, bash, tcsh stb.). A parancsértelmezők tartalmaznak néhány beépített parancsot. A többi begépelt utasítást mint programnevet értelmezik. A parancsként megadott állománynévvel keresnek egy futtatható állományt a PATH környezeti változó által megadott könyvtárakban. Ha megtalálják, akkor betöltik és lefuttatják. A parancsértelmező a fent említett fork metódussal lemásolja magát, és a megtalált állomány az új, leszármazott processzben fut. Normál esetben a parancsértelmező megvárja a gyerekprocessz futásának a végét, és csak ezután adja vissza a vezérlést a felhasználónak, de lehetőség van a processzt a háttérben is futtatni. A futtatható fájl többféle bináris vagy szöveges parancsállomány (script) lehet. A parancsállományt az első sorában megadott program (amely általában egy parancsértelmező), ennek hiányában az éppen használt parancsértelmező értelmezi.
14
Mint látható, Linux alatt a szálak nincsenek szigorúan megkülönböztetve a processzektől . A szálak valójában közös memórián osztozó processzek saját processzazonosítóval.
20
2.3. Processzek
A bináris állományok információkat tartalmaznak az operációs rendszer számára, hogy értelmezni és futtatni tudja őket, továbbá tartalmazzák a programkódot és az adatokat. A Linux alatt a leggyakrabban használt bináris formátum az ELF. 15 A támogatott bináris formátumokat a kernel fordításakor választhatjuk ki, vagy utólag modulként illeszthetjük be az értelmezésüket. Az általánosan használt formátumok az a.out, az ELF, de például a Java class fájlok felismerését is bekapcsolhatjuk.
2.3.6. Ütemezés A Linux rendszerek párhuzamosan több folyamatot futtathatnak. Ritka az az eset, amikor a processzorok számát nem haladja meg a folyamatok száma. Ezért, hogy az egyes processzek többé-kevésbé párhuzamosan futhassanak, az operációs rendszernek folyamatosan váltogatnia kell, hogy melyik processz kapja meg a processzort. Az ütemező feladata az, hogy szabályozza a proceszszoridő kiosztását az egyes folyamatok számára. Ahhoz, hogy az ütemezés felhasználói szempontból kényelmes legyen, szükség van arra, hogy a felhasználó az egyes folyamatok prioritását szabályozhassa, és az ő szemszögéből nézve a processzek párhuzamosan fussanak. A rossz ütemezési algoritmus az operációs rendszert döcögőssé, lassúvá teheti, az egyes processzek túlzott hatással lehetnek egymásra a processzoridő elosztása során. Ezért a Linux fejlesztői nagy figyelmet fordítottak az ütemező kialakítására. A processzek váltogatásával kapcsolatban két kérdés merül fel. •
Mikor cseréljünk le egy processzt?
•
Ha már eldöntöttük, hogy lecserélünk egy processzt, melyik legyen az a processz, amelyik a következő lépésben megkaphatja a CPU-t?
Ezekre a kérdésekre adunk választ a továbbiakban.
2.3.6.1. Klasszikus ütemezés A klasszikus Linux-ütemezés időosztásos rendszert használ. Ez azt jelenti, hogy az ütemező az időt kis szeletekre osztja (time slice), és ezekben az időszeletekben valamilyen kiválasztó algoritmus alapján adja meg az egyes processzeknek a futás lehetőségét. Ám az egyes processzeknek nem kell kihasználniuk az egész -
15
Az ELF (Executable and Linkable Format, futtatható és linkelhető formátum) bináris formátumot eredetileg a System V Release 4 UNIX-verzióban vezették be. Később a Linux fejlesztői is átvették, mert a belső felépítése sokkal flexibilisebb, mint a régebbi a.out formátum. Ezen kívül egy hatékony debug formátuma is létezik, amelynek neve DWARF (Debugging With Attribute Record Format), amely dinamikusan egy láncolt listában tárolja a hibakereséshez szükséges információt. 21
2. fejezet: Betekintés a Linux-kernelbe
időszeletet, akár le is mondhatnak a processzorról, ha éppen valamilyen rendszereseményre kell várakozniuk. Ezek a várakozások a rendszerhívásokon belül vannak, amikor a processz éppen kernelüzemmódban fut. 16 A Linuxban a nem futó processzeknek nincs előjoga az éppen futó processzekkel szemben, nem szakíthatják meg a futó folyamatokat annak érdekében, hogy átvegyék a processzort. Vagyis az időszeleten belül csak a futó folyamatok mondhatnak le a processzorról önkéntes alapon, és adhatják át egymásnak a futás lehetőségét. A fenti két alapelv gondoskodik arról, hogy megfelelő időpontban következzen be a váltás. A másik feladat a következő processz kiválasztása a futásra készek közül. Ezt a választási algoritmust a kernelen belül a schedule() függvény implementálja. A klasszikus ütemezés három stratégiát (policy) támogat: a szokásos Unix ütemezési módszert és két, valós idejű (real-time) processzek ütemezésére szolgáló algoritmust. A valós idejű processz a szokásos értelmezés szerint azt jelentené, hogy az operációs rendszer garantálja, hogy az adott processz egy megadott, rövid időn belül megkapja a processzort, amikor szüksége van rá, így reagálhat a külső eseményekre. Ezt hívjuk „hard real-time"-nak. Ám a Linux csak egy úgynevezett „soft real-time" megközelítést támogat, amely a valós idejű processzeket a kiválasztás során előre veszi, így a lehetőség szerinti legkisebb késleltetéssel juttatja processzorhoz. Míg a klasszikus ütemezésnél jelentős az eltérés a Linux soft real-time megoldása és egy hard real-time rendszer között, a manapság használatos megoldásoknál már nem olyan éles a határvonal. (Ezt a témakört részletesen lásd a 2.3.8. alfejezetben.) A Linux a következő futtatandó processz kiválasztásához prioritásos ütemezőalgoritmust használ. A normál processzek két prioritásértékkel rendelkeznek: statikus és dinamikus prioritással. A valós idejű processzekhez a Linux tárol még egy prioritásértéket is, a valós idejű prioritást (real time priority). Ezek a prioritásértékek egyszerű egész számok, amelyeknek a segítségével a kiválasztóalgoritmus súlyozza az egyes processzeket. • A statikus prioritás A névben szereplő statikus szó jelentése az, hogy értéke nem változik az idő függvényében, csak a felhasználó módosíthatja. A processzinformációk között a neve nice, amely arra utal, hogy az ütemező milyen „kedves" lesz a processzel, és mennyire kínálja processzoridővel.
16
Minden processz részben felhasználói üzemmódban, részben kernelüzemmódban fut. A felhasználói üzemmódban jóval kevesebb lehetősége van a processznek, mint kernelüzemmódban, ezért bizonyos erőforrások, rendszerszolgáltatások igénybevételéhez át kell kapcsolnia. Ilyenkor az átkapcsoláshoz a processz egy rendszerhívást hajt végre (a Linux felhasználói kézikönyv [manual] 2. szekciója tartalmazza ezeket, például: read()). Ezen a ponton a kernel futtatja a processz további részét.
22
2.3. Processzek
•
A dinamikus prioritás A dinamikus prioritás lényegében egy számláló, értéke a processz futásának függvényében változik. Segítségével az ütemező nyilvántartja, hogy a processz mennyi futási időt használt el a neki kiutaltból. Ha például egy adott processz sokáig nem jutott hozzá a CPU-hoz, akkor a dinamikus prioritási értéke magas lesz. A processzinformációk között a neve counter, vagyis számláló.
•
A valós idejű prioritás Jelzi, hogy a processz valós idejű, így minden normál processzt háttérbe szorít a választáskor. További funkciója, hogy a valós idejű processzek közötti prioritásviszonyt megmutassa. A processzinformációk között a neve rt_priority.
Normál processzek esetében a Linux ütemezési algoritmusa az időt korszakokra (epoch) bontja. A korszak elején minden processz kap egy meghatározott mennyiségű időegységet, vagyis a kernel a számlálóját egy, a statikus prioritásából meghatározott értékre állítja Amikor egy processz fut, ezeket az időegységeket használja el, vagyis a számlálója csökken. Ha elfogyott az időegysége, akkor már csak a következő ciklusban futhat legközelebb. Egy korszaknak akkor van vége, amikor minden RUNNING állapotú processznek elfogyott az időegysége (tehát a várakozó processzek nem számítanak). Ilyenkor új korszak kezdődik a processzek életében. Ezzel a módszerrel elérhető, hogy ésszerű időn belül minden processz kapjon több-kevesebb futásidőt, vagyis egyiket se éheztessük ki. A FIFO valós idejű ütemezés esetén csak a valós idejű prioritásnak van szerepe az ütemezésben. A legnagyobb prioritású processzek közül a sorban a legelső futhat egészen addig, amíg át nem adja másnak a futás lehetőségét (várakoznia kell, vagy véget ért), vagy nagyobb prioritású processz nem igényli a processzort. A körbeforgó (round-robin) valós idejű ütemezési algoritmus a FIFO továbbfejlesztett változata. Működése hasonlít a FIFO ütemezéshez, ám egy processz csak addig futhat, amíg a neki kiutalt időegysége el nem fogy, vagyis a számlálójának az értéke el nem éri a 0-t. Ilyenkor a sor végére kerül, és a rendszer megint kiutal számára időegységeket. Ezáltal lehetővé teszi a CPU igazságos elosztását az azonos valós idejű prioritással rendelkező processzek között.
2.3.6.2. Az 0(1) ütemezés Az 0(1) ütemezés a 2.6-os kernellel együtt jelent meg. A Java virtuális gépek miatt volt rá szükség a sok párhuzamosan futó szál következtében. Mivel a korábbi ütemezőalgoritmus futási ideje egyenes arányban állt a processzek/ szálak számával, ezért nagy mennyiségű szál esetén a hatékonysága jelentősen csökkent. Az 0(1) ütemező erre a problémára jelentett megoldást, mivel az ütemezési algoritmus futásideje nem növekszik a processzek számával. Ezt a Linux úgy oldja meg, hogy prioritási szintekbe rendezi a processzeket, amely 23
2. fejezet: Betekintés a Linux-kernelbe
egy kétszeresen láncolt lista. Egy bittérkép alapján az ütemező nagyon gyorsan meg tudja találni azt a legmagasabb prioritási szintet, ahol processz várakozik. Ezután az ezen a prioritási szinten elhelyezkedő első processzt kell futtatnia. A bittérkép mérete csak a prioritási szintek számától függ, a processzek számától nem.
2.3.6.3. Teljesen igazságos ütemező A teljesen igazságos ütemező (Completely Fair Scheduler, CFS) a 2.6.23-as kerneltől kezdődően az általános kernel alapértelmezett ütemezőalgoritmusa. A klasszikus ütemezési algoritmushoz képest az egyik legjelentősebb eltérése, hogy a futási idő arányára koncentrál. A CFS olyan körkörös prioritású ütemező, amely a processzek időszeletének arányát próbálja igazságossá tenni. Ideális esetben, ha van N darab processzünk, mindegyik a rendelkezésre álló idő 1 /N-ed részében fut. Ezt a számítást egy adott időtartamra kalkuláljuk ki, a neve céllappangási idő (target latency). Ha ez az idő 30 ms, és három processzünk van, N = 3, mindegyik processzre 1/3 arányban jut idő, vagyis mindegyik 10 ms-ig fut. A processzek közötti váltás időigénye elhanyagolható. Ha azonban túl sok processz fut a rendszerben, tételezzük fel, hogy 100, akkor 0,3 ms-onként kellene váltani, ez pedig már nem kifizetődő. Ezért ennek az algoritmusnak van egy minimális felbontása (minimum granularity), amely alá nem megy az ütemezés. Ha ez az érték 1 ms, akkor mind a 100 processz 1 ms-ig fut. Természetesen ebben az esetben az algoritmus már nem is nevezhető igazságosnak még az időarány tekintetében sem. A nice értékek abszolút értéke nem számít, a relatív értékek alapján az algoritmus egy arányszámot számol. Két processz esetén nulla, és egy 5 nice értékű ugyanolyan arányban fut, mint egy 10 és 15 értékű. Az algoritmus implementációja azt az időt tárolja, amennyit a processz legutoljára futott, pontosabban ezt még elosztja a processzek számával. Ezt az értéket virtuális futásidőnek (virtual runtime) nevezzük, amely nanoszekundum felbontású. Ezt az értéket az ütemező folyamatosan karbantartja. Ideális esetben ez az érték ugyanannyi lenne minden azonos prioritású processz esetében. Egy valós processzornál ezt úgy lehet ellensúlyozni, hogy mindig a legkisebb virtuális futásidejű processzt futtatjuk. A CFS is pontosan így tesz. Ahhoz, hogy megtalálja a legkisebb értéket, a várakozási listát egy teljesen kiegyensúlyozott piros-fekete bináris fában tárolja. Ennek a leggyakrabban használt bal oldali elemét gyorsítótárazza is a hatékonyság érdekében. Az ütemezés ennél az algoritmusnál O(log n) komplexitású, ám tényleges értéke kisebb, mint a korábbi ütemezőnél volt. A taszkok váltása konstans idő, viszont a leváltott taszk beillesztése a bináris fába O(log n) időt igényel. Az ütemező további újítása a korábbakkal szemben, hogy az ütemezési stratégiák implementációja moduláris felépítésű lett. Így szabadon bővíthető további algoritmusokkal. Ugyanakkor tartalmaz egy új stratégiát is, amelynek neve „batch". Ez lehetővé teszi olyan alkalmazások futtatását, amelyeket
24
2.3. Processzek
hosszabban érdemes futtatni a processzoron, ugyanakkor nem igénylik a gyakori meghívást, mert nem kell felhasználói eseményekre reagálniuk. Ezek többnyire szerveralkalmazások. A CFS-ütemezőben is az történik, hogy a valós idejű folyamatok megelőzik a normál ütemezésűeket. Ugyanakkor, mivel nincsenek időszeletek, ezért a rendszer szinte azonnal válthat, ha egy valós idejű folyamat futásra késszé válik. A gyorsítótárazás miatt a taszkváltás is rövid idő alatt végrehajtódik.
2.3.6.4. Multiprocesszoros ütemezés Szimmetrikus multiprocesszoros (SMP) környezetben az ütemezési metódust kicsit módosítani kell. Ilyenkor minden processzor külön, saját magának futtatja az ütemező funkciót, ám a processzoroknak célszerű információkat cserélniük a rendszer teljesítményének a növelése érdekében. Amikor az ütemező kiszámolja az adott processz súlyozását, azt is figyelembe kell vennie, hogy korábban a processz ugyanazon a processzoron futott-e, vagy egy másikon. Azok a processzek, amelyek az adott CPU-n futottak, mindig előnyt élveznek, mivel a CPU-hardver gyorsítótára még mindig tartalmazhat rájuk vonatkozó információkat. Ezzel a módszerrel az operációs rendszer növelni tudja a találatok számát a gyorsítótárakban. Ezzel kapcsolatban azonban felvetődik egy probléma. Tegyük fel, hogy az ütemező találkozik azzal az esettel, hogy egy processz prioritása nagyobb, mint a többié, ám korábban egy másik processzoron futott. Ilyenkor vagy elveszítjük gyorsítótárakban visszamaradó információk lehetőségét, vagy nem használjuk ki az SMP-architektúrából adódó lehetőséget, hogy a ráérő processzoron futtassuk a processzt. A Linux-SMP a dilemma feloldására egy adaptív empirikus szabályt alkalmaz. Ez egy olyan kompromisszum, amely függ a processzorok gyorsítótárának a méretétől. Ha a gyorsítótár nagyobb, akkor a rendszer jobban ragaszkodik ahhoz, hogy egy processz mindig ugyanazon a processzoron fusson.
2.3.7. Megszakításkezelés A Linuxban két megszakításkezelő-típust különböztetünk meg. A „gyors" megszakításkezelő letiltja a megszakításokat, ezért gyorsra kell elkészítenünk. A „lassú" megszakításkezelő nem tiltja le a megszakításokat, mert az általa igényelt hosszabb futásidőre ez nem lenne jó ötlet. Azért, hogy a megszakításkezelő rutinok gyorsan lefuthassanak, a Linuxfejlesztők egy olyan megoldást alkalmaznak, amely a feladatot két részre választja szét: • Felső rész (Top half) Ez a tényleges megszakításkezelő rutin. A feladata az, hogy az adatokat gyorsan letárolja az utólagos feldolgozáshoz, majd bejegyezze a másik fél futtatására vonatkozó kérelmét. 25
2. fejezet: Betekintés a Linux-kernelbe
•
Alsó rész (Bottom hal}) Ez a rész ténylegesen már nem a megszakításkezelőben fut le, hanem utána kicsivel. A komolyabb, időigényesebb számításokat itt végezzük el. Technikailag két eszköz közül választhatunk az alsó rész implementációja során: kisfeladat (Tasklet), munkasor (Work queue).
2.3.8. Valósidejűség Jelenleg már aránylag kevés eltérés van a normál és a valós idejű kernel között. Így nem kritikus helyeken a normál kernelt is nyugodtan választhatjuk a beágyazott rendszereinkhez Nézzük meg, melyek azok az elemek, amelyek mindezt lehetővé teszik. •
Mint láthattuk, a CFS-ütemező nanoszekundum felbontású, így nagyon gyors reakciót tesz lehetővé, és a taszkváltás is konstans időt igényel.
•
A kernelszálak a 2.6-os kernelben megszakíthatók, így egy hosszabb művelet sem tudja lefogni a processzort.
•
A szinkronizálásokat optimalizálták a kernelben, hogy a lehető legkevésbé akadályozzák egymást a futó processzek.
A valós idejű (RT) kernelt a normál kernelből egy javítófolt- (patch) halmaz segítségével állíthatjuk elő. A valós idejű kernel az alábbi további funkciókkal rendelkezik: •
Tartalmaz egy direkt hozzáférési lehetőséget a fizikai memóriához.
•
Tartalmaz néhány memóriakezelésbeli módosítást.
•
A gyenge pontok felderítésére pedig tartalmaz egy késleltetésmonitorozó eszközt (Latency tracer).
2.3.9. Idő és időzítők A kernel könyveli a processzek létrehozási időpontját, és az életük során felhasznált CPU-időt. Ezeknek az időknek a mértékegysége történelmileg a jiffy (pillanat), amelynek normál mértékegységben értelmezett értéke a kernel beállításától függ. A rendszer könyveli a processznek a kernel-, illetve felhasználói üzemmódban töltött idejét. Ezek mellett a könyvelések mellett a Linux támogatja az intervallumidőzítőket is. A processz ezeket felhasználhatja, hogy különböző jelzéseket küldessen magának, amikor lejárnak. Háromféle intervallumidőzítőt különböztetünk meg:
26
2.4. Memóriakezelés
•
Real Az időzítő valós időben dolgozik, és lejártakor egy SIGALRM jelzést küld.
•
Virtual Az időzítő csak akkor működik, amikor a processz fut, és lejártakor egy SIGVTALRM jelzést küld.
•
Profile Az időzítő egyrészt a processz futási idejében működik, másrészt akkor, amikor a rendszer a processzhez tartozó műveleteket hajt végre. Lejártakor egy SIGPROF jelzést küld. Elsősorban arra használják, hogy lemérjék, a processz mennyi időt tölt a felhasználói, illetve a kernelüzemmódban.
Egy vagy akár több időzítőt is használhatunk egyszerre. Beállíthatjuk őket egy-egy jelzésre vagy ismétlődőre is. A Linux kezeli az összes szükséges információt a processz adatstruktúrájában. Rendszerhívásokkal konfigurálhatjuk, indíthatjuk, leállíthatjuk és olvashatjuk őket.
2.4. Memóriakezelés A memória a CPU után a másik legfontosabb erőforrás, így a memóriakezelő alrendszer az operációs rendszerek egyik legfontosabb eleme.
2.4.1. A virtuálismemória-kezelés A kezdetek óta gyakran felmerül a probléma, hogy több memóriára van szükségünk, mint amennyit a gépünk fizikailag tartalmaz. Ugyanakkor a rendelkezésünkre áll a merevlemez, amely ugyan lassabban kezelhető, de nagy kapacitással rendelkezik. Ezért jó lenne, hogy amikor kifutunk a szabad memóriaterületből, akkor a memória egyes, nem használt részeit átmenetileg a merevlemezre helyezhetnénk, így felszabadulhatna a szükséges hely a programok számára. A virtuálismemória-kezelés az a módszer, amely ezt biztosítja számunkra• a memória és a merevlemez felhasználásával nagyobb memóriaterület elérését teszi lehetővé a processzeknek, mint amennyi szabad fizikai memória valójában rendelkezésünkre áll. Mindezt ráadásul úgy oldjuk meg, hogy a processz számára az egész memóriaterület egységesen kezelhető és átlátszó legyen.
27
2. fejezet: Betekintés a Linux-kernelbe
2.4.2. Lapozás A virtuálismemória-kezelés megvalósításának egyik, jelenleg legelterjedtebben használt módszere a memórialapozás technikája. A rendszer memóriáját tartományokra, úgynevezett lapokra (page) osztjuk. Ezeket a lapokat a kernel egymástól függetlenül mozgathatja a memória és a merevlemez között. Természetesen a lapok kezelése többletadminisztrációval jár. A rendszernek nyilván kell tartania, hogy az egyes lapok éppen hol helyezkednek el. Szükség esetén a lapokat ki kell írnia a háttértárolóra, vagy betöltenie, és az egészet le kell képeznie a processz számára használható formára. De a lapozás a memóriakorlátok leküzdésénél többet is nyújt. A lapszervezésű memóriát védelmi célokra is felhasználhatjuk. A rendszerben minden processz saját virtuálismemória-területtel rendelkezik. Ezek a címtartományok teljesen elkülönülnek egymástól, így az egyik futó folyamatnak nem lehet hatása a másikra. Továbbá a hardver virtuálismemória-kezelő mechanizmusa lehetővé teszi, hogy a lapokhoz védelmi attribútumokat rendeljünk, így egyes memóriaterületeket teljesen írásvédetté tehessünk. Nézzük meg, hogyan is működik a lapozás (paging) módszere a gyakorlatban. A 2.3 ábra egy egyszerűsített példát mutat be, amelyben két processz virtuálismemória-területét képezzük le a fizikai memóriára. (A Linux ennél bonyolultabb, többlépcsős leképezést használ, erről később lesz szó.) A processz a működése során folyamatosan használja a memóriát. Ott található a kódja, amelyeket a processzor kiolvas és végrehajt, de ott tárolja az adatait is. Ezek az adatok mind a memória egy-egy tárolóegységében helyezkednek el, amelyekre címekkel hivatkozhatunk. A virtuális memória használatakor ezek a címek mind virtuális címek a virtuális memóriában. (Vagyis minden processznek van egy saját „virtuális birodalma".) Ezeket a virtuális címeket a memóriakezelő egység (Memory Management Unit, MMU) alakítja fizikai címekké. Az MMU a korszerű rendszereknél a processzor része. Az átalakítást az operációs rendszer által karbantartott információs táblák alapján végzi el, amelyeknek neve laptábla. Ennek a folyamatnak a megkönnyítésére a virtuális és a fizikai memória egyenlő méretű (Intel x86-os architektúra esetén 4 kB-os) lapokra tagolódik. Minden lap rendelkezik egy saját egyedi lapazonosító számmal (Page Frame Number). Példánkban a virtuális cím két részből tevődik össze. Egy eltolásból (offset) és egy lapazonosító számból (Page Frame Number, PFN). Ha a lapok mérete 4 kB, akkor az alsó 12 bit adja az eltolást, a felette lévő bitek a lap számát. Minden alkalommal, amikor a processzor egy virtuális címet kap, szétválasztja ezeket a részeket, majd a virtuális lapazonosítót megkeresi a laptáblában, és átkonvertálja a lap fizikai kezdőcímére. Ezek után az eltolás segítségével a laptábla alapján már megtalálja a memóriában a kérdéses fizikai címet.
28
2.4. Memóriakezelés Processz Y
Processz X VPFN 7 VPFN 6
VPFN 7
Processz Y Lap tábla
Processz X Lap tábla
-411—
VPFN 5
VPFN 5 VPFN 4
PFN 4
VPFN 4
VPFN 3
PFN 3
VPFN 3
VPFN 2
PFN 2
VPFN 2
VPFN 1
PFN 1
VPFN 1
VPFN 0
PFN 0
VPFN 0
VIRTUÁLIS MEMÓRIA
FIZIKAI MEMÓRIA
VIRTUÁLIS MEMÓRIA
2.3. ábra. A virtuális memória leképezése fizikai memóriára VPFN laptáblaptábA példánkban (2.3. ábra) két processz van, és mindkét processz saját lával rendelkezik. Ezek a laptáblák az adott processz virtuális lapjait a melával mória fizikai lapjaira képezik le. ttribútumokat tartalmazza: Minden laptáblabejegyzés az a
•
Az adott táblabejegyzés érvényes-e.
•
A fizikai lapazonosító (PFN).
•
Hozzáférési információ: az adott lap kezelésével kapcsolatos információk, írható-e, kód vagy adat.
2.4.3. A lapozás implementációja a Linuxon A virtuális cím leképezését fizikai címmé az MMU végzi. Az MMU a proceszeltészor része, ebből következően a cím leképezése az egyes architektúrákon eltérő lehet. Az x86-os architektúra esetén a virtuális címből a fizikai cím kétszintű leképezéssel kapható meg (lásd Mint látható, a virtuális cím ebben az esetben 3 részre bontható: lapkönyvtárindex, laptáblaindex, eltolás. A lapkönyvtár (page directory) a laptáblákra mutató hivatkozások tömbje. A lapkönyvtárindex ebből a tömbből választ ki egy laptáblát. A laptábla tartalmazza a hivatkozásokat a fizikai memória me egyes lapjaira. A laptáblaindex ebből meghatároz egy elemet, vagyis egy memórialapot. A fizikai cím ennek a lapnak a címéből és az eltolás összegéből mórialapot. számítható. A virtuális cím ilyen módon képezhető le fizikai címmé.
29
2. fejezet: Betekintés a Linux-kernelbe
Fizikai memória
Virtuális cím Lapkönyvtár index
Eltolás (offset)
Laptáblaindex
Lapkönyvtár
Laptábla
2.4. ábra. Kétszintű leképezés (x86 32bit)
Egyes 64 bites architektúrákon, mint például az Alpha, egy ilyen felosztásban a lapkönyvtár és laptáblatömbök nagyon nagy méretűek lennének, ezért a rendszertervezők bevezették a háromszintű leképezést. Ezt a 2.5. ábrán láthatjuk. Fizikai memória
Virtuális cím Lapkönyvtárindex
Középlapkönyvtárindex
Lapkönyvtár
Laptáblaindex
Középső lapkönyvtár
2.5. ábra. Háromszintű leképezés (Alpha)
30
offset
Laptábla
2.4. Memóriakezelés
Ahogy az ábrából is látható, a leképezés alapelve megegyezik az előzőekben tárgyalt kétszintű leképezéssel, csak kiegészül egy további szinttel. A lapkönyvtár és a laptábla közé beiktattunk egy középső lapkönyvtárat (page middle directory), továbbá a virtuális cím is négy részre tagolódik. A lapkönyvtárbejegyzések így a középső lapkönyvtárakra hivatkoznak, amelynek a bejegyzései laptáblákra mutatnak. A fizikai cím, hasonlóan az előzőhöz, a laptábla által tartalmazott lapcímből és az eltolás összegéből adódik. A Linux-kernel fejlesztői természetesen egységesre szerették volna elkészíteni az MMU-k kezelését, függetlenül az architektúrák különbségeitől. Ezért a Linux-kernel a háromszintű leképezést alkalmazta. Ahhoz azonban, hogy ez az x86-os architektúrákon is működjön, egy kis trükkhöz kellett folyamodni: az x86-os rendszerek esetén a középső lapkönyvtár csak egy elemet tartalmaz, így a háromszintű leképezés kétszintűvé egyszerűsödik. Ezt követték az ia64-es rendszerek, ahol már a négyszintű leképezést támogatja a hardver. Eleinte a kernelfejlesztők trükkökkel a háromszintű leképezést alkalmazták ezeken a rendszereken is, ám ezzel mesterségesen korlátozták a folyamatok maximális virtuális címterét 512 GB-ra. A kernel miatt korlátozni a hardver képességét nem tűnt célszerűnek, ezért később átalakították a leképezőalgoritmust négyszintűre. Így a virtuális cím felépítése az ia64-es rendszerek esetében a 2.6. ábra szerint alakul. PGD PGD PUD PMD PTE
PUD
PMD
PTE
Eltolás
Lapkönyvtár (Page Global Directory) Felső lapkönyvtár (Page Upper Directory) Középső lapkönyvtá Directory) Laptábla (Page Table)
2.6. ábra. A virtuális cím felépítése ia64-es rendszerek esetén
a jelenlegi kernel a platformfüggetlen virtuálismemória-kezelő algoritalgoritmusban musban a négyszintű leképezést használja, amelyet az egyszerűbb architektúrák esetében illeszt az adott rendszerhez. Így
2.4.4. Igény szerinti lapozás A valós élettel ellentétben a lustaság a számítógépek világában sokszor igen előnyös tulajdonság. Ugyanis ha csak akkor végez el egyes műveleteket a gép, amikor tényleg szükséges, akkor ezzel rendszeridőt takarítunk meg a többi feladat számára, így a rendszerünk teljesítménye növekszik.
31
2. fejezet: Betekintés a Linux-kernelbe
Ez az elmélet, átültetve a memóriakezelés világába, jelentheti azt, hogy csak akkor tölti be a rendszer a lapot a háttértárolóról, amikor egy processz hivatkozik rá, igényli az információt. Például egy adatbázis-kezelő programnál elég, ha csak azokat az adatokat tartja a memóriában, amelyekre éppen szükség van. Ezt a technikát igény szerinti lapozásnak (demand paging) nevezzük. Amikor egy processz olyan laphoz próbál hozzáférni, amelyik éppen nem található meg a memóriában, az MMU egy laphibát (page fault) generál, amellyel értesíti az operációs rendszert a problémáról 17 Ha a hivatkozott virtuális cím nem érvényes, az azt jelenti, hogy a processz olyan címre hivatkozott, amelyre nem lett volna szabad. Ilyenkor az operációs rendszer — a többi védelmében — megszünteti a processzt. Ha a hivatkozott virtuális cím érvényes, de a lap nem található éppen a memóriában, az operációs rendszernek be kell hoznia a hivatkozott lapot a háttértárolóról. Természetesen ez a folyamat eltart egy ideig, ezért a processzor addig egy másik processzt futtat tovább. A beolvasott lap közben beíródik a merevlemezről a fizikai memóriába, és bekerül a megfelelő bejegyzés a laptáblába. Ezek után a processz a megállás helyétől fut tovább. Ilyenkor természetesen a processzor már el tudja végezni a leképezést, így folytatódhat a feldolgozás. A Linux az igény szerinti lapozás módszerét használja a processzek kódjának a betöltésénél is. Amikor a programot lefuttatjuk, akkor a rendszer nem tölti be a teljes kódot a fizikai memóriába, csak az elejét, míg a maradékot csak leképezi a folyamat virtuálismemória-területére. Ahogy a kód fut, és laphibákat okoz, a Linux úgy hozza be a kód többi részét is. Ez általában öszszességében is gyorsabb, mint betölteni a teljes programot, mert a nagyobb programok esetében gyakran csak egy része fut le a kódnak.
2.4.5. Lapcsere Amikor a processznek újabb virtuális lapok behozására van szüksége, és nincs szabad hely a fizikai memóriában, az operációs rendszernek helyet kell teremtenie. Ezt úgy teszi meg, hogy egyes lapokat eltávolít a fizikai memóriából. Ha az eltávolítandó lap kódot vagy olyan adatrészeket tartalmaz, amelyek nem módosultak, akkor nem szükséges a lapot lementeni. Ilyenkor ez egyszerűen eldobható, és legközelebb, amikor szükség lesz rá, megtalálható a háttértárolón. Ha azonban a lap módosult, akkor az operációs rendszernek el kell tárolnia a tartalmát, hogy később előhozhassa. Ezeket a lapokat nevezzük piszkos lapnak (dirty page), és az állományt, ahova az MMU az eltávolításkor 17
Az illegális memória-hozzáférések (pl. csak olvasható lapra írás) szintén laphibát eredményeznek (ezt lásd később).
32
2.4. Memóriakezelés
elmenti őket, lapcsereállománynak (swap file). 18 A hozzáférés a lapcsereeszközhöz nagyon hosszú ideig tart a rendszer sebességéhez képest, ezért az operációs rendszernek az optimalizáció érdekében mérlegelnie kell. Ha a lapcsere-algoritmus nem elég hatékony, előfordulhat, hogy egyes lapok folyamatosan cserélődnek, és ezzel pazarolják a rendszer idejét. Hogy ezt elkerüljük, az algoritmusnak lehetőleg a fizikai memóriában kell tartania azokat a lapokat, amelyeken a processzek éppen dolgoznak, vagy később dolgozni fognak. Ezeket a lapokat hívjuk munkahalmaznak (working set). Ugyanakkor az algoritmusnak elég egyszerűnek kell lennie, hogy ne igényeljen túl sok rendszeridőt a választás. A kernel döntéshozó munkáját támogató algoritmusok sokszor bele vannak építve a processzorokba. A kernel ezek közül ugyanakkor csak azokat az algoritmusokat használhatja fel, amelyeket minden processzor ismer, a többit szoftverben kell implementálnia. A Linux az úgynevezett Least Recently Used (LRU, „legrégebben használt") lapozási technikát alkalmazza a lapok kiválasztására. Ebben a sémában a rendszer nyilvántart egy lapok közötti sorrendet az alapján, hogy mikor fértek hozzá utoljára az adott laphoz. Minél régebben fértek hozzá egy laphoz, annál inkább sor kerül rá a következő lapcsereműveletnél. A laphozzáférési sorrendet egy láncolt listában lehetne kezelni. Ha hozzáférünk egy laphoz, akkor a hivatkozását a láncolt lista elejére tesszük. Ezáltal a hivatkozásnak a listában elfoglalt helye megmutatja, hogy milyen sorrendben fértünk hozzá a lapokhoz. Ha azonban minden laphozzáférés esetén frissítenénk a láncolt listát, akkor ez használhatatlan mértékben lassítaná a memória-hozzáférés sebességét. 19 Ezért az előbb említett egyszerű metódussal szemben a Linux egy módosított durva léptékű LRU-technikát használ. Ez a megoldás alapvetően két műveleten alapul. Amikor egy folyamat hozzáfér egy laphoz, akkor ezt egy jelzőbit beállításával jelzi a rendszer. Ezt a műveletet minden jelenlegi architektúra hardveresen támogatja, így gyorsan működik. Másrészt elég egyszerű ahhoz, hogy szükség esetén szoftveresen is megvalósítható legyen. A másik művelet két láncolt lista nyilvántartása. Az egyik lista az aktív lapokat, a másik az inaktív lapokat tartja nyilván. A lapok mindkét irányban vándorolnak a két lista között. A váltás alapja a hozzáférési bit állapota. A kernel bizonyos időközönként megvizsgálja az elóbb említett hozzáférést jelző bit állapotát. Ha a lap idáig az inaktív listában szerepelt, akkor átkerül az aktív lista elejére. Ha idáig aktív volt, és a legutóbbi vizsgálat óta nem fértek hozzá, akkor az inaktív lista elejére kerül. Ha ezek után a legrégebben 18
19
A lapcsereállomány nem feltétlen fájl, lehet partíció vagy akár külön merevlemez is. Ezért célszerűbb egységesen eszköznek nevezni. A Linux több lapcsereeszközt is tud használni egyszerre. Ezeket prioritás szerint rendezi. Amíg meg nem telik, a legnagyobb prioritású eszközt használja, majd a következővel folytatja. A megoldás a gyakorlatban azért sem implementálható, mert a kernelnek értesítést kellene kapnia minden memórialap-hozzáférésről. Ez a hardver támogatása nélkül nem oldható meg. 33
2. fejezet: Betekintés a Linux-kernelbe
használt lapra vagyunk kíváncsiak, akkor az inaktív lista végén találjuk meg. Nem biztos, hogy az inaktív lista végén a sorrend teljesen korrekt az utolsó hozzáférés időpontja szerint, de nincs is szükségünk abszolút pontos eredményre. Mindegyik ott található laphoz régen fértek hozzá. Ez a kis pontatlanság nem zavaró, hiszen ennek következtében az algoritmusunk nagyságrendekkel gyorsabb, mint a korábban tárgyalt pontos algoritmus.
2.4.6. Megosztott virtuális memória A virtuálismemória-kezelés lehetővé teszi több processznek, hogy egy közös memóriaterületen osztozzanak. Ehhez csak arra van szükség, hogy bejegyezzünk a processzek laptábláiba egy közös fizikai lapra való hivatkozást, így képezve le ezt a lapot a virtuális címterületre. Természetesen ugyanazt a lapot a két virtuális címtartományban két különböző helyre is leképezhetjük. Ez a megosztott memóriakezelés lehetőséget ad a folyamatoknak, hogy a közös memóriaterületen adatokat cseréljenek. Ugyanakkor a megfelelő működés érdekében szinkronizálási módszereket is használni kell. A Linux a szálkezelést is a megosztott virtuális memória segítségével implementálja. (Erről már volt szó a 2.3.4. alfejezetben.) Ilyenkor a processzek közös memórián osztoznak, és mint szálakat használhatjuk őket.
2.4.7. Másolás íráskor (COW technika) A Linux a megosztott memóriát nemcsak adatátvitelre, illetve a szálak implementációjánál használja, hanem a folyamatok másolásának gyorsítására is (fork). Elméletileg egy új folyamat létrehozásakor a szülőfolyamat címtere lemásolódik, és ez alkotja a gyerek címterét. A valóságban nem ez történik, mert ez nagyon lassú és nagy memóriaigényű folyamat lenne. Helyette a Linux a másolás íráskor (copy on write, COW) technikáját használja. Amikor a gyerekfolyamathoz le kellene másolni a szülőfolyamat memóriatartalmát, valójában a rendszer csak a virtuálismemória-leképezéshez használt laptáblákat másolja le. Így a gyerekfolyamat címterébe ugyanazok a fizikai lapok képződnek le, mint amelyeket a szülő használ. Ám a folyamatok elkülönítése érdekében a lapok csak olvashatók lesznek mindkét folyamat számára. Ha valamelyik folyamat írni próbálja a lapot, ez egy hozzáférési hibát okoz. A hiba lekezelőrutinja tudja a hiba okát, ezért a lapot lemásolja, és lecseréli a virtuális memóriában. Ettől kezdve mindkét folyamat ismét tudja írni a lapot, illetve a másolatát.
34
2.4. Memóriakezelés
2.4.8. A hozzáférés vezérlése A laptáblabejegyzések az eddig tárgyaltak mellett hozzáférési információkat is tartalmaznak Amikor az MMU egy bejegyzés alapján a virtuális címeket fizikai címekké alakítja át, párhuzamosan ellenőrzi azokat a hozzáférési információkat is, hogy az adott processz számára a művelet engedélyezett-e, vagy sem. Több oka is lehet, amiért korlátozzuk a hozzáférést egyes memóriaterületekhez. Egyes területek (például a programkód tárolására szolgáló memóriarész) csak olvasható lehet, ezért az operációs rendszernek meg kell akadályoznia, hogy a processz adatokat írhasson a kódjába. Ezzel szemben azoknak a lapoknak, amelyek adatokat tartalmaznak, írhatónak kell lenniük, de futtatni nem szabad a memória tartalmát. Meg kell akadályoznunk továbbá, hogy a processzek hozzáférhessenek a kernel adataihoz, a biztonság érdekében ezeket csak rendszerhívásokon keresztül érhetik el. A Linux az alábbi jogokat tartja nyilván egy lappal kapcsolatban (ezeket képezi le az adott architektúrán érvényes jogokra): •
a lap bent van-e a fizikai memóriában;
•
olvasható-e;
•
írható-e;
•
futtatható-e;
•
a lap a kernel címteréhez vagy a felhasználó címteréhez tartozik-e;
•
a lapot módosították-e (dirty), így ki kell-e majd írni a lapcsereállományba;
•
a laphoz hozzáfértek-e;
•
további, a gyorsítótárakkal kapcsolatos beállítások.
2.4.9. A lapkezelés gyorsítása Ha egy virtuális címet megpróbálunk leképezni fizikai címmé, akkor látható, hogy ez több memória-hozzáférést is igényel, mire végigjutunk az összes szinten. Jóllehet idáig azt mondtuk, hogy a memória olvasása aránylag gyors művelet, valójában elmarad a CPU sebessége mögött. Így a lapleképezés műveletei visszafoghatják a rendszer teljesítményét. Hogy ezt megakadályozzák, a rendszermérnökök gyorsítótárakat integráltak az MMU-ba. Ezek neve: Translation Look-aside Buffer (TLB, „félretekintő fordításbuffer": a „félretekintés" az alternatív keresési módra utal).
35
2. fejezet: Betekintés a Linux-kernelbe
A TLB tárolja a legutóbbi lapleképezések eredményét. Amikor egy virtuális címet kell lefordítania az MMU-nak, akkor először egyező TLB-bejegyzést keres. Ha talál, akkor a segítségével azonnal lefordíthatja a fizikai címre, és ezzel jelentős gyorsulást érhet el. A Linuxnak a TLB kezelésével kapcsolatosan nincs sok teendője. Egyetlen feladata, hogy értesítse az MMU-t, ha valamelyik tárolt leképezés már nem érvényes.
2.5. A virtuális állományrendszer Ebben a fejezetben bemutatjuk az Unix-világ egyik legfontosabb absztrakcióját, az állományabsztrakciós felületet. Ennek a felületnek a programozását a 4. Állomány- és I/O kezelés fejezetben, az állománykezelésnél mutatjuk be, a felület megvalósítását pedig a 7. Fejlesztés a Linux-kernelben fejezetben részletezzük.
2.5.1. Az állományabsztrakció Az első Unix rendszer egyik újítását, amely még ma is áthatja az összes operációs rendszert, a hagyomány a következőképpen foglalja össze minden állomány („everything is a file"). Ez a talán kissé leegyszerűsített megfogalmazás azt takarja, hogy a különböző 1/O perifériák sok tekintetben úgy használhatók, mint az állományok. Ha egy folyamat használni szeretné őket, jeleznie kell ezt a szándékát a kernelnek, amely egy állományleírót ad vissza. Ezek után mind az állományokat, mind a perifériákat írjuk, olvassuk, majd bezárjuk. Az implementáció szintjén ez úgy jelenik meg, hogy az állományok megnyitásakor egy állományleírót kapunk vissza, és ugyanazokkal a függvényekkel írhatjuk, illetve olvashatjuk az állományokat. Ez az ötlet nagyon kényelmessé tette a perifériák és az állományok közötti átjárhatóságot. A billentyűzet is egy állomány, amelyről csak olvashatunk, a terminál kimenete egy olyan állomány, amelyet csak írhatunk. Ezért egy program kimenete lehet egy állomány vagy egy terminál, ez mindössze a megnyitott állományleírótól függ. Sőt két program közötti kommunikációt megvalósító csővezeték használata is egy állományleíróval végzett írás és olvasás. Természetesen rögtön felmerül az a probléma, hogy az íráson és az olvasáson kívül számos művelet van (például pozicionálás), amelyre számos eszköz (például a billentyűzet) nem alkalmas. Sőt már az előző bekezdésben felfigyelhettünk arra, hogy a billentyűzetet reprezentáló leírót csak olvashatjuk, míg a terminál leíróját csak írhatjuk.
36
2.5. A virtuális állományrendszer
Ez egyáltalán nem zavaró, hiszen bár vannak különbségek, mi a hasonlóságokat szeretnénk kiaknázni. Egy bemeneti állomány, amelyből csak olvasunk, lecserélhető a billentyűzetre anélkül, hogy egyetlen olvasást végző függvényt lecserélnénk a programunkban. A továbbiakban az állomány és a fájl szavakat a Linux filozófiájával összhangban absztrakt értelemben használjuk• mind állományrendszerbeli állományok, mind 1/0 eszközök lehetnek a leíró mögött. Az állományabsztrakciós felület az összes állomány- és I/O művelet uniója, összessége. Ez az alábbi műveleteket jelenti: •
Olvasás (read): byte-ok olvasása egy adott méretű bufferba.
•
Írás (write): egy adott méretű buffer kiírása.
•
Pozicionálás (llseek): az aktuális pozíció módosítása.
•
Aszinkron olvasás és írás (aio_read, aio_write): POSIX aszinkron I/O műveletek.
•
Könyvtár tartalmának olvasása (readdir): ha az állomány könyvtár, akkor a tartalmát adja vissza.
•
Várakozás állományműveletre (poll): ha az olvasási vagy írási művelet éppen nem hajtható végre, akkor tudunk várakozni a feltétel teljesülésére.
•
I/O vezérlés (ioctl): speciális műveletek, beállítások az állományra.
•
Leképezés a memóriába (mmap): az állományt vagy annak egy részét leképezhetjük a virtuális memóriába, így memóriaműveletekkel írhatjuk és olvashatjuk. Ha az állomány eszköz, akkor a segítségével lehetőség van megosztott memória kialakítására az alkalmazás és az eszközmeghajtó között.
•
Megnyitás (open): az állomány megnyitása. A kernel a megnyitás alapján tudja, hogy az állomány használatban van.
•
Lezárás (close): az állomány lezárása.
•
Szinkronizálás (sync, fsync, fflush, aio_fsync ): ha bufferelt írást alkalmazunk, akkor a buffer tartalmát azonnal kiírja.
•
Állományzárolás (flock): ha több folyamat használná az állományt, akkor a zárolásokkal szinkronizálhatjuk a hozzáférést.
Ismét hangsúlyozzuk, hogy ritka az az eszköz vagy állomány-rendszerbeli állomány, amely az összes műveletet képes lenne megvalósítani. Ha egy kernelobjektum megvalósítja az állományabsztrakciós interfészt, akkor legalább egy műveletet támogat a fentiek közül. Egy leíróra meghívhatjuk a fenti függvények bármelyikét. Ha a művelet nem lenne támogatva, akkor hibaüzenettel térne vissza, amelyet a programunkban lekezelhetünk.
37
2. fejezet: Betekintés a Linux-kernelbe
Összefoglalva az eddigieket: az állományleírók akkor cserélhetők fel, ha csak a mindegyikük által támogatott műveleteket használjuk. Vagyis az állományabsztrakciós felület az műveletek uniója, a műveletek metszete mentén pedig ugyanazokkal a függvényekkel használhatunk több különböző állománytípust. Ezek után vegyük sorra az állománytípusokat. Ezek az alábbiak: •
egyszerű állományok (regular files),
•
speciális állományok (special files).
Az egyszerű állományok a hagyományos, állomány-rendszerbeli állományokat jelentik. Egy állomány-rendszerbeli állomány műveletei függenek az állomány típusától. Az Ext3-as állományrendszer egyszerű állományai az alábbi műveleteket támogatják: •
megnyitás, lezárás;
•
olvasás, írás;
•
aszinkron olvasás, írás;
•
pozicionálás;
•
I/O kontroll;
•
memóriába való leképezés;
•
szinkronizálás.
A speciális állományok olyan nem hagyományos állományok, amelyek megvalósítják az állományabsztrakciós felület legalább egy műveletét.
2.5.2. Speciális állományok A Linux számos olyan állománytípust is használ, amely a hagyományos értelemben véve nem állomány, ám implementálja az állományabsztrakciós interfészt.
2.5.2.1. Eszközállományok Az eszközökhöz való hozzáférés eszközállományokon (device file) keresztül történik. Az eszközállományoknak két típusa van: blokkos eszközállomány (block device file) és karakteres eszközállomány (character device file). A karakteres eszközállományok az általánosabban használt eszközinterfészek. Az állományabsztrakciós interfésznek akár minden függvényét támogathatják attól függően, hogy az eszközre értelmezhetők-e. A blokkos eszközállományok speciálisabbak, és csak az alábbi műveleteket támogatják:
38
2.5. A virtuális állományrendszer
•
megnyitás, bezárás;
•
olvasás, írás;
•
aszinkron olvasás, írás;
•
pozicionálás;
•
I/O vezérlés;
•
memóriába való leképezés;
•
szinkronizálás.
Jóllehet a karakteres és a blokkos eszközöket megadhatjuk a fenti módon a rajtuk értelmezett műveletekkel, a működésük alapján érthetjük meg őket igazán. Az állományabsztrakciós felület tulajdonképpen függvénypointerek halmaza: összerendeli a műveleteket azok megvalósításával. A műveleteket úgynevezett eszközvezérlők (device drivers) valósítják meg. Ha például meghívjuk az open rendszerhívást, akkor ez a kernelben úgy van implementálva, hogy megkeresi az adott állományleíróhoz tartozó eszközvezérlő függvénymutató listáját, és kiválasztja az openhez tartozó bejegyzést. Ha az open műveletet nem valósítja meg az adott eszközvezérlő, akkor ez a mutató nulla. Ebben az esetben a kernel az open rendszerhívás visszatérési értékében jelzi a hibát. Ha az eszközvezérlő megvalósítja a megnyitási műveletet, akkor a vizsgált mutató egy kezelőfüggvényre mutat, amelyet a rendszerhívás implementációja meghív. Az eszközvezérlők a kernel részét alkotják. Gyakran úgynevezett kernelmodulban implementáljuk őket (lásd a 7.3. Kernelmodulok című alfejezetet). A karakteres eszközvezérlők, amint nevük is mutatja, byte-onkénti írástolvasást tesznek lehetővé. A karakteres eszközök egyszerűen megadják a támogatott műveletekre mutató függvénymutatókat, a kernel pedig közvetlenül meghívja őket. A blokkos eszközvezérlők felépítése speciálisabb, mivel olyan eszközökhöz készültek, amelyeknél nem férhetünk hozzá egy-egy byte-hoz közvetlenül, hanem csak byte-ok csoportját, blokkokat tudunk kezelni. Erre jó példa a merevlemez, amelyhez ha byte-onként férnénk hozzá, akkor ez drasztikusan lelassítaná a rendszert. Az eszközvezérlő felépítése sokkal bonyolultabb, mert köztes gyorsítótárakat kell alkalmaznia a blokkok tárolására, illetve aszinkron mechanizmusokat a blokkok mozgatására. Az állományabsztrakciós interfészt sem közvetlenül valósítják meg: ezt a kernel valósítja meg helyettük, és egy műveletsort hoz létre számukra, amelyben felsorakoztatja a kéréseket. Eközben a kernel számos optimalizációt is elvégez. A blokkos eszközvezérlők tetszőleges sorrendben szolgálhatják ki a műveletsorban található műveleteket. Jóllehet vannak eszközök, amelyek se nem blokkosak, se nem karakteresek (például a hálózati kártya), az eszközvezérlők nagy többsége jól megvalósítható valamelyik eszközvezérlő-típussal.
39
2. fejezet: Betekintés a Linux-kernelbe
2.5.2.2. Könyvtár A könyvtár olyan speciális állomány, amely a benne lévő állományok listáját tartalmazza. A régi Unix rendszerekben az implementációk megengedték, hogy a programok az egyszerű állományok kezelésére szolgáló függvényekkel hozzá is férjenek a könyvtárállományokhoz. A könnyebb kezelhetőségért azonban egy speciális rendszerhíváskészlet került az újabb rendszerekbe. (Ezeket lásd a 4.4. Könyvtárműveletek alfejezetben.)
2.5.2.3. Szimbolikus hivatkozás A szimbolikus hivatkozás (symbolic link, symlink, soft link) olyan speciális állomány, amely egy másik állomány elérési információit tartalmazza. Amikor megnyitjuk, a rendszer érzékeli, hogy szimbolikus hivatkozás, kiolvassa az értékét, és megnyitja a hivatkozott állományt. Ezt a műveletet a szimbolikus hivatkozás követésének hívjuk. A rendszerhívások alapértelmezett esetben követik a szimbolikus hivatkozásokat.
2.5.2.4. Csővezeték A csővezeték (pipe) a Unix-világ legegyszerűbb IPC-mechanizmusa. Mint a neve is elárulja, egy virtuális csővezetéket képez memóriában, amelynek végeire egy-egy állományleíróval hivatkozhatunk. Általában az egyik processz információkat ír bele az egyik oldalán, míg egy másik processz a másik végén a beírási sorrendben kiolvassa az adatokat. Mivel a két processz párhuzamosan kezeli, ezért kis memóriaterületre van szükség köztes tárolóként. A parancsértelmező a csővezetékeket a processzek közötti I/O átirányításra, míg sok más program az alprocesszeikkel való kommunikációra használja. Két típusát különböztetjük meg: névtelen (unnamed) és megnevezett (named) csővezetékeket. A névtelen csővezetékek akkor jönnek létre, amikor szükség van rájuk, és amikor mindkét oldal lezárja, akkor eltűnnek. Azért névtelenek, mert nem látszódnak a fájlrendszerben, nincsen nevük. A megnevezett csővezetékek ezzel szemben fájlnévvel jelennek meg a fájlrendszerben, és a processzek ezzel a névvel férhetnek hozzájuk. A csővezetékeket FIFOnak is nevezik, mivel az adatok FIFO (first in, first out, elsőként berakott elem vehető ki először) rendszerben közlekednek rajta. Hangsúlyozandó, hogy az állományabsztrakciós felület használata nem feltétlenül jelenti azt, hogy az állomány megjelenik az állományrendszerben. Erre jó példát nyújtanak a névtelen csővezetékek és a socketek. -
40
2.5. A virtuális állományrendszer
2.5.2.5. Socket A socketek hasonlítanak a csővezetékekre. IPC-csatornaként használhatók a folyamatok között, ám flexibilisebbek, mint a csővezetékek. Kétirányúak, és lehetővé teszik a kommunikációt két, különböző gépen futó processz között is. Vagyis ezekkel valósíthatjuk meg a hálózati kommunikációt (lásd a későbbi fejezetekben). A socketek állományként kezelhetők, mint a többi speciális állomány is, ám a rendszer tartalmaz olyan függvényeket is, amelyek speciálisan a socketekhez készültek. Az állományrendszerben találkozhatunk úgynevezett socketállományokkal. Ez a Unix Domain Socket protokoll (lásd a 6.4. Unix domain socket alfejezetben) címzési mechanizmusának a része, és két folyamat közötti socketkapcsolat felépítésében lát el feladatot. Közvetlenül az állományt nem használjuk, csak meghatározott rendszerhívások paramétereként. A socketállomány csak a Unix Domain Socket protokoll esetén tölt be szerepet. Más protokolloknál a socketmechanizmus nem használja az állományrendszert.
2.5.3. Az inode Az állomány egyetlen egyedi azonosítója az inode (information node, információs csomópont). Kulcsszerepet tölt be a kernel állománykezelésében. Az állomány inode-ja tartalmaz szinte minden információt az állományról, beleértve a jogokat, a méretét, a hivatkozások számát. Ez alól két információ képez kivételt: •
az állomány neve,
•
az állomány adattartalma, mivel az inode csak a mutatót tartalmazza az állományhoz tartozó adatblokkokra, magát az adatot nem. 20
Az állományok neve a könyvtárakban van eltárolva. A könyvtárállomány név és inode-szám összerendeléseket tartalmaz. Vagyis amikor egy állomány tartalmát meg akarjuk nézni, akkor megadjuk a nevét. A kernel a könyvtárállományban megkeresi a névhez rendelt inode-ot. Az inode-ban talált mutató alapján pedig elérkezünk a tényleges adathoz. Ezt a leképezést a 2.7. ábra jeleníti meg.
20
A Linux-kernel 3.2-es verziójától az ext4-es állományrendszer tartalmazza az úgynevezett inline data funkciót, amely lehetővé teszi, hogy nagyon kis adatmennyiség esetén az inode szabad területén tárolódjon az állomány tartalma. Ez sok kis állomány esetében számottevő tárhely-megtakarítást eredményez.
41
2. fejezet: Betekintés a Linux-kernelbe Könyvtár
Könyvtári
fájli inode1 fáj l2 inode2
inode1 inode
Jogosultság Tulajdonos Csoport Adatindex
inode inode2
Jogosultság Tulajdonos Csoport Adatindex
2.7. ábra. Állománynévtől az adatig
Lehetőségünk van arra is, hogy ugyanarra az inode-ra más névvel is hivatkozzunk, akár másik könyvtárból. Ezt nevezzük merev hivatkozásnak (kard link) (lásd a 2.8. ábrát). Könyvtár
Könyvtári
fájlt inode1 fájl2 inode2
inode
inode1
Jogosultság Tulajdonos Csoport Adatindex
Könyvtár Könyvtárt
á113 inode1
2.8. ábra. Merev hivatkozás
Az inode-számnak azonban csak egy állományrendszeren belül van értelme. Ezért merev hivatkozást csak egy partíción belül hozhatunk létre. Partíciók között csak a korában említett szimbolikus hivatkozás használható (lásd a 2.9. ábrát).
42
22.52.
Könyvtár Könyvtárt
fáj 11 fájl2 inode2
A virtuális állományrendszer
inode inodei
Jogosultság Tulajdonos Csoport Adatindex
inode1 Könyvt yvtárt
fáil3 inode3
inode inode2
Jogosultság Tulajdonos Csoport Link név
2.9. ábra. Szimbolikus hivatkozás
Az inode tartalmazza a rá hivatkozó állománynevek, vagyis a merev hivatkozások számát. Ezt hívjuk link countnak (a kapcsolatok száma) Amikor egy állományt törlünk, akkor valójában egy merev hivatkozást törlünk, és ez a szám csökken eggyel. Ha eléri a 0 értéket, és egyetlen folyamat sem tartja éppen nyitva az állományt, akkor ténylegesen törlődik, és a hely felszabadul. Viszont ha legalább egy folyamat nyitva tartja, csak akkor történik meg a tárolóhely felszabadítása, miután mindenki bezárta. 21 Az inode tárolásának módja és tartalma a használt állományrendszer függvénye. Jelentősen eltérhet a különböző állományrendszer-típusoknál. Ám ennek lekezelését nem háríthatjuk az alkalmazásokra. Ezért a kernel a memóriában csak egyféle reprezentációt használ, amelyet in-core inode-nak almazások minden ee a reprezentációval találtalálkoznak. A merevlemezen tárolt inode-okat on-disk inode-nak nevezzük. koznak. Amikor egy processz megnyitja az állományt, az on-disk inode betöltődik, és a kernelben található állományrendszer-kezelő rutinok automatikusan in-core inode-dá alakítják. A leképezést visszafelé is elvégzik, amikor az in-core inode módosul. A megfeleltethető értékeket visszakonvertálják, és lementik az ondisk inode-ba. Az on-disk és az in-core inode nem teljesen ugyanazt az információt tartalmazza. Például csak az in-core inode könyveli az adott állományhoz kapcsolódó folyamatok számát. Néhány állománytípus, például a névtelen csővezeték, nem rendelkezik on-disk inode-dal, csak a memóriában létezik.
21
Ilyenkor nem törlődik ténylegesen az állomány tartalma, csak speciális esetekben. Valójában csak újra felhasználhatónak lesznek nyilvánítva afájl3 kok. 43
22. fejezet: Betekintés a Linux-kernelbe
2.5.4. Az állományleírók Az alkalmazások számára az állományt többnyire az állományleíró reprezentálja Amikor egy állományt megnyitunk, akkor a kernel visszaad egy kis egész számot (int), amely a továbbiakban az állománnyal kapcsolatos műveletek során hivatkozásként szolgál az állományra. Az állományleíró csak a folyamaton belül van értelmezve. Ha ugyanazt az állományt egy másik folyamatban is megnyitjuk, akkor lehet, hogy másik állományleírót kapunk. Az állományleírók konkrét számértéke valójában az állományok megnyitásának sorrendjétől függ. A kernel 0-tól kezdődően kezdi kiosztani a folyamat számára. Ám az első három leíró foglalt. •
0: bemenet
•
1• kimenet
•
2: hibakimenet
Ezt követően az újabb állománymegnyitások során egyre nagyobb számokat kapunk vissza. Lehetőségünk van arra is, hogy állományleírókat kicseréljünk egymással. Így az is megoldható, hogy a 0, 1, 2 leírók mögött az állományt kicseréljük, és így a folyamat bemenetét vagy kimenetét például egy csővezetékre cseréljük le. Ezt a kimenet/bemenet átirányításának nevezzük (lásd részletesen a 4.1.8. Állományok átirányítása című alfejezetben). A bemenetet, a kimenetet és a hibakimenetet nemcsak a programban, hanem akár már indításnál a shell parancsban is átirányítatjuk:
ere A fenti példában az első program kimenetét egy csővezetékre kötjük, amelynek a másik végét a sort program bemenetére csatlakoztatjuk. A sort program ábécésorrendbe rendezi a sorokat, majd a kimenetét egy állományba irányítjuk át.
2.6. A Linux programozási felülete A Linux programozási felületének két jól elkülöníthető szintje van: az egyik a kernel felülete, a másik az erre épülő, felhasználói üzemmódban futó könyvtárak. A kernel felhasználói üzemmódban hívható funkcióit rendszerhívásoknak (system call, röviden syscall) nevezzük, ezek összessége adja a kernel programozási felületét. A rendszerhívások teszik lehetővé az átjárást a felhasználói és a kernelüzemmód között: ezeket ugyanis felhasználói üzemmódban hívjuk, de kernelüzemmódban futnak. Mivel az üzemmódok közötti váltás architektúrafüggő, ezért az implementáció részletei is különböznek az egyes architektúrákon. 44
22.62. A Linux programozási felülete
A rendszerhívást egyedi szám, a rendszerhívásszám (syscall number) azonosítja. Erre azért van szükség, mert az üzemmódok közötti átkapcsoláskor nem hagyományos függvényhívást végzünk: a kernelnek egy belépési pontja van, amely a memórián és a regisztereken keresztül veszi át az adatokat, köztük a rendszerhívásszámot. Ez utóbbi segítségével egy táblázatból kiválasztja, majd meghívja a megfelelő rendszerhívás-kezelőt. A Linux örökölte a rendszerhívásait a Unix rendszerektől, amely meglehetősen stabil és kiforrott felületet ad. Ezért nagyon ritkán vezetnek be új rendszerhívásokat. Ha mégis szükségünk lenne erre, a Linux makrókkal teszi egyszerűvé új rendszerhívás készítését. Ezt a 2.10. ábra szemlélteti. A rendszerhívások meghívásához tisztáznunk kell az alkalmazásprogramozói felület (Application Programming Interface, API) és a bináris alkalmazásfelület (Application Binary Interface, ABI) fogalmát. Ezekre a fogalmakra magyarul is az angol rövidítést használjuk. Az API forráskódszintű felületet definiál, a Linux estében C-függvények prototípusait, illetve azok viselkedését, a Linux esetén kézikönyvoldalakat. Jóllehet a rendszerhívásokat közvetlenül is hívhatnánk, hiszen azok is Cfüggvények, a Linuxot a C programkönyvtár (C library, libc) API-ján keresztül programozzuk. Ennek az API-nak túlnyomó része POSIX-szabvány, de a Unix/Linux operációs rendszerek estében elég közel van a rendszerhívások biztosította API-hoz. Sok esetben a libc függvény maga a rendszerhívás. Ugyanakkor nem minden libc függvény felel meg közvetlenül egy rendszerhívásnak. A libc fölé számtalan API áll rendelkezésre, sokuk interpreter és virtuális gép formájában, ezáltal teszi lehetővé a C-nél magasabb szintű nyelvek használatát. A szabványos C++-könyvtár is a libc-re épít. -
Felhasználói üzemmód - -- write() libc hívás
teml_handler() rendszersysteml_handler sys_write() rendszerhívás Kernel üzemmód 2.10. ábra. A rendszerhívás folyamata
Mint a neve is sugallja, az ABI a bináris kompatibilitás feltételeit írja le, ide tartoznak a hívási konvenciók, a regiszterek használata, byte-sorrend. Az ABI lényegében a fordítót és a linkert érinti. Míg a Linux API nagy részét a POSIX-szabvány definiálja, az ABI k specifikációja architektúrafüggő. Ez a specifikáció fellelhető az interneten, gyakorlatban arra építünk, hogy egy adott platformon a gcc csomag ismeri és támogatja a Linux ABI-ját. -
45
HARMADIK FEJEZET
Programkönyvtárak készítése
A programkönyvtárak alapjainak ismerete elengedhetetlen egy Linux rendszer testreszabásához, elkészítésük és felhasználásuk nélkül pedig nem képzelhető el nagyobb program. Ha beépülő modulokat (plugin) szeretnénk megvalósítani, vagy a programunk architektúráját lecserélhető bináris komponensekre építjük fel, ugyancsak a programkönyvtárak nyújtják az implementáció hátterét. Ebben a fejezetben áttekintjük a programkönyvtárak alapjait, majd programozási kérdéseiket tárgyaljuk meg C nyelven, végül kitérünk a C++-osztályokat tartalmazó programkönyvtárakkal kapcsolatos megoldásokra. A fejezetet egy haladó témakör zárja: megvizsgáljuk a programbetöltés folyamatát, valamint ennek keretében a sebességoptimalizáló eszközök működését is.
3.1 . Statikus programkönyvtárak Bevezetésként nézzünk meg egy egyszerű példát: ceiling.c */ /* include double ceil(double); int main() { double x; printf("Kerem az x-et: "); scanf("%lf", &x); printf("ceil(x) = %6.4f\n", ceil(x)); return 0; }
32. fejezet: Programkönyvtárak készítése double cei 1 (doubl e x) {
double i x=(int)x; return ix ./libround.so.1 (0x006c7000) libc.so.6 => /lib/t1s/libc.so.6 (0x009dd000) /lib/ld-linux.so.2 => /libild-linux.so.2 (0x009c4000) ldd ./libround.so libc.so.6 => /lib/t1s/libc.so.6 (0x00815000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x009c4000)
A programkönyvtárban található szimbólumokat a statikus programkönyvtáraknál megismert nm segédprogrammal írathatjuk ki.
3.2.3. Megosztott programkönyvtárak dinamikus betöltése Feladat Módosítsuk úgy a kerekítő főprogramunkat, hogy ne csak a saját kerekítőalgoritmusunkat tudja használni, hanem más programozók is írhassanak kerekítőalgoritmusokat: ezeket beépülő modulnak (plug-in) nevezzük.
35
List Dynamic Dependecies: kb. listázd ki a dinamikus függőségeket. 63
3. fejezet: Programkönyvtárak készítése
A feladathoz meg kell oldanunk azt, hogy a lefordított főprogram képes legyen olyan programkönyvtárakat betölteni, amelyek még nem állnak rendelkezésre linkelési időben. A statikus könyvtárakkal ez természetesen kivitelezhetetlen. Az előző fejezetben a megosztott programkönyvtárat a linker név szerint hozzáfűzte a programunkhoz, az adott nevű programkönyvtárhoz pedig automatikusan betöltőkódot generált, amely a program indulásakor megkereste és betöltötte a könyvtárat. Természetesen ügyeskedhetnénk az LD_LIBRARY PATH változóval, hogy különböző helyről töltse be az ugyanolyan nevű, de különböző programkönyvtárunkat, vagy felülírhatnánk az adott könyvtárat, játszhatnánk az so-névvel, ám ezeket a megoldásokat egyértelműen nevezhetjük igénytelennek. Ez akkor válik nyilvánvalóvá, ha egy nagyobb program különböző részeiben különböző beépülő modulokat szeretnénk használni. Szerencsére lehetőségünk van arra, hogy futás közben igény szerint betöltsünk egy megadott nevű programkönyvtárat, és annak függvényeire és kívülről elérhető változóira név szerint hivatkozhassunk. A programkönyvtárat használat után el is távolíthatjuk a memóriából. Nézzük meg, milyen függvényekkel lehetséges ez: const char* pluginPath = "./libround.so.1"; handle = dlopen(pluginPath, if(!handle)
RTLD_LAZY);
{
fputs(dlerror(), stderr); exit(1);
Példánkban sztringkonstansként definiáljuk a programkönyvtár elérési útvonalát, a gyakorlatban legtöbbször ezt konfigurációs állományból vagy felhasználói adatbevitel alapján kapjuk meg. Ezután megnyitjuk a programkönyvtárat a dlopen segítségével. Ez a függvény végzi el a dinamikus linkelést, amelyet az előző fejezet példájában a linker végzett el, valamint a betöltést. Ha a programkönyvtár további programkönyvtárakat használ, azokat is automatikusan betölti a hivatkozásaikkal együtt tetszőleges mélységig. A bemutatott dinamikus betöltés során a program indulásakor nem töltődik be a programkönyvtár, csak akkor, amikor a vezérlés eléri a dlopen függvényt. Vagyis ha a megosztott programkönyvtár nem található, a program elindul, mindössze a dlopen függvény visszatérési értéke lesz NULL. Ellenkező esetben egy leírót (handle) kapunk, amelyet a programkönyvtáron végzett műveletek során át kell adnunk paraméterként. Ha valami hiba történik, bővebb információval a dlerror függvény szolgál, amely a hiba leírását adja vissza C-típusú sztringként. Ez a hibakezelési módszer érvényes a programkönyvtár továbbiakban ismertetett függvényeire is. Nézzük meg, hogy a program betöltése után hogyan férhetünk hozzá a program függvényeihez: 64
3.2. Megosztott programkönyvtárak
double (*round)(double); round = dlsym(handle, "round"); if((error = dlerror()) ! = NULL) {
fputs(error, stderr); exit(1); printf("%lf\n", round(3.14));
A fenti programrészletben a round függvényhez férünk hozzá. Elsőként definiálunk egy függvénypointert, amely egy double argumentumlistájú double visszatérési értékű függvényre mutat. A fordítónak szüksége van a függvény prototípusára (lásd korábban is). Ennek megadása elkerülhetetlen, hiszen csak a linkelést/betöltést végezzük dinamikusan, a fordítást nem. Egy programkönyvtárbeli függvényre mutató mutatót a dlsym függvénnyel szerezhetünk. A függvény meghívása ezek után úgy történik, mint bármelyik mutatójával az adott függvény meghívása. A dlsym tulajdonképpen egy adott szimbólum kezdőcímét adja vissza, legyen az függvény vagy változó. Így a programkönyvtár roundingMethod nevű globális változójához nagyon hasonlóan férhetünk hozzá: int* roundingMethod; roundingMethod = dlsym(handle, "roundingMethod"); if((error = dlerror()) != NULL) {
fputs(error, stderr); exit(1); *roundingMethod =1;
A fordítónak ezúttal is szüksége van a változó típusára, amelyet ezúttal is pointerként definiálunk; ugyanazzal az indirekcióval férhetünk hozzá a pointer tartalmához, mint általában. Ha itt rossz típust adunk meg, akkor a függvényhívást előkészítő kódrészlet által a veremben elhelyezett adatstruktúra különböző lesz a függvény által várttól, és ez szinte bármilyen furcsa működést eredményezhet. Mindig gondosan figyeljünk a főprogramban megadott pointer és a programkönyvtárbeli függvény egyezésére. A dlsym nemcsak az általunk betöltött programkönyvtárban található szimbólumokat adja vissza, hanem a programkönyvtár által használt programkönyvtárak szimbólumait is tetszőleges mélységig. Ha nem használjuk a programkönyvtárat, akkor fel kell szabadítanunk, ennek hatására — ha a program közvetlenül vagy más betöltött programkönyvtárakon keresztül nem hivatkozik a programkönyvtárra — az operációs rendszer eltávolítja a programkönyvtárat a memóriából. A felszabadítást a dlclose függvény végzi el: dlclose(handle);
65
3. fejezet: Programkönyvtárak készítése
Térjünk vissza a dlopen függvényhez. A függvény második paramétereként szabályozhatjuk azt, hogy a programkönyvtárban található szimbólumokat (lásd a 3.4.3.4. Dinamikusan linkelt megosztott könyvtár linkelése és betöltése alfejezetet) mikor oldja fel a dinamikus linker Ha azt szeretnénk, hogy a dlopen meghívásakor történjen a szimbólumfeloldás, akkor az RTLD_NOW értéket használjuk. Ilyenkor, ha a valamelyik szimbólumfeloldás nem sikerül, a dlopen hibával tér vissza. Ha a nem definiált szimbólumhoz való hozzáféréskor szeretnénk a szimbólum feloldását, akkor az RTLD_LAZY jelzőbitet adjuk át a függvénynek. Útmutató Ha nem szeretnénk a betöltést a szimbólum-hozzáférés sebességére optimalizálni, valamint ráérünk az egyes szimbólum-hozzáférésnél lekezelni a hibákat, használjunk az RTLD_LAZY-t.
A teljes program forráskódja a következő: // dynamic_roundmai n c #include #include #include "round. h" int errorcode; const char* pluginPath = "./libround.so.1"; -
int mai n(i nt argc, char **argv)
{
void *handle; double ("round)(double); int* roundingMethod; char *error; /" Megnyitjuk a konyvtarat. * / handle = dlopen(pluginPath, RTLD_LAZY); i f(! handle) {
fputs(dlerror(), stderr) ; exit(1); }
/* Hozzaferunk a round szimbol umhoz "/ round = dl sym(handl e , "round"); if((error = dlerror()) ! = NULL) {
fputs(error, stderr); exit(1);
66
3.2. Megosztott programkönyvtárak /* Hozzaferunk a roundi ngmethod szimbol umhoz . */ roundi ngmethod = dl sym(handl e , " roundi ngmethod") ; i f((error = dl error()) != NULL) { fputs (error , stder r) ; exi t (1) ;
double x= 4.2; *roundi ngmethod =0; pri ntf ("%1 f \ n" , round(x)) ; *roundi ngmethod =1; pri ntf ("%1 f\ n" round(x)); *roundingmethod =2; printf("%lf\n", round(x)); *roundingmethod =3; round(x); printf("%d\n",errorCode); /* felszabaditjuk a konyvtarat dlclose(handle); }
Fordításkor hozzá kell linkelnünk a programunkhoz a dinamikus linker könyvtárát, ez a fentiekben ismertetett függvényeket tartalmazó programkönyvtár (libdl.so): gcc dynamic_roundmain.c -o dynamic_roundmain -1d1
Amikor azonban futtatjuk a programot, hibaüzenetet kapunk: ./libround.so.1: undefined symbol: errorCode
A függőség ugyanis esetünkben kétirányú: nemcsak a programkönyvtárban lévő szimbólumokat kell feloldani, hanem a programkönyvtárban externként definiált errorCode szimbólumot is. Úgy tudjuk rávenni a linkert, hogy a főprogram publikus szimbólumait tegye eléretővé a dinamikusan betöltött programkönyvtárak számára, hogy a gcc-nek megadjuk az rdynamic kapcsolót: -
gcc dynamic_roundmain.c -o dynamic_roundmain -1 dl -rdynami c
67
3. fejezet: Programkönyvtárak készítése
Ha azt szeretnénk, hogy a programkönyvtárunk hasonlóképpen megosztaná a külsőleg hozzáférhető szimbólumait az általa használt programkönyvtárakkal, használjuk az RTLD_GLOBAL jelzőbitet a dlopen paramétereként VAGY kapcsolatban a többi jelzőbittel. Útmutató
Valójában ez egy eléggé ritkán használt megoldás, mindezzel inkább a dinamikusan
betöltött programkönyvtárak lehetőségeit szeretnénk illusztrálni. Ha a programkönyvtárnak szüksége van főprogrambeli változókra vagy függvényekre, azt lehetőleg pointerekkel adjuk át a programkönyvtár függvényeinek.
Ismét hangsúlyozzuk, hogy a betöltés teljesen dinamikus, a programnak nincsen függősége a megosztott könyvtárra. Programunk csak a libc tót (printf és társai), valamint a dinamikus programbetöltőtől (dynamic loader) függ (dlopen és hasonlókat tartalmazó programkönyvtár). -
ldd /dynamic_roundmain libdl.so.2 => /lib/libdl.so.2 (0x00afb000) 1ibc.so.6 => /lib/t1s/libc.so.6 (0x009dd000) /lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x009c4000)
Mindebből következik, hogy ha megadtunk konstruktort, illetve destruktort a programkönyvtárnak, akkor az a programkönyvtárat elsőként betöltő dlopen, illetve az utolsóként felszabadító dlclose hatására hívódik meg. Az így használt programkönyvtárat dinamikusan linkelt megosztott programkönyvtárnak (dynamically linked shared library) nevezzük, de használatos a dinamikusan betöltött programkönyvtár (dynamically loaded shared library) elnevezés is. Mind a statikusan, mind a dinamikusan linkelt jelző a megosztott programkönyvtár felhasználásának a módját jelenti, a megosztott könyvtár számára ez nem jelent különbséget, csupán a főprogram számára. Dinamikus esetben a főprogram függvényhívásokkal tölti be és távolítja el a programkönyvtárat, a szimbólumfeloldást függvénypointereken keresztül végzi: a szimbólumhivatkozás a függvénypointer, a dlsym által visszaadott érték a szimbólumdefiníció helye. Statikus esetben a linker gondoskodik róla, hogy a programkönyvtárakat betöltse az induló program, és a szimbólumfeloldást is elvégzi. Ebben a fejezetben ismertettük a C nyelvű programozás egyik legrugalmasabb architektúráját: hogyan lehet olyan beépülő modulokat írni, amelyekről elég csak a benne lévő függvények nevét és argumentumlistáját, illetve a változók nevét és típusát ismernünk, a könyvtár kiválasztását és így a konkrét implementációt megadhatjuk futási időben. Felmerül a kérdés, hogy létezik-e olyan megoldás, ahol még ennél is kevesebb információ is elég lenne, például megkérdezhetnénk a könyvtárat, milyen függvényei vannak, ezeknek melyek az argumentumai, és milyen típusúak.
68
3.3. Megosztott könyvtárak C++ nyelven
A válasz sajnos nemleges, ezzel ugyanis elértük a C nyelv határait: a struktúrák adattagjainak felépítése és a függvények argumentumai elvesznek a fordítás során: pointerműveletekké vagy veremkezelő utasításokká alakulnak, amelyekből lehetetlen visszanyerni a fenti kérdésekre kapható válaszokat. 36
3.3. Megosztott könyvtárak C++ nyelven Az előző fejezetekben láttuk, hogy a programkönyvtárak használatát lehetővé tévő mechanizmusok a fordítás után, a linkelés és a betöltés folyamatának részeként aktiválódnak. A C++-szimbólumok linkerszintű reprezentációja némileg eltér a C nyelvűekétől. Ebben a fejezetben bemutatjuk, hogy miként kezelhetjük ezeket a különbségeket. Mivel a C++ objektumorientált nyelv, így a programok alapvető adatstruktúrái az osztályok, nem pedig a függvények, mint a C-programokban. Ezért az elvárásaink is mások: míg az előző fejezetben az adott prototípusú C-függvények implementációját kicserélhettük egy újabb verziójú programkönyvtárban, a C++ esetében ugyanolyan interfészű (ugyanolyan publikus tagváltozókkal és tagfüggvényekkel rendelkező) osztályokkal szeretnénk ugyanezt megtenni. Jóllehet erre a problémára nem lehet igazán elegáns C/C++ megoldást javasolni, az alábbiakban bemutatjuk a lehetőségeket.
3.3.1. Programkönyvtárbeli C++-osztályok használata A C++ nyelv lehetővé teszi a függvénynevek túlterhelését (function overloading), ez azt jelenti, hogy ugyanazt a függvénynevet különböző paraméterlistával többször is felhasználhatjuk. Mivel a sok ugyanolyan nevű szimbólum linkerhibához vezetne, a C++ ezt a névelferdítés (name mangling) használatával oldja meg. Ez azt jelenti, hogy a fordító az argumentumlista típusait beleépíti a függvénynévbe. Erre szabványos eljárás nincs, minden fordító saját megoldást választhat, és választ is a tapasztalatok szerint. Ennek következtében a C++-függvények szimbólumneveit nemcsak hogy nem egyszerű kitalálni a függvénynév alapján, de még a sokat tapasztalt programozói szem számára sem túl esztétikus a látvány. A névelferdítés a statikus könyvtárak esetén akkor okozhat problémát, ha az egyik állományt C-fordítóval, a másikat C++-fordítóval fordítjuk. Sőt ugyanez a probléma a különböző C++-fordítók esetében is. Ekkor a linker szintjén ugyanazok a prototípusok másként látszódhatnak, ezért a linker nem tudja öszszepárosítani a függvény hívását a függvény implementációjával, és nem defi36
Ez a probléma vezetett el a Java- és .NET-környezetek önleíró adatstruktúráihoz. 69
3. fejezet: Programkönyvtárak készítése
niált szimbólumot jelez. Ez a probléma programkönyvtárak használata nélkül is ugyanúgy előkerülhet különböző fordítóval fordított tárgykódú állományok linkelésekor. A nem dinamikusan betöltött, megosztott programkönyvtár esetén is hasonló a helyzet: azonos fordítónál a linker elvégzi az összepárosítást. Nézzünk egy példát. Feladat Írjunk C++-osztályt, amelynek egyetlen függvénye felfelé kerekíti a megadott bemenetet. Az osztályt megosztott programkönyvtárban helyezzük el.
Elsőként készítsük el az osztályt. Az osztálydeklarációt külön .h állományban helyezzük el: // rounder.h class Rounder {
publ i c : int Round(doubl e) ; } ;
Az osztály egyetlen függvényének a definícióját tartalmazza a rounder.cpp állomány: #include #include #include "rounder.h" int Rounder::Round(double x) {
doubl e ix; ix = (int)x; return ix < x ? ix+1 : ix; }
Ezekből az állományokból megosztott könyvtárat készítünk a már jól ismert módon, de ezúttal C++-fordítóval: g++ -c rounder.cpp g++ -shared -w1,-soname,librounder.so.1 -o librounder.so.1.0.1 \ rounder.o -lc /sbin/ldconfig -n .
70
3.3. Megosztott könyvtárak C++ nyelven
Ezután elkészítjük a főprogramot a roundermain.cpp állományban: #i ncl ude "rounder . h" #i ncl ude using namespace std; int mai n() {
double x= 4.2; Rounder r; cout « r.Round(x)«endl; }
A főprogramot a szokásos módon fordítjuk: -orounder
de rmai n
cp
-
lrounder
Ha visszatekintünk a forráskódra, látható, hogy a főprogram csak az osztálydeklarációt ismeri, a függvények implementációját nem. A fordító tudja, hogyan kell a C++-függvényeket kezelni, a linker pedig összepárosítja a megfelelően fordított szimbólumhivatkozásokat az implementációval. A dinamikus linkelésű programkönyvtáraknál éppen az okoz problémát, hogy C-függvénypointereket használunk (ez a dlsym visszatérési értéke), így a fordító nem ismeri fel, hogy C++-függvényekről van szó, és nem képes helyettünk kezelni. A linkerszintű nevek elferdítési konvencióján túl rögtön egy másik problémába is ütközünk. A C++-osztályok nem statikus tagfüggvényeinek az adott objektumpéldányra mutató pointert („this pointer") is át kell adnunk. Mind a névelferdítést, mind a this pointer átadását érdemes a fordítóra bíznunk, nincs értelme „kézzel" hamisítanunk, mert a fordítókat nem kötik szabványok, az implementáció egyik verzióról a másikra változhat, sőt elviekben akár egy függvény hozzáadása is módosíthatja a névelferdítést. A névelferdítésre, illetve a C++-tagfüggvények hívására a dlsym nem nyújt támogatást. Következésképpen C++-tagfüggvényeket közvetlenül nem érhetünk el a dlsym függvény használatával. Névelferdítés esetén a megoldást az jelenti, ha megadjuk a fordítónak, hogy egyes függvényeknél ne használjon névelferdítést. Ezt az extern „C" kulcsszóval érhetjük el. extern
"C" double round (doubl e x)
{
}
71
3. fejezet: Programkönyvtárak készítése
Természetesen, ha egy függvényt extern „C"-vel deklaráltunk, akkor nem terhelhetjük túl. Ezt a megoldást azonban tagfüggvényekre nem alkalmazhatjuk. Útmutató Ha programkönyvtárban található C++-osztályokat szeretnénk felhasználni, írjunk extern „C" függvényeket, amelyek példányosítják az osztályokat, és felszabadítják a létrehozott objektumokat.
Az eddigiekre építve a következő fejezetben bemutatjuk, hogyan tudunk dinamikusan betöltött programkönyvtárak C++-objektumaihoz hozzáférni.
3.3.2. C++-objektumok dinamikus betöltése programkönyvtárból Feladat Az előző feladat kerekítést végző megosztott programkönyvtárát módosítsuk úgy, hogy dinamikusan is betölthető legyen. Készítsük el a dinamikus betöltést végző főprogramot is.
Mint ahogy a függvények esetében, itt is egy indirekció segít: míg a függvényeknél függvénypointert használtunk, itt az osztályra mutató pointerrel dolgozunk. Természetesen az osztály deklarációjára továbbra is szükség van a főprogramban is, ez alapján az információ alapján veszi észre a fordító, hogy egy C++-osztály tagfüggvényét kell hívnia, és így kezelni tudja helyettünk a C++-sajátosságokat. A függvény implementációját viszont elrejtjük a főprogram elől, ez adja a megosztott könyvtár erejét: bármikor lecserélhetjük egy másik implementációval. Ennek azonban az az ára, hogy nem használhatjuk a new operátort, ugyanis ennek a fordítása azt feltételezi, hogy a konstruktor címe rendelkezésre áll linkelési időben, ez pedig a dinamikus linkelésű könyvtárak esetében már nem teljesül. A példányosítást tehát a programkönyvtár egy függvényében kell elvégeznünk. Mivel ezt a függvényt meg szeretnénk hívni a főprogramból, extern „C"-nek deklaráljuk. Ennek jegyében így egészíthetjük ki a rounder.h állományt: extern "c" Rounder* create(); extern "c" void destroy(Rounder*); typedef Rounder*(*create_t)(); typedef void(*destroy_t)(Rounder*);
Előrelátóan a pointertípusokat is deklaráljuk, hiszen azokkal a dlsym függvénymutatókkal tér vissza, amelyeket konvertálnunk kell a függvény típusára. Az eddigiek alapján a főprogram egyszerű:
72
3.3. Megosztott könyvtárak C++ nyelven
#include #include #include #include
"rounder.h"
using namespace std; const char* pluginPath = "./librounder.so.1"; int main(int argc, char **argv) { void *handle; create_t createFuncPtr; destroy_t destroyFuncPtr; char *error; // Megnyitjuk a konyvtarat. handle = dlopen(pluginPath, RTLD_LAZY); if(!handle) { fputs(dlerror(), stderr); exit(1); } // Hozzaferunk a create szimbolumhoz. createFuncPtr = (create_t) dlsym(handle, "create"); if((error = dlerror()) != NULL) { fputs(error, stderr); exit(1); } // Hozzaferunk a destroy szimbolumhoz. destroyFuncPtr = (destroy_t)dlsym(handle, "destroy"); if((error = dlerror()) != NULL) { fputs(error, stderr); exit(1); } double x= 4.2; // Letrehozunk egy Rounder tipusu objektumot Rounder* r = (*createFuncPtr)(); cout « r->Round(x)«endl; // Felszabaditjuk a Rounder objektumot (*destroyFuncPtr)(r);
73
3. fejezet: Programkönyvtárak készítése
// Felszabaditjuk a konyvtarat dlclose(handle); }
A dlsym visszatérési értékét explicit típuskonverzióval kell az adott típusra alakítani, mert a C++ — a C nyelvvel ellentétben — szigorúan típusos nyelv. Fordításkor azonban linkertől jövő hibaüzenetet kapunk: (.text+0x107): function main': undefined reference to 'Rounder::Round(double) collect2: ld returned 1 exit status
1
A linker nem képes elérni a tagfüggvényt, mert az a programkönyvtárban van definiálva. Nem dinamikusan betöltött könyvtár esetén a linker megtalálta a teljes osztálydefiníciót a programkönyvtárban, itt azonban linkelési időben nem tudunk semmit a függvény implementációjáról, így annak címéről sem. A linker tehát joggal jelzi, hogy nem találja a függvényt. Eddigi ismereteink alapján máshogyan is eljuthattunk volna erre a következtetésre: már egyszerű függvények esetében is a függvénypointer alapján tudtunk hozzáférni a függvényekhez, itt azonban csak az osztályhoz férünk hozzá mutatóval, a függvényhez nem. Látszólag ekkor el is akadunk, hiszen visszaértünk a kiindulási problémához: C++-tagfüggvényekhez nem tudunk pointeren keresztül hozzáférni, másképp pedig a linker problémát jelez. Ha az implementációt beleírjuk a függvényekbe, akkor már az egész osztályt átemeltük a főprogramba. Egy olyan megoldásra van szükségünk, amely esetén •
nem kell megadnunk a függvény törzsét, csak a deklarációját,
•
mégis tudunk pointert definiálni az osztályra, és meg tudjuk hívni a függvényeit.
A C++-ban a tisztán virtuális függvény pontosan ilyen nyelvi konstrukció. Vagyis létrehozunk egy kizárólag virtuális függvényeket tartalmazó absztrakt osztályt, amelyet mind a főprogramban, mind a programkönyvtárban definiálunk. A létrehozást végző függvény ugyanakkor egy leszármazott osztályt példányosít (az absztrakt osztályt nem is tudná), és ezt adja vissza az absztraktősosztály-típusú mutatón keresztül. Mindezek fényében a programkönyvtárat az alábbiak szerint valósíthatjuk meg. A közös rounder.h tartalmazza az absztrakt osztályt és a névelferdítés nélküli függvényeket: cl ass Rounder { publ i c: vi rtual int Round(doubl e) = 0; vi rtual ~Rounder(){} ;
74
3.3. Megosztott könyvtárak C++ nyelven
extern "c" Rounder* create(); extern "C" void destroy(Rounder*); typedef Rounder*(*create_t)(); typedef void(*destroy_t)(Rounder*);
Mivel az osztály leszármazottjait Rounder típusú pointeren keresztül szabadítjuk fel, virtuális destruktort is definiálunk. Elviekben ez is lehetne tisztán virtuális, de a C++-ban a tisztán virtuális destruktor szintaxisa meglehetősen átláthatatlan. A programkönyvtárat így implementáltuk: #include #include #include " rounder h" class upRounder: public Rounder {
int Round(doubl e) ; } ;
int upRounder::Round(double x) doubl e ix; ix = (int)x; return ix 0, akkor van kiirando adat. */ while((len = read(STDIN_FILENO, buf, sizeof(buf))) != 0) {
i f (1 en == -1) if(errno == EINTR) {
continue; // ujra megprobaljuk }
perror (" read") ; return -1;
if(write(STDOUT_FILENO, buf, len) == -1) {
perror("write"); return -1;
return 0;
102
4.1. Egyszerű állománykezelés
A megoldásban a hibakezelés némi magyarázatra szorul. Elképzelhető, hogy mielőtt az olvasásművelet bármilyen adatot beolvasott volna, jelzés érkezik. Ilyenkor az olvasásműveletet ismét megpróbálhatjuk. Ezt az esetet úgy tudjuk azonosítani, hogy az errno az EINTR értéket veszi fel. A fenti programban az állomány végéig olvasunk, ha a read függvény állományvége karaktert olvas, nullával tér vissza; ekkor a ciklus már nem fut le. A terminálon Ctrl + D billentyűkombinációval írhatjuk be az állomány vége karaktert. Tudjuk, hogy ha a visszatérési érték nagyobb, mint nulla, ez a beolvasott adat méretét jelenti. Útmutató Mindig fordítsunk különös figyelmet arra, hogy a read visszatérési értéke, ne pedig a megadott bufferméret alapján elemezzük a buffert. Ha sztringet olvasunk, ne felejtsük el a lezárókaraktert a buffer végén elhelyezni.
Az olvasáshoz hasonlóan az írás is lehet részleges, de ez egyszerű állományok esetében sohasem fordul elő. Speciális állományoknál (például csővezetékek, lásd a 4.5. Csővezetékek alfejezetet) megeshet, hogy a write az adatnak csak egy részét írta ki, és egy ciklusban újra ki kell írnunk a maradékot. Ezt a módszert a megnevezett csővezetékeknél ismertetjük. Ha előre tudjuk, hogy miként szeretnénk használni az állományt, jelentős teljesítményjavulást érhetünk el a posixjadvise függvénnyel. Ennek segítségével az állomány egyes régióira megadhatjuk a használat módját, például azt, hogy az állomány egy régióját használjuk vagy nem használjuk, a közeljövőben használjuk egyszer vagy többször, esetleg szekvenciálisan férünk hozzá. Megjegyzendő még, hogy ha adatszerkezetünk tömbökből áll (például mátrixokat szeretnénk kiírni/beolvasni), akkor ezek kiírása, illetve beolvasása hatékonyabb a bufferek tömbjét kezelő writev és readv fügvényekkel, amelyek egyben atomi végrehajtást is biztosítanak.
4.1.6. Az írásművelet finomhangolása Az írásművelet mélyebb megértéséhez meg kell ismernünk, hogy a Linux hogyan, milyen lépésekben optimalizálja ezt a műveletet. Mivel a merevlemezek sebessége több nagyságrenddel kisebb a processzor sebességénél, érdemes az éppen módosított állományokat a memóriában tartani. Erre a Linux egy lemezgyorsítótárat (disk cache) hoz létre, amelyet laptárnak (page cache) nevezünk. A gyorsítótár virtuálismemória-lapokból áll, ezek tartalma az egy lemezen található blokk adatai. Az összerendelést a lapok és a blokkok között az úgynevezett I/O bufferek tárolják." 55
A lapkezelésű virtuális tárolókkal való integráció eredményeképpen a bufferek tartalmát memórialapok jelentik, a buffereknek csak az összerendelést kell kezelniük, a kernelnek az adatot nem kell külön tárolnia. 103
4. fejezet: Állomány- és I/O kezelés
Olvasáskor a kernel megnézi, hogy az adott blokkhoz tartozó lap bent van-e a laptárban. Ha igen, akkor ezzel tér vissza. Ha nem, akkor a kernel betölti az állomány kért inode-ját a laptárba — mivel egy állományt általában szekvenciálisan olvasunk — az állomány folytatását kijelölő inode-dal együtt. Így az olvasás először a memóriabeli inode-ot keresi, ha nincs bent a memóriában, akkor behozza, ahogy a következő inode-ot is (előretekintő lapozási stratégia). Mivel a virtuális memóriában mindig a legfrissebb lap van, ez mindig a legfrissebb adatot találja meg, és nem jelent problémát, hogy az írás által okozott változás nem jelenik meg a lemezen. A write függvény csak néhány ellenőrzést végez, lemásolja a kiírandó adatot a laptár megfelelő lapjára, ezután visszatér. A memórialapok kiírását a háttértárolóra tisztítószálak (flusher threads) végzik akkor, ha elfogy a memória, a kiíratlan adatok egy adott időintervallumnál hosszabb ideig vannak a memóriában, 56 valamint az sync, illetve az fsync rendszerhívás hatására. Az alábbiakban megnézzük, hogy a kernel hogyan írja ki az adatokat a lemezre. A kernel úgynevezett blokkos eszközvezérlőkön (lásd a 2.5.2.1. Eszközállományok alfejezetet) keresztül írja ki a lapokat. Ez azt jelenti, hogy az adatokat a kernel nem adatfolyamban juttatja el a lemezre írást elvégző eszközvezérlőhöz, hanem egy feladatsorban feladatokat rendel neki. Az optimalizálás miatt mind a kernel, mind az eszközvezérlő felcserélheti a feladatok sorrendjét. A kernel I/O ütemező (I/O scheduler) alrendszere megpróbálja úgy elrendezni az adatokat, hogy az egymáshoz közeli blokkokra vonatkozó írásműveleteket összegyűjti, a lemezmeghajtó hardver működésének megfelelő sorrendbe rakja, így az eszközvezérlő együtt írja őket ki a lemezre. Ez a megoldás kétségkívül gyors, de van néhány hátránya is. Az első probléma a késleltetett írás következménye. Ha az írás rögtön visszatért, nem tudunk hibaüzenetet adni, hiszen a write függvény már rég visszatért, sőt előfordulhat, hogy a program futása is befejeződött, mire a kernel háttérszálai elvégzik a kiírást. A másik probléma a sorrend. Ha minden adat kiíródott, a sorrend sohasem jelent problémát vagy inkonzisztens viselkedést, de ha a rendszer — például áramszünet miatt — összeomlik, akkor kellemetlen következmények lehetnek. A fejezet további részében megmutatjuk, hogy miként kényszeríthetjük a kernelt arra, hogy minden változtatást azonnal írjon ki a lemezre. Lényeges, hogy ezeket a módszereket csak indokolt esetben használjuk, mert jelentősen lelassítják a programunkat, illetve a rendszer működését. A fentiekben részletesen ismertetett bufferelt írás és olvasás ugyanis nagyon hatékony, és nélkülük jelentős teljesítménycsökkenésre számíthatunk.
Ezt a /proc/sys/vm/dirty_expire_centisecs állományban állíthatjuk be századmásodpercekben. 104
4.1. Egyszerű állománykezelés
Mivel ebben az esetben nem használunk buffereket, a lemez tartalma teljesen összhangban, más szóval szinkronban van a kiírt tartalommal. Ezért ezt az állománykezelést szinkronizált I/O-nak (synchronized I/O) nevezzük. Elsőként két függvény mutatunk be:
int Mindkét függvény kiírja a megadott leíró által kijelölt állományt a merevlemezre. Az írásművelet végét meg is várja, csak ezután tér vissza. Ha a merevlemeznek hardveres gyorsítótára van, ezt a függvény nem tudja kezelni, ezért előfordulhat, hogy az adat csak a gyorsítótárig jut, így a lemezre nem íródik ki. Amíg az fsync az állományhoz tartozó metaadatot is kiírja (időbélyek és egyéb inode-adatok), az fsyncdata csak az adatot szinkronizálja. Ugyanakkor egyik sem szinkronizálja az állományhoz tartozó könyvtárbejegyzést, így előfordulhat, hogy egy állomány átnevezése után az állomány a merevlemezen teljesen friss, de egy rendszerösszeomlás után nem érhető el, mert a könyvtárbejegyzés csak a memóriában létezett. Az egész rendszer összes bufferét az alábbi függvénnyel írathatjuk ki a merevlemezre: vold syne(vói ./pipex
A program létrehozta az alábbi állományt az aktuális könyvtárban: S ls pipex -1 prw 1 tihamer staff 0 sep 8 00:23 pipex
Az első p azt jelzi, hogy az állomány típusa csővezeték („pipe"). Ezután elkészíthetjük a csővezeték másik oldalán az írásműveletet végző programot. Ez nem okoz különösebb meglepetést: int main(int argc, char*argv[]) {
ínt fd; int len, res; char* buffer = "Hello!"; len = strlen(buffer)-1; // Nem kuldjuk at a lezarokaraktert fd = open (argv[1], O_WRONLY); i f(fd == -1) {
perror("open"); return -1; }
if( (res = write(fd, buffer, len)) == -1) perror("write");
close(fd); return 0; ]
.11•1 1~--—-
-
-
Érdemes megfigyelni, hogy ha elindítjuk az olvasást végző programot, amíg nem érkezik adat, a read függvény nem tér vissza: várakozik. Ez problémát jelenthet, ha egyszerre több csővezetéket szeretnénk figyelni. Ez a probléma nagyon gyakran előfordul: kezelését a következő fejezetben mutatjuk be.
125
4. fejezet: Állomány- és I/O kezelés
4.6. Blokkolt és nem blokkolt I/O Nézzük meg az alábbi példát. Feladat Írjunk programot, amely két csővezetékről (pipel és pipe2) olvas adatot folyamatosan,
és ezeket kiírja a szabványos kimenetre.
Elsőként deklaráljuk a szükséges változókat, közöttük leírókat, és a buffert, majd megnyitjuk az állományt: int fds[2]; char buf[2048]; int fdix; ínt res;
0111
/* Olvasasra megnyitjuk a pipel es pipe2 allornanyokat, ha leteznek. */ i f((fds[0]=open("pipel", O_RDONLY)) < 0) perror("open pipel"); return 1; } if((fds[1]=open("pipe2", O_RDONLY)) < 0) { perror("open pipe2"); return 1; }
Ezek után felváltva olvassuk a csővezetékeket: fdix=0; while(1) /* Ha az adat rendelkezesre all, beolvassuk, es megjelenitjuk */ res=read(fds[fdix], buf, sizeof(buf) - 1); if(res == 0) printf("A pipe%d lezarult\n", fdix+1); return 0; }
else if(res < 0) perror("read"); return 1;
126
4.6. Blokkolt és nem blokkolt I/O buf [res] = ' \O' ; printf("Pipe%d: %s", fdix+1, buf); /* A masik leí ro kerul sorra. */ fdi x=(fdix+1)%2;
A csővezetékekkel foglalkozó fejezetben láttuk, hogy ha akkor olvasunk a csővezetékből, amikor nincsen rajta adat (a másik oldal nem küldött semmit), az olvasásművelet blokkolódik: adatra várakozik, és csak akkor tér vissza, ha a csővezeték másik oldalán írásművelet történik. A kommunikációnak ezt a módját blokkolt 1/0-nak (blocking I/O) nevezzük. Ez nemcsak azt jelenti, hogy a programunk ki van szolgáltatva egy másik program kénye-kedvének, hanem azt is, hogy két I/O kommunikációban nem vehetünk részt egyszerre. Ugyanis ha adatra várakozunk az egyik csővezeték végén, nem tudunk figyelni arra, hogy milyen adatok jönnek egy másik csővezetéken, vagy hogy a felhasználó milyen billentyűzetparancsot ad ki a billentyűzeten, esetleg a hálózaton. A megoldás kézenfekvő: kérjük meg a kernelt, hogy ne blokkolja az olvasásműveleteinket. Ezt nern blokkolódó (nonblocking) 1/0-nak nevezzük. Ha az állományokat az O_NONBLOCK opcióval nyitjuk meg, akkor az írási-olvasási műveletek nem blokkolják az olvasás- és az írásműveleteket. Ilyenkor a readO rendszerhívás azonnal visszatér. Ha nem állt rendelkezésre olvasandó adat, akkor az olvasási és írási műveletek —1-et adnak vissza, és az errno változót EAGAIN ra állítják. Írás esetén ez azt jelenti, hogy még egyszer meg kell ismételnünk a műveletet. 6° -
Feladat Módosítsuk az előző programot úgy, hogy nem blokkolódó 1/0
-
t használunk.
Ehhez az állomány megnyitását az O_NONBLOCK jelzőbittel végezzük: /* Olvasasra megnyitjuk a pipel es pipe2 allomanyokat, ha leteznek. */ if((fds[0]=open("pípe1", O_RDONLY j O_NONBLOCK)) < 0) {
perror("open pipel"); return 1; }
if((fds[1]--open("pipe2", O_RDONLY { perror("open pípet"); return 1;
O_NONBLOCK)) < 0)
Olvasás esetén kezelnünk kell azt az esetet is, amikor nincs beolvasott adat (az errno értéke EAGAIN):
60
Ez egyszerű állományok esetében sosem fordul elő. 127
4. fejezet: Állomány- és I/O kezelés fdix=0; while(1)
I
/* Ha az adat rendelkezesre all, beolvassuk, es megjelenitjuk *I res=read(fds[fdix], buf, sizeof(buf) - 1); if(res > 0)
I
buf[res] = • \O'; príntf("Pipe%d: %s", fdix+1, buf);
else if(res == 0) printf("A pipe%d lezarult\n", fdix+1); return 0; else if((res < 0) && (errno 1= EAGAIN))
I
I
perror("read"); return 1;
/* A masik leiro kerul sorra. */
I
fdix=(fdix+1)%2;
A nem blokkolt read() abban az esetben, amikor a csővezetéken nem áll rendelkezésre olvasandó adat, de a csővezeték másik oldalát nyitva tartjuk, EAGAIN hibaüzenetet ad visszatérési értékként. A csővezeték másik oldalának lezárását az előző példához hasonlóan a 0 visszatérési érték jelzi. A nem blokkolt I/O kezelés megadja ugyan a lehetőségét, hogy az egyes állományleírók között gyorsan váltogassunk, de ennek az az ára, hogy a program folyamatosan olvasgatja mindkét leírót, és ezzel terheli a rendszert, még akkor is, amikor nincs kommunikáció. Ezért ezt a módszert egyáltalán nem érdemes alkalmaznunk. Az ideális megoldás az, hogy az operációs rendszernek megadjuk, hogy milyen állományleírókat szeretnénk olvasni, és a rendszer értesít az adat rendelkezésre állásáról, továbbá a program erre az értesítésre reagálva olvas. Fontos megjegyezni: annak érdekében, hogy az értesítésre való várakozás közben elkerüljük a blokkolt olvasásnál tapasztalt „elhanyagolt" állományleírók problémáját, a programban a minket érdeklő összes állományleíróra egyszerre és egy helyen kell várakoznunk. Itt különösen jól megfigyelhető az állományabsztrakció ereje: minden olyan objektum elérhető állományként, amelyre érdemes várakozni. A „biztonság kedvéért" ilyenkor nem blokkolódó állományműveleteket használunk. Ezt a megoldást I/O multiplexelésnek (I/O multiplexing) nevezzük, amelyet a Linux többféleképpen is támogat. Az alábbiakban ezeket vesszük sorra.
128
4.7. A multiplexelt I/O módszerei
4.7. A multiplexelt I/O módszerei Az előző fejezetben láttuk, hogy ha egyszerre több adatforrással szeretnénk kommunikálni, akkor multiplexelt 1/0-ra van szükségünk. Ez minden esetben a következő forgatókönyvet jelenti: A) Megkérjük a kernelt, hogy értesítsen, ha az állományleíró készen áll valamilyen I/O műveletre. B) Várakozunk az értesítésre. Ilyenkor a processz „várakozik" állapotba kerül, nem veszi el a processzoridőt más taszkoktól. C) Az értesítés felébreszti a processzt, ekkor megvizsgáljuk, hogy melyik állományleíróról és -műveletról szól az értesítés. D) Végrehajtjuk az I/O műveletet nem blokkolódó üzemmódban. E) Újrakezdjük a B) lépéstől. Ez a megoldás egy más jellegű programvezérlést jelent, mint az eddigi programok. Ahelyett, hogy a program elejétől végig lefutna némi várakozással, a programnak lesz olyan része, amely eseményekre várakozik, és azokra reagál. 61
4.7.1. Multiplexelés a select() függvénnyel A POSIX rendszerekben a multiplexelés legrégebbi megvalósítása a selectO rendszerhívás. Ebben a megoldásban egyetlen rendszerhívás különböző paramétereivel adjuk meg, hogy milyen állományleírókon milyen művelet végrehajhatóságára vagyunk kíváncsiak, maximum mennyi időt szeretnénk várakozni, továbbá ugyanez a rendszerhívás várakozik az eredményekre, és adja vissza őket. A függvény deklarációja a következő: 4include
hmiL
int select(int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct tímeval *timeout);
A középső három paraméter (readfds, writefds és exceptfds) egy-egy halmaz, ezekkel adhatjuk meg, hogy mely állományleírókra vagyunk kíváncsiak, illetve visszatérés után ezek a halmazok tartalmazzák azokat az állományleírókat, amelyek készek a rajtuk való műveletvégzésre. Az első állományleíró-lista, a readfds, azokat az állományleírókat tartalmazza, amelyeken olvasást végez6,
Ha az egész program ilyen vezérlésre épül, eseményvezérelt programnak hívjuk. A 8.3. A Qt eseménykezelés-modellje alfejezetben részletesen bemutatjuk az ilyen programok sajártosságait.
129
4. fejezet: Állomány- és I/O kezelés
hetünk (olvasható adat áll rendelkezésre). A writefds az írásra kész állományleírókra várakozik. Az exceptfds nagyon ritkán használatos. Egy gyakori esetet leszámítva 62 használata meglehetősen esetleges, így a továbbiakban nem foglalkozunk vele. Ha valamilyen hiba történik az állománnyal, akkor az belekerül a readfds be is, és az olvasást végző függvény adja vissza a hibát, valamint egyúttal tipikusan az errno t is beállítja. Ezekből a listákból bármelyik lehet NULL. 63 Mindegyik paraméter egy-egy mutató egy fd_set adatstruktúrára, amely az állományleírók egy halmaza. Ezeket a következő makrókkal kezelhetjük: -
-
Fo_zERo(fd_set *set);
Ez a makró kitörli az állományleíró-listát. Az alábbi a makró használatos a lista inicializálására: FO_SET(int
fd, fd_set *set);
Az alábbi makró az fd leírót hozzáadja a listához: FD_CLR(int
fd, fd_set *set);
A következő makró az fd leírót kitörli a listából: FD_ISSET(int
fd, fd_set *set);
Ez a makró igazzal tér vissza, ha az fd benne van a listában. A select függvény első paramétereként a listákban szereplő legnagyobb állomány leírójánál eggyel nagyobb számot kell megadnunk, így nekünk kell kíkeresni a legnagyobb leírót. A timeout paraméter tartalmazza azt a maximális időt, ameddig a selectO várakozhat. Ha ez letelik, akkor a selectO mindenképpen visszatér. A select() visszatérésekor módosítva adja vissza az értéket, jelezve, hogy mennyi idő van még hátra a megadott maximális várakozási időből, ez azonban nem mindegyik rendszeren van így, ezért tekintsük ezt az értéket definiálatlannak. A fentiek tükrében a select függvényt az alábbi forgatókönyv szerint használhatjuk: A) Megkérjük a kernelt, hogy értesítsen akkor, ha az állományleíró készen áll valamilyen I/O műveletre. 1. Összerakjuk a megfelelő halmazokat (FD_SET). Mivel a select majd módosítja az átadott leíróhalmazt, ezeket célszerű lemásolni, azaz eltárolni egy másik változóban. Mivel a leíróhalmazon nem lehet végigiterálni, külön eltároljuk az állományleírókat, esetünkben egy fds tömbben. 62
63
Socketek esetén soron kívüli adat érkezett (lásd a 6.2. Az összeköttetés alapú kommunikáció alfejezetet). A select függvényt sokszor használják portolható várakozásra, ilyenkor az utolsó kivéte-
lével az összes argumentumot lenullázzuk. 130
4.7. A multiplexelt I/O módszerei
2. Kiszámoljuk a legnagyobb állományleírót. 3. Inicializáljuk a timeval struktúrát. B) Várakozunk az értesítésre. Ilyenkor a processz „várakozik" állapotba kerül, nem veszi el a processzoridőt más taszkoktól. 1. Meghívjuk a select függvényt. C) Az értesítés felébreszti a processzt, ekkor megvizsgáljuk, hogy melyik állományleíróról és -műveletről szól az értesítés. 1. Megvizsgáljuk a select visszatérési értékét. a) Ha kisebb, mint nulla, akkor hiba történt. Kiírjuk a hibát, majd kilépünk. b) Ha nulla, akkor lejárt a maximum-várakozásiidő, de semmi változás nem történt a leírókkal. Itt elvégezzük azokat a műveleteket, amelyekért a maximumidőt beállítottuk, majd E-től folytatjuk. c)
Ha nagyobb, mint nulla, akkor ez a három halmazban található összes leíró száma.
2. Felhasználva az Al -ben készített fds tömböt, megnézzük, hogy annak elemei benne vannak-e a halmazban (FD_ISSET). D) Végrehajtjuk az I/O műveletet nem blokkolódó üzemmódban. 1. Ha az állomány benne van a readfds-ben, addig olvassuk, amíg van olvasandó adat. A nulla érték továbbra is az állomány végét jelenti, csővezetékeknél (és socketeknél) azt, hogy a kapcsolatot a másik oldalon lezárták. Ha lezárták, és folytatni akarjuk a kommunikációt, kivesszük az állományleírót a halmazokból, és folytatjuk E-től. 2. Ha az állomány benne van a writefds-ben, akkor írhatjuk. E) Újrakezdjük a B) lépéstől. 1. Az Al-ben készült másolat alapján visszaállítjuk a leíróhalmazokat, és beállítjuk a timeval struktúrát. 2. Folytatjuk B-től. Ennek tükrében vizsgáljuk meg a következő egyszerű példát. Feladat Módosítsuk az előző programot úgy, hogy a multiplexelést select függvénnyel oldjuk meg.
131
4. fejezet: Állomány- és I/O kezelés /* multipipe3.c - A pipel-t es a pipe2-t olvassa parhuzamosan, a select() metodussal.*/ #include #include #include #include
int main(voíd) { int fds[2]; char buf[2048]; ínt í, res, maxfd; fd_set watchset; /* A figyelendo leirok. */ fd_set inset; /* A select() metodus altal frissitett lista. */ /* Olvasasra megnyitjuk a pipel es pípet allomanyokat, ha l eteznek. */ if((fds[0]=open("pipel", O_RDONLY I O_NONBLOCK)) < 0) {
perror("open pipel"); return 1; if((fds[1]=open("pipe2", O_RDONLY I O_NONBLOCK)) < 0) {
perror("open pipe2"); return 1;
/* A ket leírot elhelyezzek a listaban. */ FD_ZER0(&watchset); FD_SET(fds[0], &watchset); FD_SET(fds[1], &watchset); /* Kiszamoljuk a legnagyobb leiro erteket. */ maxfd = fds[0] > fds[1] ? fds[0] : fds[1]; /* A ciklus addig tart, amíg legalabb egy leirot figyelunk. while(FD_IssET(fds[0], &watchset) II FD_ISSET(fds[1], &watchset)) {
/* Lemasoljuk a leirolistat, mert a select() metodus modositja. */ inset=watchset; if(select(maxfd 4- 1, &ínset, NULL, NULL, NULL) < 0) perror("select"); return 1;
132
4.7. A multiplexelt I/O módszerei
/" Ellenorizzuk, mely leiro tartalmaz olvashato adatot."/ for(i=0; i < 2; i++) {
if(FD_ISSET(fds[i], &inset)) /* Az fds[i] olvashato, ezert olvassuk is. res = read(fds[i], buf, sizeof(buf) - 1); if(res > 0)
./
{
buf[res] = '\0'; printf("Pipe%d: %s", 1+1, buf); else if(res == 0) {
/* A pipe-ot lezartak. "/
close(fds[1]); Fo_CLR(fds[1], &watchset); el se perror("read"); return 1;
return 0;
Ez a program hasonló eredményt ad, mint a nem blokkolt módszert használó, ám jóval takarékosabban bánik az erőforrásokkal. Összehasonlítva a programot a korábban kifejtett forgatóköny D.1. pontjával, látható, hogy nem olvastuk addig az állományt, ameddig csak lehetett (az EAGAIN bekövetkeztéig). Elviekben kétféle eseményről kaphatunk értesítést: •
az adott állományra adat érkezett, így olvashatóvá vált, illetve
•
olvasható adat áll rendelkezésre az adott állományban.
Az első esetben csak a változásról kapunk értesítést, a másik esetben mindaddig, amíg az állományleíró olvasható állapotban van. Az első módszert élvezérelt (edge triggered), a másodikat szintvezérelt (level triggered) értesítésnek nevezzük. A későbbiekben tárgyalt epoll-t kivéve mindegyik módszer szintvezérelt. Ezért, ha az állományt addig olvassuk, amíg tudjuk, megkíméljük magunkat néhány felesleges select hívástól, illetve az argumentumok felépítésétől, de adatot akkor sem veszítünk, ha egyszerre csak egybuffernyi adatot olvasunk be. A select függvényt a BSD Unix vezette be, a hívásnak van egy POSIXverziója is:
133
4. fejezet: Állomány- és I/O kezelés #include ‹sys/select.h> int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *ntimeout, const sigset_t *sigmask);
A legfontosabb különbség a kettő között az, hogy a pselect a várakozás idejére képes letiltani jelzéseket a jelzésmaszkot tartalmazó utolsó paraméter alapján. Visszatéréskor a kernel visszaállítja az eredeti beállításokat. Akad két apróbb különbség is: a maximum-várakozásiidőt nagyobb felbontással adhatjuk meg a timespec struktúrával, és a függvény ezt az értéket nem írja felül. Bár ennek a struktúrának nanoszekundumot is megadhatunk, a gyakorlatban nincsen nagy jelentősége, mert ezek a függvények a mikroszekundum felbontást sem tudják garantálni. A select és a pselect legfontosabb előnye a hordozhatóság. Legnagyobb hátránya az, hogy első paraméterként a legnagyobb leírót kell megadnunk, és a függvények a színfalak mögött az összes kisebb leírót figyelemmel kísérik, függetlenül attól, hogy benne van-e valamelyik halmazban. Természetesen a várakozási időt sem kényelmes minden hívás után beállítani, de ezt a problémát a pselect kiküszöböli.
4.7.2. Multiplexelés a poll() függvénnyel A selectO mellett a Linux rendelkezik egy másik hasonló eszközzel is: a poll0lal. Ez a függvény a System V Unix válasza a multiplexelésre, amely erőforrás-takarékosabb, mint a selectO. Ellentétben a select függvénnyel, a poll számára minden leíróhoz jelzőbitekkel megadjuk azokat az eseményeket, amelyekről értesítést szeretnénk kapni. A függvény ahelyett, hogy ezeket felülírná, egy másik jelzőbitsorozattal adja vissza az adott leíróhoz tartozó értesítéseket. Ezt az adatstruktúrát a
pollfd definiálja: struct pollfd { int fd; short events; short revents; 1;
/* állományleíró */ /* figyelt események */ /* vissszaadott (bekövetkezett) események */
Az fd mezőnek a megnyitott állományleírót adjuk értékül. Az events mező adja meg, hogy a pollQ az adott állomány mely eseményeit figyelje. Ez egy olyan bitmaszk az adott eseményekre, amely a 4.7. táblázatban bemutatott jelzó'bitek VAGY kapcsolatából áll össze:
134
4.7. A multiplexelt I/O módszerei
4,7. táblázat. A poll jelzőbitjei
kdt•
Jelentés
POLLIN
Adat érkezett, amelyet beolvashatunk.
POLLPRI
Soron kívüli adat érkezett, amelyet beolvashatunk.
POLLOUT
Írhatunk.
POLLWRBAND
Soron kívüli adatot írhatunk.
A Linux ezeken kívül további jelzőbiteket is ismer, jó néhány közülük ekvivalens
a fentiek valamelyikével. A select readfds halmazának a POLLIN I POLLPRI bitkombináció felel meg, míg a writefds nek a POLLOUT POLLWRBAND. Az revents mező tartalmazza az értesítéseket, vagyis az events mezőben megadott események közül azokat, amelyek bekövetkeztek. Van három esemény, amelyet mindenképpen visszakapunk, ha megtörténtek, ezért ezeket nem kell megadnunk figyelendő eseményekként. Ezeket a 4.8. táblázat foglalja össze. -
4.8. táblázat. A poll által figyelhető események Jelzőbit
Jelentés
POLLERR
Hiba történt az állományleíróval.
POLLHUP
A kapcsolatot lezárták. 64
POLLNVAL
Rossz állományleírót adtunk meg.
A poll rendszerhívás ezekből az eseményekből álló tömböt, annak méretét és egy várakozási időt vár: 4include int sem_init(sem_t *sem, int pshared, unsigned int value);
Ha a pshared nem 0, akkor a szemaforhoz más processzek is hozzáférhetnek, egyébként csak az adott processz szálai. A szemafor értéke a value paraméterben megadott szám lesz. Megnevezett szemafort az alábbi függvénnyel hozhatunk létre: finclude sem_t *sem_open(const char *name, int oflag, ...);
Az első paraméter a szemafor neve, a második a megnyitás jelzőbitjei az állomány megnyitásánál megismert O_CREAT és O_EXCL, amelyekhez az fcntl.h állományt is be kell építenünk. Ha az O_CREAT jezló'bitet beállítjuk, akkor egy harmadik és egy negyedik paramétert is meg kell adnunk. Ekkor a függvény prototípusa így néz ki:
233
5. fejezet: Párhuzamos programozás sem_t *sem_open(const char *name, int oflag, mode_t mode, unsigned int value); _ _
A további paraméterek a jogosultságok és a szemafor kezdeti értéke. Vegyük sorra a műveleteket. Egy sem szemafor értékét a
#include
"aele•~1~•11~el
int sem_wait(sem_t *sem); int sem_trywait(sem_t *sem);
függvényekkel csökkenthetjük eggyel. Ha a sem szemafor értéke pozitív, mindkét függvény eggyel csökkenti az értékét, és visszatér 0-val. Ha a szemafor értéke 0, a sem_trywait azonnal visszatér EAGAIN értékkel, míg a sem_wait várakozik a szemafor pozitív értékére. Ez utóbbi várakozást vagy a szemafor állapota (az értéke nagyobb lesz, mint nulla), vagy egy jelzés szakíthatja meg. A szemafort értékét használat után a #include int sem_post(sem_t *sem);
függvénnyel növelhetjük eggyel. A szemafor aktuális értékét a #include int sem_getvalue(sem_t *sem, int *sval);
függvénnyel kérdezhetjük le. Ha a szemafort lefoglalták, akkor a visszatérési érték nulla vagy egy negatív szám, amelynek abszolút értéke megadja a szemaforra várakozó processzek számát. Ha a sval pozitív, ez a szemafor aktuális értékét jelenti. Névtelen szemafort a #include int sem_destroy(sem_t *sem);
11111111L~211=1
függvénnyel szüntethetünk meg. Megnevezett szemafort a #include int sem_close(sem_t *sem);
függvénnyel zárhatunk le.
234
--
.__
5.5. POSIX-szinkronizáció
A megnevezett szemaforok a processz kilépésekor vagy az exec függvénycsalád hívásakor automatikusan lezáródnak. Mivel a megnevezett szemaforok perzisztensek, a close függvény hatására nem szabadulnak fel, és értéküket is megőrzik. Ha a close után újra meghívjuk a sem_open függvényt a nevével, ugyanazt a szemafort kapjuk vissza, ugyanolyan állapotban, ahogy a processzek hagyták. A megnevezett szemafort a sem_unlink függvénnyel törölhetjük. A szemafor az állományokhoz hasnonlóan nem törlődik rögtön, a kernel megvárja míg az összes processz lezárja a leírót, és utána szabadítja csak fel. Ugyanakkor az a processz, amely a sem_unlinket hívta, már nem nyithatja meg újra. Feladat Készítsünk olyan programot, amely egy repülőtéri bejelentkezést szimulál. Hozzunk létre egy POSIX-szemafort, ahol a szemafor a légitársaság pultjait jelenti. Ezúttal egy nagy közös sor van, és a szemafor kezdeti értéke jelzi a pultok maximális számát. Egy szál hozza létre a szemafortömböt, egy másik szál pedig a következőt: „menjen oda" az első üres pulthoz, ha minden pult foglalt, álljon be abba a sorba, ahol legkevesebben várakoznak. Az utasok nem feltétlenül érkezési sorrendben kerülnek sorra, mert a túl későn érkező utasokat a később indulók előreengedhetik.
Elsőként az utasszálat mutatjuk be: /* Globalis valtozok *7 sem_t szemafor; int utas_szamlalo=0;
`_
void* utas_szal (void"arg) {
í nt sorsz am=" (i nt" - )arg ; Free(arg): printf("A %d. utas sorban all...\n",sorszam); /* varakozunk a szemaforra: innen csak akkor jutunk */ /* tovabb, ha egy pult szabad lesz */ sem_wait(&szemafor); printf("A %d. utas sorra kerult...\n",sorszam); sleep(KISZOLGALASI_IDO) ; printf("A %d. utas tavozott...\n",sorszam); /* El engedjuk a szemafort sem_post(&szemafor); return NULL;
tr i
}
235
5. fejezet: Párhuzamos programozás
A szál megpróbálja lefoglalni a szemafort. Ha nem sikerül, várakozik, vagyis sorba áll. Ha sikerül, akkor a szemafort, vagyis a szabad pultok számát csökkenti eggyel, várakozik a kiszolgálási ideig, majd megnöveli eggyel a szemafort, és kilép („távozik"). A főprogram egy lehetséges implementációja az alábbi: int main() {
int ch; int* p_sorszam; pthread_t szal_leiro; /* Beallitjuk a szemafor erteket */ sem_init(&szemafor,O,PULTOK_SZAMA); printf("Nyomjon u-t uj utas kuldesehez, k-t a kilepeshez!\n"); do do ch=getchar(); }while(ch!='u'&&ch!='k'); utas_szamlalo++; p_sorszam=malloc(sizeof(int)); *p_sorszam=utas_szamlalo; pthread_create(&szal_leiro,NULL,utas_szal,p_sorszam); while(ch!='k'); printf("Pultokat bezartak, sorban allok hazamennek...\n"); return 0; }
A főprogram a felhasználói bemenet alapján hozza létre a szálakat, vagyis az utasokat. Kilépéskor az összes szál megszűnik, vagyis az összes utas eltűnik, ez feladatunkban nem okoz problémát, ezért nem várakozunk a szálakra, így leíróikat sem mentjük el.
5.5.4. Spinlock Amikor egy szál nem tudja lefoglalni a mutexet, rögtön várakozó állapotba kerül. Amikor a mutex állapota megváltozik, akkor a szálat fel kell ébreszteni, amely újra megpróbálja lefoglalni a mutexet. Ha a mutexet csak nagyon rövid ídeig fogják a szálak, akkor a várakozás helyett érdemes rögtön újra
236
5.5. POSIX-szinkronizáció
próbálkozni. Ha ez sorozatosan nem sikerül, akkor az állandó próbálkozással pazaroljuk a processzoridőt. Ha viszont ez rendre sikerül, akkor a várakozásfelébresztés idejét megtakarítottuk. Pontosan ezt a forgatókönyvet valósítja meg a spinlock. Feladat Készítsünk szálbiztos FIFO-tárolót spinlockkal.
Elsőként deklarálnunk kell a spinlock objektumot, majd gondoskodnunk kell az inicializálásáról és a felszabadításáról. templatecclass Type> cl ass Fi fo
pthread_spinlock_t spin; } ;
/* Al apertel mezett konstruktor '/ template Fi fo : Fi fo() /* Inicializaljuk a spi nlockot es a tarolo strukturakat. pthread_spi n_i ni t(&spi n , 0) ;
*1
}
/* Destruktor */ templ a te Fi fo: :-Fi fo ()
pthread_spi n_destroy(&spi n) ;
A Lock
osztály végzi a lefoglalást és felszabadítást:
cl ass Lock {
pthread_spi nlock_t* spin; publ ic: Lock (pthread_spi n1 ock_t*spi n) : spin(spin){pthread_spin_lock(spin);} -Lock {pthread_spin_unlock(spin);} ; 1;
237
5. fejezet: Párhuzamos programozás
Pusztán az osztály alapján nem tudjuk megmondani, hogy érdemes-e a mutexet spinlockra cserélni, hiszen az elemek kivétele és elhelyezése során a használattól függően gyakori vagy nagyon ritka is lehet. Útmutató Általában használjunk mutexet, hacsak nem vagyunk meggyőződve az ütközés ritkaságáról.
5.5.5. További lehetőségek: POSIX megosztott memória és üzenetsorok A POSIX megosztott memóriát az shm_open függvénnyel hozhatjuk létre, és az shm_unlink függvénnyel szüntethetjük meg. A megosztott memóriát a processzek a megnevezett szemaforhoz hasonlóan / karakterrel kezdődő névvel tudják azonosítani. A POSIX-üzenetsorok szintén név alapján azonosíthatók független processzekből, szülő-gyermek viszony esetén a leíró öröklésével oldható meg a közös üzenetsor létrehozása. Megnyitáskor egy leíró jön létre (mq_open). A küldő- (mq_send) és az olvasó- (mq_receive, mq_timedreceive) függvények a read/write-hoz hasonló buffereket várnak. Az üzenetekhez prioritásokat is rendelhetünk. Az olvasófüggvények alapértelmezésben blokkolódnak, a nem blokkolódó üzemmódot nekünk kell beállítani. A szemaforhoz hasonlóan létezik lezárás (mq_close) és megszüntetés (mq_unlink).
5.6. Jelzések A jelzések a Linux programozásának szinte minden területén előfordulnak, így az előző fejezetekben is számtalanszor utaltunk rájuk. A jelzések lényegében egyidősek a Unix rendszerekkel. A jelzés (signal) a processzek közötti aszinkron kommunikáció egy formája, amelynek segítségével egy egész számot lehet küldeni, és amelyet a processz soron kívül kezel. Az aszinkron tulajdonság azt jelenti, hogy általános esetben a jelzést küldő processz nem várakozik a küldés után, hanem rögtön folytatja a futását, nem kap értesítést arról, hogy a jelzés célba ért-e. Minden jelzéshez egy egyedi egész számot rendelünk, amely egytől számozódik. Az ezekhez a számokhoz rendelt szimbolikus konstansokhoz a signal.h állományon keresztül férhetünk hozzá (a signum.h állomány tartalmazza őket). A konstansok SIG előtaggal kezdődnek, például a program futásának megszakítására szolgáló SIGINT a kettes sorszámú szignált jelöli (#define SIGINT 2). A soron kívüli kezelés azt jelenti, hogy a processz normál végrehajtása megszakad, lefut a jelzést kezelő kód, majd utána folytatódik az ere-
238
5.6. Jelzések
deti végrehajtás. E miatt a tulajdonságuk miatt a jelzéseket gyakran a megszakításokhoz hasonlítják. Jóllehet a jelzéseket elsősorban processzek közötti kommunikációra használjuk, egy folyamat küldhet magának is jelzéseket. Sőt ez a kommunikációs forma szálak között is használható. Bár jelzéseket bármelyik processz küldhet, a jelzések legfőbb forrása maga a kernel. Az alábbi esetekben a kernel nagyon gyakran jelzésekkel küld értesítést. •
Valamilyen hardveresemény történt, tipikusan hardveres megszakítás érkezett, és a kernel erről értesíteni szeretné a processzeket (például időzítés, nullával való osztás).
•
A felhasználó a terminálfelületen valamilyen speciális billentyűkombinációt nyomott le, például Ctrl + C SIGINT jelzést generál.
•
Valamilyen szoftveresemény történt: egy állományleíró olvashatóvá vált, soron kívüli adat érkezett a hálózaton, gyermekprocessz befejezte a futását.
A jelzések alapkoncepciója nagyon egyszerű: egy folyamat vagy a kernel egy egész számot küld a programunknak, ennek hatására a program normális futása megszakad, és a programon belül automatikusan meghívódik egy kezelőfüggvény, amely reagál a jelzésre, vagy ha a processz nem regisztrált kezelőfüggvényt, a kernel vagy figyelmen kívül hagyja, vagy egy alapértelmezett kezelőfüggvénnyel kezeli a jelzést. Az eddigi fejezetekben pontosan így tekintettünk a jelzésekre. Ugyanakkor ebben a témakörben különösen igaz az, hogy az ördög a részletekben rejlik. A Unix rendszereknek meglehetősen sok kísérletezésre volt szüksége ahhoz, hogy a jelzések megbízható kommunikációs módszerré váljanak, azaz ne vesszenek el a processzek tudta nélkül. Ahhoz, hogy biztonsággal használjuk a jelzéseket, részletesen meg kell értenünk a működésüket és a helyes használat részleteit. Ebben segítenek a következő fejezetek.
5.6.1. A jelzésküldés és -fogadás folyamata A küldő processz szempontjából meglehetősen egyszerű a helyzet. A jelzés küldésére felhasználói üzemmódban a kill, tgkill, valamint a sigqueue rendszerhívások valamelyikével lehet utasítani a kernelt. Jelzést egy adott processznek, processzcsoportnak vagy szálnak lehet küldeni. Ha a processznek nem volt joga a címzettnek jelzést küldeni, a rendszerhívások hibával térnek vissza. Ezt a folyamatot jelzésküldésnek (sending a signal) nevezzük. A címzett processz reakcióját egy jelzésre a jelzés elrendezésének (disposition) nevezzük. A címzett processz háromféleképpen rendezheti el a küldött jelzést:
239
5. fejezet: Párhuzamos programozás
•
Figyelmen kívül hagyja: ilyenkor a processz állapota és futása ugyanaz marad, mintha a jelzés meg sem érkezett volna.
•
Lefuttat egy függvényt, az úgynevezett jelzéskezelőt (signal handler). Ezt a függvényt a processznek előre be kell regisztrálnia.
•
Hagyja, hogy a kernel egy alapértelmezett funkciót hajtson végre.
A jelzéstől függően a kernel alapértelmezett funkciója háromféle lehet: •
Figyelmen kívül hagyja a jelzést (F).
•
Terminálja a processzt Labnormal process termination", T).
•
A processz memóriabeli és regisztereinek aktuális állapotát elmenti egy állományba (core dump), majd terminálja (CD).
•
A processz felfüggesztett (STOPPED) állapotba kerül (STOP).
•
A processz felfüggesztett állapotból a futásra kész (RUNNING) állapotba kerül (FUT).
Az egyes jelzéseket és a kernel alapértelmezett funkcióját az 5.9. táblázat táblázat tartalmazza. 5.9. táblázat. A jelzések azonosítója, leírása és alapértelmezett elrendezése
Jelzés
Leírás
Funkció
SIGABRT
Az abort0 függvény generálja.
CD
SIGALRM
Egy alarmQ által felhúzott időzítő lejárt.
T
SIGBUS
Memória-hozzáférési problémák.
CD
SIGCHLD
A gyermekprocesszt terminálták vagy felfüggesz- F tették.
SIGCONT
A processz a leállítás után folytatja a futását.
FUT
SIGHUP
A processz terminálját becsukták.
T
SIGFPE
Lebegőpontos számítási hiba.
CD
SIGILL
A processzor illegális utasítást hajtott végre.
CD
SIGINT
A felhasználó megszakításkaraktert (^C) küldött T a terminálon keresztül.
SIGIO
Aszinkron 1/0: olvasható adat érkezett.
T
SIGKILL
Mindenképpen terminálja a processzt.
T
SIGQUIT
A felhasználó kilépési karaktert (^\) küldött.
CD
SIGPIPE
A processz olyan csővezetékbe írt, amelynek nin- T csen olvasója.
SIGPROF
A profiler által használt időzítő lejárt.
T
SIGPWR
Tápfeszültséghiba.
T
240
5.6. Jelzések
IJelzés
Leírás
Funkció
SIGSEGV
Illegális memóriacímhez való hozzáférés.
CD
SIGSTOP
Mindenképpen felfüggeszti a folyamat futását.
STOP
SIGTERM
Kezelhető (felülbírálható) processzmegszüntetés. T
SIGTRAP
Töréspont nyomkövetéshez.
SIGTSTP
A felhasználó felfüggesztéskaraktert (^Z) küldött. STOP
SIGTTIN
A háttérben futó alkalmazás megpróbálta olvasni STOP a terminált.
SIGTTOU
A háttérben futó alkalmazás írni próbált a termi- STOP nálra.
SIGWINCH
A terminál mérete megváltozott.
SIGURG
Sürgős I/0 esemény.
F
CD
SIGUSRI
Programozó által definiált jelzés.
T
SIGUSR2
Programozó által definiált jelzés.
T
SIGXCPU
CPU időszeletének túllépése.
CD
SIGXFSZ
Fájlméret határának átlépése.
CD
SIGVTALRM
A setitimer() által felhúzott virtuáls időzítő lejárt. T
A fenti táblázatot áttanulmányozva láthatjuk, hogy a Linux elég szigorú: ha a tipikus forgatókönyv szerínt általában kezelnünk kellene egy jelzést, akkor a kerne] alapértelmezésben terminálja a programot, nehogy rejtve maradjon a hiba. Ha komoly hardverhiba történik, amely után könnyen előfordulhat, hogy a hiba felderítése érdekében nyomon szeretnénk követni a processzt, ott a kernel core dump állományt is készít. A processz felfüggesztésével és újraindításával kapcsolatos jelzések kezelését a terminál feledatvezérlése szerint alakították ki. A rendszer elvárt működése érdekében a kernel fenntartja a lehetőséget, hogy bizonyos jelzések elrendezését ne lehessen megváltoztatni. Ezeknek a jelzéseknek a nevét vastagon szedtük a táblázatban. Ugyanígy a 0 processzazonosítójú processznek nem lehet jelzést küldeni, míg az 1-es processznek (ínit) csak olyanokat továbbít a kernel, amelyre jelzéskezelőt definiál. Ha egy processz jelzést kap, megszakítja a futását, és végrehajtja az adott jelzéshez tartozó elrendezést. Mivel a pontos feltételek implementációfüggők, érdemes azzal a feltételezéssel dolgoznunk, hogy bármelyik két utasítás között érkezhet jelzés, és végrehajtódhat az elrendezése. Ha a jelzéskezelőból használjuk a program változóit, a fenti feltételezés tükrében oda kell figyelnünk a versenyhelyzetekre. Vegyük példaként az alábbi pszeudokódot:
241
5. fejezet: Párhuzamos programozás
int idozito; static void idozito_jelzeskezelo
I
idozito = 1;
i nt main() { // idozito_jelzeskezelo beregisztrálása a SIGALRM jelzésre idozito = 0; alarm(150); // 150 nanoszekundum múlva SIGALRM jelzés while(!idozito) { pause(); // A pause függvény a következő jelzésig várakozik }
I A fenti programrészlet egy klasszikus hibát tartalmaz. A megoldás alapgondolata az, hogy a főprogram úgy várakozik egy meghatározott ideig, hogy felhúz egy órát. Az óra a beálított idő leteltével egy SIGALRM jelzést küld. A főprogram és a jelzéskezelő egy globális változón keresztül kommunikál: az idozito értéke az induláskor nulla, a jelzéskezelő állítja be egyre. A főprogram addig várakozik, amíg a változó értéke egy nem lesz. A pause függvény egy jelzésre vár. A while ciklusra azért van szükség, hogy ha más jelzés ébresztené fel a pause-t, akkor tovább várakozzunk A fenti program az esetek nagy részében jól működik. Ám ha az ütemezés éppen úgy alakul, elképzelhető, hogy a jelzés az időzítő vizsgálata után, de még a pause függvény meghívása előtt következik be. Addig, ameddig a program más jelzést nem kap, várakozik — ez pedig tetszőleges ideig eltarthat. A megoldás az lenne, ha beállíthatnánk egy kritikus szekciót, amelynek a futása alatt a processz nem fogadhatna jelzéseket. Viszont ha eközben jelzés érkezík, annak nem szabad elvesznie, ezért egy várakozási sorban kell tartani. Természetesen ennek az alapgondolatnak többféle variációja létezhet, például a várakozási sor feldolgozásának sorrendje sokféle lehet, vagy egy adott típusú jelzés többszöri előfordulásakor csak egyet tárolhatunk. Első megoldásként tételezzük fel az alábbi lehetséges megoldást. Minden egyes jelzéshez hozzárendelünk egy engedélyezőbitet. Ha ez a bit igaz, a processz kezeli a jelzést, ha nem igaz, akkor a kernel nem kézbesíti a jelzést. Mivel ez utóbbi esetben a jelzés nem veszhet el, minden jelzéshez felállítunk egy várakozási sort is. Amikor egy nem engedélyezett jelzés érkezik, a kernel beleteszi ebbe a sorba Amikor a processz újra engedélyezi a jelzéseket, akkor a kernel az éppen érvényes elrendezés szerint kézbesíti a várakozási sorokban található jelzéseket: a legkisebb értékűhöz tartozó sort üríti ki először. Természetesen — a várakozási sorok FIFO-jellege miatt — érkezési sorrendben dolgozza fel egy adott sor elemeit. Az ezen az elven működő jelzéseket valós
242
5.6. Jelzések
idejű jelzéseknek (real-time signal) nevezzük. A valós idejű jelzéseket a sigqueue függvény segítségével adhatjuk hozzá a várakozási sorokhoz, a SIGRTMIN és a SIGRTMAX közötti jelzésekkel a kezdő és a végértéket is beleértve. Mivel a jelzés értéke ilyenkor csak a prioritásról árulkodik, a sigqueue további paraméterek elküldését is lehetővé teszi. Sokszor a valós idejű jelzések által nyújtott lehetőségeknél jóval kevesebbre van szükségünk. Nézzük meg példaként azt az esetet, amikor egy időzítőeseményre szeretnénk valamilyen feladatot periodikusan elvégezni. Előfordulhat, hogy a processzor leterheltsége miatt az események egymásra futnak: egy eseményre még nem tudtunk reagálni, de az újabb már megérkezett. Ilyenkor nem probléma, ha arra az eseményre, amelyról amúgy is lemaradtunk, nem reagálunk. A torlódás leegyszerűsítésével szemben viszont nem szeretnénk, ha egy jelzés elveszne. Ezalatt pontosan azt értjük, hogy ha a jelzés feldolgozásának megkezdése után még egy ugyanolyan jelzés érkezik, arról külön értesítést szeretnénk kapni, nem veszhet el. Ezt érdemes úgy tekintenünk, hogy az egyes jelzésekhez tartozó várakozási sor egyelemű, vagyis egyszerre egy adott típusból csak egy feldolgozatlan jelzést tudunk tárolni. Az így működő jelzéseket hagyományos jelzéseknek (traditional signals) nevezzük. A hagyományos jelzéseket a kill rendszerhívással küldhetünk.
Útmutató Mivel a hagyományos jelzések esetében a torlódó jelzésekről a processz nem kap külön értesítést, a jelzéseket nem érdemes számolni, mert egy adott jelzéssorozatra a processzor terhelésétöl és egyéb rendszerjellemzőktől függő eredményt kapunk. Mind a hagyományos mind a valós idejü jelzések esetén egy elküldött jelzésre a címzett maximum egyszer reagálhat („consumable resource"). Jelzés egyik modellben sem veszhet el, pusztán az egymásra torlódott jelzésekről csak egy értesítést kap a címzett. A valós idejű jelzéseknél a sorrend adott, a hagyományos jelzéseknél nincs előírva, de az implementációk a processz adott állapotát érintő jelzéseket általában előbb kézbesítík. A kerneltől kapott jelzések hagyományos jelzések, mínt ahogy a Linux rendszer és a programok többsége is ilyen jelzéseket küld. A fentiekben tehát a kritikus szekciónak csak az egyik felét vizsgáltuk: a kritikus szekciót úgy alakítottuk ki, hogy a jelzéseket letiltottuk, valamint egy szigorú és egy megengedőbb megoldást ismertettünk arra, hogy a jelzések a letiltás alatt ne vesszenek el. Viszatérve a példánkhoz, a kritikus szekciót az óra felhúzása előtt beállítjuk. A SIGALRM hagyományos jelzés: ha a kritikus szekcióban már érkezik jelzés, az nem veszik el, ha több is érkezik, csak egyre hívódik meg a jelzéskezelő: int idozito; static void idozito_jelzeskezelo {
idozito = 1;
243
5. fejezet: Párhuzamos programozás
int main() {
// idozito_jelzeskezelo beregisztrálása a SIGALRM jelzésre idozito = 0; // Az SIGALRM letiltása sigprocmask(...); alarm(150); // 150 nanoszekundum múlva SIGALRM jelzés whi le( ! i dozi to) // Kritikus szekció vége sigprocmask(...)
SIGALRM engedélyezése
pause(); // A pause függvény a következő jelzésig várakozík
A fenti programrészlettel sajnos nem sokkal vagyunk előrébb: ha a jelzés a kritikus szekció vége (sigprocmask) és a pause függvény között következik be, a főprogram ugyanúgy nem ébred fel, mint azelőtt. A pause helyett olyan atomi műveletre van szükségünk, amely osztatlanul engedélyezi a SIGALRM jelzést, és várakozik a legközelebbi jelzésig. Ez a sigsuspend függvény. Argumentumában megadhatjuk azokat a jelzéseket, amelyre várakoznia kell, ezeket engedélyezi. Ha a megadott jelzések valamelyike érkezik, először megvárja a jelzéskezelő lefutását, és csak utána tér vissza. Viszont ha sigsuspend a jelzéskezelő lefutása után tér vissza, akkor a jelzéskezelő futása után rögtön le kell tiltania a megadott jelzéseket, vagyis vissza kell állítania az egyes jelzések engedélyezettségét a hívás előtti állapotokba. Ugyanis ha nekünk kellene ezt megtennünk egy soron következő sigprocmask hívással, akkor jelzéseket veszthetnénk: int idozito; static void idozito_jelzeskezelo() {
idozito = 1;
int main() {
// idozito_jelzeskezelo beregi sztrálása a SIGALRM jel zésre idozito = 0; /7 Az SIGALRM letiltása
sigprocmask(...); alarm(150); // 150 nanoszekundum múlva SIGALRM jelzés
244
while(!idozito) // Kritikus szekció vége - SIGALRM engedélyezése, várakozás sigsuspend(...); // Az átadott jelzés a SIGALRM // visszatérés után a hívás elötti állapotot állítja vissza
Ezzel megoldottuk a jelzésre várakozás problémáját anélkül, hogy jelzéseket vesztettünk volna. Útmutató A pause függvény helyett használjunk sigsuspendet.
Speciális jelzés a nulla értékű jelzés: ha ezt küldjük, a kernel semmit nem kézbesít, viszont ellenőrizhetjük, hogy az adott azonosítójú processznek jogunk van-e üzenetet küldeni, vagy azt, hogy a processz megtalálható-e a rendszerben. Ez utóbbira jobb megoldás a wait függvények használata (ha a szülő szeretné tudni, hogy a gyermeke fut-e) vagy közös szinkronizációs objektumok, zárolások, illyetve IPC-mechanizmusok használata, amelyeket a kilépő processz átállít. Ezek a technikák ugyanis nem okoznak problémát akkor sem, ha a nem használt azonosítókat a kernel új processzekhez rendeli.
5.6.2. Jelzések megvalósítása A kernel szempontjából ez valamivel összetettebb folyamat. Tegyük fel, hogy processz vagy maga a kernel egy jelzésküldést kezdeményez. Ha a küldő folyamatnak volt ehhez joga, a kernel értesíti a címzett processzt a jelzés érkezéséről, vagyis beállítja a processz megfelelő adatstruktúráit. A kernel szempontjából ez a jelzésküldés folyamatának az első része, amelyet a jelzés generálásának (signal generation) nevezünk, míg a folyamat második része a jelzés kézbesítése (signal delivery). A kézbesítés folyamán a kernel feldolgoztatja a címzett processzel a jelzést. Azokat a generált jelzéseket, amelyek még nem lettek kézbesítve, függőben lévő (pending) jelzéseknek nevezzük. Az egyes jelzések engedélyezett/letiltott állapotát a jelzésmaszk (signal mask) tárolja. Minden folyamathoz tartozik egy jelzésmaszk, amelyet a már említett sigprocmask függvénnyel állíthatunk. A SIGSTOP és a SIGKILL jelzéseket nem lehet letiltani. A kernel megpróbálja kiszűrni az egyszerűbben kezelhető eseteket. Elsőként megvizsgálja, hogy a generálandó szignált nem hagyja-e figyelmen kívül a processz. Ha ez így van, akkor a jelzés nem is generálódik. Ezt csak akkor lehet megtenni, ha a jelzés engedélyezett, mert letiltott jelzés nem veszhet el, hiszen a processz engedélyezés előtt megváltoztathatja a jelzés elrendezését.
245
5. fejezet: Párhuzamos programozás
A második eset az, amikor a kernel jelzést generál, és megpróbálja azonnal kézbesíteni. Ez akkor lehetséges, ha a címzett processz már fut. Ez úgy fordulhat elő, hogy a kernel hardvermegszakítás miatt generálta a jelzést (például nullával való osztás), amelyet az éppen futó processznek kell kézbesítenie. A másik lehetőség az, ha a processz saját magának küldte a jelzést. Ezekben az esetekben a generálás után azonnal megtörténik a kézbesítés is. A nem azonnal kézbesített jelzések esetében a generáláskor a kernel várakozási sorba helyezi el a jelzést, és beállít egy jelzőbitet a processzleíróban. A kernel két várakozási sort tart fenn a függőben lévő jelzések számára: a privát várakozási sort szálanként hozza létre, míg a megosztott várakozási sor a processznek szóló jelzéseket tartalmazza. A várakozási sor maximális méretét az operációs rendszer konfigurációs paraméterei között tartjuk nyilván (RLIMIT SIGPENDING). Ha a generált jelzés nem valós idejű, és van már ilyen jelzés a várakozási sorban, akkor nincs további lépésre szükség, a generálás véget ér. Ellenkező esetben a kernel hozzáadja a jelzést a megfelelő sorhoz. Ha a jelzés engedélyezve van, akkor a kernel beállítja a processzben a jelzés érkezését jelölő jelzőbitet, és megpróbálja felébreszteni a processzt, vagyis várakozási állapotból átviszi a futásra kész állapotba, és hozzáadja a futásra kész processzek listájához. A kernel minden egyes alkalommal, amikor kernelüzemmódból felhasználói üzemmódba kapcsol azért, hogy egy adott processzt futtasson, ellenőrzi, hogy ennek a processznek van-e függőben lévő jelzése. Tipikusan ilyen átkapcsolás történik a taszkváltás, illetve rendszerhívásokból való visszatérés esetén. Ha vannak ilyen jelzések, akkor a kernel kézbesíti őket. Így, amikor processz futtatása legközelebb sorra kerül, a kezdetét veszi a kézbesítés folyamata. Mivel a kernel nem hoz létre minden jelzésnek külön várakozási sort, csak kettőt tart fenn, kézbesítéskor nem sorrendben dolgozza fel az üzeneteket, hanem „válogat" a bennük lévő jelzések között. A kernel elsőként elkezdi kiüríteni először a privát, majd utána a megosztott várakozási sort. Először a legalacsonyabb számú jelzéseket dolgozza fel, az azonos értékűeket pedig érkezési sorrendben. Ha a jelzés elrendezése a kernel alapértelmezett mechanizmusa, akkor meghívódik az alapértelmezett mecahnizmus. Jól ismert kivételt jelent az ínit processz, ez esetben ugyanis a kernel figyelmen kívül hagyja a jelzést. Ha a jelzés elrendezése egy jelzéskezelő meghívását írja elő, akkor ezt a kernelnek kell meghívnia. Mivel a függvény a felhasználói címtérben van, a kernelnek át kell kapcsolnia. Ez több problémát is felvet. A függvénynek nem a veremben található címre kell visszatérnie, hanem a kernelbe. Ezért a kernel a jelzéskezelő hívása előtt beállítja a sigreturn függvény címét, amely egy rendszerhíváson keresztül visszatér a kernelbe. Ráadásul, mivel a jelzéskezelők rendszerhívásokat is használhatnak, fontos, hogy a kernel helyett ne a megszakított főprogramba térjen vissza a függvény. Ezért a jelzéskezelő futtatásához a kernel nem használhatja a megszakított program veremtartalmát.
246
5.6. Jelzések
A Linux erre azt a megoldást használja, hogy létrehoz egy vermet a felhasználói címtartományban (ennek helyét mi is megadhatjuk a signaltstack függvénnyel), arra átmásolja a kernel kontextusát, köztük a jelzés esetleges paramétereit, ezután beállítja a visszatérési címet a sigreturn címére, majd elugrik a jelzéskezelő kezdőcímére. A jelzéskezelő végeztével a sigreturn rendszerhívás átkapcsol kernelüzemmódba, visszaállítja az eredetileg megszakított program kontextusát, majd átadja neki a vezérlést. Sokszor nem akarunk visszatérni a jelzéskezelőből, kényelmesebb elugrani egy program adott pontjára. Ehhez a setjmp longjmp párost választhatjuk. Az előbbi elmenti a kontextust a program egy adott pontján egy bufferba, a longjmp pedig visszaállítja. Ez azért fog működni, mert a longjmp a kernel által beállított vermet felülírja a program setjmp által elmentett kontextusával, beleértve az utasításszámlálót is. Így a program a megfelelő veremtartalommal és kontextussal folytatja a futását a setjmp által kijelölt helytől. Könnyen lehet azonban, hogy a jelzéskezelőben más jelzések vannak letiltva és engedélyezve, mint a setjmp által meghatározott ponton. Mivel a setjmp nem menti el a jelzésmaszkot, a jelzéskezelőben érvényes maszkkal fut tovább a program. Ha ezt el szeretnénk kerülni, akkor a használjuk a sigsetjmp és a siglongjmp függvényeket, amelyek mindössze annyiban különböznek a setjmpllongjmp párostól, hogy ezek elmentik, illetve visszaállítják a jelzésmaszkot is. A processz létrehozásával kapcsolatos szabályok az alábbiak. Ha a fork hívással hozunk létre új processzt, az örökli a szülő által beállított elrendezéseket és a jelzésmaszkot, viszont a függőben lévő jelzéseket nem. Az exec függvénycsalád a jelzéskezelőkre beállított elrendezést alapértelmezettre állítja, ugyanis az új program betöltésével a jelzéskezelők címe érvénytelenné válik. Minden egyéb jelzésekkel kapcsolatos beállítás megmarad. -
5.6.3. A jelzéskezelő és a főprogram egymásra hatása Elsőként vizsgáljuk meg annak hatását, hogy egy jelzés milyen állapotban éri a processzt. Azt az esetet már korábban tárgyaltuk, amikor a processz éppen fut: ilyenkor a kernel a jelzést azonnal kézbesíti. A rendszer várakozó állapota valójában kétféle állapotot takar: megszakítható (INTERRUPTIBLE) és megszakíthatatlan (UNINTERRUPTIBLE). Ez a megkülönböztetés pontosan a jelzések miatt létezik: megszakíthatatlan állapotban a processz nem fogadhat jelzést. Ilyenkor a generálás megtörténik, de a kézbesítés majd csak akkor, ha a processz elhagyja ezt az állapotot. A processz általában meglehetősen kevés időt tölt ebben az állapotban, főként merevlemezzel kapcsolatos műveletek esetében, ezért ez legtöbbször nem okoz problémát. Nagyon ritkán, de előfordulhat, például valamilyen lemezhiba folytán, hogy a processz beragad ebbe az állapotba. Természetesen ilyenkor még SIGKILL t sem képes fogadni, csak a rendszer újraindítása segít a prob-
247
5. fejezet: Párhuzamos programozás
lémán. Ezt elkerülendő, a Linux egy újabb állapotot is bevezet a megszakíthatatlan állapoton belül, ez a KILLABLE. Ebben az állapotban csak a SIGKILL érkezésekor ébreszti fel (futó állapotba viszi át) a processzt, amelynek a futása a jelzés fontosságának megfelelően rögtön megszakad. A megszakítható állapotban érkezett jelzések különösen fontosak a programozó számára, ugyanis ebben az állapotban szoktak várakozni a blokkolódó rendszerhívások, például a read függvény. Ilyenkor a jelzés megszakítja a rendszerhívást, majd a kernel lefuttatja a jelzés elrendezését. Ezután két választási lehetőségünk van: a rendszerhívásból visszatérünk EINTR értékkel, vagy újraindítjuk a rendszerhívást. A jól megírt program fel van készülve az EINTR visszatérési értékre, és szükség szerint újraindítja a rendszerhívást. Ezt már bemutattuk a 4.1.5. Részleges és teljes olvasás alfejezetben: while((len = read(STDIN_FRENo, buf, sizeof(buf))) != 0) { if(len == -1) { i f(errno == EINTR) {
continue; // ujra megprobaljuk
perror("read"); return -1;
if(write(STDOUT_FILENO, buf, len) == -1) perror("write"); return -1; }
Erre a megoldásra akár makrót is definiálhatunk: ha a visszatérési érték hiba, és az errno értéke EINTR, akkor egy while ciklusban még egyszer meghívjuk a függvényt: #define SIGNAL_RESTART(FUNC) while((FuNC) == -1 && errno == EINTR); • • •
SIGNAL_RESTART(len = read(STDIN_FILEN0, buf, sizeof(buf))) i f(len = -1) // További hibák kezelése
248
5.6. Jelzések
Minden pontenciálisan blokkolódó hívás még a fenti makróba csomagolva sem a legkényelmesebb megoldás. A sigaction függvény SA_RESTART paraméterével megadhatjuk, hogy az egyes jelzéseknél a rendszer automatikusan újraindítsa (újra meghívja) a megszakított rendszerhívást. Ennek használata elég körülményes, mert jelzésenként kell beállítani az újraindítást, valamint nem minden rendszerhívás indítható újra. Ezt az információt a kézikönyv 7. fejezetében található signal oldala írja le. A másik problémát a függvények nem globális adatai okozhatják (globális változók, statikus lokális változók). Például a printf függvény globális buffereket módosít. Ha a printf egy futása éppen módosítja a globális buffert, de még nem végzett a művelettel, ebben az inkonzisztens állapotban meghívódik egy jelzéskezelő, amely szintén hív egy printf-et, amely a globális buffert inkonzisztens állapotban találja. Ilyenkor nagyon nehéz megjósolni, mi lesz a program működése, azt viszont nem nehéz, hogy ez nagy valószínűséggel nem az elvárt viselkedés lesz. Hasonló a probléma a memóriafoglalásnál és -felszabadításnál (malloclfree), amelynek számos implementációja globális láncolt listában tartja az adatokat. Ezeket a függvényeket nem reentráns függvényeknek nevezzük. Ha a jelzéskezelőben is és a főprogramban is használunk nem reentráns függvényeket, a program működése definiálatlan. Ezt általában az alábbi szabály betartásával szokták elkerülni. Útmutató Ne használjunk a jelzéskezelőben nem reentráns függvényeket.
A reentráns függvények listáját szintén a signal (7) kézikönyoldala tartalmazza (jelzésbiztos függvényeknek nevezve őket). Tipikusan azok a függvények nincsenek ezek között, amelyek valamilyen I/O műveletet végeznek, vagy a malloc/ free függvények valamelyikét hívják. A következő egymásra hatás abból ered, hogy egy műveleti hardveresemény által kiváltott jelzés (SIGBUS, SIGILL, SIGSEGV, SIGFPE) feldolgozása után a megszakított program ugyanonnan folytatja a futását, ahol abbahagyta. Ha például nullával való osztás történt, a jelzés feldolgozása és a jelzéskezelő normál visszatérése után folytadódik a program, amely újra előidézi a nullával való osztást és vele együtt a jelzést is. Útmutató A hardveresemény által kiváltott jelzések esetében törekedjünk a legegyszerűbb megoldásra: hagyjuk érvényesülni az alapértelmezett kezelés mechanizmusát. Ha ez mégsem oldható meg, azok jelzéskezelőit vagy ugróutasítással (siglongjmp, longjmp), vagy az exit függvénnyel hagyjuk el.
Ha egy hardveresemény által kiváltott jelzést figyelmen kívül hagyunk, a kernel ennek ellenére kézbesíti. Ha letiltjuk őket, akkor terminálja a programot.
249
5. fejezet: Párhuzamos programozás
5.6.4. Jelzések és a többszálú processz Többszálú programok esetén a szabványok és az implementációk arra törekedtek, hogy megtartsák a jelzések eredeti viselkedését abban az esetben, amikor a processznek küldünk jelzéseket, ugyanakkor megpróbálták ezt kiegészíteni egy intuitív és használható szálak közötti jelzésküldéssel. Egy többszálú programban a szálak nem osztoznak az alábbiakon: •
jelzésmaszk,
•
a már említett privát várakozási sor,
•
a kernel általt a jelzéskezeló'k számára létrehozott ideiglenes verem.
Ez azt jelenti, hogy a jelzéselrendezések viszont közösek a szálakra nézve: ha egy szál megváltoztat egy elrendezést, az az egész processzre, így a többi szálra is érvényes. Az alapértelmezett elrendezések közül a STOP és a terminálás processzszintű: a kernel az összes szálat megállítja, illetve terminálja. A legfontosabb kérdés az, hogy ki kapja a többszálú processznek küldött jelzést. Az egyes szálaknak külön küldött jelzést (pthread_kill, pthread_sigqueue) az adott szál kapja. Az azonnal kézbesített jelzéseket (harverjelzések és a processz önmagának küldött jelzései), valamint a SIGPIPE jelzést szintén. A többit a kernel mind a processz várakozási sorában helyezi el. Ha a kernel a processz várakozási sorában lévő jelzést dolgoz fel, amelynek elrendezése egy jelzéskezelő, akkor kiválaszt egy tetszőleges szálat, amely nem tiltja le az adott jelzést, és ezt megszakítva futtatja le a jelzést. Ha mindegyik blokkolja a jelzést, az természetesen a várakozási sorban marad. Sajnos a szálkezelő függvényeket, beleértve a szinkronizációt is, nem használhatjuk a jelzéskezelőben. Útmutató Szálakban ne használjunk aszinkron jelzéskezelést. Lehetőleg egyetlen szálban engedélyezzük a jelzéseket, és ott szinkron módon kezeljük őket.
5.6.5. Jelzések programozása A jelzések működése után vegyük sorra a programozás részleteit. A programkódban kétféleképpen valósíthatunk meg jelzéskezelést. Az egyik a már ismertetett megoldás, amikor jelzéskezelőt adunk meg. A másik lehetőség — amely nagyon hasonlít a select/poll függvényekhez — az, hogy letiltjuk az összes letiltható jelzést, és egy függvénnyel várakozunk arra, hogy a processz/szál várakozási sorába jelzés érkezzen Amikor a függvény visszatér, kezeljük a jelzést. Ez utóbbi sokszor nagyon praktikus lehet — kifejezetten szálak esetén —, sem a reentráns függvényekkel, sem pedig a jelzések elveszítésével nem kell foglalkoznunk. Az első módszert aszinkron jelzéskezelésnek (asynchronous signal handling), míg a másodikat szinkron jelzéskezelés-
250
5.6. Jelzések
nek (synchronous signal handling) nevezzük. Hangsúlyozni kell, hogy csak a jelzéskezelés szinkron, a kommunikáció formája továbbra is aszinkron, hiszen a jelzést küldő processz/szál nem várakozik. A jelzéskezélésnél viszont nem tetszőleges helyen szakítja meg a programot, hanem szinkronizáltan, a várakozófüggvény belsejében. Nem kezelhetjük szinkron módon sem a SIGKILL, sem a SIGSTOP jelzéseket, hiszen ezek elrendezését nem változtathatjuk meg, nem tilthatjuk le őket, valamint nem is várakozhatunk rájuk. A továbbiakban sorra vesszük azokat a függvényeket, amelyekkel mindezt megvalósíthatjuk.
5.6.5.1. Jelzések küldése Jelzéseket a ki//0 rendszerhívással küldhetünk: #include #include int kill(pid_t pici, int sig);
Apid argumentum a processz azonosítója, a sig pedig a jelzés azonosítója. A pid speciális értékeivel lehetőségünk van arra, hogy az ínit processzen és saját magán kívül az összes processznek (-1) küldjünk jelzést. Ha a sig argumentum nulla, akkor a ki//0 függvény nem küld jelzést, de a hibaellenőrzést elvégzi. Így megnézhetjük, hogy van-e jogunk és lehetőségünk jelzést küldeni egy processznek. Ha a pid kisebb, mint nulla, akkor a kernel a változó abszolút értékének megfelelő processzcsoportnak küldi a jelzést. Ezt a konverzíót elvégzi helyettünk az alábbi függvény, ahol közvetlenül megadhatjuk a processzcsoport azonosítóját: #include #include ,nt main() sigset_t alarmMask, fullmask; int fd; struct signalfd_siginfo info; int ret; sigemptyset(&alarmmask); sigaddset(&alarniMask, SIGALRM); // Az összes jelzés letiltása si gfi 1 1 set(8/ful 1 mask) ; sigprocmask(5IG_BLOCK, &filllmask, NULL); /7 Leíró létrehozása fd = signalfd(-1, &alarmmask, 5Fp_N0NBL0Ck); alarm(10); // 10 masodperc múlva SIGALRM jelzés // A select az stdinnre es a jelzés leírójára figyel fd_set readfds; FD_SET(STDIN_FILENO, &readfds); FD_SET(fd, &readfds); select(fd+1, &readfds, NULL, NULL, 0); if(FD_ISSET(fd, &readfds)) // Na benne van az eredményhalmazban {
ret = read(fd, &info, sizeof(struct signalfd_siginfo)); printf("%d processz jelzest kuldott.\n", info.ssi_pid); }
26]
5. fejezet: Párhuzamos programozás
close(fd); // Le kell zárnunk az állományleírót printf("megérkezett vagy megnyomták.\n"); return(alarm(0)); // Kikapcsoljuk az időzitőt }
5.6.6. A SIGCHLD jelzés A processzek tárgyalásánál (lásd az 5.1. Processzek alfejezetben) már említettük, hogy a processzek a kilépés után nem szűnnek meg teljesen: néhány lekérdezhető statisztikát és a kilépési értéket tartalmazó adatstruktúrákat megőrzik, hogy a szülőprocessz le tudja kérdezni ezeket. Ezt a processzállapotot neveztük zombinak. A zombiprocesszeket úgy szabadíthatjuk fel, hogy meghívjuk valamelyik wait függvényt vagy blokkok, vagy nem blokkolt módon. A gyermekprocessz kilépéséról, azaz zombiállapotba kerüléséról a szülőprocessz SIGCHLD jelzést kap. Mivel a wait függvények jelzésbiztosak, a jelzésből rögtön meg is hívhatjuk ó'ket az alábbiak szerint: i nt oldErrno = errno; while (waitpid( - 1, NULL, WNOHANG) > 0) continue;
errno = oldErrno;
A fenti kódrészlet végigiterál az összes kilépett (zombi) gyermekfolyamaton. Ha nincs több ilyen gyermekfolyamat, a függvény nullával tér vissza, és a ciklus véget ér. Akkor is kilépünk, ha hiba történt. Mivel az errno változót a főprogram is használhatja, ezért elmentjük, és visszaáWtjuk, ha a waitpid esetleg megváltoztatta volna. A zombik begyűjtésére ez a leghordozhatóbb megoldás. Útmutató A jelzéskezelőkben mindig gondoskodjunk az errno változó elmentéséről és visszaállításáról, ha olyan függvényt hívunk, amely átállíthatja az errno változót.
Ha azt szeretnénk, hogy a szülő értesítést kapjon arról, hogy a gyermekprocessz jelzést kapott, akkor a sigaction függvénynek meg kell adnunk a SA_NOCLDSTOP jelzőbitet, amikor a SIGCHLD jelzéskezelőjét beállítjuk. Ha le szeretnénk tiltani azt, hogy egy már létező processz zombivá alakuljon, több módszer is létezik. A SIGCHLD jelzés alapértelmezett beállítása a figyelmen kívül hagyás. Ennél az egy jelzésnél mást jelent, ha meghagyjuk az alapértelmezettet, vagy expliciten beállítjuk a jelzés figyelmen kívül hagyását. Az utóbbi esetben a processz gyermekprocesszeiből nem képződik zombi,
262
5.6. Jelzések
kilépéskor megszűnnek A beállítás előtt már zombivá alakult folyamatok viszont nem szűnnek meg. Nagyon hasonló működést érhetünk el, ha a sigaction hívás SA_NOCLDWAIT kapcsolójával állítjuk be e jelzéskezelőt. Ilyenkor szintén nem alakulnak zombivá a gyermekprocesszek, viszont megszűnésükről jelzést kapunk. Ekkor természetesen nem férünk hozzá a statisztikákhoz és a visszatérési értékekhez. Figyeljünk arra, hogy a jelzéskezelőt vagy a figyelmen kívül hagyást még az első gyermekprocessz létrehozása előtt beállítsuk, különben lemaradhatunk az értesítésről.
263
HATODIK FEJEZET
Hálózati kommunikáció Mai, számítógép-hálózatokkal átszőtt világunkban a Linux rendszerek egyik fő alkalmazási területét a hálózati alkalmazások jelentik. Ebben a fejezetben ezeknek a kommunikációknak a megvalósítási alapjait ismerjük meg. Nem foglalkozunk az összes protokollal, jóllehet a Linux többet is támogat (TCP/IP, AppleTalk, IPX stb.). Ezek részletes ismertetése meghaladná a könyv kereteit. Vizsgálódásunk egyik területe a Berkeley socket-API, amely tulajdonképpen egy általános kommunikációs interfész. Mi két implementációját tárgyaljuk. A legfontosabb a TCP/IP protokoll használata, amely lényegében működteti az internetet. A másik, egyszerűbb protokoll a Unix domain socket, amely tulajdonképpen nem hálózati kommunikáció, hanem egy olyan IPC-mechanizmus, amely csak egy gépen belül használható, ám a programozás hasonlósága miatt itt tárgyaljuk. Továbbá megismerhetjük a TCP/IP-re épülő távoli eljáráshívást (Remote Procedure Calling, RPC). Ez egy magasabb szintű, általában egyszerűbben használható kommunikációs metódus.
6.1. A socket Mint a Linux más erőforrásait, a socketeket is a fájlabsztrakciós interfészen keresztül implementálták a rendszerbe. A hálózati kapcsolatnak azokat a végpontjait, amelyeket a programozó használhat, socketeknek nevezzük, ezek egyben egy állománytípust is jelentenek. Létrehozásuk a socket0 rendszerhívással történik, amely egy állományleíróval tér vissza. Miután a socketet inicializáltuk, a read0 és a write() függvényekkel kezelhető, mint minden más állományleíró, használat után pedig a close0 függvénnyel le kell zárnunk. Új socketeket a socket0 rendszerhívással hozhatunk létre, amely az inicializált socket állományleírójával tér vissza. Létrehozásakor a sockethez egy meghatározott protokollt rendelünk, ám ezek után még nem kapcsolódik sehová. Ebben az állapotában még nem olvasható vagy írható:
6. fejezet: Hálózati kommunikáció
#include
int
.ocket (i nt domain, í nt type, int protocol);
Mint az openO, a socket() is 0-nál kisebb értékkel tér vissza hiba esetén, és ha sikeres, az állományleíróval, amely 0 vagy annál nagyobb. Három paraméter definiálja a használandó protokollt. A domain a protokollcsaládot adja meg, és értéke a 6.1. táblázatban található lista valamelyike: 6.1. táblázat. A protokolcsctládokhoz tartozó értékek Protokoll
Jelentés
PF_UN1X, PF_LOCAL
Unix domain socket (gépen belüli kommunikáció)
PF INET
IPv4 protokoll
PF INET6
IPv6 protokoll
PF IPX
Novell IPX
PF NETLINK
A kernel felhasználói interfésze
PF X25
X.25 protokoll
PF AX25
AX.25 protokoll, a rádióamatőrök használják
PF ATMPVC
Nyers ATM-csomagok
PF APPLETALK
AppleTalk
PF PACKET
Alacsony szintű csomaginterfész
A következő paraméter, a type, a protokollcsaládon belül a kommunikáció módját definiálja a 6.2. táblázatban található értékek egyikével. 6.2. táblázat. A kommunikáció lehetséges módjai Típus
Jelentés
SOCK STREA11,1
Sorrendtartó, megbízható, kétirányú, kapcsolatalapú byte-folyam-kommunikációt valósít meg.
SOCK DGRA111
Datagramalapú (kapcsolatmentes, nem megbízható) kommunikáció.
SOCK SEQPACKET
Sorrendtartó, megbízható, kétirányú, kapcsolatalapú kommunikációs vonal, fix méretű datagramok számára.
SOCK RAW
Nyers hálózatiprotokoll-hozzáférést tesz lehetővé.
SOCK RDM
Megbízható datagramalapú kommunikációs réteg. (Nem sorrendtartó.)
SOCK PACKET
Elavult opció.
266
6.2. Az összeköttetés-alapú kommunikáció
Az utolsó paraméter, a protocol, a protokollcsaládon belül konkretizálja a protokollt az adott kommunikációs módhoz. A családokon belül általában csak egy protokoll létezik egy típusú kommunikációra, amely egyben az alapértelmezett. Így ennek a paraméternek az értéke leggyakrabban O. Például a PF_INET családon belül a TCP (Transmission Control Protocol) az alapértelmezett folyamprotokoll, és az UDP a datagramalapú. Ám egy kivételt is mutathatunk. Ez a SOCK RAW kommunikációs mód. A SOCK RAW mód például a PF INET család választásával lehetővé teszi olyan socketek létrehozását, ahol az IPv4 kommunikációs protokoll implementálását a felhasználói tartományban végezzük el. Ám ennél többre is képes. Az utolsó, protocol paraméterként megadhatunk a 0 mellett olyan értékeket is, mint az IPPROTO_RAW, az IPPROTO_ICMP, az IPPOROTO_IGMP, az IPPROTO TCP, az IPPROTO_UDP. Ezzel olyan protokollokat is elérhetünk a családból, amelyet a többi paraméterrel nem (ICMP, IGMP). 78 A kommunikációs típusok listáját végignézve láthatjuk, hogy alapvetően két részre oszlanak: a kapcsolat- vagy összeköttetés-alapú, illetve a kapcsolat vagy összeköttetés nélküli kommunikáció. Az összeköttetés-alapú kommunikáció egy kapcsolódási folyamattal kezdődik, amelynek eredményeképpen egy folyamatos kapcsolatot hozunk létre két fél között. Ennek során lényegében egy kétirányú csatorna jön létre, amelyen a programok oda-vissza kommunikálhatnak. A kommunikáció végén pedig le kell bontani a csatornát. Az összeköttetés nélküli kommunikáció esetében viszont elmarad a kapcsolódási folyamat. A kiküldött csomagjainkat külön-külön címezzük és küldjük a többi kommunikációs résztvevőnek. Továbbá bárkitől kaphatunk is csomagot. A kommunikáció végén a kapcsolatot sem kell lebontanunk, csak abbahagyjuk a kommunikációt. Analógiaként nézhetjük a telefonálást és az SMS-küldést. A telefonálás a kapcsolatalapú kommunikációra hasonlít. Tárcsáznunk kell a kommunikációs partnerünket, neki fogadnia kell a hívást, beszélgetünk, és a végén bontunk. Az SMS esetén azonban nem kell végigvinnünk a hívási folyamatot, csak megcímezzük az üzenetet, és elküldjük. Ugyanígy bármikor kaphatunk másoktól is üzeneteket.
6.2. Az összeköttetés-alapú kommunikáció Első lépésként az összeköttetés-alapú kommunikációt tekintjük át. Ennek a kommunikációs módnak lényeges része a kapcsolat felépítése Amikor ez létrejön, a kommunikáció hasonlóan történik, mint korábban a csővezetékeknél láttuk. Majd a párbeszéd végén valamelyik fél bontja a kapcsolatot. Nézzük meg sorban ezeket a műveleteket. 78
A SOCK RAW módot csak rendszergazdai jogosultságokkal futó folyamatokban használhatjuk. 267
6. fejezet: Hálózati kommunikáció
6.2.1. A kapcsolat felépítése Ha egy összeköttetés-alapú kapcsolatotadatfolyam-socketet (stream socket) hoztunk létre, akkor hozzá kell kapcsolódnunk egy másik géphez. A socket kapcsolódása aszimmetrikus művelet, vagyis a létrejövő kapcsolat két oldalán a feladatok eltérnek egymástól. A kapcsolat felépítését követően a kommunikáció viszont már szimetrikus. A kliensoldalon a socket létrehozása után kapcsolódunk (connect) a szerverhez. Ha a kapcsolódás sikeres (a szerver elfogadja a kapcsolódási kérelmet), akkor a socketen mint állományleírón keresztül tudunk adatot küldeni és fogadni. Ezt a típusú socketet klienssocketnek nevezzük. A szerver és a kliens közti lényeges különbség a kapcsolat felépítésében és a kezelendő socketek számában rejlik. Ennek megfelelően a szerveroldalon kétfajta socket található. Az egyiket a továbbiakban szerversocketnek hívjuk. Funkciója szerint várakozik (listen) egy címen, amelyet előre megadtunk (bind), és arra vár, hogy a kliensoldali socketek kapcsolódjanak (connect) hozzá. Ha egy kapcsolódási kérelem érkezik, a szerver elfogadja (accept), ennek eredményeképpen létrejön egy klienssocket. Ezen keresztül történik az adatcsere. A kapcsolódási kérelem elfogadása után a kommunikáció a szerversockettől függetlenül zajlik, a szerversocket csak további kapcsolódási kérelmekre vár. A szerveroldalon ezért gyakran több klienssockettel kell törődnünk.
6.2.2. A socket címhez kötése Ahhoz, hogy a kliens meg tudja címezni a szervert a kapcsolódáskor, a szervernek az adott címhez kell kötnie a socketjét. Ez egy olyan helyi cím, ahol a szerver a bejövő kapcsolatokat várja. A kliensprogramnál is lehetőség van lokális cím megadására, ez azonban nem kötelező, mert nem hivatkozunk a címre. Ilyenkor a rendszer automatikusan egy címet generál a kapcsolódáskor. A címhozzárendelés műveletét kötésnek (binding) nevezzük, és a bind() rendszerhívással tehetjük meg: #include int bí nd(i nt sock , struct sockaddr *my_addr, socklen_t addrlen);
Az első paraméter a socket leírója, a második a címet leíró struktúra, az utolsó a címet leíró struktúra hossza. A címstruktúra alakja itt nincs specifikálva, mivel protokollonként eltérő a címábrázolás. Mindig az adott protokoll címalakját használjuk. Mivel az egyes címtípusoknak a méretigénye eltérő, ezért kell megadnunk a cím hosszát.
268
6.2. Az összeköttetés-alapú kommunikáció
6.2.3. Várakozás a kapcsolódásra A címhez kötött socketünk önmagában még nem alkalmas a kapcsolatok fogadására, még nem lesz belőle szerversocket. A processz a listen() rendszerhívással állítja be a socketre ahhoz, hogy fogadja a kapcsolódásokat, majd a kernel kezeli a kapcsolódási igényeket. Egy kapcsolódási igény beérkezése után azonban még nem épül fel azonnal a kapcsolat. A várakozó processznek az acceptO rendszerhívással kell elfogadni a kapcsolódást. Azokat a kapcsolódási igényeket, amelyeket az accept0-tel még nem fogadott, függőben lévő kapcsolatnak (pending connection) nevezzük. Normál esetben az accept() függvény blokkolódik, amíg egy kliens kapcsolódni nem próbál hozzá. Természetesen átállíthatjuk az fenti() rendszerhívás segítségével nem blokkolódó módba is. Ilyenkor az accept0 azonnal visszatér, amikor egyetlen kliens sem próbál kapcsolódni. Mivel a szerversocket is állomány, a selectQ vagy a poll() rendszerhívást is alkalmazhatjuk a kapcsolódási igények észlelésére. Ezekben az esetekben a szerversocketre érkezett kapcsolódási igény mint olvasási esemény érzékelhető. A listen() és az accept() függvények formája a következő: #include int listen(int sock, int backlog); int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);
Első paraméterként mindkét függvény a socket leíróját várja. A listen() második paramétere, a backlog, amely megadja, hogy hány kapcsolódni kívánó socket kérelme után utasítsa vissza az újakat. Vagyis ez a függőben lévő kapcsolatok várakozási lista mérete. Az accept0 fogadja a kapcsolódásokat. Visszatérési értéke az új kapcsolat leírója, egy klienssocket. Az addr és az addrlen paraméterekben a másik oldal címét kapjuk meg. Az addr által mutatott struktúrába a cím, az addrlen által mutatott változóba pedig a cím mérete kerül.
6.2.4. Kapcsolódás a szerverhez A kliens a létrehozott socketet a szerverhez hasonlóan a bind0 rendszerhívással hozzárendelheti egy helyi címhez. Ezzel azonban a kliensprogram általában nem törődik, a kernelre bízza, hogy ezt automatikusan megtegye. Ezek után a kliens a connectO rendszerhívással kapcsolódhat a szerverhez: #include int connect(int sock, struct sockaddr *addr, socklen_t addrlen);
269
6. fejezet: Hálózati kommunikáció
Az első paraméter a socket leírója, a további paraméterek a szerversocket címét adják meg. A kapcsolat felépülését a 6.1. ábra mutatja. Kliens
Szerver
socket() bind() (isten() socket()
accept()
connect()
•
write()
Kommunikáció ►
•
read()
read() 4
write()
close()
close()
6.1. ábra. Az összeköttetés-alapú kommunikáció
6.2.5. A kommunikáció A kapcsolat felépítését követően a szerveroldalon az accept0 által visszaadott klienssocketet, kliensoldalon pedig a kapcsolathoz létrehozott socketet két összekapcsolt állományleíróként használhatjuk, mint korábban a csővezetékeknél. Amit az egyik oldalon a write() függvénnyel beleírunk, azt a másik oldalon a read0 függvénnyel olvashatjuk. Ám eltérően a csővezetékektől a kommunikáció kétirányú, vagyis mindkét oldalon írhatunk és olvashatunk is
6.2.6. A kapcsolat bontása A socketkapcsolat lebontása tipikusan az állományoknál megismert close0 rendszerhívással történik Ha a kapcsolat egyik végét lezárjuk, akkor azt a másik oldal is érzékeli, amikor legközelebb olvassa a kapcsolat klienssocketjét. Ekkor a socket már nem használható további kommunikációra, ezért a leírót a túloldalon is lezárjuk. 270
6.2. Az összeköttetés-alapú kommunikáció
A shutdown0 rendszerhívás lehetőséget ad arra is, hogy a kétirányú kommunikációból csak az egyik irányt zárjuk le: #include ‹sys/socket.h> int shutdown(int sockfd, int how);
Az első paraméter a socket leírója. A második a lezárás módját adja meg a 6.3. táblázatban található értékek közül. 6.3, táblázat. A shutdown módjai
Típus
Jelentés
SHUT RD
Csak az olvasási ágat zárjuk le. Az olvasás „állomány vége" jelzést (0 visszatérési érték) ad vissza. Írni továbbra is tudunk a socketbe.
SHUT WR
Csak az írási ágat zárjuk le. Amikor a túloldal teljesen kiolvasta a korábban elküldött tartalmat, egy „állomány vége" jelzést kap. A további írási próbálkozásra hibát kapunk, olvasni viszont továbbra is tudjuk.
SHUT RDWR
Mind az olvasási, mind az írási ágat lezárjuk. Mintha az előző két lezárást egyaránt elvégeznénk.
Ha a shutdown0 rendszerhívást a SHUT RDWR opcióval hívjuk meg, akkor a működés hasonlít a close() rendszerhíváséra. Néhány jelentős eltérés azonban van a két megoldás között. A close0 az állományleírót zárja le, míg a shutdown0 a kommunikációs csatornát. Vagyis ha a dup0 rendszerhívással a socketleíróról másolatot készítünk, akkor a close0 csak egy leírót zár le. A kapcsolat csak akkor záródik le, ha minden leírót lezártunk. Ezzel szemben, ha a shutdown0-t bármelyik másolatra meghívjuk, akkor lezárja a kapcsolatot, és a többi másolaton keresztül sem folytathatjuk a kommunikációt. Ha a másolat a fark() rendszerhívás hatására születik, akkor is hasonló a helyzet. Ugyanakkor a shutdown0 nem zárja le az állományleírót. Ezért használata után a close0 meghívására van szükség.
6.2.7. További kapcsolatok kezelése a szerverben A 6.1. ábrán látható kommunikációs folyamat során a szerver csak egy kapcsolatot képes kiszolgálni, hiszen a kapcsolat felépülése után nem hívja meg ismét az accept() függvényt. A szerverek többségénél azonban nem ez a kívánt működési mód. A tipikus elvárás az, hogy egy szerver több kapcsolatot is képes fogadni, és párhuzamosan kiszolgálja a klienseket. Ennek megvalósításához a szerverimplementációnkban párhuzamosan kell kommunikálnunk a 271
6. fejezet: Hálózati kommunikáció
kliensekkel a klienssocketeken keresztül, közben ismét meg kell hívnunk az accept() függvényt a szerversocketre. (A 6.5.12.2. TCP szerver alkalmazás al-
fejezetben láthatunk majd példákat a szerverfeladatok párhuzamosítására.)
6.3. Az összeköttetés nélküli kommunikáció Összeköttetés-alapú kommunikáció esetén a kommunikációt megbízható adatfolyamként foghatjuk fel. Ez egyrészt kényelmes megoldás, mert nem kell foglalkoznunk azzal, hogy az adat, amelyet elküldtünk, valóban célba ért-e: a kommunikációt végrehajtó függvények jelzik a hibát vagy a kapcsolat bontását. Másrészt azonban az adatok szinkronizációja több csomagforgalmat és kernelerőforrás-használatot (CPU, memóriabufferek, időzítők) is jelent. Ezért olyan alkalmazások esetén, ahol „nem túl nagy probléma", ha elveszik „egy-egy" csomag, ott alkalmazhatunk összeköttetés nélküli megoldást, amely egyben gyorsabb kommunikációt eredményez. Ilyenek tipikusan a multimédia-alkalmazások, hiszen ha egy zene hangmintáit küldjük el, nem jelent számottevő minőségromlást egy-egy keret kimaradása, valamint, ha későn érkezik, nem is tudunk mit kezdeni vele, hiszen már előrébb tartunk a lejátszásban. Az összeköttetés nélküli kommunikáció esetén létrehozunk egy socketet (socket()), és egy címhez rendeljük (bind()). Ezen keresztül fogadjuk a hozzánk érkező csomagokat (datagram). Ugyanakkor nemcsak fogadjuk a csomagokat, de küldünk is a többi alkalmazás számára. Ám mivel létrejött kapcsolat híján a rendszer nem tudhatja, kinek szánjuk a csomagokat, ezért egyesével meg is kell címeznünk őket. Így a korábban látott read/write függvények itt nem működnek. 1. fél
2. fél
socket()
socket()
bind()
bind0
sendto() recyfrom()•
Kommunikáció
►
recvfrom() sendto()
4,
....... ♦ ................................ close() close() 6.2. ábra. Összeköttetés nélküli kommunikáció
272
6.3. Az összeköttetés nélküli kommunikáció
A 6.2. ábra mutatja az összeköttetés nélküli kommunikáció létrehozásának a menetét. A két oldal ebben az esetben szimmetrikus, szemben a korábban látott összeköttetés-alapú kommunikációval. Mindkét oldal működik kezdeményezőként és fogadóként is. Sőt valójában nem is csak két oldal van, hanem több kommunikációs fél is elképzelhető. Ahhoz, hogy a hozzánk érkező csomagokat fogadni tudjuk, a socketünket hozzá kell kötnünk egy címhez. Lehetőségünk van azonban arra, hogy a két kommunikációs fél esetében csak az egyiknél végezzük el a címhez kötést. Ebben a struktúrában a bind() függvényt használó oldal lesz a szerver, a dinamikuscím-hozzárendelést használó oldal a kliens. Felvetődik a kérdés, hogyan deríti ki a szerver a kliens címét, és küld neki csomagot. Ehhez az szükséges, hogy a kliens kezdeményezze a kommunikációt az első csomag elküldésével. A szerver fogadva a csomagot megtalálja benne a kliens címét. Ezt követően a szerver a kapott címre válaszcsomagot tud küldeni.
6.3.1. A kommunikáció Láthatóan az összeköttetés nélküli kommunikáció esetében a korábban megismert readO és write() függvények nem használhatók. Ennek az az oka, hogy nem adatfolyam-jellegű kommunikációt végzünk, hanem csomagokat kezelünk. Ez még megoldható is lehetne az absztrakciós interfészen keresztül, másik nagy hiányosságuk azonban az, hogy nem támogatják a címkezelést. Így helyettük a recufromQ és a sendto() függvényeket kell használnunk. Összeköttetés nélküli kapcsolat esetén tehát az adatfogadás az #include #include ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
függvénnyel történik. Az első három paraméter a readO függvényból már ismerős lehet. A sockfd a socket leírója, a buf a buffer mutatója, amelybe az adatokat várjuk. A len a buffer mérete. Ha a megadott buffer rövidebb, mint amekkora hosszúságú adat érkezett, akkor a recyfrom() a csomag végét automatikusan levágja. A flags paraméter socketspecifikus I/O beállításokra szolgál. Ezeket bővebben a mm() függvénynél tárgyaljuk (lásd a 6.5.12. Összeköttetés-alapú kommunikáció alfejezetben). A src_addr paraméterben egy címstruktúra mutatóját várja a függvény, ha szeretnénk visszakapni tőle a csomag forráscímét. Ezt a struktúrát előre le kell foglalnunk, és az addrlen paraméterben adjuk át a méretét. A visszakapott cím méretét szintén az addrlen paraméterben kapjuk meg. Ha nem vagyunk kíváncsiak a csomag forrására, akkor NULL mutatók megadásával ezt a szolgáltatást kikapcsolhatjuk. 273
6. fejezet: Hálózati kommunikáció
Ameddig nem érkezik adat, a recyfrom() függvény várakozik, majd ezt követően az adatok mennyiségével vagy hibát jelző mínusz értékkel tér vissza, ahogy a read0 függvénynél történik. Az összeköttetés nélküli kapcsolatok esetén küldésre az #include #include ssi ze_t sendto(i nt sockfd, const voí d *buf, si ze_t len, i nt flags, const st ruc t sockaddr *dest_addr, sockl en_t addrlen);
függvényt használjuk. Mint az előző esetben, az első három paraméter hasonlít a writeQ függvény paramétereire. A sockfd a socket leírója, a buf a buffer mutatója, amelyben az adatok találhatók. A len a bufferben lévő adatok mérete. A flags paraméter socketspecifikus I/O beállításokra szolgál. Ezeket bővebben a send0 függvénynél tárgyaljuk (lásd a 6.5.12. Összeköttetés alapú kommunikáció alfejezetben). A dest_addr paraméterben egy címstruktúra mutatóját várja a függvény, amely a célcímet tartalmazza. A struktúra méretét az addrlen foglalja magában. A függvény addig blokkolódik, amíg az adatokat a protokollkezelő rendszer a kimenő bufferbe bele nem tudja írni. Ezt követően visszatér az elküldött byte-ok számával. -
6.3.2. A connect() használata Bár az összeköttetés nélküli kommunikáció esetén nem építünk fel kapcsolatot a két fél között, a connectO függvény mégis használható Ekkor valójában nem kapcsolatot hoz létre, hanem csak bejegyzi a megadott célcímet. Így a csomagok küldésénél nem kell megadnunk mindig a célcímet, mert a connectO nél megadottakat használja. Ezáltal lehetővé válik a readQ és a writeQ függvények használata is csomagok küldésére és fogadására. Az alábbi lista összefoglalja a connectQ függvény hatásait arra a socketre, amelyre meghívtuk: -
•
A writeQ függvénnyel (vagy a később tárgyalt send() függvénnyel) kiküldött csomagok mind a connectO függvényben megadott címre érkeznek.
•
A socketen keresztül csak olyan csomagokat kapunk meg, amelyek a connect0 ben megadott címről érkeznek. -
•
274
A túloldal nem érzékeli, hogy a connect() függvényt használtuk, mivel nem épül fel kapcsolat.
6.4. Unix domain socket
6.3.3. A socket lezárása A socketet mindkét oldalon a closeQ függvénnyel zárhatjuk le. Kapcsolat hiányában azonban ez nem jelent kapcsolatzárást, és a túloldal nem érzékeli, hogy lezártuk a socketet. Csupán annyi történik, hogy a továbbiakban nem fogadjuk a csomagokat.
6.4. Unix domain socket A Unix domain socket a legegyszerűbb protokollcsalád, amely a socket-API-n keresztül elérhető. Valójában nem hálózati protokoll. Csak egy gépen belül képes kapcsolatokat felépíteni. Habár ez komoly korlátozó tényező, mégis sok alkalmazás használja, mivel flexibilis IPC-mechanizmust nyújt. A címei állománynevek, amelyek az állományrendszerben jönnek létre. Azok a socketállományok, amelyeket létrehoz, a stat() rendszerhívással vizsgálhatók, ám nem lehet megnyitni az open() függvénnyel őket. Helyette a socket API-t kell használni. A Unix domain socket támogatja mind az adatfolyam- (stream), mind a csomag- (datagram) kommunikációs interfészt, a datagram interfész azonban ritkán használatos. A stream interfész a megnevezett csővezetékekhez hasonlít, ám nem teljesen azonos velük. Ha több processz megnyit egy megnevezett csővezetéket, akkor egyikük kiolvashatja belőle, amit egy másik beleírt. Lényegében olyan, mint egy hirdetőtábla. M egyik processz elküld egy üzenetet rá, egy másik pedig elveszi onnan. Ezzel szemben a Unix domain socket kapcsolatorientált. Minden kapcsolat egy-egy új kommunikációs csatorna. A szerver egyszerre több klienskapcsolatot kezelhet, és mindegyikhez külön leíró tartozik. E tulajdonságok révén alkalmasabb az IPC-feladatokra, mint a megnevezett csővezeték, ezért sok Linux-szolgáltatás alkalmazza, többek közt az X Window System és a naplórendszer ís.
6.4.1. Unix domain socket címek A Unix domain socket címek állománynevek a fájlrendszerben. Ha az állomány nem létezik, akkor a rendszer, amikor meghívjuk a bind() függvényt, socket típusú állományként létrehozza. Ha az állománynév már használt, akkor a bindQ hibával tér vissza (EADDRINUSE). A bind() a 0666-t állítja be jogosultságnak (módosítva az umask értékével). Ahhoz, hogy a connect() rendszerhívással kapcsolódhassunk a sockethez, olvasási és írási joggal kell rendelkeznünk a socketállományra. 275
6. fejezet: Hálózati kommunikáció
A Unix domain socket címeinek megadásához a struct sockaddr_un struktúrát használjuk: #include #include struct sockaddr_un unsigned short sun_famíly; /* AF_UNIX * / char sun_path[uNIX_PATH_MAx]; /* eleresi u t
*/
;
A sunjamily mezőnek, AF UNIX-nak kell lennie. Ez jelzi, hogy Unix domain socket címet tartalmaz. A sun_path tartalmazza az állománynevet C-stringként, vagyis egy '\0' karakterrel lezárva. A rendszerhívásokban használt címstruktúraméret az állománynév hossza, plusz a sunjamily elem mérete. Bár használhatjuk a teljes struktúraméretet is, hiszen az állománynevet lezártuk.
6.4.2. Unix domain socket adatfolyam szerveralkalmazás Az összeköttetés-alapú szerveralkalmazás felépítése megegyezik a korábban a 6.2. Az összeköttetés-alapú kommunikáció alfejezetben tárgyaltakkal, kiegészítve a Unix domain socket kommunikációban használt címzéssel. Feladat Készítsünk egy Unix domain socket szerveralkalmazást, amely fogadja a kliensek kapcsolatait (egyszerre egyet), és a tőlük kapott adatokat kiírja az alapértelmezett kimenetre.
A megoldás a következő: /* uszerver.c - Egyszeru pelda szerver a Unix domain socket hasznalatara. */ #include #include #include #include
int mai n (voi d) struct sockaddr_un address; int sock, conn; socklen_t addrlen; char buf[1024]; int amount;
276
6.4. Unix domain socket
/* Letrehozzuk a socketet. */ 1f((sock = socket(FF_UNIX, SOCK_STREAM, 0)) < 0) perror("socket"); return -1;
/* Letoroljuk a korabbí socketallomanyt. */ unlink("./sample-socket"); memset(&address, 0, sizeof(address)); address.sun_family = AF_UNIX; strncpy(address.sun_path, "./sample-socket", sizeof(address.sun_path) - 1); /* A teljes Cirrl hossz tartalmazza a sun_family elemet es az eleresi ut hosszat. */ addrlen = sizeof(address.sun_family) + strnlen(address.sun_path, sizeof(address.sun_path));
•
/* A socketet hozzakotjuk a címhez. */ if(bind(sock, (struct sockaddr *) &address, addrlen)) perror("bind"); return -1;
/* Bekapcsoljuk a kapcsolodasra valo varakozast. */ if(listen(sock, 5)) perror("listen"); return -1; } /* Fogadjuk a kapcsolodasokat. */ while((conn = accept(sock, (struct sockaddr* ) &address, &addrlen)) >= 0) { /* Fogadjuk az adatokat. */ printf("Adatok erkeznek...\n"); while ((amount = read(conn, buf, sizeof(buf))) > 0) if (write(STDOUT_FILENO, buf, amount) != amount) perror("write"); return -1; } }
if(amount < 0) perror("read"); return -1;
277
6. fejezet: Hálózati kommunikáció
príntf("...vege\n"); /* Bontjuk a kapcsolatot. close(conn);
*/
if(conn < 0) perror("accept"); return -1;
/* Lezarjuk a szerver socketet. */ close(sock); return 0;
Ez az elég egyszerű példaprogram bemutatja a szükséges rendszerhívásokat, viszont egyszerre egy kapcsolat lekezelésére alkalmas. (Természetesen készíthetünk ennél bonyolultabb, több kapcsolat párhuzamos kezelésére is alkalmas megoldásokat.) Az általános socketkezeléshez képest láthatunk a programban egy unlink0 hívást. Erre azért van szükség, mert ha már az állomány létezik, a bind0 hibával térne vissza, akkor is, ha az állomány socketállomány.
6.4.3. Unix domain socket adatfolyam kliensalkalmazás A kliensalkalmazás természetesen ugyancsak követi a socketeknél már ismertetett módszereket, a Unix domain socket kommunikációnál tárgyalt címzéssel. Feladat
Készítsük el az előző fejezetben látott szerver klienspárját. A kliens az alapértelme-
zett bemeneten fogadja az általunk beírt szöveget, és ezt a kapcsolaton keresztül küldje el a szervernek.
A következő program egy példa a megoldásra: /* ukliens.c - Egyszeru peldakliens a Unix domain socket hasznalatara. */ #include #include 0)
I O.
struct sockaddr_un address; int sock; size_t addrlen; char buf[1024]; int amount;
if (write(sock, buf, amount) != amount) . perror("wTite"); i re t urn
ilfil
} } if(amount < 0) { perror("read"); return -1; } /* Bontjuk a kapcsolatot. */ close(sock); return 0;
279
6. fejezet: Hálózati kommunikáció
6.4.4. Unix domain socket datagram kommunikáció Az előző fejezetekben láthattuk az általános összeköttetés-alapú kommunikációs megoldások alkalmazását a Unix domain socket protokollra. Ezekkel analóg módon az összeköttetés nélküli datagramkommunikáció is az általános függvényeket alkalmazza kiegészítve a Unix domain socket címzéssel. Ezért datagramkommunikációra példát majd csak az IP-kommunikáció tárgyalásánál mutatunk Útmutató Bár a datagramkommunikáció nem megbízható és nem sorrendtartó a specifikációk értelmében, ez a Unix domain socket protokoll esetében nem igaz. Mivel a kommunikáció csak lokálisan, a kernel mechanizmusain keresztül történik, így garantált, hogy nem vesznek el csomagok, és nem cserélődik fel a sorrendjük.
6.4.5. Névtelen Unix domain socket Mivel a Unix domain socket egy sor előnnyel rendelkezik a csővezetékekkel szemben (ilyen például a kétirányú kommunikáció), ezért gyakran alkalmazzák IPC-kommunikációhoz. A használatukat megkönnyítendő létezik egy socketpair0 rendszerhívás, amely egy pár összekapcsolt, név nélküli socketet hoz létre: include #include i nt socketpai r(int domain, int type,
int prot, int sockfdsl2]);
Az első három paraméter megegyezik a socket0 rendszerhívásnál tárgyaltakkal. Az utolsó paraméter, a sockfds, tartalmazza a visszaadott socketleírókat. Szemben a névtelen csővezetékekkel a két leíró egyenértékű.
6.4.6. A Linux absztrakt névtere A Unix domain socket kommunikáció során címként socketállományokat használva szembesülhetünk néhány problémával:
280
•
A socketállományokat rendszeresen törölnünk kell, mert ha az állomány létezik, akkor a bindO hibával tér vissza.
•
Ha megszakad a program, akkor ott marad az állományrendszerben a socketállomány.
6.4. Unix domain socket
•
Előfordulnak olyan helyzetek, amikor nincs jogosultságunk állományt létrehozni.
•
Használhatunk olyan állományrendszert, amely nem támogatja a socketállományokat.
Ezek a problémák mínd azt mutatják, hogy az állományok használata címként sokszor gondot jelent. Ugyanakkor a Linux más rendszerektől eltérően tartalmaz egy plusz szolgáltatást, amellyel elkerülhetjük az állománynevek használatát. Ez a szolgáltatás az absztrakt névtér. Az absztrakt névtér használata egyszerű. Alapesetben a címstruktúra sun_path eleme egy C-sztringet tartalmaz, vagyis nem null karakterek sorozatát a végén lezárva egy '\0' karakterrel. Ha absztrakt nevet szeretnénk megadni, akkor ettől eltérően egy '\0' karakterrel kell kezdenünk a mezőt, majd az ezt követő karakterek tartalmazzák az azonosítót. Ám ebben az esetben nem jelezhetjük 0' karakterrel az azonosító végét. Így a megadott szöveget teljesen a címstruktúra végéig veszi figyelembe a rendszer. Ezt azonban befolyásolhatjuk, amikor megadjuk a címstruktúra hosszát. Nézzünk erre egy példát: struct sockaddr_un address; size_t addrlen; memset(&address, 0, sizeof(address)); address.sun_family = AF_UNIX; address.sun_path[0] = 0; strncpy(address.sun_path + 1, "myaddr", sizeof(address.sun_path) - 2); addrlen = sizeof(address.sun_family) + strnlen(address.sun_path + 1, sizeof(address.sun_path) - 1) + 1;
A sun_path mező első elemének beállítjuk a 0-t. Ezt követően a második bytetól bemásoljuk az általunk megadott szöveget. A méret kiszámolásánál figyelnünk kell arra, hogy a szöveg hosszát csak a második karaktertől mérjük, illetve a kezdő '\0' karaktert is hozzáadjuk. Hasonlóan összeállítva a címet mind a szervernél, mind a kliensnél a korábbi példák továbbra is működnek, de nem igénylik állományok létrehozását, illetve törlését. Útmutató
Bár ebben az esetben nem láthatjuk az állományrendszerben a címeket, a netstat
programmal lehetőségünk van kilistázni a Unix domain socket szervereket, illetve kapcsolatokat. Így a címként használt absztrakt neveket is láthatjuk.
281
6. fejezet: Hálózati kommunikáció
6.5. IP Az előző fejezetben a Unix domain socket protokoll használatával csak egy gépen belül kommunikálhattak a folyamataink. Ebben a fejezetben bemutatjuk az Internet protokoll (IP) használatát, amellyel már lehetőségünk van számítógép-hálózaton keresztül kommunikációt felépíteni különböző számítógépeken futó folyamatok között. Az IP-kommunikáció során is az eddig megismert socketkezelő függvényeket alkalmazzuk. Eltérést nagyrészt csak a címzésnél látunk. A fejezetben párhuzamosan ismertetjük az IP protokoll korábbi 4-es verziójának (IPv4) és az új 6-os verziójának (IPv6) használatát. Jelenleg az IPv4 protokoll használatos széles körben, ám a programjainkban célszerű felkészülnünk az IPv6 támogatására is, hogy a jövőben használhatók legyenek.
6.5.1. Röviden az IP-hálózatokról Mielőtt ismertetnénk az IP protokollok használatát, illetve az internetes kommunikációt, röviden bemutatjuk az IP-hálózatok működését, hogy a később tárgyalt fogalmak értelmet nyerjenek. Kommunikáció a hálózaton Az internet lokális hálózatokból épül fel, sok kisebb-nagyobb hálózatból, amelyeket útvonalválasztók (routerek) kapcsolnak össze. Ez azt is jelenti, hogy a hálózati kommunikáció egy lokális hálózaton belül lévő számítógépek között másképpen zajlik, mint az egymástól távoli, különböző lokális hálózatokba tartozó számítógépek között. Lokális hálózat Lokális hálózatnak tekintendő az a hálózat, amelyen belül két számítógép között router közbeiktatása nélkül, közvetlenül lehet kommunikálni. Ez tipikusan egy switchre, 79 vagy több switchből álló struktúrára UTP-kábellel kapcsolódó számítógépeket jelent. Szokták ezt szegmensnek vagy alhálózatnak is neve zni. 90
79
8°
A switch olyan hálózati eszköz, amely a portjaira (csatlakozóira) kapcsolt eszközök közötti kommunikációt biztosítja. Elődje a HUB, amely az egyik portjára érkező jeleket a többi portjára továbbítja. A switch ehhez képest annyi többletintelligenciával rendelkezik, hogy a csomagokat megvizsgálja, és csak arra a portjára továbbítja, ahol a címzett eszköz található. Másfajta hálózattípusok is léteznek (Token bus, Token ring), ám ezekkel a hétköznapok során ritkán találkozunk
282
6.5. IP
Klasszikus esetben egy lokális hálózaton belül, ha az egyik számítógép elküld egy csomagot, akkor azt az összes többi számítógép megkapja, de csak az használja fel, amelyiknek szól. Hogy kinek szól, azt a címzett gép hálózati kártyájának fizikai címe (MAC-cím, hardvercím stb.) határozza meg. Ez a cím minden hálózati kártyára egyedi, és csak ennek ismeretében lehetséges a két számítógép között kommunikációt megvalósítani. Manapság a működés csak annyiban tér el, hogy a switcheszközök is képesek értelmezni a fizikai címet és megjegyezni, hogy melyik portjukon található az ezt használó gép. Így az optimalizáció érdekében csak arra küldik tovább a csomagot. Vagyis a kommunikáció két gép között a fizikai címmel történik. Miért van szükség akkor az IP-címre, miért nem használja a protokoll a fizikai címeket? Először is, mert kényelmetlen, nehezen megjegyezhető. De ami sokkal fontosabb: elvileg még megváltoztathatatlan 81 a hálózati kártya legyártása során adott egyedi cím. Így garantálják, hogy egy lokális hálózaton véletlenül se legyen két egyforma fizikai címmel rendelkező gép. Mivel a fizikai címeket a gyártáskor határozzák meg, ezért semmilyen információt nem hordoz az alhálózattal kapcsolatban. Önmagában a címből nem tudjuk eldönteni, hogy a lokális hálózatunk tagja-e, vagy ha nem, akkor hol található a világban. Elvileg összeállíthatnánk egy nyilvántartási táblázatot, de ennek karbantartása lehetetlen feladat lenne. Ezért van szükség egy másik cím használatára is, és ez az IP-cím. Hogyan dönthető el, hogy egy adott IP-címmel rendelkező gépnek (címzettnek) mi a fizikai címe? Erre szolgál az ARP (Address Resolution Protocol). Ha egy gép egy másiknak akar csomagot küldeni, akkor első körben a címzett IP-címe és a hálózati maszk alapján eldönti, hogy a célgép vele egyező alhálózatnak a tagja-e, vagy sem (a módszert lásd a 6.5.3. IPv4-es címzés alfejezetben). Ha a célgép ugyanannak az alhálózatnak a tagja, akkor elküld egy broadcast- (mindenkinek szóló) üzenetet, amelyben megkérdezi, hogy melyik is az adott IP-címmel rendelkező számítógép, és mi a fizikai címe. Az üzenetet mindenki veszi, de csak az válaszol rá, aki az adott IP-cím tulajdonosa, és elküldi a kezdeményezőnek a saját fizikai címét (a kezdeményező a sajátját természetesen feltüntette az üzenetben). Ezt követően a kezdeményező — hogy ne kelljen folyton ARP-üzeneteket küldözgetni — elhelyezi a címzettre vonatkozó információkat egy gyorsítótárba (ARP Cache), és legközelebb, ha ugyanazzal a címzettel akar kommunikálni, akkor már ebből veszi az adatokat. A fizikai cím kiderítése után már csak annyi a feladat, hogy ezzel a fizikai célcímmel kell a gépnek csomagokat küldenie, így a célgép megkapja őket.
81
Gyakorlatban a gyártási folyamat egyszerűsítése miatt megváltoztatható, illetve szoftveresen felül is írható. 283
6. fejezet: Hálózati kommunikáció
Globális hálózat Mi történik akkor, ha olyan címzettel akar egy számítógép kommunikálni, amelyik nincs vele egy szegmensen? Ekkor jut szerephez a router. A router (útválasztó) egy kitüntetett számítógép a szegmensen, amely egyszerre több lokális hálózathoz is kapcsolódik, és amelyik éppen ezért több szegmensbe is tud adatot küldeni, így lehetővé teszi a szegmensek közötti kommunikációt. Ha egy számítógép egy másik szegmensben (ezt az alhálózati maszk segítségével állapítja meg) lévő géppel akar kommunikálni, akkor nem közvetlenül a címzettel kezdeményez kapcsolatot, hanem az alapértelmezett routerrel (ez minden gép esetében be van állítva). Ehhez persze először ARP-vel kideríti a router fizikai címét, majd elküldi az adatcsomagot, azzal az utasítással, hogy a megadott IP-címre kell eljuttatni. Ezt követően, ha a célcím valamelyik, a routerhez kapcsolódó alhálózathoz tartozik, akkor a router ARP-vel kideríti ennek a fizikai címét, és elküldi a csomagot. Ha a címzett semelyik, a routerhez kapcsolódó szegmenshez sem tartozik, akkor a router is egy másik routerrel veszi fel a kapcsolatot, amely több másik alhálózatot kezelő routerrel is kapcsolatban áll, és annak küldi tovább a csomagot. Ezt a megoldást addig ismétli a rendszer, amíg eljut egy olyan szintre, ahol a router tudja, hogy a célgép alhálózata merre található. Ezt követően a csomag eljut a célalhálózat routeréhez, amely továbbítja a célgépnek. Fontos megjegyezni, hogy két számítógép között ebben az esetben is csak egy szegmensen belül és a fizikai címek alapján zajlik a közvetlen kommunikáció. Szegmenseken kívülre közvetetten (routerek közbeiktatásával) kerülnek a csomagok.
6.5.2. Az IP protokoll rétegződése Az IP protokoll családot több protokollra és több rétegre bontjuk. A rétegeket és a protokollokat a 6.3. ábra szemlélteti: Alkalmazások
TCP
UDP IP Ethernet
Szállítási réteg Hálózati réteg Adatkapcsolati réteg
6.3. ábra. Az IP protokoll család gyakrabban használt elemei
284
6.5. IP
Az adatkapcsolati réteg biztosítja a kommunikációt a hálózat elemei között. A hálózati réteg teszi lehetővé a csomagok eljutását a küldőtől a címzettig. Az IP protokoll is ebben a rétegben található, vagyis a legfőbb feladata az, hogy az interneten található két gép között megoldja a címzést és az adatok továbbítását. A szállítási réteg biztosítja, hogy az alkalmazások között az adatátvitel transzparensen megvalósulhasson. Az UDP protokoll lényegében az alkalmazások megcímezhetőségével (portok kezelése) egészíti ki az IP-t. A TCP a portok kezelése mellett még a kapcsolat megbízhatóságát is garantálja.
6.5.3. IPv4-es címzés Az IP-címek egyedíek mínden hoszt esetében, és 32 bitből (4 byte-ból) állnak. Szokásos megjelenésük a 4 byte pontokkal elválasztva. Próbáljuk meg lekérdezni saját gépünk IP-címét: 8 ip addr 1: lo: mtu 16436 qdisc noqueue state UNKNOWN link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: eth0: mtu 1500 qdisc pfifo_fast state UP qlen 1000 link/ether 00:22:3f:dl:d3:a7 brd ff:ff:ff:ff:ff:ff i net 192.168.1.10/24 brd 192.168.1.255 scope global eth0 i net6 fe80::211:2fff:fedl:d3a7/64 scope link valid_lft forever preferred_lft forever
~a
Amikor egy szervezet hálózati adminisztrátora igényel egy címtartományt, akkor kap egy 32 bites hálózati címet és egy 32 bites hálózati maszkot. Ez a két szám meghatároz egy címtartományt. Ezt a 6.4. ábra szemlélteti. Hálózati cím
il000000liolol000 ocw0000ll00000000
Hálózati maszk 11111111111111111 4- Hálózati
Hoszt -›
Helyi cím
110000 00 10101000 0000 0001 0 0 0 0 1010
Idegen cím
10 0 110 0 0 01 0 0 0 0 1 0 101 111 01 0 0 0 0 1 0 10
6.4. ábra. IPv4-es hálózati cím és maszk
285
6. fejezet: Hálózati kommunikáció
Egy IP-címből azok a bitek, ahol a hálózati maszk 1-est tartalmaz, a hálózati címet határozzák meg. Azok a bitek, ahol a maszkban 0 szerepel, az adott gép egyedi hosztcímét adják meg. Vagyis egy hálózaton belül a számítógépek egyedi IP-címei csak azokban a bitekben térhetnek el a hálózati címtől, ahol a maszkban 0 szerepel. Ha az IP-cím a maszk 1-es értékeinél is eltér, akkor az már egy másik alhálózatnak az eleme. Egy számítógépen csak a gép egyedi IP-címét és a hálózati maszkot szoktuk megadni, mivel az előző összefüggések alapján ebből a két értékből a hálózati cím előállítható. A megadás során még egy egyszerűsítéssel szoktunk élni. A hálózati maszkban nem keveredhetnek az 1-es és a 0-s értékek. A maszk elején x darab 1-es, majd ezt követően (32—x) darab 0 bit következik. Így a maszk teljesen leírható x-szel, amely egy szám 0 és 32 között. Ezt a számot az IP-cím után egy „1" jellel elválasztva írjuk. Például:
X.01Atitt ..10,
-
A „/24" azt jelzi, hogy a maszk 24 darab 1-est majd 8 darab 0-t tartalmaz. Vagyis az első 24 bit a hálózati címhez, a maradék 8 bit a hosztcímhez tartozik. Ugyanez a maszk pontozott formátumban így néz ki:
.Z.5.295.a~: • _ Egy tartomány első és utolsó címének speciális jelentése van. Ezeket számítógépeknek nem adjuk ki. Az első cím, amikor a hosztcím bitjei végig 0-k, a hálózati cím. Az utolsó cím, amikor a hosztcím végig 1-es a broadcastcím. A broadcastcímre küldött csomagokat az alhálózat minden gépe megkapja.
6.5.4. IPv4-es címosztályok Manapság a hálózati címtartományok meghatározást hálózati cím és hálózati maszk alapján végezzük. Korábban azonban a tartományok meghatározása az ún. címosztályok alapján történt. Ezt jelenleg már nem használják, ám időnként még mindig találkozunk a címosztályok fogalmával. Az IPv4 32 bites címe ebben az esetben is kétfelé oszlik. Az első M bit egy azonosító, amely megmutatja a hálózat típusát, a következő N bit a hálózat címe, a maradék 32-M-N bit pedig az adott hálózaton belül egy számítógép címe. Attól függően, hogy N értékét mekkorára választjuk, több hálózatot címezhetünk meg (N-et növelve), illetve több gépből álló hálózatot címezhetünk meg (N-et csökkentve). A címosztályokat a 6.4. táblázat foglalja össze:
286
6.5. IP 6.4. táblázat. Címosztályok
Cím
Első cím
Utolsó cím
Azonosító
A osztály
1.0.0.0
127.255.255.255
0
7
B osztály
128.0.0.0
191.255.255.255
10
14
C osztály
192.0.0.0
223.255.255.255
110
21
D osztály
224.0.0.0
239.255.255.255
1110
Többes küldés
E osztály
240.0.0.0
247.255.255.255
11110
Fenntartva
Feladat Állapítsuk meg, hogy a www.aut.bme.hu szerver IP-címe milyen osztályba tartozik. $ ping www.aut.bme.hu PING www.aut.bme.hu (152.66.188.11) 56(84) bytes of data.
Amint a táblázat alapján látható, ez egy B osztályú IP-cím, és ez nem meglepő, hiszen a BME-hez elég sok gép tartozik, így a hosztok megcímzéséhez 16 bitre van szükség (az A osztály esetén adott 24 sok lenne, a C osztályban használt 8 kevés).
6.5.5. IPv4-es speciális címek A teljes IPv4-es címtartományban vannak olyan tartományok, amelyeknek speciális a jelentése. A 127.0.0.1 cím tipikusan a loopbackcím, amely arra használatos, hogy a folyamatok a saját gépüket megcímezzék, vagyis egy gépen belül folytassanak IP-kommunikációt. Valójában a 127.0.0.0/8 tartomány bármelyik címét lehet erre a feladatra használni, de az első címet szokták. A 10.0.0.0/8, a 172.16.0.0/12 és a 192.168.0.0/16 privát IP-címtartományok. Ez azt jelenti, hogy az interneten nem találkozunk ilyen címeket használó gépekkel, mivel az útvonalválasztók (router) nem továbbítják ezeket a csomagokat. A saját hálózatunkban szabadon használhatjuk a privát címtartomány címeit, közvetlenül ezek a gépek azonban nem kommunikálhatnak az interneten. 82 A 224.0.0.0/4 D osztályú címtartomány címeit a többes küldéshez használhatjuk. A többes küldés során egy-egy csomagot nemcsak egy gépnek, hanem hosztok csoportjának küldjük el. (Használatát bővebben lásd a 6.5.13.2. Többes küldés alfejezetben.)
82
Ha privát címeket használó számítógépekkel el akarjuk érni az internetet, akkor címfordítást (network address translation, NAT) kell használnunk. 287
6. fejezet: Hálózati kommunikáció
6.5.6. IPv6-os címzés A 6-os verziószámú IP-címek létrejöttének elsődleges oka az, hogy az eredeti (4-es verziójú) IP-címekből kifogytunk. A probléma megoldására dolgozták ki az IPv6-ot, amely már 128 bites címeket használ. Az IPv6 a 16 byte-os címeken kívül számos többletszolgáltatást nyújt, így például: •
automatikuscím-konfiguráció,
•
fejlettebb többes küldés,
•
az útvonalválasztók (router) feladata egyszerűsödött,
•
több útvonalválasztási opció,
•
mobilitás,
•
hitelesítés,
•
adatbiztonság.
A 16 byte-os IP-címek leírásához értelemszerűen más formátumot használunk: a számokat hexadecimálisan 4-es csoportokra osztjuk kettősponttal elválasztva, ez összesen 8 csoportot jelent. Például:
0000:4200010000:0000}203Z£: ~1~0CM.'"' Várható, hogy az IPv6-os címek kiosztásánál (és valószínűleg még elég sokáig) a cím kezdetben sok, nullákkal teli blokkot tartalmaz. Egyszerűsítésképpen bevezették azt, hogy a kezdő nullák minden blokkon belül elhagyhatók, egy vagy több nullából álló blokk két kettősponttal helyettesíthető:
•0000t, t n 546A4: FEEI)4 DEAL Az áttérés megkönnyítésére a régi IP-címek két kettősponttal kezdődően a hagyományos, pontokkal elválasztott módon írhatók le:
A régi IP-címek a következő minta alapján illeszkednek az új címbe:
000'400.~0004:s00~~,;gxxX_-_ -_ _ Itt az XXXX:XXXX az IPv4-es cím hexadecimális formátumban. A lehetséges rövidítéseket alkalmazva az átírási séma a következő: _
288
6.5. IP
Igya
152.66.188.11 IPv4-es cím IPv6 os formátumban az alábbi: -
::FFFF:9842:BCOB
6.5.7. Portok Eddig az IP-réteg címzését vizsgáltuk, amely a számítógép-hálózaton lévő gépek azonosítására szolgál. Egy gépen azonban általában több szolgáltatás is fut, amelyeket meg kell különböztetnünk egymástól. Vagyis az egyes alkalmazásokat is meg kell címeznünk valahogyan. A TCP- és az UDP-réteg lehetővé teszi több virtuális „csatorna" létrehozását két gép között. A szállítási réteg ezt a kommunikációt a portokkal azonosítja. Egy TCP/UDP kommunikációban mindkét fél külön portszámmal rendelkezik. Az 1024-nél kisebb számú portokat jól ismert portoknak (well-known ports) nevezzük, amelyek meghatározott szolgáltatásoknak vannak fenntartva. A felhasználói programok az 1024-tól 49 151-ig lévő tartományt használhatják (regisztrált portok registered ports). A dinamikus és a magánportok (Dynamic and Private Ports) a 49 152 — 65 535 intervallumban helyezkednek el. Az Internet Assigned Numbers Authority (TANA, az internet számhozzárendelő hatósága) szervezet által definiált portok listája a http:/ /www.iana.org/ assignments/port-numbers oldalon található. A legismertebb szolgáltatások portjait a 6.5. táblázat foglalja össze. —
6.5. táblázat.
Szolgáltatások portjai
Szolgáltatás neve
Port
ftp-daca
20
ftp
21
ssh
22
telnet
23
smtp
25
http
80
port map
111
https
443
Linux alatt a szolgáltatások az /etc/services fájlban vannak felsorolva.
289
6. fejezet: Hálózati kommunikáció
6.5.8. A hardverfüggő különbségek feloldása A hálózati kommunikáció byte-ok sorozatán alapszik. Egyes processzorok azonban különböző módon tárolják a különböző adattípusokat. Mivel a különböző processzorra! szerelt gépeknek is szót kell érteniük egymással, ezért ennek a nehézségnek az áthidalására definiáltak egy hálózati byte-sorrendet (network byte order). A hálózati byte-sorrendben az alacsonyabb helyértékű byte jön elóbb („a nagyobb van hátul" — big endian). Azoknál az architektúráknál, ahol az ún. hoszt byte-sorrendje ellenkező („a kisebb van hátul" — little endian) — ilyenek például az Intel 8086-os alapú processzorok konverziós függvények állnak rendelkezésünkre, amelyeket a 6.6 táblázat foglal össze. 6.6. táblázat. Byte-sorrend-konverziós függvények Függvény
Leírás
ntohs
Egy 16 bites számot a hálózati byte-sorrendből a hoszt byte-sorrendbe (big-endian—little-endian) vált át.
ntohl
Egy 32-bites számot a hálózati byte-sorrendból a hoszt byte-sorrendjébe (big-endian—little-endian) vált át.
htons
Egy 16-bites számot a hoszt byte-sorrendjéből hálózati byte-sorrendbe (little-endian—big-endian) vált át.
htonl
Egy 32-bites számot a gép byte-sorrendjéből hálózati byte-sorrendbe (líttle-endian—big-endian) vált át.
Azokon az architektúrákon, ahol nem szükséges ez a konverzió, ezek a függvények a megadott argumentumértékekkel térnek vissza, vagyis hordozható kód esetén mindenképpen alkalmazzuk ezeket a függvényeket. A byte-sorrend problémájával elsősorban a címzésnél találkozunk. Az átvitt adatok értelmezését már mi definiáljuk az alkalmazásszintű protokoll specifikálásánál, ám az implementáció során érdemes észben tartani ezt a problémát.
6.5.9. A socketcím megadása A 6.2. Az összeköttetés-alapú kommunikáció alfejezetben bevezettük a connectO függvényt, amellyel a szerverhez tudunk kapcsolódni, illetve a bind0 hívást, amellyel címet rendelhetünk a socketekhez. Mindkét függvény egy-egy címet vár paraméterül. Nézzük meg közelebbről a cím megadásának a módját. IPv4
Az IPv4-es címeket a sockaddr_in struktúra definiálja, amelyet a netinet/ in.h állomány tartalmaz: 290
6.5. IP
struct sockadd•_in sa_family_t in_port_t struct in_addr unsigned char
};
sin_family: /* Címcsalád = AF_INET */ sin_port; /" A port száma */ sin_addr; IPv4 cim */ sin_zero[8]; / * struct sockaddr vége */
Az in_addr struktúra felépítése a következő: struct in_addr in_addr_t s_addr; /* előjel nélküli 32 bites szám */
IPv6 Az IPv6-os struktúra hasonlít az IPv4-hez, ám ebben az esetben a cím 32 bit helyett 128 bites. A címet a sockaddr_in6 struktúra tárolja, amely szintén a netinetlin.h állományban található: struct sockaddr_in6 sa_family_t in_port_t uint32_t struct in6_addr uint32_t }: shememmew
sin6_family; /* Címcsalád = AF_INET6 sin6_port; /* A port száma */ sín6_flowinfo; sin6_addr; /* IPv6 cím */ sin6_scope_id;
A sinű_flowinfo és a sin6 scope_id mezők értelmezésére jelen keretek közt nem térünk ki. 0 értéket használunk az esetükben. Az in6 addr struktúra felépítése az alábbi: struct in6_addr a
uint8_t s6_addr[16];
N _
41"- ►
MMM
;
Mind az IPv4, mind az IPv6 esetében a rendszer a címet bináris formában, hálózati byte-sorrendben várja. Ezek megadása azonban a felhasználó számára nehézkes lenne. A 6.5.3. IPv4-es címzés és a 6.5.6. IPv6-os címzés alfejezetekben láthattuk az elterjedt címmegadási formátumokat. Ezek szöveges formátumok, amelyekból a bináris cím előállítása függvények segítségével lehetséges. Az IPv4-es protokoll esetén az inet_aton() és az inet_ntoa() függvényeket használhatjuk arra, hogy a pontozott szöveges formátumból a bináris formátumot előállítsuk, illetve fordítva. Ezek a függvények azonban csak IPv4 esetében használhatók, ezért manapság már elavultnak számítanak.
291
6. fejezet: Hálózati kommunikáció
Az inet_pton0 és az inet_ntop0 függvények hasonlítanak az inet_aton0 és az inet_ntocco függvényekre, ám mind az IPv4 pontozott szöveges formátumát, mind az IPv6 hexadecimális szöveges formátumát támogatják. Ezért ezeket a függvényeket érdemes megvizsgálni. Az inet_pton0 függvény nevében a p prezentációt (presentation), az n hálózatot (network) jelent. Vagyis a függvény az ember által kezelhető szöveges formátumú címből hálózati byte-sorrendes bináris formátumot állít elő. i nclude
int inet_pitoneint af, const char *src, void *4st) 4Az af a címcsalád, vagyis esetünkben AF INET vagy AF INET6. Az src paraméter az IP-cím szöveges reprezentációját tartalmazza. A dst paraméternek egy nem tipizált mutatót kell beállítanunk. Ennek valójában egy in_addr vagy egy in_addr6 típusú struktúrára mutató értéknek kell lennie, attól függően, hogy IPv4-es vagy IPv6-os címet alakítunk át. A függvény visszatérési értéke sikeres konverzió esetén 1. Ha 0 értéket kapunk vissza, akkor az azt jelenti, hogy a szöveges reprezentáció nem megfelelő. Hibás af érték esetén pedig —1 értéket ad vissza a függvény. Az inet_ntop0 függvény az ellenkező irányú konverziót végzi el. A hálózati bináris címből egy olvasható szöveges címet állít elő: #include const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
Az af értéke ebben az esetben is AF INET és AF INET6 lehet. Az src paraméternek egy in_addr vagy egy in_addr6 struktúrára kell mutatnia, amely a bináris címet tartalmazza. A dst paraméterként meg kell adnunk egy karaktertömb-mutatót, amelybe a függvény a szöveges reprezentációt elhelyezi nullával lezárva. A tömb méretét a size paraméterrel kell specifikálnunk. Ha túl kicsi tömböt adunk meg, akkor a konverzió sikertelen lesz. Sikeres konverzió esetén a függvény a szöveges reprezentáció mutatójával tér vissza (a dst paraméter). Ellenkező esetben NULL értékkel jelzi a hibát. A megfelelő szövegbufferméret megválasztásához a netinet/in.h állomány tartalmaz két segéddefiníciót az IPv4-es és az IPv6-os címekhez:
#dgfine 44W' «afilite 1~A011~
292
10,
6.5. IP
6.5.10. Lokális cím megadása A 6.2.2. A socket címhez kötése alfejezetben megmutattuk a socketek címhez kötését. A címhez kötés művelete egy lokális cím megadását igényli. Az eddig látott függvények segítségével össze tudunk állítani egy olyan struktúrát, ahol a programunkat futtató számítógép IP-címét adjuk meg, továbbá egy portot. Ezt követően az adott címhez köthetjük a socketet. Ezzel a módszerrel azonban több gond is van. Egyrészt ki kellene derítenünk a programot futtató gép címét. Másrészt egy internetre kötött gépnek legalább két IP-címe van: egy hálózati és egy loopbackcíme A megoldás egy speciális címbeállítás használata, amely a futtató gép minden IP-címéhez hozzáköti a socketet. Ez a cím IPv4 esetén az INADDR ANY, amely négybyte-os, 0-t tartalmazó érték. Megfog INAOD,LANY
ttin.A4dr_t) No0000000)
Az IPv6 is rendelkezik egy hasonló definícióval, ennek neve IN6ADDR ANY INIT, és értéke 16 darab 0 byte-ot tartalmazó tömb: #define IN6ADOR—ANY—XNZ1-
Ot0,0~9404nAMilb04
Bár a két konstans hasonló, a használatukban van egy különbség. Az INADDR_ ANY skalártípus, ezért bárhol használhatjuk értékadásra. Viszont az IN6ADDR_ANY _INIT tömbérték, amelyet a C nyelv szintaktikája szerint egy egyszerű egyenlőségjellel nem másolhatunk le, kivéve a tömb létrehozását: torsi strUOt InLiddr*100,0
0 .XN6APORANYLXNZ ,
A rendszer tartalmazza az ebben a példában szereplő értékadást, így az in6addr_any globális változó fel is használható értékadásra. Bizonyos szolgáltatások esetén előfordul, hogy azokat csak lokálisan elérhetővé akarjuk tenni, és nem publikáljuk az internet felé. Ilyenkor csak a loopbackinterfészhez kötjük hozzá a socketet. Ennek meghivatkozására is tartalmaz konstansokat a rendszer. IPv4 esetén a loopbackinterfész címe
INADDRLOOBACK: Iftlefine IkAnDILL001%,«
.ax7fOoGLIII 1 127 .4.tra.
IPv6 esetén egy tömbkonstansunk van IN6ADDR_LOOPBACK_INIT néven:
4ht!ifil* DítOtikE0OPWIUSTY . ,11kt,t 144 _ Att 4:0.0A41.4.44';04 gti;04tUl *-
293
6. fejezet: Hálózati kommunikáció
A definíció mellett in6addr_loopback néven globális változó is rendelkezésünkre áll:
const struct in6_addr in6addr_loopback Útmutató Figyeljünk arra, hogy az IPv4-es konstansok hosztbyte-sorrendben vannak, ezért még konvertálni kell öket hálózati byte-sorrendre a htonI0 függvénnyel. Ez a 0-t tartalmazó INADDR_ANY esetén még nem fontos, de az INADDR_LOOPBACK értéke már nem O. Az IPv6-os konstansok viszont hálózati byte-sorrendben vannak.
6.5.11. Név- és címfeloldás Az IP-címek használata a felhasználók számára nehézkes, mivel sok számot kellene fejben tartaniuk. Emellett, ha a szerver IP-címét átírjuk, akkor a felhasználók nem találják meg többé. Ezért a gyakorlatban nem is IP-címeket, hanem hosztneveket használunk. Ha a hoszt nevéből az IP-címét szeretnénk megállapítani, akkor névfeloldásról beszélünk. A névfeloldás történhet számos forrás alapján: •
lokális állomány (/etc/hosts),
•
központi szerverek (DNS, mDNS, Yellow pages stb.),
Szerencsére programozóként nem kell minden lehetséges forrást leimplementálnunk. Helyette a Linux egységes névfeloldást végző függvényeket nyújt.
6.5.11.1. A getaddrinfo() függvény A getaddrinfo0 függvény a hosztnevet és a szolgáltatásnevet (lásd 6.5.7. Portok alfejezetben) képes átkonvertálni IP-címmé és portszámmá. 83 A függvény egyaránt kezeli az IPv4-es és az IPv6-os címeket. Használata során olyan kritériumokat adunk meg, amelyekkel meghatározzuk a megfelelő címeket. Ennek hatására a függvény egy címekből álló listát ad vissza. A függvény alakja a következő: #include #include int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res); void freeaddrinfo(struct addrinfo *res); const char *gai trerror(int errcode);
83
A getaddrinfo() függvény elődeinek tekinthető a gethostbynaine(), amely hosztnév feloldásra képes, illetve a getservbyname(), amely a szolgáltatások portját adja vissza. Ezeket a függvényeket azonban számos korlátjuk miatt manapság nem használjuk.
294
6.5. IP
A lista memóriafoglalásának felszabadításához és a hibakezeléshez további függvények állnak rendelkezésre. A node paraméterben kell megadnunk a hosztnevet, a service paraméterben pedig azt a szolgáltatást, amelyeknek a címére kíváncsiak vagyunk Mind a két esetben szöveges formátumot használunk. A hosztnévnél megadhatunk IP-címet a szokásos formátumokban — ekkor az inet_pton0 függvényt helyettesítheti —, illetve a hoszt nevét. A szolgáltatásnál is használhatjuk a megnevezés mellett a port számát szöveges, decimális formátumban. Ha a service paraméternek NULL értéket adunk meg, a függvény a válaszban a portszámot nem állítja be. A hints paraméterben adhatjuk meg azokat a kritéríumokat, amelyek meg kell, hogy feleljenek a találatoknak. A res paraméterben kapjuk meg a választ láncoltlista-formában. Később a láncolt lista felszabadításában a freeaddrinfo() függvény segít, amellyel egy lépésben megoldható a feladat. A getciddrinfo0 függvény visszatérési értéke 0, ha sikeresen végrehajtódott. Hiba esetén a visszatérési érték egy olyan hibakód, amelynek szöveges információvá alakítását a gai_strerrorO függvénnyel végezhetjük el. Mind a kritériumok, mind a válasz típusa struct addrinfo, ennek felépítése a következő: struct addrinfo int ai_flags ; ai _fami 1 y ; int int ai_socktype; int ai_protocol ; si ze_t ai_addrlen; struct sockaddr *ai_addr; char *ai_canonname; struct addrinfo *al_next; } ;
Az ai_family mező a címcsaládot adja meg. Értéke lehet AF INET és AF_INET6
is. Kritériumként AF UNSPEC értéket is beállíthatunk. Ekkor a válaszlistában IPv4-es és IPv6-os címek is szerepelhetnek. Az ai_socktype mezőben a SOCK STREAM és a SOCK DGRAM értékeket vagy 0-t adhatunk meg. 0 érték esetén mind TCP-, mind UDP-bejegyzéseket tartalmazhat a válaszlista, ha az adott szolgáltatás mindkét protokollt támogatja. Az ai_protocol a protokollindexet adja meg. Kritériumként 0-t megadva azt jelenti, hogy a protokollértéket nem kötjük meg. Az eddig felsorolt három mező rendre a socketO függvény paraméterei lehetnek, amikor felhasználunk egy válaszbejegyzést. Az ai_addrlen mező a cím méretét adja meg byte-okban. A címet az ai_addr mező tartalmazza byte-osan sockaddr_in (IPv4) vagy sockaddr_in6 (IPv6) formátumban. Ezeket az értékeket használhatjuk a cím megadásánál.
295
6, fejezet: Hálózati kommunikáció
Az ai_canonname mező csak a lista első eleménél van kitöltve, és a hoszt nevét tartalmazza abban az esetben, ha kritériumként az ai_flags mezőben az AI CANONNAME értéket megadtuk. Az ai_next mező a láncolt lista következő elemére mutat, illetve a lista utolsó eleménél az értéke NULL. A lista végére hagytuk az ai_flags mezőt, amely egy kicsit hosszabb magyarázatot igényel. Ez a mező a kritériumok összeállításánál kap szerepet. Egy bitmaszkról van szó, amely különböző opciókat kapcsolhat be. A használható értékeket a 6.7. táblázat foglalja össze: 6.7. táblázat.
A címmegadás és szú-rés opciói -
Jelzőbit
Leírás
AI ADDRCONFIG
A válaszlista csak akkor tartalmaz IPv4-es címet, ha a lokális gépnek legalább egy IPv4-es címe van, amely nem a loopbackcím. Illetve IPv6-os címek esetén hasonlóképpen.
AI ALL
Csak az AI V4MAPPED értékkel van jelentése. Értelmezését lásd ott.
AI CANONNAME
Ha a node paraméter értéke nem NULL, akkor a válaszlista első elemének ai_canonname mezője tartalmazza a hoszt nevét.
AI NUMERICHOST
Kikapcsolja a névfeloldást, és a node paraméter értéke csak numerikus címreprezentáció lehet. Ezzel megtakaríthatjuk a névfeloldás idejét.
AI NUMERICSERV
Kikapcsolja a szolgáltatás névfeloldását. Szolgáltatásnak csak számot adhatunk meg (szövegesen).
AI PASSIVE
A címstruktúra egy szerversocket címhez kötéséhez használható lokális címet tartalmaz, ha ez az opció be van állítva, és a node paraméter NULL. Vagyis a cím INADDR ANY vagy IN6ADDR ANY INIT lesz.
Al V4MAPPED
Ha ez az opció be van kapcsolva, és mellette a kritérium aijamily mező értéke AF INET6, és a függvény ennek ellenére csak IPv4-es címet talál, akkor az IPv4-es címet IPv6-os formátumban adja vissza. Ha az AI ALL opcióval együtt alkalmazzuk, akkor IPv6-os és IPv4-es címeket is visszaad, de utóbbiakat IPv6-os formátumba alakítva.
Járjuk körbe az AI PASSIVE opciót egy kicsit jobban. Ha ezt az opciót beállítjuk, és nem adunk meg hosztnevet, akkor a kapott cím tipikusan a bind0 függvénnyel használható (INADDR ANY vagy IN6ADD_ANY INI7). Ha nem állítjuk be ezt az opciót, akkor a kapott cím a connect() és a sendto0 függvények számára használható. Ez esetben, ha megadunk hosztnevet, akkor annak a címét vagy címeit kapjuk vissza. Arra is lehetőségünk van azonban, hogy nem adunk meg hosztnevet, és akkor a loopbackcímet kapjuk vissza (INADDR_LOOPBACK vagy IN6ADDR_LOOPBACK INIT).
296
6.5. IP
Bár a kritériummegadásra és a válaszlista előállítására ugyanazt a struktúrát használjuk, valójában a mezők használatában van némi eltérés. Kritérium megadásakor csak az aijlags, az aiJamily, az ai_socktype és az aiprotocol mezőknek van szerepe. A többi mező értékét nem használjuk, értéküknek 0-nak vagy NULL-nak kell lennie. A válaszban pedig az aijlags mezőnek nincsen szerepe. Ha semmilyen kritériumot nem kívánunk beállítani, csak a hoszt nevére és a szolgáltatásra szűrni, akkor kritériumként NULL értéket is beállíthatunk. Ez azzal egyenértékű, mintha az aijlags mezőnek (AI V4MAPPED I Al ADDRCONFIG) értéket, az aijamily mezőnek AF UNSPEC értéket, míg a többi mezőnek 0-t állítottunk volna be. Feladat Alkossunk egy egyszerű programot, amely a paraméterként kapott hosztnevet IPcímmé alakítja és kiírja. /* getípaddr.c - internetes névfeloldás. */ #include #include #include #include int main(int argc, char* argv[]) struct addrinfo hints; struct addrinfo* res; struct addrinfo* p; int err; char ips[INET6_ADORSTRLEN]; if(argc != 2) printf("Használat: %s \n", argv[0]); return -1;
} memset(&hints, 0, sizeof(hínts)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; err = getaddrinfo(argv[11, NULL, &hints, &res); if(err != 0) { fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1;
} for(p = res; p != NULL; p = p >ai_next) -
if(p->ai_family == AF_INET)
{
297
6. fejezet: Hálózati kommunikáció i f(inet_ntop(AF_INET, &((struct sockaddr_in*)(p->ai_addr))-> sin_addr, íps, sizeof(ips)) != NULL) {
printf("IP: %s\n", íps); }
else if(p->ai_family == AF_INET6) if(inet_ntop(AF_INET6, &((struct sockaddr_in6*)(p->ai_addr))-> sin6_addr, ips, sizeof(ips)) != NULL) printf("IP: Yos\n", ips);
}
}
freeaddrinfo(res); return 0;
Teszteljük is le: ./getipaddr www.aut.bme.hu IP: 152.66.188.11
S . /getipaddr www.google.com IP: 173.194.35.176 IP: 173.194.35.177 IP: 173.194.35.178 IP: 173.194.35.179 IP: 173.194.35.180 IP: 2a00:1450:4016:801::1011 S . /getipaddr that.s.not.funny.com getaddrinfo: Name or service not known
6.5.11.2. A getnanné*;fi3O függvény A getnameinfo0 függvény nagyjából a getaddrinfoo függvény inverze. Az a feladata, hogy a socketcímet hoszt- és szolgáltatásnévvé konvertálja." A függvény alakja az alábbi:
84
Ez a függvény egyesíti a korábban, az IPv4 esetén használatos gethostbyaddr0 és geiservbyport0 függvények funkcionalitását, ám azoknál flexibilisebb.
298
#include #include int getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen, char *serv, size_t servlen, int flags);
Az sa paraméter a socketcímstruktúra (sockaddr_in vagy sockaddr_in6) muta-
tója. A struktúra méretét a salen paraméterben kell megadnunk. A host és a serv paraméterként egy-egy általunk lefoglalt karaktertömböt kell átadnunk, amelyeknek méretét a hostlen és a servlen paraméterekkel tudjuk közölni. Ebben a két tömbben kapjuk vissza a hoszt és a szolgáltatás nevét NULL terminált sztringként. Ha valamelyik értékre nem vagyunk kíváncsiak, akkor NULL értéket és 0 hosszúságot megadva ezt jelezhetjük. Természetesen legalább az egyiket kérnünk kell, mert különben nincs értelme meghívni a függvényt. A flags paraméter módosíthatja a függvény működését a 6.8. táblázat szerint. 6.8. táblázat. A getnameinfo függvény jelzőbitjei
Jelzőbit
Leírás
NI DGRAM
Egyes portoknál TCP és UDP protokollok esetében más-más szolgáltatás fut. Alapértelmezetten a TCP-szolgáltatás nevét kapjuk vissza. Ezzel a paraméterrel az UDP-szolgáltatást kapjuk meg.
NI NAMEREQD
Alapértelmezetten, ha a hosztnevet nem találja a függvény, akkor szöveges IP-címet ad vissza. Ha megadjuk ezt az opciót, akkor ebben az esetben hibával tér vissza a függvény.
NI NOFQDN
Alapértelmezetten teljes hosztnevet (Fully Qualified Domain Name) kapunk vissza, ezzel az opcióval csak a rövid hosztnevet.
NI NUMERICHOST
Ha megadjuk, akkor nem történik névfeloldás, hanem csak szöveges IP-címet kapunk vissza. Ez történik akkor is, ha nem sikerül a hosztnevet kideríteni.
NI NUMERICSERV
Numerikusan, egy decimális számot tartalmazó szövegként kapjuk vissza a portot.
Sikeres végrehajtás esetén a függvény 0-val tér vissza. Sikertelenség esetén hibakódot kapunk visszatérési értékként, amelyet ekkor is a gai_strerror() függvénnyel tudunk szöveggé alakítani. A getnameinfo() függvényt elsősorban az acceptQ vagy a reevfrom() függvények által visszaadott címekre alkalmazzuk. Tipikusan a naplózáshoz alakítjuk vissza a socketcímeket a felhasználó számára is értelmezhető formátumba. Feladat Az
előző fejezet példáját alakítsuk úgy át, hogy a socketcímet szöveges IP-címmé a
getnameinfo0 függvénnyel alakítjuk vissza.
299
6. fejezet: Hálózati kommunikáció
/* getipaddr.c - Internetes névfeloldás. */ #include #include #include int main(int argc, char* argv[]) {
struct addrinfo hints; struct addrinfo* res; struct addrinfo* p; int err; char ip5[INET6_ADDRSTRLEN]; if(argc != 2) printf("Használat: %s \n", argv[0]); return -1; }
memset(&hints, 0, sizeof(hints)); hints.ai_family = Ar_uNSPEC; hints.ai_socktype = SOCK_STREAM; err = getaddrinfo(argv[1], NULL, &hints, &res); i f(err != 0) {
fpríntf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1;
for(p = res; p != NULL; p = p->ai_next) {
if(getnameinfo(p->ai_addr, p->ai_addrlen, ips, sizeof(íps), NULL, 0, NI_NUMERICHOST) = 0) printf("IP: %s\n", ips); } }
freeaddrinfo(res); return 0;
Ám nemcsak a címet hozhatjuk szöveges formába ezzel a függvénnyel, hanem inverz névfeloldást is végezhetünk vele. Feladat Készítsünk egy programot, amely a paraméterként megkapott IPv4-es címet hosztnévvé alakítja.
300
6.5. IP /* gethost.c - IP címhez tartozó név feloldása. */ #ínclude #include #include #include
int main(int argc, char* argv[]) struct sockaddr_ín addr; char name[Ni_mAxH0ST]; int err; if(argc != 2) printf("Használat: %s \n", argv[0]); return -1; }
memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; i fOnet_ptOn(AF_INET, argv[1], &addr.sin_addr) == 0)
‚11
perror("inet_pton"); return -1;
err = getnameinfo((struct sockaddr*)&addr, sizeof(addr), name, sizeof(name), NULL, 0, NI_NAMEREQD); if(err != 0) fprintf(stderr, "getnameinfo: %s\n", gai_strerror(err)); return -1;
printf("A gép neve: %s\n", name); return 0; }
A programot letesztelve a következő kimenetet kapjuk: $ ./gethost 152.66.188.11 A gep neve: www.aut.bme.hu $ ./gethost 152.66.188.111 qetnameinfo: Name or service not known
emmemer-
301
6. fejezet: Hálózati kommunikáció
A példaprogramban szembesültünk azzal a problémával, hogy foglalnunk kell egy megfelelő méretű buffert a gép nevének. Szeretnénk akkorát foglalni, hogy beleférjen a teljes név, de nem tudhatjuk, mi elég. Hasonló problémával a szolgáltatásnevek feloldásánál is szembesülhetünk. A programozó dolgát az alábbi két definíció megkönnyíti:
Ez a két érték akkora, hogy a várható nevek beleférnek ekkora bufferekbe.
6.5.12. Összeköttetés-alapú kommunikáció A 6.2. Az összeköttetés-alapú kommunikáció alfejezetben már bemutattuk az összeköttetés-alapú kommunikáció egyes lépéseit. IP-kommunikáció esetén is ezeket a lépéseket követjük, csak annyi kiegészítéssel, hogy IP-socketet hozunk létre, és a címet az előző fejezetekben megismert módon állítjuk össze. Így jelenlegi ismereteink alapján már képesek vagyunk egy TCP-kommunikáció felépítésére és használatára. A kommunikáció során használhatjuk a reado és a write() függvényeket, a rendszer tartalmaz azonban két olyan másik függvényt, amely több lehetőséget nyújt. Az adatok küldéséhez használhatjuk a send0 függvényt: #include ssize_t send(int sockfd, const void *buf,, si ze_t len, int flags) ;
Az első argumentum a socket leírója, a második az elküldendő adat bufferének a mutatója, a harmadik az elküldendő adat mérete. Eddig a paraméterezés megegyezik a write() függvényével. A különbséget az utolsó, flags argumentum jelenti, amelyben további opciókat állíthatunk be. A flags argumentumban megadott jelzőbitek jelentését a 6.9. táblázat mutatja. 6.9. táblázat. A send jelzőbitjei
4040bit _
Lelt4
MSG_DONTROUTE A csomag nem mehet keresztül az útválasztókon, csak közvetlenül ugyanazon a hálózaton lévő gép kaphatja meg.
MSG_DONTWAIT
Engedélyezi a nem blokkoló I/O-t. EAGAIN hibával tér vissza, ha várakozni kellett volna a kiírásra, mert tele van a buffer.
MSG_MORE
További adatot szeretnénk még kiküldeni. Ezért nem küldi el a csomagot addig, amíg nem kap egy sendO hívást MSG_MORE opció nélkül.
302
6.5. iP
48126bit
Leírás
MSG_NOSIGNAL
Adatfolyam-alapú kapcsolat esetén a program nem kap SIGPIPE jelzést, amikor a kapcsolat megszakad. Ez azonban az EPIPE hibajelzést nem érinti.
MSG_OOB
Soron kívüli sürgős adatcsomagot (out-of-band data) küld. Általában jelzések hatására használják.
'
A sertd() visszatérési értéke hasonlít a write() függvényéhez. Sikeres küldés esetén az elküldött byte-ok számát kapjuk vissza, hiba esetén pedig negatív értéket. Utóbbinál az errno globális változó tartalmazza a hibát. A send0 párja a recv() függvény, amely a readO függvényre hasonlít: 4include ssize_t recv(int sockfd, void *buf, size_t len, int flags); A függvény első három paramétere a read() függvénynél is megszokott alakú.
Sorban a socketleíró, a fogadó buffer és a buffer mérete. Ezt követi a flags paraméter, amellyel további opciókat állíthatunk be. A flags paraméter értéke a 6.1(). táblázatban összefoglalt jelzó'bitekből állhat össze:
r
6.10. táblázat. A recv jelzőbitjei
Jelzőbit
d
Leírás
MSG_DONTWAIT
Engedélyezi a nem blokkoló 1/0-t. EAGAIN hibával tér vissza, ha várakozni kellett volna az olvasásra, mert nem érkezett adat.
MSG_OOB
Soron kívüli adat fogadása.
MSG_PEEK
Az adat beolvasása történik meg anélkül, hogy a beolvasott adatot eltávolítaná a bufferből. A következő recv0 hívás ugyanazt az adatot még egyszer kiolvassa.
MSG_WAITALL
Addig nem tér vissza, amíg a megadott buffer meg nem telik, vagy egyéb rendhagyó dolog nem történik, például jelzés érkezik.
A recv0 függvény visszatérési értéke megegyezik a read() függvényével. Sikeres olvasás esetén a bufferbe beírt byte-ok száma, hiba esetén mínusz érték, valamint 0, ha a túloldal lezárta a kapcsolatot. Gyakori feladat, hogy a TCP-kapcsolaton keresztül egy állományt kell elküldenünk. Ezt leimplementálhatjuk úgy, hogy egy ciklusban a readO függvénnyel beolvassuk az adatokat egy bufferba, majd a send0 függvénnyel elküldjük a TCP-kapcsolaton. Ez a megoldás jól működik, ám nagyobb méretű állomány esetében sokszor meghívódna a ciklus, és egyre inkább előjönne a hátránya, miszerint az adatokat az egyik rendszerhívással a kernelból egy tömbbe másoljuk, majd egy másik rendszerhívással vissza a kernelbe. Egyszerűbb és gyorsabb egy lépésben a kernelben elvégezni az egész műveletet. Ezt valósítja meg a sendfileQ rendszerhívás: 303
6. fejezet: Hálózati kommunikáció #include ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
A függvény az in_fd állományleíróval megadott állományból az out_fd állományleíróval leírt socketkapcsolatba küldi az adatokat. Ha az offset paraméter nem NULL, akkor egy olyan változó mutatóját kell, hogy tartalmazza, amely megadja az eltolást az in_fd állomány elejéhez képest. Az olvasást és a küldést csak ettól a pozíciótól kezdi el a függvény. Amikor a függvény visszatér, akkor módosítja az eltolás értékét. Az új érték az utolsó elküldött byte + 1 lesz. Ha az offset nem NULL, akkor a függvény nem módosítja a bemeneti állomány aktuális pozícióját. Ha az offset értéke NULL, akkor az olvasás a bemeneti állomány aktuális pozíciójától indul, és a függvény végén frissül is. A count paraméter a másolandó byte-ok számát adja meg. Ha több lenne, mint amennyi byte-ot az állomány tartalmaz, akkor csak az állomány végéig továbbítja a byte-okat. Sikeres visszatéréskor a visszatérési érték az elküldött byte-ok száma. Hiba esetén mínusz érték, és az errno globális változó tartalmazza a hibakódot. A sendfile0 rendszerhívás mellett a Linux támogat még további nem szabványos rendszerhívásokat is hasonló célokra: spliceO, vmspliceO, teeO. Ezekre jelen keretek közt nem térünk ki.
6.5.12.1. TCP kliens-szerver példa Eddigi ismereteink alapján könnyen meg tudunk valósítani egy egyszerű TCP-szervert és a hozzákapcsolódó klienst. Feladat Alkossunk egy egyszerű TCP-szervert, amely képes egy kapcsolat fogadására, majd a klienstől kapott adatokat kiírja az alapértelmezett kimenetre.
A feladatot az alábbi, IPv6-ot támogató szerverprogram megoldja: #include #include #include #include #include #include #include #define PORT "1122"
304
6.5. IP int main() struct addrinfo hints; struct addrinfo* res; ínt err; struct sockaddr_in6 addr; socklen_t addrlen; char ips[Ni_mAXHOST]; char servs[NI_MAXSERV]; int ssock, csock; char buf[256]; int len; int reuse;
I
memset(&hints, 0, sizeof(hínts)); hínts.aí_flags = AI_PASSIVE; hints.ai_family = AF_INE76; hints.ai_socktype = SOCK_STREAM; err = getaddrinfo(NULL, PORT, &hints, &res); i f(err I-- 0) fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1; if(res == NULL) {
return -1; }
ssock = socket(res->ai_family, res->ai_socktype, res->ai_protocol); if(ssock < 0) perror("socket"); return 1;
reuse = 1; setsockopt(ssock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); if(bind(ssock, res->ai_addr, res->ai_addrlen) < 0) {
perror("bind"); return 1; } if(listen(ssock, 5) < 0) perror("listen"); return 1;
305
6. fejezet: Hálózati kommunikáció
freeaddrinfo(res); addrlen = sizeof(addr); while((csock = accept(ssock, (struct sockaddr*)&addr, &addrlen)) >= 0) {
if(getnameinfo((struct sockaddr*)&addr, addrlen, i ps, sizeof(ips), servs, sizeof(servs), 0) == 0) {
printf("Kacsolódás: %s:%s\n", ips, servs); }
while((len = recv(csock, buf, sizeof(buf), 0)) > 0) write(STDOUT_FILENO,
buf, len);
printf("Kapcsolat zárása.\n"); close(ssock);
close(ssock); return 0;
A címhez kötéshez össze kell állítanunk egy lokális címet. Ezt megtehetjük közvetlenül a sockaddr_in6 struktúra kitöltésével, vagy rábízhatjuk a getaddrinfo() függvényre. Példánkban az utóbbit választottuk, hogy bemutassuk a használatát. A szervercímhez a függvény kritériumában meg kell adnunk az Al PASSIVE opciót. Ugyanakkor a paraméterek közt hosztnévnek NULL értéket adtunk meg. A két beállítás hatására a cím értéke 1N6ADDR ANY INIT lesz. A cím előállítása után létrehozunk egy socketet, majd a setsockoptQ függvény segítségével beállítjuk, hogy a programunk leállása után a szolgáltatás portja azonnal ísmét használható legyen. (A setsockopt() függvényt bővebben a 6.6. Socketbeállítá sok alfejezetben ismertetjük.) Ezt követően hozzákötjük a socketet a címhez, és bekapcsoljuk a szervermódot. Majd egy cikluson belül fogadjuk a kapcsolódásokat, kiírjuk a kliens címét, portját és a kapott adatokat. Amikor a kliens lezárja a kapcsolatot, akkor a recv() függvény nullával tér vissza, és megszakad a ciklus. Ezt követően a szerver is zárja a socketet, és várja a következő kapcsolatot. Feladat Alkossuk meg az előző szerver klienspárját. A kliens kapcsolódjon a paraméterként megadott szervergéphez, és a socketkapcsolaton küldje el az alapértelmezett bemenetén kapott szöveget.
Az alábbi példaprogram valósítja meg a feladatot:
306
6.5. IP #include #include #include #include #include #include #include
#define PORT "1122" int main(int argc, char* argv[]) {
struct addrinfo hints; struct addrinfo* res; int err; int csock; char buf[1024]; i nt len; if(argc != 2) {
printf("Használat: %s \n", argv[0]); return 1; }
memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; err = getaddrinfo(argv[1], if(err != 0)
PORT, &hints, &res);
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1; } if(res == NULL) {
return -1; } csock = socket(res->ai_family, res->ai_socktype,
res >ai_protocol); -
if(csock < 0) { perror("socket"); return -1;
if(connect(csock, res->ai_addr, res->ai_addrlen) < 0) perror("connect"); return -1;
307
6. fejezet: Hálózati kommunikáció
while((len = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
send(csock, buf, len, 0); }
close(csock); freeaddrinfo(res); return 0;
•
}
A példaprogramban a getaddrinfo() függvény segítségével előállítjuk a célgép megadott portjához tartozó címstruktúrát. Ha több címet is visszakapunk, akkor a lista első elemét választjuk a kapcsolódáshoz. A kapcsolat létrejötte után a program folyamatosan olvassa az alapértelmezett bemenetet, és a szöveget elküldi a socket, kapcsolaton keresztül Amikor a bemenetet a CTRL +D gombokkal lezárjuk, akkor a program zárja a socketkapcsolatot, és véget ér.
6.5.12.2. TCP szerver alkalmazás Az előző fejezetbeli szerverpélda alkalmazása tartalmaz egy súlyos hiányosságot. Az alkalmazás egyszerre csak egy kapcsolatot képes fogadni. Addig, amíg ezt kiszolgálja, nem hívódik meg az aceept() függvény, és nem fogad újabb kapcsolatot. Az ilyen jellegű szervereket iteratív szervernek nevezzük, és legfeljebb olyan esetekben alkalmazzuk, amikor a kliensek kiszolgálása nagyon gyors. Általában a kliens-szerver kommunikáció hosszabb, és a szerverektől azt várjuk el, hogy konkurensen egyszerre több klienst is kiszolgáljanak. Ilyenkor párhuzamosan kell futnia a kapcsolatok fogadásának és a kliensek kiszolgálásának. Erre a problémára több szerveroldali megközelítést alkalmazhatunk. Kapcsolatonként egy szál (folyamat) Minden bejövő kapcsolatnak új szálat (vagy folyamatot) indítunk, amelynek átadjuk a kapcsolódott klienssocketet. A klienssocketet ezt követően már a szál (folyamat) kezeli. Ekkor minden kapcsolatot a kód szempontjából szimmetrikusan kezelünk, de a szálak (folyamatok) közötti váltás lassító tényező. Illetve a saját stack miatt nem skálázódik jól nagy szervereken. Processzek esetében a Linux copy-on-write technikája (forkQ használatakor csak akkor másolja le a memóriaterületet, ha arra a gyermekprocessz ír) jelentősen csökkenti az új processz létrehozásának az idejét, jóllehet egy új processz létrehozása külön erőforrást emészt fel a rendszerből, és az indítható processzeknek felső korlátja van.
308
6.5. IP
Ugyanakkor processzek esetében az 5.1. Processzek alfejezetben tárgyalt exec() függvénycsalád segítségével könnyen indíthatunk más programokat." Robusztusság szempontjából viszont, ha új processzt indítunk, annak esetleges összeomlása nem rántja magával az összes többi processzt. Előre elindított szálak (folyamatok) A fenti módszert gyorsíthatjuk azzal, hogy a szerverprogram elindítása után rögtön egy meghatározott számú szálat (folyamatot) indítunk el („process pool", „thread pool"), és ha valamelyik szabad, az kezeli az újonnan bejövő kapcsolatot. A korábbi megoldáshoz képest ekkor egy feladatkiosztó algoritmust is kell készítenünk. A megoldás előnye az, hogy a szálak (folyamatok) számának korlátozásával optimálisan kihasználhatjuk a gép erőforrásait. Emellett egy DoS-támadás 86 csak a szolgáltatást érinti, és nem „fojtja" meg az egész gépet. A módszer hátránya az, hogy esetleg feleslegesen sok szálat (processzt) futtat foglalva az erőforrásokat. Ennek kezelésére a komolyabb programok menedzsmentalgoritmust alkalmaznak, amely a terhelés figyelembevételével megszüntet vagy létrehoz szálakat (folyamatokat). Ám egy ilyen algoritmus implementálása bonyolítja a programot. Nézzünk egy egyszerű példát az előre allokált szálak használatára. A példaprogramban egy véletlen számokat visszaadó szervert mutatunk be: #include #include #include #include #ínclude #include #include #define PORT 2233 #define THREAD_NUM 3
int ssock; pthread_mutex_t smutex = PTHREAD_MUTEX_INITIALIZER ;
Ha paraméterként vesszük át a futtatandó program nevét, mindig gondoljuk át a szerver támadhatóságát. Ha egy nem ellenőrzött sztringet átadunk az execQ függvénynek, a távoli felhasználó akármit lefuttathat a gépünkön a futó szerverprogram jogosultságával. A leleményes betörő ilyenkor például lefuttatja a sendmail segédprogramot, hogy az küldje el neki a jelszavakat tartalmazó fájlt, amellyel aztán otthon „eljátszadozhat". Alapszabály tehát: futtatás előtt mindig ellenőrizzük a kívülről kapott paramétereket. K6 Az elárasztásos támadás (DoS, denial-of-service attack) vagy elosztott elárasztásos támadás (DDoS, distributed denial-of-service attack) során olyan sok kapcsolódási kérelem érkezik a szerverszolgáltatáshoz, amelyet az nem tud kezelni. A támadó így teszi elérhetetlenné a szolgáltatást, de akár az egész szervergépet is megbéníthatja. 85
309
6. fejezet: Hálózati kommunikáció
void* comth(void* arg)
f
int ix; int csock; char buf[64]; ix = *((int*)arg); free(arg); while(1) pthread_mutex_lock(&smutex); csock = accept(ssock, NULL, NULL); pthread_mutex_unlock(&smutex); sleep(3); spríntf(buf, "%d. szál: %d\n", ix, rand() % 10); send(csock, buf, strnlen(buf, sizeof(buf)), 0); close(csock); return NULL;
} int main()
f struct sockaddr_in6 addr; pthread_t th[THRE4o_Num]; int i; int reuse; int* pix; if((ssock = socket(PF_INET6, SOCK_STREAM, 0)) < 0) {
perror("socket"); return 1; } reuse = 1; setsockopt(ssock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); memset(&addr, 0, addr.sin6_family addr.sin6_addr = addr.sin6_port =
310
sízeof(addr)); = AF_INET6; in6addr_any; htons(PORT);
S.S. IP
if(bind(ssock, (struct sockaddr*)&addr, sizeof(addr)) < 0) perror("bind"); return 1;
if(listen(ssock,
5) < 0)
perror("listen"); return 1;
srand(time(NULL)); for(i = 0; i < THREAD_NUM; i++) pix = malloc(sizeof(int)); *pix = i; pthread_create(&th[i], NULL, comth, pix); }
// végtelen várakozás while(1) sleep(1); return 0;
A program induláskor elvégzi a szerversocket beállításait, majd létrehoz három szálat. A kapcsolatok szétosztására a szálak között egy aránylag egyszerű trükköt alkalmazunk. Egy mutex segítségével elérjük, hogy egyszerre egy szál hívhassa meg az acceptQ függvényt, és fogadhasson kapcsolatot. Így nem kell átadnunk a klienskapcsolatokat a szálaknak. A szerver kipróbálásához a telnet program segítségével kapcsolódjunk a tcp 2233-as porthoz. Kis várakozás után visszakapunk egy véletlen számot, és a szerver bontja a kapcsolatot. Több kliens kezelése egy szálon Az állományok párhuzamos kezelésénél megismert seleet(), pollQ és epoi/0 függvényeket használhatjuk a socketek esetén is. Így egy szálon, eseményvezérelten kezeljük az összes klienssocketet. Emellett a szerversocketet is kezelhetjük ezzel a megoldással, mert a kapcsolódás egy olvasási eseményt generál ebben az esetben. Az egyszálú megvalósítás lehetővé teszi, hogy az egyes klienskapcsolatok között adatokat mozgassunk szinkronizálás nélkül. Így leginkább ilyen jellegű alkalmazásokban látszik az előnye. Ugyanakkor a módszer hátránya az, hogy a kód bonyolultabb, nem annyira szimmetrikus, mint a korábbiakban látottak.
311
6. fejezet: Hálózati kommunikáció
Az alábbi program egy csevegőszervert valósít meg. Egy ilyen szolgáltatásnál a felhasználók egymásnak küldik az üzeneteiket, vagyis az egyik klienskapcsolaton érkező szöveget a többi kapcsolatra kell kiküldenünk. A kapcsolatok közötti adatátvitel miatt a szolgáltatást egy szálon érdemes leimplementálni: #include #include #include #ínclude #include #include #include
#define PORT 3344 #define MAXCONNS 5 #define FULLMSG "megtelt!"
,iI•111■
•
int ssock; int connect_list[mAxCONNs]; struct pollfd poll_list[mAXcoNNS + 1]; int build_poll_list() { int i, count; poll_list[0].fd = ssock; poll_list[0].events = POLLIN; count = 1; for(i = 0; í < MAXCONNS; i++) {
if(connect_list[1] >= 0) poll_list[count].fd = connect_list[i]; poll_list[count].events = POLLIN; count++;
return count;
void handle_new_connection() int i, csock; csock = accept(ssock, NULL, NULL); if(csock < 0) return; for(i = 0; i < MAXCONNS; i++) {
if(connect_list[i] < 0)
312
6.5.1p
{ connect_list[i] = csock; csock = -1; break;
if(csock >= 0) send(csock, FULLMSG, strlen(FULLMSG), 0); close(csock);
void process_read(int csock) { char buf[256]; int len; int i; len = recv(csock, buf, sizeof(buf), 0); if(len > 0) { for(i = 0; i < MAXCONNS; i++) { i f((connect_list[i] >= 0) && (connect_list[i] != csock)) send(connect_list[i], buf, len, 0); }
int main() struct sockaddr_in6 addr; i nt reuse; int i; int íi; if((ssock = socket(PF_INET6, SOCK_STREAM, 0)) c 0) { perror("socket"); return 1; } reuse = I; setsockopt(ssock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); memset(&addr, 0, addr.sinfl_family addr.sin6_addr = addr.sin6_port =
sizeof(addr)); = AF_INET6; in6addr_any; htons(PORT);
313
6. fejezet: Hálózati kommunikáció i f(bind(ssock, (struct sockaddr*)&addr, sizeof(addr)) < 0) perror("bind"); return 1; } if(listen(ssock, 5) < 0) { perror("listen"); return 1; } for(i = 0; i< MAXCONNS; i++) connect_list[i] = -1; while(1) i nt count = build_poll_list(); if(poll(poll_list, count, -1) > 0) { if(poll_list[0].revents & POLLIN) {
handle_new_connection(); for(i = 1; í < count; i++) if(poll_list[i].revents & (POLLERR I POLLHUP)) for(ii = 0 ; ii < MAXCONNS; ii++) i f(connect_list[ii] = pol 1_1 í st[i { connect_list[ii] = -1;
I
. fd)
cl ose(pol 1_11 st[i].fd) ; else íf(poll_list[i].revents & POLLIN)
_..
11111
1
process_read(poll_líst[i].fd);
return 0;
A program beállít egy szerversocketet, majd összeállít egy kapcsolatlistát. Ezt követően a poll0 rendszerhívással várja, hogy kapcsolódási kérelem érkezzen. Amikor egy olvasási esemény érkezik a szerversocketre, akkor fogadja a kapcsolatot, és elhelyezi a listába (vagy visszautasítja, ha a szerver megtelt). Ezt követően ismét meghívódik a pollQ. Ha valamelyik klienskapcsolatra érkezík adat, akkor a program beolvassa, és kiküldi a többi kapcsolatra. 314
6.5. IP
A szerverre a telnet program segítségével tudunk csatlakozni (tcp 3344-es port). Érdemes egyszerre több kapcsolatot felépíteni. Ezt követően, amit az egyik kapcsolaton elküldünk, az a szöveg a többi kapcsolaton megjelenik.
6.5.12.3. TCP-kliensalkalmazás A kliensalkalmazás az esetek többségében egyetlen klienssocketet tartalmaz, amely kapcsolatot kezdeményez valamilyen szerver felé. Majd a kapcsolat felvétele után megindul a kommunikáció. Itt az adatfogadás jelenthet némi problémát, ugyanis alapértelmezésben a recv0 hívás blokkolódik, és mindaddig nem tér vissza, amíg nem érkezik valamilyen csomag. Ráadásul kliensoldalon gyakran grafikus felhasználói felület található, amely látszólag „lefagy", mert az alkalmazás blokkolt állapotban nem tudja frissíteni a képernyőt és reagálni a felhasználói interakciókra. Ugyanez a probléma léphet fel szöveges terminál esetében is, hiszen blokkolt állapotban nem tudunk olvasni a szabványos bemenetról. Lehetséges megoldások a következők. Szöveges módban továbbra is használhatjuk a selectO, poll(), epoll() függvényt, amellyel nemcsak a socketre, hanem a szabványos bemenetre (stdin) is várakozunk. Egy másik megoldás a külön szál indítása. Ilyenkor azonban sokszor meg kell oldanunk a szálak kommunikációját és a közös adathozzáférés szinkronizációját. Külön folyamatot is indíthatunk a socketkommunikációra, de a nehézségek hasonlóak a szálakéihoz. Grafikus felhasználói felület esetén aszinkron socket kezelésére van szükségünk. A grafikus fejlesztői könyvtárak tartalmaznak ennek megvalósításához mechanizmusokat. Használjuk ezeket. A socketkommunikációban lehetőségünk van a magas szintű fájlkezelés használatára is. Erre mutat példát az alábbi kliensprogram, amely a HTTP 0.9-es protokoll segítségével lement egy internetoldalt. A kapcsolódás után a szerveralkalmazásnál már látottaknak megfelelően használhatnánk a recv() és a send() hívásokat a kommunikációra (amelyek, mint kiderült, sokkal több lehetőséget adnak a kommunikáció szabályozására), de a példa egyszerűségénél fogva kényelmesebb a magas szintű megoldást alkalmazni: #include #include #include #include #include #include #include #include int main(int argc, char* argv[]) struct addrinfo hints; struct addrinfo* res;
315
6. fejezet: Hálózati kommunikáció strucc addrinfo* p; ínt err; char ips[INET6_ADDRsTRLEN]; int csock; char buf[1024]; FILE* fpsock; FILE* fpfile; int len; if(argc != 4) { printf("Használat: %s \n", argv[0]); return 1;
memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; 1105 err = getaddrinfo(argv[1], "http", &hints, &res); if(err != 0) { fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1; } fpfile = fopen(argv[3], "w"); if(fpfile == NULL) { perror("fopen"); return -1; } for(p = res; p != NULL; p = p->ai_next) { lf(getnameinfo(p->ai_addr, p->ai_addrlen, ips, sizeof(ips), NULL, 0, NI_NUMERICHOST) == 0) { printf("Kacsolódás: %s\n", ips); } csock = socket(p->ai_family, p->ai_socktype, p->ai_protocol); i f(csock < 0) continue;
I
316
if(connect(csock, p->ai_addr, p->ai_addrlen) == 0) { printf("Sikeres kapcsolódás.\n"); fpsock = fdopen(csock, "r+"); // HTTP 0.9 oldal lekérés fpríntf(fpsock, "GET %s\r\n", argv[2]); fflush(fpsock); printf("Adatok mentése.\n");
6.5. IP
whíle((len = fread(buf, 1, sizeof(buf), fpsock)) > 0) fwrite(buf, 1, len, fpfile); printf("Kapcsolat bontása.\n"); fclose(fpsock); break; }
el se perror("connect");
close(csock);
fclose(fpfile); freeaddrinfo(res);
return 0; Próbáljuk ki a programot.: ./httpment www.aut.bme.hu / test.html
6.5.13. Összeköttetés nélküli kommunikáció Összeköttetés nélküli kapcsolathoz az IP-család esetében a szállítási rétegben UDP-t (User Datagram Protocol, felhasználói datagramprotokoll) használunk Az UDP protokoll működése egyszerűbb, a fejléce kisebb, így gyors kommunikációt tesz lehetővé. Ám nem garantált, hogy •
a csomag célba ér;
•
az elküldött adatok ugyanabban a sorrendben érkeznek meg, amelyben küldtük őket.
Az UDP-csomagok mérete legfeljebb 64 kB lehet.
6.5.13.1. UDP-kommunikáció-példa A 6.3. Az összeköttetés nélküli kommunikáció alfejezetben bemutattuk a kapcsolat nélküli kommunikáció használatát, az előző fejezetekben pedig az IP-címek kezelését. Így egy UDP-kommunikációt megvalósító programot is összeállíthatunk.
317
6. fejezet: Hálózati kommunikáció
A datagram-alapú kommunikációknál általában nem különböztetünk meg szerver- és kliensszerepet, mivel mindkét oldal fogad és küld is adatokat. A következő példában azonban a könnyebb érthetőség kedvéért a két funkciót szétválasztottuk. Így lesz egy fogadó- és egy küldőprogramunk. A fogadóprogram UDP-csomagokat fogad. Kiírja a küldőt és a csomag tartalmát: #include #include #include #include #include #include #include
#define PORT "1122" int main() { struct addrinfo hints; struct addrinfo* res; int err; struct sockaddr_in6 addr; sockl en_t addrlen; char ips[NI_MAXHOST]; char se rvs [NI_MAXSERV] ; int sock; char buf [256]; í nt len; memset(&hints. 0, sizeof(hints)); hints.ai_flags = AI_PASSIVE; hints.ai_family = AF_INET6; hínts.ai_socktype = SOCK_DGRAM; err = getaddrinfo(NULL, PORT, &hints, &res); i f(err != 0) fprintf(stderr, "getaddrinfo: %s \n", gai_strerror(err)); return -1; } if(res == NULL) {
return -1;
sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
318
b 5. IP
if(sock < 0) perror("socket"); return 1;
i f(bind(sock, res->ai_addr, res->ai_addrlen) < 0) perror("bind"); return 1;
} freeaddrínfo(res): addrlen = sízeof(addr); while((len = recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&addr, &addrlen)) > 0)
{ if(getnameinfoUstruct sockaddr*)&addr, addrlen, ips, sizeof(ips), servs, sizeof(servs), 0) == 0) fprintf(stdout, "%s:%s: ", ips, servs); fflush(stdout);
} write(STDOUT_FILENo, buf, len);
close(sock);
return 0;
A küldőprogram a szabványos bemeneten fogad szöveget, és soronként egyegy csomagban elküldi a szervernek. Egészen addig csinálja ezt, amíg a CTRL + D billentyűkkel leállítjuk a bevitelt: #include #include #include #include #include #include #include #include
#define PORT "1122" int main(int argc, Ihar* argv[]) struct addrinfo hints; struct addrinfo* res;
319
6. fejezet: Hálózati kommunikáció i nt err; int sock; char buf[1024]; int len; if(argc != 2)
f
printf("Használat: %s \n", argv[0]); return 1;
memset(&hints, 0, sizeof(hints)); hints.aí_family = AF_UNSPEC; hints.ai_socktype = SOCK_DGRAM; err = getaddrínfo(argv[1], if(err != 0)
PORT, &hints, &res);
{
fprintf(stderr, "getaddrinfo: %s\n", gaí_strerror(err)); return -1; }
if(res == NULL) {
return -1; }
sock = socket(res->ai_family, res->ai_socktype, res->aí_protocol); if(sock < 0) perror("socket"); return -1;
while((len = read(ST0IN_FILENO, buf, sizeof(buf))) > 0) {
sendto(sock, buf, len, 0, res->ai_addr, res->ai_addrlen);
freeaddrínfo(res); close(sock); return 0;
6.5.13.2.
Többes küldés
Amikor nemcsak egy végpontnak, hanem egyszerre többnek szeretnénk elküldeni valamit, elég egyszerű dolgunk van: speciális IP-címre kell küldenünk a csomagot. A többes küldést (multicast) az IP-hálózaton az útválasztók bonyolítják le. 320
6.5. IP
IPv4 esetén valamelyik D osztályú (224.0.0.0-239.255.255.255) címet kell használnunk. Minden D osztályú cím egy hosztcsoportot jelent Vannak ideiglenes hosztcsoportok, amelyhez csatlakozhatunk, vagy amelyból kiválhatunk. Van azonban néhány állandó cím is. 87 6.11. táblázat. Előredefiniált IPv4-es multicastcsoportok
Az egy LAN-on lévő útválasztók
Az IPv6 esetén a többes küldés címeinek az ff00::/ 8 tartomány van fenntartva. Ebben az esetben is számos előre definált csoport van. 88 6.12. táblázat. Előredefiniált IPv6-os multicastcsoportok
Az egy LAN-on lévő útválasztók
A többes küldés fogadása előre definiált csoportoknál nem igényel plusz műveletet. Ha azonban dinamikusan létrehozott csoportot szeretnénk használni, akkor a socketünket hozzá kell rendelnünk a kiválasztott csoporthoz. Ezt a setsockopt0 függvénnyel (bővebben lásd a 6.6. Socketbeállítások alfejezetben) végezhetjük el: #include ai_family, res->ai_socktype, res->ai_protocol); if(sock < 0) {
perror("socket"); return 1;
if(bind(sock, res->ai_addr, res->ai_addrlen) < 0) {
perror("bind"); return 1;
freeaddrinfo(res); memset(&mreq, 0, sizeof(mreq)); inet_pton(AF_INET, "224.1.1.1", &mreq.imr_multiaddr);
324
6.5. IP if(setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof(mreq)) != 0 ) perror("IP_ADO_MEMBERSHIP"); return -1;
memset(&mreq6, 0, sizeof(mreq6)); net_pton(AF_INET6, "ff02 : :13D" , &mreq6, pv6mr_mul ti add r) ; i f(setsockopt(sock, IPPROTO_IPv6, IPV6_JOIN_GROUP, &mreq6, sizeof(mreq6)) != 0 ) perror("IPV6_JOIN_GROUP"); return -1; }
addrlen = sizeof(addr); while((len = recvfrom(sock, buf, sizeof(buf), 0, (strucc sockaddr*)&addr, &addrlen)) > 0)
MI
i f(getnameinfo((struct sockaddr*)&addr, addrlen, ips, sizeof(ips), servs, sizeof(servs), 0) == 0) { fprintf(stdout, "%s:%s: ", ips, servs); fflush(stdout);
1
write(STDOUT_FILENO, buf, len);
close(sock); return 0;
A szolgáltatásunkat hozzárendeljük a 224.1.1.1 IPv4-es és az f'f()2::13D IPv6-os csoportcímekhez. Ha lefuttatjuk a módosított fogadóprogramot, akkor a korábbi küldőprogrammal tesztelhetjük. Feladat Teszteljük a módosított UDP-fogadó programot úgy, hogy a küldőprogramnál a következő címeket használjuk: 224.1.1.1, ff02::13D, ff02::1.
325
6. fejezet: Hálózati kommunikáció
6.6. Socketbeállítások A socketbeállításokkal a socket működésének számos jellemzőjét tudjuk módosítani. Egy-egy socketművelet során több protokollszintet is használunk egyszerre. Egy TCP-kommunikáció során például használjuk a TCP-, az IPés a socketszintet. Az egyes szinteken különböző opciókat tudunk beállítani és ezzel hangolni a kommunikációt. A beállításokat a getsockoptQ rendszerhívással kérdezhetjük le, és a setsockoptO rendszerhívással módosíthatjuk: #include int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen); int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
Az első paraméter a socket leírója. Ezzel hivatkozunk arra a socketre, amelynek a beállításait módosítani szeretnénk. A level paraméterrel adjuk meg, hogy melyik protokollszinthez tartozik az a beállítás, amelyet kezelni szeretnénk. A 6.16. táblázatban összefoglaljuk a tipikusan használt értékeket. 6.16. táblázat. IP protokoll szintek
Érték
Szint
„man" oldal
SOL_SOCKNT
socket és egyben Unix
socket(7), unix(7)
1PPROTO_IP
IPv4
ip(7)
rPPROTO IP6
IPv6
ipv6(7)
IPPROTO_TCP
TCP
tcp(7)
1PPROTO_UDP
UDP
udp(7)
Az optname paraméterben adjuk meg a kiválasztott opciót. A lehetséges beállítások a protokollszinttől függenek. Az előző táblázat utolsó oszlopában megadtuk az adott szintet leíró „man" oldal nevét. Ezek a leírások tartalmazzák a teljes beállításlistát. Az optval paraméter tartalmazza a beállítás értékét, míg az optlen a változó méretét. Az érték típusa függ a beállítástól, és az előbb említett leírások tartalmazzák. Az alábbiakban kiemelünk néhány gyakrabban használt beállítást. A többes küldés beállításaira nem térünk ki (ezeket lásd a 6.5.13.2. Többes küldés alfejezetben).
326
6.6. Socketbeállítások
SO_KEEPALIVE A socketszint tartalmazza. Kapcsolatalapú socket esetén ú. n. életben tartó (keep-alive) üzeneteket küld. Ennek segítségével abban az esetben is érzékelhetjük a kommunikációs hibát, ha egyébként hosszan nem forgalmazunk adatot. Az érték típusa integer. 0 és 1 értékkel kapcsolhatjuk ki, illetve be.
SO_REUSEADDR A socketszint tartalmazza. A bindo rendszerhívás számára jelzi, hogy a lokális portot újra felhasználhatja, ha éppen nincs aktív socket az adott porton. Az érték típusa integer. 0 és 1 értékkel kapcsolhatjuk ki, illetve be. Útmutató Ahogy a korábbi példáinkban is látható volt, ennek a beállításnak a használata igen gyakori. Ha nem használnánk, akkor a folyamat leállítását követően nem lehetne az alkalmazást azonnal újraindítani, mert ekkor a bind() művelet hibával térne vissza. Azt jelezné, hogy a portot már másik folyamat használja. Ekkor ki kell várnunk egy bizonyos időt, amíg a port újra használhatóvá válik. A SO_REUSEADDR beállítás használatával viszont a port azonnal újrahasznosítható, ha lezártuk a szerversocketet.
TCP_CORK, UDP_CORK A TCP-, illetve az LTDP-réteg tartalmazza őket. Ha beállítjuk ezt az opciót, akkor a sendO, illetve a sendto0 függvények meghívásakor nem küldi el azonnal a csomagokat, hanem egy várakozási sorban gyűjtögeti őket. Ezt követően az opció kikapcsolásakor az adatok kiküldése egy csomagban történik. Az érték típusa integer, és 0/1 értékekkel kapcsolhatjuk ki és be az opciót. Ezek a beállítások nem hordozhatók, csak Linux rendszereken működnek. Az alábbi példában a korábbi UDP-küldő program módosításával demonstráljuk a beállítás működését: #include #include #include #include #include #include #ínclude #include #include #define PORT "1122" #define SAYS "Simon mondja: " int main(int argc, char* argv[]) struct addrinfo hints; struct addrinfo* res; ínt err;
BEI 327
6. fejezet: Hálózati kommunikáció
int sock; char buf[1024]; int len; int state; i f(argc != 2) printf("Használat: %s \n", argv[0]); return 1;
memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hínts.ai_socktype = SOCK_DGRAM; err = getaddrinfo(argv[1], PORT, &hínts, &res); if(err != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1;
„
}
if(res == NULL) {
return -1;
sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol); i f(sock < 0) {
perror("socket"); return -1; }
state = 1; setsockopt(sock, IPPROTO_UDP, UDP_CORK, &state, sizeof(state)); while((len = read(STDIN_FTLENO, buf, sizeof(buf))) > 0) {
sendto(sock, buf, len, 0, res->ai_addr, res->ai_addrlen); }
freeaddrinfo(res); state = 0; setsockopt(sock, IPPROTO_UDP, UDP_CORK, &state, sizeof(state)); close(sock): return 0;
328
6.6. Socketbeállítások
Fordítsuk le és próbáljuk ki a programot. Azt tapasztaljuk, hogy a beírt szöveget nem soronként küldi el a fogadónak, hanem egyben, egy csomagban. A gyakorlatban finomabban tudjuk szabályozni a csomagok összevonását a sendO, illetve a sendto() rendszerhívás MSG_MORE opciójával. Ilyenkor az MSG MORE opcióval küldött adatok belekerülnek a várakozási sorba, majd a soron következő MSG_MORE opció nélküli hívás során az adatok kiküldése egyben történik meg. Ennek működését a következő példaprogram mutatja be: #include #include #include #include #include #include #í nclude #include #define PORT "1122" #define SAYS "Simon mondja: " int main(int argc, char* argv[]) struct addrinfo hints; struct addrinfo* res; int err; int sock; char buf[1024]; int len; if(argc != 2) {
printf("Használat: %s \n", argv[0]); return 1; }
memset(&hints, 0, sizeof(hints)); hints.ai_famíly = AF_UNSPEC; hints,ai_socktype = SOCK_DGRAM; err = getaddrinfo(argv[1], if(err != 0)
PORT, &hints, &res);
{
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(err)); return -1; if(res == NULL)
t
return -1;
sock = socket(res->ai_family, res->aí_socktype, res->ai_protocol);
329
6. fejezet: Hálózati kommunikáció
i f(sock < 0) perror("socket"); return -1;
while((len = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
sendto(sock, SAYS, strlen(SAYS), MSG_MORE, res->ai_addr, res->ai_addrlen); sendto(sock, buf, len, 0, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res); cl ose(sock) ; return 0;
Ha a programot kipróbáljuk, akkor azt tapasztalhatjuk, hogy a két sendto0 hívás adatai egyben, egy csomagban küldődnek ki.
6.7. Segédprogramok A hálózati kommunikáció implementálásakor belefuthatunk hibákba, amikor a kommunikáció valamiért nem tud felépülni a programjaink között. Ilyenkor segédprogramok segíthetnek kideríteni, hogy meddig jutottunk el a kapcsolat felépítésében. Az egyik legfontosabb program a netstat, amely számos információt kiír a hálózati alrendszerről. Paraméterek nélkül kiírja az éppen élő hálózati kapcsolatokat. A -a paraméterrel a lista kiegészül a szerver- és az UDP-socketekkel. A -t és a -u opcióval kiegészítve a listát leszűkíthetjük a TCP-, illetve UDP-socketekre. Emellett a -n opcióval kikapcsolhatjuk a névfeloldást, és mindent numerikusan láthatunk A netstat a forgalmazott csomagokról nem ad információt, ám a tcpdump program igen. Segítségével monitorozhatjuk a hálózati csomagforgalmat, és alacsony szinten megnézhetjük, hogy a programunk tényleg küld-e vagy kape csomagokat. A tcpdump program szöveges interfésze miatt nagyobb forgalom esetén nehezen kezelhető. Ám a feladatra létezik egy grafikus felhasználói felülettel rendelkező program, a wireshark. 89 A grafikus felület nagyban segíti a program használatát és az adatok értelmezését. 89
A Wireshark program weboldala: http://www.wireshark.org/.
330
6.8. Távoli eljáráshívás
, '
a
Q.7 ,Caoture
émkae
C4r8.uril8Frer8 wON0(.3 , p ,rdr,,,t 147 66 188 111 rWcres'neri: 1671 Tele.$.71lt Jpols Irccm,lls he(:.
a a •
ZT ITU ‚
0>sbnac e 1 0.000000
2 0.000514
J üi ts
•"•
848•8388:0 ,
Hetimtett-_f3:de:ta tlicrosof_fb. ld: f
,
Lenath
Broadcast Hewlett
-
ARP t3 de la
ARP
42 who has 152.66.188.11? is a1 00:15
65 I52.66 188.11
-- .11 • II
Frame 1, 42 bytes un wtre (336 birs), 42 bytes captured ( 336 blts) Ethernet II. Src: Hewlett. fS:de:la (68:b5:99:f3:de: la), 0st: Broadcast (ff:ff:ff:ff:ff:ff)
6.5. ábra. A Wireshark főablaka
A Wireshark program felületét a 6.5. ábra mutatja. A program főablaka három részre tagolódik. A felső részben egy listát találhatunk, amely az elkapott csomagokat tartalmazza. Ha ebből a listából kiválasztunk egy csomagot, akkor az alsó részben láthatjuk a bináris tartalmát, míg a középső rész ezt értelmezett formában mutatja. A vizsgálódásaink során elsősorban a középső részt használjuk. A középső mező a kiválasztott csomag által tartalmazott keretek listáját mutatja. Ahogy a csomag az egyes hálózati rétegeken keresztül haladva felépül, minden réteg egy új keretet alkot belőle. Minden keret rendelkezik néhány fejlécmezővel és egy adatrésszel. A lista elemeit lenyitva a fejlécmezők tartalmát tekinthetjük meg.
6.8. Távoli eljáráshívás A távoli eljáráshívás (Remote Procedure Call, RPC) egy magas szintű kommunikációs paradigma. Lehetővé teszi távoli gépeken lévő eljárások meghívását, miközben elrejti a felhasználó elől az alsóbb hálózati rétegeket. Az RPC logikai kliens-szerver modellt valósít meg. A kliens szolgáltatási igényeket küld a kiszolgálónak. A szerver fogadja az igényeket, végrehajtja a kért funkciót, választ küld, majd visszaadja a vezérlést a kliensnek.
331
6. fejezet: Hálózati kommunikáció
Az RPC megkíméli a felhasználót az alsóbb hálózati rétegek ismeretétől és programozásától. A hívások transzparensek. A hívónak explicite nincs tudomása az RPC-ről, a távoli eljárásokat ugyanúgy hívja, mint egy helyi eljárást.
6.8.1. Az RPC-modell A távoli eljáráshívás modellje hasonlít a helyi eljáráshívás modelljére. A helyi eljárás hívója a hívás argumentumait egy jól meghatározott helyre teszi, majd átadja a vezérlést az eljárásnak. A hívás eredményét a hívó egy jól meghatározott helyről elveszi, és tovább fut. Távoli eljáráshívás esetén a végrehajtási szál két folyamaton (kliens és szerver) halad keresztül. A hívó üzenetet küld a szervernek, majd válaszra vár. A hívó üzenete tartalmazza a hívás paramétereit, a válaszüzenet pedig az eljárás eredményét. A kliens kiveszi a válaszüzenetből az eredményt, és tovább fut.
A gép
B gép
Kliensprogram
Szerverdémon RPC-hívás Szolgáltatás meghívása
°17 Válasz
Szolgáltatás végrehajtása
Szolgaltatás kész
Program folytatódik
6.6. ábra. RPC kommunikáció -
A szerveroldalon egy várakozó — alvó — processz várja a hívók üzeneteit. Az érkező üzenetből kiveszi az eljárás paramétereit, elvégzi a feladatot, majd visszaküldi a válaszüzenetet. A két processz közül egy időben csak az egyik aktív, a másik várakozó állapotban van.
6.8.2. Verziók és számok Minden RPC-eljárást egyértelműen meghatároz egy programszám és egy eljárásszám. A programszám az eljárások egy csoportját jelöli. A csoporton belül minden eljárásnak egyedi eljárásszáma van. Ezen kívül minden programnak van verziószáma is. Így a szolgáltatások bővítése vagy változtatása esetén 332
6.8. Távoli eljáráshívás
kell új programszámot adni, csak a verziószámot növelni. A programszámok egy része előredefiniált, más részük fenntartott. A fejlesztők számára a 0x20000000-0x3fffffff tartomány áll rendelkezésre. nem
6.8.3. Portmap Minden hálózati szolgáltatáshoz dinamikusan vagy statikusan hozzá lehet rendelni portszámot. Ezeket a számokat regisztráltatni kell a gépen futó portmap démonnal. Ha egy hálózati szolgáltatás portszámára van szükség, akkor a kliens egy RPC-kérő üzenetet küld a távoli gépen futó portmap démonnak. A portmap RPC-válaszüzenetben megadja a kért portszámot. Ezután a kliens már közvetlenül küldhet üzenetet a megadott portra. A fentiekből következik, hogy a portmap az egyetlen olyan hálózati szolgáltatás, amelynek mindenki által ismert, dedikált portszámmal kell rendelkeznie. Jelen esetben ez a portszám a 111.
6.8.4. Szállítás Az RPC független a szállítási protokolltól. Nem foglalkozik azzal, hogy miként adódik át az üzenet az egyik processztől a másiknak. Csak az üzenetek specifikációjával és értelmezésével foglalkozik. Ugyancsak nem foglalkozik a megbízhatósági követelmények kielégítésével. Ez egy megbízható szállítási réteg - például az összeköttetés-alapú TCP - felett kevésbé okoz gondot. Egy kevésbé megbízható szállítási réteg - például UDP - felett futó RPC-alkalmazásnak magának kell gondoskodnia azonban az üzenetek megbízható továbbításáról. Linux alatt az RPC támogatja mind az UDP-, mind a TCP-szállítási réteget. Az egyszerűség kedvéért a TCP-t ajánlatos használni.
6.8.5. XDR Az RPC feltételezi az ún. külső adatábrázolás (eXternal Data Representation, XDR) meglétét. Az XDR gépfüggetlen adatleíró és kódoló nyelv, amely jól használható különböző számítógép-architektúrák közötti adatátvitelnél. Az RPC tetszőleges adatstruktúrák kezelésére képes, függetlenül az egyes gépek belső adatábrázolásától. Az adatokat elküldés előtt XDR-formára alakítja (serializing), a vett adatokat pedig visszaalakítja (deserializing).
333
6. fejezet: Hálózati kommunikáció
6.8.6.
rpcinfo
Az rpcinfo parancs használatával információkat kérhetünk a portmap démonnál bejegyzett programokról: program neve, száma, verziója, portszáma, a használt szállítási réteg. Ezen kívül meg lehet vele kérdezni, hogy egy program adott verziója létezik-e, illetve válaszol-e a kérésekre. A lokális gépre az információk lekérdezése a következő:
6.8.7. rpcgen A távoli hívást használó alkalmazások programozása nehézkes és bonyolult lehet. Az egyik nehézséget éppen az adatok konverziója jelenti. Szerencsére létezik egy rpcgen nevű program, amely segíti a programozót az RPC-alkalmazás elkészítésében. Az rpcgen egy fordító. Az RPC-program interfészének definícióját — az eljárások nevét, az átadott és visszaadott paraméterek típusát — az úgynevezett RPC nyelven kell leírni. A nyelv hasonlít a C-re. A fordító az interfészdefiníció alapján néhány C nyelvű forráslistát állít elő: a közösen használt definíciókat (headerfájlt), a kliens- és a szerveroldali RPC-programok vázát (skeleton). A vázak feladata az, hogy elrejtsék az alsóbb hálózati rétegeket a program többi részétől. A programfejlesztőnek mindössze az a feladata, hogy megírja a szerveroldali eljárásokat. Az eljárások tetszőleges nyelven megírhatók, figyelembe véve természetesen az interfészdefiníciót és a rendszerhívási konvenciókat. Az eljárásokat összeszerkesztve a szerveroldali vázzal, megkapjuk a futtatható szerveroldali programot. A kliensoldalon a főprogramot kell megírni, ebből hívjuk a távoli eljárásokat. Ezt linkelve a kliensoldali vázzal, megkapjuk a futtatható kliensprogramot. Az rpcgen nek létezik egy —a kapcsolója is, amely tovább egyszerűsíti a feladatunkat Használatával az interfészállományból még a megírandó programrészekre is kapunk egy C nyelvű vázat, amelyet már csak ki kell egészítenünk, hogy használhassuk. (Ügyeljünk arra, hogy egy ismételt generálással a C-vázak felülíródnak, ezért célszerű átneveznünk őket.) Így általában a következő parancsot használjuk: -
-rpoOr-41, - I rltferfa~. ahol az interface.x az interfészállomány.
334
1
6.8. Távoli eljáráshívás
6.8.8. Helyi eljárás átalakítása távoli eljárássá Tegyük fel, hogy van egy eljárásunk, amely lokálisan fut. Ezt az eljárást szeretnénk távolivá tenni. Az átalakítást egy egyszerű példán vizsgáljuk meg. A lokális program a híváskor megadott paramétert, mint üzenetet, kiírja a képernyőre. A cél az, hogy távoli gép képernyőjére is tudjunk üzenetet kiírni. A program lokális változata a következő: /* printmsg.c - uzenet kiirasa a kepernyore. */ #include int printmessage(char* msg) í printf("%s\n", msg); return 1;
int main(int argc, char* argv[]) { char* message; i f(argc != 2) t fprintf(stderr, "Hasznalat: %s \n", argv[0]); return -1; message = argv[1]; tf(!printmessage(message)) fprintf(stderr, "%s: nem megjelenitheto az uzenet.\n", argv[0]); return -1; }
printf("Az uzenet elkuldve!\n"); return 0;
A protokoll kialakításához szükség vannak annak ismeretére, hogy az eljárásnak milyen típusú paraméterei és visszatérési értékei vannak. Jelen esetben a printmessage0 függvény szöveget vesz át paraméterként, és egy egész számot ad vissza. A protokollt hagyományosan egy .x kiterjesztésű állományban írjuk meg: /* msg.)( - Tavolí uzenet nyomtatas protokollja.''/ program MESSAGEPROG version MESSAGEVERS { int PRINTMESSAGE(string) = 1; } = 1; } = 0x20000099;
/* /* /* /* /*
programnev verzionev 1. fuggveny verzioszam programszam
*/ */ */ */ */
335
6. fejezet: Hálózati kommunikáció
A távoli program egyetlen eljárást tartalmaz, amelynek száma 1. Az RPC automatikusan generál egy O. eljárást is. Ez a szerver megszólítására (pinging) használható A nagybetűk használata nem szükséges, csak hasznos konvenció. Észrevehető, hogy az argumentum típusa nem char*, hanem string. Ez azért van, mert a különböző nyelvek másként értelmezik a szöveges típust, sőt a char* még a C nyelvben sem egyértelmű. A használható változótípusokat az XDR dokumentációja tartalmazza. Nézzük a távoli eljárás implementációját: /* msg_proc.c - Tavoli printmessage eljaras. */ #include #include #include "msg.h" int* printmessage_1(char** msg)
f
static int result; printf("%s\n", *msg); result = 1; return (&result);
A printmessage_10 távoli eljárás három dologban különbözik a printmessage() helyi eljárástól. Argumentumként stringre mutató pointert vesz át string helyett. Ez minden távoli eljárásra érvényes. Ha nincs argumentum, akkor void*-ot kell megadni. Vissza egész számra mutató pointert ad, egész helyett. Ez szintén jellemző a távoli eljárásokra. A pointer-visszaadás miatt kell a result változót staticként megadni. Ha nincs visszatérési érték, akkor void*-ot kell használni. A távoli eljárás nevének a végére „_1" került. Általában az rpcgen által generált eljárások neve a protokolldefinícióban szereplő név kisbetűkkel, egy aláhúzási karakter, végül pedig a verziószám. Végül következzen a kliensprogram, amely a távoli eljárást meghívja: /* rprintmsg.c - A printmsg.c tavoli verzioja. */ #include #include #include "msg.h" ínt maín(int argc, char* argv[]) { CLIENT* cl; int* result; char* server; char* message;
336
6.8. Távoli eljáráshívás i f(argc != 3)
{ fprintf(stderr, "Hasznalat: %s .szerver> \n", argv[0]); return -1; server = argv[1]; message = argv[2]; /* A kliensleiro letrehozasa, kapcsolat felvetele a
szerverrel. -V cl = clnt_create(server,
MESSAGEPROG, MESSAGEVERS ,
"tcp");
if(c1 == NULL) {
clnt_pereateerror(server); return -1; }
7* A tavoli eljaras meghivasa. */
result = printmessage_1(&message, cl); if(*result == NULL) {
clnt_perror(cl, "call failed"); return -1; }
if(result == 0)
f
fprintf(stderr, "%s: %s nem megjelenitheto az uzenet\n", argv[0], server); return -1; printf("Az uzenet elkuldve a %s szervernek!\n", server); return 0;
Első lépésként a kapcsolatot hozzuk létre a clnecreate0 meghívásával. A visszakapott leírót a távoli eljárás argumentumaként használjuk. A cInt_create0 utolsó paramétere a szállítási protokollt adja meg. Jelen esetben ez TCP, de lehet UDP is. A printmessage_1O eljárás meghívása pontosan ugyanúgy történik, ahogy azt az msg_proc.c állományban deklaráltuk, de itt utolsó paraméterként meg kell adni a kliens-kapcsolatazonosítót is. A távoli eljáráshívás kétféle módon térhet vissza hibával. Ha maga az RPC-hívás nem sikerült, akkor a visszatérési érték NULL. Ha a távoli eljárás végrehajtása közben következik be hiba, akkor az alkalmazástól függ a hibajelzés. Esetünkben a 0 visszatérési érték mutatja a hibát.
337
6. fejezet: Hálózati kommunikáció
A kiegészítendő kódvázat (skeleton) tartalmazó állományokat az
r>00:111~ • x paranccsal generálhatjuk. Ez a következő állományokat hozza létre: •
Egy msg.h headerállományt, amelyben definiálja a MESSAGEPROG, MESSAGEVERS és PRINTMESSAGE konstansokat, továbbá a függvényeket kliens- és szerveroldali formában is.
•
A kliensprogram vázát az msg_clnt.c állományban. Ebben szerepel a printmessage_1() eljárás, amelyet a kliensprogram hív.
•
A kiszolgálóprogram vázát az msg_suc.c állományban. Ez a program hívja az msgproc.c állományban található printmessage_10 eljárást.
Az rpcgen a —a kapcsoló használatakor ezek mellett a kliens- és a szerverprogram egyszerű implementációját és a makefile-t is létrehozza. (Vigyázzunk a make clean használatával, mert itt a szokásos megvalósítással szemben a generált kliens- és szerverprogram forrását is letörli.)
338
HETEDIK FEJEZET
Fejlesztés a Linuxkernelben Az operációs rendszer egyik feladata az, hogy egy egységes, magas szintű programozási felületet nyújtva elrejtse a felhasználó elől a rendszer hardverelemeinek a kezelését. Az állománykezelés során például nem kell foglalkoznunk a konkrét tárolóeszköz közvetlen vezérlésével, mivel az operációs rendszer megteszi ezt helyettünk. Ha a fejlesztők belemerülnek a Linux-kernelbe, annak az az egyik leggyakoribb oka, hogy speciális eszközeikhez meghajtóprogramot szeretnének készíteni. Ilyenkor az a feladat, hogy az eszköz alacsony szintű interfészére építve megvalósítsák az operációs rendszer magas szintű interfészét. A Unix világában az eszközöket állományként, az úgynevezett fájlabsztrakciós interfészen kezeljük (lásd a 2.1. A Linux-kernel felépítése alfejezetben). Ez a Linux-meghajtóprogram esetében azt jelenti, hogy az eszköz kezelését állományműveletekre kell leképeznünk. 9° A Linuxnál ez a feladat sok esetben nem is olyan nehéz, ám komoly odafigyelést igényel, mivel a kernel a rendszer érzékeny része: egy elrontott kód ezen a helyen a rendszer összeomlását is eredményezheti. Természetesen az eszközök skálája, így az eszközvezérlőké is, elég széles. Ráadásul a kernel kapcsolódó függvényeit gyakran váltják fel új verziók. Ezért ebben a fejezetben átfogó bevezetést nyújtunk a leggyakrabban használt technikákba, főként a kevéssé változó alapelvek ismertetésére és azok illusztrálására szorítkozunk, mintegy kiindulási alapként azok számára, akik ebben a témában szeretnének elmélyedni. Linux alatt az eszközvezérlőket kernelmodulként valósítjuk meg. A kernelmodul a betöltés után hozzákapcsolódik a kernelhez, és annak szerves részét alkotja. Így lényegében a kernelmodul-fejlesztés Linux-kernel-fejlesztést jelent.
9
° A Linuxban nem minden meghajtó rendelkezik állományinterfésszel, ám a többség igen.
7. fejezet: Fejlesztés a Linux-kernelben
7.1. Verziófüggőség A kernel a Linux egyik legdinamikusabban fejlődő része. Ebből következően a verzióváltások során gyakran kerülnek bele strukturális és szintaktikai változások. A 2.4-es stabil kernel verzióról a 2.6-os stabil verzióra történő váltás a modulok jelentős átírását tette szükségessé, mert a két kernelverzió közötti különbségek jelentősek voltak. A 2.6-os verziótól kezdődően a kernel fejlesztési ciklusa megváltozott. Megszűnt a stabil mellett párhuzamosan megjelenő fejlesztői kernelváltozat, így a változások közvetlenül a kernel egyetlen fejlesztési vonalába kerültek. Ennek egyik hatása az, hogy szinte folyamatosan kisebb átalakításokat kell végeznünk a moduljainkon. Természetesen, ha két egymástól számozásban távolabb eső 2.6.X-es kernelverzió között váltunk, akkor a kis módosítások száma is megnövekszik. Ugyanakkor nem jellemző olyan drasztikus nagy átalakítás, mint a korábban említett 2.4 ---> 2.6 váltás esetében. Ebből is következik, hogy a kernel fő verziószámai régóta nem is változtak. 9 ' A nemrég megjelent 3.0-s kernel verziószáma is valójában csak szimbolikus, és nem tartalmaz jelentős eltéréseket a 2.6.39-es verzióhoz képest. A folyamatos fejlődés tükrében fontos felhívni a figyelmet arra, hogy az ebben a fejezetben található forráskódok a könyv íráskor aktuális 2.6.39-es kernelverzióhoz készültek. Jóllehet későbbi verziók használatakor előfordulhat, hogy a szintaktika egyes pontokon eltér, ám a kód mögött álló alapelvek lassabban változnak, így reményeink szerint a fejezet a későbbi kernelverzióknál is aktuális marad. A kisebb szintaktikai módosításokat általában a fordításkor kapott hibajelzések alapján rögtön észrevesszük. Ilyenkor a helyes szintaktika könnyen felderíthető az aktuális kernelforrás tanulmányozásával. Ha keresünk például egy olyan eszközvezérlőt, amely a problémás részletében hasonlít a mi eszközvezérlőnkre, akkor innen kideríthetjük, hogy az aktuális kernelverzió esetében mi a helyes szintaktika. Időnként a kernelforrás mellett található dokumentációk is segíthetnek. Sajnos a kernel nem tartozik a Linux jól dokumentált részei közé, ugyanis a forráskód fejlesztése általában gyorsabb, minthogy a dokumentáció követni tudná. Szerencsére az utóbbi években jelentős fejlődés tapasztalható ezen a téren is: a kernelfejlesztó'k egyre inkább dokumentálják a munkájukat.
91
A 2.6.0-s kernelverzió 2003. december 17-én jelent meg. A 2.6.X-es kernelek korszaka egészen 2011. július 21-ig tartott, amikor Linus Torvalds bejelentette a 3.0-s verziót a Linux-kernel 20. születésnapja alkalmából.
340
7.2. A kernel- és az alkalmazásfejlesztés eltérései
7.2. A kernel- és az alkalmazásfejlesztés eltérései Először részleteznünk kell a kernel és az alkalmazások fejlesztése közti különbségeket. A későbbi fejezetekben elsősorban kernelmodulok fejlesztését tárgyaljuk, ám nincs különbség a modulként elkészített és a kernelbe közvetlenül belefordított kódok között a megkötések tekintetében. Először is a kernelrészek készítésénél lehetőleg felejtsünk el más nyelveket, és dolgozzunk C-ben, esetleg assemblyben. Általában az alkalmazások egy feladaton dolgoznak az indítástól a folyamat végéig. Ezért ezeknek egyetlen belépési pontjuk van, C nyelvű programok esetén tipikusan a main függvény. A kernelmodulok életciklusa sokkal jobban hasonlít a nem statikus programkönyvtárakéhoz: a programkönyvtár rendelkezésre állását regisztrálással adjuk a rendszer tudtára, ettől kezdve arra vár, hogy egy program betöltse és meghívja a szolgáltatásait. Hasonlóképpen a kernelmodulok először csak regisztrálódnak, hogy később feladatokat teljesíthessenek, és ezzel az inicializáló függvényük már véget is ér. A modul másik belépési pontja a tisztogatófüggvény, amely a modul eltávolításakor aktivizálódik, pontosabban közvetlenül előtte. A modul szolgáltatásait kötött prototípusú függvények valósítják meg, amelyeket a programok a kernel közvetítésével például az állományabsztrakciós interfészen keresztül használhatnak. Szekvenciálisan lefutó kódot csak úgy implementálhatunk, ha kernelszálat indítunk (lásd a 7.17. Várakozás alfejezetet). A programok fejlesztésekor gyakran használunk olyan függvényeket, amelyek fejlesztői könyvtárakban vannak. Ilyenkor maga a program nem tartalmazza a függvényt, hanem a fordítási fázis után a linker vagy a betöltőprogram a linkelési fázisban oldja fel ezeket a külső hivatkozásokat. Ilyen például a printf() függvény, amely a libc könyvtárban található. Ezzel szemben a modulok csak a kernellel linkelődnek, ezért csak olyan függvényeket használhatnak, amelyek a kernelben (beleértve más modulokat) már léteznek. Természetesen a modulok fejlesztésében nem használhatunk semmilyen libc függvényt, hiszen az egy jóval magasabb szintű, felhasználói üzemmódban használatos függvénykönyvtár. A kernel által rendelkezésre bocsátott függvényeket a / proc /kallsyms virtuálisszöveg-állományban tudjuk megnézni. Ez csak az éppen aktuális függvénylistát jeleníti meg, amely a modulok betöltésével és eltávolításával bővülhet, illetve csökkenhet. Mivel nem használhatunk fejlesztői könyvtárakat, így a hagyományos headerállományok sem szerepelhetnek a kódunkban. Helyettük a kernelforrás include alkönyvtárában található állományok kerülnek bele, és erről a kernelforrás Makefile-ja gondoskodik azzal, hogy a megfelelő elérési utakat adja meg a fordítónak. Ebben a könyvtárban találunk egy linux és egy asm könyvtárat, amelyek az általunk használható headerállományokat tartalmazzák.
341
7. fejezet: Fejlesztés a Linux-kernelben
A kernelrészek kódjait és adatait általában a fizikai memóriában tároljuk, vagyis nem kerülhetnek ki egy lapcsere folytán a merevlemezre. Hogy megértsük ennek az okát, nézzünk meg egy példát. Tegyük fel, hogy a kernel adatait is olyan virtuálismemória-területen tartjuk, mint az alkalmazásokéit. Ebben az esetben előfordulhat, hogy egyes lapok kikerülnek a lapcserepartícióra, ha kevés a hely a fizikai memóriában. Ha éppen a lapcsere algoritmuskódja vagy adatai kerülnének ki: többet nem lehetne visszatölteni sem ezt a lapot, sem más lapokat a rendszerbe. Természetesen a fizikai memória véges, ezért ne szabad elpazarolni. Nagy mennyiségű adathoz kernelfejlesztéskor is lehetőségünk van arra, hogy virtuális memóriát is allokáljunk. Ezekre a területekre csak adatokat helyezhetünk el, és számolnunk kell az esetleges lapcsere okozta lassabb hozzáféréssel. Lebegőpontos számításokat ne használjunk. Ha mégis szükséges lenne, akkor nekünk kell gondoskodnunk az FPU állapotának lementéséről és viszszaállításáról. Az utolsó nagy eltérés a hibák lekezelésénél tapasztalható. Amíg a programok fejlesztésekor egy hibás memória, illetve I/O művelet (segmentation fault) a rendszer szempontjából nem veszélyes, és könnyen kiszűrhető, a kernelmodulban elkövetett hasonló hiba komoly következményekkel, akár még a rendszer összeomlásával is járhat. Ugyanakkor a kernel fejlődésével a hibakezelő metódusok is fejlődtek, így manapság az esetek többségében a rendszer még működőképes marad, és olyan hibajelzést (a jól ismert Oops üzenet) képes produkálni, amely nagyban segíti a fejlesztést. Ugyanakkor egy hiba után a rendszer instabil állapotba kerülhet, ezért célszerű újraindítani. Ellenkező esetben furcsa, nem determinisztikus hibajelenségeket tapasztalhatunk, amelyek lehetetlenné tehetik a hiba felderítését. A hibakeresés is nehézkesebb: az alkalmazásoknál használt módszerek nem eredményesek, vagy csak komolyabb előkészületek után, és akkor is csak megkötésekkel. Ám más módszerek is rendelkezésünkre állnak, bár ezek sokszor nem nyújtanak olyan segítséget, mint amit az alkalmazásoknál megszokhattunk. A továbbiakban bemutatunk majd néhány jól használható módszert.
7.2.1. Felhasználói üzemmód — kernelüzemmód A felhasználói alkalmazások saját virtuális címteret kapnak indításkor: ebben a címtérben tudnak dolgozni a futásuk során. Ugyanakkor az egymás mellett futó párhuzamos folyamatok el vannak választva egymástól. Egymás területeihez nem férhetnek hozzá, csak szabályozott csatornákon érintkezhetnek (lásd a korábbi fejezetekben). A folyamat virtuális címtartománya 32 bites rendszerben 2 32 , vagyis 4 GB. Ezt a Linux rendszer két részre osztja. Az alső 3 GB a felhasználói címtartomány, míg a felső 1 GB a kernel számára fenntartott tartomány. A méretek függetlenek attól, hogy a számítógép valójában mennyi fizikai memóriát tartalmaz, mert itt csak virtuális címterületekről van szó. 342
7.3. Kernelmodulok
A mai processzorok több privilégiumszint használatát teszik lehetővé. Ezek azt szabályozzák, hogy a folyamatok mihez férhetnek hozzá. Közülük a Linux két szintet használ: a felhasználói üzemmódot és a kernelüzemmódot. A fő különbség a két üzemmód között az, hogy felhasználói üzemmódban a folyamatok a felső kernelcímtérhez nem férhetnek hozzá, nem olvashatják, írhatják vagy futtathatják a rajta lévő kódot. Az átjárást a felhasználói üzemmód és a kernelüzemmód között a rendszerhívások nyújtják. A rendszerhívások meghívásával tudnak a folyamatok olyan műveleteket végrehajtani, amelyek a saját virtuális birodalmukon kívül található részekre hatással vannak. Ilyenkor a rendszer ellenőrzi a jogosultságaikat, és végrehajtja a műveletet. Azok a műveletek, amelyek kernelmódban futnak, például a rendszerhívások implementációja vagy a késó'bb tárgyalandó kernelszálak, hozzáférhetnek a kernel címtartományának az egészéhez. Ez azt jelenti, hogy semmilyen korlátozás nem vonatkozik rájuk, bármilyen műveletet végrehajthatnak. Vagyis elmondhatjuk, hogy a hibás vagy a rosszindulatú kódot tartalmazó modulokkal szemben a kernel védtelen, ezért a rendszer adminisztrátorára, illetve a rendszerfejlesztőkre hárul az a feladat, hogy ezektől a veszélyektől a rendszert megvédjék.
7.3. Kernelmodulok A Linux-kernel eredetileg monolitikus, vagyis egyetlen nagy program, amely tartalmaz minden olyan funkciót és belső adatstruktúrát, amely az operációs rendszerhez tartozik (lásd a 2.1. A Linux kernel felépítése alfejezetben). Az alternatíva a mikrokernel lenne, amely minden funkcionális elemet külön egységekben tartalmaz, és az egyes részek között jól meghatározott kommunikáció zajlik. Így minden új komponens vagy funkció hozzáadása a kernelhez nagyon időigényes feladat lenne. A korai kernelverzióknál, ha egy új eszközt tettünk a gépbe, a kernelt ténylegesen újra kellett konfigurálni és fordítani. A Linux úgy orvosolta a monolitikus kernel rugalmatlanságát, hogy az 1.2-es verzió óta már teljes támogatást nyújt a futás közben betölthető kernelmodulok használatához. A kernelmodulok olyan tárgykódú binárisok, amelyeket a rendszer indítása után bármikor betölthetünk, és dinamikusan hozzálinkelhetünk a kernelhez. Majd amikor már nincs rá szükségünk, lekapcsoljuk (unlink), és eltávolíthatjuk a modult memóriából. Ez lehetővé teszi, hogy az operációs rendszer egyes komponenseit dinamikusan betöltsük és eltávolítsuk, attól függően, hogy szükségünk van-e rájuk, vagy sem. A legtöbb eszközvezérlőt modulként valósították meg, és ez a megoldás javasolható a saját eszközvezérlőink elkészítésénél is. Ez a megközelítés magát a fejlesztést is nagyban könnyíti és gyorsítja. -
343
7. fejezet: Fejlesztés a Linux-kernelben
Feladat A példa-eszközvezérlö feladata az lesz, hogy olvasáskor kiírja a „Hello!" szöveget, íráskor pedig a konzolra kiírja a speciális állományba foglaltakat.
7.3.1. Hello modul világ Kezdésként nézzük meg, hogyan alkothatjuk meg a legegyszerűbb kernelmodult. Ezt tekinthetjük majd vázként is a későbbi munkánkhoz. Ez a kód a kernel 2.6.x-es verziója alatt fordul és működik: /* hellomodule.c
-
Egyszeru kernel modul. */
#include #include int ni t_module(void) {
printk("Hello modul vilag! \n"); return 0;
void cl eanup_module(void) printk("viszlat modul vilag! \n");
MODULE_LICENSE("GPL");
A modul a lehető legegyszerűbb. Minden modul minimum két függvénnyel rendelkezik. Az egyik a modul betöltésekor (init_module()), a másik az eltávolításkor (cleanup_module()) hajtódik végre. Ezek formája a 2.6-os kernel esetén a példában látható. A gyakorlatban azonban nem a függvények beépített neveit használjuk, hanem a saját nevekkel létrehozott függvényeket regisztráljuk be, ez ugyanis flexibilisebb megoldás. Ezzel a kibővítéssel a következő kódig jutunk: /* hellomodule.c - Egyszeru kernel modul. */ #include #include #include
static int
ini t hello_init(void)
{
printk("Hello modul vi lag! \n"); return 0; }
344
7.3. Kernelmodulok
static void
exit hello_exit(void)
{
printk("viszlat modul vilag!\n");
module_init(hello_init); module_exit(hello_exit); mODuLE_DEsCRIPTIoN("Hello module"); MODULE_LICENSE("GPL");
A modul betöltésekor és eltávolításakor meghívandó függvényeket a module_init() és a module_exit() makrókkal jelöljük ki. Megfigyelhetünk még egy kis eltérést a függvények alakjában. Nevezetesen az _init és az _exit makrókat. Ezeket a makrókat arra használjuk, hogy megjelöljük a kernel számára az inicializációs és az eltávolító függvényeket. A kernel a makrók által nyújtott információt felhasználhatja a memóriafelhasználás optimalizálására. Jelenleg ezek az optimalizációs algoritmusok csak akkor lépnek működésbe, ha a kernelmodult belefordítjuk a kernelbe. Ha tehát a kernelbe építettük a modult, az init makró hatására az inicializációs folyamat után az init függvény megszűnik, és a hozzátartozó memória felszabadul. Az _exit makró hatására a tisztogatófüggvényt figyelmen kívül hagyja a rendszer a fordítás során, hiszen a kernel kitöltődésekor nincs szükség felszabadításra. Ha a modult nem építjük a kernelbe, ezek a függvények normál inicializáló és tisztogatófüggvényekké válnak, és a makróknak init és az _exit makrók használata nincs különösebb hatásuk. Vagyis az azért célszerű, hogy egy kódban mindkét esetet kezelhessük. A példaprogramban a regisztrált függvényeink kiírásokat végeznek. Az üzenetek kiírására a printk() függvényt használjuk, amely hasonlít a printf() függvényhez, de a Linux-kernelben van definiálva. A modul betöltésekor a Linux-kernelhez linkelődik, így a printk() függvény is használhatóvá válik. (A printf() például nem használható, hiszen a felhasználói könyvtárban van definiálva.) A modul végén makrókkal megadhatjuk a modul készítőjét, leírását és a licencinformációit. Jóllehet ezeket az adatokat nem kötelező megadni, legalább a licencet célszerű beállítanunk Ha nem GPL licencet állítunk be, akkor a modul betöltésekor figyelmeztetést kapunk, illetve egyes, a kernelben lévő függvények elérhetetlenek lesznek a modulunk számára.
_
_
345
7. fejezet: Fejlesztés a Linux-kernelben
7.3.2. Fordítás Miután elkészültünk a kernelmodulunk forráskódjával, a következő lépés a fordítás. A fordításhoz mindenképpen szükség van a használt kernel forrására. Ez nemcsak azt jelenti, hogy le kell töltenünk egy kernelforrást, amely ugyanolyan verziójú, mint amit éppen használunk, hanem hogy ha vannak módosítások (patch) a kernelünkben, akkor azoknak a forrásban is meg kell lenniük, továbbá a konfigurációnak is egyeznie kell. Szerencsére ez többnyire nem jelent komoly problémát, hiszen a disztribúciók többsége minden kernelverzióhoz tartalmazza a forráscsomagot is (tipikusan: linux-source, linux-kernel-source) vagy pedig egy kernelfejlesztői csomagot (tipikusan: linux-devel, linux-kernel-devel, linux-headers). Utóbbi általában nem teljes forráscsomag, hanem csak azok a részei vannak benne, amelyek a modulok fordításához szükségesek. Ez természetesen lényegesen kisebb, ám számunkra teljes értékű. A modulunk fordításához felhasználjuk a kernelforrás Makefile-jait. Ezért a telepítés után pontosan tudnunk kell, hogy melyik könyvtárban helyezkedik el a fő Makefile. Ezt a korábban említett és feltelepített forráscsomagok állománylistájának a megtekintésével lehet kideríteni. A forrás gyökérkönyvtárára van szükségünk (tipikusan: /usr/src/ alatt egy könyvtár, amely a nevében a kernelverziót is tartalmazza). A fordítási metódus a következő: 1. A modul könyvtárában létre kell hoznunk egy Makefile nevű állományt. Ebben az állományban a következő sor beírásával jelezhetjük, hogy a hellomodulel.c állományt szeretnénk felvenni a fordítási listába (a tárgykódú állomány nevét kell megadni, vagyis a „.0" kiterjesztést: bel -1~91 e .9
olaj -rt1
2. Ezt követően meg kell hívnunk a make parancsot úgy, hogy a kernelforrás fó'könyvtárában lévő Makefile állományt használja. Ugyanakkor át kell adnunk a SUBDIRS paraméterrel a modulforrásunk könyvtárát. Ezt követően a „modules" célt is meg kell adni a fordításhoz, amellyel jelezzük, hogy kernelmodulokat szeretnénk fordítani. Ezzel a parancssor a következő lesz: make
-
C = MSG_LEN) return 0; // Maximum annyi bajtot szolgalunk ki, amilyen hosszu a szoveg. if((*ppos+count) > MSG_LEN) count = MSG_LEN *ppos; -
/* Atmasoljuk az adatot a kernel cimteruletrol, az olvaso fuggveny altal kapott bufferbe. */ if(copy_to_user(buf, msg+*ppos, count)) {
return
-
EFAULT;
*ppos+=count; /* Vísszaterunk a visszaadott adatmennyiseg hosszaval. */ return count;
/* A megnyitas muveletet lekezelo fuggveny. */ static int hello_open(struct inode *inode, struct file *pfile) t /* Noveljuk a hasznalati szamlalot. */ try_module_get(THIs_mODULE); printk(DEVICE_NAME " open.\n"); return 0;
359
7. fejezet: Fejlesztés a Linux-kernelben
/* A lezaras muveletet lekezelo fuggveny. */ statíc int hello_close(struct inode *inode, struct file *pfile)
{ printk(DEvICE_NAME " close.\n"); /* Csokkentjuk a hasznalati szamlalot. */ module_put(THIS_M0DuLE): return 0;
static struct file_operations hello_fops = owner: THIS_MODULE, read: hello_read, write: hello_write, open: hello_open, release: hello_close }: /* A kernel modul inicíalízalasat vegzo fuggveny. static int init hello_init(void) int res; struct devicec err; /* Regisztraljuk a karakter tipusu eszkozt. '/ res = register_chrdev(major_num, DEVICE_NAME, &hello_fops); i f(res GBUFFERSIZE)
count = GBUFFERSIZE; }
if(down_ínterruptible(&lock)) return ERESTARTSYS; if(copy_from_user(gbuffer, buffer, count)) -
{
gbufferlen = 0; up(&lock); return EFAULT; -
gbufferlen = count; up(&lock); return count; } /* A kernel modul inicializalasat vegzo fuggveny. */ static int init hello_init(void)
_
struct proc_dir_entry *hello_proc_ent; /* Dinamikusan hozzuk letre a proc allomany bejegyzest. */ hello_proc_ent = create_proc_entry("hello",
S_IFREG I S_IRUGO I
S_IWUGO, 0);
/* Beallitjuk a link szamlalot es a lekezelo fuggvenyt. if(!hello_proc_ent) { remove_proc_entry("hello",0); printk("Error: A /proc/hello nem letrehozhato.\n"); return -ENOMEM; }
hello_proc_ent->nlink = 1; hello_proc_ent->read_proc = procfile_read; hello_proc_ent->write_proc = procfile_write; hello_proc_ent - >mode = S_IFREG I S_IRUGO I S_IWUGO; hello_proc_ent - >uid = 0;
373
7. fejezet: Fejlesztés a Linux-kernelben
hello_proc_ent->gid = 0; hello_proc_ent->size = 256; printk("A /proc/hello letrehozva.\n"); return 0;
/* A kernel modul eltavolítasa elott a felszabaditasokat vegzi. exit hello_exit(void) static void
A
/
/* megsemmisitjuk a proc allomany bejegyzest. */ remove_proc_entry("hello",0); printk("A /proc/hello eltavolitva.\n");
module_init(hello_init); module_exit(hello_exít); MODULE_DESCRIPTION("Hell0 proc"); MODULE_LICENSE("GPL");
A példában egy olyan proc állományt hozunk létre, amely hasonlít a valós állományokra. Vagyis amit beleírunk, azt utána kiolvashatjuk belőle. Ugyanakkor tartalmaz egyszerűsítéseket is, így nem teljes értékű implementáció. A virtuális állomány párhuzamos olvasása és írása versenyhelyzetet eredményezhet, ezért a példában a globális tárolótömbhöz való hozzáférést szinkronizációs eszközzel kell szabályoznunk. A korábbi példákban nem volt szükség erre, mert a globális változónkat csak olvastuk, ezzel szemben ebben a példában olvassuk és írjuk is párhuzamosan. A példában ezt egy szemaforral tettük. (A szinkronizációs eszközöket és használati területüket egy későbbi fejezetben tárgyaljuk, ám a helyes implementációhoz szükség volt a használatára.) Ahhoz, hogy a folyamatok ténylegesen tudják írni az állományt, az írást kezelő függvény regisztrációja mellett arra is szükség van, hogy az írásjogot megadjuk a felhasználóknak Az állomány létrehozásánál láthatjuk, hogy a példában mindenkinek joga van írni a virtuális állományunkat. Felmerülhet a kérdés: hogyan lehetséges az, hogy írható az állományunk, pedig nem is a /proc/sys könyvtár alatt található. Az igaz, hogy általában az írható proc állományok az említett könyvtár alatt találhatók, de ez csak konvenció. Valójában a /proc könyvtár alatt tetszőleges állományt tehetünk írhatóvá.
374
7.7. A hibakeresés módszerei
7.7. A hiba keresés módszerei A hibakeresésre kernelfejlesztés esetében korlátozottabbak a lehetőségeink, mint egy alkalmazásnál. Mivel a kernelmodul a betöltés után a kernel szerves részét alkotja, ezért ilyenkor lényegében abban a programban kell hibát keresnünk, amely a működő rendszer központi eleme, és ugyanezen program alatt futnak a hibakeresésre használt eszközeink is. Ezért a feladat jóval bonyolultabb, mint felhasználói címtartományban. A legegyszerűbb és ugyanakkor leggyakrabban használt módszereket már megmutattuk. Mind a printk() függvény, mind a /proc állományrendszer alkalmas arra, hogy információt közöljünk a kernelmodul állapotáról, így használhatók a hibakereséséhez. Ezeken kívül léteznek kifinomultabb módszerek is, amelyekre ugyancsak mutatunk példát ebben a fejezetben.
7.7.1. A príntk() használata A legegyszerűbb és ezért az egyik leggyakrabban használt módszer a hibakeresésre, ha a lényeges pontokon kiírjuk az állapotokat. A kernel esetén ezt a printkQ függvénnyel tehetjük meg, majd a kernelnaplóból nézhetjük vissza.'" A kernelnaplóba írt üzenetek különböző fontosságúak lehetnek. A hibakereséshez általában minden szóba jöhető információt kiíratunk, ez pedig nagy mennyiségű naplóbejegyzést eredményez. Viszont valószínűleg kisebb a száma a fontos, konkrét hibát jelző üzeneteinknek. Azért, hogy ezeket a naplózásokat kézben tarthassuk, a Linux szintekbe szervezi a különböző fontosságú üzeneteket, ezt célszerű nekünk is alkalmaznunk a kiírásaink során. Az alábbi szinteket különböztetjük meg (7.3. táblázat): 7.3, táblázat. Kernel üzenetek szintjei
Címke
Érték
Leírás
KERN_DEBUG
A legkevésbé fontos üzenetek, amelyeket csak tesztelésnél használunk.
KERN_INFO
Információk a driver működéséről.
KERN_NOTICE
Nincsen gond, de azért jelzünk valamilyen említésre méltó szituációt.
KERN_WARNING
Problémás helyzet jelzése.
KERN_ERR
Hiba jelzése.
101
A kernelnaplót a dmesg paranccsal vagy a log állományokból nézhetjük vissza. Tipikus helye a /var/logimessages vagy /var/logisyslog állomány a syslog rendszer beállításától függően. 375
7. fejezet: Fejlesztés a Linux-kernelben
Címke
Érték
Leírás
KERN_CRIT
Kritikus hiba jelzése.
KERN_ALERT
Vészhelyzet, amely esetleg sürgős beavatkozást igényel.
KERN_EMERG
A legfontosabb üzenetek. Többnyire a rendszer meghalása előtt kapjuk.
naplózórendszer lehetővé teszi, hogy a későbbiekben a fontossági szint alapján szűrjünk. Vagyis nem szükséges a szoftveren módosítani, ha például az egyszerűbb hibakeresési üzeneteket ki akarjuk kapcsolni. A fontossági szintek használata a következő:
A
printk(KERN_DEBUG "uzenet \ n") ;
A szűrés beállítása az alábbi: echo 8 > /proc/sys/kernel/printk
A példában a „8"-as érték adja meg, hogy a KERN_DEBUG üzeneteket is látni szeretnénk. Ahogy ezt a számot csökkentjük, úgy csak az egyre fontosabb üzeneteket láthatjuk. A „0" érték azt jelenti, hogy csak a KERN_EMERG üzeneteket kapjuk meg. Előfordul, hogy szeretnénk egy üzenetben kiírni, hogy éppen merre járunk a kódban. Természetesen ezt megtehetjük úgy, hogy a szövegbe beleírjuk a pozíciót, ám létezik ennek automatizált módja is. Például a forráskódállomány nevének és sorszámának kiírása a következő: printk(KERN_DERuG "Kod: %s:% -i \n",
FILE ,
LINE )
Ha az éppen aktuális programkód pointerére vagyunk kíváncsiak, vagyis arra, hogy a kiírást végző gépi instrukció hol található a virtuális memóriában, a következőt kell tennünk: printk(KERN_DEBuG "Cim: Yop\n", ptr);
7.7.2. A /proc használata Az előző fejezetben láthattuk, hogyan valósíthatunk meg olyan állapotellenőrzést, amikor is a kernelmodul adja a futása során a jelzéseket. Néha azonban egy ilyen jellegű megoldás olyan mennyiségű állapotinformációval áraszt el bennünket, amelynek a kezelése kényelmetlen. Emellett a rendszer futását is erősen lelassíthatja egy ilyen jellegű kiírásáradat.
376
7.7. A hibakeresés módszerei
Ilyenkor a megoldás az lenne, ha bizonyos időpontokban, amikor éppen szükség van rájuk, megvizsgálhatnánk az állapotváltozók aktuális értékét, vagyis igény szerinti lekérdezést alkalmaznánk. Ezt teszi lehetővé a /proc állományrendszer a virtuális állományain keresztül. Ennek implementációját korábban láthattuk. Az újdonságot csak az jelenti, hogy a korábban megismert mechanizmust hibakeresésre is használhatjuk. A /proc állományrendszer mellett hasonló hatást érhetünk el az ioca0 rendszerhívás implementációjával is, amelyre most nem térünk ki. Az ioca0 rendszerhívást azonban csak eszközvezérló'knél alkalmazhatjuk, így általános modulok esetében nem járható út.
7.7.3. Kernelopciók A kernel fordítása előtt, a konfiguráláskor számos hibaérzékelési mechanizmust bekapcsolhatunk. Ezeknek az opcióknak a hatására a fordítás során plusz hibaérzékelési és kezelési algoritmusok fordulnak bele a kernelbe. Így az általunk írt kód hibáira könnyebben fény derülhet. A kernel hibaérzékelési algoritmusainak egy jó része azonban a plusz ellenőrzések miatt lassítja a kernel működését, így normál rendszerben nem használhatók, ezért alapesetben ki vannak kapcsolva. A hibaérzékelési/kezelési opciókat a kernelkonfiguráció során a „Kernel Hacking" ágban találhatjuk meg. Minden opcióhoz kapunk segítséget, amely leírja az adott beállítás által nyújtott szolgáltatást. A lehetséges hibakeresési opciók listája hosszú, és kernelverziónként változik, bővül, így nem vállalkozunk a lista részletes leírására és elemzésére. Az alábbiakban összegyűjtöttünk néhány fontosabb opciót. •
CONFIG_DEBUG_KERNEL: Bekapcsolja a hibakeresési opciókat.
•
CONFIG_MAGIC_SYSRQ: Bekapcsolja a „magic SysRq" mechanizmust. Ez segítséget nyújthat kritikus esetben a státusz megvizsgálására. (A „magic SysRq" mechanizmusról a 7.7.5. Magic SysRq alfejezetben lesz szó részletesebben.)
•
CONFIG_DEBUG_SLAB: További ellenőrzések bekapcsolása, amelyekkel memóriahasználati hibák deríthetó'k fel. Speciális byte-értékek beállításával érzékelhetővé teszi a memóriainicializálási és -túlírási hibákat.
•
CONFIG_DEBUG_SPINLOCK: Ciklikus zárolás használati hibák detektálása a feladata, például az inicializálás elmulasztása. (A ciklikus zárolási eszközt a szinkronizálás témakörében tárgyaljuk.)
377
7. fejezet: Fejlesztés a Linux-kernelben
•
CONFIG_DEBUG_SPINLOCK_SLEEP: A ciklikus zárolási eszköz leírásánál szó lesz arról, hogy a ciklikus zárolást nem szabad olyankor használni, amikor a kritikus szakaszban sleepet használó függvény van. Ezzel az opcióval kideríthetjük, ha mégis elkövettük ezt a hibát.
•
CONFIG_DEBUG_INFO: A hibakeresési információk belefordítása a kernelbe. Ezt később a gdb-vel használhatjuk.
•
CONFIG_DEBUG_STACKOVERFLOW: A stacktúlcsordulás érzékelése.
•
CONFIG_DEBUG_STACK_USAGE: A veremhasználat monitorozása, statisztikák.
•
CONFIG_DEBUG_PAGEALLOC: A felszabadított lapokat eltávolítja a kernel memóriaterületéből.
•
CONFIG_DEBUG_DRIVER: Az eszközvezérlők kódjaiban bekapcsolja a hibaüzenetek kiírását.
Bár nem a kernel hibakeresési szekciójában szerepelnek, ám a hibakeresés szempontjából hasznos opciók lehetnek az alábbiak is: •
CONFIG_KALLSYSMS: Ezen opció hatására a szimbólumok elérhetőek lesznek a /proc/kallsyms állományban.
•
CONFIG_IKCONFIG, CONFIG_IKCONFIG_PROC: Az aktuális kernel konfigurációja elérhetővé válik a proc állományrendszeren keresztül. Így ellenőrizhetjük a beállításait, illetve az adott beállításokhoz fordíthatjuk a modulokat.
•
CONFIG_PROFILING: Általában a rendszer finomhangolására szolgál, de segíthet a hibák felderítésében is.
7.7.4. Az Oops üzenet Ha a kernelben, kernelmodulokban súlyos hiba történik, akkor a kernel ezt egy „Oops" üzenettel jelzi. Természetesen a súlyos hiba eredményezheti a rendszer teljes lefagyását, ez pedig odáig fajulhat, hogy még „Oops" üzenetet sem kapunk, ez azonban manapság ritka. Ugyanakkor attól, hogy a rendszer működőképes marad, még sérülhet olyan mértékben, hogy a további működése kiszámíthatatlan lesz. Ezt az „Oops" üzenet jelzi is. Ilyenkor a hibaüzenet feldolgozása után célszerű a rendszert újraindítani. Ha ezt elmulasztanánk, akkor „furcsa" hibákat kaphatunk, amelyek lehetetlenné teszik a további hibakeresést. Ahhoz, hogy az „Oops" üzenet könnyen értelmezhető legyen, a kernelt a CONFIG_KALLSYMS és a CONFIG_DEBUG_INFO opciók bekapcsolásával kell fordítanunk. Szerencsére ezek az opciók többnyire a normál használatban lévő kernelekben is be vannak kapcsolva, így nem kell új kernelt fordítanunk.
378
7.7. A hibakeresés módszerei
Az alábbiakban egy „Oops" üzenetet láthatunk (néhány sor elhagyásával): BUG:
unable to handle kernel NULL pointer dereference at 00000000
EIP: 0060:[]
EFLAGS: 00210246 CPU: 1 EIP is at iret_exc+0x6aa/0x97e EAX: 00000000 EBX: 00000005 ECX: 00000005 EDX: 00000003 ESI: b8015000 EDI: 00000000 EBP: f33c5f68 ESP: f33c5f54 DS: 007b ES: 007b FS: 00d8 GS: 0033 SS: 0068 Process bash (pid: 8148, ti=f33c5000 task=f31e19a0 task.ti=f33c5000) Stack: 00000005 00000005 00000005 f24b5600 f90d3047 f33c5f74 f90d3054 00000005 f33c5f90 c0492f18 f33c5f9c b8015000 f24b5600 fffffff7 b8015000 f33c5fb0 c049300c f33c5f9c 00000000 00000000 00000000 00000001 00000005 f33c5000 Call Trace: [] ? hello_write+Ox0/0x29 [buggy_driver] [] ? hello_write+Oxd/0x29 [buggy_driver] [] ? vfs_write+0x84/0xdf [] ? sys_write+Ox3b/0x60 [] ? syscall_call+0x7/Oxb
A legelső sor megmutatja, hogy pontosan milyen hiba is történt. Az EIP értéke elárulja, hogy éppen melyik gépi utasításnál járt a program. Az értelmezés egyszerűsítése érdekében nem memóriacímet ad meg, hanem a C függvény nevét és az eltolást a függvény belépési pontjához képest. Esetünkben bár a hiba az adott ponton következett be, a ténylegesen hibás kód valójában nem ott van. Ebből is látható, hogy az EIP értéke nem feltétlenül a hiba helyére mutat. A „Call Trace" mező tartalmazza az egymásba ágyazódó függvényhívásokat. Esetünkben itt találjuk meg azt a sort, amely a kernel egy másik részén a hibát okozta. Ez a sor a hello_write függvényben található, és a függvény elejéhez képest a Oxd eltolás környékén van a problémás utasítás. Ezt onnan tudjuk, hogy nem a megszakításkezelő visszatérésénél találjuk a hibát, és nem is a függvény belépési pontjánál. Így a hivatkozási listában ez a következő elem, amely jogosan gyanús lehet számunkra. Mivel az eltolás gépi utasításban értendő, ezért még meg kell fejtenünk, hogy ez konkrétan hol található a C forrásban.
7.7.4.11. Az Aops" üzenet értelmezése kernel esetében Ha a hibás kódrészlet a kernelben található, és nem külön kernelmodulban, akkor az elemzéshez szükségünk lesz a vmlinux állományra, amely a kernel fordításakor keletkezik. Ez az állomány a kernel tömörítetlen, helyfüggő programkódja. Így az „Oops" üzenet által megadott címeken található utasítások visszakereshetők belőle. A hibakeresés a gdb program segítségével történik:
379
7. fejezet: Fejlesztés a Linux-kernelben
Ezt követően megkereshetjük a memóriacímhez tartozó sort: WftelidbY De megkereshetjük a függvény neve alapján is: (gdb) info line fuggveny
7.7.4.2. Az „Oops" üzenet értelmezése kernelmodul esetében Ha a probléma egy kernelmodulban található, akkor a feladatunk nehezebb. Az előző esetben használt umlinux állomány ekkor természetesen nem tartalmazza a kérdéses részeket. A problémás részek a modul állományában találhatók. Ugyanakkor az „Oops" üzenetben lévő memóriacímek pedig attól függnek, hogy a kernelmodult az adott helyzetben hova töltötte be a kernel, amely természetesen változhat. Vagyis van egy címünk, de nem tudhatjuk, hogy a binárisban ez melyik helyet jelenti. Így hivatalos módszer nincs is jelenleg erre a helyzetre. Szerencsére azért némi ügyeskedéssel kideríthetjük a hiányzó információt. Nyissuk meg a kernelmodult:
Mivel az „Oops" üzenetben már a kernel visszafejtette az abszolút címeket C függvénynevekre és eltolásokra, ezért kérdezzük le, hogy a kernelmodulban most hol található az a függvény, amelyhez képest az eltolást nézzük: , 440.. 10:1 Uniz htlons" .
A kapott pozícióhoz adjuk hozzá az eltolást, és kérdezzük le az így kapott címet, amely a hibás sor:
Ez a parancs megad egy sorszámot a C-forrásban, amely a példában a 17. Nézzük meg, mi található ott:
Ezzel eljutottunk a hibás sorig, és már csak rá kell jönniink, miért adódott a hiba. Ha esetleg szeretnénk az egész függvényt assemblyben látni és ellenőrizni, hogy mi található az adott eltolási ponton, akkor az alábbi paranccsal fejthetjük vissza a kódot:
380
7.7. A hibakeresés módszerei
7.7.5. Magic SysRq Az előző fejezetben láttuk, hogy időnként előfordulhat, hogy a rendszer „Oops" üzenet nélkül teljesen lefagy. Ez értelemszerűen nagyban hátráltatja a munkánkat, mert nincs semmi információnk a hibáról, legfeljebb annyi, amit addig a kernel a konzolra kiírt. Szerencsére azért ilyenkor sem vagyunk teljesen elveszve. A Magic SysRq mechanizmus révén speciális gombkombinációk lenyomásával egyszerű parancsokat hajthatunk végre. Ezekkel megvizsgálhatjuk a rendszer aktuális állapotát. Természetesen ez nagyon alacsony szintű vizsgálatot jelent, így mélyrehatóbb ismereteket igényel az így rendelkezésre álló információ felhasználása. PC esetén a speciális gombkombináció: ALT + SysRq és a parancs. Néhány parancsot példaként bemutatunk, a teljes listát a kernel forrásában a Documentation/sysrq.txt állományban találhatjuk meg. 7.4. táblázat. Magic SysRq parancsok
Parancs
Leírás
b
Azonnal újraindítja a rendszert az állományrendszerek szinkronizálása és lecsatolása nélkül.
c
Rendszerösszeomlást okoz a napló (crashdump) generálása érdekében.
e
SIGTERM szignált küld minden folyamatnak az ínit kivételével.
g
Elindítja a kgdb t, ezáltal lehetővé téve a távoli hibakeresést.
h
Megjeleníti a segítséget.
-
SIGKILL szignált küld minden folyamatnak az ínit kivételével. 1
Megmutatja az aktív CPU-stack backtrace információit.
m
Az aktuális memóriainformációkat kiírja a konzolra. Az aktuális regiszterértékeket kiírja a konzolra.
s
Az állományrendszereket szinkronizálja, kiírja a buffer tartalmát.
t
Az aktuális taszkok listáját és információit kiírja a konzolra.
u
Csak olvasható módban újracsatolja az állományrendszereket.
0— 9
Beállítja a konzol naplózási szintjét. (Lásd a 7.7.1. A printkQ használata alfejezetben.)
A mechanizmust a kernelben engedélyezni vagy tiltani a /proc/sys/kernel/ sysrq virtuális állomány írásával lehet. Ha gondjaink támadnának a SysRq gombkombináció használatával, akkor a parancskaraktert a /proc/sysrq trigger virtuális állományba írva ugyanazt a hatást érhetjük el: -
e cho
t > /proc/s y s rq-t ri gg e r
381
7. fejezet: Fejlesztés a Linux-kernelben
7.7.6. A gdb program használata A gdb program használata a kernel esetében jóval korlátozottabb, mint ahogy az alkalmazásoknál megismertük. Figyelembe kell vennünk, hogy ebben az esetben egy olyan „alkalmazásban" keressük a hibát, amelyben éppen fut a hibakereső alkalmazás is. Ezért nincs lehetőségünk a folyamat megállítására, léptetésére, mert az hatással lenne a hibakereső alkalmazásra is. Ezért lényegében csak egy pillanatnyi helyzetet értékelhetünk ki A mechanizmus a gdb program core állománykezelését használja. Ehhez a kernel generál egy virtuális core állományt /proc/kcore néven. Igy a parancssor a következőképpen néz ki: gdb vmlinux /proc/kcore
Ezt követően a gdb print utasításának segítségével megnézhetjük a változók aktuális tartalmát. Ám vegyük figyelembe, hogy az első megtekintés után a gdb tárolja az értéket a gyorsítás érdekében. Vagyis ugyanannak a változónak egy későbbi lekérdezésében már nem bízhatunk meg maradéktalanul. A modulok debugolása nem megoldott ebben a mechanizmusban.
7.7.7. A kgdb használata Lehetőség van arra is, hogy egy másik gépről soros porton keresztül debugoljuk a kernelt. Az előző megoldáshoz képest így nagyobb lehet a mozgásterünk, mivel interaktívan debugolhatunk. A módszer használatához szükség van egy kernelkiegészítésre (patch), amelyet a régebbi kernelverziók esetén rá kell húznunk a kernelforrásra, az újabb verziók esetén azonban már tartalmazza a forrás. A kgdb elérhetősége a következő: http://kgdb.sourceforge.net . A távoli debugoláshoz, meg kell adnunk a kommunikációra használt soros vonalat és annak beállításait. Ha belefordítottuk a kernelbe a kgdb-t, akkor a kernel betöltésekor, ha modulként fordítottuk, a modul betöltésekor meg kell adnunk az alábbi paramétert: kgdboc=,[baud]
Például: kgdboc=/dev/tty50,115200
Ha ezt elmulasztottuk volna, akkor utólagos beállításra is van lehetőség a sysfs állományrendszeren keresztül: echo tty5O > /sys/module/kgdboc/parameters/kgdboc
382
7.7. A hibakeresés módszerei
Ezt követően a debugüzemmódot többféleképpen is indíthatjuk: •
A kernel fordításakor beállíthatjuk, hogy automatikusan induljon el.
•
A kernel indításakor egy paraméter segítségével is elindíthatjuk.
•
Menet közben az SysRq +g billentyűkombinációval indíthatjuk.
Az elindítást követően egy másik gépről soros porton keresztül a gdb programmal debugolhatunk. Ehhez természetesen szükségünk van a módosított kernel vmlinux állományára is a távoli gépen. A gdb elindítása a hagyományos módon történik:
4db :2ortInioc Ezt követően meg kell adnunk a soros kommunikáció sebességét, illetve hogy melyik porton keresztül csatlakozzon a másik gépre: set remotebaud 115200 target remote /devitty50
_ 411111111ffla
Amikor a gdb csatlakozik a másik géphez, akkor hasonlóan az alkalmazások hibakereséséhez megvizsgálhatjuk a tesztelt rendszer belső állapotát, vagy elhelyezhetünk töréspontokat a kernelben, és folytathatjuk a futtatást a töréspontig. A kernelben történő hibakereséshez jól használható módszer, a modulokat azonban ezzel a módszerrel is nehézkes nyomkövetni, bár nem lehetetlen. Ha a soros port nyújtotta sebességet zavaróan lassúnak találnánk, akkor vannak hálózaton működő megvalósítások is (kgdb over ethernet), ám ezek alapértelmezésben nem részei a kernelnek.
7.7.8. További hibakeresési módszerek Az eddigi felsorolással nem zárult le teljesen az eszköztárunk. A User Mode Linux (UML, felhasználói módban futó Linux) virtuális Linux rendszereket képes futtatni felhasználói folyamatként egy valódi Linux rendszerben. Ezáltal az UML lehetőséget nyújt arra, hogy a virtuális rendszerben teszteljünk anélkül, hogy a valós rendszert veszélyeztetnénk. Ám ennél többre is képes: segítségével a Linux-kernelt mint egy felhasználói folyamatot debugolhatjuk. Így a hibakeresés olyan lesz, mint a felhasználói alkalmazások esetében. Az UML hátránya az, hogy beágyazott rendszerek fejlesztésekor csak akkor használható, ha a célgép architektúrája megegyezik a fejlesztői géppel, illetve a perifériák is ugyanúgy elérhetők. Általában ez nehezen teljesíthető követelmény, de ilyenkor jól használható helyette a kgdb. Az UML másik hátránya az aránylag munkaigényes rendszer-összeállítás.
383
7. fejezet: Fejlesztés a Linux-kernelben
További kernel-üzemmódbeli hibakeresési eszköz lehet még az OHCI1394 Firewire port használata, amelyen keresztül direkt hozzáférés nyerhető a memóriához, így nagyon alacsony szintű vizsgálatokat végezhetünk egy másik gép segítségével.
7.8. Memóriakezelés a kernelben 7.8.1. Címtípusok A kernelprogramozás során több címtípussal is találkozhatunk. Ezért célszerű megvizsgálnunk, milyen címtípusokat különböztetünk meg. Ezek közül nem mindegyiket használjuk a kernelben, mivel a fizikai cím közvetlen használata összeférhetetlen a virtuális címzés elveivel, de a többivel találkozhatunk munkánk során. •
Fizikai cím: A processzor a memóriához való hozzáféréshez használja.
•
Kernel-logikaicím: A kernel által használt címtartomány a normál allokálásoknál Fizikai címként kezeljük, viszont a legtöbb architektúrán valójában a fizikai címtől egy eltolással tér el.
•
Kernel-virtuáliscím: Valójában a kernel-logikaicím is egy virtuális cím: lineáris leképezés fizikai címre. A vmalloc kal allokált memóriánál viszont a címtartomány folytonossága fontosabb. Ezért itt valódi virtuális címeket használunk, amelyeknél nem garantált a lineáris leképezés. -
•
Felhasználói virtuális cím: A felhasználói processzek saját virtuális címtartománnyal rendelkeznek. A felhasználói virtuális cím ebben a címtartományban egy hivatkozás.
•
Buszcím: Az architektúrák általában a perifériák fizikai címeit használják az adatátvitelre. Ezért a buszcím fizikai cím.
7.8.2. Memóriaallokáció A kernelmodulokban a memóriaallokációt leggyakrabban a kmalloc0 függvénnyel végezzük. Ennek általános alakja a következő: void* ktalloc(size-t sizo,
384
flAgs);
7.8. Memóriakezelés a kernelben
A flags mező értéke a leggyakrabban GFP KERNEL, ám használhatunk további értékeket is:
•
GFP_ATOMIC: Normál esetben a kmalloe0 meghívhatja a sleep függvényt. Ezzel a paraméterrel ez nem történhet meg, a memóriafoglalás atomi műveletté válhat, ezért a megszakításkezelő függvényekben is használható.
•
GFP_KERNEL: A szokásos allokációs metódus. Az allokáció során a kernel használhat sleep függvényt.
•
GFP_USER: A felhasználói címtartománybeli lapok allokálásánál használatos alapértelmezett beállítás. Ettől a foglalás még nem a felhasználói címtartományban történik, mindössze ugyanazokkal a beállításokkal megy végbe az allokáció.
•
GFP_NOIO: Ugyanaz, mint a GFP_KERNEL, csak közben nem hajtódnak végre az 1/0 műveletek.
•
GFP_NOFS: Mint a GFP_KERNEL, csak közben nem hajtódnak végre az állományrendszer-műveletek.
A felszabadítást minden esetben a kfree0 függvény végzi:
Igfree(voli *afidit'l;
—
ffl~á
A kmalloc0 által allokált terület a kernel logikai címtartományába esik (kernelbeli logikai cím), vagyis lényegében fizikai címeket használunk egy eltolással. A fizikai cím használata megnehezíti a lapokon átnyúló allokációt, mert megfelelő méretű összefüggő területet kell találni hozzá a memóriában. Ezért az allokáció egyszerűsítésére a kernel induláskor a memóriát több különböző méretű részre osztja. Ezek egy részét konkrét, a kernelben gyakran használt leíróstruktúrák tárolására tartja fent, így ezek allokációja gyors lesz. A másik részét általános allokációk részére, különböző méretű blokkokra osztva allokálja. Ezt az allokációs stratégiát hívjuk slab allokációnak. A Linux-kernel több konkrét algoritmust is tartalmaz erre a feladatra, amelyeknek a működése eltérő." Mivel a slab allokáció algoritmusa sokféle lehet, a konkrét jellemzők is eltérnek, használatuk azonban egységes. A kmalloc0 az általános használatú blokkokat alkalmazza, azokból azt a legkisebb szabad blokkot osztja ki az allokációs kérésre, amelybe még belefér az igényelt terület. Vagyis a krnalloe0 lényegében nem is allokál, csak a szabad blokkok közül osztogat. Ebből is láthatjuk, hogy az általa kiosztott terület véges, ezért óvatosan kell bánnunk vele, és kerülnünk kell a memóriaszivárgást.
102
Az eredeti SLAB algoritmus helyett manapság a SLUB használata az elterjedt. Emellett még a SLOB (Simple List Of Blocks) algoritmus is választható, amely a kicsi, beágyazott rendszerek számára előnyös. 385
7. fejezet: Fejlesztés a Linux-kernelben
A kmalloc0 használata során az alábbi korlátokra figyeljünk: •
A blokkok mérete előre rögzített: tipikusan a 2 hatványai; az egyre nagyobb blokkokból pedig egyre kevesebb van. A minimum és a maximum méret általában 8 byte és 8 kilobyte.
•
Ha nagyobb memóriaterületre van szükség, akkor a vmallocQ-kal allokálhatunk a virtuális memóriából.
•
A másik lehetőség nagyméretű allokációk esetén az, ha teljes lapot vagy több lapból álló összefüggő részt kérünk.
Ez utóbbi két lehetőségre mutatunk néhány függvényt. Mint az előző felsorolásban is szerepelt, ha nagy egybefüggő területre van szükségünk, akkor a vmalloc() függvénnyel allokálhatunk a virtuális címtartományból: void* vmal 1 oc(unsi gned long si ze);
Ilyenkor a felszabadítás a ufree() függvénnyel történik: vold vfrea(void* addr);
A vmalloc0-nál nagyobb teljesítményt érhetünk el, ha egy lapot vagy több lapból álló folytonos területet allokálunk az alábbi függvényekkel: unsigned long get_zeroed_page(gfp_t gfp_mask); unsigned long __get_free_page(gfp_t gfp_mask); unsigned long _get_free_pages(gfp_t gfp_mask, unsigned int order);
Az első függvény egy nullákkal teleírt lapot ad vissza. A második hasonló az elsőhöz, de nem inicializálja a területet. A harmadik függvénnyel kaphatunk vissza több lapból álló területet. Mivel azonban a lapok számának 2 egész számú hatványának kell lennie, ezért a visszaadott lapok száma a következőképpen adódik: lapszám = 2^order. A lap(ok) felszabadítását az alábbi függvényekkel végezzük: void free_page(unsigned long addr); void free_pages(unsigned long addr, unsigned int order);
7.9. A párhuzamosság kezelése A kernelfejlesztés során is találkozhatunk olyan helyzetekkel, amikor a párhuzamos végrehajtás miatt szinkronizációra van szükség. Ilyen esetek a következők:
386
7.9. A párhuzamosság kezelése
•
Ha megszakításkezelőben használunk olyan változókat, amelyek máshol is hozzáférhetők a rendszerben.
•
Ha a kódunk sleep() rendszerhívást tartalmaz. Ez tulajdonképpen azt jelenti, hogy a függvényünk futását megállítjuk az adott ponton, és más folyamatokra vált át a kernel. (Van több olyan más rendszerhívás is, amely használja a sleep() függvényt. Láttuk, hogy a kmalloc() is ilyen, ha nem a GFP ATOMIC kapcsolót használjuk, de a copy_from_user() is idetartozik.)
•
Ha kernelszálakat használunk.
•
Ha többprocesszoros rendszert használunk. (Ide tartoznak a többmagos processzorok és a többszálú processzorok is.)
A párhuzamosság problémáinak kezeléséhez olyan szinkronizációs eszközökre van szükségünk, amelyek a kölcsönös kizárást valósítanak meg. A Linux-kernelben több ilyen eszközt is találunk, ezeket tipikus alkalmazási területükkel együtt a következő fejezetekben mutatjuk be.
7.9.1. Atomi műveletek A legegyszerűbb szinkronizáció, ha nincs szükségünk szinkronizációs eszközre. Ezt úgy érhetjük el, ha a műveletünk atomi, vagyis gépikód-szinten egy utasításban valósul meg. Így nincs olyan veszély, hogy a művelet végrehajtása közben egy másik processzoron futó másik kód hatással lesz a műveletünkre, mivel az egy lépésben valósul meg. Annak eldöntéséhez, hogy a művelet atomi-e, gépikód-szinten kell megvizsgálnunk. Ebből adódóan platformfüggő és C-fordító-függő, hogy egy művelet tényleg atomi lesz-e. A kernel platformfüggő, assembly részeiben gondoskodhatunk arról, hogy teljesüljön a műveleteinkre ez a feltétel. Ám a platformfüggetlen kernelrészek számára is lehetővé kellett tenni, hogy garantáltan atomi műveleteket használhassanak. Ezt a kernel fejlesztői egy atomi típus és a rajta értelmezett függvények definiálásával érték el. Amikor a kernelt egy új platformra ültetik át a fejlesztők, akkor ezeket a függvényeket az adott architektúrának megfelelően kell megvalósítaniuk úgy, hogy a végeredmény valóban atomi legyen, vagy megfelelően szinkronizált. Előfordulhat, hogy egy adott platformon az implementáció a hagyományos műveleteknél lassabb. Ezért az atomi műveleteket csak ott használjuk, ahol tényleg szükség van rájuk. Az atomi műveletek egyszerűek, különben nem lehetne egy gépi utasításban elvégezni őket. 103 Ezért az atomi függvények halmaza csak egész számokon értelmezett érték növelő/csökkentő/tesztelő és bitműveleteket tartalmaz. 103
Ez nem jelenti azt, hogy minden atomi művelet minden processzortípuson egy gépi utasításra fordul le. Ám a lista összeállítása során egy-egy processzor utasításkészletéból indultak ki a fejlesztők. 387
7. fejezet: Fejlesztés a Linux-kernelben
Az egész számot kezelő atomi függvények csak az atomic_t adattípuson értelmezettek, amely egy előjeles egész számot tartalmaz. Más, hagyományos típusokkal nem használhatók. Továbbá az atomi adattípuson nem használhatók a hagyományos operátorok sem. Az atomic_t típusú változót csak az ATOMIC_INITO makróval inicializálhatjuk. Például: ":*tiitte
4"4,atitbilö-';«~t~ilia~
Az alábbi atomi egészszám-műveleteket használhatjuk (7.5. táblázat): 7.5. táblázat. Atomi egészszám-műveletek Függvény
Leírás
atomic_read(atomic_t *u)
Az atomi érték kiolvasása.
atomic_set(atomic_t *v, int i)
Az atomi érték beállítása i-re.
void atomic_add(int i, atomic_t *v)
Hozzáadja i t v hez.
void atomic_sub(int i, atomic_t *u)
Kivonja i t
void atomic_inc(atomic_t *u)
Megnöveli 1 gyel v értékét.
-
-
-
-
void atomic_dec(atomic_t *v)
Csökkenti 1 gyel v értékét.
int atomic_add_return(int i, atomic_t *v)
Hozzáadja i t v hez és visszaadja az eredményt.
int atomic_sub_return(int i, atomic_t *v)
Kivonja i t v ből, és visszaadja az eredményt.
int atomic_inc_retu.rn(atomic_t *v)
Növeli a v értékét 1-gyel, és visszaadja.
int atomic_dec_return(atomic_t *v)
Csökkenti a v értékét 1-gyel, és viszszaadja.
int atomic_add_negative(int i, atomic_t *v);
Hozzáadja i-t v-hez. Ha az eredmény negatív, akkor igaz, egyébként hamis értékkel tér vissza.
int atomic_sub_and_test(int i, atomic_t *v);
Kivonja i-t v-ből. Ha az eredmény 0, akkor igaz, egyébként hamis értékkel tér vissza.
int atomic_inc_and_test(atomic_t *0)
Megnöveli 1-gyel v értékét. Ha az eredmény 0, akkor igaz, egyébként hamis értékkel tér vissza.
int atomic_dec_and_test(atomic_t *u)
Csökkenti 1-gyel v értékét. Ha az eredmény 0, akkor igaz, egyébként hamis értékkel tér vissza.
int atomic xchg(atomic_t *v, int i);
Beállítja v-t i-re, és v korábbi értékével tér vissza.
int atomic_cmpxchg(atomict *v, int u, int i);
Ha v értéke megegyezik u-val, akkor beállítja v-nek az i értékét. A visszatérési érték a régi értéke a v-nek.
388
-
-
-
-
-
7.9. A párhuzamosság kezelése
Függvény
Leírás
int atmnic_add_unless(atontic_t *u, int i, int u);
Ha v értéke nem egyenlő u-val, akkor i-t hozzáadja v-hez, és igaz értékkel tér vissza. Egyébként a visszatérési értéke hamis.
Az atomi műveletek másik csoportját a bitműveletek alkotják. A bitműveletek unsigned long típusú értékeket kezelnek, és egy memóriacímmel megadott memóriaterületen végzik el a műveletet annyi byte-ra értelmezve, amennyi az unsigned long típus mérete az adott architektúrán. A byte-sorrend (littleendian, big-endian) értelmezése szintén az architektúrától függ• annak a byte-sorrendjével egyezik meg. Ez sajnos azt jelenti, hogy a bitműveletek platformfüggők. A bitértékeket az alábbi függvényekkel állíthatjuk be: void set_bit(unsigned long nr, volatile unsigned long *addr) void clear_bit(unsigned long nr, volatile unsigned long *addr); void change_bit(unsigned long nr, volatile unsigned long *addr);
A függvények értelmezése sorrendben az nr bit beállítása, törlése, átállítása. Az értékbeállítást kombinálhatjuk ellenőrzéssel is: int test_and_set_bit(unsigned long nr, volatile unsigned long *addr); int test_and_clear_bit(unsigned long nr, volatile unsigned long *addr); int test_and_change_bit(unsigned long nr, _ _ _ volatile unsigned long *addr); _
A függvénynevek is utalnak arra, hogy először az adott bit értékének vizsgálata történik meg, és ezt követi az érték állítása. Ha a régi bitérték 1, akkor igaz a visszatérési érték, egyébként hamis.
7.9.2. Ciklikus zárolás (spinlock) A ciklikus zárolás (spinlock) egy olyan szinkronizációs eszköz, amely folyamatosan, egy CPU-t terhelő ciklusban kísérletezik a zárolás megszerzésével mindaddig, amíg meg nem szerezte. Ezért csak rövid kritikus szakaszok esetén használjuk, egyébként a várakozó szálak/folyamatok számottevő mértékében pazarolnánk a CPU-t. Ugyanakkor a rövid szakaszok esetében hatékonyabb, mint az összetettebb szemafor. További előnye az, hogy egyprocesszoros rendszernél üres szakasszal helyettesíti a rendszer, ugyanis ott nincs szükség ilyen jellegű szinkronizálásra.
389
7. fejezet: Fejlesztés a Linux-kernelben
Ugyanakkor figyelnünk kell arra, hogy a ciklikus zárolással védett kritikus szakasz ne tartalmazzon sleep0 et hívó függvényt. Ha ugyanis egy száll folyamat lefoglalja a zárolást, és sleep0 miatt egy olyan szál/folyamat kapja meg a vezérlést, amelyik szintén megpróbálja megszerezni a zárolást, akkor CPU-pazarlóan vár rá a végtelenségig. Szélsőséges esetben ez holtponthoz és így a rendszer lefagyásához vezet. Az előző példával analóg eset állhat elő, ha megszakításkezelő függvényben használunk ciklikus zárolást. Ezért megszakításkezelőben erősen ellenjavallt a ciklikus zárolás alkalmazása. Egy ciklikus zárolás létrehozása az alábbiak szerint történhet: -
_ _ Oxi Ezt követően inicializálnunk kell: s pi rt.:11;x1L3 ni t U1 pc ; Ám a létrehozást és az inicializálást egy lépésben egy makróval is elvégezhetjük, sőt érdemesebb ezt a megoldást választani: ~4É ~INF-3~1-0(.1W Ock) ;Lefoglalása a következő: ..01111.0tV411«ic.) ;
Felszabadítása pedig az alábbi: stvirt_mrtydkal:04c): Előfordulhat, hogy a kritikus szakasznál arról is gondoskodnunk kell, hogy párhuzamosan ne hívódhasson meg egy megszakításkezelő. A megszakításkezelés átmeneti letiltását és engedélyezését az alábbi függvényekkel tehetjük meg: unsi gned 1 ong fl ags ;
spi n_1 ock_i rqsave (8,1ock , flags); spi n_unl ock_i rgrestore(&lock,
flags);
1~1~ .
11111
-
Tapasztaltabb programozóknak gyanús lehet, hogy a spin_lockirqsave0 második paramétereként nem a flags változó mutatója szerepel. Ez nem nyomdahiba. A bemutatott függvények valójában makrók, ezért valójában nem érték szerinti átadást láthatunk, hanem a változót adjuk át.
390
7.9. A párhuzamosság kezelése
7.9.3. Szemafor (semaphore) A szemafor (senzaphore) a ciklikus zárolásnál összetettebb mechanizmus, ezért több erőforrást is igényel. Így a nagyon rövid szakaszok esetén inkább a ciklikus zárolást részesítjük előnyben. Ugyanakkor a komolyabb esetekben, illetve ha a ciklikus zárolás a megkötései miatt nem használható, akkor ez a helyes választás. Figyeljünk arra, hogy a semaphore a várakozáshoz sleepQ függvényt használ, így nem alkalmazhatjuk olyan helyen, ahol a sleep()-es függvények tiltottak.'" A semaphore létrehozása a következő:
Struct semaphore mm; Inicializálása eszerint történik:
rfrtid siminiltCs:tmct
va3.).;
A létrehozást és az inicializálást kombináló makró az alábbi:
static DerINLSOIMORk(sem); A val paraméter a szemafor kezdőértékét adja meg. A szemafor lefoglalásakor lehet, hogy várakozásra kényszerül a függvény. Ám attól függően, hogy ezt a várakozást mi szakíthatja meg, több függvény is választható a lefoglalásra. Egyszerű lefoglalás az, amikor csak a szemafor felszabadulása vet véget a várakozásnak:
00« .(5tr41:kt..se~tore Ha egy jelzés megszakíthatja a várakozást (ilyenkor -EINTR a visszatérési értéke): itt*.4S,WIUMW.1%ii‚tible(St -rilCt. $fflOritgire: ' 11'4004 Ha kritikus szignál szakíthatja csak meg:
ticiWn.*17141~.~tiotk
10
' Nem használhatunk sleep0-es függvényeket például a megszakításkezelő függvényekben (hardver- és szoftver-megszakításkezelők). Emellett ciklikus zárolás által védett szakaszokban sem. 391
7. fejezet: Fejlesztés a Linux-kernelben
Ha nem akarunk várakozni, hanem csak egy hibajelzést szeretnénk, amenynyiben nem lehet lefoglalni: 500110e *$.210; Ha időkorlátos várakozást szeretnénk:
int downiMeout(wuct semaphöre *sem,long jiffies); Az időkorlát mértékegysége jiffy. (Lásd a 2.3.9. Idő és időzítők alfejezetben.) A szemafor elengedése a következő: uraid up(stNct semaphöre *sem);
7.9.4. M utex A kernelfejlesztők a szemafort többnyire 1 kezdőértékkel mutexként kölcsönös kizárás megvalósítására használják olyan esetekben, ahol a ciklikus zárolás nem alkalmazható. Ha azonban nem használjuk ki teljesen a funkcióit, akkor lehetséges egy egyszerűbb megvalósítás is, amellyel valamelyest jobb lesz a rendszer teljesítménye. Ilyen megfontolás vezette a fejlesztőket, amikor bevezették a kernel mutexeszközét. A kernelfejlesztések során a mutex azokban az esetekben használható, ahol egyébként szemafort használnánk a kölcsönös kizárásra. A megkötések nagyrészt a szemafor megkötéseivel azonosak, ugyanakkor vannak új elemek is: •
Csak a lefoglaló szabadíthatja fel.
•
Rekurzív foglalás vagy többszörös felszabadítás nem engedélyezett.
•
Nem használható megszakítás kontextusában. (Vagyis sem hardver-, sem szoftvermegszakítást kezelő függvényben.)
A mutex típusa az alábbi: ,„%t
Inutglci
Inicializálása a következő:
inutex~ttmutW).; A létrehozás és az inicializálás a következő makróval történhet: WINP.iltilliTEXONt«) ;
392
7.9. A párhuzamosság kezelése
A mutex lefoglalása így néz ki: void mutex_lock(struct mutex *lock);
A mutex lefoglalása, ha a várakozást jelzéssel megszakíthatóvá szeretnénk tenni, a következő: 1
1~14
4.012
1,4
41jítitii;
A mutex lefoglalása, ha sikertelenség esetén nem várakozni akarunk, hanem hibavisszajelzést szeretnénk kapni: int mutex_trylock(struct mutex *lock);
11115~11—
A mutex felszabadítása az alábbi:
A mutex foglaltságának ellenőrzése pedig eszerint történik: -
int mutex_is_locked(struct mutex *lock);
7.9.5. Olvasó/író ciklikus zárolás (spinlock) és szemafor (semaphore) A kritikus szakasznál célszerű megkülönböztetnünk, hogy a szakaszon belül a védett változót írjuk vagy olvassuk. Mivel ha csak olvassuk az értéket, akkor azt párhuzamosan több szál is probléma nélkül megteheti (megosztott zárolás). Míg ha írjuk, akkor másik szálnak sem írni, sem olvasni nem szabad (kizáró zárolás). A két eltérő zárolás használatával növelhetjük a rendszer teljesítményét. Az olvasó/író ciklikus zárolás létrehozása és inicializálása a következő: rwlock_t rwlock; rwlock_i ni t (&rwlock)
; 1111~1~1~1~"
A létrehozást és az inicializálást kombináló makró: stati C DEF/NE_RWLOCK( rwl ock)
~~~ -
•=.
Olvasási (megosztott) zárolás: read_lock(&rwlock); read_unlock(&rwlock);
393
7. fejezet: Fejlesztés a Linux-kernelben
Írási (kizáró) zárolás: wri te_l ock (8irwl ock) ; wri te_unl ock(&rwl ock) ;
Az olvasó/író szemafor létrehozása: struct rw_semaphore rwsem;
Inicializálás: void ínit_rwsem(struct rw_semaphore *sem);
Lefoglalás olvasásra: void down_read(struct rw_semaphore *sem); ínt down_read_trylock(struct rw_semaphore *sem);
Felszabadítás olvasás esetén: void up_read(struct rw_semaphore *sem);
Lefoglalás írásra: void down_write(struct rw_semaphore *sem); int down_write_trylock(struct rw_semaphore *sem);
Felszabadítás írás esetén: void up_wr te(struct rw_semaphore *sem) ;
Előfordulhat, hogy egy olvasási zárolást felszabadítás nélkül írási zárolásra szeretnénk átalakítani, ám erre a Linux-kernel implementációjában nincs lehetőség: ilyen jellegű próbálkozás holtpontot okoz. Ugyanakkor az írási zárolást bármikor „visszaléptethetjük" olvasási zárolásra: void downgrade_wri te(struct rw_semaphore *sem) ;
7.9.6. A nagy kernelzárolás A nagy kernelzárolás (Bíg Kernel Lock) egy globális rekurzív ciklikus zárolás. Használata nem javasolt, mivel értelemszerűen jelentősen korlátozza a kernel működését, és rontja a rendszer valósidejűségét, ugyanis akadályozza a kernel párhuzamos működését.
394
7.10.1/0 műveletek blokkolása
Ez a zárolásfajta a 2.0-s verziótól volt jelent a kernelben, amikor a többprocesszoros rendszerek támogatása belekerült (symmetric multiprocessing, SMP). A lehetséges konkurenciaproblémákat úgy előzték meg a fejlesztők, hogy kernelüzemmódban az egész kernelt zárolták. Ezzel kivédték, hogy egy másik processzor is futtasson párhuzamosan kernelmódú kódot. A konkurenciaproblémák kezelésére széles körben alkalmazták, mert segítségével úgymond „biztosra lehetett menni", és nem kellett végiggondolni az adott konkurenciahelyzetek hatásait. Ugyanakkor ez egy átmenetinek szánt megoldás volt, amelynek a helyét később átgondoltabb, kifinomultabb megoldások vették át. Az idők folyamán egyre több helyről sikerült kiszorítani, és végül a 2.6.37-es verzióban sikerült végleg eltávolítani. Bár lehetőség van rá, hogy a kernel konfigurációja során visszakapcsoljuk, ez értelemszerűen nem javasolt. A teljesség kedvéért azonban a nagy kernelzárolás lefoglaló és felszabadító függvényeit is megemlítjük:
1 ock_ke rnel 0 ; unl ock_kernel () ;
_
7.10. I/O műveletek blokkolása Az eszközvezérlőknél gyakran felmerülő probléma, hogy várakoznunk kell a hardverre, hogy végre tudjunk hajtani egy műveletet. Ennek leggyakoribb formája, hogy a felhasználói alkalmazás már olvasná az adatokat, de még nem kaptunk meg mindent az eszköztől. Nem célszerű hibajelzéssel visszatérnünk, mert akkor ismételten meg kell hívnia az olvasást/írást végző függvényünket Azt az utasítást sem adhatjuk, hogy itt az állomány vége, mert nem igaz. Helyette blokkolnunk kellene a függvényt, és csak akkor kellene visszatérnie, amikor már van adatunk. Ez a megoldás már ismerős lehet az alkalmazásfejlesztésből, hiszen számos függvénynél tapasztalhattuk, hogy csak akkor térnek vissza, ha megérkezett az adat. Most megvizsgáljuk, hogyan tudjuk ezt a működést kerneloldalon megvalósítani. Természetesen nem célszerű a kódunkba egy várakozó ciklust tenni, mert ez a várakozás idejére teljesen lefoglalna egy CPU-t. Ha csak egyetlen CPU van a gépünkben, akkor ez egyenlő a rendszer lefagyásával. Helyette egy sleep jellegű várakozást kell választanunk, vagyis a várakozás időtartamára engedjük, hogy más folyamatok fussanak, míg a minket hívó folyamat alszik. Majd amikor az esemény bekövetkezik, akkor felébresztjük, és folytathatja a működését. Erre a kernel a következő hatékony megoldást biztosítja. A 2.3.2. A processz állapotai alfejezetben említettük a processz állapotait. Ha egy processz egy adott eseményre várakozik, a kernel várakozási állapotba állítja a
395
7. fejezet: Fejlesztés a Linux-kennelben
processzt, és elhelyezi egy kétszeresen láncolt listába. Ha egy jelzés „felébreszti" ezeket a folyamatokat, ezek ellenőrzik, hogy az esemény, amelyre várakoznak, bekövetkezett-e. Ha nem, akkor maradnak a várakozási sorban, ha igen, akkor eltávolítják magukat a várakozási sorból. A várakozási sor programozása egy ugyanilyen nevű, úgynevezett várakozásisor- (wait queue) változót igényel a Linuxban. Ennek létrehozása és inicializálása a következő: wait_queue_Wead_t wait_queue; init_waitqueue_head(&wait_queue);
~~~-
Ha statikusan hozzuk létre, vagyis nem egy függvény lokális változója, illetve dinamikusan allokált struktúra része, akkor a következő makróval fordítási időben is elvégezhetjük a létrehozást és inicializálást: DECLARE_WAIT_QUEUE_HEAD(wait_queUe);
7.10.1. Elaltatás Miután létrehoztuk a várakozásisor-változót, a segítségével elaltathatjuk a processzeket a következő függvények valamelyikével: sleep_on(wait_queue_head_t *queue);
1111111~~1111111~11-:
A processzt a sleep függvény segítségével eseményre várakozó állapotba küldi, és behelyezi a várakozási sorba. Hátránya ennek a függvénynek, hogy jelzésekkel nem szakítható meg, ezért a processz beleragadhat az adott műveletbe. interruptible_sleep_on(wait_queue_head_t *queue);
Működése megegyezik a sleep_on() függvényével, de jelzések megszakíthatják. Az eszközvezérlőkben az egyik leggyakrabban használt függvény a blokkolásra: sleep_on_timeout(wait_queue_head_t *queue, long timeout);
Ez a sleep_on() timeoutos változata. Vagyis a megadott időnél tovább nem várakozik. Az idő jiffyben van megadva. (Lásd a 2.3.9. Idő és időzítők alfejezetben.) interruptible_sleep_on_timeout(wait_queue_head_t *queue, _ _ _ long timeout); _
Ez az interruptible_sleep_on() timeoutos változata (lásd az előzőt):
396
7.10. I/O műveletek blokkolása
wait_event(wait_queue_head_t queue, int condition); wait_event_timeout(wait_queue_head_t queue, int condition, long timeout);
Ez a makró kombinálja a várakozást és a feltétel tesztelését. Mindezt úgy, hogy a kritikus versenyhelyzeteket kizárja. Használatakor a folyamat csak akkor ébred fel ténylegesen, ha a feltétel (condition) igaz értékű: wait_event_interruptible(wait_queue_head_t queue, int condition); wait_event_interruptíble_timeout(wait_queue_head_t queue, int condition, long timeout);
Ez a wait_euent() megszakítható változata. Ez a makró a javasolt módszer az eseményekre való várakozásnál, ugyanis tartalmazza a biztonságos feltétel kiértékelését és a megszakíthatóságot is: waiLeveot_,Ici 1101 e(wai t_queue_h ead_t queue,
int conditi on);
Ez a csak fontos szignálokkal megszakítható változat. A sleep_on használata manapság nem javasolható. Helyette a wait_eventet használják. Ennek az a fő oka, hogy a sleep_ont a fejlesztők többnyire egy while ciklusban alkalmazták. Ennél a megoldásnál azonban a wait_event jobb.
7.10.2. Felébresztés Míután elaltattuk a folyamatot, fel is kell ébresztenünk, amikor már megérkezett a várt adat. Ezt az eszközvezérlő egy másik részén szoktuk elvégezni, tipikusan egy megszakításkezelőben. Erre a következő függvények használhatók: wake_up(wait_queue_head_t *queue)
Ez a függvény felébreszti az összes olyan folyamatot, amely a várakozási listában szerepel. wake_up_interruptible(wait_queue_head_t *queue)
Ez a függvény azokat a folyamatokat ébreszti fel, amelyek interruptible sleep állapotban vannak. A többiek tovább alszanak. wake_up_sync(waít_queue_head_t *queue) wake_up_interruptible_sync(wait_queue_head_t *queue)
Az előző függvények a folyamatok felébresztése mellett azonnal egy ütemező hívást is eredményeztek, hogy a várakozó processzek ténylegesen tovább futhassanak. Ez a két függvény csak futásra kész állapotba teszi ó'ket, de újraütemezést nem vált ki. 397
7. fejezet: Fejlesztés a Linux-kernelben
Ha interruptible sleep állapotban vagyunk, akkor lényegében mindegy, hogy a wake_up0 vagy a wake_up_interruptible0 függvényt használjuk, a szokás azonban az utóbbi alkalmazása.
7.10.3. Példa Feladat Írjunk egy karakteres eszközvezérlőt, amelyben az olvasás addig blokkolódik, amíg nem irunk az eszközállományba. Mivel nincs feltétel, amelyre várakoznánk, ezért a sleep_on függvényt használhatjuk.
/* Blokkolas pelda 1 */ #include #include #include #ínclude #include #include
#define DEVICE_NAME "hello" static ínt major_num=120; #define CLASS_NAME "helloclass" static struct class* hello_class; wait_queue_head_t wait_queue; /* Az iras muveletet lekezelo rutin. */ static ssize_t hello_write(struct file *pfile, const char *buf, size_t count, loff_t *ppos) { printk("Ebresztes...\n"); wake_up_interruptible(&wait_queue); return count;
/* Az olvasas muveletet kezelo fuggveny. */ static ssize_t hello_read(struct file *pfile, char *buf, size_t count, loff_t *ppos) { if(pfile->f_flags & O_NONBLOCK) { return -EAGAIN;
printk("Alvas...\n"); interruptible_sleep_on(&wait_queue); printk("Alvas vege\n"); return 0;
398
7.10. I/O műveletek blokkolása
/* A megnyitas muveletet lekezelo fuggveny. */ static int hello_open(struct ínode *inode. struct file *pfile) try_module_get(THIS_mODuLE); printk(DEvICE_NAmE " megnyitas.\n"); return 0;
/* A lezaras muveletet kezelo fuggveny. */ static int hello_close(struct inode *inode, struct file *pfile) {
printk(DEVICE_NAME " lezaras.\n"); module_put(THIS_mODuLE); return 0; }
static struct file_operations hello_fops = {
owner: THIS_MODULE, read: hello_read, write: hello_write, open: hello_open, release: hello_close 1; /* A kernel modul inicializalasat vegzo fuggveny. */ static int init hello_init(void) int res; struct device* err; init_waitqueue_head(&wait_gueue); /* Regisztraljuk a karakter tipusu eszkozt. */ res = register_chrdev(major_num, DEVICE_NAME, &hello_tops); if(resvm_end - vma->vm_start; if(vsize != PAGE_SIZE) printk(DEVICE_NAME " hibas meret: %lu\n", vsize); EINVAL; return -
}
if (vma->vm_flags & VM_WRITE) return -EPERM; if (PAGE_SIZE > (1 « 16)) return -ENOSYS; if(remap_pfn_range(vma, vma->vm_start, virt_to_phys((void*)data) > PAGE_SHIFT, vma->vm_end - vma->vm_start, vma->vm_page_prot)) return -EAGAIN; } vma >vm_ops = &hello_vm_ops; hello_vm_open(vma); return 0; -
406
7.13.1/0 portok kezelése
A kezelőfüggvényünkben ellenőrizzük a paramétereket, a méretet és a jogosultságbeállításokat. Ezt követően elvégezzük a cím átalakítást és a leképezést egy sorban. Majd a megnyitó és a lezáró függvény regisztrációja következik, és egyben meg is hívjuk a megnyitó függvényt. A kész kezelőfüggvényünket be kell állítanunk az eszközvezérlő regisztrálásánál, hogy az mmap metódust implementálja, és máris használhatjuk ezt a gyors kommunikációs módot az alkalmazásainkban: struct file_operations hello_fops = {
owner:
THIS_MODULE,
mmap:
hello_mmap
1;
7.13. I/O portok kezelése Az eddigi fejezetekben bemutattuk, hogy hogyan tudunk az alkalmazások és a kernelmodulok között kommunikálni. Ám egy eszközvezérlőnél ez még csak a feladat egyik fele, hiszen az eszközvezérlőnek nem elég az alkalmazásokkal kommunikálnia, az általa kezelt hardverrel is tartania kell a kapcsolatot. Ezért a következő néhány fejezetben azt mutatjuk be, hogyan tudunk kernelből különféle eszközöket vezérelni. Az egyszerűbb hardvereszközökkel a kommunikációt leggyakrabban az I/O portok segítségével folytatja a CPU, mégpedig az in és az out parancsok segítségével. A Linux-kernelmodulokban is elérhetjük ezeket a parancsokat, illetve portokat. Ám a portok használata előtt illik a tartományt lefoglalni. Az I/O porttartomány lefoglalásának az a célja, hogy a kernel könyvelje, hogy melyik tartományt melyik eszközvezérlő használja. Így egy lefoglalási kérelem során jelezheti, ha az adott tartományt egy másik eszközvezérlő netán már használja. Természetesen az eszközvezérlő eltávolításakor a tartományt is fel kell szabadítanunk, hogy más eszközvezérlő is használhassa. Porttartomány lefoglalása az alábbi függyénnyel történik: struct resource* request_region(resource_size_t start, resource_size_t n, const char *name);
A start a tartomány eleje, az n a tartomány mérete. A name paraméterre azért van szükség, hogy ha megnézzük a lefoglalt tartományok listáját, akkor láthassuk, hogy melyik eszközvezérlő használja. Ezért ez a paraméter az eszközvezérlő szöveges elnevezését tartalmazza. A lefoglalhatóságot külön is ellenőrizhetjük: int check_region(resource_size_t start, resource_size_t n);
407
7. fejezet: Fejlesztés a Linux-kernelben
A használat után a terület felszabadítása a következő:
rvi7fase.re9icr«resource..~ 5tarti rpstkume..,size_t
ki);
A lefoglalt I/O portok listáját felhasználóként is elérhetjük a /proc/ioports virtuális állomány megtekintésével. A tartomány lefoglalása után a portokat különböző I/O műveletekkel érhetjük el. Az adat méretétől függően több függvény közül is választhatunk: •
8 bites
unsigned char inb(int port);
vojg1 outb(unsigned char value, int port); •
1 6 bites
unsigned short inw(int port) ; voí d outw(unsí gned short value, int port) ;
•
32 bites
unsigned long inb(int port); void outl (unsigned long value, int port) ;
Ugyanakkor mindegyik függvénynek létezik egy várakozó („pause") változata, amely egy kis várakozást is tartalmaz. Erre a lassabb buszok/kártyák használatakor lehet szükségünk. Ezeknek a függvényeknek az alakja megegyezik az előzőekben felsoroltakkal, ám a függvények nevének a végére egy _p utótagot kell illeszteni.
7.14. 1/0 memória kezelése Bár az egyszerű eszközöknél az 1/0 portok használata népszerű, a kicsit komolyabb eszközökkel az I/O memórián keresztül tartjuk a kapcsolatot, mivel sokkal gyorsabb és sokkal nagyobb mennyiségű adat átvitelére alkalmas. Az I/O memória olyan, mint egy szokásos RAM-terület. Ám nem egy memóriamodult kell keresnünk az alaplapon, hanem ez valójában az eszközökben, a bővító'kártyákon foglal helyet, és a processzor többnyire egy buszon (ISA, PCI) keresztül éri el. A teljes képhez hozzátartozik, hogy a processzorgyártók manapság gyakran integrálnak eszközöket az alaplapi vezérlő chipkészletbe vagy éppen a processzorba. Természetesen ekkor az I/O memória is integráltan található meg.
408
7.14. I/O memória kezelése
Bár elméletben egyszerű memóriamutatókkal is tudnánk kezelni ó'ket, mint egy memóriatömböt, ám ez a módszer nem javasolható. Helyette speciális függvényeink vannak erre a feladatra. Mielőtt hozzáférhetnénk az I/O memóriaterülethez le kell foglalnunk: struct resource* request_mem_region(resource_size_t start, resource_size_t n, const char *name);
A start paraméterrel adhatjuk meg a régió elejének fizikai címét, az n-nel a méretét, míg a name az eszközkezelőnk szöveges elnevezése a késó'bbi adminisztrációhoz. Mielőtt lefoglalnánk a területet, ellenőrizhetjük, hogy más már használja-e: int Ch«k_metn_region(resource_size_t start, resource_size_t n);
A lefoglalt területet nem szabad elfelejtenünk felszabadítani, amikor már nincs rá szükségünk:
Ilk~~~fopin~ ~
~+._
A foglalásokat felhasználóként is ellenőrizhetjük a /proc/iomem állomány megnézésével. Ebben tételesen szerepelnek a használt memóriaterületek és az eszközvezérlők megnevezései. Sok rendszeren az I/O memória lefoglalása önmagában nem elég a memóriaterület eléréséhez, ugyanis nem lehet őket közvetlenül elérni. Ezeken a rendszereken a buszmemóriából a kernel által elérhető virtuális memóriaterületre kell leképezni a régiót. A leképezés az alábbi függvényekkel történhet: void __iomem *ioremap(resource_size_t offset, unsigned long size);
void _iomem *ioremap_nocache(resource_size_t offset,
t
unsigned long size);
A művelet végén a leképezés megszüntetéséről se feledkezzünk meg:
01111~~1~~1~1ffik
Ezen a ponton már hozzáférhetünk az eszköz memóriaterületéhez, és adatokat mozgathatunk. Némelyik rendszer esetében közvetlenül memóriakezelő függvényekkel is megtehetjük ezt, ám általában csak az alábbi függvények működnek: unsigned ínt íoread8(void __iomem *); unsigned int ioreadl6(void _iomem *); unsigned int ioread32(void _iomem *); void iowrite8(u8, voíd __iomem *); void iowritel6(u16, void iomem *); void iowrite32(u32, void _iomem *);
1111111W 409
7. fejezet: Fejlesztés a Linux- kernelben
A függvények 1, 2, vagy 4 byte-ot olvasnak be vagy írnak ki a megadott címre. Léteznek azonban ismétlődő („repetitive") megoldások is a tömbök kezelésére: void ioread8_rep(void _iomem *port, void *buf, unsigned long count); void ioreadl6_rep(void iomem *port, void *buf, unsigned long count); voíd ioread32_rep(void _iomem *port, void *buf, unsigned long count); void iowrite8_rep(void iomem *port, const void *buf, unsigned long count); void iowritel6_rep(voíd iomem *port, const void *buf, unsigned long count); void iowrite32_rep(void iomem *port, const void *buf, unsigned long count);
Továbbá a megszokott C-függvényeknek is megtalálhatjuk az analógiáit az I/O memória kezelésére:
_
void memset_io(volatile void i omem *addr, unsigned char val, int count); void memcpy_fromio(void *dst, const volatile void i omem *src, int count); void memcpy_toio(volatile void iomem *dst, const void *src, int count);
7.15. Megszakításkezelés Ha az eszközvezérlőnkben megszakítást szeretnénk kezelni, akkor az alábbi műveleteket kell elvégeznünk: 1. Létre kell hoznunk egy megszakításkezelő függvényt. 2. Regisztrálnunk kell a megszakításkezelő függvényt az adott megszakításhoz. 3. A meghajtóprogram eltávolításakor a megszakításkezelő regisztrációját is el kell távolítanunk. A megszakításkezelő függvénymutató típusa a következő: typedef rq retu rn_t (*i rq_handl er_t) (int irq,
voi d *devid);
A függvény paraméterei a megszakítás száma (irq), amelynek hatására a függvény meghívódik, illetve egy azonosító (devid), amelyet a kezelőfüggvény regisztrációjánál adunk meg.
410
7.15. Megszakításkezelés
A megszakításkezelőnket az alábbi függvénnyel regisztrálhatjuk: int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *devname, void *devid); 4■11111~.
=Mb
Paraméterei a megszakítás száma (irq), a megszakításkezelő függvény (handler), az opciók (flags), az eszközvezérlő neve (devname), megosztott megszakítás esetén az egyedi azonosító (devid). A flags mező értékei az alábbiak lehetnek:
•
IRQF_DISABLED (SA_INTERRUPT): A megszakításkezelő futása alatt a megszakítások letiltódnak. Ezért lehetőleg rövidre kell megírnunk a megszakításkezelőt.
•
IRQF_SHARED (SA_SHIRQ): Annak jelzése, hogy a megszakítást megosztjuk más megszakításkezelőkkel. Ilyenkor általában több hardver használja ugyanazt a megszakításvonalat.
•
IRQF_SAMPLE_RANDOM (SA_SAMPLE_RANDOM): A megszakítás felhasználható a véletlenszám-generálásához. Ezt olyan megszakításoknál alkalmazhatjuk, amely véletlenszerűen generálódik. Ilyen például a billentyűzet megszakítása.
•
IRQF_TIMER: A megszakítás az időzítőtól (timer) érkezik.
A megszakításkezelő függvény implementációjánál az alábbi értékekkel térhetünk vissza: •
IRQ_HANDLED: A megszakítást kezelte a függvény.
•
IRQ_NONE: A megszakítást nem kezelte a függvény.
A sikeres regisztrációt a /proc/interrupts virtuális fájlban ellenőrizhetjük, amely tartalmazza a megszakításokat és a megszakításkezelőket. A megszakításkezelő regisztrációjának törlése a következő függvénnyel történik: void free_irq(unsigned int irq, void *devid); Ha a megszakításvonalat megosztottan használtuk más megszakításkezelőkkel, akkor ügyeljünk arra, hogy a felszabadításnál ugyanazt a devid értéket adjuk meg, amelyet a regisztrációnál. A rendszer nem akadályozza meg, hogy más eszközvezérlő regisztrációját töröljük, ezért különösen oda kell figyelnünk rá.
411
7. fejezet: Fejlesztés a Linux-kernelben
7.15.1. Megszakítások megosztása Mivel a megszakításvonalak száma többnyire véges, ezért a hardverkészítők gyakran kényszerülnek arra, hogy több eszköz között osszanak meg egy megszakításvonalat. Ebben az esetben a megszakításkezelő függvény regisztrációjánál jeleznünk kell az IRQF SHARED flaggel a megosztott használatot. Emellett a devid paraméternek meg kell adnunk egy egyedi értéket, amellyel később a regisztrációnkat azonosíthatjuk. A megszakításkezelő függvény implementációjánál az IRQ_NONE visszatérési értékkel jelezhetjük, ha a megszakítás nem az általunk kezelt eszköznek szólt. Természetesen ilyenkor vigyáznunk kell a megszakítás esetleges letiltásával és engedélyezésével, mivel ebben az esetben más eszközök kezelésére is hatással lehet.
7.15.2. A megszakításkezelő függvények megkötései A megszakításkezelő függvény implementációjakor több szabályt is be kell tartanunk. •
Nem használhatunk sleep0-es függvényt, mivel beláthatatlan következményekkel járna egy taszkváltás a megszakításkezelő kontextusban.
•
A kmalloc0-os allokációt is csak a GFP ATOMIC flaggel végezhetjük, mivel egyéb esetben sleep0 függvényt használhat az allokáció során.
•
A kernel és a user space között nem mozgathatunk adatokat, mivel az erre szolgáló függvények sleep0-es függvények.
•
Törekedjünk a gyorsaságra. A nagy számításokat lehetőleg máshol végezzük.
•
Mivel nem processz hívja meg a függvényt, ezért a processzspecifikus adatok a megszakításkezelőből nem érhetó'k eb
7.15.3. A megszakítás tiltása és engedélyezése Ha egy megszakítást le szeretnénk tiltani, az alábbi két függvénnyel tehetjük meg: void disablo_irq(unsigned int irq);
vola dhable,_1 ro—nosync(unsigned int i rO); -
412
7.15. Megszakításkezelés
A nosync függvény abban különbözik a társától, hogy míg a normál változat az esetleg futó megszakításkezelő függvény végét megvárja a visszatérés előtt, a nosync változat azonnal visszatér a függvény meghívása után. A megszakítás ismételt engedélyezése az alábbi függvénnyel történik: void enable_irq(unsigned int rq);
Ha szükségünk van az összes megszakítás letiltására, illetve engedélyezésére, akkor ezt az alábbi függvényekkel tehetjük meg: void local_i rg_enable(); void local_i rq_disable();
Többnyire szeretnénk a korábbi beállításokat lementeni, illetve az engedélyezéskor visszaállítani. Az alábbi függvények erre nyújtanak lehetőséget: 1-06 void local_i rg_save(unsigned leng flags); void local_i rg_restore(unsigned long flags);
7.15.4. A szoftvermegszakítás A szoftvermegszakítások (software interrupt, softirq) lehetővé teszik a kernelnek egy feladat végrehajtásának a késleltetését. Jellegre és megkötésekben egyeznek a korábban tárgyalt hardvermegszakításokkal, csak nem hardveresemény generálja őket, hanem szoftver váltja ki a mechanizmust.'w Amikor egy függvényhívással szoftvermegszakítást váltunk ki, akkor erre a kernel nem azonnal reagál, hanem kicsivel később hívja meg a kezelőfüggvényt. Egész pontosan, amikor egy hardvermegszakítás kezelőfüggvénye véget ér, akkor a kernel lefuttatja a várakozó szoftvermegszakításokhoz tartozó kezelőfüggvényeket. A szoftvermegszakítás-kezelés központi eleme egy 32 bejegyzést tartalmazó tábla, amely az egyes kezelőfüggvények mutatóit, adatait tartalmazza. A megszakítás generálásakor a regisztrált függvények hívódnak meg. A 32-es limit aránylag szűknek tűnhet, ám közvetlenül nem szokás használni ezt a mechanizmust. Ehelyett az erre épülő szolgáltatásokat vesszük igénybe, ilyenek például a kisfeladatok (lásd a 7.15.5.1. A kisfeladat (tasklet) alfejezetben).
1°6 1°7
A megadott függvények valójában makrók, ezért a paraméter nem érték szerint adódik át. A Linux-kernel esetén tárgyalt szoftvermegszakítás terminológia nem egyezik meg az x86-os világban használt hasonló nevű fogalommal, azaz nem az Intel x86-os processzor INT x instrukciójának használatáról van szó.
413
7. fejezet: Fejlesztés a Linux-kernelben
7.15.5. A BH-mechanizmus A hardvermegszakítás-kezelőben sokszor komolyabb adatfeldolgozást is el kell végeznünk, ekkor azonban nem lesz gyors az implementáció, amely a megszakításkezelő egyik legfontosabb alapkövetelménye. Erre a problémára nyújt megoldást az alsórész- (Bottom half, BH) mechanizmus, amely a megszakításkezelőt egy felső részre (Top half) és egy alsó részre (Bottom half) választja szét. A felső rész a tényleges megszakításkezelő rutin. Ennek feladata csak az adatok gyors letárolása a késó'bbi feldolgozáshoz, illetve a feldolgozó rutin futtatásának kérvényezése. Az alsórész-rutin már nem megszakításidőben fut, ezért a futás idejére nem érvényesek a szigorú megkötések. Az alsó rész implementációjára két mechanizmust használhatunk: •
Kisfeladat (Tasklet)
•
Munkasor (Workqueue)
A két megoldásnak eltérő tulajdonságai, előnyei és hátrányai vannak, amelyeket a következőkben részletesen megvizsgálunk. Létezik még egy harmadik megoldás is, amely közeli rokonságban áll a munkasorral, nevezetesen a kernelszálak használata a feladatok elvégzésére, ennek később külön fejezetet szentelünk.
7.15.5.1. A kisfeladat (tasklet) Ha a hardvermegszakításban elvégeztük a gyors kritikus dolgokat, kézenfekvő megoldás, hogy a feladat maradék részére meghívunk egy szoftvermegszakítást (lásd a 7.15.4. A szoftvermegszakítás alfejezetben). A Linux-kernel erre külön támogatást nyújt, ezeket kisfeladatoknak (tasklet) nevezzük. A kisfeladatok szoftvermegszakítás által meghívott kódrészletek, amelyeket a programozó regisztrálhat, illetve eltávolíthat, és amelyeket a kernel ütemez. Így a megoldásunk a következő. A megszakítás időigényesebb részét egy megadott paraméterlistájú függvényben implementáljuk, és beregisztráljuk mint kisfeladatot. A hardvermegszakítás végén kérjük a kisfeladat ütemezését. Ha nem használjuk tovább a kisfeladatot, akkor fel kell szabadítanunk. Kisfeladatot akkor alkalmazunk, ha a feladat túl kevés ahhoz, hogy külön szálat rendeljünk hozzá. A kisfeladat implementációja során figyelembe kell vennünk néhány megkötést:
414
•
A kisfeladat futtatását többször is kérhetjük, mielőtt lefutna. Ilyenkor azonban csak egyszer fut le.
•
Ha a kisfeladat fut már egy CPU-n a kérvényezéskor, akkor később ismét lefut.
•
A kisfeladat azon a CPU-n fut le, ahol először kérvényezték.
7.15. Megszakításkezelés
•
Egyszerre a kisfeladat csak egy példányban futhat még SMP-rendszerben is.
•
Különböző kisfeladatok viszont futhatnak párhuzamosan SMP-rendszerben.
•
A kisfeladat nem indul el, amíg a megszakításkezelő be nem fejeződik.
•
A kisfeladat futása során meghívódhat a megszakításkezelő.
•
Mivel a kisfeladat szoftvermegszakítás-kontextusban fut, ezért a megszakítás-kezelők implementációs megkötései rá is érvényesek: sleep0-et tartalmazó függvényt nem használhatunk kisfeladatban sem.
A kisfeladat-mechanizmus használatához először létre kell hoznunk egy kezelőfüggvényt Ennek alakja a következő: vala k^seÍadat^i(urísignd léging '
j
Feladatot a következő makróval hozhatunk létre: DÉCLARLtA$ 10,....ET(Ov
,
A név a kisfeladat neve, amellyel később hivatkozhatunk rá. A függvény a kezelőfüggvényünk neve. Az adat mezőben található számot kapja meg a kezelőfüggvényünk adatként egyetlen paraméterén keresztül. Ezt követően a kisfeladat lefuttatását a tasklet_scheduleQ függvénnyel kérhetjük:
if:Utásklet'
.~1.1
le
Nézzük meg, hogyan is használhatjuk ezeket a függvényeket. Feladat Írjunk kernelmodult, amely számolja a beállított megszakítást. A számláló értékét egy kisfeladat másolja le, hogy aztán a másolatot elérjük egy proc állományon keresztül. /* helloi rq.c - Egyszeru megszaki tas kezel es kisfeladat haszna] ataval . */ #include #include #include #include #include unsigned long counter1=0; unsigned long counter2=0; unsigned int cirq=1; static unsigned int devid=33;
415
7. fejezet: Fejlesztés a Linux-kernelben
static DEFINE_SRINLOCK(hello_lock); void hello_tasklet_proc(unsigned long); DECLARE_TASKLET(hello_tasklet, hello_tasklet_proc, 0); /* megszakitas kezelo fuggveny. */ irgreturn_t counter_irq(int irq, void *dev_id) { counterl++; tasklet_schedule(&hello_tasklet); return IRQ_NONE; } /* Tasklet kezelo fuggveny. */ void hello_tasklet_proc(unsigned long nothing) { unsigned long flags; spin_lock_irqsave(&hello_lock, flags); counter2=counterl; spin_unlock_irgrestore(&hello_lock, flags); } /* Az allomany olvasasat lekezelo fuggveny. */ int procfile_read(char* buffer, char** buffer_location, off_t offset, int buffer_length, int *eof, void *procdata) { int len; spin_lock(&hello_lock); len=sprintf(buffer, "Szamlalo: %lu\n", counter2); spin_unlock(&hello_lock); return len;
/* A kernel modul inicializalasat vegzo fuggveny. */ static int _init hello_init(void) { struct proc_dir_entry *hello_proc_ent; /* Regisztraljuk a megszakitas kezelot. */ if(request_irq(cirq, counter_irq, IRQF_SHARED, "Hello szamlalo", &devid)) { printk(KERN_WARNING "A %d megszakitas foglalt.\n", cirq); cirq=-1; } /* Dinamikusan hozzuk letre a proc allomany bejegyzest. */ hello_proc_ent = create_proc_entry("hello", S_IFREG I S_IRUGO, 0);
416
7.15. Megszakításkezelés
/* Beallítjuk a link szamlalot es a lekezelo fuggvenyt if(hello_proc_ent) { hello_proc_ent->nlink = 1; hello_proc_ent->read_proc = procfile_read;
*
return 0; } /* A kernel modul eltavolitasa elott a felszabaditasokat vegzi.
static void _exit hello_exit(void) { /* Megszuntetjuk a megszakitas kezelo regisztraciojat. if(cirg>=0) free_irg(cirg, &devid);
*/
/* Megsemmisitjuk a proc allomany bejegyzest. */ remove_proc_entry("hello",0); } module_init(hello_init); module_exit(hello_exit); m000LE_DEscRIPTI0N("Bello proc"); MODULE_LICENSE( "GPL ");
A példában az 1-es megszakításra regisztrálunk, és megosztottan használjuk az eredeti kezelőfüggvénnyel. Ez a megszakításvonal általában az időzítő, amelyre rendszeresen érkeznek megszakítások. Mivel a mi megszakításkezelő függvényünk csak egy „potya"-függvény, és nem akarjuk megzavarni a rendszer működését, ezért visszatérési értékként IRQ_NONE értéket használunk. Az időzítő megszakítása helyett választhatunk azonban más megszakításvonalat is, amelyre vagy nincs megszakításkezelő regisztrálva, vagy megosztottan használatos.
7.15.5.2. Munkasor A munkasor (work queue) olyan kódrészlet, amelyet kernelszálak futtatnak a háttérben. A munkasor aktiválása a 7.10. I/O műveletek blokkolása alfejezetben tárgyalt várakozási sorokon keresztül történik: a munkasor aktiválását ugyanúgy eseményként kezeli a futtató szál, amelyre várakozik. Az esemény ekkor a futtatás kérelmezése. A munkasor tágabb lehetőségeket nyújt, mint a kisfeladat: mivel a függvény ebben az esetben egy külön kernelszálban hívódik meg, ezért egyrészt használhatunk sleep() es függvényeket is, másrészt az implementáció hosszára nincs megkötés, mivel közben lehetséges a taszkváltás. Ugyanakkor továbbra sem érhetjük el más processzek címterét, ám ez nem szokott komoly problémát jelenteni. -
417
7. fejezet: Fejlesztés a Linux-kernelben
Használatuk alapkoncepciói nagyon hasonlítanak a kisfeladatokéhoz: regisztrálunk egy végrehajtó rutint, kérjük annak lefuttatását, ez majd valamikor egy későbbi időpontban lefut, és amikor nincs már szükségünk rá, fel kell szabadítanunk. Az adatstruktúrák és a működés természetesen más, mint a kisfeladatok esetében. A munkasor használatához létre kell hoznunk egy kezelőfüggvényt, amelyet a mechanizmus meghív. Ennek alakja a következő: void (*work_func_t)(struct work_struct *);
Továbbá létre kell hoznunk magát a munkasort: struct workqueue_struct * create_workqueue(const char *name);
Ugyanakkor a kernelmodul eltávolításakor nem szabad elfelejtenünk a munkasor megsemmisítését: void destroy_workqueue(struct workqueue_struct *wq);
A létrehozást követően a megszakításkezelő rutinban a feldolgozófüggvényünkből egy work_struct típusú elemet kell létrehoznunk. Az első alkalommal ezt az alábbi makróval tehetjük meg: INIT_WORK(struct work_struct *work, void (*function)(void *));
A későbbi meghívások során a módosításhoz elegendő a következő makrót használnunk: PREPARE_WORK(struct work_struct *work, void (*function)(void *));
Még szintén a megszakításkezelő rutinban el kell helyeznünk a work_struct elemmel reprezentált feladatot a munkasorba: int queue_work(struct workqueue_struct *wq, struct work_struct *work);
Feladat Valósítsuk meg az előző fejezetben látott példát kisfeladat helyett munkasorral. /* hel 1 oi rq2 . c - Egyszeru pel da a munkasor haszna] atara. */ #include #include #include #include #include #include
418
7.15. Megszakításkezelés
unsigned long counter1=0; unsigned long counter2=0; unsigned int cirq=1; static unsigned int devid=33; static bEFINE_SPINL0cK(hello_lock); static struct workqueue_struct* hello_wq; void hello_wq_proc(struct work_struct* work); /* Megszakitas kezelo fuggveny. */ ircireturn_t counter_irq(int irq, void *dev_id) { static int inited = 0; static struct work_struct task; counterl++; /* A workqueue taszk elokeszitese. if(inited == 0) { INIT_woRk(&task, hello_wq_proc); inited = 1; //else //{ // PREPARE_WORK(&task, hello_wq_proc); //} /* A taszk behelyezese a workqueue-ba. */ queue_work(hello_wq, &task); return IRQ_NONE; }
/* A feladatot vegrehajto fuggveny. */ void hello_wq_proc(struct work_struct* work) {
unsigned long flags; spin_lock_irqsave(&hello_lock, flags); counter2=counterl; spin_unlock_irgrestore(&hello_lock, flags);
/* Az allomany olvasasat lekezelo fuggveny. */
int procfile_read(char* buffer, char** buffer_location, off_t offset, int buffer_length, int *eof, void *procdata) { int len; spin_lock(&hello_lock); len=sprintf(buffer, "counter: %lu\n", counter2); spin_unlock(&hello_lock); return len; }
419
7. fejezet: Fejlesztés a Linux-kernelben /* A kernel modul inicializalasat vegzo fuggveny. */ static int init hello_init(void) {
struct proc_dir_entry *hello_proc_ent; /* Letrehozzuk a workqueue-t. */ hello_wq = create_workqueue("helloworkqueue"); /* Regisztraljuk a megszakitas kezelot. */ if(request_irq(cirq, counter_irq, IRQF_SHARED, "Hello counter", &devid)) {
printk(KERN_WARNING "A %d megszakitas foglalt.\n", cirq); cirq=-1;
/* Dinamikusan hozzuk letre a proc allomany bejegyzest. */ S_IRUGO, hello_proc_ent = create_proc_entry("hello", S_IFREG 0); /* Beallitjuk a link szamlalot es a lekezelo fuggvenyt. */ if(hello_proc_ent) hello_proc_ent->nlink = 1; hello_proc_ent->read_proc = procfile_read; return 0;
/* A kernel modul eltavolitasa elott a felszabaditasokat vegzi. */ exit hello_exit(void) static void /* Megszuntetjuk a megszakitas kezelo regisztraciojat. */ if(cirq>=0) free_irq(cirq, &devid); /* Megsemmisitjuk a workqueue-t. */ destroy_workqueue(hello_wq); /* Megsemmisitjuk a proc allomany bejegyzest. */ remove_proc_entry("hello",0);
module_init(hello_init); module_exit(hello_exit); MODULE_DESCRIPTION("Hello proc"); MODULE_LICENSE("GPL");
A példa működésében megegyezik a kisfeladat témakörénél látottakkal, azzal az eltéréssel, hogy itt munkasort használtunk a feladat végrehajtásakor.
420
7.16. A kernelszálak
7.16. A kernelszálak A megszakításkezelés alsó részeként kernelszálakat is használhatunk. Ez hasonlít a munkasor esetére, ám több lehetőségünk nyílik, mert mi magunk végezzük a szálkezelést. Például a megszakítás hatására akár egy felhasználói módban futó programot is elindíthatunk, vagy kommunikálhatunk vele. (Lásd a call_usermodehelper04.) Természetesen a megszakításokon kívül más esetekben is alkalmazhatjuk a kernelszálakat, tipikusan akkor, amikor egy hosszú adatfeldolgozás megvalósítására van szükségünk. A kernelszál (kernek thread) lényegében egy olyan folyamat, amelynek nincs virtuális címterülete a felhasználói címtartományban. Ehelyette a szálak a kernel címtartományán osztoznak. Erre a memóriakezelésnél oda kell figyelnünk. A kernelszál létrehozásához először is meg kell írnunk a szálfüggvényt, amely a szál belépési pontjaként szolgál, hasonlóan az alkalmazásszintű szálkezeléshez. A szálfüggvény alakja az alábbi: int thr
dfh(void *data);
Ezt követően egy alvó állapotú szálat az alábbi függvénnyel hozhatunk létre:
A threadfn a szál fő függvénye. A data a függvénynek átadott paraméter. A namefmt, ... rész a szál neve printf-es alakban argumentumokkal — vagyis egy formázó szöveg, majd egy nem meghatározott számú paraméter. Visszatérési értékként megkapjuk a szálat leíró struktúrára mutató pointert. Ezzel hivatkozhatunk később a szálra. Mivel jelenleg még alvó állapotban van a szálunk, fel kell ébresztenünk: int wake_up prcrcess (strúct - t .
k
t
'sk);
A létrehozás és az ébresztés műveletét egyben is elvégezhetjük: struct task_struct *kthread_run(int (*threadfn)(void *data), void *data, const char namefmt[], ...);
A függvény paraméterezése megegyezik a szál létrehozásánál látottakkal. Ezeknek a függvényeknek a meghívása után létrejött a kernelszálunk, és egymás után hajtja végre a szálkezelő függvényben foglalt utasításokat. Amikor a függvénynek vége, akkor a szál is leáll. Fontos, hogy a szálat kívülről nem állíthatjuk le. Ennek az az oka, hogy egy ilyen véletlenszerű időpontban 421
7. fejezet: Fejlesztés a Linux-kernelben
elkövetett megszakítás nem definiált állapotba hozhatná a rendszert. Helyette a kezelőfüggvényünkben kell rendszeresen ellenőrizni, hogy szálunkat más szálak le akarják-e állítani. Ezt a következő függvénnyel tehetjük meg: int kthread_should_stop(void);
A 0-tól eltérő érték jelenti, hogy le kellene állítanunk a szálunkat, ezért a függvény közvetlenül használható az if paramétereként. Ha ezt implementáltuk, akkor a szál leállítását egy másik szálból az alábbi függvénnyel kérhetjük: int kthread_stop(struct task_struct *k);
Figyeljünk arra, hogy ezt a függvényt már leállt, nem létező szálakra ne hívjuk meg, mert hibát eredményez.'" Feladat Írjunk egy kernelmodult, amely elindít egy szálat. A szál másodpercenként növeljen egy számlálót, amelynek értékét ki is írja. Emellett figyelnünk kell arra, hogy a modul eltávolítása előtt le kell állítanunk a szálat. /* hellothread.c - Egyszeru szalkezeles.
*7
#include