你们要的 Inline ASM 疑难解答

你们要的 Inline ASM 疑难解答

15 October 2017 Tech

背景

我校 (西交利物浦) 的 信息与计算科学 专业的大二的课程中,有一门 (CSE101,计算机系统) 涉及到一个使用 MSVC Inline ASM 完成的作业。由于大部分人此前没有接触过 ASM 甚至没有接触过编程,而课程本身因为 一些原因 难以理解 (I_cant_hear_you.jpg),所以最近几天我一直收到关于这个作业的各种各样的问题,其中很多都是重复的类似的问题。当然,这也怪我本人在群里 “假装自己很会 ASM”(手写 x86 ASM 真的是第一次)。不管怎么说,因为大家都会遇到类似的问题,所以我想着我要不还是干脆写篇博客一起回答一下,以减轻我的多线程工作负担(x)。

当然,写这篇文章的直接原因是应某同学的要求 —— 她发给我一篇来自其他同学的教程,让我修改一下其中可能存在的没有讲清楚的点。于是我决定还是自己从头写一份好了……

作业要求

使用 MSVC Inline ASM 编写一个满足如下要求的程序

  1. 让用户输入一个在 [2, 5] 之间的整数 n
  2. 循环 n 次,每次问用户索取一个新的正整数(如果不是,退出循环,跳到 3)
  3. 当循环退出的时候,打印退出消息并打印总循环次数
  4. 从小到大排序用户输入的数字并输出
  5. 输出用户输入的数字的总和

示例输出

Select total number of positive integers (between 2-5): 5
Enter positive integer 1: 67
Enter positive integer 2: 13
Enter positive integer 3: 21
Enter positive integer 4: 39
Enter positive integer 5: -9
Program terminates and has looped 4 times.
Your integers from lowest to highest is 13, 21, 39, 67
The total amount is 140

技能和前置要求

部分来自 来自其他同学的教程

  • Microsoft Visual Studio, 非 Windows 用户请使用虚拟机或者双系统
  • Visual Studio 的基本用法和调试器使用方法
  • C / C++ 的 基础的基础 (变量定义,数组定义,printfscanf
  • 基本的 x86 汇编指令和寄存器知识
  • MSVC Inline ASM 中与 C 部分交互的知识
  • 指针 和 值 的区别 (leamov 作用有啥不同,方括号有啥作用)
  • 什么是 esp
  • 理解冒泡排序算法

声明

这篇文章仅作参考使用,本文中不会附带完整的源代码片段,更不会直接给出作业的答案。本人无法保证该文的绝对正确性,以下全部是本人在自己完成这个作业的时候得出的经验性结论。我此前没有学习过 x86 ASM, 也无法对该话题作出最精确的描述。

作为未来的软件工程师,应该善用搜索引擎,知识库和问答网站(此处指 Google / Bing / DuckDuckGo,问答网站包括 StackOverflow,对于这个作业的知识库包括 Wikipedia / MSVC 官方文档不包括 百度搜索 / 百度文库 / 百度百科 / 百度知道)。我也从来没有专门学习过 x86 ASM,以下所有知识都是我在国庆假期前做这个作业的时候通过这些渠道获得。没有人能一直给出答案或者专门为了一个问题编写教程和问答,请一定要明白这一点。

(偷偷附加一点,我其实对这个课程使用 MSVC Inline ASM 挺有微词,不过主要是因为我的日常系统是 Linux,而 C 的不同实现之间的内联汇编并不兼容。况且如此使用 ASM 还依赖了一部分 C 的知识 —— 倒不如给学生提供一个能进行输入输出的库,然后让学生直接使用一个专门的汇编器来做这个作业。)

输入 / 输出

大部分人遇到的第一个问题就是如何输出字符串,如何读入来自用户的输入。

这个问题实际上就是使用 C 标准库中的两个函数

// 输出
printf(format, arg1, arg2, arg3....);
// 输入
scanf(format, &some_variable);

我在这里不想细讲这两个函数是怎么回事,而是提供在本作业中会用到的特殊情况。

1. 输出一个字符串,例如 Hello World

在 C 中是这样写的

printf("Hello World");

转换成内联汇编

const char msg1[] = "Hello World";

__asm {
  ........
  lea    eax, msg1
  push   eax
  call   printf
  add    esp, 4
}

由于这时候 printf 只消耗了一个参数(要打印的那个字符串),所以后面对栈顶指针 esp 加了 4 (4 = 1 * 4)

2. 输出一个整数变量,例如循环变量 i

在 C 中是这样写的

int i = 1; // 假设你有个变量 i,不一定是定值 1
printf("%d", i);

这里的 %d 是一个占位符,表示稍后输出的时候把这里替换成一个整数。

转换成内联汇编

int i = 1; // 同上
const char format1[] = "%d";

__asm {
  ........
  mov   eax, i
  push  eax
  lea   eax, format1
  push  eax
  call  printf
  add   esp, 8
}

当然,这里的 %d 还可以附加上其他内容,比如 Your number is: %d, 输出的时候这个 %d 所在的位置还是会被换成你传入的那个整数变量。

注意,这里由于 printf 消耗了两个参数,所以对 esp 增加了 8 (8 = 2 * 4)

同时,给 printf 传值的时候,如果传入整数变量,请使用 mov,而字符串请使用 lea,这是传值和传指针的区别,我不想在这里细讲(涉及到 C 的字符串和内存结构的问题)

3. 请用户输入一个整数,并存储到一个变量,比如 j

使用 C

int j; // 来个空变量
scanf("%d", &j);

应该不难看出来,这里的第一个参数是和 printf 一样的格式。%d 表示此处将要读入一个整数输入。

转换成内联汇编

int j;
const char format2[] = "%d";

__asm {
  ........
  lea   eax, j
  push  eax
  lea   eax, format
  push  eax
  call  scanf
  add   esp, 8
}

此处 j 同样是个整数变量,但是却使用了 lea 而不是 mov,原因是 scanf 需要一个内存指针以便把读取的内容写入变量(注意 C 版本中的那个 &j,即取指针)。

这里由于 scanf 消耗了两个参数,所以同样给 esp 增加 8。

当然,你在要求用户输入之前,可能最好先输出一个消息,提示用户需要输入什么内容。

P.S.: 如果要换行,可以在字符串中使用 \n

总而言之,在内联汇编中调用 C 的函数,主要的点就是把参数或者参数的指针倒序推进栈里,然后 call 函数,然后根据参数数量调整 esp 的值。

循环

循环其实在课件里讲的挺清楚的,就是 loop 这个指令,它会把 ecx 减 1,如果此时 ecx 是零了,那就跳出循环,否则跳回循环开始的地方继续循环。

只有一个可能踩到的坑,那就是 ecx 这个值实际上是 没有保证 的 —— 当你调用其他函数,比如说按照上面讲到的方法进行输入输出的时候,由于被调用的函数里也可能存在循环,当输入输出完成的时候,这个 ecx 的值就可能已经被改变了,由此会导致你的 loop 判断失效。

解决这个问题的办法非常简单,只需要在循环的开始保存 ecx 的值,并在调用 loop 指令之前将其恢复。有两种办法:

1. 利用栈来保存

我们可以在循环的开始把 ecx 推进栈,再在循环的结尾把它弹出

__asm {
  ........
  L1:
  push  ecx
  ........
  pop   ecx
  loop  L1
}

有的人问过我,这个栈不是用来传参数的吗?它确实 可以 用来给 printf 这样的函数传参数,但是,如果你给函数传了两个参数,当函数被调用结束以后,我们自行恢复了栈顶指针 esp 的值(你应该已经注意到上面对 esp 进行的加操作),由此保证了我们自己推进去的 ecx 的值永远在栈顶 —— 所以最后只需要一次 pop 就可以拿到 ecx 的值。这也是为什么 call 完成以后,对 esp 的加操作的数值 (4, 8, 12...) 只依赖于被调用的函数消耗了多少参数(参数数量 * 4)而不是总共进行了多少次 push

2. 利用变量来保存

其实是一回事,只不过这次我们让 C 自己帮我们在栈上分配空间来保存而已。

int i;

__asm {
  ........
  L1:
  mov   i, ecx
  ........
  mov   ecx, i
  loop  L1
}

我想我就不必再多加解释了。

不使用 loop

你也可以完全不使用 loop,自己使用 jmp 系列指令并自己维护循环计数器来完成循环操作。这也是我使用的方法,但在这里不想细说 —— 基本的逻辑和 loop 是完全一样的。

计算循环次数

这是部分人碰到的一个问题。他们直接把循环中的 ecx 当成「第几次循环」来输出,结果发现这个值是倒着走的 —— 请仔细看 loop 指令的说明,你就明白为什么了。

要想输出正向递增的循环次数,你有两种途径,一是自行维护一个往上走的循环变量(或者干脆像我一样不使用 loop

int k = 0;

__asm {
  ........
  L1:
  push  ecx
  mov   eax, k
  add   eax, 1
  mov   k, eax
  ........
  pop   ecx
  loop  L1
}

这个 k 就是当前循环的次数了。另一个方法是你只要对 ecx 进行一点简单的运算就可以

int total;

__asm {
  ........
  mov   total, ecx // 把总循环次数保存下来
  L1:
  push  ecx
  ........
  // 要用到当前循环次数的地方
  pop   ecx // 还原 ecx
  push  ecx // 赶紧把它保存回去,这时候 ecx 仍然是读出来的值
  mov   eax, total
  sub   eax, ecx
  add   eax, 1 // total - ecx + 1
  // 此时 eax 的值就是当前循环次数了
  ........
  pop   ecx
  loop  L1
}

数组读写

  1. 循环 n 次,每次问用户索取一个新的正整数(如果不是,退出循环,跳到 3)

中,由于你需要循环 n 次向用户获取正整数,所以你需要一个数组来保存输入的数值。如果你不知道数组是什么,请自行搜索。

这个作业中我们只需要定义一个长度为 5 的数组就好了,因为 n 最大只能是 5 (在 [2, 5] 之外的输入是非法的,你需要进行判断并输出错误信息)。像这样在 C 中定义一个长度为 5 的数组

int arr[5];

这个 arr 变量就是一个长度为 5 的数组。C 中的整型数组实际上是内存中的连续区域,以 4 字节划分,而变量名 arr 对应的就是它的第一个成员的地址。在这个地址上,+4 即可获取第二个成员,+8 即可获取第三个成员,以此类推。

比如我们修改上面 输入 / 输出 部分的例子,把用户的输入存进 arr 的第 (i + 1) 个成员里(i 大概会是你的循环的当前次数,请看上面关于循环部分的描述)

int i; // 这个变量你可以用来保存当前循环次数,我不作强制规定,这里只是个定义
int arr[5];
const char format2[] = "%d";

__asm {
  ........
  lea   eax, arr[i * 4]
  push  eax
  lea   eax, format2
  push  eax
  call  scanf
  add   esp, 8
}

以上汇编对应 C 的代码

int i; // 只是个定义
int arr[5]; // 也只是个定义
scanf("%d", &arr[i]); // C 版本里不需要乘 4

比对一下这个汇编代码和原本读取输入的汇编代码的区别,你就知道如果要输出数组成员该怎么做了。实际上就是把原来的变量名替换成了 arr[i * 4],之所以乘 4,就是因为我上面提及的内存结构。请再次注意,这里我说的是使用第 (i + 1) 个成员,而不是第 i 个。也就是说,这个计数 i 是要从零开始的,i = 0 代表第一个。

当然,你也可以学习来自其他同学的教程中的做法

int num_array[5];
_asm{
  .......
  lea   ebx, num_array 
  Loop1:
  ........
  //只写存值的部分
  mov   edi, temp // temp 是一个临时变量,内容应该是本次接受的用户输入的整数
  mov   [ebx], edi 
  add   ebx, 4
  ........
}

这里的做法是,在循环开始之前,先把 num_array 的起始成员的地址放进 ebx 中,每次循环的结尾对 ebx 加 4,这就意味着,在第 k 次循环的时候,ebx 中永远是数组 num_array 的第 k 个成员的 指针。于是,mov [ebx], something 就意味着把 something 的值复制到 ebx 这个寄存器中的 那个指针 所指向的内存区域,也就是 num_array 的第 k 个成员。([ebx] 这个中括号的作用就在这里。如果没有中括号,就是直接赋值给 ebx 寄存器,而不是 它所含有的指针 所指向的内存区域)

当然,这么做的话,你会需要像保存 ecx 一样,保存 ebx 的值,以免它被莫名其妙修改 —— 具体怎么做,我在上面的 循环 部分已经描述过了。

排序

这里大部分人都打算使用冒泡排序 —— 反正我也不高兴用汇编写什么快排……

冒泡排序的思路和具体算法,我就不想在此赘述了,作为最简单的排序算法之一,到处都能查询到。不过要注意的是,由于课件上给出的示例代码是 MASM,你并不能直接把它用在内联汇编中 —— 这个算法过于简单,也没有这个必要。排序本身并没有什么难的,之所以很多人卡在这里,其实大部分都是因为不会使用循环和数组操作。

我们来看看冒泡排序中涉及到的操作

  1. 两层循环: 和一层循环在操作上并没有什么区别,仍然是那几个注意事项。只要你在内外层都做好 ecx 的保护工作,两层循环就完全不会互相影响
  2. 读取数组成员: 上面已经讲过
  3. 比较大小: 就是 cmp 系列指令和 jmp 系列指令组合
  4. 交换数组成员: 和读取数组成员是一样的,上面也已经讲过,无非几次 movarr[i * 4] 来替代变量名)

就这么多了。最后的输出,你还需要另一个循环,把排序好的数组成员一个个输出,这也是之前已经提及的。

“按任意键退出”

大部分人都遇到了这么一个问题:写好的程序,戳运行,然后黑框框一闪而过,输出了什么都看不到。

所以你需要做一个按任意键退出的功能,其实就是等待用户输入一个任意的字符。实现也很简单,在汇编部分的最后

__asm {
  ........
  call getchar
  call getchar
}

是的,大部分情况下你需要 call 两次 getchar,原因是前面 scanf 会在标准输入中留下一个换行符,然后被 getchar 读取,导致第一个 getchar 立即返回从而失效。

你可以在调用两次 getchar 之前先打印一条消息,比如 Press any key to exit... 来获得更好的 用户体验

后记

关于这个问题,我能说的也就这么多。也感谢一直在问我这些问题的同学(们),否则我可能到现在还在看错作业要求(……)

以上大部分问题都可以在搜索引擎/问答网站上找到答案,这也是我完成这个作业的方法。当然我觉得这个课程最好在讲一定的 C 语言知识以后再开设,因为其实整个过程就是在充当 C 的人脑编译器,把 C 源代码(的一部分)人肉编译成 ASM。

作为工程师,解决问题的能力肯定是必要条件。