Przed rozpoczęciem rozwiązywania zadań warto przeczytać o wstępie do Systemów Operacyjnych i rozdziały 7.8, 7.9, 8.1, 9.7 z CS:APP.
Opisz różnice między przerwaniem sprzętowym (ang. hardware interrupt), wyjątkiem procesora (ang. exception) i pułapką (ang. trap). Dla każdego z nich podaj co najmniej trzy przykłady zdarzeń, które je wyzwalają. W jakim scenariuszu wyjątek procesora nie oznacza błędu czasu wykonania programu? Kiedy pułapka jest generowana w wyniku prawidłowej pracy programu?
Wyjątek procesora to klasa, która dzieli się na cztery rodzaje. Są nimi:
fork
, execve
, exit
. Korzysta z nich też każdy debugger, np. dla breakpointów.Różnica między zwykłą funkcją a syscall
jest taka, że zwykła funkcja wykonywana jest w user mode, a syscall
w kernel mode. Wyjątki asynchroniczne (a więc przerwania sprzętowe) nie powodują przerwania działania programu.
Opisz mechanizm obsługi przerwań bazujący na wektorze przerwań (ang. interrupt vector table). Co robi procesor przed pobraniem pierwszej instrukcji procedury obsługi przerwania (ang. interrupt handler) i po natrafieniu na instrukcję powrotu z przerwania? Czemu procedura obsługi przerwania powinna być wykonywana w trybie jądra (ang. kernel mode) i używać stosu odrębnego od stosu użytkownika?
Aby obsłużyć przerwanie, kontroler umieszcza numer na linii adresowej, okręslając które urządzenie wymaga uwagi i ustawia sygnał mający na celu przerwanie pracy procesora.
Sygnał przerwania powoduje, że procesor zatrzymuje operację, którą wykonywał, i zaczyna robić coś innego. Numer na liniach adresowych jest wykorzystywany jako indeks do tabeli znanej jako wektor przerwań i służy do pobrania nowej wartości licznika programu (PC). Zwykle przerwania są numerowane od 0 do 255 (używamy więc 1 bajta). Ta wartość PC wskazuje na początek odpowiedniej procedury obsługi przerwania (zwykle jump
, aby zaoszczędzić liczbę wykonywanych instrukcji). Zazwyczaj od tego momentu rozkazy pułapek i przerwań korzystają z tego samego mechanizmu, a często współdzielą ten sam wektor przerwań. Wektor ten może być zaszyty "na sztywno" w sprzęcie lub może znajdować się w dowolnym miejscu pamięci, a rejest procesora wskazuje na jego początek.
Procedura obsługi przerwania to kod mający na celu obsługę konkretnego sygnału żądania przerwania, najczęściej określa je system operacyjny lub BIOS.
Przed uruchomieniem procedury obsługi przerwania sprzęt zawsze zapisuje pewne informacje, ich rodzaj i miejsce zapisu różnią się w zależności od procesora. Całkowitym minimum jest zapisanie PC, dzięki czemu można wznowić przerwany proces. W drugim skrajnym podejściu zapisywane są wszystkie widoczne rejestry, a także wiele rejestrów zewnętrznych.
Wyjątek jest bardzo zbliżony do zwykłej procedury, działa on jednak w trybie jądra, aby mieć dostęp do wszystkich zasobów systemowych. Bez tego np. page fault nie działa, ponieważ wpisuje on na dysk fizyczny, a user mode tego nie może zrobić.
Jeżeli do zapisu wykorzystywany jest stos, może narodzić się problem jakiego stosu użyć. Jeżeli zostanie wykorzystany bieżący stos, może być to stos należący do procesu użytkownika, a wartość wskaźnika stosu może być nieprawidłowa, co doprowadzi do błędu krytycznego, gdy sprzęt podejmie próbę zapisania pewnych słów pod wskazanym adresem. Dlatego korzysta się więc ze stosu jądra, gdyż istnieje wtedy większa szansa na to, że wskaźnik stosu będzie miał prawidłową wartość i będzie wskazywał na stronę dostępną w pamięci. Przełączenie do trybu jądra może jednak wymagać zmiany kontekstu MMU i prawdopodobnie spowoduje utratę ważności dużej części lub całości pamięci podręcznej oraz buforów TLB. Ponowne załadowanie tych danych zwiększa czas obsługi przerwania, przez co przyczyni się do marnotrastwa czasu procesora.
Kiedyś, np. w procesorach Intel 8080 nie używano stosu, a dwa rejestry: jeden systemowy, a drugi do obsługi przerwań.
Tryb jądra (ang. kernel mode) zezwala na zrobienie wszystkiego w systemie, daje dostęp do hardware'u, natomiast tryb użytkownika (ang. user mode) na to nie pozwala, nie możemy np. czytać z pamięci czy do niej zapisywać, uruchomiony kod musi korzystać z API systemu, aby wykonać wspomniane zadania.
Bazując na formacie ELF (ang. Executable and Linkable Format) opisz składowe piku wykonywalnego. Czym różni się sekcja od segmentu? Co opisują nagłówki programu? Skąd system operacyjny wie, pod jakim adresem ma umieścić segmenty programu i gdzie położona jest pierwsza instrukcja programu?
main.o
z pliku main.c
bez linkowania:$ gcc -c main.c
$ readelf -t -s [nazwa].o
Plik relokowalny (ang. relocatable object file) w formacie ELF wygląda w taki sposób:
Zwykle plik ELF składa się z następujących sekcji:
Sekcja | Przeznaczenie |
---|---|
.text |
Kod maszynowy skompilowanego programu. |
.rodata |
Dane tylko do odczytu, np. sekwencje znaków niezmienialnych string używanych w funkcji printf oraz tabele skoków dla instrukcji switch . |
.data |
Zainicjalizowane zmienne globalne oraz static . Lokalne zmienne są trzymane na stosie w trakcie uruchomienia programu (run time) i nie pojawiają się ani w sekcji .data , ani w .bss . |
.bss |
Niezainicjalizowane zmienne globalne oraz static , włącznie ze zmiennymi zainicjalizowanymi na 0, ta sekcja nie zajmuje miejsca w pliku relokowalnym, jest jedynie placeholderem. |
.symtab |
Tabela symboli z informacją o funkcjach i zmiennych globalnych, które są zdefiniowane i do których odwołuje się w programie. |
.rel.text |
Lista lokalizacji z sekcji .text , która zostanie zmodyfikowana w trakcie linkowania tego pliku relokowalnego z innymi. W ogólności instrukcje, które wywołują "zewnętrzne" (ang. external) funkcje lub odwołują się do zmiennych globalnych, będą musiały być zmodyfikowane, jednak funkcje wywoływane lokalnie nie muszą być modyfikowane. |
.rel.data |
Informacje o relokacji dla zmiennych globalnych, do których występują odwołania lub są zdefiniowane w tym module. W ogólności są to zainicjalizowane zmienne globalne, których wartością początkową jest adres oraz funkcje zdefiniowane "zewnętrznie". |
.debug |
Lista symboli do debugowania, zawiera lokalne zmienne i definicje typów (typedef ), zmienne globalne zdefiniowane przez program i te, do których się odwołuje, jak i oryginalny kod źródłowy w języku C. Aby sekcja ta powstała, należy skompilować program z flagą -g . |
.line |
Mapowanie pomiędzy liniami z pliku źródłowego a instrukcjami kodu maszynowego z sekcji .text , podobnie pojawia się po użyciu flagi -g . |
.strtab |
Tablica string ów dla sekcji tabeli symboli .symtab oraz .debug , jak i nazw sekcji w nagłówkach. |
Sekcje zawierają informacje potrzebne w trakcie linkowania, a segmenty informacje wykorzystywane w trakcie runtime. Często sobie odpowiadają w pamięci wirtualnej procesu.
System operacyjny wie pod jakim adresem ma umieścić segmenty programu dzięki segment header table. Adres pierwszej instrukcji znajduje się w headerze ELF.
Przykładowy wydruk polecenia readelf -t -s swap.o
z jakiegoś zadania z ASKa:
There are 9 section headers, starting at offset 0x1f8:
Section Headers:
[Nr] Name
Type Address Offset Link
Size EntSize Info Align
Flags
[ 0]
NULL NULL 0000000000000000 0000000000000000 0
0000000000000000 0000000000000000 0 0
[0000000000000000]:
[ 1] .text
PROGBITS PROGBITS 0000000000000000 0000000000000040 0
000000000000001e 0000000000000000 0 1
[0000000000000006]: ALLOC, EXEC
[ 2] .rela.text
RELA RELA 0000000000000000 0000000000000148 6
0000000000000060 0000000000000018 1 8
[0000000000000040]: INFO LINK
[ 3] .data
PROGBITS PROGBITS 0000000000000000 0000000000000060 0
0000000000000008 0000000000000000 0 8
[0000000000000003]: WRITE, ALLOC
[ 4] .rela.data
RELA RELA 0000000000000000 00000000000001a8 6
0000000000000018 0000000000000018 3 8
[0000000000000040]: INFO LINK
[ 5] .bss
NOBITS NOBITS 0000000000000000 0000000000000068 0
0000000000000004 0000000000000000 0 4
[0000000000000003]: WRITE, ALLOC
[ 6] .symtab
SYMTAB SYMTAB 0000000000000000 0000000000000068 7
00000000000000c0 0000000000000018 5 8
[0000000000000000]:
[ 7] .strtab
STRTAB STRTAB 0000000000000000 0000000000000128 0
000000000000001b 0000000000000000 0 1
[0000000000000000]:
[ 8] .shstrtab
STRTAB STRTAB 0000000000000000 00000000000001c0 0
0000000000000036 0000000000000000 0 1
[0000000000000000]:
Zapoznaj się z rozdziałami 3.4 i A.2 dokumentu System V Application Binanry Interface AMD64 Architecture Processor Supplement i odpowiedz na następujące pytania:
_start
? Do czego służy auxiliary vector? Można go wyświetlić wydając przykładowe polecenie LD_SHOW_AUXV=1 /bin/true
.Disclaimer: Warto pamiętać o tym że ABI (Application Binary Interface) i API (Application Programming Interface) to dwie zupełnie różne rzeczy, w ABI są rzeczy takie jak obsługa ramki stosu i inne niskopoziomowe sprawy, a w API są bardziej wysokopoziomowe wywołania, takie jak syscall
e do porozumiewania się z innymi programami. ABI x32 pozwala na pracowanie na architekturze 64-bitowej na adresach 32-bitowych, dzięki temu zwiększamy szybkość działania o około 30%.
Przestrzeń adresowa procesu jest przygotowywana w następny sposób: procesor jest resetowany do "czystego" stanu, tzn. wszystkie flagi (CF, ZF, IF, itp.) są wyzerowane.
Auxiliary vector w trakcie uruchamiania programu otrzymuje od systemu operacyjnego informacje o środowisku, w którym ten program będzie działać.
Procedura _start
jest entry pointem programu, tzn. adres tego symbolu jest adresem, do którego wykonywany jest skok na początku wykonania programu. Zwykle jest ona zawarta w pliku crt0.o
, który zawiera kod startowy dla środowiska C runtime.
syscall
to procedura jądra, działa ona podobnie do standardowych funkcji, jednak zamiast jump
, używa procedur dla jądra. Jeżeli wynik w %rax
będzie w przedziale <-4095, -1>
, to syscall
zwróci błąd wywołania systemowego.
Przypomnij jak wygląda mechanizm tłumaczenia adresów bazujący na wielopoziomowej tablicy stron procesorów z rodziny x86-64. Przedstaw algorytm obliczania adresu fizycznego na podstawie adresu wirtualnego z uwzględnieniem uprawnień dostępu. Jaką rolę w procesie tłumaczenia odgrywa pamięć TLB?
Tłumaczenie adresów to proces znajdowania adresu fizycznego z adresu wirtualnego. Architektura x86-64 używa czteropoziomowej tablicy stron:
63-48 |
47-39 |
38-29 |
29-21 |
20-12 |
11-0 |
---|---|---|---|---|---|
level 4 | level 3 | level 2 | level 1 | offset |
16 najbardziej znaczących bitów adresu jest nieużywane.
Algorytm translacji dla pojedynczej tabeli stron wygląda następująco:
A dla stron procesów z rodziny x86-64: używamy rejestru %cr3
przechowującego adres fizyczny czwartego poziomu tabeli stron (aby ostatecznie dostać się do adresu, który chcemy). Algorytm ten przebiega w następujący sposób:
%cr3
i indeksu z L4 z wirtualnego adresu aby dotrzeć do poziomu L3 tabeli stron.Bufor TLB (ang. translation lookaside buffer) (czasami pamięć asocjacyjna) to urządzenie sprzętowe służące do odwzorowywania adresów wirtualnych na fizyczne bez konieczności sięgania do tabeli stron. Zwykle jest ono zlokalizowane wewnątrz jednostki MMU i składa się z niewielkiej liczby pozycji - każda pozycja zawiera informacje dotyczące jednej strony.
Uruchom program 1_ls
pod kontrolą narzędzia ltrace -S
. Na podstawie śladu wykonania programu zidentyfikuj, które z wywołań systemowych są używane przez procedury: opendir
, readdir
, printf
i closedir
. Do czego służy wywołanie systemowe brk
? Używając debuggera gdb
i polecenia catch syscall brk
zidentyfikuj, która funkcja używa brk
.
Wywołanie systemowe to interfejs pomiędzy wykonywanym programem, a jądrem systemu operacyjnego. Funkcje systemowe wywoływane są przez specjalny mechanizm, wspierany przez dany procesor. Są to między innymi dostęp do systemu plików, komunikacja międzyprocesowa, uruchamianie innych programów, sterowanie urządzeniami systemowymi, obsługiwanie komunikacji sieciowej.
Najpierw robię make
w folderze, a później wywołuję poniższe polecenie:
$ ltrace -S ./1_ls libapue/
Na wyjściu otrzymuję mniej więcej taki wydruk (wycięte niepotrzebne linijki):
opendir("libapue/" <unfinished ...>
SYS_openat(0xffffff9c, 0x7fff476df52f, 0x90800, 0) = 3
readdir(0x55ae2a86a260 <unfinished ...>
SYS_getdents64(3, 0x55ae2a86a290, 0x8000, -2630) = 144
SYS_write(1, "libapue.a\n", 10libapue.a
) = 10
<... puts resumed> ) = 10
readdir(0x55ae2a86a260) = 0x55ae2a86a2b0
puts("error.o" <unfinished ...>
SYS_write(1, "error.o\n", 8error.o
) = 8
<... puts resumed> ) = 8
readdir(0x55ae2a86a260) = 0x55ae2a86a2d0
puts(".." <unfinished ...>
SYS_write(1, "..\n", 3..
) = 3
<... puts resumed> ) = 3
readdir(0x55ae2a86a260) = 0x55ae2a86a2e8
puts("error.c" <unfinished ...>
SYS_write(1, "error.c\n", 8error.c
) = 8
<... puts resumed> ) = 8
readdir(0x55ae2a86a260) = 0x55ae2a86a308
puts("." <unfinished ...>
SYS_write(1, ".\n", 2.
) = 2
closedir(0x55ae2a86a260 <unfinished ...>
SYS_close(3) = 0
Wywnioskować więc można, że przez wywołanie funkcji:
opendir
jest wywoływane SYS_openat
readdir
jest wywoływane SYS_getdents64
printf
jest wywoływane SYS_write
closedir
jest wywoływane SYS_close
Wywołanie systemowe SYS_brk
powoduje zmianę wielkości sterty programu, dzięki temu zmieniana jest lokalizacja przerwania programu (koniec segmentu danych procesu). Zwiększenie wartości brk
powoduje przydzielenie pamięci do procesu, a jej zmniejszenie zwalnia tę pamięć. Funkcje takie jak malloc
, realloc
, free
niejawnie zmieniają wartość brk
.
Po włączeniu 1_ls
w gdb
i ustawieniu catch syscall brk
otrzymamy:
Catchpoint 1 (call to syscall brk), __brk (addr=addr@entry=0x0)
at ../sysdeps/unix/sysv/linux/x86_64/brk.c:31
31 ../sysdeps/unix/sysv/linux/x86_64/brk.c: No such file or directory.
Pod kontrolą narzędzia strace
uruchom program 2_cat
korzystający bezpośrednio z wywołań systemowych do interakcji ze standardowym wejściem i wyjściem. Pokaż, że program oczekuje naodczyt na deskryptorze pliku 0 i pisze do pliku o deskryptorze 1. Naciśnij kombinację klawiszy CTRL+D
kończąc wejściowy strumień danych – co zwróciło read
? Zmodyfikuj program tak, by czytał z plikupodanego w linii poleceń. Co się stanie, jeśli przekażesz ścieżkę do katalogu zamiast do pliku regularnego?
Przed rozpoczęciem zadań warto przeczytać te PDFy o procesach, API procesów oraz o przestrzeniach adresowych, jak i rozdziały 2.1, 10.3 i 11.4 z Modern Operating Systems.
W systemach uniksowych wszystkie procesy są związane relacją rodzic-dziecko. Uruchom polecenie ps -eo user,pid,ppid,pgid,tid,pri,stat,wchan,cmd
. Na wydruku zidentyfikuj identyfikator procesu, identyfikator grupy procesów, identyfikator rodzica oraz właściciela procesu. Kto jest rodzicem procesu init? Wskaż, które z wyświetlonych zadań są wątkami jądra. Jakie jest znaczenie poszczególnych znaków w kolumnie STAT? Wyświetl drzewiastą reprezentację hierarchii procesów poleceniem pstree
– które z zadań są wątkami?
Każdy proces posiada swojego rodzica (oprócz init
, którego rodzicem jest kernel ppid 0
), który utworzył dziecko przy pomocy fork()
. Każdy proces posiada również swój identyfikator procesu (PID), a więc unikalną liczbę, dzięki której łatwiej jest go zidentyfikować. Identyfikatorem grupy procesów (PGID) oznacza się zbiór jednego lub większej ilości procesów, które mogą razem otrzymywać sygnały. Identyfikator rodzica (PPID) to identyfikator procesu, który za pomocą fork()
utworzył procesy dzieci. Właściciel to użytkownik, który uruchomił proces i posiada pełne uprawnienia do kontrolowania ich, np. zatrzymywania lub zabijania. Wątki jądra to wątki wykonujące kod jądra, nie są one powiązane z procesami z przestrzeni użytkownika, nie używają również pamięci użytkownika (gdy dodamy vsz
do tabeli polecenia ps -eo ...
zobaczymy 0
(KiB) dla procesów root
). W kolumnie STAT oznacza je litera I
. Wywołanie ps
bez argumentów sprawi, że wypisana zostanie lista procesów w bieżącej sesji.
Aby dowiedzieć się jakie znaczenie mają poszczególne znaki w kolumnie STAT, używamy man ps
i szukamy tego, co nas interesuje:
PROCESS STATE CODES
Here are the different values that the s, stat and state output specifiers
(header "STAT" or "S") will display to describe the state of a process:
D uninterruptible sleep (usually IO)
I Idle kernel thread
R running or runnable (on run queue)
S interruptible sleep (waiting for an event to complete)
T stopped by job control signal
t stopped by debugger during the tracing
W paging (not valid since the 2.6.xx kernel)
X dead (should never be seen)
Z defunct ("zombie") process, terminated but not reaped by its parent
For BSD formats and when the stat keyword is used,
additional characters may be displayed:
< high-priority (not nice to other users)
N low-priority (nice to other users)
L has pages locked into memory (for real-time and custom IO)
s is a session leader
l is multi-threaded (using CLONE_THREAD, like NPTL pthreads do)
+ is in the foreground process group
Wątki jądra można rozpoznąc po vsz
mającym wartość 0 (kolejna kolumna w ps
wspomniana wcześniej) oraz po tym, że cmd
jest w nawiasach kwadratowych, tzn. nie jest znana nazwa polecenia, które je utworzyło. Wątki w pstree
można rozpoznać po sposobie zapisu ich nazw: n*[{name}]
to grupa n wątków.
Do czego służy system plików proc(5) w systemie Linux? Dla wybranego przez siebie procesu o identyfikatorze pid
wydrukuj zawartość katalogu /proc/pid
. Wyświetl plik zawierający argumenty programu oraz zmienne środowiskowe. Podaj znaczenie następujących pól pliku status
: Uid
, Gid
, Groups
, VmPeak
, VmSize
, VmRSS
, Threads
, voluntary_ctxt_switches
, nonvoluntary_ctxt_switches
.
System plików proc(5)
to pseudosystem plików, który udostępnia interfejs do struktur danych jądra. Zazwyczaj jest montowany w katalogu /proc
i jest montowany tam automatycznie przez system, jednak może być również zamontowany manualnie komendą:
mount -t proc proc /proc
Jest on w większości przeznaczony tylko do odczytu, lecz niektóre pliki umożliwiają modyfikacje zmiennych jądra. Poniższe katalogi zawierają następujące dane:
/proc/[pid]
zawiera informacje związane z procesami o danym pid/proc/[tid]
zawiera informacje związane z wątkami o danym tid/proc/self
to link symboliczny przekierowujący do własnego /proc/[pid]
procesu/proc/thread-self
to link symboliczny przekierowujący do własnego /proc/self/task/[tid]
wątkuArgumenty programu to parametry przekazane do programu w momencie wywołania tego programu. Są to argc
(liczba argumentów włącznie z nazwą polecenia) oraz argv[]
(argumenty wraz z nazwą polecenia). Możemy je znaleźć w pliku /proc/[pid]/cmdline
.
Zmienne środowiskowe to zmienne, których wartości są ustawiane poza programem: nazwane wartości, przechowywane i zarządzane przez powłokę systemową. Każdy proces posiada swój zestaw zmiennych środowiskowych, które dziedziczy po rodzicu. Znajdują się one w /proc/[pid]/environ
.
W pliku /proc/[pid]/status
znajduje się większość informacji z /proc/[pid]/stat
oraz /proc/[pid]/statm
, jednak są w formacie bardziej przystępnym dla ludzi. Ich wybrane pola to:
Uid
oraz Gid
przechowują identyfikator użytkownika/grupy rzeczywisty (właściciel procesu), efektywny (zwykle to co real, ale czasem jest to zmieniane, żeby nieuprzywilejowany użytkownik mógł uzyskać dostęp do plików, które mogą zostać otworzone tylko przez root), zapisany (używany gdy proces działa z podwyższonymi uprawnieniami, np. root i musi wykonać pracę na mniejszych uprawnieniach, dzięki czemu może zmienić chwilowo usera) oraz system plików.Groups
to lista grup uzupełniających dziedziczonych po rodzicu, mówią one jakie uprawnienia ma procesVmPeak
to największy rozmiar pamięci wirtualnejVmSize
to bieżący rozmiar pamięci wirtualnejVmRSS
(virtual memory resident set size), czyli rozmiar zbioru roboczego procesu (pamięci wirtualnej używanej przez proces)Threads
to liczba wątków w procesie zawierającym ten wątekvoluntary_ctxt_switches
to liczba dobrowolnych zmian kontekstu (np. proces opuszcza CPU, bo nie ma nic do roboty i np. oczekuje na IO)nonvoluntary_ctxt_switches
to liczba zmian kontekstu wywołanych przez wywłaszczenie procesu.Znajdź pid
procesu X-serwera, a następnie używając polecenia pmap
wyświetl zawartość jego przestrzeni adresowej. Zidentyfikuj w niej poszczególne zasoby pamięciowe – tj. stos, stertę, segmenty programu, pamięć anonimową, pliki odwzorowane w pamięć. Należy wyjaśnić znaczenie kolumn wydruku!
Aby znaleźć pid, używamy ps ax | grep X
- flagi ax
odpowiadają kolejno za szukanie w procesach innych użytkowników oraz pośród procesów działających poza terminalem. Następnie potokiem przekazujemy stdout
do grep
, w którym szukamy X-serwer. Na wyjściu uzyskałem:
1158 tty1 Sl+ 0:00 /usr/lib/xorg/Xorg vt1 -displayfd 3 -auth /run/user/118/gdm/Xauthority -background none -noreset -keeptty -verbose 3
1957 tty2 Rl+ 20:02 /usr/lib/xorg/Xorg vt2 -displayfd 3 -auth /run/user/1000/gdm/Xauthority -background none -noreset -keeptty -verbose 3
Za pomocą sudo pmap 1158
lub sudo pmap 1957
uzyskujemy zawartość przestrzeni adresowej procesu o danym pid. Tutaj jest przykładowy wydruk tego polecenia.
Stos (stack) jest na samym dole, możemy jednak wywołać sudo less /proc/1957/maps
, aby uzyskać dokładny przedział adresów przeznaczony na stos (podobnie działa flaga -X
, jednak dostajemy tylko początek stosu):
7ffd05252000-7ffd05273000 rw-p 00000000 00:00 0 [stack]
Sterta (heap) podobnie nie jest pokazywana w pmap
, lecz można ją znaleźć w /proc/[pid]/maps
(najlepiej w less
wyszukać pattern ?heap
):
55e2b66fd000-55e2b8a1a000 rw-p 00000000 00:00 0 [heap]
Segmenty programu to spójne bloki pamięci o jednolitym przeznaczeniu i atrybutach z punktu widzenia procesu ładowania i uruchamiania programu. Jeden segment może zawierać wiele sekcji (linker odpowiada za sekcje, loader za segmenty). Segment kodu to obszar pamięci zawierający kod maszynowy programu. Segmenty programu są zmapowane do Xorg
:
Address Kbytes RSS Dirty Mode Mapping
000055e2b56a4000 244 168 0 r---- Xorg
000055e2b56e1000 1588 1576 0 r-x-- Xorg
000055e2b586e000 484 192 0 r---- Xorg
000055e2b58e7000 12 12 12 r---- Xorg
000055e2b58ea000 48 48 48 rw--- Xorg
Na podstawie Mode
możemy wyznaczyć segment text (r-x--
czyli readable and executable). Pola oznaczone r----
to prawdopodobnie sekcje .rodata
, a rw---
to sterta.
Pamięć anonimowa to pamięć niebędąca powiązana z żadnym plikiem ani obiektem. Powszechnym anonimowym mapowaniem jest stos i sterta wspomniane powyżej. Jest ona oznaczona jako [anon]
.
Pliki odwzorowane w pamięć (ang. memory-mapped file) to segment pamięci wirtualnej mający bezpośrednie mapowanie co do bajta z jakimś plikiem lub zasobem. Dzięki temu unika się stosowania funkcji systemowych na plikach takich jak read()
lub write()
, gdyż proces adresuje plik bezpośrednio. Plik może być traktowany jak ciągły obszar w pamięci procesu, dostępny poprzez bezpośrednie operacje pobrania i podstawienia wartości. Wszystkie modyfikacje dokonane w pamięci są później zapisywane na dysku. Znaleźć je można w Mapping
jako pliki *.so
.
Poza tymi sekcjami znajdują się również te:
7ffd053ae000-7ffd053b1000 r--p 00000000 00:00 0 [vvar]
7ffd053b1000-7ffd053b3000 r-xp 00000000 00:00 0 [vdso]
vvar
zawiera zmienne systemowe takie jak gettimeofday
, dzięki czemu syscall
działa znacznie szybciej. vdso
to biblioteka współdzielona udostępniana przez jądro żeby przyspieszyć wykonywanie pewnym syscalli, które niekoniecznie muszą działać w przestrzeni jądra.
Poszczególne kolumny wydruku pmap [pid] -x
oznaczają:
[anon]/[stack]
Używając programu lsof
wyświetl zasoby plikopodobne podpięte do procesu przeglądarki firefox
. Wyjaśnij znaczenie poszczególnych kolumn wykazu, po czym zidentyfikuj pliki zwykłe, katalogi, urządzenia, gniazda (sieciowe lub domeny uniksowej) i potoki. Przekieruj wyjście z programu lsof
, przed i po otwarciu wybranej strony, odpowiednio do plików before
i after
. Czy poleceniem diff -u before after
jesteś w stanie zidentyfikować nowo utworzone połączenia sieciowe?
Zasoby plikopodobne (deskryptor pliku) to zasób mający deskryptor pliku, ale niebędący "prawdziwym" plikiem (np. strumienie stdin
, stdout
, stderr
).
Do znalezienia pid firefox
używamy pgrep firefox
lub ps -ax | grep firefox
(warto pamiętać, że Linux jest case sensitive i firefox =/= Firefox
). Ja preferuje pgrep
, bo wydruk jest dużo bardziej czytelny.
Mając znaleziony pid procesu, używamy lsof -p [pid]
do wyświetlenia zasobów plikopodobnych (poniżej tylko część wydruku):
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
firefox-e 26587 two cwd DIR 8,20 4096 3670018 /home/two
firefox-e 26587 two rtd DIR 8,20 4096 2 /
firefox-e 26587 two txt REG 8,20 228408 7613180 /usr/lib/firefox-esr/firefox-esr
firefox-e 26587 two mem REG 8,20 27190832 6298743 /usr/lib/x86_64-linux-gnu/libicudata.so.63.1
firefox-e 26587 two mem REG 8,20 1886216 6298748 /usr/lib/x86_64-linux-gnu/libicuuc.so.63.1
firefox-e 26587 two mem REG 8,20 2984248 6298744 /usr/lib/x86_64-linux-gnu/libicui18n.so.63.1
firefox-e 26587 two mem REG 8,20 186968 6303480 /usr/lib/x86_64-linux-gnu/libsoxr.so.0.1.1
firefox-e 26587 two mem REG 8,20 687176 6303506 /usr/lib/x86_64-linux-gnu/libxvidcore.so.4.3
Poszczególne kolumny wydruku to (więcej w dokumentacji):
COMMAND
to polecenie, które uruchomiło procesPID
to identyfikator procesuUSER
to właściciel procesuFD
to numer deskryptora pliku lub jedno z poniższych:
cwd
- bieżący katalogL[nn]
- referencje bibliotek (AIX)err
- informacja błędu FD (w kolumnie NAME)jld
- katalog jail (FreeBSD)ltx
- dzielona biblioteka text (kod i dane)M[xx]
- pamięć mapowana w hex typu [xx]
m86
- DOS Merge mapped filemem
- plik zmapowany w pamięcimmap
- urządzenie zmapowane w pamięcipd
- katalog rodzicartd
- katalog rootatr
- kernel trace file (OpenBSD)txt
- tekst programu (kod i dane)v86
- VP/ix mapped fileTYPE
to typ węzła powiązanego z plikiem:
DIR
- katalogiDEL
- usunięty plik zmapowanyREG
- plik zwykłyIPv4
- socket IPv4ax25
- socket Linux AX.25inet
- Internet domain socketsock
- socket of unknown domainunix
- UNIX domain socket - służy do wymiany danych pomiędzy procesamix.25
- HP-UX x.25 socketSTSO
- stream socket (wysyłanie i odbieranie danych)FIFO
- specjalny plik (nazwany potok), który jest częścią systemu plikówDEVICE
to numery urządzeńSIZE/OFF
to rozmiar pliku lub jego offset w bajtachNODE
to numer węzła lub pliku lokalnegoNAME
to nazwa punktu montowania (ang. mount point) i systemu pliku w którym plik się znajduje.Po FD
następuje jeden ze znaków (r
- dostęp do odczytu, w
- dostęp do zapisu, u
- dostęp do odczytu i zapisu, spacja dla braku trybu bez blokady i -
dla nieznanego trybu z blokadą).
Plik zwykły to plik, który ma -
w polu trybu (widoczne po ls -l
), np. -r--r--r--
, w lsof
jest oznaczany jako REG
.
Katalog (ang. directory) to struktura organizacji danych (specjalny rodzaj pliku), który zawiera referencje do plików lub innych katalogów, w lsof
jest oznaczany jako DIR
.
Plik urządzenia (ang. device file) to plik służący do komunikacji programu z urządzeniem poprzez używanie jego sterowników przy użyciu syscall
wejścia/wyjścia.
Gniazdo (ang. socket) to dwukierunkowy punkt końcowy połączenia, dzięki czemu możliwe jest przesyłanie danych w obie strony. Posiada ono trzy główne właściwości: typ gniazda identyfikujący protokół wymiany danych, lokalny adres i opcjonalny numer portu identyfikujący proces. W lsof
oznaczony przez sock
, IPv4
, unix
itp.
Potok (ang. pipe) to mechanizm komunikacji międzyprocesowej umożliwiający wymianę danych pomiędzy dwoma procesami. Odbywa się poprzez połączenie stdout
jednego procesu z stdin
drugiego. Polega ono na stworzeniu specjalnego pliku, który służy jako łącznik między procesami. W lsof
oznaczony jako FIFO
.
Identyfikacja nowych połączeń sieciowych odbywa się następująco:
firefox
.lsof -p [pid] > before
.lsof -p [pid] > after
.colordiff before after | grep IPv4
(lub zwykłe diff
, ale kolorki są lepsze i bardziej czytelne), można dodać flagę -u
, żeby zamiast >
i <
mieć +
i -
.-firefox-e 26587 two 44u IPv4 1207913 0t0 TCP 192.168.1.93:57190->waw02s08-in-f206.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 61u IPv4 1216103 0t0 TCP 192.168.1.93:56562->104.18.165.34:https (ESTABLISHED)
+firefox-e 26587 two 64u IPv4 1217516 0t0 TCP 192.168.1.93:38430->93.184.220.29:http (ESTABLISHED)
+firefox-e 26587 two 66u IPv4 1215340 0t0 TCP 192.168.1.93:50266->151.101.193.69:http (ESTABLISHED)
-firefox-e 26587 two 68u IPv4 1207845 0t0 TCP 192.168.1.93:59768->151.101.1.69:https (ESTABLISHED)
-firefox-e 26587 two 69u IPv4 1207914 0t0 TCP 192.168.1.93:35178->91.228.74.189:https (ESTABLISHED)
+firefox-e 26587 two 68u IPv4 1216124 0t0 TCP 192.168.1.93:36126->151.101.193.69:https (ESTABLISHED)
+firefox-e 26587 two 69u IPv4 1217804 0t0 TCP 192.168.1.93:36240->waw02s17-in-f2.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 71u IPv4 1217805 0t0 TCP 192.168.1.93:58634->waw02s08-in-f206.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 74u IPv4 1215362 0t0 TCP 192.168.1.93:40126->waw02s18-in-f10.1e100.net:https (ESTABLISHED)
-firefox-e 26587 two 82u IPv4 1204904 0t0 TCP 192.168.1.93:38624->waw02s18-in-f10.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 82u IPv4 1218627 0t0 TCP 192.168.1.93:50552->104.17.79.107:https (ESTABLISHED)
+firefox-e 26587 two 83u IPv4 1219141 0t0 TCP 192.168.1.93:39258->sof01s12-in-f1.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 130u IPv4 1217806 0t0 TCP 192.168.1.93:49940->a2-18-14-44.deploy.static.akamaitechnologies.com:https (ESTABLISHED)
-firefox-e 26587 two 144u IPv4 1205015 0t0 TCP 192.168.1.93:37646->65.9.96.42:https (ESTABLISHED)
+firefox-e 26587 two 144u IPv4 1215372 0t0 TCP 192.168.1.93:36484->stackoverflow.com:https (ESTABLISHED)
+firefox-e 26587 two 149u IPv4 1216134 0t0 TCP 192.168.1.93:57004->91.228.74.198:https (ESTABLISHED)
-firefox-e 26587 two 157u IPv4 1205014 0t0 TCP 192.168.1.93:59368->lg-in-f157.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 154u IPv4 1216144 0t0 TCP 192.168.1.93:40222->65.9.96.9:https (ESTABLISHED)
+firefox-e 26587 two 155u IPv4 1218728 0t0 TCP 192.168.1.93:53496->mil02s06-in-f2.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 156u IPv4 1215390 0t0 TCP 192.168.1.93:53500->mil02s06-in-f2.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 157u IPv4 1216151 0t0 TCP 192.168.1.93:49504->a104-85-249-211.deploy.static.akamaitechnologies.com:http (ESTABLISHED)
+firefox-e 26587 two 158u IPv4 1215399 0t0 TCP 192.168.1.93:53504->mil02s06-in-f2.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 159u IPv4 1216158 0t0 TCP 192.168.1.93:38592->par10s21-in-f195.1e100.net:http (ESTABLISHED)
+firefox-e 26587 two 161u IPv4 1215420 0t0 TCP 192.168.1.93:44586->waw02s16-in-f1.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 162u IPv4 1215400 0t0 TCP 192.168.1.93:38400->lg-in-f156.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 164u IPv4 1219680 0t0 TCP 192.168.1.93:38648->par10s21-in-f195.1e100.net:http (ESTABLISHED)
+firefox-e 26587 two 166u IPv4 1218798 0t0 TCP 192.168.1.93:53514->mil02s06-in-f2.1e100.net:https (ESTABLISHED)
+firefox-e 26587 two 168u IPv4 1220643 0t0 TCP 192.168.1.93:46608->nrt20s20-in-f3.1e100.net:https (ESTABLISHED)
-firefox-e 26587 two 173u IPv4 1205057 0t0 TCP 192.168.1.93:42264->151.101.65.69:https (ESTABLISHED)
-firefox-e 26587 two 194u IPv4 1210628 0t0 TCP 192.168.1.93:38768->waw02s18-in-f10.1e100.net:https (ESTABLISHED)
Wbudowanym poleceniem powłoki time
zmierz czas wykonania długo działającego procesu, np. polecenia find /usr
. Czemu suma czasów user
i sys
(a) nie jest równa real
(b) może być większa od real
? Poleceniem ulimit
nałóż ograniczenie na czas wykonania procesów potomnych powłoki tak, by limit się wyczerpał. Uruchom ponownie wybrany program – który sygnał wysłano do procesu?
Czas wykonania to czas spędzony na wykonywaniu zadania. Ograniczenie czasu wykonania to limit czasu przeznaczony na wykonanie zadania.
Wykonajmy polecenie time find /usr
. Jego wydrukiem będzie coś takiego:
real 0m7.808s
user 0m0.495s
sys 0m1.676s
Aby dowiedzieć się dlaczego user + sys != real
należy wyjaśnić co oznacza każdy z czasów:
user
to czas CPU spędzony w trybie użytkownika podczas wykonywania procesu określonego przez polecenie time
sys
to czas CPU spędzony na wykonywaniu różnych syscall
przez jądro, liczony jest tylko dla tego konkretnego procesureal
to realny czas od uruchomienia procesu do jego zakończenia.Może dojść do sytuacji, gdzie czas user
jest większy od real
- dzieje się tak, gdy proces używa kilku rdzeni. Czasy na każdym rdzeniu są wtedy sumowane, dzięki czemu możemy mieć sytuacje takie jak poniższa (wytłumaczenie stąd):
real 0m8.512s
user 0m8.737s
sys 0m1.956s
Ograniczenie czasu wykonania nakładamy przez ulimit -t [sekundy]
. Czas ograniczenia liczy się tylko jako user + sys
, real
może być większy niż nałożony przez nas limit. Proces zostaje zabity sygnałem SIGKILL
. Można zobaczyć listę wszystkich limitów poprzez ulimit -a
.
Napisz program, który będzie prezentował, że pliki procesu są kopiowane przez referencję w trakcie wywołania fork(2)
. W procesie głównym otwórz plik do odczytu open(2)
. Czy zamknięcie pliku close(2)
w procesie głównym zamyka plik także w dziecku? Czy odczyt z pliku read(2)
zmienia pozycję kursora lseek(2)
w drugim procesie? Wyjaśnij zachowanie swojego programu!
Przed każdym komunikatem diagnostycznym wypisz pid
procesu. W drugiej części zadania należy wydrukować bieżącą pozycję kursora pliku przed operacją odczytu z pliku. Należy wykorzystać dostarczone funkcje opakowujące uniksowe wywołania systemowe z biblioteki libcsapp
.
Kopiowanie przez referencję to kopiowanie wskaźnika na obiekt, a nie zawartości obiektu.
Pozycja kursora to offset od początku pliku, od którego następuje czytanie/pisanie.
static void do_read(int fd) {
pid_t pid;
pid = Fork();
if(pid == 0){
Read(fd,buf,16);
printf("%d: %ld\n",pid, Lseek(fd,0,SEEK_CUR));
}
else{
Read(fd,buf,16);
printf("%d: %ld\n",pid,Lseek(fd,0,SEEK_CUR));
}
exit(0);
}
static void do_close(int fd) {
int child_status;
pid_t pid;
pid = Fork();
if(pid == 0){
printf("%d: Closing file %d\n",pid,fd);
Close(fd);
exit(0);
}
else {
Waitpid(pid,&child_status,0);
printf("%d: Child status: %d\n",pid,child_status);
printf("%d: Cursor position: %ld\n",pid, Lseek(fd,0,SEEK_CUR));
Read(fd,buf,16);
printf("%d: Reading from file %d: %s\n",pid,fd,buf);
exit(0);
}
}
Po wywołaniu ./fileref read
na wyjściu uzyskamy:
4606: 16
0: 32
Dochodzi do kopiowania przez referencję, ponieważ dziecko ma dostęp do tego samego pliku co rodzic.
Możemy zauważyć, że oba procesy korzystają z tego samego kursora, ponieważ końcowa pozycja kursora wynosi ./fileref close
uzyskamy:
0: Closing file 3
5029: Child status: 0
5029: Cursor position: 0
5029: Reading from file 3: Write programs t
Mimo tego, że dziecko zamknęło plik, zanim rodzic zaczął z niego czytać, to nie wystąpił żaden błąd. Oznacza to, że plik rodzica nie został zamknięty. Wynika to z faktu, że każdy proces posiada swój własny file descriptor. Jest to liczba całkowita, która pozwala na komunikację z plikiem. Właśnie dlatego zamknięcie pliku w procesie-dziecku nie powoduje zamknięcia go w rodzicu.
Rozwiąż problem board
przechowywana jest wartość ndselect
wraca wielokrotnie z kolejnymi liczbami z zakresu
Linie wydruków plansz z prawidłowymi ustawieniami hetmanów nie mogą się przeplatać.
Uważaj, żeby przez przypadek nie zaprogramować fork bomby! Należy wytłumaczyć działanie programu rysując diagram procesów pokazany na wykładzie.
#include "csapp.h"
#include <pthread.h>
pthread_mutex_t lock;
static int ndselect(int n)
{
/* TODO: A loop is missing here that spawns processes and waits for them! */
pid_t pids[8];
for (int i = 0; i < n; i++)
{
pids[i] = Fork();
if (pids[i] == 0)
{
return i;
}
}
for (int i = 0; i < 8; i++)
{
waitpid(pids[i], NULL, 0);
}
exit(0);
}
static int conflict(int x1, int y1, int x2, int y2)
{
return x1 == x2 || y1 == y2 || x1 + y1 == x2 + y2 || x1 - y1 == x2 - y2;
}
static void print_line_sep(int size)
{
for (int i = 0; i < size; ++i)
printf("+---");
printf("+\n");
}
static void print_board(int size, int board[size])
{
pthread_mutex_lock(&lock);
for (int i = 0; i < size; ++i)
{
print_line_sep(size);
for (int j = 0; j < size; ++j)
printf("|%s", board[i] == j ? " Q " : " ");
printf("|\n");
}
print_line_sep(size);
printf("\n");
pthread_mutex_unlock(&lock);
}
int main(int argc, char **argv)
{
if (argc != 2)
app_error("Usage: %s [SIZE]", argv[0]);
int size = atoi(argv[1]);
if (size < 2 || size > 9)
app_error("Give board size in range from 4 to 9!");
if (pthread_mutex_init(&lock, NULL) != 0)
{
app_error("mutex error");
}
int board[size];
/* TODO: A loop is missing here that initializes recursive algorithm. */
for (int i = 0; i < size; i++)
{
int pos = ndselect(size);
if (pos == -1)
exit(0);
board[i] = pos;
for (int j = 0; j < i; j++)
{
if (conflict(j, board[j], i, board[i]))
exit(0);
}
}
print_board(size, board);
return 0;
}
Warto przeczytać 10.3 i 11.4 z Modern Operating Systems, 4.6 z Operating Systems: Internals and Design Principles oraz 8, 10 z APUE.
Na podstawie rysunku 4.15 z §4.6 (rysunek na 207. stronie, widoczny poniżej) przedstaw stany procesu w systemie Linux. Podaj akcje albo zdarzenia wyzwalające zmianę stanów. Które przejścia mogą być rezultatem działań podejmowanych przez: jądro systemu operacyjnego, kod sterowników, proces użytkownika? Wyjaśnij różnice między snem przerywalnym i nieprzerywalnym. Czy proces może zablokować lub zignorować sygnał SIGKILL
?
Można wyróżnić następujące stany procesu w systemie Linux:
Dwa ostatnie stany różnią się tym, że sen przerywalny reaguje na sygnały, przez które może zostać obudzony, a sen nieprzerywalny nie obsługuje sygnałów.
Poniżej przedstawione przejścia między stanami są wywoływane przez takie zdarzenia/akcje:
Początek | Koniec | Akcja |
---|---|---|
Ready | Executing | zaplanowanie wykonania procesu przez scheduler, a więc działanie jądra systemu operacyjnego |
Executing | Zombie | dobrowolne zakończenie się procesu przez exit() zamknięcie przez system w przypadku deadlocka otrzymanie sygnału SIGKILL lub SIGTERM błąd I/O (sterownik urządzenia) |
Executing | Sleep (both) | wydarzenie I/O wybudzane przez jądro sygnał (tylko w przypadku interruptible sleep) |
Stopped | Ready | otrzymanie sygnału SIGSTOP lub SIGCONT od procesu użytkownika |
Zablokowanie sygnału to określenie na niedostarczenie sygnału do procesu do momentu odblokowania go przez system operacyjny. Proces blokuje sygnał poprzez użycie funkcji:
int sigprocmask(int how, const sigset_t *set, sigset *oldset)
Zignorowanie sygnału to sytuacja, w której nie została zdefiniowana procedura obsługi konkretnego sygnału.
Sygnału SIGKILL
nie da się zablokować, a zignorowanie tego sygnału spowoduje zabicie procesu.
Wyjaśnij różnice w tworzeniu procesów w systemie Linux (§10.3.3) i WinNT (§11.4.3). Naszkicuj przebieg akcji podejmowanych przez jądro w trakcie obsługi funkcji fork(2)
i execve(2)
. Wyjaśnij jak system uniksowy optymalizuje klonowanie procesów z użyciem kopiowania przy zapisie?
Tworzenie procesu w systemie Linux (jedyna możliwość to fork()
):
task_struct
i obszar użytkownika są tworzone i wypełniane w dużej części na podstawie zawartości odpowiednich struktur procesu rodzica.Tworzenie procesu w systemie WinNT różni się znacząco, wywołuje się tam procedurę CreateProcess
, która jako argument przyjmuje nazwę pliku wykonywalnego. Procedura ta tworzy zupełnie nowy proces (a nie klonuje, jak w przypadku Linuxa). Stworzony proces (dziecko) nie otrzymuje kopii pamięci swojego rodzica.
W §10.3.3 (Modern Operating Systems) można znaleźć taki schemat przedstawiający przebieg akcji podejmowanych przez jądro w trakcie obsługi fork(2)
oraz execve(2)
:
Wywołanie fork
sprawia, że proces wywołujący przechodzi w tryb jądra oraz tworzy nową strukturę zadania i kilka innych, dodatkowych struktur danych, jak stros trybu jądra czy struktura thread_info
. Nowa struktrua zadania, która jest umieszczana w pamięci na pozycji przesuniętej o pewną stałą wartość względem końca stosu danego procesu, reprezentuje szereg parametrów odpowiedniego procesu, w tym adres jego deskryptora. Zdecydowana większość zawartości deksryptora jest kopiowana z procesu rodzica. System operacyjny musi znaleźć dostępny PID i zaktualizować odpowiedni wpis w tablicy indentyfikatorów, aby wskazywał na nową strukturę zadania. Na sam koniec kopiowane są rejestry rodzica do dziecka.
Wywołanie exec
sprawia, że jądro na samym początku szuka programu wykonywalnego, następnie sprawdza uprawnienia wykonywania, po czym kopiuje argumenty i środowisko i zwalnia starą przestrzeń adresową, po czym tworzy nową. Argumenty i zmienne środowiskowe są kopiowane na stos, sygnały są zerowane, a rejestry inicjalizowane (wypełnione zerami).
Kopiowanie przy zapisie (ang. copy on write) to technika zarządzania zasobami, która pozwala zoptymalizować klonowanie procesów w systemach uniksowych. Polega ona na odraczaniu kopiowania pamięci rodzica do momentu, w którym jakiś proces próbuje do niej pisać - nowo utworzony proces otrzymuje swoją tablicę stron, jednak wszystkie są oznaczone tylko do odczytu. Gdy proces dziecka próbuje pisać do pamięci, ma miejsce błąd naruszenia reguł ochrony (protection fault). Jądro wydziela wówczas pamięc dla nowej kopii strony dla procesu potomnego i oznacza ją jako dostępną do odczytu i zapisu. Oznacza to, że kopiowane są tylko strony, co do których proces potomny sformułował żądania zapisu.
Na podstawie dokumentacji fork(2)
(§8.3) i execve(2)
(§8.10) wymień najważniejsze zasoby procesu, które są dziedziczone przez (a) proces potomny (b) nowy program załadowany do przestrzeni adresowej. Czemu przed wywołaniem fork
należy opróżnić bufory biblioteki stdio(3)
? Czemu w trakcie execve
jądro przywraca domyślną obsługę wyłapywanych sygnałów?
Wywołując fork
proces potomny dziedziczy te właściwości:
set-user-ID
oraz set-group-ID
pwd
) oraz ścieżkę root'aPrzed jego wywołaniem należy opróżnić bufory stdio
, ponieważ chcemy uniknąć kilkukrotnego wypisywania danych.
Nowy program załadowany do przestrzeni adresowej poleceniem execve
dziedziczy z procesu wywołującego go następujące właściwości:
pwd
) oraz ścieżkę root'atms_utime
, tms_stime
, tms_cutime
, tms_cstime
Jądro przywraca domyślną obsługę wyłapywanych sygnałów w trakcie wywołania execve
, ponieważ użytkownik sam może zdefiniować sygnały SIGUSR1
i SIGUSR2
, które nie powinny być w żaden sposób obsługiwane przez nowy program.
Uruchom program xeyes
po czym użyj na nim polecenia kill
, pkill
i xkill
. Który sygnał jest wysyłany domyślnie? Przy pomocy kombinacji klawiszy CTRL+Z
wyślij xeyes
sygnał SIGTSTP
, a następnie wznów jego wykonanie. Przeprowadź inspekcję pliku /proc/pid/status
i wyświetl maskę sygnałów oczekujących na dostarczenie. Pokaż jak będzie się zmieniać, gdy będziemy wysyłać wstrzymanemu procesowi kolejno: SIGUSR1
, SIGUSR2
, SIGHUP
i SIGINT
. Co opisują pozostałe pola pliku status
dotyczące sygnałów? Który sygnał zostanie dostarczony jako pierwszy po wybudzeniu procesu?
Spójrzmy na man
każdego polecenia, skąd dowiemy się o wysyłanych sygnałach:
kill
is TERM
.pkill
will send the specified signal (by default SIGTERM
) to eachprocess instead of listing them on stdout.xkill
(sekcja caveats): This command does not provide any warranty that the application whose connection to the X server is closed will abort nicely, or even abort at all. All this command does is to close the connection to the X server. Many existing applications do indeed abort when their connection to the X server is closed, but some can choose to continue.Polecenia te wykonuje się w taki sposób:
$ kill [pid]
$ pkill [process name]
$ xkill (po czym wybiera się okno, które zabijamy)
Warto dodać, że po kilkukrotnym uruchomieniu xeyes
(np. dla 5 działających procesów jednocześnie) pkill xeyes
zabije wszystkie te procesy.ki
Wykonując CTRL+Z
wywołujemy sygnał SIGTSTP
(lekko się różni od SIGSTOP
) na programie xeyes
, wznawiamy jego działanie przez fg
lub bg
(zależy czy chcemy aby działało w tle, czy nie).
Sygnały oczekujące to sygnały, których dostarczenie jest wstrzymane do momentu wyjścia przez proces z nieprzerywalnego snu.
Sygnały SIGUSR1
, SIGUSR2
, SIGHUP
i SIGINT
wysyłamy w procedurze kill -[sygnał] [pid]
. W pliku /proc/pid/status
znajdują się pola dotyczące sygnałów:
SigQ
to pole zawierające dwie liczby które oznaczają liczbę zakolejkowanych sygnałów dla real user ID oraz limit zakolejkowanych sygnałów dla tego procesu,SigPnd
, ShdPnd
to pola z liczbą sygnałów oczekujących na wątek oraz na cały proces,SigBlk
, SigIgn
, SigCgt
to maski sygnałów blokowanych, ignorowanych oraz złapanych.Po uruchomieniu xeyes
możemy sprawdzić maski przez cat /proc/[pid]/status
, klikamy CTRL+Z
, żeby wysłać sygnał SIGTSTP
, a następnie wywołujemy kolejno:
$ kill -SIGUSR1 [pid]
$ cat /proc/[pid]/status (to polecenie co każy kolejny kill)
$ kill -SIGUSR2 [pid]
$ kill -SIGHUP [pid]
$ kill -SIGINT [pid]
Jako pierwszy po wybudzeniu procesu wysyłany jest sygnał SIGHUP
:
[1] + 19997 continued xeyes
[1] + 19997 hangup xeyes
Uzupełnij program reaper.c
prezentujący powstawanie sierot. Proces główny przyjmuje rolę żniwiarza (ang. reaper) przy użyciu prctl(2)
. Przy pomocy procedury spawn
utwórz kolejno procesy syna i wnuka. Następnie osieroć wnuka kończąc działanie syna. Uruchom podproces wywołujący polecenie ps
, aby wskazać kto przygarnął sierotę – przykład poniżej (zwróć uwagę na numery grup procesów):
PID PPID PGRP STAT CMD
24886 24643 24886 S+ ./reaper (main)
24888 24886 24887 S ./reaper (grandchild)
24889 24886 24886 R+ /usr/bin/ps -o pid,ppid,pgrp,stat,cmd
Po udanym eksperymencie należy zabić wnuka sygnałem SIGINT
, a następnie po nim posprzątać drukując jego kod wyjścia. Wysłanie SIGINT
do procesu głównego jest zabronione! Zauważ, że proces główny nie zna numeru pid wnuka. W rozwiązaniu należy wykorzystać setpgid(2)
, pause(2)
, waitpid(2)
i kill(2)
.
Użycie funkcji sleep(3)
lub podobnych do właściwego uszeregowania procesów jest zabronione!
Sierota (ang. orphant) to proces, który stracił rodzica, zostaje on "zaadoptowany" przez proces wyżej w hierarchii. Żniwiarz (ang. reaper) to proces, który wyszukuje procesy zombie i odczytuje ich kod wyjścia, a więc wynik zakończenia programu zwracany do środowiska i zapisywany w zmiennej $
w bashu - 0
oznacza sukces, a pozostałe wartości błąd.
#include "csapp.h"
static pid_t spawn(void (*fn)(void))
{
pid_t pid = Fork();
if (pid == 0)
{
fn();
printf("(%d) I'm done!\n", getpid());
exit(EXIT_SUCCESS);
}
return pid;
}
static void grandchild(void)
{
printf("(%d) Waiting for signal!\n", getpid());
/* TODO: Something is missing here! */
pause();
printf("(%d) Got the signal!\n", getpid());
}
static void child(void)
{
pid_t pid;
/* TODO: Spawn a child! */
setpgrp();
pid = spawn(grandchild);
printf("(%d) Grandchild (%d) spawned!\n", getpid(), pid);
}
/* Runs command "ps -o pid,ppid,pgrp,stat,cmd" using execve(2). */
static void ps(void)
{
/* TODO: Something is missing here! */
char *argv[] = {"ps", "-o", "pid,ppid,pgrp,stat,cmd", NULL};
// execvp - sprawdza PATH, używa envp = environ
execvp(argv[0], argv);
}
int main(void)
{
/* TODO: Make yourself a reaper. */
#ifdef LINUX
Prctl(PR_SET_CHILD_SUBREAPER, 1);
#endif
printf("(%d) I'm a reaper now!\n", getpid());
pid_t pid, pgrp;
int status;
/* TODO: Start child and grandchild, then kill child!
* Remember that you need to kill all subprocesses before quit. */
printf("(%d) Spawning child\n", getpid());
pid = spawn(child);
waitpid(pid, &status, 0);
printf("(%d) Child quit with %d\n", getpid(), status);
pgrp = -pid;
printf("(%d) Spawning ps\n", getpid());
pid = spawn(ps);
waitpid(pid, &status, 0);
printf("(%d) ps quit with %d\n", getpid(), status);
printf("(%d) Killing grandchild\n", getpid());
kill(pgrp, SIGINT);
waitpid(pgrp, &status, 0);
printf("(%d) Granchild quit with %d\n", getpid(), status);
return EXIT_SUCCESS;
}
Uzupełnij program cycle.c
, w którym procesy grają w piłkę przy pomocy sygnału SIGUSR1
. Proces główny tworzy CTRL+C
co wyśle SIGINT
do całej rodziny. Możesz wprowadź do zabawy dodatkową piłkę wysyłając sygnał SIGUSR1
poleceniem kill
. Czy piłki ostatecznie skleją się w jedną? W rozwiązaniu należy wykorzystać sigprocmask(2)
, sigsuspend(2)
i kill(2)
.
Użycie funkcji sleep(3)
lub podobnych do właściwego uszeregowania procesów jest zabronione!
#include "csapp.h"
#include "signal.h"
static void signal_handler(int signum, siginfo_t *info, void *data)
{
if (signum == SIGINT)
{
safe_printf("(%d) Screw you guys... I'm going home!\n", getpid());
_exit(0);
}
}
static void play(pid_t next, const sigset_t *set)
{
for (;;)
{
printf("(%d) Waiting for a ball!\n", getpid());
Sigsuspend(set); // tutaj sobie czekamy na sygnal (pilke), zawieszamy proces do otrzymania kolejnego sygnalu
usleep((300 + random() % 400) * 1000);
Kill(next, SIGUSR1);
printf("(%d) Passing ball to (%d)!\n", getpid(), next);
}
}
int main(int argc, char *argv[])
{
if (argc != 2)
app_error("Usage: %s [CHILDREN]", argv[0]);
int children = atoi(argv[1]);
if (children < 4 || children > 20)
app_error("Give number of children in range from 4 to 20!");
/* Register signal handler for SIGUSR1 */
struct sigaction action = {.sa_sigaction = signal_handler};
Sigaction(SIGINT, &action, NULL);
Sigaction(SIGUSR1, &action, NULL);
sigemptyset(&action.sa_mask); // ustawienie maski na 0
sigaddset(&action.sa_mask, SIGUSR1); // obsluga sygnalu SIGUSR1 (pilka)
Sigprocmask(SIG_BLOCK, &action.sa_mask, NULL);
pid_t next_pid = getpid();
for (int i = 0; i < children; i++)
{
pid_t pid = Fork();
if (!pid)
play(next_pid, &action.sa_mask);
else
next_pid = pid;
}
Kill(next_pid, SIGUSR1);
play(next_pid, &action.sa_mask);
return EXIT_SUCCESS;
}
Inna wersja tego rozwiązania:
static void play(pid_t next, const sigset_t *set)
{
for (;;)
{
printf("(%d) Waiting for a ball!\n", getpid());
/* TODO: Something is missing here! */
sigsuspend(set);
usleep((300 + random() % 400) * 1000);
Kill(next, SIGUSR1);
printf("(%d) Passing ball to (%d)!\n", getpid(), next);
}
int main(int argc, char *argv[])
{
if (argc != 2)
app_error("Usage: %s [CHILDREN]", argv[0]);
int children = atoi(argv[1]);
if (children < 4 || children > 20)
app_error("Give number of children in range from 4 to 20!");
/* Register signal handler for SIGUSR1 */
sigset_t mask;
sigfillset(&mask);
sigdelset(&mask, SIGINT);
sigprocmask(SIG_SETMASK, &mask, NULL);
struct sigaction action = {.sa_sigaction = signal_handler};
Sigaction(SIGINT, &action, NULL);
Sigaction(SIGUSR1, &action, NULL);
int parent_pid = getpid();
printf("parent pid: %d\n", parent_pid);
int pids[21];
/* TODO: Start all processes and make them wait for the ball! */
sigset_t mask2;
sigfillset(&mask2);
sigdelset(&mask2, SIGINT);
sigdelset(&mask2, SIGUSR1);
for (int i = 0; i < children; i++)
{
pid_t pid = fork();
if (!pid)
play(i ? pids[i - 1] : parent_pid, &mask2);
else
pids[i] = pid;
}
Kill(pids[children - 1], SIGUSR1);
play(pids[children - 1], &mask2);
return EXIT_SUCCESS;
}
Uzupełnij program demand
o procedurę obsługi sygnału SIGSEGV
. Program ma za zadanie demonstrować przechwytywanie błędów stron, których nie było w stanie obsłużyć jądro SO.
Obsługujemy zakres adresów od ADDR_START
do ADDR_END
. Pod losowo wybrane wirtualne strony z podanego przedziału zostanie podpięta pamięć wirtualna w trybie tylko do odczytu. Następnie program wygeneruje do zadanego przedziału adresów zapisy, które zakończą się naruszeniem ochrony pamięci.
Po wyłapaniu sygnału SIGSEGV
, korzystając z procedur mmap_page
i mprotect_page
odpowiednio zmapuj brakującą stronę (błąd SEGV_MAPERR
) i odblokuj zapis do strony (błąd SEGV_ACCERR
). Dostęp do adresów spoza ustalonego zakresu powinien skutkować zakończeniem programu. Należy wtedy ustalić właściwy kod wyjścia tak, jakby proces został zabity sygnałem!
...
Fault at rip=0x55cb50d54389 accessing 0x10003fc0! Make page at 0x10003000 writable.
Fault at rip=0x55cb50d54389 accessing 0x10007bb0! Map missing page at 0x10007000.
...
Fault at rip=0x55cb50d5439c accessing 0x10010000! Address not mapped - terminating!
W procedurze obsługi sygnału można używać tylko procedur wielobieżnych (ang. reentrant) – sprawdź w podręczniku ich listę. Możesz wykorzystać procedurę safe_printf
, będącą okrojoną wersją printf
. Czemu można ją bezpiecznie wywołać w wnętrza sigsegv_handler
?
Adres powodujący błąd strony i rodzaj błędu znajdziesz w argumencie sigsegv_handler
o typie siginfo_t
, który opisano w podręczniku sigaction(2)
. Wskaźnik instrukcji, która spowodowała błąd strony, można przeczytać ze struktury przechowującej kontekst procesora uc->uc_mcontext
. Odpowiednie definicje znajdują się w pliku nagłówkowym /usr/include/x86_64-linux-gnu/sys/ucontext.h
.
Procedury wielobieżne to takie procedury, które mogą być przerwane podczas wykonywania, a później bezpiecznie wznowione.
static void sigsegv_handler(int signum, siginfo_t *info, void *data) {
ucontext_t *uc = data;
/* TODO: You need to get value of instruction pointer register from `uc`.
* Print all useful data from `info` and quit in such a way that a shell
* reports program has been terminated with SIGSEGV. */
greg_t rip_addr = uc->uc_mcontext.gregs[17];
void* access_addr = info->si_addr;
int err_code = info->si_code;
if(access_addr > ADDR_START && access_addr < ADDR_END) {
void* page_addr = (void*)((long)access_addr - ((long)access_addr % PAGE_SIZE));
if(err_code == 1) {
safe_printf("Fault at rip=%lx accessing %lx! Map missing page at %lx.\n", (long)rip_addr, (long)access_addr, (long)page_addr);
mmap_page(page_addr, PROT_WRITE);
} else if(err_code == 2) {
safe_printf("Fault at rip=%lx accessing %lx! Make page at %lx writable.\n", (long)rip_addr, (long)access_addr, (long)page_addr);
mprotect_page(page_addr, PROT_WRITE);
}
} else {
safe_printf("Fault at rip=%lx accessing %lx! Address not mapped - terminating!\n", (long)rip_addr, (long)access_addr);
exit(11);
}
}
W artykule A fork() in the road skrytykowano wywołanie fork(2)
. Na podstawie sekcji 4 publikacji przedstaw pobieżnie argumentację autorów przeciwko fork
. Następnie opowiedz jak posix_spawn(3)
pomagają zniwelować niektóre z wymienionych wad.
Problemy z fork
:
fork
w kwestii:
madvise
Linuxa: MADV_DONTFORK
, MADV_DOFORK
, MADV_WIPEONFORK
). madvise()
to wywołanie systemowe informujące system pamięci wirtualnej systemu operacyjnego o planowanym sposobie użycia danego obszaru pamięci;O_CLOEXEC
- dzieci nie dziedziczą deskryptorów plików, FD_CLOEXEC
- zamknij plik po wywołaniu funkcji z rodziny exec
);pthread_atfork
rejestruje fork handlery, które mają zostać wykonane podczas wywołania fork
w kontekście wątku, który wywołał fork
);fork
duplikuje całą przestrzeń adresową: klasycznym przykładem jest bufor IO, który musi być flushowany przez użytkownika;fork
ma jedynie jeden wątek. Możliwe jest, że przestrzeń adresowa dziecka nie będzie spójną kopią przestrzeni rodzica, np. gdy jeden wątek dokonuje alokacji pamięci i posiada blokadę sterty, a inny wątek wywoła fork
, alokacja pamięci w nowym dziecku będzie niemożliwa gdyż będzie ono czekało na oblokowanie, które nigdy nie nastąpi. malloc
nie jest bezpieczny w połączeniu z fork
;fork
łamie zasadę najmniejszego uprzywilejowania;fork
rzędu 100ms, a słaba wydajność powoduje, że posix_spawn
nie wykorzystuje zazwyczaj fork
, a Solaris implementuje spawn
jako natywne wywołanie systemowe;fork
, należy stworzyć wiele mapowań stron copy-on-write, które prawdopodobnie nigdy nie zostaną zmodyfikowane.fork
jest wygodnym API dla procesów jednowątkowych, które używają mało pamięci, nadaje się on do implementacji powłok, problemem jest to, żę większość programów nie jest powłokami.
posix_spawn(3)
tworzy nowy proces dziecko, który wykonuje wskazany plik (łączy ze sobą fork
i exec
, działa więc podobnie do CreateProcess()
z WinNT). API posix_spawn
ułatwia refaktoryzację kodu, który zawierał w/w wywołania, które mogły znajdować się w odległych miejscach w kodzie.
Zaprezentuj sytuację, w której proces zostanie osierocony. W terminalu uruchom dodatkową kopię powłoki bash
. Z jej poziomu wystartuj sleep 1000
jako zadanie drugoplanowe i sprawdź, kto jest jego rodzicem. Poleceniem kill
wyślij sygnał SIGKILL
do uruchomionej wcześniej powłoki i sprawdź, kto stał się nowym rodzicem procesu sleep
. Wyjaśnij przebieg i wyniki powyższego eksperymentu. Wytłumacz zachowanie eksperymentu, gdy zamiast SIGKILL
wyślemy powłoce sygnał SIGHUP
.
Wskazówka: Uruchom powłokę przy pomocy polecenia strace -e trace=signal
.
Proces osierocony to proces, którego rodzic zmarł, a on nadal żyje. Taki proces po zabiciu rodzica zostaje przepięty do init
(PID 1), lub innego żniwiarza.
strace -e trace=signal bash # właściwie bash by wystarczył
sleep 1000 &
ps ax -o pid,ppid,command | grep sleep
kill -KILL 0
ps ax -o pid,ppid,command | grep sleep
U mnie nowym rodzicem zostaje PID 2142.
$ cat /proc/2142/cmdline
/lib/systemd/systemd --user
Przy SIGHUP
podobnie, ale SIGHUP
zostaje rozesłany przez bash
, więc zabije sleepa. Używając info bash
możemy uzyskać następującą informację: "The shell exits by default upon receipt of a SIGHUP
. Before exiting, an interactive shell resends the SIGHUP
to all jobs, running or stopped. Stopped jobs are sent SIGCONT
to ensure that they receive the SIGHUP
."
Wyświetl konfigurację terminala przy pomocy polecenia stty -a
i wskaż znaki które (a) sterownik terminala zamienia na sygnały związane z zarządzaniem zadaniami (b) służą do edycji wiersza. Możliwości edycji wiersza zaprezentuj na przykładzie polecenia cat
. Następnie uruchom find /
i obserwuj zachowanie programu po naciśnięciu kombinacji klawiszy CTRL+S
i CTRL+Q
– jakie sygnały wysyła sterownik terminala do zadania pierwszoplanowego? Następnie wstrzymaj zadanie pierwszoplanowe sleep 1000
i przy pomocy wbudowanego polecenia powłoki bg
przenieś to zadanie do wykonania w tle. Jaki sygnał został użyty do wstrzymania zadania? Na przykładzie programu vi
wskaż kiedy program może być zainteresowany obsługą tego sygnału oraz SIGCONT
.
Terminal to program pozwalający użytkownikom na uruchomienie powłoki, dzięki czemu umożliwiona jest komunikacja z powłoką.
speed 38400 baud; rows 50; columns 211; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R;
werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk -brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc -ixany -imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke -flusho -extproc
Sygnały związane z zarządzaniem zadaniami:
intr
wysyła sygnał przerwaniaquit
wysyła sygnał zamknięciaswtch
włącza inną warstwę powłokistart
/stop
wznawia/wstrzymuje wyświetlaniesusp
wysyła sygnał stopSygnały związane z edycją wiersza (do zaprezentowania z cat
):
erase
kasuje ostatni wprowadzony znak,kill
eof
wysyła znak końca pliku (końca wejścia)eol
/eol2
wysyła znak końca wierszawerase
kasuje ostatnie wprowadzone słoworprnt
powtarza bieżący wierszlnext
wprowadza kolejny znak w cudzysłowieUruchamiając find /
, a następnie używając CTRL+S
i CTRL+Q
można zatrzymać, a następnie wznowić wyświelanie wyjścia. Kombinacje te nie wysyłają żadnego sygnału, są one częścią software flow control. XOFF
(czyli ^S
) powiadamia proces/urządzenie wysyłające dane, że bufor wejścia jest pełny i nie powinny być już wysyłane żadne dane. Terminal odpowiada za obsługę tych wywołań i jest odpowiedzialny za ich obsługę - może umożliwić dalsze działanie i wysyłanie danych, odrzucenie danych, czy wznowienie po otrzymaniu XON
(^Q
).
Proces pierwszoplanowy to proces uruchamiany bezpośrednio, na który czekamy aż się zakończy (wywołanie dowolnej komendy bez &
). Możemy zobaczyć jego interfejs, "porozumiewać się" z nim na bieżąco, w przeciwieństwie do procesu drugoplanowego.
Teraz uruchamiamy sleep 1000
i wstrzymujemy go (CTRL+Z
) sygnałem SIGTSTP
. Poleceniem bg
możemy je przenieść do działania w tle. W vi
/vim
wstrzymać proces możemy będąc poza insert mode, a powracamy do niego poleceniem fg
, które wywołuje sygnał SIGCONT
.
Uruchom w powłoce bash
polecenie cat - &
. Czemu zadanie zostało od razu wstrzymane? Jaki sygnał otrzymało? Zakończ to zdanie wbudowanym poleceniem powłoki kill
. Następnie porównaj działanie polecenia cat /etc/shells &
przed i po zmianie konfiguracji terminala poleceniem stty tostop
. Jaki efekt ma włączenie flagi tostop
na zachowanie sterownika terminala?
Zauważ, że powłoka dostaje SIGCHLD
w wyniku zmiany stanu procesu potomnego, a nie tylko jego zakończenia, i ewentualnie wybiera grupę pierwszoplanową. Jak przy pomocy waitpid(2)
rozróżnić wstrzymanie i kontynuowanie od zakończenia procesu potomnego? Jaka funkcja biblioteki standardowej służy do wyboru grupy pierwszoplanowej?
Program zostaje od razu wstrzymany sygnałem SIGSTOP
. Użycie -
w miejscu nazwy pliku jest postrzegane przez cat
jako synonim do stdin
, przez co program zostaje natychmiastowo wstrzymany, jako że nic nie może się wtedy znależć w stdin
.
Wbudowane polecenie powłoki to polecenie, które powłoka uruchamia bezpośrednio, bez wywoływania innego programu.
Wywołanie cat /etc/shells &
sprawia, że wypisywana jest zawartość całego pliku, a następnie program się kończy. Po zmianie konfiguracji poleceniem stty tostop
, do grupy procesów drugoplanowych (bg
) próbujących pisać do ich terminala (a dokładniej sesji, w którym zostały one uruchomione) zostaje wysłany sygnał SIGTTOU
. Domyślnie zatrzymuje on wszystkie procesy w grupie procesów, jednak w tym przypadku zatrzymuje procesy drugoplanowe, które chcą pisać do terminala, przez co po uruchomieniu cat /etc/shells &
nie otrzymamy żadenj informacji poza tym, że proces został wstrzymany. Możemy go jednak wznowić fg
i dostaniemy oczekiwany output.
Wywołanie poniższej funkcji:
pid_t waitpid(pid_t pid, int *wstatus, int options);
zapisuje do wstatus
dane o zmianie stanu dziecka. Za pomocą makr WIFEXITED()
, WIFSTOPPED()
, WIFCONTINUED()
można dostać informacje o tym, czy dziecko zakończyło się w zwykły sposób, zostało zatrzymane sygnałem, czy też otrzymało SIGCONT
i kontynuuje działanie.
(APUE 9.7)
Aby wybrać grupę pierwszoplanową (ang. foreground group), możemy użyć funkcji:
int tcsetpgrp(int fd, pid_t pgrpid);
Ustawiając argument pgrpid
na odpowiednią wartość, zmieniamy grupę pierwszoplanową.
Procedury setjmp(3)
i longjmp(3)
z biblioteki standardowej języka C służą do wykonywania nielokalnych skoków. Uproszczone odpowiedniki tych procedur znajdują się w pliku libcsapp/Setjmp.s
, a definicja Jmpbuf
w pliku include/csapp.h
. Wyjaśnij co robią te procedury, a następnie przeprowadź uczestników zajęć przez ich kod. Dlaczego Jmpbuf
nie przechowuje wszystkich rejestrów procesora? Czemu Longjmp
zapisuje na stos wartość przed wykonaniem instrukcji ret
?
W C nie można użyć goto
znajdującego się w innej funkcji, dlatego musimy skorzystać z setjmp
oraz longjmp
. Funkcję setjmp
wywołujemy w miejscu, do którego chcemy wrócić (i tam powinna zwrócić 0
, jako że wywołaliśmy ją bezpośrednio). W argumencie przekazujemy środowisko env, a więc np. jmpbuffer
(typu jmp_buf
, zadeklarowanego globalnie). Funkcja longjmp
używa danych z env do przekazania kontroli w miejsce, w którym setjmp
zostało wywołane i przywraca stos do stanu w czasie wywołania setjmp
. Gdy longjmp
się powiedzie, program będzie działał dalej i setjmp
zwróci wartość drugi raz - będzie to wartość przekazana w argumencie val
, a gdy programista przekaże 0
do val
, to zwrócone zostanie 1
.
Jmpbuf
nie przechowuje wszystkich rejestrów procesora, gdyż jest to funkcja taka jak każda inna, dlatego może odrzucić rejestry caller-saved, które przechowują wartości tymczasowe. Dzięki temu nie trzeba ich zapisywać i przywracać.
Longjmp
zapisuje na stos wartość %r11
, do której wcześniej przypisany był %rip
, dzięki temu wiadomo, którą instrukcję należy wykonać po powrocie.
/* Setjmp & longjmp implementation without sigprocmask */
typedef struct {
long rbx;
long rbp;
long r12;
long r13;
long r14;
long r15;
void *rsp;
void *rip;
} Jmpbuf[1];
int Setjmp(Jmpbuf env);
noreturn void Longjmp(Jmpbuf env, int val);
_JB_RBX = 0
_JB_RBP = 1 # poczatek stosu
_JB_R12 = 2
_JB_R13 = 3
_JB_R14 = 4
_JB_R15 = 5
_JB_RSP = 6 # biezaca lokalizacja na stosie
_JB_RIP = 7 # wskaznik instrukcji
.text
.globl Setjmp
.type Setjmp,@function
Setjmp: # int Setjmp(Jmpbuf env (%rdi)):
movq (%rsp),%r11 # przenosimy adres powrotu do r11 (kopia zapasowa)
movq %rbx,(_JB_RBX * 8)(%rdi) # przenosimy rejestry do jmp_buf
movq %rbp,(_JB_RBP * 8)(%rdi)
movq %r12,(_JB_R12 * 8)(%rdi)
movq %r13,(_JB_R13 * 8)(%rdi)
movq %r14,(_JB_R14 * 8)(%rdi)
movq %r15,(_JB_R15 * 8)(%rdi)
movq %rsp,(_JB_RSP * 8)(%rdi)
movq %r11,(_JB_RIP * 8)(%rdi)
xorl %eax,%eax
ret
.size Setjmp, . - Setjmp
.globl Longjmp
.type Longjmp,@function
Longjmp: # noreturn void Longjmp(Jmpbuf env (%rdi), int val (%esi)):
movq (_JB_RBX * 8)(%rdi),%rbx # przywracamy rejestry z jmp_buf
movq (_JB_RBP * 8)(%rdi),%rbp
movq (_JB_R12 * 8)(%rdi),%r12
movq (_JB_R13 * 8)(%rdi),%r13
movq (_JB_R14 * 8)(%rdi),%r14
movq (_JB_R15 * 8)(%rdi),%r15
movq (_JB_RSP * 8)(%rdi),%rsp
movq (_JB_RIP * 8)(%rdi),%r11
movl %esi,%eax # przenosimy drugi argument do %rax, jesli jest rowny
# 0, to zmieniamy go na 1
testl %eax,%eax
jnz 1f # if (!eax) eax = 1; else goto 1;
incl %eax
1: movq %r11,(%rsp) # umieszczenie starego %rip (z %r11) na stos
ret
.size Longjmp, . - Longjmp
Uzupełnij program game
tj. prostą grę w szybkie obliczanie sumy dwóch liczb. Zadaniem procedury readnum
jest wczytać od użytkownika liczbę. Jeśli w międzyczasie przyjdzie sygnał, to procedura ma natychmiast wrócić podając numer sygnału, który przerwał jej działanie. W przeciwnym przypadku zwraca zero i przekazuje wczytaną liczbę przez pamięć pod wskaźnikiem num_p
. Twoja implementacja procedury readnum
musi wczytać całą linię w jednym kroku! Należy wykorzystać procedury siglongjmp(3)
, sigsetjmp(3)
i alarm(2)
. Kiedy Twój program będzie zachowywać się poprawnie zamień procedury nielokalnych skoków na longjmp(3)
i setjmp(3)
. Czemu program przestał działać?
static void signal_handler(int signo)
{
siglongjmp(env, signo);
}
static int readnum(int *num_p)
{
char line[MAXLINE];
int n;
alarm(1);
n = sigsetjmp(env, 0);
if(n)
{
*num_p = 0;
return n;
}
Read(STDIN_FILENO, line, MAXLINE);
*num_p = atoi(line);
return 0;
}
Problem pojawia się przy longjmp
i setjmp
, ponieważ w standardzie nie jest określone czy te dwie funkcje zachowują maskę bitów.
Program coro
wykonuje trzy współprogramy połączone ze sobą w potok bez użycia pipe(2)
. Pierwszy z nich czyta ze standardowego wejścia znaki, kompresuje białe znaki i zlicza słowa. Drugi usuwa wszystkie znaki niebędące literami. Trzeci zmienia wielkość liter i drukuje znaki na standardowe wyjście.
W wyniku wykonania procedury coro_yield
współprogram przekazuje niezerową liczbę do następnego współprogramu, który otrzyma tę wartość w wyniku powrotu z coro_yield
. Efektywnie procedura ta implementuje zmianę kontekstu. Taką prymitywną formę wielozadaniowości kooperacyjnej (ang. cooperative multitasking) można zaprogramować za pomocą setjmp(3)
i longjmp(3)
.
Uzupełnij procedurę coro_add
tak, by po wznowieniu kontekstu przy pomocy Longjmp
wykonała procedurę fn
, po czym zakończyła wykonanie współprogramu. Zaprogramuj procedurę coro_switch
tak, by wybierała następny współprogram do uruchomienia i przełączała na niego kontekst. Jeśli współprogram przekazał wartość parametru EOF
, to należy go usunąć z listy aktywnych współprogramów.
Program używa listy dwukierunkowej TAILQ
opisanej w queue(3)
. Zmienna runqueue
przechowuje listę aktywnych współprogramów, running
bieżąco wykonywany współprogram, a dispatcher
kontekst programu, do którego należy wrócić, po zakończeniu wykonywania ostatniego aktywnego współprogramu.
Wartość EOF
możemy przekazać kombinacją CTRL + D
.
#include "queue.h"
#include "csapp.h"
#define CORO_STKSIZE 4096
#define CORO_STKALIGN 16 /* As required by SysV ABI ! */
#ifndef EOF
#define EOF (-1)
#endif
#ifndef NOTHING
#define NOTHING (-2)
#endif
typedef struct coro {
TAILQ_ENTRY(coro) co_link;
const char *co_name;
void *co_stack;
Jmpbuf co_ctx;
} coro_t;
static TAILQ_HEAD(, coro) runqueue = TAILQ_HEAD_INITIALIZER(runqueue);
static coro_t *running;
static Jmpbuf dispatcher;
/* Initialize coroutine stucture with stack. */
static void coro_init(coro_t *co, const char *name) {
memset(co, 0, sizeof(coro_t));
co->co_name = name;
/* Allocates a fresh stack for the coroutine! */
if (posix_memalign(&co->co_stack, CORO_STKALIGN, CORO_STKSIZE) < 0)
unix_error("posix_memalign error");
}
/* Detach a stack from coroutine structure. */
static void coro_destroy(coro_t *co) {
free(co->co_stack);
}
/*
* Switch between subsequent coroutines.
*
* Dead coroutines, i.e. ones that returned EOF, get removed from the run queue.
* Feed next coroutine (value returned from coro_yield) with the result from
* previous one (parameter passed to coro_yield).
* Return to dispatcher if there're no more coroutines to run.
*/
static noreturn void coro_switch(int v) {
coro_t *curr = running;
/* TODO: Use description above to implement the body. */
if(v==EOF)
{
TAILQ_REMOVE(&runqueue,curr,co_link);
if(TAILQ_EMPTY(&runqueue))
{
Longjmp(dispatcher,NOTHING);
}
}
if(TAILQ_NEXT(curr,co_link)==NULL)
{
running=TAILQ_FIRST(&runqueue);
Longjmp(TAILQ_FIRST(&runqueue)->co_ctx,v);
}
running=TAILQ_NEXT(curr,co_link);
Longjmp(running->co_ctx,v);
}
/* Save caller context and switch back to next coroutine. */
static int coro_yield(int v) {
int nv = Setjmp(running->co_ctx);
if (nv == 0)
coro_switch(v);
return nv;
}
/* Configure coroutine context to be executed. */
static void coro_add(coro_t *co, void (*fn)(int)) {
int v = Setjmp(co->co_ctx);
if (v) {
/* This will get executed when coroutine is entered first time. */
fn(v);
/* Coroutine must pass EOF to be removed from runqueue! */
coro_switch(EOF);
}
/* Coroutine will be running on its private stack! */
co->co_ctx->rsp = co->co_stack + CORO_STKSIZE;
TAILQ_INSERT_TAIL(&runqueue, co, co_link);
}
/* Take first coroutine and feed it with passed value. */
static int coro_run(int v) {
running = TAILQ_FIRST(&runqueue);
int nv = Setjmp(dispatcher);
if (nv == 0)
Longjmp(running->co_ctx, v);
return nv;
}
/*
* Actual coroutines that perform some useful work.
*/
static void func_1(int _) {
int words = 0;
char prev_ch = ' ';
char ch;
while (Read(0, &ch, 1) > 0) {
if (isspace(ch)) {
if (isspace(prev_ch))
continue;
words++;
}
coro_yield(ch);
prev_ch = ch;
}
if (!isspace(ch))
words++;
dprintf(STDERR_FILENO, "\nfunc_1: words = %d\n", words);
}
static void func_2(int ch) {
int removed = 0;
while (ch != EOF) {
if (!isalpha(ch)) {
removed++;
ch = NOTHING;
}
ch = coro_yield(ch);
}
dprintf(STDERR_FILENO, "func_2: removed = %d\n", removed);
}
static void func_3(int ch) {
int printed = 0;
while (ch != EOF) {
if (ch != NOTHING) {
printed++;
if (islower(ch))
ch = toupper(ch);
else if (isupper(ch))
ch = tolower(ch);
Write(STDOUT_FILENO, &ch, 1);
}
ch = coro_yield(NOTHING);
}
dprintf(STDERR_FILENO, "func_3: printed = %d\n", printed);
}
int main(void) {
coro_t co[3];
coro_init(&co[0], "func_1");
coro_init(&co[1], "func_2");
coro_init(&co[2], "func_3");
coro_add(&co[0], func_1);
coro_add(&co[1], func_2);
coro_add(&co[2], func_3);
coro_run(NOTHING);
coro_destroy(&co[0]);
coro_destroy(&co[1]);
coro_destroy(&co[2]);
dprintf(STDERR_FILENO, "Bye, bye!\n");
return EXIT_SUCCESS;
}