Efektywne programowanie w Turbo Pascalu

ŁF

Efektywne programowanie w Turbo Pascalu

Czyli co zrobić, żeby program był mały i szybki, czego się wystrzegać, żeby nie był tragicznie wolny, jakie czary rzucać, żeby z 16 bitów zrobiło się 32 - i podstawy assemblera, bo bez tego się nie obejdzie.

1 Efektywne programowanie w Turbo Pascalu
     1.1 Zmienne
     1.2 Parametry
     1.3 Instrukcje warunkowe
     1.4 Liczby rzeczywiste</code>
     1.5 Liczby całkowite</code>
          1.5.1 Operatory shl i shr</code>
          1.5.2 Div i mod</code>
     1.6 Trochę assemblera</code>
     1.7 Operacje 32-bitowe</code>
     1.8 Typy</code>
          1.8.3 Zbiory</code>
          1.8.4 Char, byte, string, pointer</code>
     1.9 Błędy</code>
2 Zaawansowana optymalizacja kodu</code>
     2.10 Bezpośredni dostęp do pamięci</code>
          2.10.5 Absolute</code>
     2.11 Przerwania</code>
3 Ustawienia kompilatora</code>

Zmienne

Kompilator Turbo Pascala dokleja na początku kodu exeka procedurkę, która czyści zawartość stosu; innymi słowy - wszystkie zmienne globalne są zerowane, niezależnie od tego, czy tego chcemy, czy nie. Z jednej strony nas to martwi, bo nie zawsze potrzebujemy "czyszczenia" stosu - zabiera to cenny czas procesora. Z drugiej strony jest to dość wygodne, bo nie musimy sami zerować wszystkich zmiennych globalnych. Dlatego też instrukcja taka jak poniższa

var
  x : byte;

begin
  x := 0
end.

jest kompletnie bez sensu.

Ale co zrobić, kiedy chcemy zmienną globalną zainicjować wartością inną niż 0? Oczywiście, nie ma problemu, robimy np.:

 x := 1 

i jesteśmy z siebie zadowoleni. Ale TP pozwala nam na zrobienie tego jeszcze inaczej: zmienną x deklarujemy w ten sposób:

const
  x : byte = 1;

I oszczędzamy w ten sposób... jedną instrukcję procesora ;-) Ale i cztery bajty kodu, a to już coś.

Parametry

Teraz co nieco o parametrach.
Pascal w wersji Borlanda umożliwia trzy sposoby przekazywania parametrów:

  • przez wartość - najwolniejszy z możliwych, ale najbezpieczniejszy:
procedure f(x : single)
  • przez adres - tak szybki jak i groźny:
procedure f(var x : single)
  • jako stała - szybki i bezpieczny, ale nie zawsze możliwy:
procedure f(const x : single)

Czym różnią się te trzy sposoby?
Pierwszy powoduje, że cała zawartość parametru jest kopiowana na stos, a to co widzi wywoływana procedura/funkcja, to ta kopia na stosie. Jeśli teraz zmodyfikujemy - chcący bądź niechcący - naszą kopię parametru, to po wyjściu z procedury wartość samego parametru się nie zmieni. To dość bezpieczne rozwiązanie, prawda? Ale mamy mały problem - jeśli parametr reprezentuje sobą coś naprawdę dużego (duży to dla mnie mający ponad 8 bajtów ~:-)), to po pierwsze tracimy czas, którego wymaga skopiowanie parametru, a po drugie tracimy miejsce na stosie, które może się przydać (skądinąd znany błąd Stack overflow). Nota bene - tego sposobu używa C oraz Java.
Popatrzmy na drugi sposób: to, co widzi procedura, to dokładnie nasza zmienna. Na stos jest wrzucany tylko jej 4-bajtowy adres. Wielką zaletą jest to, że program nie kopiuje bezmyślnie tej zmiennej na stos, przez co nie spowalnia niepotrzebnie samego siebie - ale jest i haczyk. Jak Ci wiadomo zmiana wartości takiego parametru wewnątrz funkcji zmienia jej wartość "na stałe", tzn. po wyjściu z procedury może się okazać, że z naszą zmienną, która przed chwilą była parametrem, stało się coś dziwnego, a kilka linijek niżej pojawiają się nie wiadomo dlaczego błędy dzielenia przez zero, przekroczenia wartości ($R i $Q) czy po prostu program się wykrzacza. Oczywiście nam sposób nr 2 bardzo się podoba, bo to najprostsza metoda na zmianę wartości parametru (a nie jego kopii, jak w pierwszej metodzie). Ale - beware :) - im bardziej zaawansowany program, tym większe prawdopodobieństwo pojawienia się wyżej wymienionych nieporządanych efektów. A próbowaliście skompilować coś w tym stylu?

procedure f(var x : single);
begin
  writeln(x:3:2)
end;

begin
  f(1)
end.

Brzydki błąd numer 20 (Variable expected) rozwiąże częściowo sposób trzeci, najbardziej egzotyczny. Łączy w sobie bezpieczeństwo pierwszej metody oraz szybkość metody drugiej. No tak, jest i minus. Parametr przed którym stoi magiczne słowo const jest wewnątrz procedury traktowany jak stała - więc nie ma nadziei, że uda się zmienić jego wartość. I bardzo dobrze, bo do tego służy drugi sposób.

Słówko dla bardziej obeznanych ludków: jeśli wydaje się Wam, że stała to świętość i nie da się zmodyfikować jej wartości, to jesteście w błędzie :) Wiemy przecież, że wszystko zajmuje miejsce w pamięci, nawet stałe. Oczywiście kompilator i debugger wmawiają nam, że nie można zmienić wartości stałej, ba! że nie ma ona w ogóle adresu w pamięci (Ctrl+F4, wklepujemy jakąś stałą poprzedzoną znaczkiem @, Enter i ...Ups! Poznany wcześniej błąd Variable expected), ale od czego nasza złośliwość?
Z pomocą wskaźników robimy taki przekręt:

const
  str = 'jedno wielkie nic';

procedure w(const s : string);
var
  ps : ^string;
begin
  ps := @s;       {@s to adres naszej stałej, 
                  który podobno nie istnieje}
  ps^ := 'xx';
  writeln(s)
end;

begin
  w(str)
end.

I w ten jakże okrutny sposób zmieniamy naszą stałą... Najbardziej wnikliwi pewnie dopiszą jeszcze po linijce w(str); kolejną instrukcję: writeln(str). Zdziwieni? Nasza stała nie zmieniła wartości... Nie czepiać się mnie, tylko kompilatora, który niepotrzebnie dubluje stałą, tzn. dzieje się tak, jakby procedura w została zadeklarowana w taki sposób:

procedure w(s : string);

, czyli duplikujemy w pamięci naszą stałą.
Ogólnie mówiąc - polecam stosowanie pierwszej metody tylko dla parametrów mniejszych od 4 bajtów, wszystkie pozostałe trzeba poprzedzić w deklaracji słówkiem var albo const.

Instrukcje warunkowe

Przejdźmy do czegoś, co pomoże zaoszczędzić jeszcze trochę czasu procesora - instrukcje warunkowe.
Co tu można zyskać albo stracić? Jeśli chodzi o kod źródłowy, to popatrzmy na coś takiego:

...
if b = true then
...

To jest jedno z najczęściej popełnianych nadużyć. Co prawda działa to jak trzeba, i nie dokłada dodatkowych instrukcji, ale męczy palce.
Jak działa instrukcja

if ... then

W miejsce trzech kropek wsadzamy coś, co jest typu logicznego - boolean. Owszem, warunek b = true

 jest tego typu, ale sama zmienna b również. Po co więc dodatkowe literki, skoro można zrobić tak:

```delphi
if b then

Proste? A jeśli chcemy sprawdzić warunek na fałsz, to robimy

if not b then

Jeśli zaś chodzi o kod wykonywalny, to trzeba zajrzeć do opcji kompilatora (Options -> Compiler), i wyłączyć opcję Complete boolean eval. Powoduje ona, że sprawdzane są wszystkie warunki podane w instrukcji if ... else, nawet jeśli już z pierwszego warunku wynika, że instrukcja się wykona/nie wykona.
Zerknijmy na to:

if true or false then

Wiadomo, że

true or false

da w wyniku true, co więcej, kompilator jest na tyle niegłupi, że jeśli dostanie warunek true or ...

 to przy wyłączonej opcji $B nie sprawdzi pozostałych (...) warunków. To zaoszczędzi czas procesora, zwłaszcza jeśli pod trzema kropkami kryją się jakieś funkcje, a nie tylko zmienne. No właśnie: wiemy, że warunki są sprawdzane w kolejności ich wpisania - więc jeśli najpierw do warunku wpiszemy funkcję (zwracającą wartość logiczną), a potem jakieś zmienne, to funkcja będzie wywoływana przy każdym sprawdzaniu warunków. Po co?! Funkcję umieszczamy na końcu, wtedy jest szansa, że z pozostałych zmiennych uda się od razu określić wynik warunku - bez potrzeby wywoływania funkcji. Posuwając się dalej w tym rozumowaniu - najbliżej początkowego if-a powinny znajdować się funkcje wykonywane najszybciej, a najdalej te najwolniejsze.

Funkcje i procedury
-------------------

Należy też unikać zbędnego wywoływania procedur i funkcji. Jak to zrealizować w praktyce? Skoro już jesteśmy przy warunkach logicznych, to spójrzmy na to:

```delphi
if f(x) > 100 then y := f(x);

Mamy dwa wywołania tej samej funkcji pod rząd, i żeby było śmieszniej, to oba z tą samą wartością parametrów. Aż się prosi o to, żeby zadeklarować nową zmienną (koniecznie lokalną), nazwijmy ją z, i zrobić tak:

var
  z   : single;
...
  z := f(x);
  if z > 100 then y := z;
...

Proste i logiczne :)

Liczby rzeczywiste

Przyczepimy się też do liczb rzeczywistych.
Zacznijmy od tego, iż procesor wykonuje operacje logiczne i stałoprzecinkowe przynajmniej cztery razy szybciej niż zmiennoprzecinkowe. W przypadku typów nie do końca kompatybilnych z systemem operacyjnym i z samym procesorem, mnożnik ten może niebotycznie wzrosnąć.
Co zrobić, żeby możliwie jak najmniej używać FPU?
Po pierwsze - używamy takiego typu zmiennoprzecinkowego, jaki nam jest potrzebny. Brzmi to dziwnie, więc tłumaczę: im większy typ (single - double - extended), tym większa dokładność obliczeń, a co za tym idzie dłuższy czas wykonywania obliczeń. Dla większości zastosowań wystarcza "najsłabszy" typ - single. Typ real należy wyrucić do kubełka, albo na początku programu zdefiniować sobie

type real = single

. Dlaczego? Bo jest to sztuczny wytwór o dość nieprzyjemnym 6-bajtowym rozmiarze, a my lubimy wielokrotności czwórki, tak jak i nasz 32-bitowy procesor. Z tego powodu nie przepadamy również za typem extended; nie lubimy go również za to, że jest bardzo duży - 80 bitów. Chyba że ktoś potrzebuje dokładność do 20 miejsca po przecinku.
Ponadto jeśli kiedyś będziesz chciał przepisać swój kod pod windows, typ real ma tą wadę, że nie wspiera go system operacyjny i Delphi musi się samo męczyć z emulacją.

Liczby całkowite

Teraz dryfujemy w stronę liczb całkowitych.
Przede wszystkim jak najrzadziej posługujemy się dzieleniem zmiennoprzecinkowym (/). Jeśli wynik ma być liczbą całkowitą lub nie musi mieć tysiąca cyfr po przecinku, to do upadłego próbujemy zastąpić / przez div. Naprawdę się opłaca.

W programach często pojawiają się różnego rodzaju "progress bar"y, wskazujące użytnikowi ile jeszcze godzin potrwa wykonywana czynność ;-) Zachodzi więc potrzeba policzenia czegoś w procentach, co oznacza potrzebę dzielenia. Można to zrobić tak:

procent := round(value/maxvalue*100);

(przy okazji może wyjść coś śmiesznego - round(value/maxvalue)*100, co da wartość 0 i 100 i żadną inną; już widzę zdziwione twarze początkujących :->). Ale wpadamy w dzielenie zmiennoprzecinkowe. Zerknijmy tu:

procent := value*100 div maxvalue;

Ładne? Łatwe? I nie ma użycia liczb zmiennoprzecinkowych!

Operatory shl i shr

Na typie całkowitym można wygrać jeszcze trochę czasu procesora. Mało kto używa takich operandów jak shr i shl - a żałujcie, bo jest to najszybszy z możliwych sposobów do pomnożenia/podzielenia przez potęgę dwójki (najczęściej przez 2,16 i 256). Jak działa shr, a jak shl?
x shr 0 = x shl 0 = x
x shr 1 = x div 2
x shr 2 = x div 4
x shr 3 = x div 8
x shr 4 = x div 16
x shr 8 = x div 256
x shl 1 = x * 2
x shl 2 = x * 4
x shl 3 = x * 8
x shl 4 = x * 16
x shl 8 = x * 256

Operatory shr/shl noszą miano operatorów przesuwania bitów (SHift bits Right i SHift bits Left). Operand - tzn. "parametr" dla naszych operatorów - zawiera się w przedziale 1-32, informuje o ile przesunąć bity w liczbie x. Shr przesuwa je w prawo (zmniejsza liczbę), shl - w lewo (zwiększa liczbę).
Trzeba pamiętać, że byte($80 shl 1) = 0, bo wyskoczyliśmy poza zakres bajta (512*1).
Jeśli jeszcze nie rozumiesz, to popatrz:
mamy liczbę równą np. 5; w zapisie binarnym 5 = 0101b. Teraz jeśli zrobimy 5 shr 1 to nasza liczba będzie wyglądać tak:
5 shr 1 = 0010b = 3. Zjadło nam ostatniego bita, a wcześniejsze przesunęło o jedno miejsce w prawo. Teraz odwrotnie: 5 shl 1 = 1010b = 9 - wszystkie bity przesunięte o jeden w prawo, a tam gdzie nie było co wsunąć (czyli tam, gdzie stała najniższa jedynka) upchnięte jest 0. Ufff.
Tak więc 1 shl 31 = 0000 0000 0000 0000 0000 0000 0000 0001 shl 31 = 0100 0000 0000 0000 0000 0000 0000 0000 = 2^31 = MaxLongInt.

Skoro wiemy już to wszystko, to od tej pory wszędzie tam, gdzie coś mnożymy lub dzielimy przez potęgę dwójki stosujemy shr i shl, a nie div czy *. Dzięki temu zwiększymy szybkość operacji o dobre kilkadziesiąt procent.
Podobnie można zrobić używając procedurek inc i dec zamiast opłakanych x := x + 1 czy x := x - 1. Podam taki przykład: dec(x); dec(x); dec(x); wykona się dużo szybciej niż x := x - 3, a niewiele wolniej od dec(x,3). Polecam używanie tych dwóch maleństw wszędzie gdzie to możliwe, zastępując nimi wszelkie dodawanie i odejmowanie liczb całkowitych.

Div i mod

Czasem zdarzy się nam intensywnie używać takiego kodu:

x := a div b;
y := a mod b;

Marnujemy połowę czasu procesora, ponieważ już pierwsze dzielenie modulo daje w wyniku od razu iloraz i resztę, a my operację dzielenia wykonujemy ponownie, nie mogąc się dossać do potrzebnej reszty z dzielenia. Jest na to rada: zakładając, że a, b, x i y to liczby typu word, można zrobić całkiem ładny przekręt; niestety wymaga on znajomości chociaż podstaw assemblera (odsyłam do być może już napisanego kursu TP-assemblera).

asm             {nieco bolesne może być bx=0}
  mov ax,[a]    {ax := a}
  mov bx,[b]    {bx := b}
  xor dx,dx     {dx := 0}
  div bx        {ax*b + bx = b}
  mov [x],ax    {x := ax}
  mov [y],bx    {y := bx}
end;

Trochę assemblera

Ogólnie rzecz biorąc assembler to bardzo miła rzecz, chociaż ten wbudowany w kompilator TP momentami potrafi być bardzo uparty. Co nie przeszkadza w rzucaniu bardzo mocnych czarów.
Nauczymy się dopalać czterokrotnie najważniejszą procedurę w TP: move. Nie wiedzieć czemu jest ona 8-bitowa, co mnie przeraża, jako iż kiedy powstawały pierwsze wersje TP to komputery 8-bitowe istniały tylko w muzeach.
Żeby było weselej, to TP używa tej procedurki do wrzucania wszelkich parametrów na stos, co tragicznie wręcz spowalnia jego i tak nie najwyższą wydajność. Brrr... Na szczęście nie trzeba dużo rzeźbić, żeby pozbyć się tej nieznośnej procedury. Ale! TP używa tego dinozaura do kopiowania wszystkiego: nawet coś takiego zostanie stylowo wykonane bajt po bajcie...

var
  s1,s2 : string;
...
  s1 := s2;
...

co jest równoznaczne z takim zapisem:

var
  s1,s2 : string;
...
  move(s1[0],s2[0],byte(s1[0]));
...

Nie wdając się jeszcze w 32-bitowe operacje, wyczaruję procedurkę, która będzie dwa razy szybsza od move.

procedure move16(var src;var dest;count : word); assembler;
asm
  mov ax,count    {ax := count               }
  xor dx,dx       {dx := 0      / dxax = count}
  mov bx,2        {bx := 2}
  div bx          {dxax div 2}

  push ds         {ten ważny rejestr na stos,
                   bo nie można go zmodyfikować}
  mov cx,ax       {cx := ax}
  lds si,src      {ds:si := @src}
  les di,dest     {es:di := @dest}

  cld             {czyścimy bit d, coby błędów nie było}
  rep movsw       {repeat dest[dx]:=src[dx];dec(dx) until dx=0}

  cmp dx,0        {dx to reszta z dzielenia ax modulo 2}
  jz @@1          {jeśli dx = 0 to skaczemy do @@1}
  movsb           {jeśli nie, to cx=1 i należy skopiować
                   ostatni, nieparzysty bajt}
@@1:
  pop ds          {zdejmujemy ze stosu wcześniej upchnięty ds}
end;

Trochę skomplikowane, ale bardzo przyjemne.

Operacje 32-bitowe

Teraz rzucimy jeszcze mocniejsze czary. Skopiujemy dane cztery razy szybciej :) Trzeba będzie przerzucić się na rozkazy 32-bitowe, co wydaje się niemożliwe w 16-bitowym kompilatorze Turbo Pascala. Ale potrafimy czarować :)

procedure move32(var src;var dest;count : word); assembler;
asm
  mov ax,count   {początek jak w powyższej procedurze,}
  xor dx,dx      {tylko że dzielimy count na 4 a nie na 2}
  mov bx,4
  div bx

  push ds
  mov cx,ax
  lds si,src
  les di,dest

  cld               
  db $F3,$66,$A5 {i tu czarujemy; te magiczne liczby to kod 
                  oznaczający 32-bitową instrukcję rep movsd}
  cmp dx,0
  jz @@1
  mov cx,dx
  rep movsb      {jeszcze tylko brakujące do wielokrotności
                  czwórki bajty}
@@1:             {i jesteśmy w domu}
  pop ds
end;

No to walnęliśmy z grubej rury. Większej prędkości już nie wyciągniemy z komputera; tego kodu nie wyprzedzi ani Delphi, ani C++, bo to niemal czysty assembler. Teraz tylko przydałoby się sprawdzić, czy mam rację - czyli ile zyskamy na procedurach move16 i move32. No problemo - piszemy taki programik (oczywiście na początku wklejamy wyżej napisany kod procedur move16 i move32).

type
  buf      = array[1..64000] of byte;

var
  buf1,buf2 : ^buf;
  i         : word;
  t         : longint absolute $0040:$006C;
  l         : longint;

begin
  getmem(buf1,64000);
  getmem(buf2,64000);
  buf1^[2] := 1;

  l := t;
  for i := 1 to 1024 do      {po bajcie, czyli 8-bitowo}
  move(buf1^,buf2^,64000);
  writeln(62.5/((t-l)/18.2):2:2,' MB/s');

  l := t;
  for i := 1 to 1024 do      {po wordzie, czyli 16-bitowo}
  move16(buf1^,buf2^,64000);
  writeln(62.5/((t-l)/18.2):2:2,' MB/s');

  l := t;
  for i := 1 to 1024 do      {po dwordzie, czyli 32-bitowo}
  move32(buf1^,buf2^,64000);
  writeln(62.5/((t-l)/18.2):2:2,' MB/s');

  freemem(buf1,64000);  {te instrukcje możemy pominąć, bo}
  freemem(buf2,64000);  {i tak pamięć zostanie zwolniona }
                        {programu przy zakończeniu       }
end.

Pod wrażeniem? Na moim stareńkim P200 z pamięcią 66MHz wyniki były takie:
move: 18.96 MB/s
move16: 35.55 MB/s
move32: 63.19 MB/s

63 MB/s to przecież prędkość mojej pamięci... Ale zaraz zaraz, można jeszcze szybciej! Testy z pamięcią XMS dają 135 MB/s - ale o tym w innym artykule.
Tak więc można nieźle dopalić program zastępując procedurkę move. Zwrócę uwagę na to, że nawet kopiowanie zawartości stringa odbywa się procedurką 8-bitową. Dlatego zamiast robić

var
  s1,s2 : string
...
  s2 := s1;
...

robimy

var
  s1,s2 : string
...
  move32(s1,s2,length(s1));
...

zakładając oczywiście, że string s2 jest przynajmniej takiej samej długości jak s1, bo w przeciwnym wypadku zamażemy pamięć, w której mogą się znajdować jakieś dane. Podobnie kopiujemy wszelkie tablice.
Tak swoją drogą to jeśli mamy tablice o kilkubajtowym rozmiarze, to bardziej opłaci się kopiowanie "ręczne" - tzn.

type
  TTab = array[1..3] of word;
var
  a,b : TTab;
...
  a[1] := b[1];        {zamiast a := b}
  a[2] := b[2];
  a[3] := b[3];
...

ale to już ociera się o zboczenie ~:-) Komu by się chciało oszczędzić dwie instrukcje kosztem niezmiernie długiego klepania w klawiaturę?

Typy

Zaprezentuję jeszcze magię typów. Dzięki niej będziecie mogli tworzyć takie kody źródłowe, że każdy początkujący po prostu zwątpi :) Na dzień dobry poznamy niezbyt często używany rodzaj deklaracji typów. Popatrzmy:

Zbiory

type
  TTyp = (a,b,c);

Czyli że zmienna typu TTyp może przyjmować wartości a, b, c i - teoretycznie - żadne inne. Nie liczbowe, nie znakowe, nie wskaźnikowe - tylko te trzy literki będące składowymi tylko tego typu.
To jest bardzo pomocne, bo po pierwsze powoduje, że kod staje się bardziej czytelny (załóżmy np., że TTyp = (Open,Close,Flush)). To samo można uzyskać stosując stałe (np. Open = 0; Close = 1; Flush = 2), ale pojawia się wątek ewentualnego błędu. Bo o ile do procedury korzystającej z TTyp nie wepchniemy parametru wykraczającego poza dopuszczalne, to jeśli procedura będzie obsługiwana poprzez stałe, to niechcący może się zdarzyć, że zamiast stałej pojawi się np. liczba 3. I co wtedy? Albo byliśmy przewidujący, albo program się wykrzacza (inny probem, że w poprawnie napisanym programie nie ma prawa trafić się nieprzewidziana wartość parametru).
Oczywiście można zakombinować, i na siłę spowodować, że TTyp nie będzie mieć wartości równej ani a, ani b, ani c. Jak? Tutaj zaczyna się piękne rzeźbienie, które właśnie chciałbym przedstawić.

Trochę kodu:

var
  t   : TTyp;
  i	: ^byte;

begin
  i := @t;
  i^ := 0;
end;

Sprawdzamy Ctrl+F4 co reprezentuje sobą zmienna t. Okazuje się, że t = a. Coś świta... to zamiast i := 0 zrobimy i := 1, i mamy t = b. Eureka! A co będzie, jeśli zrobimy i^ := 4? Ctrl+F4 odpowiada, że jest to TTyp(4). Czyli można TTypa "przewartościować" tak, aby był równy czemuś, czemu wedle teorii równy być nie może. :) W praktyce nasz TTyp można traktować jako bajt, co łatwo sprawdzić wklepując w okienku "Evaluate and modify" tekst sizeof(t), a potem jeszcze byte(t).

Char, byte, string, pointer

Pójdźmy dalej w rozumowaniu: typ char można traktować jako byte, a byte - jako char. Teoretycznie umożliwiają to nam funkcje ord i chr, ale po co z nich korzystać? Turbo Pascal pozwala na potraktowanie typu jako czegoś w rodzaju funkcji. Właściwie każdy typ można "zamienić" na inny typ bez używania jakichkolwiek operacji na danych - jedyny warunek to ta sama wielkość typów. Ale i to można w pewnym stopniu pominąć. Na przykład pointer(0) = nil, longint(nil) = 0, byte('A') = 65, char(65) = 'A', byte(string[0]) = Length(string)... Jak to wykorzystać?
Wyobraźmy sobie, że chcemy skrócić stringa o jeden znak. Jeżeli będziemy działać zgodnie z "tradycją", to robimy delete(s,1,Length(s)) a to boli. Zabiera cenny czas procesora. Potraktujmy s jako łańcuch charów, a te jako konkretne liczby, i robimy dec(s[0]) albo w dłuższej wersji

  if s[0] <> #0 then dec(s[0]);

Co jeszcze? Załóżmy, że trzeba przesunąć offset wskaźnika o jeden bajt w górę. Nic prostszego! inc(longint(ptr)) - proste?
Jeżeli chcemy wypisać wszystkie znaki ASCII na ekran - można to zrobić w pętli na dwa sposoby:

var
  ch : char
  b  : byte;
...
for ch := #0 to #255 do writeln(ch)
for b := 0 to 255 do writeln(char(b))
...

Oczywiście drugi sposób to zbędne rzeźbienie, ale pokazuje samą metodologię. Teraz jakiś bardziej sensowny przykład: jeśli chcemy porozbijać np. longinta na cztery oddzielne bajty, to mamy do wyboru dwie metody: jedną prostą (?), ale niepotrzebnie pochłaniającą czas procesora, oraz jedną nieco magiczną, ale za to zużywającą zero (!) instrukcji.
Start!

{#### metoda pierwsza ####}
var
  l         : longint;

begin
  l := 1234567890;
  {po kolei: od wyższego bajtu wyższego worda 
   do niższego bajtu niższego worda}
  writeln(l shr 24:3,' ',l shr 16 and 255:3,' ',
          l shr 8 and 255:3,' ',l and 255:3)
end.
{#### metoda druga ####}
type
  word = record
    lo,hi : byte;
  end;

  dword = record
    lo,hi : word;
  end;

var
  l         : longint;

begin
  l := 1234567890;
  writeln(word(dword(l).hi).hi:3,' ',word(dword(l).hi).lo:3,' ',
          word(dword(l).lo).hi:3,' ',word(dword(l).lo).lo:3)
end.

{#### i klon drugiej metody ####}
type
  dword = record
    lo2,hi2,lo1,hi1 : byte;
  end;

var
  l         : longint;

begin
  l := 1234567890;
  writeln(dword(l).hi1:3,' ',dword(l).lo1:3,' ',
          dword(l).hi2:3,' ',dword(l).lo2:3)
end.

Błędy

Jeszcze mały wykład na temat błędów przekroczenia zakresu ($R) i
"nadciekania" (Overflow :)). Jest to jeden z najbardziej wrednych błędów w Turbo Pascalu:
robisz operację matematyczną, której wynik jak najbardziej mieści się
w zmiennej, do której jest przypisywany - a tu wyskakuje na zmianę
Arithmetic overflow albo Range check error. Pozbędziemy się tego koszmarku.
Może najpierw trochę kodu, który wykona taki błąd:

{$Q+,R+}
var
  a,b : word;
  c   : longint;
begin
  a := 1000;    {mieści się, bo 1000<65535}
  b := 2000;
  c := a * b;   {tu również wszystko powinno być
                 ok, skoro a*b = 2000000 < 2147483647}
end.

No właśnie - skąd błąd? Ano - kompilator TP wykazuje się tu bezgranicznym idioctwem i tworzy kod, który wykonuje działania na liczbach nie takich, jak ma być wynik, a takich, jakie występują w działaniu; oczywiście dopasowuje się do najmniejszej liczby, a więc próba mnożenia a*b, gdzie a : byte = 10; b : word = 100; też zakończy się porażką, chociaż 100 mieści się w zakresie nie tylko longinta, ale i worda. Lekarstwo? Trzeba poinformować kompilator, że wynik może być większy niż to, czego on oczekuje. Zrobimy to brutalnie, korzystając z metod częściowo opisanych akapit wyżej.

{$Q+,R+}
var
  a,b : word;
  c   : longint;
begin
  a := 1000;
  b := 2000;
  c := longint(a) * longint(b);
end.

I wszystko gra. Tylko mnożenie trwa nieco dłużej, dlatego trzeba dobrze kombinować czy wystarczy bajt, word czy dopiero longint. Tak samo robimy z dodawaniem; na szczęście w dzielenie nie trzeba ingerować. A odejmowanie? To już inna bajka, bo mogą wyjść liczby ujemne i trzeba uważać do czego przypisujemy wynik...
Dużo więcej na temat brutalnej zamiany typów w bibliotece obsługującej duże liczby całkowite - vlong i w artykule poświęconym tej bibliotece oraz algorytmowi szyfrowania metodą RSA.

Zaawansowana optymalizacja kodu

Skoro już zaczęliśmy znęcanie się nad Pascalem, to kontynuujmy. Teraz rzucimy naprawdę mocne zaklęcia - pokażę jak wykorzystać to, że programujemy w DOSie w trybie rzeczywistym i jakie to niesie za sobą zalety. Delphi wymięka!

Bezpośredni dostęp do pamięci

Po pierwsze - bezpośredni dostęp do pamięci (jak to działa i jak to efektywnie i efektownie wykorzystać), a co za tym idzie grzebanie w pamięci karty graficznej (tryb tekstowy i tryb 13h), i conieco o przerwaniach. Ale po kolei.

Zacznijmy od czegoś takiego, jak "tablice" mem, memw oraz meml, a także słowko absolute. Zakładam, że znany jest Ci sposób adresowania pamięci trybu rzeczywistego (segment:offset, gdzie "fizyczne" położenie komórki pamięci to segment*16+offset, co w sumie daje 1MB).

O ile pod Windowsem (w wersjach serii 95, 98, Me w zasadzie tylko teoretycznie) istnieje coś takiego jak ochrona pamięci, czyli teoretycznie nie można się dostać do zasobów pamięciowych innego programu, o tyle DOS nie ma żadnych zabezpieczeń przed nadużyciami ze strony oprogramowania (jedynym odstępstwem jest w DOSie >= 7.0 wieszanie systemu [zwane też jego zatrzymaniem] przy próbie bezpośredniego dostępu do dysku, ale i to można ominąć bez najmniejszego wysiłku). Nas bardzo cieszy ta wiadomość, bo po pierwsze będziemy mogli bruździć innym programom, a po drugie i ważniejsze będziemy mogli korzystać nie ze swojej pamięci - tzn. z pamięci nie będącej w posiadaniu naszego programu. Jakie to niesie ze sobą zalety?
Będziemy mogli ingerować w wektory przerwań, oszukiwać interpreter poleceń, grzebać w pamięci karty graficznej a nawet trzymać tam swoje dane, wieszać Windowsa i DOSa :), a przede wszystkim będziemy mogli zdobyć kilogramy całkiem interesujących informacji. Na dzień dobry bardzo ogólna rozpiska tego, co siedzi w pierwszym MB pamięci.

Segment</td>Zawartość</td></tr>0000</td>Pierwszy KB to tablica przerwań; jeśli tu namieszamy, to system staje. Następnie BIOS (kupa WAŻNYCH danych), sterowniki i sam DOS (io.sys + kilka kopii (!) command.com-a) Potem aż do końca pierwszych 640KB mamy pamięć do "normalnego" wykorzystania - tzn. tu siedzą sobie wszystkie programy uruchamiane pod gołym DOSem.</td></tr>A000</td>Cały segment (64KB) dla pamięci trybu graficznego; tu sobie można całkiem ładnie pogrzebać</td></tr>B000</td>Pierwsze 32KB to pamięć monochromatycznego trybu tekstowego (07h); w związku z tym, że karty Hercules to historia, to znajdują się tu dane programów</td></tr>B800</td>Kolejne 32KB to pamięć kolorowego trybu tekstowego. Bezpośredni zapis do niej pozwoli nam kilkudziesięciokrotnie przyspieszyć proces wypisywania danych. To chyba najważniejszy kawałek całej pamięci, nie licząc pierwszego kilobajta, w którym zapisane są adresy procedur obsługi przerwań</td></tr>C000</td>Albo pustka totalna, albo dane programów, albo tzw. BIOS (VGA) Shadow, czyli kopia ROMu BIOSu (karty graficznej)</td></tr>D000</td>Jak wyżej</td></tr>E000</td>Pierwsze 16KB bardzo nam się przyda - jest to okienko w które można wpisywać/odczytywać dane, które potem za pomocą funkcji menedżera pamięci EMS (emm386) upchniemy ponad jedynie słusznym pierwszym megabajtem. Ponad tym 16-KBajtowym oknem znajdują się albo śmiecie, albo dane emm386 i himem.sys, albo dane zewnętrznych programów.</td></tr>F000</td> BIOS Shadow albo dane programów</td></tr></table>

Na pierwszy rzut oka nic ciekawego. Ale tylko na pierwszy... Zaczynamy zabawę! Rozpoczniemy od grzebaniu w pamięci obrazu, bo to jest najprostsze i najbezpieczniejsze. Z tabelki wiemy, że segment pamięci obrazu to $B800. Posiadając tą informację możemy przystąpić do grzebania. Przyda się jeszcze informacja o organizacji pamięci trybu tekstowego: jeśli wypisujesz procedurką write/writeln jakiś napis w jakimś kolorze, to możesz się domyślić (?), że jedno pole zajmuje bajt na literkę + bajt na kolor = dwa bajty. I masz rację. Najpierw literka, potem kolor. Takich pól jest szerokośćwysokość ekranu, czyli najprawdopodobniej 8025 = 2000. Jeśli wiemy, że pole = 2 bajty to wywnioskujemy że nasz obraz zajmuje 4000 bajtów (nie mylić z 4KB=4096B).
Coś na początek:

begin
  mem[$B800:0] := byte('#')
end.

Po wykonaniu powyższego kodu i naciśnięciu guziczków Alt+F5 okazuje się, że w górnym lewym rogu ekranu pojawił się znaczek #. Ambitnie...

Posuniemy się dalej.

begin
  mem[$B800:1] := 14
end.

Alt+F5 i oto widzimy że nasz znaczek stał się żółty. Co zrobiliśmy? Najpierw upchnęliśmy bajt odpowiadający znakowi # do pamięci obrazu do pierwszego pola (pierwszy = 0, bo indeksowanie zaczyna się nie od jedynki a od zera), a dokładniej do miejsca odpowiedzialnego za pamiętanie znaku wyświetlanego na ekranie w punkcie (0,0). Potem zrobiliśmy to samo, ale z fragmentem pamięci odpowiedzialnym za kolor. Teraz zrobimy coś bardziej ambitnego:

var
  w : word;
begin
  for w := 0 to 2000 do mem[$B800:w*2+1] := 14
end.

Alt+F5 pokazuje, że wszystkie literki są żółte. Łapiesz o co chodzi?
Zapis do parzystej komórki ekranu powoduje zmianę literki, a do nieparzystej -
jej koloru.

Skoro już potrafimy pisać i czytać z pamięci ekranu, to możemy
napisać procedurkę do jego błyskawicznego czyszczenia.

var
  i   : word;

begin
  for i := 1 to 1000 do meml[$B800:i shl 2] := $07200720
end.

Posługujemy się tablicą meml, ponieważ korzystanie z tablicy memw znacznie spowalnia kod (musimy wykonać dwa razy więcej iteracji pętli). To, co przypisujemy do tablicy, to dwa takie same wordy oznaczające: $07 kolor (jasny szary) a $20 numer ASCII literki ($20 = 32 to spacja). I shl 2 to inaczej i*4, tylko że szybsze. Razy 4 bo zrzucamy za każdym zamachem cztery bajty a nie jeden. Generowany kod jest szybki, ale niestety wolniejszy o kilkanaście procent od procedurki ClrScr z modułu Crt. No to sięgamy do assemblera:

begin
  asm
    mov ax,$B800     {ax := segment obrazu}
    mov es,ax        {es := ax}
    xor di,di        {di := 0 => es:di adresem pamięci ekranu}
    mov cx,2000      {cx - liczba iteracji}
    mov ax,$0720     {ax := kod znaczka i jego kolor}
    rep stosw        {i wypełniamy ax-em cx wordów nad es:di}
  end;
end.

Teraz przydałoby się porównać czasy wykonania procedur:

uses crt; {skorzystamy z tego modułu celem porówniania 
           czasów wykonania pętli}
var
  i,j   : word;
  T     : longint absolute $0040:$006C;
  l     : longint;
  a,b,c : word;

begin
  l := t;
  for i := 1 to 10000 do ClrScr;
  a := (t-l);

  l := t;
  for i := 1 to 10000 do
  for j := 1 to 1000 do meml[$B800:j shl 2] := $07200720;
  b := (t-l);

  l := t;
  for i := 1 to 10000 do
  asm
    mov ax,$B800
    mov es,ax
    xor di,di
    mov cx,2000
    mov ah,textattr
    mov al,$20
    rep stosw
  end;
  c := (t-l);

  writeln(a/18.2:1:2);
  writeln(b/18.2:1:2);
  writeln(c/18.2:1:2);
end.

No i widzimy: najszybsza jest ta procedurka napisana w assemblerze, potem ClrScr, a tuż za nią procedurka w Pascalu. Mamy kolejną możliwość przyspieszenia kodu. (Można dopalić tą procedurę jeszcze bardziej, stosując operacje 32-bitowe, chociaż jest to niezła rzeźba; jednak spróbujmy:

  asm
    mov ax,$B800
    mov es,ax
    xor di,di
    mov cx,1000

    mov bh,textattr
    mov bl,$20         {bx := atrybuty}
    mov ax,bx          {ax := bx}
    db 66h; shl ax,16  {eax := eax shl 16                  }
    mov ax,bx          {ax := bx         / eax:=bx shl 16+bx}
    db $F3,$66,$AB     {rep stosd}
  end;

co da nam jakieś 30% zysku w stosunku do procedury ClrScr i 10% względem tej samej procedurki w wersji 16-bitowej; czy taka rzeźba się opłaca? hm...) Ale z czyszczenia ekranu rzadko się korzysta; zróbmy więc coś bardziej pożytecznego - usprawnimy procedurę write. Jednak najpierw wykład na temat słówka absolute. Służy ono do zmuszania kompilatoea, żeby daną zmienną upchnął do konkretnego obszaru pamięci, tzn. żeby odczyt zawartości zmiennej oznaczał odczyt z konkretnej komórki pamięci (zdefiniowanej po słowie absolute).

Absolute

W artykule już kilka razy pojawiało się coś takiego:

  T     : longint absolute $0040:$006C;

co oznacza nic innego jak traktowanie komórki pamięci znajdującej się pod adresem $40:$6C jako longinta, i upakowanie jej pod nazwą T. A tak się łądnie składa, że to co tam w pamięci siedzi, to timer komputera, odmierzający czas od północy. Na każdą godzinę przypada 64K jego tyknięć (a więc 18.2 na sekundę), co wciąż bezczelnie wykorzystujemy przy pomiarach prędkości procedur.

Co jeszcze się kryje w segmencie (patrzymy do tabelki kilka akapitów wyżej) BIOSu? Oto najczęściej używane cudeńka:

Segm:Offs</td>Zawartość</td></tr>$40:$50</td>Bajt: położenie kursora na ekranie - współrzędna X</td></tr>$40:$51</td>Bajt: położenie kursora na ekranie - współrzędna Y</td></tr> $40:$4A</td>Bajt: bieżąca rozdzielczość ekranu - współrzędna X; Read-Only</td></tr>$40:$84</td>Bajt: bieżąca rozdzielczość ekranu - współrzędna Y; Read-Only</td></tr>$40:$17</td>Word: stan klawiatury (co wciśnięte: Caps Lock, Num Lock, Scroll Lock, Shift prawy/lewy, Alt prawy/lewy, Ctrl prawy/lewy itp.)</td></tr>$40:$6C</td>DWORD: ilość tyknięć zegara od północy; 65536 tyknięć na godzinę; Read-Only</td></tr></table>

To są tylko najbardziej podstawowe dane; ich ilość jest wręcz gigantyczna.

Opis całej pamięci (i nie tylko) znajduje się w pliku int.rar [plik niedostępny - Vogel] (plik jest skompilowaną do tph wersją listy Ralph Brown' Interrupt List, łatwo dostępnej w internecie); jest to plik pomocy Turbo Pascala 7.0, należy go dodać poleceniem Help->Files->New, a po otworzeniu spisu treści (Shift+F1) wklepać hasło memory i wybrać Interrupt List. Zobaczycie wielkie skarby BIOSu, DOSu i innych programów... :)))
Ale wróćmy do tematu przyspieszenia procedury write. Mamy dwie możliwości: albo stworzyć procedurę, która działa w taki sam sposób (pisze od miejsca, w którym jest kursor, następnie przestawia go na koniec wypisanego tekstu, a jeśli kończy się miejsce na ekranie, to przesuwa całość o linijkę w dół), albo stworzyć coś znacznie bardziej przydatnego (moim zdaniem). Zrobimy to drugie, może później i to pierwsze - o ile będzie mi się chciało :-P Stworzymy procedurę, która w podanym miejscu będzie wyrzucać na ekran napis. Bez przestawiania kursora i przesuwania ekranu - to jest niepotrzebne w większości przypadków, zwłaszcza gdy projektujemy własny interfejs dla programu. Oto pierwsza wersja kodu, napisana bez udziału assemblera.

const
  textattr : byte = 7;

procedure WriteXY(x,y : byte;const s : string);
var
  maxX   : byte absolute $0040 : $004A;
  i,j    : integer;
begin
{$Q-,R-}  j := (y*maxx+x-1); {mała optymalizacja - żeby nie 
                             liczyć tego dla każdej iteracji}
  for i := 1 to length(s) do
    memw[$B800:(j+i) shl 1] := (textattr shl 8) + byte(s[i]);
end;

Hmm...trochę zakręcone, ale działa. Dla włączonych $R i $Q procedura jest wolniejsza od procedury write o jakieś 40%, ale kiedy wyłączymy kontrolę zakresu, zyskujemy 10-20% względem write. A to jeszcze bez assemblera... Ale najpierw objaśnienia: widać już, po co przydało się słówko absolute - dzięki niemu możemy określić szerokość ekranu w znakach, i dzięki temu namierzyć miejsce w pamięci, od którego rozpoczynamy pisanie; potem wystarczy pętla po parzystych offsetach pamięci - i już.

Teraz wyciągniemy z Pascala wszystko i zrobimy to w assemblerze.

procedure writect(var Dest; const Str: String; Attrs: Word); assembler;
asm
        PUSH    DS            {ds na stos}
        LDS     SI,Str        {ds:si := @Str}
        CLD
        LODSB                 {al :=bajt_ze_stosu=Length(str)}
        MOV     CL,AL         {cl := al}
        XOR     CH,CH         {cx := al}
        JCXZ    @@3           {if cx=0 then exit}
        LES     DI,Dest       {es:di := @dest}
        MOV     BX,Attrs      {bx := attrs}
        MOV     AH,BL         {ah := bl}
@@1:    LODSB                 {repeat al := bajt_ze_stosu}
        STOSW                 {na_stos(ax)}
        LOOP    @@1           {until cx=0}

@@3:    POP     DS            {ds ze stosu}
end;

procedure twritexy(x,y : byte;const Str: String);
var
   maxX   : byte absolute $0040 : $004A;
begin
  writect(mem[$B800:(y*maxx+x)*2],str,textattr*256+textattr)
end;

Tu już zyskujemy nie 10% czy 20%, ale powyżej 60% (im krótszy napis, tym więcej). Szybciej już się nie da.

A teraz zastąpimy procedurę write przez drugą, identycznie działającą ale szybszą o 10-25%. Uprzedzam, że trochę pisaniny będzie; ponadto skorzystamy z powyższej procedurki twritexy.

procedure ClrEolXY(y : byte); assembler;
asm
  mov ax,0040h
  mov es,ax
  mov di,004Ah
  mov dl,[es:di]

  mov ax,0B800h
  mov es,ax
  xor ah,ah
  xor dh,dh

  mov al,y
  mul dl
  add ax,ax
  mov di,ax

  mov ah,textattr

  mov al,32
  xor dh,dh
@@1:
  inc dh
  stosw
  cmp dl,dh
  jnz @@1
end;

procedure xwrite(const s : string);
var
  WhereX         : byte absolute $40:$50;
  WhereY         : byte absolute $40:$51;
  maxX           : byte absolute $40:$4A;
  maxY           : byte absolute $40:$84;
  i,j            : integer;
begin
 i := (WhereX+Length(s)) div maxx;

 if i+WhereY > maxy then
 begin
   move32(mem[$B800:i*maxx*2],mem[$B800:0],maxx*maxy*2);
   dec(wherey,i);
   ClrEolXY(maxy);
 end;

 twritexy(WhereX,WhereY,s);

 WhereX := (WhereX+Length(s)) mod maxx;
 inc(wherey,i);
end;

I ta kupa kodu tylko po to, żeby wygrać nasze 10-20 procent... Oczywiście całość można połączyć w jedną procedurę napisaną w assemblerze - wtedy może dociągniemy do 25% - tylko po co, skoro procedury write używa się wręcz sporadycznie.

Przerwania

Teraz poznamy przerwania - kolejna rzecz, której pod Windowsem teoretycznie nie uświadczymy... Teoretycznie, bo kompilator Delphi zna instrukcję int (można ładnie zresetować Windozę, podczepiając pod jakieś zdarzenie taką linijkę kodu: asm int 19h end; ale nie radzę próbować pod windows 95 czy 98).

Co to takiego przerwanie? Jest to wywołanie jednej z 256 procedur, których adresy znajdują się w pierwszym kilobajcie pamięci RAM. Przerwania to serce systemu DOS, tworzą coś w rodzaju bibliotek, z których może skorzystać każdy DOSowy program. Nasze oprogramowanie może przejąć któryś z wektorów i związane z nim funkcje. Procedurę obsługi przerwania można wywołać z parametrami. Przekazujemy je przez rejestry - ax..dx,es,di,ds,si...itp., w ten sam sposób otrzymujemy ewentualne dane od przerwania.

Jak wywołać przerwanie?

begin
asm
  int 0
end
end.

Jeśli wykonamy ten kod, program się wykrzaczy na linijce ' int 0' z błędem
dzielenia przez zero. Dlaczego? Bo przerwanie 0 jest odpowiedzialne za obsługę
wyjątku dzielenia przez właśnie zero. Nasz program nie wie, czy przerwanie
wywołał procesor, czy my sami - po prostu się wywala i już.
Oczywiście możemy zastąpić "domyślną" obsługę dowolnego przerwania naszą
własną procedurką, ale to jednak dość niebezpieczne, zwłaszcza jeśli
program jest jeszcze w etapie debuggowania. Jednak często jest to niezbędne -
np. przejęcie obsługi przerwania 1Bh jest pod DOSem jedynym sposobem
na zablokowanie przerwania działania programu po naciśnięciu Ctrl-Break,
a dzięki przerwaniu 1Ch wywoływanemu przez procesor 18 razy na sekundę
można pod DOSem zaimplementować coś w rodzaju wielozadaniowości.

Nota bene - czemu DOSowe programy tak łatwo wieszają system? Przede wszystkim dlatego, iż zamazują tablicę wektorów przerwań (jest to banalne, wystarczy tylko przypisać jakąś wartość niezainicjowanemu wskaźnikowi), a wtedy wywołanie któregoś przerwania nie trafia na właściwy adres, bo ten został zamazany, i procesor zaczyna wykonywać praktycznie losowy kod - może to doprowadzić przy odrobinie szczęścia do uszkodzenia partycji bądź zresetowania pamięci CMOS. Jednak najczęściej system po prostu staje. Przydałoby się wiedzieć które przerwanie do czego służy; spis większości funkcji przerwań znajduje się w pliku int.rar (jest to plik pomocy Turbo Pascala - tph), ja przedstawię tylko kilka przykładów.

Numer przerwania</td>Opis</td></tr>00h</td>Błąd dzielenia przez zero</td></tr>10h</td>BIOS: karta graficzna: ah=0: ustawianie rozdzielczości i trybu (np.: al=03h - tryb tekstowy 80x25,16 kolorów; al=13h - tryb graficzny 320x200, 256 kolorów);
ax=4F02h: ustawianie rozdzielczości SVGA (np.: bx=101h - 640x480x8; bx=103h - 800x600x8; bx=10Fh - 320x200x24)</td></tr>13h</td>BIOS: twardy dysk - bezpośredni odczyt, zapis i formatowanie sektorów</td></tr> 16h</td> BIOS: klawiatura: ax=10h - czeka na naciśnięcie klawisza, zwraca w ax jego kod i czyści bufor (readkey)
ax=11h - zwraca w ax kod naciśniętego klawisza (keypressed)</td></tr>19h</td>reset systemu; pod windowsem natomiast błyskawicznie zamyka program</td></tr>1Ah</td>BIOS: czas systemowy - pobieranie i ustawianie</td></tr>1Bh</td>Przerwanie wywoływane przez naciśnięcie Ctrl+Break</td></tr>1Ch</td>Przerwanie wywoływane co 1/18 sekundy</td></tr>21h</td>DOS: przerywanie działania programu, procedury związane z obsługą systemu plików (odczyt, zapis, zmiana nazw, zakładanie i kasowanie plików i katalogów itp.), wypisywanie łańcuchów znaków, czas, ładowanie programów rezydentnych, obsługa pamięci niskiej, obsługa sieci, długie nazwy plików i katalogów...</td></tr>23h</td>Przerwanie wywoływane przez naciśnięcie Ctrl+C</td></tr>25h</td>DOS: odczyt z partycji - bez podziału na Cylinder/Head/Sector</td></tr>26h</td>zapis j.w.</td></tr>28h</td>DOS: wektor wywoływany gdy DOS się nudzi (nie jest wykonywany żaden program poza COMMAND.COM)</td></tr>2Fh</td>DOS Multiplex - zestaw bardzo wielu funkcji robiących niemal wszystko. Praktyczne wykorzystanie multipleksu 2Fh w bibliotece int2F (w dziale źródła - Pascal)</td></tr>31h</td>DPMI: obługa trybu chronionego</td></tr>33h</td>Mysz: pokazywanie/ukrywanie, pobieranie zdarzeń, położenia itp.</td></tr>4Bh</td>DMA: operacje na pamięci bez udziału procesora</td></tr></table>

Każde z przerwań może obsługiwać kilkaset funkcji - w sumie robi się całkiem pokaźna biblioteka - coś na kształt Windozowego API. Z przerwań korzystamy możliwie często, bo znacznie upraszczają wiele rzeczy, pozwalają nie korzystać z wielu modułów (np. dos czy crt), a przede wszystkim wykonują się dość szybko. Jako przykład podam czyszczenie ekranu (zakładam, że jesteśmy w trybie tekstowym 80x25):

procedure MyClrScr; assembler;
asm
  mov ax,13h
  int 10h
end;

Krótkie, sprawne, bezczelne - i średnio szybkie :( Ale ile kodu oszczędza! Umiejętność korzystania z assemblera i przerwań to podstawa pisania szybkich programów; jeśli używasz Delphi, to assembler przyda się również Tobie (szczególnie że kompilator Delphi umożliwia korzystanie z 32-bitowych instrukcji bez zbędnego rzeźbienia). Radzę zapoznać się z zawartością pliku pomocy int.rar, może się bardzo przydać tym, którzy chcą stworzyć pod DOSem coś ciekawego i niekonwencjonalnego (przykładem może być biblioteka do obsługi długich nazw plików czy multipleksu 2Fh [interfejs CD-AUDIO, zarządzanie energią, buforem dysku] tudzież do obsługi pamięci EMS - wszystkie biblioteki są w dziale źródła-Pascal). Można pokusić się nawet o napisanie menedżera pamięci (takiego, jak w trybie chronionym - RAM + dysk), oraz obsługi czegoś w rodzaju wielozadaniowości. Ale o tym w innym artykule.

Słówko o rekurencji. Jest taki powiedzenie: "Iterować jest rzeczą ludzką, wykonywać rekursywnie - boską" ;-) No tak, ale rekurencja nie jest dobra do wszystkiego. Jeśli dany problem możemy rozwiązać na dwa analogiczne sposoby - jeden rekurencyjnie, a drugi iteracyjnie, to który będzie szybszy? Intuicja mówi, że rekurencja; błąd. O ile metody różnią się tylko sposobem wykonania, a nie całym algorytmem, to wygra iteracja.

Dlaczego? Wynika to z różnicy pomiędzy tym, co jest odkładane na stos przy iterowaniu i rekurowaniu. Każda "warstwa" rekurencji wrzuca na stos wszystkie swoje parametry (więc trzeba dbać, aby tych parametrów nie było za wiele objętościowo), a ponadto jeszcze kilka rejestrów - w sumie przynajmniej 8 bajtów. W praktyce około 16. To spowalnia. Wady tej jest pozbawiona iteracja, o ile jest rozsądnie skonstruowana. Tylko że rzadko kiedy da się zastąpić rekurencję iterowaniem...

Ustawienia kompilatora

Co jeszcze zostało na sam koniec? Pogrzebiemy w konfiguracji. Pożytek może przynieść zajrzenie do okna Options->Memory. Jeśli nasz program nie używa sterty, to High heap limit można spokojnie ustawić na 0. Możesz również poeksperymentować z wartością Stack size (wielkość stosu) - KONIECZNIE Z WŁĄCZONĄ OPCJĄ Stack checking z okna Options->Compiler.

Jeśli nasz program wciąż ślicznie działa, nie wykrzacza się i nie zawiesza (przynajmniej teoretycznie ;->), to grzebiemy w oknie Options->Compiler i robimy coś na kształt optymalizacji kodu, wyłączając po kolei opcje:

Range checking ($R-); to pozwoli przyspieszyć program nawet o połowę</li> Stack checking ($S-); dopali wywoływanie procedur i funkcji</li> I/O checking ($I-); przyspieszy odrobinę operacje wejścia-wyjścia</li> Overflow checking ($Q-); podobnie jak Range checking.</li> </ul>

Najlepszą metodą na w miarę bezpieczne dopalenie programu jest jednoczesne zastosowanie opcji $R- i $Q-. $I- w praktyce nie daje prawie nic, a w razie błędu I/O nie można zlokalizować miejsca jego pojawienia się. Najweselsze efekty są przy wyłączonej opcji Stack checking, kiedy to program niechcący nie zmieści się w swoim stosie. W najlepszym wypadku (jeśli działasz pod Wingrozą) wyświetli się okienko p.t. "Program MS-DOS wykonał błąd ...", a w najgorszym stanie cały system (tak tak, są setki sposobów na zawieszenie Windozy dosowym programem :)).

Zmniejszymy też nieco objętość samego exeka: wyłączamy opcje Debug information, Local symbols oraz Symbol information, a następnie sięgamy do okienka Options->Debugger i wyłączmy dwie pierwsze opcje: Integrated debugging/browsing i Standalone debugging. Spowoduje to, iż kompilator nie będzie wrzucał do kodu zbędnych informacji umożliwiających stawianie breakpointów.
Z góry uprzedzam, że jeśli wyłączymy te opcje, to programu nie da się wykonać krok po kroku.

Co jeszcze można zrobić? Zakładając, że włączone są opcje Word align data, 286 instructions i 8087/80287, a wyłączone jest Emulation, to nie sięgając po zewnętrzne narzędzia - nic.

Jeżeli wykonaliśmy już to wszystko, to z naszymi nowymi ustawieniami przydałoby się przekompilować nie tylko program, ale i biblioteki, z których on korzysta. Wybieramy Run->Build all i jesteśmy szczęśliwi, bo to już koniec tego artykułu.

Jednak radzę nie zapominać, że 16 bitów to już przeszłość i nawet największe przekręty nie spowodują, że kod generowany przed TP będzie równie szybki jak ten skompilowany w Delphi czy TMT Pascalu.

Na "do widzenia" małe objaśnienie dla mniej zorientowanych: co to jest stos i sterta?
Stos to pamięć naszego programu, w której są umieszczane wszystkie zmienne; jej wielkość jest ograniczona - 64KB dla programu + 64KB dla procedur i funkcji. Dlatego też ilość zmiennych globalnych i lokalnych jest ograniczona (spróbujcie umieścić w pamięci dwie tablice o wielkości np. 40KB), musimy też pamiętać, że ze stosu korzysta sam kod programu.
Sterta zaś to pamięć, do której mamy dostęp za pomocą wskaźników i procedur GetMem i New. Jest jej teoretycznie znacznie więcej niż pamięci w stosie (max 640KB-(rozmiar stosu + rozmiar kodu programu)), i dlatego to z tej pamięci korzystamy przy operowaniu na dużych danych.

7 komentarzy

Bardzo dobry art. Proponuję dodać do "1.3-Instukcje warunkowe" zastąpienie instukcji w stylu

If a=b then c:=true else c:=false;

na

c:=a=b;

Podoba mi się ten artykuł.
To naprawdę fajny artykuł.

Bardzo dobra robota.
Naprawdę dobra robota.
Daję 6.

Podstawianie zmiennych: Move32(S1,S2,Succ(Length(S1)) byłoby eleganckie.
Użycie segmentu b800: Zamiast $B800 wstaw SegB800 ;-)
Może kiedyś napiszę o przyśpieszaniu operacji dyskowych, czytam mianowicie z dyskietki 3,5" szybciej niż inni z dysku. Mam trochę takich doświadczeń. Prywatnie chętnie pomagam. Jakby co to [email protected]

Mam problem z odwołaniem Mem, memw i meml (a dokładniej segment b800). <ort>Puki</ort> pisze w Pascalu wszystko fajnie program chodzi bez <ort>zażutów</ort>. problem pojawia się po kompilacjii na exe i odpaleniu go moim oczom ukazuje się czarny ekran. co lepsze chodzi cała reszta programu(tj. poprawnie zapisuje do plików reaguje na zegar itp. itd) program jest otwierany pod win xp professional, korzystam ze standardowego kompilatora Borland Turbo Pascal 7.0

Bardz dobra robota - wstyd że wcześniej nie oceniałem tego artykułu ;) Ciekawy artykuł, widać, że włożyłeś w jego powstanie dużo pracy. Ode mnie 6.

Bardzo fajny artykuł, ale jest w nim chyba malutki błąd. W części przy rzutowaniu typu LongInt na rekord złożony z typu word. Chyba powinien być byte (modyfikacja II metody).
Szkoda, że niektórych z przedstawionych tu przekrętów nie da się zrobić na WinNT :(