使用 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
位代码。