使用 GCC 和 GNU Binutils 編寫能在 x86 實模式運行的 16 位代碼


不可否認,這次的標題有點長。之所以把標題寫得這么詳細,主要是為了搜索引擎能夠准確地把確實需要了解 GCC 生成 16 位實模式代碼方法的朋友帶到我的博客。先說一下背景,編寫能在 x86 實模式下運行的 16 位代碼,這個話題確實有點復古,所以能找到的資料也相應較少。要運行 x86 實模式的程序,目前我知道的只有兩種方式,一種是使用 DOS 系統,另一種是把它寫成引導扇區的代碼,在系統啟動時直接運行。很顯然,許多講自己實現操作系統的書籍都會講到 x86 實模式,也只有自己實現操作系統引導的朋友需要用到 x86 實模式,所以我這篇文章的閱讀用戶數肯定很少,雖然我自認為它填補了網上關於該話題相關資料缺乏的空白。因此,凡是逛到我這篇文章的朋友,請點一下推薦,謝謝。

為什么說我這篇博客填補了相關話題的空白呢?那是因為不管是那些寫書的,還是網上寫文章的,一旦需要編寫 16 位的實模式代碼,都喜歡拿 NASM 說事兒,一點也不顧 GNU AS 的感受。當然,這是有歷史原因的,因為 Linux 自從其誕生起就是 32 位,就是多用戶多任務操作系統,所以 GCC 和 Gnu AS 一移植到 Linux 上就是用來編寫 32 位保護模式的代碼的。而且,ELF 可執行文件格式也只有 ELF32 和 ELF64,沒聽說過有 ELF16 的。即使是 Linux 自己,剛誕生的時候(1991年),也只有使用 as86 匯編器來編寫自己的 16 位啟動代碼,直到 1995 年以后,GNU AS 才逐步加入編寫 16 位代碼的能力。

下面開始我的 GCC 和 GNU Binutils 的 16 位代碼之旅。我決定使用 DOS 作為我的測試環境,所以最后生成的可執行文件都把它制作成 DOS 系統中可運行的 Plain Binary 格式。第一步安裝一個 qemu 虛擬機來運行 FreeDOS,安裝虛擬機在 Ubuntu 中只需要一個 sudo apt-get install qemu 命令就可以完成,所以我就不截圖了。但是 FreeDOS 的軟盤映像文件需要到 Qemu 的官網上面去下載,下載地址如下圖:

使用 qemu-system-i386 -fda freedos.img 可以運行 Qemu 虛擬機和 FreeDOS 系統,如下圖:

因為匯編語言更接近底層,而 C 語言更高級,所以先從匯編語言開始,逐步過渡到 C 語言。先寫一個簡單的、能在 DOS 中顯示一個“Hello,world!”的匯編語言程序,考慮到我之后會使用該程序調用 C 語言的 main 函數,並且該程序負責讓程序運行結束后順利返回 DOS 系統,所以我把這個程序命名為 test_code16_startup.s。其代碼如下:

下面對以上代碼進行簡單解釋:

1. GNU AS 匯編器使用的匯編語言采用的是 AT&T 語法,該語法和 Intel 語法不同。我更喜歡 AT&T 的語法,原因有兩個,一是 AT&T 語法是 Linux 世界中通用的標准,二是 AT&T 語法在某些概念方面確實理解起來更簡單(比如內存尋址模式)。有匯編語言基礎的人,AT&T 語法學起來也很快,主要有以下幾條:①匯編指令后面跟有操作數長度的后綴,比如 mov 指令,如果操作數是 8 位,則用 movb,如果操作數是 16 位,則用 movw,如果操作數是 32 位,則用 movl,如果操作數是 64 位,則用 movq,其余指令依此類推;②操作數的順序是源操作數在前,目標操作數在后,比如 movw %cs, %ax 表示把 cs 寄存器中的數據移動到 ax 寄存器中,這個順序和 Intel 匯編語法正好相反;③所有的寄存器使用 % 前綴,如 %ax, %di, %esp 等;④對於立即數,需要使用 $ 前綴,比如 $4,  $0x0c,而且如果一個數字是以 0 開頭,則是 8 進制,以其它數字開頭,是 10 進制,以 0x 開頭則是 16 進制,標號當立即數使用時,需要 $ 前綴,比如上面的 pushw $message,而標號當函數名使用時,不需要 $ 前綴,比如上面的 callw display_str;⑤內存尋址方式,眾所周知,x86 尋址方式眾多,什么直接尋址、間接尋址、基址尋址、基址變址尋址等等讓人眼花繚亂,而 AT&T 語法對內存尋址方式做了一個很好的統一,其格式為 section:displacement(base, index, scale),其中 section 是段地址,displacement 是位移,base 是基址寄存器,index 是索引,scale 是縮放因子,其計算方式為線性地址=section + displacement + base + index*scale,最重要的是,可以省略以上格式中的一個或多個部分,比如 movw 4, %ax 就是把內存地址 4 中的值移動到 ax 寄存器中,movw 4(%esp), %ax 就是把 esp+4 指向的地址中的值移動到 ax 寄存器中,依此類推。我上面的介紹是不是全網絡最簡明的 AT&T 匯編語法教程?

2. 在以上代碼中我全部使用的都是 16 位的指令,如 movw、pushw、callw 等,並且直接在代碼中定義了字符串“Hello, world!”。

3. 在以上代碼中使用了函數 display_str,在調用 display_str 之前,我使用 pushw $15 和 pushw $message 將參數從右向左依次壓棧,然后使用 callw 指令調用函數,這和 C 語言的函數調用約定是一樣的。調用 callw 指令會自動將 %ip 寄存器壓棧,而在函數開始時,我又用 pushw %bp 將 %bp 寄存器壓棧,所以 %esp 又向下移動了 4 個字節,所以在函數中使用 0x4(%esp) 和 0x6(%esp) 可以訪問到這兩個參數。在 32 位代碼中,由於調用函數時壓棧的是 %eip 和 %ebp,所以需要使用 0x8(%esp) 和 0xc(%esp) 來依次訪問壓棧的參數。關於匯編語言函數調用的細節,我這里有一本好書Linux匯編編程指南.pdf。這是一本免費的英文版電子書,其原名為《Programming from the ground up》。

4. 以上代碼使用 BIOS 中斷 int 0x10 來輸出字符串,使用 DOS 中斷 int 0x21 來返回 DOS 系統。

5. 最重要的是,需要使用 .code16 指令讓匯編器將程序匯編成 16 位的代碼。

代碼完成后,使用下面一串命令就可以把它進行匯編、鏈接,然后轉換成 DOS 下的純二進制格式(Plain Binary),最后復制到 FreeDOS.img 中,使用 Qemu 虛擬機執行 FreeDOS,然后運行該 16 位實模式程序。這一串命令及其運行效果如下圖:

這些命令中比較重要的選項我都特意標出來了。由於我用的是 64 位的環境,所以調用 as 命令的時候需要指定 --32 選項,調用 ld 命令的時候需要指定 -m elf_i386 選項。指定以上選項后,生成的是 32 位的 ELF 目標文件,否則默認會生成 64 位的 ELF 目標文件,如果目標文件是 64 位,以后和 C 語言生成的目標文件連接時會出問題。使用 32 位環境的朋友們不用特意指定這兩個選項。由於 DOS 系統總是把 Plain Binary 文件載入到 0x100 地址處執行,所以調用 ld 命令時,需要指定 -Ttext 0x100 選項。ld 命令執行完成后,生成的是 ELF 格式的可執行文件 test.elf,最后需要調用 objcopy 生成純二進制文件,-j .text 選項的意思是只需要代碼段,因為我把“Hello, world!”也是定義在代碼段中的,-O binary 選項指定輸出格式為純二進制文件,輸出文件為 test.com。最后,將 freedos.img 鏡像文件 mount 到 Ubuntu 中,將 test.com 拷貝到其中,然后 umount,然后運行虛擬機,在 DOS 中運行 test,就可以看到效果了。

除了 as 和 ld,GNU Binutils 中的其它程序也是寫程序和分析程序時的好幫手。可以使用 readelf -S 查看 test.elf 文件中的所有段,也可以使用 objdump -s 命令將 test.elf 中的數據以 16 進制形式輸入,如下圖:

當然,也可以使用 objdump -d 或者 objdump -D 將程序進行反匯編,查看是否真正生成了 16 位代碼,如下圖:(反匯編時一定要指定 -m i8086 選項)

也可以對純二進制格式的文件進行反匯編,必須指定 -b binary 選項,如下圖,對 test.com 進行反匯編:

反匯編時,一定要指定 -m i8086 選項,否則 objdump 不知道反匯編的是 16 位代碼。(前面提到過 Linux 從誕生起就是 32 位,所以 ELF 只有 32 位和 64 位兩種,沒有 16 位的ELF格式。)如下圖,如果使用 -m i386 選項進行反匯編,反匯編結果將不知所雲:

下面進入 C 語言的世界。為了搞清楚 C 語言生成的 16 位代碼的匯編指令有哪些特別之處,先寫一個簡單的 C 語言程序進行調研,如下圖:

該程序有以下特點:

1. 程序的開頭使用了 __asm__(".code16\n") 嵌入匯編指令,以指示 as 生成 16 位代碼;

2. display_str 函數的簽名和之前匯編語言中的相同,可以使用它來觀察 C 語言生成的代碼如何傳遞參數。

使用下面的命令對程序進行編譯和反匯編,如下圖:

從上圖可以看出,C 語言生成的代碼雖然是 16 位,但是它有如下特點:①從生成的 display_str 函數中可以看出,函數一開始是 push %ebp,而不是 push %bp;②在 display_str 函數中獲取參數的位置分別為 0x8(%ebp) 和 0xc(%ebp),而不是我在匯編語言中寫的 0x4(%ebp) 和 0x6(%ebp);③從生成的 main 函數可以看出,調用 diaplay_str 之前,沒有使用 push 命令把參數壓棧,而是直接通過 sub $0x18, %esp 調整 %esp 的位置,然后使用 mov 指令將參數放到指定位置,和使用 push 指令的效果相同;④雖然我在 display_str 函數的定義中故意將長度參數定義為 short,但是從生成的代碼中可以看到依然是每隔 4 個字節放一個參數。

另外需要說明的是,調用 gcc 時除了指定 -c 選項指示它只編譯不連接外,還要指定 -m32 選項,這樣才會生成 32 位的匯編代碼,而只有在 32 位的匯編代碼中使用 .code16 指令,才能編譯成 16 位的機器碼。如果沒有指定 -m32 選項,則生成的是 64 位匯編代碼,然后匯編時會出錯。使用 -m32 選項后,生成的目標文件是 ELF32 格式。ELF32 格式的目標文件只能和 ELF32 格式的目標文件連接,這也是為什么前面的 as 和 ld 需要指定 --32 和 -m elf_i386 選項的原因。

通過以上分析,似乎可以得出以下結論:只需要將匯編代碼中的 pushw %bp 更改為 pushl %ebp,然后將獲取參數的位置調整為 0x8(%ebp) 和 0xc(%ebp),就可以從 C 語言里面成功調用到匯編語言中的函數了。而事實上,還有一點點小差距。從上面的反匯編代碼中可以看到,函數調用時使用的是 16 位的 call 指令,該指令壓棧的是 %ip,而不是 %eip,而 C 語言生成的函數框架中獲取的參數位置是按照將 %eip 壓棧計算出來的,它們之間差了兩個字節。

為了證明我以上判斷的准確性,我將上面的C語言程序和匯編程序修改后,編譯連接成一個完整的程序,看看它究竟能否正確運行。如下圖:

C 語言程序修改很簡單,就是去掉了 display_str 函數的實現,只保留聲明。匯編代碼如下圖:

匯編語言的更改包含以下幾個地方:將 display_str 函數導出,將 pushw %bp 改為 pushl %ebp,同時修改獲取參數的位置。編譯、連接、運行程序的指令如下:

可以看到“Hello world from C language”沒有正確顯示出來。上面的命令都是前面用過的,不需要多解釋,唯一不同的是使用 C 語言寫的程序多了一個 .rodata 段,所以在 objcopy 的時候需要把這個段也包含進來。

由於 C 語言生成的函數框架都是從 0x8(%ebp) 開始取參數,它認為 0x0(%ebp) 是 old ebp,0x4(%ebp)是 %eip,而事實上使用 16 位的 call 指令調用函數后,0x4(%ebp) 中是 %ip 而不是 %eip,所以要從 0x6(%ebp) 開始取參數。我們不可能修改 C 語言生成的函數框架,只能看看能否將 16 位的 call 改成 32 位的 call。

辦法當然是有的,那就是不使用 .code16,而使用 .code16gcc。.code16gcc 和 .code16 不同的地方就在於它生成的匯編代碼在使用到 call、ret、jump 等指令時,都生成 32 位的機器碼,相當於 calll,retl,jumpl。這也是 .code16gcc 叫 .code16gcc 的原因,因為它就是配合 GCC 生成的函數框架使用的。

下面再來修改代碼,C 語言代碼修改很簡單,只需要將 .code16 改成 .code16gcc 即可,如下圖:

通過反匯編,可以看到它使用了 32 位的 calll 和 retl,如下圖:

匯編程序的修改主要是將 .code16 改為 .code16gcc,然后手動將 callw 改成 calll,將 retw 改成 retl,如下圖:

最后,編譯連接,拷貝到 freedos.img,運行虛擬機,查看運行效果,如下圖:

大功告成,運行效果如上圖。

總結:

編寫運行於 x86 實模式下的 16 位代碼是一個很復古的話題,編寫能在 DOS 下運行的 Plain Binary 可執行文件是一個更復古的話題。以往,凡是需要使用 x86 的 16 位實模式的時候,作者都喜歡用 NASM 來編程。比如《30天自制操作系統》、《Orange's 一個操作系統的實現》、《x86匯編語言——從實模式到保護模式》等書籍都以 NASM 匯編器和 Intel 匯編語法作為示例。而且他們都是在進入 32 位保護模式后,才讓匯編語言和 C 語言共同工作。

我用 Linux 操作系統,所以我就是想不管是寫 32 位代碼,還是 16 位代碼,都能使用 GCC 和 GNU AS。我還想即使是在 16 位模式下,也能盡量少用匯編語言,多用 C 語言。經過努力,有了上面的文章。使用 GCC 和 GNU Binutils 編寫運行於 x86 實模式的 16 位代碼的過程如下:

1. 如果只用匯編語言編寫 16 位程序,請使用 .code16 指令,並保證只使用 16 位的指令和寄存器;如果要和 C 語言一起工作,請使用 .code16gcc 指令,並且在函數框架中使用 pushl,calll,retl,leavel,jmpl,使用 0x8(%ebp) 開始訪問函數的參數;很顯然,使用 C 語言和匯編語言混編的程序可以在實模式下運行,但是不能在 286 之前的真實 CPU 上運行,因為 286 之前的 CPU 還沒有 pushl、calll、retl、leavel、jmpl 等指令。

2. 使用 as 時,請指定 --32 選項,使用 gcc 時,請指定 -m32 選項,使用 ld 時,請指定 -m elf_i386 選項。如果是反匯編 16 位代碼,在使用 objdump 時,請使用 -m i8086 選項。

3. 在 DOS 中運行的 .com 文件會被加載到 0x100 處執行,所以使用 ld 連接時需指定 -Ttext 0x100 選項;引導扇區的代碼會被加載到 0x7c00 處執行,所以使用 ld 連接時需指定 -Ttext 0x7c00 選項。

4. 使用 gcc、as、ld 生成的程序默認都是 ELF 格式,而在 DOS 下運行的 .com 程序是 Plain Binary 的,在引導扇區運行的代碼也是 Plain Binary 的,所以需要使用 objcopy 將 ELF 文件中的代碼段和數據段拷貝到一個 Plain Binary 文件中,使用 -O binary 選項; Plain Binary 文件也可以反匯編,在使用 objdump 時需指定 -b binary 選項。

 

(京山游俠於2014-08-24發布於博客園,轉載請注明出處。)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM