目录

你可能是一个经验丰富的程序员,也可能是一个初入此道的新人,但你是否想过数据类型到底代表着什么?

当我们写下 int a; float b; double c;后,编译器到底会做哪些事情?数据类型又是如何影响机器指令的生成的?

今天我们就一起探索一下数据类型的本质。

数据类型是怎么来的

熟悉计算机发展史的朋友肯定都知道:早期的程序是直接写给机器的,最早的程序是直接通过在纸带上打孔来编程的,这其实就是0和1二进制编码的早期形态。这就是最早的编程方式,这个时候是没有数据类型的概念的。

汇编语言里,一条 add 指令操作的是寄存器或内存里的一串比特,至于这串比特代表有符号整数、浮点数还是别的什么,完全由程序员自己记在脑子里。同一种加法指令,操作整数和操作浮点数在硬件上是两套不同的电路,如果选错了就会得到无意义的结果。

最早把整数和浮点数区分开的高级语言之一是 Fortran。它在 1954 年设计、1957 年交付编译器时,靠变量名的首字母隐式区分类型:I 到 N 开头的变量是整数,其余按浮点处理。这么设计的原因很务实,Cardelli 与 Wegner 在关于类型演化的综述里指出:整数运算和浮点运算的硬件表示不同,用整数做循环下标和用浮点做数值计算,逻辑上本就是两回事。

把类型从隐式变成显式,并引入编译期类型检查,是 ALGOL 60(1960 年)的贡献。它要求变量必须先声明类型,编译器据此检查赋值与运算是否合法。这是类型第一次从“程序员的约定”变成“语言强制的约束”。

此后出现了一段有趣的回头路。BCPL(1967 年)和它所衍生的 B 语言是无类型的:所有数据都是统一宽度的机器字,由程序员决定怎么解释。C 语言(1972 年,Ritchie)又把类型重新引入,但刻意贴近硬件,只保留 char、int、long、指针等少数几种、且宽度与机器字长挂钩的类型。C++(1985 年,Stroustrup)在 C 的基础上加入了用户自定义类型(类、结构体、联合体、枚举)与模板,类型体系从此既能贴近硬件,又能抽象成高层的领域模型。

回看这条脉络,类型从诞生起它的职责可以概括如下:

  1. 告诉编译器数据怎么表示(占多少字节、怎么对齐),否则无法分配内存。
  2. 让编译器在编译期拦截非法操作(把字符串赋给整数、对函数做加法),否则错误只能到运行时才暴露。
  3. 给编译器提供优化依据(严格别名下不兼容类型指针通常不别名同一对象;有符号整数溢出为未定义行为时编译器可据此作假设),这一点在后面的有符号/无符号循环与 TBAA 示例里都能看到。

什么是数据类型,它有什么用

一个具体的数据类型通常隐含传递了如下三个潜规则:

  1. 它限定了取值的集合
  2. 它限定了允许的操作
  3. 它限定了值在内存中的表示方式。

以 C++ 为例,它的类型体系可以用下面这颗树表示。

图1:C++ 类型分类树,基础类型经派生得到指针/引用/数组等,再组合成用户自定义类型

落在代码里,数据类型对程序的作用体现在以下三个层面:

  1. 内存表示。类型决定了对象的大小与对齐要求。sizeof 给出字节数,alignof 给出对齐量(地址须是它对齐量的整数倍)。这两个量正是后文分析内存布局与对齐的基础。
  2. 语义解释。同样 32 个比特,当成 int 看待和当成 float 看待,得到的数值天差地别。下面这张图给出同一个 32 位模式 0x3F800000 在两种类型下的不同解释。

图2:同一段 32 位比特,按 int 解释是 1065353216,按 IEEE-754 float 解释是 1.0

  1. 作约束与优化的依据。类型圈定了哪些运算合法、哪些结果确定。比如对无符号整数做减法,结果按 2 的 N 次方取模,是良好定义的;而对有符号整数,溢出是未定义行为,编译器因此可以把“不会发生溢出”作为假设去优化。无符号与有符号在比较、回绕上的差异,见下文有符号与无符号循环的汇编对比;严格别名如何省掉内存重读,见再后面的 TBAA 示例。

数据类型对内存和程序的影响

类型在内存上最直接的体现,是决定对象占多少字节、按什么边界对齐;结构体排布时,为满足对齐,成员之间常会多出填充字节(padding)。

看一个具体结构:

// 用来观察编译器如何插入 padding 的结构体
struct Demo {
    char   a;   // 1 字节,对齐要求 1
    int    b;   // 4 字节,对齐要求 4
    char   c;   // 1 字节,对齐要求 1
    short  d;   // 2 字节,对齐要求 2
};

在 x86-64 System V ABI 的典型对齐规则下,各成员按声明顺序排布,但要各自满足对齐要求:

  1. a 在偏移 0;b 要求 4 字节对齐,于是编译器在 a 之后插入 3 字节填充,b 落到偏移 4;
  2. c 在偏移 8;d 要求 2 字节对齐,偏移 9 不满足,于是 d 落到偏移 10,偏移 9 再补 1 字节填充;
  3. 最终 sizeof(Demo) 是 12,而不是 1+4+1+2=8。

下图按字节画出 Demo 的 12 个位置,灰色即填充。

图3:Demo 的 12 字节布局,灰色为编译器插入的填充字节,b 因对齐落在偏移 4

填充不只是多占几字节,而且还改变了访问成员时的机器指令。

从下面汇编代码可以看出:取 b 成员的函数,其汇编代码里实际访问的是偏移 4 的位置。

read_b(Demo const&):
    push    rbp
    mov     rbp, rsp
    mov     QWORD PTR -8[rbp], rdi   # 保存引用 d(即对象地址)
    mov     rax, QWORD PTR -8[rbp]   # rax = &d
    mov     eax, DWORD PTR 4[rax]    # 取 d.b:偏移 4,正是 padding 推出来的位置
    pop     rbp
    ret

4[rax] 里的 4,来自 a 的 1 字节加上其后 3 字节填充,所以 b 从偏移 4 起。若误以为成员紧挨着排、b 从偏移 1 起,按错误偏移去读就会拿到错数据。

这就是类型在“内存”层面的直接影响:类型决定布局,布局决定偏移,偏移决定指令里的立即数。

对齐还关系到程序能否正确运行。

在 x86-64 上未对齐的访问多半由硬件兜底,开销通常也不大。但是在 ARM 等架构上,对未对齐的多字节数据读写可能触发对齐异常(SIGBUS),并发时还可能会破坏原子性。

C++ 要求对象按类型对齐存放,就是为了提前避开这些风险。

有符号整数与无符号整数在内存里往往一样宽,看起来很像,但标准对溢出后的行为的规定却完全不同:

  1. 有符号整数溢出是未定义行为(UB),编译器可据此假设溢出不发生;
  2. 无符号整数运算按 2 的 N 次方取模,回绕是良好定义的。

这个区别会直接改变控制流。

下面有两个循环的示例代码,区别仅仅是把循环变量从 int 改成了 unsigned。

// 有符号计数:i 从 n 递减到 0,循环 n+1 次后正常结束
int count_down_signed(int n)
{
    int c = 0;
    for (int i = n; i >= 0; --i) {
        ++c;
    }
    return c;
}

// 无符号计数:i 是 unsigned,i >= 0 永远成立;减到 0 后再 -- 回绕成
// UINT_MAX,循环不会终止(这是“已定义但危险”的回绕,不是 UB)
unsigned count_down_unsigned(unsigned n)
{
    unsigned c = 0;
    for (unsigned i = n; i >= 0; --i) {
        ++c;
    }
    return c;
}

把这两段编译成汇编后,差异就会立刻显现出来。

有符号版本的循环头仍保留比较与条件跳转。

.L2:
    cmp     DWORD PTR -8[rbp], 0   # 比较 i 与 0
    jns     .L3                    # i 非负则继续,否则退出

无符号版本的同位置,比较被整个消除,只剩一条无条件跳转。

.L6:
    add     DWORD PTR -4[rbp], 1   # c++
    sub     DWORD PTR -8[rbp], 1   # i--(无符号回绕)
    jmp     .L6                    # 无条件跳回:i >= 0 永真,循环条件被优化掉

i >= 0 在无符号下恒为真,编译器连判定都不生成。

这是“类型改变生成的指令”的又一例证。

因此如果把 unsigned 用错地方,那么就会写出看似正确、实则永不终止的循环。

不同数据类型对运行与内存管理的影响

选型时,不同类型的取舍主要落在运行效率、内存占用与正确性这三方面。

整型宽度直接决定单值占多少字节:int8_t、int32_t、int64_t 分别是 1、4、8 字节。在数组、缓冲区、网络包这类大批量数据里,更窄的类型能缩小总占用、提高缓存命中率,并减轻内存带宽压力。但宽度过窄会触及正确性:用 int8_t 累加超过 127 的计数会溢出,换成 int32_t 则不会。因此宽度不是越小越好,要在可表示范围与资源开销之间权衡。

在浮点类型里 float(4 字节)与 double(8 字节)的取舍是精度与体积的经典权衡。

double 尾数更长、表示更精确,但占双倍空间、在向量化与传输上更贵。

数值计算密集的场景常默认用 double,而图形顶点、传感器采样这类大规模数据则更倾向 float。

数组类型提供的是连续且同构的存储。下标访问 a[i] 在底层被编译成基址加偏移的寻址。它高效,但类型本身不携带边界信息,越界访问是未定义行为,越界后果需由程序员自己承担。

指针与引用常被放在一起比较。在 64 位平台上,二者通常都占 8 字节,但语义不同:

  1. 指针是可以重新绑定的独立对象;
  2. 引用是已有对象的别名,一旦绑定便不能再改变指向。

结构体的成员顺序则直接影响 padding 带来的空间浪费。把成员按对齐量从大到小排列(如先 double、再 int、最后 char),往往会消掉更多填充。成员重排是零成本的优化,也是社区里讲“内存对齐”时被反复强调的实用技巧。在多线程共享的结构里,不当的布局还可能让两个本不相关的字段落在同一缓存行,引发伪共享(false sharing),进而拖慢并发性能。

社区里还有一类被反复讨论的“类型深坑”:type punning 与严格别名规则。所谓 type punning,是用一种类型的指针去读取另一种类型对象的比特表示,比如把一个 int 对象当成 float 读出来。C++ 标准规定,通过不兼容类型的左值去访问对象,属于未定义行为。唯一被允许“穿透”一切类型的是 char、unsigned char 和 std::byte。这条规则的目的正是给编译器提供类型级的别名分析(TBAA)依据。

类型级别名分析能让编译器假设“指向不同类型的指针不会指向同一块内存”,从而省掉冗余的内存读取。

把下面两段函数编译一下,差别就看出来了。

// a、b 指向不同类型时,编译器可假定两者不重叠(TBAA),无需重读 *a
int foo_int_float(int* a, float* b)
{
    *a = 1;
    *b = 2.0f;
    return *a;
}

// a、b 都是 int*,可能指向同一对象,编译器必须重读 *a
int foo_int_int(int* a, int* b)
{
    *a = 1;
    *b = 2;
    return *a;
}

开着优化时(TBAA 默认开着),foo_int_float 写完 *a 就直接返回 1,不会再读一遍内存。

foo_int_float(int*, float*):
    mov     DWORD PTR [rdi], 1          # *a = 1
    mov     eax, 1                      # 直接返回 1,没有重读 *a
    mov     DWORD PTR [rsi], 0x40000000 # *b = 2.0f(0x40000000 即 2.0f 的位模式)
    ret

换成同类型的 foo_int_int 就不行了:a、b 可能指着同一块内存,编译器只好再读一遍 *a。

foo_int_int(int*, int*):
    mov     DWORD PTR [rdi], 1
    mov     DWORD PTR [rsi], 2
    mov     eax, DWORD PTR [rdi]        # 必须重读 *a
    ret

那次少读是不是碰巧?把 TBAA 关掉,再用同一份源码编译,重读就回来了:

foo_int_float(int*, float*)  [关闭 TBAA]:
    mov     DWORD PTR [rdi], 1
    mov     DWORD PTR [rsi], 0x40000000
    mov     eax, DWORD PTR [rdi]        # 关闭 TBAA 后,必须重读 *a
    ret

同一份源码,只是指针类型不同、TBAA 开或关,指令里就多了一条内存读取。类型对运行的影响就在这里:它不只是纸面上的约定,还会左右编译器的优化行为。

再说回 type punning。要安全地看一个对象的比特表示,应该用 std::memcpy 或 C++20 的 std::bit_cast。

这样编译器知道你是在做表示转换,不会踩到未定义行为。

#include <cstring>
// 安全地做类型双关:用 memcpy 而非 reinterpret_cast 强转
int punning_via_memcpy(int* pi)
{
    float f;
    std::memcpy(&f, pi, sizeof(f));
    return *pi + static_cast<int>(f);
}

不同类型的数据在内存里的位置

到这里需要澄清一个常见误解。

一个数据落在内存的哪个区域,不由它的类型决定,而由它的存储期(storage duration)和链接属性决定。

类型只决定它在该区域里占多大以及内存如何对齐。

C++ 标准把存储期分为自动、静态、动态、线程局部四类,它们各自对应进程地址空间里的一块区域。

一个典型进程的地址空间从低地址到高地址大致如下。

图4:进程地址空间布局,各段由存储期与链接决定,类型只决定段内的字节数与对齐

图4里每个段通常放哪类对象,可以这样对应:

  1. 已初始化的全局/静态变量 → data 段;
  2. 未初始化的全局/静态变量 → bss 段(可执行文件里几乎不占空间,加载时清零);
  3. 字符串字面量,以及带静态存储期的 const 常量 → rodata 段(只读);
  4. 局部变量 → 栈;
  5. new / malloc 分配的对象 → 堆;
  6. 函数代码 → text(代码)段;
  7. thread_local 变量 → 各线程自己的 TLS 段。

下面用一个真实程序打印各类变量的地址,再对照该进程的 /proc/self/maps。

#include <cstdio>
#include <cstdlib>

int              g_init   = 42;            // 已初始化全局变量 -> data 段
int              g_uninit;                 // 未初始化全局变量 -> bss 段
const int        g_const  = 7;            // 只读全局常量 -> rodata 段
const char*      g_lit    = "rodata-str"; // 指针在 data,字符串字面量在 rodata
thread_local int g_tls    = 9;            // 线程局部 -> TLS 段

int main()
{
    int          local = 1;               // 局部变量 -> 栈
    int*         heap  = new int(2);      // 动态分配 -> 堆
    static int   svar  = 3;               // 局部静态 -> data 段
    const char*  lit   = "stack-lit";     // 字面量本身在 rodata,指针在栈

    std::printf("g_init   %p  data\n",    (void*)&g_init);
    std::printf("g_uninit %p  bss\n",     (void*)&g_uninit);
    std::printf("g_const  %p  rodata\n",  (void*)&g_const);
    std::printf("str-lit  %p  rodata\n",  (void*)g_lit);
    std::printf("g_tls    %p  tls\n",     (void*)&g_tls);
    std::printf("local    %p  stack\n",   (void*)&local);
    std::printf("heap     %p  heap\n",    (void*)heap);
    std::printf("svar     %p  data\n",    (void*)&svar);
    std::printf("main     %p  text\n",   (void*)&main);
    return 0;
}

一次具体运行的输出(开启地址随机化,故每次地址会变,但各变量所属的段固定):

g_init   0x56f60de6b014  data
g_uninit 0x56f60de6b02c  bss
g_const  0x56f60de69104  rodata
str-lit  0x56f60de690d4  rodata
g_tls    0x7feef292273c  tls
local    0x7ffefe40012c  stack
heap     0x56f64d3cd2b0  heap
svar     0x56f60de6b010  data
main     0x56f60de68160  text

对照该进程内存映射中的关键几行:

56f60de67000-56f60de68000 r--p  ...  dt_layout   # 代码等只读段
56f60de68000-56f60de69000 r-xp  ...  dt_layout   # text(代码)
56f60de69000-56f60de6a000 r--p  ...  dt_layout   # rodata(常量、字面量)
56f60de6b000-56f60de6c000 rw-p  ...  dt_layout   # data + bss
56f64d3bb000-56f64d3dc000 rw-p  ...  [heap]      # 堆
7ffefe3e1000-7ffefe402000 rw-p  ...  [stack]     # 栈

将打印出的地址与上述映射对照,结果如下:

  1. g_const、str-lit 落在 56f60de69000–56f60de6a000 的 r--p 区间,对应 rodata;
  2. g_init、svar、g_uninit 落在 56f60de6b000–56f60de6c000 的 rw-p 区间,对应 data/bss;
  3. main 落在 r-xp 区间,对应 text;
  4. heap 落在标注为 [heap] 的区间;
  5. local 落在标注为 [stack] 的区间;
  6. g_tls 落在独立的线程局部存储(TLS)区域。

核对下来,类型只管占几个字节、怎么对齐;进 data、bss、栈还是堆,看的是存储期(全局、静态、局部、new 出来的、thread_local 等),跟是 int 还是 float 无关。

小结

数据类型远不只是“整数还是小数”的标签。它因整数与浮点等硬件表示不同而产生,又因编译期类型检查而成为语言约束。在 C++ 中,类型同时规定对象的大小与对齐、比特如何解释、哪些运算合法,以及编译器可依据哪些前提做优化。

对内存而言,类型影响大小、对齐与填充。填充会改变成员访问指令中的偏移。

对运行而言,类型影响指令选择与优化。例如:无符号回绕可使循环条件被消除,类型级别名分析可使编译器省去一次内存读取。至于数据落在地址空间的哪一块,则由存储期与链接决定。类型只决定在该区域内占多大以及如何对齐。

理清这几层关系之后,类型就不再只是语法条目,而是连接高级语言与机器实现的关键环节。

参考资料

  1. L. Cardelli, P. Wegner, On Understanding Types, Data Abstraction, and Polymorphism, ACM Computing Surveys 17(4), 1985:论述类型在 Fortran、ALGOL 60 之后的演进,以及类型检查与抽象的关系。

  2. B. W. Kernighan, D. M. Ritchie, The C Programming Language(第 2 版),Prentice Hall:C 语言类型系统与经典的内存布局(text/data/bss/heap/stack)图示。

  3. D. M. Ritchie, The Development of the C Language, in History of Programming Languages II (HOPL-II), ACM, 1993:记录 C 为何在 B 的无类型基础上重新引入类型。

  4. B. Stroustrup, The C++ Programming Language(第 4 版),Addison-Wesley:C++ 类型体系、用户自定义类型与模板的设计。

  5. R. E. Bryant, D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective(《深入理解计算机系统》),Pearson:进程地址空间布局、数据表示与对齐的性能含义。

  6. ISO/IEC 14882:2023, Programming Languages — C++:基础类型([basic.fundamental])、类型类别([basic.types])、严格别名与可访问类型([basic.lval])、存储期([basic.stc])、对齐([basic.align])。

  7. cppreference.com, Type / Fundamental types / Type aliasing / Alignment / Storage duration:对 C++ 标准条款的条目化转述与可检索示例。

  8. System V Application Binary Interface, AMD64 Architecture Processor Supplement (psABI):x86-64 进程内存布局、调用约定与 max_align_t 对齐约定。