- 基础版:能够读取用户输入,解析并执行简单的命令。
- 进阶版:增加内建命令(如
cd,help)、管道()和后台执行(&)功能。 - 完整版:增加输入/输出重定向(
>,<)和环境变量处理。
项目结构
my_shell/
├── Makefile
└── my_shell.c
基础版 Shell
这个版本的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
代码解释
-
头文件:
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类型定义。
-
main函数:while(1): 创建一个无限循环,持续等待用户输入。print_prompt(): 打印>提示符。read_input(): 使用fgets()安全地读取一行输入,并处理Ctrl+D(EOF) 的情况。parse_input(): 使用strtok()函数按空格分割字符串,将结果存入args数组。args必须以NULL这是exec系列函数的要求。execute_command(): 核心执行逻辑。
-
execute_command函数:fork(): 创建一个子进程,父进程返回子进程的PID,子进程返回0。- 子进程 (
pid == 0):execvp(args[0], args): 用一个新程序替换当前进程的映像。args[0]: 要执行的程序名。args: 参数数组,args[0]是程序名,args[1...]是参数,args必须以NULLp表示会使用PATH环境变量来搜索可执行文件,ls可以直接执行,不需要./ls。execvp成功,它不会返回,如果失败,它会返回-1,此时我们打印错误信息并退出子进程。
- 父进程 (
pid != 0):waitpid(pid, &status, 0): 父进程调用此函数挂起自己,直到指定的子进程(pid)结束,这实现了同步执行,即父进程必须等待子进程执行完毕后才能继续。
进阶版 Shell
在基础版上,我们增加:

(图片来源网络,侵删)
- 内建命令:
cd和help。 - 管道:
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);
}
代码解释 (新增部分)
-
内建命令 (
execute_builtin,shell_cd,shell_help):cd命令不能通过fork/exec来实现,因为exec会用新进程替换掉当前进程,改变的是子进程的当前工作目录,而父进程(Shell)的不会改变,我们必须在 Shell 父进程中直接调用chdir()函数。getenv("HOME")用于获取HOME环境变量的值。help命令只是打印一些帮助信息,直接在 Shell 内部实现即可。
-
后台执行 (
execute_in_background):- 同样是
fork/exec,但父进程不调用waitpid(),而是立即返回,继续等待下一个命令。 - 为了防止后台进程的输出干扰 Shell,我们将它的标准输入、输出、错误都重定向到
/dev/null。
- 同样是
-
管道 (
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 (可选扩展)
在进阶版上,我们可以增加:

(图片来源网络,侵删)
- 输入/输出重定向 (
>,<,>>)。 - 环境变量 (
$VAR)。
输入/输出重定向实现思路
- 在
parse_input时,除了分割`,还要识别><>>`。 - 当遇到这些符号时,记录下文件名,并将该符号在
args数组中置为NULL。 - 在
execute_command中,检查args数组中是否有这些NULL标记。 - 如果有,使用
open()打开文件,并用dup2()将标准输入或输出重定向到该文件描述符。 - 对于
ls -l > file.txt,args会被解析为["ls", "-l", NULL, "file.txt"],执行ls前,用dup2()将STDOUT_FILENO重定向到file.txt的文件描述符。
环境变量实现思路
- 在
parse_input时,识别到 开头的字符串。 - 使用
getenv()函数获取该环境变量的值。 - 将
$VAR替换为getenv("VAR")返回的字符串。 - 这通常需要更复杂的解析逻辑,因为要处理拼接等情况。
总结与扩展
这个项目从最基础的 fork/exec 开始,逐步增加了Shell的核心功能,通过这个过程,你可以深刻理解:
- 进程控制:
fork,exec,waitpid的协同工作。 - 进程间通信: 管道的原理和实现。
- 文件描述符:
dup2如何重定向标准流。 - 系统调用: 如何与Linux内核交互来获取信息(如
getcwd,getenv)和执行操作(如chdir)。
你可以基于这个框架继续扩展,
- 历史命令: 保存用户输入过的命令,用上下箭头键浏览。
- 自动补全: 按Tab键补全命令或文件名。
- 信号处理: 优雅地处理
Ctrl+C(SIGINT) 和Ctrl+Z(SIGTSTP)。 - 更复杂的解析: 支持引号( 或 )来处理带空格的参数。
- 作业控制: 实现
jobs,fg,bg命令。
