Урок 4
- Да вот, Танненбаума читаю.
- Процесс убьют. Убийца - операционная система.
Любая игра подразумевает интерактивность. Есть отдельный жанр игр, называемый "zero-player game", но они являются, по сути, симуляцией, и игра в них подобна прохождению игр на ютубе или футболу по телевизору - то есть, кто-то всё равно является активным агентом, от чьего решения зависит развитие игровой ситуации, просто в "zpg" этот агент симулируется внутри игры. Игра не обязательно должна быть трёхмерной, даже не обязана быть двухмерной, или даже графической, но по определению она подразумевает получение реакции игрока на текущее состояние игрового мира, и последующее развитие игровой ситуации в зависимости от реакции пользователя (или отсутствия её). Пошаговая игра из первого урока использовала текстовый ввод с консоли. Текстовый ввод устроен таким образом, что в момент, когда программе нужно получить информацию от пользователя - программа останавливается (в нашем случае - на строке с scanf()
) до тех пор, пока пользователь не нажмёт Enter (другими словами, функция scanf()
работает в блокирующем режиме - блокирует дальнейшее выполнение программы до тех пор, пока не получит строку, которую сможет вернуть). В момент нажатия Enter набранная в консоли строка отправляется в операционную систему, ОС передаёт введённую строку программе, и выполнение программы продолжается - с полученной строкой. Игра в реальном времени же не может ожидать пользовательского вода, динамический мир должен продолжать "жить", графика должна продолжать отрисовываться. Существуют разные возможности обрабатывать пользовательский ввод, не прерывая исполнение программы.
Но ведь мы уже взаимодействовали с программой, используя отличный от текстового ввода механизм! А именно останавливали программу нажатием Ctrl+C. При нажатии Ctrl+C той програме, которая запущена в текущем терминале (формально говоря - группе процессов), отправляется сигнал о необходимости завершить работу, и программа, реагируя на этот сигнал, завершается. Что такое сигнал? Сигнал - это простейший из механизмов для осуществления связей между процессами. Программа может отправить сигнал какой-либо другой программе (так терминал отправляет сигнал о необходимости завершиться нашей программе), и ожидать от этой другой программы реакции. Сигнал - это просто число, которое программа получает. Каждое число несёт какую-либо функцию, которая должна быть известна программе, чтобы программа могла на сигнал отреагировать. Так при нажатии Ctrl+C программе отправляется сигнал SIGINT - то есть, фактически, цифра 2. Сигналы делятся на блокируемые и неблокируемые (но это другое "блокирование", смысл вкладывается иной, нежели когда говорится о блокирующей функции). Блокируемы сигналы - это такие, для которых можно переопределить обработчик (изменить реакцию программы на сигнал, формально - установить диспозицию сигнала). Неблокируемых сигналов всего два - SIGKILL и SIGTERM, они, как и многие блокируемые сигналы приводят к завершению программы. Среди сигналов существуют и два сигнала SIGUSR1 и SIGUSR2, которые непосредственно предназначены для переопределения, и осуществления пользовательских функций.
Попробуем сначала установить диспозицию SIGINT с тем, чтобы программа завершалась корректно.
Функция-обработчик сигнала устанавливается по указателю на эту функцию, разберёмся с тем, как работать с указателями на функцию. Сначала напишем программу, которая вызывает функцию непосредственно:

Указатель на функцию технически устроен так же, как и указатель на переменную - указатель на переменную содержит просто адрес этой переменной в памяти, так же и указатель на функцию содержит просто адрес функции (ведь весь код функции размещён где-то в коде программы, и есть самая первая команда этой функции - вот её адрес в памяти и будет содержаться в указателе)
Но объявление указателя на функцию синтаксически выглядит сложнее указателя на переменную: "void (ptr_boo*)()
". Пока что это выглядит не очень страшно, но если функция принимает несколько аргументов, а более того - возвращает значение какого-нибудь хитрого типа - объявление довольно сильно разрастается и, к тому же, становится менее понятным.
Стоит разобрать, что есть что в объявлении:
-
void (*ptr_boo)()
: void - тип возвращаемого значения. В данном случае объявляемый указатель сможет указывать на функции, возвращающие void. -
void (*ptr_boo)()
: * - звёздочка непосредственно перед именем переменной указывает, что объявляется не функция, а указатель на функцию. Звёздочка (как и в случае указателя на переменную) может быть указана через пробел, но они с именем указателя в любом случае должны быть окружены скобоками - чтобы звёздочка относилась к имени функции, а не к типу возвращаемого значения. -
void (*ptr_boo)()
: ptr_boo - непосредственно название указателя. Синтаксис объявления сбивает с толку именно тем, что имя указателя пишется прямо посреди всей конструкции - однако, с этой особенностью получается довольно быстро освоиться. -
void (*ptr_boo)()
: () - В скобках перечисляются аргументы функции. Функция, boo(), которая будет размещаться по указателю в нашем случае, не принимает аргументов, но в общем случае в скобках перечислены типы аргументов в соответствии с типами, указанными в сигнатуре той функции, на которую должен указывать указатель, напримерint (some_ptr*)(int, char *, float)
может указывать на функцию, которая принимает три аргумента: типов int, указатель на char и float, а возвращает - int.
Итак, создадим указатель на функцию boo и вызовем функцию по указателю:

Несмотря на то, что непосредственно функция boo() в main() не вызывается, тем не менее она вызывается опосредованно - через указатель, и на стандартный вывод отправляется текст. Разберём, что происходит в функции main():
-
void (*ptr_boo)();
- создаётся (определяется) указатель на функцию, которая не принимает аргументов и возвращает void (функция boo() как раз такая!) -
ptr_boo = boo;
- указателю ptr_boo присваевается адрес функции boo(). Имя функции (без скобок после него) компилятор рассматривает как адрес функции, и записывает этот адрес в указатель (если бы здесь было написано boo(), то это был бы не адрес функции, а обычный вызов функции, и в указатель записывалось бы возвращаемое из функции значение. Так как из boo() возвращается не адрес функции, то компилятор заметит несоответствие типов и выдаст ошибку). Синтаксис языка C не делает разницы между именем функции и взятием адреса имени функции, поэтому записиboo
и&boo
равнозначны. -
ptr_boo();
- вызов функции по указателю. Синтаксически выглядит так же, как и обычный вызов функции - и по сути им и является. Если в обычном случае оператор круглые скобки применяется к имени функции, которое одновременно является адресом функции, то применение оператора круглые скобки к указателю, т.е. адресу функции ничем и не отличается от применения к имени. Ещё раз, стоит видеть, что здесь происходит не вызов функции ptr_boo(), а вызывается функция boo(), адрес которой хранится в указателе ptr_boo.
Пользуясь механизмом указателей на функции, можно создать такую функцию, которая при вызове сначала запоминает, какое действие мы от неё хотим, причём заранее функция это действие выполнять не умеет (запоминает указатель на функцию, который ей будет передан непосредственно в момент вызова), а потом, где-то внутри своей работы, выполнит действие в зависимости от того, какая функция была запомнена - вызовет эту запомненную функцию.
Такие функции, которые сначала запоминают функцию-обработчик, передаваемую им в качестве аргумента, а потом, по необходимости (произведя необходимые подготовительные действия) вызывают эту запомненную функцию, называются callback-функциям. Например, в начальном меню игры есть две кнопки - "Начать игру" и "Настройки". В зависимости от нажатой игроком кнопки нужно запустить одну из двух разных функций - либо StartGame(), либо OptionsMenu(). Чтобы практически идеинтичный код обработки нажатия на кнопку не дублировать два раза - один раз с вызовом StartGame(), другой - OptionsMenu(), можно написать функцию нажатия на (абстрактную) кнопку, и эта функция будет принимать указатель на функцию-обработчик в зависимости от кнопки, которую выбрал пользователь. И тогда нажатие на любую из кнопок будет обрабатывать одна и та же функция, но вызываться она будет с указателем на функцию либо начала игры, либо перехода в меню настроек - в зависимости от выбора в меню.

Теперь, понимая принцип функционирования callback-функций и указателей на функции можно вернуться к диспозиции сигнала. Для установки пользовательской диспозиции сигнала нужно написать и зарегистрировать функцию-обработчик сигнала: регистрация функции-обработчика осуществляется вызовом callback-функции signal(). signal() имеет следующую сигнатуру: void (*)(int) signal(int, void (*)(int));
- функция возвращает указатель на функцию, возвращающую void и принимающую int, а принимает функция signal() два аргумента - int и так же, как и с возвращаемым значением - функцию, принимающую int и возвращающую void. Значение, которое возвращает функция signal(), может сигнализировать об ошибке - если вернулось значение SIG_ERR. Если ошибки нет, то возвращается предыдущая диспозиция сигнала. Её можно запомнить, чтобы, например, позже вернуть диспозицию "как было". Напишем и зарегистрируем обработчик для SIGINT:

Программа запускается, регистрирует собственный обработчик сигнала SIGINT и входит в бесконечный цикл, занимая процессор бесконечной проверкой условия цикла. При нажатии Ctrl+C программа, как и раньше, завершается - но теперь завершается иначе. Раньше, с диспозицией сигнала по умолчанию процесс завершался принудительно как только поступал сигнал SIGINT. Теперь же обработчик всего лишь меняет значение условия цикла - и программа, в очередной раз проверяяя условие цикла и убедившись, что оно не верно, просто выходит из него, выполняет последующие инструкции и таким образом естественным путём, штатно доходит до конца цикла main(). Программа завершается (с кодом возврата 0, то есть - без ошибки. Обычно нажатие Ctrl+C приводит к завершению программы с ошибкой. Код ошибки сохраняется в системной переменной ?, получить его можно сразу после завершения программы, введя в терминале команду echo $?. Системная утилита echo всегда служит для печати того значения, которое было ей передано - обозначение $? символизирует, что в echo передаётся не символ ?, а системная переменная ?. Сравните код возврата из программы после нажатия Ctrl+C и после нажатия Ctrl+\. Код возврата в случае нормального завершения программы - это то значение, с которым вызван return функции main, и обычно оно полагается равным 0 как признак того, что программа завершилась без ошибки).
Что происходит в программе?
-
#define SIGINT 2
- Просто задаёт макроподстановку для макроса SIGINT. В дальнейшем тексте программы текстовая строка "SIGINT" будет заменятся на текстовую строку "2". Значение 2 является непосредственно кодом сигнала, который отправляется процессу при нажатии Ctrl+C в терминале. -
typedef void (*signalhandler_type)(int);
signalhandler_type signal(int, signalhandler_type);
Здесь объявляется функция signal, которая принимает два аргумента:- int.
- Указатель на функцию, принимающую int и возвращающую void.
Вообще-то объявление выглядит так:void (*signal(int, void(*)(int)))(int);
Но человеку неподготовленному разобрать, что при такой записи подразумевается, невозможно - да и подготовленный человек поморщится и скажет, что надо использоватьtypedef
.typedef
- это ключевое слово для задания псевдонимов типов (сокращение от type definition). Так, к примеру, записьtypedef float distance;
задаёт типу float синоним distance, и далее можно написать, например, такое определение переменной road_length:distance road_length = 4.2f;
Ещё раз: typedef не создаёт новый тип, а только устанавливает синоним к уже сущесвующему типу.
Таким образом строкаtypedef void (*signalhandler_type)(int);
задаёт типуvoid (*)(int)
синонимsignalhandler_type
, то есть "указатель на функцию, принимающую int и возвращающую void", и неопрятная конструкцияvoid (*signal(int, void(*)(int)))(int);
typedef void (*signalhandler_type)(int); signalhandler_type signal(int, signalhandler_type);
-
int main_cycle;
- определяется флаг, задающий условие выполнения главного цикла программы. Пока флаг будет поднят, программа должна продолжать исполняться. - Определяем нашу собственную функцию handler(), которая будет уведомлять о том, что она вызвана, и сбрасывать флаг исполнения главного цикла. Сигнатура функции, которая может быть установлена в качестве обработчика сигналов должна соответствовать следующим соглашениям: принимается один аргумент типа int и тип возвращаемого значения - void. Наша функция handler() этим требованиям удовлетворяет.
-
signal(SIGINT, handler);
- регистрируем наш обработчик сигнала для сигнала SIGINT. -
main_cycle = 1;
- флаг поднимается и -
while(main_cycle) { ; }
- программа заходит в бесконечный цикл до тех пор, пока обработчик сигнала не сбросит флаг исполнения.
Разобрались с программой, но у вас могут возникнуть следующие вопросы:
- Откуда взялась функция signal()?
- Мы написали её объявление, но в программе нет определения. Какой конкретно код выполняется в момент вызова signal()?
- Откуда компилятор о ней знает?
#include
. Нужно понимать, что это не так. Директива препроцессора #include
исключительно вставляет в текст нашего файла текст файла (целиком), указанного в угловых скобках либо кавычках. Существуют так называемые header-based библиотеки, для которых это и является включением библиотеки в наш файл, но стандартная библиотека header-based библиотекой не является. В тексте файла stdio.h содержится объявление функции printf() - чтобы компилятор в принципе знал о её существовании, пусть и не зная её реализации. Объявление функции является своего рода обещанием компилятору, что определение функции либо будет предоставлено компилятору позднее, либо - если компилятор так определения и не встретит - линковщик позже найдёт эту функцию и слинкует место вызова функции с её реализацией. Линковщик же ищет среди известных ему библиотек, а стандартная бибилиотека известна ему по умолчаниююОбъявления всех функций стандартной библиотеки указаны в соответсвующих загововочных файлах. Объявление функции signal() (как и константы SIGINT) указаны в заголовочном файле signal.h, поэтому вместо ручного указания объявления функции (и используемых констант):
Теперь возможно переписать анимацию из третьего урока так, чтобы программа завершалась не по счётчику, а по получению сигнала SIGINT:
#include <stdio.h>
#include <signal.h>
#define WIDTH 80
#define HEIGHT 24
#define DAMPING 500
int main_cycle;
char screen[(WIDTH + 1) * HEIGHT]; /* One extra char for linebreak */
void handler(int dummy)
{
main_cycle = 0;
}
void init_screen()
{
for (int y = 0; y < HEIGHT; y++)
{
for (int x = 0; x < WIDTH; x++)
{
screen[(WIDTH + 1) * y + x] = '.';
}
screen[(WIDTH + 1) * y + WIDTH] = '\n';
}
screen[(WIDTH + 1) * HEIGHT - 1] = '\0';
}
void draw_pixel(int x, int y, char p)
{
if (x < WIDTH && y < HEIGHT)
{
screen[(WIDTH + 1) * y + x] = p;
}
}
void init_terminal()
{
printf("\033[?1049h"); /* Enables buffer mode in terminal */
printf("\033[?47h"); /* Tells terminal to save screen */
printf("\033[?25l"); /* Hide cursor */
printf("\033[2J"); /* Erase console screen */
}
void restore_terminal()
{
printf("\033[?25h"); /* Restore cursor visibility */
printf("\033[?47h"); /* Restore pre-program screen */
printf("\033[?1049l"); /* Disables buffer mode in terminal */
}
int main()
{
int t = 0;
if (signal(SIGINT, handler) == SIG_ERR); /* Registers handler()*/
{
printf("ERROR! unable to register handler for SIGINT!\n");
}
main_cycle = 1; /* Sets runprogram flag */
init_terminal();
/* Main cycle */
while(main_cycle)
{
init_screen(); /* Erase old image from buffer */
for (int x = 1; x < WIDTH; x++)
{
int y = HEIGHT * x / WIDTH;
int x_tmp = (x + t / DAMPING) % WIDTH;
draw_pixel(x_tmp, y, '#');
}
printf("\033[H"); /* Set cursor to upper-left position */
printf("%s", screen); /* Draws "screen" */
t++;
}
restore_terminal();
return 0;
}
Самостоятельная работа:
- Указатели в С. Ссылки. Указатели на функции.
- Препроцессор языка C. Директива #include. Зачем её писать? Почему эта директива не является "подключением библиотеки"? Как можно использовать библиотечную функцию printf() без использования директивы #include? В чём разница между #include <...> и #include "..."? Заголовочные файлы - где компилятор их находит?
- Компиляция. Линковка. Динамическая линковка против статической линковки - в чём разница?
- Функция как адрес в памяти. Стек вызовов. Размещение локальных переменных в памяти процесса. Размещение аргументов функции, возвращаемого значения, адреса возврата на стеке.
- Почему нельзя создать массив неизвестного на момент компиляции размера на стеке?
- Выведите и посмотрите в терминале код возврата вашей программы. Зачем нужен код возврата? А как получить код возврата из баш-скрипта и использовать его? Зачем нужны скрипты в терминале?