C语言与汇编
部分过程可参考C++ primer plus
本书余下的篇幅讨论源代码文件中的内容;本节讨论创建源代码文 件的技巧。有些C++实现(如Microsoft Visual C++、Embarcadero C++ Builder、Apple Xcode、Open Watcom C++、Digital Mars C++和Freescale CodeWarrior)提供了集成开发环境(integrated development environments,IDE),让您能够在主程序中管理程序开发的所有步骤, 包括编辑。有些实现(如用于UNIX和Linux的GNU C++、用于AIX的 IBM XL C/C++、Embarcadero分发的Borland 5.5免费版本以及Digital Mars编译器)只能处理编译和链接阶段,要求在系统命令行输入命令。 在这种情况下,可以使用任何文本编辑器来创建和修改源代码。例如, 在UNIX系统上,可以使用vi、ed、ex或emacs;在以命令提示符模式运 行的Windows系统上,可以使用edlin、edit或任何程序编辑器。如果将 文件保存为标准ASCII文本文件(而不是特殊的字处理器格式),甚至 可以使用字处理器。另外,还可能有IDE选项,让您能够使用这些命令 行编译器。
编译和链接
Stroustrup实现C++时,使用了一个C++到C的编译器程序, 而不是开发直接的C++到目标代码的编译器。前者叫做cfront(表示C前 端,C front end),它将C++源代码翻译成C源代码,然后使用一个标准 C编译器对其进行编译。这种方法简化了向C的领域引入C++的过程。其 他实现也采用这种方法将C++引入到其他平台。随着C++的日渐普及, 越来越多的实现转向创建C++编译器,直接将C++源代码生成目标代 码。这种直接方法加速了编译过程,并强调C++是一种独立(虽然有些 相似)的语言。
Linux系统中最常用的编译器是g++,这是来自Free Software Foundation的GNU C++编译器。Linux的多数版本都包括该编译器,但并 不一定总会安装它。g++编译器的工作方式很像标准UNIX编译器。例 如,下面的命令将生成可执行文件a.out
目前有些不太理解的是int类型的长度居然是可变的。
C++的基本类型分为两组:一组由存储为整数的值组成,另一组由 存储为浮点格式的值组成。整型之间通过存储值时使用的内存量及有无 符号来区分。整型从最小到最大依次是:bool、char、signed char、 unsigned char、short、unsigned short、int、unsigned int、long、unsigned long以及C++11新增的long long和unsigned long long。
还有一种wchar_t 类型,它在这个序列中的位置取决于实现。C++11新增了类型char16_t 和char32_t,它们的宽度足以分别存储16和32位的字符编码。C++确保 了char足够大,能够存储系统基本字符集中的任何成员,而wchar_t则可 以存储系统扩展字符集中的任意成员,short至少为16位,而int至少与 short一样长,long至少为32位,且至少和int一样长。确切的长度取决于实现。
字符通过其数值编码来表示。I/O系统决定了编码是被解释为字符 还是数字。
浮点类型可以表示小数值以及比整型能够表示的值大得多的值。3 种浮点类型分别是float、double和long double。C++确保float不比double 长,而double不比long double长。通常,float使用32位内存,double使用 64位,long double使用80到128位。
通过提供各种长度不同、有符号或无符号的类型,C++使程序员能 够根据特定的数据要求选择合适的类型。
quick start
目录:/work
gcc -m32 -S hello.c # 只编译生成汇编代码片段,且通过 32 位的模式生成
gcc -S hello.c
gcc -S -fno-asynchronous-unwind-tables # 去除生成的 针对debug 使用的信息
hello程序
#include <stdio.h>
int main()
{
printf("Hello, World! \n");
return 0;
}
.file "hello.c" ;表明当前代码文件
.section .rodata ;一个小节,rodata 只读数据段.除开数据段还有只读数据段
.LC0:
.string "Hello, World! "
.text
.globl main
.type main, @function
main:
.LFB0:
pushq %rbp
movq %rsp, %rbp
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
ret
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
解释:
从上面的代码中我们可以看见,在进入 mian 函数
后首先会处理 rbp
和 rsp
,并且在调用 ret 之前会先将 rbp
的值恢复。(注意:上面的代码是 64位机上编译的代码,所以 寄存器是 r 开头,表示 64位)
lea指令是啥意思
除此之外,我们还验证了一个东西:返回值是通过 ax 寄存器存储的。
通过命令生成汇编代码如下(64bit):
.file "hello.c"
.text
.section .rodata
.LC0: ;任何英文然后+: 就是一个地址 字符串首地址
.string "Hello, World! " ;string类型, 值为 hello,world
.text ;代码段
.globl main ;全局符号名字 main 全局范围
.type main, @function ;类型为 方法、函数
main: ;任何英文然后+: 就是一个地址 main函数首地址
endbr64
pushq %rbp ;将此时的rbp压栈
movq %rsp, %rbp ; rbp = rsp
movl $.LC0, %edi
call puts
movl $1, %eax ;将函数的返回值 放入到 eax 中【约定俗成】
popq %rbp ;rbp = pop
ret
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
32位的
.file "hello.c" ;表明当前代码文件
.text
.section .rodata ;一个小节,rodata 只读数据段
.LC0: ;任何英文然后+: 就是一个地址
.string "Hello, World! " ;string类型, 值为 hello,world
.text ;代码段
.globl main ;全局符号名字 main 全局范围
.type main, @function ;类型为 方法、函数
main:
pushl %ebp ;将此时的rbp压栈
movl %esp, %ebp ; rbp = rsp
// 开辟main函数栈帧
andl $-16, %esp ;与操作符号,将低位都变为0,清空的过程
subl $16, %esp ;开辟空间
movl $.LC0, (%esp) ;将字符串的地址保存在esp指向的内存单元中
call puts
movl $1, %eax ;将返回值 放入到 eax 中
leave
ret
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
验证值传递代码
关键词是函数调用,可以参考一下视频进行学习。
https://www.bilibili.com/video/BV1RS4y1B75v
https://www.bilibili.com/video/BV1Nt4y1G728
值传递
#include <stdio.h>
int main()
{
int i = 1;
fun(i);
return 0;
}
void fun(int a)
{
a = a + 1;
}
.file "paramTrans.c"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp ;int i = 1 开辟栈空间并存放值
movl $1, -4(%rbp)
movl -4(%rbp), %eax ;存放参数,通过 eax 中转
movl %eax, %edi
movl $0, %eax
call fun
movl $0, %eax
leave
ret
.size main, .-main
.globl fun
.type fun, @function
fun:
pushq %rbp ;开辟栈帧
movq %rsp, %rbp
movl %edi, -4(%rbp) ;获取参数
addl $1, -4(%rbp) ;执行代码
popq %rbp ;恢复RBP
ret
.size fun, .-fun
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
通过上述代码和注释,可以看出,此时是使用的是 寄存器%edi
进行传参
栈传值
#include <stdio.h>
int main()
{
int i = 1;
fun(i,i,i,i,i,i,i,i,i,i,i,i,i);
return 0;
}
void fun ( int a, int b, int c, int d, int e, int f, int h, int i, int j, int k, int l, int m, int n)
{
a = a + b + c + d + e + f + h + i + j + k + l + m + n + 1;
}
.file "paramDemo2.c"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $80, %rsp
movl $1, -4(%rbp)
movl -4(%rbp), %r9d
movl -4(%rbp), %r8d
movl -4(%rbp), %ecx
movl -4(%rbp), %edx
movl -4(%rbp), %esi
movl -4(%rbp), %eax
movl -4(%rbp), %edi
movl %edi, 48(%rsp)
movl -4(%rbp), %edi
movl %edi, 40(%rsp)
movl -4(%rbp), %edi
movl %edi, 32(%rsp)
movl -4(%rbp), %edi
movl %edi, 24(%rsp)
movl -4(%rbp), %edi
movl %edi, 16(%rsp)
movl -4(%rbp), %edi
movl %edi, 8(%rsp)
movl -4(%rbp), %edi
movl %edi, (%rsp)
movl %eax, %edi
movl $0, %eax
call fun
movl $0, %eax
leave
ret
.size main, .-main
.globl fun
.type fun, @function
fun:
pushq %rbp ;开辟栈帧
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl %ecx, -16(%rbp)
movl %r8d, -20(%rbp)
movl %r9d, -24(%rbp)
movl -8(%rbp), %eax
movl -4(%rbp), %edx
addl %eax, %edx
movl -12(%rbp), %eax
addl %eax, %edx
movl -16(%rbp), %eax
addl %eax, %edx
movl -20(%rbp), %eax
addl %eax, %edx
movl -24(%rbp), %eax
addl %eax, %edx
movl 16(%rbp), %eax
addl %eax, %edx
movl 24(%rbp), %eax
addl %eax, %edx
movl 32(%rbp), %eax
addl %eax, %edx
movl 40(%rbp), %eax
addl %eax, %edx
movl 48(%rbp), %eax
addl %eax, %edx
movl 56(%rbp), %eax
addl %eax, %edx
movl 64(%rbp), %eax
addl %edx, %eax
addl $1, %eax
movl %eax, -4(%rbp)
popq %rbp
ret
通过观察我们可以发现,当寄存器不够使用时,就会使用 寄存器 + 栈内存 的方式 进行传递参数。别人定义的,自己去实现的。
结论:
global表示全局的符号,(变量或者函数)
.section描述节信息
.data表示数据段
.text表示代码段
.code32编译32位的东西可以这么做
数据的可见性
#include <stdio.h>
int data = 0;
int sum(){
return data;
}
int main(){
sum();
return 1;
}
过将该代码编译,可以得到如下汇编代码
.file "main.c"
.globl data
.bss
.align 4
.type data, @object
.size data, 4
data:
.zero 4
.text
.globl sum
.type sum, @function
sum:
pushq %rbp
movq %rsp, %rbp
movl data(%rip), %eax
popq %rbp
ret
.size sum, .-sum
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
movl $0, %eax
call sum
movl $1, %eax
popq %rbp
ret
可见,在 sum 函数
和 data
前,都有一个 .global
,我们就想是否是由于 .global
导致了数据和函数的全局可见性呢?为了验证这一点,我们可以将 int data
定义到其他文件,然后将两个文件合并编译,查看是否可以编译成功
// demo.c 文件
#include <stdio.h>
int sum(){
return data;
}
int main(){
sum();
return 1;
}
// data.C 文件
int data = 0;
编译的时候会编译不通过,但是这并不是什么问题,是因为一些语法问题,虽然说编译是不涉及到代码之间的整合的,但是我们在之后运行的时候是需要知道怎么去找这个数据的,找一个数据需要怎么找呢? 通过 变量名 + 类型
,这两者匹配就可以确定位置了,所以需要给定这两个信息,这就需要使用 extern
关键字了,用它来表明:该数据是在外部文件中定义的,包括 变量名信息 和 变量类型信息
// demo.c 文件
#include <stdio.h>
extern int data;
int sum(){
return data;
}
int main(){
sum();
return 1;
}
单独对该文件进行编译,得到汇编代码:
.file "main.c"
.text
.globl sum
.type sum, @function
sum:
pushq %rbp
movq %rsp, %rbp
movl data(%rip), %eax
popq %rbp
ret
.size sum, .-sum
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
movl $0, %eax
call sum
movl $1, %eax
popq %rbp
ret
指针
一个需求:在函数 A 中的一个变量,想要在调用函数 B 后,由 函数 B 将该值修改后返回,并且函数 B 对该值的修改需要对 函数 A 可见。
如果我们需要多个方法中共享一个数据的操作的话,就需要他们同时有该真实数据的内存地址,所以就需要进行内存地址的传递。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rXGY3Css-1670667358690)(C:/Doc/typora_pic/1653469335801-8f59b911-d6f5-4893-bec0-78da70749441-16662439264426.jpeg)]
首先我们需要传递数据的地址,就需要拿到数据的地址,在汇编语言层面,是通过lea
指令获取到数据的地址,如:lea -10(%ESP)
,而为了可读性和方便,C语言 中将该取地址操作抽象为了&
,类似:&a
标识获取a变量的地址。
既然我们拿到了内存地址,那需要用什么去表示当前值是一个内存地址值呢?-----> 在汇编层面呢,是使用了()
来表示括号内的值是内存地址,通过(内存地址)
获取其中的内容,例如:mov -8(%EPB) %EAX
,同样为了方便对()
抽象,成为*
,类似:void *p
而需要一种类型来接受这个值,也就是指针,但是我们需要知道所指的这块儿内存中存放的数据的宽度,所以就需要借助原有类型来数据宽度,将表示宽度的类型放到 *
之前,表示宽度,如:int *P
表示指向一块内存地址,且该内存地址中的数据宽度为 int 的宽度,也就是4字节。
有了上面的推理,我们看一下接下来这段代码
#include <stdio.h>
void incr(int *p){
(*p)++;
}
int main(){
int shareData = 1;
incr(&shareData);
return 1;
}
汇编得出以下汇编代码:
main:
pushq %rbp
movq %rsp, %rbp
// 开辟栈空间存放 数据
subq $16, %rsp
// 创建变量 shareData
movl $1, -4(%rbp)
// 取地址 放入到 rax 中
leaq -4(%rbp), %rax
// 将 shareData 的地址放入到 rdi 作为传参
movq %rax, %rdi
// 将 eax 归零
movl $0, %eax
call incr
movl $1, %eax
leave
ret
incr:
pushq %rbp
movq %rsp, %rbp
// 取参,此时拿到的是 地址 ==> p
movq %rdi, -8(%rbp)
// 将地址信息放入 rax
movq -8(%rbp), %rax
// 将 rax 中的地址 中 的数据 放入到 eax 中 ---> 1
movl (%rax), %eax
//
leal 1(%rax), %edx
// 将地址信息放入 rax 中
movq -8(%rbp), %rax
// 将运算的结果, 放入到 rax 所表示的地址中
movl %edx, (%rax)
popq %rbp
ret
为何这里实现 (*p)++ 是通过 leal 1(%eax),%edx
?
通过该行代码的后续操作,可见此次往 edx 存放的内容是最终的运算结果 ----- ((*p)++
后的结果 2),但是已知lea
指令是获取地址的行为呀,
猜想1 : 难道 lea 操作数 等价于 mov 指令?
验证:
- 更改汇编代码
incr:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl (%rax), %eax
// 更改这个位置
movl 1(%rax), %edx
movq -8(%rbp), %rax
movl %edx, (%rax)
popq %rbp
ret
- 编译成功,但是执行报错 ----- 段错误
猜想2:既然我无法解决 leal 指令的问题,那么这个1(%rax)
是怎么计算的呢?因为 ()
是解引用,也即括号内的值是一个地址,但是此处的 rax 中装入的已经是一个数了,为何要解引用呢,难道 立即数(数)
表示 这个数 + 立即数 的结果?
验证:
- 更改汇编代码如下
incr:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq -8(%rbp), %rax
movl (%rax), %eax
// 更改这个位置
leal 2, %edx
movq -8(%rbp), %rax
movl %edx, (%rax)
popq %rbp
ret
- 编译成功,且得到输出结果 2
所以目前可以认为 leal num,reg
可以将该数存入寄存器中,且imm(num)
可以表示 num + imm
论据:
intel 手册中只谈了 lea 指令将地址加载的作用,并不涉及到 lea 一个数
最终得知这是一个技巧
数组是什么
我们再看如下代码
#include <stdio.h>
int main(){
int arr[] = {1,2,3};
int *p = &arr[0];
int a = *P;
int b = *(p + 1);
int c = *(p + 2);
}
编译得到汇编代码如下:
main:
pushq %rbp
movq %rsp, %rbp
// 数组声明
movl $1, -32(%rbp)
movl $2, -28(%rbp)
movl $3, -24(%rbp)
// int *p = &arr[0]
// 将 arr[0] 地址放入到 p 中
leaq -32(%rbp), %rax
movq %rax, -8(%rbp)
// 将地址放入 rax
movq -8(%rbp), %rax
// 将 rax 中的地址 取出数据,放入 eax
movl (%rax), %eax
// 将 eax 中的数据 放入 栈中开辟的变量内存中
movl %eax, -12(%rbp)
// ……
movq -8(%rbp), %rax
movl 4(%rax), %eax
movl %eax, -16(%rbp)
movq -8(%rbp), %rax
movl 8(%rax), %eax
movl %eax, -20(%rbp)
movl $0, %eax
popq %rbp
ret
综上,我们发现通过指针其实是可以实现数组的,或者说指针和数组是一种实现方式,只不过数组在使用上更加方便 ----> 所以我们可以理解为 数组是指针的语法糖。
结构体又是什么?
简单的结构体
#include <stdio.h>
struct Student{
int age;
}
int main(){
struct Student stu = {666};
printf("%s",stu.age);
}
如果有这样一个结构体,那么在内存中是怎样去存放数据的呢?
猜想一下:如果是在方法中的话,就会先开辟栈空间,开辟多少靠计算,类似上述代码就是:4字节,但是需要进行内存对齐(cpu规定),所以就应该是开辟 16字节空间
.file "demo.c"
.section .rodata
.LC0:
.string "%s"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp 开辟16字节栈空间
movl $666, -16(%rbp) 填充 age 属性,立即数$666放入到开辟的空间中
*****printf 函数******
movl -16(%rbp), %eax 通过eax寄存器交给esi寄存器(字符串操作时,用于存放数据源的地址)
movl %eax, %esi
movl $.LC0, %edi 字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作
movl $0, %eax
call printf
leave
ret
结构体 + 字符串
那在内存中是怎样去定位不同的内容的呢?很容易想到通过偏移量来定位,比如我想要得到 id 信息,就需要拿到该结构的初始地址,然后偏移到对应的位置即可。
如果是靠偏移量去做,那不就和数组一样了?只不过是内部数据的类型不统一而已,那我们是否可以拿到一个指针指向初始地址,然后 ++ 遍历获取值呢?其实这是不行的,虽然他类似于数组,但是本质上来说,这并不是数组。(当然如果真的这样操作了,由于C语言没有进行越界检查,还是可以执行成功的,只是结果不会是想见的那样)
struct Student{
int age;
char name[4];
}
int main() {
struct Student stu = {
666,"aaa"
};
printf("%s", stu.name);
}
执行:gcc -S -fno-asynchronous-unwind-tables demo.s
.file "hello2.c"
.section .rodata 只读数据段
.LC0:
.string "%s"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $666, -16(%rbp) 存立即数
movl $6381921, -12(%rbp) 6381921是aaa的阿斯克码
leaq -16(%rbp), %rax 把地址给到rax寄存器
addq $4, %rax 通过rax寄存器将地址给到rsi寄存器
movq %rax, %rsi
movl $.LC0, %edi
movl $0, %eax
call printf
leave
ret
结论:
1、字符串采用ASCCII编码,放到自己的内存空间栈上
2、“aaa”取出,拼成32位,高八位和低八位依次组合
结构体 + 指针
#include<stdio.h>
struct Student{
int age;
char *name;
};
int main() {
struct Student stu = {
666,"aaa"
};
char name = stu.name[0];
printf("%s", stu.name);
return 1;
}
.file "demo.c"
;***** 只读数据段 ******;
.section .rodata
.LC0:
.string "aaa"
.LC1:
.string "%s"
;***** main函数 ******;
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp
movl $13, -32(%rbp) ;在开辟的32位中分了4位给666 [666, , , ]
movq $.LC0, -24(%rbp) ;变成[13, ,.LC0首地址低 , .LC0首地址高 , ]
movq -24(%rbp), %rax
movzbl (%rax), %eax
movb %al, -1(%rbp) ;拿到低8位
;***** print函数 ******;
movq -24(%rbp), %rax
movq %rax, %rsi
movl $.LC1, %edi
movl $0, %eax
call printf
movl $1, %eax
leave
ret
数组不等于指针,指针不等于数组。直接声明的数组会在栈空间中生成,而声名指针会在rodata中生成。