[Перевод] Учебное пособие - Написание оболочки на C

Shell Программа перевода самородков C

Легко думать, что вы «недействительноПрограммисты». Есть программы, которыми пользуются все, и их разработчиков можно легко похвалить. Хотя разрабатывать крупномасштабные программные проекты непросто, зачастую основные идеи такого программного обеспечения очень просты. Самостоятельная реализация такой программы Программирование — это забавный способ доказать, что вы можете быть настоящим программистом.Итак, в этом посте описывается, как я написал свою собственную простую оболочку Unix на C. Я надеюсь, что другие люди тоже найдут это забавным.

Оболочка, описанная в этой статье (называетсяlsh), допустимыйGitHubПолучите его исходный код на .

Внимание ученики школы!Во многих курсах есть задания, требующие написания оболочки, и некоторые преподаватели знают такие учебники и код. Если вы являетесь студентом такого курса, пожалуйста, не копируйте (или копируйте и не изменяйте) код здесь без разрешения. япредположениеНе полагайтесь на этот учебник.

Базовый жизненный цикл Shell

Давайте посмотрим на оболочку сверху вниз. В своем жизненном цикле оболочка выполняет три основные функции.

  • инициализация: на этом этапе оболочка обычно загружает и выполняет свой файл конфигурации. Эти конфигурации изменяют поведение оболочки.
  • объяснить выполнение: Затем оболочка считывает команды из стандартного ввода (который может быть интерактивным вводом или файлом) и выполняет их.
  • прекращение: когда все команды выполнены, оболочка выполняет команду выключения, освобождает всю память, а затем завершает работу.

Эти три шага слишком общие и могут быть применены к любой программе, но мы можем использовать их для основ нашей оболочки. Наша оболочка будет простой, без каких-либо файлов конфигурации и без каких-либо команд выключения. Затем нам просто нужно вызвать функцию цикла, а затем завершить работу. Но для архитектуры нам нужно помнить, что жизненный цикл программы — это не просто циклы.

int main(int argc, char **argv)
{
  // 如果有配置文件,则加载。

  // 运行命令循环
  lsh_loop();

  // 做一些关闭和清理工作。

  return EXIT_SUCCESS;
}

Здесь вы можете видеть, что я только что написал функцию:lsh_loop(). Эта функция будет зацикливаться, интерпретировать и выполнять команду за командой. Далее мы увидим, как этот цикл реализован.

Основные циклы в оболочке

Мы уже знаем, как запускается программа оболочки. Теперь рассмотрим основную логику программы: что делает оболочка в своем цикле? Простой способ обработки команд состоит в выполнении следующих трех шагов:

  • читать: Чтение команды из стандартного ввода.
  • анализировать: Разделить командную строку на имя программы и параметры.
  • воплощать в жизнь: запустить анализируемую команду.

Ниже я перевожу эти идеи вlsh_loop()код:

void lsh_loop(void)
{
  char *line;
  char **args;
  int status;

  do {
    printf("> ");
    line = lsh_read_line();
    args = lsh_split_line(line);
    status = lsh_execute(args);

    free(line);
    free(args);
  } while (status);
}

Давайте посмотрим на этот код. Первые несколько строк — это просто объявления. Цикл Do-while более удобен при проверке переменной состояния, поскольку он выполняется один раз перед проверкой значения переменной. Внутри цикла мы печатаем приглашение, вызываем функцию для чтения строки ввода, разбиваем строку на аргументы и выполняем эти аргументы соответственно. Наконец, мы освобождаем место в памяти, ранее выделенное для строки и аргументов. Обратите внимание, что мы используемlsh_execute()Возвращаемая переменная состояния определяет, когда выйти из цикла.

прочитать строку ввода

Чтение строки из стандартного ввода звучит просто, но это может быть сложно сделать в C. Плохая новость заключается в том, что у вас нет возможности заранее узнать, как долго пользователь будет печатать в оболочке. Следовательно, вы не можете просто выделить кусок пространства, надеясь удержать ввод пользователя, но вы должны временно выделить определенную длину пространства, а затем перераспределить больше места, когда ввод пользователя не подходит. Это распространенная стратегия в языке C, и мы также будем использовать этот метод для реализацииlsh_read_line().

#define LSH_RL_BUFSIZE 1024
char *lsh_read_line(void)
{
  int bufsize = LSH_RL_BUFSIZE;
  int position = 0;
  char *buffer = malloc(sizeof(char) * bufsize);
  int c;

  if (!buffer) {
    fprintf(stderr, "lsh: allocation error\n");
    exit(EXIT_FAILURE);
  }

  while (1) {
    // 读一个字符
    c = getchar();

    // 如果我们到达了 EOF, 就将其替换为 '\0' 并返回。
    if (c == EOF || c == '\n') {
      buffer[position] = '\0';
      return buffer;
    } else {
      buffer[position] = c;
    }
    position++;

    // 如果我们超出了 buffer 的大小,则重新分配。
    if (position >= bufsize) {
      bufsize += LSH_RL_BUFSIZE;
      buffer = realloc(buffer, bufsize);
      if (!buffer) {
        fprintf(stderr, "lsh: allocation error\n");
        exit(EXIT_FAILURE);
      }
    }
  }
}

В первой части много объявлений. Если вы не заметили, я склонен использовать старый стиль C, помещая объявления переменных перед другим кодом. Точка этой функции (очевидно, бесконечная)while (1)в цикле. В этом цикле мы читаем символ (и сохраняем его какintвведите вместоcharтип, это важно! EOF — это целочисленное значение, а не символьное значение. Если вы хотите использовать его значение в качестве условия суждения, вам нужно использоватьintтип. Это распространенная ошибка новичков в языке C. ). Если символ является символом новой строки или EOF, мы завершаем текущую строку нулем и возвращаем ее. В противном случае мы добавляем этот символ в текущую строку.

Далее мы проверяем, не превысит ли следующий символ текущий размер буфера. Если это так, мы сначала перераспределяем буфер (и проверяем, было ли выделение памяти успешным). Вот и все.

Если вы знакомы с новой версией стандартной библиотеки C, вы заметитеstdio.hсуществует одинgetline()функцию, которая почти аналогична той, которую мы только что реализовали. Честно говоря, я не знал о существовании этой функции, пока не написал приведенный выше код. Эта функция была расширением GNU для стандартной библиотеки C до 2008 года, и большинство современных систем Unix уже должны иметь эту функцию. Я сохраню код, который я написал, и я рекомендую вам изучить его таким образом, прежде чем использовать его.getline. В противном случае вы потеряете шанс учиться! Во всяком случае, естьgetlineПосле этого функция не имеет значения:

char *lsh_read_line(void)
{
  char *line = NULL;
  ssize_t bufsize = 0; // 利用 getline 帮助我们分配缓冲区
  getline(&line, &bufsize, stdin);
  return line;
}

Разобрать строку ввода

Итак, давайте вернемся к исходному циклу. в настоящее время мы достигаемlsh_read_line(), получил строку ввода. Теперь нам нужно разобрать эту строку в список параметров. Я собираюсь сделать здесь огромное упрощение, предполагая, что мы не разрешаем экранирование кавычек и обратной косой черты в наших аргументах командной строки и просто используем пробелы в качестве разделителя между аргументами. В этом случае командаecho "this message"вместо использования одного параметраthis messageВызвать эхо, но с двумя аргументами:"thisиmessage".

С этими упрощениями все, что нам нужно сделать, это пометить строку пробелом в качестве разделителя. Это означает, что мы можем использовать традиционные библиотечные функции.strtokПриходи и поработай для нас.

#define LSH_TOK_BUFSIZE 64
#define LSH_TOK_DELIM " \t\r\n\a"
char **lsh_split_line(char *line)
{
  int bufsize = LSH_TOK_BUFSIZE, position = 0;
  char **tokens = malloc(bufsize * sizeof(char*));
  char *token;

  if (!tokens) {
    fprintf(stderr, "lsh: allocation error\n");
    exit(EXIT_FAILURE);
  }

  token = strtok(line, LSH_TOK_DELIM);
  while (token != NULL) {
    tokens[position] = token;
    position++;

    if (position >= bufsize) {
      bufsize += LSH_TOK_BUFSIZE;
      tokens = realloc(tokens, bufsize * sizeof(char*));
      if (!tokens) {
        fprintf(stderr, "lsh: allocation error\n");
        exit(EXIT_FAILURE);
      }
    }

    token = strtok(NULL, LSH_TOK_DELIM);
  }
  tokens[position] = NULL;
  return tokens;
}

Этот код выглядит иlsh_read_line()чрезвычайно похоже. Это потому, что они так похожи! Мы используем ту же стратегию — берем буфер и расширяем его динамически. Но здесь мы используем массив указателей с завершающим нулем, а не массив символов с завершающим нулем.

В начале функции мы начинаем вызыватьstrtokразделить токены. Эта функция возвращает указатель на первый токен.strtok()На самом деле он возвращает указатель на внутреннюю часть строки, которую вы передали, и помещает байты в конец каждого токена.\0. Мы помещаем каждый возвращаемый указатель в массив (буфер) указателей на символы.

Наконец, мы перераспределяем массив указателей, если это необходимо. Этот процесс повторяется до тех пор, покаstrtokПока токены не будут возвращены. На этом этапе мы устанавливаем конец списка токенов на нулевой указатель.

На этом наша работа выполнена, и мы получаем массив токенов. Далее мы можем выполнить команду. Итак, вопрос в том, как мы выполняем команду?

Как Shell запускает процессы

Теперь мы действительно подошли к сердцу оболочки. Основная функция оболочки — запуск процессов. Так что написание оболочки означает, что у вас есть хорошее представление о том, что происходит в процессе и как процесс запускается. Итак, здесь я собираюсь немного отвлечься и поговорить о процессах в Unix.

В Unix есть только два способа запустить процесс. Первый (на самом деле не единственный способ) — стать процессом Init. При запуске Unix-машины загружается ее ядро. После загрузки и инициализации ядра запускается один процесс, называемый процессом Init. Этот процесс всегда будет запускаться при включении машины и отвечает за управление и запуск других необходимых вам процессов, чтобы машину можно было использовать в обычном режиме.

Поскольку большинство программ не являются Init, есть только один способ запустить процесс: использоватьfork()системный вызов. При вызове этой функции операционная система сделает копию текущего процесса и позволит им работать одновременно. Исходный процесс называется «родительским процессом», а новый процесс называется «дочерним процессом».fork()Он вернет 0 в дочернем процессе и идентификатор процесса (PID) дочернего процесса в родительском процессе. По сути, это означает, что единственный способ запустить новый процесс — скопировать существующий.

Кажется, это проблема. В частности, когда вы хотите запустить новый процесс, вы определенно не хотите снова запускать ту же программу — вы хотите запустить другую программу. Этоexec()Что делают системные вызовы. Он заменит текущую запущенную программу совершенно новой программой. Это означает, что всякий раз, когда вы звонитеexec, операционная система остановит ваш процесс, загрузит новую программу, а затем запустит новую программу на месте. Процесс никогда не начинается сexec()Возвращается в вызове (если не возникает ошибка).

Благодаря этим двум системным вызовам у нас есть все необходимое для запуска большинства программ в Unix. Во-первых, существующий процесс разветвляется на два разных процесса. Затем дочерний процесс используетexec()Замените запущенную программу новой. Родительский процесс может продолжать заниматься другими делами, даже используя системные вызовы.wait()Следите за подпроцессами.

Какие! Мы так много говорили. Но с этим в качестве фона следующий код для запуска программы имеет смысл:

int lsh_launch(char **args)
{
  pid_t pid, wpid;
  int status;

  pid = fork();
  if (pid == 0) {
    // 子进程
    if (execvp(args[0], args) == -1) {
      perror("lsh");
    }
    exit(EXIT_FAILURE);
  } else if (pid < 0) {
    // Fork 出错
    perror("lsh");
  } else {
    // 父进程
    do {
      wpid = waitpid(pid, &status, WUNTRACED);
    } while (!WIFEXITED(status) && !WIFSIGNALED(status));
  }

  return 1;
}

Эта функция использует список параметров, который мы создали ранее. Затем он разветвляет текущий процесс и сохраняет возвращаемое значение. когдаfork()При возвращении мы фактически имеемдваПроцессы, работающие одновременно. Дочерний процесс войдет в первую ветвь if (pid == 0).

В дочернем процессе мы хотим запускать введенные пользователем команды. Итак, мы используемexecОдин из нескольких вариантов системного вызова:execvp.execРазличные варианты делают что-то немного по-разному. Некоторые принимают строковые аргументы переменной длины, некоторые принимают списки строк, а некоторые позволяют вам устанавливать среду, в которой выполняется процесс.execvpЭтот вариант принимает имя программы и массив строковых аргументов (также называемый вектором (отсюда 'v') (первым элементом массива должно быть имя программы). «p» означает, что нам не нужно указывать путь к файлу программы, только имя файла, и пусть операционная система ищет путь к файлу программы.

Если команда exec возвращает -1 (вернее, если это так), мы знаем, что что-то пошло не так. Затем мы используемperrorРаспечатайте системное сообщение об ошибке вместе с названием нашей программы, чтобы пользователь знал, что пошло не так. Затем мы позволяем оболочке продолжать работать.

Второе условие if (pid < 0)экзаменfork()ошибка. Если есть ошибка, мы печатаем ошибку и продолжаем - мы не делаем больше никакой обработки ошибок, кроме информирования пользователя. Мы позволяем пользователю решить, нужно ли ему отказаться.

Третье условие if указывает, чтоfork()Выполнено успешно. Здесь будет выполняться родительский процесс. Мы знаем, что дочерний процесс будет выполнять процесс команды, поэтому родительский процесс должен дождаться завершения выполнения команды. Мы используемwaitpid()ждать, пока процесс изменит состояние. К несчастью,waitpid()Есть много вариантов (например,exec()Такой же). Процесс может изменить свое состояние разными способами, и не все состояния указывают на завершение процесса. Процесс может завершиться (как обычно, так и с кодом ошибки), или он может быть завершен по сигналу. Итак, нам нужно использоватьwaitpid()Макрос предназначен для ожидания выхода или завершения процесса. Наконец, функция возвращает 1, указывая на то, что функция верхнего уровня должна продолжать запрашивать у пользователя ввод.

Встроенные функции оболочки

Вы, возможно, узнали,lsh_loop()функция называетсяlsh_execute(). Но функция, которую мы написали выше, называетсяlsh_launch(). Это интересует. Хотя команды, выполняемые оболочкой, являются программой, есть и другие. Некоторые команды встроены в оболочку.

Причина здесь на самом деле довольно проста. Если вы хотите изменить текущий каталог, вам нужно использовать функциюchdir(). Проблема в том, что текущий каталог является атрибутом процесса. Ну, если вы напишете метод с именемcdпрограмма для изменения текущего каталога, она изменит только свой собственный текущий каталог, а затем прекратит работу. Текущий каталог его родительского процесса не изменяется. Поэтому он должен выполняться самим процессом оболочки.chdir(), чтобы обновить текущий каталог. Затем, когда он запускает дочерний процесс, дочерний процесс также наследует этот новый каталог.

Точно так же, если есть программа с именемexit, он также не может заставить оболочку, которая его вызвала, выйти. Эта команда также должна быть встроена в оболочку. Кроме того, большинство оболочек запускают сценарии настройки, такие как~/.bashrc) для настройки. Эти сценарии используют некоторые команды, которые изменяют поведение оболочки. Эти команды, если они реализованы самой оболочкой, также изменяют только собственное поведение оболочки.

Поэтому имеет смысл добавить некоторые команды в саму оболочку. Команда, которую я добавляю в свою оболочку,cd,exitиhelp. Вот реализация их функции:

/*
  内置 shell 命令的函数声明:
 */
int lsh_cd(char **args);
int lsh_help(char **args);
int lsh_exit(char **args);

/*
  内置命令列表,以及它们对应的函数。
 */
char *builtin_str[] = {
  "cd",
  "help",
  "exit"
};

int (*builtin_func[]) (char **) = {
  &lsh_cd,
  &lsh_help,
  &lsh_exit
};

int lsh_num_builtins() {
  return sizeof(builtin_str) / sizeof(char *);
}

/*
  内置命令的函数实现。
*/
int lsh_cd(char **args)
{
  if (args[1] == NULL) {
    fprintf(stderr, "lsh: expected argument to \"cd\"\n");
  } else {
    if (chdir(args[1]) != 0) {
      perror("lsh");
    }
  }
  return 1;
}

int lsh_help(char **args)
{
  int i;
  printf("Stephen Brennan's LSH\n");
  printf("Type program names and arguments, and hit enter.\n");
  printf("The following are built in:\n");

  for (i = 0; i < lsh_num_builtins(); i++) {
    printf("  %s\n", builtin_str[i]);
  }

  printf("Use the man command for information on other programs.\n");
  return 1;
}

int lsh_exit(char **args)
{
  return 0;
}

Этот код состоит из трех частей. Первая часть включает в себя мою функциюпредварительная декларация. Предварительное объявление — это когда вы объявляете (но не определяете) символ, вы можете использовать его до его определения. Я делаю это, потому чтоlsh_help()Используется массив встроенных команд, и этот массив включает в себяlsh_help(). Лучший способ разорвать этот цикл зависимости — использовать предварительные объявления.

Вторая часть представляет собой массив имен встроенных команд, за которыми следует массив соответствующих им функций. Это сделано для того, чтобы встроенные команды можно было добавлять в будущем, просто изменяя эти массивы, а не изменяя громоздкий оператор «switch» где-то в коде. если ты не понимаешьbuiltin_funcутверждение, что нормально! Я тоже не понимаю. Это массив указателей на функции (функция, которая принимает массив строк в качестве аргументов и возвращает целое число). Любое объявление указателей на функции в C будет сложным. Мне все еще нужно посмотреть, как объявляются указатели на функции!

Наконец, я реализовал каждую функцию.lsh_cd()Сначала функция проверяет, существует ли ее второй аргумент, и выводит сообщение об ошибке, если это не так. Затем он вызываетchdir(), проверяет наличие ошибок и возвращает. Вспомогательная функция выводит красивые сообщения вместе с именами всех встроенных функций. Функция выхода возвращает 0, что является сигналом для выхода из командного цикла.

Объединение встроенных команд и процессов

Последняя недостающая часть нашей программы — это реализацияlsh_execute(). Эта функция либо запускает встроенную команду, либо запускает процесс. Если вы дочитали до этого места, то знаете, что у нас осталась очень простая функция для реализации:

int lsh_execute(char **args)
{
  int i;

  if (args[0] == NULL) {
    // 用户输入了一个空命令
    return 1;
  }

  for (i = 0; i < lsh_num_builtins(); i++) {
    if (strcmp(args[0], builtin_str[i]) == 0) {
      return (*builtin_func[i])(args);
    }
  }

  return lsh_launch(args);
}

Все, что делает эта функция, — это проверяет, совпадает ли команда с каждой встроенной командой, и если да, запускает встроенную команду. Если ни одна встроенная команда не соответствует, мы вызываемlsh_launch()чтобы начать процесс. Следует отметить, что возможно, что пользователь ввел пустую строку или строка содержит только пустые символы.argsСодержит только нулевые указатели. Итак, нам нужно проверить эту ситуацию в самом начале.

все вместе

Выше приведен весь код для этой оболочки. Если вы читали его, вы должны полностью понять, как работает оболочка. Чтобы попробовать это (на машине с Linux), вам нужно скопировать эти фрагменты кода в файл (main.c), а затем скомпилируйте его. Обязательно включите в код только одинlsh_read_line()реализация. Вам необходимо включить следующие заголовочные файлы в начало файла. Я добавил комментарии, чтобы вы знали, откуда взялась каждая функция.

  • #include <sys/wait.h>
    • waitpid()и связанные с ним макросы
  • #include <unistd.h>
    • chdir()
    • fork()
    • exec()
    • pid_t
  • #include <stdlib.h>
    • malloc()
    • realloc()
    • free()
    • exit()
    • execvp()
    • EXIT_SUCCESS, EXIT_FAILURE
  • #include <stdio.h>
    • fprintf()
    • printf()
    • stderr
    • getchar()
    • perror()
  • #include <string.h>
    • strcmp()
    • strtok()

Когда у вас будут готовы файлы кода и заголовков, просто запуститеgcc -o main main.cкомпилировать, то./mainзапустить его.

В качестве альтернативы вы можете начатьGitHubПолучите код. Эта ссылка ведет непосредственно к текущей версии кода на момент написания этой статьи — я могу обновить код в будущем, добавив некоторые новые функции. Если код обновится, я постараюсь обновить детали и идеи реализации кода в этой статье.

Эпилог

Если вы читаете это, удивляетесь, откуда я знаю, как использовать эти системные вызовы. Ответ прост: через справочные страницы. существуетman 3pДля каждого системного вызова имеется обширная документация. Если вы знаете, что ищете, и просто хотите узнать, как это использовать, справочная страница — ваш лучший друг. Если вы не знаете, какой интерфейс предоставляет вам стандартная библиотека C и Unix, рекомендую прочитатьСпецификация POSIX, особенно Глава 13, «Файлы заголовков». Вы можете найти каждый заголовочный файл и то, что в нем нужно определить.

Очевидно, что эта оболочка недостаточно функциональна. Некоторые вопиющие упущения:

  • Аргументы разделяются только пробелами, экранирование кавычек и обратной косой черты не учитывается.
  • Никаких пайпов и редиректов.
  • Слишком мало встроенных команд.
  • Подстановочных знаков нет.

Реализация этих функций на самом деле очень интересна, но это далеко за пределами того, что может вместить статья, подобная моей. Если я начну внедрять что-то из этого, я обязательно напишу об этом дополнительную статью. Однако я призываю читателей попробовать реализовать эти функции самостоятельно. Если вам это удастся, напишите мне в разделе комментариев ниже, я хотел бы увидеть ваш код.

Наконец, спасибо за чтение этого руководства (если кто-то это делает). Мне было очень весело писать его, и я надеюсь, что вы получите удовольствие от чтения. Дайте мне знать, что вы думаете в комментариях!

обновить:В предыдущей версии этой статьи яlsh_split_line()Я столкнулся с некоторыми неприятными ошибками, которые просто компенсировали друг друга. Спасибо пользователю Reddit /u/munmap (и другим комментаторам) за обнаружение этих ошибок! существуетздесьПосмотрите, что я делаю неправильно.

Обновление два:Спасибо пользователю GitHub ghswa за помощь, которую я забылmalloc()проверка нулевого указателя. Он/она также указываетgetlineизсправочная страницаПредусмотрено, что место в памяти, занимаемое первым параметром, должно быть освобождено, поэтому мое использованиеgetline()изlsh_read_line()реализуется,lineдолжен быть инициализирован дляNULL.

Если вы обнаружите ошибки в переводе или в других областях, требующих доработки, добро пожаловать наПрограмма перевода самородковВы также можете получить соответствующие бонусные баллы за доработку перевода и PR. начало статьиПостоянная ссылка на эту статьюЭто ссылка MarkDown этой статьи на GitHub.


Программа перевода самородковэто сообщество, которое переводит высококачественные технические статьи из ИнтернетаНаггетсДелитесь статьями на английском языке на . Охват контентаAndroid,iOS,внешний интерфейс,задняя часть,блокчейн,продукт,дизайн,искусственный интеллекти другие поля, если вы хотите видеть больше качественных переводов, пожалуйста, продолжайте обращать вниманиеПрограмма перевода самородков,официальный Вейбо,Знай колонку.