О компиляторах.
Как я понимаю развитие средств программирования. Взгляд с птичьего полета. Своеобразно и местами примитивно.
- В начале было так: компилятор или интерпретатор каким-либо образом размещался в памяти машины (с перфокарт, с магнитной ленты и т.д.). Отводились области памяти под получаемый код и исходный текст. Таким же способом заносился исходный текст и проводилась компиляция. Исходный текст программы, как правило, создавался на бумаге, без компьютера. Примером такого подхода, можно считать компьютер Радио-86 РК.
- В те времена память была дорогостоящим ресурсом, поэтому возникали ситуации, когда целиком разместить исходный текст, компилятор и результат в памяти было невозможно. Поэтому исходный текст разбивался на части и компиляцию проводили по частям. Части хранились во внешней памяти. При проведении компиляции по частям, может возникнуть ситуация, когда чтобы откомпилировать одну часть, компилятору надо знать другую часть. Для исключения таких ситуаций было введено понятие функции – обособленной части кода, имеющей одну точку входа и одну точку выхода. Функцию разрезать на части и компилировать раздельно нельзя. Но из функции, находящейся в одной части, можно вызывать функции, находящиеся в других частях. Для этого были придуманы т.н. заголовочные файлы. В них описаны только прототипы (заголовки) функций. По заголовкам компилятор строит подготовительную часть кода, оставляя не заполненным только адрес вызова. Потом специальная программа линкер соединяет все части и прописывает получившиеся адреса.
- По мере развития аппаратных средств стало возможным хранить данные во внешней памяти не просто в виде последовательности байт (опять Радио-86 РК), а снадбить последовательности заголовком или каталогом, посредством которого последовательности байт стало возможным сопоставить (присвоить) имя. А также находить её во внешней памяти не по смещению, а по имени и загружать в оперативную память автоматически – появилось понятие файл и дисковой операционной системы. Части исходного текста стали хранить в файлах.
- Далее память перестала быть дорогостоящим ресурсом. Тем не менее, оказалось удобно продолжать работать с исходный текстом, разбитом на отдельные файлы. В этих файлах стали хранить части программы, имеющие самостоятельное значение. Дело в том, что:
- Очень неудобно работать с единым большим текстом (писатели разбивают свои произведения на тома, тома на главы).
- Любую большую программу можно отладить только по частям (человеческий мозг не может одновременно удерживать более 7 сущностей).
- Нет необходимости ранее отлаженные и компилированные части компилировать каждый раз заново. Возможность присоединить уже ранее скомпилированную часть ускоряет процесс компиляции. На медленных машинах было большим плюсом. В настоящее время актуально только для очень больших проектов.
- Части одного проекта можно обособить и использовать повторно в другом проекте, даже не меняя их. Так появилось понятие библиотеки.
- Если компиляция по частям стала обычным явлением, то возникла необходимость автоматизировать этот процесс. Компилятор должен обработать все исходные части, линкер их соединить. Для этого были созданы make-файлы. В них прописывается необходимая последовательность действий.
- Очень большие проекты, будучи скомпилированы в один также очень большой файл все равно будут требовать очень много памяти. Чтобы память вычислительной системы использовать менее затратно, было придумано понятие оверлея в DOS или DLL в Windows. Это по сути та же библиотека, но собранная таким образом, чтобы её мог использовать не только линковщик, но и операционная система. Когда основной программе требуется выполнить функцию, находящиюся в такой библиотеке, она сначала обращается к системе с просьбой загрузить файл библиотеки в память. Потом также с помощью системы определяет по имени функции указатель на неё. Дальше можно пользоваться функцией как обычно. Так как такие библиотеки загружаются в память на этапе выполнения программы, то их называют динамически загружаемыми.
Использование INCLUDE.
INCLUDE это директива препроцессору на включение в исходный текст содержимого какого-либо другого файла.
Возможно:
- Включить файл с исходным тестом целиком. В этом случае препроцессор соберет для компилятора все части в один большой файл с исходным тестом, а компилятор создает объектный файл без внешних ссылок. Линковщику не требуется ничего дополнительного, чтобы сделать из него исполняемый код. Плюс – код можно делить на части в произвольном порядке, даже разрезать функции. Минус – такой подход способствует неряшливому программированию, очень трудно не только использовать какую-либо часть повторно для другого проекта, но и более-менее отладить проект.
- Включить только объявления функций и переменных. Так называемый заголовочный файл. В таком случае, при использовании этих функций компилятор создаст объектный файл с внешними ссылками. Линковщику понадобится дополнительный объектный файл, в котором содержатся данные функции. При таком подходе разрезать функции на части уже не получится. Каждая часть исходного теста должна быть самодостаточной, что способствует созданию более структурированного кода. Что впрочем не мешает развести огромное количество глобальных переменных и все равно запутать программу.
О технике программирования.
В программировании очень важно провести декомпозицию, т.е. разбить проект на части. Для этого можно использовать понятие уровней абстракции. Как я понимаю на примере. Пусть нам требуется написать программу реализующую алгоритм работы записывающего устройства.
- Пусть нам требуется простейшее устройство.
Нужно записать один звуковой фрагмент произвольной длины, потом воспроизвести его заданное число раз, потом опять записать и т.д. Запись вести без сжатия. Имеем три клавиши управления: запись,стоп, воспроизведение. Память не требующую программного управления или требующую минимального управления (записать адрес, потом данные) – скажем РУ10 или I2S память. В таком случае программу можно писать произвольно, не составляя какой-либо структуры. Проект простой, использование абстракций не требуется. - Немного усложним задачу.
Будем использовать память с программным управлением (инициализация, команда на запись, команда на чтение, блочный обмен и т.п.) – скажем FLASH память. Разметка на файлы не требуется. Работа с памятью требует специфических действий и если не ввести уровень абстракции, то в процессе программирования придется держать в голове сразу два понятия – алгоритм работы устройства в целом и алгоритм работы с FLASH-памятью. Применив абстрагирование – написав отдельный набор в какой-то мере унифицированных функций для работы с памятью, можно второе понятие сильно упростить. Теперь не надо постоянно держать в голове алгоритм работы с конкретной памятью, а можно ограничится общими более простыми действиями – записать блок, прочитать блок. - Еще усложним задачу.
Пусть требуется использовать линию связи вместо непосредственного управления. В таком случае на 1-ый уровень абстракции добавляются функции для работы с линией связи (получить байт, передать байт). Т.к. в одном байте можно закодировать 256 команд, то использование какого-либо протокола не требуется – можно работать напрямую. - Усложним еще больше.
Пусть кроме команд требуется передавать записанные данные по линии связи, а также передавать информацию о состоянии устройства. Придется ввести использовать специальный протокол – 2-ой уровень абстракции. Надо же различать, когда передаются данные, команды или информация о состоянии. Функции, реализующие протокол обмена данными над функциями для работы с линией связи. Основная особенность – функции протокола обмена не работают напрямую с аппаратурой, а используют функции для работы с линией связи. Этим достигается сохранение малого числа понятий в процессе написания программы. - Предположим, что в линии связи могут возникать ошибки.
Придется добавить в протокол проверку целостности и обеспечение повторов для гарантированной доставки. В таком случае без пакетной обработки не обойтись. Пакет – это группа передаваемых байт, снабженная началом и концом. Также пакет может снабжаться контрольной суммой. В начале пакета можно разместить заголовок – кому данные предназначены, что из себя представляют. В таком случае будет 3 уровня абстракции: команды – пакетная связь – линия связи. - Пусть нужно записывать и хранить не один фрагмент, а несколько и выдавать требуемый.
В таком случае требуется разметка памяти – 2 ой уровень абстракции для работы с памятью над 1 – ым (просто запись и чтение).
По мере усложнения проекта, вводятся новые уровни абстракции, это позволяет не держать весь проект в голове целиком, а сосредотачиваться в один момент времени только на какой-то одной части проекта. Для эффективного разделения на уровни абстракции нужно заранее четко поставить задачу и продумать структуру проекта (общий алгоритм работы, число и состав уровней абстракции). Слишком сильная декомпозиция тоже вредна: увеличивает размер программы, замедляет её работу – появляется много вложенных функций, увеличивает время написания проекта – для множества уровней требуется много функций. Недостаточное абстагирование быстро запутывает программу, делает невозможным как дальнейшее развитие проекта, так и поддержку (внесение изменений).
О структуре программного проекта.
При создании любого, достаточно большого программного проекта, удобно использовать несколько простых правил:
- Иерархическая структура.
Если выполняемая задача может быть разделена на несколько уровней, её нужно делить на уровни, не пытаться выполнить всю за раз. Ну, например, математическая задача оптимизации – оптимизируемая функция и оптимизатор. Не делать все в одной функции. - Раздельность.
Проект должен состоять из отдельных частей, слабо связанных друг с другом. В идеале поведение каждой функции должно определятся исключительно её аргументами. Можно вызвать из почти любого (за исключением инициализации) места программы и не боятся что функция что-то испортит или неправильно отработает. Написал функцию – и забыл об её устройстве. Можно ограничится только описанием её входных и выходных переменных, её действием. - Субординация.
Функции более высокого уровня могут обращаться только к функциям среднего уровня и не должны обращатся напрямую к низкоуровневым функциям. Иначе можно нарушить правильную работу функций среднего уровня. Функции нижнего уровня ничего не должны знать об высокоуровневых функциях.
Такая структура наиболее проста, но лишает нижние модули возможности сразу сообщить о каком-либо событии верхнему. Нижний может лишь ждать, когда верхний опросит его. Для обеспечения такой возможности и сохранении структуры проекта можно использовать т.н. функции обратного вызова. Их можно использовать двумя способами:
- На этапе компиляции.
Нижний модуль дает любое имя для функции, которую он будет вызывать из верхнего модуля. В дальнейшем её имя переопределяется на настоящее в настроечных файлах нижнего модуля с помощью директивы препроцессора #define. Чтобы линкер смог найти требуемую функцию она объявляется в нижнем модуле как extern, т.е. находящаяся не в данном модуле, а в каких-либо других модулях проекта. Линкер просмотрит все модули проекта и подключит функцию с указанным именем. Конечно, если её объявление в другом модуле совпадает с прототипом в модуле, откуда её вызывают и больше она нигде не объявлена. Для этого даже не надо прототип функции включать в заголовочный файл. - На этапе выполнения.
Верхний модуль во время инициализации передает нижнему указатель на свою функцию, с помощью которой нижний будет сообщать верхнему о событиях.