本實驗是 操作系統之基礎 - 網易雲課堂 的配套實驗,推薦大家進行實驗之前先學習相關課程:
L4 操作系統接口
L5 系統調用的實現
實驗目的:
建立對系統調用接口的深入認識;
掌握系統調用的基本過程;
能完成系統調用的全面控制;
為后續實驗做准備。
此次實驗的基本內容是:在 Linux 0.11 上添加兩個系統調用,並編寫兩個簡單的應用程序測試它們。
(1)iam()
第一個系統調用是 iam(),其原型為:
int iam(const char * name);
完成的功能是將字符串參數 name 的內容拷貝到內核中保存下來。要求 name 的長度不能超過 23 個字符。返回值是拷貝的字符數。如果 name 的字符個數超過了 23,則返回 “-1”,並置 errno 為 EINVAL。
在 kernal/who.c 中實現此系統調用。
(2)whoami()
第二個系統調用是 whoami(),其原型為:
int whoami(char* name, unsigned int size);
它將內核中由 iam() 保存的名字拷貝到 name 指向的用戶地址空間中,同時確保不會對 name 越界訪存(name 的大小由 size 說明)。返回值是拷貝的字符數。如果 size 小於需要的空間,則返回“-1”,並置 errno 為 EINVAL。
也是在 kernal/who.c
中實現。
(3)測試程序
運行添加過新系統調用的 Linux 0.11,在其環境下編寫兩個測試程序 iam.c 和 whoami.c。最終的運行結果是:
$ ./iam lizhijun
$ ./whoami
lizhijun
實驗提示:
操作系統實現系統調用的基本過程(在 MOOC 課程中已經給出了詳細的講解)是:
應用程序調用庫函數(API);
API 將系統調用號存入 EAX,然后通過中斷調用使系統進入內核態;
內核中的中斷處理函數根據系統調用號,調用對應的內核函數(系統調用);
系統調用完成相應功能,將返回值存入 EAX,返回到中斷處理函數;
中斷處理函數返回到 API 中;
API 將 EAX 返回給應用程序。
在外部看調用一個接口,只需傳入表示文件描述符的整數,和進程ID作為參數給相應的系統調用;而接口內部的實現邏輯相對來說是復雜的,比如創建一個新的進程,拷貝當前進程。
在通常情況下,調用系統調用和調用一個普通的自定義函數在代碼上並沒有什么區別,但調用后發生的事情有很大不同。
調用自定義函數是通過 call 指令直接跳轉到該函數的地址,繼續運行。
而調用系統調用,是調用系統庫中為該系統調用編寫的一個接口函數,叫 API(Application Programming Interface)。API 並不能完成系統調用的真正功能,它要做的是去調用真正的系統調用,過程是:
把系統調用的編號存入 EAX;
把函數參數存入其它通用寄存器;
觸發 0x80 號中斷(int 0x80)。
linux-0.11 的 lib 目錄下有一些已經實現的 API。Linus 編寫它們的原因是在內核加載完畢后,會切換到用戶模式下,做一些初始化工作,然后啟動 shell。而用戶模式下的很多工作需要依賴一些系統調用才能完成,因此在內核中實現了這些系統調用的 API。
我們不妨看看 lib/close.c,研究一下 close() 的 API:
其中 _syscall1 是一個宏,在 include/unistd.h 中定義。
將 _syscall1(int,close,int,fd) 進行宏展開,可以得到:
int close(int fd)
{
long __res;
__asm__ volatile ("int $0x80"//調用系統中斷0x80
: "=a" (__res)//返回值eax(__res)
: "0" (__NR_close),"b" ((long)(fd)));//輸入為系統中斷調用號__NR_name
if (__res >= 0)//如果返回值>=0,則直接返回該值
return (int) __res;
errno = -__res;//否則置出錯號,並返回-1
return -1;
}
上面是內嵌匯編,基本格式為:
asm("匯編語句模塊"
:輸出寄存器
:輸入寄存器
:會被修改的寄存器);
即:
__asm__ volatile ("int $0x80"//匯編語句模塊
: "=a" (__res)//"=a"表示輸出寄存器為eax
: "0" (__NR_close),"b" ((long)(fd)));//"0"表示輸入寄存器使用與上面相同的寄存器,即eax; "b"表示輸入寄存器為ebx
再看一下前面系統的調用的過程:
應用程序調用庫函數(API);
API 將系統調用號存入 EAX,然后通過中斷調用使系統進入內核態;
內核中的中斷處理函數根據系統調用號,調用對應的內核函數(系統調用);
系統調用完成相應功能,將返回值存入 EAX,返回到中斷處理函數;
中斷處理函數返回到 API 中;
API 將 EAX 返回給應用程序。
這就是 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。
*/
/*
而在應用程序中,要有:
*/
/* 有它,_syscall1 等才有效。詳見unistd.h */
#define __LIBRARY__
/* 有它,編譯器才能獲知自定義的系統調用的編號 */
#include "unistd.h"
/* iam()在用戶空間的接口函數 */
_syscall1(int, iam, const char*, name);
/* whoami()在用戶空間的接口函數 */
_syscall2(int, whoami,char*,name,unsigned int,size);
在 0.11 環境下編譯 C 程序,包含的頭文件都在 /usr/include 目錄下。
該目錄下的 unistd.h
是標准頭文件(它和 0.11 源碼樹中的 unistd.h
並不是同一個文件,雖然內容可能相同),沒有 __NR_whoami
和 __NR_iam
兩個宏,需要手工加上它們,也可以直接從修改過的 0.11 源碼樹中拷貝新的 unistd.h
過來。
注意:
unistd.h
不能直接在oslab直接直接修改,
而需要在虛擬機中修改,
在oslab中有一個mount-hdc
腳本
運行sudo ./mount-hdc
可以把虛擬機硬盤掛載在oslab/hdc 目錄下。
(這個也可以實現文件共享)
在hdc/usr/include 目錄下修改unistd.h
修改后的unistd.h
文件:
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
中:
!……
! # 這是系統調用總數。如果增刪了系統調用,必須做相應修改,
!本題中因為增加了sys_iam()和sys_whoami()就要將其加2
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
! # push %ebx,%ecx,%edx,是傳遞給系統調用的參數
pushl %ebx
! # 讓ds, es指向GDT,內核地址空間
movl $0x10,%edx
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
修飾為其他函數可見。
需要把nr_system_calls修改為74,表示中斷函數的個數
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_xxxxxx()
與之對應,它們都是我們學習和模仿的好對象。
比如在 fs/open.c
中的 sys_close(int fd)
:
int sys_close(unsigned int fd)
{
// ……
return (0);
}
它沒有什么特別的,都是實實在在地做 close() 該做的事情。
所以只要自己創建一個文件:kernel/who.c,然后實現兩個函數就萬事大吉了。
注意:
指針參數傳遞的是應用程序所在地址空間的邏輯地址,在內核中如果直接訪問這個地址,訪問到的是內核空間中的數據,不會是用戶空間的。所以這里還需要一點兒特殊工作,才能在內核中從用戶空間得到數據。為此,我們需要的就是include/asm/segment.h中的get_fs_byte()和put_fs_byte()函數
get_fs_byte()
函數定義:
put_fs_byte()
函數定義:
who.c
文件:
#include <string.h>
#include <errno.h>
#include <asm/segment.h>
char msg[24];
int sys_iam(const char * name)
{
char tep[26];
int i = 0;
for(; i < 26; i++)
{
tep[i] = get_fs_byte(name+i);//get_fs_byte用於在內核空間中獲取用戶空間的數據
if(tep[i] == '\0') break;
}
if (i > 23) return -(EINVAL);
strcpy(msg, tep);
return i;
}
int sys_whoami(char * name, unsigned int size)
{
int len = 0;
for (;msg[len] != '\0'; len++);
if (len > size)
{
return -(EINVAL);
}
int i = 0;
for(i = 0; i < size; i++)
{
put_fs_byte(msg[i], name+i);//put_fs_byte用於把數據由核心態復制到用戶態內存空間
if(msg[i] == '\0') break;
}
return i;
}
修改Makefile
要想讓我們添加的 kernel/who.c 可以和其它 Linux 代碼編譯鏈接到一起,必須要修改 Makefile 文件。
Makefile 里記錄的是所有源程序文件的編譯、鏈接規則,《注釋》3.6 節有簡略介紹。我們之所以簡單地運行 make 就可以編譯整個代碼樹,是因為 make 完全按照 Makefile 里的指示工作。
Makefile 在代碼樹中有很多,分別負責不同模塊的編譯工作。我們要修改的是 kernel/Makefile。需要修改兩處。
(1)第一處
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
即添加who.o
(2)第二處
### 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
添加了 who.s who.o: who.c ../include/linux/kernel.h ../include/unistd.h。
Makefile 修改后,和往常一樣 make all 就能自動把 who.c 加入到內核中了。
在linux-0.11目錄下make all
,who.c
會被加入內核
新增iam.c
跟whoami.c
文件以測試是否添加系統調用成功:
注意這兩個文件是要在linux 0.11版本上編譯的,所以我們應當先通過運行mount-hdc文件來把虛擬機的硬盤掛載在oslab/hdc 目錄下,然后進入hdc/user/root目錄中(這個目錄就是虛擬機一開機的所在的目錄)再創建iam.c和whoami.c。
內核源代碼的unistd.h文件中定義了宏函數_syscalln(),其中n代表攜帶的參數個數,該宏函數展開時會通過int0x80進入內核並找到對應編號的系統調用。若我們要在用戶程序中直接執行對應的系統調用,那么該系統調用宏的形式如下(具體參考linux-0.11內核完全注釋的5.5章節或課程實驗提示):
#define LIBRARY
#include <unistd.h>
_syscalln( …)
因此在iam.c和whoami.c文件代碼中需要包含這三行代碼,然后我們就可以在main函數中直接使用系統調用了。
main函數的兩個參數是argc和argv[],其中argc的值是在命令行運行程序時給的參數的個數;argv是一個指針數組,argv[1]是在命令行執行程序時傳遞給它的第一個參數的地址, argv[2] 是在命令行執行程序時傳遞給它的第二個參數的地址…因此在iam.c文件中的main里直接使用系統調用函數iam,參數即為argv[1]。
iam.c:
#define __LIBRARY__
#include <unistd.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
_syscall1(int,iam,const char*,name)
int main(int argc,char* argv[])
{
iam(argv[1]);
return 0;
}
whoami.c:
#define __LIBRARY__
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
_syscall2(int, whoami, char*, name, unsigned int, size);
int main(int argc, char ** argv)
{
char t[30];
whoami(t, 30);
printf("%s\n", t);
return 0;
}
切換到oslab目錄,
./run
運行虛擬機
在Bochs窗口命令行下編譯iam.c和whoami.c文件
gcc -o iam iam.c
gcc -o whoami whoami.c
運行測試:在虛擬機中通過iam調用將字符由用戶態傳入內核,然后通過whoami將傳入內核的名字打印出來
./iam liudong
whoami
運行結果: