Syk:浅谈C++语法基础
一、计算机基础知识(简述内存)
1. 32位、64位处理器
32位和64位是指 :CPU一次处理数据的能力是32位还是64位,是计算机CPU中通用寄存器一次性处理、传输、暂时存储的信息的最大长度。即CPU在单位时间内(同一时间)能一次处理的二进制数的位数。不能将数据总线与地址总线的宽度与cpu位数当作一致。
2.CPU读取内存数据
一个程序以二进制形态存入于外存,当运行程序,会由外存转入内存,CPU会通过某种方式获得其程序第一条指令的首地址,通过CPU寄存器偏移读取分析一条条指令,通过一条指令先得到操作码(告诉cpu这条指令做什么),在得到地址码(告诉CPU到存储单元地址去取数据)。CPU首先通过控制总线告诉内存这个数据是读还是写,然后通过地址总线告诉内存所要获取数据的地址,最后通过数据总线获取数据。
数据总线
a.是CPU与内存或其他器件之间的数据传送的通道。
b.数据总线的宽度决定了CPU和外界的数据传送速度。
c.每条传输线一次只能传输1位二进制数据。8根数据线一次可传送一个8位二进制数据(即一个字节)。
d.数据总线的宽度,决定有多少的内存可以被访问。
数据总线是双向三态形式的总线。数据的含义是广义的,它可以是真正的数据,也可以指令代码或状态信息,有时甚至是一个控制信息,因此,在实际工作中,数据总线上传送的并不一定仅仅是真正意义上的数据。
地址总线
a.CPU是通过地址总线来指定存储单元的。
b.地址总线决定了cpu所能访问的最大内存空间的大小。10根地址线能访问的最大的内存为(1KB)。
c.地址总线的宽度,决定有多少的内存可以被存取。
控制总线
a.决定了CPU对其他控件的控制能力以及控制方式。
由此可见在计算机的世界里万物皆数字——进制
3.关于指针所占内存大小
一个指针在64位地址总线的计算机上,占8个字节;
(0xFF FF FF FF FF FF FF FF)
一个指针在32位地址总线的计算机上,占4个字节。
(0xFF FF FF FF)
假如,某计算机的地址总线是32位,0和1的不同组合可通过32位传输,即CPU能访问最大内存为2^32个存储单元(4GB),CPU最大寻址范围为2^32,也就是4个字节的大小,因此,我们只需要4个字节就可以找到所有的数据。同理,在64位地址总线的计算机中,指针占8个字节。
4.计算机存储器
计算机存储器分为两大类:内存存储器(随机存取存储器或内存条)和外部存储器(外存)。
内存储器(内存条):暂时存储进程以及数据的地方,又称主存,是CPU能直接寻址的存储空间。特点是内存容量小,存取速度快,读写反应速度快。只能临时保存信息(经cup处理后的数据),断电后信息就会消失,这就需要另一种存储器——外存储器。
外存储器:外存容量大,存取速度比内存慢,能永久保存信息,断电后信息不会消失。它好比是数据的外部仓库一样,相当于有了记忆功能,外存主要是磁盘。
内存是用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。计算机中所有程序的运行都是在内存中进行的,内存的运行决定了计算机的稳定运行, 因此内存的性能对计算机的影响非常大.
外存通常是磁性介质或光盘,像硬盘,软盘,磁带,CD等,能长期保存信息,并且不依赖于电来保存信息,但是由机械部件带动,速度与CPU相比就显得慢的多。
如果你的电脑没有安装内存条,那电脑是不会显示界面的,但如果你的电脑没有安装硬盘的话,电脑还是会显示英文界面的,但根本无法进入操作系统。
5.编码规范
ANSI
ANSI是一种字符代码,为使计算机支持更多语言,通常使用 0x00-0x7F 范围的1 个字节来表示 1 个英文字符。超出此范围的使用0x80-0xFFFF来编码,即扩展的ASCII编码。
不同的国家和地区制定了不同的标准,由此产生了 GB2312、GBK、GB18030、Big5(繁体中文)、Shift_JIS(日本) 等各自的编码标准。在简体中文的windows系统上打开记事本输入文字字符,其格式默认就是ANSI即GB2312
Unicode——万国码
但对于不同字符系统而言,就要经过字符码转换,非常麻烦,如中英、中日、日韩混合的情况。为解决这一问题,国际组织制定了可以容纳世界上所有文字和符号的字符编码方案Unicode。Unicode用数字0-0x10FFFF来映射这些字符,最多可以容纳1114112个字符,或者说有1114112个码位。码位就是可以分配给字符的数字。
Unicode 编码方式
以汉字“汉”为例,它的 Unicode 码点是 0x6c49,对应的二进制数是110110001001001,二进制数有 15 位,这也就说明了它至少需要 2 个字节来表示。可以想象,在 Unicode 字典中往后的字符可能就需要 3 个字节或者 4 个字节,甚至更多字节来表示了。
这就导致了一些问题,计算机怎么知道你这个 2 个字节表示的是一个字符,而不是分别表示两个字符呢?
于是,为了较好的解决 Unicode 的编码问题, UTF-8 和 UTF-16 两种当前比较流行的编码方式诞生了。
UTF-8
UTF-8 是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长。它可以使用 1 - 4 个字节表示一个字符,根据字符的不同变换长度。
解码的过程也十分简单:如果一个字节的第一位是 0 ,则说明这个字节对应一个字符;如果一个字节的第一位1,那么连续有多少个 1,就表示该字符占用多少个字节。
UTF-16
Unicode 是一本很厚的字典这么多的字符不是一次性定义的,而是分区定义。每个区可以存放 65536 个字符。称为基本平面。他的码点范围写成 16 进制就是从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面。
剩下的字符都放在辅助平面,码点范围从 U+010000 到 U+10FFFF。
UTF-16 编码结合了定长和变长两种编码方法的特点。它的编码规则很简单:基本平面的字符占用 2 个字节,辅助平面的字符占用 4 个字节。也就是说,UTF-16 的编码长度要么是 2 个字节,要么是 4 个字节。
UTF-32
当然还有一个 UTF-32 的编码方式,定长编码,字符统一使用 4 个字节,这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了 3 倍,这显然是无法接受的。
二、关于基本数据类型取值范围的计算
计算机存储数据按照补码方式来存。
原码:最高位为符号位(0表示正,1表示负)。
反码:正数=原码 负数:除符号位按位取反
补码:正数=原码 负数:反码+1
为什么计算机用补码存数据?
原码中0的表示有正负之分:
[+0]原 = 0000 0000 0000 0000;
[-0]原 = 1000 0000 0000 0000。
所以二进制原码表示时,范围是 -32767 ~ -0和0~32767,有两个0。
而计算机采用而补码存储数据0的表示是唯一的:
[+0] 补= [-0] 补= 0000 0000 0000 0000
负数表示需依照补码规则,即-32767的补码为1000 0000 0000 0001。
所以补码中会比原码多一个编码出来,这个编码就是1000 0000 0000 0000,因为任何一个原码都不可能在转化成补码时变成1000 0000 0000 0000,所以人们规定1000 0000 0000 0000这个补码编码为-32768。
结论:
short型数据占两个字节,即该short型数据取值范围为-32768 ~ 32767。
三、关于二维数组偏移
首先,定义一个二维数组int Array[y][x];
对于二维数组的指针偏移的运算法则。同一维数组一样,二维数组的数组名也是指向第一个数组元素的(即Array等于&Array[0][0]),且二维数组的数组元素在计算机内的内存存放也是连续的,但不一样的是:Array+1的值不是&Array[0][1],而是&Array[1][0](即对于二维数组 Array[y][x],指针Array+1中的1表示的字节数是sizeof(int)*x)。
但是注意,同一、二维数组名一样,虽然Array+1的值等于第二行第一个元素的地址,但是使用指针运算符引用 *(Array+1)得到值依然只是一个地址。
相关代码:
int arr[5][6] =
{ 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 };
printf("*(*(arr+4)+2)=%d\n", *(*(arr + 4) + 2));
//arr[0]是一个地址 arr的数据类型为int(*)(6)类型 sizeof(int)*x
std::cout << arr<<" "<<&arr[0][0]<<" " << *arr[0] << " " <<arr+1/6<< std::endl;
std::cout << **(arr+1) << std::endl;
//访问二维数组行中的值
std::cout << *(arr + 4) + 2 << " " << &arr[4][2] << std::endl;
四、函数本质
函数将我们所写好的C++代码转换为机器可读懂的二进制数,编译时利用某种规则将他变成程序文件放到外存中。程序在运行会通过某种规则将外存中我们写好的代码(指令)加载到主存中,CPU会通过某种方式获取到指令。一个程序一旦编译生成他的二进制数就不会改变,但函数(指令)的首地址可能会改变。一个程序的生成其实就是函数将我们的C++代码翻译成机器语言(二进制数)。函数的内存空间其实存放的是给予CPU 的指令。
相关代码:
#include <iostream>
int Add(int a, int b)
{
return a + b;
}
int main()
{
int c = Add(1, 2);
std::cout << Add<<std::endl;
char* str = (char*)Add;
for (int i = 0; i < 30; i++)
{
printf("%02X\n", (unsigned char)str[i]);
}
}
运行结果与反汇编作比较:
根据反汇编与运行结果我们得出函数本质。在Visual Studio反汇编代码中,左边是函数内存空间的地址,中间是机器语言(十六进制可转二进制),右边是汇编语言。可见其每一行内存空间都存放对CPU的指令即机器语言(二进制数)。我们通过for循环输出函数内存空间时也发现,其内存空间存放者一条条二进制数。由此可见其本质。
五、栈溢出(函数角度理解栈)
在实际开发中,一个函数可能会被反复调用,如果每次都分配内存空间那么系统开销将会非常大,如果为变量分配内存空间又特别浪费内存资源,所以有了栈的概念,栈的本质是提前分配好的一段内存空间,主要用来存放局部变量、函数的参数值。
简单来讲栈就是操作系统用两个指针维护的一段内存,通过两个指针(esp栈顶、ebp栈底)的偏移实现栈的创建与释放。
通过汇编分析栈溢出漏洞:
int GetAge()
{
int rt;
std::cout << "请输入学员的年龄:";
std::cin >> rt;
return rt;
}
由上图可知程序未对输入数组进行数量控制,可通过数组偏移破坏栈平衡。栈平衡如果被破坏,函数就可能不能返回到预期的位置,同理,利用这个原理,我们也可以控制目标程序进入指定的位置,来获取目标操作系统的控制权限,这也就是栈攻击的技术原理,同时编写代码时也要积极预防栈攻击。
堆:本质就是空闲内存区,C++把堆称为自由储存区,只要你的程序加载后,没有占用的空闲内存都是自由储存区。堆由开发人员分配和释放, 若开发人员不释放,程序结束时由 OS 回收。
栈的效率高于堆,但容量有限。
记两个汇编代码:
lea:从内存里取地址传递给寄存器,相当于&取址符。
mov:传递的是地址所指的内容。
关于函数调用约定:
函数调用于被调用者之间的一种协议:a.参数入栈顺序 b.如何恢复栈平衡
| 函数调用约定 | 栈平衡 | 参数入栈顺序 | 说明 |
|–|–|–|–|–|
| _cdecl | 由调用者恢复栈平衡 | 从右到左依次入栈 | 正因此调用方式可支持不定量参数|
|_fastcall|函数自己恢复栈平衡|第一个参数通过ecx传递,第二个参数通过edx传递,剩余参数从右往左|_fastcall函数执行速度比较快|
| _stdcall | 函数自己恢复栈平衡 | 从右到左依次入栈 | Windows Api函数的调用约定 |
|_thiscall|由调用者恢复栈平衡|从右到左依次入栈|类的非静态成员函数调用所遵循的约定|
_thiscall是访问C++类的成员函数时定义的函数调用约定。
类的静态成员函数调用约定,本质上采用的是_cdecl约定
因为类的静态成员函数(全局区)本质上就是一个普通的函数,所以根本没有传递对象的指针,因此,也就不能访问其成员变量,而类的静态成员变量本质上相当于一个全局变量,有着固定的内存地址,与类对象并无关系,所以类的静态成员可以在类没有实例的情况下通过类::静态成员这样的形式来访问。
六、从编译器的角度理解定义和声明
声明的本质是与编译器的对话,单纯的声明并不存在内存的分配,只是给编译器一个大体的概念,既然是对话,我们可以多次对话,所以对于同一个事物,我们可以多次声明;
而定义的本质是要通过编译器与计算机对话,这就涉及到内存的分配和访问,因此同一事物,不管声明多少次,但是只能有一次定义;
从内存分配角度函数的定义:
函数以二进制存于硬盘
七、从底层理解类
类中的非静态成员函数都可以使用this指针, this指针本质上来讲就是把对象的指针通过寄存器ecx传入成员函数的,因此类中成员函数访问其成员变量时,都是通过指针+偏移的形式来访问的,不管是否明确使用this指针。所以当我们通过汇编代码做逆向分析时,看到ecx要及其敏感,因为这可能存放这一个类的首地址。
类:编译器自动为类添加默认构造函数、默认副本构造函数、默认析构函数、重载赋值运算符(具有返回值)。
从汇编代码理解类的本质
某些时候,我们为了表示某个类里的指针是否为空,可以重载该类的void*类型(void *a)。例如:if(a)、if(a!=NULL)、if(!a)、if(a!=nullptr)。
虽然可以通过重载bool类型转换来实现这个操作,但我们不推荐bool重载。
八、从内存角度理解继承(虚基类问题)
继承:子类不能继承父类的构造函数、析构函数、 重载赋值运算符。
内存角度继承分析代码:
class Object
{
int data_0 = 1;
int data_1 = 2;
public:
int data_2 = 3;
int data_3 = 4;
Object() {
std::cout << "Object was created!" << std::endl;
}
};
class thingObject :public Object//virtual
{
int tdata_0 = 81;
int tdata_1 = 82;
public:
int tdata_2 = 83;
int tdata_3 =84;
thingObject() {
std::cout << "thing was created!" << std::endl;
}
};
//继承关系
class MoveObject :public Object//virtual
{
//Object x; 包含关系
int Mdata_0 = 11;
int Mdata_1 = 22;
public:
int Mdata_2 = 33;
int Mdata_3 = 44;
MoveObject() {
std::cout << "MoveObject was created!" << std::endl;
}
};
class NPC :public MoveObject, thingObject {
int Ndata_0 = 31;
int Ndata_1 = 32;
public:
int Ndata_2 = 43;
int Ndata_3 = 54;
NPC() {
std::cout << "NPC was created!" << std::endl;
}
};
int main() {
NPC obj;
//由类本质可知 通过指针偏移读取类内私有成员变量
int* nRead = (int*)&obj;
std::cout << "data_0的值:" << nRead[0] << std::endl;
std::cout << "OBJ内存地址:" << &obj << std::endl;
//for循环输出数组 窥探继承本质
for (int i = 0; i < sizeof(obj) / 4; i++) {
std::cout << "内存地址:" << &nRead[i] << "值:" << nRead[i] << std::endl;
}
}
运行结果:
我们通过类的本质的概念,对接收类对象地址的数组进行for循环输出,通过输出规则的内存地址,以此来窥探继承本质。我们得知对象obj的地址其实是传入了最大的父类当中,然后通过一次次偏移来实现子类成员变量的访问。要首先将子类对象地址传入其最大的父类中。
但通过上述程序我们发现,公共基类在派生类中有两次拷贝,为避免内存浪费等,此时我们可将其设为虚基类。让其在派生类中只有一次拷贝。
但当我们将其设为虚基类时发现,成员变量的访问顺序发生改变,虚基类的成员变量已存入最末端。
将基类改为虚基类的运行结果
此处涉及了一个虚基类表的问题,具体实现机制可参考下文多态中虚函数的实现机制。
九、类的成员函数的函数指针
多态构成条件:
a.必须通过基类的指针或引用调用函数。
b.被调用的函数是虚函数,且必须完成对基类虚函数的重写。
类的成员函数的函数指针,其实在某种程度上也实现动态。
用法示例:
class Wolf;
//声明一个类的成员函数的函数指针类型
typedef void(Wolf::* pGroup) ();
typedef void (*_count)();
class Wolf {
public:
Wolf()
{
pGroup _pGzFunction = &Wolf::Group01;
this->*_pGzFunction)();
}
static void count()
{
std::cout << "static count!" << std::endl;
}
void Group0()
{
std::cout << "Group0" << std::endl;
}
void Group01() {
std::cout << "Group01" << std::endl;
}
void Group02() {
std::cout << "Group02" << std::endl;
}
};
int main()
{
//定义函数指针指向 一定程度上也实现了多态
pGroup _pFunction = &Wolf::Group0;
// Wolf q;
Wolf* pWolf = new Wolf();
Wolf a;
//通过函数指针访问类成员函数方式 需要实例化对象,并需要告诉函数指针哪个类。
(a.*_pFunction)();
(pWolf->*_pFunction)();
//针对类内静态成员函数 可直接访问
_count _pC = &Wolf::count;
_pC();
}
不常用,不多赘述。
十、从底层理解多态本质(虚函数的实现机制)
从汇编代码认识虚函数实现机制
#include <iostream>
class AIM {
public:
//unknown
int HP{ 2000 };
virtual void Eat()
{
std::cout << "AIM" << std::endl;
}
virtual void Die()
{
std::cout << "父类Die函数" << std::endl;
}
};
class WOLF :public AIM {
public:
virtual void Eat()
{
std::cout << "WOLF" << std::endl;
}
virtual void Die()
{
std::cout << "WOLF-Die" << std::endl;
}
virtual void Sound()
{
std::cout << "aoaoaoaoaoaoaoa~" << std::endl;
}
};
int main()
{
//对象多态 向上类型转换
AIM* wolf = new WOLF();
//父类指针
//
//输出AIM类所占内存空间
std::cout << sizeof(AIM) << std::endl;
//查看
std::cout << wolf << " " << &wolf->HP << std::endl;
wolf->Die();
}
输出结果:
由输出结果发现在具有虚函数的类的对象内存的最前端多了四个字节(32位)。
此刻我们透过汇编代码开始分析其中猫腻:
我们发现其读取了类对象指针wolf所指内容存入了eax中,即AIM类对象最前端多出的那个地址。然后读取了那多出地址指向的地址存入了edx,接着edx通过偏移+4将多出地址+4的这个地址存入了eax,最后call eax跳转访问到了Die成员函数。
由图解释:
这里其实涉及了一个虚函数表的问题。相关定义如下:
虚函数大家都知道是基本用于实现多态的,当父类指针指向子类对象的时候,如何确定调用的函数是父类里的还是子类里面的,这就要用到虚函数表。
a.编译器会为每个有虚函数的类创建一个虚函数表,如有类中没有虚函数,就没有虚函数表。同时,如果所有类都有自己的虚函数,那么一个类不会有另外一个类的虚函数表,包括两个类属于继承关系。
b.虚函数表会被一个类的所有对象所拥有
类的每个虚函数成员占据虚函数表的一行,所以说,如果类中有N个虚函数,那么该虚函数表将会有N*4的大小。并不是每个类对象都会有自己的表。
c.编译器会将虚函数表指针存放在类对象最前面的位置
对于类的每个对象,编译器都会为其生成一个透明不可见的指针,这个指针就是虚函数表指针,存放在该对象内存的最前位置。例如:一个类拥有虚函数表,类对象内存布局中前4个字节就是虚函数表的地址(32位)。
d.父类指针指向子类时,调用时实际上是根据子类的虚函数表进行查找。
关于输出虚函数地址:
类对象内存中前4个字节就是虚函数表的地址,那么我们获取类对象前4个字节,(int)&father就是虚函数表地址,我们再给他转换成地址指针为(int*)(int)&father。