Создание базовой оболочки в C: парсинговые команды и процессы выполнения
Создание оболочки с нуля на C остается одним из лучших способов углубить ваше понимание того, как операционные системы управляют процессами, обрабатывают пользовательский ввод и выполняют программы. В то время как современные оболочки, такие как Bash, Zsh и Fish, невероятно сложны, их основная функциональность сводится к простому циклу: читайте команду, анализируйте ее, создайте новый процесс и ждите, пока он закончится. В этом расширенном руководстве вы подробно пройдетесь по каждому из этих шагов, от настройки надежного парсера ввода до создания процесса с помощью fork и exec . К концу у вас будет рабочая оболочка, которую вы можете расширить с помощью перенаправления, конвейеров и управления работой. Код намеренно прост, чтобы выделить основные концепции системы.
Что на самом деле делает раковина
В основе оболочки лежит интерпретатор командной строки. Она обеспечивает текстовый интерфейс, где пользователи вводят команды, а оболочка переводит эти команды в действия, выполняемые операционной системой. Когда вы вводите ls-la, оболочка должна найти исполняемый файл ls, создать новый процесс для его запуска, передать аргументы -la, а затем дождаться окончания этого процесса, прежде чем подсказать вам следующую команду. Это, казалось бы, простое поведение включает в себя несколько фундаментальных системных вызовов и тщательное управление памятью и процессами.
Создание минимальной оболочки учит вас:
- Чтение и токенизация входных данных от стандартного входного сигнала
- Создание и управление процессами с помощью вилки
- Замена образа памяти процесса на exec
- Совместная работа родителей и детей с ,
- Обработка распространенных ошибок изящно
Понимание этих строительных блоков дает вам представление о том, как все Unix-подобные системы работают под капотом, и это обеспечивает прочную основу для изучения более продвинутых функций, таких как обработка сигналов, управление работой и межпроцессная связь.
Обсуждение Shell Loop
Каждая оболочка построена вокруг основного контура, который повторяется бесконечно, пока пользователь не попросит выйти. Этот контур печатает подсказку, читает строку ввода, анализирует ее, а затем действует по парсируемой команде. Простейшая версия выглядит так в псевдокоде:
while (1) {
print_prompt();
read_input();
parse_input();
execute_command();
}
В C вы обычно используете fgets для чтения ввода, потому что он обрабатывает границы линий и переполнение буфера более безопасно, чем gets. Подсказка может быть такой же простой, как строка mysh>, но реальные оболочки часто включают текущий рабочий каталог, имя пользователя и имя хоста.
Обработка Edge Cases in Input
Ввод пользователя редко бывает чистым. Надежная оболочка должна обрабатывать пустые строки, ведущие и отстающие белые поля, чрезвычайно длинные команды и условие конца файла (Ctrl + D). Если fgets возвращает NULL, вы должны вырваться из петли и выйти изящно. Если ввод состоит только из белого пространства, оболочка должна просто перезапускаться, не пытаясь ничего выполнить. Вы также должны обрабатывать случай, когда входная линия длиннее вашего фиксированного буфера, либо усечением его, либо динамическим изменением размера буфера. Для базовой реализации буфер 1024 байтов обычно безопасен, но вы всегда должны проверять, что полная строка была прочитана, проверяя, что строка заканчивается новым строковым символом.
#define MAX_INPUT 1024
char input[MAX_INPUT];
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break; // EOF
}
// Remove trailing newline, if present
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
} else {
// Input too long, flush remaining characters
int ch;
while ((ch = getchar()) != '\n' && ch != EOF);
}
Парсинг команд в аргументы
После того, как у вас есть чистая строка ввода, вам нужно разделить ее на токены. Первый токен - это имя команды (например, ls ), а остальные токены - аргументы этой команды. Стандартная библиотека C предоставляет strtok для этой цели, но вы должны быть осторожны, потому что strtok изменяет исходную строку и не является безвредной для ниток. Для однопоточной оболочки, такой как эта, она работает нормально.
char *args[MAX_ARGS];
int arg_count = 0;
args[arg_count] = strtok(input, " \t");
while (args[arg_count] != NULL && arg_count < MAX_ARGS - 1) {
arg_count++;
args[arg_count] = strtok(NULL, " \t");
}
args[arg_count] = NULL; // execvp expects a NULL-terminated array
Этот токенизатор разделяет вход на пространства и вкладки. Он не обрабатывает цитируемые строки, поэтому команда, подобная , перекликается с «привет, мир» , неправильно разбивается на три токена, а не на два. Обработка цитат — это ценное улучшение, которое требует более сложного парсера, но для базовой оболочки этого простого подхода достаточно для запуска большинства команд с одним словом.
После токенизации следует проверить, является ли первый токен NULL (пустой командой). Если да, просто продолжайте следующую итерацию цикла без разветвления.
Встроенные команды
Не все команды должны порождать новый процесс. Некоторые команды, такие как cd и exit, должны выполняться самим процессом оболочки, потому что они влияют на состояние оболочки. Например, cd изменяет текущий рабочий каталог оболочки; если вы разветвили процесс для cd, этот ребенок изменит свой собственный каталог, а затем выйдет, оставив рабочий каталог родителя неизменным.
выход
Команда exit немедленно завершает оболочку. Это самая простая встроенная реализация: просто проверьте, равен ли первый токен «выходу» и вырыву из основного цикла. По желанию вы можете принять аргумент о статусе выхода и передать его системному вызову exit.
Справочник изменений (cd)
Команда cd требует, чтобы вы позвонили chdir. Каталог целей является вторым аргументом. Если аргумент не предоставлен, вы можете по умолчанию перейти в домашний каталог пользователя (доступен через HOME переменную среды. Всегда проверяйте значение возврата chdir и распечатайте сообщение об ошибке, если каталог не существует или недоступен.
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
if (path == NULL) {
fprintf(stderr, "cd: HOME not set\n");
continue;
}
}
if (chdir(path) != 0) {
perror("cd");
}
continue; // skip fork/exec
}
Создание процесса с помощью Fork
Для любой команды, которая не является встроенной, ваша оболочка должна создать процесс для выполнения. Система вызова fork создает новый процесс, дублируя процесс вызова. Новый процесс называется ребенком, а оригиналом является родитель. После fork оба процесса продолжают выполняться из одной и той же точки в коде. Единственная разница заключается в обратном значении fork: он возвращает 0 ребенку, а PID ребенка родителю.
pid_t pid = fork();
if (pid == -1) {
perror("fork");
continue;
}
if (pid == 0) {
// Child process
// ...
} else {
// Parent process
// ...
}
Почему Форк?
Вы можете задаться вопросом, почему вам вообще нужно создавать отдельный процесс. Причина в том, что exec, который загружает новую программу в память, полностью заменяет текущий процесс. Если оболочка называется exec напрямую, программа оболочки будет заменена и никогда не вернется, чтобы принимать новые команды.
Выполнение программы с помощью Exec
Семейство функций FLT:0 заменяет текущий процесс новой программой.execl,execlp,execle,execvp,execvp,execvpe. Ключевое различие между ними заключается в том, как программа расположена и как передаются аргументы. Для вашей оболочки execvpexecvp является наиболее удобным, поскольку он ищет переменную среды PATH для исполняемого и принимает NULL-концевой массив аргументов, который точно соответствует тому, что вы построили во время разбора.
if (pid == 0) {
// Child process
execvp(args[0], args);
// If execvp returns, an error occurred
perror("exec");
exit(EXIT_FAILURE);
}
Обратите внимание на призыв к выход после ошибки . Если execvp не удается (например, потому что команда не существует), процесс ребенка должен прекратиться; в противном случае он будет продолжать работать с любым кодом, который следует, что обычно является циклом оболочки родителя.
В ожидании детского процесса
После разветвления родительский процесс обычно ждет, пока ребенок закончит, прежде чем снова посоветовать. Это делается с помощью функции wait или waitpid. waitpid дает вам больше контроля, потому что вы можете указать, какого ребенка ждать (используя PID, возвращенный fork) и потенциально установить параметры, чтобы избежать блокировки.
int status;
waitpid(pid, &status, 0);
Переменная status содержит информацию о том, как ребенок закончил.WIFEXITED, WEXITSTATUS, WIFSIGNALED и WTERMSIG для извлечения деталей. Для базовой оболочки достаточно знать, что ребенок закончил; печать кода выхода — хороший штрих для отладки.
Блокировка vs. неблокировка
Простой waitpid вызов выше блокирует родителя до выхода ребенка. Это правильное поведение для процесса переднего плана. Если вы позже добавите поддержку фоновых процессов (запуск команды с &), вы установите WNOHANG опцию, чтобы избежать блокировки, и вам нужно будет управлять списком детских PID, чтобы пожинать их позже.
Соединяя вместе всю оболочку
Вот полная минимальная оболочка, которая объединяет все обсуждаемые до сих пор части. Она обрабатывает встроенные exit и cd, анализирует ввод с использованием strtok, вилки для внешних команд и ждет, пока ребенок закончит. Для ясности, проверка ошибок включена, но сохранена краткой.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#define MAX_INPUT 1024
#define MAX_ARGS 64
int main(void) {
char input[MAX_INPUT];
char *args[MAX_ARGS];
int should_run = 1;
while (should_run) {
printf("mysh> ");
fflush(stdout);
if (fgets(input, MAX_INPUT, stdin) == NULL) {
printf("\n");
break;
}
// Remove trailing newline
size_t len = strlen(input);
if (len > 0 && input[len-1] == '\n') {
input[len-1] = '\0';
}
// Tokenize
int i = 0;
args[i] = strtok(input, " \t");
while (args[i] != NULL && i < MAX_ARGS - 1) {
i++;
args[i] = strtok(NULL, " \t");
}
args[i] = NULL;
if (args[0] == NULL) {
continue; // empty line
}
// Handle built-in commands
if (strcmp(args[0], "exit") == 0) {
should_run = 0;
continue;
}
if (strcmp(args[0], "cd") == 0) {
const char *path = args[1];
if (path == NULL) {
path = getenv("HOME");
}
if (chdir(path) != 0) {
perror("cd");
}
continue;
}
// Fork and execute external command
pid_t pid = fork();
if (pid < 0) {
perror("fork");
continue;
}
if (pid == 0) {
// Child
execvp(args[0], args);
perror("exec");
exit(EXIT_FAILURE);
} else {
// Parent waits
int status;
waitpid(pid, &status, 0);
}
}
return 0;
}
Этот код представляет собой полную рабочую оболочку. Скопируйте его в файл под названием myshell.c, скомпилируйте его с gcc-o myshell myshell.c и запустите его. Вы увидите подсказку, в которой вы можете ввести команды, такие как ls, pwd, echo hello и cd/tmp. Команда exit завершает оболочку.
Общие подводные камни и советы по отладке
Даже при таком небольшом количестве кода могут пойти не так несколько вещей. Вот наиболее частые проблемы и как их исправить:
Командный не найден
Если вы вводите команду, которой не существует (например, foobar ), execvp возвращается и ребенок печатает «exec: Нет такого файла или каталога» перед выходом. Это правильное поведение, но вы можете проверить errno после execvp не различает «файл не найден» и «разрешение отказано».
Пропавшая Newline или сокращенный вход
Если ваша подсказка появляется, не дожидаясь ввода, вероятной причиной являются оставшиеся символы в буфере ввода от предыдущего вызова. Всегда проверяйте, что fgets потребляет всю строку (т.е. что последний символ перед нулевым терминатором является новой линией). Если это не так, промойте оставшийся вход, как показано ранее.
Зомби-процессы
Если вы забудете позвонить , (или , ), то детские процессы, которые заканчиваются, станут зомби, пока вы их не пожнете. Оболочка в примере вызывает , поэтому зомби не должны появляться. Однако, если вы позже добавите фоновые процессы и не сможете их пожинать, накопление зомби может стать проблемой. Решение состоит в установке обработчика SIGCHLD, который вызывает , который вызывает , с WNOHANG, чтобы очистить завершенных детей.
Расширение оболочки
После того, как базовая оболочка будет работать, вы можете добавить функции, которые приближают ее к реальной оболочке. Каждая функция научит вас большему о операционной системе.
Перенаправление ввода/вывода
Поддержка >, < и >> требует от вас разбора командной строки для этих операторов, открытия соответствующих файлов с использованием open и использования dup2 для перенаправления стандартного ввода или вывода перед вызовом execvp. Это делается в процессе ребёнка между fork и exec.
Трубы
Ввод выходной сигнал одной команды во входную сигнальную систему другой команды (]cmd1 | cmd2) более активен. Вам необходимо создать трубу с pipe, перенаправить выходную сигнализацию одного ребенка на конец записи трубы, а другой — на конец чтения, а затем дождаться завершения обоих.
Контроль за работой
Добавление выполнения фона (]& в конце команды) и возможность выводить рабочие места на передний план требует управления таблицей вакансий, обработки SIGTSTP , SIGCONT и SIGCHLD и использования tcsetpgrp для управления владением терминалом.
История командования
Внедрение простого механизма истории (стрелки вверх/вниз для запоминания предыдущих команд) включает захват ввода с использованием режима необработанного терминала (через tcgetattr и tcsetattr ) или с использованием библиотеки readline, которая обеспечивает эту функциональность из коробки.
Дальнейшее чтение и ресурсы
Для углубления понимания представленных здесь концепций неоценимы следующие ресурсы:
- Руководство по библиотеке GNU C: Примеры создания процессов — официальная документация по вилкам , exec и ождите .
- Спецификации базы Open Group: командный язык Shell — формальная спецификация поведения оболочки POSIX; полезна, если вы хотите соответствовать стандарту.
- Википедия: Unix shell — Широкий обзор истории оболочки, вариантов и особенностей.
- Руководство Beej по межпроцессной коммуникации — охватывает трубы, FIFO, очереди сообщений и общую память с практическими примерами.
Заключение
Создание базовой оболочки в C — это больше, чем академическое упражнение; это заставляет вас напрямую взаимодействовать с основными абстракциями операционной системы. Вы видели, как читать и анализировать пользовательский ввод, как fork создает новый процесс, и как exec заменяет память этого процесса запрошенной программой. Вы также добавили встроенные команды, которые должны работать в собственном контексте оболочки и обрабатывать ошибки изящно. Полный код оболочки, предоставленный в этой статье, является прочной отправной точкой, которую вы можете расширить с помощью перенаправления, конвейеров, управления работой и истории. Каждое расширение углубит ваше понимание управления процессом, дескрипторов файлов и сигналов. К тому времени, когда вы добавили даже несколько из этих функций, у вас будет инструмент, который действительно полезен и гораздо более сильное понимание того, как современные оболочки работают под поверхностью.