Skip to content

讲义:C 语言的类型系统与内存模型

施工中

本页面正在施工:

  • 将较难内容迁移到课后模块。就程序设计这门课来说,需要讲述的内容抽象程度较高,如大端法、小端法等具体内容则无需讲解。与计算机系统有关的内容应当迁移到扩展模块
  • 优化讲义结构

Abstract

  • 程序内存空间的布局
  • C 类型系统的描述
  • 如何阅读声明
  • 如何管理内存

Tip

在本节讲义中,若无特别说明,我们默认采用 C99 标准。

前置:数、进制与数据

  • 十进制:20231029
  • 二进制:[0b] 1001101001011001101110101
  • 八进制:[0o] 115131565
  • 十六进制:[0x] 134b375

你可以通过 %x 格式化字符串来打印十六进制数。

printf("%x\n", 20231029);
printf("%d\n", 0x20231029);

如何阅读它们?

这些都是“十”:0b10100o120xa10

  • 0x18
    • 零埃克斯十八零叉十八零乘十八
    • 零埃克斯一八零叉一八
  • 0o23
    • 零欧二十三
    • 零欧二三
  • 0b1010
    • 零币一千零一十
    • 零币一零一零
  • 0x10000
    • 零埃克斯一万零叉一万零乘一万
    • 零埃克斯一零零零零零叉一零零零零

ASCII

每个字符对应一个数字,即其 ASCII 码。如 A ASCII 码为 65a ASCII 码为 97

类型系统

类型是与数据相关的属性,它决定了数据的存储方式可进行的操作

变量是数据的载体,它是存储在内存中的一块空间,有类型

计算机如何存储数据?

在一台 s390x 架构的计算机上:

MAGIC_R(0x20231029);
// =====
// 0x20231029: 4 (0x4) byte(s)
// 0000  20 23 10 29

大端序与小端序

大端和小端名称的来源

来源于《格列佛游记》中的大小端之争:

我下面要告诉你的是,Lilliput Blefuscu 这两大强国在过去 36 个月里一直在苦战。战争开始是由于以下的原因:我们大家都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端,可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了。因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。老百姓们对这项命令极其反感。历史告诉我们,由此曾经发生过 6 次叛乱,其中一个皇帝送了命,另一个丢了王位。这些叛乱大多都是由 Blefuscu 的国王大臣们煽动起来的。叛乱平息后,流亡的人总是逃到那个帝国去寻求避难。据估计,先后几次有 11000 人情愿受死也不肯去打破鸡蛋较小的一端。关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派任何人不得做官

主流架构(如 x86、AMD64、ARM)使用小端序。一些不常见的架构(如 SPARC)使用大端序。网络协议使用大端序

先来练练手

你应当已经知道,不同类型的变量一般占据不同的字节数。

int i = 0x12345678;
MAGIC(i);

long long ll = 0xfedcba9876543210LL;
MAGIC(ll);

double d = 3.14159265358979323;
MAGIC(d);

float f = d;
MAGIC(f);

char c = 'A';
MAGIC(c);

MAGIC_R('A');

MAGIC_R((short)ll);

MAGIC("Hello world! I am a l" "ong string.");
在小端序计算机上可能的运行结果
=====
i: 4 (0x4) byte
0000  78 56 34 12
=====
ll: 8 (0x8) byte
0000  10 32 54 76 98 ba dc fe
=====
d: 8 (0x8) byte
0000  18 2d 44 54 fb 21 09 40
=====
f: 4 (0x4) byte
0000  db 0f 49 40
=====
c: 1 (0x1) bytes
0000  41
=====
'A': 4 (0x4) byte <!--(1)!-->
0000  41 00 00 00
=====
(short)ll: 2 (0x2) byte
0000  10 32
=====
"Hello world! I am a l" "ong string.": 33 (0x21) byte
0000  48 65 6c 6c 6f 20 77 6f 72 6c 64 21 20 49 20 61
0010  6d 20 61 20 6c 6f 6e 67 20 73 74 72 69 6e 67 2e
0020  00
  1. 其类型为 int,故占用 4 字节。

基本类型

类型 - cppreference.com

整数类型:char short int long long long

其中,除了 char 以外的类型默认为 signed,即有符号数。也就是说,int 就是 signed int。将 signed 换为 unsigned,就得到了无符号数。

那么 char 呢?

char signed char unsigned char三个不同的类型,尽管在大多数实现中,char 表现为有符号数。

字符类型解惑

或许你会和我同样对以下几个问题感到困惑:

  • 为什么字符常量的类型是 int 且长度是 4 个字节?
  • 为什么 getchar() 等函数返回 int 而不是 char
  • 宽字符、多字节字符和 Unicode 究竟如何使用?

这里将解释前两个问题,第三个问题不做要求,有兴趣可以参看中的相关内容。

  • 字符常量的类型为什么是 int

多字符常量(Multicharacter constants)继承于 C 语言的前身 B 语言。它们的主要用途是用于编写汇编语言,因为汇编语言中的指令通常是多字节的。例如,'abcd' 可以用于表示一个 32 位的指令。

C 标准中,多字符常量被定义为 int 类型,长度是 4 个字节。在 C 语言的实际使用中,多字符常量通常是出于调试目的而嵌入结构中的魔数(Magic Numbers),就像有些人会使用 0xfeedbeef0xdeadbeef 而不是 NULL 来标记指针的未初始化已删除状态。这样做的好处是,如果程序出现了错误,我们可以通过打印出这些魔数来定位错误的位置。

我们使用时应当避免将多字符常量从 int 类型转换为 char 类型,因为这一转换过程是由编译器实现决定的。比如 char a = 'ABCD',在 gcc、clang、msvc 上均为 a = 'D',但是在 armcc 上为 a = 'A'

  • getchar() 为什么要返回 int 类型?

因为它会返回 EOF,而 EOF 在标准中定义为 int 类型,通常为 (int)-1

This macro is an integer value that is returned by a number of narrow stream functions to indicate an end-of-file condition, or some other error situation.

为什么要这么定义?从逻辑上说,EOF 应当与任何一个字符值都不同(char)-1 也是一个合法的字符(因为它是 char 类型,根据 Latin-1 编码,char 类型的每个可能值都表示一个字符,所以不能用作 EOF,必须使用 (int)-1,它与前者宽度不同,因此是不同的值。

还记得的在类型转换中提到的整形提升吗?如果我们让 getchar() 返回 (char)-1,当函数接收到 (char)-1 时,它会执行从无符号数到有符号数的转换(即使实现为有符号的 char,从而返回 (int)255,这与 EOF 的定义不符。

参考资料:

浮点类型:float double long double

复数类型

在其后加上 _Complex 即为复数类型,如 double _Complex。相同的,有 _Imaginary

是的,C 在语言层面上支持复数和虚数,但并不是所有的编译器都支持这一特性。例如,gcc clang 目前均不支持 _Imaginary

定长整数类型与其他整数类型

stdint.h 中定义,如 int8_t uint8_t int16_t uint16_t int32_t uint32_t int64_t uint64_t

sizeof 运算符与 offsetof 宏的结果:size_t,足够表示任何对象的大小。常被定义为 unsigned long

stddef.h 中定义,ptrdiff_t 表示两个指针相减的结果。常被定义为 long

如何输出这些整数类型?

inttypes.h 中定义了一系列格式化字符串,如 PRId32 一般展开为 "d",而 PRIu64 可能展开为 "llu"

这些定义在不同的平台上可能有所不同。使用这些格式化字符串可以保证在不同平台上输出正确的结果(且不会引发编译器警告

uint64_t i = 0xdeadbeefcafebabe;
printf("%" PRIu64 "\n", i); // 应当包含 inttypes.h

对于 size_tptrdiff_t,可以使用 %zu%td

结构体、联合体、枚举类型

枚举类型实质上是整数类型。它的值是由编译器自动分配的(一般从 0 开始,也可手动指定。

结构体的大小是其成员大小的总和,加上对齐所需的填充字节。

struct point {
  int x;
  long y;
};
struct point p = {1234, -5678};
MAGIC(p);

union un_t {
  long l;
  double d;
};
union un_t un;
un.d = 3.14159265358979323;
MAGIC(un);

enum en_t { ENA, ENB, ENC = 114514 };
MAGIC_R(ENA);
MAGIC_R(ENC);

MAGIC_PTR(&p.x);
MAGIC_PTR(&p.y);
MAGIC_PTR(&un.l);
MAGIC_PTR(&un.d);
在小端序计算机上可能的运行结果
=====
p: 16 (0x10) byte
0000  d2 04 00 00 00 00 00 00 d2 e9 ff ff ff ff ff ff
=====
un: 8 (0x8) byte
0000  18 2d 44 54 fb 21 09 40
=====
ENA: 4 (0x4) bytes
0000  00 00 00 00
=====
ENC: 4 (0x4) bytes
0000  52 bf 01 00
&p.x: 0x16d3cee18
&p.y: 0x16d3cee20
&un.l: 0x16d3cee10
&un.d: 0x16d3cee10

字符串,数组,函数,指针类型

数组是一系列相同类型的对象的集合。数组的大小是其元素大小的总和。

int arr[] = { 0xbeef, 0xcafe, 0xdead, 0xface, 0xfeed };
MAGIC(arr);

char str1[] = "Hello world!";
const char *str2 = "Hello world!";
MAGIC(str1);
MAGIC(str2);

main 函数为例,其有两种(标准规定的)形式:

int main(void);                   // 类型为 int(void)
int main(int argc, char *argv[]); // 类型为 int(int, char *[])

C 标准不允许对函数类型应用 sizeof 运算符。

所有的指针类型都拥有相同的大小。我们将在后面的章节中讨论指针类型。

数组到指针退化

void 类型

void 是一个不完整类型,即不存在 void 类型的变量。它作为函数的返回类型,表示函数没有返回值。它作为函数的参数类型,表示函数不接受参数。

void f(void) { /* 实现略去 */ }
void g(void) {
  return f(); // 仅当 f 的返回类型为 void 时才能这样写
}
那么 void * 呢?

void * 是一个完整类型,它表示一个指针,指向未知类型的对象(即“舍弃”了类型信息的指针

任何类型的指针均可隐式转换为 void * 类型,但是反过来不行。这是因为 void * 类型的指针不知道指向的对象的大小,因此不能进行解引用操作。

int i = 0x12345678;
void *p = &i;
printf("%p\n", p);
printf("%d\n", *(int *)p);
什么是不完整类型?

不完整类型是指只知道其存在,但无法知道其大小的类型。比如:

struct incomp;

我们只知道 struct incomp 存在,但是不知道它的大小(因为没有给出其定义。因此,struct incomp 是一个不完整类型。但是可以声明指向 struct incomp 类型的指针。

void 类型是唯一可作为函数返回类型的不完整类型。这是 C 语法的一条特殊规定,其意义就是标定“函数没有返回值”。

使用 typedef 为现有的类型定义别名

int a;

你已经很熟悉这样的声明了,对吧?a 是一个 int 类型的变量。

typedef int a;

这就是说,a 现在就是 int 的别名。你可以这样使用它:

a b; // 等价于 int b;

我们之前提到过的 size_tptrdiff_tint8_t 等类型,就是通过 typedef 定义的。

考试中还会要求你阅读代码片段,为形参等位置填写类型声明。请参考历年卷总结中的例题。

C C++ struct 并不完全一致

对于结构体

struct point {
  int x;
  int y;
}

C++ 中,此声明引入的新类型名为 point,而在 C 中,此声明引入的新类型名为 struct pointpoint 本身不是一个类型名。

下面的写法是常见的:

typedef struct point point;

此即定义了一个类型名 point,其为 struct point 的别名。

内存模型

C 语言的内存模型中,字节(byte)是最小的可寻址的内存单元,其被定义为一系列连续的(bit可寻址意味着每个字节都拥有其编号,即地址

MAGIC_PTR(&i);
MAGIC_PTR(&ll);
MAGIC_PTR(&d);
MAGIC_PTR(&f);

MAGIC_SIZED(f, 0x20);

地址和指针

地址是一个无符号整数,它表示内存中的一个字节。指针是一个变量,其存储了一个地址。由于这个原因,所有指针类型的大小都是相同的。

MAGIC_R(&i);
MAGIC_PTR(&i);

对象和标识符

每个被存储的值都占用一定的物理内存,这样的一块内存称为对象。对象可以储存一个或多个值。声明变量时,创建了一个标识符(identifier,其与对象相关联。

所以,定义一个变量时实际上做了两件事:

  • 为对象分配内存
  • 将标识符与对象关联

对象的其他含义

“面向对象编程”中的对象指的是“类对象”。C 语言中没有“类对象”这一概念。

思维训练:

int *psi = &i;

MAGIC(i);
MAGIC_R(*psi);
MAGIC(psi);
MAGIC_PTR(&i);
MAGIC_PTR(&psi);
在小端序计算机上可能的运行结果
=====
i: 4 (0x4) bytes
0000  78 56 34 12
=====
*psi: 4 (0x4) bytes
0000  78 56 34 12
=====
psi: 8 (0x8) bytes
0000  4c ae db 6f 01 00 00 00
&i: 0x16fdbae4c
&psi: 0x16fdbae08

我们可以观察到什么?

  • psi 变量的值就是 i 关联的对象的地址(0x16fdbae4c
  • 通过 psi 可以访问 i 关联的对象(*psi
  • psi 本身作为一个变量,也有自己的地址(0x16fdbae08

左值与右值

指代对象的表达式被称为左值。这个术语来自于赋值语句,因为赋值语句的左边必须是一个对象。

如果可以使用左值改变对象中的值,那么称为可修改的左值

  • i 是标识符,是可修改的左值
  • *psiarr[3] 是表达式,是可修改的左值
  • 2 * i&i&psi,不是标识符,是右值
const char *pc = "Good morning my neighbors";
  • pc 是可修改的左值
  • *pc 是不可修改的左值

参考:值类别 - cppreference.com

内存管理

C 的内存管理是一大痛点,因为它本身并不提供检查机制,一切都依赖于程序员自己。因此,好好掌握内存管理对于写出安全、稳定的程序是非常必要的。

int *dangling() {
  int i = 0xdeadbeef;
  int *p = &i;
  return p;
}

int main() {
  int *pfi = dangling();
  MAGIC(pfi);
  MAGIC(*pfi);
}

在这里 pfi 称为悬垂指针(dangling pointer,它指向了一个已经被销毁的对象。使用悬垂指针是未定义行为,可能会导致程序崩溃。

常见变式

char *get_a_string() {
  char str[] = "Hello world!";
  return str;
}
int main() {
  char *str = get_a_string();
  printf("%s\n", str);
}

问题出在哪?

str 作为一个数组,其生命周期在 get_a_string 函数返回时结束。

它与下面的程序有什么区别?

char *get_a_string() {
  char *str = "Hello world!";
  return str;
}
int main() {
  char *str = get_a_string();
  printf("%s\n", str);
}

存储期、作用域和链接

存储期描述对象,表明在内存中存储的时间。

作用域和链接性描述标识符,表明程序的哪些部分可以使用它。

存储期

  • 静态存储期(static) 在程序开始时创建,在程序结束时销毁。如全局变量和静态变量。
  • 自动存储期(automatic) 在程序进入作用域时创建,在程序离开作用域时销毁。如局部变量。
  • 动态分配存储期(allocated) 在程序显式地分配内存时创建,在程序显式地释放内存时销毁。如 malloc() 分配的内存。
#include <stdlib.h>
int global;
int main() {
  int automatic;
  int *allocated = malloc(sizeof(int));

  MAGIC_PTR(&global);
  MAGIC_PTR(&automatic);
  MAGIC_PTR(allocated);
}

作用域

作用域 - cppreference.com

块作用域
Note

C99 之前,块作用域的变量必须在块的开头声明。

C99的概念被放宽:控制语句(条件、循环)也是块(即使没有使用花括号。比如:

for(int i = 0;;)
  function();

i 具有块作用域,是循环的一部分。

更精细的定义:循环体是整个循环的子块。你可以这样想它:

{
  for(int i;;) {
    int i;
  }
}

上面的两个同名变量 i 不在同一个块中,因此不是重复定义。

Tip

同名的变量,内部覆盖外部。

函数作用域

goto 语句的标签。这意味着,即使标签在内层的块中,它的作用域也延伸至整个函数。

函数原型作用域

函数原型中的变量名。从定义处到函数原型结束。

只在你使用 VLA 时需要注意这一顺序:

void use_VLA(int n, int m, ar[n][m]);
文件作用域

在任何函数外定义的变量。从定义处到文件末尾。

这样的变量称为全局变量。

翻译单元

你所认为的多个文件对于编译器来说可能是一个文件。比如头文件:预处理时,头文件被插入。对于编译器来说,它看到的是单个文件。

这样的单个文件称为一个翻译单元。每个翻译单元对应一个源文件和它 include 的文件。

刚才我们说的文件作用域其实是在整个翻译单元可见


由于讲义中涉及到的内容较多,无法在一节课内全部讲解。后续的讲义将会随回放链接更新而更新。

链接

阅读和撰写类型声明