1

GCC 内联汇编

 2 years ago
source link: http://www.kongjunblog.xyz/2021/04/gcc.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

GCC 内联汇编

GNU C 允许在 C 代码中嵌入汇编代码,这种特性被称为内联汇编。使用内联汇编可以同时发挥 C 和汇编的强大能力。

本文介绍 GCC 的内联汇编拓展,Clang 编译器兼容大部分 GCC 语言拓展,因此 GNU C 的内联汇编特性大部分在 Clang 中工作正常。

本文实验环境如下:

xxxxxxxxxx
Linux Friday 5.8.17-300.fc33.x86_64 #1 SMP Thu Oct 29 15:55:40 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
gcc (GCC) 10.2.1 20201016 (Red Hat 10.2.1-6)

使用 64 位 AT&T 风格 x86 汇编,为了和编译器自动生成的注释区分开,我添加的注释使用##风格。

基本内联汇编

基本内联汇编是 GCC 对内联汇编最简陋的支持,它实际上已经没有任何使用价值了,介绍它只是为了说明使用内联汇编的基本原理和问题。

基本内联汇编的语法如下:

xxxxxxxxxx
asm asm_qualifiers ( AssembleInstructions )

asm_qulifiers包括以下两个修饰符:

  • volatile: 指示编译器不要对 asm 代码段进行优化
  • inline: 指示编译器尽可能小的假设 asm 指令的大小

这两个修饰符的意义先不用深究,本文会逐步介绍它们的作用。

asm不是 ISO C 中的关键字,如果我们开启了 -std=c99 等启用 ISO C 的编译选项,代码将无法成功编译。然而,内联汇编对于许多 ISO C 程序是必须的,GCC 通过 __asm__ 给程序员开了个后门。使用 __asm__ 替代 asm 可以让程序作为 ISO C 程序成功编译。volatile 和 inline 也有加 __ 的版本。

AssembleInstructions是我们手写的汇编指令。基本内联汇编的例子如下:

xxxxxxxxxx
__asm__ __valatile__(
      "movq %rax, %rdi \n\t"
      "movq %rbx, %rsi \n\t"
);

编译器不解析 asm 块中的指令,直接把它们插入到生成的汇编代码中,剩下的任务有汇编器完成。这个过程有些类似于宏。为了避免我们手写的汇编代码挤在一起,导致指令解析错误,通常在每一条指令后面都加上\n\t获得合适的格式。

编译器不解析 asm 块中的指令的一个推论是:GCC 对我们插入的指令毫不知情。这相当于我们人为地干涉了 GCC 自动的代码生成,如果我们处理不当,很可能导致最终生成的代码是错误的。考虑以下代码段:

xxxxxxxxxx
#include <stdio.h>
int
main()
{
    unsigned long long sum = 0;
    for (size_t i = 1; i <= 10; ++i)
    {
        sum += i;
    }
    printf("sum: %llu\n", sum);
    return 0;
}
--------------------------------------
              output
--------------------------------------
sum: 55

这段代码很简单,只是简单的整数求和。反汇编结果如下:

xxxxxxxxxx
    .file   "basic-asm.c"
    .text
    .section    .rodata
.LC0:
    .string "sum: %llu\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc            ## 进入函数
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6        ## 分配局部变量
    subq    $16, %rsp
    movq    $0, -8(%rbp)            ## sum
    movq    $1, -16(%rbp)           ## i
    jmp .L2
.L3:                        ## for body
    movq    -16(%rbp), %rax         ## sum += i
    addq    %rax, -8(%rbp)
    addq    $1, -16(%rbp)           ## ++i
.L2:
    cmpq    $10, -16(%rbp)          ## for 条件判断
    jbe .L3
    movq    -8(%rbp), %rax          ## 传递参数给 printf
    movq    %rax, %rsi              ## x86-64 通常可以使用 6 个寄存器传递参数
    movl    $.LC0, %edi             ## 从做往右依次为 %rdi, %rsi, %rdx, %rcx, %r8, %r9
    movl    $0, %eax                ## 更多的参数通过堆栈传递
    call    printf
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 10.2.1 20201016 (Red Hat 10.2.1-6)"
    .section    .note.GNU-stack,"",@progbits

可以看到在 for body 中,变量i被分配到-16(%rbp)中,我们在sum += i前插入这段代码来验证基本内联汇编的处理过程。

xxxxxxxxxx
__asm__ __volatile__(
        "movq $100, -16(%rbp)\n\t"
        );

反汇编结果如下:

xxxxxxxxxx
    .file   "basic-asm.c"
    .text
    .section    .rodata
.LC0:
    .string "sum: %llu\n"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    subq    $16, %rsp
    movq    $0, -8(%rbp)
    movq    $1, -16(%rbp)
    jmp .L2
.L3:
#APP                        ## 可以看到编译器直接将我们的指令插入到了汇编文件中
# 9 "basic-asm.c" 1
    movq $100, -16(%rbp)
# 0 "" 2
#NO_APP
    movq    -16(%rbp), %rax
    addq    %rax, -8(%rbp)
    addq    $1, -16(%rbp)
.L2:
    cmpq    $10, -16(%rbp)
    jbe .L3
    movq    -8(%rbp), %rax
    movq    %rax, %rsi
    movl    $.LC0, %edi
    movl    $0, %eax
    call    printf
    movl    $0, %eax
    leave
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size   main, .-main
    .ident  "GCC: (GNU) 10.2.1 20201016 (Red Hat 10.2.1-6)"
    .section    .note.GNU-stack,"",@progbits

我们通过基本内联汇编将变量i的值修改为100,因此程序会直接退出for循环,运行结果为:

xxxxxxxxxx
sum = 100

基本内联汇编中没有程序员和编译器的交流,程序员不知道编译器将生成怎样的代码,编译器也不知道程序员希望它怎样生成代码为内联汇编的结果几乎不可控制,因此内联汇编没有任何实用价值。

拓展内联汇编

从上面基本内联汇编的介绍可以发现,生成正确的代码需要程序员和编译器的通力合作,只有充分的交流才能确保结果的正确。拓展内联汇编很好的实现了程序员和编译器的交流,程序员不再打乱编译器的代码生成,而是提供充分信息来辅助、微调编译器的代码生成。

基本原理和思路

在编译器生成代码的过程是一个动态的过程,变量可能被分配到寄存器(如 rax)中,也可能被分配到内存中;一个整型字面值可能是 32 位立即数,也可能是 64 位大立即数;可能使用 rax 寄存器,也可能使用 rbx 寄存器。程序员任何擅自的篡改都会导致生成错误的代码。

拓展内联汇编从程序员处获取信息,并根据获取的信息调整自己生成代码的行为。比如,程序员要求将某个变量分配到 rax 寄存器中,编译器就会将该变量分配在 rax 中,并调整其他部分的代码,使程序员的要求不影响正确代码的生成。

因此,使用拓展内联汇编的基本思路就是:提供尽可能多的信息给编译器。程序员提供的信息越多,出错的概率就越小。除了提供信息,程序员还应该清楚地明白 GCC 对内联汇编做的假设和限制。

拓展内联汇编的语法结构如下:

xxxxxxxxxx
asm asm-qualifiers ( AssemblerTemplate
                 : OutputOperands
                 [ : InputOperands
                 [ : Clobbers ] ])
asm asm-qualifiers ( AssemblerTemplate
                      :
                      : InputOperands
                      : Clobbers
                      : GotoLabels)

asmasm-qualifiers和基本内联汇编基本相同。基本内联汇编提供了在汇编中跳转到 C Label 的能力,因此asm_qualifiers中增加了 goto。goto 修饰符只能用于第二种形式中。

AssemblerTemplate是程序员手写的汇编指令,但是增加了几种更方便的表示方法。

可以将拓展内联汇编 asm 块看成一个黑盒,我们给一些变量、表达式作为输入,指定一些变量作为输出,指明我们指令的副作用,运行后这个黑盒会按照我们的要求将结果输出到输出变量中。

OutputOperands表示输出变量,InputOperands表示输入变量,Clobbers表示副作用(asm 块中可能修改的寄存器、内存)等。

拓展内联汇编语法结构比较复杂,没法一下讲清楚,先给出一个例子一览全貌。

xxxxxxxxxx
// 测试 val 的第 bit 位是否为 1
int
bittest(unsigned long long val, unsigned long long bit)
{
    int ret;
    __asm__ (
        "movl $0, %0 \n\t"         // %0 代表 ret(第 0 个输入/输出)
        "btq %2, %1 \n\t"          // %1 代表 val(第 1 个输入/输出),%2 代表 bit。btq 指令将 val 的第 bit 位存入 CF 中
        "jnc %=f \n\t"              // 若 CF 标记为 1,将 ret 设置为 1
        "movl $1, %0 \n\t"
        "%=: movl $0, %0 \n\t"
        : "=&rm" (ret)         // ret 为输出变量。该变量可以被分配到通用寄存器或内存中中。不允许该输出变量与输入重叠。
        : "r" (val), "r" (bit)      // val 和 bit 是输入变量,分配到任意通用寄存器中
        : "cc", "memory"            // asm 块可能读取、修改条件寄存器和内存
            );
    return ret;
}

这个例子使用到了拓展内联汇编的绝大多数功能。

GCC 支持多种汇编方言,x86 汇编默认使用 AT&T 语法,但也支持 Intel 语法。GCC 生成的汇编指令可以通过编译选项 -masm=dialect 切换。如果使用 Intel 语法,那么 asm 块中的 AT&T 语法就无法正确编译,反之亦然。可以通过{ dialect 0 | dialect 1 | dialect 2 ... }来兼容多种方言。这里使用bt指令(bit test)来说明使用方法。

xxxxxxxxxx
"bt{l %[Offset],%[Base] | %[Base],%[Offset]}; jc %l2"

编译器根据编译选项 -masm 展开后为:

xxxxxxxxxx
"btl %[Offset],%[Base] ; jc %l2"   /* att dialect */
"bt %[Base],%[Offset]; jc %l2"     /* intel dialect */

`%l2代表 C Label。

特殊字符串

内联汇编中使用%N表示第N个输入/输出(从 0 开始数),使用{}|表示不同方言。AT&T 语法中寄存器要加%前缀,因此%需要被跳脱。拓展内联汇编中,%要写成%%,如%%rax

GCC 还特别提供了%=生成在所有 asm 块中唯一的数字,这个功能用于生成不重复的 local label 供跳转指令使用。我们最开始的bittest()就使用了这个功能。

介绍到这里,实际上就说完了AssemblerTemplate的全部内容,开始介绍输出列表、输入列表、修改列表的细节。

语法结构如下:

xxxxxxxxxx
[ [asmSymbolicName] ] constraint (cvariablename)
  • asmSymboicName

    我们可以给cvariablename起一个只能在该 asm 中使用的别名,并通过%[asmSymbolicName]访问它。比如: [value] "=m" (i) ,可以在 asm 块中通过%[value]访问它。

  • constraint(限制)和 modifier(修饰语)

    constraint 在拓展汇编中至关重要,它和 modifier 是拓展内联汇编和基本内联汇编的根本差异之处。它们的作用都是给编译器提供信息,不同之处在于:constraint 提供输入/输出变量位置的信息(如分配到寄存器还是内存中),modifier 只能用于输出,提供输出变量的行为信息(如只读/读写,是否可以在指令中交换次序)。

  • cvariablename

    cviriablename 是一个 C 变量,因为它是 asm 的输出变量,必须要可以被修改,因此必须是左值。

因为 modifier 只能用于输出变量上,因此只先介绍 modifier。

constraint 用来表示输入/输入变量的位置,既有通用的(如任意通用寄存器,不同平台对应不同寄存器),也有特定平台的拓展(如 x86 中的 a,对应寄存器 (r|e)ax),使用时查阅 GCC 手册即可。本文只介绍几个常用的通用、x86、RISC-V constraint。

modifier 有以下四个:

  • =: 操作对象是只写的。这意味着操作对象中的值可以被丢弃,并且写入新数据。
  • +: 操作对象是读写的。这以为着可以在 asm 中合法的读取操作对象,并且 C 变量在进入 asm 块时就已经加载到对应的位置中。
  • &: 指示该操作对象一定不能和输入重叠。
  • %: 表示该操作对象可以交换次序。这个 modifer 我不是很理解,似乎没有大的用处。

&比较难以理解,单独解释。GCC 假设汇编代码在产生输出前会消耗掉输入,可能会将不相关的输出/输入分配到同一个寄存器中。实际上输入和输出的次序不一定满足 GCC 的假设,这时就会出错。举两个例子说明这个问题。

细心的读者应该会注意到在testbit()函数中,输出ret被分配到寄存器或内存中,constraint 中使用了&。假如我们删除掉&会怎么样呢?

xxxxxxxxxx
// 测试程序
// bittest()删除 &
int
main()
{
    if (bittest(1, 0))
        printf("0\n");
    else
        printf("1\n");
    return 0;
}

编译后运行结果为 1。这很显然是错的。反汇编bittest(),关键部分代码如下:

xxxxxxxxxx
    movq    %rdi, -24(%rbp)     ## 第一个参数(val)
    movq    %rsi, -32(%rbp)     ## 第二个参数(bit)
    movq    -24(%rbp), %rax     ## 变量 val 分配到 rax 中
    movq    -32(%rbp), %rdx
#APP
# 23 "bt.c" 1
    movl $0, %eax           ## 变量 ret 也被分配到 rax 中
    btq %rdx, %rax
    jnc 15f                 ## 15 是 %= 生成的
    movl $1, %eax
    15:
# 0 "" 2
#NO_APP

可以发现,错误的根源在于我们指示 GCC 将变量retval分配到通用寄存器中,GCC 假设输入在产生输出前就被消耗(输入/输入分配到同一个寄存器中不会出错),因此将retval都分配到了寄存器 rax 中。在执行 bt 指令前,我们将返回值ret设置成0,覆盖了val,导致错误。

还有一种可能的输入/输出重叠的情况:输出 A 被分配到寄存器中,输出 B 被分配到内存中,访问内存 B 时错误地使用了输出 A 被分配到的寄存器。访问内存中的 B 很可能需要使用到寄存器(如内存寻址),GCC 这时将访问 B 过程中使用到的寄存器视为输入,根据“输入在产生输出之前就被消耗掉了”的假设,GCC 很可能会在访问 B 的过程中使用 A 对应的寄存器(假设在访问完 B 后才写入 A,这时情况正常)。实际情况可能不符合 GCC 的假设,用户可能在访问 B 之前写入 A,在访问 B 时使用的寄存器中的值(这个值错误地变成了 A 的值)可能是错误的。

陷阱

  • GCC不保证在进入 asm 块时,输出变量已被加载到 constraint 指定的位置中。如果需要 GCC 在进入 asm 块时将变量加载到 constraint 指定的位置中,请使用+
  • constraint 是指定变量在 asm 块中的位置,而不是在函数中的位置。变量val的 constraint 为 r 说明它在进入/退出 asm 块时被分配到通用寄存器中,但在进入 asm 块前它的位置是不确定的。如果要控制 asm 块外变量被分配的位置,可以使用 GNU C 的寄存器变量拓展。

语法结构如下:

xxxxxxxxxx
[ [asmSymbolicName] ] constraint (cexpression)

asmSymbolicNameconstraint和输出列表一样。

输入列表中不可以使用=+这两个 constraint。

因为输入是只读的,因此不要求输入是左值,任何 C 表达式都可以作为输入。

GCC 假设输入是只读的,在退出 asm 块时输入的值不被改变。我们不能通过修改列表来告知 GCC 我们将修改一个输入。如果我们确实需要修改输入,有两种办法:

  • 使用可读写的输出替换输入。
  • 将输入绑定到一个不使用的输出上。

第一种方法的原理显而易见,加上+限制的输出在进入 asm 块时就被分配到对应的位置中,除了可以写外,跟输入变量没有区别。

第二种方法是变通方法,当我们将输入绑定(放入同一个位置)到一个不使用的输出时,我们修改输入就相当于生成输出,绕开了 GCC 不修改输入的规定。使用这种方法要小心 GCC 发现输出变量未使用,将 asm 优化掉,需要添加 volatile 修饰符。

我个人建议使用第一种方法,虽然第一种方法在语意上不太合适,但能够实现我们的目的,并且比较好理解。

在使用内联汇编时,我们写的汇编代码可能会产生一些副作用,GCC 必须清楚地知道这些副作用才能调整自己的行为,生成正确的代码。

举一个可能导致生成错误代码的例子。我们使用字符串复制指令movsb将一段内存复制到另一个地址,movsb会读取、修改寄存器 rsi 和 rdi 的值,如果我们不告诉 GCC 我们写的汇编代码有“修改 rsi 和 rdi”的副作用,GCC 会认为 rsi 和 rdi 没有被修改,生成错误的代码。

在使用内联汇编时我们必须提供给 GCC 尽可能多的信息,汇编代码可能有哪些副作用(修改了哪些寄存器,是否访问内存)是使用内联汇编时需要始终考虑的问题。

修改列表(Clobeerrs)的语法结构如下:

xxxxxxxxxx
: "Clobber" (cexpression)

Clobber有以下几个:

  • cc: 条件(标准)寄存器。如 x86 的 EFLAGS 寄存器。
  • memory: 读/写内存。为了确保读取到正确的值,GCC 可能会在进入 asm 块前将某些寄存器写入内存中,也可能在必要的时候将内存中存储的寄存器值重新加载到寄存器中。
  • 寄存器名:如 x86 平台的 rax 等,直接写原名即可。

constraint

这里介绍几个常用的 constraint:

  • r: 通用寄存器
  • i: 在汇编时或运行时可以确定的立即数
  • n: 可以直接确定的立即数,如整形字面量
  • g: 任意通用寄存器、内存、立即数

这些是 GCC 提供的通用 constraint,在不同处理器上有不同的实现。比如 x86 上的通用寄存器是 rax、r8 等,在 RISC-V 上是 x0 到 x31。

有些指令,如 x86 常用的mov指令,两个操作数既可以都是寄存器、也可以一个是寄存器一个是内存地址。这时就有三种组合,我们可以将 constraint 可以分为多个候选组合传递给 GCC,如:

xxxxxxxxxx
:   "m,r,r" (output)
:   "r,m,r" (input)

constraint 通过分组,并且一一对应。上面的代码段相当于以下三个输出/输入列表组合在一起:

xxxxxxxxxx
:   "m" (output)
:   "r" (input)
------------------
:   "r" (output)
:   "r" (input)
------------------
:      "r" (output)
:   "m" (input)

一个输入/输出可以有多个constraint,GCC 会自动选择其中最好的一个。如:"rm" (output)表示output既可以分配到通用寄存器中,也可以分配到内存中,由 GCC 自己选择。

多 constraint 和分组的 constraint 是两码事。还拿 x86 上的mov指令举例,mov指令不允许两个操作数都是内存地址,因此我们不能写出这样的输出/输入列表:

xxxxxxxxxx
:   "rm" (output)
:   "rm" (input)

这个列表表示outputinput都可以分配到内存或通用寄存器中,可能出现两变量同时被分配到内存中的情况,这是 mov 指令就会出错。

goto 列表

GCC 提供了在内联汇编中使用 C Label 的功能,但这个功能有限制,这能在 asm 块没有输出时使用。C Label 在内联汇编中直接当成汇编的 label 使用即可,唯一要注意的是在内联汇编中 C label 的命名。

在内联汇编中使用%lN来访问 C label,因为%l在内联汇编中已经有了特殊的意义(x86 平台的修饰符,表示寄存器的低位子寄存器,如 rax 中的 eax),因此 GCC 将 C label 对应的N设置为输入输出总数加 goto 列表中 C label 的位置。

xxxxxxxxxx
asm goto (
    "btl %1, %0\n\t"
    "jc %l2"
    : /* No outputs. */
    : "r" (p1), "r" (p2)
    : "cc"
    : carry);
return 0;
carry:
return 1;

标签carry之前有两个输入,carry在 goto 列表的第 0 位,因此使用%l2引用carry

标记寄存器的使用

在某些平台,比如 x86,存在标记寄存器。GCC 允许将标记寄存器中的某个标准输出到 C 变量中,这个变量必须是标量(整形、指针等)或者是布尔类型。当 GCC 支持这个特性时,会与定义宏__GCC_ASM_FLAG_OUTPUTS__

标记输出约束为@cccond,其中cond为指令集定义的标准条件,在 x86 平台上即条件跳转的后缀。

因为访问的是标记寄存器中的标记(很可能是一个比特),因此不能在 asm 块中通过%0等形式显示访问,也不能给多个约束。

使用标记寄存器,可以简化前面的testbit()

xxxxxxxxxx
int
bittest(unsigned long long val, unsigned long long bit)
{
    int ret;
    __asm__ __volatile__(
        "btq %2, %1 \n\t"
        : "=@ccc" (ret)
        : "r" (val), "r" (bit)
        : "cc", "memory"
            );
    return ret;
}

asm 的大小

为了生成正确的代码,一些平台需要 GCC 跟踪每一条指令的长度。但是内联汇编由编译器完成,指令的长度却只有汇编器知道。

GCC 使用比较保守的办法,假设每一条指令的长度都是该平台支持的最长指令长度。asm 块中所有语句分割符(如;等)和换行符都作为指令结束的标准。

通常,这种办法都是正确的,但在遇到汇编器的伪指令和宏时可能会产生意想不到的错误。汇编器的伪指令和宏最终会被汇编器转换为多条指令,但是在编译器眼中它只是一条指令,从而产生误判,生成错误的代码。

因此,尽量不要在 asm 块中使用伪指令和宏

X86 特定

x86 平台有些些专门的 constraint ,如:

  • a: ax 寄存器,在 32 位处理器上是 eax,在 64 位处理器上是 rax
  • b(bx),c(cx),d(dx),S(si),D(di): 类似与a
  • q: 整数寄存器。32 位上是abcd,64 位增加了 r8 ~ r15 8 个寄存器

x86 中一个大寄存器可以重叠地分为多个小寄存器,比如 rax 第 32 位可以作为 eax 单独使用,eax 低 16 位又可以作为 ax 单独使用,ax 高 8 位可以做为 ah 单独使用、低 8 位可以作为 al 单独使用。针对这种情况,GCC 在 x86 平台专门提供了一些修饰符来调整生成的汇编代码中寄存器、内存地址等的格式。

xxxxxxxxxx
uint16_t  num;
asm volatile ("xchg %h0, %b0" : "+a" (num) );

这段代码将num分配到 ax 寄存器中,在64 位处理器上是 rax,32 位处理器上是 eax,但程序员只需要访问它的子寄存器 ah 和 al。h表示访问 ah(bh、ch 等),b表示访问 al(bl、cl 等)。因此内联汇编指令插入到汇编代码中时变成了xchg ah, al,而不是原始的xchg rax, raxxchg eax, eax

完整的 GCC x86 修饰符可以在手册中找到。

RISC-V 特定

GCC 对 RISC-V 平台提供了以下额外的 constrait。

  • f: 浮点寄存器(如果存在的花)
  • I: 12 比特立即数
  • J: 整数 0
  • K: 用于 CSR 访问指令的 5 比特的无符号立即数
  • A: 存储在通用寄存器中的地址

GCC 没有提供对 RISC-V 特定寄存器的 constrait,如果我们需要将变量分配到特定的寄存器,只能通过分配寄存器变量的方式曲线救国。

寄存器变量

寄存器变量是 ISO C 的特性,语法为:

xxxxxxxxxx
register type cvariable
xxxxxxxxxx
register size_t i;
for (i = 0; i < 100; ++i)
    /* do something */

ISO C 中的寄存器变量特性只是“建议”将某个变量分配到寄存器中,最终是否分配到寄存器中由编译器决定,并且没有提供指定寄存器的语法,分配到哪个寄存器也由编译器决定。

GCC 拓展了 ISO C 中寄存器变量的特性,提供了指定寄存器的语法,只要分配的寄存器合法就会分配成功。

语法结构如下:

xxxxxxxxxx
register type cvariable asm ("register")
xxxxxxxxxx
register unsigned long long i asm ("rax");  // x86

该代码段将变量i分配到寄存器 rax 中。

因为变量在寄存器中,因此寄存器变量的使用有以下限制:

  • 全局寄存器变量不能有初始值不能初始化。可执行文件无法给寄存器提供初值。
  • 不能使用 volatile 等修饰符。
  • 不能取地址。

寄存器变量仅仅是指示编译器将变量放置在特定的寄存器中,不意味这在该变量的整个生命周期中该变量都独占该寄存器,该寄存器很可能会被分配为别的变量使用。程序员只可以假设在声明时变量在指定的寄存器中,之后的语句中不能假设该变量仍在该寄存器中,生成的任何指令都可能修改该寄存器的值。

既可以全局变量也可以是局部变量。由于上面提到的限制,将全局变量声明为寄存器变量几乎总是错误的做法,很可能破坏 C ABI,对性能也未必有大的提升,仅在极有限的场景下使用全局寄存器变量,因此不解释全局寄存器变量。

当 GCC 没有提供将变量分配到特定寄存器中的 constraint 时,我们将该变量声明为局部寄存器变量,并将其分配到特定的寄存器中。然后紧贴着写内联汇编,分配到寄存器中就使用r constrait。

以下代码封装了 RISC-V ecall。

xxxxxxxxxx
void
sbi_console_putchar(int ch)
{
    register int a0 asm ("x10") = ch;           // 变量 a0 分配到寄存器 x10 中
    register uint64_t a6 asm ("x16") = 0;       // 变量 a6 分配到寄存器 x16 中
    register uint64_t a7 asm ("x17") = 1;       // 变量 a7 分配到寄存器 x17 中
    __asm__ __volatile__ (
        "ecall \n\t"
        : /* empty output list */
        : "r" (a0), "r" (a6), "r" (a7)
        : "memory"
        );
}

陷阱:

  • 小心在定义了寄存器变量后,使用寄存器变量前,某些语句修改的寄存器的值
  • 局部寄存器变量只能配合内联汇编使用,或者按照标准 C ABI 在函数之间传递。其他所有用法都是未定义的,工作正常仅仅是运气。
  • 尽可能不要使用宏和伪指令
  • 使用= constraint 时不要假设在进入 asm 块时,变量已被分配到寄存器中
  • 不要修改输入变量,除非它和输出相关联
  • 尽可能考虑全面,尽可能提供多的信息
  • 小心输出输入重叠,使用&constraint 解决这个问题
  • 较宽泛的 constraint 可以给 GCC 更大自由,生成更好的代码,但程序员要考虑的事情也变多了
  • 小心打字错误,如将$1打成1,这可能导致段错误
  • 小写指令的操作对象类型错误,这可能导致段错误。如 x86 的cmov指令要求源是寄存器或内存位置,目的操作对象是寄存器。

C++

此博客中的热门博文

使用 Vim 搭建 C/C++ 开发环境

刚接触 Vim 的同学往往因为无法搭建开发环境而“从入门到放弃”,本文旨在帮助这些同学搭建开发环境,聚焦于最核心的开发需求,忽略换配色调字体之类的细枝末节。如果需要开箱即用的 vim 配置(发行版),可以使用 Spacevim 。 本文使用 neovim-nightly,但也适用于 Vim 8.2+,不需要读者有任何 VimL 基础,以 C/C++ 为例,但应该适用于任何语言。   插件管理 在 Vim 中,插件只是一些脚本,存放在特定的目录中,运行时将它们所在的目录加入到 runtimepath 中。Vim 8 内置了插件管理功能,但不支持高级的插件管理功能。Vimmers 实现了多个插件管理器,可以自动下载、更新、安装插件,还可以延迟加载、按需加载,提高启动速度。 上古时期流行手动或使用 Vundle 管理插件,以上两种方式已经落伍了,这里介绍目前比较流行的三个插件管理器: vim-plug :简单易用高效,是目前最流行的插件管理器 dein.vim :功能强大,但使用复杂 vim-pathogen :另一款流行的插件管理器,没有用过不做评价 以上三款插件管理器风格各不相同,都有大量用户,功能相当完善,根据自己的喜好选取即可。推荐新手选择 vim-plug,对启动时间特别敏感的同学可以考虑 dein.vim,我的配置在安装 70 余个插件的情况下,启动仅需 60 余秒。 使用 vim-plug 安装插件只需要在 .vimrc 中写入以下代码: call plug#begin('~/.vim/plugged') " 括号里面是插件目录                                 " 只能在 plug#begin() 和 plug#end() 之间写安装插件的命令 Plug 'junegunn/vim-easy-align'   " 用户名/插件名,默认从 github 下载安装 Plug 'https://github.com/junegunn/vim-github-dashboard.git' " 从特定 URL 下载安装 call plug#end() 使用 dein.vim 安装插件: ​ x set runtimepath+=

Ibex 架构介绍

  Ibex 是什么? Ibex was initially developed as part of the PULP platform under the name "Zero-riscy" , and has been contributed to lowRISC who maintains it and develops it further. It is under active development. Ibex 是一个产品级的 32 位开源 RISC-V 处理器,使用 SystemVerilog 编写,麻雀虽小(11000 行左右),五章俱全。支持 RV32I、RV32C、RV32M、RV32B 等拓展,支持了 M-Mode 和 U-Mode,完整实现了 RISC-V 指令集规定的控制状态寄存器、中断异常、调试支持等,适用于嵌入式系统。 总体架构如下: 流水线 Ibex 默认使用两级流水线,但也支持三级流水线(实验性特性)。两级流水分别为: 取值(IF):通过预取缓冲区(prefetch buffer)从内存中取值,可以一个周期取一条指令,只要指令侧内存支持。 译码/执行(ID/EX):译码并立即执行,所有的操作,包括寄存器读写、内存访问都在该阶段进行。 Ibex 支持多周期指令,每条指令都至少需要两个周期才能通过流水线,周期数更大的指令将导致流水线停顿多个周期。指令类型及其停顿周期如下: Instruction Type Stall Cycles Description Integer Computational 0 Integer Computational Instructions are defined in the RISCV-V RV32I Base Integer Instruction Set. CSR Access 0 CSR Access Instruction are defined in ‘Zicsr’ of the RISC-V specification. Load/Store 1 - N Both loads and stores stall for at least one cycle to await a response. For loads this response is t

UNIX 进程关系

进程的起源 BSD 终端登录 网络登录 进程与进程组 会话与控制终端 作业控制 孤儿进程和孤儿进程组 总结 UNIX是分时系统,同时运行着多个进程,进程之间相互联系,形成了进程组、会话等进程关系,这些进程关系会影响某些函数/系统调用和信号的行为。 进程的起源 所有的进程都有一共同的起源,加电开机启动操作系统并登录(获取 login shell )就是用户进程的起始 1 。这里介绍传统的UNIX登录机制。 UNIX登录的过程一般分为两种: 终端登录( terminal login ) 网络登录( network login ) 终端登录就是在本地计算机中启动操作系统并取得 login shell,比如没有安装桌面环境(KDE、GNOME等)的 GNU/Linux 系统,启动后就是命令行,要求输入用户名和密码,验证通过后就会取得 login shell。 网络登录就是通过网络登录远程计算机取得 login shell,比如腾讯云、阿里云的 Linux 服务器,登录后就是命令行黑框框,就好像是在本地登录的一样。 BSD 终端登录 启动 init 进程,init 进程可能会读取终端相关的配置文件,如 /etc/ttys 等。 为每个终端 fork 出一个子进程,并使用 exec() 函数执行 getty() 例程。 getty() 例程打开终端设备(标准输出、标准输入、标准错误)、读取用户名、初始化环境变量,并使用 exec() 系列函数执行login 例程。 login 例程接收密码,如果验证成功就修改工作目录到家目录、修改终端所有权、设置用户信息(UID、GID等)、初始化环境变量(SHELL、PATH等)后执行 login shell。 如果验证失败,终止进程,init 进程再次执行步骤2。 通过以上步骤,用户就取得了 login shell,并且 login shell 是 init 进程的子进程(exec 系列函数只执行程序不改变进程ID)。login shell 也会读取它的配置文件来初始化自身。 BSD 的终端登录方式在 UNIX 世界有巨大的影响,被许多 UNIX 实现采用,比如 Linux 早期版本(Linux 0.12)就是用类似 BSD 的登录方式。现在终端登录复杂了很多,许多系统装有 X window,登录后是图形界面而不是字符界面。G

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK