Видео: YouTube
В прошлой публикации мы совсем немного затронули тему функций. В этот раз строго обязательно ее развить, потому как никакой из современных модных языков программирования не даст вам стать профессиональным разработчиком и при этом не вникать в самую суть. В этот самый момент мы подошли к важному перекрестку, где смыкаются понятия перехода при выполнении программы и стека. А это не только место хранения адреса возврата из функции, но и дисциплина обслуживания данных. Без понимания базовых основ не будет понимания весомого большинства алгоритмов обработки данных, прекрасной и ужасной рекурсии. Также нелегким будет путь освоения модных функциональных языков программирования и в целом лямбда исчисления, callback функций. Современные технологии создают видимость легкости, скрывают много подробностей, заманивают начинающих и уж потом встает в полный рост неотвратимость, сажающая вас за учебники.
Глобальные переменные.
В прошлый раз мы рассмотрели задачу сложения элементов массива.
Исходный код на языке Си не блистал своим качеством, вообще плохая идея делать данные видимыми для всех функций, размещая их в глобальной области. В первую очередь, очень скоро у вас закончится фантазия на имена переменных, ведь зачастую их смысл бывает очень похожим. Это может быть счетчик, накопитель результата, какое-то смещение от начала и другие. Конечно, удобно не заморачиваться с тем, какие данные каким функциям будет видно, но это довольно быстро приведет к коллапсу. Запутаетесь буквально сразу. Кроме того, глобальные переменные привяжут ваши функции намертво к тому месту где они находятся, а хотелось бы использовать их в других программах и при этом не переписывать. Хорошим стилем является максимально скрыть данные, относящиеся к конкретной функции от других функций.
Этим самым достигается две цели.
- возможность использовать одинаковые названия у переменных с одинаковыми назначениями.
- возможность скопировать функцию и вставить ее в другую программу, при этом все что нужно для работы у нее уже будет.
Остается решить одну маленькую проблему. Это как передать данные из одной функции в другую.
Передача параметров в функцию. Стек.
Как вы понимаете, проблемы в этом нет. Изобретен великолепный механизм, называемый передачей параметров через стек. Обратимся к схеме процессора, представленной впервые в прошлом выпуске.
Из регистрового файла выходит шина с содержимым регистра SP. Это указатель стека. Указатель через шину адреса обращается к данным в памяти. Рассмотрим подробнее его работу.
На этом рисунке содержимое регистра это число 6 в двоичном виде, он указывает на ячейку памяти с адресом 6. Устройство может выполнить команду помещения числа в стек. Мнемоника команды — push.
При этом сперва вычитается единица из содержимого регистра, он будет указывать на соседнюю ячейку с адресом 5. Сразу же после этого можно поместить число в ячейку. В данном примере это единица.
То место, в которое указывает стек называется вершиной стека. Следующая команда push занесет число по адресу 4.
Соответственно, еще одна команда push занесет число в ячейку 3.
Наблюдаем как вершина стека смещается в сторону меньших адресов. Еще про эту ситуацию говорят, что стэк растет вниз.
Теперь команды с мнемоникой pop извлекают из памяти данные, на которые указывает вершина стека. При этом к содержимому указателя добавляется единица. Как можно заметить, содержимое памяти при выполнении инструкции pop не стирается.
Сотрется оно только при перезаписи ячеек другими данными. Настало время рассмотреть исходный код и отладить программу. Первое что бросается в глаза это то, что массив объявлен в функции main.
Согласно синтаксису языка, по его имени никакая функция его больше не сможет увидеть. Строчкой ниже описан вызов функции, которая посчитает сумму элементов массива. Теперь у функции есть параметры. Первым параметром является имя массива, оно же указатель на нулевой элемент. Второй параметр это количество элементов в массиве. Эта функция возвращает результат, который будет записан в переменную result, объявленную как ячейку в 1 байт размером. Сама функция оформлена выше. Сперва указан тип возвращаемого результата. Это char. Название функции sum. В скобках через запятую два параметра. Первый это массив типа char без указания его размера. Известно, что компилятор отправит сюда именно указатель на нулевой элемент. Второй параметр это один байт, указывающий, сколько элементов в массиве. Справа представлена модель памяти общей для команд и данных. Зеленым цветом указаны инструкции функции main с красным вызовом функции. А красным цветом обозначены инструкции функции sum. После компиляции исходного кода на языке Си и запуска программы, машинные коды расположатся в памяти примерно так.
Размещение локальных переменных.
Что происходит сразу первой строкой при входе в функцию main? Программе необходимо разместить в памяти элементы массива. Все что не является глобальными данными и объявляется внутри функции — размещается в стеке. Рассмотрим как это происходит.
Вершине стека задается какое-то определенное значение, пусть это будет 255. Далее через регистр R1 операнды, являющиеся элементами массива, попадают в стек, начиная с последнего элемента массива. Сначала в R1 помещается 9, потом содержимое R1 помещается на вершину стека командой push. Отдельной командой в этом процессоре уменьшается на единицу указатель стека SP . Эта команда с мнемоникой dec. После занесения в стек всего массива с последнего по первый элемент в нем же определяется место для хранения результата сложения. В исходном коде она обозначена именем result.
Это делается командой push, которая заносит в стек содержимое какого либо из регистров.
Вызов функции.
Передача параметров.
Пришло время вызвать функцию. Вот тут особое внимание. Прежде чем это сделать, в нее необходимо передать параметры. Делается это начиная с последнего параметра.
В регистр заносится число 5 и сохраняется на вершине стека. Уменьшаем содержимое SP, при этом вершина стека движется к младшим адресам. Размещаем указатель на массив. Компилятору сделать это совсем не сложно. На текущий момент нулевой элемент массива расположен на три ячейки выше указателя стека SP, где бы этот указатель не был.
Это смещение 3 определилось исходя из количества и размеров переменных и параметров. Теперь происходит переход на выполнение функции. В отдельный стек адресов помещается адрес возврата.
Это адрес следующей инструкции за вызовом функции. В других наиболее распространенных процессорах отдельного стека под адреса возвратов нет и они пользуются тем же самым стеком, где размещаются данные.
Переход по адресу начала функции произошел по команде с мнемоникой jnc, регистр PC указывает на первую инструкцию этой функции. Функции sum необходимо разместить в стеке свою переменную result, она будет хранить промежуточный результат суммы всех элементов массива. Поскольку она должна быть сразу обнулена, то в регистр заносится ноль, содержимое регистра заносится в стек.
Кроме того, анализ исходного кода компилятором показал, что в функции есть еще одна переменная. Это индекс массива. Согласно тексту программы index должен быть обнулен, поэтому проделываются те же манипуляции. Заносим в регистр ноль, сохраняем в стеке.
Выполнение функции.
В этот самый момент функция sum готова для выполнения. Обратим внимание, что переданные параметры — указатель на массив и количество элементов теперь находятся в пользовании функции. Вместе с другими локальными переменными они создают область видимости, подсвеченную на модели памяти синим цветом.
Эту область называют стековым фреймом функции. За пределами своего фрейма функция может получить доступ только к глобальным переменным. Функция main() также имеет свой фрейм и он отмечен зеленой областью вверху. Туда входит массив и результат выполнения функции. Функция sum() имеет доступ к массиву только потому что в ее собственном фрейме хранится указатель на массив и называется он mas.
Возврат результата.
После статьи о циклах и массивах подробности подсчета суммы элементов уже не так интересны. Сейчас гораздо важнее понять, каким образом результат вернется в функцию main(). Для это воспользуемся регистром R1.
Сохраним в него содержимое ячейки, на одну выше чем вершина стека. Перед выходом из функции выполняются операции по очистке стека.
Просто поднимается его вершина, оставляя внизу все локальные переменные, которые были размещены функцией. Некоторое соглашение говорит о том, что кто намусорил, тот и убирает. Восстановлением адреса возврата в регистр PC происходит переход обратно в функцию main().
Раз она намусорила, размещая параметры в стеке, то пришло время убраться. Поднимаем вершину стека еще на две ячейки. Теперь мы в стековом фрейме функции main().
Не забываем, что в R1 у нас все еще хранится результат сложения, поместим его в ячейку, выделенную как раз для этого.
Тут можно было бы и вывести результат, но программист это не захотел делать, начинается выход из функции main(). Просто прибираемся в стеке и заносим в регистр ноль, как признак успешного выполнения программы.
Для кого этот ноль, разберемся позже.
Бинарный интерфейс приложений ABI.
Немного подробнее о соглашениях. Это очень низкоуровневое понятие и у большинства программистов никогда и мысли не возникнет о том, что там в процессоре кто-то прибирает за собой. Это имеет значение при совмещении различных технологий. Так, сами по себе модные интерпретируемые языки программирования демонстрируют совершенно отвратительные показатели производительности. Иногда чтобы выполнить задачу программам, написанным на этих языках требуется в десятки раз больше времени, чем, программам, написанным на проверенных компилируемых языках. Новички, пишущие чат боты в первый же час изучения Python и обучающие нейросети, знайте, что вся работа была уже сделана за вас. Вы пользуетесь библиотеками, написанными на языках Cи или C++. Возвращаемся к поднятой теме. Чтобы совместить разные технологии, программисты на низком уровне раскладывают все по полочкам. Кто куда что кладет и откуда забирает результат. Эти все сведения собраны в документе, называемым ABI. (Application Binary Interface).
Он же бинарный интерфейс приложений. В нем как раз и описывается порядок обмена данными. Если что-то делается без оглядки на принятые соглашения то нет смысла удивляться, что ничего не заработает.
Эпилог.
В этом видео мы рассмотрели процедуру формирования стекового фрейма при вызове функции. Убедились, что на организацию даже самой простой функции уходит немало процессорного времени. Если полезной работы в функции совсем мало, а функция вызывается сотни тысяч раз, то теперь понятно, где засел тот монстр, пожирающий всю производительность. Для борьбы с ним придумано ключевое слово static.
В этом контексте оно означает, что программист не желает организовывать функцию по полной программе со всеми подготовками и уборкой. Просто хотим разместить машинный код тела функции без лишних операций. На этом пока все, далее поговорим о рекурсивных функциях.