Выполнение программы — различия между версиями

Материал из Викиконспекты
Перейти к: навигация, поиск
(Последовательное выполнение)
м (rollbackEdits.php mass rollback)
 
(не показано 26 промежуточных версий 4 участников)
Строка 3: Строка 3:
  
 
==Последовательное выполнение==
 
==Последовательное выполнение==
При старте процесса создается единица выполнения кода - ''поток''. Поток имеет точку входа - адрес нахождения первой команды программы в адресном пространстве, и счетчик команд. Каждая команда занимает несколько байт, не обязательно постоянное число. Можно считать, что поток передает команду и ее аргументы процессору, тот выполняет ее и сообщает результат, после этого поток переходит на следующую команду с помощью счетчика команд.  
+
При старте процесса создается единица выполнения кода {{---}} ''поток''. Потоку передается точка входа {{---}} адрес нахождения первой команды программы в адресном пространстве. Можно считать, что поток передает команду и ее аргументы процессору, тот выполняет ее и сообщает результат, после этого поток переходит на следующую команду с помощью счетчика команд. Каждая команда в адресном пространстве занимает несколько байт, не обязательно постоянное число.
  
 +
{{TODO|t=запилить пруфлинк на то, как на самом деле}}
 
{{TODO|t=можно написать про переключение потоков}}
 
{{TODO|t=можно написать про переключение потоков}}
{{TODO|t=запилить картинку и/или пример на дизассемблированном коде}}
+
==Вызов функций==
{{TODO|t=запилить пруфлинк на то, как на самом деле}}
+
===Адрес возврата===
 +
[[Файл:Func.png|right|frame|<center>Вызов функции</center>]]
 +
Представим, что в нашем коде есть функция, не принимающая аргументов, ничего не возвращающая, и ничего не выполняющая:
 +
void f()
 +
{
 +
}
 +
Для ее вызова в адресном пространстве будет использоваться команда <tex>call f</tex>, которая переведет поток на начало функции <tex>f</tex>, а для возврата обратно в место, откуда функция была вызвана {{---}} команда <tex>ret</tex>. Возникает вопрос: как найти адрес команды, к которой надо перейти после выполнения <tex>ret</tex>? Можно сохранить этот адрес в переменной, но, в таком случае, нельзя будет вызывать функции из других функций, или создавать рекурсии:
 +
void f()
 +
{
 +
}
 +
 +
void g()
 +
{
 +
  f();
 +
}
 +
Поэтому, в адресном пространстве существует ''стек'', на который при вызове функции кладется адрес возврата. Команда <tex>ret</tex> удаляет текущий адрес возврата с вершины стека, и устанавливает счетчик команд по этому адресу.
 +
===Передача параметров===
 +
Представим, что теперь нам необходимо вызвать чуть более сложную функцию:
 +
void f(int a, int b)
 +
{
 +
}
 +
Передавать аргументы внутрь функции удобно, также используя стек. Во всех конвенциях вызова C++ сначала передаются аргументы в обратном порядке, а потом - адрес возврата, то есть, вызов <tex>f(239, 566)</tex> будет исполнен как
 +
push 566
 +
push 239
 +
call f
 +
, где <tex>push</tex> <tex>n</tex> {{---}} команда "положить на стек n". Адрес возврата положится на стек при вызове команды <tex>call</tex>.
 +
 
 +
Хорошим объяснением того, почему конвенции вызова используют обратный порядок передачи аргументов являются эллипсисы (функции с неопределенным количеством аргументов).
 +
 
 +
{{TODO|t=пример программы с эллипсисом}}
 +
 
 +
===Возвращаемое значение===
 +
Возвращаемое функцией значение тоже храниться на стеке, рядом с локальными переменными.
 +
{{TODO|t=дописать}}
 +
{{TODO|t=это мы вроде так для простоты считаем, ссылка на правду}}
 +
===Конвенции вызова===
 +
====Отличия конвенций====
 +
Заметим, что мы положили аргументы на стек, но не удалили их оттуда, поэтому при следующем вызове <tex>ret</tex> будет взят неверный адрес возврата. В С++ существует несколько конвенций вызова функций, рассмотрим 2 из них: <tex>\_\_stdcall</tex> и <tex>\_\_cdecl</tex>.
 +
 
 +
При использовании <tex>\_\_stdcall</tex> аргументы удалятся из стека при выполнении команды <tex>ret</tex>, а при использовании <tex>\_\_cdecl</tex> после возврата из функции необходимо переместить вершину стека командой <tex>add</tex>. Рассмотрим пример:
 +
void __cdecl f(int a, int b)
 +
{
 +
}
 +
 +
void __cdecl g(int a, int b, int c)
 +
{
 +
}
 +
 +
int main()
 +
{
 +
  f(2, 3);
 +
  g(1, 2, 3);
 +
  return 0;
 +
}
 +
При выполнении этого кода можно будет сэкономить на количестве команд, не удаляя аргументы <tex>2</tex> и <tex>3</tex> со стека:
 +
push 3
 +
push 2
 +
call f
 +
push 1
 +
call g
 +
add esp, 12 //смещает вершину стека на 12 байт
 +
 
 +
====Конфликт конвенций====
 +
По умолчанию в С++ используются конвенции вызова <tex>\_\_cdecl</tex> и <tex>\_\_thiscall</tex>, которая используется для методов класса и отличается тем, что кладет один аргумент в регистр процессора.
 +
 
 +
Посмотрим, что произойдет, если искусственно создать конфликт конвенций вызова:
 +
void __decl f(int a)
 +
{
 +
}
 +
 +
int main()
 +
{
 +
  void __stdcall (*g)(int);
 +
  g = (void __stdcall (*)(int)) &f;
 +
  g(239);
 +
  return 0;
 +
}
 +
Так как <tex>g</tex> задана конвенция вызова <tex>\_\_stdcall</tex>, после ее вызова вершина стека не будет сдвигаться. Однако, <tex>f</tex> задана конвенция вызова <tex>\_\_cdecl</tex>, поэтому аргумент не удалится со стека при вызове <tex>ret</tex>, и при следующем запросе адреса возврата будет выдан адрес <tex>239</tex>, скорее всего, не являющийся валидным.
 +
 
 +
==Размещение локальных переменных==
 +
 
 +
[[Файл:locals.png|right|thumb|200px|<center>Стек при выполнении функции</center>]]
 +
 
 +
В функциях для выполнения промежуточных вычислений, временного хранения данных и пр. могут быть использованы локальные переменные. Эти переменные являются временными и создаются при вызове функции, а удаляются при завершении ее выполнения. Для использования локальных переменных необходимо выделить память. В С++ память для всех локальных переменных функции выделяется при начале ее выполнения, и удаляется перед выполнением команды <tex>ret</tex>, чтобы в вершине стека снова оказался адрес возврата.
 +
 
 +
С помощью функции <tex>\_alloca()</tex> можно увеличить размер стека для размещения локальных данных. Так как объем стека ограничен,при использовании этой функции может возникнуть переполнение стека. Простейший пример кода, который вызовет переполнение:
 +
void f(int size)
 +
{
 +
  for (;;)
 +
  {
 +
    _alloca(size);
 +
  }
 +
}
 +
Однако, переполнение может возникнуть и в менее очевидном случае:
 +
void f(int size)
 +
{
 +
    _alloca(size);
 +
}
 +
 +
int main()
 +
{
 +
  int size = 239;
 +
  for(;;)
 +
  {
 +
    f(size);
 +
  }
 +
}
 +
Для упрощения выполнения, компилятор может посчитать, что функция <tex>f</tex> имеет свойство <tex>inline</tex>, и преобразовать код в следующий:
 +
int main()
 +
{
 +
  int size = 239;
 +
  for(;;)
 +
  {
 +
    _alloca(size);
 +
  }
 +
}
 +
, что, в свою очередь, вызовет переполнение.
 +
 
 +
==Архив==
 +
 
 +
{{TODO|t=включить в главу про кучу}}
 +
 
 +
Динамическая память - это память, получающаяся из свободной памяти при выделении памяти. Слово "динамическая" здесь означает "динамически распределяемая". Динамическая память выделяется и освобождается функциями <tex>VirtualAlloc</tex> и <tex>VirtualFree</tex>. Вызывая <tex>VirtualAlloc</tex>, указывая размер блока памяти и желаемый атрибут доступа (обычно: чтение-запись). Система выделяет от свободной памяти блок. Теперь в программе выделена память, и есть указатель на нее. Когда память надо освободить - вызывайте <tex>VirtualFree</tex>. Система переведёт память обратно в свободную.
 +
Чем плохо такое выделением памяти? Оно выделяет больше памяти, чем нужно, и происходит частое обращение к ядру. Для этого в языке c++ есть особенные функции, для работы с областью памяти - куча "heap".
 +
 
 +
В стандартной библиотеке, пришедшей из языка C, <tex>libc</tex> реализованы функции <tex>malloc()</tex> и <tex>free()</tex>, соответственно для выделения и освобождения памяти. В самом C++ есть аналогичные функции <tex>new (new[])</tex> и <tex>delete(delete[])</tex>.
 +
 
 +
Для каждого <tex>malloc/new/new[]</tex> должны вызываться <tex>free/delete/delete[]</tex>, т.к. память сама не освобождается при выходе из функций. Не вызвав эти функции, куча останется неосвобожденнной, и произойдут утечки памяти.
 +
 
 +
{{TODO|t=запилить картиночки, поправить корявые фразочки}}
 +
 
 +
Рассмотрим процесс выделения памяти в куче на примере простой программы:
 +
 
 +
[[Файл:vmmap_before.png|right|thumb|200px|Память не выделена]]
 +
[[Файл:vmmap_after.png||right|thumb|200px|Выделена память под массив]]
 +
[[Файл:vmmap_delete.png||right|thumb|200px|Память освобождена]]
 +
 
 +
int main()
 +
{
 +
    int *a = new int [1000000];
 +
    delete [] a;
 +
    return 0;
 +
}
 +
 
 +
До выполнения
 +
int *a = new int [1000000];
 +
куча в адресном пространстве программы занимает <tex>1152kB</tex>, а после выполнения команды - <tex>5060kB</tex>. Значит, память была выделена именно в куче, и объем выделенной памяти равен <tex>3908kB</tex>, что почти в точности соответствует объему массива <tex>int</tex> из <tex>1000000</tex> элементов при выделении <tex>4B</tex> на элемент.
 +
 
 +
После выполнения
 +
delete [] a;
 +
размер кучи снова равен <tex>1152kB</tex>, как и до выделение памяти под массив.
 +
 
 +
 
 +
{{TODO|t=Показать в адресном пространстве ран-тайм, библиотеки ядра}}
 +
 
 +
==Проблема, возникающая при использовании многих библиотек==
 +
{{TODO|t=насколько я помню, это позиционировалось не столько как проблема, больше - просто фича. Поправить}}
 +
{{TODO|t=запилить картиночки, поправить корявые фразочки}}
 +
 
 +
Для начала рассмотрим пример программы, где используются две библиотеки: пользовательская (<tex>a.dll</tex>) и стандартная (<tex>msvcrt.dll</tex>). В случае, когда библиотека <tex>a.dll</tex> использует для работы с кучей библиотеку <tex>msvcrt.dll</tex>, никаких проблем не возникнет, т.к. при работе программы куча будет одна и функции для работы с ней будут одинаковые, что в пользовательской библиотеке, что в нашей программе.
 +
 
 +
Теперь рассмотрим другую ситуацию, когда в программе используется всё та же <tex>msvcrt.dll</tex>, но в <tex>a.dll</tex> пусть используется другая стандартна библиотека для работы с кучей: <tex>msvcrtr.dll</tex>.
 +
Здесь может случится неприятная вещь: если мы будем пытаться освободить память из кучи, используя средства пользовательской библиотеки, у нас произойдет ошибка, т.к. кучи для библиотеки и программы будут разные и функция <tex>free</tex> может просто не понять, от куда ей надо освобождать, потому что в библиотеке она использовала <tex>new</tex> из <tex>msvcrtr.dll</tex>, а при вызове <tex>delete</tex> из программы мы обратимся к <tex>msvcrt.dll</tex>.
  
==Вызов функций==
+
Согласитесь, не очень приятная ошибка. Для борьбы с ней есть верный способ --- использовать статическое объявление функций из стандартных библиотек: мы "выдёргиваем" только те функции, которые нас интересуют и объявляем их через <tex>static</tex>. У нас будет явное обращение к ним и тогда не произойдет ошибки, возникающей во втором примере.

Текущая версия на 19:21, 4 сентября 2022

Эта статья находится в разработке!

Последовательное выполнение

При старте процесса создается единица выполнения кода — поток. Потоку передается точка входа — адрес нахождения первой команды программы в адресном пространстве. Можно считать, что поток передает команду и ее аргументы процессору, тот выполняет ее и сообщает результат, после этого поток переходит на следующую команду с помощью счетчика команд. Каждая команда в адресном пространстве занимает несколько байт, не обязательно постоянное число.


TODO: запилить пруфлинк на то, как на самом деле

TODO: можно написать про переключение потоков

Вызов функций

Адрес возврата

Вызов функции

Представим, что в нашем коде есть функция, не принимающая аргументов, ничего не возвращающая, и ничего не выполняющая:

void f()
{
}

Для ее вызова в адресном пространстве будет использоваться команда [math]call f[/math], которая переведет поток на начало функции [math]f[/math], а для возврата обратно в место, откуда функция была вызвана — команда [math]ret[/math]. Возникает вопрос: как найти адрес команды, к которой надо перейти после выполнения [math]ret[/math]? Можно сохранить этот адрес в переменной, но, в таком случае, нельзя будет вызывать функции из других функций, или создавать рекурсии:

void f()
{
}

void g()
{
  f();
}

Поэтому, в адресном пространстве существует стек, на который при вызове функции кладется адрес возврата. Команда [math]ret[/math] удаляет текущий адрес возврата с вершины стека, и устанавливает счетчик команд по этому адресу.

Передача параметров

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

void f(int a, int b)
{
}

Передавать аргументы внутрь функции удобно, также используя стек. Во всех конвенциях вызова C++ сначала передаются аргументы в обратном порядке, а потом - адрес возврата, то есть, вызов [math]f(239, 566)[/math] будет исполнен как

push 566
push 239
call f

, где [math]push[/math] [math]n[/math] — команда "положить на стек n". Адрес возврата положится на стек при вызове команды [math]call[/math].

Хорошим объяснением того, почему конвенции вызова используют обратный порядок передачи аргументов являются эллипсисы (функции с неопределенным количеством аргументов).


TODO: пример программы с эллипсисом

Возвращаемое значение

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

TODO: дописать

TODO: это мы вроде так для простоты считаем, ссылка на правду

Конвенции вызова

Отличия конвенций

Заметим, что мы положили аргументы на стек, но не удалили их оттуда, поэтому при следующем вызове [math]ret[/math] будет взят неверный адрес возврата. В С++ существует несколько конвенций вызова функций, рассмотрим 2 из них: [math]\_\_stdcall[/math] и [math]\_\_cdecl[/math].

При использовании [math]\_\_stdcall[/math] аргументы удалятся из стека при выполнении команды [math]ret[/math], а при использовании [math]\_\_cdecl[/math] после возврата из функции необходимо переместить вершину стека командой [math]add[/math]. Рассмотрим пример:

void __cdecl f(int a, int b)
{
}

void __cdecl g(int a, int b, int c)
{
}

int main()
{
  f(2, 3);
  g(1, 2, 3);
  return 0;
}

При выполнении этого кода можно будет сэкономить на количестве команд, не удаляя аргументы [math]2[/math] и [math]3[/math] со стека:

push 3
push 2
call f
push 1
call g
add esp, 12 //смещает вершину стека на 12 байт

Конфликт конвенций

По умолчанию в С++ используются конвенции вызова [math]\_\_cdecl[/math] и [math]\_\_thiscall[/math], которая используется для методов класса и отличается тем, что кладет один аргумент в регистр процессора.

Посмотрим, что произойдет, если искусственно создать конфликт конвенций вызова:

void __decl f(int a)
{
}

int main()
{
  void __stdcall (*g)(int);
  g = (void __stdcall (*)(int)) &f;
  g(239);
  return 0;
}

Так как [math]g[/math] задана конвенция вызова [math]\_\_stdcall[/math], после ее вызова вершина стека не будет сдвигаться. Однако, [math]f[/math] задана конвенция вызова [math]\_\_cdecl[/math], поэтому аргумент не удалится со стека при вызове [math]ret[/math], и при следующем запросе адреса возврата будет выдан адрес [math]239[/math], скорее всего, не являющийся валидным.

Размещение локальных переменных

Стек при выполнении функции

В функциях для выполнения промежуточных вычислений, временного хранения данных и пр. могут быть использованы локальные переменные. Эти переменные являются временными и создаются при вызове функции, а удаляются при завершении ее выполнения. Для использования локальных переменных необходимо выделить память. В С++ память для всех локальных переменных функции выделяется при начале ее выполнения, и удаляется перед выполнением команды [math]ret[/math], чтобы в вершине стека снова оказался адрес возврата.

С помощью функции [math]\_alloca()[/math] можно увеличить размер стека для размещения локальных данных. Так как объем стека ограничен,при использовании этой функции может возникнуть переполнение стека. Простейший пример кода, который вызовет переполнение:

void f(int size)
{
  for (;;) 
  {
    _alloca(size);
  }
}

Однако, переполнение может возникнуть и в менее очевидном случае:

void f(int size)
{
   _alloca(size);
}

int main()
{
  int size = 239;
  for(;;)
  {
    f(size);
  }
}

Для упрощения выполнения, компилятор может посчитать, что функция [math]f[/math] имеет свойство [math]inline[/math], и преобразовать код в следующий:

int main()
{
  int size = 239;
  for(;;)
  {
    _alloca(size);
  }
}

, что, в свою очередь, вызовет переполнение.

Архив

TODO: включить в главу про кучу

Динамическая память - это память, получающаяся из свободной памяти при выделении памяти. Слово "динамическая" здесь означает "динамически распределяемая". Динамическая память выделяется и освобождается функциями [math]VirtualAlloc[/math] и [math]VirtualFree[/math]. Вызывая [math]VirtualAlloc[/math], указывая размер блока памяти и желаемый атрибут доступа (обычно: чтение-запись). Система выделяет от свободной памяти блок. Теперь в программе выделена память, и есть указатель на нее. Когда память надо освободить - вызывайте [math]VirtualFree[/math]. Система переведёт память обратно в свободную. Чем плохо такое выделением памяти? Оно выделяет больше памяти, чем нужно, и происходит частое обращение к ядру. Для этого в языке c++ есть особенные функции, для работы с областью памяти - куча "heap".

В стандартной библиотеке, пришедшей из языка C, [math]libc[/math] реализованы функции [math]malloc()[/math] и [math]free()[/math], соответственно для выделения и освобождения памяти. В самом C++ есть аналогичные функции [math]new (new[])[/math] и [math]delete(delete[])[/math].

Для каждого [math]malloc/new/new[][/math] должны вызываться [math]free/delete/delete[][/math], т.к. память сама не освобождается при выходе из функций. Не вызвав эти функции, куча останется неосвобожденнной, и произойдут утечки памяти.


TODO: запилить картиночки, поправить корявые фразочки

Рассмотрим процесс выделения памяти в куче на примере простой программы:

Память не выделена
Выделена память под массив
Память освобождена
int main()
{
    int *a = new int [1000000];
    delete [] a;
    return 0;
}

До выполнения

int *a = new int [1000000];

куча в адресном пространстве программы занимает [math]1152kB[/math], а после выполнения команды - [math]5060kB[/math]. Значит, память была выделена именно в куче, и объем выделенной памяти равен [math]3908kB[/math], что почти в точности соответствует объему массива [math]int[/math] из [math]1000000[/math] элементов при выделении [math]4B[/math] на элемент.

После выполнения

delete [] a;

размер кучи снова равен [math]1152kB[/math], как и до выделение памяти под массив.


TODO: Показать в адресном пространстве ран-тайм, библиотеки ядра

Проблема, возникающая при использовании многих библиотек

TODO: насколько я помню, это позиционировалось не столько как проблема, больше - просто фича. Поправить

TODO: запилить картиночки, поправить корявые фразочки

Для начала рассмотрим пример программы, где используются две библиотеки: пользовательская ([math]a.dll[/math]) и стандартная ([math]msvcrt.dll[/math]). В случае, когда библиотека [math]a.dll[/math] использует для работы с кучей библиотеку [math]msvcrt.dll[/math], никаких проблем не возникнет, т.к. при работе программы куча будет одна и функции для работы с ней будут одинаковые, что в пользовательской библиотеке, что в нашей программе.

Теперь рассмотрим другую ситуацию, когда в программе используется всё та же [math]msvcrt.dll[/math], но в [math]a.dll[/math] пусть используется другая стандартна библиотека для работы с кучей: [math]msvcrtr.dll[/math]. Здесь может случится неприятная вещь: если мы будем пытаться освободить память из кучи, используя средства пользовательской библиотеки, у нас произойдет ошибка, т.к. кучи для библиотеки и программы будут разные и функция [math]free[/math] может просто не понять, от куда ей надо освобождать, потому что в библиотеке она использовала [math]new[/math] из [math]msvcrtr.dll[/math], а при вызове [math]delete[/math] из программы мы обратимся к [math]msvcrt.dll[/math].

Согласитесь, не очень приятная ошибка. Для борьбы с ней есть верный способ --- использовать статическое объявление функций из стандартных библиотек: мы "выдёргиваем" только те функции, которые нас интересуют и объявляем их через [math]static[/math]. У нас будет явное обращение к ним и тогда не произойдет ошибки, возникающей во втором примере.