of 77 /77
- 1 - Шапорев Т.В., ассистент Программа учебного курса Информатика и применение компьютеров в научных исследованияхсеместр 2 Москва, 2006 Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного программирования Примечание: по техническим причинам практически всё обучение ведётся на ПЭВМ совместимых с IBM PC, что крайне негативно сказывается на кругозоре студентов. Жела- тельно при всякой возможности объяснять студентам разницу между специфическими деталями реализации конкретной архитектуры и другими общепринятыми/возможными под- ходами. В идеале студенты должны понимать не только как нечто реализовано, но и какой выбор был сделан при проектировании той или иной особенности, и почему именно такой. 1. Причины изучения языка C в курсе информатики. Исторические причины создания и популярности языка C. Достоинства и недостатки C (по сравнению с Паскалем и/или другими языками). 2. Перечень конструкций C, соответствующих Паскалю. Возможности C, отсутствующие в Паскале. Возможности Паскаля, отсутствующие в C (кратко, т.к. всё равно компилятор не позволит). 3. Концепция макропроцессора. Пример макропроцессора - C-шный препроцессор. Директивы препроцессора. 4. Общие сведения об архитектуре ЭВМ. Возможные подходы. Понятие микрокода. 5. Наш подопытный кролик - IBM PC и процессор 8086. Представление чисел. Адресация. Основные регистры и их назначение. Понятие о взаимодействии с периферийными устройствами и BIOS. 6. Язык ассемблера, как средство описания архитектурно-зависимых программ. Перечень основных команд процессора 8086. 7. Команды ассемблера, не имеющие непосредственного отображения в машинные команды и, в частности, команды организации ассемблерных процедур (подпрограмм). Соглашение о связях. 8. Переменные и регистровая арифметика. 9. Команды сравнения и условные переходы. Организация циклов. 10. Арифметическое устройство (сопроцессор), и компиляция арифметических выражений с плавающей точкой. 11. Система программных прерываний, как пример системных вызовов, экстракодов и т.п. Сервис DOS и BIOS. 12. Продвинутыевозможности: работа со строками (и что ещё?) ввод-вывод в порты. Возможности ассемблера, которые не изучаются в курсе информатики. 13. Причины возникновения модульной разработки программ. Схема работы типичного компилятора. Компоновщик и загрузчик. Приёмы модульной организации программ в C. Приёмы модульной организации программ в ассемблере и C. 14. Сборка программ из нескольких файлов из командной строки. Идея дерева зависимостей, и его реализация на примере Turbo C. Универсальный механизм сборки больших проектов — make. Язык make-файлов.

Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

Embed Size (px)

Citation preview

Page 1: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 1 -

Шапорев Т.В., ассистент

Программа учебного курса “Информатика и применение компьютеров в научных исследованиях”

семестр 2 Москва, 2006

Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C

и основы технологии модульного программирования Примечание: по техническим причинам практически всё обучение ведётся на ПЭВМ совместимых с IBM PC, что крайне негативно сказывается на кругозоре студентов. Жела-тельно при всякой возможности объяснять студентам разницу между специфическими деталями реализации конкретной архитектуры и другими общепринятыми/возможными под-ходами. В идеале студенты должны понимать не только как нечто реализовано, но и какой выбор был сделан при проектировании той или иной особенности, и почему именно такой. 1. Причины изучения языка C в курсе информатики. Исторические причины создания и

популярности языка C. Достоинства и недостатки C (по сравнению с Паскалем и/или другими языками).

2. Перечень конструкций C, соответствующих Паскалю. Возможности C, отсутствующие в Паскале. Возможности Паскаля, отсутствующие в C (кратко, т.к. всё равно компилятор не позволит).

3. Концепция макропроцессора. Пример макропроцессора - C-шный препроцессор. Директивы препроцессора.

4. Общие сведения об архитектуре ЭВМ. Возможные подходы. Понятие микрокода. 5. Наш подопытный кролик - IBM PC и процессор 8086. Представление чисел. Адресация.

Основные регистры и их назначение. Понятие о взаимодействии с периферийными устройствами и BIOS.

6. Язык ассемблера, как средство описания архитектурно-зависимых программ. Перечень основных команд процессора 8086.

7. Команды ассемблера, не имеющие непосредственного отображения в машинные команды и, в частности, команды организации ассемблерных процедур (подпрограмм). Соглашение о связях.

8. Переменные и регистровая арифметика. 9. Команды сравнения и условные переходы. Организация циклов. 10. Арифметическое устройство (сопроцессор), и компиляция арифметических выражений с

плавающей точкой. 11. Система программных прерываний, как пример системных вызовов, экстракодов и т.п.

Сервис DOS и BIOS. 12. “Продвинутые” возможности: работа со строками (и что ещё?) ввод-вывод в порты.

Возможности ассемблера, которые не изучаются в курсе информатики. 13. Причины возникновения модульной разработки программ. Схема работы типичного

компилятора. Компоновщик и загрузчик. Приёмы модульной организации программ в C. Приёмы модульной организации программ в ассемблере и C.

14. Сборка программ из нескольких файлов из командной строки. Идея дерева зависимостей, и его реализация на примере Turbo C. Универсальный механизм сборки больших проектов — make. Язык make-файлов.

Page 2: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 2 -

Содержание семинарских занятий 1. Основные приёмы редактирования программы. Компиляция простейшей программы на C.

Обязательно изучить command line компилятор! (А вот компиляция из интегрированной среды НЕ обязательна.)

2. Возможности форматированного вывода с помощью процедуры printf(). Обратная сторона — процедура scanf(),— и почему она менее употребительна.

3. Понятие буферизованного потока ввода-вывода (FILE). Стандартные потоки ввода-вывода. Процедуры fprintf() и fscanf(). Возможность создать свой поток с помощью функции fopen(). Процедура fclose(). Другие стандартные функции буферизованного ввода-вывода.

4. Организация текстовых строк в C. Приёмы работы со строками (организация временных буферов). Функции стандартной библиотеки для работы со строками. Функции sprintf() и sscanf(), их недостатки и преимущества использования strtol(), strtod() и им подобных.

5. Сборка прогаммы на C из нескольких файлов. Приёмы написания файлов заголовков (.h). Сборка программы посредством объектного кода. Использование объектного кода для компоновки программы на разных языках программирования. Пример сборки программы на C и ассемблере.

6. Генерация компилятором ассемблерного эквивалента C-шной программы. Анализ полученного кода.

7. Пример программирования “в машинных кодах”. 8. Сравнение ассемблерного результата работы DOS-овского и 32-битового компиляторов

(для одинаковых C-шных фрагментов).

Page 3: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 3 -

Введение: место информатики в общеинститутском курсе и место второго семестра в курсе информатки

Хотелось бы надеяться, что необходимость изучения информатики в настоящее время ни у кого не вызывает сомнений, однако на практике это не так, и вопрос “зачем физику про-граммирование” не возникнет только у совсем уж беспечного студента (хотя далеко не все рискуют задать этот вопрос). Наверное, есть резон попытаться на такой вопрос ответить, тем более, что понимание взаимосвязей между разными частями всего институтского курса и разными частями курса информатики должно помочь и в понимании материала этого семестра.

Роль информатики в физическом образовании во многом подобна высшей математике: дифференциальное и интегральное исчисление нужны не сами по себе, а потому, что громад-ное количество практических задач описывается дифференциальными уравнениями (в част-ных производных), и их надо уметь решать. Однако, если даже удалось наконец-то для некото-рой задачи выписать соответствующую систему уравнений — это только начало. Если уда-лось найти решение в виде формул — считайте, что вам сильно повезло, потому что на практи-ке в подавляющем большинстве случаев такие системы не сводятся ни к каким вразумитель-ным формулам. Тем не менее ситуация не безнадёжная: если вспомнить, что формулы как правило нужны тоже не сами по себе, а для получения численного результата, то вот как раз этот результат может быть расчитан непосредственно, без промежуточного этапа в виде фор-мул. Методики непосредственного получения результата в численном виде составляют со-держание курса вычислительной математики, но прежде чем пытаться что-то понять в вы-числительной математике, надо представлять возможности своего рабочего инструмента — компьютера — не хуже, скажем так, чем уметь брать табличные интегралы. Если все силы уходят не на содержание задачи, а на войну с “проклятым ящиком” — это, легко понять, совсем не дело.

Более того, компьютером надо уметь пользоваться эффективно, мысль о том, что со-временный-то числогрыз переварит всё, что ему ни подсунь — это вредная иллюзия. Были, есть, и в обозримом будущем будут задачи, которые не под силу ни одному компьютеру, на-пример, прогноз погоды. Прогресс в области вычислительной техники как-то уж слишком легко сводится на нет небрежностью программирования: быстродействие современной пер-сональной ЭВМ превосходит быстродействие БЭСМ-6 на вычислительных задачах примерно в 100 раз по порядку величины. Можно ли сказать, что за прошедшие сорок лет точность прогнозов выросла не на два порядка, а хотя бы вдвое?

Если теперь от общих представлений вернуться к более конкреным нуждам этого семестра, то в нём, по большому счёту, будут решаться две (с половиной) задачи.

Во-первых, дать представление о том, как вообще устроен и работает компьютер, и в этом необходимым средством является язык ассемблера.

Необходимость знакомства с языком ассемблера можно более подробно расшифровать следующим образом. Во-первых, были и будут задачи, для решения которых ни один язык высокого уровня просто не приспосблен. К таким задачам часто относится работа с перифе-рийными устройствами (если выбирать близкий к физической науке пример, то это может быть взаимодействие с экспериментальной установкой). Во-вторых, встречаются случаи, когда качество программы, разработанной на языке высокого уровня, неприемлемо плохое по сравнению с тем, что могло бы быть написано на ассемблере “вручную”. На практике это как правило выражается в том, что программа работает слишком долго.

Значение последнего случая всё время снижается, по мере того, как постоянно совер-шенствуются средства реализации языков высокого уровня. Качество кода, сгенерированного современным оптимизирующим компилятором, может иной раз превосходить то, что в силах сочинить нормальный программист. Подвох в том, что это качество из компилятора надо ещё суметь извлечь (а для этого надо хотя бы отдалённо представлять, во что превращается про-грамма на языке высокого уровня, и вот тут незаменим ассемблер). Бездумное использование языка высокго уровня как правило осталяет от возможного качества пустое место. Например, использование в C++ класса “прямоугольник” (CRect) там, где достаточно структуры “прямоугольник” (RECT), ясно говорит, что программист даже не допускал мысли о том, во что превратится эта с виду невинная конструкция, а это ведь не худший прмер. На практике встречаются куски кода, прямо сказать, изумительные в своём идиотизме!

Page 4: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 4 -

Вторая задача данного семестра - познакомиться с языком “Си”. Хотя в первом семестре преподавание на многих потоках ведётся на Паскале, С будет совершенно необходим в третьем семестре в курсе операционных систем — и это не говоря уж о его практическом значении!

Наконец, материал данного семестра касается технологии модульного программиро-вания в объёме того минимума, который необходим для работы с C и ассемблером.

Немного о терминологии В русскоязычной литературе по программированию слово “оператор” используется в

двух довольно различных смыслах: для символа элементарного действия — вроде единст-венного знака “плюс”, — и для обозначения целого законченного предложения языка про-граммирования. Это тем более обидно, что “в оригинале” такой неоднозначности нет, в анг-лийском используются два разных слова: operator и statement соответственно, так что для утвердившегося словоупотребления не видно никаких оснований, кроме лени и небрежности. Чтобы избежать неоднозначности в последующем изложении для обозначения элементарного действия используется слово “операция” (женского рода). Английское statement по-видимому лучше всего было бы переводить (предложенным Новосибирской школой) словом “предпи-сание”, хотя в современной литературе чаще встречается слово “инструкция” — оба этих варинта будут дальше использоваться на равных.

Общие сведения о языке C Язык программирования “Си” был разработан в начале 70-х годов прошлого века

Брайаном Керниганом и Денисом Ричи для операционной системы “Юникс”. С тех пор оба они — C и UNIX — приобрели немалую популярность, и оба заметно изменились по сравнению с первоначальным вариантом.

Поскольку преподавание в первом семестре велось на Паскале, постольку C будет излагаться “с опорой” на Паскаль — между этими языками много общего и те, кто действи-тельно разобрался, как устроен Паскаль, легко поймут C по аналогии. Если у кого-то знания Паскаля не настолько хороши, что ж, остаётся надеяться, что им удастся разобраться хотя бы с одним C — хотя в данном курсе и не предусмотрены исчерпывающие объяснения для всех конструкций C, примеров использования этих конструкций будет немало.

(Некоторое время тому назад были довольно популярны — а может быть и до сих пор остались — споры о том, какой язык лучше, Паскаль или C. В первую очередь такие споры характеризуют кругозор спорщиков, характеризуют однозначно и отнюдь не лестно. Желание спорить тут как правило говорит о том, что человек в глаза не видел даже FORTRAN IV, а уж со стороны Lisp или SNOBOL Паскаль и C смотрятся как близнецы-братья.)

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

Мне не известно доподлинно, какие именно цели ставили перед собой создатели C, однако не вызывает сомнений, что одними из основных целей были легкость создания ком-пилятора и краткость. Без знания этих целей некоторые конструкции C понять невозможно.

Чтобы понять, насколько это хорошо или плохо, необходимо упомянуть о надёжности языка. Надёжность — если и не главная, то уж точно одна из основных характеристик языка программирования1. Вообще-то надёжность языков программирования — весьма обширная тема, на которую можно прочесть отдельный семестровый курс как минимум. Поскольку времени на это нет, ограничимся пониманием надёжности как того, насколько язык програм-мирования способствует ясному недвусмысленному изложению на нём намерений програм-миста и тем самым сокращает количество возможных ошибок. Все популярные языки про-граммирования не очень-то надёжны: дополнительная надёжность (как и следовало ожидать) даётся только ценой дополнительных усилий программиста, так что высоко надёжные языки были разработаны, но не стали популярными, используются они (например Ada) в достаточно

1 см. например Янг С. Алгоритмические языки реального времени: конструирование и разработка. Пер.с англ. — М.: Мир, 1985.

Page 5: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 5 -

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

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

Общие сведения о работе компилятора Слово “компилятор” уже использовалось несколько раз, давно пора объяснить, что же

это такое. Существует два подхода к тому, когда и как выполнять компьютерные программы. Самым естественным выглядит способ, при котором программа выполняется сразу же,

по мере её чтения компьютером (то есть не просто компьютером, а компьютером при помощи соответствующих программных средств). Это называется прямой интерпретацией.

Несмотря на кажущуюся естественность, более распространён другой подход: про-грамма сначала как-то предварительно обрабатывается, а только потом уж выполняется. Смысл этого действа в том, что языки программирования, несмотря на их вроде бы неестест-венность, всё-так устроены так, чтобы было удобнее человеку, а не компьютеру. Вот этот про-цесс перевода с исходного языка в более приемлемую для компьютерной обработки форму и называется компиляцией.

Таким образом компилятор — это некая сущность, “какое-то чего-то”, которое считы-вает программу на исходном языке и переводит её в эквивалентную программу на целевом языке. Важной функцией компилятора является вывод сообщений об ошибках в программе (буде таковые имеются).

Исходная программа → Компилятор →

Целевая программа

↓ Сообщения

об ошибках

На первый взгляд может показаться, что интерпретация и компиляция — взаимо-

исключающие подходы, но на самом деле отношения между ними ближе к симбиозу. На тему о компиляторах “вообще” можно говорить ну очень долго. Например, я

сознательно использовал в определении слово “сущность”, хотя все известные мне компиля-торы являются сущностями более конкретными, каждый компилятор — это программа, хотя (теоретически) можно сделать и по-другому. Компилятором на самом-то деле является любая известная мне типографская система, и так далее, и тому подобное. Однако ж в рамках семест-рового курса нас будет интересовать довольно узкое подмножество компиляторов - те ком-пиляторы, которые программу на каком-нибудь языке программирования переводят на “ма-шинный язык” (что бы под этим ни понимать); и даже эту ограниченную область мы будем изучать на примере всего-то двух компиляторов - компиляторов языков C и ассемблера для IBM PC совместимых персоналок.

Компилятор C работает по следующей схеме.

Page 6: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 6 -

Исходная программа ↓ Пользовательские файлы объявлений (.h) → Препроцессор ←

Системные файлы объявлений (.h)

↓ Программа со сделанными

подстановками

↓ Компилятор

↓ Ассемблерный код (.asm) ↓ Ассемблер

↓ Объектный код (.obj) ↓ Редактор связей ←

Системные библиотеки (.lib)

↓ Выполняемый файл (.exe)

Любая из клеточек на схеме может сама по себе рассматриваться как отдельный

компилятор. По такой схеме работал самый первый компилятор языка C (кстати говоря, для

операционной системы UNIX, а вовсе не для DOS). Современные компиляторы как правило устроены несколько иначе, как правило все эти многочисленные клеточки объединяются всего в два этапа, тем не менее схемой можно пользоваться довольно смело, поскольку стандарт языка C требует, чтобы независимо от способа реализации результат работы компилятора выглядел так, как если бы он этой схеме соответствовал.

Прежде, чем рассматривать особенности конкретных компиляторов, желательно оговорить ещё несколько понятий.

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

Так вот, когда готовая к выполнению программа находится в памяти ЭВМ (в виде битиков), то, строго говоря, только в этом случае она и представлена на “машинном языке”.

Такому представлению соответствувет в DOS формат .COM-файлов, но возможности его настолько ограничены, что вы с ним практически не будете иметь дела.

Когда в этом курсе идёт речь о компиляторе в “машинный язык”, то под “машинным языком” понимается способ представления информации в выполняемых (.EXE) и объектных (.OBJ) файлах. По сравнению с “машинным языком” в строгом смысле этого слова, и выпол-няемые, и объектные файлы содержат некий минимум дополнительной информации, причём этот “минимум” разный для разных случаев (чем, собственно, и обусловлена необходимость в двух разных форматах; вообще говоря для этих целей можно использовать один формат, так и сделано в части современных операционных систем, но мы-то будем тренироваться на старенькой DOS).

Чтобы объяснить, в чём эта разница, придётся упомянуть ещё пару новых понятий: редактор связей (по-английски linker) и загрузчик (соответственно loader). Я сознательно гово-

Page 7: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 7 -

рю “упомянуть”, а не “определить” потому, что для точного определения функций этих сущ-ностей знаний пока недостаточно. Тем не менее понимать, о чём идёт речь, уже необходимо.

Так вот, если в программе встречается вызов функции, то в её представлении на “ма-шинном языке” код, отвечающий за этот вызов, должен быть как-то связан с тем кусочком кода, где эта функция была определена. Эту работу выполняет редактор связей (linker).

Далее, компиляторы как правило генерируют целевую программу так, чтобы она могла работать в произвольном месте памяти ЭВМ — уж куда её ни определят ответственные за то программы, чтобы там и работала. Такая вот гибкость даётся не за просто так, обычно это означает, что такая “отвязанная” программа ни в каком конкретном месте работать не может. Привязку программы к конкретному месту в памяти (или, забегая вперёд, “размещение по конкретным адресам”) выполняет загрузчик (loader).

Если теперь вспомнить, с чего начинался весь этот пассаж, то основная разница между объектными (.OBJ) и выполняемыми (.EXE) файлами заключается в том, что объектные фай-лы в обязательном порядке содержат информацию для редактора связей, так называемую таблицу имён, name table. Выполняемые файлы, хотя и могут содержать такую информацию, предназначены вообще-то для другого. Для выполняемых файлов обязательной является ин-формация загрузчика — таблица перемещений или relocation table (а роль собственно загруз-чика в DOS выполняет сама операционная система).

Стоит ещё раз подчеркнуть, что эта схема, хоть и очень характерная, всё-таки специ-фична для DOS. Эта схема не так уж плоха в качестве примера, но ведь бывает и по-другому. С одной стороны, как уже было сказано, в ряде современных систем отсутствует деление на объектные и выполняемые файлы. (Следует отметить, что в таких системах словом “загруз-чик” - loader - может быть названа программа, фактически выполняющая функции редактора связей.) С другой стороны можно встретить системы, в которых загрузчик не упрятан внутрь операционной системы, а существует как самостоятельная программа.

Наконец, можно ведь реализовать компилятор и так, чтобы результатом его работы была программа на “машинном языке” в строгом смысле этого слова, уже размещённая в памяти и готовая к выполенению. При таком подходе можно было бы избежать сложностей, связанных с объектными и выполняемыми файлами. Тем не менее, в современном компьютер-ном мире утвердилась более сложная схема, так как она предоставляет больше возможностей — что это за возможности, вы будете узнавать в течение семестра.

Таким образом вы получили достаточное (на данном этапе) представление о финальных клеточках в схеме работы компилятора.

Если теперь обратить-таки внимание на начало этой схемы, то обнаруживается первое существенное отличие языка C от Паскаля (и многих других языков): неотъемлемой частью языка C является препроцессор.

Препроцессор На вышеприведённой схеме клеточкой под названием “компилятор” (в отличие от

компилятора в общем смысле этого слова) названа конструкция, которая из программы на стандартном, якобы машинно-независимом языке, порождает нечто, предназначенное для вполне конкрентной ЭВМ.

Идея препроцессора заключается в том, чтобы как-то преобразовать исходную про-грамму перед тем, как передать её “собственно компилятору”. В принципе, с формальной точки зрения, можно всё те же возможности реализовать на одном уровне с основными конс-трукциями языка, так что препроцессор не является совсем уж необходимым, и в большинстве языков препроцессор в стандарте языка отсутствует. Тем не менее некоторое время тому назад препроцессоры были модной темой, так как позволяли сравнительно легко приспособить язык “под себя”, то есть язык универсальный, а значит под решение большинства задач подходя-щий лишь постольку-поскольку, приспособить для решения конкретной задачи, добавив в него с помощью препроцессора конструкции, ориентированные на какую-то конкретную предметную область. Сейчас модно делать такие вот предметно-ориентированные расшире-ния с помощью полиморфизма в объектно ориентированных языках, но совсем простой пре-процессор так и остался в языке С как отголосок былой моды.(Язык ассемблера для PC включает свой собственный макропроцессор, заметно более мощный, чем C-шный. Хотя макроассемблер и не нужен для понимания взаимосвязи языков высокого уровня с

Page 8: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 8 -

архитектурой ЭВМ, для изучения самой концепции полезно сравнить возможности макропроцессоров C и ассемблера.)

Внешне директивы препроцессора выглядят как команды какого-то своего собствен-ного языка внутри “нормальной” C-шной программы: все директивы препроцессора начина-ются с символа # (диез) в первой позиции строки. (Большинство современных реализаций язы-ка позволяют начинать директивы не только в первой позиции, но лучше наверное так не делать, вместо этого можно вставлять пробелы после символа #.)

C-шный препроцессор выполняет три функции: 1) слияние файлов; 2) условную компиляцию; 3) макроподстановки.

Во-первых, если исходная программа содержит директиву #include с последующим именем файла, эта единственная строчка заменяется на весь текст указанного файла. Имя файла в директиве #include обязательно заключается в кавычки: либо обычные двойные кавычки, либо угловые скобки - символы “меньше” и “больше”, например так:

#include <stdio.h> Точная разница между этими двумя способами может разниться от компилятора к

компилятору, но по смыслу двойные кавычки предназначены для нормальных файлов поль-зователя, а угловые кавычки — для так называемых системных хидеров, то есть файлов, раз-работанных вместе с компилятором как составная часть реализации языка. Сами включаемые файлы называются “файлами заголовков” (буквальный перевод исходного header files) и по традиции имеют расширение .h, хотя это и не обязательно, препроцессору, в отличие от компилятора, всё равно, какое у файла расширение.

Хотя по сравнению с другими макрогенераторами C-шный препроцессор — это предел примитивизма, макроподстановки в C — наиболее замысловатая возможность препроцессора изо всех трёх.

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

В простейшем варианте макрос используется для того, чтобы дать константе мнемоническое имя, например:

#define PI 3.14159265358912 Теперь везде в последующем тексте программы вместо идентификатора PI

автоматически будет подставлено его значение. Важно понимать, что макропроцессор никак не анализирует, что именно он

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

Макроопределение с параметрами — более сложная конструкция, это аналог функции. Аналогично функции, при выполнении макроподстановки формальные параметры заменяют-ся фактическими. Например, следующее определение задает вычисление куба числа:

#define cube(x) ((x)*(x)*(x)) Обратите внимание, на множество скобочек и ещё раз вспомните , что препроцессор

меняет текст программы, и не более того. Если бы макрос был определён в виде #define cube(x) x*x*x То конструкция cube(x+1) развернулась бы в x+1*x+1*x+1 (то есть 3*x+1), а

совсем не то, что требовалось. Эмпирическое правило на это счёт заключается в том, что язык C никак не наказывает

за использование “лишних” скобок, поэтому если вы сомневаетесь в результате, то берите в скобки всё, что можно (хотя и злоупотреблять этим не стоит, так как программа становится менее читабельной).

О макросах необходимо сделать ещё как минимум два замечания. Во-первых, понятие области видимости для макросов весьма условно: макрос

действует с того места, где его определили и до конца файла; совершенно неважно, было определение внутри или снаружи функции, блока и т.п. Если такая глобальная видимость доставляет неудобства, то каждый отдельный макрос можно отменить директивой #undef.

Во-вторых, существуют так называемые предопределённые макросы, которые компи-лятор предоставляет что называется “забесплатно”. Предопределённые макросы бывают

Page 9: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 9 -

стандартные, то есть обязанные присутствовать в любых компиляторах C, и макросы, специ-фические для отдельных конкретных компиляторов. Полезными примерами стандартных макросов являются __FILE__ и __LINE__: первый из них заменяется на имя файла с текстом программы, а второй — на порядковый номер строки в этом файле. Примерами спе-цифических макросов могут быть __TURBOC__, который содержит закодированный номер версии компилятора Turbo C (и отсутствует в других), _MSC_VER, который содержит номер версии Microsoft С и т.п.

Директивы условной компиляции являются препроцессорным аналогом условной инструкции if, только в силу специфики работы препроцессора, кусок кода внутри директив условной компиляции, если ему “не повезло”, вообще исключается из целевой программы: он не попадает на вход “собственно компилятору”, на его хранение и выполнение (на последую-щих этапах) не тратятся никакие ресурсы и т.п.

В самом первом стандарте Кернигана и Ричи для условной компиляции было всего пять директив препроцессора: каждый такой “условно компилируемый” кусок начинался одной из директив #if, #ifdef или #ifndef, обязательно заканчивался #endif, а между ними при необходимости могло присутствовать #else.

Разница между первыми тремя инструкциями заключается в следующем: условие ди-рективы #ifdef (сокращение от if defined) считается выполненным, если за ней следует имя уже определённого макроса. Директива #ifndef (if not defined) использует противоположное усло-вие. Наконец, директива #if — наиболее сложная изо всех трёх, после неё должно следовать константное арифметическое выражение. Условие #if считается выполненным, если значение этого выражение отлично от нуля. Константное выражение — это выражение, состоящее из констант (например, целых чисел) и элементарных операций языка; смысл этого требования в том, чтобы значение выражения могло быть вычислено до начала выполнения программы (и более того, до того, как программа будет хотя бы приготовлена к выполнению). Хотя переменные в константное выражение включать нельзя, макросы использовать можно!

Современный стандарт языка включает и другие возможности условной компиляции, наиболее полезной из них наверное является предикат defined: конструкция defined(ИМЯ) проверяет, определён ли макрос с заданным именем, но, в отличие от #ifdef предикат можно включать в константное выражение, так что, например, одной директивой #if можно прове-рить несколько макросов.

В качестве примера и практической рекомендации стоит упомянуть, что конструкция #if 0 . . . #endif

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

Особенности языка Если попытаться сравнить между собой программы на Паскале и C, то на первый

взгляд может показаться, что между этими двумя языками сплошные различия: вместо begin - end фигурные скобки { }, вместо операции присваивания единственный знак равенства и так далее. Более-менее полный перечень приведён в приложении для изучения самостоятельно или на семинарах. Тем не менее легко понять, что, несмотря на разницу в обозначениях, обозначают-то они примерно одно и то же. Более серьёзных различий не так уж и много.

Во-первых, в C, в отличие от Паскаля, различаются большие и малые буквы. Разница, вроде бы, совсем не принципиальная, но на практике вызывает на удивление много ошибок.

Во-вторых, структура программы упрощена по сравнению с Паскалем: вложенные функции запрещены, а понятие “раздела описаний” размыто: внутри функций все описания должны содержаться в начале блока до первого выполняемого оператора, но порядок описа-ний не регламентируется, а снаружи функций, то есть при описании глобальных объектов, нет даже этих ограничений. Основное правило — объект языка должен быть описан раньше его первого использования, и в общем-то это всё.

(Может быть здесь следует подчеркнуть разницу между тем, как программа читается компилятором и как она выполняется: если порядок обращения к разным функциям на этапе выполнения программы определяется логикой действий этой программы, и в общем случае

Page 10: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 10 -

этот порядок непредсказуем, то порядок чтения программы компилятором всегда один и тот же: “слева направо и сверху вниз”, если смотреть в текстовом редакторе.)

Если однако же заглянуть внутрь описаний, то синтаксис описания типов заметно другой. Если сравнивать с Паскалем, в котором в инструкции описания переменных слева присутствует полная спецификация типа, одинаковая для всех последующих переменных, то в C сначала указывается только базовый тип, который затем может быть изменён модификато-ром рядом с именем переменной. Такая двойственная система придумана для сокращения записи, например, инструкция «int a, *b;» “зараз” описывает переменные двух разных типов: целочисленного и указателя на целое. За всё приходится платить, и такая краткая запись менее наглядна, чем в Паскале, особенно невразумительно подобный способ записи выглядит в инструкциях описания типов (typedef).

Наконец, язык C в явном виде использует разбиение программы на файлы. Если вспомнить Виртовский стандарт Паскаля, то какое бы то ни было деление про-

граммы на части там просто проигнорировано: программа считается единым монолитным объектом, вся целиком подаётся на обработку компилятору и т.п. Такой подход мало прием-лем, так сказать, “в реальной жизни”.

Во-первых, реальные программные проекты как правило разрабатываются не одним человеком, а командой. Если такая команда попытается одновременно менять текст програм-мы — каждый человек на свой лад, — то результатом будет полная неразбериха.

Во-вторых, объём “настоящих” программных проектов измеряется как правило сотнями тысяч и миллионами строк. Попытайтесь представить, насколько неудобно и медленно редактировать и компилировать такой объём информации за раз. Даже поиск нужной строки в таком количестве информации превращается в проблему.

Словом, давным-давно принято хоть сколько-нибудь большую программу делить на разные файлы и, например, Turbo Pascal предлагает для этого не менее двух разных способов. Увы, оба они нестандартные, и нет никаких гарантий, что эти способы заработают в любой другой реализации Паскаля.

Разработчики C заранее предусмотрели возможность разбиения программы на части. (Хотя, справедливости ради, нужно отметить, что возможности эти до предела примитивны, интересующиеся могут для сранения познакомиться с Виртовской же Модулой или модным - пока ещё - языком Java.). Программа на C компонуется из файлов, будь то файлы с исходным кодом или скомпилированные объектные файлы. Каждый файл представляет собой самосто-ятельный модуль и, например, объекты, объявленные в файле как глобальные, можно сделать невидимыми из других файлов с помощью ключевого слова static.

Начальные сведения об устройстве компьютера. Слово “компьютер” вообще говоря обозначает весьма широкий класс устройств. Ради

примера можно упомянуть так называемые аналоговые компьютеры в которых, образно го-воря, вместо вычисления значения функции “синус” используется генератор синусоидального сигнала. Такого рода компьютеры развиваются и постепенно приобретают всё большую попу-лярность, вот только относительная их доля стремительно снижается на фоне просто чудо-вищного прогресса компьютеров, основанных на цифровом принципе.

Информатику принято считать достижением двадцатого века, причём второй его по-ловины. Это не совсем так. Всякая уважающая себя наука пытается искать своё начало в тру-дах Аристотеля. Забираться так далеко вглубь времён конечно можно, но если говорить всерьёз, то деление современных компьютеров на функциональные блоки вполне соответст-вует идеям английского ученого XIX века Чарлза Бэбиджа.

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

взаимодействие узлов машины в ходе этого процесса — устройство управления, УУ; ⇒ устройство, обеспечивающее собственно обработку информации —

арифметико-логическое устройство, АЛУ; ⇒ устройство для хранения исходных данных, промежуточных величин и результатов

расчётов, а также самой программы обработки информации — запоминающее устройство или просто память;

Page 11: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 11 -

⇒ устройства, преобразующие информацию в форму, доступную компьютеру — устройства ввода;

⇒ устройства, преобразующие результаты обработки в форму, предназначенную для человека (или устройства какого-то типа, отличного от данного компьютера) — устройства вывода.

устройства

ввода

↑↓ память

(ОЗУ, ПЗУ) →←

процессор (УУ, АЛУ)

→←

внешняя память

↑↓ устройства

вывода

Следует подчернкнуть, что приведённое деление — именно фукциональное, в противо-

вес общепринятому ныне конструктивному. Если, грубо говоря, необходимо назвать набор ингредиентов, которые надо купить для сборки компьютера, то совершенно естественно не делать различий между УУ и АЛУ, так как оба они (вместе со многими другими интересными вещами) находятся внутри центрального процессора, “памятью” при этом оказываются мик-росхемы ОЗУ, но не регистры процессора и не жесткие диски, внешняя память вместе с уст-ройствами ввода-вывода чохом попадает в категорию “периферийные устройства” и т.д. В общем-то такой подход действительно лучше подходит для многих практических задач, но только не для данного курса.

Систематическое изложение принципов постороения ЭВМ было сделано ближе к на-шим дням - в середине XX века - группой авторов, включавшей Джона фон Неймана. Из-за вы-сокого авторитета Неймана теперь архитектура ЭВМ в целом носит название “фон-нейма-новской”. (Такое положение дел преувеличивает личные заслуги Неймана, но бороться за историческую справедливость теперь вряд ли уместно.)

Фон-неймановская архитектура опирается на следующие принципы: ⇒ Двоичное представление чисел и других данных (для сравнения можно напомнить, что

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

⇒ Программа хранится в той же памяти, что и обрабатываемые данные (в виде набора нулей и единиц), то есть принцип “хранимой программы” (для сравнения, первые ЭВМ программировались ручной перекоммутацией электрических цепей).

⇒ Память (для команд и данных) поделена на ячейки, доступ к ячейке осуществляется по её порядковому номеру или адресу — так называемый принцип адресности.

⇒ На каждом шаге из памяти выбирается и выполняется команда, адрес которой хранится в специальном устройстве. Наличие такого устройства — программного счетчика — ещё один принцип фон-неймановской архитектуры.

Изложенные принципы выбраны так, чтобы при минимальном объёме максимамльно облегчить понимание работы компьютеров. Следует быть готовым к тому, что реальный компьютер будет похож на изложенную идеализированную схему лишь постольку-поскольку. Например, практически во всех современных компьютерах нет одного арифметического устройства, вместо этого используются несколько разных специализированных устройств, вплоть до того, что в старых моделях IBM PC целочисленная арифметика выполнялась цент-ральным процессором, а устройство “действительной” арифметики даже конструктивно (а не только функционально) было отдельным блоком, отдельной микросхемой. Существуют при-меры и более фундаментальных различий (но менее наглядные).

Page 12: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 12 -

Иерархия памяти. То, что оперативная память является отдельным устройством, влечёт много важных

последствий, сейчас остановимся на таком из них: устройства в составе центрального про-цессора не работают непосредственно с оперативной памятью. Например АЛУ для выполне-ния какой-либо операции сначала считывает операнды внутрь себя, а результат операции тоже получается внутри АЛУ и в оперативную память его ещё (может быть) придётся переписать. То есть внутри процессора тоже существуют некие специализированные устройства хранения информации — они называются регистрами.

Регистры бывают двух типов: регистры устройств используются этими самыми уст-ройствами по мере надобности, но система команд машины совершенно необязатльно даёт программисту возможность обращаться к таким регистрам непосредственно. Кроме того су-ществуют так называемые регистры процессора, которые видны в системе команд (и, соответ-ственно, языке ассемблера). Регистры процессора как правило играют роль сверхоперативной памяти.

Таким образом, устройства памяти образуют иерархию, упорядоченную по мере убывания быстродействия и увеличения объёма: регистры - оперативная память - внешняя память. Важной частью современных компьютеров являются также устройства постоянной памяти и кэш-память, но из-за нехватки времени мы их сейчас рассматривать не будем.

Справедливости ради следует упомянуть, что регистры далеко не всегда рассматрива-ются как разновидность памяти. По-видимому это связано с тем, с какой именно ЭВМ автор учебника познакомился первой; действительно, если взять IBM PC, в которой регистров не-много или, пуще того, БЭСМ-6, в которой регистров данных было этак примерно полтора, то, казалось бы, при чём тут память? Если же взять какую-нибудь из современных ЭВМ, в которой регистры считаются десятками, то картина выглядит несколько иначе. Я не вижу смысла спорить, какой из подходов более правильный, лучше вместо этого заранее догово-риться о терминах.

Микрокод. Если проанализировать материал предыдущего раздела, то можно заметить ещё одну

существенную особенность: устройство машины, каким оно предстаёт программисту в виде системы команд, может существенно отличаться от реального положения дел. Действительно, многие с виду элементарные команды на самом деле реализованы как небольшие программы, хранящиеся непосредственно в центральном процессоре, а не в оперативной памяти (помните, как первые ЭВМ программировались физической перекоммутацией? — идея та же). Набор таких программ называется микрокодом.

Это понятие можно проиллюстрировать следующим примером: команда сдвига в язы-ках программирования высокого уровня (как правило) отображается в команду сдвига соот-ветствующей ЭВМ. В процессоре Intel 8088 не было устройства сдвига на заданное значение: там было устройство сдвига на единичку, а сдвиг на другие значения был реализован в виде микропрограммного цикла, сдвигавшего операнд по единичке за раз до нужного результата.

Цикл работы центрального процессора. При выполнении очередной команды практически любой современный процессор (а

точнее — его устройство управления) проделывает примерно одинаковый набор действий: 1. считывание очередной команды из памяти по счётчику адреса; 2. дешифрация команды (например, не нужны ли команде данные из памяти); 3. формирование нового значения счётчика адреса (адреса следующей команды); 4. выборка операндов из памяти (если нужно); 5. выполнение команды (например в арифметическом устройстве); 6. запись результата в память (если требуется). Подавляющее большинство современных процессоров укладывается в эту простень-

кую схему, всё разнообразие их поведения обеспечивается пятым пунктом. Полезно обратить внимание на следующие особенности.

Во-первых невразумительное положение пункта “вычисление адреса следующей ко-манды” в середине списка. Связано это с тем, что длина команды в общем случае (и в IBM PC

Page 13: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 13 -

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

Во-вторых, цикл обработки команд — бесконечный, его завершение не предусмотрено. Хотя современные процессоры могут включать команду “конец работы” она же “останов процессора” — это скорее дань традиции, чем необходимость; современный компьютер, пока включен, всегда чем-нибудь занят: при завершении прикладной программы он всё свое время уделяет выполнению операцинной системы, а операционная система, если нет других дел, в бесконечном цикле ждёт команд пользователя.

В третьих, в данной схеме напрочь проигнорирован такой принципиально важный момент, как обработка преываний. Что это такое — вы узнаете в третьем семестре в курсе операционных систем.

Наконец, эта схема позволяет проиллюстрировать ещё одно важное понятие. Устройст-во управления имеет достаточно сложную внутреннюю структуру, и как правило каждый шаг схемы выполняется отдельной подсистемой внутри УУ, то есть в какой-то мере самостоя-тельным устройством. Так что для увеличения скорости работы процессора можно выполне-ние последовательных команд частично совместить по времени: пока очередная команда выполняется арифметическим устройством, для следующей за ней выбираются из памяти операнды, в это же время третья команда дешифруется и т.д. Этот приём, общепринятый для повышения производительности современных процессоров, называется конвейеризацией.

Тактовая частота. Рассмотренная схема демонстрирует ещё одну важную идею: выполнение любой ко-

манды подразделяется на какое-то количество элементарных шагов — тактов (содержание каждого шага может очень сильно различаться в зависимости от устройства процессора и са-мой команды). Для того, чтобы разные устройства работали согласованно, сигнал к началу каждого такта подается специальным устройством, единым на весь компьютер — генератором тактовых импульсов (этакий большой полковой барабан, под который все “делают левой”). Чем больше тактовая частота, тем, при прочих равных, быстрее работает компьютер.

Оговорка “при прочих равных” весьма существенна. Во-первых, тактовую частоту нельзя увеличивать произвольно — с какого-то момента

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

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

Наконец, производительность компьютера в целом зависит не только от центрального процессора, а частенько — даже и не в первую очередь от него.

Типизация системы команд. Общим местом в современных учебниках стало то, что система команд процессора

может относиться к одному из двувх типов: CISC или RISC. Строго говоря, в такой вот категоричной форме этот тезис просто неверен; наверное любой реально существующий процессор включает элементы и того, и другого. Тем не менее эти аббревиатуры обозначают реально существующие противоборствующие тенденции в архитектуре компьютеров.

Обозначение CISC расшифровывается как Complex Instruction Set Computer и говорит о том, что в процессоре предусмотрено как можно больше разнообразных команд на все случаи жизни.

Буквы RISC означают, что процессор умеет выполнять только самый минимум команд, без которых совсем нельзя обойтись, зато уж эти-то команды реализованы максимально эффективно.

Смысл этого противостояния в том, что сложность процессора всегда чем-то ограни-чена, в первую очередь технологией производства (и не стоит забывать о цене изделия!), так

Page 14: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 14 -

что, грубо говоря, одно и то же количество вентилей в кристалле можно употребить либо на разнообразный набор операций, либо на лучшую коммутацию между элементами, либо на что-нибудь ещё, но на всё сразу и по-максимуму никак не получится.

Наиболее характерной чертой CISC-процессоров (кроме набора команд) является наличие выделенных регистров: какие-то операции могут выполняться только с одним или несколькими регистрами, но не с какими-то другими. (Например, в любимом 86 семействе, команды умножения и деления работают только с сумматором, но не со счётчиком или несколькими другими регистрами, зато команда сдвига может иметь вторым аргументом только регистр счётчика и так далее).

В противоположность этому, в RISC-процессорах регистры как правило равноправны. Исторически, поскольку разработчики первых процессоров были ограничены в средс-

твах и вынуждены были программировать только необходимый минимум, постольку, как только технология это позволила, процессоры стали развиваться в направлении CISC. На сего-дняшний день преобладает по-видимому тенденция RISC.

Если вернуться к нашему любимому примеру, то процессор Pentium имеет внутри себя RISC ядро, а унаследованная от предков система команд типа CISC выполняется с помощью микрокода.

Структура команды и её “адресность”. Представление команды процессора в памяти компьютера (в двоичном виде) обычно

подразделяется на код операции (что именно надо сделать) и адресную часть (то есть откуда брать данные и куда поместить результат). В зависимости от кода операции адресная часть может отсутствовать. Если она всё же присутствует, то может быть организована по-разному.

Допустим, для примера, что нам надо закодировать машинный аналог элементарного оператора:

A = B + C В такой операции участвуют адреса трёх разных переменных и представляется

естественным, что все три адреса должны присутствовать в команде. Системы команд организованные в соответствии с данным принципом, принято называть трёхадресными.

Теперь немного арифметики: общепринятая длина адреса в современных компьютерах 32 бита, то есть трёхадресная команда заняла бы более 96 бит — чёртову дюжину байт. Такие длинные команды практически нигде не встречаются. Каким образом?

На практике более употребительными являются системы команд двухадресного типа в которых результат кладётся на место одного из аргументов. При таком подходе приведённый пример пришлось бы транслировать в две машинные команды следующего типа:

A = B; A = A + C или, используя более уместную в таком случае C-шную нотацию:

A = B; A += C В результате у нас имеются две команды с четырьмя адресными частями: то есть

количество битиков в машинном представлении возросло на один адрес и один код операции. В чём смысл подобной “экономии”?

Подвох в том, что “в реальной жизни” обычно используются выражения более сложные, чем B+C. Добавим в пример единственное слагаемое:

A = B + C + D В случае трёхадресной системы команд это выражение всё равно пришлось бы

развернуть в несколько машинных команд, примерно так: A = B + C; A = A + D Для двухадресной системы команд получилась бы последовательность A = B; A += C; A += D Количество адресных частей сравнялось. Если ещё хоть чем-нибудь усложнить

пример, то двухадресная система команд даст более короткий машинный код. Можно развить эту идею дальше: если, например, принять соглашение, что все ариф-

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

Page 15: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 15 -

Обозначим греческой ∑ регистр сумматора (не требующий адресной части), тогда приведённые примеры развернутся соответственно в

∑ = B; ∑ += C; A = ∑ и ∑ = B; ∑ += C; ∑ += D; A = ∑ То есть ни одного лишнего адреса, зато гораздо больше кодов операций. Поскольку

как правило код операции короче адреса, такой подход даёт выигрыш в объёме кода и как правило проигрыш в быстродействии.

Реальное положение дел, как обычно, не вполне соответствует теоретическим постро-ениям. В системе команд семейства Intel 86 большинство команд устроено по двухадресному принципу, при этом однако ж во-первых не все команды, во-вторых из этих двух “адресов” хотя бы один должен относиться к регистру, а адрес ячейки памяти может быть только один. Если ещё вспомнить, что регистры процессора не всегда считают памятью… Для обозначения такого положения дел иногда изобретаются термины вроде “полутора-адресной” системы команд — остроумно, но не информативно.

Семейство Intel 86 и IBM PC. Вооружившись знанием теории, можно наконец приступать к изучению реальной

аппаратуры. Нашими лабораторными мышками будут процессоры семейства Intel 86. Выбор для изучения именно этого типа процессоров имеет массу недостатков, но к

сожалению, это единственный реально доступный выбор. Если у вас есть возможность изучить процессор другого типа — сделайте это обязательно! Впрочем, в любой ситуации можно найти преимущества — безалаберное устройство Intel 86 затрудняет его изучение, зато наглядно даёт понять разницу между терией и практикой.

Кроме того, изучение будет сконцентрировано на возможностях, существующих с са-мых старых версий процессора (и потому присутствующих во всём семействе). Такой выбор также небезупречен: в современные версии процессора добавлено множество возможностей для повышения производительности и организации многозадачной работы; было бы весьма поучительно, например, проследить, какие именно команды были добавлены в i386 для обра-ботки критических участков, вот только знания, необходимые для этого, излагаются семест-ром позже в курсе операцинных систем. Что же касается материала текущего семестра, то в задачу генерации кода типичной прикладной программы эти новые возможности не вносят принципиальных отличий.

Для понимания особенностей устройства процессоров Intel 86 и основанных на них компьютеров IBM PC необходимо помнить, что эти особенности — результат очень непросто-го компромисса между несколькими взаимно противоречивыми тенденциями: желанием по-лучить высокую производительность при низкой стоимости изделия, при этом ещё обеспечив совместимость со старыми версиями. Система команд самого первого процессора в серии составлялась так, чтобы названия команд совпадали с ещё более старым процессором 8080, впоследствии это требование ещё ужесточилось, общая часть команд у старых и новых процессоров семейства совпадает не только по названиям, но даже в двоичном представлении (в том самом “машинном языке”)! Так что упрёк в “безалаберности” фирма Intel не заслужила — на самом деле система команд продумана весьма тщательно, вот только удобство программирования при этом было отнюдь не основным требованием.

Самый, пожалуй, яркий пример такого компромиссного решения — это организация оперативной памяти.

С понятием “ссылки” или “указателя” вы должны быть знакомы по первому семестру; в общем случае (в языках вроде Lisp или Java) за ним может скрываться весьма непростая сущ-ность, но постольку, поскольку мы имеем дело с ассемблером IBM PC или языком C, эта сущность вырождается в очень наглядную конструкцию: оперативная память компьютера рассматривается как последовательность байт, а указатель по сути является порядковым номером байта в памяти. Если речь идёт об языке ассемблера, то указатель принято называть “адресом”.

В случае IBM PC эта идея порядкового номера извращена почти что до полной неуз-наваемости. Суть конфликта в следующем: процессор 8086 приспособлен для работы с так на-зываемыми “словами” из 16 бит, то есть всего возможно 216 = 65536 различных слов, так что использование в качестве адреса одного слова ограничило бы объём памяти 64-мя килобай-

Page 16: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 16 -

тами. Во время разработки процессора было очевидно, что это слишком мало, и было принято решение поддержать до мегабайта памяти, что требует, как несложно посчитать, 20 бит. Изме-нять процессор так, чтобы он оперировал с порциями информации по 20 бит — это, если сильно смятчать выражения, слишком накладное решение, так что адрес формируется из двух слов. Но два слова — это 32 бита, 12 бит вроде бы лишние. Этот запас использован, чтобы позволить более гибкое программирование. В результате, тот самый “порядковый номер байта” из двух слов адреса формируется следующим образом: старшее слово из пары умно-жается на 16 (или, что то же самое, сдвигается влево на 4), и к нему прибавляется значение младшего слова адреса.

Такая организация имеет далеко идущие последствия. Например, один байт памяти мо-жет иметь 212 = 4096 различных адресов (все правильные!). Из-за этого в языке C операции вычитания адресов, сравнения адресов и им подобные в общем случае некорректны — вопреки тому, что написано едва ли не во всех учебниках! То есть можно использовать такую операцию в программе, и компилятор сгенерирует нечто в предположении, что программист знает, что делает. Но вот чтобы такая конструкция работала как надо, а не как получится, надо действительно хорошо знать, что происходит.

В завершение темы надо рассказать о терминологии: старшее слово в адресной паре называется “сегментом”, а младшее — “смещением”. Вообще термин “сегмент” примени-тельно к IBM PC может использоваться в нескольких взаимосвязанных, но при этом принци-пиально различных смыслах: 1) сегментная часть адреса, 2) кусок памяти размером 64K, у которого порядковый номер начального байта содержит

нули в младших разрядах (0x00000, 0x10000, 0x20000 и так далее до 0xf0000), 3) кусок памяти произвольного размера с нулевым значение смещения и тому подобное. Так что каждый раз придётся разбираться по контексту, что значит слово “сегмент” именно в этом случае.

Кстати, сегмент длиной в 16 байт называется “параграфом”. С сегментной организацией памяти связаны ещё два нужных для практики понятия: до-

статочно часто бывает так, что рабочий набор данных умещается в пределах одного сегмента, при этом ко всем данным можно доступаться с помощью только одного смещения (а про сегментную часть временно забыть); такой “урезанный” 16-битовый указатель называется “ближним” (near). Соответственно, “полноценный” длинный указатель называется “дальним” (far).

Чтобы рассказать об организации памяти, пришлось немного забежать вперёд, если

теперь всё же вернуться и расскзаывать по порядку, то… Процессор Intel 8086 предназначен для операций с данными порциями по 16 бит (так

называемыми словами) или 8-битовыми байтами; и та, и другая порция данных может рас-сматриваться либо как двоичное представление целого неотрицательного числа, либо как це-лое число со знаком в двоичном дополнительном коде (что такое двоичный дополнительный код, должно быть известно по первому семестру). В системе команд процессора предусмот-рены средства, чтобы сделать более удобной работу с более длинными порциями данных; именно так реализованы целые числа размером 32 бита (которые есть и в Паскале, и в C).

Для хранения данных внутри процессора предусмотрены устройства в виде так называ-емых “регистров”. Регистры обычно делятся на три группы: сегментные, индексные и общего назначения. Все регистры в той или иной мере специализированы, к какой бы группе они ни относились. Сегментные регистры предназначены для хранения сегментных частей адреса — и только. Индексные регистры предназначены для адресных смещений, но могут участвовать и в некоторых арифметических оперециях. Регистры общего назначения используются для всего разнообразия целочисленной арифметики, но опять-таки существуют ограничения на то, какой регистр в какой операции может участвовать. Обратитите внимание, что речь идет только о целочисленной арифметике — реализация арифметики так называемых действительных чисел будет обсуждаться отдельно.

В “машинном языке” различные регистры попросту имеют разные номера, а в языке ассемблера регистры процессора обозначаются мнемокодами из двух букв. Для лучшего за-

Page 17: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 17 -

поминания большинство таких двухбуквенных мнемокодов можно считать английской аб-бревиатурой: например CS — code segment, IP — instruction pointer и так далее.

Названия сегментных регистров заканчиваются на букву S (от слова segment). Изна-чально таких регистров было четыре: CS, DS (data segment), ES и SS (stack segment), в совре-менных версиях процессора для удобства добавлены ещё сегментные регистры, например FS и GS.

Названия индексных регистров заканчиваются на буквы I (от слова index) или P (то есть pointer): BP (base pointer), DI (destination index), SI (source index), SP (stack pointer).

Названия 16-битовых регистров общего назначения заканчиваются буквой X (непоня-тно от какого слова), но для начальных букв расшифровка существует: AX (accumulator), BX (base), CX (counter), DX (data). Только к регистрам этого типа можно обращаться по частям: младший (low) байт сумматора, то есть AX, доступен как регистр AL, а старший (high) как AH, аналогичным образом можно разобрать на байты и другие три регистра.

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

знаний о языке ассемблера, что и не удивительно: всё-таки процессор устроен достаточно не-просто, чтобы для его описания потребовалось специальное средство, свой собственный язык. Так вот язык ассемблера как раз для этого и придуман.

Языков ассемблера (или просто “ассемблеров”) существует много: каждый более-менее оригинальный процессор (в лучшем случае — семейство процессоров вроде Intel 86) обязательно порождает свой собственный язык ассемблера, а то и не один.

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

Может возникнуть вопрос, зачем вообще такие сложности, и почему бы все те задачи, что решаются на языке ассемблера, не решать непосредтвенно на машинном языке. Самый простой (но не единственный) ответ заключается в том, что компилятор ассемблера снимает с программиста массу рутинной работы: мнемокоды запоминать всё-таки легче, чем соответст-вующие номера регистров, операций и т.п., а уж чего стоит вычиление адресных выражений, вы можете попробовать “живьём” на практическом занятии с программой debug.

По сравнению с языками высокого уровня, язык ассемблера имеет более жесткую структуру: ассемблерные программы записываются построчно, каждая инструкция в отдель-ной строке, и каждая строка делится на так называемые “поля”: имя, мнемокод, операнды и комментарий — любое поле может быть пустышкой.

Поля имени, мнемокода, а в большинстве случаев и операндов тоже, содержат иден-тификаторы, и на первый случай можно считать, что идентификаторы эти такие же, как в языках высокого уровня (на самом деле разница есть и её можно узнать в любом учебнике по языку ассемблера). Часть идентификатов зарезервирована, то есть имеет специальное значе-ние (например, уже поминавшиеся обозначения регистров): как в языке C нельзя назвать пере-менную if или break, так и в ассемблере нельзя определить имя AX или DS.

Язык ассемблера, следуя Паскалевской традиции, как правило не различает больших и маленьких букв в идентификаторах, ну а поскольку для языка C требуется другой подход, то компилятор ассемблера можно заставить различать большие и маленькие буквы в именах, если это надо.

Строка разбирается на поля так: если идентификатор начинается в первых трех пози-циях строки, то это имя (можно сказать, что так определяется переменная или имя функции), в противном случае индентификатор воспринимамется как мнемокод (например название ма-шинной команды). Всё, что следует за мнемокодом (через один или несколько пробелов) — это операнды. В любом месте строки может встретиться точка с запятой, после неё следует

Page 18: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 18 -

комментарий. Если после имени стоит двоеточие, то это метка; как и в языках высокого уровня, метка предназначена для передачи управления.

Чтобы не ограничиваться чистой теорией, вот пример трансляции выражения A=B+C на ассемблер IBM PC:

mov ax,B add ax,C mov A,ax Даже из этого примера можно понять, что самая популярная команда — это MOV (от

слова move), то есть передача данных без изменений.

Взаимодействие между программами. Уже несколько раз я упоминал разницу между C и Паскалем — казалось бы, при чём

они тут, если речь идёт об языке ассемблера? Дело в том, что сейчас программы только на языке ассемблера практически никто и никогда не пишет, уж слишком велики трудозатраты. Вообще, по мере совершенствования языков высокого уровня доля ассемблерного кода стре-мительно снижается. С другой стороны, был, есть, и никуда не собирается исчезать код, ко-торый ни на чём кроме ассемблера не напишешь. Например, выполнение любой стандартной C-шной программы начинается с вызова функции main(), но ведь что-то должно её вызвать. Основной объём ассемблерного кода в современных программах как раз занят взаимодействи-ем с операционной системой и аппаратурой.

На этом стоит остановиться подробнее.

Архитектура ввода-вывода IBM PC. Как правило взаимодействие с любым внешним устройством происходит через его

контроллер, а контроллер имеет какие-то свои регистры (следует помнить, что внешним устройством считается всё то, что не внутри центрального процессора, например жёсткий диск). Аналогично оперативной памяти, регистры контроллера получают порядковые номера (адреса), не обязательно подряд. На этом аналогия заканчивается, и не надо забывать, что адрес в оперативной памяти принципиально отличается от адреса регистра контроллера (или, как их ещё называют, номера порта). Обмениваться данными с регистром контроллера по его индивидуальному номеру порта можно с помощью команд IN и OUT.

В приложении приведён пример программы, рисующей на экране точку в 16-цветном режиме видеоадаптеров EGA/VGA при горизонтальном разрешении 640 точек. Главное назна-чение этого примера — показать, насколько такого рода программирование громоздко и неудобно. Теперь стоит вспомнить, что в одном только стандартном видеоадаптере VGA су-ществуют режимы с другим количеством цветов, другими разрешениями, а ведь есть ещё и другие типы видеоадаптеров… Чтобы облегчить программистам взаимодействие со всевоз-можной разнообразной аппаратурой, при разработке аппаратуры параллельно создается и набор программ для наиболее употребительных действий. В IBM PC такие программы при-нято делать частью аппаратуры (хранить в постоянном запоминающем устройстве), а вся совокупность таких программ носит название базовой системы ввода-вывода, BIOS (Basic Input-Output System). Сравните, наколько проще нарисовать точку с помощью универсального обращения к BIOS.

Сверху над этими двумя уровнями взаимодействия (портами ввода-вывода и BIOS) надстраивается ещё один — операционная система (которая считывается с диска и “живёт” в оперативной памяти). Сейчас не стоит останавливаться на точной разнице между ними, тем более, что операционным системам будет посвящён весь третий семестр, пока будет достаточ-но запомнить, что BIOS выполняет операции вроде отрисовки точки на экране, а операцион-ная система “заведует” файлами, запуском прикладных программ и тому подобными вещами. Для IBM PC были разработаны несколько похожих версий дисковых операционных систем (DOS): PC DOS, MS DOS и так далее.

Отдельные программы-функции в составе BIOS или DOS принято называть “прерыва-ниями” (interrupts). В других операционных системах аналогичные функции могут называться иначе: системными вызовами, экстракодами и т.п.

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

Page 19: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 19 -

Соглашение о связях Любая “настоящая” программа логически делится на большое количество кусков кода:

подпрограммы, процедуры, функции — как бы их ни называть. Для того, чтобы эти куски кода могли успешно взаимодействовать и не “портить друг другу жизнь”, фрагметы кода (подпро-граммы… далее по списку) должны подчиняться определённым правилам, так называемому соглашению о связях. Соглашение о связях не есть нечто незыблемое, оно может различаться для разных языков программирования и даже для разных компиляторов одного языка, а уж для компьютеров разного типа оно просто не может совпадать.

Поскольку функции BIOS и DOS задумывались как универсальные, то есть не завися-щие ни от какого конкретного языка программирования, то их соглашение о связях мало похо-же на соглашения о связях языков высокого уровня, начиная с того, что обрашение к прерыва-нию выполняется ассемблерной командой INT (а не CALL).

Для языков высокого уровня на IBM PC под управлением DOS существуют два обще-принятых и довольно похожих соглашения: C-шное и Паскалевское. Компиляторы языков высокого уровня автоматически обеспечивают выполнение этих соглашений, ну а при про-граммировании на ассемблере эта ответственность ложится на программиста.

Соглашение о связях для языка C включает например следующие пункты: 1) Подпрограмма может безвозвратно испортить значения регистров AX, BX, CX, DX и ES;

значения регистров DS, SS, SP, BP, SI, DI на выходе из подпрограммы должны быть такими же, как на входе (то есть их можно менять в процессе работы, но после этого надо вернуть на место).

2) Подпрограмма может использовать по своему усмотрению область стека после текущего значения указателя, но не должна менять данные в стеке до указателя, за исключением собственных параметров.

3) Параметры передаются подпрограмме через стек, причём порядок размещения параметров в памяти совпадает с их описанием (а это значит, что в стек они помещаются в обратном порядке, “задом наперёд”).

4) Функция, возвращает 16-битовое (например целое) значение в регистре AX; чтобы вернуть 32-битовое значение младшие разряды помещаются в AX, а старшие в регистр DX.

Список неполный, и нет большого смысла пытаься изложить его полностью прямо сейчас, такт как даже из приведённых четырех пунктов половина скорее всего непонятна.

Область стека и передача параметров Общая идея стека подробно изучалась в первом семестре, теперь поговорим об

особенностях реализации этой идеи в системе команд Intel 8086: стек представляет собой сегмент памяти, отличающийся от всех остальных только тем, что внутри него работают две дополнительные команды:

push - поместить машинное слово на вершину стека (и сдвинуть вершину); pop - извлечь слово с вершины стека (и подвинуть вершину обратно). Специально для этого механизма существует пара регистров SS:SP, которая и указы-

вает на вершину стека, а команды push и pop просто меняют значение SP. Важно запомнить, что в процессорах семейства Intel 86 (впрочем, как и практически во всех других современных процессорах) стек растёт “вглубь”, то есть команда push уменьшает значение SP. (В старых ЭВМ было сделано наоборот, но современный вроде бы неестественный рост стека “вниз” на практике оказался удобнее; чтобы понять, чем именно он удобнее надо представлять типичное распределение памяти процесса, которое будет изучаться в курсе операционных систем.)

Поскольку Intel 8086 позволял поместить в стек только содержимое регистра, то типичная последовательность кодов для вызова подпрограммы (процедуры или функции) выглядит примерно так (вспоминайте соглашение о связях):

mov ax,значение последнего параметра push ax mov ax,значение предпоследнего параметра push ax . . . mov ax,значение второго параметра

Page 20: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 20 -

push ax mov ax,значение первого параметра push ax call имя/адрес подпрограммы add sp,сколько нужно, чтобы отменить предыдущие команды push (Исключительно ради примера можно заметить, что в случае паскалевского

соглашения о связях завершающая команда add не требуется.)

Пролог и эпилог Аналогично способу передачи параметров, соглашение о связях обуславливает обще-

принятую последовательность действий при входе в подпрограмму и при выходе из неё, так называемые пролог и эпилог. Например, практически любая подпрограмма начинается командами push bp и mov bp,sp и заканчивается командами pop bp и ret (сокращение от слова return) — это пролог и эпилог в минимальном варианте. Полный варинт пролога включает следующие части:

1) упрятывание в стек BP и запоминание нового положения стека в нём; 2) резервирование памяти под локальные переменные; 3) упрятывание в стек других регистров, значения которых придётся восстанавливать.

Пункты 2 и 3 здесь необязательные и выполняются только при необходимости (вообще говоря, можно обойтись и без первого тоже).

Эпилог нужен для того, чтобы “вернуть на место” изменения, сделанные в прологе. Важно то, что если в прологе порядок действий менялся или какие-то действия были пропущены то эпилог должен измениться соответствующим образом.

Для более-менее сложной подпрограммы пролог и эпилог выглядят примерно так: push bp mov bp,sp

начало пролога и подпрограммы запомнить положение “своих” данных в стеке

sub sp,10 место для 5 переменных типа int или одной double и одной int и т.п. push ds push si push di

необязательная часть

собственно код подпрограммы pop di pop si pop ds

необязательная часть эпилога; порядок и набор регистров должен соотвествовать прологу

mov sp,bp забыть локальные переменные pop bp ret

всё восстановлено возврат в вызывающую программу

Типичное распределение памяти в стеке Поскольку стек играет важную роль в работе программ, то для лучшего усвоения

изложенного материала полезно представить, как выглядит “личная” область памяти одной подпрограммы внутри стека:

Page 21: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 21 -

“чужие” данные — не трогать!

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

программу здесь была вершина стека перед командой CALL

старое значение BP здесь была вершина стека на входе в подпрограмму SS:BP ⇒

область локальных переменных

старое значение (например) DS старое значение (например) SI старое значение (например) DI SS:SP ⇒ (новая) вершина стека начало (ещё) не использованной части стека; может

быть использовано другими (под)программами

Модели памяти Объём памяти IBM PC заметно больше одного сегмента, поэтому при разработке про-

грамм в общем случае приходится считать, что объём кода программы и объём данных, с кото-рыми она работает, могут быть произвольно большими (пока хватает оперативной памяти, естественно). Такой универсальный подход однако ж даётся “не бесплатно”, и наоборот, если заранее известно, что данные программы или код можно уложить в пределах одного сегмента, то на этом можно сэкономить: использовать ближние указатели вместо дальних. Такие про-граммы занимамют меньше памяти, быстрее работают, а главное, так как их легче писать, а компилятору легче генерировать, то шанс сделать ошибку тоже снижается.

К сожалению, заранее довольно трудно определить как много памяти того или иного сорта потребуется программе, а компилятору (в рамках существующих технологий) это иногда просто невозможно сделать, поэтому информация такого рода сообщается компилятору заранее в виде так называемой “модели памяти”.

Одной из самых удобных для программиста оказывается “маленькая” (small) модель памяти, в которой считается, что объём кода и объём данных ограничены 64К (причём код и данные живут в разных сегментах, так что ограничение считается поврозь для каждого из них). При этом все указатели в языке C можно считать “ближними” и, например, адресная арифметика работает так, как описано у Кернигана и Ричи, без ограничений, связанных с особенностями IBM PC. Впрочем, большинство программ для IBM PC по необходимости на-писаны в “большой” (large) модели, когда ни объём кода, ни объём данных не ограничены одним сегментом. Естественно, существуют и промежуточные варианты: модель для боль-шого кода и маленьких данных называется “средней” (medium), а модель для маленького кода и больших данных — компактной (“compact”). Всего общепринятых моделей памяти шесть (оставшиеся две изучите самостоятельно).

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

Ассемблерная подпрограмма может быть объявлена либо “ближней” (near), либо “дальней” (far) — это необходимо компилятору языка ассемблера, чтобы правильно сформи-ровать команды вызова подпрограммы CALL и заменить команду RET на соответствующую команду возврата (в системе команд Intel 8086 есть команды RETN и RETF, а вот команды “просто” RET нету). При вызове “дальней” подпрограммы меняется значение регистра сег-мента кода (CS), соответственно команда CALL содержит полный (сегмент:смещение) адрес вызываемой подпрограммы, адрес возврата в стеке занимает 4 байта (и эти четыре байта извлекаются из стека командой RETF); поскольку при вызове “ближней” подпрограммы

Page 22: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 22 -

значение CS не меняется, то команда CALL и адрес возврата в стеке короче на два байта (только смещение), и команда RETN работает соответственно.

Ассемблерная подпрограмма начинается инструкцией PROC (procedure) и заканчива-ется инструкцией ENDP (end procedure), и как раз аргументом инструкции PROC и является тип подпрограммы: FAR или NEAR (а имя подпрограммы указывается в поле имени — вспо-минайте строение ассемблерных инструкций).

Предписания организации подпрограмм PROC и ENDP — это примеры ассемблерных инструкций, которые никогда не порож-

дают никаких машинных команд сами по себе (поскольку не для того предназначены). Если заглянуть в прмеры, то там присутствуют ещё несколько таких же “служебных” инструкций.

Самая простая из них — это END, завершение всей ассемблерной программы. Инструкции SEGMENT и ENDS похожи на начало и конец программы и обозначают

начало и конец названного сегмента; деление программы на сегменты — это, вообще говоря, довольно мощная идея с массой полезных прменений (котрые будут изучаться в курсе опера-ционных систем), но из-за ограничений системы команд в Intel 8086 эти инструкции использу-ются практически только для того, чтобы различать код и данные. В отличие от подпрограмм, один и тот же сегмент можно несколько раз “начинать” и “заканчивать” вперемешку с другими сегментами, компилятор ассемблера сам позаботится о том, чтобы все данные каждого сегмента оказались действительно в одном сегменте, вместе и подряд.

Наконец, инструкция ASSUME говорит, адрес какого сегмента планируется хранить в том или ином сегментном регистре — это бывает нужно компилятору для вычисления адре-сов. Ответственность за то, чтобы сегментный регистр действительно содержал объявленное значение, опять-таки возлагается на программиста. (Как правило регистр CS меняется нуж-ным образом командами передачи управления, так что о нём можно не заботиться, а вот изменять сегментные регистры данных — обычное дело).

Для того, чтобы писать ассемблерные подпрограммы для языков высокого уровня, теперь не хватает только одной инструкции PUBLIC — сделать имя (подпрограммы) видимым из других файлов (сравните с языком C, где имена всех функций видимы “по умолчанию” но функцию можно сделать невидимой снаружи с помощью слова static; на самом деле все “видимые” C-шные функции при переводе на ассемблер объявляются PUBLIC).

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

ция всё-таки хранится в оперативной памяти, и вот настало время изучить обращение к ячейкам памяти, по крайней мере в том объёме, что потребуется для понимания примеров.

Язык ассемблера не делает различий междй знаковыми и беззнаковыми типами: эта разница зависит от того, какими командами обрабатываются данные, а не от их описания.

Проще всего описываются статические переменные: для этого существуют инструкции DB (define byte) и DW (define word). Вообще говоря, эти инструкции более универсальны и, например, DB время от времени используется, чтобы записать куски какой-нибудь “экзоти-ческой” машинной команды, но пока не стоит в это углубляться.

(Необязательный пример для особо любознательных: процессор i386 или более новый под управлением DOS имитирует 8086, то есть, в частности, выполняет арифметические операции с 16-битовыми словами. Тем не менее все возможности 32-битового режима имеются в наличии, и можно, в частности, заставить любую арифме-тическую команду выполняться в 32-битовом режиме, поместив перед ней “префикс смены разрядности” с шест-надцатиричным кодом 66. Большинство DOS-овских ассемблеров “не знает” о такой возможности, так что при-ходится это делать “вручную”: например, последовательность двух инструкций “db 66h и mul cx” фактически означает “mul ecx” и сформирует 64-битовый результат! Это как раз тот случай, когда одна ассемблерная инструкция отображается в нецелое количество машинных команд.)

Если инструкции DB или DW размещены в сегменте данных и содержат имя, то это как раз и будет определение переменной с данным именем. Имя переменной будет автоматически заменяться ассемблером на её адрес (если быть точным, то на её смещение внутри сегмента).

Если начальное значение переменной неинтересно (может быть любым), то в поле операндов указывается вопросительный знак, например так: mychar db ? ; static char mychar;

Но при необходимости можно переменную инициализировать, например так: mychar db 'A' ; static char mychar = 'A';

Page 23: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 23 -

Точно так же можно описать и строку (сравните с языками высокого уровня): mystr db 'This is string',0 ; static char mystr[] = "This is string";

Нормальные или, говоря правильным языком, автоматические переменные устроены хитрее: так как они размещаются в стеке, и их адрес меняется в зависимости от того, в какой последовательности вызывались подпрограммы, то обращаться к ним приходится относи-тельно вершины стека SS:SP. Тем не менее именно SP не используется практически никогда (слишком уж часто он меняется), вместо этого в прологе подпрограммы значение SP копиру-ется в BP, и затем BP используется для адресации переменных. В итоге обращение к автомати-ческим переменным имеет вид вроде SS:[BP+6] (это параметр) или SS:[BP-8] (а это просто локальная переменная). Квадратные скобки означают косвенную адресацию: команда ис-пользует содержимое ячейки памяти, адресное смещение которой указано в скобках. (Если заглянуть в ассемблерный результат работы компилятора, то там будут выражения [BP+6] или [BP-8], так как для индексного регистра BP по умолчанию подставляется именно сегмент стека.) Такие конструкции легко обрабатываются компилятором, потому-то он и забывает имена локальных переменных, но человеку мало подходят. При программировании на ассемб-лере “вручную” можно заставить его помнить автоматическую переменную например так: myint equ [bp-8]

Инструкция EQU (функциональный аналог #define) — это ещё одна инструкция, которая сама по себе машинных команд не порождает.

В итоге, определена ли переменная как автоматическая (см. выше) или статическая (myint dw ?), можно её использовать в командах вроде “mov ax,myint”, а необходимые преобразования проделает компилятор ассемблера.

Арифметические операции Как уже отмечалось, типичная арифметическая команда Intel 8086 устроена по “полу-

тора адресному” принципу: операндов у неё два, но одним из них обязательно должен быть ре-гистр, а вторым может быть как регистр, так и константа или переменная (которая задаётся своим адресом в памяти — то есть тоже, в общем-то, константа); результат команды записы-вается на место первого операнда, затирая его старое седержимое. Именно так устроены ко-манды сложения-вычитания ADD и SUB и побитовые логические операции AND, OR и XOR.

Такое единообразие, однако, быстро заканчивается. У команд свига влево-вправо SHL и SHR вторым операндом может быть либо

константа единица, либо регистр CL, и ничего иного (впрочем, это ограничение снято в современных версиях процессора).

Сильнее всего от общей схемы отходят команды умножения и деления: эти команды одноадресные, то есть они всегда работают с сумматором и туда же помещают результат. Так, например, команда умножения может перемножать 8-битовые данные, при этом одним из множителей всегда является AL, а результат размещается в AX; если умножаются 16-битовые числа, то один из множителей обязательно AX, а старшие разряды 32-битового результата помещаются в DX. Команда деления работает, что называется, в обратном порядке: делит 16-битоыве числа на 8-битовые или 32-битовые на 16-битовые, кроме частного команда деления сразу же формирует и остаток от деления (так что результатом команды деления являются два числа).

Следует обратить внимание на такое неочевидное обстоятельство: из-за представления целых чисел в виде дополнительного кода, командам сложения и вычитания всё равно, имело ли целое число знак с точки зрения программиста, или же число подразумевалось беззнако-вым — команды сложения-вычитания работают одинаково. Для команд умножения и деления это не так, и существуют разные команды беззнакового умножения-деления MUL и DIV, и аналогичные команды, в которых числа считаются имеющими знак, IMUL и IDIV.

Ещё одна, очевидная, особенность этих команд в том, что они меняют разрядность результата, “растягивают” или “сжимают” его вдвое. Сделано это для того, чтобы при необ-ходимости было легче запрограммировать работу с целыми числами, длиннее “зашитых” в процессор 16 бит; именно так реализованы 32-битовые целые числа, и ничто, в принципе, не мешает запрограммировать целые числа сколь угодно длинными.

Page 24: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 24 -

Флаг переноса Естественно, умножения и деления недостаточно для полноценной арифметики, и для

расширения команд сложения-вычитания тоже предусмотрена помощь, но сделано это иначе. Максимум, что может получиться при сложении двух 16-битовых чисел, это 17-бито-

вое число, то есть чтобы не потерять информацию, достаточно “расширить сумматор” всего на один бит, так что каманда сложения фомирует 16-битовый результат и 1-битовый флаг пере-носа. Аналогично, при вычитании 16-битовых чисел достаточно в дополнение к 16-битовому результату запомнить флаг заёма (если уменьшаемое было меньше вычитаемого). Чтобы использовать этот флаг переноса-заёма существуют специальные версии команд сложения и вычитания ADC и SBB. Сложение двух 32-битовых чисел выглядит примерно так: add ax,bx ; сложили младшие разряды adc dx,cx ; сложили старшие разряды с учётом переноса

Кроме сложения-вычитания флаг переноса формируется командами сдвига SHL и SHR и используется соответствующими версиями команд циклического сдвига (вращения) RCL и RCR. (Поскольку флажок тот же самый, можно использовать результаты сложения в командах “вращения” или наоборот результаты сдвига в команде сложения, хотя как правило делать это незачем).

Флаг переноса-заёма существует не сам по себе, это часть набора разнообразных флажков, объединённых в регистр флагов, который будет изучаться немного погодя.

Безусловный переход и “короткая” адресация Команда безусловной передачи управления JMP (сокращение от “jump”) является точ-

ным аналогом оператора go to в языках высокого уровня (если придерживаться исторической справедливости, то это наоборот оператор go to имитирует машинную команду перехода). В системе команд Intel 8086 предусмотрены команды “дальнего” и “ближнего” переходов, но команды “дальних” переходов практически никогда не генерируются компиляторами ЯВУ, а вот о командах “ближних” переходов стоит поговорить подробнее.

Во-первых, команды ближних переходов содержат не адрес места назначения (как можно было бы подумать, и как это реально было в старых ЭВМ), а разность адресов места назначения и текущего — так как эта разность не зависит от адреса начала программы в оперативной памяти, то такой подход существенно сокращает объём работы загрузчику.

Во-вторых, если эта разность укладывается в диапазон плюс-минус сто двадцать с не-большим байт, то её можно закодировать одним байтом — такой переход называется “корот-ким” и для него существует свой собственный код команды (хотя на ассемблере это всё тот же мнемокод JMP). Компилятор языка ассемблера может самостоятельно заменить “ближний” переход “коротким”, а если он не справляется с этой задачей самостоятельно, можно потре-бовать от компилятора сделать короткий переход, начав операнд команды JMP ключевым словом short.

“Короткие” переходы не заслуживали бы подробного изучения, если бы не команды условной передачи управления — в них все переходы “короткие”.

Условные переходы и регистр флагов Команды условных переходов начинаются с буквы J (всё от того же слова jump), за

которой следует код условия. Условия могут быть существенно разными. В ЭВМ предыдущих поколений (да впрочем и во многих нынешних) условием пере-

хода является нулевое содержимое какого-нибудь регистра (обычно это был сумматор). От-голоском тех времён в системе команд Intel 8086 присутствует команда JCXZ— переход по нулевому содержимому регистра CX. С использованием таких команд конструкция вроде if (value == 1234) транслировалась бы в ассемблер примерно так: mov cx,value ; загрузить в регистр значение переменной xor cx,1234 ; сравнить его с образцом jcxz куда_надо ; если условие выполнено

Таким способом программировать можно, но в Intel 8086 принят другой подход. В составе процессора существует 16-битовый регистр флагов, объединяющий самые

разные признаки размером в один бит: обрабатывать ли прерывания от аппаратуры, в какую

Page 25: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 25 -

сторону читать строки и т.п. (впрочем, из 16 бит регистра используются отнюдь не все). В этот регистр арифметичские операции автоматически заносят несколько бит информации о резуль-тате команды: уже упоминавшийся флаг переноса, а кроме того, присутствует ли в результате (старший) знаковый бит, не является ли результат нулём и чётность количества битов в результате. Команды условных переходов проверяют именно эти признаки из регистра флагов (или их комбинацию). Кроме того, специально для вычисления различных условий придума-ны команды “сравнения” CMP (compare) и “проверки” TEST, которые вычисляют результат и устанавливают признаки в регистре флагов, но никуда вычисленный результат не записыва-ют; CMP выполняет операцию вычистания (аналогично SUB), а TEST аналогичен AND. Казалось бы, зачем такие сложности? Представьте, что требуется скомпилировать конструк-цию чуть посложнее предыдущей: if (value == 1234 || value == 4321). С ис-пользованием команды CMP это выглядело бы примерно так: mov ax,value ; загрузить в регистр значение переменной cmp ax,1234 ; сравнить с образцом je куда_надо ; если совпали cmp ax,4321 ; сравнить с образцом je куда_надо ; если совпали Попробуйте представить, как это могло бы выглядеть без команды CMP.

Компиляция условных операторов Условные переходы ассемблера являются аналогом высокоуровневой конструкции

if (условие) goto, так что компиляция такого рода условных инструкций вроде бы оче-видна. Проблема в том, что в современных языках высокого уровня предпочитают условные инструкции другого вида, и надо уметь изображать низкоуровненвыми средствами конструкции вроде C-шных:

if (условие) { первая_последовательность_операторов; } else { другая_последовательность_операторов; } Задача эта в программировании отнюдь не новая (тем более что “дедушка” современ-

ных языков программирования — язык FORTRAN — как раз и не включал общепринятых ныне выскоуровневых условных конструкций), так что и решение хорошо известно. Авторы старых учебников любят приводить рецепт: высокоуровневая условная конструкция преобразуется как if (условие) goto L1; goto L2; L1: первая_последовательность_операторов; goto L3; L2: другая_последовательность_операторов; L3: ;

(Здесь L1, L2 и L3 — метки, сгенерированные “для внутренних нужд”, исключительно с целью трансляции выскоуровневой конструкции.)

Не верьте этому рецепту! Ни один уважающий себя компилятор на нормальной архитектуре не сделает такого безобразия. Гораздо лучше было бы if (!условие) goto L1; первая_последовательность_операторов; goto L2; L1: другая_последовательность_операторов; L2: ;

(Обратите внимание на значок отрицания перед условием.) Либо

Page 26: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 26 -

if (условие) goto L1; другая_последовательность_операторов; goto L2; L1: первая_последовательность_операторов; L2: ;

К сожалению, архитектура Intel 8086 не является нормальной (в этом смысле). Самое время вспомнить, что конструкция if()goto здесь фактически означает ассемблерную ко-манду условного перехода, и переход этот может быть только “коротким”. То есть если длина ассемблерного кода для “последовательности операторов” превысит 128 байт (а так бывает очень часто), то получившаяся конструкция просто не может существовать в Intel 8086. А вот у безусловного прехода (goto без if, он же команда JMP) такого ограничения нет, так что PC-шные компиляторы часто вынуждены генерировать неказистую первую конструкцию. (Оптимизирующий компилятор может сгенерировать оптимальную последовательность, если получится; компиляторы попроще и “поглупее” всегда создают неоптимальный, зато универ-сальный вариант.)

При программировании на ассемблере “вручную” всегда имеет смысл сначала попро-бовать оптимальный вариант, и только если он уж совсем никак не получается, тогда исполь-зовать универсальный. (Слишком сложно? Но если вас не волнует качество кода программы, то стоит ли вообще писать эту программу на ассемблере?)

Кстати, начиная с i386 в системе команд предусмотрены “ближние” условные перехо-ды (в дополнение к “коротким”), так что причин использовать неоптимальный рецепт стало ещё меньше.

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

не только проще (то есть меньше возможностей ошибиться), но ещё и заметно быстрее. Если вспомнить основной цикл работы процессора, то легко понять, что команды

перехода — это “дорогое удовольствие” (по сравнению с большинством арифметических) потому что конвейер приходится останавливать и запускать заново с другого адреса (в современных процессорах приложена масса усилий, чтобы уменьшить накладные расходы на перезапуск конвейера, но картину в целом это не меняет: конвейер лучше не дёргать).

Чтобы избавиться от “лишних” команд перехода, программисты и разработчики компиляторов иногда идут на удивительные трюки.

Начнём издалека: квалифицированные программисты практически никогда не пишут инструкций вроде mov ax,0, поскольку того же эффекта можно добиться командой xor ax,ax, которая в полтора раза короче и может работать быстрее. Другой вариант того же приёма sub ax,ax. (Использование вычитания вместо “исключающего или” допустимо для семейства Intel 86, на других процессорах это как правило проигрышный вариант). Знание этого приёма машинно-зависимой оптимизации потребуется для понимания следующего примера.

Для вычисления минимума двух чисел существует стандартный примитив min(), как правило это макрос вроде

#define min(a,b) ((a)<(b) ? (a) : (b)) Условная операция “?” компилируется практически так же, как и условная инструкция

if (то есть if (a<b) result=a; else result=b;), и ассемблерный аналог уже не должен представлять сложности: mov ax,a cmp ax,b jnb L1 mov ax,a ; запишем минимум в AX jmp short L2 L1: mov ax,b ; запишем минимум в AX L2:

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

Page 27: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 27 -

mov ax,a cmp ax,b jb L1 ; AX уже содержит минимум mov ax,b ; запишем минимум в AX L1:

Или так (за счёт использования дополнительного регистра повышается быстродействие и сокращается количество байт в машинном коде): mov ax,a mov bx,b cmp ax,bx jb L1 ; AX уже содержит минимум mov ax,bx ; перепишем минимум из BX в AX L1:

Если скорость выполнения важней объёма программы, то вычисление минимума можно записать без команд перехода (трюк подсмотрен в компиляторе Zortech C): mov ax,a mov bx,b sub ax,bx sbb dx,dx ; все единицы, если a<b, иначе 0 and ax,dx ; разность, если a<b, иначе 0 add ax,bx ; теперь AX содержит минимум

Другой пример: конструкция вида flag = a>=b;

как правило компилируется так, как если бы это было flag = a>=b ? 1 : 0;

то есть очень похоже на if (a>=b) { flag = 1; } else { flag = 0; } Расшифруем. Условная инструкция if превратится (в лучшем случае) в

mov ax,a cmp ax,b jb L1 mov ax,1 mov flag,ax jmp short L2 L1: mov ax,0 mov flag,ax L2:

Предыдущий вариант, то есть единственная инструкция присваивания с условной операцией отображается чуть короче: mov ax,a cmp ax,b jb L1 mov ax,1 jmp short L2 L1: mov ax,0 L2: mov flag,ax

Эту конструкцию можно ещё “ужать” более-менее хакерскими приёмами:

Page 28: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 28 -

mov ax,a cmp ax,b mov ax,0 ; регистр флагов не меняется! jb L1 inc ax L1: mov flag,ax

Но всё-таки лучше вспомнить, что компилируется-то логическое выражение и (как и для арифметического выражения) команды перехода для него “неестественны”: mov ax,a cmp ax,b sbb ax,ax inc ax mov flag,ax

С использованием команды SETNB и ей подобных (доступных в i386 и более новых процессорах) такая логика записывается ещё короче и не только для >=, а для более широкого набора логическийх операций.

(Упражнения: 1) перепишите оптимальный вариант кода для Intel 8086 для условия a<b; 2) перепишите код с использованием команд i386.)

Среди программистов существует странная мода записывать на C такие конструкции именно в виде условной инструкции if, потому что, якобы, “так понятнее”. Увы, при этом ком-пилятор скорее всего лишается шансов сгенерировать оптимальный код, что же касается понятности, то, во-первых, программа тем понятней, чем короче, во-вторых формальная логика придумана математиками не просто так, и по-моему лучше научиться мыслить понятиями логических предикатов2 и не искать оправдания собственной лени.

Довольно эффектный трюк — вычисление абсолютной величины целого числа3 (считаем, что число уже помещено в регистр AX): cwd ; для отрицательного числа DX := -1 xor ax,dx ; для отрицательного числа AX := -1 - AX sub ax,dx ; для отрицательного числа AX := AX - (-1)

(Сравнение с результатом работы C-шного компилятора сделайте самостоятельно.) Ещё одна рекомендация напоследок: квалифицированные программисты практически

никогда не пишут cmp ax,0, потому что того же результата можно добиться командой or ax,ax (и несколькими другими подобными). В учебнике Кернигана и Ричи рекоменду-ется сравнивать значения с нулём, потому что это более эффективно, и данный пример показывает, в чём заключается эта большая эффективность для семейства Intel 86.

Компиляция циклов …Проще всего объясняется в понятиях “вырожденного if”. Напоминание: Эта глава содержит довольно много конструкций вида if()goto. Их

следует понимать как ассемблерные операторы условного перехода, рассмотренные в предыдущем разделе. (Писать в таком стиле на языках высокого уровня — например C — было бы, как правило, очень плохой идеей.)

Отличительная особенность циклов — переходы “вверх”, во всех других случаях их как правило надо избегать. (Если при программировании на C или Паскале у вас возникает неодолимое желание написать goto вверх, то скорее всего вы пытаетесь запрограммировать скрытый цикл. В языке высокого уровня лучше такой цикл не прятать и написать явно. Но вернёмся к ассемблеру.)

Проще всего (и поэтому эффективней) компилируется цикл

2 Изложение Булевой алгебры можно найти во множестве книг, например, в любом учебнике математической логики, но для прикладного программирования наукообразие не обязательно, и достаточно самого популярного описания. Мне нравится вот это: Отряшенков Ю.М. Юный кибернетик. М.: Дет.лит., 1978. стр. 343-362. 3 Трюк заимствован из книги Магда Ю.С. Использование ассемблера для оптимизации программ на C++. — СПб.: БХВ-Петербург, 2004. Оригинальный автор трюка в книге не указан.

Page 29: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 29 -

do { тело_цикла; } while (условие);

В идеальном случае это было бы L1: тело_цикла; if (условие) goto L1;

С учётом ограничения на длину условного перехода получается нечто не столь изящное: L1: тело_цикла; if (!условие) goto L2 goto L1; L2:

Ненамного сложнее конструкция while (условие) { тело_цикла; }

Первое, что приходит в голову, это L1: if (!условие) goto L2 тело_цикла; goto L1; L2:

Что ж, такая конструкция наглядна и будет работать, но это всё, что о ней можно сказать хорошего. Средствами ассемблера цикл с предусловием можно превратить в эквивалентный цикл с постусловием: goto L2 ; вот так вот, сразу в хвост! L1: тело_цикла; L2: if (условие) goto L1

По сравнению с предыдущим вариантом здесь в общем те же команды, но распреде-лены они иначе: за счёт дополнительного перехода снаружи цикла внутри цикла переходов стало меньше. Поскольку большинство циклов выполняется больше одного раза, постольку такой подход как правило даёт выигрыш во времени работы программы.

Изменения, которые могут потребоваться из-за ограничения на длину перехода, полностью аналогичны циклу do-while.

Каким образом цикл for преобразуется в цикл while в общем случае написано в учебнике Кернигана и Ричи, и добавить к этому в общем-то нечего.

А вот что имеет смысл рассмотреть подробнее, так это частный случай цикла с заданным числом повторений. По традиции в C такая конструкция записывается как for (i=0; i<N; i++) { тело цикла; }

Причём переменная i в теле цикла использоваться не должна. Эта форма записи цикла “неправильная” в том смысле, что она усложняет оптималь-

ную компиляцию, впрочем, она уже настолько вошла в традицию, что современные оптимизи-рующие компиляторы специально “обучены” её распознавать.

Правильнее было бы i=N; do { тело цикла; } while (--i);

(Обратите внимание, что эти конструкции, строго говоря, не эквивалентны, и возможность замены первой на вторую подразумевает в частности N>0.)

Эта конструкция “правильнее” в том смысле, что во всех известных мне архитектурах команда цикла (если она есть) следует именно такому подходу — цикл с постусловием. В случае Intel 8086

Page 30: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 30 -

mov cx,N L1: тело_цикла; loop L1

Поскольку команда LOOP на самом деле — “замаскированный” условный переход, то к ней применяется всё то же ограничение на длину перехода, кроме того счётчиком цикла может быть только регистр CX. Оба этих ограничения настолько затрудняют автоматическую генерацию кода, что компиляторы попроще вообще не используют команду LOOP и делают циклы только общего вида, а вот при программировании “вручную” имеет смысл использо-вать эту команду всегда, когда возможно.

Арифметика с плавающей точкой …То, что обычно называется “действительной” арифметикой. Нелишне напомнить, что так называемые “действительные” числа в компьютере вовсе

не являются таковыми в математическом смысле, а всего лишь пытаются их изображать. На самом деле это подмножество рациональных чисел, причём сильно ограниченное: во-первых дроби только двоичные, во-вторых и те отнюдь не все… и так далее.

Такие числа хранятся в компьютере в нормализованном виде, то есть мантисса и экспо-нента (только, в отличие от общепринятых десятичных, и то и другое двоичное). В отличие от целых чисел, “действительные” числа обычно хранятся в прямом, а не дополнительном коде. Стандарт языка C предлагает два типа для отображения так называемых действительных чи-сел: тип float для “нормальной” точности и тип double для удвоенной. В большинстве совре-менных компьютеров, включая IBM PC, размер этих типов 32 и 64 бита соответственно; в эти биты упакованы экспонента, мантисса и знак мантиссы. (Сейчас становится всё больше процессоров с типом double размером 128 бит. Современная технология это позволяет гораздо легче, чем раньше, вот только зачем?). Устройство арифметики с плавающей точкой в IBM PC понимает оба этих типа, а кроме того целые числа размером 16 и 32 бита, но ни с одним из этих типов не работает. Все числовые данные при загрузке в это устройство преобразуются в его собственную внутреннюю форму: числа с плавающей точкой размером 80 бит.

Для понимания особенностей устройства так назывемой “действительной” арифметики полезно очередной раз обратиться к истории: в ранних версиях процессора “действительную” арифметику сочли излишней роскошью (и правда, зачем она для игры в digger… doom’а тогда ещё не было), и в центральном процессоре просто не было такого устройства. Желающие могли отдельно купить математический сопроцессор, чтобы наслаждаться высокопроизводи-тельной арифметикой. Технология не стояла на месте, так что начиная с 486-го процессора устройство арифметики с плавающей точкой — неотъемлемая часть центрального процес-сора, но особенности остались, и команды “действительной” арифметики мало похожи на все остальные.

Из-за того, что математический сопроцессор был отдельным устройством, работавшим параллельно основному, стала возможна ситуация, когда центральный процессор попытается читать из оперативной памяти результаты, который арифметический сопроцессор ещё не успел вычислить. Чтобы этого избежать, приходится заставлять центральный процессор ждать конца вычислений командой FWAIT (она же просто WAIT).

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

Мнемокоды команд с плавающей точкой начинаются с буквы F (от слова foat). Полный перечень таких команд приведён вприложении, изучите их самостоятельно.

Чтобы хоть как-то проиллюстрировать все эти рассуждения, вот пример трансляции выражения r = sqrt(x2 + y2): FLD qword ptr x FMUL qword ptr x FLD qword ptr y FMUL qword ptr y

Page 31: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 31 -

FADD FSQRT FSTP qword ptr r FWAIT

(Слова “qword ptr” фактически задают тип данных: “четверное слово”, то есть 64 бита.) Другой пример есть в приложении (в задаче о перемножении матриц).

Структуры данных Язык C предоставляет два способа структуризации данных: набор разнородных

объектов, то есть структура в узком смысле слова (то, что обозначается ключевым словом struct) и набор однородных объектов — одномерный массив. Комбинация этих способов позволяет строить практически сколь угодно сложные структуры (в широком смысле слова): двумерный массив рассматривается как одномерный массив одномерных массивов, можно “сделать” массив из стркутур, включить структру в состав другой структуры и т.п.

Все такие структуры на самом деле существуют только в воображении программиста (и компилятора), поскольку оперативная память с точки зрения системы команд — всё равно линейная последовательность байт. Соответственно, для доступа к элементам структуры до-статочно знать адрес начала структуры (то есть соответствующей ей последовательности байт) и смещение нужного элемента структуры относительно её начала.

В случае статических или автоматических данных работа со структурами не вносит ничего принципиально нового по сравнению с простыми переменными, более интересен случай, когда в явном виде имеется адрес структуры (например, параметр подпрограммы).

Рассмотрим для примера такую C-шную структуру: struct dummy { double flt64; float flt32; short int16; char int8; } *dummyP; Для хранения адреса можно в принципе пользоваться любым индексным регистром, но

поскольку они как правило уже заняты для других целей, компиляторы обычно используют для обращения к структурам (в широком смысле, то есть и к массивам тоже) регистр BX или адресную пару ES:BX для “дальних” адресов (на современных процессорах разрешено для косвенной адресации использовать AX и DX или их 32-битовые аналоги, как правило именно так и делают 32-битовые компиляторы).

Обращение к полю int16, то есть аналог C-шной конструкции (*dummyP).int16 или dummyP->int16 могло бы выглядеть так (вспомните, что квадратные скобки обозначают косвенную адресацию):

mov ax,es:[bx+12] Обращение к полю int8, то есть (*dummyP).int8 или dummyP->int8

соответственно: mov al,es:[bx+14] Опять-таки, обращение к полям структуры по численному значению смещения

приемлемо для компилятора, но плохо подходит людям, так что можно упростить себе жизнь, описав структуру средствами ассемблера: dummy STRUC flt64 dq ? flt32 dd ? int16 dw ? int8 db ? dummy ENDS

(Размеры каждого поля структуры нужны компилятору ассемблера для вычисления смещения, а вот никаких начальных значений у них быть не может, как их не бывает и в С-шном описании структуры).

Теперь к полям структуры можно обращаться по именам, например так: mov al,es:int8[bx] Или наоборот mov al,es:[bx][int8]

Page 32: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 32 -

Что эквивалентно mov al,es:[bx+int8] Пишите как больше нравится, лишь бы сохранялся смысл. Такой вроде бы простой подход может содержать неочевидный подвох: если бы

С-шная структура была описана в виде struct dummy { char int8; short int16; float flt32; double flt64; }

то есть по возрастанию размеров, то в ассемблере перечень полей того же размера в том же порядке описывал бы принципиально другую структуру данных. Дело в том, что С-шные ком-пиляторы как говорится, выравнивают поля в структуре на границу машинного слова, так что у большинства DOS-овских компиляторов поле int8 заняло бы два байта, а на современных 32-битовых системах как правило не только int8, но и int16 были бы “растянуты” до 4х байт. Чтобы ещё запутать ситуацию, можно вспомнить, что, например, компилятору Microsoft C можно указать, на какое количество байт делать такое выравнивание (то есть оно для данного комилятора вообще говоря неизвестно какое). Подробное обсуждение того, зачем и как именно делается такое выравнивание, выходит за рамки этого курса, но можно дать сравнительно простую практическую рекомендацию: при прочих равных поля в структурах лучше описывать по убыванию размеров.

Массивы принципиально отличаются от структур (в узком смысле слова) тем, что доступ к элементам массива осуществляется по индексу, который вычисляется; то есть смещение от начала массива (структуры в широком смысле слова) уже не константа, а пере-менная величина. В принципе можно просто прибавить смещение нужного элемента к адресу начала массива (командой add bx,…), но можно использовать другой подход: как правило для такого случая оправдано использование дополнительного индексного регистра, если по-прежнему, в ES:BX лежит адрес начала массива, то можно вычисленное смещение нужного элемента поместить в SI (или DI), тогда к элементу массива можно обратиться как ES:[BX+SI] (а можно и наоборот, переменное смещение вычислять в BX, а адрес начала хранить в ES:SI).

Хотя мнемоника операций (квадратные скобки) специально подчёркивает аналогию между массивами в C и ассемблерным эквивалентом, это всё-таки аналогия, а не точное соответствие, и в регистре должно быть именно смещение, а не индекс. То есть для С-шной конструкции A[i] или, что по определению то же самое *(A+i), значение смещения будет равно значению индекса i если A типа char, но если A типа short, то смещение должно быть равно удвоенному значению инднкса i, и т. п. Обобщая, можно сказать, что компилятор для вычисления смещения умножает индекс на размер элемента массива (то есть на sizeof(A[0]) или sizeof(*A) — выбирайте что больше нравится), а при программировании на ассемблере эта обязанность возлагается на программиста.

Изложенная идея структуризации следует схеме работы компиляторов, причём в “большой” (large) модели памяти. На практике эту схему можно и как правило нужно разнооб-разить: например, использовать для доступа к структуре ES:DI, или вместо ES использовать DS (так делают все компиляторы в “малой” - small - модели памяти) или даже вместо ES ис-пользовать SS (а вот этого уже ни один компилятор не делает, уж слишком много ограничений на подобного рода трюк) — всего не перечислишь.

Операции со строками Принято считать, что строки в языке C — это последовательность символов, заканчива-

ющаяся символом с кодом 0. (Было бы поучительно сравнить разные соглашения о представ-лении строк: например, в Паскале вместо символа конца каждая строка содержит свою длину, а в MS-DOS есть функция, для которой концом строки является символ $). Тем не менее, строго говоря, в самом языке C средства работы со строками отсутствуют. Компилятор умеет последовательность символов в двойных кавычках преобразовывать в массив типа char и автоматически добавлять нулевой байт, но это и всё. Во всех других аспектах нет никаких строк, а есть массивы типа char и только (для работы с такими массивами как со строками есть

Page 33: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 33 -

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

Такое положение дел связано с проблемой распределения памяти: в отличие от других типов данных строки могут занимать слишком разный объём памяти. Так что если хочется, чтобы, например, в результате выполнения последовательности

str1=”ab”; str2=”cde”; str3=str1+str2; получилась строчка “abcde”, то для этих шести байт надо сначала найти место, что называется выделить память. Но это бы ещё полбеды, с такой задачей справилась бы функция malloc() из стандартной библиотеки. Главная сложность — понять, когда эта память больше не нужна и вовремя её освободить (в терминах C — вовремя отдать функции free). Определить это в общем случае не так уж просто, потому что, например, в последовательности

str3=str1+str2; str3=””; место можно освобождать немедленно, а в последовательности

str3=str1+str2; str4=str3; str3=””; ничего освобождать нельзя, так как str4 содержит ссылку на строку, и она ещё может быть когда-нибудь использована.

Не то, чтобы такая задача была принципиально неразрешима, наоборот, она давно ре-шена, и соответствующие механизмы есть во многих языках программирования, в стареньком Lisp’е или сравнительно новом языке Java, или… примеров много. Однако реализация такого механизма в общем случае приводит к довольно большим накладным расходам, и разработ-чики языка C решили этих расходов избежать, переложив задачу распределения памяти на программиста — выкручивайся как умеешь. Что ж, решение на самом-то деле оправданное, очень немного прикладных задач действительно требуют механизма распределения памяти в самом общем виде, а часто удается обойтись даже без функции malloc() или её аналогов.

Операции =, + или != к строкам применять можно, но это будут операции над указате-лем на начало массива, то есть скорее всего не то, чего интуитивно хотелось; операции такого типа над содержимым строк обеспечиваются стандартными библиотечными функциями strcpy(), strtcat() и strcmp(). В результате то, что в языке вроде Java выглядело бы как str=”A”+”b” превращается в strcat(strcpy(str,”A”),”b”). Основной недостаток такой конструкции вовсе не громоздкость или ненаглядность, а ненадёжность: если строка-результат была по недомыслию описана как char str[2] (вместо char str[3]), то последствия могут быть непредсказуемыми.

Поскольку работа со строками нужна едва ли не в любой программе, разработчики Intel 8086 предусмотрели для этого специальный набор команд, но команды задумывались универсальными (не только для языка C) и, если уж подбирать C-шные аналогии, больше похожи на набор стандартных функций стандарта ANSI с префиксом mem: memcpy(), memcpy(), memcmp() и т.д.

Команды работы со строками устроены более-менее единообразно: берут входные данные по адресу DS:SI, помещают результат по адресу ES:DI (сегментные регистры можно при желании поменять) и изменяют значение индексного регистра так, чтобы он указывал на следующий результат. Положение “следующего” зависит от флажка направления (в регистре флагов) и размера порции данных, то есть, во-первых команды умеют работать не только по возрастанию адресов (как обычно), но и по убыванию (так что перед строковыми операциями нужно установить4 направление работы командами CLD или STD), а во-вторых команды могут считать порцией данных (одним символом) байт или слово (и это очень удобно для работы с современными многонациональными наборами символов вроде Unicode). Сверх того, процессор можно заставить повторять команду нужное число раз: максимальное коли-чество повторений задаётся в регистре CX, а условие повторения префиксом REP, REPE или REPNE.

Наверное самая популярная команда — это MOVS, пересылка порции данных (аналог функции memcpy), компиляторы генерируют её для присваивания структур или даже пере-менных типа double (восемь байт всё-таки).

Ну а теперь — несколько примеров. (Кстати, вопрос на пройденный материал: для какой модели памяти написаны эти примеры?)

Функция memset() могла бы выглядеть так: 4 Будьте бдительны: в 32-битовом режиме соглашение об использовании флага направления другое!

Page 34: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 34 -

_memset proc near push bp mov bp,sp push di cld les di,[bp+4] ; указатель mov al,[bp+8] ; символ mov cx,[bp+10] ; количество rep stosb pop di mov sp,bp pop bp ret _memset endp

Функция memcpy() могла бы выглядеть так: _memcpy proc near push bp mov bp,sp push ds push si push di cld les di,[bp+4] ; куда lds si,[bp+8] ; откуда mov cx,[bp+12] ; сколько rep movsb pop di pop si pop ds mov sp,bp pop bp ret _memcpy endp

Следует только иметь в виду, что побайтовые операции наименее эффективны, если уж есть возможность, лучше использовать операции над более крупными порциями: 16-битовыми словами или даже 32-битовыми (на современных процессорах).

Функцию strlen() разберём подробнее. Самый простой пример без использования строковых операций мог бы выглядеть так (при анализе примеров рекомендую мысленно поделить их на части: сначала “отсечь” пролог и эпилог, в том, что осталось выделить цикл и вычисление возвращаемого значения по результатам цикла): _strlen proc near push bp mov bp,sp les bx,[bp+4] ; указатель xor ax,ax ; счётчик cycle: mov dl,es:[bx] test dl,dl jz end inc ax inc bx jmp cycle end: mov sp,bp pop bp ret _strlen endp

Две операции инкремента внутри цикла наводят на мысль, что одна из них лишняя. Действительно, её можно сократить, если возпользоваться разностью указателей (в данном случае это можно — кстати, а почему?).

Page 35: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 35 -

_strlen proc near push bp mov bp,sp les bx,[bp+4] ; указатель cycle: mov al,es:[bx] test al,al jz end inc bx jmp cycle end: mov ax,bx sub ax,[bp+4] mov sp,bp pop bp ret _strlen endp

В этом цикле основной “пожиратель времени” — команды перехода. Если подумать, то можно одну из них сэкономить: _strlen proc near push bp mov bp,sp les bx,[bp+4] ; указатель cycle: mov al,es:[bx] inc bx test al,al jnz cycle dec bx mov ax,bx sub ax,[bp+4] mov sp,bp pop bp ret _strlen endp

Этот пример вплотную подходит к использованию строковых операций: _strlen proc near push bp mov bp,sp push si cld les si,[bp+4] ; указатель cycle: lods byte ptr es:[si] test al,al jnz cycle mov ax,si dec ax sub ax,[bp+4] pop si mov sp,bp pop bp ret _strlen endp

Цикл стал ещё короче, но можно обойтись без цикла в явном виде вообще: _strlen proc near push bp mov bp,sp push di cld les di,[bp+4] ; указатель xor al,al mov cx,65535 ; максимум repne scasb mov ax,cx not ax ; AX := 65535 - AX dec ax pop di mov sp,bp pop bp ret _strlen endp

Page 36: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 36 -

В примерах не возникали команда сравнения CMPS (основа функций memcmp, strcmp и им подобных) и префикс REPE — изучите их самостоятельно.

Сервис прерываний Слово “прерывание” (interrupt) в DOS объединяет три принципиально разных понятия:

аппартное прерывание (например нажата клавиша или сработал таймер), исключительная си-туация (например деление на ноль) и программное прерывание (например запрос на чтение файла). Подробно эти понятия разбираются в курсе операционных систем, а пока будет достаточно того, что у всех трех драматически различных понятий всё же есть нечто общее: в любом из этих случаев управление на время передаётся заранее предусмотренному куску программного кода, а потом как правило возвращается обратно (если повезёт).

В общем-то, как уже было сказано, каждый обработчик прерывания представляет со-бой по сути подпрограмму, аналогичную C-шной или паскалевской. Однако требования к под-программам обработки прерываний предъявляются совсем не те, что к подпрограммам языков высокого уровня. Основная сложность в том, что обработка прерывания происходит, как говорится, на фоне выполнения основной программы. Например, системный таймер генериру-ет прерывания примерно 19 раз каждую секунду, и заранее невозможно предсказать, чем будет заниматься программа пользователя в момент прерывания. Тем не менее все такие (и любые другие кстати) прерывания необходимо обработать так, чтобы пользовательская про-грамма ничего не заметила. В результате, во-первых, обработчик прерывания не имеет права “испортить” значение никаких регистров, даже регистра флагов. (Есть множество случаев, когда обработчик прерывания целенаправленно меняет значение регистров, но об этом поз-же.) Понятно, однако, что не менять совсем ничего просто невозможно, и обработчики пре-рываний используют свободное пространство в стеке для своих нужд (упрятать и восстано-вить регистры в первую очередь). По этой причине с указателем стека в DOS надо обращаться особенно аккуратно: если указатель стека временно указаывает куда-то “не туда”, и в этот момент произойдёт прерывание — последствия могут быть катастрофическими.

Обращение к обработчику прерываний тоже происходит по особым правилам (это можно сделать командой CALL, но как правило это всё-так происходит иначе.) Все возмож-ные прерывания перенумерованы от 0 до 255: 0 — деление на ноль, 8 — сработал таймер, 9 — нажата или отпущена клавиша на клавиатуре и так далее. Обращение к прерыванию обычно происходит по номеру: в случае аппаратного прерывания или исключительной ситуации нужный номер знает аппаратура, а в случае программного прерывания номер указывает программист в команде INT. Адреса обработчиков прерываний хранятся в самом первом килобайте оперативной памяти (по 4 байта на адрес начиная с нулевого адреса) по порядку номеров. При необходимости можно этот адрес поменять, заменив обработку прерывания на что-то своё, но, как легко понять, делать это надо осторожно.

С точки зрения языка C (или Паскаль) прерывания — тема посторонняя. Хотя компиля-торы для DOS как правило включают более-менее развитые средства работы с прерываниями, эти средства специфичны для DOS (и становятся практически бесполезны даже в MS Windows, не говоря уж о других операционных системах), вполне понятно поэтому, что стандарт языка их игнорирует. Наиболее общепринятым средством обработки аппаратных прерываний и исключительных ситуаций в языке C являются, наверное, так называемые сигналы (см. описание функции signal()), которые будут изучаться в курсе операционных систем.

Что касается программных прерываний, то, по идее, всё необходимое для общения с операционной системой должно быть заранее включено в стандартные библиотеки языка, так что нужды в обращениях к прерываниям возниакать не должно. На практике, однако ж, такая нужда возникает довольно часто.

Подпрограммы, использующие сервис прерываний, как правило устроены единооб-разно: параметры C-шной функции переписываются в регистры в форме, требуемой обраще-нием к прерыванию, а после команды INT результат работы переписывается обратно в форму, приемлемую для языка высокого уровня — это как раз тот случай, когда обработчик прерывания может намеренно изменить содержимое регистров.

Сервис, предоставляемый программными прерываниями, — это очень обширная тема, ниже перечислены наиболее популярные номера прерываний:

Page 37: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 37 -

10h видео BIOS (вывод на экран); 16h клавиатурный BIOS (ввод с клавиатуры); 1Ah системный таймер; 21h сервис DOS (работа с файлами, запуск программ и т.п.); 33h работа с устройством “мышь”.

Напоследок, как водится, несколько примеров. Часто возникает нужда издать некий предупредительный звуковой сигнал, чтобы при-

влечь внимание пользователя к ошибке. В принципе для этого в языке C предусмотрен спе-циальный символ “звонок”: '\a' (или '\007' в старых компиляторах), так что если написать нечто вроде putchar('\a') или putchar(7), то нужный эффект будет достигнут. Подвох в том, что этот способ работает не всегда: если вывод средствами DOS перенаправлен в файл, то в файле появится байт с кодом 7 совершенно беззвучно. Одно из возможных решений заключается в том, чтобы “вывод на экран” делать средствами BIOS; поскольку BIOS понятия не имеет о файлах, он всегда будет пытаться прозвучать: _biosbeep proc near ; void biosbeep(void) mov bx,0 ; номер видеостраницы зависит от… много чего, но 0 есть всегда mov ah,14 ; номер функции: отобразить символ mov al,7 ; код символа int 10h ret _biosbeep endp

Этот пример для наглядности записан неоптимально, обычно то же самое делается короче: _biosbeep proc near ; void biosbeep(void) xor bx,bx mov ax,0E07h int 10h ret _biosbeep endp

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

видеоадаптере EGA популярен режим с 43 строками, а на VGA c 50, и т.п. В общем случае количество строк надо уметь спросить: _screen_rows proc near ; int screen_rows(void) push bp mov ax,1130h ; запрос информации генератора символов mov bh,0 mov dl,24 int 10h xor ax,ax mov al,dl inc ax pop bp ret _screen_rows endp

В этом примере нуждаются в пояснениях два момента. Во-первых, хотя пролог и эпи-лог здесь тоже вроде бы не требуются, упрятать и восстановить BP всё-таки нужно, поскольку его меняет прерывание. Во-вторых, стоит обратить внимание на команду установки регистра DL перед прерванием, которая в соответствии с документацией не значит ничего, потому что прерывание запишет в DL номер последней строки и сотрёт то, что там было раньше. Нужна эта команда из-за того, что использованная здесь сервисная функция видео-BIOS появилась в адаптере EGA, а до того её не было. Вот в случае таких старых адаптеров обработчик прерыва-ния для неизвестной ему функции сохранит регистры неизменными, и для устаревших адаптеров всё равно получится правильный результат 25.

Работа с устройством типа “мышь” вошла в моду уже после того, как возник DOS, и до-вольно долго после этого не существовало единого стандарта на взаимодействие с “мышиной” аппаратурой. В результате в библиотеках DOS-овских компиляторов отсутствуют функции для работы с “мышью”, а для успешной работы с “мышью” в DOS требуется специальный программный драйвер, соответствующий именно тому типу “мыши”, который подключен к компьютеру. Основное назначение драйвера — именно унифицировать взаимодействие с устройством. Драйвер, если он есть, переставляет на себя адрес обработчика прервания 33h,

Page 38: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 38 -

так что дальше можно пользоваться сервисными функциями этого прерывания. Например, следующая функция определяет положение курсора на экране и возвращает битовую маску нажатых клавиш (в младших трех разрядах результата): _mouse_query proc near ; int mouse_query(short *x, short *y) push bp mov bp,sp mov ax,3 int 33h mov ax,bx ; возвращаемое значение mov bx,[bp+4] ; адрес x mov [bx],cx ; *x := номер столбца mov bx,[bp+6] ; адрес y mov [bx],dx ; *y := номер строки pop bp ret _mouse_query endp

(Вопросы на повторение пройденного: 1) для какой модели памяти написана эта функция? 2) что изменится для “большой” (large) модели?)

Что осталось “за кадром” Поскольку данный курс ограничен довольно жёсткими рамками, то даже перечень

смежных тем был бы непозволительной роскошью. Тем не менее, рассказывая о языке ассемблера IBM PC, нельзя не упомянуть два принципиальных момента.

Во-первых, это не просто ассемблер, а макроассемблер. Ассемблерный макропроцес-сор умеет всё то же, что C-шный (хотя инструкции выглядят немного иначе), но этого мало. Можно запрограммировать собственную инструкцию, внешне не отличимую от “стандарт-ной”, которая будет разворачиваться в более-менее сложную последовательность других инструкций. Таким образом можно, например, записывать вызов подпрограммы в одну строчку (аналогично языкам высокого уровня), либо унифицировать заголовок и завершение подпрограммы — и тогда их будет легко заменить при изменении модели памяти или пере-ходе от C-шного соглашения о связях к какому-нибудь другому. Если для языков высокого уровня объектно-ориентированный синтаксис предоставляет гибкие и мощные средства перенастроить язык для собственных целей, то для ассемблера таких средств не придумано (или по крайней мере они не получили распространения); так что если почему-то возникла нужда написать более-менее большую и сложную программу только на ассемблере, то использование макровозможностей становится просто необходимым.

Во-вторых, современные процессоры “семейства Intel 86” очень сильно отличаются от своего прародителя. Кроме того, что для удобства программирования добавлено много новых команд, и сам процессор стал 32-разрядным (а это принципиальная разница!), теперь процес-сор приспособлен для полноценной поддержки современных операционных систем5. Напри-мер, в прцессор добавлены средства синхронизации параллельного исполнения и возмож-ность виртуальной памяти (что это такое опять-таки будет рассказано в курсе операционных систем). Кроме того, в процессоре возникла система привилегий или режимов доступа. Хотя при работе под управлением DOS ресурсами вычислительной системы формально распоря-жалась DOS, это было дело сугубо добровольное. Любая программа могла прочитать или записать любое место в оперативной памяти или на диске, выполнить любую операцию на любом устройстеве и т.п. (В результате, например, игрушка digger полностью брала на себя управление компьютером и даже не сохраняла DOS в оперативной памяти.) Такой режим существует и теперь (и называется real mode), но для многопользовательской многозадачной системы он не подходит. Современные операционные системы и прикладные программы вы-полняются в так называемом “защищённом” режиме (protected mode), в котором всей полно-

5 Для желающих ознакомиться с современным положением дел издано много книг, например Магда Ю.С. Ассемблер. Разработка и оптимизация Windows приложений. — СПб.: БХВ-Петербург, 2003. Магда Ю.С. Использование ассемблера для оптимизации программ на C++. — СПб.: БХВ-Петербург, 2004. Однако ж не теряйте бдительности! Несмотря на все правильные слова об оптимизации, большинство примеров в этих книгах далеко не оптимальны — хоть на ассемблере, хоть на C. Впрочем, из этого тоже можно извлечь пользу: такая самостоятельная дальнейшая оптимизация “оптимальных” примеров — это очень полезное упражнение. Если Вы знаете действительно хорошую книгу на данную тему, то, пожалуйста, дайте мне знать.

Page 39: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 39 -

той прав обладает только операционная система (да и то, скорее всего, не все её части), а все остальные процессы в системе имеют столько прав, сколько им ОС позволила.

Чтобы выполнять на современной (32-разрядной) ОС старые (16-разрядные) програм-мы для DOS предусмотрен специальный режим “виртуального 8086”, в котором старая про-грамма якобы работает на 8086, но ОС отслеживает все более-менее сомнительные операции вроде обращений в “чужую” память или к портам ввода-вывода. (Такое положение дел при-водит к ситуациям, абсурдным с точки зрения DOS-овского программиста, например, обраще-ние к видеопамяти работает медленнее, чем обращение к функциям BIOS).

Ассемблерные вставки Одно из главных неудобств при работе с ассемблером — необходимость “вручную”

отслеживать взаимную согласованность программ: попытка собрать C-шную программу в “большой” модели с ассемблерной подпрограммой для “маленькой” закончится успешно в том смысле, что скорее всего выполняемый .EXE файл соберётся, но вот работать такой гиб-рид скорее всего не будет. (Впрочем попытка наоборот прилинковать к С-шной программе в “маленькой” модели ассемблерный код для “большой” будет ничуть не лучше.)

Теперь представьте, что вы долго разрабатывали программу на смеси C и ассемблера в “маленькой” модели (потому что глупо использовать “большую”, если программа умещается в маленькую), программа росла-росла и перестала помещаться в “маленькую” модель, надо переходить на “большую”, и значит надо вручную перелопачивать все ассемблерные подпро-граммы. В такой ситуации практически нереально избежать ошибок.

Для современных процессоров само понятие “модель памяти” неактуально, но есть много других подвохов, например, необходимость добавить аргумент в подпрограмму в уже существувющем программном комплексе способна вызвать ничуть не лучший хаос.

В общем-то те же самые проблемы возникли бы при работе только на ассемблере, без языков высокого уровня, но раз уж компилятор C умеет автоматизировать такого рода работу, было бы глупо этим не пользоваться. В результате естественного желания максимум забот переложить на компилятор большая часть ассемблерного кода существует в виде ассемблер-ных вставок в C-шные программы. Писать ассемблерные вставки в чём-то даже сложнее, чем просто ассемблерные программы, поскольку необходимо знать не только язык ассемблера, но и особенности работы компилятора, для которого пишется такая вставка. (Например, воз-можность “испортить” содержимое какого-нибудь регистра определяется уже не стандартным соглашением о связях, а тем, для чего использовал этот регистр конкретный компилятор в конкретной функции.) Тем не менее затраченные усилия как правило окупаются лучшей надёжностью.

Ассемблерные вставки в большинстве компиляторов выглядят довольно похоже — после ключевого слова asm, _asm или __asm следует ассемблерный блок, то есть последова-тельность ассемблерных инструкций в фигурных скобках. (К сожалению любимый компиля-тор Turbo C устроен немного иначе, так что в примерах слово asm встречается перед каждой ассемблерной инструкцией).

Например, процедура для установки курсора “мыши” в заданное место экрана могла бы выглядеть так: void mouse_gotoxy(int x, int y) { asm { mov dx,y mov cx,x mov ax,4 int 51 } }

Здесь нет нужды описывать подпрограмму far или near и считать на пальцах смещение параметров относительно BP — обо всём этом позаботится компилятор.

Сборка программы из разных файлов Для языков, компилируемых в машинный код как правило применяется один и тот же

подход: множество исходных файлов компилируется в объектный формат (.obj или .o в

Page 40: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 40 -

UNIX), потом объектные файлы собираются редактором связей (linker) в выполняемый файл (.exe). Такой подход используется для 1) возможности одновременной работы над программой нескольких человек; 2) более удобного редактирования программы одним человеком; 3) сборки программы на разных языках программирования; 4) обхода ограничений на объём программы; 5) сокращения времени сборки программы; 6) повышения эффективности повторного использования кода; 7) извлечения дополнительной прибыли.

Пункты 1-3 обсуждались раньше, об остальных стоит поговорить подробнее. Компилятор в своей работе ограничен ресурсами компьютера, в первую очередь

объёмом оперативной памяти. При попытке компилировать всю программу зараз компилято-ру скорее всего не хватит памяти на нескольких десятках или сотнях тысяч строк. При обработке по отдельности кадого из множества мелких исходных файлов легко собираются программы общим объёмом миллионы строк.

Возьмём пример, с которым я хорошо знаком: коммерческая программа, с которой я работаю состоит из чуть более миллиона строк исходного кода на C (С++ и ассемблере). Компиляция всего этого кода занимает больше часа на моей PC и примерно полдня на Sun 4 (под UNIX). Однако необходимость компилировать всё подряд возникает редко, как правило достаточно перекомпилировать только те файлы, которые я менял с прошлого раза и “слинковать” то, что получилось — занимает это в худшем случае несколько минут. Как говорится, “почувствуйте разницу”.

Программирование в современном понимании развивается больше полувека, и разви-вали его не самые глупые люди, так что пытаться сочинять все программные решения заново самому было бы, мягко выражаясь, слишком самонадеянно. В реальной жизни программы практически никогда не пишутся “с нуля”, вместо этого в той или иной форме используется программный код, написанный другими людьми. Одна из возникающих трудностей связана с тем, что из “написанного другими” для каждой конкретной последующей программы нужно отнюдь не всё, часто встречается ситуация, когда из куска кода в много тысяч строк нужны всего несколько сотен. Если есть исходные тексты, то их можно отредактировать, но это лишняя работа и риск лишних ошибок. А вот если есть уже скомпилированная библиотека (в объектном формате), то её и не исправишь! Так что при разработке программ, рассчитанных на повторное использование, становится особенно важно заранее нарезать их как можно мель-че. Для библиотек считается идеалом ситуация, когда каждый файл с текстом программы содержит только одну “видимую снаружи” функцию (функций со словом static может быть сколько угодно) — при этом из библиотеки гарантированно не будет взято ничего лишнего. (Стоит пожалуй заметить, что этим правилом не стоит чересчур увлекаться: во-первых бывают-таки редкие случаи, когда буквальное выполнение этого правила увеличивает накладные расходы, а главное к приложениям требования не те, что к библиотекам.)

Ну и наконец распространение библиотек в скомпилированном объектном формате часто используется, чтобы их продавать без права доступа к исходным текстам. При этом во-первых сохраняются разного рода коммерческие секреты, так называемое know-how, во-вторых покупатель не может ничего исправить сам и вынужден покупать у фирмы-разработчика исправление ошибок, новые версии и т.п. О достоинствах, недостатках и морали такого подхода можно спорить долго (и на самом деле на эти темы спорят не переставая), но, нравится это или нет, такая практика существует.

Начинающие программисты частенько пытаются объединять программы из разных файлов не на уровне объектного кода, а на уровне исходных текстов, с помощью #include. Такой подход работает на маленьких программах, но считается черезвычайно дурным тоном, поскольку противоречит пунктам 3-6, и по мере роста программы довольно быстро перестаёт работать. (Не вижу смысла рассказывать об этом подробнее — если вы никогда так не делали и даже не поняли о чём речь, то, наверное, оно и к лучшему.)

При правильном подходе с помощью #include включаются только так называемые файлы заголовков (.h). Файлы заголовков содержат только инструкции, не порождающие ма-шинного кода непосредственно, то есть макросы и определения типов, в том числе структур и перечислений. При этом гарантируется, что такие определения существуют только в одном

Page 41: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 41 -

экземпляре, так что не может возникнкть ошибки из-за случайной разницы между разными экземплярами описаний. Кроме того, файлы заголовков как правило включают прототипы функций (то есть описания без определений, аналог forward declaration из Паскаля). Хотя с формальной точки зрения наличие прототипов не является строго обязательным, на практике это позволяет “отлавливать” многие ошибки, так что пренебрегать прототипами ни в коем случае не стоит. Наконец, в файлы заголовков полезно включать описания глобальных переменных (если уж они есть).

При такком подходе часто возникает проблема, связанная с тем, что компилятор C позволяет переопределять макросы, но не объекты языка, и возникает она из-за того, что файлы заголовков тоже могут содержать инструкцию #include. Например, системные за-головки <sys/stat.h> и <fcntl.h> зависят от описаний типов из <sys/types.h>, если эти описания пропустить, то программа не скомпилируется. Можно конечно (как это было в старых реали-зациях языка C) потребовать, чтобы программист обязательно сам включал соответствующий файл, но логичнее было бы поставить #include <sys/types.h> первой строкой в оба зависимых файла, и не забивать человеку голову такими деталями. Если, однако ж, при этом где-то потребуются оба файла <sys/stat.h> и <fcntl.h>, то файл <sys/types.h> был бы включен в программу дважды, и вторую попытку определить уже определённый тип (повторение инст-рукции typedef или struct с тем же именем) компилятор не разрешил бы. Для обхода этого ограничения сравнительно недавно вошёл в моду простой приём с использованием препро-цессора: почти любой header-файл нынче заключается в конструкцию вроде #ifndef __SYS_STAT_H #define __SYS_STAT_H /* содержимое файла */ #endif

Имя макроса в #ifndef/#define может быть в общем-то любым, главное, чтобы оно нигде больше в программе не использовалось. Несложно видеть, что, сколько бы раз ни включался в программу сам файл, его содержимое препроцессор пропустит лишь однажды. (Разумеется, этот приём можно использовать и в пользовательских заголовках с тем же успехом.)

В заключение хотелось бы подчеркнуть два принципиальных момента. Во-первых, поскольку header-файл (при нормальном положении дел) содержит описа-

ния, то включение его в программу вовсе не означает “подключения библиотеки” — об этом ещё придётся позаботиться на этапе редактирования связей (“линковки”). (Почему-то сту-денты обычно уверены, что соответствующий #include — это всё, что требуется для исполь-зования библиотеки. Так вот, это не так.)

Во-вторых, не стоит жалеть усилий на правильные заголовки. Если какой-то объект используется хотя бы в двух разных исходных файлах, то есть смысл его описание вынести в какой-нибудь из header-файлов (создавать отдельный файл для каждого объекта конечно же нет смысла), и включить этот header и туда, где объект только используется, и туда, где опре-деляется (чтобы компилятор проверил соответствие описания и определения). Затраченные усилия как правило окупаются с лихвой, поскольку “разное понимание” в разных файлах объекта с одним именем приводит к серьёзным ошибкам, которые иной раз непросто найти6.

Хотя в общем случае сборка программы из объектных файлов требует вызова редак-

тора связей (“линкера”), обычно так не делают из-за того, что програме на C (да и любом другом языке высокого уровня) кроме скомпилированных исходников требуются стандартные библиотеки языка программирования; что это за библиотеки знает компилятор, но не “линкер”. Чтобы избежать связанных с этим неудобств, компилятор C сделан достаточ-но “умным”: он понимает разницу между файлами на C (и C++, если это плюсовый компиля-тор), ассемблере, объектными файлами и библиотеками и умеет обрабатывать их все нужным образом, так что при сборке простых программ можно просто чохом подсунуть компилятору все исходники — пусть разбирается. Разумеется, такой подход не слишком-то хорош (проти-воречит пунктам 3 и 5), но реализация аккуратного подхода для большой программы пред-ставляет собой достаточно сложную задачу, чтобы для неё потребовались специальные средства. 6 см. например задачи 11 и 65 в Уэллин С. Как не надо программировать на C++. Пер. с англ. — СПб.: Питер, 2004.

Page 42: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 42 -

Утилита make Средств сборки больших программных проектов существувет, скажем так, заметно

больше одного, но первоочередная задача у них у всех одинаковая: лениться как можно больше, то есть выполнять тольку ту работу, которая абсолютно необходима, и не трогать файлы, если без этого можно обойтись. Для выполнения такой основной задачи используется идея дерева зависимостей.

┌─ sample1.c ┌─ sample1.obj ─┤ │ └─ common.h │ │ ┌─ sample2.c sample.exe ─┼─ sample2.obj ─┤ │ └─ common.h │ │ ┌─ sample3.c └─ sample3.obj ─┤ └─ common.h

Как можно понять из нарисованного примера, кончный результат — выполняемый файл — непосредственно зависит от объектных файлов, из которых он собирается. Объектные файлы, в свою очередь, зависят от C-шных текстов, из которых они получаются, и обратите внимание на зависимость от header-файлов (если изменился файл заголовков, то это измене-ние может как-то повлиять на все C-шные тексты, в которые он включен, так что хочешь - не хочешь, их надо перекомпилировать).

Если дерево зависимостей известно, то испольуется оно сравнительно просто: надо обойти всё дерево и для каждой зависимости проверить, что время последней модификации зависимого файла больше (позже) чем времена всех файлов, от которых он зависит; если это не так, то зависимый файл надо пересобрать.

Остаётся вопрос, как описать дерево зависимостей. Самая простая известная мне реализация сделана в интергрированной среде Turbo C: в

отдельном текстовом файле просто перечисляются все файлы на C, из которых собирается вы-полняемый файл, если нужно указать зависимость от header-ов, то они перечисляются после C-шного файла в скобках. Дерево из предыдущего примера описывалось бы файлом sample.prj следующего содержания: sample1.c (common.h) sample2.c (common.h) sample2.c (common.h)

Этот механизм описания зависимостей легко освоить, но, увы, он слишком ограничен в своих возможностях. Такое описание рассчитано только на C, работу с другими языками программирования (даже ассемблером) автоматизировать не получится.

Кроме того стоит подчеркнуть ещё один принципиальный момент. Дерево зависимос-тей для большинства программ включает завсимости только двух уровней: исходный язык (ас-семблер, C и т.п) — объектный код и объектный код — выполняемый файл (так что дерево превращается в этакий кустарник), но такое положение дел в общем случае не обязательно. Самый простой пример наверное такой: текст на C не столь уж редко генерируется какой-нибудь другой программой (например, именно так работает самый сейчас распространённый компилятор языка FORTRAN — это компилятор из FORTRAN в C), так что появляется дополнительный уровень зависимости. В общем случае глубина уровней зависимости может быть любой.

На сегодня универсальным стандартом сборки сложных программ стала утилита make (происходящая из UNIX). Для аккуратного описания зависимостей make использует свой собственный язык, над освоением которого придётся потрудиться.

Задание для make — это, в общем-то, перечисление отдельных зависимостей. Описа-ние одной зависимости состоит из трех частей: во-первых “цель” (target) то есть того, что надо получить в результате, во-вторых списка файлов, от которых “цель” зависит и в третьих списка команд, которыми “цель” изготавливается. Как раз список команд и делает make универсальным: команда может быть вызовом любого компилятора или другой программы, раньше приготовленной тем же make-ом и т.п. Зависимость записывается в файле так: слева название цели, после двоеточия список зависимостей, а в следующих строчках список команд. Каждая строка с командой из списка должна начинаться символом табуляции (это нелепое

Page 43: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 43 -

требование табуляции отсутствует в современных версиях программы, но лучше всё-таки поставить именно табуляцию, вреда от этого точно не будет).

Зависимости в файле могут быть перечислены в практически произвольном порядке, make попытается их упорядочить сам и построить как можно более полное дерево.

Если make вызвать без параметров, то он попытается найти задание в файле с именем makefile и сделать первую встречную “цель” в файле. Задание для сборки предыдущего при-мера компилятором Turbo С могло бы выглядеть так: sample.exe: sample1.obj sample2.obj sample3.obj tcc -e sample.exe sample1.obj sample2.obj sample3.obj sample1.obj: sample1.c common.h tcc -o sample1.obj -c sample1.c sample2.obj: sample2.c common.h tcc -o sample2.obj -c sample2.c sample3.obj: sample3.c common.h tcc -o sample3.obj -c sample3.c

Этот пример легко меняется для использования ассемблера:

sample.exe: sample1.obj sample2.obj sample3.obj tcc -e sample.exe sample1.obj sample2.obj sample3.obj sample1.obj: sample1.c common.h tcc -o sample1.obj -c sample1.c sample2.obj: sample2.c common.h tcc -o sample2.obj -c sample2.c sample3.obj: sample3.asm tasm -ml sample3.asm

Даже на таких простых примерах можно заметить много повторяющихся деталей, и

остальная часть языка make предназначена как раз для того, чтобы облегчить работу с повторяющимися элементами.

Как и всякий уважающий себя язык программирования, язык make включает перемен-ные. Поскольку язык make полноценным языком всё-таки не является, переменные могут быть только одного типа — строки. (Переменные make во многом похожи на переменные командного интерпретатора UNIX, сказывается происхождение, но кое в чём они принципи-ально различны.) Присваивание переменной значения записывается с помощью знака равенства и выглядит достаточно традиционно, например: OBJS = sample1.obj sample2.obj sample3.obj

Использование переменной (в отличие от C, Паскаля и т.п.) надо обозначить явно с помошью знака $ (доллар), и кроме того, если имя переменной длиннее одной буквы, то его надо взять в скобки. То есть строчка OBJS останется неизменной, комбинация $(OBJS) будет заменена на ранее присвоенное значение а для $OBJS будет сделана попытка к значению переменной $O приписать строку BJS (и наверное это не то, что надо).

Кроме того, make понимает набор специальных переменных с однобуквенными именами вроде @ или *. Так, $@ означает полное имя текущей “цели”, а $* — то же имя без расширения. Прошлый пример теперь можно переписать так: OBJS = sample1.obj sample2.obj sample3.obj sample.exe: $(OBJS) tcc -e $@ $(OBJS) sample1.obj: sample1.c common.h tcc -o $@ -c sample1.c sample2.obj: sample2.c common.h tcc -o $@ -c sample2.c sample3.obj: sample3.asm tasm -ml $*.asm

Page 44: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 44 -

Использование переменных не только сокращает запись, но, что более ценно, уменьша-

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

Можно ещё заметить, что команды вызова компилятора похожи друг на друга, и make включает так назваемые правила для сокращения записи однотипных команд. Чисто C-шный пример может быть переписан так: OBJS = sample1.obj sample2.obj sample3.obj sample.exe: $(OBJS) tcc -e $@ $(OBJS) .c.obj: tcc -o $@ -c $*.c sample1.obj: sample1.c common.h sample2.obj: sample2.c common.h sample3.obj: sample3.c common.h

Псевдозависимость .c.obj на самом деле задаёт правило создания файлов с суф-

фиксом .obj из файлов с суффиксом .c. Если бы не зависимость от header-а, то зависимости для отдельных файлов можно было вообще пропустить.

(Такая форма записи правил унаследована от самого первого make-а, и на самом деле не слишком-то удобна; в большинстве современных программ есть дополнительная более со-вершенная форма записи. Например, в GNU make (ставшим стандартом для UNIX) правило начиналось бы «%.obj: %.c» (ну или «%.o: %.c», если это именно UNIX) а в микрософ-товском nmake соответственно «{}.obj: {}.c». К сожалению, обсуждение всех этих раз-личий выходит за рамки курса.)

Наконец, необходимо упомянуть, что поведение make можно существенно изменить, указав в командной строке параметры. Одна из самых полезных возможностей — это указать какую именно “цель” make-у надо достичь (то есть не обязательно начинать с самой первой), благодаря этому большинство makefile-ов приспособлено для решения нескольких разных задач. (Остальные возможности изучите самостоятельно по документации.)

Ещё один пример makefile-а есть в приложении.

Необязательная глава: как не пользоваться C++ До сих пор данный курс старательно умалчивал о существовании C++. Сделано это

сознательно: объектно-ориентированное программирование — слишком большая тема. Нижеследующий материал рассчитан на предварительное знакомство с основами

объектно-ориентированного подхода и поэтому является необязательным. Какую пользу смогут из него извлечь те, кто с оными основами не знаком… могу только надеяться, что какую-нибудь да смогут.

Начать можно, наверное, с такого вот тезиса: всё, что делается на C++ можно сделать и в C. Компилятор C++ “всего лишь” сокращает количество ручной работы (примерно как компилятор C “всего лишь” сокращает количество ручной работы по сравнению с ассембле-ром). Точно так же, как до сих пор возникает нужда программировать на ассемблере, а не C, существуют ситуации, в которых удобней реализовывать объектно-ориентированный подход средствами чистого C (без плюсов). Примерами могут служить GTK+7 или подсистема ввода-вывода ОС UNIX (изучается в курсе операционных систем).

(Почему-то множество программистов считает, что объектно-ориентированный подход неразрывно связан с объектно ориентированными языками, и если писать на объектно-ориентированном языке, то автома-тически получится объектно-ориентированная программа. Это настолько не так, что меньшей ошибкой будет считать, будто между объектно-ориентированной программой и объектно-ориентированными языками нет вообще никакой связи. Не верите мне — читайте классиков8.) 7 см. например Костельцев А.В. GTK+. Разработка переносимых графических интерфейсов. — СПб.: БХВ-Петербург, 2002. 8 см. например Г.Буч Объектно-ориентированный анализ и проектирование. 2-е изд./Пер. с англ. — М.: «Издательство Бином», СПб.: «Невский Диалект», 2001.

Page 45: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 45 -

Тезис о “равномощности” C и С++ часто вызывает недоверие (“не может быть!”). На практике этот тезис подтверждается, например, тем, что первый компилятор C++ был сделан именно в виде препроцессора из C++ в чистый C.

Преобразование из C++ в C основывается на следующих простых приёмах: 1. К именам функций-методов незаметно для программиста добавляется имя класса и специ-

фикации типов возвращаемого значения и всех параметров (это называется “сигнатурой”). То есть все перегруженные методы “на нижнем уровне” имеют разные имена.

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

3. Каждая функция-метод получает первым (невидимым в C++) параметром указатель на экземпляр класса (доступный по слову this).

Работа компилятора C++ рассчитана на максимально общий случай, при “ручной” реализации тех же идей можно многое упростить за счёт знания особенностей задачи. Например, очень часто удобно пользоваться такой вот парой “операторов”: #define new(class_name) (class_name*)malloc(sizeof(class_name)) #define delete(instance) free(instance)

Эта пара определений для предельно упрощенного случая, тем не менее есть много задач, в которых этот приём полезен.

При “ручной” реализации наследования можно использовать один из двух приёмов: 1) включить структуру надкласса в начало определения подкласса (при этом синтаксис

обращений к унаследованным полям будет различаться в надклассе и подклассе); 2) скопировать поля надкласса в начало определения подкласса (теоретически такой подход

будет работать не всегда), эту работу можно автоматизировать с помощью препроцессора. Ниже приведён довольно длинный пример для иллюстрации этого подхода.

(По-видимому объектно-ориентированный подход невозможно продемонстрировать на коротких примерах, но обсуждение онтологических корней такой закономерности выходит слишком далеко за рамки данного курса.)

typedef struct {int x, y;} Point; typedef struct {int color; int pattern;} Attribute; typedef enum {gtUnknown, gtGroup, gtLine, gtRectangle, gtPolygone} GraphicType; #define DECLARE_COMMON_GRAPHIC_FIELDS\ GraphicType gType;\ struct _unknown *next;\ void (*draw)(struct _unknown*);\ void (*delete)(struct _unknown*); typedef struct _unknown { /* абстрактный класс */ DECLARE_COMMON_GRAPHIC_FIELDS } Unknown; static void generic_free(Unknown *uP) { free((char*)uP); } typedef struct { DECLARE_COMMON_GRAPHIC_FIELDS Point start, end; Attribute line_type; } Line; static void draw_line(Unknown *p) { /* сокращено для наглядности */ }

Page 46: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 46 -

/* exported */ Line *new_line(Point *from, Point *to, Attribute *ltype) { register Line *lP = (Line*)malloc(sizeof(Line)); if (lP) { lP->gType = gtLine; lP->next = NULL; lP->draw = draw_line; lP->delete = generic_free; lP->start = *from; lP->end = *to; lP->line_type = *ltype; } return lP; } typedef struct { DECLARE_COMMON_GRAPHIC_FIELDS Point left_top, right_bottom; Attribute line_type, fill_type; } Rectangle; static void draw_rect(Unknown *p) { /* сокращено для наглядности */ } /* exported */ Rectangle *new_rectangle(Point *from, Point *to, Attribute *ltype, Attribute *ftype) { register Rectangle *rP = (Rectangle*)malloc(sizeof(Rectangle)); if (rP) { rP->gType = gtRectangle; rP->next = NULL; rP->draw = draw_rect; rP->delete = generic_free; rP->left_top = *from; rP->right_bottom = *to; rP->line_type = *ltype; rP->fill_type = *ftype; } return rP; } typedef struct { DECLARE_COMMON_GRAPHIC_FIELDS Unknown *contents; } Group; static void draw_group(Unknown *grP) { register Unknown *p; for (p=((Group*)grP)->contents; p; p=p->next) (*(p->draw))(p); } static void free_chain(register Unknown *p) { register Unknown *x; while (p) { x = p->next; (*(p->delete))(p); p = x; } } static void delete_group(Unknown *grP) { free_chain(((Group*)grP)->contents); generic_free(grP); }

Page 47: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 47 -

/* exported */ Group *new_group(Unknown *contents) { register Group *grP = (Group*)malloc(sizeof(Group)); if (grP) { grP->gType = gtGroup; grP->next = NULL; grP->draw = draw_group; grP->delete = delete_group; grP->contents = contents; } return grP; } typedef struct { DECLARE_COMMON_GRAPHIC_FIELDS Attribute fill_type; Line *border; } Polygone; static void draw_poly(Unknown *p) { /* сокращено для наглядности */ } static void delete_poly(Unknown *polyP) { free_chain((Unknown*)((Polygone*)polyP)->border); generic_free(polyP); } /* exported */ Polygone *new_polygone(Attribute *fill, Line *lines) { register Polygone *p = (Polygone*)malloc(sizeof(Polygone)); if (p) { p->gType = gtPolygone; p->next = NULL; p->draw = draw_poly; rP->delete = delete_poly; rP->fill_type = *fill; rP->bordedr = lines; } return rP; }

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

Во-первых, компилятор C++ работает по-другому. Одно из отличий в том, что C++ не хранит ссылки на методы непосредственно в структуре класса, вместо этого класс включает указатель на массив ссылок на методы (такая двойная адресация позволяет пользоваться од-ной таблицей виртуальных методов для всех экземпляров класса, там самым экономя память и упрощая работу конструктора).

Во-вторых, примеру существенно не хватает универсальности, он рассчитан на работу только с динамически создаваемыми объектами, создание статических или автоматических объектов не предусмотрено, а если их все-таки создать, то обращение к методу delete скорее всего приведёт к аварийному завершению программы.

Основной недостаток этого подхода, опять-таки, не громоздкость, а снижение надёж-ности: каждое явное приведение типа означает, что компилятору запретили искать ошибку в этом месте, поля структуры не могут быть private или protected, так что компилятор не может проверить правильность их инкапсуляции и т.д.

В общем, этот пример ни в коем случае не означает, что надо делать именно так, и, например, уже упомянутый GTK+ использует другой подход (с другими недостатками).

Тем не менее, кое-что этот пример показать способен: за счёт чего достигается полиморфизм, чем создание объекта отличается от простого выделения памяти, и почему содержание свежесозданного экземпляра класса существенно отлично от нулей.

Page 48: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 48 -

Практическое занятие: программирование в “машинных кодах” Цель занятия: продемонстрировать проблему вычисления адресов переходов на

примере простейшей программы (в формате .COM). Средства: стандартная утилита debug (потребуются команды a, rcx, w). Для примера можно использовать любую простую функционально завершенную про-

грамму, содержащую по крайней мере один условный и один безусловный переход. Ниже для этой цели разбирается программа askyesno, но можно (и желательно!) придумать другие примеры.

Постановка задачи: в командном языке DOS отсутствует возможность “задать во-прос” пользователю. Чтобы снять это ограничение, можно написать программу, которая ждёт нажатия клавиш “Y” или “N”, допустимы таккже “y” или “n”, любые другие считаются не-правильными и пропускаются; результат сообщается в виде кода выхода процесса (код выхода в командном языке DOS можно проверить с помощью конструкции if errorlevel).

(В приложении приводится для самостоятельного изучения программа ask с более широкими функциональными возможностями.)

При создании программы утилитой debug в командах переходов придётся указывать числовые значения адреса, их-то и надо вычислить. Размеры команд для вычислений можно либо узнать в справочной литературе, либо выяснить экспериментальным путём с помощью той же утилиты debug.

Ниже приводятся три варианта решения, требуется выбрать один из них, разобраться в его работе и реализовать.

Вариант 1: _TEXT segment assume cs:_TEXT org 256 ask proc read: mov ah,8 int 21h cmp al,'Y' je yes cmp al,'y' je yes cmp al,'N' je no cmp al,'n' je no jmp read no: mov al,1 jmp exit yes: mov al,0 exit: mov ah,4Ch int 21h ask endp _TEXT ends end ask

Page 49: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 49 -

Вариант 2: _TEXT segment assume cs:_TEXT org 256 ask proc read: mov ah,8 int 21h cmp al,'Y' je yes cmp al,'y' je yes cmp al,'N' je no cmp al,'n' jne read no: mov ax,4C01h int 21h yes: mov ax,4C00h int 21h ask endp _TEXT ends end ask

Вариант 3: _TEXT segment assume cs:_TEXT org 256 ask proc read: mov ah,8 int 21h or al,'Z'xor'z' xor al,'y' jz exit cmp al,'n'xor'y' jne read mov al,1 exit: mov ah,4Ch int 21h ask endp _TEXT ends end ask

Вопросы для (само)проверки:

1) Что значит шестнадцатиричная константа 4С00h из вторго варианта? 2) Какую роль выполняет команда OR в третьем варианте? Как для этой же цели

воспользоваться командой AND? 3) Переделайте третий вариант так, чтобы не использовать операции XOR (можно заменить

вычитанием). 4) Третий вариант можно укоротить ещё хотя бы на один байт. Как?

Page 50: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 50 -

Требования к внешнему виду (форматированию) программ

Составлены на основании практического опыта, учебника Кернигана и Ричи, требований IJG, требований InfoZip Group, книги Java Programming: Elements of Style и других источников.

Данные требования ориентированы на разработку с использованием редакторов vi, Turbo C, MultiEdit и им подобных с гарантированно широким окном редактирования. При программировании, например, в среде MSVC, потребуется другой стиль форматирования.

1. Количество символов в строке не должно превышать 72 (для того, чтобы строка была полностью видна и изображалась как одна строка). Если вы АБСОЛЮТНО уверены, что программа всю оставшуюся ей жизнь будет редактироваться в одном конкретном редакторе, то допустимо увеличить длину строк до предела, определяемого этим редактором (например, 79 для Turbo C).

Желательно следить за тем, чтобы в конце строки отсутствовали символы пробела или табуляции, даже если общая длина строки укладывается в ограничение.

2. Размер табуляции должен считаться равным восьми (как это делают Norton Commander, редактор vi по умолчанию, терминальный драйвер, программы more/less, принтеры и т.д. и т.п.).

3. Для визуального выделения логической вложенности программных блоков необходимо использовать отступы с величиной, кратной 4; длинные текстовые строки в случае необходимости (суммарная длина строки с отступами превосходит ограничение) допустимо начинать в самой левой позиции. В случае необходимости допустимо использовать размер отступа, кратный 2 или 8, при этом выбранный размер должен быть одинаков для всех исходных текстов программы, из скольки бы файлов она ни состояла. Для формирования отступов допустимо использовать табуляции (размером 8), причем табуляции нельзя смешивать с пробелами, то есть сначала - слева - только табуляции, потом - при необходимости - только пробелы.

Если указанные правила использования табуляции вас не устраивают, то не используйте символ табуляции вообще и формируйте отступы с помощью исключительно пробелов — это хорошая практика.

4. Точка с запятой после закрывающей фигурной скобки допускается только в случае технической необходимости.

5. Каждый оператор пишется с новой строки. Исключения из этого правила допускаются в случае, если оператор (одиночный или составной) следует за операторами if, else, for или while, и вся конструкция умещается в одну строку (с учётом отступов и ограничения длины).

6. Одиночный оператор не следует заключать в фигурные скобки. Исключения допускаются при необходимости подчекрнуть сложную логическую структуру программы.

7. Открывающая фигурная скобка тела функции и/или блока, содержащего внутри декларативные операторы, занимает отдельную строку (за исключением операторов do, if, else и т.п. - см. ниже).

8. Открывающая фигурная скобка, следующая за операторами if, else, for, while и switch, пишется в одной строке с этими операторами.

9. Закрывающая фигурная скобка занимает отдельную строку (за исключением случая, когда весь составной оператор записан в одну строку - см. выше - или цикла do/while); для неё используется отступ, равный отступу оператора с соответствующей отктрывающей скобкой.

10. Фигурные скобки в операторе do/while пишутся в одной строке с соответствующими словами: do { } while ();

11. Метки case и default в операторе switch сдвигаются на половину выбранного отступа относительно оператора switch.

12. Не допускается передача управления оператором goto вверх по тексту программы.

13. Не допускается передача управления оператором goto внутрь блока (составного оператора), минуя его открывающую скобку.

Page 51: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 51 -

Упражнения на организацию памяти 1. Элементарная матричная алгебра переменной размерности. Матрицы должны быть определены как

двумерные массивы входного языка, напрмер для языка C: double A[3][3], B[3][3], C[3][3]; D[19][19], E[19][19], F[19][19];

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

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

Пример решения задачи такого типа приведён в приложении. Список возможных задач (в порядке возрастания сложности):

a) транспонирование прямоугольной матрицы (M×N); b) умножение квадратной матрицы размерности N на вектор слева; c) умножение прямоугольной матрицы (M×N) на вектор справа; d) умножение прямоугольной матрицы (M×N) на вектор слева; e) умножение прямоугольных матриц (M×N); f) для прямоугольной (M×N) матрицы вычислить произведение исходной матрицы на

транспонированную; g) для квадратных матриц A и B размерности N вычислить AB-BA; h) из квадратной матрицы размерности N получить матрицу размера N-1 путем удаления i-й строки и j-го

столбца (i, j - параметры); i) найти в матрице размерности N максимальный по модулю элемент и путем (только) перестановки строк

и столбцов поместить его в верхний левый угол; j) найти в матрице размерности N минимальный по модулю элемент и путем (только) перестановки строк и

столбцов поместить его в нижний правый угол; k) решение системы линейных уравнений размерности N методом Гаусса; l) решение системы линейных уравнений размерности N методом Гаусса с выбором главного элемента; m) обращение матрицы размерности N методом Гаусса; n) обращение матрицы размерности N методом Гаусса с выбором главного элемента; o) вычисление детерминанта квадратной матрицы размерности N приведением к треугольному виду; p) вычисление детерминанта квадратной матрицы размерности N в соответствии с определением

детерминанта (рекурсивно). 2. Реализовать динамическую структуру данных и тест для демонстрации её работоспособности (аналогично

матричным операциям). Структура может быть одной из списка: a) стек; b) односвязный список; c) односвязный упорядоченный список; d) двусвязный список; e) двусвязный упорядоченный список; f) очередь; g) очередь с приоритетами; h) многомерный массив, сконструированный как вектор векторов; i) хэш-таблица ограниченного максимального размера с разрешением коллизий; j) хэш-таблица ограниченного максимального размера без разрешения коллизий; k) хэш-таблица автоматически наращиваемого размера с разрешением коллизий; l) хэш-таблица автоматически наращиваемого размера без разрешения коллизий; m) автоматически растущая/убывающая хэш-таблица с разрешением коллизий; n) автоматически растущая/убывающая хэш-таблица без разрешения коллизий; o) двоичное дерево; p) сбалансированное двоичное дерево; q) троичное дерево; r) сбалансированное троичное дерево; s) n-арное дерево; t) сбалансированное n-арное дерево.

Page 52: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного
Page 53: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 53 -

Упражнения на стиль программирования Большинство задач заимствованы из книги Кернигана и Пайка, там же следует искать

ответы. Ответы к задачам 3, 7 и 8 приведены на следующей странице. Если вы ещё не прочли эту книгу и не понимаете, что может здесь значить слово

“улучшить”, то просто попытайтесь сделать код короче.

1) Улучшите функцию: int smaller(char *s, char *t) { if (strcmp(s, t) < 1) return 1; else return 0; }

2) Улучшите выражение: if (!(c == 'y' || c == 'Y')) return;

3) Улучшите выражение: length = length < BUFSIZE ? length : BUFSIZE;

4) Улучшите выражение: flag = flag ? 0 : 1;

5) Улучшите выражение: quote = *line == '"' ? 1 : 0;

6) Улучшите выражение: if (val & 1) bit = 1; else bit = 0;

7) Улучшите выражение: flag = (*p=='.' && *s=='.') || (*p!='.' && *s!='.');

8) Улучшите выражение: flag = (a < 0 && b >= 0) || (a >= 0 && b < 0);

9) Найдите ошибку в приведенном фрагменте: int read(int *ip) { scanf("%d", ip); return *ip; } . . . insert(&graph[vert], read(&val), read(&ch));

10) Перечислите все возможные варианты, которые может напечатать код: n = 1; printf("%d %d\n", n++, n++);

11) Улучшите следующий код: if (retval != SUCCESS) { return retval; } /* Все хорошо! */ return SUCCESS;

11) Найдите ошибку в следующем коде: int i; char s[256]; for (i=0; i<sizeof(s)-1; i++) if ((s[i] = getchar()) == '\n' || s[i] == EOF) break; s[i] = '\0';

Page 54: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 54 -

Ответы: Для задачи 3 лучшим решением как правило является if (length > BUFSIZE) length = BUFSIZE; Но случаи разные бывают. Если важна скорость, а компилятор умеет вычислять

минимум без команд перехода (подобно тому, как описано в разделе “примеры оптимизации сравнений”), то именно тогда следует предпочесть

length = min(length, BUFSIZE); Задачи 7 и 8 (как и несколько предыдущих) предназначены для формирования

мышления в понятиях логических предикатов. Решения могут быть например такими 7) flag = (*p==’.’) == (*s==’.’); 8) flag = (a<0) != (b<0); Но не надо искать “единcтвенно верное” решение, частенько его нет и быть не может!

Для задачи 7 существует другое столь же правильное решение flag = (*p!=’.’) == (*s!=’.’); А в задаче 8 в зависимости от особенностей компилятора можно предпочесть

flag = (a<0)^(b<0); — важно понимать, что логически это одно и то же.

Page 55: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 55 -

Приложение A: Таблица аналогий между Паскалем и C

(помните, что это именно аналогии, но не всегда точные соответствия). Pascal С Примечания (* комментарий *) /* комментарий */ определение константы 1) #define

2) enum 3) “переменная” const

1) перечислены в порядке предпочтения; 2) неупорядочены, т.е. нет раздела

описаний констант. Определение типа typedef неупорядочены, т.е. нет раздела описаний

типов структурный тип struct вариантная запись union константа выбора необязательна описание переменной описание переменной массивы (arrays) массивы (многомерные

переменные) индексы нумеруются начиная с 0

INTEGER int REAL double или float SINGLE float DOUBLE double EXTENDED long double BYTE unsigned char отсутствует в K&R WORD unsigned int SHORTINT signed char ANSI LONGINT long int BOOLEAN int логические выражения в C вырабатывают

тип int, но на этом аналогия заканчивается!FALSE 0 точный аналог TRUE 1 !FALSE всегда вычисляется как 1, но на

этом аналогия заканчивается! CHAR char STRING char* функция функция нет аналога слову function, описание или

определение фукции понимается по контексту.

Процедура функция типа void отсутствует в K&R предварительное (forward) описание функции

прототип или предварительное описание функции

прототипов не было до ANSI С

Операции ↑ (^ в Turbo Pascal) унарная * разная ассоциативность @ унарный & . (точка) . ↑. -> NEW malloc(), calloc() DISPOSE free() + + - - * бинарная * DIV / MOD % арифметический AND бинарный & логический AND && арифметический OR | логический OR || арифметический NOT ~ логический NOT ! XOR ^ SHL << SHR >> = == ≠ (<> в Turbo Pascal) != > > < < ≥ (>= в Turbo Pascal) >=

Page 56: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 56 -

Pascal С Примечания ≤ (<= в Turbo Pascal) <= := = Управляющие конструкции PROGRAM main() выполнение программы начинается с main,

на этом аналогия заканчивается! Метка метка нет предварительного описания GOTO goto BEGIN { END } IF THEN ELSE if () else CASE switch аналогия чисто функциональная,

семантика использования сильно отличается

WHILE DO while () FOR I:=J TO K for (i=j; k>=i; i++) FOR I:=J DOWNTO K for (i=j; i>=k; i--) REPEAT UNTIL

do { } while (! )

Ввод-вывод FILE (неструктурированный) FILE* ASSIGN fopen() CLOSE fclose() SEEK fseek() FLUSH fflush() IOResult ferr() FilePos ftell() Eof feof() BlockRead fread() BlockWrite fwrite() Read fgets(), scanf(), fscanf() аналогия чисто функциональная,

семантика использования сильно отличается

ReadLn scanf(), fscanf() аналогия чисто функциональная, семантика использования сильно отличается

Write fputs(), printf(), fprintf() аналогия чисто функциональная, семантика использования сильно отличается

WriteLn puts(), printf(), fprintf() аналогия чисто функциональная, семантика использования сильно отличается

Конструкции Паскаля, не имеющие удовлетворительных аналогов в C.

Примечания вложенные функции множества (set) как правило моделируются битовыми масками ограниченные целые типы WITH FILE OF

Возможности C, отсутствующие в Паскале. 1. Препроцессор. 2. Раздельная компиляция включена в стандарт. 3. Инструкции перехода (замаскированные goto): break, continue, return. 4. Инструкции присваивания внутри выражений. 5. Адресная арифметика. 6. Операции изменения ++, --, +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=. 7. Условная операция “?”. 8. Операция “,” (запятая).

Page 57: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 57 -

Приложение B: Команды процессоров семейства Intel 86

Ниже приведён алфавитный перечень команд ассемблера, и не более того; за особен-ностями работы конкретных команд следует обращаться к соответствующим учебникам.

Используются следующие обозначения: reg - регистр mem - адрес ячейки памяти r|m - регистр или адрес ячейки памяти val - непосредственное значение (целая константа).

Вторая колонка содержит номер версии процессора, начиная с которой работает данная возможность, команды “действительной” арифметики помечены звёздочкой.

Мнемокод ver Описание AAA подправить AL после текстового сложения AAD приготовить AL к текстовому делению AAM подправить AL после текстового умножения AAS приготовить AL к текстовому вычитанию ADC reg,r|m|val сложение с учётом переноса ADD reg,r|m|val сложение AND reg,r|m|val побитовое И BOUND 1 проверка интервала BSF 3 Bit Scan Forward BSR 3 Bit Scan Reverse BSWAP reg32 4 инвертировать порядок 4 байт в двойном слове BT r|m,reg|val 3 проверить битовую маску BTC r|m,reg|val 3 проверить и инвертировать битовую маску BTR r|m,reg|val 3 проверить и очистить битовую маску BTS r|m,reg|val 3 проверить и установить битовую маску CALL вызов подпрограммы CBW знаковое расширение байта (AH заполняется знаком AL) CLC очистить флаг перноса CLD очистить флаг направления (возрастание адресов) CLI запретить аппаратные прерывания CLTS|CTS 2 clear task switch CMC инвертировать флаг переноса CMP reg,r|m|val сравнение (неразрушающее вычитание) CMPSB строковое сравнение байт в памяти CMPSW строковое сравнение слов в памяти CMPXCHG 4 сравнить и обменять местами CWD знаковое расширение слова (DX заполняется знаком AX) DAA подправить AL после двоично-десятичного сложения DAS приготовить AL к двоично-десятичному вычитанию DEC r|m вычесть 1 DIV r|m беззнаковое деление (расширенного) сумматора на заданное значение ENTER 1 образование стекового кадра процедуры F2XM1 * возвести 2 в степень и вычесть 1 FABS * абсолютное значение FADD * прибавить к вершине стека следующее значение FADD mem * прибавить к вершине стека действительное число из памяти FADDP * прибавить к вершине стека значение из стека и вытолкнуть стек FBLD mem * загрузить двоично-десятичное значение (преобразовать в действительное) FBST mem * записать вершину стека в двоично-десятичном виде, стек не менять FBSTP mem * вытолкнуть в память вершину стека в двоично-десятичном виде FCHS * изменить знак FCLEX * clear exceptions FCOM * сравнить вершину стека со следующим числом FCOM mem * сравнить вершину стека со значением в памяти FCOMP r|m * сравнить вершину стека со значением и вытолкнуть вершину стека FCOMPP * сравнить вершину стека со следующим числом, вытолкнуть обоих FCOS * косинус FDECSTP * цикличекий сдвиг содержимого стека по убыванию

Page 58: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 58 -

Мнемокод ver Описание FDIV * поделить вершину стека на элемент стека FDIVP * поделить вершину стека на элемент стека и вытолкнуть стек FDIVR * поделить элемент стека на вершину стека FDIVRP * поделить элемент стека на вершину стека и вытолкнуть стек FFREE * пометить элемент стека как неиспользованный FIADD mem * сложить вершину стека с целым числом из памяти FICOM mem * сравнить вершину стека с целым числом в памяти FICOMP mem * сравнить вершину стека с целым числом и вытолкнуть вершину стека FIDIV mem * поделить вершину стека на целое число из памяти FIDIVR mem * поделить целое число на вершину стека (результат поместить на вершину)FILD mem * загрузить целое значение (преобразовать в действительное) FIMUL mem * умножить вершину стека на целое число из памяти FINCSTP * цикличекий сдвиг содержимого стека по возрастанию FINIT * initialize FIST mem * записать в память вершину стека как целое число, стек не менять FISTP mem * вытолкнуть в память вершину стека как целое число FISUB * вычесть из вершины стека целое число в памяти FISUBR * вычесть из целого числа вершину стека (результат поместить на вершину) FLD mem * загрузить действительное значение FLD1 * загрузить (поместить на вершину стека) единицу FLDENV * load FPU environment from memory (as after FSTENV) FLDL2E * загрузить (поместить на вершину стека) log2e FLDL2T * загрузить (поместить на вершину стека) log210 FLDLG2 * загрузить (поместить на вершину стека) log102 FLDLN2 * загрузить (поместить на вершину стека) ln 2 FLDPI * загрузить (поместить на вершину стека) число “пи” FLDCW mem * загрузить из памяти управляющее слово FLDZ * загрузить (поместить на вершину стека) ноль FMUL * перемножить вершину стека и элемент стека FMULP * перемножить вершину стека и элемент стека и вытолкнуть стек FNOP * пустышка: ничего не делать FPATAN * арктангенс FPREM * остаток от деления вершины стека на следующее значение FPREM1 4* остаток от деления в соответствии со стандартом IEEE FPTAN * тангенс FRNDINT * округлить до ближайшего целого FRSTOR mem * восстановить состояние процессора (после FSAVE) FSAVE mem * записать состояние процессора (94 байта) в память FSCALE * возвести 2 в степень FSIN * синус FSINCOS * синус и косинус FSQRT * квадратный корень FST mem * записать в память вершину стека, стек не менять FSTENV mem * store FPU environment to memory FSTP mem * вытолкнуть вершину стека в память FSTCW mem * записать в память управляющее слово (сопроцессора) FSTSW AX|mem * записать слово состояния (сопроцессора) в AX или память FSUB * вычесть из вершины стека следующее значение FSUB mem * вычесть из вершины стека действительное число из памяти FSUBP * вычесть из вершины стека элемент стека и вытолкнуть стек FSUBR * вычесть из элемента стека вершину стека (результат поместить на

вершину) FSUBRP * вычесть из элемента стека вершину стека и вытолькнуть стек FTST * сравнить вершину стека с нулём FUCOM 4* сравнить вершину стека с заданным элементом стека FUCOMP 4* сравнить вершину стека с заданным элементом стека и вытолкнуть стек FUCOMPP 4* сравнить вершину стека с заданным элементом и вытолкнуть стек дважды FWAIT|WAIT синхронизация с устройством действительной арифметики FXAM * выполнить проверки над вершиной стека FXTRACT * выделить мантиссу и показатель FYL2X * умножить вершину стека на двоичный логарифм следующего значения FYL2XP1 * Y·log2(X+1) HLT останов процессора

Page 59: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 59 -

Мнемокод ver Описание IDIV r|m знаковое деление (расширенного) сумматора на заданное значение IMUL r|m знаковое умножение (расширенного) сумматора на заданное значение IMUL reg,r|m,val 2 знаковое умножение регистра на константу IN AL|AX,val|DX считать байт|слово из порта ввода-вывода INC r|m прибавить 1 INSB 1 строковое чтение последовательности байт из порта ввода-вывода INSW 1 строковое чтение последовательности слов из порта ввода-вывода INT val программное прерывание INTO val прерывание по переполнению IRET возврат из прерывания JA|JNBE переход если больше — беззнаковое сравнение JAE|JNB переход если больше или равно — беззнаковое сравнение JB|JNAE переход если меньше — беззнаковое сравнение JBE|JNA переход если меньше или равно — беззнаковое сравнение JCXZ переход при нулевом CX JE|JZ переход по совпадению (равенству, нулю) JG|JNLE переход если больше JGE|JNL переход если больше или равно JL|JNGE переход если меньше JLE|JNG переход если меньше или равно JMP безусловный переход JNE|JNZ переход по несовпадению (неравенству, не нулю) JNP|JPO переход на нечет JNS переход по отсутствию знакового бита (положительного результата) JP|JPE переход на чёт JS переход по присутствию знакового бита (отрицательного результата) LAHF упаковать регистр флагов в AH LEA reg,adr вычислить эффективный адрес LEAVE 1 удаление стекового кадра процедуры LDS reg,mem загрузить из памяти адресную пару DS:reg LES reg,mem загрузить из памяти адресную пару ES:reg LFS reg,mem 3 загрузить из памяти адресную пару FS:reg LGS reg,mem 3 загрузить из памяти адресную пару GS:reg LSS reg,mem 3 загрузить из памяти адресную пару SS:reg LOCK префикс блокировки шины LODSB (строковая операция) достать AL из памяти LODSW (строковая операция) достать AX из памяти LOOP цикл (только по счётчику) LOOPE|LOOPZ цикл по условию совпадения LOOPNE|LOOPNZ цикл по условию несовпадения MOV reg,r|m|val поместить значение в регистр MOV mem,reg поместить значение в память MOVSB строковая перепись байт из памяти в память MOVSW строковая перепись слов из памяти в память MOVSX 3 передача данных с расширением знаковым битом MOVZX 3 передача данных с расширением нулями MUL r|m беззнаковое умножение (расширенного) сумматора на заданное значение NEG reg арифметическая инверсия NOP пустышка: ничего не делать NOT reg побитовая инверсия OR reg,r|m|val побитовое ИЛИ OUT val|DX,AL|AX передать байт|слово в порт ввода-вывода OUTSB 1 строковая запись последовательности байт в порт ввода-вывода OUTSW 1 строковая запись последовательности слов в порт ввода-вывода POP reg извлечь из стека слово данных POPA 1 извлечь из стека AX, BX, CX, DX, SI, DI, BP, SP POPF слово из стека поместить в регистр флагов PUSH r|m поместить слово данных в стек PUSH val 2 поместить в стек значение PUSHA 1 поместить в стек AX, BX, CX, DX, SI, DI, BP, SP PUSHF поместить в стек регистр флагов RAR reg,1|CL циклический арифметический сдвиг вправо

Page 60: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 60 -

Мнемокод ver Описание RCL reg,1 циклический сдвиг влево через регистр переноса RCR reg,1 циклический сдвиг вправо через регистр переноса REP префикс строковой операции: повторять CX раз REPNE|REPNZ префикс строковой операции: повторять c проверкой на неравенство REPE|REPZ префикс строковой операции: повторять c проверкой на равенство RET|RETF|RETN возврат из функции (C style) RET val возврат из функции (Pascal) ROL reg,1|CL циклический сдвиг (вращение) влево ROL reg,1|CL циклический сдвиг (вращение) вправо TEST reg,r|m|val побитовая проверка — неразрушающее И SAHF распаковать AH в регистр флагов SBB reg,r|m|val вычитание с учётом заёма (переноса) SETcond r|m 3 установить байт в true (1) либо false (0) в соответствии с условием cond;

условие кодируется так же, как в командах условного перехода Jcond. SAR reg,1|CL арифметический сдвиг вправо SHL reg,1|CL сдвиг влево SHL reg,val 1 сдвиг влево SHLD r|m,reg,val|CL 3 двойной сдвиг влево SHRD r|m,reg,val|CL 3 двойной сдвиг вправо STC установить флаг переноса (в yes) STD установить флаг направления (по убыванию адресов) STI разрешить аппаратные прерывания SCASB строковый поиск в памяти: сравнение с AL SCASW строковый поиск в памяти: сравнение с AX STOSB (строковая операция) записать AL в память STOSW (строковая операция) записать AX в память SUB reg,r|m|val вычитание XADD r|m,reg 4 обменять местами и сложить XCHG reg,r|m поменять местами значения XLAT табличная трансляция AL ← ES:BX[AL] XOR reg,r|m|val нетождественность (побитовое сравнение)

Page 61: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 61 -

Приложение C: Примеры процедур рисования точек

Пример функции рисования точки в 16-цветном режиме EGA/VGA с горизонтальным разрешением 640 через интерфейс видеоконтроллера: _TEXT segment byte public 'CODE' assume cs:_TEXT _EGA_putpixel proc near ; void near EGA_putpixel(short x, short y, int color) push bp mov bp,sp mov ax,0A000h mov es,ax mov ax,80 ; Fetch bytes per raster mul word ptr ss:[bp+6]; Compute offset past y rasters mov bx,ss:[bp+4];x mov cl,bl ; Save for future shr bx,1 ; Get offset within raster as x/8 shr bx,1 shr bx,1 add bx,ax ; Add offsets together and cl,7 ; Compute which bit in a byte mov ch,80h shr ch,cl ; and use it to prepare mask mov ah,ss:[bp+8];color mov dx,3Ceh ; Address of Graphics controller mov al,5 ; Select MODE register out dx,al inc dx mov al,2 ; Select mode to be PACKED WRITE out dx,al dec dx mov al,8 ; Select BIT MASK register out dx,al inc dx mov al,ch ; and set MASK register to it out dx,al ; to preserve unused 7 bits dec dx mov al,es:[bx] ; Read to latch register mov es:[bx],ah ; Set pixel color to new value mov al,5 ; Select MODE register out dx,al inc dx mov al,0 ; Select default write mode out dx,al dec dx mov al,8 ; Select BIT MASK register out dx,al inc dx mov al,255 ; Enable all 8 bits for write out dx,al mov sp,bp pop bp ret _EGA_putpixel endp _TEXT ends public _EGA_putpixel end

Page 62: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 62 -

Пример универсальной функции рисования точки с использованием BIOS: _TEXT segment byte public 'CODE' assume cs:_TEXT _putpixel proc near ; void near putpixel(short x, short y, int color) push bp mov bp,sp mov al,ss:[bp+8] ;color mov cx,ss:[bp+4] ;x mov dx,ss:[bp+6] ;y xor bx,bx ;BX := 0 mov ah,12 int 10h mov sp,bp pop bp ret _putpixel endp _TEXT ends public _putpixel end

Page 63: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 63 -

Приложение D: Утилита ask

В командном языке DOS отсутствует возможность “задать вопрос” пользователю. Для обхода этого ограничения написано много разных программ, одно из лучших решений пред-ложено в наборе утилит П.Нортона. Ниже приводится программа “по мотивам” нортоновской.

Предлагаемая программа ask получает в командной строке два обязательных парамет-ра: текст вопроса и строка возможных вариантов ответа. Программа печатает текст вопроса в качестве приглашения пользователю, ждёт нажатия одной из требуемых клавиш и возвращает её порядковый номер (позицию во второй строке, считая от нуля) как код возврата в опера-ционную систему (его можно использовать в командном языке DOS с помощью конструкции if errorlrvel). Дополнительно программе можно задать код ответа “по умолчанию”, то есть по нажатию клавиши Enter, и величину таймаута, после которого ответ пользователя можно больше не ждать.

Программы такого типа часто запускаются с дискеты или CD ROM (то есть медленных устройств), поэтому “быстродействие” такой программы напрямую зависит от её размера: чем меньше, тем лучше. Соответственно, в предлагаемой программе были приложены усилия, чтобы уменьшить размер выполняемого файла, везде, где можно, стандартные “детали” языка C заменены на узкоспециализированные функции только для данного случая.

Первый из приведённых вариантов включает оптимизацию, подходящую для любого DOSовского компилятора; использование знаний о внутреннем устройстве Turbo С во втором варианте позволило уменьшить программу ещё вдвое! (Сравните с размером программы, написанном только средствами C).

Вариант 1.

#include <dos.h> #include <time.h> #include <stdlib.h> #include <string.h> #ifndef FALSE # define FALSE 0 # define TRUE 1 #endif #define NONE (-1) #define LC(x) ((x)|('z'^'Z')) /* convert ASCII char to lowercase */ #ifdef NO_ASM # define dosDisplay(p) bdosptr(9, (p), 0) #else static void dosDisplay(char far *s) { asm push ds asm mov ah,9 asm lds dx,s asm int 21h asm pop ds } #endif static void dosDisplayChar(int c) { _DL = c; _AH = 2; geninterrupt(0x21); } static void dosDisplayString(register char *s) { while (*s) dosDisplayChar(*s++); } static int checkCharacterReady(void) { _AH = 11; geninterrupt(0x21); return _AL; }

Page 64: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 64 -

static int readChar(void) { _AH = 8; geninterrupt(0x21); return _AL; } static void biosbeep(void) { _BX = 0; _AX = 0x0E07; geninterrupt(0x10); } #ifdef NO_ASM # define timer() clock() #else static clock_t timer(void) { asm mov ah,0 asm int 1Ah asm mov ax,dx asm mov dx,cx } #endif static int matchlen(register char *str, register char *pattern) { register int i = 0; while (pattern[i] && LC(str[i]) == pattern[i]) ++i; return i; } /* disable environment to reduce the code size */ void _setenvp() {} int main(int argc, char *argv[]) { static char newline[] = "\r\n$"; register int k, n; register char *s; char *p; int beep = FALSE, clear = FALSE; long dflt = (long)NONE; /* no default */ unsigned long timeout = 0L; /* infinite */ clock_t start=0L; int result = NONE; if (argc < 3) { dosDisplay( "Usage: ask <prompt-string> <answers> [options]\r\n" "Options are:\r\n" "\t-beep sound a beep\r\n" "\t-clear drain the input before processing\r\n" "\t-default=N the default answer number on timeout or enter\r\n" "\t-timeout=N timeout in seconds\r\n$"); goto end; } for (n=3; n<argc;) { /* check the options */ s = argv[n++]; if ('-' == *s || '/' == *s) ++s; if ((k = matchlen(s, "beep")) > 0 && !s[k]) { beep = TRUE; continue; } if ((k = matchlen(s, "clear")) > 0 && !s[k]) { clear = TRUE; continue; } if ((k = matchlen(s, "default")) > 0) { if (!s[k]) s = argv[n++]; else if ('=' == *(s += k)) ++s; p = s; dflt = strtol(s, &p, 10); if (p && p > s && !*p && !(~255L & dflt)) continue; /* Ok */ } if ((k = matchlen(s, "timeout")) > 0) {

Page 65: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 65 -

if (!s[k]) s = argv[n++]; else if ('=' == *(s += k)) ++s; p = s; timeout = strtol(s, &p, 10); if (p && p > s && !*p && timeout > 0 && !(timeout & ~0xffffL)) continue; /* Ok */ } /* give up */ dosDisplay("\007\007\007Illegal option: $"); dosDisplayString(s); dosDisplay(newline); goto end; } if (NONE == (result = (int)dflt)) { timeout = 0L; } else if (timeout) { /* convert seconds to the system timer ticks */ /* approximate 1193180/65536 as 19663/1080 */ timeout = (timeout * 19663 + 1079) / 1080; } if (clear) while (checkCharacterReady()) readChar(); if (beep) biosbeep(); dosDisplayString(argv[1]); /* prompter */ p = argv[2]; /* answers */ if (timeout) start = timer(); do { k = checkCharacterReady() || !timeout ? readChar() : 0; if (k && NULL != (s = strchr(p, k))) { result = s - p; break; } if (NONE != result && ('\n'== k || '\r'== k)) /*Enter*/ break; } while (!timeout || (unsigned long)(timer() - start) < timeout); if (/*echo &&*/ (unsigned)result < strlen(p)) { dosDisplayChar(p[result]); } dosDisplay(newline); end: return result; }

Пример командного файла для сборки: set SRC=%1 if not "%SRC%" == "" goto BUILD SRC=ask.c :BUILD tcc -mt -B -G- -N- -O -Z -d -f- -f87- -k- -r -w -M -eask %SRC% if errorlevel 1 goto END if exist ask.com del ask.com exe2bin ask.exe ask.com :END

Вариант 2.

#include <dos.h> #include <time.h> #include <stdlib.h> #include <string.h> #ifndef FALSE # define FALSE 0 # define TRUE 1 #endif #define NONE (-1) #define LC(x) ((x)|('z'^'Z')) /* convert ASCII char to lowercase */ static void dosDisplay(char far *s) {

Page 66: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 66 -

asm push ds asm mov ah,9 asm lds dx,s asm int 21h asm pop ds } static void dosDisplayChar(int c) { _DL = c; _AH = 2; geninterrupt(0x21); } static void dosDisplayString(register char *s) { while (*s) dosDisplayChar(*s++); } static int checkCharacterReady(void) { _AH = 11; geninterrupt(0x21); return _AL; } static int readChar(void) { _AH = 8; geninterrupt(0x21); return _AL; } static void biosbeep(void) { _BX = 0; _AX = 0x0E07; geninterrupt(0x10); } static clock_t timer(void) { asm mov ah,0 asm int 1Ah asm mov ax,dx asm mov dx,cx } static int matchlen(register char *str, register char *pattern) { register int i = 0; while (pattern[i] && LC(str[i]) == pattern[i]) ++i; return i; } #define whitespace(x) (' ' == (x) || '\t'== (x)) #define is_newline(x) ('\r'== (x) || '\n'== (x)) static unsigned argLength; static char *getArgString(char **args, char *end) { register char *s = *args; register char *p; while (s < end && whitespace(*s)) ++s; if (s>=end || is_newline(*s)) goto fail; if ('\"' == *s || '\'' == *s) { /* quoted string */ for (p=s; ++s<end && *s != *p;); ++p; } else { /* regular parameter */ for (p=s; s<end && !(whitespace(*s) || is_newline(*s));) ++s; } *s = '\0'; argLength = s - *args; *args = s + 1;

Page 67: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 67 -

return p; fail: *args = end; return NULL; } static unsigned long mul16to32(unsigned a, unsigned b) { asm mov ax,a asm mul word ptr b } static int getNumber(register char *s, unsigned *result) { register int n; register unsigned long u = 0; for (n=0; s[n]>='0' && s[n]<='9'; ++n) { if (~0xffffL & (u = mul16to32((unsigned)u, 10) + (s[n]-'0'))) break; } *result = (unsigned)u; return n && !s[n]; } /* convert seconds to the system timer ticks */ static unsigned long seconds2ticks(unsigned s) { /* 1193180 = (18L << 16) + 13532 */ asm mov ax,13532 asm mul word ptr s asm rcl ax,1 asm adc dx,0 asm mov cx,dx asm mov ax,18 asm mul word ptr s asm add ax,cx asm adc dx,0 } void cdecl _setargv() {} void cdecl _setenvp() {} void cdecl exit(int status) { _exit(status); } unsigned cdecl _stklen = 0x200; unsigned cdecl _heaplen = 0; int cdecl main() { static char newline[] = "\r\n$"; register int k, c; unsigned alen; char beep = FALSE, clear = FALSE; unsigned dflt = NONE; /* no default */ unsigned tout = 0; /* infinite */ unsigned long timeout, start; register int result = NONE; char *prompt, *answers = NULL; # ifdef __TINY__ /* the following works in tiny model only! */ char *argend, *args = (char*)128; /* inside PSP */ # endif register char *s; if ((k = *(unsigned char*)args) >= 127) k = 126; argend = ++args + k; /* get the mandatory args */ if (NULL != (prompt = getArgString(&args, argend))) { answers = getArgString(&args, argend); } if (!answers) { dosDisplay(

Page 68: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 68 -

"Usage: ask <prompt-string> <answers> [options]\r\n" "Options are:\r\n" "\t-beep sound a beep\r\n" "\t-clear drain the input before processing\r\n" "\t-default=N the default answer number on timeout or enter\r\n" "\t-timeout=N timeout in seconds\r\n$"); goto end; } alen = argLength; /* remember the length of the answers string */ while (NULL != (s = getArgString(&args, argend))) { if ('-' == *s || '/' == *s) ++s; if ((k = matchlen(s, "beep")) > 0 && !s[k]) { beep = TRUE; continue; } if ((k = matchlen(s, "clear")) > 0 && !s[k]) { clear = TRUE; continue; } if ((k = matchlen(s, "default")) > 0) { if (!s[k]) { s = getArgString(&args, argend); k = 0; } else if ('=' == s[k]) ++k; if (s && getNumber(s+k, &dflt)) { if (!(~255L & dflt)) continue; /* Ok */ } } if ((k = matchlen(s, "timeout")) > 0) { if (!s[k]) { s = getArgString(&args, argend); k = 0; } else if ('=' == s[k]) ++k; if (s && getNumber(s+k, &tout)) { if (tout) continue; /* Ok */ } } /* give up */ dosDisplay("\007\007\007Illegal option: $"); dosDisplayString(s); dosDisplay(newline); goto end; } timeout = NONE == (result = (int)dflt) ? 0L : seconds2ticks((unsigned)tout); if (clear) while (checkCharacterReady()) readChar(); if (beep) biosbeep(); dosDisplayString(prompt); start = timer(); do { c = checkCharacterReady() || !tout ? readChar() : 0; for (k=0; /*answers[k]*/ k<alen; k++) { if (answers[k] == c) { result = k; goto ok; } } if (NONE != result && ('\n'== c || '\r'== c)) /*Enter*/ break; } while (!tout || (unsigned long)(timer() - start) < timeout); ok: if ((unsigned)result < alen) dosDisplayChar(answers[result]); dosDisplay(newline); end: return result; }

Командный файл для сборки: tasm C0,C0T /t /mx /D__TINY__ /D__NOFLOAT__ tcc -mt -B -G- -N- -O -Z -d -f- -f87- -k- -p -r -w -c ask2.c tlink C0T.obj ask2.obj, ask, /d /c /m /t

Page 69: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 69 -

Приложение E: Пример задачи: умножение матриц

Язык C изначально не предназначался для задач математической физики и матричной алгебры в частности. Вследствие этого, для программирования матричной алгебры в C обыч-но применяются сравнительно сложные приёмы, основанные на динамическом распределении памяти и т.п. Пурист от программирования мог бы сказать, что все эти подходы неверны, так как матрица — это двумерный массив и только с двумерными массивами входного языка программирования и надо работать. Проблема в том, что на C довольно легко написать программу умножения матриц 3×3, 17×17 или любого другого наперёд заданного размера, но вот написать кусочек кода, который в одиночку смог бы обрабатывать двумерные массивы разных размерностей, уже довольно непросто (потому-то и изобретаются переусложнённые обходные пути). Для сравнения можно заметить, что на Алголе, Фортране или любом другом соответствующем языке такая задача решается элементарно, а вот в ISO стандарт Паскаля были внесены изменения специально для задач такого типа, потому что в исходном - Виртовском - Паскале задача не решается вообще.

Ниже приводится пример программы умножения квадратных матриц на диалекте GNU C, стандарт языка такого - увы - не позволяет: void matmul(int n, double A[n][n], double B[n][n], double C[n][n]) { /* A*B => C */ register int i, j, k; register double s; for (i=0; i<n; i++) for (j=0; j<n; j++) { for (s=0.0, k=0; k<n; k++) s += A[i][k]*B[k][j]; C[i][j] = s; } }

Пример 1: заготовка Данный пример демонстрирует принцип перемножения матриц на C, и содержит про-

цедуру перемножения матриц наперёд заданной размерности и так называемый “тестовый драйвер”, то есть программу для проверки работоспособности разрабатываемой процедуры — всё в одном файле. Хотя данный пример и не является решением сформулированной выше задачи, это хорошая стартовая точка для её решения. #include <stdio.h> #ifndef N # define N 3 #endif void matmul(double A[N][N], double B[N][N], double C[N][N]) /* A*B => C */ { register int i, j, k; register double s; for (i=0; i<N; i++) { for (j=0; j<N; j++) { for (s=0.0, k=0; k<N; k++) s += A[i][k]*B[k][j]; C[i][j] = s; } } } static void mdump(double m[N][N]) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) printf(" %.3f", m[i][j]); printf("\n"); } }

Page 70: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 70 -

static void mget(double m[N][N], char *name, int prompt) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) { if (prompt) printf("Enter %s[%d,%d]: ",name,i,j); scanf("%lf", &(m[i][j])); } } } main() { double a[N][N], b[N][N], c[N][N]; int /*bool*/ prompt = isatty(0); if (prompt) printf("Test %dx%d matrix multiplication\n",N,N); if (prompt) printf("Enter the 1st matrix:\n"); mget(a, "A", prompt); if (prompt) printf("Enter the 2nd matrix:\n"); mget(b, "B", prompt); if (prompt) { printf("1st matrix:\n"); mdump(a); printf("2nd matrix:\n"); mdump(b); } matmul(a, b, c); if (prompt) printf("Result:\n"); mdump(c); }

Пример 2: простое решение Решение задачи для переменной размерности основано на том, что сколь бы сложная

структура ни была описана в языке высокого уровня, память компьютера организована линей-но, так что элементы структуры данных последовательно расположены в памяти. На этот раз процедура умножения выделена в отдельный файл, так что для неё требуется дополнительный файл описаний (.h).

Вот как теперь выглядит тестовый драйвер (файл mtest.c): #include <stdio.h> #include "matmul.h" #ifndef N # define N 3 #endif void mdump(double m[N][N]) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) printf(" %.3f", m[i][j]); printf("\n"); } } void mget(double m[N][N], char *name, int prompt) { register int i, j; for (i=0; i<N; i++) { for (j=0; j<N; j++) { if (prompt) printf("Enter %s[%d,%d]: ",name,i,j); scanf("%lf", &(m[i][j])); } } }

Page 71: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 71 -

main() { double a[N][N], b[N][N], c[N][N]; int /*bool*/ prompt = isatty(0); if (prompt) printf("Test %dx%d matrix multiplication\n",N,N); if (prompt) printf("Enter the 1st matrix:\n"); mget(a, "A", prompt); if (prompt) printf("Enter the 2nd matrix:\n"); mget(b, "B", prompt); if (prompt) { printf("1st matrix:\n"); mdump(a); printf("2nd matrix:\n"); mdump(b); } MATMUL(N, a, b, c); if (prompt) printf("Result:\n"); mdump(c); }

Вот используемые определения (файл matmul.h): void matmul(int n, double *Aptr, double *Bptr, double *Cptr); #define MATMUL(n,a,b,c) matmul((n), &(a)[0][0], &(b)[0][0], &(c)[0][0]) #define nMATMUL(a,b,c) MATMUL(sizeof(c)/sizeof((c)[0]),(a),(b),(c))

И вот наконец-то сама процедура (файл matmul.c): #include "matmul.h" void matmul(int n, double *Aptr, double *Bptr, double *Cptr) /* A*B => C */ { register int i, j, k; register double s; #define A(r,c) Aptr[n*(r) + (c)] #define B(r,c) Bptr[n*(r) + (c)] #define C(r,c) Cptr[n*(r) + (c)] for (i=0; i<n; i++) { for (j=0; j<n; j++) { for (s=0.0, k=0; k<n; k++) s += A(i,k)*B(k,j); C(i,j) = s; } } #undef C #undef B #undef A }

Пример 3: двойной Если из приведённого примера непонятно, зачем такие сложности, то вот пример с

двумя наборами матриц разного размера. Если вы придумаете более простую единую процедуру умножения, пожалуйста, дайте мне знать! ☺

Page 72: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 72 -

#include <stdio.h> #include "matmul.h" #ifndef N1 # define N1 3 #endif #ifndef N2 # define N2 5 #endif void mdump1(double m[N1][N1]) { register int i, j; for (i=0; i<N1; i++) { for (j=0; j<N1; j++) printf(" %.3f", m[i][j]); printf("\n"); } } void mdump2(double m[N2][N2]) { register int i, j; for (i=0; i<N2; i++) { for (j=0; j<N2; j++) printf(" %.3f", m[i][j]); printf("\n"); } } void mget1(double m[N1][N1], char *name, int prompt) { register int i, j; for (i=0; i<N1; i++) { for (j=0; j<N1; j++) { if (prompt) printf("Enter %s[%d,%d]: ",name,i,j); scanf("%lf", &(m[i][j])); } } } void mget2(double m[N2][N2], char *name, int prompt) { register int i, j; for (i=0; i<N2; i++) { for (j=0; j<N2; j++) { if (prompt) printf("Enter %s[%d,%d]: ",name,i,j); scanf("%lf", &(m[i][j])); } } } main() { double a[N1][N1], b[N1][N1], c[N1][N1]; double d[N2][N2], e[N2][N2], f[N2][N2]; int /*bool*/ prompt = isatty(0); if (prompt) printf("Test %dx%d matrix multiplication\n",N1,N1); if (prompt) printf("Enter the 1st matrix:\n"); mget1(a, "A", prompt); if (prompt) printf("Enter the 2nd matrix:\n"); mget1(b, "B", prompt); if (prompt) { printf("1st matrix:\n"); mdump1(a); printf("2nd matrix:\n"); mdump1(b); }

Page 73: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 73 -

MATMUL(N1, a, b, c); if (prompt) printf("Result:\n"); mdump1(c); if (prompt) printf("Test %dx%d matrix multiplication\n",N2,N2); if (prompt) printf("Enter the 1st matrix:\n"); mget2(d, "D", prompt); if (prompt) printf("Enter the 2nd matrix:\n"); mget2(e, "E", prompt); if (prompt) { printf("1st matrix:\n"); mdump2(d); printf("2nd matrix:\n"); mdump2(e); } MATMUL(N2, d, e, f); if (prompt) printf("Result:\n"); mdump2(f); }

Пример 4: оптимизированный Вышеприведённая процедура написана так, чтобы быть максимально понятной, при

этом она не слишком-то эффективна. Например, для доступа к каждому элементу матрицы используется операция умножения, работающая сравнительно долго. Можно вместо неё использовать последовательные сложения, если заметить, что элементы матрицы “извлекаются” из памяти с постоянным шагом. Разберитесь с тем, как работает этот пример, прежде чем переходить к следующему. #include "matmul.h" void matmul(int n, double *Aptr, double *Bptr, double *Cptr) /* A*B => C */ { register int i, j, k; register double s; register double *Ai, *Bkj; for (Ai=Aptr, i=0; i<n; Ai+=n, i++) { for (j=0; j<n; j++) { Bkj = Bptr + j; for (s=0.0, k=0; k<n; Bkj += n, k++) s += Ai[k] * *Bkj; /* C(i,j) = s; */ *Cptr++ = s; } } }

Пример 5: язык ассемблера Вышеприведённый пример всё ещё далёк от идеала: из-за наличия промежуточной

переменной s промежуточные результаты вычислений в самом внутреннем цикле постоянно передаются между устройством вычисления с плавающей точкой и памятью (что при совре-менной архитектуре IBM PC сильно тормозит дело). В языке C не предусмотрен явный способ этого избежать, но при программировании на ассемблере можно промежуточные результаты хранить в стеке “числогрыза”. _TEXT segment byte public 'CODE' assume cs:_TEXT _matmul proc far ; void far matmul(int N, double far *A, double far *B, double far *C) ; assert N > 0 push bp mov bp,sp push ds push si push di N = word ptr ss:[bp+6] A = dword ptr ss:[bp+8]

Page 74: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 74 -

B = dword ptr ss:[bp+12] C = dword ptr ss:[bp+16] mov cl,3 mov ax,N shl ax,cl ; N *= sizeof(double) mov si,N loop_i: xor dx,dx ; j := 0 loop_j: fldz ; s := 0.0 mov cl,3 mov bx,dx shl bx,cl ; j * sizeof(double) les di,B add di,bx ; j-th column lds bx,A ; i-th row mov cx,N loop_k: fld qword ptr ds:[bx] add bx,8 ; Ai += sizeof(double) fmul qword ptr es:[di] add di,ax ; Bjk += N*sizeof(double) faddp loop loop_k lds bx,C fstp qword ptr ds:[bx] add bx,8 ; C += sizeof(double) mov word ptr C[2],bx inc dx cmp dx,N jb loop_j mov bx,word ptr A[2] add bx,ax ; A += N*sizeof(double) mov word ptr A[2],bx sub si,1 jnz loop_i fwait pop di pop si pop ds mov sp,bp pop bp ret _matmul endp _TEXT ends public _matmul end

Упражнения к примеру: 1. Сравните этот код с результатом работы вашего любимого компилятора. 2. Как можно улучшить процедуру, если использовать возможности i386 и более новых

версий процессора? 3. Предложите другие способы оптимизировать процедуру — не менее двух. 4. Приведённый пример соответствует “большой” (large) модели памяти для DOS. Что

изменилось бы в примере для других моделей памяти: compact, medium, small? Как изменился бы пример для MS Windows в 32-битовом режиме? Для Linux?

5. Можете ли вы написать аналогичный код для архитектуры, отличной от Intel?

Пример 6: сборка проекта Ниже приводится makefile для сборки примера компилятором Turbo C в DOS.

Page 75: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 75 -

CC = c:\tc\tcc CFLAGS = -d -w -G -O -Z -f87 PROJ = mtest.exe OBJS = matmul.obj mtest.obj default: $(PROJ) clean: del *.obj del $(PROJ) $(PROJ): $(OBJS) $(CC) $(CFLAGS) -e$@ $(OBJS) .c.obj: $(CC) $(CFLAGS) -o$@ -c $*.c matmul.obj: matmul.c matmul.h mtest.obj: mtest.c matmul.h

Упражнения:

1. Напишите makefile для компилятора gcc или другого UNIXового. 2. Измените пример для использования ассемблерного кода.

Page 76: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 76 -

Литература Книг по программированию сейчас издаётся много, но лишь некоторые из них

заслуживают того, чтобы их разыскивать специально: 1) Б.Кернигaн, Д.Ритчи Язык прогрaммировaния Си. А.Фьюэр Зaдaчник по языку Си. Пер.с

англ. — М.: Мир, 198?. Имеется новое издание: Б.Кернигaн, Д.Ритчи Язык прогрaммировaния Си. Пер.с англ., 3-е изд., испр. — СПб.: Невский Диалект, 2001.

2) Б.Керниган, Р.Пайк Практика программирования. Пер.с англ. — СПб.: Невский Диалект, 2001.

Следующие книги перечислены потому, что я ими пользовался, и информация по программе данного курса в них заведомо есть. Но, например, для углублённого изучения системы команд Intel 86 можно использовать практически любую другую книгу на эту тему — их издано очень много. Из нижеперечисленного можно особо рекомендовать книгу Ерёмина. 3) Д.Баррон Ассемблеры и загрузчики. Пер.с англ. — М.: Мир, 1974. 4) Е.А.Еремин Популярные лекции об устройстве компьютера. — СПб.: БХВ-Петербург,

2003. 5) Б.Э.Смит, М.Т.Джонсон Архитектура и программирование микропроцессора INTEL

80386. Пер.с англ. В.Л.Григорьева. — М.: Конкорд, 1992. 6) Р.У.Себеста Основные концепции языков программирования, 5-е изд. Пер.с англ. — М.:

Издательский дом “Вильямс”, 2001. Список литературы заведомо неполный (всё-таки перед вами не монография), ссылки

на некоторые книги даны непосредственно в тексте.

Page 77: Отображение языков высокого уровня на архитектуру ЭВМ на примере языка C и основы технологии модульного

- 77 -

Оглавление. Программа учебного курса .............................................................................................................................................. 1 Содержание семинарских занятий .................................................................................................................................. 2 Введение: место информатики в общеинститутском курсе и место второго семестра в курсе информатки .......... 3 Немного о терминологии ................................................................................................................................................. 4 Общие сведения о языке C............................................................................................................................................... 4 Общие сведения о работе компилятора.......................................................................................................................... 5 Препроцессор .................................................................................................................................................................... 7 Особенности языка ........................................................................................................................................................... 9 Начальные сведения об устройстве компьютера......................................................................................................... 10 Иерархия памяти............................................................................................................................................................. 12 Микрокод......................................................................................................................................................................... 12 Цикл работы центрального процессора........................................................................................................................ 12 Тактовая частота. ............................................................................................................................................................ 13 Типизация системы команд. .......................................................................................................................................... 13 Структура команды и её “адресность”. ........................................................................................................................ 14 Семейство Intel 86 и IBM PC. ........................................................................................................................................ 15 Начальные сведения о языке ассемблера. .................................................................................................................... 17 Взаимодействие между программами. ......................................................................................................................... 18 Архитектура ввода-вывода IBM PC.............................................................................................................................. 18 Соглашение о связях ...................................................................................................................................................... 19 Область стека и передача параметров .......................................................................................................................... 19 Пролог и эпилог .............................................................................................................................................................. 20 Типичное распределение памяти в стеке...................................................................................................................... 20 Модели памяти................................................................................................................................................................ 21 Предписания организации подпрограмм...................................................................................................................... 22 Простые переменные в языке ассемблера .................................................................................................................... 22 Арифметические операции ............................................................................................................................................ 23 Флаг переноса ................................................................................................................................................................. 24 Безусловный переход и “короткая” адресация ............................................................................................................ 24 Условные переходы и регистр флагов .......................................................................................................................... 24 Компиляция условных операторов ............................................................................................................................... 25 Примеры оптимизации сравнений ................................................................................................................................ 26 Компиляция циклов........................................................................................................................................................ 28 Арифметика с плавающей точкой................................................................................................................................. 30 Структуры данных.......................................................................................................................................................... 31 Операции со строками.................................................................................................................................................... 32 Сервис прерываний ........................................................................................................................................................ 36 Что осталось “за кадром”............................................................................................................................................... 38 Ассемблерные вставки ................................................................................................................................................... 39 Сборка программы из разных файлов .......................................................................................................................... 39 Утилита make .................................................................................................................................................................. 42 Необязательная глава: как не пользоваться C++ ......................................................................................................... 44 Практическое занятие: программирование в “машинных кодах”.............................................................................. 48 Требования к внешнему виду (форматированию) программ ..................................................................................... 50 Упражнения на организацию памяти............................................................................................................................ 51 Упражнения на стиль программирования .................................................................................................................... 53 Приложение A: Таблица аналогий между Паскалем и C............................................................................................ 55 Приложение B: Команды процессоров семейства Intel 86.......................................................................................... 57 Приложение C: Примеры процедур рисования точек ................................................................................................. 61 Приложение D: Утилита ask .......................................................................................................................................... 63 Приложение E: Пример задачи: умножение матриц ................................................................................................... 69 Литература ...................................................................................................................................................................... 76