Operační systém UNIX a jazyk C

Jan Brodský
Luděk Skočovský

SNTL Nakladatelství technické literatury Praha 1989, ISBN 80-03-00049-1

12. Přenositelnost Unixu


Program je přenositelný, je-li na jeho uvedení do provozu v prostředí jiného počítače (operačního systému) třeba méně času, než by si vyžádalo jeho nové programování. Program je zcela přenositelný, není-li nutno jeho zdrojovou podobu měnit.

12.1 Přenositelnost obecně

Programy psané v asembleru nejsou přenositelné. Dokonce i programy psané ve vyšších programovacích jazycích jsou málokdy přenositelné. Je to způsobeno explicitními nebo implicitními předpoklady o strojově závislých rysech, jako je počet bitů slabiky a slova, znakové abecedy, struktura a organizace souborů, ovládání periferních zařízení atd. Nejrozšířenější programovací jazyky univerzálního použití (Cobol, Fortran, Pascal, Ada) mají i přes normalizační snahy dialekty nebo jsou dostupné jen pro některé počítače. Vážným důvodem pro vznik dialektů je efektivita, dosahovaná využíváním specifických rysů použitého počítače a operačního systému.

Přitom přenositelnost programů je ekonomicky významná. Je-li nepřenositelný program využíván v několika místech na různých počítačích, náklady na vývoj a údržbu programu se násobí počtem různých počítačů, resp. operačních systémů. Navíc technické vybavení se vyvíjí někdy rychleji než programové vybavení.

Nepřenositelné programy zůstanou vázány na zastaralou techniku, pokud nejsou znovu přepsány. Někdy je nevýhodné, je-li uživatel vázán na počítač jediného výrobce. Všechny tyto důvody mluví pro přenositelnost programů.

V této souvislosti se vynořuje otázka, proč nepřenést celý operační systém.

Proti mluví silná závislost operačního systému na technickém vybavení a fakt, že programy v asembleru (a operační systémy mezi ně zpravidla patří) nejsou přenositelné.

Pro mluví jednotné prostředí pro vývoj a užívání programů, dále současný trend ve vývoji technického vybavení k rychlejším procesorům a větším pamětem. Cena za použití vyššího programovacího jazyka, měřená spotřebou paměti a režií systému, bude tedy přijatelná.

Unix je vhodným kandidátem na přenositelný operační systém. Je z více než 90% napsán v jazyku C, nepříliš vzdáleném od úrovně strojového kódu, závislost na technickém vybavení lze izolovat do několika málo částí systému.

12.2 Projekt přenositelného Unixu

První přenos Unixu probíhal v letech 1977/78 v Bell Laboratories z 16bitového počítače PDP-11 na 32bitový střediskový počítač Interdata 8/32 [4]. Cílem projektu bylo:

Projekt byl úspěšný. Ustavil neformální normu jazyka C [17] a zahájil přenos Unixu na řadu počítačů. Mezi nejznámější z nich patří verze z univerzity v Berkeley pro VAX (poslední je 4.3BSD), Xenix pro mikropočítače, HP-UX s rozšířením pro reálný čas pro řadu HP 9000 [27], počítače IBM System/370 [2], minipočítač IBM Series/1 [13]. Práce na přenosu Unixu pokračovaly i uvnitř AT&T pro multiprocesorovou sestavu se společnou pamětí a na systémy s procesory Intel 8086, Univac 1100, 3B20S a 3B5 [2].

Přenositelný kompilátor jazyka C má ve zdrojovém tvaru asi 8000 řádků. První průchod (lexikální a syntaktická analýza, správa tabulek, výstavba stromů pro výrazy, úvod a závěr funkcí) obsahuje 4600 řádků, z toho asi 600 strojově závislých. V druhém průchodu (generování cílového kódu) je asi 1000 řádků strojově závislých; tedy 20% kompilátoru je strojově závislých. V komentářích je vysvětleno, co je třeba změnit pro jiný cílový počítač.

Kompilovaný program se mezi oběma průchody předává v pracovním souboru jako mezikód s výrazy ve formě stromu. To pro kompilátor Fortranu 77 umožnilo napsat pouze první průchod a využít tak generování kódu pro oba jazyky (a jejich portabilní kompilátory).

V průběhu projektu došlo k rozšíření jazyka C o základní typ unsigned, union a možnost předefinovat typy konstrukcí typedef. Úpravy byly vynuceny odlišnými reprezentacemi základních typů na různých procesorech. Reprezentací zde myslíme délku v bitech, zarovnání, adresové uspořádání bitů a slabik v paměti a použitou aritmetiku.

Při psaní přenositelného programu nelze spoléhat na reprezentaci objektů jazyka. Je např. obvyklým (a špatným) postupem zapisovat obsah celé struktury na vnější paměť jako záznam s představou, že může být na jiném počítači přečten jako záznam do struktury téhož typu a zpracován po složkách. To ale může, vzhledem k jinému způsobu (např. zarovnání), dopadnout katastrofálně.

12.3 Přenositelnost a jazyk C

Jazyk C zaručuje pouze, že typ short má nejvýše tolik bitů, kolik int a ten zase nejvýše tolik bitů, kolik long. Typ int je reprezentován buď 16, nebo 32 bity podle použitého procesoru. To může vést k problémům zejména při přenosu programů z 32bitového na 16bitový procesor. Příčin může být více:

Proto zcela přenositelné programy používají proměnné vlastních typů, které jsou definovány v hlavičkových souborech. Stačí pak případně vyměnit definici typů (long) v hlavičkách souboru.

Závislost na reprezentaci je i záludnější. Např. výraz

int x;
x &= 0177400;
nuluje spodních 8 bitů x. To ale platí pouze na 16bitových procesorech (na 32bitových nuluje navíc horních 16 bitů). Přenositelná konstrukce je
x &= ~0377;
která nuluje spodních 8 bitů nezávisle na počtu bitů reprezentace x.

Jazyk C nedefinuje reprezentaci typu char (některé implementace chápou, char jako signed, jiné jako unsigned). Podezřelým je tedy výraz

char c;
c += -5;
jehož vyhodnocení závisí na chápání char. Pro signed je vše v pořádku a výsledek je správný. Je-li chápán jako unsigned, je konstanta -5 přetypována na unsigned s nepředvídatelnou kladnou hodnotou a překvapujícím výsledkem.

Jazyk C nepředepisuje pořadí vyhodnocení výrazů. To může vést k problémům v situacích, jako je např.

a[i] = b[i++];

Pro odhalení podobných záludností byl vytvořen program lint (odst. 6.4.3).

Zastavme se nyní nad problémem definice programovacího jazyka a přenositelnosti. Zůstane-li zachován význam správného programu (tj. programu vytvořeného podle definice jazyka) na více různých počítačích, je program zcela přenositelný. Nezůstane-li na některém počítači význam programu zachován, je na něm jazyk špatně implementován. Implementace jazyka je však vždy kompromisem mezi striktním dodržením definice jazyka a efektivností pro daný počítač. Definice jazyka totiž implikuje model počítače, na kterém je implementace myslitelná. Blíží-li se skutečný počítač modelu, implementace se snadno provede a je efektivní, v opačném případě je asi lepší se o implementaci nepokoušet.

Model počítače pro jazyk C vyžaduje řadu operací nad celočíselnými proměnnými reprezentovanými různým počtem bitů. Typ short musí mít délku alespoň 16 bitů, typ long alespoň 32 bitů. Typ int může mít délku buď jako short, nebo jako long (řídí se efektivností operací). Musí být zajištěny unsigned short a unsigned int (nikoli však unsigned long). Reprezentace typu char musí mít alespoň 8 bitů a reprezentace celočíselných typů musí mít celistvý násobek počtu bitů reprezentace char. Doporučenou standardní abecedou (alespoň pro Unix) je ASCII. Ukazatele jsou často používány (potřebné operace jsou přiřazení, porovnání, přičtení a odečtení celého čísla a indirekce). Ukazatel je vždy spřažen s typem objektu, na který ukazuje. Jediné povolené a často používané přetypování je (char*) a zpět. To u slovně adresovaných počítačů znemožňuje efektivní implementaci některých programů.

Programy v jazyku C obvykle sestávají z mnoha malých, často volaných funkcí. Funkce může být rekurzívně volána a zpravidla užívá několik automatických proměnných, lokálních pro každou aktivaci. Z toho vyplývá nutnost použít zásobníku pro ukládání adresy návratu, automatických proměnných a uschovaných registrů každé aktivace. To ale opět znamená, že efektivnost implementace závisí na tom, jak snadno zvolený počítač pracuje se zásobníkem. Počítač bez podpory zásobníkové architektury (např. s malým počtem indexregistrů a bázových registrů) neumožní dobrou implementaci jazyka C.

12.4 Přenositelnost a jádro

Struktura Unixu je vrstvená a jednoduchá. Technické vybavení je obaleno jádrem, což je jediná složka systému, kterou uživatel nemůže změnit. Jádro poskytuje základní systémové služby (vstupní a výstupní operace, systém souborů a adresářů, práce s procesy).

Jádro a většina uživatelských programů je psána v jazyku C. Jádro je navíc doplněno několika moduly v asembleru (asi 1000 řádků). Pro přenos Unixu je tedy třeba mít na cílovém počítači prostředí jazyka C. Pomůckou pro jeho vytvoření je SGS (Software Generation System), sestávající z přenositelného kompilátoru jazyka C (překládajícího do asembleru), přenositelného asembleru, přenositelného sestavujícího programu a definice přenositleného formátu souborů (COFF - Common Object File Format) [2]. Tím je umožněn i nepřímý SGS (na hostitelském počítači, odlišném od cílového počítače).

Nástroje SGS vytvářejí proveditelný program v rámci adresového prostoru procesu. Ten je členěn (počínaje adresou 0) na text programu, rozšiřitelnou datovou oblast a zásobník. Text programu obsahuje instrukce zpravidla sdílitelné více procesy současně

12.4.1 Přerušení

Proces žádá jádro o provedení služby voláním funkce smluveného jména. Technicky jde o podprogram obsahující instrukci volání supervizoru (např. u PDP-11 TRAP). Využívá se zde mechanismu přerušovacího systému. U většiny současných počítačů slouží přerušení k přepínání procesoru na činnosti vyžadované buď periferními zařízeními, nebo prováděným programem. Mezi periferní zařízení se počítá i časovač. Uveďme nyní příklad mechanismu přerušení počítačů PDP-11, domovských počítačů Unixu.

V okamžiku, kdy procesor přijme žádost o přerušení, uschová obsah čítače instrukcí (PC) a stavového registru (PS) do interních registrů (uchová stav); dále zavede jejich nový obsah ze dvou po sobě jdoucích slov na začátku operační paměti (z příslušného přerušovacího vektoru) a konečně uloží jejich starý obsah z interních registrů do nového zásobníku. Uschování obsahu jiných registrů je již pak věcí programů obsluhujících přerušení. Unix vždy v novém stavovém slově nastavuje režim jádra, tedy režim privilegovaný.

Periferní přerušení mají možnou prioritní úroveň 4 až 7 (7 je nejvyšší nepřerušitelná úroveň periferního přerušení). Procesor rovněž podle nastavení stavového slova pracuje na úrovních 0 až 7; činnost procesoru se přeruší jen periferním přerušením vyšší úrovně.

Instrukce TRAP se chová obdobně jako periferní přerušení, ale není maskovatelná a uplatní se bez ohledu na úroveň činnosti procesoru. Unix pro její obsluhu nastaví procesor do privilegovaného režimu s úrovní 7 (přerušitelnou jen instrukcí TRAP). To znamená, že voláním podprogramu obsahujícího instrukci TRAP dojde k přepnutí procesu z fáze uživatelské do fáze systémové. Této fázi přísluší zásobník v systémovém segmentu procesu, kde je rovněž řada jiných informací o procesu; např. tabulka otevřených souborů a účtovací informace. Systémový segment není z adresového prostoru procesu viditelný.

Jádro je, stejně jako každý jiný program, strukturováno na text, datovou oblast a zásobník. Ten je ale na rozdíl od procesů v uživatelské fázi umístěn v systémovém segmentu procesu. Adresový prostor jádra začíná, stejně jako prostor uživatelského procesu, adresou 0. To má výhodu v tom, že jádro se dá vytvořit stejnými nástroji jako uživatelský program. Systémových zásobníků je více, tedy jádro musí umět měnit obsahy mapovacích registrů samo sobě. To platí ostatně i v případě překrývané struktury textu jak jádra, tak uživatelských programů.

Z hlediska obsahového je jádro členěno na tři části; rutiny návaznosti na procesor, ovládače periferních zařízení a moduly obsluhující volání jádra.

12.4.2 Návaznost jádra na procesor

První skupinu asemblerovských rutin návaznosti na procesor tvoří podprogramy volané ze zbytku jádra. Jsou v asembleru, protože je nelze napsat v C (maskování přerušení, změna mapovacích registrů, přenos dat mezi adresovými prostory, emulace chybějících instrukcí na různých modelech téže řady počítačů apod.). Tyto rutiny musí být při přenosu Unixu na jiný procesor napsány znovu.

Druhou skupinu tvoří rutiny, které předvádějí přerušení (periferní, chybová, volání jádra) na aktivační záznam (podle konvencí kompilátoru jazyka C) příslušené funkce v jádře. Potom zbytek jádra lze chápat jako běžný program.

První z nich je rutina call, která je vyvolána posloupností instrukcí

jsr r0,call
< adresa obslužného programu >

V okamžiku aktivace call je v systémovém zásobníku uložen starý obsah PS a PC; instrukce jsr tam dále uloží starý obsah registru r0 a do r0 uloží adresu slova za jsr; call postupuje dále a ukládá nové stavové slovo, starý r1, starý SP (ukazatel zásobníku), dev (část nového stavového slova určující zařízení, které přerušilo) a konečně vyvolá instrukcí

jsr 7, *(r0)+
obslužný program a ten také uloží návratovou adresu do rutiny call.

Obsah zásobníku je v tomto okamžiku znázorněn na obr. 12.1.

[OBR. 12.1]

Obslužná rutina (např. clock pro obsluhu časovače) je již psána jako funkce v jazyku C. Obsah zásobníku je rutinou call nastaven tak, jako by byla vyvolána

clock(dev, SP, r1, nPS, r0, PC, PS)
protože asemblerovská rutina call vytvořila na systémovém zásobníku aktivační záznam volání funkce.

Na zásobníku dosud nejsou uschovány obsahy zbývajících registrů r2r5; to je úkolem rutiny csv kompilátorové podpory. Kompilátor zajistí, že každá funkce začíná instrukcí

jsr r5,csv
a rutina csv uloží na zásobník obsahy zbývajících registrů. Obsah zásobníku je tak o ně doplněn (obr. 12.2).

Každá funkce v jazyku C končí voláním další rutiny kompilátorové podpory cret. Ta odstraní ze zásobníku obsahy registrů r2r5 a provede návrat do rutiny call. Zbytek rutiny call odstraní postupně dev, ...,starý r0 ze zásobníku a provede instrukci

rtt
návratu po přerušení. Tím se obnoví stav před přerušením a procesor pokračuje v dřívější činnosti.

Obsluha přerušovacího systému v Unixu a aktivační záznam funkce v jazyku C spolu těsně souvisí a návrh kompilátoru a jádra operačního systému musí probíhat současně. Rovněž volací posloupnost funkce musí být navržena efektivně (často se používá). Test efektivity volání funkce je jedním ze základních porovnávacích testů jednotlivých verzí Unixu [28].

12.5 Ovládače

Ovládače zařízení jsou sice psány v jazyku C, ale zpravidla se využívá paměťově mapovaných vstupních a výstupních instrukcí. Ovládací registry zařízení jsou pak adresovány jako speciální, pevná část operační paměti. V Unixu jsou viditelné pouze v adresovém prostoru jádra. Ovládací registry jsou v ovládači deklarovány jako prázdná struktura a odkazuje se na ně přes ukazatel, který je naplněn konstantní adresou prvního z nich.

[OBR. 12.2]

Ovládače jsou strukturovány do několika funkcí s dobře definovaným rozhraním. Jedna z nich obsluhuje příslušné periferní přerušení a je volána rutinou call. Jako příklad uvádíme fiktivní obsluhu časovače v systému s procesorem MC 68000. Přerušení obsluhuje funkce clkintr:

#define CLKADDR	0xf1a080	/* adresa ovládacího registru */
typedef long time_t;
extern time_t time;		/* systémový čas v s*/
struct clk_map {
	char c_a;		/* ovládací registr A */
	char c_b;		/* ovládací registr B */
	char c_c;		/* ovládací registr C */
}

clkintr()
{ register struct clk_map *clk;
register char scratch;

clk=(struct clk_map*)CLKADDR;	/* adresa do ukazatele */
scratch = clk->c_c;		/* nulování příznaků */
time++;				/* zvýšení systémového času */
}

Ovládače zařízení je třeba na cílovém počítači napsat znovu. Není-li cílový počítač vybaven mapovanými instrukcemi vstupu a výstupu, musí se použít asemblerovské moduly.

12.6 Obsluha volání jádra

Funkce volání jádra obsahující instrukci TRAP, která způsobí přerušení. Rutinou call se vyvolá odpovídající obslužný modul. Jejich závislost na architektuře cílového počítače se týká:

Tyto záležitosti musí být upraveny podle vlastností cílového počítače. Vlastnosti cílového počítače požadované jádrem Unixu lze shrnout do bodů:

Z důvodů efektivity je lépe, má-li alespoň dvě sady mapovacích registrů (pro jádro a pro běžící uživatelský proces). Má-li totiž pouze jednu sadu, je třeba při každém přerušení měnit její obsah. Adresový prostor procesu musí být větší než 64KB (některé programy jsou delší). Dále nutnou součástí technického vybavení je časovač a disková paměť; všechny diskové paměti (včetně pružných disků) musí přenášet bloky téže délky.

12.7 Závěr

Poslední částí Unixu, o které jsme zatím nemluvili , jsou nástroje (příkazové interprety shell a C-shell, editory, tar apod.). Ty jsou zpravidla zcela přenositelné. Na několika místech se vyskytuje "nUxi" problém. Jde o problém (přehozeného) pořadí, ve kterém se zpracovávají znaky, uložené po dvou v 16bitovém slově.

Závěrem je třeba říct, že Unix a C jsou přenositelné, nikoli však (možná zatím) zcela přenositlené. O tom svědčí i publikované údaje o změnách při přenosu Unixu na IBM Series/1 [13]. Celkem bylo změněno 33% řádků (samostatné programy 100%, asembler 100%, kompilátor jazyka C 61%, knihovna C 37%, jádro 25%, ostatní programy 4%).