C语言是怎么变成汇编的

引言:定义一个函数,什么都不写什么都不做的时候,编译器和链接器就已经自动生成了一个框架

如果不想要它自动生成的这个,就可以使用裸函数

  1. 什么是裸函数

    1
    2
    void __declspec(naked) Function()
    {...}

    裸函数调用时与普通函数无异,都是生成一个 call 指令,也就是会先把下一条指令地址压入堆栈,将EIP的值改为 call 后面跟的地址,然后再通过一个 jmp 中转跳到 jmp 后的地址开始执行。

    但是由于是裸函数什么都没做,跳转到函数执行空间之后会发现里面全部都是 int 3,程序运行到这里没有返回没法结束,就会出错,解决方式也很简单,加个 retn 就可以了

    1
    2
    3
    4
    5
    6
    7
    void __declspec(naked) Function()
    {
    __asm
    {
    ret
    }
    }

    自定义函数需要注意的三点:参数、局部变量、返回值

    • 函数的参数,在 call 之前就存到堆栈中了

    • 传参根据函数使用的调用约定来决定参数入栈的顺序

    • 函数的局部变量是存在缓冲区中的,所以要先提升堆栈及填充后才能存放局部变量

    • 函数的返回值,是存在eax寄存器中或者内存中的

    • 一定不能少平衡堆栈

  2. 无参数无返回值的函数框架

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    void __declspec(naked) Function()
    {
    __asm
    {
    //保留调用前的栈底
    push ebp
    //提升堆栈
    mov ebp, esp
    sub esp, 0x40
    //保留现场
    push ebx
    push esi
    push edi
    //开始填充缓冲区
    mov eax, 0xCCCCCCCC
    mov ecx, 0x10
    lea edi, dword ptr ds:[ebp-0x40]
    rep stosd

    //恢复现场
    pop edi
    pop esi
    pop ebx
    //降低堆栈
    mov esp, ebp
    pop ebp

    ret
    }
    }
  3. 有参数有返回值的函数框架

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    int __declspec(naked) Function(int x, int y)
    {
    __asm
    {
    //保留调用前的栈底
    push ebp
    //提升堆栈
    mov ebp, esp
    sub esp, 0x40
    //保留现场
    push ebx
    push esi
    push edi
    //开始填充缓冲区
    mov eax, 0xCCCCCCCC
    mov ecx, 0x10
    lea edi, dword ptr ds:[ebp-0x40]
    rep stosd

    //函数的核心功能
    mov eax, dword ptr ds:[ebp+8]
    add eax, dword ptr ds:[ebp+0xC]

    //恢复现场
    pop edi
    pop esi
    pop ebx
    //降低堆栈
    mov esp, ebp
    pop ebp

    ret
    }
    }
  4. 带局部变量的函数框架

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    int __declspec(naked) Function(int x, int y)
    {
    __asm
    {
    //保留调用前的栈底
    push ebp
    //提升堆栈
    mov ebp, esp
    sub esp, 0x40
    //保留现场
    push ebx
    push esi
    push edi
    //开始填充缓冲区
    mov eax, 0xCCCCCCCC
    mov ecx, 0x10
    lea edi, dword ptr ds:[ebp-0x40]
    rep stosd
    //局部变量
    mov dword ptr ds:[ebp-4], 2
    mov dword ptr ds:[ebp-8], 3

    //函数的核心功能
    mov eax, dword ptr ds:[ebp+8]
    add eax, dword ptr ds:[ebp+0xC]

    //恢复现场
    pop edi
    pop esi
    pop ebx
    //降低堆栈
    mov esp, ebp
    pop ebp

    ret
    }
    }

函数的调用约定

调用约定规定了参数的传递顺序

常见的几种调用约定:

调用约定 参数压栈顺序 平衡堆栈
__cdecl 从右至左入栈 调用者清理栈
__stdcall 从右至左入栈 自身清理堆栈
__fastcall ECX/EDX 传送前两个参数;剩下参数:从右至左入栈 自身清理堆栈
  1. __cdecl调用约定

    C/C++ 默认使用,使用堆栈(内存)传递参数,顺序为从右往前 push;在 call 的外面平衡堆栈(谁调用谁平衡),call 指令后跟一条add esp,x,又称为外平栈

  2. __stdcall调用约定

    win32 的 API 函数默认使用,传参依旧从右往前 push,但是堆栈平衡是在调用函数内的(内平栈)。具体方法为 retn 后面跟一个立即数,表示修改 eip 的值为返回地址并且将 esp + 立即数,来做到平衡堆栈。

  3. __fastcall调用约定

    传递参数使用寄存器,特点就是速度快(远远比内存快)。但是参数传递只会使用两个寄存器(edx,ecx),参数多了依旧会使用内存传递。如果是只使用寄存器来传递参数的话,就不需要平衡堆栈了。

    两参数的情况:

    两个以上的参数:

    这里需要插播一句,可以看到在实现函数的计算功能之前,有两条 mov 指令

    1
    2
    mov dword ptr [ebp-8], edx
    mov dword ptr [ebp-4], ecx

    这并不是定义了两个局部变量,而是编译器自己把 edx,ecx 寄存器中的两个数存到了内存中。因为在函数的运行过程中,edx 和 ecx 很容易被覆盖,为了不丢失这两个参数,就先把他们存到内存中备份了

  4. 如何判断一个函数的传参个数

    • 不可以简单根据 add 和 retn 后的数来判断,因为还有可能用了寄存器
    • 也不可以简单根据 call 前 push 和 mov 了几个来判断,因为现在 push 的可能是下一个函数才会用到的

    正确的方法为:结合起来!更稳妥一些就是老老实实看指令。

程序的入口

main 是语法规定的用户入口,并不是真正的程序入口。应用程序入口通常是启动函数

  1. mainCRTStartup()

    mainCRTStartup() 就是程序的入口,最好不要轻易修改,因为这个函数做了很多初始化工作,如果修改了这个入口后面的程序会出现很多问题

    具体做了哪些初始化工作呢:

    1
    2
    3
    4
    5
    6
    7
    GetVersion()  //获得当前操作系统的版本
    _heap_init() //初始化堆的空间大小
    GetCommandLineA() //获取命令行的参数
    _crtGetEnvironmentStringsA() //获取环境变量
    _setargv()
    _setenvp()
    _cinit()

    这些初始化工作也可以被用于 main 函数的识别。看到这些函数,就在后面找,看到一个有三个参数的,那个就是了

  2. 其他入口函数

    • mainCRTStartup 和 wmainCRTStartup 是控制台环境下多字节编码和 Unicode 编码的启动函数
    • 而 WinMainCRTStartup 和 wWinMainCRTStartup 是 windows 环境下多字节编码和 Unicode 编码的启动函数

数据类型与数据存储

  1. C 语言中的数据类型

  2. 学习数据类型的三个要素

    • 存储数据的宽度:能存多大
    • 存储数据的格式:里面到底存啥
    • 作用范围(作用域):哪里可以用、哪里不能用
  3. 整数类型:char short int long

    数据类型 位数(BIT) 字节数 汇编对应类型
    char 8 BIT 1 字节 BYTE
    short 16 BIT 2 字节 WORD
    int 32 BIT 4 字节 DWORD
    long 32 BIT 4 字节 DWORD

    举例:

    1
    2
    3
    4
    5
    void Plus() {
    char i = 0xFF; //byte
    short x = 0xFF; //word
    int y = 0xFF; //dword
    }

    如果数据超出了数据类型的表示范围,那就只存它那个大小的内容:

    整数类型分为有符号(signed)和无符号(unsigned)两种:

    1
    2
    3
    4
    5
    void Plus() {
    //默认就是有符号
    char i = 0xFF;
    unsigned char k = 0xFF;
    }

    虽然内存中是一样的,但是打印出来就不一样了,有符号是 -1,无符号是 255,看你怎么用

    是有/无符号数这件事,在做类型转换、比较大小和数学运算的时候一定要多加注意,其他时候都无所谓

  4. 浮点类型:float double

    问题就在于小数的存储,float 和 double 在存储方式上都是遵从IEEE的规范的,任何一个浮点数都是由三部分组成的

    float 的存储方式如下:

    double 的存储方式如下:

    将一个 float 型转化为内存存储格式的步骤为:

    1. 先将这个实数的绝对值化为二进制格式:整数和小数部分分开转换,小数部分可能会有乘不尽的情况,因此需要一个精确位数来约束

    2. 将这个二进制格式实数的小数点左移或右移 n 位,直到小数点移动到第一个有效数字的右边

    3. 从小数点右边第一位开始数出二十三位数字放入第 22 到第 0 位

    4. 如果实数是正的,则在第 31 位放入“0”,否则放入“1”

    5. 如果 n 是左移得到的,说明指数是正的,第 30 位放入“1”。如果 n 是右移得到的或 n=0,则第 30 位放入“0”

    6. 如果 n 是左移得到的,则将 n 减去 1 后化为二进制,并在左边加“0”补足七位,放入第 29 到第 23 位

      如果 n 是右移得到的或 n=0,则将 n 化为二进制后在左边加“0”补足七位,再各位求反,再放入第 29 到第23 位

    举例说明:

  5. 英文字符存储

    • ASCII 码使用指定的 7 位或 8 位二进制数组合来表示 128 或 256 种可能的字符
      • 标准 ASCII 码使用 7 位二进制数来表示所有的大写和小写字母,数字 0 到 9、标点符号,以及在美式英语中使用的特殊控制字符
      • 扩展 ASCII 码允许将每个字符的第 8 位用于确定附加的 128 个特殊符号字符、外来语字母和图形符号。

    ASCII 码表这里就不做展示了,需要的时候去搜就行了

  6. 中文字符存储

    计算机发明之处及后面很长一段时间,只用应用于美国及西方一些发达国家,ASCII 能够很好满足用户的需求。但是当天朝也有了计算机之后,为了显示中文,必须设计一套编码规则用于将汉字转换为计算机可以接受的数字系统的数。天朝专家把那些 127 号之后的奇异符号们(即EASCII)取消掉,规定:一个小于 127 的字符的意义与原来相同,但两个大于 127 的字符连在一起时,就表示一个汉字,前面的一个字节(他称之为高字节)从 0xA1 用到 0xF7,后面一个字节(低字节)从 0xA1 到 0xFE,这样我们就可以组合出大约 7000 多个简体汉字了。在这些编码里,还把数学符号、罗马希腊的字母、日文的假名们都编进去了,连在 ASCII 里本来就有的数字、标点、字母都统统重新编了两个字节长的编码,这就是常说的"全角"字符,而原来在 127 号以下的那些就叫"半角"字符了。上述编码规则就是 GB2312 或 GB2312-80。

    中文字符存储:

    1
    2
    char* x = "啊";
    char* y = "北";

课后作业

  1. __declspec(naked) 裸函数实现下面的功能

    1
    2
    3
    4
    5
    6
    int Plus(int x, int y, int z) {
    int a = 2;
    int b = 3;
    int c = 4;
    return x+y+z+a+b+c;
    }

    裸函数如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    int __declspec(naked) Plus(int x, int y, int z) {
    __asm{
    //保留调用前的栈底
    push ebp
    //提升堆栈
    mov ebp, esp
    sub esp, 0x40
    //保留现场
    push ebx
    push esi
    push edi
    //开始填充缓冲区
    mov eax, 0xCCCCCCCC
    mov ecx, 0x10
    lea edi, dword ptr ds:[ebp-0x40]
    rep stosd

    //函数的核心功能
    mov dword ptr ds:[ebp-0x4], 0x2
    mov dword ptr ds:[ebp-0x8], 0x3
    mov dword ptr ds:[ebp-0xC], 0x4

    mov eax, dword ptr ds:[ebp+8]
    add eax, dword ptr ds:[ebp+0xC]
    add eax, dword ptr ds:[ebp+0x10]
    add eax, dword ptr ds:[ebp-0x4]
    add eax, dword ptr ds:[ebp-0x8]
    add eax, dword ptr ds:[ebp-0xC]

    //恢复现场
    pop edi
    pop esi
    pop ebx
    //降低堆栈
    mov esp, ebp
    pop ebp

    ret
    }
    }
  2. 将 float 类型的 12.5 转换成 16 进制

  3. 将 CallingConvention.exe 逆向成 C 语言

    先找到 main,往下找找,看到一个 push 了三个参数的 call,前面也有 GetVersion、GetCommandLineA(没截上)啥的,这个应该就是 main 了

    进去 call 之后,函数全貌如下:

    可以看到有一个 00401019,传了五个参数,两个通过寄存器,三个通过 push 进内存传递,应该是个 __fastcall,传了 1,3,4,6,7 这五个参数,进去分析一下这个函数

    一共有三个 call,后面两个地址是一样的,是同一个函数,先看第一个

    这个函数没有在外面平衡堆栈,使用内存传递了三个参数,推测使用的是__stdcall,函数内的逻辑也很简单,就是把传入的三个参数加起来了,返回三个数的和。传递进去的三个参数就是 1,3,4(不清楚的话可以画一下堆栈图,堆栈图真是个好东西),运算的结果通过外面的mov dword ptr ss:[ebp-0xC], eax存到局部变量里,接下来就是那个蓝色框的函数了,它长这样:

    这个是在外面平衡堆栈的,传参也是通过内存,所以是__cdecl。功能也是很简单的加法运算,传递进去的参数为1、3,返回 1+3 的值并通过函数外的mov dword ptr ss:[ebp-0x10], eax存到一个新的局部变量里,下一个蓝色的框也是一样,但是传进去的参数是前面两次得到的局部变量([ebp-0xC][ebp-0x10]),求和返回。这个大函数就解决了

    回到 main 里面,刚才那个函数返回值存到了局部变量里,下面还有一个 call,但这就是一个 printf,就不进去看了

    所以整理一下,还原后的 c 如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    int __stdcall Plus1(int x, int y, int z) {
    return x + y + z;
    }

    int __cdecl Plus2(int x, int y) {
    return x + y;
    }

    int __fastcall Plus(int a, int b, int c, int d, int e) {
    int f;
    int g;

    f = Plus1(a, b, c); //1+3+4
    g = Plus2(a, b); //1+3

    return Plus2(f, g); //8+4
    }

    int main(int argc, char* argv[]) {
    int ans;

    ans = Plus(1, 3, 4, 6, 7);
    printf("%d", ans);

    return 0;
    }