使用 C 语言编写运行于16位实模式下的代码
通常16位实模式下的代码都是用汇编语言写的,但是为什么要用c语言写呢?因为爽啊!今天老李就教大家怎么用c语言写出来可以运行在实模式下的代码。话不多说,开干!
环境准备
- 系统:
Ubuntu 18.04.4 LTS - 编译器:
gcc version 7.4.0 (Ubuntu 7.4.0-1ubuntu1~18.04.1) - 汇编器:
GNU as (GNU Binutils for Ubuntu) 2.30 - 链接器:
GNU ld (GNU Binutils for Ubuntu) 2.30 - 虚拟机:
QEMU emulator version 2.11.1(Debian 1:2.11+dfsg-1ubuntu7.21)
实战
我们还是按照讲解代码的套路来。先来看看实模式下的常规操作,涉及过程调用和显存的操作。
常规操作
代码
.section .text
.globl start
start:
.code16
movw $0xb800, %ax
movw %ax, %es
xorw %ax, %ax
movw %ax, %ss
movw $0x7c00, %sp
pushw $1
pushw $2
callw sum
addw $4, %sp
orw $0x0a30, %ax
movw %ax, %es:0
jmp .
sum:
pushw %bp
movw %sp, %bp
movw 4(%bp), %ax
addw 6(%bp), %ax
popw %bp
retw
.org 510
.word 0xAA55
解释
第6、7行设置显存。
第9、10、12行设置堆栈段及栈顶指针。
第14、15行将sum函数要用到的两个参数压栈。我们使用的是pushw指定了数据宽度为16位。注意这里只能选择相加之后结果是个位数的参数,因为作为示例,我们这里只处理了个位数的显示。
第16行调用sum函数。
第17行恢复栈顶指针。
第19行将数字转化为对应的ascii码(+0x30)并附加显示属性(0x0a)。
第20行将要显示的数据送入显存对应的内存。
第22行陷入死循环。
第24行开始定义sum函数。
第25行将bp压栈保护。
第26行将sp赋值给bp,下面通过bp读取压入栈中的参数。
第28行将第二个压入的参数,即立即数2从栈中移动到ax中。因为call指令隐式压入了ip、pushw %bp压入了bp,占用了4个字节,所以第二个压入的参数距离栈顶的偏移量是4,第一个参数偏移量是6。
第29行取出第一个参数和第二个参数相加,结果保存在ax中。
第31行恢复bp。
第32行退出函数。
编译运行
$ as --32 boot.s -o boot.o
$ objcopy -O binary -j .text boot.o boot.bin
$ qemu-system-i386 boot.bin

反编译
$ objdump -D -b binary -m i386 -Mi8086,suffix boot.bin
boot.bin: 文件格式 binary
Disassembly of section .data:
00000000 <.data>:
0: b8 00 b8 movw $0xb800,%ax
3: 8e c0 movw %ax,%es
5: 31 c0 xorw %ax,%ax
7: 8e d0 movw %ax,%ss
9: bc 00 7c movw $0x7c00,%sp
c: 6a 01 pushw $0x1
e: 6a 02 pushw $0x2
10: e8 0c 00 callw 0x1f
13: 83 c4 04 addw $0x4,%sp
16: 0d 30 0a orw $0xa30,%ax
19: 26 a3 00 00 movw %ax,%es:0x0
1d: eb fe jmp 0x1d
1f: 55 pushw %bp
20: 89 e5 movw %sp,%bp
22: 8b 46 04 movw 0x4(%bp),%ax
25: 03 46 06 addw 0x6(%bp),%ax
28: 5d popw %bp
29: c3 retw
...
1fe: 55 pushw %bp
1ff: aa stosb %al,%es:(%di)
先把结果放在这里,稍后过来对比。
下面我们尝试一下32位指令和寄存器。
使用 32 位指令和寄存器
代码
.section .text
.globl start
start:
.code16
movw $0xb800, %ax
movw %ax, %es
xorw %ax, %ax
movw %ax, %ss
movw $0x7c00, %sp
pushl $1
pushl $2
calll sum
addl $8, %esp
orw $0x0a30, %ax
movw %ax, %es:0
jmp .
sum:
pushl %ebp
movl %esp, %ebp
movl 0x8(%ebp), %eax
addl 0xc(%ebp), %eax
popl %ebp
retl
.org 510
.word 0xAA55
解释
与常规操作中的主要区别在第14~17行和sum函数中。我们指定了指令的长度为32位,加了l后缀。
第17行因为指定数据的长度是32位,所以一个操作数的长度是4个字节,压入了两个,这里加8恢复栈顶指针。
第28、29行因为数据都是32位的,eip、ebp也都是32位的,所以这里参数的偏移量是2*4 = 8、2*4+4 = 0xc。
编译运行
结果同上,不贴图了。我们主要反编译一下,看一下同样的效果,底层的区别在哪里。
反编译
$ objdump -D -b binary -m i386 -Mi8086,suffix boot.bin
boot.bin: 文件格式 binary
Disassembly of section .data:
00000000 <.data>:
0: b8 00 b8 movw $0xb800,%ax
3: 8e c0 movw %ax,%es
5: 31 c0 xorw %ax,%ax
7: 8e d0 movw %ax,%ss
9: bc 00 7c movw $0x7c00,%sp
c: 66 6a 01 pushl $0x1
f: 66 6a 02 pushl $0x2
12: 66 e8 0d 00 00 00 calll 0x25
18: 66 83 c4 08 addl $0x8,%esp
1c: 0d 30 0a orw $0xa30,%ax
1f: 26 a3 00 00 movw %ax,%es:0x0
23: eb fe jmp 0x23
25: 66 55 pushl %ebp
27: 66 89 e5 movl %esp,%ebp
2a: 67 66 8b 45 08 movl 0x8(%ebp),%eax
2f: 67 66 03 45 0c addl 0xc(%ebp),%eax
34: 66 5d popl %ebp
36: 66 c3 retl
...
1fc: 00 00 addb %al,(%bx,%si)
1fe: 55 pushw %bp
1ff: aa stosb %al,%es:(%di)
对比第14~17行,这4行指令前都多了前缀0x66。0x66指令前缀用于反转当前默认操作数大小,处理器当前运行在16位实模式下,操作数大小反转后成为32位。
第23、24行多了前缀0x67。0x67指令前缀用于反转当前默认地址大小,因为这两行涉及内存寻址。
有了上面两个示例作为基础,下面我们将sum改造为c语言函数。
使用 C 语言
代码
汇编代码
.section .text
.globl start
start:
.code16
movw $0xb800, %ax
movw %ax, %es
xorw %ax, %ax
movw %ax, %ss
movw $0x7c00, %sp
pushl $1
pushl $2
calll sum
addl $8, %esp
orw $0x0a30, %ax
movw %ax, %es:0
jmp .
汇编部分我们从上面的示例中删掉了jmp指令之后的内容。sum函数我们放在c语言的代码中,引导标识0xAA55我们稍后手动写入。
c 语言代码
int sum(int x, int y)
{
return x + y;
}
和通常的代码没有区别,只是编译、链接稍有不同。
编译链接
编译
先来编译汇编语言的部分。
$ as --32 boot.s -o boot.o
编译c语言部分。
$ cc -m16 -ffreestanding -fno-pic -c sum.c -o sum.o
-m16告知编译器生成 16 位指令-ffreestanding告知编译器按独立环境编译,该环境可以没有标准库,且对main()函数没有要求。该选项隐含设置了-fno-builtin,且与-fno-hosted等价-fno-pic告知编译器禁止生成位置无关的代码
链接
$ ld -m elf_i386 boot.o sum.o -o boot.elf
这里会有一个警告,因为我们没有指定入口点,而默认的入口点是_start。我们并没有用到,不理他就行了。
接下来从elf文件中复制出纯二进制的代码段内容。
$ objcopy -O binary -j .text boot.elf boot.bin
手动添加可引导标志0xAA55,这里我们通过之前写的一个小工具来完成这项工作。
$ ./sign boot.bin
运行结果还是同上,不贴图了。
反编译
来看看包含c语言代码的程序和之前纯汇编的有什么不同。
$ objdump -D -b binary -m i386 -Mi8086,suffix boot.bin
boot.bin: 文件格式 binary
Disassembly of section .data:
00000000 <.data>:
0: b8 00 b8 movw $0xb800,%ax
3: 8e c0 movw %ax,%es
5: 31 c0 xorw %ax,%ax
7: 8e d0 movw %ax,%ss
9: bc 00 7c movw $0x7c00,%sp
c: 66 6a 01 pushl $0x1
f: 66 6a 02 pushl $0x2
12: 66 e8 0d 00 00 00 calll 0x25
18: 66 83 c4 08 addl $0x8,%esp
1c: 0d 30 0a orw $0xa30,%ax
1f: 26 a3 00 00 movw %ax,%es:0x0
23: eb fe jmp 0x23
25: 66 55 pushl %ebp
27: 66 89 e5 movl %esp,%ebp
2a: 67 66 8b 55 08 movl 0x8(%ebp),%edx
2f: 67 66 8b 45 0c movl 0xc(%ebp),%eax
34: 66 01 d0 addl %edx,%eax
37: 66 5d popl %ebp
39: 66 c3 retl
...
1fb: 00 00 addb %al,(%bx,%si)
1fd: 00 55 aa addb %dl,-0x56(%di)
23,26c23,27
< 2a: 67 66 8b 45 08 movl 0x8(%ebp),%eax
< 2f: 67 66 03 45 0c addl 0xc(%ebp),%eax
< 34: 66 5d popl %ebp
< 36: 66 c3 retl
---
> 2a: 67 66 8b 55 08 movl 0x8(%ebp),%edx
> 2f: 67 66 8b 45 0c movl 0xc(%ebp),%eax
> 34: 66 01 d0 addl %edx,%eax
> 37: 66 5d popl %ebp
> 39: 66 c3 retl
对比一下,我们发现在c语言的版本中,编译后的指令中多了一个步骤,上面的第7行,将一个参数放入edx中。比我们的纯汇编代码多了一条指令,换句话说,c语言生成的代码没有我们手写的汇编语言代码效率高。因为完成同样的任务,我们用了更少的指令。
总结
首先要意识到虽然是在实模式下,但是我们的处理器是32位处理器,所以我们能够使用32位寄存器。在汇编代码中是通过给指令加l后缀指定操作数或地址的大小,对应生成的机器码中会加上0x66、0x67前缀反转当前默认操作数或地址大小。其次是C语言部分,虽然代码的写法和平常一样,但是编译的时候需要指定生成16位代码。
