Trans46

advertisement
4.6. Внутреннее представление Си-программы
Система представлений о внутренней среде исполнения Си-программы (имеется в
виду «классический» Си, а не Си++) максимально приближена к «реальностям», с
которыми сталкивается программа, работающая непосредственно на уровне архитектуры,
и не имеющая под собой «прослойки» программного обеспечения. К этому языку
применим термин «машинно-независимый ассемблер», а основные понятий его
семантики прямо проецируются на соответствующие элементы архитектуры, в том
числе:

«чистота» программного кода. При отсутствии обращений к внешним
функциям компилятор генерирует программный код, представляющий собой
«вещь в себе». Кроме того, строго оговорены неявные преобразования,
сопровождающиеся «вставками» постороннего кода;

соответствие базовых типов данных и форматов представления данных в
процессоре. Целые типы ассоциируются с машинными словами различной
размерности, поддерживается набор операций арифметических, логических
и поразрядных преобразований;

работы с памятью на низком уровне. Тип указателя однозначно
интерпретируется как адрес в непрерывном адресном пространстве. На
низком уровне указатели различных типов приводятся друг к другу и к
целому типу, передача указателя от одной программной компоненты к
другой обеспечивает доступ к данным из любой точки программы;

все типы данных имеют фиксированную размерность, которая может быть
получена программой с помощью операции sizeof. Программы может
реализовать любую систему распределения памяти и свободно переходить от
одного представления данных к другому в одной и той же области памяти;

максимально используется статическое связывание имен переменных и
функций с адресами памяти, большинство имен получает фиксированные
адреса или смещения (например, относительно текущего фрейма стека);

структуры данных переменной размерности создаются на основе системы
динамического распределения памяти, которое фактически реализовано вне
языка (библиотека функций), использует указатели и средства адресной
арифметики;
Перечисленные свойства позволяют говорить о Си как единственной альтернативе
Ассемблеру при программировании встроенных систем, работающих на «голой»
архитектуре, внутренних кодов ядра операционных систем, программ с повышенными
требованиями к быстродействию и использованию памяти. В качестве примера
рассмотрим механизм вызова функций, использующий простой способ передачи
параметров через стек и установление из соответствия посредством объявлений
(передаче вызывающей программе заголовка или прототипа функции, если вызываемая
функция находится вне зоны «видимости» программы).
Вызов функции и передача параметров
Механизм вызова функций использует аппаратный стек, который реализуется на
базе оперативной памяти. Элементы стека расположены в оперативной памяти, каждый
из них занимает одно слово. Регистр SP в любой момент времени хранит адрес элемента
в вершине стека. Стек растет в сторону уменьшения адресов: элемент, расположенный
непосредственно под вершиной стека, имеет адрес SP + 4 (при условии, что размер слова
© Теория языков программирования и методы трансляции
1.
равен четырем байтам), следующий SP + 8 и т.д.). Размещение стека «вниз головой»
является историческим анахронизмом: при отсутствии сегментации памяти
динамические данные программы размещались в памяти «снизу вверх», а стек рос в
обратном направлении. В настоящее время стек размещается в отдельном сегменте (SS),
указатель стека SP (а также указатель фрейма стека BP) содержат относительные адреса в
этом сегменте.
Оперативная память
адрес содержимое
0
4
8
...
...
SP
элементы
<=вершина стека
SP+4 Стека
SP+8
...
...
32
2 -4
Поскольку регистр SP содержит адрес машинного слова, его значение всегда
кратно четырем. При помещении элемента x в стек значение SP сначала уменьшается на
4, затем x записывается в слово оперативной памяти с адресом SP. При извлечении
элемента из стека сначала слово с адресом SP копируется в выходную переменную x,
затем значение SP, т.е. адрес вершины стека, увеличивается на 4. Обычно команда
добавления в стек обозначается словом push, команда извлечения из стека — словом
pop:
push X ~ SP := SP − 4;
m [SP] := X;
pop X ~ X := m [SP] ;
SP := SP + 4;
Здесь через m[SP] обозначается содержимое слова памяти с адресом SP (m сокращение от memory).
Команды вызова подпрограммы call и возврата return
Одно из главных назначений аппаратного стека — поддержка вызовов
подпрограмм. При вызове подпрограммы надо сохранить адрес возврата, чтобы
подпрограмма могла по окончанию своей работы вернуть управление вызвавшей ее
программе. В старых архитектурах, в которых аппаратный стек отсутствовал (например,
в компьютерах IBM 360/370), точки возврата сохранялись в фиксированных ячейках
памяти для каждой подпрограммы. Это делало невозможной рекурсию, т.е. повторный
вызов той же подпрограммы непосредственно из ее текста или через цепочку
промежуточных вызовов, поскольку при повторном вызове старое содержимое ячейки,
хранившей адрес возврата, терялось
Во всех современных архитектурах точка возврата сохраняется в аппаратном
стеке, что делает возможным рекурсию, а также параллельное выполнение нескольких
легковесных процессов (нитей). Для вызова подпрограммы ƒ служит команда call,
которая осуществляет переход к подпрограмме ƒ (т.е. присваивает регистру PC адрес ƒ) и
одновременно помещает старое содержимое регистра PC в стек: call ƒ => push PC; PC:= ƒ;
© Теория языков программирования и методы трансляции
2.
В момент выполнения любой команды регистр PC содержит адрес следующей
команды, т.е. фактически адрес возврата из подпрограммы ƒ. Таким образом, команда
call сохраняет в стеке точку возврата и осуществляет переход к подпрограмме ƒ.
Для возврата из подпрограммы используется команда return. Она извлекает из
стека адрес возврата и помещает его в регистр PC: return => pop PC;
Аппаратный стек и локальные переменные подпрограммы
Поскольку аппаратный стек располагается в оперативной памяти, в нем можно
размещать обычные переменные программы. Размещение локальных переменных в стеке
обладает рядом преимуществ по сравнению со статическим размещением переменных в
фиксированных ячейках оперативной памяти. Как уже говорилось выше, это позволяет
организовывать рекурсию. Кроме того, в современных архитектурах принципиальное
значение имеет поддержка параллельных процессов, работающих над общими
статическими переменными. Это так называемые легковесные процессы, потоки или
нити (Thread), работающие параллельно в рамках одной программы. На использовании
нитей, например, основана работа всех графических приложений в системе Microsoft
Windows 32: одна нить обрабатывает сообщения графической системы (нажатия на
клавиатуру и кнопки мыши, перерисовка окон, выборка команд из меню и т.п.), другие
нити занимаются вычислениями, сетевым обменом, анимацией и т.п.
Различные нити работают параллельно над общими статическими данными,
совершая таким образом некоторую совместную работу. При этом одна и та же
подпрограмма может вызываться из разных нитей. В отличие от статических
переменных, которые являются общими для всех нитей, для каждой нити выделяется
свой отдельный стек. При использовании нитей очень важно, чтобы локальные
переменные подпрограммы располагались в стеке. Иначе было бы невозможно
параллельно вызывать одну и ту же подпрограмму из разных нитей: повторный вызов
подпрограммы, уже работающей в рамках другой нити, разрушил бы статический набор
локальных переменных этой подпрограммы. А при использовании стека наборы
локальных данных одной и той же подпрограммы, вызываемой из разных нитей,
различны, поскольку они располагаются в разных стеках. Таким образом, разные нити
работают с разными наборами локальных переменных, не мешая друг другу.
Рассмотрим более подробно, как размещаются локальные переменные
подпрограммы в стеке, на примере языка Си. В Си подпрограммы называются
функциями. Функция может иметь аргументы и локальные переменные, т.е. переменные,
существующие только в процессе выполнения функции. Рассмотрим для примера
функцию ƒ, зависящую от двух входных аргументов x и y целого типа, в которой
используются три локальные переменные a, b и c также целого типа. Функция
возвращает целое значение.
int f(int x, int y) { int a, b, c;... }
Пусть в некотором месте программы вызывается функция ƒ с аргументами x = 222, y =
333:
z = f(222, 333);
Вызывающая программа помещает фактические значения аргументов x и y функции ƒ в
стек, при этом на вершине стека лежит первый аргумент функции, под ним — второй
аргумент. Вызов функции транслируется в следующие команды:
push 333
push 222
call ƒ
© Теория языков программирования и методы трансляции
3.
Обратите внимание, что в стек сначала помещается второй аргумент функции,
затем первый, в результате на вершине стека оказывается первый аргумент. При
выполнении инструкции вызова call в стек помещается также адрес возврата.
В момент начала работы функции ƒ cтек имеет следующий вид:
адрес возврата <=SP
222
333
....
На вершине стека лежит адрес возврата, под ним — фактическое значение
аргумента x, затем фактическое значение аргумента y.
Перед началом работы функция ƒ должна захватить в стеке область памяти под
свои локальные переменные a, b, c. В языке Си принято следующее соглашение: адрес
блока локальных переменных функции в момент ее работы помещается в специальный
регистр процессора, который называется FP, от англ. Frame Pointer — указатель кадра.
(В процессоре Intel 80386 роль указателя кадра выполняет регистр EBP.) В первую
очередь функция ƒ сохраняет в стеке предыдущее значение регистра FP. Затем значение
указателя стека копируется в регистр FP. После этого функция ƒ захватывает в стеке
область памяти размером в 3 машинных слова под свои локальные переменные a, b, c.
Для этого функция ƒ просто уменьшает значение регистра SP на 12 (три машинных слова
равны двенадцати байтам). Таким образом, начало функции ƒ состоит из следующих
команд:
ush FP
FP := SP
SP := SP − 12
После захвата кадра локальных переменных стек выглядит следующим образом.
C
<=SP
B
A
старое значение FP <=FP
адрес возврата
x=222
y=333
...
Аргументы и локальные переменные функции ƒ адресуются относительно
регистра FP. Так, аргумент x имеет адрес FP+8, аргумент y - адрес FP+12. Переменная a
имеет адрес FP-4, переменная b - адрес FP-8, переменная c - адрес FP-12.
По окончании работы функция ƒ сначала увеличивает указатель стека на 12,
удаляя таким образом из стека свои локальные переменные a, b, c. Затем старое значение
FP извлекается из стека и помещается в FP (таким образом, регистр FP восстанавливает
свое значение до вызова функции ƒ). После этого осуществляется возврат в вызывающую
программу: адрес возврата снимается со стека и управление передается по адресу
возврата. Результат функции ƒ передается через нулевой регистр.
R0 := результат функции
SP := SP +12
pop FP
return
Вызывающая программа удаляет из стека фактические значения аргументов x и y,
помещенные в стек перед вызовом функции ƒ.
© Теория языков программирования и методы трансляции
4.
Виртуальные функции и динамическое связывание
Несмотря на существенные различия Си++ от классического Си, программный
код Си++ также достаточно «чист» даже при наличии элементов динамического
связывания. Ярким примером здесь является механизм реализации виртуальных
функций. В Си++ синтаксически неизменный вызов виртуальной функции с одним и тем
же именем в зависимости от типа указуемого объекта приводит вызову одноименной
функции в различных производных классах, в зависимости от того, объект какого класса
«скрыт» под указателем. Механизм виртуальных функций реализуется через указатели на
функции, которые связываются с объектом базового класса в момент его создания (то
есть динамически, во время работы программы):
 для каждой пары производный класс - базовый класс транслятором создается
массив указателей на функции (таблица функций), каждой виртуальной функции
соответствует в нем свое значение индекса (смещение);
 в объекте базового класса имеется указатель на массив указателей на функции,
куда при конструировании объекта производного класса записывается адрес
таблицы его виртуальных функций;
 если объект базового класса расположен не в начале объекта производного
класса, то перед вызовом виртуальной функции транслятор должен предусмотреть
преобразование указателя с объекта базового класса на объект производного
(например, использовать дополнительную таблицу смещений);
 вызов виртуальной функции транслируется в вызов функции по указателю,
извлеченному по фиксированному смещению из таблицы, связанной с объектом
базового класса.
Сказанное можно проиллюстрировать средствами того же Си. Естественно, что модель
не отражает всех свойств оригинала, например, различия интерфейсов виртуальных
функций.
// Выделены компоненты, создаваемые транслятором
class A {
void (**ftable)();
// Указатель на массив указателей
public:
// на виртуальные функции (таблицу функций)
virtual void x();
virtual void y();
virtual void z();
A();
~A(); };
#define
vx
0
// Индексы в массиве
© Теория языков программирования и методы трансляции
5.
#define
#define
vy
vz
1
2
// указателей на виртуальные функции
// Таблица адресов функций класса А
void (*TableA[])() = { A::x, A::y, A::z };
A::A()
{ ftable = TableA; …}
// Назначение таблицы для класса А
class B : public A {
public:
void x();
void z();
B();
~B(); };
// Таблица адресов функций класса B
// A::y - наследуется из А, B::x - переопределяется в B
void (*TableB[])() = { B::x, A::y, B::z };
B::B()
{ A::ftable = TableB; …}
// Назначение таблицы для класса B
void main(){
B nnn;
A *pa = &nnn;
pa->x(); }
// ссылается на объект производного класса B
// Указатель p базового класса A
// реализация - (*pa->ftable[vx])();
Таким образом, несмотря на динамический характер связывания, программный
код, его производящий, является максимально компактным и эффективным.
© Теория языков программирования и методы трансляции
6.
Download