Moduł 7

Materiały do zajęć z Systemów operacyjnych prowadzonych na Wydziale Matematyki i Informatyki Uniwersytetu im. Adama Mickiewicza w Poznaniu.

« Wróć do spisu materiałów

Program make

W tym paragrafie będziemy korzystać z plików źródłowych dostępnych tutaj.

Choć w przypadku prostych projektów, złożonych z jednego lub dwóch plików źródłowych, ręczne wywoływanie kompilatora nie stanowi problemu, to jednak w przypadku skomplikowanych aplikacji, złożonych już z kilku plików, może to być męczące i wymagające czasowo, szczególnie jeśli kompilacja poszczególnych części zajmuje dużo czasu. Chcielibyśmy bowiem uniknąć sytuacji, w której musimy niepotrzebnie kompilować pewne fragmenty kodu.

Na szczęście program make pozwala nam zautomatyzować proces kompilacji. Poszukuje on w bieżącym katalogu pliku o nazwie makefile, a następnie wykonuje zawarte w nim instrukcje. Jeśli chcemy, aby korzystano z innego pliku z instrukcjami, możemy wywołać program make z opcję -f.

Zapoznaj się z podręcznikiem programu make.

W ogólności, plik makefile składa się z jednego lub wielu bloków postaci:

pliki docelowe: pliki zależne
[tab] polecenie
      ...

Jeśli nie stwierdzono inaczej, program make oprze swoje działanie na pierwszym bloku w pliku makefile.

Utwórz w katalogu z projektem plik makefile, o treści:

program:
	gcc main.c hello.c silnia.c -o program

a następnie wykonaj polecenie

$ make

Zapoznaj się z efektami.

Zawartość pliku docelowego może zależeć od innych plików. W naszym przykładzie, zawartość pliku obiektu main.o, tworzonego w procesie assemblacji, zależy od plików main.c oraz funkcje.h. Jeśli czas modyfikacji któregokolwiek z tych plików będzie nowszy niż czas utworzenia pliku main.o, konieczne jest ponowne wygenerowanie tego ostatniego. Jeśli jednak plik main.o jest nowszy niż wszystkie pliki, od których zależy, nie trzeba go ponownie tworzyć. Dzięki temu można oszczędzić zasoby procesora. Efekt procesu linkowania, równoważny poleceniu

$ gcc main.o hello.o silnia.o -o program

zależy z kolei od plików obiektów main.o, hello.o oraz silnia.o. Jeśli któryś z nich jest nowszy niż plik program, należy wykonać proces linkowania. W przeciwnym przypadku jest to zbędne. To rozumowanie przedstawia ogólną zasadę działania programu make.

Jeśli w danym bloku pliku makefile zdefiniowane są pliki zależne, muszą one istnieć zanim wykonane zostaną instrukcje z bloku. Może się też zdarzyć, że w wyniku działania innego bloku ich data modyfikacji uległaby zmianie. Dlatego też program make poszukuje w pliku makefile bloków, których wykonanie może mieć wpływ na zawartość plików zależnych i wykonuje te bloki najpierw. Bloki te z kolei mogą zależeć od innych plików, na który wpływ mogą mieć instrukcje w innych blokach itd. Tak oto powstaje drzewo zależności, które jest wpierw konstruowane, a następnie wykonywane od liści do korzenia.

Zmodyfikuj plik makefile z poprzedniego ćwiczenia tak, aby był postaci:

program: main.o hello.o silnia.o
	gcc main.o hello.o silnia.o -o program

main.o: main.c funkcje.h
	gcc -c main.c

hello.o: hello.c
	gcc -c hello.c

silnia.o: silnia.c
	gcc -c silnia.c

clean:
	-rm *.o program

Następnie odwołaj się do instrukcji w odpowiednich blokach pliku makefile, wykonując polecenia:

$ make hello.o
$ make hello.o
$ make
$ make clean

Zapoznaj się z efektami. Skąd program make wie, że (wraz z wywołaniem drugiego i trzeciego polecenia) nie trzeba ponownie wykonywać instrukcji w bloku generującym plik hello.o? Za co odpowiada symbol łącznika przed poleceniem rm w bloku clean? Dlaczego utworzyliśmy blok clean, mimo że w wyniku poleceń w jego wnętrzu nie jest tworzony żaden plik o takiej nazwie?

Wykonaj dwukrotnie polecenie

$ make program

Następnie zmień nieznacznie treść pliku hello.c, by w końcu wykonać ponownie polecenie

$ make program

Jak zachował się program make? Narysuj drzewo zależności dla bloku program i prześledź sposób myślenia programu, aby wyjaśnić to zachowanie.

Funkcje systemowe w języku ANSI C

Wraz ze standaryzacją języka C (standard ANSI C) powstała specyfikacja dynamicznej biblioteki standardowej języka C (zwanej ISO C Library), której zadaniem jest udostępnianie programistom pewnych funkcji (takich jak np. printf). Jasne określenie, jakie funkcje mają być dostępne dla programistów, jak mogą być wywoływane i jakie wartości mają zwracać, pozwala tworzyć łatwo przenośne programy. Nie jest jednak zaskoczeniem, że implementacja funkcji z biblioteki standardowej różni się pomiędzy systemami operacyjnymi – korzystają one bowiem z różnych niskopoziomowych funkcji, udostępnianych przez jądro systemu (wywoływanych przez tak zwane system calls lub kernel calls).

Równolegle do standardu ANSI C wprowadzono standard POSIX, który określa funkcje udostępniane przez bibliotekę standardową C (zwaną POSIX C Library) w systemach zgodnych z tym standardem. Tę wersję będziemy nazywać krótko libc i to na niej będziemy opierać się na tych zajęciach.

Biblioteka libc udostępnia generyczną funkcję syscall, zadeklarowaną w nagłówku unisys.h,[1] umożliwiającą bezpośrednie odwołania do jądra systemu.[2] Udostępnia ona dodatkowo interfejsy do odwołań systemowych, ułatwiające korzystanie z nich. W ramach tych zajęć będziemy korzystać z tych interfejsów, nazywając je dla ułatwienia funkcjami systemowymi.

Dowiedz się, jakie pliki nagłówkowe są związane z biblioteką standardową ISO C, a jakie z biblioteką standardową POSIX C. Odnajdź kody źródłowe nagłówków stdio.h, unisys.h oraz fcntl.h i dowiedz się, jakie funkcje są zadeklarowane w każdym z nich.

[1] Funkcje zdefiniowane w bibliotece standardowej są zadeklarowane w licznych nagłówkach, podzielonych tematycznie.

[2] Lista możliwych odwołań dla jądra systemu Linux jest dostępna na przykład na stronie http://docs.cs.up.ac.za/programming/asm/derick_tut/syscalls.html, a stałe odpowiadające wartościom liczbowym dla poszczególnych odwołań zadeklarowane są zawsze w nagłówku sys/syscall.h.

W systemach z rodziny Linux oraz FreeBSD biblioteka matematyczna, definiująca funkcje zadeklarowane w nagłówku math.h, jest dostarczana osobno jako libm. W wielu innych systemach funkcje matematyczne są częścią biblioteki standardowej libc.

Obsługa plików

Do obsługi plików możemy wykorzystać funkcje systemowe open, creat, close, read, write oraz lseek. Pierwsze dwie zadeklarowano w nagłówku fcntl.h, pozostałe zaś w nagłówku unistd.h.

Korzystając z poleceń

$ man 2 read
$ man 2 write

zapoznaj się z konstrukcją funkcji read i write? Jakie przyjmują argumenty? Jakie wartości zwracają?

Zobacz odpowiedź

Obie funkcje przyjmują za argumenty, kolejno, numer deskryptora, wskaźnik i rozmiar danych oraz zwracają rozmiar odczytanych/zapisanych danych.

Zapoznaj się z poniższym kodem źródłowym:

#include <unistd.h>

#define BUFSIZ 128

int main(){
    char buf[BUFSIZ];
    int n;

    while ((n = read(0, buf, BUFSIZ)) > 0)
        write(1, buf, n);
    return 0;
}

Co robi ten program? Przeanalizuj jego kod linia po linii. Czy znasz program, który zachowuje się podobnie?

Zapoznaj się z konstrukcją funkcji creat, open, lseek oraz close. Jakie nagłówki są niezbędne do tego, aby skorzystać z każdej z nich?

Zobacz odpowiedź

Funkcje open i creat wymagają nagłówków sys/types.h, sys/stat.h oraz fcntl.h, close nagłówka unistd.h, a lseek nagłówków sys/types.h oraz unistd.h.

Funkcja creat zwraca deskryptor utworzonego pliku lub -1, jeśli tworzenie pliku zakończyło się niepowodzeniem. Jako argumenty przyjmuje ścieżkę do pliku i wartość typu mode_t, określającą parametry trybu. Tę ostatnią tworzy się wykorzystując sumę logiczną stałych (flag) zdefiniowanych w pliku nagłówkowym sys/stat.h.

Korzystając z podręcznika funkcji creat, zapoznaj się z flagami trybu (o nazwach rozpoczynających się od S_). Następnie zinterpretuj poniższe wywołania funkcji creat:

  • int f = creat("plik.txt", S_IRUSR | S_IRGRP);
  • int f = creat("plik.txt", S_IRWXU | S_IRUSR);
  • int f = creat("plik.txt", (mode_t) 0644);
  • int f = creat("plik.txt", 0644);

Jak działa operator |? Czy w którymś przypadku flagi się pokrywają (tzn. pominięcie którejś nie wpłynie na wartość sumy logicznej)? Dlaczego trzecie i czwarte wywołanie są równoważne? Czy pominięcie wiodącego zera wpłynie na interpretację wartości? Dlaczego?

Zobacz odpowiedź

Trzecie i czwarte wywołania są równoważne, bo rzutowanie typu i tak się odbywa. Wiodące zero jest niezbędne, bowiem wskazuje wyraźnie na to, że liczba jest zapisana w postaci ósemkowej, a nie dziesiętnej.

Określ, jak zostanie zinterpretowany ostatni argument wywołania funkcji creat, jeśli napiszemy

int f = creat("plik.txt", 420);

Dlaczego?

Czy wartość maski uprawnień, określonej za pomocą polecenia umask, ma wpływ na uprawnienia do tworzonych plików?

Zobacz odpowiedź

Tak, uprawnienia określone jako argument są filtrowane przez dopełnienie maski.

Funkcja open zwraca deskryptor otwartego pliku lub -1, jeśli pliku nie udało się otworzyć. Jako argumenty przyjmuje ścieżkę do pliku, flagi związane z obsługą pliku i (opcjonalnie) argument związany z trybem dostępu do pliku. Podobnie jak w przypadku trybu, flagi związane z obsługą pliku konstruuje się jako sumę logiczną stałych (flag) zdefiniowanych w pliku nagłówkowym fcntl.h.

Korzystając z podręcznika funkcji open, zapoznaj się z następującymi flagami (o nazwach rozpoczynających się od O_), związanymi z obsługą pliku:

  • O_CREAT,
  • O_APPEND,
  • O_RDONLY,
  • O_RDWR,
  • O_WRONLY.

Następnie zinterpretuj poniższe wywołania funkcji open:

  • int f = open("plik.txt", O_RDWR);
  • int f = open("plik.txt", O_WRONLY | O_APPEND);
  • int f = open("plik.txt", O_RDWR | O_CREAT, S_IRUSR | S_IRGRP);
  • int f = open("plik.txt", O_RDONLY | O_CREAT, 0644);

Kiedy wartość trzeciego argumentu jest brana pod uwagę?

Zobacz odpowiedź

Trzeci argument jest brany pod uwagę w chwili tworzenia pliku, to znaczy wtedy, gdy przekazana jako drugi argument wartość wskazuje na wykorzystanie flagi O_CREAT.

Utwórz plik o nazwie plik.txt i zawartości równej xxxxxxxxxx (10 razy x). Następnie skompiluj i uruchom następujący program:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFSIZ 5

int main(){
    char t[BUFSIZ];
    int f = open("plik.txt", O_RDWR);
    read(f, t, BUFSIZ);
    write(f, "X", 1);
    return 0;
}

Jak wygląda zawartość pliku plik.txt po jego wykonaniu?

Do poruszania się wewnątrz pliku służy funkcja lseek. Przyjmuje ona trzy argumenty: deskryptor pliku, dodatnią lub ujemną wartość przesunięcia (w bajtach, typu off_t) oraz jeden z warunków przesunięcia – SEEK_SET (przesunięcie względem początku pliku), SEEK_CUR (przesunięcie względem bieżącej pozycji w pliku) lub SEEK_END (przesunięcie względem końca pliku).

Dowiedz się, jak na działanie funkcji lseek wpływają warunki przesunięcia SEEK_DATA oraz SEEK_HOLE.

Ostatnią z prezentowanych funkcji jest close. Przyjmuje ona jeden argument – deskryptor pliku, który chcemy zamknąć. Jeśli operacja zakończy się powodzeniem, zwracana jest wartość 0.

System operacyjny dba o to, aby programy nie nadwyrężały komunikacji z plikami. Dlatego też liczba kanałów komunikacji z plikami, z których może jednocześnie korzystać program jest ograniczona. To dobry powód, aby zamykać pliki, z których nie będzie się już korzystać.

Zapoznaj się z zawartością pliku źródłowego cp.c, dostępnego tutaj. Przeanalizuj, w jaki sposób wykorzystane są w nim funkcje systemowe, skompiluj go, a następnie wypróbuj. Jak działa funkcja perror?

Zobacz odpowiedź

The perror() function produces a message on standard error describing the last error encountered during a call to a system or library function.

Napisz program, który wypisuje na ekranie zawartość plików, których nazwy wskazano jako argumenty. Jeśli odczytanie pliku nie jest możliwe, należy wyświetlić odpowiedni błąd.

Zobacz odpowiedź

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

#define BUFSIZ 128

int main(int argc, char *argv[]){
    char buf[BUFSIZ];
    int f,i,n;

    for(i=1; i<argc; i++){
        if((f = open(argv[i], O_RDONLY)) > 0){
            while ((n = read(f, buf, BUFSIZ)) > 0)
                write(1, buf, n);
            close(f);
        } else {
            perror(argv[i]);
        }
    }
    return 0;
}

Struktura procesów

Z wcześniejszych zajęć pamiętamy, że jedną z charakterystycznych cech systemów uniksopodobnych jest hierarchiczna struktura procesów. Fakt, że procesy tworzą strukturę drzewa sprawia, że każdy z nich nie tylko identyfikuje się jednoznacznie wartością PID, ale także (z wyjątkiem procesu init) ma dokładnie jednego rodzica.

W nagłówku unistd.h zadeklarowane są funkcje getpid oraz getppid, obie typu pid_t zdefiniowanego w nagłówku sys/types.h. Zwracają one, odpowiednio, identyfikator bieżącego procesu i jego rodzica.

Przeanalizuj następujący kod źródłowy:

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(){
    printf("PID: %d, PPID: %d\n", getpid(), getppid());
    return 0;
}

Co robi ten program? Sprawdź, jak zmieni się efekt jego działania z każdym kolejnym jego uruchomieniem. Jaki proces jest związany z wartością zwracaną przez funkcję getppid?

Do duplikowania procesu służy funkcja fork zadeklarowana w nagłówku unistd.h. Tworzy ona dokładną kopię procesu (wraz ze wszystkimi zmiennymi, zarówno lokalnymi jak i globalnymi), umieszcza go w hierarchii procesów w pozycji dziecka procesu wywołującego, a następnie w obu instancjach zwraca wartość: w procesie macierzystym PID nowego procesu-dziecka, a w utworzonym procesie wartość zero. Jeśli proces duplikacji zakończy się niepowodzeniem, funkcja fork zwróci wartość ujemną.

Przeanalizuj następujący kod źródłowy:

#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>

int main(){
    int pid;

    if((pid = fork()) >= 0){
        if(pid == 0){
            printf("Dziecko - PPID: %d\n, PID: %d",
                   getppid(), getpid());
        } else {
            printf("Rodzic - PPID: %d, PID: %d, CPID: %d\n",
                   getppid(), getpid(), pid);
        }
    } else {
        perror("fork");
    }

    return 0;
}

Co robi ten program? Przetestuj jego działanie.

Korzystając z zasobów internetu, dowiedz się, jakie mogą być praktyczne zastosowania funkcji fork.[1]

Jeśli jesteś leniwy, wejdź po prostu na stronę http://stackoverflow.com/questions/985051/what-is-the-purpose-of-fork.

Co by się stało, gdyby proces rodzica zakończył się przed procesem dziecka? Czy wówczas proces potomny zostałby automatycznie unicestwiony? Jeśli nie, to jaki byłby wówczas identyfikator jego rodzica? Dowiedz się, czym jest (w rozumieniu procesów) sierota i jak tworzy się procesy demonów.