Что входит в состав микрокомпьютерных систем? Типичный ответ на этот вопрос: "микропроцессор, шина, подсистема памяти, подсистема ввода-вывода и интерфейсная часть, объединяющая все компоненты воедино".
Но это справедливо только для аппаратной составляющей. Любая микрокомпьютерная система требует наличия программного обеспечения (ПО), которое будет управлять аппаратурой. Программное обеспечение, в свою очередь, можно разделить на системное программное обеспечение и прикладное.
В состав прикладного ПО могут входить разного рода библиотеки в виде наборов подпрограмм, необходимых для работы программ.
К числу системного ПО можно отнести трансляторы с языков программирования высокого уровня, ассемблеры, текстовые редакторы и разного рода программы, которые несут вспомогательные функции при создании других программ. Нам уже известны три уровня языков программирования -- это машинный язык, Ассемблер и высокоуровневые языки программирования.
Программы на машинном языке -- это такие программы, которые микропроцессор "понимает" без дополнительной обработки. Язык ассемблера состоит из набора инструкций, каждая из которых практически один-в-один соответствует своей машинной команде. Ассемблерные инструкции записываются в виде мнемоник, которые ближе к человеческому языку, чем машинные коды и потому более просты для понимания человеком. Языки высокого уровня очень близки к естественному английскому языку, а их структурированность позволяет программисту легко и просто излагать свои мысли. Однако, независимо от того на каком языке была написана программа, будь то ассемблер или высокоуровневый язык, ее необходимо преобразовать в машинный код с помощью программы, которая называется транслятором. Чаще их называют ассемблер и компилятор, или интерпретатор соответственно.
Компиляторы высокоуровневых языков, подобных C/C++, имеют возможность трансляции программы с языка высокого уровня на язык ассемблера. GNU C/C++ Compiler, вызванный с ключом -S, сгенерирует ассемблерный код, эквивалентный тексту исходной программы на языке C/C++. Знакомство с тем, как элементарные конструкции, такие как циклы, вызовы функций, объявления переменных выглядят на языке ассемблера позволит вам достичь высочайших вершин мастерства. Прежде чем я продолжу, хочу заметить, что знание архитектуры Intel x86, позволит вам лучше понять материал, излагаемый ниже.
Для начала напишите небольшую программу на языке C, которая выводит на экран сообщение hello world и скомпилируйте ее с ключом -S. В результате вы получите файл с ассемблерным кодом, соответствующим исходной программе. По-умолчанию GCC создает файл с ассемблерным кодом с тем же именем, что и исходный файл, заменяя расширение `.c' на `.s'. Попробуйте интерпретировать несколько строк в полученном файле.
Микропроцессоры Intel 80386 и выше имеют огромное число регистровых инструкций и режимов адресации. Тем не менее, начальных сведений о нескольких основных инструкциях будет вполне достаточно, чтобы разобраться в коде, генерируемом компилятором.
Как правило, инструкция на языке ассемблера включает в себя метку, мнемонику и операнды. Способ записи операндов однозначно определяет способ их адресации. Мнемоника определяет операцию, выполняемую над операндами. Фактически все ассемблерные инструкции оперируют с регистрами и ячейками памяти. Процессоры Intel 80386 и выше имеют ряд (32 битных) регистров общего назначения: eax, ebx, ecx и пр.. Два регистра для работы со стеком: ebp и esp. Типичная инструкция, записанная с использованием синтаксиса GNU Assembler Syntax (GAS), выглядит так:
movl $10, %eax
Эта инструкция записывает число 10 в регистр eax. Префиксы `%', перед именем регистра, и `$', перед числом, обязательны ибо того требует синтаксис ассемблера. Следует отметить, что не все ассемблеры придерживаются одинакового синтаксиса.
Напишем нашу первую программу на языке ассемблера и сохраним ее в файл first.s. Текст программы приводится ниже.
#Листинг 1 .globl main main: movl $20, %eax ret
Эту программу можно ассемблировать и слинковать в исполняемый модуль a.out, если дать команду cc first.s. Расширение имени файла `.s' помогает компилятору идентифицировать язык, на котором написана программа, в результате cc вызывает ассемблер и линковщик, пропуская стадию компиляции.
Первая строка программы -- это комментарий. .globl -- это директива ассемблера, она объявляет имя main глобальным и делает его доступным для линковщика. Это совершенно необходимо, поскольку ваша ассемблерная программа будет линковаться с библиотекой языка C, которая в свою очередь вызывает функцию main. Если эту строку убрать, то линковщик выразит свое неудовольствие сообщением 'undefined reference to symbol main' (ссылка на неопределенный символ main). Эта программа просто записывает число 20 в регистр eax и возвращает управление операционной системе.
Следующая наша программа вычисляет факториал числа, находящегося в регистре eax. Результат вычислений сохраняется в регистре ebx.
#Листинг 2 .globl main main: movl $5, %eax movl $1, %ebx L1: cmpl $0, %eax // сравнить содержимое регистра eax с 0 je L2 // переход на L2 если 0==eax (je - jump if equal, перейти если равно) imull %eax, %ebx // ebx = ebx*eax decl %eax // decrement eax (уменьшить на 1) jmp L1 // безусловный переход на L1 L2: ret
Здесь L1 и L2 -- это метки. Когда программа приходит в точку L2, ebx содержит факториал числа, находящегося в регистре eax.
При создании сложной программы мы обычно разбиваем ее на простые подзадачи. Для каждой из таких подзадач пишется своя подпрограмма или функция. После этого мы можем вызывать ту или иную функцию по мере необходимости. В Листинге 3 приводится пример создания и вызова подпрограмм на языке ассемблера.
#Листинг 3 .globl main main: movl $10, %eax call foo ret foo: addl $5, %eax ret
Здесь инструкция call передает управление подпрограмме foo. Инструкция ret, в подпрограмме foo, передает управление обратно, следующей инструкции, расположенной за инструкцией call.
Как правило, функции определяют свои наборы локальных переменных и входных аргументов, используемые при каждом вызове. Для этих переменных необходимо выделять области памяти, обычно для этих целей используется стек. Очень важно понимать основные принципы выделения места на стеке под локальные переменные и аргументы, как эти переменные инициализируются и как они используются при повторном, рекурсивном или ином способе вызова функции, в ходе выполнения программы. Работа с регистрами esp/ebp и использование инструкций работы со стеком push/pop, является центральным аспектом, знание которого обязательно для понимания механизма вызова подпрограмм и возврата в точку вызова.
Часть памяти, выделяемой программе системой, резервируется под стек. Процессоры Intel 80386 и выше имеют в своем распоряжении регистр esp (от англ. stack pointer -- указатель вершины стека), в котором хранится адрес текущей вершины стека. На рисунке 1 показано содержимое стека, на который положено три целых числа 49,30 и 72 (каждое целое число занимает 4 байта), и значение регистра esp, который хранит адрес текущей вершины стека.
Рисунок 1Один необычный момент, связанный со стеком для архитектуры Intel, заключается в том, что стек растет "вниз", т.е. от старших адресов -- к младшим. На рисунке 2 показан стек после выполнения инструкции pushl $15.
Рисунок 2Содержимое регистра esp уменьшилось на 4, а на вершину стека было помещено число 15, которое разместилось по четырем ячейкам памяти с адресами 1988, 1989, 1990 и 1991.
Инструкция popl %eax скопирует число с вершины стека (четыре байта) в регистр eax и увеличит содержимое регистра esp на четыре. А что если вам нужно просто "выбросить" число с вершины стека, никуда его не копируя? Для этого можно просто выполнить инструкцию addl $4, %esp, которая просто увеличит содержимое регистра указателя стека на четыре.
В Листинге 3, инструкция call foo помещает адрес возврата из подпрограммы на стек и передает управление на метку foo. Подпрограмма завершается инструкцией ret, которая снимает с вершины стека адрес возврата и передает управление по этому адресу. Совершенно очевидно, что на вершине стека должен лежать корректный адрес возврата.
Программы на языке C могут включать в себя сотни и тысячи переменных. Ассемблерный листинг, эквивалентный программе на языке C, поможет вам понять как эти переменные размещаются и как используются регистры процессора, при работе с переменными.
Количества регистров в процессоре не так много и они не могут быть использованы для хранения всех переменных, имеющихся в программе. Локальные переменные размещаются на стеке. Листинг 4 демонстрирует -- как это делается.
#Листинг 4 .globl main main: call foo ret foo: pushl %ebp movl %esp, %ebp subl $4, %esp movl $10, -4(%ebp) movl %ebp, %esp popl %ebp ret
Прежде всего содержимое указателя стека копируется в регистр ebp, ( от англ. base pointer -- указатель основания, или базы). Этот регистр служит своего рода реперной точкой, относительно которой вычисляются адреса локальных переменных, размещенных на стеке. Регистр ebp может использоваться вызывающей программой, поэтому функция foo прежде всего сохраняет его содержимое на стеке. Инструкция subl $4, %esp резервирует место на стеке (четыре байта) для хранения целого числа. В следующей строке выполняется копирование числа 10 (четыре байта) по адресу на 4 меньшем, чем содержимое базового регистра ebp. Инструкция movl %ebp, %esp восстанавливает содержимое указателя стека, которое имелось после выполнения первой инструкции подпрограммы foo и инструкцией popl %ebp восстанавливает содержимое регистра ebp. Теперь указатель стека имеет то же значение, что и перед исполнением первой инструкции подпрограммы foo. В таблице ниже показано как изменялось содержимое стека и регистров ebp и esp, начиная с точки входа в функцию main и после выполнения каждой инструкции в программе из Листинга 4 (исключая момент выхода из функции main). Из таблицы видно, что в точке входа в функцию main, в регистрах ebp и esp находились значения 7000 и 4000, а на стеке, по адресам с 3988 по 3999, находились некоторые значения -- 219986, 1265789 и 86. Так же видно, что инструкция call foo положила на стек адрес возврата из подпрограммы -- 30000.
Таблица 1Стек может использоваться для передачи в подпрограмму значений входных аргументов. Будем придерживаться соглашений, принятых в языке C, при передаче входных параметров в подпрограмму. В соответствии с которыми, регистр eax служит для возврата результата в вызывающую программу, а входные аргументы передаются вызываемой подпрограмме через стек. Листинг 5 демонстрирует вызов простой функции sqr, которая принимает один входной аргумент.
#Listing 5 .globl main main: movl $12, %ebx pushl %ebx call sqr addl $4, %esp // вернуть содержимое esp в состояние, предшествующее выполнению инструкции push ret sqr: movl 4(%esp), %eax imull %eax, %eax // найти произведение eax * eax, результат остается в eax ret
Внимательно прочитайте первую строку подпрограммы sqr. Вызывающая программа поместила на стек содержимое регистра ebx и затем выполнила инструкцию call. Инструкция call положила на стек адрес возврата. Таким образом, на входе в подпрограмму, входной аргумент оказался на стеке, по адресу, на четыре байта выше текущей вершины стека.
В Листинге 6 представлены программа на языке C и модуль функции, написанной на языке ассемблера. Программа на языке C находится в файле main.c, а ассемблерный модуль с текстом функции -- в файле sqr.s. Чтобы скомпилировать и слинковать такую программу дайте команду cc main.c sqr.s.
#Listing 6 //main.c main() { int i = sqr(11); printf("%d\n",i); } //sqr.s .globl sqr sqr: movl 4(%esp), %eax imull %eax, %eax ret
Обратный вариант (вызов C-шной функции из ассемблера) не менее прост и понятен. В Листинге 7 демонстрируется возможность вызова функции, написанной на языке C, из программы, написанной на языке ассемблера.
#Listing 7 //print.c print(int i) { printf("%d\n",i); } //main.s .globl main main: movl $123, %eax pushl %eax call print addl $4, %esp ret
Я полагаю, что прочитанного вами в этой статье уже достаточно для понимания содержимого ассемблерных листингов, которые создает gcc. В Листинге 8 приведено содержимое файла add.s, полученного в результате выполнения команды gcc -S add.c. Обратите внимание: в демонстрационных целях я удалил из файла add.s некоторые директивы ассемблера.
#Листинг 8 //add.c int add(int i,int j) { int p = i + j; return p; } //add.s .globl add add: pushl %ebp movl %esp, %ebp subl $4, %esp // выделяется место для переменной p movl 8(%ebp),%edx // 8(%ebp) -- ссылка на переменную i addl 12(%ebp), %edx // 12(%ebp) -- ссылка на переменную j movl %edx, -4(%ebp) // -4(%ebp) -- ссылка на переменную p movl -4(%ebp), %eax // возвращаемое значение помещается в регистр eax leave // аналог комбинации инструкций movl %ebp, %esp; popl %ebp ret
Вызов функции add с аргументами 10 и 20, будет оттранслирован в следующий ассемблерный код:
pushl $20 pushl $10 call add
Обратите внимание: последний аргумент помещается на стек первым.
Мы уже знаем, что пространство под локальные переменные выделяется на стеке простым уменьшением содержимого регистра esp. А как выделяется пространство под глобальные переменные? Ответ на этот вопрос вы найдете в Листинге 9.
#Listing 9 //glob.c int foo = 10; main() { int p = foo; } //glob.s .globl foo foo: .long 10 .globl main main: pushl %ebp movl %esp,%ebp subl $4,%esp movl foo,%eax movl %eax,-4(%ebp) leave ret
Строка foo: .long 10 определяет блок памяти, под именем foo, размером в 4 байта и инициализирует его начальным значением. Директива .globl foo объявляет имя foo глобальным, что позволяет ссылаться на него из других модулей. Теперь попробуйте заменить объявление int foo на static int foo. Посмотрите -- как теперь будет выглядеть ассемблерный код. Вы наверняка заметите, что исчезла директива .globl. Попробуйте изменить тип переменной (double, long, short, const и т.п.).
Если программа не просто производит какие-либо арифметические вычисления на языке ассемблера, а так же организует ввод/вывод данных и т.п., то ей не обойтись без вызова служб операционной системы. В действительности, если не касаться системных вызовов, то программирование на ассемблере практически не зависит от типа операционной системы.
Существует два общепринятых способа выполнения системных вызовов в Linux: через библиотеку libc и напрямую.
Libc выполняет роль защитной прослойки, предохраняя приложение от возможных ошибок на тот случай, если в ядре изменится синтаксис того или иного системного вызова и предоставляет POSIX-совместимый интерфейс с ядром. Однако, ядро Linux само по себе является более или менее POSIX-совместимым, это означает, что синтаксис вызова библиотечных функций-оберток из libc в точности совпадает с синтаксисом реальных системных вызовов ядра (и наоборот).
Системные вызовы в Linux выполняются через прерывание int 0x80. Соглашение о системных вызовах в Linux отличается от общепринятого в Unix и соответствует соглашению "fastcall". Согласно ему, программа помещает в регистр eax номер системного вызова, входные аргументы размещаются в других регистрах процессора (таким образом, системному вызову может быть передано до 6 аргументов через регистры ebx, ecx, edx, esi, edi и ebp), после чего вызывается инструкция int 0x80. Если системному вызову необходимо передать большее количество аргументов, то они размещаются в структуре, адрес на которую передается в качестве первого аргумента. Результат возвращается в регистре eax, а стек вообще не используется.
Рассмотрим листинг, представленный ниже.
#Листинг 10 #fork.c #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <unistd.h> int main() { fork(); printf("Hello\n"); return 0; }
Скомпилируйте программу командой cc -g fork.c -static.
Запустите gdb и дайте команды
file fork
disassemble fork.
(Прим.ред. -- во-первых, для cc необходимо добавить ключ -o и в этом
случае команда сборки fork.c будет выглядеть следующим
образом: cc -o fork -g fork.c -static. Либо, запустив gdb, придётся указывать не
file fork, а file a.out, т.к. при отсутсвии ключа -o линкуется
программа с именем a.out. Это относится и к остальным примерам.)
Перед вами появится ассемблерный листинг программы, откуда вы увидите
как выполняется системный вызов fork. Ключ -static -- это
ключ статической линковки GCC (см. страницы справочного руководства
man gcc). Попробуйте проделать то же самое с другими системными
вызовами.
Компилятор GNU C предоставляет возможность вставлять ассемблерный код прямо в текст программы на языке C. Само собой разумеется, что используемые ассемблерные инструкции зависят от архитектуры.
Для вставки ассемблерного кода используется инструкция asm, например:
asm ("fsin" : "=t" (answer) : "0" (angle));
что для процессоров семейства x86 соответствует выражению на языке C:
answer = sin(angle);
Вы можете заметить, что в отличие от обычного ассемблера, инструкция asm допускает указание входных и выходных аргументов с использованием синтаксиса языка C. Не следует бездумно пользоваться инструкцией asm. Но тогда зачем ею пользоваться вообще?
#Листинг 11 #Name : bit-pos-loop.c #Description : Отыскивает позицию бита в цикле #include <stdio.h> #include <stdlib.h> int main (int argc, char *argv[]) { long max = atoi (argv[1]); long number; long i; unsigned position; volatile unsigned result; for (number = 1; number <= max; ++number) { for (i=(number>>1), position=0; i!=0; ++position) i >>= 1; result = position; } return 0; }
#Listing 12 #Name : bit-pos-asm.c #Description : Отыскивает позицию бита с помощью bsrl #include <stdio.h> #include <stdlib.h> int main(int argc, char *argv[]) { long max = atoi(argv[1]); long number; unsigned position; volatile unsigned result; for (number = 1; number <= max; ++number) { asm("bsrl %1, %0" : "=r" (position) : "r" (number)); result = position; } return 0; }
Скомпилируйте эти два примера с ключами оптимизации, как это показано ниже:
$ cc -O2 -o bit-pos-loop bit-pos-loop.c $ cc -O2 -o bit-pos-asm bit-pos-asm.c
Для измерения скорости исполнения каждой версии воспользуйтесь командой time и задайте в командной строке достаточно большое число, чтобы каждая версия отработала по крайней мере несколько секунд..
$ time ./bit-pos-loop 250000000
и
$ time ./bit-pos-asm 250000000
Результаты будут несколько отличаться для разных машин, однако вы наверняка заметите, что версия программы, которая использует ассемблерную вставку, работает намного быстрее.
(Прим.ред. -- наверное компьютеру (P4-1.7ГГц), где я проверял примеры, предложенные автором нужно что-то посолиднее -- результаты тестов одинаковые.)Оптимизатор GCC пытается переупорядочить и переписать код программы с целью минимизации времени исполнения, даже в том случае, когда в программе имеются ассемблерные вставки. Когда оптимизатор обнаруживает, что результат исполнения asm-инструкции нигде не используется, то он может просто исключить ее из текста программы, если между инструкцией asm и ее операндами отсутствует ключевое слово volatile (как частный случай, gcc не перемещает ассемблерные вставки, не возвращающие результат выполнения, за пределы цикла). Любая, отдельно взятая, ассемблерная вставка может быть перемещена со своего места и крайне трудно угадать заранее как ею распорядится оптимизатор. Единственная возможность сохранить порядок следования ассемблерных инструкций -- это вставить весь блок ассемблерного кода в одну инструкцию asm.
Использование инструкций asm может снизить эффективность оптимизатора, поскольку компилятор ничего не знает о семантике asm. В этом случае GCC вынужден перейти к более консервативному режиму прогнозирования, что может привести к отказу от некоторых видов оптимизации.
Разберите программу, приведенную ниже:
#Listing 13 /* myprint.c */ #include <stdio.h> #include <stdlib.h> int main() { int i; void my_print(int k) { printf("%d\n",k); } scanf("%d",&i); my_print(i); return 0; }
Скомпилируйте эту программу командой cc -S myprint.c и просмотрите ассемблерный код. Попробуйте скомпилировать эту программу командой cc -pedantic myprint.c. Что вы заметили?
Я только что сдал экзамены за последний курс Правительственного Колледжа Компьютерных Наук в городе Трикур (Trichur), Индия, штат Kerala.