C语言基础知识
1.关键字
1.volatile
1.基本概念
因为访问寄存器要比访问内存单元快的多,所以编译器可能会优化读取和存储,暂时使用寄存器中的值,当要求使用volatile声明变量值的时候,系统总是重新从它所在的内存读取数据,忽略优化。保证精确度。提醒编译器它后面所定义的变量随时都有可能改变。
2.使用场景
- 并行设备的硬件寄存器(如:状态寄存器PC)
就拿I/O端口来说,我们会去操作映射到对应IO端口的寄存器。判断寄存器的值,加上volatile关键字。
在中断服务函数中修改全局变量。
在多线程中修改全局变量。
3.示例
/* 未优化 */ while(*pRegister == 0){ //不改变*pRegister的值 } /*优化后*/ if (*pRegister == 0){ while(1){ //不改变*pRegister的值 } }
解释:
在上面的循环中,*pRegister
的值不会发生改变,所以循环中就不再判断*pRegister
的值了,运行效率提升。但是pRegister
指向的特殊功能寄存器,其值是由硬件改变的,而软件却不再判断*pRegister
的值了,那么就进入死循环了,即使*pRegister
的值发生了改变,软件也察觉不到了。
2.static
1.static修饰变量
static 关键字不仅可以用来修饰变量,还可以用来修饰函数。在使用 static 关键字修饰变量时,我们称此变量为静态变量。静态变量的存储方式与全局变量一样,都是静态存储方式。但这里需要特别说明的是,静态变量属于静态存储方式,属于静态存储方式的变量却不一定就是静态变量。例如,全局变量虽然属于静态存储方式,但并不是静态变量,它必须由static加以定义后才能成为静态全局变量。
1.隐藏于隔离
全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,全局变量在各个源文件中都是有效的。加上static关键字后,就限定在避免在其它的源文件中引起错误。
2.保存变量内容的持久性
希望函数中局部变量的值在函数调用结束之后不会消失,因此对局部变量加上static修饰后,存储就从栈区移到静态存储区。需要保留函数上一次调用结束时的值。如果初始化后,变量只会被引用而不会改变其值,则这时用静态局部变量比较方便,以免每次调用时重新赋值。
3.默认初始化
初始化为0。在C语言中,静态变量,即全局变量和static变量,是在程序运行前创建的,其中已初始化的全局变量和static变量在编译阶段就完成了,初始值就已经保存在磁盘的.data段了,进程加载时将其映射到内存空间即可;未初始化的全局变量需要进程加载时真正的为.bss段分配内存空间,并赋值为0。静态变量的创建和初始化都是在运行前完成的。
2.static修饰函数
函数的定义和声明默认情况下是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。好处:
<1> 其他文件中可以定义相同名字的函数,不会发生冲突。
<2> 静态函数不能被其他文件所用。
3.static有与C++中的类
- 类中的静态成员变量
派生类对象与基类对象共享基类的静态数据成员。静态数据成员一个地方被修改,其他所有对象的该静态数据成员都同样发生改变。
- 类中的静态成员函数
3.const
C语言中是可读,C++中是可读、常量。
1.常量的创建
C语言和C++中都可以用#define来定义常量(称宏常量)。除此之外,C++语言还可以用const来定义常量(称const常量)。
2.为什么需要常量
- 程序的可读性(可理解性)变差。我们总会忘记这些数字或字符串代表什么意义。
- 一旦数字很多,改动麻烦。
3.安全性--C++
在C++中为了安全性,我们就要少用甚至不要再用宏了,不带参数的宏命令我们可以用常量const来替代,比如const double PI = 3.1415,可以起到同样的效果,而且还比宏安全,因为这条语句会在编译阶段进行语法检查。而宏常量在预处理阶段被替换,不会进行语法检查。因此在C++中,尽量使用const去替代非参数宏命令,这样大大增强我们程序的健壮性。
3.const在C++中的使用
C语言里的const和C++里的const,两者作用机制是不一样的。
C++把const看做常量,编译器会使用常数直接替换掉。C++中的const机制类似C语言中的宏,都是替换,不过C++的const是在编译阶段替换,C语言的宏是在预处理阶段替换。
4.C语言中用宏定义来表示常量不用Const
在C语言中,const修饰的变量依然被当做变量,那么其在内存中依然有存储它的空间。并且可以通过指针间接的改变该内存空间的值。但是如果使用的是宏定义的化,会在预处理阶段对其进行替换,这就保证了常量不会发送变化。
5.const修饰指针变量
常量指针:const在''左边,则指针指向的变量值,不可直接通过指针改变。
指针常量:const在''右边,则指针的指向不可变。
6.const修饰参数传递
当 const 参数为指针时,可以防止指针被意外篡改。int *const a
7.const和define的区别
- const生效于编译的阶段;define生效于预处理阶段。
- const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
- const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
4.inline
其作用是将函数展开,把函数的代码复制到每一个调用处。这样调用函数的过程就可以直接执行函数代码,而不发生跳转、压栈等一般性函数操作。可以节省时间,也会提高程序的执行速度。
1.内联函数
1.基本概念
C++中内联函数是通常与类一起使用。如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。内联函数相当与C语言中的#define宏定义中的带参数定义。内联是以代码复制为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。
2.为什么使用内联函数
函数调用是有调用开销的,执行速度要慢很多,调用函数要先保存寄存器,返回时再恢复,复制实参等等。如果本身函数体很简单,那么函数调用的开销将远大于函数体执行的开销。为了减少这种开销,我们才使用内联函数。
3.使用要求
- 内联函数的函数体内不能含有复杂的结构控制语句,如switch和while,否则编译器将该函数视同普通函数那样产生函数调用代码。
- 递归函数不能被用来作为内联函数。
- 内联函数一般适合于只有1-5行语句的小函数,对于一个含有很多语句的大函数,没必要使用内联函数来实现。
- 内联函数的定义必须出现在内联函数第一次被调用之前。
- 对内联函数不能进行异常接口声明,就是不能声明可能抛出的异常。
4.内联函数和函数的区别
- 内联函数比普通函数多了关键字inline
- 内联函数避免了函数调用的开销,普通函数有调用的开销。
- 普通函数在被调用的时候,需要寻址(函数入口地址),内联函数不需要寻址。
- 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句。普通函数没有这个要求。
5.内联函数的作用
内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。
5.register
1.功能
register修饰符暗示编译程序相应的变量将被频繁地使用,如果可能的话,应将其保存在CPU的寄存器中,以加快其存储速度。
2.限制
- register变量必须是能被CPU所接受的类型。
这通常意味着register变量必须是一个单个的值,并且长度应该小于或者等于整型的长度。不过,有些机器的寄存器也能存放浮点数。
- 因为register变量可能不存放在内存中,所以不能用“&”来获取register变量的地址。
- 只有局部自动变量和形式参数可以作为寄存器变量,其它(如全局变量)不行。
在调用一个函数时占用一些寄存器以存放寄存器变量的值,函数调用结束后释放寄存器。此后,在调用另外一个函数时又可以利用这些寄存器来存放该函数的寄存器变量。
- 局部静态变量不能定义为寄存器变量。不能写成:
register static int a, b, c
; - 由于寄存器的数量有限(不同的cpu寄存器数目不一),不能定义任意多个寄存器变量,而且某些寄存器只能接受特定类型的数据(如指针和浮点数),因此真正起作用的register修饰符的数目和类型都依赖于运行程序的机器,而任何多余的register修饰符都将被编译程序所忽略。
2.指针函数/函数指针
1.函数指针
函数指针声明为指针,它与变量指针不同之处是,它不是指向变量,而是指向函数【()带括号】指向函数的指针。
C在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。有了指向函数的指针变量后,可用该指针变量调用函数,就如同用指针变量可引用其他类型变量一样( * ptr)( a ,b)
。
1.函数指针的用处
- 比如有多个函数的申明,它们有不同的具体实现,如果需要调用它们,就可以用一个指针轮流指向它们。
- 回调机制就是很好的应用函数指针的例子,这是函数指针作为回调函数的一个参数。
- 回调函数是由别人的函数执行时调用你实现的函数。
- 你到一个商店买东西,刚好你要的东西没有货,于是你在店员那里留下了你的电话,过了几天店里有货了,店员就打了你的电话,然后你接到电话后就到店里去取了货。在这个例子里,你的电话号码就叫回调函数,你把电话留给店员就叫登记回调函数,店里后来有货了叫做触发了回调关联的事件,店员给你打电话叫做调用回调函数,你到店里去取货叫做响应回调事件。
2.函数指针有什么作用呢,为什么不直接调用函数而要使用函数指针?
通常我们认为一件事由“动作”和“数据”组成,比如“小明吃饭”中,小明是数据,饭是数据,吃是动作。动作和数据边界清晰,各自含义也直观。把动作数据化,真的是各种编程语言一路下来心心念念的要有各种实现各种改进的念想。就以典型面向过程式的C语言为例:函数指针在C语言中就存在。
3.u-boot中的函数指针
(*((void(code*)(void))0x0000))();
对应的语句就是汇编中的 LJMP 0000H
;原理是将0x0000
强制类型转换成一个返回值和参数都是void型的函数指针;函数指针原型是 void (*func)(void)
;该函数指针指向的应是ROM区,所以加关键词code;(void(code*)(void))0x0000
把0强制类型转换成code区的函数指针;调用该函数指针,程序计数器PC指向0000H
,程序复位。
2.指针函数
指针函数实质是一个函数。函数都有返回类型,只不过指针函数返回类型是某一类型的指针。
3.Struct 和 Union
1.区别
- 在存储多个成员信息时,编译器会自动给struct第个成员分配存储空间,struct 可以存储多个成员信息,而Union每个成员会用同一个存储空间,只能存储最后一个成员的信息。
- 都是由多个不同的数据类型成员组成,但在任何同一时刻,Union只存放了一个被先选中的成员,而结构体的所有成员都存在。
- 对于Union的不同成员赋值,将会对其他成员重写,原来成员的值就不存在了,而对于struct 的不同成员赋值是互不影响的。
- UNION变量所占用的内存大小=其中最长成员的内存大小。共用体占用的内存应足够存储共用体中最大的成员。(同一时间只使用了一种数据)。这意味着一个变量(相同的内存位置)可以存储多个多种类型的数据。
2.字节对齐
1.基本大小
2.自然对齐
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
3.为什么要字节对齐
需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。(减少访问内存的次数)
1.效率问题
而如果变量在自然对齐位置上,则只要一次就可以取出数据。各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。
2.取出数据错误
一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
4.字节对齐原则(struct和union都适用)
- 结构体变量中成员的偏移量必须是当前成员大小的整数倍;
- 占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍。
5.注意事项
- 结构体内使用指针套用另一个结构体,结构体该部分的大小为该指针的大小。
- 结构体当中静态变量不占用结构体空间
- 当类不包含任何成员的时候,大小本该是0,但是为了便于区分,大小是1
- bool类型在结构体内的大小为1个字节
6.强制对齐
1.规则
- 对齐字节数 = min(成员起始地址应是n的倍数时填充的字节数, 自然对齐时填充的字节数)
- 占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍。
2.示例解释#pragma pack(n)
表示我们结构体成员所占用内存的起始地址需要是n的整数倍。所以就需要填充一定的字节数。同时满足占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍。
#pragma pack(4) using namespace std; struct example{ int a; char b; short c; char d; }test_struct;
int类型大小为4个字节,char为一个字节,这个时候如果按照成员起始地址应是n的倍数时填充的字节数来填充,应该填充3个字节,如果按照自然对齐时填充的字节数来填充只需要填充1个字节。因为是取最小值原则所有填充1个字节。之后short是2个字节,再之后是char是1个字节。根据足占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍原则填充3个字节。
7.取消对齐规则
定义结构体时加上attribute((packed))
4.typedef和define有什么区别
1.原理不同
#define
是C语言中定义的语法,是预处理指令,在预处理时进行简单而机械的字符串替换,不作正确性检查,只有在编译已被展开的源程序时才会发现可能的错误并报错。该命令有两种格式:一种是不带参数的宏定义,另一种是带参数的宏定义。
typedef
是关键字,在编译时处理,有类型检查功能。它在自己的作用域内给一个已经存在的类型一个别名,但不能在一个函数定义里面使用typedef,函数声明的时候可以用。用typedef定义数组、指针、结构等类型会带来很大的方便,不仅使程序书写简单,也使意义明确,增强可读性。
2.功能不同
typedef
用来定义类型的别名,起到类型易于记忆的功能。
#define
不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
3.作用域不同
#define
没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。
typedef
有自己的作用域。
4.宏定义的好处
- 方便程序的修改
- 提高程序的运行效。宏定义的展开是在程序的预处理阶段完成的,无需运行时分配内存,能够部分实现函数的功能,却没有函数调用的压栈、弹栈开销,效率较高。
- 增强可读性
- 字符串拼接
5.宏定义的缺点--#define
宏命令发生在预编译阶段,属于暴力替换,并不安全。在C++中不带参数的宏命令我们可以用常量const来替代,而带参数的宏命令有点类似函数的功能,在C++中可以使用内联函数或模板来替代。
5.编译过程
1.预处理、编译、汇编、链接
- 预处理
主要处理源代码中以字符#开始的预编译指令,如“#include”、“#define”。
- 编译
预处理完成的文件进行一系列的词法分析、语法分析、语义分析之后生成汇编文件。
- 汇编
汇编文件进行汇编。翻译成机器语言指令,把这些指令打包成一种可重定位目标程序的格式。是一个二进制文件。
- 链接
负责处理合并目标代码,生成一个可执行目标文件,可以被加载到内存中,由系统执行。
2.总结 E S C O
- 源文件.c
- 首先进行预处理 gcc -E hello.c 注释:这部分主要是处理对“#”开头的部分。将预处理的结构保存为hello.i ,gcc -E hello.c > hello.i (人为命名)
- 编译gcc -S hello.i 会产生hello.s的汇编文件
- 然后用gcc -c hello.s 对汇编文件进行汇编产生目标文件hello.o目标文件(二进制文件)
- 最后进行链接可以生成可执行文件 gcc hello.o -o hello
注释:
- hello.i 和 hello文件是用户自己定义的。
- “>” 符号为文件重定向。
6.避免同一个文件被include多次(重定义)
C/C++中有两种方式,一种是#ifndef方式,一种是#pragma once方式
1.#ifndef
#ifndef __SOMEFILE_H__ #define __SOMEFILE_H__ ... ... // 声明、定义语句 #endif
它不光可以保证同一个文件不会被包含多次,也能保证不同文件完全相同的内容不会被包含两次。缺点是如果自定义的宏名不小心“重名”了,两份不同的文件使用同一个宏名进行#ifndef
,那么会导致编译器找不到声明的情况。
2.#pragma once
#pragma once
是编译器提供的指令,同一个文件不会被包含多次。注意这里所说的“同一个文件”是指物理上的一个文件,而不是指内容相同的两个文件。带来的好处是,你不必再费劲想个宏名了,当然也就不会出现宏名碰撞引发的奇怪问题。对应的缺点就是如果某个头文件有多份拷贝,本方法不能保证他们不被重复包含。
3.两者的区别
#ifndef
和#pragma oncr
都发生在预处理阶段,#ifndef
的方式依赖于宏名字不能冲突,这不光可以保证同一个文件不会被包含多次,也能保证内容完全相同的两个文件不会被不小心同时包含。#ifndef
是C/C++语言特性,#pragma once
是编译器提供的指令。
4.知识拓展#ifdef&#if
1.#ifdef
#ifdef
指令说明:如果预处理器已经定义了后面的标识符,那么执行所有指令并编译C代码,直到下一个#else
或者#endif
出现为止(无论#else
和#endif
谁先出现)。如果有#else
指令,那么,在未定义标识符时会执行#else
和#endif
之间的所有代码。
从以上的说明中可以总结以下几点:
1)#ifdef
只是判断后面的标识符有没有定义,而不在乎标识符的值,标识符是0是1对它来说都没有区别,只要预先定义了,执行#ifdef
后的代码;
2)#ifdef
是和#else
搭配使用的,没有#elif
搭配之说;
3)#ifdef
必须要有#endif
配合使用;
2.#if
#if
说明:#if
指令更像常规的C中的if,#if后跟常量整数表达式。如果表达式为非零值,则表达式为真。在该表达式中可以使用C的关系运算符和逻辑运算符。且可以使用#elif指令扩展if-else序列.
总结出来的点为:
1)#if
是要判断它后面表达式真假的,是真才执行#if后的代码;
2)#if
和#elif
搭配使用,这就可以用多种条件编译选择;
3)#if
也是必须要以#endif
配合使用。
7.if-else原则
- if-else最近匹配原则:else与其上面最近的一个if语句配对。
- 如果没有大括号,if-else的作用域仅仅是紧跟的第一条语句。
int main(){ int i = -1, j = 2; if (i > 0) if (j > 3) i++; else j++; i -= j; }
int main(){ int i = -1, j = 2; if (i > 0) if (j > 3) i++; else j++; i -= j; }
9.枚举
第一个名称的值为 0,第二个名称的值为 1,第三个名称的值为 2,以此类推。
注意:
enum color { red, green=5, blue };
问red是多少? red = 0;
默认情况下,enum的值是从0开始的,而green 的值为 5。那么blue 紧接着的值为 6,因为默认情况下,每个名称都会比它前面一个名称大 1。
10.do…while(0)的作用
使复杂的宏在展开时,能够保留初始的语义,从而保证程序正确的逻辑。
11.形参和实参
函数的形参必须是变量,用于接受实参传递过来的值;而实参可以是常量、变量或表达式,其作用是把常量、变量或表达式的值传递给形参。如果实参是变量,它与所对应的形参是两个不同的变量。
实参和形参是不同的变量,各自占有自己的内存,当然形参的值发生改变不会影响实参的值。
让形参指针指向的地址,可以使用const!我们这样定义double cylinder(double *const r, double *const h)
。那么这个时候形参指针指向的地址就不可以改变了。
12.函数传递参数的方式
1.值传递
该方法把参数的实际值赋值给函数的形式参数。在这种情况下,修改函数内的形式参数对实际参数没有影响。
2.地址传递
该方法把参数的地址赋值给形式参数。在函数内,该地址用于访问调用中要用到的实际参数。这意味着,修改形式参数会影响实际参数。
3.引用传递
该方法把参数的引用赋值给形式参数。在函数内,该引用用于访问调用中要用到的实际参数。这意味着,修改形式参数会影响实际参数。
13.实型数据的存储和编码
float型数据码长四个字节32位,四个字节分别记为B1,B2,B3和B4,32个位分别记为b1,b2,…,b32。其中b1为符号位,0表示正数,1表示负数。b2至b9为指数的编码,值为127。b10-b32为尾数编码,分别表示2^-1,2^-2,…,2^-23各位上的数码。float型数据的编码方式如下图所示。
其中float型与编码的关系如下公式计算:
1.小数的二进制形式
那么拿到一个十进制的数,首先要把它拆分成整数部分和分数部分,用二进制小数点左边表示整数部分。再用小数点右边表示分数部分。
例如:
比如3.625可以拆成3和0.625。3用二进制表示就是11,0.625在二进制下就是0.101,合起来就是(11.101)2。
解析:
- 0.625 * 2 = 1.25 .......1
- (1.25-1)*2 = 0.5........0
- 0.5*2=1 完毕
即从1-3表明0.625的二进制为0.101。
14.数组指针和指针数组
1.数组指针
数组指针也称行指针。假设有定义int (*p)[n]
;且()优先级高,可以说明p是一个指针,且指向一个整形的一维数组。这个一维数组的长度是n,也可以说是p的步长,也就是说执行p+1,p要跨过n个整形数据的长度。
访问数组中第i行j列的一个元素,有几种操作方式,*(p[i]+j)
、*(*(p+i)+j)
、(*(p+i))[j]
、p[i][j]
。
2.指针数组
指针数组不同于数组指针,假设有定义int *p[n]
;且[]
优先级高,可以理解为先与p结合成为一个数组,再由int *
说明这是一个整型指针数组,它有n个指针类型的数组元素。
这里若执行p+1操作则是错误的,p=a
这样的赋值也是错误的,这里*p=a
赋值才是正确的,这里*p表示指针数组第一个元素的值,a的首地址的值。指针数组每个元素的值存储的是地址(即指针)。
15.指针和结构体
#include <stdio.h> typedef struct Register {//寄存器 int register1; int register2; int register3; int register4; }tregister; int main(){ int initArr[] = {1,0,1,1}; tregister *p = (tregister *)initArr; printf("Register1 = %d\n", p->register1); // 1 printf("Register2 = %d\n", p->register2); // 0 printf("Register3 = %d\n", p->register3); // 1 printf("Register4 = %d\n", p->register4); // 1 return 0; }
这里定义了一个结构体tregister
,然后定义了一个数组initArr
,我们将指针initArr
强制转换为tregister
,就发现数组里的值被一一赋给了结构体里的成员。这就是典型的指针强转为结构体。
16.野指针
1.解释
野指针不同于空指针,空指针是指一个指针的值为null,而野指针的值并不为null,野指针会指向一段实际的内存,只是它指向哪里我们并不知情,或者是它所指向的内存空间已经被释放,所以在实际使用的过程中,我们并不能通过指针判空去识别一个指针是否为野指针。
2.野指针产生原因
- 指针变量的值未被初始化。
如果指针声明在全局数据区,那么未初始化的指针缺省为空,如果指针声明在栈区,那么该指针会随意指向一个地址空间。
- 指针所指向的地址空间已经被free或delete。
指针被释放掉之后需要指向NULL。如果已经free或delete,那么此时堆上的内存已经被释放,但是指向该内存的指针如果没有人为的修改过,那么指针还会继续指向这段堆上已经被释放的内存,这时还通过该指针去访问堆上的内存,就会造成不可预知的结果,给程序带来隐患。
- 指针操作超越了作用域。
3.避免野指针
- 栈上指针初始化置NULL
- 堆上申请内存后判空
- 指针释放后置NULL
17.内存池
1.内存分配过程
进程发起申请内存的动作之后,会在系统的空闲内存区寻找合适大小的内存块,如果满足就直接分配,如果不满足就会向上查找。如果过大就会进行分裂,一部分分给申请进程,一部分放入空闲区。释放时需要找到这个块对应的伙伴,如果伙伴也为空闲,就进行合并,放入高阶空闲链表,如果不空闲就放入对应链表。
2.内存池介绍
程序可以通过系统的内存分配方法预先分配一大块内存来做一个内存池,之后程序的内存分配和释放都由这个内存池来进行操作和管理,当内存池不足时再向系统申请内存。
我们通常使用malloc等函数来为用户进程分配内存。它的执行过程通常是由用户程序发起malloc申请内存的动作,在标准库找到对应函数,对不满128k的调用brk()系统调用来申请内存,大于 128K 时,调用mmap(),接着由操作系统来执行brk/mmap系统调用。
malloc是在标准库,真正的申请动作需要操作系统完成。用户程序申请内存需要经过3层分别为应用层、标准库再到操作系统。内存池是专为应用程序提供的专属的内存管理器,它属于应用程序层。所以程序申请内存的时候就不需要通过标准库和操作系统,明显降低了开销。
3.内存池的工作原理
固定内存池的内存块实际上是由链表连接起来的,前一个总是后一个的2倍大小。当内存池的大小不够分配时,向系统申请内存,大小为上一个的两倍,并且使用一个指针来记录当前空闲内存单元的位置。当我们要需要一个内存单元的时候,就会随着链表去查看每一个内存块的头信息,如果内存块里有空闲的内存单元,将该地址返回,并且将头信息里的空闲单元改成下一个空闲单元。
18.内存申请
1.用户态
1.malloc
- 当开辟的空间小于 128K 时,调用 brk()函数;
- 当开辟的空间大于 128K 时,调用mmap()。
malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块连接,每一个空闲块记录了一个未分配的、连续的内存地址。
1.brk()
brk是将数据段(.data)的最高地址指针_edata往高地址推(也就是堆顶指针),也就是扩展当前的堆的大小。
2.mmap()
mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。
2.realloc--内存大小的调整
realloc(void* ptr,size_t size)
该函数是已经分配好内存块的重新分配,如果开始指针分配为NULL,则和malloc是一样的。否则如果开始内存块小,保存原内存块,再此基础上,再次增加内存块。如果内存块过大,则再次基础上减去尾部内存块。返回值是分配好的内存块的头指针。
3.calloc()
void* calloc(size_t num ,size_t size)
size_t num为元素个数,size_t size为每个元素的字节大小。基本和malloc一样,唯一的优点就是在返回在堆区申请的那块动态内存的起始地址之前,会将每个字节都初始化为0。
2.内核态
1.kmalloc
void *kmalloc(size_t size, int flags);
第一个参数是要分配的块的大小,第二个参数是分配标志(常用GFP_KERNEL)。内核负责管理系统物理内存,物理内存只能按页面进行分配,因此,kmalloc是基于页进行分配。kmalloc 可以处理的最小的内存块是32或64,最大分配的内存大小为128K。
2.kzalloc
void*kmalloc(size_t size,int flags)
kzalloc() 实际上只是额外附加了 __GFP_ZERO 标志。所以它除了申请内核内存外,还会对申请到的内存内容清零。
3.vmalloc
vmalloc() 函数则会在虚拟内存空间给出一块连续的内存区,但这片连续的虚拟内存在物理内存中并不一定连续。由于 vmalloc() 没有保证申请到的是连续的物理内存,因此对申请的内存大小没有限制,如果需要申请较大的内存空间就需要用此函数了。
19.new和malloc的区别,各自底层实现原理
- new是操作符,而malloc是函数。
- new在调用的时候先分配内存,再调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。
- malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。
- new可以被重载;malloc不行
- new分配内存更直接和安全。
- new发生错误抛出异常,malloc返回null。
20.段错误的快速定位
- 编译 gcc xx.c** -** g后,运行可执行文件会自动生成core文件;
- 若未自动生成core文件则在命令行输入ulimit -c unlimited后重做步骤一后在做下一步;
- 用gdb调试 gdb a.out core;
- 输入where查看详细信息,既可精准定位段错误的位置。
21.满二叉树和完全二叉树
二叉树还有两种特殊形式, 一个叫作满二叉树, 另一个叫作完全二叉树。
1.满二叉树
一个二叉树的所有非叶子节点都存在左右孩子, 并且所有叶子节点都在同一层级上, 那么这个树就是满二叉树。
2.完全二叉树
完全二叉树的条件没有满二叉树那么苛刻: 满二叉树要求所有分支都是满的; 而完全二叉树只需保证最后一个节点之前的节点都齐全即可。
22.二叉堆
二叉堆本质上是一种完全二叉树, 它分为两个类型。
- 最大堆。最大堆的任何一个父节点的值, 都大于或等于它左、 右孩子节点的值。
- 最小堆。最小堆的任何一个父节点的值, 都小于或等于它左、 右孩子节点的值
二叉堆可以用来实现堆排序。堆排序是一个效率要高得多的选择排序,首先把整个数组变成一个最大堆,然后每次从堆顶取出最大的元素,这样依次取出的最大元素就形成了一个排序的数组。堆排序的核心分成两个部分,第一个是新建一个堆,第二个是弹出堆顶元素后重建堆。
建堆过程的时间复杂度是O(n),堆排序的时间复杂度是O(nlogn)。
23.哈希表
- 哈希表是一个非常有用的数据结构。可以实现常数时间复杂度的查找。
- 哈希表通过键值对来实现,将键值对放入数组中,键和值一一对应,找到键也就找到了值。
- 哈希表哈希函数来获取键值对的哈希值,对应数组的下标。
1.哈希冲突
- 开放地址法(线性探测法):如果得到的哈希地址冲突(该位置上已存储数据)的话 ,我们就是将这个数据插到下一个位置,要是下个位置也已存储数据 ,就继续到下一个,直到找到正确的可以插入的数据 。
- 二次探测法:当遇到冲突后 ,下次所找到的位置为当前的位置加上n的二次方
- 链地址法:如果得到的哈希地址冲突, 只需要插入到对应的链表中即可。
2.哈希表的初始数组容量一般为多少,为什么?
- 需要是2的指数,因为这样可以保证hash分布均衡,减少哈希冲突
- 效率问题,如果为4,一开始就得频繁扩容,效率低;如果为64,浪费数组空间。
3.哈希表的负载因子为什么是0.75?
还是一样的,数值太少,频繁扩容。数值太大,容易哈希冲突。加载因子就是表示Hash表中元素的填满程度。
加载因子 = 填入表中的元素个数 / 散列表的长度
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
24.红黑树
是一种近似平衡的二叉查找树,它能够确保任何一个节点的左右子树的高度差不会超过二者中较低那个的一倍。
具体来说,红黑树是满足如下条件的二叉查找树(binary search tree)
- 每个节点要么是红色,要么是黑色。
- 根节点必须是黑色
- 红色节点不能连续(也即是,红色节点的孩子和父亲都不能是红色)。
- 对于每个节点,从该点至null(树尾端)的任何路径,都含有相同个数的黑色节点。
- 最长的路径长度不会超过任意路径的两倍。
在树的结构发生改变时(插入或者删除操作),往往会破坏上述条件3或条件4,需要通过调整(左旋或右旋)使得查找树重新满足红黑树的条件。
25.C++中map和set的区别
在C++中,容器map和set都是用红黑树实现的,但是两者也有区别,具体如下
1.map
经过排序了的二元组的集合,map中的每个元素都是由两个值组成,其中的key(键值,一个map中的键值必须是唯一的) 是在排序或搜索时使用,它的值可以在容器中重新获取;而另一个值是该元素关联的数值。
2.set
包含了经过排序了的数据,这些数据的值(value)必须是唯一的。和 map容器不同,使用 set 容器存储的各个键值对,要求键 key 和值 value 必须相等。所以我们可以认为set就是元素的集合,如 {'a','b','c'} 。
3.总结
map和set的底层实现机制:红黑树(RB-Tree)。
26.一个线程占多大内存?
一个linux的线程大概占8M内存。linux的栈是通过缺页来分配内存的,不是所有栈地址空间都分配了内存。因此,8M是最大消耗,实际的内存消耗只会略大于实际需要的内存(内部损耗,每个在4k以内)。
27.互斥量能不能在进程中使用?
不同的进程之间,存在资源竞争或并发使用的问题,所以需要互斥量。进程中也需要互斥量,因为一个进程中可以包含多个线程,线程与线程之间需要通过互斥的手段进行同步,避免导致共享数据修改引起冲突。可以使用互斥锁,属于互斥量的一种。
28.单核机器上写多线程程序,是否要考虑加锁,为什么?
在单核机器上写多线程程序,仍然需要线程锁。
原因:因为线程锁通常用来实现线程的同步和通信。在单核机器上的多线程程序,仍然存在线程同步的问题。因为在抢占式操作系统中,通常为每个线程分配一个时间片,当某个线程时间片耗尽时,操作系统会将其挂起,然后运行另一个线程。如果这两个线程共享某些数据,不使用线程锁的前提下,可能会导致共享数据修改引起冲突。
29.C与C++区别
- C语言是C++的子集,C++可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
- C++是面向对象的编程语言,C++引入了新的数据类型——类,由此引申出了三大特性(划重点)封装、继承、多态。而C语言则是面向过程的编程语言。
- C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。STL的一个重要特点是数据结构和算法的分离,其体现了泛型化程序设计的思想。C++的STL库相对于C语言的函数库更灵活、更通用。
30.头文件“”和<>的区别
- 尖括号<>的头文件是系统文件,双引号""的头文件是自定义文件
- 编译器预处理阶段查找头文件的路径不一样。
- 使用尖括号<>的头文件的查找路径:编译器设置的头文件路径-->系统变量。
- 使用双引号""的头文件的查找路径:当前头文件目录-->编译器设置的头文件路径-->系统变量。
31.extern "C"
1.作用
extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。
2.编译区别
由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。两种不同的语言,有着不同的编译规则,比如一个函数fun,可能C语言编译的时候为_fun,而C++则是fun。
3.使用场合
在多个人协同开发时,可能有的人比较擅长C语言,而有的人擅长C++,这样的情况下也会有用到。
32.系统调用同步和异步
1.同步
所有的操作都做完,才返回给用户结果。即写完数据库之后,再响应用户,用户体验不好。双方的动作是经过双方协调的,步调一致的。
2.异步
不用等所有操作都做完,就响应用户请求。即先响应用户请求,然后慢慢去写数据库,用户体验较好。双方并不需要协调,都可以随意进行各自的操作。
33.系统调用阻塞和非阻塞
1.阻塞
调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。
2.非阻塞
非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。
34.IO多路复用
1.基础知识
I/O多路复用就是通过一种机制,可以监视多个文件描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知应用程序进行相应的读写操作。没有文件句柄就绪时会阻塞应用程序,交出cpu。IO多路复用解决的本质问题是在用更少的资源完成更多的事。
2.IO多路复用的三种实现方式
1.select
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
select的调用会阻塞到有文件描述符可以进行IO操作或被信号打断或者超时才会返回。select将监听的文件描述符分为三组,每一组监听不同的需要进行的IO操作。readfds是需要进行读操作的文件描述符,writefds是需要进行写操作的文件描述符,exceptfds是需要进行异常事件处理的文件描述符。这三个参数可以用NULL来表示对应的事件不需要监听。
- 单个进程所打开的FD是有限制的,通过FD_SETSIZE设置,默认1024
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)
2.poll
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd { int fd; _/* file descriptor */_ short events; _/* requested events to watch */_ short revents; _/* returned events witnessed */_ };
和select用三组文件描述符不同的是,poll只有一个pollfd数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。events参数是我们需要关心的事件,revents是所有内核监测到的事件。
- poll与select相比,只是没有fd的限制,其它基本一样。
- 每次调用poll,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 对socket扫描时是线性扫描,采用轮询的方法,效率较低(高并发时)。
3.epoll
int epoll_create(int size);
创建一个epoll实例
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
往epoll实例中增删改要监测的文件描述符
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
用于阻塞的等待可以执行IO操作的文件描述符直到超时。
epoll只能工作在linux下
- level-triggered
水平触发表示只要有IO操作可以进行比如某个文件描述符有数据可读,每次调用epoll_wait都会返回以通知程序可以进行IO操作。
- edge-triggered
边缘触发表示只有在文件描述符状态发生变化时,调用epoll_wait才会返回,如果第一次没有全部读完该文件描述符的数据而且没有新数据写入,再次调用epoll_wait都不会有通知给到程序,因为文件描述符的状态没有变化。
select和poll都是水平触发机制,且不可改变,只要文件描述符中有IO操作可以进行,那么select和poll都会返回以通知程序。而epoll两种通知机制可选。
4.select对比poll
poll和select基本上是一样的,poll相比select好在如下几点:
- poll传参对用户更友好。比如不需要和select一样计算很多奇怪的参数比如nfds(值最大的文件描述符+1),再比如不需要分开三组传入参数。
- poll会比select性能稍好些,因为select是每个bit位都检测,假设有个值为1000的文件描述符,select会从第一位开始检测一直到第1000个bit位。但poll检测的是一个数组。
- select的时间参数在返回的时候各个系统的处理方式不统一,如果希望程序可移植性更好,需要每次调用select都初始化时间参数。
而select比poll好在下面几点
- 支持select的系统更多,兼容更强大,有一些unix系统不支持poll
- select提供精度更高(到microsecond)的超时时间,而poll只提供到毫秒的精度。
5.epoll对比poll和select
epoll优于select&poll在下面几点:
- 在需要同时监听的文件描述符数量增加时,select&poll是O(N)的复杂度,epoll是O(1),在N很小的情况下,差距不会特别大,但如果N很大的前提下,一次O(N)的循环可要比O(1)慢很多,所以高性能的网络服务器都会选择epoll进行IO多路复用。
- epoll内部用一个文件描述符挂载需要监听的文件描述符,这个epoll的文件描述符可以在多个线程/进程共享,所以epoll的使用场景要比select&poll要多。
4.三则的区别
35.原子操作
指的是由多步操作组成的一个操作。如果该操作不能原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。有点类似互斥锁,但是原子操作比锁效率更高,这是因为原子操作更加接近底层,它的实现原理是基于总线加锁和缓存加锁的方式。原子操作也可以作为多线程编程的解决方案之一。
36.引用与指针的区别
(1)指针是实体,占用内存空间,引用是别名,与变量共享内存空间。
(2)指针不用初始化或初始化为NULL,引用定义时必须初始化。
(3)指针中途可以修改指向,引用不可以。
(4)指针可以为NULL,引用不能为空。
(5)sizeof(指针)计算的是指针本身的大小,而sizeof(引用)计算的是它引用的对象的大小。
(6)如果返回的是动态分配的内存或对象,必须使用指针,使用引用会产生内存泄漏。
(7)指针使用时需要解引用,引用使用时不需要解引用*。
(8)有二级指针,没有二级引用。
37.witch case
switch语句的判断条件可以接受int,byte,char,short,枚举,不能接受其他类型。不能用浮点数的原因,计算机对浮点数相等的比较精确度不高,容易把两个相等的浮点数当作不等来处理。
38. memcpy()/memmove()/strcpy()
1.memcpy()
memcpy 函数可能会导致行为未定义, 而 memmove 函数能够避免这种问题。
A空白,B有内容。B内容复制到A中,结尾必须加‘/0’,结束标识符。
#include <stdio.h> #include<string.h> int main() { char* s = "http://www.runoob.com"; char d[20]; memcpy(d, s + 11, 6);// 从第 11 个字符(r)开始复制,连续复制 6 个字符(runoob) // 或者 memcpy(d, s+11*sizeof(char), 6*sizeof(char)); //d[6] = '\0'; printf("%s", d);//输出runoob return 0; }
A有内容,B有内容 。A长B短,B内容复制到A中
#include<stdio.h> #include<string.h> int main(void) { char src[] = "***"; char dest[] = "abcdefg"; printf("使用 memcpy 前: %s\n", dest);//打印:abcdefg memcpy(dest, src, strlen(src)); printf("使用 memcpy 后: %s\n", dest);//打印:***defg dest[3] = '/0'; printf("加入/0后: %s\n", dest);//打印:***加‘/0’无法结束后面部分 return 0; }
2.memmove()
memmove 函数能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,复制后源区域的内容会被更改。当src 和 dest 所指的内存区域重叠时,memmove() 仍然可以正确的处理,不过执行效率上会比使用 memcpy() 略慢些。
3.strcpy()
把 src 所指向的字符串复制到 dest,需要注意的是如果目标数组 dest 不够大,而源字符串的长度又太长,可能会造成缓冲溢出的情况
char *strcpy(char *dest, const char *src) char s[]="abcdefgh",*p =s; p += 3; printf("%d\n", strlen(strcpy(p,"ABCD"))); //输出位4
4.strcpy和memcpy区别
- 复制的内容不同。
strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
- 复制的方法不同。
strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,如果空间不够,就会引起内存溢出。memcpy则是根据其第3个参数决定复制的长度。
- 用途不同。
通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy,由于字符串是以“\0”结尾的,所以对于在数据中包含“\0”的数据只能用memcpy。
- 操作对象不同
strcpy的操作对象是字符串,memcpy 的操作对象是内存地址,并不限于何种数据类型。
5.memcpy和memmove区别
memcpy和memmove都是从存储区 str2 复制 n 个字节到存储区str1,两者的主要区别在于: 源存储空间和目标存储空间重叠时,memcpy可能会出错,而memmove不会出错。源存储空间和目标存储空间重叠,有两种情况。
1.情况一
目标存储空间在前,源存储空间在后,两者有重叠部分。这种情况下使用memcpy和memmove是一样的,结果都正确。
2.情况二
源存储空间在前,目标存储空间在后,两者有重叠部分。这种情况下使用memcpy就会产生如图所示的结果,目标存储区域str1变成为{11,12,13,14,11,12},出错。而使用memmove就能正确拷贝数据,因为memmove会判断这种重叠情况,如果出现这种重叠情况,则从后往前拷贝数据。如下图所示。
39.位运算
1.计算1、0的个数
1.求二进制1的个数(与运算)
i = i &(i - 1)
int numOfOne(x){ int count = 0; while(x){ count++; x = x&(x-1); } return count; }
2.利用位运算消除二进制最后一个1
将它们相与result = n&(n-1) = 01101000
,就可以把最后一个1消除了
原理:n-1的话,会一直向前寻找可借的位,从而跳过低位连续的0,而向最低位的1借位,借位后最低位的1变为0,原先最低位1的下一位从0变为1,其余位都不变,相与之后其它位不变,1(最低位1)0 &01(n-1对应的位)= 00,从而消除最低位的1。
3.求二进制0的个数(或运算)
i = i |(i+1)
int numOfZero(x) { while(x+1) { count++; x = x|(x+1); } return count; }
2.两个数交换
方法一
定义一个中间变量
方法二--aba
通过二进制异或方法交换,如a=3二进制为11,b=2的二进制为10,按位异或(两个值相同为0,否则为1)
a=a^b
, 异或后a=11^10=01
b=a^b
, 异或后b=01^10=11(此时b为开始a的值)
a=a^b
异或后a=01^11=10(此时a为开始b的值)
这样子a与b就实现了交换
3.左移&右移&反转
1.指定的某一位数置1
宏 #define setbit(x,y) x|=(1<<y)
2.指定的某一位数置0
宏 #define clrbit(x,y) x&=~(1<<y)
3.指定的某一位数取反
宏 #define reversebit(x,y) x^=(1<<y)
4.获取的某一位的值
宏 #define getbit(x,y) ((x) >> (y)&1)#C/C++##秋招##面试#
嵌入式学习笔记 内容设计C语言基础知识、Linux内存管理、操作系统、Linux进程&线程、串口协议、硬件、RAM汇编等 希望秋招的同学早点下车