16
Laboratorium Architektury Komputerów
Ćwiczenie 4
Programowanie mieszane
Wprowadzenie
Współcześnie, bardziej złożone oprogramowanie tworzone jest przez zespoły kilku lub kilkunastu programistów. Zazwyczaj każdy z nich koduje (programuje) jeden lub więcej modułów funkcjonalnych, realizujących wyraźnie wyodrębnione operacje tworzonej aplikacji. W tego rodzaju pracach pożądane jest, by każdy z programistów miał szeroką swobodę działania, ograniczoną jedynie przez te elementy oprogramowania, które wiążą ze sobą poszczególne moduły funkcjonalne.
Powyższy postulat realizuje się poprzez kodowanie oprogramowania w postaci wielu oddzielnych plików, zawierających kod źródłowy programu. Zazwyczaj pojedynczy programista tworzy kilka takich plików. Są one następnie tłumaczone na kod zrozumiały przez procesor i scalane (konsolidowane), tworząc kompletny, gotowy program zapisany w pliku .EXE (system Windows) czy .out (system Linux).
W przypadku tworzenia oprogramowania współpracującego z urządzeniami niestandardowymi, zadaniem jednego z modułów funkcjonalnych jest organizowanie współpracy z tym urządzeniem. W wielu przypadkach, ze względu na specyficzne wymagania urządzenia, taki moduł musi być kodowany w asemblerze, niekiedy przez konstruktora urządzenia. W rozpatrywanej sytuacji kod asemblerowy musi być przystosowany do współdziałania z pozostałym oprogramowaniem, kodowanym zazwyczaj w języku wysokiego poziomu (np. C/C++).
Niniejsze opracowanie przybliża zagadnienia związane z współdziałaniem kodu napisanego w asemblerze z kodem w języku C (i po pewnych rozszerzeniach C++), w środowisku 32-bitowym systemu Windows. Bardzo podobne, lub identyczne mechanizmy stosowane są w innych systemach operacyjnych.
Kompilacja, konsolidacja (linkowanie) i ładowanie
W wielu środowiskach programowania wytworzenie programu wynikowego wykonywane jest w dwóch etapach. Najpierw kod źródłowy każdego modułu programu zostaje poddany kompilacji (jeśli moduł napisany jest w języku wysokiego poziomu) lub asemblacji (jeśli moduł napisany jest w asemblerze). W obu tych przypadkach uzyskuje się plik w języku pośrednim (rozszerzenie .OBJ). Następnie uzyskane pliki .OBJ poddaje się konsolidacji czyli linkowaniu. W trakcie linkowania dołączane są także wszystkie niezbędne programy biblioteczne. W rezultacie zostaje wygenerowany plik zawierający program wynikowy z rozszerzeniem .EXE. Plik ten zawiera kod programu w języku maszynowym (czyli zrozumiałym przez procesor), aczkolwiek niektóre jego elementy wymagają korekcji uzależnionej od środowiska, w którym program będzie wykonany. Korekcja ta następuje w trakcie ładowania programu.
Niektóre programy biblioteczne mają charakter uniwersalny i są wykorzystywane przez wiele programów użytkowych. Wygodniej byłoby więc dołączać te programy dopiero w trakcie wykonywania programu, co pozwoliłoby na zmniejszenie rozmiaru pliku .EXE. W takim przypadku mówimy, że program korzysta z biblioteki dynamicznej (zapisanej w pliku z rozszerzeniem DLL). Omawiane fazy translacji pokazane są na poniższym rysunku.
Pliki .OBJ generowane przez różne kompilatory (w danym środowisku) zawierają kod w tym samym języku, który możemy uważać za język pośredni, stanowiący jak gdyby "wspólny mianownik" dla różnych języków programowania.
Podprogramy w technice programowania mieszanego
Problem tworzenia programu, którego fragmenty napisane są w różnych językach programowania wymaga m.in. ustalenia sposobu komunikowania się poszczególnych fragmentów ze sobą. Komunikacja taka staje się stosunkowo łatwa do zrealizowania, jeśli poszczególne fragmenty programu mają postać podprogramów (procedur). Podprogramy stanowią, ze swej natury, w pewien sposób wyizolowaną część programu, a komunikacja z nimi odbywa się wg ściśle ustalonego protokołu, określającego formaty danych i wzajemne obowiązki programu wywołującego i wywoływanego podprogramu. Protokół ten nazywany jest także opisem interfejsu podprogramu (procedury). W ten sposób, w trakcie wykonywania programu, wywoływanie fragmentów napisanych w różnych językach programowania, sprowadza się do wywoływania odpowiednich podprogramów. W przypadku języka C wywołanie podprogramu oznacza po prostu wywołanie funkcji języka C, której kod został zdefiniowany w innym pliku, niekoniecznie napisanym w języku C.
Powyższe rozważania wskazują, że interfejs do podprogramów musi być jasno i przejrzyście zdefiniowany, a zarazem musi być na tyle uniwersalny, by mógł być implementowany przez kompilatory różnych języków programowania. Z tego powodu producenci oprogramowania (m.in. firma Microsoft) ustalają pewne niskopoziomowe protokoły wywoływania podprogramów, przeznaczone dla wytwarzanych przez nich kompilatorów języków programowania.
Omawiane protokoły, a także związane z nimi różne reguły i ustalenia opisujące współpracę między modułami tego samego programu, jak również między modułami programu a systemem operacyjnym czy bibliotekami, określane są jako interfejs ABI (ang. Application Binary Interface). Interfejs ABI różni się tym od interfejsu API, że dotyczy programów w wersji binarnej lub skompilowanej (w języku pośrednim) podczas gdy interfejs API dotyczy kodu źródłowego.
Interfejs ABI definiuje sposób wywoływania funkcji, przekazywania argumentów i wyników, określa wymagania dotyczące zachowania rejestrów, postępowania z parametrami przekazywanymi przez stos, itp. W dalszym ciągu rozpatrzymy szczegóły interfejsu ABI dotyczące trybu 32-bitowego, a w dalszej części omówimy nieco bardziej złożony interfejs stosowany w trybie 64-bitowym.
Konwencje wywoływania podprogramów stosowane w trybie 32-bitowym
W oprogramowaniu komputerów osobistych rodziny PC, wyłoniły się trzy typy interfejsu procedur. Jeden z nich używany jest przez kompilatory języka C (standard C), drugi przez kompilatory Pascala (standard Pascal), a trzeci standard StdCall stanowiący połączenie dwóch poprzednich, używany jest w systemie Windows do wywoływania funkcji wchodzących w skład interfejsu Win32 API.
Główne różnice między standardami dotyczą kolejności ładowania parametrów na stos i obowiązku usuwania parametrów, który należy najczęściej do wywołanego podprogramu (funkcji), jedynie w standardzie C zajmuje się tym program wywołujący. W standardzie Pascal parametry wywoływanej funkcji zapisywane są na stos kolejności od lewej do prawej, natomiast w standardzie C i StdCall od prawej do lewej. Istnieją też opisane dalej inne różnice.
Standard
Kolejność ładowania na stos
Obowiązek zdjęcia parametrów
Pascal
od lewej do prawej
wywołany podprogram
C
od prawej do lewej
program wywołujący
StdCall
Dalsze wymagania są następujące.
1. W trybie 32-bitowym parametry podprogramu przekazywane są przez stos. W standardach C i StdCall parametry ładowane są na stos w kolejności odwrotnej w stosunku do tej w jakiej podane są w kodzie źródłowym, np. wywołanie funkcji calc (a,b) powoduje załadowanie na stos wartości b, a następnie a.
2. Jeśli parametr ma postać pojedynczego bajtu, to na stos ładowane jest podwójne słowo (32 bity), którego najmłodszą część stanowi podany bajt.
3. Jeśli parametrem jest liczba 64-bitowa (8 bajtów), to najpierw na stos ładowana jest starsza część liczby, a następnie jej młodsza część. Taki schemat ładowania stosowany jest w komputerach, w których liczby przechowywane są w standardzie mniejsze niżej (ang. little endian) i wynika z faktu, że stos rośnie w kierunku malejących adresów.
4. Obowiązek zdjęcia parametrów ze stosu po wykonaniu podprogramu w przypadku standardu C należy do programu wywołującego. Funkcje systemowe Windows stosują standard Stdcall, w którym parametry zapisane na stosie zdejmowane są wewnątrz wywołanej funkcji. Również w standardzie Pascal parametry zdejmowane są wewnątrz wywołanej funkcji.
5. W standardzie C jeśli parametrem funkcji jest nazwa tablicy, to przekazywany jest adres tej tablicy.
6. Wyniki podprogramu przekazywane są przez rejestr EAX. Wyniki 8-bitowe przekazywane są przez rejestr AL, a 16-bitowe przez rejestr AX. Jeśli wynikiem podprogramu jest adres (wskaźnik), to przekazywany jest także przez rejestr EAX. Jeśli wynikiem jest liczba zmiennoprzecinkowa typu float lub double, to wynik ten dostępny jest na wierzchołku stosu rejestrów koprocesora.
7. Jeśli podprogram zmienia zawartość rejestrów EBX, EBP, ESI, EDI, to powinien w początkowej części zapamiętać je na stosie i odtworzyć bezpośrednio przed zakończeniem. Pozostałe rejestry robocze mogą być używane bez konieczności zapamiętywania i odtwarzania ich zawartości.
8. Ponadto znaczniki operacji arytmetycznych i logicznych (w rejestrze znaczników) mogą być używane bez ograniczeń. Znacznik DF powinien być zerowany zarówno przed wywołaniem podprogramu, jak i wewnątrz podprogramu przed rozkazem RET, jeśli używane były rozkazy operacji blokowych (np. MOVSB).
Podprogramy kodowane w asemblerze
Omawiany wyżej standard C jest standardem domyślnym dla programów napisanych w językach C i C++ (programy w C++ wymagają dodatkowych działań — zob. dalszy opis). Opcjonalnie można zdefiniować funkcję (podprogram), która będzie wywoływana w standardzie StdCall lub Pascal.
Podprogram w asemblerze przystosowany do wywoływania z poziomu języka C musi być skonstruowany dokładnie wg tych samych zasad co funkcje w języku C. Wynika to z faktu, że program w języku C będzie wywoływał podprogram w taki sam sposób, w jaki wywołuje inne funkcje w języku C.
Wszystkie nazwy globalne zdefiniowane w treści podprogramu w asemblerze muszą być wymienione na liście dyrektywy PUBLIC. Jednocześnie nazwy innych używanych zmiennych globalnych i funkcji muszą być zadeklarowane na liście dyrektywy EXTRN.
Ze względu na konwencję nazw stosowaną przez kompilatory języka C, każdą nazwę o zasięgu globalnym wewnątrz podprogramu asemblerowego należy poprzedzić znakiem podkreślenia _ (nie dotyczy to standardu StdCall).
Technika przekazywania parametrów przez stos
Mechanizmy przekazywania parametrów przez stos rozpatrzmy na przykładzie funkcji (podprogramu)
int szukaj_max (int a, int b, int c);
która wyznacza największą liczbę całkowitą, spośród trzech liczb podanych jako argumenty funkcji. Podana funkcja, wraz z odpowiednimi parametrami, zostanie wywołana na poziomie języka C, ale kod funkcji zostanie napisany w asemblerze. Przykładowy program w języku C, w którym wywoływana jest omawiana funkcja może mieć postać:
#include <stdio.h>
int main()
{
int x, y, z, wynik;
printf("\nProszę podać trzy liczby całkowite: ");
scanf_s("%d %d %d", &x, &y, &z, 32);
wynik = szukaj_max(x, y, z);
printf("\nSpośród podanych liczb %d, %d, %d, \
liczba %d jest największa\n", x,y,z, wynik);
return 0;
}
W reprezentacji maszynowej podanego programu, bezpośrednio przed wywołaniem funkcji szukaj_max zostaną wykonane trzy rozkazy push, które umieszczą na stosie aktualne wartości zmiennych z, y, x (parametry ładowane są na stos w kolejności od prawej do lewej). Następnie zostanie wykonany rozkaz call, który wywoła omawianą funkcję (podprogram). Zarówno trzy rozkazy push, jak i rozkaz call stanowią fragment kodu programu, który został wygenerowany przez kompilator języka C. Po wykonaniu rozkazu call procesor rozpocznie wykonywanie kolejnych rozkazów podprogramu (funkcji) szukaj_max. W tym momencie sytuacja na stosie będzie następująca:
W celu wyznaczenia największej liczby spośród podanych x,y,z, wywołany podprogram musi oczywiście odczytać te liczby ze stosu. Jednak odczytywanie parametrów ze stosu za pomocą rozkazu pop byłoby kłopotliwe: wymagałoby uprzedniego odczytania śladu rozkazu call, a po wykonaniu obliczeń należało by ponownie załadować tę wartość na stos. Odczytane parametry można by umieścić w rejestrach ogólnego przeznaczenia — rejestry te jednak używane są do wykonywania obliczeń i przechowywania wyników pośrednich. W tej sytuacji umieszczenie wartości x,y,z w rejestrach ogólnego przeznaczenia mogłoby znacznie utrudnić kodowanie podprogramu ze względu na brak wystarczającej liczby rejestrów.
W celu zorganizowania wygodnego dostępu do parametrów umieszczonych na stosie przyjęto, że obszar zajmowany przez parametry będzie traktowany jako zwykły obszar danych. W istocie stos jest bowiem umieszczony w pamięci RAM i nic nie stoi na przeszkodzie, by w pewnych sytuacjach traktować jego zawartość jako zwykły obszar danych.
Dostęp do danych znajdujących się w obszarze stosu wymaga znajomości ich adresów. W każdej chwili znane jest położenie wierzchołka stosu: wskaźnik stosu ESP określa adres komórki pamięci, w której znajduje dana ostatnio zapisana na stosie, czyli wierzchołek stosu. Aktualnie na wierzchołku stosu znajduje się ślad rozkazu call, a powyżej wierzchołka stosu (posuwając się górę, czyli w głąb stosu) znajduje się wartość x, jeszcze dalej y, i w końcu z. Ponieważ każda wartość zapisana na stosie zajmuje 4 bajty, więc wartość x znajduje się w komórce pamięci o adresie równym zawartości rejestru ESP powiększoną o 4, co na rysunku oznaczone jest jako [esp] + 4. Analogicznie wartość y dostępna jest pod adresem [esp] + 8, a wartość z pod adresem [esp] + 12.
Ponieważ zawartość rejestru ESP może się zmieniać w trakcie wykonywania podprogramu (np. wskutek wykonywania rozkazów push i pop), konieczne jest użycie innego rejestru, którego zawartość, ustalona przez cały czas wykonywania podprogramu, będzie wskazywała obszar parametrów na stosie — rolę tę pełni, specjalnie do tego celu zaprojektowany rejestr EBP. Jeśli zawartość rejestru EBP będzie równa zawartości ESP, to w podanych wyrażeniach symbol esp można zastąpić przez ebp.
Zgodnie z podanymi wcześniej wymaganiami interfejsu ABI, użycie w podprogramie rejestru EBP wymaga zapamiętania jego zawartości na początku podprogramu i odtworzenia w końcowej części podprogramu. Zatem przed skopiowaniem zawartości rejestru ESP do EBP konieczne jest zapamiętanie zawartości rejestru EBP na stosie. Ostatecznie więc dwa pierwsze rozkazy podprogramu będą miały postać:
push ebp ; zapisanie zawartości EBP na stosie
mov ebp,esp ; kopiowanie zawartości ESP do EBP
Rozkazy te występują prawie zawsze na początku podprogramu i określane są jako standardowy prolog podprogramu (funkcji).
Zapisanie zawartości rejestru EBP na stosie spowodowało zmianę wyrażeń adresowych opisujących położenie wartości x,y,z. Aktualna sytuacja na stosie pokazana jest na rysunku obok.
W tym momencie można przystąpić do poszukiwania liczby największej. W kodzie programu w języku C określono typ parametrów funkcji szukaj_max jako int, co oznacza że parametry te są 32-bitowymi liczbami ze znakiem (kodowanymi w systemie U2). W trakcie porównywania liczb używać będziemy więc rozkazów jg jge, jl, jle. Najpierw porównywane są wartości x i y — jeśli liczba x jest większa lub równa od y, to następnie wartość x jest porównywana z wartością z, a w przeciwnym razie wykonywane jest porównywanie wartości y i z...
sote12