uClinux中的兩點分析

 
簡介
前一段時間,曾先後移植了uClinux-2.0.x和uClinux-2.4.x的內核,
我的移植基本上是從零做起,linux並沒有支持該目標機的代碼,所以這
個移植工作基本上是新增加對一種目標機的支持。

工作過程中,我學到了不少知識,除了操作系統,還了解了一些編
譯,調試,彙編,鏈接的的技術,在此我會一併介紹,可能介紹比較多
的是連接器,因爲這個相對和操作系統聯繫更加緊密一些。
我希望能夠與大家分享自己經驗,同時,有錯誤和不當的地方歡迎
網友指出,共同進步,這是我寫這些原創帖的動力。

“編程並非零和的遊戲。將己所知教給程序員同胞,他們並不會奪你
所知。能將我所知與人分享,我感到高興,因爲我身在其中、熱愛編程。”
——John Carmack

uClinux下用戶程序的執行

之所以從用戶程序談起,是因爲我們平常接觸最多的還是應用程序。
從應用程序引出到操作系統我覺得比較自然。下面就從一個簡單例子介
紹一個程序如何在操作系統中運行。

假如有個c程序:
int main(int argc, char **argv[])
{
printf("hello world!/n");
return 0;
}

這是一個最簡單不過的程序了,一般一個C語言程序,都從main開始
執行。那麼,main函數是不是與其他函數有所區別,地位有些特殊呢?
不是的。main函數和其他函數地位一樣。其實,我們完全可以做到讓一個
c程序從任何地方開始執行。比如linux,它就沒有main函數,大家都知道,
系統執行過啓動的一段彙編後,就會跳轉到位於init/main.c中的
start_kernel中開始執行。

那麼爲什麼用戶程序都要從main函數執行呢?這就是用戶C庫的原因。
一般用戶用c語言開發時會調用一些庫函數,編譯成obj文件後,在鏈接過
程中把庫函數的二進制代碼鏈接進入程序,最後形成二進制可執行文件。
鏈接過程中,鏈接器會在用戶程序前插入一些初始化的代碼。uClinux下
是在crt0.s中(我移植的是uClibc庫)。不管什麼平臺下什麼形式的crt0.s,
這個文件最後幾行代碼中肯定有一個jmp(或者call或br等轉移指令) main
(或__uClibc_main)。這就是爲什麼你的程序都從main開始執行。如果你把
這個跳轉標號改成任意一個標號,比如foo。而你的程序裏面既有main,又
有foo,則這種情況下,程序就先從foo開始執行。所以,main函數和其他
函數一樣,並沒有特殊地位。

下面談談在uClinux中,main函數的argc,argv是參數怎樣傳遞的。我們
以flat格式可執行文件爲例。uClinux下支持一種叫flat的可執行文件格式。
這種文件格式比較簡單,基本上是平鋪的,所以叫flat很形象。現在好像
uClinux-2.4.x內核的版本已經能夠支持elf格式的文件執行了。不過爲了
舉例簡單,我還是用flat格式舉例。這裏暫不分析flat文件格式,我們把注
意力放到參數傳遞上。uClinux開發用戶程序,首先當然是編碼,然後編譯,
編譯生成的文件是elf格式的,所以要用工具elf2flt將elf文件轉換成flat,
假設這個工作已經完成。

我們在uclinux的shell下執行一個文件foo x y,foo是程序名,x, y是
參數。學過C語言的都知道,x,y作爲參數會傳遞給main,其中argc=3,
argv[0]="foo", argv[1]="x", argv[2]="y"。這些參數是如何傳遞進來的呢。
在你執行一個程序的時候,操作系統會調用

do_execve(char *filename, char**argv, char**envp, struct pt_regs *regs),
這個操作會根據文件路徑打開文件,裝入內存,argv就是放到命令行參數,envp是
環境變量參數。

在裝入文件時,系統會根據不同的文件格式調用不同文件裝入的handler,如果
是flat格式,就會調用load_flat_binary(),在fs/binfmt_flat.c中。有關參數,會
根據一路傳遞下來的argv,envp首先處理一遍計算出參數的個數argc,envc。然後在函
數create_flat_tables裏面建立好參數表。整個函數代碼如下:
static unsigned long create_flat_tables(unsigned long pp, struct linux_binprm *
bprm)
{
(1) unsigned long *argv,*envp;
(2) unsigned long * sp;
(3) char * p = (char*)pp;
(4) int argc = bprm->argc;
(5) int envc = bprm->envc;
(6) char dummy;

(7) sp = (unsigned long *) /
((-(unsigned long)sizeof(char *))&(unsigned long) p);

(8) sp -= envc 1;
(9) envp = sp;
(10) sp -= argc 1;
(11) argv = sp;

(12) flat_stack_align(sp);
(13) if (flat_argvp_envp_on_stack()) {
(14) --sp; put_user((unsigned long) envp, sp);
(15) --sp; put_user((unsigned long) argv, sp);
(16) }

(17) put_user(argc,--sp);
(18) current->mm->arg_start = (unsigned long) p;
(19) while (argc-->0) {
(20) put_user((unsigned long) p, argv );
(21) do {
(22) get_user(dummy, p); p ;
(23) } while (dummy);
(24) }
(25) put_user((unsigned long) NULL, argv);
(26) current->mm->arg_end = current->mm->env_start = (unsigned long) p;
(27) while (envc-->0) {
(28) put_user((unsigned long)p, envp); envp ;
(29) do {
(30) get_user(dummy, p); p ;
(31) } while (dummy);
(32) }
(33) put_user((unsigned long) NULL, envp);
(34) current->mm->env_end = (unsigned long) p;
(35) return (unsigned long)sp;
}
(1)-(6)行是變量聲明。其中argc和envc分別記錄前面已經計算出來的參數個數和
環境變量參數個數。p=pp是參數和環境變量數組的指針,sp是你要執行程序的用戶區
堆棧,就是foo程序執行時,用戶空間堆棧的起始地址。(8)-(11)是一個堆棧調整。首
先sp移動envc 1個單位,這envc 1個用來存放一共envc個envp[0]->envc[envp-1]元素
地址的,多餘一個放0,表示envp數組結束。然後sp在移動argc 1各單位,留出argc 1
單位空間,這argc 1個單位是用來存放argc個argv[0]->argv[argc-1]元素地址的,多
餘一個也放0,表示argv數組結束。經過堆棧調整,argv和envp各自指向自己在堆棧中
的位置。如果開始堆棧初值記爲init_sp,則現在envp=init_sp-(envc 1),
argv=envp-(argc 1)。

(12)無關緊要,略去不提。(13)-(17)又是一次堆棧調整。(14)是sp再移動1個單
位,然後將envp放入這個地址(此時envp=init_sp-(envc 1)),然後(15)又將sp移動一個
單位,將argv寫入. (17)是移動堆棧後將argc也寫入裏面.

(18)-(35)行是將argv[0]->argv[argc-1](在p所指向地方)依次寫入argv所指堆棧
區域中.然後再將envp[0]->envp[envc-1](也是由p所指)寫入envp所指的堆棧區域中.
在寫入同時,還要設置進程控制塊相應的數據結構,如arg_start,env_start,env_end等.

下面舉例和畫圖來說明過程.比如執行foo x y,此時argc=3,argv[0]="foo",
argv[1]="x", argv[2]="y", envc=1, envp[0]="path=/bin". 假設用戶堆棧起始
空間堆棧地址是sp=0x1f0000,pp=0x1c0000.則處理過後在foo執行前,他的用戶空
間堆棧frame如下:


--------------------------------
0x1f0000 | 0000 |
--------------------------------
0x1efffc | envp[0] = 0x1c0008 | ---->指向"path=/bin"
--------------------------------
0x1efff8 | 0000 |
--------------------------------
0x1efff4 | argv[2] = 0x1c0006 | ----->指向"y"
--------------------------------
0x1efff0 | argv[1] = 0x1c0004 | ----->指向"x"
--------------------------------
0x1effec | argv[0] = 0x1c0000 | ----->指向"foo"
--------------------------------
0x1effe8 | start addr of envp = 0x1efffc|
在寫入同時,還要設置進程控制塊相應的數據結構,如arg_start,env_start,env_end等.

下面舉例和畫圖來說明過程.比如執行foo x y,此時argc=3,argv[0]="foo",
argv[1]="x", argv[2]="y", envc=1, envp[0]="path=/bin". 假設用戶堆棧起始
空間堆棧地址是sp=0x1f0000,pp=0x1c0000.則處理過後在foo執行前,他的用戶空
間堆棧frame如下:


--------------------------------
0x1f0000 | 0000 |
--------------------------------
0x1efffc | envp[0] = 0x1c0008 | ---->指向"path=/bin"
--------------------------------
0x1efff8 | 0000 |
--------------------------------
0x1efff4 | argv[2] = 0x1c0006 | ----->指向"y"
--------------------------------
0x1efff0 | argv[1] = 0x1c0004 | ----->指向"x"
--------------------------------
0x1effec | argv[0] = 0x1c0000 | ----->指向"foo"
--------------------------------
0x1effe8 | start addr of envp = 0x1efffc|
到r2-r6裏來傳遞。當然,如果超過5個,就要藉助堆棧了。

既然main帶了參數,那麼在調用main之前,要把argc放到r2裏面,argv放到r3裏
面,envp放到r4裏面。剛纔說了,sp是用戶空間堆棧起始地址。所以在開始執行foo
代碼時候,r0=sp,在上文例子裏r0等於0x1effe0.則如下僞彙編代碼可以讓參數裝入
正確寄存器。

load r2, (r0) /* r2 = argc */
load r3, (r0, 4) /* r3 = argv */
load r4, (r0, 8) /* r4 = envp */
call main /* 跳轉到main函數 */

call _exit

以上代碼就是最簡單的進入main函數前的預處理。當然,不同系統不同格式文件處
理方式是不同的,剛纔的一些例子是我碰到的一些情景和解決方案。剛學c語言那陣

覺得main挺神祕,做過系統就知道,其實main跟別的函數沒有任何區別:)

printf和標準輸出

上次寫到main函數的參數傳遞.現在繼續往下進行.最近忙實驗室的事情,看了一週
的文章,也沒啥進展,週末寫點技術貼,放鬆一下:-)

進入main函數後,就要調用printf("Hello World!/n");了.順便將C語言參數傳遞提
一下.字符串"Hello World!/n"編譯器是當作字符串常量來處理的,雖然printf是在main
內部調用,但"Hello World!/n"可不是放在main的棧中,字符串常量至少是放到.data段的
,準確說是放在只讀數據段.rodata,這個我在工作站上驗證了一把.假如編輯的文件名是
hello.c,首先編譯生成elf格式二進制文件gcc hello.c -o hello然後用命令
objdump -s hello -s參數會將所有段信息dump出來.你會看到"Hello World!/n"位於
.rodata段.

printf()是個標準C庫函數.雖然功能簡單,但實現起來卻不容易.這是個和平臺相關的
函數.在pc上,printf輸出是輸出到終端屏幕,在嵌入式設備上,一般printf()是輸出到串口.
同是調用printf(),最終輸出的設備卻不同,從直覺的肯定是感覺printf()底層和平臺是相
關的.那麼printf()是怎樣實現的呢?

可以看一下C庫程序的代碼,這裏以uClibc爲例.
int printf(const char * __restrict format, ...)
{
va_list arg;
int rv;

va_start(arg, format);
rv = vfprintf(stdout, format, arg);
va_end(arg);

return rv;
}
printf支持字符串格式化輸出,具體參數處理這裏不提.可以看到printf()調用了
vfprintf(),vfprintf()第一個參數是stdout是標準輸出設備.標準輸出設備是個結構體,
最重要的成員就是他的描述符,其值爲1.

跟進vfprintf()函數看,裏面是複雜的參數處理,因爲printf()的參數形式很靈活,
所以在vfprintf()裏面要對傳進來的參數進行解析處理,形成最終的輸出格式.有興趣的
可以看一下,藉助這個可以讓你在一個沒有操作系統的平臺上實現你自己的printf()
函數.這樣你在裸機上調程序時輸出調試信息就更方便些(實際上uClinux的printk就是
這麼幹的).

vfprintf()在參數處理之後,就是輸出了,輸出調用的是putc(),進入putc()然後
再跟進幾層函數,發現調用了linux系統調用write()。呵呵,是的,輸出就是藉助操作
系統代碼完成的。在write之前所有的代碼都是C庫的代碼,可以說是和平臺無關的。
而涉及到具體輸出,就要調用操作系統提供給你的接口。系統調用的原理就是通過一定
手段(一般是trap陷阱)進入操作系統的內核空間,調用操作系統代碼來完成某些任務。

Linux系統調用針對不同平臺有不同的實現方式。這個以後再講。調用write()後,
進入內核空間,首先來到的就是sys_write(),這個函數代碼位於fs/read_write.c中。
一進入sys_write(),就要根據傳進來的fd描述符找到相應的file結構。對於標準輸出,
fd=1,每個進程的進程控制塊都有一個打開文件的數組。file結構就是根據fd在這個
數組中查找到相應的結構。找到結構後,就會調用file->write()來向外輸出。具體輸出
到哪裏,就要看file結構對應的設備驅動是什麼。一般嵌入式系統可以從串口將信息輸
出,那麼file->write()最底層就是調用的串口驅動的類似transmit_char的函數。

有關linux的設備驅動有很多書介紹,整個驅動的結構很複雜,我這裏也沒必要提了.
至於終端設備怎樣掛在驅動隊列裏面,怎麼根據標準輸出的描述符找到相應的驅動結構
有興趣的請查閱相關資料.


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章