系统调用实现原理(Printf函数为例)

系统调用实现(Printf函数为例)

Sys_call

调用程序时,会检查当前段的CPL(位于CS中),与目标段的DPL(位于gdt中),如果权限不够无法执行,所以我们无法以用户态直接访问某些指令并执行。而通过系统调用可以从用户态转变为内核态,执行相关程序。实现的方法为0x80中断,改变CS中的CPL为0。

以printf函数为例,其本身调用了write函数,同时格式化字符串被缓冲到用户态buf中:

// linux/init/main.c

static int printf(const char *fmt, ...)
{
	va_list args;
	int i;

	va_start(args, fmt);
	write(1,printbuf,i=vsprintf(printbuf, fmt, args));
	va_end(args);
	return i;
}

write函数的源代码为:

// linux/lib/write.c

#define __LIBRARY__
#include <unistd.h>

_syscall3(int,write,int,fd,const char *,buf,off_t,count)
// linux/include/unistd.h

#define __NR_write	4

#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
	: "=a" (__res) \
	: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
	return (type) __res; \
errno=-__res; \
return -1; \
}

经过宏展开为:

int write(int fd, const char * buf, off_t count)
{
    long __res;
    __asm__ volatile (
        "int $0x80"
        : "=a" (__res)
        : "0" (__NR_write),
          "b" ((long)(fd)),
          "c" ((long)(buf)),
          "d" ((long)(count))
    );
    if (__res >= 0) { return (int)__res; }
    errno = -__res;
    return -1;
}

其中汇编代码的意思是:将__NR_write放入EAX,fd放入EBX,buf放入ECX,count放入EDX,执行int $0x80中断,然后将EAX放入__res

__NR_##name(这里是__NR_write)被称为系统调用号,在unistd.h中已经宏定义,因为都通过int 0x80进入中断,可用这个区分需要执行的内核对应物。

0x80中断执行的程序是加载OS核心文件时已经配置好了的。在执行system中的main时,执行了sched_init函数,设置了int 0x80执行system_call函数:

// linux/kernel/sched.c

void sched_init(void)
{
    ...
    set_system_gate(0x80,&system_call);
}
// linux/include/asm/system.h

#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
	"movw %0,%%dx\n\t" \
	"movl %%eax,%1\n\t" \
	"movl %%edx,%2" \
	: \
	: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
	"o" (*((char *) (gate_addr))), \
	"o" (*(4+(char *) (gate_addr))), \
	"d" ((char *) (addr)),"a" (0x00080000))

#define set_system_gate(n,addr) \
	_set_gate(&idt[n],15,3,addr)

经过宏展开为:

gate_addr是idt[80]的地址,addr是system_call中断程序的地址。

__asm__(
    "movw %%dx,%%ax\n\t"
    "movw %0,%%dx\n\t"
    "movl %%eax,%1\n\t"
    "movl %%edx,%2"
    :
    : "i" ((short) (0x8000+(3<<13)+(15<<8))), 
      "o" (*((char *) (gate_addr))),
      "o" (*(4+(char *) (gate_addr))),
      "d" ((char *) (addr)),
      "a" (0x00080000))
);

其实就是设置了以下的idt表:

观察看,0x80中断的idt表查询的DPL被设置为3,而用户态的CPL为3,所以该中断程序我们是有权跳转执行的。执行时,段选择符CS被设置为0x0008(内核代码段),此时CPL被设置为0,通过gdt表找到了内核代码段,IP为system_call的地址。

system_call函数主要执行的内容(在调用write时,我们的系统调用号已经被放在EAX中):

# linux/kernel/system_call.s

nr_system_calls = 72        # Linux 0.11 版本内核中的系统共调用总数。
.globl system_call		# 定义入口点
system_call:
	cmpl $nr_system_calls-1,%eax    # 调用号如果超出范围的话就在eax中置-1并退出
	ja bad_sys_call
	push %ds                        # 保存原段寄存器值
	push %es
	push %fs
	pushl %edx
	pushl %ecx		# push %ebx,%ecx,%edx as parameters
	pushl %ebx		# to the system call
	
	# 设置ds、es为0x10,内核数据段。
	movl $0x10,%edx
	mov %dx,%ds
	mov %dx,%es
	
	movl $0x17,%edx		# fs points to local data space
	mov %dx,%fs
	call sys_call_table(,%eax,4)        # 间接调用指定功能C函数
	pushl %eax
...

sys_call_table+4*%eax就是相应系统调用处理函数入口地址。这里通过查sys_call_table全局函数数组,call sys_call_table(,%eax,4) 就是call sys_write

// linux/include/linux/sched.h
typedef int (*fn_ptr)();

// linux/include/linux/sys.h
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, ...};

所以,整体调用printf函数的过程是:

Sys_write

sys_write完成向I/O的寄存器写相应内容,I/O设备的控制器再执行相关操作,从而完成字符在显示器上的打印。而往寄存器上写最终的指令表现为out xxx,al

为了使得外设工作的更简单,使用统一的文件视图。

设备驱动实现我们对I/O设备的使用,要有3个功能:根据相关驱动程序最终发出out指令、形成文件视图、I/O设备发出中断。

write函数通过最终系统调用,使用sys_write处理:

// linux/fs/read_write.c

int sys_write(unsigned int fd,char * buf,int count)
{
    ...
	struct file * file;
    file = current -> filp[fd];
    inode = file -> f_inode;
    ...
}

file对应了输出设备的文件(已被打开),而inode则为该文件的信息。而current当前PCB中的信息都是通过fork从父进程拷贝来的:

// kernel/fork.c

int copy_process(...)
{
	struct task_struct *p;
	...
	*p = *current;
	...
	for (i=0; i<NR_OPEN;i++)
		if ((f=p->filp[i]))
			f->f_count++;
	...
}

shell进程启动了whoami命令,shell是其父进程。一开始在init函数中,open打开了该设备文件,并使用dup拷贝了两份,三份文件对应的句柄分别为0、1、2,对应stdin、stdout、stderr。而上述sys_write的fd为1,对应着dev/tty设备。

// linux/init/main.c

void main(void)
{
    ...
    if (!fork()) { init(); }
    ...
}

void init(void)
{
    ...
    (void) open("/dev/tty0",O_RDWR,0);
    (void) dup(0);
	(void) dup(0);
    ...
    execve("/bin/sh",argv_rc,envp_rc);
    ...
}

而open的系统调用处理函数为sys_open:sys_open根据传递过来的filename使用open_namei函数找到对应的inode,然后将相关信息赋值给f结构,而f被保存在current这个PCB中。

// linux/fs/open.c

int sys_open(const char * filename,int flag,int mode)
{
    struct m_inode * inode;
	struct file * f;
	int i,fd;
    ...
    i=open_namei(filename,flag,mode,&inode);
    current->filp[fd]=f;
    ...
    f->f_mode = inode->i_mode;
	f->f_flags = flag;
	f->f_count = 1;
	f->f_inode = inode;
	f->f_pos = 0;
	return (fd);
}

继续看sys_write处理函数:判断该文件是什么类型,这里以输出到/dev/tty0的char设备为例

inode中的i_zone[0]保存了该设备是字符设备中的第几个,即设备号(ls -l /dev可查看)。

// linux/fs/read_write.c

int sys_write(unsigned int fd,char * buf,int count)
{
    ...
	inode=file->f_inode;
	if (inode->i_pipe)
		return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
	if (S_ISCHR(inode->i_mode))
		return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
	if (S_ISBLK(inode->i_mode))
		return block_write(inode->i_zone[0],&file->f_pos,buf,count);
	if (S_ISREG(inode->i_mode))
		return file_write(inode,file,buf,count);
    ...
}

现在查看rw_char:call_addr是对应设备的处理函数,根据crw_table找到对应的处理函数并执行。

// linux/fs/char_dev.c

int rw_char(int rw,int dev, char * buf, int count, off_t * pos)
{
	crw_ptr call_addr = crw_table[MAJOR(dev)];
    ...
	call_addr(rw,MINOR(dev),buf,count,pos);
}

这里的设备号是4,从设备号为0(可根据ls -l \dev查看),在crw_table表中处理函数为rw_ttyx:

// linux/fs/char_dev.c

static crw_ptr crw_table[]={
	NULL,		/* nodev */
	rw_memory,	/* /dev/mem etc */
	NULL,		/* /dev/fd */
	NULL,		/* /dev/hd */
	rw_ttyx,	/* /dev/ttyx */
	rw_tty,		/* /dev/tty */
	NULL,		/* /dev/lp */
	NULL};		/* unnamed pipes */

rw_ttyx函数中,这里是write,调用tty_write函数:

// linux/fs/char_dev.c

static int rw_ttyx(int rw,unsigned minor,char * buf,int count,off_t * pos)
{
	return ((rw==READ)?tty_read(minor,buf,count):
		tty_write(minor,buf,count));
}

在tty_write函数中:首先查看tty的write_q队列是否满,满了则sleep,直到消费者消费后发出中断后唤醒。然后从buf(这个buf工作在用户态内存)里面逐个取出字符放入write_q中,然后调用tty的write将write_q队列中的字符输出。

// linux/kernel/chr_drv/tty_io.c

int tty_write(unsigned channel, char * buf, int nr)
{
	...
	struct tty_struct * tty;
	tty = channel + tty_table;
	sleep_if_full(&tty->write_q);
	...
    char c, *b=buf;
    while (nr>0 && !FULL(tty->write_q)) {
		c=get_fs_byte(b);
        if (c=='\r') { PUTCH(13,tty->write_q); continue; }
        if (O_LCUC(tty)) c = toupper(c);
        b++; nr--;
        cr_flag = 0;
        PUTCH(c,tty->write_q);
    }
    tty->write(tty);
    ...
}

tty的结构体中,write是一个函数指针,被初始化为con_write:

// linux/include/linux/tty.h

struct tty_struct {
	struct termios termios;
	int pgrp;
	int stopped;
	void (*write)(struct tty_struct * tty);
	struct tty_queue read_q;
	struct tty_queue write_q;
	struct tty_queue secondary;
	};
// linux/kernel/chr_drv/tty_io.c

struct tty_struct tty_table[] = {
	{
        ...
		con_write,
		...
	},
    ...,
    ...
};

最终执行真正的执行函数con_write:根据汇编代码,AH中放属性、AL中放字符。最终表现为mov ax,pos(若对外设使用独立编指这里使用out),pos就是控制显卡的那个寄存器

// linux/kernel/chr_drv/console.c

void con_write(struct tty_struct * tty)
{
	...
	GETCH(tty->write_q,c);
    while(...)
    {
        if (c>31 && c<127) 
        {
            __asm__(
                "movb attr,%%ah\n\t"
                "movw %%ax,%1\n\t"
                :
                : "a" (c),
                  "m" (*(short *)pos)
            );
            pos += 2;
        }
    }
	...
}

上述函数其实就是设备驱动。而做设备驱动其实就是写一些函数然后注册上去,注册就是把处理函数放到对应表中,创建dev文件并将其与相应的处理函数对应上。

pos初始化在con_init中:

// // linux/kernel/chr_drv/console.c

#define ORIG_X			(*(unsigned char *)0x90000)
#define ORIG_Y			(*(unsigned char *)0x90001)

static inline void gotoxy(unsigned int new_x,unsigned int new_y)
{
    ...
	pos=origin + y*video_size_row + (x<<1);
}

void con_init(void)
{
    ...
    gotoxy(ORIG_X,ORIG_Y);
    ...
}

键盘输入的实现

键盘的中断号为21,处理函数被设置为keyboard_interrupt:

// linux/kernel/chr_drv/console.c

void con_init(void)
{
    ...
    set_trap_gate(0x21,&keyboard_interrupt);
    ...
}

keyboard_interrupt把60端口中的内容(扫描码)放入AL,调用key_table根据扫描码决定传入对应字符的ASCII码,put_queue函数将ASCII码放入缓冲队列con.read_q中。

# linux/kernel/chr_drv/keyboard.S

keyboard_interrupt:
	...
	inb $0x60,%al
	...
	call key_table(,%eax,4)
	...
	pushl $0
	call do_tty_interrupt
	...

key_table:
	.long none,do_self,do_self,do_self	/* 00-03 s0 esc 1 2 */
	...
	.long func,num,scroll,cursor		/* 44-47 f10 num scr home */
	...

do_self:
	lea alt_map,%ebx
	testb $0x20,mode		/* alt-gr */
	jne 1f
	lea shift_map,%ebx
	testb $0x03,mode
	jne 1f
	lea key_map,%ebx
1:	movb (%ebx,%eax),%al
	...
	call put_queue
none:	ret

key_map:
	.byte 0,27
	.ascii "&{\"'(-}_/@)="
	...

shift_map:
	.byte 0,27
	.ascii "1234567890]+"
	...

put_queue:
	...
	movl table_list,%edx		# read-queue for console
	movl head(%edx),%ecx
1:	movb %al,buf(%edx,%ecx)
// linux/kernel/chr_drv/tty_io.c

struct tty_queue * table_list[]={
	&tty_table[0].read_q, &tty_table[0].write_q,
	&tty_table[1].read_q, &tty_table[1].write_q,
	&tty_table[2].read_q, &tty_table[2].write_q
	};

接下来对字符回显,将read_q缓冲队列中的内容放到write_q中:

// linux/kernel/chr_drv/tty_io.c

void do_tty_interrupt(int tty)
{
	copy_to_cooked(tty_table+tty);
}

void copy_to_cooked(struct tty_struct * tty)
{
    ...
    GETCH(tty->read_q,c);
    if (L_ECHO(tty)) {
        PUTCH(c,tty->write_q);
        tty->write(tty);
    }
    PUTCH(c,tty->secondary);
	wake_up(&tty->secondary.proc_list);
    ...
}

然后后续回显的内容就和上部分的sys_write的从write_q队列读取字符到显卡寄存器内容一样的了。

参考自:【哈工大】操作系统 李治军。

posted @ 2023-10-14 20:25  MeYokYang  阅读(1005)  评论(1)    收藏  举报