Loading

操作系统实践之实现一个简单终端及相关源码解读

问题描述

使用C语言编写一个简单终端,要求实现内建exit命令和执行其他可执行程序。

问题分析

从执行流程来看,一个终端程序主要有以下三个功能:读入用户指令、解析用户指令和执行用户指令。

读入用户指令函数需要打印出提示信息并读入用户输入的一整行字符;

解析用户指令函数需要将用户的指令解析为一系列参数,由于不确定参数数目需要动态分配内存,故该部分还涉及内存动态分配、动态调整和使用的知识点。

执行用户指令函数需要根据用户的指令创建并执行一个子程序,此处要注意的是部分指令(如题目中要求的exit和常见的cd等)为shell的内建指令,需要由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.0Linux内核中,forkvforkclone系统调用都是通过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内核函数主要流程如下:

  1. 检查子进程是否允许被跟踪,并根据参数设置trace
  2. 调用copy_process创建一个子进程,即拷贝一份父进程
  3. 若创建成功,根据task_struct结构体获取进程的pid,再根据pid获取当前命名空间下的虚拟pidnr
  4. 对于由vfork创建的子进程,使用vfork_done这个完成量来确保首先执行子进程
  5. 子进程加入调度队列
  6. 对于由vfork创建的子进程,等待vfork_done这个信号量,即等待子进程调用exit或者exec
  7. 返回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_taskp加入到调度队列,最后释放运行队列锁。

其他知识点

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指向的进程结构体被释放,之后再访问会造成释放后访问的错误。

注: 上述原因为笔者自行猜测,未找到相关文献支持

参考文献

posted @ 2022-11-08 18:28  西瓜地上的小英雄  阅读(425)  评论(0编辑  收藏  举报