Instrumente de investigare; zona de date şi zona de cod

Să zicem că vrem să vedem reprezentările în memorie pentru diverse valori… În acest scop, am putea scrie un program simplu - definim nişte variabile şi afişăm adresele şi valorile aferente.

În C, &VARadresa variabilei VAR, iar *ADRvaloarea memorată la adresa păstrată de variabila (pointer) ADR. Operatorii de referenţiere/dereferenţiere & şi * ţin cont de tipul variabilei - mai precis, de dimensiunea zonei alocate. De exemplu, dacă &VAR = 1000 (VAR are adresa 1000) şi VAR este de tip int, iar sizeof(int) = 4 — atunci &VAR+1 este adresa 1004 (adresa valorii de tip int "următoare" celeia de la adresa lui VAR), nu 1001. Pentru a referi octeţii componenţi şi nu "întreaga" valoare (de 4 octeţi) reprezentată de VAR, trebuie să convertim &VAR (adresă de valori int) în adresă de valori de tip char, folosind (char*) &VAR.

/* "limb1.c" */
#include <stdio.h>
unsigned int j = 1234; /* 'j' şi 'tc' sunt variabile globale */
unsigned char tc[6] = {'a', 'z', 'A', 'Z', '0', '9'};

main() {
   int offs; /* 'offs' şi 'adr' sunt variabile locale */
   unsigned char* adr = (unsigned char*) &j;

   printf("adresa variabilei 'j': %p => valoare: %u (pentru 'int' se alocă %u octeţi)\n", &j, *(&j), sizeof(int));
   printf("adresa tabloului 'tc': %p (are alocaţi %u octeţi)\n", tc, sizeof(tc));

   printf("\n10 locaţii de memorie, începând de la adresa variabilei globale 'j':\n");
   for(offs = 0; offs < 10; offs++, adr++)
      printf("%p => %0X ('%c')\n", adr, *adr, *adr);

   printf("\nadresa variabilei locale 'offs': %p => valoare: %u\n", &offs, *(&offs));

   printf("\nadresa codului funcţiei 'main': %p\n", &main);
}

Considerăm chiar două variante, pentru obţinerea unui "executabil": întâi, folosind GCC (compilatorul standard adoptat pe Linux şi care se poate folosi şi pe alte sisteme de operare); apoi, folosind un compilator Borland C++ pe un sistem Windows. Vom reda rezultatele execuţiei şi le vom interpreta în contextul nostru de discuţie.

Pe Linux, deschizând un terminal şi lansând de pe linia de comandă gcc limb1.c -o limb1.exe - obţinem fişierul executabil "limb1.exe" (de aprox. 7Ko); lansând apoi "limb1.exe", obţinem:

adresa variabilei 'j': 0x80497a8 => valoare: 1234 (pentru 'int' se alocă 4 octeţi)
adresa tabloului 'tc': 0x80497ac (are alocaţi 6 octeţi)

10 locaţii de memorie, începând de la adresa variabilei globale 'j':
0x80497a8 => D2 ('�)
0x80497a9 => 4 ('')
0x80497aa => 0 ('')
0x80497ab => 0 ('')
0x80497ac => 61 ('a')
0x80497ad => 7A ('z')
0x80497ae => 41 ('A')
0x80497af => 5A ('Z')
0x80497b0 => 30 ('0')
0x80497b1 => 39 ('9')

adresa variabilei locale 'offs': 0xbfc7a24c => valoare: 10

adresa codului funcţiei 'main': 0x80483a4

Iar pe Windows, având instalat un compilator BCC.EXE ("the 16-bit command-line compiler Borland C++ Version 5") — putem proceda astfel: accesăm meniul Run... din bara de Start şi lansăm un terminal cu C:\WINDOWS\System32\cmd.exe; intrăm în directorul în care avem fişierul-sursă cd C:\Teste şi lansăm BCC: C:\Teste> C:\bc5\BIN\bcc  limb1.c. În urma compilării şi invocării editorului de legături (Turbo Link), obţinem în directorul nostru de lucru fişierul executabil "limb1.exe" (de peste 31 KB) - pe care-l lansăm apoi (de la prompt): C:\Teste> limb1.exe > limb1.txt (am redirecţionat ieşirea către "limb1.txt", pentru a transfera apoi rezultatele respective pe Linux).

adresa variabilei 'j': 00A8 => valoare: 1234 (pentru 'int' se alocă 2 octeţi)
adresa tabloului 'tc': 00AA (are alocaţi 6 octeţi)

10 locaţii de memorie, începând de la adresa variabilei globale 'j':
00A8 => D2 ('Ò')
00A9 => 4 ('')
00AA => 61 ('a')
00AB => 7A ('z')
00AC => 41 ('A')
00AD => 5A ('Z')
00AE => 30 ('0')
00AF => 39 ('9')
00B0 => 61 ('a')
00B1 => 64 ('d')

adresa variabilei locale 'offs': FFF4 => valoare: 10

adresa codului funcţiei 'main': 0293

Sau, folosind de această dată compilatorul pe 32 de biţi, bcc32.exe:

C:\Teste>bcc32 limb1.c
Borland C++ 5.0 for Win32 Copyright (c) 1993, 1996 Borland International
limb1.c:
Warning limb.c 22: Function should return a value in function main
Turbo Link  Version 1.6.72.0 Copyright (c) 1993,1996 Borland International

C:\Teste>limb1
adresa variabilei 'j': 00407074 => valoare: 1234 (pentru 'int' se alocă 4 octeţi)
adresa tabloului 'tc': 00407078 (are alocaţi 6 octeţi)

10 locaţii de memorie, începând de la adresa variabilei globale 'j':
00407074 => D2 ('╥')
00407075 => 4 ('♦')
00407076 => 0 (' ')
00407077 => 0 (' ')
00407078 => 61 ('a')
00407079 => 7A ('z')
0040707A => 41 ('A')
0040707B => 5A ('Z')
0040707C => 30 ('0')
0040707D => 39 ('9')

adresa variabilei locale 'offs': 0012FF88 => valoare: 10

adresa codului funcţiei 'main': 0040107C

Interpretarea rezultatelor şi alte observaţii

În limb1.c s-au declarat câteva variabile (bineînţeles că puteam considera şi mai multe). Variabilele globale sunt reprezentate într-o zonă de memorie contiguă, în ordinea în care apar ele în fişierul-sursă; variabilele locale au în mod clar, o zonă de reprezentare separată de aceea a variabilelor globale; zona care corespunde instrucţiunilor de executat (codul funcţiei main()) este separată faţă de celelalte zone de memorie.

Vedem că s-a folosit adresare pe 32 de biţi, respectiv pe 16 biţi - după caz. Adresele sunt reprezentate numai prin "offset" (nu ca Segment:Offset); este de intuit că locaţiile respective fac parte dintr-un acelaşi segment de memorie (o anumită porţiune a lui - pentru date şi o alta, disjunctă de prima - pentru cod). Este instructiv de experimentat cât de puţin cu programul — adăugând încă nişte variabile, recompilând şi reexecutând în diverse contexte - de exemplu după lansarea prealabilă a altor aplicaţii, sau folosind diverse opţiuni de compilare (de exemplu, privind "alinierea în memorie").

De observat că reprezentarea în memorie corespunde formatului little-endian (specifică microprocesoarelor INTEL): variabila j are reprezentarea (D2, 4, 0, 0) (respectiv, pe numai doi octeţi (D2, 0)), la adresa cea mai mică fiind octetul D2 (şi avem 0xD2 = 210, iar 210 + 4*256 = 1234 = valoarea lui j).

De observat şi că GCC (pe Linux) foloseşte codificarea "universală" Unicode (UTF-8), care reprezintă caracterele prin coduri de lungime variabilă (BCC foloseşte codul ASCII, în care fiecare caracter este reprezentat pe un octet): caracterului ţ îi corespunde un cod de doi octeţi (reprezentând î) pe care BCC i-a văzut ca atare (individual), dar GCC i-a interpretat ca reprezentând împreună un caracter. Sunt de reţinut codurile ASCII care s-au afişat: pentru 'a' 0x61, pentru 'A' 0x41 şi pentru '0' 0x30.

Privind diferenţa sensibilă de dimensiune a codului executabil (7 Ko faţă de 31 Ko), explicaţia poate decurge logic astfel: pentru obţinerea executabilului, editorul de legături trebuie să "lege" codul obiect produs de compilator pentru programul-sursă, de codul corespunzător bibliotecilor incluse (în cazul de faţă, codul funcţiei printf() din stdio); această "legare" se poate face în două moduri, după cum compilatorul respectiv este integrat sau nu, în sistemul de operare; GCC există pe orice sistem Linux, pe când BCC poate să fie sau poate să nu fie instalat, pe sistemul Windows respectiv; în cazul sistemului Linux va fi suficient pentru "legare" să se precizeze adresa codului funcţiei printf(), pe când în celălalt caz acest cod trebuie efectiv încorporat ca atare în codul executabil.

Programe de investigare specializate

Pentru investigarea memoriei şi a modului de funcţionare a programelor există instrumente software specializate, numite debugger. Pe de o parte, acestea permit vizualizarea conţinutului diverselor zone de memorie, inclusiv a regiştrilor CPU/FPU; pe de altă parte, permit execuţia programului în regim "pas cu pas" - posibilă datorită faptului că CPU oferă suportul hardware necesar (vizibil prin prezenţa "flagului" Trap flag (single step) în registrul FLAGS).

Debug (Microsoft Windows)

Folosind butonul Start ("click here to begin") şi opţiunea Run... se lansează întâi interpretorul de comenzi cmd.exe; bara de titlu a fereastrei obţinute conţine un buton (etichetat "C:\") prin a cărui punctare (prin click) se deschide un meniu care pe lângă opţiunile obişnuite pentru "butonul din stânga-sus al ferestrei Windows" (Restore, Move, Close, etc.), conţine şi un submeniu Edit care oferă opţiuni de selectare a conţinutului ferestrei şi de Copy - permiţând astfel ca rezultatele obţinute prin folosirea în fereastra respectivă a diverselor comenzi să poată fi transferate interactiv, de exemplu într-un fişier-text.

Reproducem parţial, o asemenea sesiune de lucru; după ce s-a lansat debug, s-a folosit comanda r (afişează regiştrii) şi apoi comanda ? ("Help" asupra comenzilor disponibile):

Microsoft Windows XP [Version 5.1.2600]
(C) Copyright 1985-2001 Microsoft Corp.

C:\Documents and Settings\vb>debug
-r
AX=0000  BX=0000  CX=0000  DX=0000  SP=FFEE  BP=0000  SI=0000  DI=0000
DS=135C  ES=135C  SS=135C  CS=135C  IP=0100   NV UP EI PL NZ NA PO NC
135C:0100 0000          ADD     [BX+SI],AL                         DS:0000=CD
-?
assemble     A [address]
dump         D [range]
enter        E address [list]
fill         F range list
go           G [=address] [addresses]
hex          H value1 value2
load         L [address] [drive] [firstsector] [number]
move         M range address
name         N [pathname] [arglist]
proceed      P [=address] [number]
quit         Q
register     R [register]
search       S range list
trace        T [=address] [value]
unassemble   U [range]
write        W [address] [drive] [firstsector] [number]

Comanda R a afişat conţinutul curent al regiştrilor de 16 biţi, pe două linii: întâi, regiştrii "generali" (implicaţi de exemplu în operaţii aritmetice sau folosiţi pentru a indica "offset"-uri); pe a doua linie apar regiştrii de segment, registrul "Instruction Pointer" şi starea curentă a 8 dintre flagurile microprocesorului (de exemplu, NC semnalează "Not Carry", iar NZ "No Zero"). Pe a treia linie este indicată, pe trei coloane, instrucţiunea care ar fi executată dacă s-ar tasta imediat comanda T "Trace"; pe prima coloană este adresa acestei instrucţiuni - 135C:0100, adică observând linia de deasupra CS:IP (registrul CS conţine 135C, iar IP=0100); a doua coloană redă codul maşină corespunzător instrucţiunii respective, iar a treia coloană transcrie instrucţiunea în limbaj de asamblare.

Clarificări, folosind debug

Propunem un mic experiment cu debug, pentru a face o serie de clarificări privind regiştrii CPU, reprezentarea în memorie, instrucţiunile CPU şi limbajul de asamblare.

C:\>debug
-a
135E:0100 mov AX, 415A  ; AX = 0x415A (adica "AZ")
135E:0103 mov word ptr [DI], AX  ; DS:[DI] <-- AX
135E:0105
-u 100 104
135E:0100 B85A41        MOV     AX,415A
135E:0103 8905          MOV     [DI],AX

Primind comanda a, debug asamblează instrucţiunea indicată, adică: determină "codul-maşină" corespunzător (folosind un tabel propriu de mnemonice - coduri) şi îl înscrie la adresa CS:IP curentă (CS = 0x135E indică adresa de bază a segmentului de cod, iar IP = 0x100 este "offset"ul instrucţiunii). Prima instrucţiune a fost asamblată începând de la adresa CS:0100, iar a doua - de la CS:0103; deducem că octeţii de "cod-maşină" corespunzători primei instrucţiuni ocupă locaţiile de offseturi 0x100, 0x101 şi 0x102 (iar codul-maşină corespunzător celor două instrucţiuni este cuprins între CS:0100 şi CS:0104 inclusiv - de unde şi comanda "u 100 104").

mov este mnemonica folosită în multe limbaje de asamblare pentru instrucţiunile de transfer: mov Dest, Sursă transferă date (copiază) de la Sursă la Destinaţie (în unele limbaje, sintaxa este inversată: mov Sursă, Dest).

Prin comanda u, debug dezasamblează codul-maşină de la adresa specificată, afişând un tabel cu trei coloane: adresa codului-maşină, octeţii care constituie împreună codul-maşină şi "traducerea" corespunzătoare în limbaj de asamblare. Secvenţa de octeţi 0xB85A41 reprezintă "codul-maşină" al primei instrucţiuni; primul, 0xB8 este opcode ("operation code"), specificând microprocesorului operaţia de efectuat - "încarcă în registrul AX o valoare", anume 0x415A (şi vedem că octeţii acesteia sunt inversaţi în memorie, după modelul "little-endian").

Să trecem la executarea celor două instrucţiuni:

-t
AX=415A  BX=0000  CX=0000  DX=0000  SP=FFEE  BP=0000  SI=0000  DI=0000
DS=135E  ES=135E  SS=135E  CS=135E  IP=0103   NV UP EI PL NZ NA PO NC
135E:0103 8905          MOV     [DI],AX                            DS:0000=20CD

-t ……

-d DS:0 F
135E:0000  5A 41 FF 9F 00 9A EE FE-1D F0 4F 03 C2 0D 8A 03   ZA........O.....

Primind comanda t, debug pune în execuţie instrucţiunea asamblată la adresa CS:IP curentă (CS:0x0100, în cazul primei instrucţiuni). Execuţia de către CPU a unui instrucţiuni este un proces "neinteruptibil" şi care durează unul sau mai mulţi "clock cycles"; la finalul execuţiei, IP indică adresa relativă a următoarei instrucţiuni de executat (aici, CS:0x0103).

A doua comandă t a pus în execuţie instrucţiunea mov [DI], AX, prin care conţinutul lui AX este copiat în memorie, anume în segmentul DS = 0x135E la offsetul indicat de registrul DI; în memorie, octeţii lui AX sunt inversaţi ("ZA"), fiind vorba de formatul "little-endian".

Comanda d afişează conţinutul memoriei de la adresa indicată (aici de la adresa DS:0), pe trei coloane: prima coloană conţine adresa primului octet din cei 16 afişaţi pe coloana a doua; ultima coloană redă interpretarea ASCII (înlocuind cu ".", dacă octetul respectiv nu se încadrează în gama codurilor ASCII a caracterelor "tipăribile").

Putem adăuga noi instrucţiuni, de exemplu pentru a înscrie "az" după "ZA" în zona DS:DI:

-a
135E:0105 add AX, 2020  ; 0x41 + 0x20 = 0x61 = 'a'; 0x5A + 0x20 = 0x7A = 'z'
135E:0108 xchg AL, AH  ; "exchange" AL cu AH
135E:010A mov word ptr [DI+2], AX
135E:010D
-t

add realizează aici operaţia "AX += 0x2020", care transformă octeţii 'A', 'Z' din AH şi respectiv AL în 'a' şi 'z'; apoi, xchg interschimbă între ei AH şi AL (obţinem AX=617A), încât la depunerea în memorie octeţii să apară în ordinea firească "az":

AX=617A  BX=0000  CX=0000  DX=0000  SP=FFEE  BP=0000  SI=0000  DI=0000
DS=135E  ES=135E  SS=135E  CS=135E  IP=0108   NV UP EI PL NZ NA PO NC
135E:0108 86C4          XCHG    AL,AH
-t

AX=7A61  BX=0000  CX=0000  DX=0000  SP=FFEE  BP=0000  SI=0000  DI=0000
DS=135E  ES=135E  SS=135E  CS=135E  IP=010A   NV UP EI PL NZ NA PO NC
135E:010A 894502        MOV     [DI+02],AX                         DS:0002=9FFF
-t……

-d DS:0 F
135E:0000  5A 41 61 7A 00 9A EE FE-1D F0 4F 03 C2 0D 8A 03   ZAaz......O.....

Programul DEBUG a fost creat de Tim Paterson, autorul original al sistemului de operare MS-DOS. Este un instrument uşor de folosit şi care serveşte încă (utilizatorilor individuali, sau în medii universitare) pentru studiul limbajului de asamblare - deşi nu recunoaşte decât setul de instrucţiuni Intel 8086/8087; Microsoft® nu l-a "updatat" - din fericire! - probabil pentru că a preferat să comercializeze propriile instrumente de programare: asamblorul MASM (Microsoft Macro Assembler), compilatorul şi debuggerul CodeView, şi apoi Microsoft Visual Studio.