文章目录
一、进程的内存映像
C语言中一个进程的内存映像从低地址开始分为正文段、初始化数据段、未初始化数据段、堆区、栈区五大部分:
- Text段:Text段是指用来存放程序执行代码的一块内存区域,是二进制文件(或者说处理器的机器指令)在内存中的映像。当然也有可能包含一些只读的常数变量,例如字符串常量等。这部分区域的大小在程序运行前就已经确定,并且通常只允许进行读操作,向Text段写会导致Segmention Fault。
- Data段:Data段是指存放程序中已经初始化的全局变量和静态变量的一块内存区域。Data段并不是匿名的,而是映射了程序二进制文件中在编译时就已初始化的数据,由程序初始化。
- BSS段:BSS段存放了未初始化的全局变量和静态变量,由操作系统初始化(清零),且是匿名的不映射任何文件,不占用外存空间,只在运行时占用内存。注意,这里的未初始化指的是没有显示初始化,因为全局变量和静态变量会自动隐式初始化为0,但我们没有必要把这些0都存储起来,从而节省外存空间,这也是BSS段的主要作用。
- 堆区:堆提供了程序运行时的内存分配,堆内存的生命周期在函数之外,大部分语言都提供了堆内存管理函数,如C语言的
malloc()
与free()
,因此堆区由用户管理,可控性强。堆内存分配的算法非常复杂,既要保证内存分配的实时和快速,又要尽量避免堆中出现过多碎片,由此也就引申出了FF、BF、WF、NF一系列内存分配算法与分配策略。如果当前堆的内存足够程序使用,则不需要与内核交互,在当前堆中寻找可用内存就行,否则的话需要调用brk()
系统调用向内核申请空间。 - 栈区:栈保存了局部变量、函数形参、返回地址等,调用一个新的函数会在栈上创建一个新的栈帧,当函数返回时这个栈帧会被自动销毁。一个栈帧包括:函数的返回地址和参数、临时变量(包括函数的非静态局部变量以及编译器自动生成的其他临时变量)、栈帧状态值(EBP和ESP,划定了这个函数的栈帧的范围)。栈的空间分配由指令集中专用的机器指令实现,当栈空间用尽后继续push会触发栈空间的扩展,导致Page Fault,然后在内核中调用
expand_stack()
,该函数调用acct_stack_growth()
来判断是否可以增长栈空间,如果当前栈空间的大小小于RLIMIT_STACK
,则可以继续增长栈空间,该过程由内核完成进程不会感知到。当用户的栈空间已经达到允许的最大值时,内核会给进程发送一个Segmentation Fault信号终止该进程,因此进程的栈空间只会增大不会缩小。
二、#define 与 const
#define | const |
---|---|
在预处理阶段被处理,无法调试 | 在编译、运行阶段起作用 |
简单的字符串替换,不带类型 | 定义的常数是只读变量,带类型 |
占用代码段空间 | 占用数据段空间 |
每次替换都会在内存中生成备份 | 只会生成一个备份,节省了内存空间 |
此外,#define
作为一个简单的字符串替换,还会导致边界效应:
#include <stdio.h>
#define X 1
#define Y X+2
#define Z X/Y*3
int main() {
printf("Z:%d\n", Z);
// 你以为:Z = X/Y*3 = 1/3*3 = 1
// 实际上:Z = X/Y*3 = X/X + 2*3 = 1/1 + 2*3 = 7
// 正确写法:#define Y (X+2)
return 0;
}
#define
还可以用于定义宏函数,宏函数避免了出栈和入栈,可以提高程序的执行效率,本质是以时间换空间。
#include <stdio.h>
#define SWAP(x, y) \
x = x + y; y = x - y; x = x - y;
#define MUL(x, y) \
((x) * (y))
#define PRINT(x) \
printf(#x) // # 会将后面的参数看作是字符串
#define CONN(x, y) \
x##y // ## 会进行拼接操作
int main() {
int a = 0;
int b = 1;
SWAP(a, b);
printf("a:%d, b:%d\n", a, b); // a:1, b:0
printf("MUL(a+1, b+1):%d\n", MUL(a + 1, b + 1)); // MUL(a+1, b+1):2
PRINT("a"\n); // "a"
printf("%d", CONN(1, 2)); // 12
return 0;
}
Linux中也应用了很多宏函数,如内核链表中的 list_entry()
、container_of()
与 offsetof()
函数:
#define list_entry(ptr, type, member) container_of(ptr, type, member)
#define container_of(ptr, type, member) ({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); \
})
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
const
修饰的常量由于存储在数据段,是一个伪常量,即一个限定修改的变量,因此无法用于数组初始化以及全局变量的初始化。而且 const
声明常量要在一个语句内完成,主要用于防止调用的函数篡改输入数据。
#include <stdio.h>
int main() {
const int x = 1;
// 直接赋值会报错:
// [Error] assignment of read-only variable 'x'
// x = 2;
// 但可以通过指针间接赋值,只会给出警告:
// [Warning] initialization discards 'const' qualifier from pointer target type [-Wdiscarded-qualifiers]
int *p = &x;
*p = 2;
printf("%d", x);
}
指针 | 含义 |
---|---|
const int *p 与 int const *p | 是一个指向 const int 型变量的指针,p 所指向的内存单元的内容不允许改写,但 p 本身允许改写,属于底层const |
int *const p | 是一个指向int型变量的 const 指针,*p 允许改写,但 p 不允许改写,属于顶层const |
int const *const p | 是一个指向 const int 型变量的 const 指针,因此 *p 与 p 均不允许改写 |
#include <stdio.h>
int main() {
int x = 0;
const int *p = &x;
printf("++x:%d\t", ++x); // 1
printf("*(++p)%d\t", *(++p)); // 垃圾值
// printf("++*p:%d", ++*p); // [Error] increment of read-only location '*p'
return 0;
}
三、全局变量与局部变量
在程序中,局部变量和全局变量的名称可以相同,但是在函数内,如果两个名字相同,会使用局部变量值,全局变量不会被使用,函数的形参也会被视为局部变量。此外,全局变量会保存在内存的数据段中,占用静态的存储单元;而局部变量保存在栈中,只有在所在函数被调用时才动态地为变量分配存储单元。
#include <stdio.h>
int x = 1;
int fun_1() {
int x = 2;
printf("%d\n", x);//2
}
int fun_2(int x) {
printf("%d\n", x);//3
}
int fun_3() {
printf("%d\n", x);//1
}
int main() {
int x = 3;
fun_1();
fun_2(x);
fun_3();
{
int x = 4;
printf("%d\n", x);//4
}
printf("%d", x);//3
return 0;
}
四、运算符优先级
五、位运算
#include <stdio.h>
int main() {
int A = 0x3C; // 0011 1100B
int B = 0x0D; // 0000 1101B
/* 与、或、非、异或、左移、右移 */
printf("%x\n", A & B); // 0000 1100B = CH
printf("%x\n", A | B); // 0011 1101B = 3DH
printf("%x\n", ~ A); // 1100 0011B = FFFF FFC3H
printf("%x\n", A ^ B); // 0011 0001B = 31H
printf("%x\n", A << 1); // 0111 1000B = 78H
printf("%x\n", A >> 1); // 0001 1110B = 1EH
return 0;
}
六、字符与字符串
C语言中 char
的本质是一个整数,输出的是ASCII码对应的字符。可以直接给 char
赋一个整数,输出时会按照对应的ASCII字符输出。此外,char
类型还可以进行运算。
#include <stdio.h>
int main() {
char c1 = 65;
int x1 = 65;
char c2 = 'A';
int x2 = 'A';
printf("c1:%c, x1:%d, c2:%c, x2:%d\n", c1, x1, c2, x2);
// printf:c1:A, x1:65, c2:A, x2:65
printf("c1+'2':%d, c1+'2':%c", c1+'2', c1+'2');
// printf:c1+'2':115, c1+'2':s
return 0;
}
字符 | 对应ASCII码 |
---|---|
换行 | 10 |
空格 | 32 |
# | 35 |
0 | 48 |
A | 65 |
a | 97 |
C语言没有字符串类型,使用字符数组表示字符串:
#include <stdio.h>
#include <stdlib.h>
#define MAX_SIZE 10
int main() {
char *str = (char*) malloc(MAX_SIZE * sizeof(char));
gets(str);
puts(str);
return 0;
}
字符串以 '\0'
作为结束标志,其ASCII码对应的二进制为 00000000B
,所以下面的程序输出的长度为255。
#include <stdio.h>
#include <string.h>
int main() {
char a[1000];
int i;
for (i = 0; i < 1000; i++) {
a[i] = -1 - i;
printf("%d\n", a[i]);
}
printf("%d\n", strlen(a));
return 0;
}
C语言中定义一个字符串有以下三种方式:
char *str_p = "string";
:str_p是一个字符指针保存在栈区,字符串保存在text段,指针值可以修改,但字符串内容只读不能修改。char str_a[] = "string";
:str_a与字符串都保存在栈区,字符串内容可以修改,但str_a是数组首地址,代表这个数组,其值无法修改,即不能作为左值。char *str_m = (char*) malloc(10*sizeof(char));
:str_m是一个字符指针保存在栈区,对应数组内容在堆区动态分配。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
char *str_p = "string";
char str_a[] = "string";
char *str_m = (char*)malloc(10*sizeof(char));
strcpy(str_m, "string");
str_p[1] = 65;//无法赋值
str_a[1] = 65;
str_m[1] = 65;
str_p = str_p + 1;
str_a = str_a + 1;//无法修改
str_m = str_m + 1;
puts(str_p);
puts(str_a);
puts(str_m);
return 0;
}
getch()
、getche()
、getc()
、getchar()
、gets()
、fgetc()
、fgets()
为C语言中七个常用且相似的字符与字符串处理函数。
C语言中getch()、getche()、getc()、getchar()、gets()、fgetc()、fgets()的区别与使用
七、可变参数列表
#include <stdio.h>
#include <stdarg.h>
float avg(int n, ...) {
float ret = 0;
va_list args; // 定义一个参数列表用于存放形参
va_start(args, n); // 初始化参数列表
for (int i = 0; i < n; i ++) {
ret += va_arg(args, int); // 获取所有参数
}
va_end(args); // 清理参数化列表
return ret / n;
}
int main() {
printf("%f", avg(3, 1, 2, 3)); // 第一个参数是传参数量,后面是相应数量的参数
printf("%f", avg(5, 1, 2, 3, 4, 5));
return 0;
}
八、野指针与悬空指针
野指针是一个没有被初始化的指针或者指向访问受限的内存区域的指针,野指针不能通过判断是否为NULL来避免。悬空指针是指指针正常初始化,曾指向一个对象,但该对象对应的内存区域被销毁了,但是指针未置空。
野指针与悬空指针的解引用往往会造成内存越界、段错误等问题,而且这种问题不一定会发生,难以排查,因此必须避免。
#include <stdio.h>
void fun(int **p) {
int x = 10022;
*p = &x;
}
int main() {
int *wild_p; // 野指针
int *dangling_p = NULL;
fun(&dangling_p); // 悬空指针
printf(" wild_p:%p\n", wild_p); // wild_p:0000000000000010
printf(" *wild_p:%d\n", *wild_p); // 这句无法执行
printf(" dangling_p:%p\n", dangling_p); // dangling_p:000000000065FDDC
printf("*dangling_p:%d\n", *dangling_p); // *dangling_p:0
return 0;
}
野指针与悬空指针的产生主要有以下几个原因:
- 局部指针变量没有初始化,比如上例中的wild_p,因此我们在定义局部指针变量时要初始化为NULL。
- 指针所指向的变量在指针使用前被销毁了,比如上例中的dangling_p,因此我们尽量不要再函数中返回局部变量和数组的地址。
- 使用了指向地址已经释放的指针,比如通过
malloc()
申请的堆空间通过free()
释放后又去调用该指针,因此我们在释放内存中的堆空间后要将相关指针变量置为NULL。 - 指针运算错误,比如字符串结尾没有加转义字符
\0
,再比如数组越界,这些C语言都不会帮我们进行检查,而且难以通过调试发现,需要程序员自己注意编程规范,积累编程经验。 - 进行了错误的强制类型转换,比如将int型数据强制转换为指针类型来表示内存地址,很有可能因为数字取值不当,造成段错误。
九、指针数组与数组指针
二者的区别本质上是运算符优先级的问题,C语言运算符中,优先级最高的是 ()
[]
->
.
,其次是+
-
!
~
++
--
(type)*
&
sizeof()
,很明显指针运算符 (type)*
的优先级要低于 ()
与 []
,因此便有了指针数组、数组指针、指针函数、函数指针四组概念。
指针数组:指针数组是一个数组,每个数组元素都是一个指针,相当于一个二级指针。
#include <stdio.h>
int main() {
char *brand[3] = {"Pilot", "Uni", "Sakura"}; // brand即是指针数组
for (int i = 0; i < 3; i++) {
printf("brand[%d]:%s\n", i, brand[i]);
}
return 0;
}
数组指针:数组指针是一个指针,这个指针指向一个数组的首地址,或者说这个指针存放着一个数组的首地址,但要注意区分数组首地址和数组首元素的首地址,对二者进行加一操作,数组首地址会加一个数组的长度(即下图中的16字节),而数组首元素的首地址会加一个数组元素的长度(即下图中的4字节)。
#include <stdio.h>
int main() {
int arr[4] = {0, 1, 2, 3};
int (*p_arr)[4] = &arr; // p_arr即是数组指针
printf(" arr:%p\n", arr); // 数组首元素的首地址
printf(" arr+1:%p\n", arr+1); // 数组下一个元素的首地址
printf("*(arr+1):%d\n", *(arr+1)); // 数组次元素
printf(" p_arr:%p\n", p_arr); // 数组的首地址
printf(" p_arr+1:%p\n", p_arr+1); // 下一个数组(若存在)的首地址
return 0;
}
十、指针函数与函数指针
指针函数:指针函数是一个函数,其返回值为指针类型。
#include <stdio.h>
int *sum(int a, int b) {//sum即是一个指针函数
static int s;
int *p = &s;
s = a + b;
return p;
}
int main() {
printf("*sum(1, 2):%d\n", *sum(1, 2));
/*输出:
*sum(1, 2):3*/
return 0;
}
函数指针:函数指针是一个指针,这个指针指向了一个函数。函数定义存储在代码段,因此每个函数在代码段都有自己的入口地址,而这个函数指针保存的就是函数的入口地址。函数指针最主要的应用就是回调函数。
#include <stdio.h>
int max(int a, int b) {
return a > b ? a : b;
}
/* 回调函数 */
int callback(int a, int b, int (*p)(int, int)){ // p即是一个函数指针
return p(a, b);
}
int main() {
printf("%d", callback(1, 2, max));
return 0;
}
函数指针指针
#include <stdio.h>
void *return_void_pointer(int x) {
printf("%d\n", x);
}
void return_void(int x) {
printf("%d\n", x);
}
int main () {
void *(*p_1)(int); // 指向函数的指针,该函数返回void指针
void *(**p_2)(int); // 指向函数指针的指针,该函数返回void指针
void (*p_3)(int); // 指向函数的指针,该函数返回void
void (**p_4)(int); // 指向函数指针的指针,该函数返回void
p_1 = return_void_pointer;
(*p_1)(1);
p_2 = &p_1;
(**p_2)(2);
p_3 = return_void;
(*p_3)(3);
p_4 = &p_3;
(**p_4)(4);
return 0;
}
十一、多维数组
对于一维数组,数组名是一个指向int型变量的指针,或者说是数组中首元素的地址。
对于二维数组,数组名是一个指向一个一维数组的指针,即一个数组指针。
对于三维数组,数组名是一个指向一个二维数组的指针,即一个数组数组指针。
#include <stdio.h>
int main() {
int a[2][2] = {{0, 1}, {2, 3}};
int (*p)[2] = a; // 一个数组指针
printf("%d\n", p[1][1] );
printf("%d\n", (*(p+1))[1] );
printf("%d\n", *(p[1]+1) );
printf("%d\n", *(*(p+1)+1) );
return 0;
}
liaohan@AtreusdeMBP code % clang++ main.cpp -o main
liaohan@AtreusdeMBP code % ./main
3
3
3
3
#include <stdio.h>
int main() {
char brand[2][2][10] = {{"Uni", "Pilot"}, {"Sakura", "Zebra"}};
char (*a)[2][2][10] = &brand; // 三维数组的地址
char (*b)[2][10] = brand; // 三维数组的首个二维数组的地址
char (*c)[10] = *brand; // 三维数组的首个二维数组的的首个一维数组的地址
char *d = **brand; // 三维数组的首个二维数组的首个一维数组的首个元素的地址
char e = ***brand; // 三维数组的首个二维数组的首个一位数组的首个元素的值
printf("%p\n", a);
printf("%p\n", b);
printf("%p\n", c);
printf("%p\n", d);
printf("%c\n", e);
return 0;
}
Atreus@AtreusdeMBP code % clang++ main.cpp -o main
Atreus@AtreusdeMBP code % ./main
0x16cfe7830
0x16cfe7830
0x16cfe7830
0x16cfe7830
U
#include <stdio.h>
int main() {
int a[2][2] = {{0,0},{0, 0}};
printf("%p\n", a); // 二维数组首地址
printf("%p\n", &a + 1); // 向后移动一个二维数组的大小
printf("%p\n", a + 1); // 向后移动一个一维数组的大小
printf("%p\n", *a + 1); // 向后移动一个元素大小
printf("%d\n", **a + 1); // 元素值加一
return 0;
}
atreus@MacBook-Pro % clang main.c -o main
atreus@MacBook-Pro % ./main
0x16fa235b8
0x16fa235c8
0x16fa235c0
0x16fa235bc
1
atreus@MacBook-Pro %
当然,对于数组名来说,在绝大多数表达式中,数组名的值是指向数组第一个元素的指针,但这个规则有两个例外:
- 一个是
sizeof
运算符作用于数组名时,返回的是整个数组所占用的字节,而不是一个指针所占用的字节。 - 另外一个是单目操作符
&
,它会返回一个指向数组的指针,而不是指向数组第一个元素的指针的指针。
十二、联合体
联合体就使得多个变量共享同一块内存区域,可用于判断处理器大小端、分离变量高位或低位。
#include <stdio.h>
int main() {
union {
short i;
char c[2];
}num;
num.i = 0x0180;
printf("%d\n", num.c[0]); // -128
printf("%d", num.c[1]); // 1
return 0;
}
通过输出可以看出 0180H
在内存中的存储为 1000 0000 0000 0001B
,所以处理器为小端。
十三、结构体
结构体边界对齐规则:
- 编译器按照成员列表的顺序给每个成员分配内存。
- 当成员需要满足正确的边界对齐时(一般
int
需要字对齐,short
需要半字对齐),成员之间用额外字节填充。 - 结构体的首地址必须满足结构体中边界要求最为严格的数据类型所要求的地址。
- 结构体的大小为其最宽基本类型的整数倍。
#include <stdio.h>
#include <stdlib.h>
typedef struct A {
char x[5];
int xx;
double xxx;
} A;
typedef struct B {
char x;
A xx;
} B;
int main() {
A a;
B b;
printf("%d\n", sizeof(a)); // 24
printf("%d\n", sizeof(b)); // 32
return 0;
}
C中各种类型所占内存字节大小如下:
#include <stdio.h>
int main() {
printf("%lu\n", sizeof(char)); // 1
printf("%lu\n", sizeof(short)); // 2
printf("%lu\n", sizeof(int)); // 4
printf("%lu\n", sizeof(float)); // 4
printf("%lu\n", sizeof(long long)); // 8
printf("%lu\n", sizeof(double)); // 8
return 0;
}
结构体里面的指针如果指向一个结构体类型,那么这个结构体必须是自己,而且对应 struct
不可省略。
结构体可以通过以下三种方式初始化:
#include <stdio.h>
typedef struct Student {
const char *name;
int score;
}Student;
int main() {
// 按照成员声明的顺序初始化
Student s1 = {"Xayah", 91};
printf("s1.name:%s, s1.score:%d\n", s1.name, s1.score);
// 指定初始化,成员顺序可以不定,Linux内核多采用此方式
Student s2 = {
.name = "Rakan",
.score = 92
};
printf("s2.name:%s, s2.score:%d\n", s2.name, s2.score);
// 指定初始化,成员顺序可以不定
Student s3 = {
name: "Neeko",
score: 93
};
printf("s3.name:%s, s3.score:%d\n", s3.name, s3.score);
return 0;
}
十四、枚举类型
枚举类型一般用来解决幻数(幻数就是具体的数,但他反映不出数字所代表的意义),这对于程序的修改和理解就带来了很大的麻烦,我们可以通过枚举或宏定义给这些数值赋予含义:
#include <stdio.h>
enum times{
MIN_TIMES = 1, // 最小循环次数
MAX_TIMES = 10 // 最大循环次数
};
int main() {
enum times t = MAX_TIMES;
for (int i = 1; i <= t; i++)
printf("%d\n", i);
return 0;
}
十五、关于 main 函数
main函数其实默认带三个参数,分别为:
int argc
:整型变量,命令行中的参数个数。char *argv[]
:指针数组,存储命令行中的具体参数。char *env[]
:指针数组,环境变量。
在Linux中执行以下函数:
#include <stdio.h>
int main(int argc, char *argv[], char *env[]) {
printf("argc = %d\n", argc);
printf("\n");
for (int i = 0; i < argc; i++) {
printf("argv[%d] = %s\n", i, argv[i]);
}
printf("\n");
int i = 0;
while(env[i] != NULL) {
printf("env[%d] = %s\n", i, env[i]);
i ++;
}
}
十六、关于 printf 函数
#include <stdio.h>
int main() {
printf("%o\n", 8); // 10,八进制无符号整数
printf("%x\n", 16); // 10,十六进制无符号整数
char *str = "atreus";
printf("%s\n", str); // atreus,输出字符串
printf("%c\n", str[0]); // a,输出单个字符
printf("%p\n", str); // 0000000000404008,输出指针地址
return 0;
}
此外还可以用 printf("\b")
覆盖多余输出,但是无法删除输出,因为 '\b'
的退格指的是将光标从当前位置向前移动一个字符(遇到 '\n'
或 '\r'
则停止移动),并从此位置开始继续输出字符。
#include <stdio.h>
int main() {
for (int i = 0; i < 10; i++) {
printf("i=%d", i);
printf(",");
}
printf("\b.");
/* i=0,i=1,i=2,i=3,i=4,i=5,i=6,i=7,i=8,i=9.*/
return 0;
}
十七、外部函数
十八、交换两个数
交换两个数有如下三中常用方法,但要注意,前两种方法使用前必须检查x与y的地址,如果二者地址相同(即是同一个数)会使结果为0。
#include <stdio.h>
int main() {
int x = 0, y = 1;
// Method 1
x = x + y;
y = x - y;
x = x - y;
printf("x:%d, y:%d\n", x, y);
// Method 2
x = x ^ y;
y = x ^ y;
x = x ^ y;
printf("x:%d, y:%d\n", x, y);
// Method 3
int t = 0;
t = x;
x = y;
y = t;
printf("x:%d, y:%d\n", x, y);
return 0;
}
十九、#include
#include <文件名> // 需要包含标准库头文件或者实现版本所提供的头文件
#include "文件名" // 需要包含针对程序所开发的源文件
#include
有以上两种使用方法,二者均由给定的C语言实现版本决定 #include
命令所指定文件的搜索路径,同时,也由实现版本决定文件名是否区分大小写。
搜索路径区别如下:
- 对于命令中使用尖括号指定的文件
<文件名>
,预处理器通常会在特定的系统路径下搜索,例如/usr/include
。 - 对于命令中使用双引号指定的文件
"文件名"
,预处理器通常首先在当前目录下寻找,也就是包含该程序其他源文件的目录。如果在当前目录下没有找到,那么预处理器也会搜索系统的#include
路径。文件名中可以包含路径,但如果文件名中包含了路径,则预处理器只会到该目录下寻找。
二十、return 和 exit
return()
是C的关键字,是语言级别,表示调用堆栈的返回。
exit()
是C的库函数,是系统调用级别,会立即终止调用进程。任何属于该进程的打开的文件描述符都会被关闭,该进程的子进程由进程1继承,初始化,且会向父进程发送一个 SIGCHLD
信号。一般0表示正常退出,非零表示非正常退出。
二者在 main()
函数中执行时没有明显区别。