从C到汇编
C 语言翻译成汇编语言,只有 全局变量 和 函数 有自己的名字(也是内存地址),类型信息、局部变量都被抹去,取而代之的是反复利用的“寄存器”和精确计算好的“内存地址+偏移量”。
简单的汇编语言
从这个意义上来说,汇编代码比 C 语言简单的多,因为它规则少并且统一。用汇编语言是自由的,它所有的操作都限定在“寄存器”(有名字的盒子)和“内存”(有编号的抽屉)中。它所有的操作不过是:
线性执行指令 + (有条件的)跳转,就实现了循环与选择分支功能
将盒子的数据移动到抽屉,或者反过来
将两个盒子的数据进行加减乘除、与、或...运算
一个栈模型,数据通过指定顺序的寄存器
rdi
rsi
rdx
...传入数据、偏移值(rbp)
传出数据
编译器实现的块作用域、静态变量、数据类型、指针等功能,带来了非常多规则和程序结构,人需要花时间去理解、总结和记忆这些规则,但省却了计算“内存地址+偏移量”这类工作。
C 提供的数据类型
变量在汇编里面是怎样的
如图,全局变量在汇编里面就是全局区.data
一处地址,占用固定字节,而局部变量则变成了指令里的“立即数”、或者直接用寄存器充当了、寄存器用完时也是会使用栈区内存代替的。总之变量名肯定是没了的。
基本类型
对于基本类型,在汇编看来,只有占用字节大小的不同,并无类型信息。对于name
,它是char*
类型指针,指向的内容在只读区.rodata
中,同时也可以得知,name 存的就是.LC0
处的地址。
数组
再看数组,其实数组名就是数组首地址,数组元素在全局数据区.data
。
指针可以修改,而数组名不可修改的原因就是:指针name
自身有地址,并且存储的数据是别的数据的地址,而数组名处地址存的就是自己的元素。
从图里,也可以看出使用 指针name
和 数组words
表示字符串的区别。
零长度数组
这种数组实际上不占用内存,数组名只代表一个起始地址(即该结构体的末尾地址),所以经常用于数据报文的头格式,因为末尾地址即是数据报内容的起始地址。
英文字符 与 中文字符
计算机在存储字符时,不是存储的实际字符,而是存储该字符在“字符集”中的编号(编码值)。
窄字符类型:
char
类型的窄字符,使用使用 ASCII 编码char
类型的窄字符串,微软编译器使用本地编码;GCC、LLVM/Clang 使用和源文件编码相同的编码,上图中可以看到汉字部分都转化成了源文件的UTF-8
编码值
宽字符类型:
C 语言规定,对于汉语、日语、韩语等 ASCII
编码之外的单个字符,也就是专门的字符类型,要使用宽字符的编码方式。常见的宽字符编码有 UTF-16
和 UTF-32
(都基于 Unicode
字符集)。
但在实现时,微软编译器采用 UTF-16
,使用 2 字节存储一个字符,而 GCC、LLVM/Clang 采用UTF-32
编码,使用 4 字节存储字符。
<wchar.h>
中定义了wchar_t
类型专门用来存放宽字符,同时也提供了putwchar()
、wprintf()
、%lc
格式等用于输出宽字符。程序中,字符“立即数”需要使用L
前缀来表明是宽字符。在控制台若想看到宽字符的输出,还需进行设置,代码参考如下:
#include <stdio.h>
#include <locale.h>
wchar_t name[] = L"彦神1995";
int main(){
setlocale( LC_ALL, "zh_CN" );
wprintf( "%lc", name);
return 0;
}
枚举
我们定义了一种枚举类型enum color
,但这个类型只在编译器是有用的,变成汇编代码后,这个类型就消失了,只保留了使用这个类型定义的实体 r
和 b
,而这个两个实体在汇编看来和其他数据类型(比如int
)定义的实体 没有任何区别。
结构体
Union
如图,union
中所有的数据成员共用一个空间,同一个时间只能储存其中一个数据成员,所有的数据成员具有相同的起始数据地址,未初始化的数据存于 .bss
区,char[30]
到了汇编里成了32
是为了内存对齐。
位域
内存对齐
内存对齐是编译器的行为,一般来说,编译器会设置 CPU 位数的对齐系数,比如下图是 64 位机器,对齐系数是 8Byte
,结构体Align_Y
的内存分布就如汇编代码所看到的:
但是我们也可以人为指示编译器,使用指定的对齐系数进行对齐,C 代码如下:
#pragma pack(4) // 可设置 1, 2, 4, 8
下图便是同样的代码,依次设置1, 2, 4
后生成的汇编代码,可以对比下结构体的内存分布:
我们为神马要编译器进行内存对齐操作?
平台原因: 不是所有的硬件平台都能访问任意地址上的任意数据,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
性能原因: 某些 cpu 把内存当成是一块一块的,块的大小可以是 2,4,8,16 个字节,因此 CPU 在读取内存的时候是一块一块进行读取的,块的大小称为内存读取粒度
指针
指针就是存储数据的内存首地址,编译器根据指针类型产生处理的汇编代码,在汇编代码中就没有任何指针类型信息了,有的只是地址和偏移值。
传递一个指针到函数中,其实就是把一个内存地址给了函数,函数中通过括号操作寄存器,比如movl %edx,(%rsi)
就是把edx
中的值传送到rsi
中所存地址处的值。
sizeof
是编译器实现的一种计算,与汇编代码无关。编译器认定sizeof(arr)
在函数外是计算数组arr
的元素个数,而在arr
用作指针传入到函数内使用时,再使用sizeof(arr)
计算时,就成了arr
指针的字节数。
如上图,arr[2]
等价于 *( arr + 2 )
,编译器翻译为 *( arr地址 + 该指针类型字节数 x 2)
。3[doses]
编译后的结果与doses[3]
是一样的,实际上有:doses[3] == *(doses + 3) == *(3 + doses) == 3[doses]
。
编译器规定,由于[]
的优先级高于*
,pi
是包含 2 个int*
指针的数组。而pz
是一个指针,[2]
指示该指针指向内存区域的大小为2 x int
个字节。经过编译器后,这些类型信息都会被抹去,因此在汇编代码里的表示非常简单:地址 + 偏移量。
函数指针
可以看到函数指针也就是普通的地址,在被调函数中,通过*%rcx
的方式使用这个地址。
静态变量
从编译结果看,函数内部的静态变量在汇编代码中存放在.data
区,函数返回后也不会消失,函数每次调用都使用的是这个地址。在汇编语言看来,静态变量与全局变量没有差别,可以推知静态变量是编译器实现的功能。
变量同名遮蔽
C 语言翻译成汇编语言,只有 全局变量 和 函数 有自己的名字(也是内存地址),类型信息、局部变量都被抹去,取而代之的是反复利用的寄存器
和精确计算好的内存地址+偏移量
。
从这个意义上来说,汇编代码比 C 语言简单的多,因为它规则 少 并且 统一,用汇编语言是自由的。C 编译器实现的块作用域、静态变量、数据类型、指针等功能,带来了非常多规则和程序结构,人需要花时间去理解、总结和记忆这些规则,但省却了计算内存地址+偏移量
这类工作。
setjmp 和 longjmp 的实现原理
下图是一个使用 setjmp
和 longjmp
的例子,第一次调用setjmp()
时,返回0
。然后一直到F2()
中执行到longjmp()
处,直接跳回main
函数setjmp
处继续执行,并且setjmp
此时的返回就是longjmp
设置的 1
。
这种机制的实现原理其实很简单,下面简单说明下:
首先,CPU 执行指令只看
CS:IP
的值,它指向哪儿,就执行那儿的指令C 从执行
main
函数开始,就是一套基于栈机制的指令控制模型,每个函数调用call
都会创建一个栈帧(此时的环境包括了栈顶位置rbp
、函数的参数、局部变量、返回值)等,每次返回ret
都会销毁这些变量。
所以,实现长跳转的原理就是:在setjmp()
时,将此时的栈帧的所有信息都保存在全局区的g_buf
内存块中,然后等到执行到longjmp()
时,再将这个g_buf
内存块的值一一恢复到对应的寄存器中,然后恢复g_buf
中rip
的原始值,CPU 就下一条指令自然就跳回到setjmp()
处了。
PS:这种实现机制,要求设置setjmp()
的函数永远不能return
,因为如果它返回了,那么这个帧栈就没了,g_buf
中存的当时的内存地址,可能已经被别的函数func
在使用,此时如果longjmp()
了,那么将会直接改变func
的栈帧,这种错误是不可预测的。
下面我们通过反编译来验证下:
可以看到,将rbx
等寄存器的值保存在rdi
处,而rdi
的地址就是g_buf
内存地址。
可以看到调用longjmp()
时,恢复各种寄存器的操作,特别注意下0x38(%rdi)
这个位置,setjmp
在这个位置存放了最重要的跳回地址mov (%esp),%rax
,而longjmp
正是从这个位置取到跳回setjmp
的内存地址,然后最后jmpq *%rdx
的,整个功能实现的调用流程应该很清楚了。