引言从这一讲开始我们就进入到了“C 核心语法实现篇”的学习。在这一模块中我们将围绕 C 语言的七大类核心语法深入探寻隐藏在语法背后程序代码的实际执行细节。C 语言让我们能够用一种可移植、结构化且具有人类可理解语义的方式构建我们的程序。C 标准中详细描述了 C 语言在语法和语义两个层面的基本内容但对于实现者比如编译器来说如何将这些语法和语义体现在具体的机器指令汇编代码上标准并未给出详细规定。所以问题来了在这层抽象背后一个 C 程序中的各类语法结构究竟是如何映射到机器能够识别的不同指令上的相信学完这一模块你就能找到这个问题的答案从而对程序的运行有更细粒度的把控。今天我们就从最基本的数据和量值开始。相信你在第一次接触编程这个概念时就会了解到一个完整的程序是由“算法”与“数据结构”两部分组成的。其中算法代表程序会以怎样的具体逻辑来运行数据结构代表程序运行过程中涉及数据的具体组织方式。而在一门编程语言中数据便是以不同类型“量值”的形式被组织在一起并交由算法进行处理的。所以我们可以说数据和量值是程序运行的基石。今天我们先从日常使用 C 语言时最直观的编码方式开始介绍 C 语言中的量值和数据。然后由源代码的“表象”到计算机内部我们来看看数据在计算机中存储时是如何被编码的。最后我们再来一起看下程序中的各类数据究竟被存放在哪里。C 语言中的量值与数据量值可以被粗略地分为变量variable与常量constant其中变量是指值可以在整个应用程序的生命周期中被多次改变的量而常量则与之相反在被定义后便无法被再次修改。作为一种高级语言C 语言为我们提供了可用于定义常量与变量的语法。那么首先我们就来看看不同的量值在 C 语言中是怎样体现的。变量C 语言为我们提供了众多的语言关键字keyword以用来定义相应类型的数据。比如在下面这个例子中我们通过以下几步成功定义了多个变量使用 int 等关键字来指定数据的具体类型为该数据设置一个名称通过 “” 赋值运算符为该数据设定具体的值。int x -10; // 定义一个整型变量 char y c; // 定义一个字符变量 double z 2.0; // 定义一个双精度浮点变量这里的变量具有三部分信息即变量对应的名称、所表示数据的具体类型以及当前的数据值。接下来我们围绕着 C 变量的类型、大小及符号性三个方面来详细地看一看。C 语言提供了众多的关键字可用来指定变量的类型这些类型均以字节作为单位来表示变量可容纳数据的最大宽度。例如char 类型的数据仅占用 1 个字节而 long long 类型则至少占用 8 个字节。除了最常见的用于表示数值的类型外C90 与 C99 标准还提供了 void空类型、_Bool布尔型、_Complex复数类型等类型关键字以用于指定其他非数值类型。当然C 语言中变量类型占用的具体字节大小还与程序运行所在的硬件体系结构紧密相关这也是 C 语言与其他高级编程语言有所不同的地方。C 语言最初被设计时高效性就是设计者考虑的一个主要因素。因此 C 标准委员会在考虑语言设计时会参考来自于底层硬件体系的某些因素。比如C 标准中规定int 类型的大小为执行环境架构体系所建议的自然大小。所谓自然大小可以简单理解为对于该大小的数据硬件体系能够以最高的效率进行处理。因此硬件体系不同对应的自然大小便也不同这也就意味着同一种 C 变量类型在不同硬件体系上可能会有着不同的大小。而对于 Rust 和 Java 这些语言来说它们的语言标准中直接规定了各类型的具体大小。编译器作为编程语言与硬件体系之间的抽象层它可以确保上层类型在被编译到机器指令时不会给程序的实际运行带来可观测的差异。当然保持完全不变的类型大小的代价是一定的性能开销只是在大多数情况下这部分开销并不可观。除了可以为变量指定不同的数据类型外同大多数其他静态类型语言类似在 C 语言中整型变量本身还需区分它们的“符号性signedness”。简单来说其实就是两种情况若类型仅可以存放正数则为无符号unsigned类型若正负数都可以存储则为有符号signed类型。比如下面这行代码中我们定义了一个无符号整型变量unsigned int ux 10;符号性上的区别有利于程序对某些特定的场景需求进行优化。比如在编写一个票务系统时每张票对应的编号只可能为正整数因此在使用 C 语言编写程序时便可将票编号对应的变量定义为无符号类型。这样对于同样的整数类型由于不用存储对应的符号位便可以存放更多的正整数其可表示的正整数范围会更大。常量说完了变量在 C 语言中的体现我们再来看看常量。在 C 语言中通过内联方式直接写到源代码中的字面量值一般被称为“常量”。我们在前面提到过常量的一个性质即“它们被定义后无法被再次修改”。这也就意味着这些常量数据无法灵活地被开发者操控它们只能在程序最开始出现的地方发挥作用。比如在前面定义变量的一系列代码中出现的 “-10”、“2.0” 等数字值便是常量。这些值在被拷贝并赋值给相应的变量后便结束了使命。这个时候可能有同学想问用 const 关键字按照与定义变量相同语法定义的量不也是常量吗它与字面量常量有什么区别呢这是一个非常棒的问题。一般来说我们会按照下面的方式使用 const 关键字const int vx 10; const int* px vx;通常来说在 C 语言中使用 const 关键字修饰的变量定义语句表示对于这些变量我们无法在后续的程序中修改其对应或指针指向的值。因此我们更倾向于称它们为“只读变量”而非常量。当然在程序的外在表现上二者有一点是相同的其值在第一次出现时便被确定且无法在后续程序中被修改。只读变量与字面量常量的一个最重要的不同点是使用 const 修饰的只读变量不具有“常量表达式”的属性因此无法用来表示定长数组大小或使用在 case 语句中。常量表达式本身会在程序编译时被求值而只读变量的值只能够在程序实际运行时才被得知。并且编译器通常不会对只读变量进行内联处理因此其求值不符合常量表达式的特征。误用只读变量和常量会导致编译错误下面这段代码展示了这类错误#include stdio.h int main(void) { const int vx 10; const int vy 10; int arr[vx] {1, 2, 3}; // [错误1] 使用非常量表达式定义定长数组 switch(vy) { case vx: { // [错误2] 非常量表达式应用于 case 语句 printf(Value matched!); break; } } }数据的存储形式上面我们介绍了数据在编程语言中的体现方式这是程序员能够接触到数据的最初位置。随着源代码被编译数据的实际使用形式开始变得不透明起来。接下来我们就一起看看在计算机内部数据是以怎样的形式被存放的。对于大多数计算机而言通常其内部会使用补码Two’s-complement的格式来存放有符号整数使用直接对应的二进制位格式来存放无符号整数使用 IEEE-754 标准编码格式来存放浮点数也就是小数。实际上计算机在看待数据时并不会区分其符号性而符号性的差异仅体现在计算机指令操作数据时的具体使用方式上。在接下来的内容中我不会过多讲解这三种编码方式的基本概念而是会带你看看它们都有哪些值得关注的特征。因为无符号整数的存储格式较为直接和简单因此我们主要来看下补码和 IEEE-754 这两种编码方式。补码我们先来看补码的几个特点。使用补码来存放有符号整数的一个优点是CPU 在针对有符号数进行加减法计算时不需要由于加数的符号性不同而采用多个底层加法电路这样便可减轻电路设计的负担另一方面也可以降低 CPU 的物理尺寸。一个补码所表示的实际数值由其负权重位的值与正权重位的值求和而来其中负权重位对应于最高有效位MSB的符号位即该位的二进制值在计算时按负值累加。其余各位一起对应正权重位即这些位对应的二进制值在计算时按正值累加。那具体该怎样计算呢我们来看一个简单的例子。假设我们有一组补码 “1101”那么应该如何得到它对应的有符号整数值呢按照顺序我们首先计算得到该补码对应负权重位的值为 -8而正权重位的值为 5因此该补码对应的实际值为 -3-85。具体计算步骤可以参考下图图中的 B2T 表示 “Binary to Two’s-complement”即“二进制转补码”在计算负权重位时其权重应取负值正权重位取正值。通过上面的计算过程你可以清楚地看到对于一个 4 位补码它可以表示的最大值与最小值分别是多少。计算最大值时符号位置 0其他位均置 1可以得到能表示的最大值 7。计算最小值时符号位置 1其他位均置 0可以得到最小值 -8。负整数的值可表示范围比正整数多 1 个这也是所有有符号整数的一个重要特征。到这里我们了解了补码的基本计算方式。那我要向你提出一个小问题补码的英文名称是 Two’s-complement 可直译为“对数字 2 的补充”那为什么会叫这个名字呢你可以先停下来思考一下然后再来看看我的理解首先我们来计算一下有符号整数 3 对应的四位补码可以得到一个二进制序列 “0011”。将该二进制序列与上述 -3 对应的二进制序列相加通过进位可以得到序列 “10000”该序列可以表示无符号正整数 16。因此我们可以得到这样一个结论对于非负数 x我们可以用 2w−x 来计算 −x 的 w 位表示。套用在上述的例子中可以得到“在四位补码的情况下对于非负数 3可以用无符号数 13 (即 16−3) 的位模式来表示有符号数 -3 的位模式”这个结论即两者位模式相同。而补码的英文名称正是对 x、−x 和 2w 三者之间的关系进行的总结。我们在前面提到过计算机不会区分数据的符号性符号性的差异仅由计算机指令如何使用数据而定。比如在 C 语言中当对某类型变量进行强制类型转换时其底层存储的数据并不会发生实质的变化而仅是程序对如何解读这部分数据的方式发生了改变。比如下面这个例子#include stdio.h int main(void) { signed char x -10; unsigned char y (unsigned char)x; printf(%d\n, y); // output: 246. return 0; }其中有符号整型变量 x 会按照位模式 1111 0110 的补码形式存放有符号数 -10而如果将该序列按照无符号整数的位模式进行解读则可得到如程序运行输出一样的结果即无符号整数值 246。 总之程序在进行强制类型转换时不会影响其底层数据的实际存储方式。在 C 语言中关于数据使用还有一个值得注意的问题变量类型的隐式转换Implicit Type Conversion。C 语言作为一种相对而言的“弱类型”语言其一大特征就是在某些特殊情况下变量的实际类型会发生隐式转换。在下面这个例子中定义的两个变量 x 与 y 分别为有符号整数和无符号整数且变量 x 的值明显小于变量 y但程序在实际运行时却会进入到 x y 的分支中这就是因为发生了变量类型的隐式转换。#include stdio.h int main(void) { int x -10; unsigned int y 1; if (x y) { printf(x is smaller than y.); } else { printf(x is bigger than y.); // this branch is picked! } return 0; }实际上在上面的代码中程序逻辑在真正进入到条件语句之前变量 x 的类型会首先被隐式转换为 unsigned int 即无符号整型。而根据数据类型的解释规则原先存放有 -10 补码的位模式会被解释为一个十分庞大的正整数而这个数则远远大于 1。IEEE-754我们上面主要介绍了有符号整数的补码以及它在和无符号整数交互时的一些特性。而对于浮点数类型大多数计算机体系会选择使用 IEEE-754 标准作为其编码格式。IEEE-754 是一个被众多硬件浮点计算单元FPU采用的浮点数标准这个标准解决了浮点数在硬件实现上的很多问题使其更具可移植性。对于 IEEE-754一个值得介绍的特点是它对浮点数的存储格式设计使得计算机可以简单地使用对于整数的排序函数来对浮点数进行排序。举个例子对于无符号数的二进制序列来说0010 的值肯定要小于1000 2 8。这对计算机来说很好判断。而对基于 IEEE-754 编码的 8 位浮点数4 位阶码位3 位小数位二进制序列 0 0001 001 和 0 1110 111 来说判断其大小也同样十分简单。除去最左侧的符号位外直接将其余各位当作无符号整数序列值进行比较所得结果同样适用于对应的浮点数序列。当然同整数一样C 语言在对浮点数进行类型转换时无论隐式还是显式也都不会对底层存放的浮点数据进行改动而只是将对应位序列的解释方式从浮点数改为了其他方式。在 C 语言中双精度浮点类型 double 具有作为隐式类型转换的最高优先级。当在一个表达式中存在该类型的变量时计算机会首先将其他参与变量均转换为该类型然后再进行表达式求值。数据的存储位置了解了数据的基本存储形式我们再来看看数据会被存放在哪里。在 C 语言中通过不同的语法形式我们可以定义具有不同数据类型的变量这些变量按照其定义所在位置可以被划分为局部变量、全局变量。进一步地通过添加 static 关键字可以将变量标记为静态类型以延长变量的生存期并限定其可见范围为当前编译单元即当前所在源文件通过添加 register 关键字还可以建议编译器将变量值存放到寄存器中以提升其读写性能。对于上面提到的这些变量形式其可能的数据存放位置均不尽相同。根据变量定义时使用的不同语法形式我总结了变量数据的可能存放位置如下表所示需要注意的是表格里和这一讲后面提到的以 “.” 作为开头的标识都指代对应的 Section 结构。这些结构我会在第四个模块中为你详细介绍这里你可以先有个整体感知。接下来我将具体介绍这些变量数据的可能存放位置。先来看初始化的全局变量和静态变量这类变量的值具有与应用程序同样长的生命周期其值通常会被存放到进程 VASVirtual Address Space虚拟地址空间内的 .data 中。VAS 我同样会在第四个模块中详细介绍这里先不展开了。你可以先这样简单理解应用程序在被正常加载和运行前需要首先将应用程序代码及其相关依赖项的数据映射到内存中的某个位置这段包含有应用程序正常运行所必备数据的内存段即进程的 VAS。像 .data 等以 “.” 开头作为标记的 Section 结构都代表着该内存段中的某个具体位置这些 Section 结构都为应用的正常运行提供了各方面支持。局部变量是我们在编写程序时最常使用的一种变量形式。一般来说这些变量将被存放在寄存器或应用程序 VAS 的栈内存中具体使用哪种方式则依赖于编译器的选择。除此之外未初始化的全局变量和静态变量以及直接通过 malloc、calloc 等标准库函数创建的内存块中所包含的数据其存放位置也有所不同。它们被分别存放到进程 VAS 的 .bss 以及堆内存中。这部分内容我也会在第四个模块中详细介绍。最后不同类型常量数据的存储方式也会有所不同。如下表所示由于常量本身的不可变特征它们会按照数据的大小和类型被选择性存放到进程 VAS 的 .rodata 以及 .text 中。其中.rodata 用于存放只读Read-only Data数据而 .text 通常用于存放程序本身的代码。一般的规律是若内联的常量值较大则会被单独存放到 .rodata 中保存否则会直接内联到应用程序代码中作为机器指令比如最常见的 mov 指令的字面量参数。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了 C 语言中与量值和数据相关的基本语法形式、数据实际存储时的具体编码方式以及数据在程序运行过程中的实际存储位置等相关知识。在 C 语言中我们可以通过多种语法形式来控制一个变量的属性比如变量的类型、生存期、值存储位置等。数值类型变量所具有的符号性为我们进一步精细化程序逻辑提供了可能。在计算机内部数据以二进制比特位的形式进行存放和使用。根据类型它们会被选择性地特殊编码为相应的补码或 IEEE-754 格式C 语言仅决定了如何从程序逻辑方面解释和使用这些数据而不会对数据怎样存储产生影响。走入底层不同的 C 变量定义语法形式决定了数据不同的存放位置而寄存器、栈、堆以及各类存在于进程 VAS 中的 Section 结构都可能成为数据的存放地点。和变量相比常量则显得“轻巧”很多它们无法在程序运行过程中被灵活修改其数据存放位置也失去了更多的可能性。