Linux C如何实现一个简易Shell?

99ANYc3cd6
预计阅读时长 37 分钟
位置: 首页 C语言 正文
  1. 基础版:能够读取用户输入,解析并执行简单的命令。
  2. 进阶版:增加内建命令(如 cd, help)、管道()和后台执行(&)功能。
  3. 完整版:增加输入/输出重定向(>, <)和环境变量处理。

项目结构

my_shell/
├── Makefile
└── my_shell.c

基础版 Shell

这个版本的Shell将实现以下功能:

linux c语言实现shell
(图片来源网络,侵删)
  • 显示一个命令行提示符。
  • 读取用户输入的一行命令。
  • 将命令字符串分割成命令和参数。
  • 创建子进程来执行命令。
  • 父进程等待子进程执行完毕。

my_shell.c (基础版)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <fcntl.h> // 用于文件控制
#define MAX_INPUT_LEN 1024
#define MAX_ARG_NUM 64
// 函数声明
void print_prompt();
void read_input(char *input);
void parse_input(char *input, char **args);
void execute_command(char **args);
int main() {
    char input[MAX_INPUT_LEN];
    char *args[MAX_ARG_NUM];
    while (1) {
        // 1. 打印提示符
        print_prompt();
        // 2. 读取用户输入
        read_input(input);
        // 3. 解析输入,分割成参数数组
        parse_input(input, args);
        // 4. 如果用户输入了空行或exit,则退出循环
        if (args[0] == NULL) {
            continue; // 空行,继续循环
        }
        if (strcmp(args[0], "exit") == 0) {
            break;
        }
        // 5. 执行命令
        execute_command(args);
    }
    return 0;
}
// 打印提示符
void print_prompt() {
    printf("> ");
    fflush(stdout); // 确保提示符立即显示
}
// 读取用户输入
void read_input(char *input) {
    if (fgets(input, MAX_INPUT_LEN, stdin) == NULL) {
        // 处理 Ctrl+D (EOF)
        printf("\n");
        exit(0);
    }
    // 去除末尾的换行符
    input[strcspn(input, "\n")] = '\0';
}
// 解析输入,将字符串分割成参数数组
void parse_input(char *input, char **args) {
    int i = 0;
    char *token = strtok(input, " ");
    while (token != NULL && i < MAX_ARG_NUM - 1) {
        args[i++] = token;
        token = strtok(NULL, " ");
    }
    args[i] = NULL; // 参数数组必须以NULL结尾,这是execvp的要求
}
// 执行命令
void execute_command(char **args) {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork failed");
        return;
    }
    if (pid == 0) {
        // 子进程
        if (execvp(args[0], args) == -1) {
            // 如果execvp返回,说明执行失败
            perror("execvp failed");
            exit(EXIT_FAILURE);
        }
    } else {
        // 父进程
        int status;
        waitpid(pid, &status, 0); // 等待子进程结束
    }
}

Makefile (基础版)

CC=gcc
CFLAGS=-Wall -Wextra -g
TARGET=my_shell
all: $(TARGET)
$(TARGET): my_shell.c
    $(CC) $(CFLAGS) -o $@ $^
clean:
    rm -f $(TARGET)

如何编译和运行

make
./my_shell

代码解释

  1. 头文件:

    • stdio.h: 标准输入输出。
    • stdlib.h: exit() 函数。
    • string.h: 字符串操作,如 strcmp, strtok, strcspn
    • unistd.h: fork(), execvp(), getpid(), getcwd() 等。
    • sys/wait.h: waitpid()
    • sys/types.h: pid_t 类型定义。
  2. main 函数:

    • while(1): 创建一个无限循环,持续等待用户输入。
    • print_prompt(): 打印 > 提示符。
    • read_input(): 使用 fgets() 安全地读取一行输入,并处理 Ctrl+D (EOF) 的情况。
    • parse_input(): 使用 strtok() 函数按空格分割字符串,将结果存入 args 数组。args 必须以 NULL 这是 exec 系列函数的要求。
    • execute_command(): 核心执行逻辑。
  3. execute_command 函数:

    • fork(): 创建一个子进程,父进程返回子进程的PID,子进程返回0。
    • 子进程 (pid == 0):
      • execvp(args[0], args): 用一个新程序替换当前进程的映像。
        • args[0]: 要执行的程序名。
        • args: 参数数组,args[0] 是程序名,args[1...] 是参数,args 必须以 NULL
        • p 表示会使用 PATH 环境变量来搜索可执行文件,ls 可以直接执行,不需要 ./ls
        • execvp 成功,它不会返回,如果失败,它会返回 -1,此时我们打印错误信息并退出子进程。
    • 父进程 (pid != 0):
      • waitpid(pid, &status, 0): 父进程调用此函数挂起自己,直到指定的子进程(pid)结束,这实现了同步执行,即父进程必须等待子进程执行完毕后才能继续。

进阶版 Shell

在基础版上,我们增加:

linux c语言实现shell
(图片来源网络,侵删)
  • 内建命令cdhelp
  • 管道ls -l | wc -l
  • 后台执行sleep 10 &

my_shell.c (进阶版)

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <fcntl.h>
#define MAX_INPUT_LEN 1024
#define MAX_ARG_NUM 64
// 函数声明
void print_prompt();
void read_input(char *input);
void parse_input(char *input, char **args);
void execute_command(char **args);
void execute_with_pipe(char **args);
void execute_in_background(char **args);
void execute_builtin(char **args);
// 内建命令函数
void shell_cd(char **args);
void shell_help(char **args);
int main() {
    char input[MAX_INPUT_LEN];
    char *args[MAX_ARG_NUM];
    while (1) {
        print_prompt();
        read_input(input);
        parse_input(input, args);
        if (args[0] == NULL) continue;
        // 检查是否是管道
        int i;
        for (i = 0; args[i] != NULL; i++) {
            if (strcmp(args[i], "|") == 0) {
                execute_with_pipe(args);
                break;
            }
        }
        if (args[i] != NULL) continue; // 已经处理了管道
        // 检查是否是后台执行
        for (i = 0; args[i] != NULL; i++) {
            if (strcmp(args[i], "&") == 0) {
                args[i] = NULL; // 移除 '&' 符号
                execute_in_background(args);
                break;
            }
        }
        if (args[i] != NULL) continue; // 已经处理了后台执行
        // 检查是否是内建命令
        if (strcmp(args[0], "cd") == 0 || strcmp(args[0], "help") == 0) {
            execute_builtin(args);
            continue;
        }
        // 普通命令
        execute_command(args);
    }
    return 0;
}
// ... (print_prompt, read_input, parse_input 函数与基础版相同) ...
// ... (execute_command 函数与基础版相同) ...
// 处理内建命令
void execute_builtin(char **args) {
    if (strcmp(args[0], "cd") == 0) {
        shell_cd(args);
    } else if (strcmp(args[0], "help") == 0) {
        shell_help(args);
    }
}
// cd 命令实现
void shell_cd(char **args) {
    if (args[1] == NULL) {
        // 没有参数,切换到HOME目录
        chdir(getenv("HOME"));
    } else {
        if (chdir(args[1]) != 0) {
            perror("cd failed");
        }
    }
}
// help 命令实现
void shell_help(char **args) {
    printf("My Simple Shell\n");
    printf("The following commands are built in:\n");
    printf("  cd - Change the directory.\n");
    printf("  help - Print this help message.\n");
    printf("Use the man command for information on other programs.\n");
}
// 处理后台执行
void execute_in_background(char **args) {
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork failed");
        return;
    }
    if (pid == 0) {
        // 子进程在后台运行,不需要等待父进程
        // 重定向标准输入输出到 /dev/null,防止干扰
        int dev_null = open("/dev/null", O_RDWR);
        dup2(dev_null, STDIN_FILENO);
        dup2(dev_null, STDOUT_FILENO);
        dup2(dev_null, STDERR_FILENO);
        close(dev_null);
        if (execvp(args[0], args) == -1) {
            perror("execvp failed");
            exit(EXIT_FAILURE);
        }
    } else {
        // 父进程直接返回,不等待子进程
        printf("Background process with PID %d started.\n", pid);
    }
}
// 处理管道
void execute_with_pipe(char **args) {
    int i;
    for (i = 0; args[i] != NULL; i++) {
        if (strcmp(args[i], "|") == 0) {
            args[i] = NULL; // 将管道符替换为NULL,分割命令
            break;
        }
    }
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe failed");
        exit(EXIT_FAILURE);
    }
    pid_t pid1 = fork();
    if (pid1 == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }
    if (pid1 == 0) { // 子进程1 (写管道)
        close(pipefd[0]); // 关闭读端
        dup2(pipefd[1], STDOUT_FILENO); // 将标准输出重定向到管道写端
        close(pipefd[1]);
        if (execvp(args[0], args) == -1) {
            perror("execvp failed");
            exit(EXIT_FAILURE);
        }
    }
    pid_t pid2 = fork();
    if (pid2 == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }
    if (pid2 == 0) { // 子进程2 (读管道)
        close(pipefd[1]); // 关闭写端
        dup2(pipefd[0], STDIN_FILENO); // 将标准输入重定向到管道读端
        close(pipefd[0]);
        if (execvp(args[i + 1], &args[i + 1]) == -1) {
            perror("execvp failed");
            exit(EXIT_FAILURE);
        }
    }
    // 父进程
    close(pipefd[0]);
    close(pipefd[1]);
    waitpid(pid1, NULL, 0);
    waitpid(pid2, NULL, 0);
}

代码解释 (新增部分)

  1. 内建命令 (execute_builtin, shell_cd, shell_help):

    • cd 命令不能通过 fork/exec 来实现,因为 exec 会用新进程替换掉当前进程,改变的是子进程的当前工作目录,而父进程(Shell)的不会改变,我们必须在 Shell 父进程中直接调用 chdir() 函数。
    • getenv("HOME") 用于获取 HOME 环境变量的值。
    • help 命令只是打印一些帮助信息,直接在 Shell 内部实现即可。
  2. 后台执行 (execute_in_background):

    • 同样是 fork/exec,但父进程不调用 waitpid(),而是立即返回,继续等待下一个命令。
    • 为了防止后台进程的输出干扰 Shell,我们将它的标准输入、输出、错误都重定向到 /dev/null
  3. 管道 (execute_with_pipe):

    • pipe(pipefd): 创建一个管道,pipefd[0] 是读端,pipefd[1] 是写端。
    • 我们需要创建两个子进程
    • 子进程1 (左边命令):
      • 关闭管道的读端 (close(pipefd[0]))。
      • 使用 dup2(pipefd[1], STDOUT_FILENO) 将它的标准输出重定向到管道的写端。
      • 执行左边的命令 (execvp(args[0], args))。
    • 子进程2 (右边命令):
      • 关闭管道的写端 (close(pipefd[1]))。
      • 使用 dup2(pipefd[0], STDIN_FILENO) 将它的标准输入重定向到管道的读端。
      • 执行右边的命令 (execvp(args[i+1], &args[i+1]))。
    • 父进程:
      • 关闭自己持有的管道两端。
      • 等待两个子进程都执行完毕 (waitpid 两次)。

完整版 Shell (可选扩展)

在进阶版上,我们可以增加:

linux c语言实现shell
(图片来源网络,侵删)
  • 输入/输出重定向 (>, <, >>)
  • 环境变量 ($VAR)

输入/输出重定向实现思路

  1. parse_input 时,除了分割 `,还要识别><>>`。
  2. 当遇到这些符号时,记录下文件名,并将该符号在 args 数组中置为 NULL
  3. execute_command 中,检查 args 数组中是否有这些 NULL 标记。
  4. 如果有,使用 open() 打开文件,并用 dup2() 将标准输入或输出重定向到该文件描述符。
  5. 对于 ls -l > file.txtargs 会被解析为 ["ls", "-l", NULL, "file.txt"],执行 ls 前,用 dup2()STDOUT_FILENO 重定向到 file.txt 的文件描述符。

环境变量实现思路

  1. parse_input 时,识别到 开头的字符串。
  2. 使用 getenv() 函数获取该环境变量的值。
  3. $VAR 替换为 getenv("VAR") 返回的字符串。
  4. 这通常需要更复杂的解析逻辑,因为要处理拼接等情况。

总结与扩展

这个项目从最基础的 fork/exec 开始,逐步增加了Shell的核心功能,通过这个过程,你可以深刻理解:

  • 进程控制: fork, exec, waitpid 的协同工作。
  • 进程间通信: 管道的原理和实现。
  • 文件描述符: dup2 如何重定向标准流。
  • 系统调用: 如何与Linux内核交互来获取信息(如getcwd, getenv)和执行操作(如chdir)。

你可以基于这个框架继续扩展,

  • 历史命令: 保存用户输入过的命令,用上下箭头键浏览。
  • 自动补全: 按Tab键补全命令或文件名。
  • 信号处理: 优雅地处理 Ctrl+C (SIGINT) 和 Ctrl+Z (SIGTSTP)。
  • 更复杂的解析: 支持引号( 或 )来处理带空格的参数。
  • 作业控制: 实现 jobs, fg, bg 命令。
-- 展开阅读全文 --
头像
C语言如何实现base64编码?
« 上一篇 2025-12-22
织梦网站如何取消水印?
下一篇 » 2025-12-22

相关文章

取消
微信二维码
支付宝二维码