Skip to content

数据类型基础:整数与内存

如果你之前接触过 Python,可能会记得写 x = 42 就完事了——不用告诉 Python 这个 x 是整数还是小数,解释器自己猜。但到了 C 这边,规矩就变了:每一个变量在出生的时候,我们必须明确告诉编译器"这家伙到底是什么类型的"。乍一看像是多此一举,但实际上这个"声明类型"的动作,是 C 语言性能强大的根基——编译器因为知道每个变量占多少内存、数据怎么存储,才能生成最高效的机器码。

我们这整篇 C 教程最终的目的是为学 C++ 做铺垫,而 C++ 在 C 的类型系统上做了大量的强化工作。理解了 C 的类型"哪里容易出问题",后面学 C++ 的"怎么解决这些问题"就会非常自然。所以我们先老老实实把 C 的类型系统吃透,从最基础的整数开始。

学习目标 完成本章后,你将能够:

  • [ ] 理解 C 语言整型家族的层级关系和各类型的保证范围
  • [ ] 区分有符号和无符号整数的存储方式和适用场景
  • [ ] 熟练使用 <stdint.h> 提供的固定宽度类型
  • [ ] 使用 sizeof 运算符测量类型和变量占用的内存大小

环境说明

我们接下来的所有实验都在这个环境下进行:

  • 平台:Linux x86_64(WSL2 也可以)
  • 编译器:GCC 13+ 或 Clang 17+
  • 编译选项:-Wall -Wextra -std=c17

所有代码都是标准 C,不依赖任何平台特定的 API。如果你用的是 macOS 或者 Windows 上的 MinGW,大部分实验也能跑,只是某些类型的字节数可能略有差异——我们后面会专门说这个问题。

第一步——搞清楚 C 怎么存整数

用"盒子"来理解数据类型

我们可以把内存想象成一大排编了号的盒子。每个盒子能放一个字节(byte)的数据。当你声明一个变量的时候,编译器帮你分配了若干个连续的盒子,变量名就是贴在这些盒子上的标签。数据类型决定了两件事:这个变量占几个盒子,以及盒子里的 0 和 1 怎么解读。

举个最直观的例子:int 在大多数现代平台上占 4 个盒子(4 字节 = 32 位),可以存大约正负 21 亿范围内的整数。char 只占 1 个盒子(1 字节 = 8 位),能存的数字范围小得多,但省空间。

整型家族的全家福

C 语言提供了五种标准整型,按能表示的范围从小到大排列:

类型标准保证的最小位数常见实际位数(32/64 位平台)
char8 位8 位
short16 位16 位
int16 位32 位
long32 位32 位(Windows)/ 64 位(Linux/macOS)
long long64 位64 位

注意一个关键点:C 标准只规定了每种类型的最低保证位数,编译器可以给更多但不能给更少。这就是为什么同一份代码在不同平台上可能有不同的行为——你写的 long 在 Windows 上是 32 位,在 Linux 上是 64 位,如果你的程序依赖 long 的精确宽度,跨平台的时候大概率会踩坑。

⚠️ 踩坑预警long 的宽度在不同操作系统上不一样——Windows 是 32 位,Linux/macOS 是 64 位。如果你的代码需要精确控制整数宽度,千万别用 long,用我们后面讲的固定宽度类型。

另外有一个细节值得注意:sizeof(char) 永远等于 1,这是标准规定的。但在某些稀奇古怪的 DSP 平台上,一个"字节"可能不是 8 bit。我们日常用的 x86、ARM 平台上一个字节都是 8 bit,所以暂时不用纠结这个。

来验证一下——各类型到底占多少字节

我们来写一个小程序,实际看看每种类型在你的机器上占多大:

c
#include <stdio.h>

int main(void)
{
    printf("char:      %zu 字节\n", sizeof(char));
    printf("short:     %zu 字节\n", sizeof(short));
    printf("int:       %zu 字节\n", sizeof(int));
    printf("long:      %zu 字节\n", sizeof(long));
    printf("long long: %zu 字节\n", sizeof(long long));

    return 0;
}

编译运行:

bash
gcc -Wall -Wextra -std=c17 sizeof_demo.c -o sizeof_demo && ./sizeof_demo

笔者在 Linux x86_64 上的运行结果:

text
char:      1 字节
short:     2 字节
int:       4 字节
long:      8 字节
long long: 8 字节

如果你在 Windows 上跑,long 那一行很可能是 4 字节——这就是我们刚才说的跨平台差异。

第二步——有符号还是无签名?

什么是"有符号"

整型家族里的每个成员(除了 char 比较特殊)都有两种变体:signed(有符号)和 unsigned(无符号)。这个"签名"指的是正负号——有符号类型能存正数和负数,无符号类型只能存非负数,但同样大小的内存能表示的范围翻倍。

打个比方:同样是 8 个灯泡排成一排,如果约定"第一个灯泡亮表示负号",那剩下 7 个灯泡能表示的数字范围是 -128 到 127;如果不需要负号,8 个灯泡全部用来表示数字,范围就变成了 0 到 255。

c
int signed_num = -42;           // 有符号,可以存负数
unsigned int unsigned_num = 42; // 无符号,只能存非负数

char 的符号问题

char 比较特殊——标准没有规定它到底是有符号还是无符号,这取决于编译器。在 ARM 平台上 char 通常是无符号的,在 x86 上通常是有符号的。这个区别看起来不起眼,但如果你拿 char 当"小整数"来用,跨平台的时候可能会踩坑:

c
char c = 200;           // 如果 char 是有符号的,实际存储的是 -56
unsigned char uc = 200; // 无论平台如何,值都是 200

⚠️ 踩坑预警 当你需要一个"小整数"(0~255 的范围)的时候,请用 unsigned char,不要用 charchar 的符号性取决于编译器和平台,拿它当整数用迟早会出问题。

无符号整数的回绕

无符号整数有一个明确的规则:溢出时会回绕。也就是说,如果你存了一个无符号数然后加 1 超过了它的最大值,它会从 0 重新开始。比如 8 位无符号数的最大值是 255,255 + 1 = 0

但是有符号整数溢出就危险了——这是未定义行为(Undefined Behavior,简称 UB)。简单理解就是:标准规定了"不许这样做",如果你的程序这么写了,编译器可以按任何方式处理——可能看起来正常,可能算出错误结果,可能直接崩溃。更阴险的是,编译器在优化的时候可能会假设"溢出永远不会发生",然后悄悄删掉你写的溢出检查代码。关于 UB 我们在运算符那一篇会专门展开。

⚠️ 踩坑预警 有符号整数溢出是未定义行为。INT_MAX + 1 的结果是不可预测的,不是"回绕到负数"。永远不要依赖有符号溢出的行为。

第三步——跨平台怎么办?固定宽度类型来救场

问题出在哪

刚才我们看到了 long 在 Windows 上是 32 位、在 Linux 上是 64 位的问题。如果你在写一个需要精确控制数据宽度的程序——比如和硬件打交道时需要确保一个变量恰好是 32 位——直接用 int 或者 long 是不安全的,因为它们的实际宽度因平台而异。

C99 标准给出的解决方案是 <stdint.h> 头文件。它提供了一组名字里直接带着位数的类型别名:

c
#include <stdint.h>

int8_t   i8  = -128;          // 精确 8 位有符号
uint8_t  u8  = 255;           // 精确 8 位无符号
int16_t  i16 = -32768;        // 精确 16 位有符号
uint16_t u16 = 65535;         // 精确 16 位无符号
int32_t  i32 = -2147483648;   // 精确 32 位有符号
uint32_t u32 = 4294967295U;   // 精确 32 位无符号
int64_t  i64 = 9223372036854775807LL;  // 精确 64 位有符号
uint64_t u64 = 18446744073709551615ULL; // 精确 64 位无符号

这些类型的好处是"所见即所得"——int32_t 在任何支持它的平台上都是恰好 32 位,uint8_t 永远是 8 位无符号。在嵌入式开发和跨平台代码中几乎是必用的。

需要注意的是,标准并不保证所有平台都提供全部的精确宽度类型。比如某些 DSP 可能就没有 8 位的寻址能力,那 int8_t 就不存在——编译的时候会直接报错。不过在我们日常使用的 x86 和 ARM 平台上,所有精确宽度类型都是可用的。

size_t——标准库里到处都是的家伙

在继续之前,我们还需要认识一个在标准库里到处出现的类型:size_t。它是 sizeof 运算符的返回类型,也是 strlenmalloc 等函数使用的类型。size_t 是无符号的,大小随平台变化——32 位平台上是 32 位,64 位平台上是 64 位。

c
#include <stddef.h>

size_t len = 100;       // 足以表示任何对象的大小

后续我们会经常和 size_t 打交道。目前只需要记住一点:当你需要表示"数量"或"大小"的时候,用 size_t 就对了

来验证一下——固定宽度类型的大小

c
#include <stdio.h>
#include <stdint.h>

int main(void)
{
    printf("int8_t:    %zu 字节\n", sizeof(int8_t));
    printf("uint8_t:   %zu 字节\n", sizeof(uint8_t));
    printf("int32_t:   %zu 字节\n", sizeof(int32_t));
    printf("uint32_t:  %zu 字节\n", sizeof(uint32_t));
    printf("int64_t:   %zu 字节\n", sizeof(int64_t));
    printf("size_t:    %zu 字节\n", sizeof(size_t));

    return 0;
}

编译运行:

bash
gcc -Wall -Wextra -std=c17 stdint_demo.c -o stdint_demo && ./stdint_demo

运行结果:

text
int8_t:    1 字节
uint8_t:   1 字节
int32_t:   4 字节
uint32_t:  4 字节
int64_t:   8 字节
size_t:    8 字节

很好,每种类型的字节数都和我们预期的一致。

第四步——sizeof:丈量内存的尺子

sizeof 不是函数

sizeof 是一个编译期运算符,不是函数。它在编译的时候就完成了计算,程序运行的时候没有任何开销。它的返回类型是 size_t,打印的时候用 %zu 格式说明符。

c
int x = 42;
printf("%zu\n", sizeof(x));     // 变量:输出 4(在 int 是 4 字节的平台上)
printf("%zu\n", sizeof(int));   // 类型名:同样输出 4

sizeof 在数组上有一个经典用法——计算数组元素个数:

c
int arr[] = {10, 20, 30, 40, 50};
size_t count = sizeof(arr) / sizeof(arr[0]);  // 20 / 4 = 5
printf("数组有 %zu 个元素\n", count);

原理很简单:sizeof(arr) 是整个数组占的总字节数,sizeof(arr[0]) 是单个元素的字节数,相除就是元素个数。

⚠️ 踩坑预警 这个"用 sizeof 算元素个数"的技巧只在数组定义的作用域内有效。一旦数组被传给函数,它就退化为指针了,sizeof 返回的是指针的大小(4 或 8),而不是数组的大小:

c
void bad_sizeof(int arr[])
{
    // arr 在这里已经是指针了!
    printf("%zu\n", sizeof(arr));  // 输出 4 或 8(指针大小),不是数组大小
}

关于数组退化为指针的机制,我们会在指针那一篇详细展开。这里先记住"数组传给函数就变指针"这个结论就行。

C++ 衔接

C++ 完全继承了 C 的所有整型,同时做了几件重要的事情让类型系统更安全。

首先,C++11 引入了 <cstdint> 头文件(注意没有 .h 后缀),功能和 C 的 <stdint.h> 一致,但类型被放进 std 命名空间里。其次,C++ 的 {} 初始化会禁止"窄化转换"——你不能用一个超出目标类型范围的值来初始化变量:

cpp
int x = 3.14;      // C/C++ 都允许,隐式截断为 3(编译器可能警告)
int y{3.14};        // C++ 编译错误!窄化转换被禁止
uint8_t z{1000};    // C++ 编译错误!1000 超出 uint8_t 范围

这个特性在消除一整类隐式转换 bug 方面非常有效。如果你以后写 C++ 代码,强烈建议养成用 {} 初始化的习惯。

小结

到这里,我们对 C 语言整数存储的基本机制有了一个清晰的认识。核心要点可以用几句话概括:C 标准为每种整型只规定了最低保证位数,实际宽度因平台而异,跨平台代码应该使用 <stdint.h> 的固定宽度类型。有符号和无符号的区别不仅仅是"能不能存负数",它们的溢出行为完全不同——无符号回绕是合法的,有符号溢出是未定义行为。sizeof 是我们在编译期丈量内存的工具,配合数组可以计算元素个数,但要注意数组传给函数后会退化为指针。

接下来问题来了:整数讲完了,小数怎么办?字符怎么存?变量声明了之后能不能保护它不被意外修改?这些就是我们下一篇要讨论的内容。

练习

练习 1:类型探测器

编写一个程序,打印以下所有类型的 sizeof 值,并对照标准检查它们是否满足最低保证:

c
// 请补全代码,对以下所有类型打印 sizeof
// char, short, int, long, long long
// int8_t, uint8_t, int32_t, uint32_t, int64_t
// size_t

提示:可以用一个宏来减少重复代码。

练习 2:溢出观察

分别对有符号 int 和无符号 unsigned int 做溢出实验:

c
#include <stdio.h>
#include <limits.h>

int main(void)
{
    int i = INT_MAX;
    unsigned int u = UINT_MAX;

    printf("INT_MAX  = %d,  INT_MAX + 1  = %d\n", i, i + 1);
    printf("UINT_MAX = %u, UINT_MAX + 1 = %u\n", u, u + 1);

    return 0;
}

编译运行,观察两者的行为差异。然后加上 -fsanitize=undefined 选项重新编译,看看有什么变化。

参考资源

基于 VitePress 构建