数据类型基础:整数与内存
如果你之前接触过 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 位平台) |
|---|---|---|
char | 8 位 | 8 位 |
short | 16 位 | 16 位 |
int | 16 位 | 32 位 |
long | 32 位 | 32 位(Windows)/ 64 位(Linux/macOS) |
long long | 64 位 | 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,所以暂时不用纠结这个。
来验证一下——各类型到底占多少字节
我们来写一个小程序,实际看看每种类型在你的机器上占多大:
#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;
}编译运行:
gcc -Wall -Wextra -std=c17 sizeof_demo.c -o sizeof_demo && ./sizeof_demo笔者在 Linux x86_64 上的运行结果:
char: 1 字节
short: 2 字节
int: 4 字节
long: 8 字节
long long: 8 字节如果你在 Windows 上跑,long 那一行很可能是 4 字节——这就是我们刚才说的跨平台差异。
第二步——有符号还是无签名?
什么是"有符号"
整型家族里的每个成员(除了 char 比较特殊)都有两种变体:signed(有符号)和 unsigned(无符号)。这个"签名"指的是正负号——有符号类型能存正数和负数,无符号类型只能存非负数,但同样大小的内存能表示的范围翻倍。
打个比方:同样是 8 个灯泡排成一排,如果约定"第一个灯泡亮表示负号",那剩下 7 个灯泡能表示的数字范围是 -128 到 127;如果不需要负号,8 个灯泡全部用来表示数字,范围就变成了 0 到 255。
int signed_num = -42; // 有符号,可以存负数
unsigned int unsigned_num = 42; // 无符号,只能存非负数char 的符号问题
char 比较特殊——标准没有规定它到底是有符号还是无符号,这取决于编译器。在 ARM 平台上 char 通常是无符号的,在 x86 上通常是有符号的。这个区别看起来不起眼,但如果你拿 char 当"小整数"来用,跨平台的时候可能会踩坑:
char c = 200; // 如果 char 是有符号的,实际存储的是 -56
unsigned char uc = 200; // 无论平台如何,值都是 200⚠️ 踩坑预警 当你需要一个"小整数"(0~255 的范围)的时候,请用
unsigned char,不要用char。char的符号性取决于编译器和平台,拿它当整数用迟早会出问题。
无符号整数的回绕
无符号整数有一个明确的规则:溢出时会回绕。也就是说,如果你存了一个无符号数然后加 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> 头文件。它提供了一组名字里直接带着位数的类型别名:
#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 运算符的返回类型,也是 strlen、malloc 等函数使用的类型。size_t 是无符号的,大小随平台变化——32 位平台上是 32 位,64 位平台上是 64 位。
#include <stddef.h>
size_t len = 100; // 足以表示任何对象的大小后续我们会经常和 size_t 打交道。目前只需要记住一点:当你需要表示"数量"或"大小"的时候,用 size_t 就对了。
来验证一下——固定宽度类型的大小
#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;
}编译运行:
gcc -Wall -Wextra -std=c17 stdint_demo.c -o stdint_demo && ./stdint_demo运行结果:
int8_t: 1 字节
uint8_t: 1 字节
int32_t: 4 字节
uint32_t: 4 字节
int64_t: 8 字节
size_t: 8 字节很好,每种类型的字节数都和我们预期的一致。
第四步——sizeof:丈量内存的尺子
sizeof 不是函数
sizeof 是一个编译期运算符,不是函数。它在编译的时候就完成了计算,程序运行的时候没有任何开销。它的返回类型是 size_t,打印的时候用 %zu 格式说明符。
int x = 42;
printf("%zu\n", sizeof(x)); // 变量:输出 4(在 int 是 4 字节的平台上)
printf("%zu\n", sizeof(int)); // 类型名:同样输出 4sizeof 在数组上有一个经典用法——计算数组元素个数:
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),而不是数组的大小:
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++ 的 {} 初始化会禁止"窄化转换"——你不能用一个超出目标类型范围的值来初始化变量:
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 值,并对照标准检查它们是否满足最低保证:
// 请补全代码,对以下所有类型打印 sizeof
// char, short, int, long, long long
// int8_t, uint8_t, int32_t, uint32_t, int64_t
// size_t提示:可以用一个宏来减少重复代码。
练习 2:溢出观察
分别对有符号 int 和无符号 unsigned int 做溢出实验:
#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 选项重新编译,看看有什么变化。