欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Linux C 语言内联汇编

程序员文章站 2024-03-23 14:12:28
...

Linux C 语言内联汇编

Linux 汇编语法格式

绝大多数 Linux 程序员以前只接触过 DOS/Windows 下的汇编语言,这些汇编代码都是 Intel 风格(即386 汇编语言)的。但在 Unix 和 Linux 系统中,更多采用的还是 AT&T 格式,两者在语法格式上有着很大的不同:

1. 前缀——寄存器名/立即操作数

在 Intel 汇编的语法中,寄存器和和立即数都没有前缀。
在 AT&T 汇编格式中,寄存器名要加上'%'作为前缀;立即操作数要加上 '$'前缀;

AT&T 格式 Intel 格式
movl %eax $1 mov eax 1

2. 后缀——十六进制/二进制

在 Intel 的语法中,十六进制和二进制立即数后缀分别冠以“h”和“b”,而在 AT&T 中,十六进制立即数前冠以“0x”;

AT&T 格式 Intel 格式
int $0x80 int 80h

3. 源操作数和目标操作数的位置

AT&T 和 Intel 格式中的源操作数和目标操作数的位置正好相反。在 Intel 汇编格式中,目标操作数在源操作数的左边;而在 AT&T 汇编格式中,目标操作数在源操作数的右边。

AT&T 格式 Intel 格式
addl $1, %eax add eax, 1

4. 操作数的字长修饰符

在 AT&T 汇编格式中,操作数的字长由操作符的最一个字母决定,后缀'b'、'w'、'l'分别表示操作数为字节(byte,8 bits)、字(word,16 bits)和长字(long,32 bits);而在 Intel 汇编格式中,操作数的字长是用 "byte ptr" 和 "word ptr"前缀来表示的。

AT&T 格式 Intel 格式
movb %bl,%al mov al,bl
movw %bx,%ax mov ax,bx
movl %ebx,%eax mov eax,ebx
movl (%ebx),%eax mov eax, dword ptr [ebx]

5. 跳转指令

在 AT&T 汇编格式中,绝对转移和调用指令(jump/call)的操作数前要加上'$'作为前缀,而在 Intel 格式中则不需要。 远程转移指令和远程子调用指令的操作码,在 AT&T 汇编格式中为 "ljump" 和 "lcall",而在 Intel 汇编格式中则为"jmp far" 和"call far".

AT&T 格式 Intel 格式
ljump section,offset jmp far section:offset
lcall section,offset call far section:offset

6. 与跳转指令相应的远程返回指令

AT&T 格式 Intel 格式
lret $stack_adjust ret far stack_adjust

7. 内存操作数

内存操作数也有所不同。在 Intel 的语法中,基寄存器用“[]”括起来,而在 AT&T 中,用“()”括起来。

AT&T 格式 Intel 格式
movl 5(%ebx),%eax mov eax,[ebx+5]
disp(base, index, scale) [base + index*scale + disp]

由于 Linux 工作在保护模式下,用的是 32 位线性地址,所以在计算地址时不用考虑段基址和偏移量,而是采用如下的地址计算方法:
disp + base + index * scale

8. 间接寻址方式

与 Intel 的语法比较,AT&T 间接寻址方式可能更晦涩难懂一些。Intel 的指令格式是segreg:[base+index*scale+disp],而 AT&T 的格式是%segreg:disp(base,index,scale)。其中index/scale/disp/segreg全部是可选的,完全可以简化掉。如果没有指定 scale 而指定了index,则 scale 的缺省值为 1。segreg 段寄存器依赖于指令以及应用程序是运行在实模式还是保护模式下,在实模式下,它依赖于指令,而在保护模式下,segreg 是多余的。在AT&T 中,当立即数用在 scale/disp 中时,不应当在其前冠以“$”前缀。

AT&T 格式 Intel 格式
disp(base, index, scale) [base + index*scale + disp]
mov eax,[ebx+20h] movl0x20(%ebx),%eax
add eax,[ebx+ecx*2h addl (%ebx,%ecx,0x2),%eax
lea eax,[ebx+ecx] leal (%ebx,%ecx),%eax
sub eax,[ebx+ecx*4h-20h] subl -0x20(%ebx,%ecx,0x4),%eax

9. 一些内存操作数的例子

AT&T 格式 Intel 格式
movl -4(%ebp), %eax mov eax, [ebp - 4]
movl array(, %eax, 4), %eax mov eax, [eax*4 + array]
movw array(%ebx, %eax, 4), %cx mov cx, [ebx + 4*eax + array]
movb $4, %fs:(%eax) mov fs:eax, 4

AT&T 汇编语言的相关知识

在 Linux 源代码中,以.S 为扩展名的文件是“纯”汇编语言的文件。这里,我们结合具体的例子再介绍一些 AT&T 汇编语言的相关知识。

1. AT&T 中的Section段

在 AT&T 的语法中,一个段由.section 关键词来标识,当你编写汇编语言程序时,至少需要有以下三种段:

.section .data: 这种段包含程序已初始化的数据,也就是说,包含具有初值的那些变量,例如:

 hello : .string "Hello world!\n"
 hello_len : .long 13

.section .bss:这个段包含程序还未初始化的数据,也就是说,包含没有初值的那些变量。当操作 系统装入这个程序时将把这些变量都置为 0,例如:

 name : .fill 30 # 用来请求用户输入名字
 name_len : .long 0 # 名字的长度 (尚未定义)

当这个程序被装入时,name 和 name_len 都被置为 0。如果你在.bss 节不小心给一个变量赋了初值,这个值也会丢失,并且变量的值仍为 0。

使用.bss 比使用.data 的优势在于,.bss 段不占用磁盘的空间。在磁盘上,一个长整数就足以存放.bss 段。当程序被装入到内存时,操作系统也只分配给这个段 4 个字段的内存大小。

注意:编译程序把.data 和.bss 在 4 字节上对齐(align),例如,.data 总共有 34 字节,那么编译程序把它对其在 36 字节上,也就是说,实际给它 36 字节的空间。

.section .text :这个段包含程序的代码,它是只读段,而.data 和.bss 是读/写节。

2.汇编程序指令(Assembler Directive)

上面介绍的.section 就是汇编程序指令的一种,GNU 汇编程序提供了很多这样的指令(directiv),这种指令都是以句点(.)为开头,后跟指令名(小写字母),在此,我们只介绍在内核源代码中出现的几个指令(以 arch/i386/kernel/head.S 中的代码为例)。

(1) .ascii “string”…

.ascii 表示零个或多个(用逗号隔开)字符串,并把每个字符串(结尾不自动加“0“字节)中的字符放在连续的地址单元。还有一个与.ascii 类似的.asciz,z 代表“0“,即每个字符串结尾自动加一个”\0“,例如:

int_msg:
 .asciz "Unknown interrupt\n"

(2) .byte 表达式

.byte 表示零或多个表达式(用逗号隔开),每个表达式被放在下一个字节单元。

(3) .fill 表达式

形式:.fill repeat , size , value
其中,repeat、size 和 value 都是常量表达式。

Fill 的含义是反复拷贝 size 个字节。
Repeat 可以大于等于 0。size 也可以大于等于 0,但不能超过 8,如果超过 8,也只取 8。把repeat 个字节以 8 个为一组,每组的最高 4 个字节内容为 0,最低 4 字节内容置为 value。 Size 和 value 为可选项。如果第二个逗号和 value 值不存在,则假定 value 为 0。如果第一个逗号和 size 不存在,则假定 size 为 1。

例如,在 Linux 初始化的过程中,对全局描述符表 GDT 进行设置的最后一句为:

.fill NR_CPUS*4, 8, 0  /* space for TSS's and LDT's */

因为每个描述符正好占 8 个字节,因此,.fill 给每个 CPU 留有存放 4 个描述符的位置。

(4) .globl symbol

.globl 使得连接程序(ld)能够看到 symbl。如果你的局部程序中定义了 symbl,那么,与这个局部程序连接的其他局部程序也能存取 symbl,例如:

 .globl SYMBOL_NAME(idt)
 .globl SYMBOL_NAME(gdt)

定义 idt 和 gdt 为全局符号。

(5) .quad bignums

.quad 表示零个或多个 bignums(用逗号分隔),对于每个 bignum,其缺省值是 8 字节整数。如果 bignum 超过 8 字节,则打印一个警告信息;并只取 bignum 最低 8 字节。

例如,对全局描述符表的填充就用到这个指令:

.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */
.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */
.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */
.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */

(6) .rept count

把.rept 指令与.endr 指令之间的行重复 count 次,例如

 .rept 3 .long 0
 .endr 

 # 相当于
 .long 0
 .long 0
 .long 0

(7) .space size , fill

这个指令保留 size 个字节的空间,每个字节的值为 fill。size 和 fill 都是常量表达式。
如果逗号和 fill 被省略,则假定 fill 为 0,例如在 arch/i386/bootl/setup.S 中有一句:

 .space 1024

表示保留 1024 字节的空间,并且每个字节的值为 0。

(8) .word expressions

这个表达式表示任意一节中的一个或多个表达式(用逗号分开),表达式的值占两个字节,例如:

 gdt_descr:
 .word GDT_ENTRIES*8-1

表示变量 gdt_descr 的置为 GDT_ENTRIES*8-1

(9) .long expressions

这与.word 类似

(10) .org new-lc , fill

把当前段的位置计数器提前到 new-lc(new location counter)。new-lc 或者是一个常量表达式,或者是一个与当前子段处于同一段的表达式。也就是说,你不能用.org 横跨段:如果 new-lc 是个错误的值,则.org 被忽略。.org 只能增加位置计数器的值,或者让其保持不变;但绝不能用.org 来让位置计数器倒退。
注意,位置计数器的起始值是相对于一个段的开始的,而不是子段的开始。当位置计数器被提升后,中间位置的字节被填充值 fill(这也是一个常量表达式)。如果逗号和fill 都省略,则 fill 的缺省值为 0。例如:

.org 0x2000
 ENTRY(pg0)

表示把位置计数器置为 0x2000,这个位置存放的就是临时页表 pg0。

Hello World

真不知道打破这个传统会带来什么样的后果,但既然所有程序设计语言的第一个例子都是在屏幕上打印一个字符串 “Hello World!”,那我们也以这种方式来开始介绍 Linux 下的汇编语言程序设计。

在 Linux 操作系统中,你有很多办法可以实现在屏幕上显示一个字符串,但最简洁的方式是使用 Linux 内核提供的系统调用。使用这种方法最大的好处是可以直接和操作系统的内核进行通讯,不需要链接诸如 libc 这样的函数库,也不需要使用 ELF 解释器,因而代码尺寸小且执行速度快。

Linux 是一个运行在保护模式下的 32 位操作系统,采用flat memory模式,目前最常用到的是 ELF 格式的二进制代码。一个 ELF 格式的可执行程序通常划分为如下几个部分:.text、.data 和 .bss,其中 .text 是只读的代码区,.data 是可读可写的数据区,而 .bss 则是可读可写且没有初始化的数据区。代码区和数据区在 ELF 中统称为 section,根据实际需要你可以使用其它标准的 section,也可以添加自定义 section,但一个 ELF 可执行程序至少应该有一个 .text 部分。

下面给出我们的第一个汇编程序,用的是 AT&T 汇编语言格式.

例 1. hello word —— AT&T 格式

# hello.s

# 数据段声明
.data
    msg : .string "Hello, world!\n" # 要输出的字符串
    # 字串长度
    len = . - msg

# 代码段声明
.text
    # 指定入口函数
    .global _start

# 在屏幕上显示一个字符串
_start:
    movl $len, %edx # 参数三:字符串长度
    movl $msg, %ecx # 参数二:要显示的字符串
    movl $1, %ebx # 参数一:文件描述符(stdout)
    movl $4, %eax # 系统调用号(sys_write)

    int $0x80  # 调用内核功能

    # 退出程序
    movl $0,%ebx # 参数一:退出代码
    movl $1,%eax # 系统调用号(sys_exit)

   int $0x80  # 调用内核功能

初次接触到 AT&T 格式的汇编代码时,很多程序员都认为太晦涩难懂了,没有关系,在 Linux 平台上你同样可以使用 Intel 格式来编写汇编程序:

例 2. hello word —— Intel 格式

; hello.asm
; 数据段声明
section .data
; 要输出的字符串
    msg db "Hello, world!", 0xA
; 字串长度
    len equ $ - msg

section .text ; 代码段声明
    global _start ; 指定入口函数

_start:
    ; 在屏幕上显示一个字符串
    mov edx, len ; 参数三:字符串长度
    mov ecx, msg ; 参数二:要显示的字符串
    mov ebx, 1 ; 参数一:文件描述符(stdout)
    mov eax, 4 ; 系统调用号(sys_write)

    int 0x80 ; 调用内核功能

    ; 退出程序
    mov ebx, 0 ; 参数一:退出代码
    mov eax, 1 ; 系统调用号(sys_exit)
    int 0x80 ; 调用内核功能

上面两个汇编程序采用的语法虽然完全不同,但功能却都是调用 Linux 内核提供的 sys_write 来显示一个字符串,然后再调用 sys_exit退出程序。在 Linux 内核源文件include/asm-i386/unistd.h中,可以找到所有系统调用的定义。

Linux 汇编工具

Linux 平台下的汇编工具虽然种类很多,但同 DOS/Windows 一样,最基本的仍然是汇编器、连接器和调试器。

1. 汇编器

汇编器(assembler)的作用是将用汇编语言编写的源程序转换成二进制形式的目标代码。Linux 平台的标准汇编器是GAS,它是 GCC所依赖的后台汇编工具,通常包含在 binutils 软件包中。GAS 使用标准的 AT&T 汇编语法,可以用来汇编用 AT&T 格式编写的程序:

$ as -o hello.o hello.s

Linux 平台上另一个经常用到的汇编器是NASM,它提供了很好的宏指令功能,并能够支持相当多的目标代码格式,包括 bin、a.out、coff、elf、rdf等。NASM 采用的是人工编写的语法分析器,因而执行速度要比 GAS 快很多,更重要的是它使用的是 Intel 汇编语法,可以用来编译用 Intel 语法格式编写的汇编程序:

$ nasm -f elf hello.asm

2.链接器

由汇编器产生的目标代码是不能直接在计算机上运行的,它必须经过链接器的处理才能生成可执行代码。链接器通常用来将多个目标代码连接成一个可执行代码,这样可以先将整个程序分成几个模块来单独开发,然后才将它们组合(链接)成一个应用程序。 Linux 使用 ld作为标准的链接程序,它同样也包含在 binutils 软件包中。汇编程序在成功通过GAS 或 NASM的编译并生成目标代码后,就可以使
用 ld 将其链接成可执行程序了:

$ ld -s -o hello hello.o

3.调试器

有人说程序不是编出来而是调出来的,足见调试在软件开发中的重要作用,在用汇编语言编写程序时尤其如此。Linux 下调试汇编代码既可以用 GDB、DDD 这类通用的调试器,也可以使用专门用来调试汇编代码的 ALD(Assembly Language Debugger)。

从调试的角度来看,使用 GAS 的好处是可以在生成的目标代码中包含符号表(symbol table),这样就可以使用 GDB 和 DDD 来进行源码级的调试了。要在生成的可执行程序中包含符号表,可以采用下面的方式进行编译和链接:

$ as --gstabs -o hello.o hello.s
$ ld -o hello hello.o

执行 as 命令时带上参数--gstabs可以告诉汇编器在生成的目标代码中加上符号表,同时需要注意的是,在用 ld 命令进行链接时不要加上 -s 参数,否则目标代码中的符号表在链接时将被删去。

在 GDB 和 DDD 中调试汇编代码和调试 C 语言代码是一样的,你可以通过设置断点来中断程序的运行,查看变量和寄存器的当前值,并可以对代码进行单步跟踪。

系统调用

即便是最简单的汇编程序,也难免要用到诸如输入、输出以及退出等操作,而要进行这些操作则需要调用操作系统所提供的服务,也就是系统调用。除非你的程序只完成加减乘除等数学运算,否则将很难避免使用系统调用,事实上除了系统调用不同之外,各种操作系统的汇编编程往往都是很类似的。

在 Linux 平台下有两种方式来使用系统调用:利用封装后的 C 库(libc)或者通过汇编直接调用。其中通过汇编语言来直接调用系统调用,是最高效地使用 Linux 内核服务的方法,因为最终生成的程序不需要与任何库进行链接,而是直接和内核通信。

和 DOS 一样,Linux 下的系统调用也是通过中断(int 0x80)来实现的。在执行 int 80 指令时,寄存器eax中存放的是系统调用的功能号,而传给系统调用的参数则必须按顺序放到寄存器 ebx,ecx,edx,esi,edi中,当系统调用完成之后,返回值可以在寄存器eax中获得。

所有的系统调用功能号都可以在文件 /usr/include/bits/syscall.h 中找到,为了便于使用,它们是用SYS_<name>这样的宏来定义的,如 SYS_write、SYS_exit 等。例如,经常用到的 write 函数是如下定义的:

ssize_t write(int fd, const void *buf, size_t count);

该函数的功能最终是通过 SYS_write 这一系统调用来实现的。根据上面的约定,参数 fd、buf 和 count分别存在寄存器 ebx、ecx 和edx 中,而系统调用号 SYS_write 则放在寄存器eax中,当int 0x80指令执行完毕后,返回值可以从寄存器eax中获得。

或许你已经发现,在进行系统调用时至多只有 5 个寄存器能够用来保存参数,难道所有系统调用的参数个数都不超过 5 吗?当然不是,例如 mmap 函数就有 6 个参数,这些参数最后都需要传递给系统调用 SYS_mmap:

void * mmap(void *start, size_t length, int prot , int flags, int fd, off_t offset);

当一个系统调用所需的参数个数大于 5 时,执行 int 0x80 指令时仍需将系统调用功能号保存在寄存器 eax 中,所不同的只是全部参数应该依次放在一块连续的内存区域里,同时在寄存器ebx 中保存指向该内存区域的指针。系统调用完成之后,返回值仍将保存在寄存器eax 中。

由于只是需要一块连续的内存区域来保存系统调用的参数,因此完全可以像普通的函数调用一样使用栈(stack)来传递系统调用所需的参数。但要注意一点,Linux 采用的是 C 语言的调用模式,这就意味着所有参数必须以相反的顺序进栈,即最后一个参数先入栈,而第一个参数则最后入栈。如果采用栈来传递系统调用所需的参数,在执行 int 0x80 指令时还应该将栈指针的当前值复制到寄存器 ebx 中。

处理命令行参数

在 Linux 操作系统中,当一个可执行程序通过命令行启动时,其所需的参数将被保存到栈中:首先是argc,然后是指向各个命令行参数的指针数组 argv,最后是指向环境变量的指针数据 envp

在编写汇编语言程序时,很多时候需要对这些参数进行处理,下面的代码示范了如何在汇编代码中进行命令行参数的处理:

例 1. 处理命令行参数

# args.s
.text
    .globl _start

_start:
    popl %ecx # argc
    popl %ecx # argv
    test %ecx, %ecx 

    vnext:
        # 空指针表明结束
        jz exit
        movl %ecx, %ebx
        xorl %edx, %edx
    strlen:
        movb (%ebx), %al 
        inc %edx 
        inc %ebx 
        test %al, %al 
        jnz strlen 

    movb $10, -1(%ebx) 
    movl $4, %eax # 系统调用号(sys_write)
    movl $1, %ebx # 文件描述符(stdout)
    int $0x80 

    jmp vnext 
    movl $1,%eax 
    xorl %ebx, %ebx 
    int $0x80 

    exit:
    # 系统调用号(sys_exit)
    # 退出代码
    ret

GCC 内联汇编

用汇编编写的程序虽然运行速度快,但开发速度非常慢,效率也很低。如果只是想对关键代码段进行优化,或许更好的办法是将汇编指令嵌入到 C 语言程序中,从而充分利用高级语言和汇编语言各自的特点。但一般来讲,在 C 代码中嵌入汇编语句要比"纯粹"的汇编语言代码复杂得多,因为需要解决如何分配寄存器,以及如何与 C 代码中的变量相结合等问题。GCC 提供了很好的内联汇编支持,最基本的格式是:

__asm__("asm statements");

例如:
asm(“nop”);

如果需要同时执行多条汇编语句,则应该用”\n\t”将各个语句分隔开,例如:
asm( “pushl %%eax \n\t”
“movl $0, %%eax \n\t”
“popl %eax”);

通常嵌入到 C 代码中的汇编语句很难做到与其它部分没有任何关系,因此更多时候需要用到完整的内联汇编格式:

__asm__("asm statements" : outputs : inputs : registers-modified);

插入到 C 代码中的汇编语句是以”:”分隔的四个部分,其中第一部分就是汇编代码本身,通常称为指令部,其格式和在汇编语言中使用的格式基本相同。指令部分是必须的,而其它部分则可以根据实际情况而省略。

在将汇编语句嵌入到 C 代码中时,操作数如何与 C 代码中的变量相结合是个很大的问题。GCC 采用如下方法来解决这个问题:程序员提供具体的指令,而对寄存器的使用则只需给出”样板”和约束条件就可以了,具体如何将寄存器与变量结合起来完全由 GCC 和 GAS 来负责。

在 GCC 内联汇编语句的指令部中,加上前缀'%'的数字(如%0,%1)表示的就是需要使用寄存器的”样板”操作数。指令部中使用了几个样板操作数,就表明有几个变量需要与寄存器相结合,这样 GCC 和 GAS 在编译和汇编时会根据后面给定的约束条件进行恰当的处理。

由于样板操作数也使用’%’作为前缀,因此在涉及到具体的寄存器时,寄存器名前面应该加上两个'%',以免产生混淆。

紧跟在指令部后面的是输出部(输出部的意思是变量的值会被改变,即可以作为左值)是规定输出变量如何与样板操作数进行结合的条件,每个条件称为一个”约束”,必要时可以包含多个约束,相互之间用逗号分隔开就可以了。每个输出约束都以'='号开始,然后紧跟一个对操作数类型进行说明的字后,最后是如何与变量相结合的约束。凡是与输出部中说明的操作数相结合的寄存器或操作数本身,在执行完嵌入的汇编代码后均不保留执行之前的内容,这是 GCC在调度寄存器时所使用的依据。

输出部后面是输入部,输入约束的格式和输出约束相似,但不带'='号。如果一个输入约束要求使用寄存器,则 GCC 在预处理时就会为之分配一个寄存器,并插入必要的指令将操作数装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在执行完嵌入的汇编代码后也不保留执行之前的内容。

有时在进行某些操作时,除了要用到进行数据输入和输出的寄存器外,还要使用多个寄存器来保存中间计算结果,这样就难免会破坏原有寄存器的内容。

在 GCC 内联汇编格式中的最后一个部分中,可以对将产生副作用的寄存器进行说明,以便 GCC 能够采用相应的措施。 内联汇编语句的最后一个部分告诉 GCC 它将改变该寄存器中的值,GCC 在处理时不应使用该寄存器来存储任何其它的值。

下面是一个内联汇编的简单例子:

例1: 内联汇编

#include <stdio.h>

/* inline.c */
int main()
{
    int a = 10, b = 0;

    __asm__ __volatile__(
            "movl %1, %%eax;       \n\t"
            "movl %%eax, %0         \n\t;"
            :"=r"(b) /* 输出 */ :"r"(a) /* 输入 */:"%eax"); /* 不受影响的寄存器 */

    printf("Result: %d, %d\n", a, b);
}

上面的程序完成将变量 a 的值赋予变量 b,有几点需要说明:
- 变量 b 是输出操作数,通过%0 来引用,而变量 a 是输入操作数,通过%1 来引用。
- 输入操作数和输出操作数都使用 r 进行约束,表示将变量 a 和变量 b 存储在寄存器中。输入约束和输出约束的不同点在于输出约束多一个约束修饰符’=’。
- 在内联汇编语句中使用寄存器 eax 时,寄存器名前应该加两个’%’,即%%eax。内联汇编中使用%0、%1 等来标识变量,任何只带一个’%’的标识符都看成是操作数,而不是寄存器。
- 内联汇编语句的最后一个部分告诉 GCC 它将改变寄存器 eax 中的值,GCC 在处理时不应使用该寄存器来存储任何其它的值。
- 由于变量 b 被指定成输出操作数,当内联汇编语句执行完毕后,它所保存的值将被更新。

在内联汇编中用到的操作数从输出部的第一个约束开始编号,序号从 0 开始,每个约束记数一次,指令部要引用这些操作数时,只需在序号前加上’%’作为前缀就可以了。需要注意的是,内联汇编语句的指令部在引用一个操作数时总是将其作为 32 位的长字使用,但实际情况可能需要的是字或字节,因此应该在约束中指明正确的限定符:

限定符 意义
“m”、”v”、”o” 内存单元
“r” 任何寄存器
“q” 寄存器 eax、ebx、ecx、edx 之一
“i”、”h” 直接操作数
“E”和”F” 浮点数
“g” 任意
“a”、”b”、”c”、”d” 分别表示寄存器 eax、ebx、ecx 和 edx
“S”和”D” 寄存器 esi、edi
“I” 常数(0 至 31)

例2: 输出/输入部分

在输入部分定义的变量,是不可以被改变的,即不能放在等号的左边,有点const变量的意思;
而在输出部分定义的变量,是要放在等号的左边的;

/* inline.c */
int main()
{
    int a = 10, b = 0, c = 12;

    __asm__ __volatile__(
            "movl  %1, %%eax;         \n\t"     /* b += c;*/
            "movl %2, %0;         \n\t"         /* b = c */
            "addl  %2, %0;         \n\t"        /* b += c; */
            "movl %0, %2;         \n\t"         /* error: c = b */
            :"=r"(b) /* 输出 */ :"r"(a), "r"(c)/* 输入 */:"%eax"); /* 不受影响的寄存器 */

    printf("Result: %d, %d, %d\n", a, b, c);
}

例3: 预留不受计算影响的寄存器

在 GCC 内联汇编格式中的最后一个部分中,可以对将产生副作用的寄存器进行说明,以便 GCC 能够采用相应的措施。 内联汇编语句的最后一个部分告诉GCC用户程序将显式地改变该寄存器中的值,GCC 在处理时不应使用该寄存器来存储任何其它的值。

/* inline.c */
int main()
{
    int a = 10, b = 0, c = 12;

    __asm__ __volatile__(
            "movl  %1, %%eax;         \n\t"     /* b += c;*/
            "movl %2, %0;         \n\t"    /*b = c*/
            "addl  %2, %0;         \n\t"     /* b += c;*/
            "addl  %%eax, %0;         \n\t"     /* b += c;*/
            :"=r"(b) /* 输出 */ :"r"(a), "r"(c)/* 输入 */:"%eax"); /* 不受影响的寄存器 */

    printf("Result: %d, %d, %d\n", a, b, c);
}

例4: 复杂应用

在 Linux 内核代码中,有关字符串操作的函数都是通过嵌入式汇编完成的,因为内核及用户程序对字符串函数的调用非常频繁,因此,用汇编代码实现主要是为了提高效率(当然是以牺牲可读性和可维护性为代价的)。在此,我们仅列举一个字符串比较函数strcmp,其代码在 arch/i386/string.h 中。

static inline int strcmp(const char * cs,const char * ct)
{
    int d0, d1;
    register int __res;

    __asm__ __volatile__(
     "1:\tlodsb\n\t"
     "scasb\n\t"
     "jne 2f\n\t"
     "testb %%al,%%al\n\t"
     "jne 1b\n\t"
     "xorl %%eax,%%eax\n\t"
     "jmp 3f\n"
     "2:\tsbbl %%eax,%%eax\n\t"
     "orb $1,%%al\n"
     "3:"
     :"=a" (__res), "=&S" (d0), "=&D" (d1)
     :"1" (cs),"2" (ct));


    return __res;
}

其中的“\n”是换行符,“\t”是 tab 符,在每条命令的结束加这两个符号,是为了让 gcc 把嵌入式汇编代码翻译成一般的汇编代码时能够保证换行和留有一定的空格。例如,上面的嵌入式汇编会被翻译成:

1:
lodsb //装入串操作数,即从[esi]传送到 al 寄存器,然后 esi 指向串中下一个元素
scasb //扫描串操作数,即从 al 中减去 es:[edi],不保留结果,只改变标志

jne2f //如果两个字符不相等,则转到标号 2

testb %al %al
jne 1b

xorl %eax %eax
jmp 3f

2:
sbbl %eax %eax
orb $1 %al

3:
这段代码看起来非常熟悉,读起来也不困难。其中1f 表示往前(forword)找到第一个标号为 1 的那一行,相应地,1b 表示往后找。其中嵌入式汇编代码中输出和输入部分的结合情况为:

  • 返回值__res,放在 al 寄存器中,与%0 相结合;
  • 局部变量 d0,与%1 相结合,也与输入部分的 cs 参数相对应,也存放在寄存器ESI 中,即 ESI 中存放源字符串的起始地址。
  • 局部变量 d1, 与%2 相结合,也与输入部分的 ct 参数相对应,也存放在寄存器 EDI 中,即 EDI 中存放目的字符串的起始地址。

通过对这段代码的分析我们应当体会到,万变不利其本,嵌入式汇编与一般汇编的区别仅仅是形式,本质依然不变。因此,全面掌握 Intel 386 汇编指令乃突破阅读底层代码之根本。

小结

Linux 操作系统是用 C 语言编写的,汇编只在必要的时候才被人们想到,但它却是减少代码尺寸和优化代码性能的一种非常重要的手段,特别是在与硬件直接交互的时候,汇编可以说是最佳的选择。Linux 提供了非常优秀的工具来支持汇编程序的开发,使用 GCC 的内联汇编能够充分地发挥 C 语言和汇编语言各自的优点。

可能出现的问题

1. Error: invalid instruction suffix for `popl’

程序中有一条命令为:popl %eax
使用 as 编译时出现错误:Error: invalid instruction suffix for `popl’

原因:
64 位系统和 32 位系统的差别引起的

解决方法:
在代码开头添加 .code32 即可

参考资料

Linux 汇编语法和简单实例 http://blog.csdn.net/darennet/article/details/41091133
Linux 中的汇编语言 http://blog.csdn.net/hairetz/article/details/17511567

相关标签: 汇编语言 linux