Изучение листинга ассемблирования, генерируемого компилятором C++ - часть 1

ОГЛАВЛЕНИЕ

•    Скачать исходники - 4 Кб

Введение

Компилятор VC++ может создать текстовый файл, показывающий ассемблерный код, сгенерированный для файла C/C++ file. Этот файл позволяет узнать, какой вид кода генерирует компилятор. Файл дает хорошее представление о ряде принципов, таких как обработка исключений, таблицы вызова и т.д. Элементарного знания языка ассемблера достаточно для понимания вывода файла листинга. Цель данной статьи (первой в серии из двух статей) – показать, как файл листинга помогает понять внутренние механизмы компилятора C++.

Установка файла листинга

Можно установить параметры компилятора C/C++ для генерации файла листинга в диалоговом окне «Настройки проекта VC6», как показано ниже.

В VC++.NET можно установить тот же параметр в диалоговом окне «Свойства проекта».

Компилятор генерирует следующие разные типы листинга:

1.    Только ассемблерный код (.asm)
2.    Ассемблерный код и машинный код. (.cod)
3.    Ассемблерный код вместе с исходным кодом. (.asm)
4.    Ассемблерный код вместе с машинным и исходным кодом. (.cod)

Просмотр файла листинга

Изучим листинг, сгенерированный для следующего приложения.

#include <stdio.h>

int main(int argc, char* argv[])
{
    printf("Hello World!");
    return 0;
}       

1.    Листинг только ассемблера (/FA)

Листинг ассемблирования помещается в файл с расширением .asm в промежуточном каталоге. Например, если имя файла - main.cpp, то в промежуточном каталоге появится файл main.asm. Ниже приведен фрагмент кода главной функции из файла листинга:

PUBLIC  _main
PUBLIC  ??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@ ; `строка'
EXTRN   _printf:NEAR
; COMDAT ??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@
; Файл g:\wksrc\compout\main.cpp
CONST   SEGMENT
??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@ DB 'Hello World!', 00H ; `строка'
; Флаги компиляции функции: /Ogty
CONST   ENDS
;   COMDAT _main
_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR ; COMDAT
; Строка 5
    push    OFFSET FLAT:??_C@_0N@GCDOMLDM@Hello?5World?$CB?$AA@
    call    _printf
    add esp, 4
; Строка 6
    xor eax, eax
; Строка 7
    ret 0
_main   ENDP
END    

Изучим листинг.

•    Строки, начинающиеся с ; , являются комментариями
•    PUBLIC _main означает, что функция _main используется совместно с другими файлами (в отличие от статических функций). У статических функций нет префикса.
•    CONST SEGMENT указывает начало сегмента данных CONST. Компилятор VC++ помещает в эту секцию постоянные данные, такие как строки. Видно, что строка "Hello World" помещается в сегмент CONST. Изменение любых данных в сегменте вызывает генерацию исключения нарушения доступа. Подробнее об этом позже.
•    _TEXT SEGMENT отмечает начало другого сегмента. Компилятор помещает весь код в этот сегмент.
•    _argc$ = 8 и _argv$ = 12 указывают стековые положения аргументов argc и argv. В данном случае это значит, что если прибавить 8 к указателю стека (регистр ESP процессора), то получится адрес параметра argc. Для адреса возврата будет смещение 4.
•    _main PROC NEAR указывает на начало функции _main. Заметьте, что у функций C (функций, объявленных с extern "C") в начале имени ставится _, у функции C++ имя декорируется.
•    Видно, что компилятор проталкивает адрес строки "Hello World" в стек и вызывает функцию printf. После окончания вызова функции указатель стека увеличивается на 4 (так как printf имеет соглашение о вызовах C).
•    EAX – регистр, хранящий возвращаемое значение функции. EAX подвергается операции "исключающее или" сам с собой. (Это быстрый способ привести регистр к нулю.) Причина состоит в том, что содержимое оригинального кода возвращает 0 из функции main.
•    Наконец, ret 0 – команда возврата из функции. Числовой аргумент 0, идущий за командой ret, указывает число, на которое надо увеличить указатель стека.

Это был листинг только ассемблера. Посмотрим, как выглядят три остальных листинга.

2.    Ассемблер с исходным кодом (/FAs)

Этот листинг дает  более ясную картину, чем первый. Он показывает исходный текст вместе с ассемблерным кодом.

_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR ; COMDAT

; 5    :    printf("Hello World");

    push    OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
    call    _printf
    add esp, 4

; 6    :    вернуть 0;

    xor eax, eax

; 7    : }

3.    Ассемблер с машинным кодом (/FAc)

Листинг показывает коды команд вместе с мнемониками команд. Этот листинг обычно генерируется в файле .cod. В данном примере листинг окажется в файле main.cod.

;   COMDAT 
_main   _TEXT SEGMENT
_argc$  = 8
_argv$ = 12
_main PROC
NEAR  ; COMDAT       
; Строка 5
00000 68 00 00 00 00 push OFFSET    
            FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
                
00005 e8 00 00 00 00 call _printf   
0000a   83 c4 04 add esp,  4  

; Строка 6
0000d 33  c0    xor eax, eax    

; Строка 7
0000f c3  ret   0       
_main   ENDP

4.    Ассемблер, машинный и исходный код (/FAsc)

Этот листинг также генерируется в файле .cod. Как и ожидалось, он показывает исходный код вместе с машинным кодом и ассемблером.

;   COMDAT _main
_TEXT   SEGMENT
_argc$ = 8
_argv$ = 12
_main   PROC NEAR  ; COMDAT

; 5    :    printf("Hello World");

  00000 68 00 00 00 00   push   
      OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
  00005 e8 00 00 00 00   call    _printf
  0000a 83 c4 04     add     esp, 4

; 6    :    вернуть 0;

  0000d 33 c0        xor     eax, eax

; 7    : }

  0000f c3       ret     0
_main   ENDP

Были рассмотрены все четыре типа листинга, генерируемые компилятором. Обычно нет необходимости смотреть на машинный код. Большей частью ассемблер с исходником (/FAs) – самый полезный листинг.

Посмотрев разные типы листингов и как генерировать листинги, узнаем, какую полезную информацию можно собрать из листинга.

Сегмент Const

Компилятор поместил постоянную строку "Hello World" в сегмент CONST. Последствия этого показаны на следующем пробном приложении.

#include <stdio.h> <stdio.h>

char* szHelloWorld = "Hello World";

int main(int argc, char* argv[])
{
    printf(szHelloWorld);

    szHelloWorld[1] = 'o';
    szHelloWorld[2] = 'l';
    szHelloWorld[3] = 'a';
    szHelloWorld[4] = '\'';

    printf(szHelloWorld);

    return 0;
}

Сначала это пробное приложение печатает "Hello World", пытается преобразовать строку "Hello" в "Hola'" и в конце печатает измененную строку. Скомпонуем и запустим приложение. Оно аварийно завершит работу с исключением нарушения доступа и строкой szHelloWorld[2] = 'l';.

Изменим строку

char* szHelloWorld = "Hello World"; 

на

char szHelloWorld[] = "Hello World"; 

На этот раз приложение успешно запустится. Изучение листинга показывает причину.

1.    В первом случае данные "Hello World" помещаются в сегмент CONST, являющийся неизменяемым сегментом

CONST    SEGMENT
?szHelloWorld@@3PADA DB 'Hello World', 00H ; szHelloWorld
CONST    ENDS               

2.    Во втором случае данные помещаются в сегмент _DATA, являющийся сегментом для чтения и записи

_DATA    SEGMENT
?szHelloWorld@@3PADA DB 'Hello World', 00H ; szHelloWorld
_DATA    ENDS

Встраивание функции

Из листинга  ассемблирования можно узнать одну из самых полезных вещей, а именно – является  ли функция встроенной.

Инструкция встраивания, или _declspec(inline), не заставляет компилятор сделать функцию встроенной. Есть различные факторы (в основном неизвестные), определяющие, является ли функция встроенной. Это также не значит, что отсутствие директивы встраивания перед функцией не сделает ее встроенной. Листинг ассемблирования помогает выяснить, является ли функция встроенной. Берется следующий пример:

void ConvertStr(char* argv)
{
    szHelloWorld[1] = 'o';
    szHelloWorld[2] = 'l';
    szHelloWorld[3] = 'a';
    szHelloWorld[4] = '\'';
}

int main(int argc, char* argv[])
{
    printf(szHelloWorld);
    ConvertStr(szHelloWorld);
    printf(szHelloWorld);
    return 0;
}

Изучается листинг для главной функции.

_main   PROC NEAR       ; COMDAT

; 15   :    printf(szHelloWorld);

push OFFSET FLAT:?szHelloWorld@@3PADA    ; szHelloWorld
call  _printf

; 16   :   
; 17   :    ConvertStr(szHelloWorld);
; 18   :
; 19   :    printf(szHelloWorld);

push OFFSET FLAT:?szHelloWorld@@3PADA    ; szHelloWorld
mov BYTE PTR ?szHelloWorld@@3PADA+1, 111    ; 0000006fH
mov BYTE PTR ?szHelloWorld@@3PADA+2, 108    ; 0000006cH
mov BYTE PTR ?szHelloWorld@@3PADA+3, 97 ; 00000061H
mov BYTE PTR ?szHelloWorld@@3PADA+4, 39 ; 00000027H
call    _printf
add esp, 8

; 20   :
; 21   :    вернуть 0;

    xor eax, eax

; 22   : }

    ret 0

Нет команды вызова для ConvertStr, вместо этого есть много команд сдвига BYTE PTR, изменяющих символы в строке (это делает функция ConvertStr). Это показывает, что ConvertStr на самом деле была развернута как встроенная.

Отключается развертывание встроенной функции ConvertStr с помощью _declspec(noinline).

_main   PROC NEAR  ; COMDAT

; 15   :    printf(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA   
        ; szHelloWorld

    call    _printf

; 16   :    ConvertStr(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA   
        ; szHelloWorld

    call    ?ConvertStr@@YAXPAD@Z   ; ConvertStr

; 17   :    printf(szHelloWorld);

    push    OFFSET FLAT:?szHelloWorld@@3PADA           
        ; szHelloWorld

    call    _printf
    add esp, 12  ; 0000000cH

; 18   :    вернуть 0;

    xor eax, eax

; 19   : }

    ret 0
_main   ENDP

Как ожидается, есть команда вызова ConvertStr. Компилятор решает, делать ли функцию встроенной. Иногда, если встроенная функция вызывает другую встроенную функцию, встраивается только одна функция. Порой помогает использование #pragma inline_depth() и #pragma inline_recursion(). Файл листинга показывает, была ли функция встроена.


Деструкторы

Следующий пример показывает поведение деструкторов.

class SmartString
{
private:
    char* m_sz;

public:
    SmartString(char* sz)
    {
        m_sz = new char[strlen(sz) + 1];
        strcpy(m_sz, sz);
    }
   
    char* ToStr()
    {
        return m_sz;
    }

    _declspec(noinline) ~SmartString()
    {
        delete[] m_sz;
    }
};

int main(int argc, char* argv[])
{
    SmartString sz1("Hello World");
    printf(sz1.ToStr());

    return 0;
}

Сгенерированный код выглядит так.

; 36   : {

    push    ecx

; 37   :    SmartString sz1("Hello World");

push    OFFSET FLAT:??_C@_0M@KPLPPDAC@Hello?5World?$AA@
lea ecx, DWORD PTR _sz1$[esp+8]
call    ??0SmartString@@QAE@PAD@Z      
    ; SmartString::SmartString

; 38   :    printf(sz1.ToStr());

    mov eax, DWORD PTR _sz1$[esp+4]
    push    eax
    call    _printf
    add esp, 4

; 39   :
; 40   :    вернуть 0;

    lea ecx, DWORD PTR _sz1$[esp+4]

    call    ??1SmartString@@QAE@XZ
        ; SmartString::~SmartString

    xor eax, eax

; 41   : }

    pop ecx
    ret 0
_main   ENDP

Он весьма простой, и ясно виден вызов деструктора перед завершением функции. Интересно увидеть, как деструкторы работают для массива объектов. Приложение немного изменено.

int main(int argc, char* argv[])
{
    SmartString arr[2];

    arr[0] = ("Hello World");
    arr[1] = ("Hola' World");

    printf(arr[0].ToStr());
    printf(arr[1].ToStr());
   
    return 0;
}

Теперь последние несколько строк главной функции выглядят так.

push    OFFSET FLAT:??1SmartString@@QAE@XZ  
; SmartString::~SmartString

push    2
push    4
lea eax, DWORD PTR _arr$[ebp]
push    eax
call    ??_I@YGXPAXIHP6EX0@Z@Z
xor eax, eax
leave
ret 0  

Этот код является вызовом функции ??_I@YGXPAXIHP6EX0@Z@Z, что означает "итератор деструктора вектора" (видно в листинге). Компилятор автоматически генерирует эту функцию. Перевод вышеприведенного ассемблерного кода на C++ выглядит так:

vector_destructor_iterator(arr, 2, 4, 
        &SmartString::SmartString);

Чем именно является "итератор деструктора вектора"? Если массив объектов покидает область видимости – вызывается деструктор для каждого из объектов в массиве. Как раз это делает итератор деструктора вектора. Ниже код итератора деструктора вектора L изучается и декомпилируется.

PUBLIC  ??_I@YGXPAXIHP6EX0@Z@Z              
; `итератор деструктора вектора'

; Флаги компиляции функции: /Ogsy
;   COMDAT ??_I@YGXPAXIHP6EX0@Z@Z
_TEXT   SEGMENT
___t$ = 8
___s$ = 12
___n$ = 16
___f$ = 20
??_I@YGXPAXIHP6EX0@Z@Z PROC NEAR           
; `итератор деструктора вектора', COMDAT

    push    ebp
    mov ebp, esp
    mov eax, DWORD PTR ___n$[ebp]
    mov ecx, DWORD PTR ___s$[ebp]
    imul    ecx, eax
    push    edi
    mov edi, DWORD PTR ___t$[ebp]
    add edi, ecx
    dec eax
    js  SHORT $L912
    push    esi
    lea esi, DWORD PTR [eax+1]
$L911:
    sub edi, DWORD PTR ___s$[ebp]
    mov ecx, edi
    call    DWORD PTR ___f$[ebp]
    dec esi
    jne SHORT $L911
    pop esi
$L912:
    pop edi
    pop ebp
    ret 16          ; 00000010H
??_I@YGXPAXIHP6EX0@Z@Z ENDP            
; `итератор деструктора вектора'

Из вышесказанного следует, что __t$=8 и т.д. обозначают параметры функции. Уже известен способ, посредством которого функция была вызвана из ассемблерного кода функции _main. Из этого следует такая сигнатура функции:

typedef void (*DestructorPtr)(void* object);
void vector_destructor_iterator(void* _t,
        int _n, int _s, DestructorPtr _f)       

Реализацию функции можно декомпилировать до следующего

void vector_destructor_iterator(void* _t, int _n, int _s, DestructorPtr _f)
{
    unsigned char* ptr = _t + _s*_n;

    while(_n--)
    {
        ptr -= size;
        _f(ptr);
    }                   
}

Она вызывает деструктор для каждого объекта в массиве. Теперь можно выяснить, что означают отдельные параметры.

vector_destructor_iterator(arr, 2, 4, 
    &SmartString::SmartString);

•    Первый параметр – указатель на массив.
•    Второй параметр – количество элементов в массиве
•    Третий параметр – размер отдельного элемента. В данном случае sizeof(SmartString) (= 4).
•    Четвертый параметр – адрес функции деструктора.

В данном случае компилятор знал точное количество элементов в массиве. Так что он передает 2 как размер массива итератору деструктора вектора. Спрашивается, что происходит в случае динамических массивов, выделяемых с помощью new. В таком случае компилятор не может выяснить точный размер массива, так как массив выделяется во время выполнения. Для выяснения этого приложение снова меняется.

int main(int argc, char* argv[])
{
    SmartString arr = new SmartString[2];
    arr[0] = "Hello World";
    arr[1] = "Hola' World";

    printf(arr[0].ToStr());
    printf(arr[1].ToStr());
       
    delete [] arr;

    return 0;
}

Сейчас листинг ассемблирования выглядит так:

; 30   :    
; 31   :    delete [] arr;

    push    3
    mov ecx, esi
    call    ??_ESmartString@@QAEPAXI@Z
    pop edi

; 32   :
; 33   :    return 0;

    xor eax, eax
    pop esi

Появилась новая функция - ??_ESmartString@@QAEPAXI@Z. Функция называется "деструктор удаления вектора". Ассемблерный код для деструктора удаления вектора:

PUBLIC  ??_ESmartString@@QAEPAXI@Z          
; SmartString::`деструктор удаления вектора'

; Флаги компиляции функции: /Ogsy
;   COMDAT ??_ESmartString@@QAEPAXI@Z
_TEXT   SEGMENT
___flags$ = 8
??_ESmartString@@QAEPAXI@Z PROC NEAR           
; SmartString::`деструктор удаления вектора', COMDAT

; _this$ = ecx
    push    ebx
    mov bl, BYTE PTR ___flags$[esp]
    test    bl, 2
    push    esi
    mov esi, ecx
    je  SHORT $L896
    push    edi
    push    OFFSET FLAT:??1SmartString@@QAE@XZ 
    ; SmartString::~SmartString

    lea edi, DWORD PTR [esi-4]
    push    DWORD PTR [edi]
    push    4
    push    esi
    call    ??_I@YGXPAXIHP6EX0@Z@Z
    test    bl, 1
    je  SHORT $L897
    push    edi
    call    ??3@YAXPAX@Z ; оператор удаления
    pop ecx
$L897:
    mov eax, edi
    pop edi
    jmp SHORT $L895
$L896:
    mov ecx, esi
    call    ??1SmartString@@QAE@XZ         
    ; SmartString::~SmartString

    test    bl, 1
    je  SHORT $L899
    push    esi
    call    ??3@YAXPAX@Z ; оператор удаления
    pop ecx
$L899:
    mov eax, esi
$L895:
    pop esi
    pop ebx
    ret 4
??_ESmartString@@QAEPAXI@Z ENDP            
; SmartString::`деструктор удаления вектора'

_TEXT   ENDS

Эта функция имеет соглашение о вызовах __thiscall, означающее, что первый параметр this помещается в ECX. Это псевдосоглашение о вызовах используется для вызова функций-членов. Псевдокод C++ для этого таков:-

void SmartString::vector_deleting_destructor(int flags)
{
    if (flags & 2)
    {
        int numElems = *((unsigned char*)this - 4);
        vector_destructor_iterator(this, numElems,
            4, &SmartString::SmartString);
    }
    else
    {
        this->~SmartString();
    }

    if (flags & 1)
        delete ((unsigned char*)this - 4);
}

Видно, что количество элементов хранится непосредственно перед первым элементом массива. Это значит, что оператор new[] должен выделить лишние 4 байта. Изучение ассемблера, сгенерированного для вызова new[], подтверждает это.

; 23   : SmartString* arr = new SmartString[2];

    push    12      ; 0000000cH
    call    ??2@YAPAXI@Z    ; оператор new     

Оператор new принимает размер выделяемого параметра. Видно, что число 12 помещается в стек. Размер SmartString - всего 4 байта, а суммарный размер двух элементов - 8 байтов. Оператор new действительно выделяет лишние 4 байта, чтобы запомнить количество элементов в массиве. Для дополнительного подтверждения этого перегружается оператор new[] в SmartString. Видно, что количество запрашиваемой памяти всегда на 4 байта больше фактической памяти, необходимой для хранения массива.


Конструкторы

Рассматривается ассемблерный код для секции, где вызывается оператор new.

; 23   : SmartString* arr = new SmartString[2];

    push    12  ; 0000000cH
    call    ??2@YAPAXI@Z    ; оператор new
    test    eax, eax
    pop ecx
    je  SHORT $L980
    push    2
    pop ecx
    push    OFFSET FLAT:??0SmartString@@QAE@XZ 
    ; SmartString::SmartString

    push    ecx
    lea esi, DWORD PTR [eax+4]
    push    4
    push    esi
    mov DWORD PTR [eax], ecx
    call    ??_H@YGXPAXIHP6EPAX0@Z@Z
    jmp SHORT $L981
$L980:
    xor esi, esi
$L981:

Функция ??_H@YGXPAXIHP6EPAX0@Z@Z - "итератор конструктора вектора", подобный итератору деструктора вектора.

Его перевод на псевдокод C++ выглядит так

unsigned char* allocated = 
    new unsigned char[12]; //Выделить 12 байтов, 4 байта для размера

if (allocated != NULL)
{
    //Поместить размер в первые четыре байта
    //Фактический массив начинается в выделенном + 4
    *(int*)allocated = 4;
   
    vector_constructor_iterator(allocated + 4,
        4, 2, &SmartString::SmartString);
}

Итератор конструктора вектора работает так же, как итератор деструктора вектора. Он вызывает конструктор для всех элементов в массиве.

Исключения

Во всех примерах была отключена обработка исключений перед компиляцией приложения. Активация обработки исключений заставляет компилятор генерировать много лишнего кода.

Заключение

Были изучены некоторые аспекты внутренних механизмов компилятора C++ с помощью листинга ассемблирования. Изучение листинга ассемблирования дает четкое представление о том, что компилятор делает внутри с кодом C++. Это помогает писать более качественный и эффективный код C++.