理解C语言-1:内存数据对象 English Version
初衷
市面上介绍C语言的书很多,但是大都是从语法和实践入手,以传授”怎么做”或者”怎么做好”为根本目标。俗话说授人以鱼不如授人以渔,虽然我不是C语言的设计者,但是却渴望从”为什么”切入,顺藤摸瓜理顺C语言为什么长成这样。
其次,C语言是现代计算机语言的鼻祖,也是绝大多数高级语言编译器或解释器的实现语言,理解C的设计理念,对于学习各类现代高级语言一定会有所帮助。
废话不多,让我们从内存数据对象入手拆解C语言吧。
C的目标和理念
很多书籍都把C语言归类成系统语言,这揭示了它虽然是高级语言,但却非常接近底层的本质。
在计算机体系中的最底层,数据就是存在于物理内存或者寄存器上的一串串二进制码。物理内存通过物理地址进行寻址(由地址总线的位数决定容量),每个地址对应1Byte数据;寄存器通过助记符(类似rax,ebx)进行寻址,一般一个寄存器对应1个字长的数据。
在没有C语言之前,我们的程序员前辈们就在各种小型机上使用对应的汇编语言操作这些底层数据。
汇编语言是典型的命令式语言,告诉机器”做什么”,一般由以下两部分构成:
- 操作码(op_code)
- 零个或者多个操作数(operand)
其中,操作数可以是立即数/寄存器地址/内存地址。
具体的细节可以查看不同平台对应的指令集手册,这里划下重点:
- 地址在机器指令层面就是被操作的一种数据,用于寻址。取地址是汇编指令中的原生操作(例如lea).
- 高级语言中的类型(变量)在汇编语言中并不存在,汇编语言爱的很少:给它地址和长度,它就把内容还给你。这也是编译过程存在的意义之一
当时,用汇编语言实现的UNIX已经存在了,但是用汇编语言编码应用程序实在繁琐,如何提高生产力成为当务之急。
于是,C语言呼之欲出:
C的目标是通过提供高级语法和针对各平台的编译器,提升编码效率和可移植性,主要受众是理解计算机体系架构的系统程序员。
C秉承相信程序员永远知道自己在干嘛的信条,将系统硬件(主要是内存和寄存器)直接暴露给程序员。
经过长时间的迭代,C自身也对应了一个完整的生态:
- 语言规范
语法/语义标准。 - 标准库(可移植性)
标准库实现了与操作系统无关的一系列常量/头文件/函数,大体分成如下几类:- 和数据类型相关的定义
例如ctype.h/float.h/stddef.h/limits.h - 通用的数据处理函数
例如string.h/locale.h/time.h/math.h/stdio.h/stdlib.h - 运行环境相关
例如errno.h/assert.h/setjmp.h/signal.h/stdarg.h
- 和数据类型相关的定义
- UNIX公用函数库
UNIX作为操作系统,隔离了应用程序和底层的系统硬件。系统调用是UNIX内核态入口。而公用函数库封装了这些系统调用(一个公用函数有可能调用多个系统调用),在用户态提供服务。
其中,UNIX公用函数库和C标准库都由操作系统开发方提供实现。
语言规范和标准库函数由ISO C标准定义。POSIX作为ISO C的超集,定义了更多的库函数,方便C程序在不同的操作系统间移植。
数据对象设计
PASCAL之父Niklaus Wirth有句名言:
“程序=数据结构+算法”
这里的数据结构可以降维理解成数据对象,那么C语言是如何建模数据对象的呢。
引入变量
变量的属性
要通过变量操作数据,其背后需要具备如下几个要素:
- 名字
变量的符号,保存在符号表中。 - 地址(左值)
机器指令通过地址找到字节,因此变量本身就隐含了其对应的地址:在静态编译过程中,会为查找到的变量分配地址。 - 类型
变量的类型既决定了数据的长度,又决定了数据的含义。给定同一个地址addr,一个char类型的变量a会读取接下来8bit的数据,而一个long类型的变量b则有可能对应64bit(这种特性在union的应用显得更直接)。
给定同一个地址addr,一个long类型的变量a和一个float类型的变量b有可能都读取接下来32bit的数据,但是a被解释为整数,而b则被解释为浮点数。
类型可以通过声明(显式/隐式)静态绑定,或者通过赋值动态绑定。
- 值(右值)
一个变量在赋值之后就对应一个值。 - 作用域(scope)
变量在什么范围内可以被引用(可见性)。作用域分为两类:- 静态(文本)作用域:编译器通过分析代码的嵌套关系即可确定
- 动态作用域:根据运行时堆栈调用顺序确定
- 生存期(lifetime)
变量在内存中有效存在(绑定到地址)的周期。
变量的分类
为了提高变量的表达能力,可以把变量分成简单类型和复合类型。简单类型不能再拆解,而复合类型由简单类型/复合类型组合而成。
典型的复合类型包括:
数组
数组在内存中连续布局属于同一种类型的一组数据。
看一组示例代码:1
2
3
4int main() {
int b[4] = {1, 2, 3, 4};
return b[0];
}对应的汇编代码:
1
2
3
4
5
6
7
8
9push %rbp
mov %rsp,%rbp
movl $0x1,-0x10(%rbp) ; assign 1st element
movl $0x2,-0xc(%rbp)
movl $0x3,-0x8(%rbp)
movl $0x4,-0x4(%rbp) ; assign 4th element
mov -0x10(%rbp),%eax ; set return value into eax
pop %rbp
retq结构体(其它语言中的记录)
结构体在内存中连续布局属于不同类型的一组数据。
还是一组示例代码:1
2
3
4
5
6
7
8
9
10int main() {
struct tag {
int a;
char c;
};
struct tag t;
t.a = 1;
t.c = 'c';
return t.a;
}对应的汇编代码:
1
2
3
4
5
6
7push %rbp
mov %rsp,%rbp
movl $0x1,-0x10(%rbp) ; assign 1
movb $0x63,-0xc(%rbp) ; assign 'c', it also shows t2 has be aligned by 16
mov -0x10(%rbp),%eax ; set return value into eax
pop %rbp
retq联合
联合最特殊,它定义一组可能的类型,每个变量只能解析成其中的一个类型。
示例代码:1
2
3
4
5
6
7
8
9
10int main() {
union tag {
int a;
char c;
};
union tag t1, t2;
t1.a = 1;
t2.a = 'c';
return t1.a;
}对应的汇编代码:
1
2
3
4
5
6
7push %rbp
mov %rsp,%rbp
movl $0x1,-0x10(%rbp) ; assign 1 to t1
movl $0x63,-0x20(%rbp) ; assign 'c' to t2
mov -0x10(%rbp),%eax ; set return value into eax
pop %rbp
retq你甚至可以按位指定联合中的成员。
枚举
枚举是将符号与一系列int值相绑定的复杂类型。
示例代码:1
2
3
4
5int main() {
enum e {a, b, c};
enum e v1 = a, v2 = b;
return 0;
}对应的汇编代码:
1
2
3
4
5
6
7push %rbp
mov %rsp,%rbp
movl $0x0,-0x4(%rbp) ; assign 0(a) to v1
movl $0x1,-0x8(%rbp) ; assign 1(b) to v2
mov $0x0,%eax ; set return value into eax
pop %rbp
retq
复合变量的类型同样决定了编译器如何解读这个变量的值。
指针变量
上文提到在汇编语言中,地址本身也是一种可能的数据。因此在C语言中存在与地址相对应的变量类型:指针。
简单的说,指针变量中保存的二进制数据被解析成另一个变量的内存地址,例如:
1 | int *p, a = 1; |
指针的特别之处在于必须同时声明指针变量指向的数据类型,例如:
1 | int *p; // a pointer to int |
因为指针存在的唯一意义就是通过解指针引用获得其引用的变量值或函数,如果不给出类型,编译器无法确定被引用变量的语义。从例子中可以看到,可以把函数也看作一种特殊的指针类型。
可以声明void类型的指针,这种指针大体上是为了通过静态编译,实际使用前总是会被强制转型成需要的类型。它的存在为实现类似动态类型的概念提供可能。
另外,编译器会阻止尝试获取寄存器变量地址的操作:
1 | int *p; // a pointer to int |
变量声明
一句话概括的话,声明是写给编译器的。
两句话说明的话,声明直接表达了变量的类型,即方便编译器生成符号表项及决定如何布局该变量(大小及位置),也使得编译器能够检查后续程序员的语句是否合法,减少程序员的潜在错误。
C语言声明的复杂度体现在以下两个方面:
复杂指针
可以声明指向数组/指针的指针,而数组/指针又存在自己的类型,因此存在复杂的嵌套:
1 | const char * const (*p)[4]; |
- p是一个指向拥有4个元素的不可变指针数组(该数组中的指针指向不可变的字符),
- p_to_func是接受一个整形参数并返回一个指向函数A的函数指针;函数A接受一个整形参数并返回一个指向函数B的函数指针;函数B接受一个整形参数并返回一个整形。
解声明的规则在《C专家编程》的第3章有详细阐述,诀窍是把声明作为表达式求值,这里不再赘述。
编译属性
可以在声明中加入一些能够影响编译器行为的关键字:
- auto
- static
- external
- const
- volatile
- register
这些关键字影响了变量的可链接性或者存储期等(具体详见《C Primer Plus》第12章的内容),或者影响了编译器的优化过程。
数组与指针的羁绊
C程序员都知道:数组名即是指针。
嗯,大体上是这样:
1 | int[] a = {1,2,3,4,5}; |
这也解释了为什么C语言的数组下标从0而不是1开始。
数组在函数传参时也总是会转换成指针的传递(主要是效率考量,避免复制数组内容):
例如:
1 | int func(int *p) { |
编译之后对应汇编代码如下:
1 | // func1 |
可见传参过程中,数组的确被解释为第一个元素的指针。
这里还有《C专家编程》的一个Bug,作者指出在用指针引用外部数组变量时会有问题,例如:
1 | // a.c |
实际情况是根本不会编译通过,编译器会报错:
error: redeclaration of ‘c’ with a different type: ‘char *’ vs ‘char []’
内存布局
变量的内存布局,顾名思义就是指编译器如何决定变量在内存中的地址。汇编过程中有三个段可以安置变量:
- bss(Block Started by Symbol)
放置未初始化的全局变量。 - data
放置已初始化的全局变量。 - stack
在函数中的局部变量可以选择放置在堆栈对应的栈帧中。
还是从样例代码入手:
1 | int a[]; |
通过nm命令查看该文件的符号表,可以确认a被分配在bss,b被分配在data,c被分配在栈帧中(因而不显示):
1 | 000000000060103c B a |
1 | push %rbp |
把变量分配在全局(bss/data)还是局部(stack),确定了变量的生存期:
- 分配在bss/data中的变量,程序运行期间都可以被引用
- 分配在栈帧中的变量,只有当程序运行到该函数内部时才有意义
如果要在递归函数中修改同一个变量的值,需要为它声明static属性。
内存布局的细节可以参看理解C语言-3:编译链接
结论
本文探讨了C语言如何通过变量建模内存中的信息,并列举了变量相关的重要概念。
如果把这种变量设计分成内容和行为两个要素,还可以继续延展我们的思考。
内容
保存内存中的信息是变量最原始的职能。因此它与内存的物理结构关系密切:
假设物理内存不是线性的,或者具有0个/n个地址,都会大幅改变变量的主要设计。
行为
修改变量的值是类似C这样的指令式语言的典型操作;而函数式编程(声明式编程中的子类)则规定所有变量一经分配不得修改。这种做法通过限制副作用的可能减少了人为Bug的发生,成为目前的一股潮流。
参考文献
- 指令集架构
- Perter Van Der Linden(作者), 徐波(译者), (2008). C专家编程
- Stephen Prata(作者), 姜佑(译者), (2016). C Primer Plus
- Robert W. Sebesta, (2016). Concepts of Programming Languages