热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Golang汇编层面代码分析

这篇文档是对于Go编译器套件(6g,8g,etc.)中不常用的汇编语言的快速预览,涵盖面不是很广泛。Go的汇编语言基于Plan9的汇编,Plan9网站的页面上有详细描述。如

这篇文档是对于Go编译器套件(6g, 8g, etc.)中不常用的汇编语言的快速预览,涵盖面不是很广泛。

Go的汇编语言基于Plan 9的汇编,Plan 9网站的页面上有详细描述。如果你想编写汇编语言,你应该读这篇文档,虽然它是Plan 9相关的。这边文档总结了汇编的语法,并且描述了使用汇编语言和Go程序交互时的特殊之处。

有一点是很重要的是,Go的汇编中没有直接体现出底层的机器。有些汇编细节能直接对应到机器,但有些不是。这是因为编译器套件在常规过程中不需要汇编语言。取而代之的是,编译器产生二进制的不完整的汇编指令集,链接器会完成它。实际上,链接器做了汇编指令的选择,所以当你看到类似于MOV这样的指令,链接器的实际操作可能不是一个移动指令,也许是清除或者载入。或者可能会根据指令的名字对应到真实的机器指令。总体上,机器相关的指令操作趋向于体现出真实的机器指令,但是一些通用的概念类似于移动内存数据、调用子例程、返回等操作就更抽象了。具体的细节和架构相关,我们为这种不精确性道歉。

汇编程序是生成中间码的一种方法,未完整定义的指令集作为链接器的输入。 如果你想看到特定CPU架构下的汇编指令集,如amd64,在Go标准库的源文件中就有许多例子,在runtimemath/big包中。 或者你还可以参照下面的程序,来检查编译器的汇编输出:

$ cat x.go
package main

func main() {
    println(3)
}
$ go tool 6g -S x.go        # or: go build -gcflags -S x.go

--- prog list "main" --- 0000 (x.go:3) TEXT    main+0(SB),$8-0 0001 (x.go:3) FUNCDATA $0,gcargs·0+0(SB) 0002 (x.go:3) FUNCDATA $1,gclocals·0+0(SB) 0003 (x.go:4) MOVQ    $3,(SP) 0004 (x.go:4) PCDATA  $0,$8 0005 (x.go:4) CALL    ,runtime.printint+0(SB) 0006 (x.go:4) PCDATA  $0,$-1 0007 (x.go:4) PCDATA  $0,$0 0008 (x.go:4) CALL    ,runtime.printnl+0(SB) 0009 (x.go:4) PCDATA  $0,$-1 0010 (x.go:5) RET     ,
...

FUNCDATAPCDATA指令用来包含一些垃圾收集器需要的信息。它们由编译器产生。

符号

有些符号,例如PCR0SP,是预定义的并且是对一个寄存器的引用。 另外还有两种预定义的符号,SB(static base)和FP(frame pointer)。 所有用户定义的符号,除了标签跳转之外,都是对伪寄存器的offsets操作。

SB伪寄存器可以想象成内存的地址,所以符号foo(SB)是一个由foo这个名字代表的内存地址。这种形式一般用来命名全局函数和数据。给名字增加一个<>符号,就像foo<>(SB),会让这个名字只有在当前文件可见,就像在C文件中预定义的static

FP伪寄存器是一个虚拟的帧指针,用来指向函数的参数。编译器维护了一个虚拟的栈指针,使用对伪寄存器的offsets操作的形式,指向栈上的函数参数。 于是,0(FP)就是第一个参数,8(FP)就是第二个(64位机器),以此类推。 当用这种方式引用函数参数时,可以很方便的在符号前面加上一个名称,就像first_arg+0(FP)second_arg+8(FP)。有些汇编程序强制使用这种约定,禁止单一的0(FP)8(FP)。在使用Go标准定义的汇编函数中,go vet会检查参数的名字和它们的匹配范围。 在32位系统上,一个64位值的高32和低32位表示为增加_lo_hi这个两个后缀到一个名称,就像arg_lo+0(FP)或者arg_hi+4(FP)。如果一个Go原型函数没有命名它的结果,期待的名字将会被返回。

SP伪寄存器是一个虚拟的栈指针,用来指向栈帧本地的变量为函数调用准备参数。它指向本地栈帧的顶部,所以一个对栈帧的引用必须是一个负值且范围在[-framesize:0]之间,例如: x-8(SP)y-4(SP),以此类推。在CPU架构中,存在一个真实的寄存器SP,虚拟的栈寄存器和真实的SP寄存器的区别在于名字的前缀上。就是说,x-8(SP)-8(SP)是不同的内存地址:前者是引用伪栈指针寄存器,但后者是硬件中真实存在的SP寄存器。

指令、寄存器和汇编指令始终使用大写字母表示,提醒你汇编语言编程是非常令人担忧的。(例外:在ARM平台下,代表当前goroutine的g寄存器被重新命名。)

在Go对象文件和二进制文件中,符号的完整名字是包的路径加上一个句点:fmt.Printfmath/rand.Int。但是汇编器会把句点和斜杠当做标点符号来对待,这些字符不能当做符号的标识符。取而代之的是,允许在汇编程序中使用中点字符(Unicode字符00B7)和除法斜杠(原文中是division slash,Unicode字符2215,区别于forward slash)当做标识符并且把它们重写成纯句点和斜杠。 在汇编语言的源文件中,上面的符号写成fmt·Printfmath∕rand.Int。 通过在编译时使用-S标志看到的汇编代码列表中直接显示了句点和斜杠,而不是在汇编程序中需要的Unicode替代字符(指上面的两个特殊Unicode字符)。

大部分手写的汇编文件中,不要在符号名中包含完整的包路径,因为链接器会在任何以句点开头的名字前面插入当前对象文件的路径:在math/rand包的汇编源文件中,rand包的Int函数被当做了·Int来引用。这种便捷性避免了需要在自身的源代码中硬编码导入路径,可以让代码从一个地方移动到另一个地方时变得更容易。

指令

汇编程序中使用多种指令绑定文本和数据到符号名。举个例子,下面有一个简单但是完整的函数定义。TEXT指令声明了符号runtime·profileloop,指令紧接在类似于函数的主体中。TEXT块的最后必须是某种形式的跳转,通常是一个RET(伪)指令。(如果没有,链接器会追加一个跳转到块自身的指令,TEXT块中没有fallthrough) 符号的后面,参数是标志栈帧的大小,是一个常量(但是看下面的代码):

TEXT runtime·profileloop(SB),NOSPLIT,$8
    MOVQ    $runtime·profileloop1(SB), CX
    MOVQ    CX, 0(SP)
    CALL    runtime·externalthreadhandler(SB)
    RET

这个函数的栈帧大小为8字节(MOVQ CX, 0(SP)操作栈指针),没有参数

一般情况下,栈帧的大小跟在参数的大小之后,由一个减法符号分隔。(它不是减号,只是特殊的语法) 栈帧大小是$24-8描述了函数有24字节的栈帧并且需要一个8字节的参数,存在于调用者的栈帧中。如果没有为TEXT指定NOSPLIT标志,必须提供参数大小。在使用Go标准定义的汇编函数中,go vet会检查参数大小是否正确。

注意符号名是使用中点来分割组件的,并且被定义为从伪寄存器SB开始的一个offsets。在Go源码的runtime包中,使用简称profileloop来调用。

全局数据符号使用初始化的一系列DATA指令来定义,并且跟在一个GLOBAL指令之后。每个DATA指令初始化一块指定的内存区域。没有明确初始化的内存区域会被置为零。标准的DATA指令形式为:

DATA    symbol+offset(SB)/width, value

这样就初始化了symbol,内存在指定的offset处,带有指定的width和给定的value。一个symbol中的DATA指令必须是逐渐增长的offsets。

GLOBAL指令将一个symbol声明为全局的。参数是可选的标志和需要声明为全局的数据的大小,并会初始化为零值,除非DATA指令中已经初始化它。GLOBAL指令必须跟在对应的DATA指令之后。

举例:

DATA divtab<>+0x00(SB)/4, $0xf4f8fcff
DATA divtab<>+0x04(SB)/4, $0xe6eaedf0
...
DATA divtab<>+0x3c(SB)/4, $0x81828384
GLOBL divtab<>(SB), RODATA, $64

GLOBL runtime·tlsoffset(SB), NOPTR, $4

声明并且初始化了divtab<>,一个只读的64位table含有4字节的整数值。 并且声明了runtime·tlsoffset,一个4字节并且明确被零值初始化的值,其中不含有指针。

指令可以含有一个或者两个参数。如果有两个参数,第一个是比特掩码的标志,可以写成数字的表达式,多个掩码之间可以相加或者做逻辑或运算,或者可以写成友好可读的形式。这些值定义在头文件textflag.h中:

  • NOPROF = 1 (TEXT项使用.) 不优化NOPROF标记的函数。这个标志已废弃。

  • DUPOK = 2 在二进制文件中允许一个符号的多个实例。链接器会选择其中之一。

  • NOSPLIT = 4 (TEXT项使用.) 不插入预先检测是否将栈空间分裂的代码。程序的栈帧中,如果调用任何其他代码都会增加栈帧的大小,必须在栈顶留出可用空间。用来保护处理栈空间分裂的代码本身。

  • RODATA = 8 (DATA和GLOBAL项使用.) 将这个数据放在只读的块中。

  • NOPTR = 16 这个数据不包含指针所以就不需要垃圾收集器来扫描。

  • WRAPPER = 32 (For TEXT items.) This is a wrapper function and should not count as disabling recover.

协调Runtime

为了使垃圾收集正确运行,runtime必须知道在全局数据和大多数栈帧中指针的位置。Go的编译器在编译Go源文件的时候生成这些信息,但是在汇编程序中必须明确定义这些信息。

带有NOPTR标志的数据符号,不包含指向runtime分配的数据的指针。 带有RODATA标志的数据符号,是在只读内存中分配的,并且被看做是明确定义的NOPTR类型的数据。总的大小小于一个指针大小的数据符号,也被看做是明确定义的NOPTR类型。不能在汇编语言中定义包含指针的符号;取而代之的是,符号必须定义在Go源文件中。汇编源文件中依然可以使用名字引用一个符号,即使这个符号没有使用DATA和GLOBAL指令定义。一个很好的通用规则是,在Go代码中定义非只读的数据,而不是在汇编程序中。

每个函数同样需要给出注解,标明在其参数、返回结果和本地栈帧上生存的指针的位置。如果汇编函数没有指针类型的结果并且没有本地栈帧,或者没有调用函数,唯一需要做的是为函数在同名的包中定义一个Go函数原型。在更复杂的情况下,需要明确的注释出。这些注释使用在头文件funcdata.h中定义的伪指令。

如果一个函数没有参数并且没有返回结果,就可以忽略指针信息。这可以通过在TEXT指令中使用参数大小$n-0指出。否则,Go原文件中的Go原型函数必须提供指针的信息,即使汇编函数不是直接被Go代码调用的。(这个原型会让go vet检查参数引用。) 在函数的开头,参数都假设是已经被初始化的,但是函数的返回结果会假设是未初始化的。如果在执行CALL指令时,结果中HOLD住一个指针,函数应该在开头就将返回结果初始化为零值,并且接着执行伪指令GO_RESULTS_INITIALIZED。这个指令记录了当前返回结果已经被初始化,并且在当栈帧转移和垃圾收集的时候扫描返回结果。非常具有代表性的是会安排汇编函数不返回指针或者不包含任何CALL指令;在Go标准库中的汇编函数都没有使用GO_RESULTS_INITIALIZED

如果一个函数没有本地栈帧,就可以忽略指针信息。这可以通过在TEXT指令中使用栈帧大小$0-n指出。如果函数没有包含CALL指令,同样可以忽略指针信息。否则,本地栈帧必须不包含指针(函数没有本地栈帧且含有CALL指令的情况下),汇编中必须通过NO_LOCAL_POINTERS来确认这种情况。因为栈的缩放使用过移动栈来实现的,栈指针可能在函数调用的时候发生改变:甚至栈数据的指针必须不得保持在本地变量。

架构相关的细节

列出某种机器的全部指令和细节是不切实际的。如果想看到某种特定机器的指令,如32位Intel X86,查看对应编辑器的顶层的头文件,这里是8l。就是说,在文件$GOROOT/src/cmd/8l/8.out.h中包含了C枚举量,叫做as,是指定架构的汇编器和链接器的机器指令的指令的写法。

enum    as
{
    AXXX,
    AAAA,
    AAAD,
    AAAM,
    AAAS,
    AADCB,
    ...

在上面的代码中每个指令以大写字母A开头,所以AADCB表示ADCB指令(和进位字节)。枚举量是按照字母顺序排序的,加上后面的附加内容(AXXX占据了第0个位置,被当做一个独立的指令)。对于在实际机器中的编码,这些指令序列什么都不需要改变。再说一遍,这是因为链接器会负责具体的细节。

在前一小节的例子中需要注意的是,数据在指令中的顺序是从左到右: MOVQ $0, CX清除CX。即使在某些架构上顺序是相反的,这种规则也是适用的。

这里有一些对于Go所指的架构的相关的细节的描述。

32位Intel 386

runtime中指向g结构体(goroutine)的指针通过MMU中其他未使用的寄存器来维护(这也是Golang中担心的)。 如果源码中包含了架构相关的头文件,那么汇编器会定义一个OS相关的宏,就像下面这样:

#include "zasm_GOOS_GOARCH.h"

在runtime内部,get_tls宏将g指针载入到它的参数寄存器中,并且g结构体中包含了m指针。使用CX寄存器来载入gm的指令序列如下:

get_tls(CX)
MOVL    g(CX), AX     // Move g into AX.
MOVL    g_m(AX), BX   // Move g->m into BX.

64位Intel 386(amd64)

访问gm指针的汇编和386相似,只不过指令中使用MOVQ,而不是MOVL:

get_tls(CX)
MOVQ    g(CX), AX     // Move g into AX.
MOVQ    g_m(AX), BX   // Move g->m into BX.

ARM

寄存器R10R11由编译器和链接器保留。

R10指向g(goroutine)结构体。在汇编源码中,这个指针必须以g来引用,R10这个名称是不被认可的。

为了让人类和编译器更容易的写汇编代码,ARM的链接器允许通用的寻址形式和像DIVMOD这样的伪操作,这可能不是使用一个单条的指令可以表现出来的。链接器使用多条指令来实现这些操作,经常使用R11来保存临时的值。在手写的汇编程序中可以使用R11寄存器,但是这样做就需要确认链接器还没有使用R11来实现函数中的其他指令。

当定义一个TEXT段,声明栈帧大小$-4会告诉链接器这个函数是一个leaf function,不需要在入口保存LR寄存器。

leaf function的解释:

Leaf function,A function that does not require a stack frame. A leaf function does not require a function table entry. It cannot call any functions, allocate space, or save any nonvolatile registers. It can leave the stack unaligned while it executes.

名称SP总是会引用在之前提到过的虚拟栈帧。而硬件中的SP寄存器使用R13


推荐阅读
  • 本文概述了JNI的原理以及常用方法。JNI提供了一种Java字节码调用C/C++的解决方案,但引用类型不能直接在Native层使用,需要进行类型转化。多维数组(包括二维数组)都是引用类型,需要使用jobjectArray类型来存取其值。此外,由于Java支持函数重载,根据函数名无法找到对应的JNI函数,因此介绍了JNI函数签名信息的解决方案。 ... [详细]
  • Java序列化对象传给PHP的方法及原理解析
    本文介绍了Java序列化对象传给PHP的方法及原理,包括Java对象传递的方式、序列化的方式、PHP中的序列化用法介绍、Java是否能反序列化PHP的数据、Java序列化的原理以及解决Java序列化中的问题。同时还解释了序列化的概念和作用,以及代码执行序列化所需要的权限。最后指出,序列化会将对象实例的所有字段都进行序列化,使得数据能够被表示为实例的序列化数据,但只有能够解释该格式的代码才能够确定数据的内容。 ... [详细]
  • [译]技术公司十年经验的职场生涯回顾
    本文是一位在技术公司工作十年的职场人士对自己职业生涯的总结回顾。她的职业规划与众不同,令人深思又有趣。其中涉及到的内容有机器学习、创新创业以及引用了女性主义者在TED演讲中的部分讲义。文章表达了对职业生涯的愿望和希望,认为人类有能力不断改善自己。 ... [详细]
  • 本文探讨了C语言中指针的应用与价值,指针在C语言中具有灵活性和可变性,通过指针可以操作系统内存和控制外部I/O端口。文章介绍了指针变量和指针的指向变量的含义和用法,以及判断变量数据类型和指向变量或成员变量的类型的方法。还讨论了指针访问数组元素和下标法数组元素的等价关系,以及指针作为函数参数可以改变主调函数变量的值的特点。此外,文章还提到了指针在动态存储分配、链表创建和相关操作中的应用,以及类成员指针与外部变量的区分方法。通过本文的阐述,读者可以更好地理解和应用C语言中的指针。 ... [详细]
  • 从零学Java(10)之方法详解,喷打野你真的没我6!
    本文介绍了从零学Java系列中的第10篇文章,详解了Java中的方法。同时讨论了打野过程中喷打野的影响,以及金色打野刀对经济的增加和线上队友经济的影响。指出喷打野会导致线上经济的消减和影响队伍的团结。 ... [详细]
  • 开发笔记:Java是如何读取和写入浏览器Cookies的
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了Java是如何读取和写入浏览器Cookies的相关的知识,希望对你有一定的参考价值。首先我 ... [详细]
  • Android系统源码分析Zygote和SystemServer启动过程详解
    本文详细解析了Android系统源码中Zygote和SystemServer的启动过程。首先介绍了系统framework层启动的内容,帮助理解四大组件的启动和管理过程。接着介绍了AMS、PMS等系统服务的作用和调用方式。然后详细分析了Zygote的启动过程,解释了Zygote在Android启动过程中的决定作用。最后通过时序图展示了整个过程。 ... [详细]
  • 本文整理了Java中com.evernote.android.job.JobRequest.getTransientExtras()方法的一些代码示例,展示了 ... [详细]
  • 在开发中,有时候一个业务上要求的原子操作不仅仅包括数据库,还可能涉及外部接口或者消息队列。此时,传统的数据库事务无法满足需求。本文介绍了Java中如何利用java.lang.Runtime.addShutdownHook方法来保证业务线程的完整性。通过添加钩子,在程序退出时触发钩子,可以执行一些操作,如循环检查某个线程的状态,直到业务线程正常退出,再结束钩子程序。例子程序展示了如何利用钩子来保证业务线程的完整性。 ... [详细]
  • Annotation的大材小用
    为什么80%的码农都做不了架构师?最近在开发一些通用的excel数据导入的功能,由于涉及到导入的模块很多,所以开发了一个比较通用的e ... [详细]
  • Java编程思想一书中第21章并发中关于线程间协作的一节中有个关于汽车打蜡与抛光的小例子(原书的704页)。这个例子主要展示的是两个线程如何通过wait ... [详细]
  • 基于分布式锁的防止重复请求解决方案
    一、前言关于重复请求,指的是我们服务端接收到很短的时间内的多个相同内容的重复请求。而这样的重复请求如果是幂等的(每次请求的结果都相同,如查 ... [详细]
  • 初识java关于JDK、JRE、JVM 了解一下 ... [详细]
  • Android源码深入理解JNI技术的概述和应用
    本文介绍了Android源码中的JNI技术,包括概述和应用。JNI是Java Native Interface的缩写,是一种技术,可以实现Java程序调用Native语言写的函数,以及Native程序调用Java层的函数。在Android平台上,JNI充当了连接Java世界和Native世界的桥梁。本文通过分析Android源码中的相关文件和位置,深入探讨了JNI技术在Android开发中的重要性和应用场景。 ... [详细]
  • 核心代码第一种声音[DllImport(Kernel32.dll)]引入命名空间usingSystem.Runtime.InteropServices;publicstat ... [详细]
author-avatar
互联网控军
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有