Урок 0

В котором читатель проверяет, что его навыки программирования достаточны для написания игры "Horizon" from scratch, а так же заглядывает под капот библиотечной функции и знакомится с системными вызовами Linux.
-Пользователь - это периферийное устройство компьютера,
клацающее по клавиатуре в ответ на запрос программы.

Программировать будем под линукс, писать в удобном вам текстовом редакторе на языке Си, и компилировать с использованием компилятора gcc. Если всё это готово, можно начинать.

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

Итак, HelloWorld на C:

int main() { printf("Hello, World!\n"); }
Чтобы пройти весь путь от текста программы до её исполнения на процессоре, выполняются такие последовательные действия:


  • Сохранение текста программы
  • Обработка текста компилятором
  • Передача скомпилированной программы на исполнение операционной системе.

Мы реализуем эту последовательность следующими шагами:

  • Открыть текстовый редактор (рекомендую vim, кроме шуток - научитесь пользоваться им, либо EMACS - на ваш вкус. Программировать c мышкой - медленно, не отрывая же руки от клавиатуры, когда курсор не отстаёт от мысли - естественно, как играть в игру не отвлекаясь на разглядывание джойстика чтобы найти кнопку прыжка или стрельбы. Ну и - вы ведь умеете печатать вслепую, нет нужды это упоминать?). Пока пишем простой проект, в котором легко ориентироваться, можно позволить себе работать непосредственно с файлами, вручную, и чуть позже перейти на утилиту сборки make. И когда проект разрастётся можно будет перейти в IDE по выбору, уже разбираясь в процессах, которые IDE прячет от нас под капотом (а более того, пользуясь в вашей IDE режимом совместимости vim, используя всю мощь выученных шорткатов).
  • В текстовом редакторе набрать текст вышеприведённой программы (да-да, набивание программы строка за строкой помогает осознать, что же в ней на самом деле написано). Итак, программа набрана, текстовый файл с ней (Язык C - это язык для человека, не для компьютера. Текст программы написан человеком и для человека, это просто текст) нужно сохранить с расширением .c, например "helloworld.c".
  • Дальше нужно запустить компилятор. Для запуска конкретно gcc в терминале применяем команду gcc. Если компилятор установлен в системе, он ругнётся на то, что нет файла для обработки. Компилятор прав, для работы ему нужен файл с программой, передадим ему файл аргументом командной строки, то есть напишем имя файла нашей программы сразу после команды вызова компилятора: gcc helloworld.c
    Компилятор, в соответствии с идеологией Юникс, делает свою работу молча - если не возникло проблем, то он не выведет ни символа, а, отработав, молча завершится. Если же возникла ошибка и компилятор вывел сообщение о ней - необходимо разобраться, в чём проблема и как скомпилировать программу. Кроме сообщений об ошибке компилятор может ещё и напечатать предупреждения - варнинги. Варнинги не мешают компиляции программы, но показывают, что в программе что-то написано не в соответствии со стандартами языка, а значит при исполнении программы, вероятно, что-то может пойти не так. Нужно стремиться, чтобы варнингов в программе не было.
    Результатом работы компилятора служит исполняемый файл программы. Так как компилятору не поступало никаких указаний о том, в каком виде мы хотим получить итоговую программу - компилятор сделал её именно для той архитектуры процессора и той операционной системы, на которых он был запущен (для вашего компьютера), назвал по умолчанию и положил в директорию, из которой был вызван. В результате работы компилятора (по умолчанию, если ему не было прямо указано как назвать результирующий файл) в каталоге, из которого он был вызван, появился файл a.out. Это и есть наша программа Хелловорлд.
  • Чтобы запустить программу a.out, в GNU\Linux нужно набрать ./a.out находясь в директори с этой программой.

Убеждаемся, что программа делает то, что от неё ожидалось - выводит в стандартный поток вывода (в терминал) строку "Hello, World!"

Пока ещё не слишком похоже на игру, не так ли? Но ничего не мешает поиграться с кодом, чтобы посмотреть на ошибки и предупреждения компилятора - например, вместо int main() можно написать long main() и, перекомпилировав программу заметить, что компилятор решил, что мы хотим странного (хотя и не помешал нам это сделать, лишь предупредив)


Стоит помнить, что компилятор предупреждает далеко не обо всех неоднозначных ситуациях, которые он может распознать. Хорошей практикой, а зачастую и требованием (на самом деле, это практически обязательно!), является написание кода без варнингов. Но для этого компилятору нужно сказать, что мы хотим слышать обо всех варнингах, делается это при помощи ключа -Wall, то есть "Warning: all" (Ключ компилятора - команда, передаваемая компилятору аргументом командной строки, начинающаяся с дефиса. Ключей компилятора довольно много, важные для нас приведены в справочнике сайта). Интересно, что несмотря на название ключа, включает он, к сожалению, не все предупреждения, в частности некоторые хитрые ситуации с приведением типов компилятор подмечает, но не спешит предупредить о них. Их так же можно включить своим ключом, однажды, когда ситуация случится, и вы будете искать странный баг, в лучшем случае, несколько часов - ключ этот вы запомните и будете компилировать дальше только с ним. Пока же стоит навсегда выработать привычку запускать компилятор не иначе как gcc -Wall, а лучше сразу gcc -Wall -g - ключ -g включает в файл отладочную информацию, которая позволит подключать к вашей программе дебаггер и наблюдать, что же с кодом происходит на самом деле. Итак, вернёмся к программе:

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

Так что на текущем этапе развития компьютерной техники, для того чтобы как-либо провзаимодействовать с окружающим миром (а пользователь для программы - часть окружающего мира), програма просит осуществить это взаимодействие операционную систему, и если можно - сообщить ей результат (ну а если нельзя - что ж, бывает всякое - нам, как программистам и такую ситуацию в программе нужно предусматривать)
Так вот функция printf() и берёт на себя работу по выводу на любой, каким бы он ни был, стандартный вывод передаваемого в программу текста. Как она это делает - зависит от платформы, для которой компилируется программа. Для GNU\Linux в итоговой программе для процессора вместо функции printf() компилятором будут подставлены команды, размещающие в памяти строку, которую мы хотим вывести, а потом в условленных местах (условленных спецификациями линукса) размещаются адрес этой строки и цифровой код, соответствующий той операции, которая запрашивается у операционой системы, а именно - вывод текста.
Команды эти можно увидеть и непосредственно в коде скомпилированной программы, но так как она занимает много-много места, несколько килобайт (всё из-за динамически подключаемых библиотек, которыми printf(), да и main(), пользуются по умолчанию), то не понимая пока машинных кодов, искать их не стоит. А вот саму строку "Hello, world!" найти в получившейся программе a.out, открыв программу шестнадцатиричным редактором, рекомендуется.
Но прежде, чем превратить программу в машинный код, компилятор превращает её в ассемблерный код - последний человекочитаемый язык на пути превращения "человеческого" текста в машинные коды процессора. Компилятор, если его отдельно не попросить, воспользовавшись ассемблерным кодом, уничтожает его - но можно указать компилятору остановиться сразу после создания ассемблерного кода, и оставить файл с ним в результате работы. Делается это указанием ключа -S. Компилятор, запущенный с этим ключoм оставит после себя файл с расширением .s, например helloworld.s. Это текстовый файл с текстом нашей программы, преобразованным в язык ассемблера. Стоит поглядеть на полученную программу, поискать знакомые кусочки. В будущем (не нашего проекта, а вашем программистском будущем) ориентирование в ассемблере окупится как минимум при отладке.

Но зачем уже сейчас упоминать про ассемблер? Дело в том, что в Линуксе на архитектуре x86, системные вызовы (на примере вывода текста) осуществляются следующим образом:
Сначала в регистрах процессора размещаются данные для конкретного вызова (как минимум, в регистре аex размещается код вызова). Затем, когда данные подготовлены, осуществляется "системный вызов" - команда процессора, предназначенная для общения пользовательской программы с операционной системой. Таким образом программа говорит операционной системе: "Мне нужно, чтобы ты сделала что-то, и после этого снова вернула мне управление". Операционная система, получив от программы прерывание, останавливает нашу программу, запоминая просьбу программы (значения в регистрах, которые и обозначают, что программе нужно от ОС). И когда-нибудь, когда у неё найдётся на это время, ОС выполнит (ну или нет, тут уж как получится) просьбу нашей программы, и после этого, опять же, когда захочет, программу нашу запустит дальше. Мы никак не можем повлиять на все эти времена, в течение которых наша программа будет остановлена. Хоть (по компьютерным меркам) программа будет остановлена очень надолго, сама она не заметит никакого простоя и продолжит работу как ни в чём не бывало. Тем не менее мы-то должны понимать, что каждый системный вызов - это дорогая операция, и стараться обращаться к услугам операционной системы как можно реже. В примере с функцией printf() это могло бы значить, что если нам нужно вывести 10 строчек текста, нужно не 10 раз с разными строчками вызывать printf(), а собрать сначала в программе из десяти этих разных строк один десятистрочный текст, и уже этот большой текст скормить функции printf() один раз. Но printf() на то и библиотечная функция, чтобы зачастую получше программиста понимать, что делать и как выполнять свою конкретную задачу. Далеко не каждый вызов printf() будет непосредственно транслирован в системный вызов печати текста, скорее всего где-то в программе библиотека сама будет собирать строку, пока это возможно, и уже когда ей покажется что пора, тогда она и запросит системный вызов.

Если вам интересно, системный вызов можно осуществить и вручную, не отдавая это на откуп библиотечным функциям. Далее до конца главы следует необязательная часть, которую можно спокойно пропустить - данный подход является лишь иллюстрацией работы библиотечных функций. Альтернативу библиотечным функциям можно написать и самостоятельно - но тогда нужно помнить, что программа ваша перестаёт быть переносимой, и привязывается именно к той операционной системе, системный вызов которой реализуется. Ещё одна вещь, которую нужно отметить - несмотря на то, что язык C очень близок к реальному железу, он всё же не даёт доступа непосредственно к регистрам процессора (так как язык Си создан именно для возможности абстрагироваться от непосредственной реализации компьютера, в том числе от процессора. Программа же должна компилироваться для любых процессоров, в том числе и процессоров без регистров вовсе), что необходимо для реализации системных вызовов Linux. Так что нужно будет либо делать "ассемблерные вставки" в ваш код (ассемблерные вставки не являются частью языка Си, но поддерживаются многими компиляторами, в том числе gcc), либо писать и компилировать функцию, осуществляющую системный вызов непосредственно на ассемблере, и подключать к вашей программе уже на этапе линковки. Про линковку позже, пока что линковку компилятор проводит незаметно для нас. Пока что совсем чуть про ассемблерные вставки:
asm ("nop");
gcc позволяет осуществлять вставки ассемблерного кода непосредственно в код c-программы, с использованием собственного gcc-специфичного синтаксиса - строки asm-кода (в двойных кавычках) в скобках после ключевого слова asm заключаются в круглые скобки.
asm ("nop" : "=&a"(ret) : "g"(hello), "g"(hello_size)); : результат (из eax)^ : первый^ аргумент, ^второй аргумент :
В asm-вставках компилятор gcc позволяет передавать значения в и из вашей Си-программы. Для этого через двоеточие в конце строк с asm-кодом, перечисляются переменные из Си-программы: asm ("asm-инструкция" : выходные переменные : входные переменные : прочее). Аsm-переменные (те, которыми ассемблерная вставка собирается меняться с программой на Си, и этот синтасис поддерживается только в gcc) начинаются с '%' - %0, %1, %2 и т.д.
int main()
{
    char hello[] = "Hello, World!\n";
    int hello_size = sizeof(hello);
    int ret;

    asm (
        "mov $1, %%eax\n"	// 1 в eax - запрос вывода
        "mov %1, %%rsi\n"	// в rsi - адрес строки для вывода
        "mov %2, %%edx\n"	// в edx - количество символов
        "syscall"        	// запрос системного вызова у ОС
        : "=&a"(ret) : "g"(hello), "g"(hello_size)
        // ^результат: ^первый аргумент, ^второй аргумент
    );
}

Компилируется такая программа точно так же, как и обычная программа на Си:
gcc main.c

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



Самостоятельная работа:
  • Точка входа в языке Си. Почему мы пишем именно main()? А как ещё можно начать программу и при чём здесь аргументы командной строки? Права доступа и исполнения исполняемого файла. Аргументы командной строки и возвращаемое значение.
  • Библиотека stdio. В чём разница между printf() и puts()? А между printf() и fprintf()? А в чём тогда разница между вызовами printf("Lorem ipsum"); и fprintf(stdout, "Lorem ipsum");? Зачем используется функция fflush()?
  • В чём разница между стандартным потоком вывода и потоком ошибок?
  • Как можно считывать данные со стандартного потока ввода без использования scanf()?

Углублённые вопросы для самостоятельной работы:
  • Почему простая программа, которая не делает ничего, кроме вывода одной маленькой строки - даже последняя версия с непосредственным использованием (казалось бы) одного-единственного системного вызова, который она реализует вот прям на голом железе, используя 4 процессорных инструкции, весит несколько килобайт (проверьте, сколько у вас весит "a.out")? В несколько килобайт помещаются восхитительные интро-демо:

    (да, программа, которая отрисовывает всё это, да вместе с музыкой, весит 4 килобайта. Только текст на вот этой странице, которую вы читаете, занимает около 16 килобайт, что как раз и сравнимо со скомпилированной версией "Helloworld"а - те же 16 килобайт на моей системе)
    (демосценa - сообщество, где вот уже скоро как пол века команды и одиночки соревнуются, кто создаст более техничные и более красивые "демо", то есть аудиовизуальные компьютерные произведения)
  • А как ещё можно создать точку входа в программу? При чём здесь функция _start()?
  • Зачем при запуске программы из консоли мы добавляем к её названию './', вводя './a.out' вместо 'a.out'? Что такое переменные окружения (переменные среды, runtime environments). Может ли наша программа получить доступ к переменным окружения?