目录
- C 语言的编译(Compilation)
- 变量类型(Variable Types)
- 字符(Characters)
- C 语言的类型转换(Typecasting)
- 类型函数(Typed Functions)
- 结构体(Structs)
- 成员的对齐与填充(Alignment & Padding)
- 节省结构体对齐填充的空间
- 联合体(Unions)
- `main()` 函数
- True or False?
- 指针(Pointers)
- 地址和值
- 参数传递
- 为什么使用指针?
- 指向不同大小的对象
- `sizeof()` 运算符
- 数组(Array)
- 字符串(Strings)
- 指针运算
- 指向指针的指针(Pointers to Pointers)
本文章系计算机体系结构课程 UCB CS61C: Great Ideas in Computer Architecture 的学习笔记。
C 语言的编译(Compilation)
- C 语言是一种编译型语言。
- C 编译器将 C 程序映射到面向架构(architecture-specific)的机器码中(实际上是一串 0 和 1 的组合)。
-
- 不同于Java ,Java 会通过 JVM 虚拟机将代码转换为独立于架构(architecture-independent)的字节码(bytecode)。
-
- 不同于 Python,Python 直接解释(interpret)代码,C 只是将代码编译为机器码,从而由 CPU 直接解释并运行。
编译的优势如下:
- 出色的运行性能(run-time performance)
- 合理的编译时间——编译过程(Makefiles)的增强允许我们只重新编译修改过的文件
然而,编译也有劣势:
- 编译后的文件——包括对应的可执行文件——是特定于某一体系结构的,平台之间的移植需要进行一定程度上的重构。
- 基于编译的循环「 编辑 ⇒ 编译 ⇒ 运行 ⇒ 【重复】」可能会较慢。
变量类型(Variable Types)
- 整型(
int
,integers)的大小取决于机器! 常见的大小是 4 或 8 字节(32/64 位)。
字符(Characters)
- 将字符编码为数字,ACSII 标准定义了128 个不同字符以及对应的数字。
- 一个
char
类型的变量占用 1 个字节的空间。 - 7 位(bits,比特)的空间足以存储一个字符(27= 128),但是通常会预留一个字符的空间用来处理多个字节(bytes)。
C 语言的类型转换(Typecasting)
- C 是一种弱类型语言,这意味着可以显式地从任何类型进行类型转换。
类型函数(Typed Functions)
- 我们可以定义函数之前,在头文件(header files)声明一个函数的原型(prototypes,例如
int fun(int, int);
),以便其他程序可在不考虑其他已定义的变量的情况下调用该函数作为库(library)。
结构体(Structs)
- 一种定义复合数据类型的方法
typedef struct {
int lengthInSeconds;
int yearRecords;
} Song;
Song tearsInHeaven;
tearsInHeaven.lengthInSeconds = 285;
tearsInHeaven.yearRecords = 1992;
成员的对齐与填充(Alignment & Padding)
- 为了提高内存访问效率而采取的一种策略
- 对齐:数据在内存中存储时,必须按照一定的规则进行排列。通常,数据类型的大小和处理器的字长决定了对齐的要求(例如,一个 32 位的处理器可能要求 32 位的数据类型
int
必须存储在 4 字节 32 位的边界上)。 - 结构成员按其声明顺序进行存储:第一个成员的内存地址最低,最后一个成员的内存地址最高。
- 填充:在结构体成员之间或结构体的末尾添加一些额外的字节,以确保每个成员都能对齐到相应的类型边界。填充字节不存储任何有意义的数据,它们的存在仅仅是为了满足对齐要求。值得注意的是,我们必须保证每一个变量的填充均拥有与自身大小一致的边界(如果有其他变量占用的话,例如
int
类型,它对应地址的左右边界应至少包含 4 个字节,若其中任一边界无其他变量的占用,则那一边界无需占用额外空间)。
例如,我们在 32 位架构上定义一个结构体 foo
:
struct foo {
int a;
char b;
struct foo *c;
};
这一结构体的占用情况如下:
a
:4 字节b
:1 字节- 3 字节的闲置空间
*c
:4 字节(32 位架构的指针占用 32 位即 4 字节)sizeof(struct foo) == 12
节省结构体对齐填充的空间
假设我们有如下结构体定义:
struct hello {
int a;
char b;
short c;
char *d;
char e;
};
如果我们执行 sizeof(struct hello);
这一语句,得到的结果将是 16
。
为了进一步节省空间,我们可以将字符 c
移到 pad c
的位置( ⚠️ 结构体的成员变量是有存储顺序的!):
struct hello {
int a;
char b;
char e; // e 移到这里
short c;
char *d;
};
这样,结构体的大小就缩减为 12
了。随着结构体存储数据的逐渐增多,我们可以通过调整成员变量的先后顺序来优化内存空间的管理。
联合体(Unions)
- 类似于结构体,但联合体只为单个最大元素存储足够的数据,映射所有成员的数据到同一空间,从而节省内存空间,并有助于解释机器码。
union Example {
int i;
float f;
char str[20];
};
union Example u;
u.i = 10;
u.f = 3.14;
strcpy(u.str, "Hello"); // including <string.h>
main()
函数
函数签名为 int main(int argc, char *argv[]);
argc
(argument count)- 包含命令行参数的个数(argc
至少为 1,即执行程序的路径会加入计数,每拥有一个参数就会增加 1)argv
(argument value)- 一个包含指向参数的字符串形式指针的数组,argv
的第一个元素(argv[0]
)是程序的名称,后续的元素(argv[1]
到argv[argc - 1]
)是传递给程序的参数;值得注意的是,argv
实际上总是以null point
结尾,所以argv[argc]
通常为 0。
True or False?
- C 没有显式的布尔(Boolean)变量类型,对应地,C 将
false
评估为0
或者NULL
,而所有非false
的变量即为true
。
指针(Pointers)
地址和值
- 内存相当于一个单链的、巨大的数组:
-
- 每一个数组的单元均有一个地址
-
- 每一个数组的单元均存储一些值
- 指针是包含一个内存地址的变量,它指向内存的某个位置。
int x = 3;
int y = 4;
int *p; //定义一个指针
p = &x; //令 p 指向 x
*p = 5; //修改 x 的值为 5
y = *p; //修改 y 的值为 5
参数传递
- C 按值传递参数,函数会获得一个关于参数的拷贝,因此在函数内部更改参数并不会修改外部传入的参数本身。
- 可以通过按引用(reference)传递参数来更改参数本身,函数接受指针,通过解引用(dereference)来修改值。
void addOne (int *p) {
*p += 1;
}
int y = 3;
addOne(&y);
为什么使用指针?
- 当传递一个巨大的结构体或数组时,传递一个指针远比传递整个数据要更容易、更快速。
- 指针允许使用更简洁的代码。
❗️请小心使用指针,它是 C 语言 bug 的最大单一来源,不正确的处理会导致内存泄漏。
指向不同大小的对象
- 现代机器是按字节寻址的,C 指针只是一个抽象的内存指针。
- 类型定义告知编译器每次通过指针访问地址时需要抓取多少字节。
sizeof()
运算符
- 返回一个变量或数据类型的大小(以字节为单位)。
int x;
int *y;
sizeof(x); // 4 (32-bit int)
sizeof(int); // 4 (32-bit int)
sizeof(y); // 4 (32-bit addr)
sizeof(char); // 1 (32-bit char)
sizeof()
对于数组和结构体的处理也是不一样的:-
- 对于数组,返回整个数组的长度。
-
- 对于结构体,返回其中一个实例(instance)的大小(所有结构体成员以及闲置空间的大小之和)。
数组(Array)
- C 数组并不知道自身的长度,也不会检查索引范围是否溢出。因此,我们必须在传递数组的同时将其长度也一并传递。
- 数组范围的错误会导致不规范的内存访问与写入,从而引发分段错误(Segmentation faults,软件错误,当程序试图访问其无权访问的内存位置时发生)和总线错误(Bus errors,硬件错误,通常发生在程序试图访问无效的内存地址或未对齐的内存地址时),很难查找。
- 数组类似于指针,看起来像是指向第一个元素的常量指针。
-
ar[0]
等价于*ar
;ar[2]
等价于*(ar + 2)
。
- 数组名称并不属于变量,因此求其地址是没有意义的。
- 不要返回在函数内部定义的局部数组变量存储的内存地址,局部变量会在函数执行完成后被销毁。
字符串(Strings)
- C 字符串只是一个字符数组,包含终止符
'\0'
,请在char[]
类型中正确放置空终止符'\0'
。
char letters[] = "abc";
const char letters[] = {'a', 'b', 'c', '\0'};
我们可以使用一个简单的函数 strlen()
来得到字符串的长度:
int strlen(char s[]) {
int n = 0;
while (s[n] != 0) {n++;}
return n;
}
- 如果我们引用
<string.h>
库,有内置的函数可供使用: -
int strlen(char *string)
:返回字符串的长度。
-
int strcmp(char *str1, char *str2)
:如果str1
与str2
相同,返回0
;若不相同,则返回str1
和str2
第一个不匹配的字符间的 ACSII 差值。这与str1 == str2
不同,后者是在检查两者指向的内存地址是否一致。
-
char *strcpy(char *dist, char *src)
:将字符串src
的内容拷贝至目标内存地址,目标地址需要保证有足够的空间(实际长度 + 1)。
指针运算
- 当一个指针加上(或减去)一个整数时,指针向前(向后)移动若干个元素,实际上是改变指针指向的地址从而访问不同元素。移动的元素个数等于整数值乘以指针所指向类型的大小。
- 合法的指针运算有:
-
- 对一个指针加上一个整数
-
- 在同一数组中,对两个指针求差
-
- 比较两个指针
-
- 比较一个指针与
NULL
(表示指向空地址)
- 比较一个指针与
- 不合法也不合理的指针运算有:
-
- 两个指针相加
-
- 两个指针求积
-
- 从整数减去一个指针
- 当存在多个前缀运算符时,它们从右到左应用:
-
*--p
:先使指针p
递减指向相邻的地址,然后解引用读取该地址对应的值。
-
++*p
:先解引用读取指针p
指向的地址对应值,然后使该值立刻自增。
- 对于后缀运算符,它优先于前缀运算符,但将最后生效:
-
*p++
:先解引用指针p
指向的值,再递增指针p
移到下一个元素。
-
(*p)++
:先解引用指针p
指向的值,再使该值稍后自增。
指向指针的指针(Pointers to Pointers)
- 函数要想修改指针(使指针指向不同地址),可以传入形参
**h
,再对指针的指针解引用还原为目标指针p
:
void IncrementPtr(int **h) {
*h = *h + 1;
}
int A[3] = {50, 60, 70};
int *q = A;
IncrementPtr(&q);