1. 程式人生 > >實驗3 系統呼叫

實驗3 系統呼叫

系統呼叫

實驗目的

  • 建立對系統呼叫介面的深入認識
  • 掌握系統呼叫的基本過程
  • 能完成系統呼叫的全面控制
  • 為後續實驗做準備

實驗內容

此次實驗的基本內容是:在Linux 0.11上新增兩個系統呼叫,並編寫兩個簡單的應用程式測試它們。

iam()

第一個系統呼叫是iam(),其原型為:

int iam(const char * name);

完成的功能是將字串引數name的內容拷貝到核心中儲存下來。要求name的長度不能超過23個字元。返回值是拷貝的字元數。如果name的字元個數超過了23,則返回“-1”,並置errno為EINVAL。

在kernal/who.c中實現此係統呼叫。

whoami()

第二個系統呼叫是whoami(),其原型為:

int whoami(char* name, unsigned int size);

它將核心中由iam()儲存的名字拷貝到name指向的使用者地址空間中,同時確保不會對name越界訪存(name的大小由size說明)。返回值是拷貝的字元數。如果size小於需要的空間,則返回“-1”,並置errno為EINVAL。

也是在kernal/who.c中實現。

測試程式

執行新增過新系統呼叫的Linux 0.11,在其環境下編寫兩個測試程式iam.c和whoami.c。最終的執行結果是:

\$ ./iam lizhijun

\$ ./whoami

lizhijun

實驗報告

在實驗報告中回答如下問題:

  • 從Linux 0.11現在的機制看,它的系統呼叫最多能傳遞幾個引數?你能想出辦法來擴大這個限制嗎?
  • 用文字簡要描述向Linux 0.11新增一個系統呼叫foo()的步驟。

評分標準

  • 將 testlab2.c(在/home/teacher目錄下) 在修改過的Linux 0.11上編譯執行,顯示的結果即核心程式的得分。滿分50%
  • 只要至少一個新增的系統呼叫被成功呼叫,並且能和使用者空間交換引數,可得滿分
  • 將指令碼 testlab2.sh(在/home/teacher目錄下) 在修改過的Linux 0.11上執行,顯示的結果即應用程式的得分。滿分30%
  • 實驗報告,20%

實驗提示

首先,請將Linux 0.11的原始碼恢復到原始狀態。

作業系統實現系統呼叫的基本過程(在MOOC課程中已經給出了詳細的講解)是:

  • 應用程式呼叫庫函式(API);
  • API將系統呼叫號存入EAX,然後通過中斷呼叫使系統進入核心態;
  • 核心中的中斷處理函式根據系統呼叫號,呼叫對應的核心函式(系統呼叫);
  • 系統呼叫完成相應功能,將返回值存入EAX,返回到中斷處理函式;
  • 中斷處理函式返回到API中;
  • API將EAX返回給應用程式。

  • 應用程式如何呼叫系統呼叫

在通常情況下,呼叫系統呼叫和呼叫一個普通的自定義函式在程式碼上並沒有什麼區別,但呼叫後發生的事情有很大不同。呼叫自定義函式是通過call指令直接跳轉到該函式的地址,繼續執行。而呼叫系統呼叫,是呼叫系統庫中為該系統呼叫編寫的一個介面函式,叫API(Application Programming Interface)。API並不能完成系統呼叫的真正功能,它要做的是去呼叫真正的系統呼叫,過程是:

  • 把系統呼叫的編號存入EAX
  • 把函式引數存入其它通用暫存器
  • 觸發0x80號中斷(int 0x80)

0.11的lib目錄下有一些已經實現的API。Linus編寫它們的原因是在核心載入完畢後,會切換到使用者模式下,做一些初始化工作,然後啟動shell。而使用者模式下的很多工作需要依賴一些系統呼叫才能完成,因此在核心中實現了這些系統呼叫的API。我們不妨看看lib/close.c,研究一下close()的API:

#define __LIBRARY__

#include "unistd.h"

_syscall1(int,close,int,fd)

其中_syscall1是一個巨集,在include/unistd.h中定義。將_syscall1(int,close,int,fd)進行巨集展開,可以得到:

int close(int fd) 
{ 
    long __res;      
    __asm__ volatile ("int $0x80" 
        : "=a" (__res) 
        : "0" (__NR_close),"b" ((long)(fd)));      
    if (__res >= 0)
        return (int) __res; 
    errno = -__res; 
    return -1; 
}

這就是API的定義。它先將巨集NR_close存入EAX,將引數fd存入EBX,然後進行0x80中斷呼叫。呼叫返回後,從EAX取出返回值,存入res,再通過對res的判斷決定傳給API的呼叫者什麼樣的返回值。其中NR_close就是系統呼叫的編號,在include/unistd.h中定義:

#define NR_close 6

所以新增系統呼叫時需要修改include/unistd.h檔案,使其包含NR_whoami和__NR_iam。而在應用程式中,要有:

#define __LIBRARY__ / 有它,_syscall1等才有效。詳見unistd.h /

#include "unistd.h" / 有它,編譯器才能獲知自定義的系統呼叫的編號 /

_syscall1(int, iam, const char, name); / iam()在使用者空間的介面函式 /

_syscall2(int, whoami,char,name,unsigned int,size); / whoami()在使用者空間的介面函式 /

在0.11環境下編譯C程式,包含的標頭檔案都在/usr/include目錄下。該目錄下的unistd.h是標準標頭檔案(它和0.11原始碼樹中的unistd.h並不是同一個檔案,雖然內容可能相同),沒有NR_whoami和NR_iam兩個巨集,需要手工加上它們,也可以直接從修改過的0.11原始碼樹中拷貝新的unistd.h過來。

  • 從“int 0x80”進入核心函式

int 0x80觸發後,接下來就是核心的中斷處理了。先了解一下0.11處理0x80號中斷的過程。

在核心初始化時,主函式(在init/main.c中,Linux實驗環境下是main(),Windows下因編譯器相容性問題被換名為start())呼叫了sched_init()初始化函式:

void main(void)    
{            
    ……
    time_init();
    sched_init();
    buffer_init(buffer_memory_end);
    ……
}

sched_init()在kernel/sched.c中定義為:

void sched_init(void)
{
    ……
    set_system_gate(0x80,&system_call);
}

set_system_gate是個巨集,在include/asm/system.h中定義為:

\#define set_system_gate(n,addr) \
    _set_gate(&idt[n],15,3,addr)
_set_gate的定義是:
\#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))

雖然看起來挺麻煩,但實際上很簡單,就是填寫IDT(中斷描述符表),將system_call函式地址寫到0x80對應的中斷描述符中,也就是在中斷0x80發生後,自動呼叫函式system_call。具體細節請參考《註釋》的第4章。

接下來看system_call。該函式純彙編打造,定義在kernel/system_call.s中:

……
nr_system_calls = 72        #這是系統呼叫總數。如果增刪了系統呼叫,必須做相應修改
……
.globl system_call
.align 2
system_call:
    cmpl \$nr_system_calls-1,%eax #檢查系統呼叫編號是否在合法範圍內
    ja bad_sys_call
    push %ds
    push %es
    push %fs
    pushl %edx
    pushl %ecx        
    pushl %ebx        # push %ebx,%ecx,%edx,是傳遞給系統呼叫的引數
    movl $0x10,%edx        # 讓ds,es指向GDT,核心地址空間
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx        # 讓fs指向LDT,使用者地址空間
    mov %dx,%fs
    call sys_call_table(,%eax,4)
    pushl %eax
    movl current,%eax
    cmpl $0,state(%eax)
    jne reschedule
    cmpl $0,counter(%eax)
    je reschedule

system_call用.globl修飾為其他函式可見。Windows實驗環境下會看到它有一個下劃線字首,這是不同版本編譯器的特質決定的,沒有實質區別。call sys_call_table(,%eax,4)之前是一些壓棧保護,修改段選擇子為核心段,call sys_call_table(,%eax,4)之後是看看是否需要重新排程,這些都與本實驗沒有直接關係,此處只關心call sys_call_table(,%eax,4)這一句。根據彙編定址方法它實際上是:

call sys_call_table + 4 * %eax # 其中eax中放的是系統呼叫號,即__NR_xxxxxx

顯然,sys_call_table一定是一個函式指標陣列的起始地址,它定義在include/linux/sys.h中:

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,……

增加實驗要求的系統呼叫,需要在這個函式表中增加兩個函式引用——sys_iam和sys_whoami。當然該函式在sys_call_table陣列中的位置必須和__NR_xxxxxx的值對應上。同時還要仿照此檔案中前面各個系統呼叫的寫法,加上:

extern int sys_whoami();

extern int sys_iam();

不然,編譯會出錯的。

  • 實現sys_iam()和sys_whoami()

新增系統呼叫的最後一步,是在核心中實現函式sys_iam()和sys_whoami()。

每個系統呼叫都有一個sys_xxxxxx()與之對應,它們都是我們學習和模仿的好物件。比如在fs/open.c中的sys_close(int fd):

int sys_close(unsigned int fd)
{
    ……
    return (0);
}

它沒有什麼特別的,都是實實在在地做close()該做的事情。所以只要自己建立一個檔案:kernel/who.c,然後實現兩個函式就萬事大吉了。

  • 修改Makefile

要想讓我們新增的kernel/who.c可以和其它Linux程式碼編譯連結到一起,必須要修改Makefile檔案。Makefile裡記錄的是所有源程式檔案的編譯、連結規則,《註釋》3.6節有簡略介紹。我們之所以簡單地執行make就可以編譯整個程式碼樹,是因為make完全按照Makefile裡的指示工作。

Makefile在程式碼樹中有很多,分別負責不同模組的編譯工作。我們要修改的是kernel/Makefile。需要修改兩處。一處是:

OBJS  = sched.o system_call.o traps.o asm.o fork.o \
        panic.o printk.o vsprintf.o sys.o exit.o \
        signal.o mktime.o
改為:
OBJS  = sched.o system_call.o traps.o asm.o fork.o \
        panic.o printk.o vsprintf.o sys.o exit.o \
        signal.o mktime.o who.o

另一處:

\### Dependencies:
exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
  ../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
  ../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
  ../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
  ../include/asm/segment.h

改為:

\### Dependencies:
who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h
exit.s exit.o: exit.c ../include/errno.h ../include/signal.h \
  ../include/sys/types.h ../include/sys/wait.h ../include/linux/sched.h \
  ../include/linux/head.h ../include/linux/fs.h ../include/linux/mm.h \
  ../include/linux/kernel.h ../include/linux/tty.h ../include/termios.h \
  ../include/asm/segment.h

Makefile修改後,和往常一樣“make all”就能自動把who.c加入到核心中了。如果編譯時提示who.c有錯誤,就說明修改生效了。所以,有意或無意地製造一兩個錯誤也不完全是壞事,至少能證明Makefile是對的。

  • 用printk()除錯核心

oslab實驗環境提供了基於C語言和組合語言的兩種除錯手段。除此之外,適當地向螢幕輸出一些程式執行狀態的資訊,也是一種很高效、便捷的除錯方法,有時甚至是唯一的方法,被稱為“printf法”。

要知道到,printf()是一個只能在使用者模式下執行的函式,而系統呼叫是在核心模式中執行,所以printf()不可用,要用printk()。它和printf的介面和功能基本相同,只是程式碼上有一點點不同。printk()需要特別處理一下fs暫存器,它是專用於使用者模式的段暫存器。看一看printk的程式碼(在kernel/printk.c中)就知道了:

int printk(const char *fmt, ...)
{
    ……
    __asm__("push %%fs\n\t"
            "push %%ds\n\t"
            "pop %%fs\n\t"
            "pushl %0\n\t"
            "pushl $buf\n\t"
            "pushl $0\n\t"
            "call tty_write\n\t"
            "addl $8,%%esp\n\t"
            "popl %0\n\t"
            "pop %%fs"
            ::"r" (i):"ax","cx","dx");
    ……
}

顯然,printk()首先push %fs儲存這個指向使用者段的暫存器,在最後pop %fs將其恢復,printk的核心仍然是呼叫tty_write()。檢視printf()可以看到,它最終也要落實到這個函式上。

  • 編寫測試程式

激動地執行一下由你親手修改過的“Linux 0.11 pro++”!然後編寫一個簡單的應用程式進行測試。比如在sys_iam()中向終端printk()一些資訊,讓應用程式呼叫iam(),從結果可以看出系統呼叫是否被真的呼叫到了。

可以直接在Linux 0.11環境下用vi編寫(別忘了經常執行“sync”以確保記憶體緩衝區的資料寫入磁碟),也可以在Ubuntu或Windows下編完後再傳到Linux 0.11下。無論如何,最終都必須在Linux 0.11下編譯。編譯命令是:

# gcc -o iam iam.c -Wall

gcc的“-Wall”引數是給出所有的編譯警告資訊,“-o”引數指定生成的執行檔名是iam,用下面命令執行它:

# ./iam

如果如願輸出了你的資訊,就說明你新增的系統呼叫生效了。否則,就還要繼續除錯,祝你好運!

  • 在使用者態和核心態之間傳遞資料

指標引數傳遞的是應用程式所在地址空間的邏輯地址,在核心中如果直接訪問這個地址,訪問到的是核心空間中的資料,不會是使用者空間的。所以這裡還需要一點兒特殊工作,才能在核心中從使用者空間得到資料。

要實現的兩個系統呼叫引數中都有字串指標,非常象open(char *filename, ……),所以我們看一下open()系統呼叫是如何處理的。

int open(const char * filename, int flag, ...)
{  
    ……
    __asm__("int $0x80"
            :"=a" (res)
            :"0" (__NR_open),"b" (filename),"c" (flag),
            "d" (va_arg(arg,int)));
    ……
}

可以看出,系統呼叫是用eax、ebx、ecx、edx暫存器來傳遞引數的。其中eax傳遞了系統呼叫號,而ebx、ecx、edx是用來傳遞函式的引數的,其中ebx對應第一個引數,ecx對應第二個引數,依此類推。如open所傳遞的檔名指標是由ebx傳遞的,也即進入核心後,通過ebx取出檔名字串。open的ebx指向的資料在使用者空間,而當前執行的是核心空間的程式碼,如何在使用者態和核心態之間傳遞資料?接下來我們繼續看看open的處理:

system_call: //所有的系統呼叫都從system_call開始
    ……
    pushl %edx
    pushl %ecx        
    pushl %ebx                # push %ebx,%ecx,%edx,這是傳遞給系統呼叫的引數
    movl $0x10,%edx            # 讓ds,es指向GDT,指向核心地址空間
    mov %dx,%ds
    mov %dx,%es
    movl $0x17,%edx            # 讓fs指向的是LDT,指向使用者地址空間
    mov %dx,%fs
    call sys_call_table(,%eax,4)    # 即call sys_open

由上面的程式碼可以看出,獲取使用者地址空間(使用者資料段)中的資料依靠的就是段暫存器fs,下面該轉到sys_open執行了,在fs/open.c檔案中:

int sys_open(const char * filename,int flag,int mode)  //filename這些引數從哪裡來?
/*是否記得上面的pushl %edx,    pushl %ecx,    pushl %ebx?
  實際上一個C語言函式呼叫另一個C語言函式時,編譯時就是將要
  傳遞的引數壓入棧中(第一個引數最後壓,…),然後call …,
  所以彙編程式呼叫C函式時,需要自己編寫這些引數壓棧的程式碼…*/
{
    ……
    if ((i=open_namei(filename,flag,mode,&inode))<0) {
        ……
    }
    ……
}

它將引數傳給了open_namei()。再沿著open_namei()繼續查詢,檔名先後又被傳給dir_namei()、get_dir()。在get_dir()中可以看到:

static struct m_inode * get_dir(const char * pathname)
{
    ……
    if ((c=get_fs_byte(pathname))=='/') {
        ……
    }
    ……
}

處理方法就很顯然了:用get_fs_byte()獲得一個位元組的使用者空間中的資料。所以,在實現iam()時,呼叫get_fs_byte()即可。但如何實現whoami()呢?即如何實現從核心態拷貝資料到用心態記憶體空間中呢?猜一猜,是否有put_fs_byte()?有!看一看include/asm/segment.h:

extern inline unsigned char get_fs_byte(const char * addr)
{
    unsigned register char _v;
    __asm__ ("movb %%fs:%1,%0":"=r" (_v):"m" (*addr));
    return _v;
}
extern inline void put_fs_byte(char val,char *addr)
{
    __asm__ ("movb %0,%%fs:%1"::"r" (val),"m" (*addr));
}

他倆以及所有put_fs_xxx()和get_fs_xxx()都是使用者空間和核心空間之間的橋樑,在後面的實驗中還要經常用到。

  • 執行指令碼程式

Linux的一大特色是可以編寫功能強大的shell指令碼,提高工作效率。本實驗的部分評分工作由指令碼 testlab2.sh 完成。它的功能是測試iam.c和whoami.c。

首先將iam.c和whoami.c分別編譯成iam和whoami,然後將 testlab2.sh 拷貝到同一目錄下。用下面命令為此指令碼增加執行許可權:

chmod +x testlab2.sh

然後執行之:

./testlab2.sh

根據輸出,可知iam.c和whoami.c的得分。

  • errno

errno是一種傳統的錯誤程式碼返回機制。當一個函式調用出錯時,通常會返回-1給呼叫者。但-1只能說明出錯,不能說明錯是什麼。為解決此問題,全域性變數errno登場了。錯誤值被存放到errno中,於是呼叫者就可以通過判斷errno來決定如何應對錯誤了。各種系統對errno的值的含義都有標準定義。Linux下用“man errno”可以看到這些定義。