内联汇编 - 从头开始

对于 C/C++ 程序员来说,内联汇编并不是一个新特性,它可以帮助我们充分利用计算能力。然而,大多数程序员很少有机会实际使用该特性。事实上,内联汇编只为特定的要求提供服务,在涉及先进的高层编程语言时尤其如此。

本文介绍了 IBM Power 处理器架构的两个场景。使用本文提供的示例,我们可以发现在什么地方应用了内联汇编。

场景 1:更好的库

C/C++ 编程语言支持逻辑运算。在本例中,用户使用 作为基本单位。用户编写了一个算法来计算一个 32 位变量所占用的位数。

代码 A: 计算被占用的位数

1

2

3

4

5

6

7

8

9

01        inline int bit_taken(int data)

02        {

03        int taken = 0;

04        while (data) {

05         data = (data >> 1);

06         taken++;

07          }

08        return taken;

09        }

此代码显示了如何配合使用循环和移位运算。如果用户采用最高优化级别(-O3 适用于 gcc,-O5 适用于 xlc)编译代码,那么用户可能会发现某些优化(如展开,常量数据传播,等等)都是自动完成的,它们可以生成世界上速度最快的代码。但算法的基本思路并没有发生改变。

清单 A:cntlzw 的描述

cntlzw(CountLeading Zeros Word) 指令

目的

将来自源通用寄存器的前导零的数量放进一个通用寄存器。

cntlzw 指令能够获得前导零的数量。我们以数字 15 为例,其二进制表示为 0000, 0000, 0000, 0000, 0000, 0000, 0000, 1111,cntlzw 会告诉大家,总共有 28 个前导零。经过重新考虑后,用户决定简化其算法,如代码 B 所示。

代码 B:计算内联汇编所占用的位数

1

2

3

4

5

6

7

8

9

10

11

12

13

01        #ifdef __ARCH_PPC__

02        inline int bit_taken(int data)

03        {

04        int taken;

05        asm("cntlzw %0, %1\n\t"

06        : "=b" (taken)

07        : "b" (data)

08        );

09        return sizeof(data) * 8 – taken;

10        }

11        #else

...       ...

21        #endif

名称为 __ARCH_PPC__ 的宏只包装适用于 PowerPC 架构的新代码。与代码 A 相比,新的代码已经删除了所有循环或移位。然后,使用者可能会高兴地看到 bit_taken 的性能有所提高。它在 PowerPC 上运行得更快。而且,应用程序绑定的 bit_taken 甚至表现得更好。

这个故事并不仅说明用户可以通过丰富的指令改进他的算法,而且还说明了内联汇编是提高性能的最佳助手。通过将汇编代码嵌入到 C/C++,可以最大限度地减少用户修改代码的工作。

场景 2:原子比较和交换 (CAS)

近日,随着整个计算机行业将重点转移到多处理、多线程,不可避免地带来了更多的元素(如编程中的同步)。要在多线程环境中构成同步原语(如信号量和互斥),我们经常会提到被称为比较和交换 (CAS) 的原子操作。清单 B 显示了 CAS 的伪代码。

清单 B:CAS 的伪代码

清单 1.

1

2

3

4

5

6

compare_and_swap (*p, oldval, newval):

      if (*p == oldval)

          *p = newval;

          success;

      else

         fail;

在清单 B 中,首先比较内存位置 p (*p) 的内容与已知值 oldval(这应该是当前线程中 *p 的值)。只有当它们是相同值时,才会将 newval 写入 *p。若其他线程之前已经修改了内存位置,那么比较操作会失败。

为了准确起见,CAS 应该是原子的。原子性超越了 C/C++ 的处理能力,但可以通过使用一小段内联汇编代码而得到保证。代码 C 显示了面向 PowerPC 架构实现的一个简单的 CAS。

代码 C:在 PowerPC 上的简单 CAS 实现

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

01        void inline compare_and_swap (volatile int * p, int oldval, int newval)

02        {

03        int fail;

04        __asm__ __volatile__ (

05           "0: lwarx %0, 0, %1\n\t"

06                 "      xor. %0, %3, %0\n\t"

07              " bne 1f\n\t"

08            " stwcx. %2, 0, %1\n\t"

09                 "      bne- 0b\n\t"

10            " isync\n\t"

11        "1: "

12        : "=&r"(fail)

13        : "r"(p), "r"(newval), "r"(oldval)

14        : "cr0");

15        }

此代码片段实现了清单 B 中的伪代码,但目前它对于我们来说似乎过于复杂。我们将在介绍完基本的语法后再回头探讨它。

但是,归纳我们所述的内容,在两个条件下通常需要内联汇编:

  • 代码优化

    若性能要求是至关重要的,那么内联汇编可能会有所帮助。正如我们从场景 1 中可以看到的,调优编译器选项不会永远都是最佳选择。一个便利的内联汇编代码片段可以让用户大幅提高程序的性能。

  • 硬件操作/OS 服务

    C/C++ 在场景 2 中的能力是有限的。编译器总是需要一些时间来标准化和实现最新的特性。因此,为了使用最新的硬件指令、OS 服务等,我们经常求助于内联汇编。大部分时间,它都是最佳选择。

使用内联汇编可能还有其他原因。但是总的来说,内联汇编在功能和性能方面都可以作为 C/C++ 的一种补充。

内联汇编的使用

内联汇编的语法看上去与 C/C++ 完全不同。一个合理的解释是,内联汇编并不是从 C/C++ 程序员的角度进行设计的,而是从一个编译者/汇编者的角度进行设计的。内联汇编的一般语句构成如清单 C 所示。

清单 C:内联汇编代码块的组成

1

2

3

4

5

__asm__ __volatile__(assembly template

        : output operand list

        : input operand list

        : clobber list

                            );

如清单 C 所示,内联汇编在逻辑上总是由四部分组成:

1. 关键字 asm() 或 __asm__()。修饰符 volatile 或 __volatile__:关键字 asm 或 __asm__ 用于说明随后的字符串是内联汇编代码块。volatile 或 __volatile__ 是可选的,可以将它们添加到 asm 后面,禁止某些编译器的优化。其实,asm 和 __asm__几乎是相同的,惟一的区别是,当预处理程序宏中使用内联汇编时,asm 在编译过程中可能会引发警告。volatile 和 __volatile__ 也是如此。

2. 汇编模板:
汇编模板是括号内的第一个部分。它包含汇编指令行,这些指令行都包括在双引号 ("") 中,以行分隔符(\n\t 或 \n)结束。内联汇编代码的语法是相同的,但比一般的汇编代码简单得多。这其中有许多原因。例如,它不需要在汇编模板中定义数据,因为它应该始终从 C/C++ 变量引用。而且,很少有必要在汇编模板中(为可执行文件)创建一个分段。一般情况下,除了汇编指令,只允许使用一些本地标签。(我们稍后会讨论它们)。

代码 D:内联汇编的汇编模板

1

__asm__ __volatile__ ("lwarx %0, 0, %1 \n\t" : "=&r"(ret) : "r"(p));

代码 D 显示了汇编模板的一个示例。

  1. 汇编指令由操作码 (lwarx) 和操作数 (%0, 0, %1) 组成。
  2. 如果一个指令的操作数是寄存器/立即类型的操作数,那么可以引用它作为一个带有百分比前缀编号的寄存器。 (%0, %1,...)
  3. 寄存器的编号引用了一个变量,按它在输入/输出列表中所代表的顺序排列。在代号 D 的示例中,ret 是输入/输出列表中第一个被引用的变量。因此,%0 是寄存器引用。同样地,寄存器 %1 引用变量 p
  4. 在内联汇编中,只有一些本地标签是合法的。您可能会在代码 C 中看到标签,如 0 和 1。它们是指令 bne- 0b\n\t 和 bne 1f\n\t 的分支目标。(标签的 f 后缀意味着在分支指令后面的标签,而 b 表示前一个标签)

3. 输入/输出操作数列表
输入/输出列表以冒号 (:) 开始。它们的条目用逗号 (,) 隔开。该列表在汇编模板中指定变量及其约束。以代码 D 为例,lwarx 设置有效地址,即寄存器值 %1 加上一个立即值 0。它从有效地址读取一个单词并存储到寄存器 %0。在这里,%0 是一个输出操作数,它存储结果并被写入列表。而 %1 是一个输入。这样,%0 所引用的 ret 就会放入输出列表,而 %1 所引用的 p 则会放入输入列表。
输入/输出操作数列表中列出的每个变量:

  • 必须有一个约束。例如,=&r (ret) 的约束是 r,这意味着可能将 ret 分配给任何通用寄存器。
  • 可以有一个可选的约束修饰符。例如,=&r (ret) 的修饰符是 = 和 &= 表示该变量是只写的。& 表示这个变量不能与任何输入操作数共享相同的寄存器。(早期的乱码表示在指令使用完输入操作数之前,已经修改了操作数。因此,它不能与输入操作数共享寄存器。有关的详细信息,请参阅 A guide to inline assembly for C and C++

各平台之间的约束是不同的。通常,产品文档会提供更多的实际详细信息。

4. 破坏列表(Clobber list)
乱码列表通知编译器,有些寄存器或内存已因内联汇编块造成乱码。乱码列表看起来类似于输入/输出列表(用冒号开始,并以逗号分隔)。但只用寄存器名称(如 r1f15)或 内存 充当其条目。

在代码 C 的示例中,内联汇编代码隐式地破坏了条件寄存器字段。因此,cr0 寄存器字段被放入破坏列表。如果用户认为代码更换到了一个不确定的内存空间,那么内存也会出现在列表中。我们在后面的章节将再次讨论破坏列表。

事实上,并不是所有在清单 C 中显示的组件都是必需的。一个关键字和一个汇编模板就足以构成一个基本的内联汇编。其他所有部分都是可选的。

现在,我们回到代码 C,进一步解释其指令。

lwarx %0, 0, %1
该指令在有效地址 0 + %1 上将内存读取到寄存器 %0(实际上是 *p)。此外,该指令根据指令 stwcx 预约了以后的验证。

xor. %0, %3, %0
bne 1f

该指令会比较我们刚加载到 %0 的值和 oldval (%3)。当它们不相等时,则会分支跳转到标签 1,这意味着 CAS 运算失败。

stwcx. %2, 0, %1
bne- 0b

stwcx. 检查 lwarx 的预约。如果检测成功,它会将 %2 (newval) 的内容写入 0 + %1(p) 的有效地址。如果写入失败,则会分支跳转到标签 0,以便进行重试。

isync
该指令禁止运行 iSync 之后的指令,直到 iSync 前的指令已完成。

表 A 列出了该示例的操作数列表中的所有条目,以及它们对应于代码 B 的寄存器编号。

表 A:约束、修饰符和代码 C 的寄存器引用

条目 约束(和修饰符) 引用的变量 寄存器
"=&r"(fail) =&r: writable, early clobber, general register fail %0
"r"(p) R: general register p %1
"r"(newval) R: general register newval %2
"r"(oldval) R: general register oldval %3

我们可以从代码中看到,在写回指令 stwcx. 后按照重试步骤进行操作。如果其他线程已经更新了地址 p 保持,那么重试会发现 *p 和 oldval 是不同的。因此,请控制分支跳转到标签 1 ,也就是说控制 CAS 失败。我们可以通过比较变量 fail 和 0 来对此进行判断。

lwarx 和 stwcx 在 PowerPC 架构中是非常特殊的指令。它们对于组成原子原语至关重要。如果您有兴趣,可以从 Power ISA 找到有关的更多信息。[1] 对于分枝设施,文献 [2] 提供了最好的解释。

常见错误

对于可能会犯错误的初学者来说,有一些可供他们始终查看的指南。

  • 不要忘记行分隔符 (\n\t)
  • 不要忘了行的双引号 ("")
  • 不要混淆 () 和 {}

我们还遇到过一些有趣的错误:

  1. 在内联汇编模板内使用预处理程序宏

    代码 E:在内联汇编模板内的宏。

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    01        // This is the intention:

    02        __asm__ __volatile__(

    03            "stswi %0, %1, 4\n\t"

    04                  :: "b" (t), "b" (b)

    05                    );

    01      // Macro, does not work:

    02        #define F 4

    03        __asm__ __volatile__(

    04                  "stswi %0, %1, F\n\t"

    05        :: "b" (t), "b" (b)

    06                    );

    出于某种原因,用户可能想将某个 C/C++ 宏应用于内联汇编模板。具体而言,在上面的示例中,用户试图替换一个立即值。然而,编译器拒绝执行该代码。事实上,用户不应该考虑在汇编模板中应用任何 C/C++ 预处理器操作。用户将 C/C++ 传入内联汇编的惟一接口是使用输入/输出列表。 代码 F 显示了一种可以实现用户目标的方法。

    代码 F:被引用为立即值的宏

    1

    2

    3

    4

    5

    01        #define F 4

    02        __asm__ __volatile__(

    03        "stswi %0, %1, %2\n\t"

    04         : : "b" (t), "b" (b), "i"(F)

    05                    );

    在这里,我们使用操作数的一个立即约束来引用宏。然后,通过修改宏定义,用户可以在全局修改常量。

  2. 输出操作数列表中缺少冒号。

    在代码 G 中,stswi 指令意味着它存储了 4 个字节,从寄存器 %1 开始(具体来说,如果 %1 被分配到寄存器 r0,则按顺序从 r0、r1、r2、r3...读出字节)到在 0% 的有效地址。

    对于内联汇编代码,并没有输出操作数,因为没有寄存器存储结果,也无法写入该结果。而且,输入操作数列表中包括所有变量(value 和 base)。

    代码 G:缺少冒号

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    01        // Require input only:

    02        int base[5];

    03        int value = 0x7a;

    04        __asm__ __volatile__(

    05            "stswi %0,%1,4\n\t"

    06                    : : "b" (value), "b" (base)

    07          );

    01        // But mistaken as output :

    02        __asm__ __volatile__(

    03          "stswi %0,%1,4\n\t"

    04                    : "b" (value), "b" (base)

    05                    );

    在后一种代码中,用户不幸地漏了一个冒号。现在所有输出都变成输入。在这种情况下,编译器甚至可能没有发出警告。但是,用户可能最终会在运行时发现错误。虽然这样的错误看起来很小,但它可能会破坏一切。此外,不容易发现该错误,因为错误总是很难找到的,比如在 C/C++ 代码中混淆 if (a==1) 和 if (a=1)。因此,初学者应多注意冒号。

    我们将很快将会继续讨论这种错误的更深层次原因。

内联汇编、编译器和汇编器

人们在编写内联汇编代码时会发现,最大的挑战并不是在规范中找出正确的指令,而是让输入/输出/破坏列表正常工作。有可能出现投诉和问题,比如:为什么我们需要这些列表?为什么要有约束和修饰符?等等。

在这里,我们列出了这类问题及其答案。我们希望它可以帮助用户从实现角度了解有关的更多信息。为简单起见,我们只专注于引用 C/C++ 变量的指令和寄存器操作数。

Q1:谁处理内联汇编?编译器还是汇编器?为什么我在编译时得到汇编程序错误?

A1: 答案是两者都处理(在大部分的时间)。一般情况下,汇编器会在编译器支持最新指令之前支持这些指令。因此,编译器必须调用汇编器来处理任何无法识别的指令。但是,这并不意味着汇编器会处理一切。变量和寄存器之间的关联是通过编译器完成的。(请参阅 Q2 及 Q3。) C/C++ 中的内联汇编语法检查也由编译器完成。但是,汇编指令本身不包括在内。因此,如果汇编器在检查汇编指令时发现问题,那么它会报告错误。

Q2:汇编模板中的寄存器如何引用 C++ 变量?

A2:如 Q1 的答案所示,关联由编译器完成。在内部,由一个寄存器分配和指派过程将变量映射为寄存器。在完成此过程后,汇编模板会变成一小段真正的汇编代码。然后,汇编器可以接受并处理它,从而生成最终的二进制代码。

Q3:我知道寄存器分配和指派会将寄存器与变量相关联。但是,为什么要提供一个输入/输出列表?

A3:事实上,对于寄存器分配和指派,可能会要求编译器提供输入,比如约束活跃度(liveness)。如果没有内联汇编,编译器可以通过内部分析代码找到这样的输入。但是,因为编译器认为内联汇编块里面的指令行为是未知的,所以它要求用户提供额外的信息来帮助它。
内部的约束可能与硬件有关。例如,为放入通用寄存器的操作数设置约束 r,并为放入浮点寄存器的操作数设置约束 f。此外,有时,某些硬件在某些情况下将禁止某些行为。例如,在 PowerPC 中的约束 b(它禁止使用 r0 寄存器)就属于这一类。(请参阅 A guide to inline assembly for C and C++ - Basic, intermediate, and advanced concepts,了解有关的更多详细信息)。从概念上讲,用户应该负责告诉编译器 数据类型指令的限制 等信息,因为编译器对用户所提供的汇编代码完全是未知的。 
活跃度可能受到许多方面的影响。最重要的一个方面是,是读取、写入变量,还是两者同时进行。输入/输出操作数列表和某些约束修饰符可以帮助构建该信息。(例如,约束修饰符 "+" 说明操作数是 read-write,而 "=" 说明它是 write-only。) 
整体而言,输入/输出列表用于向编译器提供信息。

整体而言,输入/输出列表用于向编译器提供信息。

Q4:破坏列表又怎么样呢?我们为什么需要它?

A4:在许多现实世界的平台上,机器指令可能会隐式地修改寄存器。可以将这视为一种硬件约束。包含寄存器名称的破坏列表可以让编译器知道没有引用变量的任何其他寄存器是否也被修改了。 而且,如果一个指令不可预知地写入一个意外的内存位置,编译器可能不知道它是否修改了任何已经存在于寄存器中的变量。(如果发生这种情况,会从内存重新加载已经存在于寄存器中的变量。)通过放进一个 内存 乱码,我们通知编译器要做一些处理,以确保生成的代码是正确的。(对于内存乱码, A guide to inline assembly for C and C++ - Basic, intermediate, and advanced concepts 提供了更好的解释。)

Q5为什么不建议使用汇编指令?

A5:有时,人们认为内联汇编可能具备汇编的完整功能。但是,情况并不总是这样。例如,如果用户不知道内联汇编代码已经嵌入最终可执行文件的代码段,那么使用汇编指令可能会引起严重的问题。

一个典型的示例是,用户希望在汇编模板中定义一个新的部分 .mysect。编译器会计算出正确的汇编代码,并将它传递给汇编器。但是,正如汇编语法所说明的那样,需要定义一个 .mysect 部分,使用当前部分覆盖它。因此,内联汇编后面的代码(这是由编译器生成的)也会汇编到 .mysect 部分中,而不是 .text(用于代码)部分。结果,该可执行文件被完全破坏。

总之,使用不属于编译器的内联汇编规范的汇编功能不是一种明智的做法。使用未获得正式支持的任何内容都可能为您的代码带来风险。

现在,让我们回到丢失冒号的问题。显然,失败的根本原因是我们向编译器提供了不正确的信息(如活动性或约束)。编译器不会抱怨,因为它不检查任何列表的正确性(只检查 C/C++ 语法错误)。并且汇编器也会很开心,因为它只处理有合理格式的指令。但事实上,编译器使用了不正确的信息工作。最后,该代码会失败。这个失败警告了我们,并且用户要为自己编写的输入/输出/乱码列表负责,这非常重要。否则,获得不可用的代码就并不奇怪了。

结束语

虽然学习内联汇编的语法并不难,但编写正确的汇编代码并不仅仅意味着编写正确的汇编指令和嵌入它们。由于编译器无法分析内联汇编块的内部情况,所以内联汇编用户应该向编译器提供比普通 C/C++ 代码更多的信息。这可能很容易出错。无论如何,您可以利用下面的技巧。

  • 只编写一个具有单一功能的较短的内联汇编块。
  • 查看编译器文档中的内联汇编部分。不要试图使用不属于编译器的内联汇编规范的汇编功能。
  • 仔细选择指令。弄清楚每一个细节。不要漏掉任何说明,比如约束、副作用等。
  • 再次检查输入/输出/乱码列表,然后再编译和运行您的代码。特别要检查是否正确使用了冒号。

From: https://www.ibm.com/developerworks/cn/aix/library/au-inline_assembly/index.html

猜你喜欢

转载自blog.csdn.net/phenixyf/article/details/90025255