0. 起因
上周末下着大雨,自己在家吃着火锅听着歌,听到周杰伦的《七里香》,里面有句歌词:
雨下整夜,我的爱溢出就像雨水
听到“溢出”这个词我脑子里就想到的栈溢出,我就将歌词篡改成“雨下整天,我的占溢出就像雨水”发了朋友圈,并配了下图1。
图中包含的是一个简单C的Hello World
程序:
#include <stdio.h>
void main() {
char *str = "hello world.";
*str = 'A';
}
但编译后执行遇到了段错误。
原本这张图是我网上随便档下来的,当时也没太在意,但却在朋友圈引发了讨论。焦点主要存在于:1)这是不是栈溢出;2)这么简单的代码为什么会引发段错误。
讨论是不是栈溢出感觉没什么意义,因为本来就是随便配图。所以令人好奇的是为什么会出现段错误。通过先验知识,我们知道段错误(segmentation fault)一般是因为程序非法访问了某一段地址。代码中hello world
是个字符串,在Java等语言中字符串是不可变对象,因此我也猜测C中的字符串是不是也有类似性质,即字符串是只读的。因此我的观点是操作系统将字符串所在的内存区域标记成只读,现在程序进行写操作,显然它没有权限,因此非法。
在我想着怎么去验证我的想法的时候,有前辈直接通过导出的汇编代码看到了真相。
.file "test.c"
.section .rodata
.LC0:
.string "hello world."
.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
movq $.LC0, -8(%rbp)
movq -8(%rbp), %rax
movb $65, (%rax)
nop
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.12) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
从汇编代码中看到,gcc通过.section
这个汇编指令将hello world.
这个字符串放到一个称之为.rodata
的数据段。.section
后面可以跟上一些标志位如r,w,x
等表示只读、可写、可执行等,如果没有跟上标志位汇编器则根据名字来确定这段数据的属性,如果这个名字是汇编器无法识别的,则默认为可写。
上面的的C语言源代码生成的汇编代码中,.section
后面只跟了个.rodata
的名字并未加标志位,但是由于这些汇编代码本身就是gcc生成给汇编器as使用的,as肯定是可以识别这个名字的。从名字看,.rodata
表示read only data
,因此hello world
就被放入了一个只读数据段,进而在运行的时候写操作引发了非法访问的段错误。这么看来,虽然我们通常不会用到汇编写程序,但是如果对汇编中的一个有所了解,有时候也是很有帮助的。
1. 简介
如果描述C语言的量词是一门,那么描述汇编的量词应该是一屋子。虽然都叫汇编,但为不同架构的芯片所写的汇编代码可能区别非常大,因为每个平台的操作码、寄存器数量等可作用不尽相同,因此同一个功能的汇编代码之间的差异可能会很大,这就使得虽然统一称为汇编,但是其实汇编不是一个,而是一群。由于上述原因,GCC中的汇编器as(assembler,汇编器)其实也是由一些列的汇编器组成的,每一个汇编器对应一种汇编格式,用户通过命令行选项可以指定到底使用哪个汇编器去生成目标文件。虽然如此,毕竟汇编代码不是直接机器可识别的机器码,最终还是需要一个汇编器来翻译,有什么样的编译器就会有什么样的语言,因此汇编中有些部分还是可以抽离出来,形成一些通用的部分。
可以通用的主要有以下三个部分:
- 目标文件格式;
- 机器独立的语法;
- 大部分伪操作码(也叫汇编指令),也就是实际上不生成机器码,知识一些指导汇编器怎么工作的指令。如前面提到的
.section
就是一个汇编指令。
如果继续抽象,就成了一种类似LLVM的中间表示(IR),这就距离机器码就更远了。
关心目标文件格式的主要是连接器,这里我们只关注下语法和一些通用汇编指令。
2. 语法
2.1. 预处理
GCC as的预处理主要做三件事:
- 多余空格去除:as会将每一行上多个连续空格变成一个空格或者制表符;
- 使用空格或者换行符替换所有注释;
- 将字符常量变成数值类型。
当然,你可以使用#APP
和#NO_APP
这对标记去指定只移除文件中某部分的多余空格和注释,如下所示。如果#NO_APP
出现在一个文件的开头则as 不会对注释和空格进行处理。
some assemble code here
#APP
as will not remove the whitespace a and comments
#NO_APP
2.2. 注释
注释分两种:一种是块注释,另一种是行注释。
块注释使用/*
和/*
包起来,可跨越多行,例如/* comments here */
。
行注释根据不同的芯片系列可能各不相同,例如在AMD 29K系列上使用分号;
表示,在SPARC上使用感叹(!
)号表示。
opcode operand1 operand2 ; here is the comment for AMD 29K family
opcode operand1 operand2 ! here is the comment for SPARC
2.3. 符号
符号可以由A-Za-z0-9_.$
等字符组成,但是第一个字符不能是数字并且大小写敏感。符号的长度没有限制。除了如果在符号中使用不在前面所述的字符中的字符,则这个符号就被该字符分成了两个符号,例如helloworld
是一个符号,而hello world
表示两个符号。
2.4. 表达式
表达式一般以换行符(\n
)或者艾特符@
表示结尾,不同的架构可能不同,例如H8/300平台还可以以美元符($
)结尾。如果反斜杠(\
)后面紧跟着换行符,下一行仍旧是本表达式的一部分。
2.5. 常量
常量的表示通过一些汇编指令来表示,这些指令包括.byte, .ascii, .float
等,例如:
.byte 74, 0112, 092, 0x4A, 0X4a, 'J, '\J # All the same value.
.ascii "Ring the bell\7" # A string constant.
.octa 0x123456789abcdef0123456789ABCDEF0 # A bignum.
.float 0f-314159265358979323846264338327\
95028841971.693993751E-40 # - pi, a flonum.
2.6. 字符串
字符串使用双引号表示,并且可以包含空字符。一些特殊字符可以使用反斜杠(\
)加上一些转义字符表示,例如:
\b
:退格键;\f
:换页符;\n
:换行符;\r
:回车符;\t
:制表符;\ digit digit digit
:例如\073
,八进制数;\x hex-digits...
:例如\xabc
,十六进制数;\\
:反斜杆;\"
:双引号。
3. 段和重定位
汇编器会把汇编代码编译成目标文件,每个目标文件由多个段组成。段,简单的说,就是一段连续的地址空间,同一个段的内容会在某一方面具有相同的属性,例如,可能每个目标文件中都有一个.rodata
段,表示它里面的内容都是只读的。
GCC汇编器as所生成的目标文件至少包含三个段:text、data以及bss,其中某些段中可以没有内容。每一段中还可以分为一些小段,称为子段(subsection)。text段包含指令以及常量或者是相当于常量的数据;data段包含运行数据;bss段包含未初始化的变量或者通用存储数据。
汇编器生成的一个个目标文件是基本不能独立运行的,需要链接器来将他们链接成一个可执行文件。链接器所读取汇编器生成的一个个目标文件是一个可执行文件的一部分,这些文件都是以地址0为开始,因此链接器要将这些目标文件中的地址做更改,这样所有目标文件的段的地址都不会重叠。
方法也很简单,链接器以段文单位,为每个段滑动分配地址。例如目标文件1中的数据段占用了整个可执行文件地址的0~0xFF,那么目标文件2的数据段就可以从0x100开始,两个目标文件中数据的内容和内容顺序是不会改变的,以此类推。为段重新分配地址的过程就称为重定位(relocation)。
打个比方,目标文件就像是一个个火车车厢,车厢基本不可能独自上路,需要将他们编组成列车。可能每个车厢在在编组之前的作为都有一号、二号、三号…编组后原都是一号的作为现在有了新的定位:一号车厢一号座、二号车厢一号座…
4. 符号和标号
所谓符号(symbol)就是程序中的一些内容的名字,例如变量名、函数名等。链接器需要靠这些符号去进行链接操作,而调试器需要靠符号去进行调试。符号有三个属性,分别是名字(name)、类型(type)以及值(value)。符号的命名规则参看2.3节。
除了符号,汇编中还有一个概念,称为标号(label)。标号就是一个类似与符号的字符串,并以冒号(:
)结尾。标号表示当前的地址,并且可以用作一些指令的操作数,例如上面例子中的main:
就是一个标号。
5. 汇编指令
汇编指令又称伪操作码,他们不会被翻译成机器码,而是用于指导汇编器进行汇编工作。汇编指令总是以英文状态的句号(.
)开头,后面的字符一般是小写,例如前面已经提到过的.section
,.float
等。下面提到的汇编指令都是通用的汇编指令。
我们先看看上面hello world.
导出的汇编代码中的汇编指令,然后再看看其他的汇编指令。
5.1. Hello World中汇编指令
.file string(.app-file string)
:用于告诉汇编其接下来的代码、指令,属于所跟的字符串表示的逻辑文件(也就是逻辑上它们是属于这个文件的),字符串可以用双引号包围,也可以没有,但是如果是空字符串,必须用双引号;.text subsection
:告诉汇编其将接下来的指令放入text
段中subsection
指定的子段中,如果subsection
省略,则默认使用0号段;.global symbol, .globl symbol
:汇编器将symbol暴露给链接器;.type int
:用于指定符号表中符号的类型;.section name
:将接下来的代码放入由name
指定的段中;.ascii "string"...
:定义字符串数据;.size
:用于添加调试信息,并且这些个指令只在生成COFF格式的文件有效;.ident
:用于向目标文件中添加标签;
Hello World中用到的一些汇编指令基本就以上这几个,接下来我们看一看其他汇编指令。
5.2. 数据指令
.align abs-expr, abs-expr, abs-expr
:将当前位置的存储区域填充到一个特定的大小,指令后面跟的三个表达式分别表示填充的字节数、填充的内容、需要跳过的最大字节数。后面两个表达式可以省略。.asciz "string"...
:指定字符串数据,与.ascii
不同的是.asciz
会在字符串末尾自动填充一个空字符;.float flonums
:定义浮点型数据;.int expressions
:定义整型数据;.byte expressions
:定义字节数据;.hword expressions
:定义存放与两个字节的数据;.octa bignums
:定义十六个字节的数据,octa
的意思是8个字,因为定义一个字为两个字节,因此八个字是十六字节;.quad bignums
:定义八个字节的数据;.string "str"
:拷贝str
到目标文件中,也就是定义字符串;.word expressions
:定义一个字长度的数据,具体的字节数以及大小端格式视具体架构而定;.long expressions
:与.int
一样;.def name
:为符号name
定义调试信息,直到遇到.endef
指令为止;.desc symbol, abs-expression
:为符号定义描述;.double flonums
:定义双精度浮点数;
5.3. 行为指令
.abort
:立即结束汇编操作;.data subsection
:将接下来的指令放入指定的数据子段,如果子段没有指定,则默认放入0号子段;.eject
:强制换页;.if absolute expression
:条件性的汇编接下来的指令,和一般的语言中的if分支类似,可以有.else, .elseif
等指令与之匹配,并以.endif
表示条件汇编结束;.include "file"
:将其他汇编文件添加进来,可以通过命令行-I
选项指定搜索路径;.line line-number
:修改逻辑行号;.linkonce [type]
:指导链接器如何操作接下来的指令;.macro [macname [macargs ...]]
定义以段宏,并以.endm
结束,也可以使用.exitm
提前推出宏,定义后的宏可以与一般操作码一样使用;.rept count
:重复执行接下来的代码指定次,直到遇到.endr
指令;.set symbol, expression
:为符号赋值;.skip size , fill
:条过size
指定数量的字节后获取fill
指定数量的字节空间;.space size , fill
与.skip
一样;.stabd, .stabn, .stabs
:定义调试信息。
以上是绝大部分通用汇编指令的简介,对于特定架构相关的汇编指令以及对指令更详细的介绍,请参阅参考文档的内容。
首发于个人微信公众号TensorBoy。微信扫描上方二维码或者微信搜索TensorBoy并关注,及时获取更多最新文章!
C++ | Python | 推理引擎 | AI框架源码,有一起玩耍的么?
References
[1] https://ftp.gnu.org/old-gnu/Manuals/gas-2.9.1/html_chapter/as_toc.html