由浅入深学习 C 语言:Hello World【提高篇】
目录
引言
1. Hello World 程序代码
2. C 语言角度分析 Hello World 程序
2.1. 程序功能分析
2.2 指针
2.3 常量指针
2.4 指针常量
3. 反汇编角度分析 Hello World 程序
3.1 栈
3.2 函数用栈传递参数
3.3 函数调用栈
3.4 函数栈帧
3.5 相关寄存器
3.6 相关汇编指令
3.7 汇编代码分析
3.7.1 invoke_main() 函数调用了 main 函数
3.7.2 main 函数的栈帧建立和销毁过程
3.7.3 "Hello World\n" 字符串在内存中是以 assii 码的形式保存
引言
本篇是 Hello World 程序提高篇,默认读者是有 C 语言编程基础,0 基础建议先阅读这篇博文的姐妹篇之由浅入深学习 C 语言:Hello World【基础篇】-CSDN博客
1. Hello World 程序代码
#include <stdio.h>
int main(int argc, const char *argv[])
{
for (int i = 0; i < argc; i++) // 打印命令行参数
printf("argv[%d] = %s\n", i, argv[i]);
printf("Hello World\n");
return 0;
}
2. C 语言角度分析 Hello World 程序
2.1. 程序功能分析
1. #include <stdio.h> // 预处理器指令,用于把 stdio.h 文件包含进来
2. int main(int argc, const char *argv[]) // 主函数,是 C 程序的入口函数
(1)函数名前的 int 是函数的返回值
(2)argc 是函数的第一个参数,参数类型是 int
- 该参数表示的是命令行参数个数,传给这个参数的值是第二个参数 argv 数组的元素个数。
- 一个 C 程序至少有一个命令行参数,这个命令行参数是 可执行文件的绝对路径名或相对路径名
(3)argv 是函数的第二个参数,参数类型是 const char* 数组,也就是说这个参数是一个数组,数组的每一项存的数据类型是 const char* (常量字符指针),指向每一个命令行参数字符串的首地址。
比如,我们的 Hello World 程序,在 linux 平台下,生成 main.out 可执行文件。
- ./main.out // 不带参数运行程序
- argc = 1
- argv 数组有 1 个元素,这个元素存储的就是 "./main.out" 这个字符串在内存中的首地址
- ./main.out 111 222 // 带 2 个参数运行程序
- argc = 3
- argv 数组有 3 个元素,第一个元素存储的是 "./main.out" 这个字符串在内存中的首地址,第二个元素存储的是 "111" 这个字符串在内存的首地址,第三个元素存储的是 "222" 这个字符串在内存的首地址
2.2 指针
在 C 语言中,不管什么数据类型的指针,实际就是一块大小固定的内存,这块内存存的值是另一块内存单元的地址,也就是说这块内存存在的意义是为了指令另一块内存单元,所以我们把它称作指针。
- 32 位程序中,这块内存大小是 4 个字节
- 64 位程序中,这块内存大小是 8 个字节
#include <stdio.h>
int main(int argc, const char* argv[]) {
int* pi = NULL; // int* 指针
double* pd = NULL; // double* 指针
printf("pi size = %d\n", sizeof(pi)); // 32位程序,pi size = 4; 64位程序,pi size = 8
printf("pd size = %d\n", sizeof(pd)); // 32位程序,pd size = 4; 64位程序, pd size = 8
return 0;
}
2.3 常量指针
常量指针,本质是一个指针,用 const 修饰的指针是常量指针,也就是说这个指针指向另一块存储常量的内存单元,所以我们不能通过常量指针来修改它指向的另一块内存单元的值。
#include <stdio.h>
int main(int argc, const char* argv[]) {
for (int i = 0; i < argc; i++) {
// *argv[i] = '1'; // error: 常量指针,这个指针指向的内存单元的值是常量,不能被修改
printf("argv[%d] = %s\n", i, argv[i]);
}
return 0;
}
所以,main 函数的第二个参数声明为 const char* (常量字符指针) 可以防止我们在实际的开发中,不小心修改了 argv 数组里的元素指向的内存单元的值。
2.4 指针常量
指针常量,本质是一个常量,用指针类型修饰的常量是指针常量,常量就必须声明的时候初始化,初始化后整个程序运行期间都不能再修改。
#include <stdio.h>
int main(int argc, const char* argv[]) {
int a = 1;
int* const pi = &a;
int b = 2;
// pi = &b; // error: 常量不能被修改
return 0;
}
3. 反汇编角度分析 Hello World 程序
3.1 栈
在计算机科学中,栈是一种后进先出(LIFO,last in first out)的数据结构,往栈中写数据,叫做入栈(push),将栈顶数据从栈中弹出,叫做出栈(pop)。
3.2 函数用栈传递参数
函数用栈传递参数的原理是由调用者通过 push 指令将需要传递给被调用者的参数压入栈中,被调用者从栈中取得参数。
3.3 函数调用栈
C 语言,所有函数的调用,是通过栈来实现的,当函数被调用时,其返回地址、参数以及局部变量会被压入栈中。当函数返回时,这些信息会从栈中弹出,以恢复到函数被调用之前的状态。我们可以称维护所有函数调用形成的这个栈空间为函数调用栈。
特别要注意的是,一个 C 程序的栈是往下生长的,即栈底在高地址,栈顶在低地址,入栈,栈顶地址会变小,出栈,栈顶地址会变大。
3.4 函数栈帧
在 C 语言中,从一个函数的进入,到这个函数返回,形成的栈空间,我们称为函数栈帧。函数栈帧不是固定不变的,而是随着函数的功能,栈帧空间可能随时在变大或缩小。
3.5 相关寄存器
- ebp: extended base pointer,扩展基址指针寄存器,一般用于当进入一个函数时,存放该函数的栈帧基址(栈帧的栈底)。
- esp: extended stack pointer,扩展栈指针寄存器,存放函数栈的栈顶
- ebp 和 esp 配合使用,共同协作维护着正在运行的函数的栈帧
- ebx: extended base register,扩展基址寄存器,特别是在数组和字符串操作中,它可以用来存储数组或字符串的基地址。此外,在调用操作系统函数时,ebx 有时也用于传递参数。
- esi: extended source index register,扩展源索引寄存器,在字符串操作中非常有用。它通常用于存储源字符串或数据数组的起始地址,在字符串指令(如 stos 等)中自动递增,以便按顺序处理数据。
- edi: extended destination index register,扩展目的索引寄存器,与 esi 相对应,在字符串操作中用于存储目标字符串或数据数组的起始地址。与 esi 相似,edi 也在字符串指令中自动递增,以接收来自源地址的数据。
- ecx: extended count register,扩展计数寄存器,在循环操作中尤其重要。它存储了循环的迭代次数,很多指令(如以 rep 为前缀的字符串操作指令)都会递减 ecx 的值来控制循环次数。
- eax: extended accumulator register,eax 寄存器通常用作累加器,在算术和逻辑运算中扮演主要角色。它经常用于存储操作数、结果以及中间值。在函数调用中,eax 常用于存放函数的返回值。
3.6 相关汇编指令
- mov: 数据传送指令,比如:mov eax, 3 ;将 3 这个立即数传送给 eax 寄存器
- push: 往栈中压入数据,CUP操作原理:(1) esp - 存储压入栈数据占用的字节数。 (2) 修改栈顶数据为要压入栈中的数据。比如:push eax ;将 eax 寄存器的数据压入栈中
- pop: 从栈顶弹出数据, CUP操作原理:(1) 栈顶数据弹出栈 (2) esp + 从栈顶弹出的数据占用的字节数。比如:pop eax ;将栈顶数据弹出,送到 eax 寄存器中
- sub: 减法指令
- add: 加法指令
- rep: 在汇编语言中,rep 的作用是根据 ecx 的值,循环执行跟在其后的指令,直到 ecx = 0 时为止。
- cmp: 根据操作结果,设置标志寄存器相关位的值,其他相关指令就可以拿到寄存器相关位的值进行相关操作
- stos: 字符串操作指令,比如:stos dword ptr es:[edi],是将 eax 的值传送给 es:[edi] 为首地址的 4 个字节单元中
- xor: 二进制异或操作,即 1 xor 0 = 1
- call: 转到标号处执行指令
- ret: 跟 call 配合,控制 cpu 返回到调用该函数的指令的下一条指令执行
3.7 汇编代码分析
int main(int argc, const char* argv[]) {
00E517E0 push ebp ;调用者的栈帧基址入栈
00E517E1 mov ebp,esp ;构建自己的栈帧基址
00E517E3 sub esp,0C0h ;栈顶往上移 0C0h 字节
00E517E9 push ebx ;调用者的 ebx 入栈,esp = esp-4
00E517EA push esi ;调用者的 esi 入栈,esp = esp-4
00E517EB push edi ;调用者的 edi 入栈,esp = esp-4
00E517EC mov edi,ebp ;edi = ebp
00E517EE xor ecx,ecx ;等价于 mov ecx, 0; 但 xor 指令更高效
00E517F0 mov eax,0CCCCCCCCh
00E517F5 rep stos dword ptr es:[edi] ;rep 的作用是根据 cx 的值,循环执行跟在其后的指令,直到 cx = 0 时为止。
;stos 串传送指令,相当于 mov es:[di], eax
;df = 0, edi = edi + 4; df = 1, edi = edi - 4;
printf("Hello World\n");
00E517F7 push offset string "Hello World\n" (0E57B30h) ;传递给 printf 函数的参数入栈,esp = esp-4
;offset:取得字符串 "Hello World\n" 在内存的首地址
00E517FC call _printf (0E510CDh) ;调用 printf 函数
00E51801 add esp,4 ;esp = esp+4,即销毁传递给 printf 函数的参数 "Hello World\n" 在内存的首地址占用的空间
return 0;
00E51804 xor eax,eax ;函数返回值放在 eax 寄存器,该指令相当于 mov eax, 0; 但 xor 指令更高效
}
00E51806 pop edi ;还原调用者的 edi,esp = esp+4
00E51807 pop esi ;还原调用者的 esi,esp = esp+4
00E51808 pop ebx ;还原调用者的 ebx,esp = esp+4
00E51809 add esp,0C0h ;栈顶往下移 0C0h 字节
00E5180F cmp ebp,esp ;计算 ebp-esp,然后根据结果对 cpu 的标志寄存器进行设置
;目的是让下一条指令 call __RTC_CheckEsp,检测该函数栈帧建立前跟销毁该函数栈帧后的esp 是否一致
00E51811 call __RTC_CheckEsp (0E5123Fh) ;检测标志寄存器相关位的值,从而判断 ebp 跟 esp 是否相等
00E51816 mov esp,ebp ;恢复调用者的栈顶
00E51818 pop ebp ;恢复调用者的栈顶基址
00E51819 ret ;调用者通过 call 指令调用函数会 push eip, 自己必须 ret 指令,pop eip
3.7.1 invoke_main() 函数调用了 main 函数
查看 VS2019 的调用堆栈窗口,我们可以知道,框架的 invoke_main() 函数调用了我们
main 函数。
3.7.2 main 函数的栈帧建立和销毁过程
(1)进入主函数栈的初始状态
(2)cpu 执行 push ebp
(3)cpu 执行 mov ebp, esp
(4)cpu 执行 sub esp, 0C0h
(5)cpu 执行 push ebx
(6)cpu 执行 push esi
(7)cpu 执行 push edi
(8)cpu 执行以下指令对栈没影响
00E517EC mov edi,ebp ;edi = ebp
00E517EE xor ecx,ecx ;等价于 mov ecx, 0; 但 xor 指令更高效
00E517F0 mov eax,0CCCCCCCCh
00E517F5 rep stos dword ptr es:[edi] ;rep 的作用是根据 cx 的值,循环执行跟在其后的指令,直到 cx = 0 时为止。
;stos 串传送指令,相当于 mov es:[di], eax
;df = 0, edi = edi + 4; df = 1, edi = edi - 4;
(9)cpu 执行 push offset string "Hello World\n"
(10)cpu 执行 call _printf 指令,cpu 进入 _printf 函数执行完该函数包含的指令返回后,栈又回到了跟没执行 _printf 函数一样的状态。
(11)cup 执行 add esp, 4
(12)cpu 执行 xor eax, eax 对栈没影响
(13)cpu 执行 pop edi
(14)cpu 执行 pop esi
(15)cpu 执行 pop ebx
(16)cpu 执行 add esp, 0C0h
(17)cpu 执行以下指令对栈没影响
00E5180F cmp ebp,esp ;计算 ebp-esp,然后根据结果对 cpu 的标志寄存器进行设置
;目的是让下一条指令 call __RTC_CheckEsp,检测该函数栈帧建立前跟销毁该函数栈帧后的esp 是否一致
00E51811 call __RTC_CheckEsp (0E5123Fh) ;检测标志寄存器相关位的值,从而判断 ebp 跟 esp 是否相等
(18)cpu 执行 mov esp, ebp
(19)cpu 执行 pop ebp
此时,我们看到函数栈的状态回到了跟刚进入该函数时的初始状态是一致的。
3.7.3 "Hello World\n" 字符串在内存中是以 assii 码的形式保存
大写字母 H:assii 码为 72,十六进制表示为 0x48
小写字母 e:assii 码为 101,十六进制表示为 0x65
小写字母 l:assii 码为 108,十六进制表示为 0x6c
小写字母 o:assii 码为 111,十六进制表示为 0x6f
大写字母 W:assii 码为 87,十六进制表示为 0x57
小写字母 r:assii 码为 114,十六进制表示为 0x72
小写字母 d:assii 码为 100,十六进制表示为 0x64
换行符 \n:assii 码为 10,十六进制表示为 0x0a