Appearance
数据类型与位级表示
概念
前面九篇我们一直在"机器那一侧":寄存器、地址、lw、sw、栈帧。现在翻到人和机器中间的那一层——C 语言。
C 的第一课就是类型。但这里有个必须先摆正的认知:
这句话什么意思? 展开说:
| 你以为 | 实际是 |
|---|---|
内存里存的是"一个 int" | 内存里存的只是一串字节 |
| 类型是数据自带的属性 | 类型只存在于编译器眼中(写进指令,不写进数据) |
运行时知道 x 是 int | 运行时一无所知——mov 只搬字节,不问类型 |
证据:你在汇编里从来没见过"类型"。lw $t0, 0($sp) 搬 4 个字节,它不知道也不想那是 int 还是 float 还是指针。 类型信息全部在编译期被"用掉"了:
| 类型在编译期决定了什么 | 落到机器上变成什么 |
|---|---|
| 占几个字节 | 选 lb / lh / lw、$sp 偏移量 |
| 有符号还是无符号 | 选 slt / sltu、符号扩展 / 零扩展、sra / srl |
| 怎么编码 | 整数用补码、浮点用 IEEE 754、指针用地址 |
| 允许什么操作 | % 不能用于 float、结构体不能相加(编译期报错) |
所以"类型"是一份契诃夫式的合同:编译器按它生成指令,然后它就被丢弃了。 运行时只剩字节。
本篇在主线上的位置:L2 的机器只有"寄存器和内存",L3 前半的汇编只有"位型和地址"。 C 的类型是盖在这两样东西上的一层"解释框架"——它让"一串位"有了名字和语义,而它自己一行机器码都不产生。 看懂这一层,"为什么
float和int都是 4 字节却完全不同"这类问题就再也不会绕晕了。
类型的三个要素(后文逐个展开):
原理
一、sizeof:类型的第一属性是"占几个字节"
"宽度"是类型最硬的部分——因为它直接决定指令选哪条、地址加多少(回看 lang/02-addressing.md 的地址算术)。
典型平台(32 位 / 64 位)的宽度表:
| 类型 | 字节数(32 位) | 字节数(64 位 Linux) | 常见取值范围(有符号) |
|---|---|---|---|
char | 1 | 1 | −128 ~ 127 |
unsigned char | 1 | 1 | 0 ~ 255 |
short | 2 | 2 | −32768 ~ 32767 |
int | 4 | 4 | −2 147 483 648 ~ 2 147 483 647 |
long | 4 | 8 | 随平台变(这是 C 最大的坑之一) |
long long | 8 | 8 | ±9.22 × 10¹⁸ |
float | 4 | 4 | ±3.4 × 10³⁸(有效 6~7 位) |
double | 8 | 8 | ±1.8 × 10³⁰⁸(有效 15~16 位) |
| 指针 | 4 | 8 | 跟地址总线宽度走 |
三条"必须记死"的观察:
char恒为 1 字节,且 C 标准规定sizeof(char) == 1(不管机器上是 8 位还是别的)——"字节"在 C 里的定义就是"一个char的宽度"。int通常是"机器最舒服的字长":32 位机是 4,64 位机上仍是 4(为了兼容,int没跟着变 8,变的是long)。所以"int一定等于机器字长"是错的。- 指针的宽度 = 地址宽度:32 位系统 4 字节、64 位系统 8 字节。这正是
lang/02说的"指针就是地址"在类型层的体现。
为什么指针宽度这么重要? 因为它解释了同一个程序换个平台就崩的经典场景:
你把指针强转成
int想省点空间 → 32 位机上刚好装得下(4=4),64 位机上被截断(8→4) → 地址高位丢失 → 段错误。sizeof(int) == sizeof(void*)是 32 位时代的巧合,不是定理。 要存指针就用intptr_t(<stdint.h>)。
有了宽度,就能算"地址加多少"(回链 lang/02):p + 1 不是"地址 +1 字节",而是 "地址 + sizeof(*p) 字节"——这就是指针算术的全部内容(详见下一篇)。
二、补码:负数为什么用"取反加一"
整数在内存里用补码(two's complement)。定义:
"取反加一"只是它的计算口诀;真正的定义是 "模 2ⁿ 意义下的加法逆元"。为什么必须这样选? 因为这样能让减法直接用加法器算:
回想 circuit/12-alu.md:ALU 做减法就是 A + B̄ + 1(把 B 取反、Cin 拉高)。补码的存在,就是为了让"减法"变成"加法 + 取反 + 进位"——省掉一整个减法器。
补码的四个必背结论:
| 结论 | 说明 | 例子(8 位) |
|---|---|---|
| 范围不对称 | 负的多一个:−2ⁿ⁻¹ 到 2ⁿ⁻¹ − 1 | 8 位:−128 ~ 127 |
| 最高位是符号位 | 1 表示负(但它不是"负号",是"权重 −2ⁿ⁻¹") | 1000 0000 = −128 |
| 全 1 = −1 | 因为 0xFFFFFFFF + 1 = 0(模 2³² 归零) | 32 位:−1 = 0xFFFFFFFF |
| 最小的负数取反加一还是自己 | −(−128) = −128(溢出,这是唯一的例外) | 8 位:1000 0000 → 取反 0111 1111 → +1 → 1000 0000 |
"最高位是符号位"这句话是个常见的过度简化,值得纠一下:
补码的高位不是"符号标记",而是一个权重为 −2ⁿ⁻¹ 的普通二进制位。 所以
0xFFFFFFFF按权重展开 =−2³¹ + 2³⁰ + … + 2⁰= −1。 这就是为什么"无符号的 4294967295"和"有符号的 −1"是同一串位——不是巧合,是同一个二进制数的两种读法。
三、有符号与无符号:同一串位,两种解释
这是 C 里最要命的一组概念。 核心事实一句话:
对应的机器指令差别(回链 lang/03-arith.md):
| 操作 | 有符号 | 无符号 | 差别 |
|---|---|---|---|
| 比较 | slt | sltu | 对同一组位给相反结果 |
| 右移 | sra(填符号位) | srl(填 0) | −7 >> 1 = −4,而 (unsigned)(−7) >> 1 = 2147483644 |
| 加载/扩展 | 符号扩展(高位补 1) | 零扩展(高位补 0) | lb vs lbu(回链 lang/02) |
| 除法 | 有符号 div | 无符号 divu | 商与余数的规则不同 |
用 4 位说明"同一串位、两种读法"(lang/03 的 slt vs sltu 就是这个):
| 位型 | 有符号读法 | 无符号读法 |
|---|---|---|
0111 | 7 | 7 |
1000 | −8 | 8 |
1111 | −1 | 15 |
所以 0xFFFFFFFF > 0 这句话的真假,完全取决于它被当成有符号还是无符号——这是所有"幽灵 bug"的源头。
四、整型提升与隐式转换:-1 > 0u 为什么为真
C 有一条规则叫"通常算术转换(usual arithmetic conversions)":当两个操作数类型不同时,先转换再运算。转换的方向是**"往更宽、更"能装"的那个走"**:
char/short → int(整型提升)
int + unsigned → unsigned ← ★ 灾难就在这里
int + long → long
float + int → float
double + float → double关键那条:int 和 unsigned 混在一起时,int 会被转成 unsigned。
于是经典的陷阱成立:
c
int i = -1;
if (i > 0u) /* 你以为在问"-1 > 0 吗" */
printf("yes\n"); /* 实际会打印 yes ! */为什么? -1 被转成 unsigned = 4294967295,而 4294967295 > 0 为真。
这个陷阱最常见的真实形态是"循环反着写":
c
/* 想让 n 递减到 0 —— 看起来没问题,其实死循环 */
for (unsigned i = n; i >= 0; i--) /* i >= 0 恒为真(无符号永不小于 0) */
...规则:只要
sizeof、数组长度、字符串长度出现在循环变量里,就要警惕(它们返回size_t,是无符号的)。sizeof(int) - 1 >= 0这种写法,在某些表达式组合下也会翻车——减法在无符号域里"借位"后变成一个巨大的正数。
另一类转换是"宽度不够时的截断(truncation)":
| 场景 | 规则 | 例子 |
|---|---|---|
| 窄 ← 宽(赋值) | 直接砍掉高位 | char c = 300; → 300 & 0xFF = 44 |
| 宽 ← 窄(赋值) | 有符号符号扩展、无符号零扩展 | int i = (unsigned char)200 → 200(零扩展) |
| 宽 ← 窄,窄是有符号 | 符号扩展 | int i = (signed char)200 → −56(200 的字节是 0xC8,符号扩展得 0xFFFFFFC8 = −56) |
这就是 lang/02 的 lb vs lbu 在 C 层的同一个故事:"取一个字节再扩展"到底补 0 还是补符号位,取决于那个 char 是有符号还是无符号。
五、IEEE 754:0.1 为什么不精确
浮点数的编码完全不同于整数。单精度(float,4 字节 = 32 位)的位域:
31 30 ──────── 23 22 ─────────────────────── 0
┌──┬───────────────────┬────────────────────────────┐
│S │ 指数 E (8 位) │ 尾数 M (23 位) │
└──┴───────────────────┴────────────────────────────┘
符号 偏置 127 隐含最高位 1求值公式(规格化数):
双精度 double 是 1 + 11 + 52 位,偏置 1023。
三条必须记住的结论:
| 结论 | 说明 |
|---|---|
| 尾数只有 23 位(单精度) | 二进制有效数字约 24 位 → 十进制约 6~7 位;double 53 位 → 约 15~16 位 |
| 十进制小数多半是无限二进制小数 | 0.1 在二进制里是 0.0001100110011…(循环),截断后必然有误差 |
所以浮点不能用 == 比较 | 0.1 + 0.2 == 0.3 为假——必须用 fabs(a-b) < 1e-9 |
为什么 0.1 一定是无限循环? 因为十进制分母含因子 5,而二进制只能表示"分母是 2 的幂"的分数:
分母里的 5 无法用 2 的幂凑出来 → 二进制下除不尽 → 只能近似。同理 1/3 在十进制下是 0.333…,在二进制下 0.1 就是"二进制的 1/3"。
一句可以记住的类比:"0.1 在二进制里,就像 1/3 在十进制里"——不是算错了,是表示法本身装不下。
六、位运算与位域:直接操作字节里的位
C 提供的位运算(与硬件一一对应,回链 circuit/10-boolean.md):
| C 运算符 | 含义 | 对应电路 | 典型用途 |
|---|---|---|---|
& | 按位与 | AND 门 | 取某个位(掩码) |
| | 按位或 | OR 门 | 置某个位 |
^ | 按位异或 | XOR 门 | 翻转位;交换变量;简单校验 |
~ | 按位取反 | NOT 门 | 取反(注意:不产生"补码求负",求负还要 +1) |
<< >> | 左移 / 右移 | 移位器(回链 circuit/12-alu.md) | ×2ⁿ / ÷2ⁿ |
四条容易踩的位运算笔记:
| 坑 | 说明 |
|---|---|
~x 不等于 -x | ~x = -x - 1(因为 -x = ~x + 1)。所以 ~0 = -1,全 1 |
>> 对负数"实现定义" | C 标准说有符号右移是"实现定义"(多数编译器算术右移 = sra)。要逻辑右移必须用无符号类型 |
| 移位量超宽是未定义行为 | x << 32 对 32 位 int 是 UB,不是"得 0" |
& 优先级低于 == | if (x & 1 == 0) 会被解析成 x & (1 == 0)——要写 (x & 1) == 0 |
位域(bit field)——把结构体成员压缩到"几位":
c
struct Flags {
unsigned a : 1; /* 占 1 位 */
unsigned b : 3; /* 占 3 位 */
unsigned c : 4; /* 占 4 位 */
}; /* 合计 8 位 = 1 字节(典型实现) */位域的用途:协议头、硬件寄存器(回链 silicon/06-datasheet.md——寄存器常常是"某几位管某功能")。缺点:布局是实现相关的(谁在高位、能不能跨字节,标准没规定死),所以不能拿来做二进制文件格式的跨平台解析——那要用显式的移位 + 掩码。
示例
例 1:把每种类型"占几个字节、什么范围"印出来
任务:对照打印 C 的常见类型宽度与取值范围。
c
#include <stdio.h>
#include <limits.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));
printf("float : %zu 字节\n", sizeof(float));
printf("double : %zu 字节\n", sizeof(double));
printf("void* : %zu 字节 <-- 跟地址宽度走\n", sizeof(void*));
printf("int 范围 : %d ~ %d\n", INT_MIN, INT_MAX);
printf("unsigned 范围: 0 ~ %u\n", UINT_MAX);
return 0;
}用 Python 把"宽度 → 范围"的推导复算一遍(范围公式:有符号 −2ⁿ⁻¹ ~ 2ⁿ⁻¹−1,无符号 0 ~ 2ⁿ−1):
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
# (类型名, 字节数, 是否有符号) —— "long 随平台变"这里按 64 位 Linux 记 8
TYPES = [
("char", 1, True),
("unsigned char", 1, False),
("short", 2, True),
("int", 4, True),
("unsigned int", 4, False),
("long (64 位)", 8, True),
("long long", 8, True),
("float", 4, None),
("double", 8, None),
("void* (64 位)", 8, False),
]
print(" " + pad("类型", 16) + pad("字节", 6) + pad("位", 5) + "取值范围")
for name, size, signed in TYPES:
bits = size * 8
if signed is None: # 浮点:给有效位数
rng = "有效约 %d 位十进制" % (7 if size == 4 else 16)
elif signed:
rng = "%d ~ %d" % (-(2 ** (bits - 1)), 2 ** (bits - 1) - 1)
else:
rng = "0 ~ %d" % (2 ** bits - 1)
print(" " + pad(name, 16) + pad(str(size), 6) + pad(str(bits), 5) + rng)
print("\n=== 三条观察 ===")
c1 = 4 * 8
print(" 1. sizeof(char) == 1 —— C 里“字节”的定义就是“一个 char”,与 8 位无关")
print(f" 2. 32 位机的 int = 4 字节 → 范围 −2^31 ~ 2^31−1 = {-(2**31)} ~ {2**31-1}")
print(f" 同机上 void* 也是 4 字节 → 历史上 sizeof(int)==sizeof(void*),但这是巧合")
print(f" 3. 指针宽度 = 地址宽度:64 位机 8 字节,把指针塞进 int 会丢高 32 位 → 段错误")预期输出:
类型 字节 位 取值范围
char 1 8 -128 ~ 127
unsigned char 1 8 0 ~ 255
short 2 16 -32768 ~ 32767
int 4 32 -2147483648 ~ 2147483647
unsigned int 4 32 0 ~ 4294967295
long (64 位) 8 64 -9223372036854775808 ~ 9223372036854775807
long long 8 64 -9223372036854775808 ~ 9223372036854775807
float 4 32 有效约 7 位十进制
double 8 64 有效约 16 位十进制
void* (64 位) 8 64 0 ~ 18446744073709551615
=== 三条观察 ===
1. sizeof(char) == 1 —— C 里“字节”的定义就是“一个 char”,与 8 位无关
2. 32 位机的 int = 4 字节 → 范围 −2^31 ~ 2^31−1 = -2147483648 ~ 2147483647
同机上 void* 也是 4 字节 → 历史上 sizeof(int)==sizeof(void*),但这是巧合
3. 指针宽度 = 地址宽度:64 位机 8 字节,把指针塞进 int 会丢高 32 位 → 段错误三条结论:
| 结论 | 说明 |
|---|---|
| 宽度决定指令与地址算术 | 选 lb/lh/lw、p+1 跳几字节,全由 sizeof 定(回链 lang/02) |
long 是"随平台变"的 | 32 位 4 字节、64 位 Linux 8 字节——要定宽就用 <stdint.h> 的 int32_t / int64_t |
| 指针宽度 = 地址宽度 | sizeof(int)==sizeof(void*) 只在 32 位机成立,不是可依赖的规律 |
例 2:补码——同一串位的两种读法
任务:取几个关键位型,同时给出"有符号"和"无符号"两种解释。
c
#include <stdio.h>
int main(void) {
unsigned u = 0xFFFFFFFFu; /* 全 1 */
printf("(unsigned) 0xFFFFFFFF = %u\n", u);
printf("(signed) 0xFFFFFFFF = %d\n", (int)u); /* 实现定义,通常 -1 */
signed char c = -1;
printf("(int)(signed char)-1 = %d\n", (int)c); /* 符号扩展 → -1 */
unsigned char uc = 0xFF;
printf("(int)(unsigned char)0xFF = %d\n", (int)uc); /* 零扩展 → 255 */
signed char sc = (signed char)200; /* 200 = 0xC8,超出 127 */
printf("(signed char)200 = %d\n", (int)sc); /* -56 */
return 0;
}用 Python 复算(Python 的位运算天然就是"模 2ⁿ",正好用来说补码):
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
MASK32 = (1 << 32) - 1
def as_signed(x, bits=32):
"""把无符号位型解释成补码有符号数"""
x &= (1 << bits) - 1
return x - (1 << bits) if x >= (1 << (bits - 1)) else x
def bits_to_str(x, bits=32, group=4):
s = format(x & ((1 << bits) - 1), "0%db" % bits)
return " ".join(s[i:i+group] for i in range(0, len(s), group))
print("=== 取反加一 = 补码求负 ===")
for x in (1, 2, 7, 128):
neg = ((~x) + 1) & MASK32
print(" -%-4d = ~%-4d + 1 = 0x%08X (校验:%d + %d = 2^32 ≡ 0)"
% (x, x, neg, x, neg))
print("\n=== 同一串位,两种读法(4 位)===")
print(" " + pad("位型", 8) + pad("有符号", 8) + "无符号")
for b in (0b0111, 0b1000, 0b1011, 0b1111, 0b0011, 0b0000):
print(" " + pad(bits_to_str(b, 4), 8) + pad(str(as_signed(b, 4)), 8) + str(b & 0xF))
print("\n=== 关键位型的 32 位两种读法 ===")
print(" " + pad("位型", 42) + pad("有符号", 14) + "无符号")
for v in (0xFFFFFFFF, 0x80000000, 0x7FFFFFFF, 0x00000001):
print(" " + pad(bits_to_str(v), 42) + pad(str(as_signed(v)), 14) + str(v & MASK32))
print("\n=== 8 位截断与扩展 ===")
n = 200
print(f" (signed char)200 : 200 = 0x{n:02X} → 符号扩展 → 0xFFFFFF{n:02X} = {as_signed(200,32) if False else 200-256}")
print(f" (unsigned char)200: 200 = 0x{n:02X} → 零扩展 → 0x000000{n:02X} = {200}")
print(f" (char)300 : 300 = 0x{300:03X} → 截断低 8 位 → 0x{300 & 0xFF:02X} = {300 & 0xFF}")
print("\n=== 唯一的例外:最小负数的相反数是它自己 ===")
mn = -(1 << 31)
print(f" 32 位最小负数 = {mn};把它取反加一:")
neg = ((~mn) + 1) & MASK32
print(f" ~({mn}) + 1 = 0x{neg:08X} → 按补码读 = {as_signed(neg)} ← 还是它自己(溢出)")预期输出:
=== 取反加一 = 补码求负 ===
-1 = ~1 + 1 = 0xFFFFFFFF (校验:1 + 4294967295 = 2^32 ≡ 0)
-2 = ~2 + 1 = 0xFFFFFFFE (校验:2 + 4294967294 = 2^32 ≡ 0)
-7 = ~7 + 1 = 0xFFFFFFF9 (校验:7 + 4294967289 = 2^32 ≡ 0)
-128 = ~128 + 1 = 0xFFFFFF80 (校验:128 + 4294967168 = 2^32 ≡ 0)
=== 同一串位,两种读法(4 位)===
位型 有符号 无符号
0111 7 7
1000 -8 8
1011 -5 11
1111 -1 15
0011 3 3
0000 0 0
=== 关键位型的 32 位两种读法 ===
位型 有符号 无符号
1111 1111 1111 1111 1111 1111 1111 1111 -1 4294967295
1000 0000 0000 0000 0000 0000 0000 0000 -2147483648 2147483648
0111 1111 1111 1111 1111 1111 1111 1111 2147483647 2147483647
0000 0000 0000 0000 0000 0000 0000 0001 1 1
=== 8 位截断与扩展 ===
(signed char)200 : 200 = 0xC8 → 符号扩展 → 0xFFFFFFC8 = -56
(unsigned char)200: 200 = 0xC8 → 零扩展 → 0x000000C8 = 200
(char)300 : 300 = 0x12C → 截断低 8 位 → 0x2C = 44
=== 唯一的例外:最小负数的相反数是它自己 ===
32 位最小负数 = -2147483648;把它取反加一:
~(-2147483648) + 1 = 0x80000000 → 按补码读 = -2147483648 ← 还是它自己(溢出)三条结论:
| 结论 | 说明 |
|---|---|
| 补码 = 模 2ⁿ 的加法逆元 | x + (−x) ≡ 0 (mod 2ⁿ)——所以"取反加一"能让减法用加法器做(回链 circuit/12) |
| 同一串位没有"正确答案" | 0xFFFFFFFF 是 −1 还是 4294967295,取决于类型,与位无关 |
| 扩展补什么由"符号性"决定 | signed char 符号扩展、unsigned char 零扩展——这正是 lb 与 lbu 的分工 |
例 3:-1 > 0u —— 隐式转换的陷阱
任务:把"有符号碰无符号"的灾难跑出来。
c
#include <stdio.h>
int main(void) {
int i = -1;
if (i > 0u)
printf("陷阱 1:-1 > 0u 竟然为真!\n");
else
printf("陷阱 1:没进坑\n");
/* 陷阱 2:无符号循环变量永远 >= 0 */
unsigned k = 0;
int count = 0;
for (k = 5; k >= 0; k--) { /* k >= 0 恒真 */
if (++count > 8) break; /* 防止真的挂死 */
}
printf("陷阱 2:unsigned 从 5 递减,循环跑了 %d 次还没停\n", count);
/* 陷阱 3:sizeof 是无符号 */
if (sizeof(int) - 4 >= 0)
printf("陷阱 3:sizeof(int)-4 >= 0 为真(它等于 0,本来就真)\n");
if (sizeof(char) - 2 >= 0) /* 1-2 = -1 → 转成无符号 = 巨大正数 */
printf("陷阱 3:sizeof(char)-2 >= 0 也为真!因为 1-2 在无符号域里是巨正数\n");
return 0;
}用 Python 复算这套转换规则(Python 默认没有"无符号 32 位",要手动模拟):
python
import unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
U32 = 1 << 32
def to_u32(x):
return x % U32 # C 的"转成 unsigned int"
def to_s32(x):
x &= U32 - 1
return x - U32 if x >= (1 << 31) else x
print("=== 陷阱 1:-1 > 0u ===")
i, zero_u = -1, 0
print(f" 比较前:C 把 int 转成 unsigned → -1 变成 {to_u32(i)}")
print(f" 于是 {to_u32(i)} > {zero_u} → {to_u32(i) > zero_u} (应为 True!)")
print(f" 如果两边都是有符号:({i} > {zero_u}) → {i > zero_u}")
print("\n=== 陷阱 2:unsigned 循环变量 ===")
print(" " + pad("k 的值", 12) + pad("k >= 0(unsigned)", 20) + "说明")
for k in (2, 1, 0, -1):
ku = to_u32(k)
print(" " + pad(str(ku), 12) + pad(str(ku >= 0), 20)
+ ("正常" if ku != to_u32(-1) else "k-- 从 0 借位 → 4294967295,循环永不结束"))
print("\n=== 陷阱 3:sizeof 是无符号(size_t)===")
print(" " + pad("表达式", 22) + pad("真实值", 8) + pad("无符号解释", 14) + ">= 0 ?")
for expr, val in (("sizeof(int) - 4", 4 - 4), ("sizeof(char) - 2", 1 - 2)):
uv = to_u32(val)
print(" " + pad(expr, 22) + pad(str(val), 8) + pad(str(uv), 14) + str(uv >= 0))
print("\n=== 安全写法对照 ===")
print(" 危险: for (unsigned i = n; i >= 0; i--) // i >= 0 恒真")
print(" 正确: for (int i = (int)n - 1; i >= 0; i--) // 用有符号")
print(" 正确: for (unsigned i = n; i-- > 0; ) // 用后置自减做判断")
print(" 危险: if (i - j >= 0) // 无符号相减可能借位")
print(" 正确: if (i >= j) // 直接比较")预期输出:
=== 陷阱 1:-1 > 0u ===
比较前:C 把 int 转成 unsigned → -1 变成 4294967295
于是 4294967295 > 0 → True (应为 True!)
如果两边都是有符号:(-1 > 0) → False
=== 陷阱 2:unsigned 循环变量 ===
k 的值 k >= 0(unsigned) 说明
2 True 正常
1 True 正常
0 True 正常
4294967295 True k-- 从 0 借位 → 4294967295,循环永不结束
=== 陷阱 3:sizeof 是无符号(size_t)===
表达式 真实值 无符号解释 >= 0 ?
sizeof(int) - 4 0 0 True
sizeof(char) - 2 -1 4294967295 True
=== 安全写法对照 ===
危险: for (unsigned i = n; i >= 0; i--) // i >= 0 恒真
正确: for (int i = (int)n - 1; i >= 0; i--) // 用有符号
正确: for (unsigned i = n; i-- > 0; ) // 用后置自减做判断
危险: if (i - j >= 0) // 无符号相减可能借位
正确: if (i >= j) // 直接比较三条结论:
| 结论 | 说明 |
|---|---|
| 混合运算一律转向无符号 | 一旦表达式里出现无符号,负数就变成巨正数 |
sizeof、strlen、数组长度都是 size_t(无符号) | 它们出现在循环条件/减法里时必须警惕 |
编译器常常给警告(-Wsign-compare) | 别关警告——这条警告就是为了防这个坑 |
例 4:浮点数——0.1 到底存成了什么
任务:把 0.1 的二进制表示和误差算出来。
c
#include <stdio.h>
int main(void) {
float f = 0.1f;
double d = 0.1;
printf("0.1f == 0.1 ? %d\n", f == 0.1f); /* 自比必真 */
printf("0.1+0.2 == 0.3 ? %d\n", 0.1 + 0.2 == 0.3); /* 通常为 0(假) */
printf("f = %.20f\n", (double)f);
printf("d = %.20f\n", d);
return 0;
}用 Python 复算(Python 的 float 就是 C 的 double,struct 能拿到原始位型):
python
import struct, unicodedata
def w(s):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in s)
def pad(s, n):
return s + " " * max(0, n - w(s))
def show32(x):
"""把 Python float 看作 C 的 float(单精度)"""
raw = struct.unpack('<I', struct.pack('<f', x))[0]
s = (raw >> 31) & 1
e = (raw >> 23) & 0xFF
m = raw & 0x7FFFFF
return raw, s, e, m
print("=== 0.1 的单精度位型 ===")
raw, s, e, m = show32(0.1)
print(f" 0.1 → 0x{raw:08X}")
print(f" S = {s} E = {e} (真指数 = {e} - 127 = {e-127}) M = 0x{m:06X}")
print(f" = (-1)^{s} × 1.{m:023b} × 2^{e-127}")
val = struct.unpack('<f', struct.pack('<I', raw))[0]
print(f" 按公式还原 = {val:.20f} (C 里 printf(\"%.20f\") 长这样)")
print(f" 与 0.1 的差 = {val - 0.1:.3e}")
print("\n=== 十进制小数为什么大多是无限二进制小数 ===")
print(" 只有分母是 2 的幂的分数,二进制才有限:")
print(" " + pad("十进制", 8) + pad("二进制", 26) + "是否有限")
for frac, dec, binstr in [(0.5, "1/2", "0.1"),
(0.25, "1/4", "0.01"),
(0.125, "1/8", "0.001"),
(0.1, "1/10", "0.0001100110011... 循环"),
(0.2, "1/5", "0.001100110011... 循环"),
(0.3, "3/10", "0.0100110011001... 循环")]:
limited = "是" if frac in (0.5, 0.25, 0.125) else "否(凑不出 5 的因子)"
print(" " + pad(str(frac), 8) + pad(binstr, 26) + limited)
print("\n=== 0.1 + 0.2 == 0.3 ? ===")
a, b = 0.1 + 0.2, 0.3
print(f" 0.1 + 0.2 = {a:.20f}")
print(f" 0.3 = {b:.20f}")
print(f" 相等吗? {a == b}")
print(f" 差 = {a - b:.3e}")
print("\n=== 正确的浮点比较写法 ===")
EPS = 1e-9
print(f" fabs(0.1+0.2 - 0.3) < 1e-9 → {abs(a - b) < EPS}")
print("\n=== 有效位数(十进制)估算 ===")
print(f" float : 尾数 23 位 + 隐含 1 位 = 24 位二进制 ≈ 24·log10(2) = {24 * 0.30103:.1f} 位十进制")
print(f" double: 尾数 52 位 + 隐含 1 位 = 53 位二进制 ≈ 53·log10(2) = {53 * 0.30103:.1f} 位十进制")预期输出:
=== 0.1 的单精度位型 ===
0.1 → 0x3DCCCCCD
S = 0 E = 123 (真指数 = 123 - 127 = -4) M = 0x4CCCCD
= (-1)^0 × 1.10011001100110011001101 × 2^-4
按公式还原 = 0.10000000149011611938 (C 里 printf("%.20f") 长这样)
与 0.1 的差 = 1.490e-09
=== 十进制小数为什么大多是无限二进制小数 ===
只有分母是 2 的幂的分数,二进制才有限:
十进制 二进制 是否有限
0.5 0.1 是
0.25 0.01 是
0.125 0.001 是
0.1 0.0001100110011... 循环 否(凑不出 5 的因子)
0.2 0.001100110011... 循环 否(凑不出 5 的因子)
0.3 0.0100110011001... 循环 否(凑不出 5 的因子)
=== 0.1 + 0.2 == 0.3 ? ===
0.1 + 0.2 = 0.30000000000000004441
0.3 = 0.29999999999999998890
相等吗? False
差 = 5.551e-17
=== 正确的浮点比较写法 ===
fabs(0.1+0.2 - 0.3) < 1e-9 → True
=== 有效位数(十进制)估算 ===
float : 尾数 23 位 + 隐含 1 位 = 24 位二进制 ≈ 24·log10(2) = 7.2 位十进制
double: 尾数 52 位 + 隐含 1 位 = 53 位二进制 ≈ 53·log10(2) = 16.0 位十进制三条结论:
| 结论 | 说明 |
|---|---|
| 浮点误差不是"精度不够",是"表示法装不下" | 0.1 在二进制里是无限循环,截断必然有误差 |
| 误差会随运算累积 | 0.1 + 0.2 的误差量级 1e-17(double)——直接 == 比较几乎必假 |
| 比较要用容差 | fabs(a-b) < eps;金融计算应当用整数(分)或十进制库,不用 double |
考点
考点
1. sizeof 与位宽(送分题,但错的人不少)
char恒 1 字节(C 里"字节"就是"一个char");int= 4 字节(64 位机上也是 4,不是 8);long随平台变(32 位 4 / 64 位 Linux 8)——要定宽用int32_t/int64_t;- 指针宽度 = 地址宽度:32 位 4 字节、64 位 8 字节;
sizeof(int)==sizeof(void*)只是 32 位的巧合; sizeof返回size_t(无符号),参与减法时容易翻车。
2. 补码(必考)
- 定义:
−x ≡ x̄ + 1 (mod 2ⁿ)——"取反加一"是口诀,本质是"模 2ⁿ 的加法逆元"; - 范围:
−2ⁿ⁻¹ ~ 2ⁿ⁻¹ − 1(负数多一个); - 全 1 = −1(
0xFFFFFFFF);最小负数取反加一还是它自己(唯一例外,溢出); - 补码让减法变成
A + B̄ + 1(回链circuit/12-alu.md的 ALU 减法通路)。
3. 有符号 vs 无符号(最容易送命)
| 操作 | 有符号 | 无符号 |
|---|---|---|
| 比较 | slt | sltu |
| 右移 | sra(补符号位) | srl(补 0) |
| 扩展 | 符号扩展(lb) | 零扩展(lbu) |
- 同一串位两种读法:
0xFFFFFFFF是 −1 还是 4294967295,由类型决定; - 混合运算转向无符号 →
-1 > 0u为真;unsigned循环变量i >= 0恒真 → 死循环。
4. 隐式转换与截断
- 窄 ← 宽:直接砍高位(
char c = 300→ 44); - 宽 ← 窄:看来源的符号性(
(unsigned char)200→ 200;(signed char)200→ −56); - 整型提升:
char/short运算前先提升成int。
5. IEEE 754(计组高频)
- 单精度:1 符号 + 8 指数(偏置 127)+ 23 尾数;双精度:1 + 11(偏置 1023)+ 52;
- 值 =
(−1)^S × 1.M × 2^(E−127)(规格化); - 有效十进制位:
float约 7 位、double约 16 位; 0.1是无限二进制小数 → 浮点不能用==比较;- 尾数隐含最高位 1(规格化数的"免费精度");E 全 0 / 全 1 是规格外(0、非规格化数、inf、NaN)。
6. 位运算
~x = −x − 1(~0 = −1,全 1);- 有符号
>>是实现定义(多为算术右移);要逻辑右移必须转无符号; x << 32对 32 位int是 UB;&优先级低于==→ 必须写(x & 1) == 0;- 位域布局是实现相关的 → 协议解析务必用显式移位 + 掩码。
小结
- 类型不是数据的属性,是编译器的说明书:运行时只剩字节,类型在编译期被"用掉"(决定指令、偏移、比较方式)。
- 类型三要素:宽度 + 符号性 + 编码。
- 宽度:
char1、short2、int4、long随平台、long long8、指针=地址宽度;sizeof返回无符号size_t。 - 补码:
−x = x̄ + 1 (mod 2ⁿ),范围−2ⁿ⁻¹ ~ 2ⁿ⁻¹−1,全 1 是 −1;它让减法用加法器实现。 - 有符号与无符号的位型完全相同,差别只在比较(
slt/sltu)、右移(sra/srl)、扩展(符号/零)。 - 混合运算一律转向无符号 →
-1 > 0u为真、无符号循环变量永不小于 0 —— 这是 C 最常见的一类 bug。 - 截断看宽度、扩展看符号:
(char)300 = 44,(signed char)200 = −56,(unsigned char)200 = 200。 - IEEE 754:1+8+23(float)/ 1+11+52(double),值 =
(−1)^S × 1.M × 2^(E−127);0.1无限循环 → 浮点不能用==。 - 位运算:
~x = −x−1、有符号右移实现定义、&优先级低于==、位域布局不可移植。
回到主线:到这里,"一串位怎么被解释"讲完了——但 C 真正区别于其他语言的东西还没出场。
int和float谁都会讲。C 的灵魂是另一件事:一个变量里装的不是值,而是"另一个变量的地址"。指针为什么能"改掉调用者的变量"?p+1为什么不是"地址加 1"?数组名和指针到底是不是一回事?——下一篇:指针的本质。
下一篇:指针的本质:地址与类型
评论(0)
当前浏览器不允许本地存储,评论无法保存。
还没有评论,来说两句。