优秀的编程知识分享平台

网站首页 > 技术文章 正文

【C语言·003】基本数据类型的字节表示与取值范围边界

nanyue 2025-09-06 09:12:01 技术文章 3 ℃

很多诡异的线上 Bug,本质都和“这个类型到底占几字节”“什么时候会溢出”“边界值到底是多少”有关。今天这篇,我们把 C 语言里基本数据类型的字节表示与取值范围一次说清,同时给出可复制的验证代码与避坑清单,让你写出的每一行都对“边界”心里有数。

1. 先把几个关键词掰直

  • 字节(byte):在 C 里,1 个 char 的大小就是 1 个字节,但一个字节不一定是 8 位。标准只要求 CHAR_BIT ≥ 8。工业界几乎都是 8 位,不过写可移植代码时应以 CHAR_BIT 为准。
  • 二进制补码(two’s complement):现代主流平台几乎都用补码来表示有符号整数。补码下,N 位有符号整数的范围是 [-2^(N-1), 2^(N-1)-1],无符号是 [0, 2^N-1]。
  • 大小端(endianness):仅影响多字节类型的内存排列(最低有效字节在前是小端),不影响算术结果。
  • 实现相关(implementation-defined):C 标准不规定 short/int/long 的具体位数与范围,编译器+平台决定。可移植写法应以 <limits.h>、<stdint.h>、<float.h> 的宏为准。

2. 整数类型:大小与范围如何“按标准说话”

常见的基本整数类型:

  • signed/unsigned char(注意plain char 可为有符号也可为无符号
  • short、int、long、long long 及其 unsigned 版本

不要死记位数,用标准库宏来拿“真相”:

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

int main(void) {
    printf("CHAR_BIT = %d\n", CHAR_BIT);
    printf("char:      sizeof=%zu, range=[%d, %d]\n", sizeof(char), CHAR_MIN, CHAR_MAX);
    printf("schar:     sizeof=%zu, range=[%d, %d]\n", sizeof(signed char), SCHAR_MIN, SCHAR_MAX);
    printf("uchar:     sizeof=%zu, range=[0, %u]\n", sizeof(unsigned char), UCHAR_MAX);

    printf("short:     sizeof=%zu, range=[%d, %d]\n", sizeof(short), SHRT_MIN, SHRT_MAX);
    printf("ushort:    sizeof=%zu, range=[0, %u]\n", sizeof(unsigned short), USHRT_MAX);

    printf("int:       sizeof=%zu, range=[%d, %d]\n", sizeof(int), INT_MIN, INT_MAX);
    printf("uint:      sizeof=%zu, range=[0, %u]\n", sizeof(unsigned int), UINT_MAX);

    printf("long:      sizeof=%zu, range=[%ld, %ld]\n", sizeof(long), LONG_MIN, LONG_MAX);
    printf("ulong:     sizeof=%zu, range=[0, %lu]\n", sizeof(unsigned long), ULONG_MAX);

    printf("long long: sizeof=%zu, range=[%lld, %lld]\n", sizeof(long long), LLONG_MIN, LLONG_MAX);
    printf("ull:       sizeof=%zu, range=[0, %llu]\n", sizeof(unsigned long long), ULLONG_MAX);

    return 0;
}

运行它,比任何“记忆表”都可靠。你会直观看到不同平台上的真实字节数和边界

3. 用位数自己推边界(并验证)

如果你已知整数类型的实际位数(比如 N = sizeof(int) * CHAR_BIT),就可以按补码模型推范围:

  • 有符号:min = -(1 << (N-1))、max = (1 << (N-1)) - 1
  • 无符号:min = 0、max = (1u << N) - 1

但注意两个坑:

  1. 左移超过位宽或对有符号负数移位都是未定义行为。因此更稳妥的方式是用无符号计算:
#include <stdio.h>
#include <limits.h>

int main(void) {
    unsigned N = sizeof(int) * CHAR_BIT;
    unsigned int umax = (N == 32) ? 0xFFFFFFFFu : (unsigned int)(~0u);
    int smax = (int)(umax >> 1);
    int smin = -smax - 1;
    printf("Derived int range: [%d, %d]\n", smin, smax);
    return 0;
}
  1. 直接写 1 << (N-1) 若 1 是 int 且 N-1 达到或超过位宽,也可能 UB。先把 1 变成无符号是通用做法:(1u << (N-1))。

4. 有符号溢出:未定义;无符号溢出:按模环绕

  • 有符号整型溢出(如 INT_MAX + 1)是未定义行为(UB),编译器甚至会基于此进行危险优化。
  • 无符号整型溢出良定义的模 2^N 环绕
unsigned int x = UINT_MAX;
x += 1; // x 变为 0,完全合法
  • 由此引申的两个常见坑:
    • 和 size_t(无符号)混用时,-1 会被转换成一个超大无符号数,导致循环或比较逻辑“飞天”。
    • 比较时发生整型提升与有符号-无符号混算:int a = -1; unsigned b = 1; printf("%d\n", a < b); 会输出 0,因为 a 被转成很大的无符号数后再比较。

建议:凡是和大小、计数相关的变量,用 size_t;和索引比较时,先做边界断言或显式转换,少做“自动类型提升”的赌徒。

5. 位移的边界与陷阱

  • 右移有符号数是实现定义(算术右移 or 逻辑右移),在补码机器上通常是算术右移(高位补符号位),但不要依赖它的可移植性。
  • 移位计数 ≥ 位宽是 UB。写宏或通用函数时,先做 shift %= (sizeof(T)*CHAR_BIT) 之类的保护。

6. 浮点类型:IEEE-754 是事实标准,但边界看 <float.h>

C 标准不强制 IEEE-754,但主流平台遵循。可移植写法仍应通过 <float.h> 查询:

#include <float.h>
#include <stdio.h>

int main(void) {
    printf("float:  sizeof=%zu, digits=%d, max=%e, min=%e\n",
           sizeof(float), FLT_MANT_DIG, FLT_MAX, FLT_MIN);
    printf("double: sizeof=%zu, digits=%d, max=%e, min=%e\n",
           sizeof(double), DBL_MANT_DIG, DBL_MAX, DBL_MIN);
    printf("ldbl:   sizeof=%zu, digits=%d, max=%Le, min=%Le\n",
           sizeof(long double), LDBL_MANT_DIG, LDBL_MAX, LDBL_MIN);
    return 0;
}

小贴士:

  • FLT_MIN/DBL_MIN 是最小正正规数,并非最小可表示正数(还有次正规数,可通过 FLT_TRUE_MIN/DBL_TRUE_MIN 在 C11 之后的实现中获取)。
  • 浮点也有溢出/下溢边界与舍入误差,比较大小优先用相对/绝对误差或 ULP 概念,不要直接 ==。

7. 看得见的“字节表示”:大小端与逐字节打印

不要用“类型强转指针”去别名访问二进制表示(会踩严格别名规则)。更安全的方式是 memcpy 把对象字节拷到 unsigned char 缓冲区:

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

void dump_bytes(const void *p, size_t n) {
    const unsigned char *b = (const unsigned char*)p;
    for (size_t i = 0; i < n; ++i) printf("%02X ", b[i]);
    puts("");
}

int main(void) {
    uint32_t v = 0x12345678u;
    unsigned char buf[sizeof v];
    memcpy(buf, &v, sizeof v);
    dump_bytes(buf, sizeof buf); // 小端平台常见输出:78 56 34 12
    return 0;
}

这个输出能帮你在调试器外快速确认平台的端序与对象的真实字节形态

8. 不同平台的数据模型(帮你推断 long 与指针大小)

常见数据模型与典型平台:

模型

int

long

long long

指针

典型平台

ILP32

32

32

64

32

32 位嵌入式/老系统

LP64

32

64

64

64

Linux/macOS 64 位

LLP64

32

32

64

64

Windows 64 位

意义:在 Linux/macOS 上 long 多为 64 位;在 Windows 上 long 仍是 32 位,但指针是 64 位。这会影响 sizeof(long)、printf 占位符、序列化协议等。

9. 固定宽度与打印宏:写出“跨平台边界友好”的代码

当你需要明确位宽(网络协议、文件格式、硬件寄存器)时,优先用 <stdint.h>:

  • int8_t/uint16_t/uint32_t/uint64_t…
  • 对应极值:INT8_MIN/UINT16_MAX/...
  • 对应 printf 宏在 <inttypes.h>:
    PRIu64、PRId32 等,避免不同平台上 %lld、%I64u 的混乱。
#include <inttypes.h>
#include <stdio.h>

uint64_t hash(const void* p, size_t n);

int main(void) {
    uint64_t h = 1234567890123456789ull;
    printf("hash = %" PRIu64 "\n", h);
    return 0;
}

10. 一眼能救命的边界清单(工程实战向)

  • 查询真实边界:用 <limits.h>/<float.h> 宏,不靠猜。
  • 打印真实大小:sizeof(T) * CHAR_BIT 得到位宽。
  • 避免 UB:有符号溢出、越界移位、别名违反统统绕开。
  • 无符号环绕:用在计数器、哈希等“模”语义场景;业务逻辑慎用。
  • 混算警惕:size_t 与 int 比较/相减前先做显式转换或断言。
  • 端序显式化:序列化/网络传输统一用固定宽度类型 + 固定端序(常用小端/网络字节序)。
  • 打印宏:跨平台输出 64 位整数,用 <inttypes.h> 的 PRI* 宏。
  • 静态断言:C11 起可用 _Static_assert(sizeof(long) == 8, "expect LP64"); 在关键路径强约束平台假设。
  • 测试边界:单测里覆盖 0/-1/INT_MAX/INT_MIN/UCHAR_MAX/…,以及溢出前后一步。
  • 浮点比较:引入容差,避免 ==。

11. 结语:把“边界意识”刻进手感

C 给了你直达底层的自由,也把“边界”交给了你。类型的字节表示决定了数据在内存里的样子,取值范围决定了算法是否安全。用标准宏拿到真值,用固定宽度类型描述协议,用静态断言约束假设,用单测压边界。做到这些,线上 Bug 里那类“看似偶发的鬼畜现象”,大概率与你无关。

最近发表
标签列表