跳至主要內容

使用 C 语言编写运行于16位实模式下的代码

未央大约 8 分钟16位x86实模式C 语言

通常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指令隐式压入了ippushw %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
16bit_c_1
16bit_c_1

反编译

$ 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位的,eipebp也都是32位的,所以这里参数的偏移量是2*4 = 82*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行指令前都多了前缀0x660x66指令前缀用于反转当前默认操作数大小,处理器当前运行在16位实模式下,操作数大小反转后成为32位。

23、24行多了前缀0x670x67指令前缀用于反转当前默认地址大小,因为这两行涉及内存寻址。

有了上面两个示例作为基础,下面我们将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,这里我们通过之前写的一个小工具open in new window来完成这项工作。

$ ./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后缀指定操作数或地址的大小,对应生成的机器码中会加上0x660x67前缀反转当前默认操作数或地址大小。其次是C语言部分,虽然代码的写法和平常一样,但是编译的时候需要指定生成16位代码。