操作系统实践之实现一个简单终端及相关源码解读
问题描述
使用C语言编写一个简单终端,要求实现内建exit
命令和执行其他可执行程序。
问题分析
从执行流程来看,一个终端程序主要有以下三个功能:读入用户指令、解析用户指令和执行用户指令。
读入用户指令函数需要打印出提示信息并读入用户输入的一整行字符;
解析用户指令函数需要将用户的指令解析为一系列参数,由于不确定参数数目需要动态分配内存,故该部分还涉及内存动态分配、动态调整和使用的知识点。
执行用户指令函数需要根据用户的指令创建并执行一个子程序,此处要注意的是部分指令(如题目中要求的exit
和常见的cd
等)为shell
的内建指令,需要由shell
自己实现。shell
进程等待子进程运行结束后,再继续执行本进程。
总而言之,Shell
终端功能思维导图如下图所示:
代码分析
read_line 读入用户指令
void print_prompt(void)
{
printf("myshell> ");
}
char *read_line(void)
{
char *line = NULL;
size_t bufsize = 0;
if (-1 == getline(&line, &bufsize, stdin))
{
free(line);
perror("readline");
exit(EXIT_FAILURE);
}
return line;
}
如上所示,读入用户指令模块主要是由打印用户提示(出于用户友好的考量)和读入整行输入两部分组成。在这里,我们假定用户的输入是单行字符,故使用getline
函数读入。
注意1: getline
函数传入的字符串地址若为空,则会自动分配一段地址空间,并将原字符串指针指向新的空间。
注意2: 需对getline
返回值进行检查,即便读取失败也要释放字符串地址空间。
parse_line 解析用户指令
#define BUFFER_SIZE 1024
#define DELIMETERS " \t\n"
char **parse_line(char *line)
{
int bufsize = BUFFER_SIZE, position = 0;
char *token;
char **tokens = (char **)malloc(BUFFER_SIZE * sizeof(char *));
if (!tokens)
{
perror("allocation error");
exit(EXIT_FAILURE);
}
token = strtok(line, DELIMETERS);
while (token != NULL)
{
tokens[position] = token;
position++;
token = strtok(NULL, DELIMETERS);
if (position >= BUFFER_SIZE)
{
tokens = (char **)realloc(tokens, bufsize * 2 * sizeof(char *));
bufsize *= 2;
if (!tokens)
{
perror("allocation error");
exit(EXIT_FAILURE);
}
}
}
tokens[position] = NULL;
return tokens;
}
如上所示,使用字符串数组char **tokens
保存对字符串解析的结果,使用strtok
函数对字符串进行分割。同时,动态调整tokens
的数组长度,以防命令参数过多。
注意1: 分隔符的宏定义#define DELIMETERS " \t\n"
中包括空格、\t
和换行符。其中,换行符是由于getline
读入字符函数会读入换行符。
execute 执行用户指令
int execute(char **args)
{
pid_t pid, wpid;
int status;
if (args[0] == NULL) // 空命令
return 1;
else if (strcmp(args[0], "exit") == 0) // exit内建命令
exit(EXIT_SUCCESS);
else
{
pid = fork();
if (pid == 0)
{
if (execvp(args[0], args) == -1)
{
perror("myshell");
}
exit(EXIT_FAILURE);
}
else if (pid < 0)
{
perror("myshell");
}
else
{
do
{
wpid = waitpid(pid, &status, WUNTRACED);
} while (!WIFEXITED(status) && !WIFSIGNALED(status));
}
}
}
如上所示,首先对命令类型进行判断,若为空命令即第一个参数指向NULL
,则直接返回;若为内建命令,则调用内建命令的实现函数,本代码中由于内建命令仅有一个exit
且易于实现,则直接在该函数中实现;对于其他命令,则创建一个子进程并调用exec
系统调用替换子进程内存代码。之后主进程等待子进程执行完毕,并返回。
源码分析
kernel_clone 内核函数
在5.15.0
Linux内核中,fork
、vfork
和clone
系统调用都是通过kernel_clone
内核函数实现,其区别在于传入参数不同以实现不同功能。其函数原型如下:
pid_t kernel_clone(struct kernel_clone_args *args)
kernel_clone
的参数由传入的kernel_clone_args
指定,该结构体中部分成员含义见以下代码注释:
struct kernel_clone_args {
u64 flags; // 创建进程的标志位集合,常见标志位含义见下表
int __user *pidfd;
int __user *child_tid; // 指向用户空间子进程的地址
int __user *parent_tid; // 指向用户空间父进程的地址
int exit_signal;
unsigned long stack; // 用户态栈的起始地址
unsigned long stack_size; // 用户态栈的空间大小
unsigned long tls; // 传递线程本地存储
pid_t *set_tid;
/* Number of elements in *set_tid */
size_t set_tid_size;
int cgroup;
int io_thread;
struct cgroup *cgrp;
struct css_set *cset;
};
其中,u64 flags
部分标志位含义如下所示:
CLONE_VM
: 父子进程共享进程地址空间CLONE_FS
: 父子进程工匠文件系统信息CLONE_FILES
: 父子进程共享打开的文件CLONE_SIGHAND
: 父子进程共享信号处理函数以及中断的信号CLONE_PTRACE
: 父进程被跟踪,子进程也会被跟踪CLONE_VFORK
: 在创建子进程时启用Linux内核完成量机制。wait_for_completion
函数会使父进程进入睡眠态,直至子进程调用execve
或者exit
释放资源CLONE_PARENT
: 指定父子进程拥有同一个父进程CLONE_THREAD
: 父子进程在同一个线程组里CLONE_NEWNS
: 为子进程创建新的命名空间
kernel_clone
源码如下所示:
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
int trace = 0;
pid_t nr;
/*
* For legacy clone() calls, CLONE_PIDFD uses the parent_tid argument
* to return the pidfd. Hence, CLONE_PIDFD and CLONE_PARENT_SETTID are
* mutually exclusive. With clone3() CLONE_PIDFD has grown a separate
* field in struct clone_args and it still doesn't make sense to have
* them both point at the same memory location. Performing this check
* here has the advantage that we don't need to have a separate helper
* to check for legacy clone().
*/
if ((args->flags & CLONE_PIDFD) &&
(args->flags & CLONE_PARENT_SETTID) &&
(args->pidfd == args->parent_tid))
return -EINVAL;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
add_latent_entropy();
if (IS_ERR(p))
return PTR_ERR(p);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
trace_sched_process_fork(current, p);
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, args->parent_tid);
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
wake_up_new_task(p);
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);
return nr;
}
kernel_clone
内核函数主要流程如下:
- 检查子进程是否允许被跟踪,并根据参数设置
trace
值 - 调用
copy_process
创建一个子进程,即拷贝一份父进程 - 若创建成功,根据
task_struct
结构体获取进程的pid
,再根据pid
获取当前命名空间下的虚拟pid
即nr
- 对于由
vfork
创建的子进程,使用vfork_done
这个完成量来确保首先执行子进程 - 子进程加入调度队列
- 对于由
vfork
创建的子进程,等待vfork_done
这个信号量,即等待子进程调用exit
或者exec
- 返回
pid
copy_process 内核函数
copy_process
函数功能是创建一个进程的复制,其代码较长,本文不对其进行分析。该函数的原型为:
static __latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
wake_up_new_task 内核函数
wake_up_new_task
函数功能是将新创建的进程加入调度队列,源码如下所示:
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;
raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING);
#ifdef CONFIG_SMP
/*
* Fork balancing, do it here and not earlier because:
* - cpus_ptr can change in the fork path
* - any previously selected CPU might disappear through hotplug
*
* Use __set_task_cpu() to avoid calling sched_class::migrate_task_rq,
* as we're not fully set-up yet.
*/
p->recent_used_cpu = task_cpu(p);
rseq_migrate(p);
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
#endif
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p);
activate_task(rq, p, ENQUEUE_NOCLOCK);
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK);
#ifdef CONFIG_SMP
if (p->sched_class->task_woken) {
/*
* Nothing relies on rq->lock after this, so it's fine to
* drop it.
*/
rq_unpin_lock(rq, &rf);
p->sched_class->task_woken(rq, p);
rq_repin_lock(rq, &rf);
}
#endif
task_rq_unlock(rq, p, &rf);
}
首先调用raw_spin_lock_irqsave
给进程p
的优先级继承自选锁上锁,同时,该函数也会禁止本地CPU的中断。WRITE_ONCE
通过使用volatile
关键字保证代码的执行顺序不会因编译器优化而乱序。在启用CONFIG_SMP
即(Symmetric Multi-Processing,对称多处理)的情况下,调用select_task_rq
为其选择一个最优CPU。随后使用__task_rq_lock
给进程p
所在的运行队列上锁。调用activate_task
将p
加入到调度队列,最后释放运行队列锁。
其他知识点
perror和printf(stderror, "....")的区别
perror
用于打印与errno
相对应的报错信息,printf(stderror, "....")
则是用于直接向标准错误输出打印内容。
return -EINVAL 的含义
EINVAL
宏定义的含义为invalid argument
,即不合法的参数,用于传入参数错误的场合。之所以其返回值为负,是因为传统上我们认为函数返回值>=0说明函数正确调用,返回值<0则出错。
kernel_clone中get_task_struct(p)的作用
有些进程可能很短就运行结束,当调用wake_up_new_task(p)
调度该进程后,p指向的进程可能立刻结束运行,即p指向的进程结构体被释放,之后再访问会造成释放后访问的错误。
注: 上述原因为笔者自行猜测,未找到相关文献支持
参考文献
- https://blog.csdn.net/jasonactions/article/details/115316642
- https://blog.csdn.net/linyt/article/details/51931737
- https://stackoverflow.com/a/1848785
- https://lkml.iu.edu/hypermail/linux/kernel/0404.2/0398.html
- https://blog.csdn.net/longwang155069/article/details/104578189
- https://www.cnblogs.com/JohnABC/p/9084750.html
- https://unix.stackexchange.com/questions/519009/spin-lock-vs-spin-lock-irq-vs-spin-lock-irqsave
- https://en.wikipedia.org/wiki/Priority_inheritance
- https://stackoverflow.com/questions/12102332/when-should-i-use-perror-and-fprintfstderr