目录

从汇编看 C 语言

C 语言编译成汇编语言教程

自从学完 Linux 下 64 位汇编语言后,对 C 的理解更深入了一步,本文记录了我再次看 C 时的想法。

C 语言翻译成汇编语言,只有 全局变量 和 函数 有自己的名字(也是内存地址),类型信息、局部变量都被抹去,取而代之的是反复利用的“寄存器”和精确计算好的“内存地址+偏移量”。

简单的汇编语言

从这个意义上来说,汇编代码比 C 语言简单的多,因为它规则少并且统一。用汇编语言是自由的,它所有的操作都限定在“寄存器”(有名字的盒子)和“内存”(有编号的抽屉)中。它所有的操作不过是:

  • 线性执行指令 + (有条件的)跳转,就实现了循环与选择分支功能

  • 将盒子的数据移动到抽屉,或者反过来

  • 将两个盒子的数据进行加减乘除、与、或...运算

  • 一个栈模型,数据通过指定顺序的寄存器rdi rsi rdx...传入数据、偏移值(rbp)传出数据

C 编译器实现的块作用域、静态变量、数据类型、指针等功能,带来了非常多规则和程序结构,人需要花时间去理解、总结和记忆这些规则,但省却了计算“内存地址+偏移量”这类工作。

C 提供的数据类型

变量在汇编里面是怎样的

如图,全局变量在汇编里面就是全局区.data一处地址,占用固定字节,而局部变量则变成了指令里的“立即数”、或者直接用寄存器充当了、寄存器用完时也是会使用栈区内存代替的。总之变量名肯定是没了的。

基本类型

对于基本类型,在汇编看来,只有占用字节大小的不同,并无类型信息。对于name,它是char*类型指针,指向的内容在只读区.rodata中,同时也可以得知,name 存的就是.LC0处的地址。

数组

再看数组,其实数组名就是数组首地址,数组元素在全局数据区.data

指针可以修改,而数组名不可修改的原因就是:指针name自身有地址,并且存储的数据是别的数据的地址,而数组名处地址存的就是自己的元素。

从图里,也可以看出使用 指针name 和 数组words 表示字符串的区别。

零长度数组

这种数组实际上不占用内存,数组名只代表一个起始地址(即该结构体的末尾地址),所以经常用于数据报文的头格式,因为末尾地址即是数据报内容的起始地址。

英文字符 与 中文字符

计算机在存储字符时,不是存储的实际字符,而是存储该字符在“字符集”中的编号(编码值)。

窄字符类型:

  • char类型的窄字符,使用使用 ASCII 编码

  • char类型的窄字符,微软编译器使用本地编码;GCC、LLVM/Clang 使用和源文件编码相同的编码,上图中可以看到汉字部分都转化成了源文件的UTF-8编码值

宽字符类型:

C 语言规定,对于汉语、日语、韩语等 ASCII 编码之外的单个字符,也就是专门的字符类型,要使用宽字符的编码方式。常见的宽字符编码有 UTF-16UTF-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,但这个类型只在编译器是有用的,变成汇编代码后,这个类型就消失了,只保留了使用这个类型定义的实体 rb,而这个两个实体在汇编看来和其他数据类型(比如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 的实现原理

下图是一个使用 setjmplongjmp 的例子,第一次调用setjmp()时,返回0。然后一直到F2()中执行到longjmp()处,直接跳回main函数setjmp处继续执行,并且setjmp此时的返回就是longjmp设置的 1

这种机制的实现原理其实很简单,下面简单说明下:

  • 首先,CPU 执行指令只看CS:IP的值,它指向哪儿,就执行那儿的指令

  • C 从执行main函数开始,就是一套基于栈机制的指令控制模型,每个函数调用call都会创建一个栈帧(此时的环境包括了栈顶位置rbp、函数的参数、局部变量、返回值)等,每次返回ret都会销毁这些变量。

所以,实现长跳转的原理就是:在setjmp()时,将此时的栈帧的所有信息都保存在全局区的g_buf内存块中,然后等到执行到longjmp()时,再将这个g_buf内存块的值一一恢复到对应的寄存器中,然后恢复g_bufrip的原始值,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的,整个功能实现的调用流程应该很清楚了。