linux内核1-GNU汇编入门_X86-64&ARM(上)

简介: linux内核1-GNU汇编入门_X86-64&ARM

1 引言


为了阅读Linux内核源代码,是需要一些汇编语言知识的。因为与架构相关的代码基本上都是用汇编语言编写的,所以掌握一些基本的汇编语言语法,能够更好地理解Linux内核源代码,甚至可以对各种架构的差异有一个更深入的理解。

大部分人可能认为汇编语言晦涩难懂,阅读手册又冗长乏味。但是,经过本人的经验,可能常用的指令也就是30个。许多其它的指令都是解决特定的情况而出现,比如浮点运算和多媒体指令。所以,本文就从常用指令出发,基于GNU汇编语言格式,对x86_64架构和ARM架构下的指令做了一个入门介绍。学习完这篇文章,希望可以对汇编有一个基本的理解,并能够解决大部分问题。

阅读本文需要一些硬件架构的知识。必要的时候,可以翻阅Intel Software Developer Manual和ARM Architecture Reference Manual。


2 开源汇编工具


对于相同的芯片架构,不同的芯片制造商或者其它开源工具可能会有不同的语法格式。所以,本文支持GNU编译器和汇编器,分别是gccas(有时候也称为gas)。

将C代码转换成汇编代码,是一种非常好的学习方式。所以,可以通过在编译选项中加入-S标志,生成汇编目标文件。在类Unix系统,汇编源代码文件使用.s的后缀标记。

比如,运行gcc -S hello.c -o hello.s编译命令,编译hello程序:

#include <stdio.h>
int main( int argc, char *argv[] )
{
    printf("hello %s\n","world");
    return 0;
}

可以在hello.s文件中看到如下类似的输出:

.file "test.c"
.data
.LC0:
        .string "hello %s\n"
.LC1:
        .string "world"
.text
.global main
main:
        PUSHQ %rbp
        MOVQ %rsp, %rbp
        SUBQ $16, %rsp
        MOVQ %rdi, -8(%rbp)
        MOVQ %rsi, -16(%rbp)
        MOVQ $.LC0, %rax
        MOVQ $.LC1, %rsi
        MOVQ %rax, %rdi
        MOVQ $0, %rax
        CALL printf
        MOVQ $0, %rax
        LEAVE
        RET

从上边的汇编代码中可以看出,汇编代码大概由三部分组成:

  1. 伪指令
    伪指令前缀一个小数点.,供汇编器、链接器或者调试器使用。比如,.file记录最初的源文件名称,这个名称对调试器有用;.data,表明该部分的内容是程序的数据段;.text,表明接下来的内容是程序代码段的内容;.string,表示一个数据段中的字符串常量;.global main,表示符号main是一个全局符号,可以被其它代码模块访问。
  2. 标签
    标签是由编译器产生,链接器使用的一种引用符号。本质上,就是对代码段的一个作用域打上标签,方便链接器在链接阶段将所有的代码拼接在一起。所以,标签就是链接器的一种助记符。
  3. 汇编指令
    真正的汇编代码,其实就是机器码的助记符。GNU汇编对大小写不敏感,但是为了统一,我们一般使用大写。

汇编代码编译成可执行文件,可以参考下面的代码编译示例:

% gcc hello.s -o hello
% ./hello
hello world

把汇编代码生成目标文件,然后可以使用nm工具显示代码中的符号,参考下面的内容:

% gcc hello.s -c -o hello.o
% nm hello.o
0000000000000000    T main
                    U printf

nm -> 是names的缩写,nm命令主要是用来列出某些文件中的符号(换句话说就是一些函数和全局变量)。

上面的代码显示的符号对于链接器都是可用的。main出现在目标文件的代码段(T),位于地址0处,也就是说位于文件的开头;printf未定义(U),因为它需要从库文件中链接。但是像.LC0之类的标签出现,因为它们没有使用.global,所以说对于链接器是无用的。

编写C代码,然后编译成汇编代码。这是学习汇编一个好的开始。

回到顶部


3 X86汇编语言


X86是一个通用术语,指从最初的IBM-PC中使用的Intel-8088处理器派生(或兼容)的一系列微处理器,包括8086、80286、386、486以及其它许多处理器。每一代cpu都增加了新的指令和寻址模式(从8位到16位再到32位)。同时还保留了与旧代码的向后兼容性。各种竞争对手(如AMD)生产的兼容芯片也实现了相同的指令集。

但是,到了64位架构的时候,Intel打破了这个传统,引入了新的架构(IA64)和名称(Itanium),不再向后兼容。它还实现了一种新的技术-超长指令字(VLIW),在一个Word中实现多个并发操作。因为指令级的并发操作可以显著提升速度。

AMD还是坚持老方法,实现的64位架构(AMD64)向后兼容Intel和AMD芯片。不论两种技术的优劣,AMD的方法首先赢得了市场,随后Intel也生产自己的64位架构Intel64,并与AMD64和它自己之前的产品兼容。所以,X86-64是一个通用术语,包含AMD64和Intel64架构。

X86-64是复杂指令集CISC的代表。


3.1 寄存器和数据类型


X86-64具有16个通用目的64位寄存器:

1 2 3 4 5 6 7 8
rax rbx rcx rdx rsi rdi rbp rsp
9 10 11 12 13 14 15 16
r8 r9 r10 r11 r12 r13 r14 r15

说它们是通用寄存器是不完全正确的,因为早期的CPU设计寄存器是专用的,不是所有的指令都能用到每一个寄存器。从名称上就可以看出来,前八个寄存器的作用,比如rax就是一个累加器。

AT&T语法-Intel语法

GNU使用传统的AT&T语法,许多类Unix操作系统使用这种风格,与DOS和Windows上用的Intel语法是不同的。下面一条指令是符合AT&T语法:

MOVQ %RSP, %RBP

MOVQ是指令,%表明RSP和RBP是寄存器。AT&T语法,源地址在前,目的地址在后。

Intel语法省略掉%,参数顺序正好相反。同样的指令,如下所示:

MOVQ RBP, RSP

所以,看%就能区分是AT&T语法,还是Intel语法。

随着设计的发展,新的指令和寻址模式被添加进来,使得这些寄存器几乎一样了。其余的指令,尤其是和字符串处理相关的指令,要求使用rsi和rdi寄存器。另外,还有两个寄存器专门为栈指针寄存器(rsp)和基址指针寄存器(rbp)保留。最后的8个寄存器没有特殊的限制。

随着处理器从8位一直扩展到64位,有一些寄存器还能拆分使用。rax的低八位是一个8位寄存器al,接下来的8位称为ah。如果把rax的低16位组合起来就是ax寄存器,低32位就是累加器eax,整个64位才是rax寄存器。这样设计的目的是向前兼容,具体可以参考下图:

图1: X86 寄存器结构

r8-r15,这8个寄存器具有相同的结构,就是命名机制不同。

图2: X86 寄存器结构

为了简化描述,我们还是着重讲64位寄存器。但是,大多数编译器支持混合模式:一个字节可以表示一个布尔型;32位对于整数运算就足够了,因为大多数程序不需要大于2^32以上的整数值;64位类型常用于内存寻址,能够使虚拟地址的空间理论上可以达到1800万TB(1TB=1024GB)。


3.2 寻址模式


MOV指令可以使用不同的寻址模式,在寄存器和内存之间搬运数据。使用B、W、L和Q作为后缀,添加在指令后面,决定操作的数据的位数:

后缀 名称 大小
B BYTE 1 字节(8位)
W WORD 2 字节(16位)
L LONG 4 字节(32位)
Q QUADWORD 8 字节(64位)

MOVB移动一个字节,MOVW移动2个字节,MOVL移动4个字节,MOVQ移动8个字节。在某些情况下,可以省略掉这个后缀,编译器可以推断正确的大小。但还是建议加上后缀。

MOV指令可以使用下面几种寻址模式:

  • 全局符号
    一般给其定义一个简单的名称,通过这个名称来引用,比如x、printf之类的。编译器会将其翻译成绝对地址或用于地址计算。
  • 立即数
    使用美元符号$标记,比如$56。但是立即数的使用是有限制范围的。
  • 寄存器
    使用寄存器寻址,比如%rbx。
  • 间接引用
    通过寄存器中包含的地址进行寻址,比如(%rsp),表示引用%rsp指向的那个值。
  • 基址变址寻址
    间接引用的基础上再加上一个常数作为地址进行寻址。比如-16(%rcx),就是寄存器rcx中的地址再减去16个字节的地址处的内容。这种模式对于操作堆栈,局部变量和函数参数非常重要。
  • 复杂地址寻址
    比如,D(RA,RB,C),就是引用*RA + RB * C + D*计算后的地址处的值。RA和RB是通用目的寄存器,C可以是1、2、4或8,D是一个整数位移。这种模式一般用于查找数组中的某一项的时候,RA给出数组的首地址,RB计算数组的索引,C作为数组元素的大小,D作为相对于那一项的偏移量。

下表是不同寻址方式下加载一个64位值到%rax寄存器的示例:

寻址模式 示例
全局符号 MOVQ x, %rax
立即数 MOVQ $56, %rax
寄存器 MOVQ %rbx, %rax
间接引用 MOVQ (%rsp), %rax
基址变址寻址 MOVQ -8(%rbp), %rax
复杂地址寻址 MOVQ -16(%rbx,%rcx,8), %rax

大部分时候,目的操作数和源操作数都可以使用相同的寻址模式,但是也有例外,比如MOVQ -8(%rbx), -8(%rbx),源和目的都使用基址变址寻址方式就是不可能的。具体的就需要查看手册了。

有时候,你可能需要加载变量的地址而不是其值,这对于使用字符串或数组是非常方便的。为了这个目的,可以使用LEA指令(加载有效地址),示例如下:

寻址模式 示例
全局符号 LEAQ x, %rax
基址变址寻址 LEAQ -8(%rbp), %rax
复杂地址寻址 LEAQ -16(%rbx,%rcx,8), %rax


3.3 基本算术运算


你需要为你的编译器提供四种基本的算术指令:整数加法、减法、乘法和除法。

ADD和SUB指令有两个操作数:源操作目标和既作源又作目的的操作目标。比如:

ADDQ %rbx, %rax

将%rbx加到%rax上,把结果存入%rax。这必须要小心,以免破坏后面可能还用到的值。比如:c = a+b+b这样的语句,转换成汇编语言大概是下面这样:

MOVQ a, %rax
MOVQ b, %rbx
ADDQ %rbx, %rax
ADDQ %rbx, %rax
MOVQ %rax, c

IMUL乘法指令有点不一样,因为通常情况下,两个64位的整数会产生一个128位的整数。IMUL指令将第一个操作数乘以rax寄存器中的内容,然后把结果的低64位存入rax寄存器中,高64位存入rdx寄存器。(这里有一个隐含操作,rdx寄存器在指令中并没有提到)

比如,假设这样的表达式c = b*(b+a),将其转换成汇编语言;在这儿,a、b、c都是全局整数。

MOVQ a, %rax
MOVQ b, %rbx
ADDQ %rbx, %rax
IMULQ %rbx
MOVQ %rax, c

IDIV指令做相同的操作,除了最后的处理:它把128位整数的低64位存入rax寄存器,高64位存入rdx寄存器,然后除以指令中的第一个操作数。商存入rax寄存器,余数存入rdx寄存器。(如果想要取模指令,只要rdx寄存器的值即可。)

为了正确使用除法,必须保证两个寄存器有必要的符号位。如果被除数低64位就可以表示,但是是负数,那么高64位必须都是1,才能完成二进制补码操作。CQO指令可以实现这个特殊目的,将rax寄存器的值的符号位扩展到rdx寄存器中。

比如,一个数被5整除:

MOVQ a, %rax    # 设置被除数的低64位
CQO             # 符号位扩展到%rdx
IDIVQ $5        # %rdx:%rax除以5,结果保存到%rax

自增和自减指令INC、DEC,操作数必须是一个寄存器的值。例如,表达式a = ++b转换成汇编语句后:

MOVQ b, %rax
INCQ %rax
MOVQ %rax, b
MOVQ %rax, a

指令AND、OR和XOR,提供按位操作。按位操作意味着把操作应用到操作数的每一位,然后保存结果。

所以,AND $0101B $0110B就会产生结果$0100B。同样,NOT指令对操作数的每一位执行取反操作。比如,表达式c = (a & ˜b),可以转换成下面这样的汇编代码:

MOVQ a, %rax
MOVQ b, %rbx
NOTQ %rbx
ANDQ %rax, %rbx
MOVQ %rbx, c

这里需要注意的是,算术位操作与逻辑bool操作是不一样的。比如,如果你定义false为整数0,true为非0。在这种情况下,$0001是true,而NOT $0001B的结果也是true!要想实现逻辑bool操作,需要使用CMP比较指令。

与MOV指令一样,各种算术指令能在不同寻址模式下工作。但是,对于一个编译器项目,使用MOV指令搬运寄存器之间或者寄存器与立即数之间的值,然后仅使用寄存器操作,会更加方便。


3.4 比较和跳转


使用JMP跳转指令,我们就可以创建一个简单的无限循环,使用rax累加器从0开始计数,代码如下:

MOVQ $0, %rax

loop: INCQ %rax JMP loop

但是,我们大部分时候需要的是一个有限的循环或者if-then-else这样的语句,所以必须提供计算比较值并改变程序执行流的指令。大部分汇编语言都提供2个指令:比较和跳转。

CMP指令完成比较。比较两个不同的寄存器,然后设置EFLAGS寄存器中对应的位,记录比较的值是相等、大于还是小于。使用带有条件跳转的指令自动检查EFLAGS寄存器并跳转到正确的位置。

指令 意义
JE 如果相等跳转
JNE 如果不相等跳转
JL 小于跳转
JLE 小于等于跳转
JG 大于跳转
JGE 大于等于跳转

下面是使用%rax寄存器计算0到5累加值的示例:

MOVQ $0, %rax

loop: INCQ %rax CMPQ $5, %rax JLE loop

下面是一个条件赋值语句,如果全局变量x大于0,则全局变量y=10,否则等于20:

MOVQ x, %rax
        CMPQ $0, %rax
        JLE .L1
.L0:
        MOVQ $10, $rbx
        JMP .L2
.L1:
        MOVQ $20, $rbx
.L2:
        MOVQ %rbx, y

注意,跳转指令要求编译器定义标签。这些标签在汇编文件内容必须是唯一且私有的,对文件外是不可见的,除非使用.global伪指令。标签像.L0.L1等是由编译器根据需要生成的。


3.5 栈


栈是记录函数调用过程和局部变量的一种数据结构,也可以说,如果没有栈,C语言的函数是无法工作的。%rsp寄存器称为栈指针寄存器,永远指向栈顶元素(栈的增长方向是向下的)。

为了把%rax寄存器的内容压入栈中,我们必须把%rsp寄存器减去8(%rax寄存器的大小),然后再把%rax寄存器内容写入到%rsp寄存器指向的地址处:

SUBQ $8, %rsp
MOVQ %rax, (%rsp)

从栈中弹出数据,正好相反:

MOVQ (%rsp), %rax
ADDQ $8, %rsp

如果仅仅是抛弃栈中最近的值,可以只移动栈指针正确的字节数即可:

ADDQ $8, %rsp

当然了,压栈和出栈是常用的操作,所以有专门的指令:

PUSHQ %rax
POPQ %rax

需要注意的是,64位系统中,PUSH和POP指令被限制只能使用64位值,所以,如果需要压栈、出栈比这小的数必须使用MOV和ADD实现。

3.6 函数调用

先介绍一个简单的栈调用习惯:参数按照相反的顺序被压入栈,然后使用CALL调用函数。被调用函数使用栈上的参数,完成函数的功能,然后返回结果到eax寄存器中。调用者删除栈上的参数。

但是,64位代码为了尽可能多的利用X86-64架构中的寄存器,使用了新的调用习惯。称之为System V ABI,详细的细节可以参考ABI接口规范文档。这儿,我们总结如下:

  1. 前6个参数(包括指针和其它可以存储为整形的类型)依次保存在寄存器%rdi%rsi%rdx%rcx%r8%r9
  2. 前8个浮点型参数依次存储在寄存器%xmm0-%xmm7。
  3. 超过这些寄存器个数的参数才被压栈。
  4. 如果函数接受可变数量的参数(如printf),则必须将%rax寄存器设置为浮动参数的数量。
  5. 函数的返回值存储在%rax

另外,我们也需要知道其余的寄存器是如何处理的。有一些是调用者保存,意味着函数在调用其它函数之前必须保存这些值。另外一些则由被调用者保存,也就是说,这些寄存器可能会在被调用函数中修改,所以被调用函数需要保存调用者的这些寄存器的值,然后从被调用函数返回时,恢复这些寄存器的值。保存参数和结果的寄存器根本不需要保存。下表详细地展示了这些细节:

表-System V ABI寄存器分配表

寄存器 目的 谁保存
%rax 结果 不保存
%rbx 临时 被调用者保存
%rcx 参数4 不保存
%rdx 参数3 不保存
%rsi 参数2 不保存
%rdi 参数1 不保存
%rbp 基址指针 被调用者保存
%rsp 栈指针 被调用者保存
%r8 参数5 不保存
%r9 参数6 不保存
%r10 临时 调用者保存
%r11 临时 调用者保存
%r12 临时 被调用者保存
%r13 临时 被调用者保存
%r14 临时 被调用者保存
%r15 临时 被调用者保存

为了调用函数,首先必须计算参数,并把它们放置到对应的寄存器中。然后把2个寄存器%r10%r11压栈,保存它们的值。然后发出CALL指令,它会当前的指令指针压入栈,然后跳转到被调函数的代码位置。当从函数返回时,从栈中弹出%r10%r11的内容,然后就可以利用%rax寄存器的返回结果了。

这是一个C代码示例:

int x=0;
int y=10;
int main()
{
    x = printf("value: %d\n",y);
}

翻译成汇编语言大概是:

.data
x:
        .quad 0
y:
        .quad 10
str:
        .string "value: %d\n"
.text
.global main
main:
        MOVQ $str, %rdi         # 第一个参数保存到%rdi中,是字符串类型
        MOVQ y, %rsi            # 第二个参数保存到%rsi中,是y
        MOVQ $0, %rax           # 0个浮动参数
        PUSHQ %r10              # 保存调用者保存的寄存器
        PUSHQ %r11
        CALL printf             # 调用printf
        POPQ %r11               # 恢复调用者保存的寄存器
        POPQ %r10
        MOVQ %rax, x            # 保存结果到x
        RET                     # 从main函数返回

3.7 定义叶子函数

因为函数参数保存到寄存器中,所以写一个不调用其它函数的叶子函数是非常简单的。比如,下面的代码:

square: function integer ( x: integer ) =
{
    return x*x;
}

可以简化为:

.global square
square:
        MOVQ %rdi, %rax         # 拷贝第一个参数到%rax
        IMULQ %rax              # 自己相乘
                                # 结果保存到%rax
        RET                     # 返回到调用函数中

不幸的是,这对于还要调用其它函数的函数是不可行的,因为我们没有为其建立正确的栈。所以,需要一个复杂方法实现通用函数。

相关文章
|
4天前
|
Linux
Linux(1)arm64根目录扩容
Linux(1)arm64根目录扩容
8 0
|
16天前
|
Linux C语言
Linux内核队列queue.h
Linux内核队列queue.h
|
29天前
|
Linux 数据安全/隐私保护 Windows
Linux入门指南:linux权限究竟是什么?和文件有什么关系?
Linux入门指南:linux权限究竟是什么?和文件有什么关系?
30 0
|
1月前
|
Shell Linux C语言
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
【Shell 命令集合 系统设置 】⭐Linux 卸载已加载的内核模块rmmod命令 使用指南
29 1
|
4天前
|
Ubuntu Linux 数据安全/隐私保护
Linux(7)Ubuntu20.04 arm64安装Docker
Linux(7)Ubuntu20.04 arm64安装Docker
18 0
|
9天前
|
算法 Linux 调度
深入理解Linux内核的进程调度机制
【4月更文挑战第17天】在多任务操作系统中,进程调度是核心功能之一,它决定了处理机资源的分配。本文旨在剖析Linux操作系统内核的进程调度机制,详细讨论其调度策略、调度算法及实现原理,并探讨了其对系统性能的影响。通过分析CFS(完全公平调度器)和实时调度策略,揭示了Linux如何在保证响应速度与公平性之间取得平衡。文章还将评估最新的调度技术趋势,如容器化和云计算环境下的调度优化。
|
14天前
|
算法 Linux 调度
深度解析:Linux内核的进程调度机制
【4月更文挑战第12天】 在多任务操作系统如Linux中,进程调度机制是系统的核心组成部分之一,它决定了处理器资源如何分配给多个竞争的进程。本文深入探讨了Linux内核中的进程调度策略和相关算法,包括其设计哲学、实现原理及对系统性能的影响。通过分析进程调度器的工作原理,我们能够理解操作系统如何平衡效率、公平性和响应性,进而优化系统表现和用户体验。
20 3
|
16天前
|
Unix 大数据 Linux
linux入门
linux入门
17 1
|
18天前
|
Linux 计算机视觉
Linux交叉编译opencv并移植ARM端
通过以上步骤,你可以在Linux上交叉编译OpenCV,并将生成的库文件和头文件移植到ARM平台上,从而在ARM上使用OpenCV。 买CN2云服务器,免备案服务器,高防服务器,就选蓝易云。百度搜索:蓝易云
36 0
|
21天前
|
负载均衡 算法 Linux
深度解析:Linux内核调度器的演变与优化策略
【4月更文挑战第5天】 在本文中,我们将深入探讨Linux操作系统的核心组成部分——内核调度器。文章将首先回顾Linux内核调度器的发展历程,从早期的简单轮转调度(Round Robin)到现代的完全公平调度器(Completely Fair Scheduler, CFS)。接着,分析当前CFS面临的挑战以及社区提出的各种优化方案,最后提出未来可能的发展趋势和研究方向。通过本文,读者将对Linux调度器的原理、实现及其优化有一个全面的认识。