云计算百科
云计算领域专业知识百科平台

【C Primer Plus 精读】Day1

【C Primer Plus 精读】Day1 从数据底层出发:整数补码、浮点数IEEE 754与大小端的深度拆解

  • 🎯 前言
  • 一、C语言为什么值得学:从"系统级语言"的本质说起
  • 二、第一个C程序背后:从源码到可执行文件
    • 深度思考:为什么要分这么多阶段?
  • 三、整数:从原码反码到补码,一次说清楚
    • 3.1 原码、反码、补码:三兄弟的故事
    • 3.2 补码的数学本质
    • 3.3 整数溢出:未定义行为
    • 深度思考:为什么有符号溢出是 UB,无符号溢出却定义明确?
  • 四、浮点数:IEEE 754 标准的精妙与陷阱
    • 4.1 科学计数法:浮点数的基本思想
    • 4.2 IEEE 754 单精度(float)的内存布局
    • 4.3 为什么 0.1 + 0.2 ≠ 0.3?
    • 4.4 特殊值:无穷大、NaN、非规格化数
    • 深度思考:float 和 double 的精度到底差多少?
  • 五、char 类型:有符号还是无符号?这是个问题
    • 5.1 为什么 char 的符号性不确定?
    • 5.2 怎么处理?
    • 深度思考:为什么 C 语言里有这么多"实现定义"和"未定义行为"?
  • 六、大小端:多字节数据的存储顺序
    • 6.1 什么是大小端?
    • 6.2 为什么会有两种字节序?
    • 6.3 用 C 代码判断大小端
    • 深度思考:除了大小端,还有"中间端"吗?
  • 📝 总结
  • 📚 参考资料

本文是《C Primer Plus》七日阅读计划的第一天,覆盖第1-3章(初识C语言、C语言概述、数据和C)。虽然是入门章节,但我想从数据底层切入,深入到补码运算、IEEE 754 浮点标准、大小端存储、char 的符号性等真正能体现 C 语言"靠近硬件"特性的知识点。


🎯 前言

这是我读《C Primer Plus》的第一天笔记。

说实话,第1-3章的内容看起来很"基础"——C语言历史、第一个 Hello World 程序、基本数据类型,很多人可能翻翻就过去了。但我觉得恰恰是这些"基础"里藏着 C 语言最本质的东西:它为什么能成为系统级编程语言?它和硬件之间到底有多近?

这篇笔记我不想停留在"int 是整型、float 是浮点型"这种浅层面,而是想往下挖几层:

  • 整数为什么用补码存储?补码的数学原理是什么?
  • 浮点数为什么不精确?IEEE 754 标准到底做了什么?
  • char 到底是有符号还是无符号?为什么这是个"实现定义"的问题?
  • 大小端是怎么回事?怎么用 C 代码判断当前平台的字节序?

先从一张全书知识体系图开始,今天我们聚焦左下角"数据表示"这一块。


#mermaid-svg-cOCrRhTiGRMtKXPK{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cOCrRhTiGRMtKXPK .error-icon{fill:#552222;}#mermaid-svg-cOCrRhTiGRMtKXPK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cOCrRhTiGRMtKXPK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cOCrRhTiGRMtKXPK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cOCrRhTiGRMtKXPK .marker.cross{stroke:#333333;}#mermaid-svg-cOCrRhTiGRMtKXPK svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cOCrRhTiGRMtKXPK p{margin:0;}#mermaid-svg-cOCrRhTiGRMtKXPK .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster-label text{fill:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster-label span{color:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster-label span p{background-color:transparent;}#mermaid-svg-cOCrRhTiGRMtKXPK .label text,#mermaid-svg-cOCrRhTiGRMtKXPK span{fill:#333;color:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK .node rect,#mermaid-svg-cOCrRhTiGRMtKXPK .node circle,#mermaid-svg-cOCrRhTiGRMtKXPK .node ellipse,#mermaid-svg-cOCrRhTiGRMtKXPK .node polygon,#mermaid-svg-cOCrRhTiGRMtKXPK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cOCrRhTiGRMtKXPK .rough-node .label text,#mermaid-svg-cOCrRhTiGRMtKXPK .node .label text,#mermaid-svg-cOCrRhTiGRMtKXPK .image-shape .label,#mermaid-svg-cOCrRhTiGRMtKXPK .icon-shape .label{text-anchor:middle;}#mermaid-svg-cOCrRhTiGRMtKXPK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cOCrRhTiGRMtKXPK .rough-node .label,#mermaid-svg-cOCrRhTiGRMtKXPK .node .label,#mermaid-svg-cOCrRhTiGRMtKXPK .image-shape .label,#mermaid-svg-cOCrRhTiGRMtKXPK .icon-shape .label{text-align:center;}#mermaid-svg-cOCrRhTiGRMtKXPK .node.clickable{cursor:pointer;}#mermaid-svg-cOCrRhTiGRMtKXPK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cOCrRhTiGRMtKXPK .arrowheadPath{fill:#333333;}#mermaid-svg-cOCrRhTiGRMtKXPK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cOCrRhTiGRMtKXPK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cOCrRhTiGRMtKXPK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cOCrRhTiGRMtKXPK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cOCrRhTiGRMtKXPK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cOCrRhTiGRMtKXPK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster text{fill:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK .cluster span{color:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cOCrRhTiGRMtKXPK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cOCrRhTiGRMtKXPK rect.text{fill:none;stroke-width:0;}#mermaid-svg-cOCrRhTiGRMtKXPK .icon-shape,#mermaid-svg-cOCrRhTiGRMtKXPK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cOCrRhTiGRMtKXPK .icon-shape p,#mermaid-svg-cOCrRhTiGRMtKXPK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cOCrRhTiGRMtKXPK .icon-shape .label rect,#mermaid-svg-cOCrRhTiGRMtKXPK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cOCrRhTiGRMtKXPK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cOCrRhTiGRMtKXPK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cOCrRhTiGRMtKXPK :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

C语言入门知识体系

语言基础第1-2章

数据与类型第3章

流程控制第6-7章

函数与指针第9章

数组与字符串第10-11章

内存管理第12章

高级数据第14-17章

整数补码

IEEE 754浮点

大小端与对齐

char符号性与可移植


一、C语言为什么值得学:从"系统级语言"的本质说起

第一章介绍了 C 语言的起源和特点,其中最打动我的一句话是:“C语言具有通常是汇编语言才具有的微调控制能力”。

这句话的分量,初学的时候可能体会不到。但你越往下学就越会发现:C 语言几乎没有"黑箱"——内存里的每一个字节是什么、变量存在栈上还是堆上、函数调用时栈帧怎么变化,这些你不仅能搞清楚,还能手动控制。这是 Python、Java 等高级语言完全不同的思维方式。

C 语言之所以能成为操作系统、编译器、嵌入式系统的首选语言,根本原因就在于它的**零开销抽象(zero-overhead abstraction)**哲学:你不需要的东西,不会让你付出任何代价。int 就是 4 字节(在大多数平台上),直接对应 CPU 的寄存器操作,没有装箱、没有对象头、没有垃圾回收。

💡 一个有趣的视角:很多人说 C 语言是"高级汇编",这话不完全准确,但也不全错。C 语言的每一个基本操作几乎都能直接映射到机器指令。你写的 a + b 就是一条 add 指令,你写的 *p 就是一次内存解引用。这种"所见即所得"的透明性,是理解计算机底层工作原理的最佳窗口。


二、第一个C程序背后:从源码到可执行文件

第二章给出了最简单的 C 程序示例:

#include <stdio.h>

int main(void) {
int num = 42;
printf("Hello, %d!\\n", num);
return 0;
}

书里讲了编译和运行的基本步骤,但我想补充一个更完整的视角:从 .c 文件到可执行文件,中间到底经历了什么?

整个过程分四步:预处理 → 编译 → 汇编 → 链接。

阶段输入输出做了什么
预处理 .c 源文件 .i 预处理后文件 处理 #include、#define、条件编译等指令
编译 .i 文件 .s 汇编代码 C 语言翻译成汇编指令
汇编 .s 文件 .o 目标文件 汇编指令翻译成机器码(二进制)
链接 .o 文件 + 库 可执行文件 合并目标文件、解析符号引用、重定位地址

可以用一条命令看到每一步的结果:

# 只做预处理
gcc -E hello.c -o hello.i

# 编译到汇编
gcc -S hello.c -o hello.s

# 汇编到目标文件
gcc -c hello.c -o hello.o

# 完整链接生成可执行文件
gcc hello.c -o hello

hello.o 是二进制的目标文件,里面已经是机器码了,但还不能直接运行——因为 printf 函数的具体地址还没填上,要等链接器去 C 标准库里找到它,把调用地址"补上",这叫符号解析和重定位。


深度思考:为什么要分这么多阶段?

问:编译器为什么不直接从源码生成可执行文件?非要分预处理、编译、汇编、链接四步?

答:这其实是一个"关注点分离"的经典设计,每一层都有自己独立的职责和存在理由:

1. 预处理独立的原因 预处理是纯文本操作(宏替换、文件包含、条件编译),它根本不理解 C 语法。把它独立出来,既简化了编译器主体(不用处理文本替换逻辑),也让宏机制更灵活——你甚至可以用预处理器处理非 C 代码。

2. 汇编独立的原因 编译阶段输出汇编而不是直接输出机器码,是因为汇编语言是机器指令的"可读形式",方便调试和理解。而且不同架构的汇编格式不同,把"生成汇编"和"汇编成机器码"分开,编译器前端可以复用,只需要为不同架构写不同的汇编器。

3. 链接独立的原因 这是最重要的一点。链接器的存在使得多文件编译成为可能:你可以把一个大项目拆成多个 .c 文件,分别编译成 .o,最后再链接到一起。修改一个文件只需要重新编译那一个文件,不用重新编译整个项目。这就是"分离编译"(separate compilation)模型。

更深层地说,链接器还负责库的链接——静态链接把库代码直接拷进可执行文件,动态链接则在运行时加载。如果没有独立的链接阶段,这些机制都无法实现。

📖 四阶段编译模型是软件工程中"分层与分离"思想的典型体现:每层只负责一件事,层间通过稳定的中间格式(预处理代码、汇编代码、目标文件)解耦。这不仅是技术实现,更是一种设计哲学——复杂系统的构建,本质就是合理拆分和接口设计。


三、整数:从原码反码到补码,一次说清楚

第三章开始讲数据类型,第一个重点就是整数类型。

书里列出了 int、short、long、long long,以及它们的 unsigned 版本,但只是介绍了用法。我想深入探讨一个问题:整数在内存里到底是怎么存的?为什么要这么存?

3.1 原码、反码、补码:三兄弟的故事

为了表示负数,计算机科学家们想过好几种方案:

原码(Sign-Magnitude):最高位是符号位,0 表示正,1 表示负,其余位是绝对值。

+5 = 0000 0101
-5 = 1000 0101

原码的问题:+0 和 -0 不统一(00000000 和 10000000),浪费了一个编码。而且做加减法要单独处理符号位,电路设计复杂。

反码(One’s Complement):正数的反码等于原码,负数的反码是符号位不变,其余位取反。

+5 = 0000 0101
-5 = 1111 1010

反码的问题:还是有 +0 和 -0 两个零,而且加法需要处理"循环进位",还是不够优雅。

补码(Two’s Complement):正数的补码等于原码,负数的补码是其反码加 1。

+5 = 0000 0101
-5 = 1111 1011 (反码 11111010 + 1 = 11111011)

补码的优势巨大:

  • 零的表示唯一(00000000)
  • 加减法可以用同一套电路:减去一个数等于加上它的补码
  • 符号位自然参与运算,不需要特殊处理

所以现代计算机无一例外都采用补码来存储有符号整数。

3.2 补码的数学本质

补码的思想其实来自于"模运算"。想象一个 8 位的计数器,它的模就是 2^8 = 256。当计数到 255 再加 1,就会溢出回到 0——这和钟表的 12 进制是一样的。

在模 256 的体系里:

  • 5 – 3 等价于 5 + (256 – 3) = 5 + 253 = 258 ≡ 2 (mod 256)
  • 253 的二进制就是 1111 1101,这恰好就是 -3 的 8 位补码表示!

所以负数的补码,本质上就是在模 2^n 下这个数的正同余数。

-n ≡ 2^n – n (mod 2^n)

这个数学性质太重要了,它意味着:用补码表示的有符号整数,所有的加减法运算都可以直接当无符号数来算,结果自动就是对的。CPU 根本不需要知道你把它当有符号还是无符号,电路是同一套——只有在判断大小、输出打印的时候,才需要区分解释方式。

3.3 整数溢出:未定义行为

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

int main() {
int max = INT_MAX;
printf("INT_MAX = %d\\n", max);
printf("INT_MAX + 1 = %d\\n", max + 1); // 会发生什么?
return 0;
}

你可能会说:“这还不简单,溢出了呗,变成 INT_MIN 了。”

但我要提醒你:有符号整数溢出在 C 标准中是"未定义行为"(Undefined Behavior, UB)。

什么意思?就是 C 标准没有规定溢出后会发生什么。你在 x86 + GCC 上可能看到"回绕"成负数,但换到另一个编译器或另一个平台,结果完全不可预测——编译器甚至可以直接把整个程序优化掉。

这是初学者最容易踩的坑之一:不要依赖有符号整数溢出的"回绕"行为。如果需要确定的回绕语义,请用 unsigned 类型——无符号整数的溢出是有明确定义的(模 2^n)。


深度思考:为什么有符号溢出是 UB,无符号溢出却定义明确?

问:同样是溢出,为什么 C 标准对有符号和无符号整数区别对待?这不是双重标准吗?

答:这不是双重标准,而是历史原因和优化空间的权衡。

历史原因:C 语言设计的年代,并不是所有计算机都用补码。有些机器用反码(比如 CDC 6000 系列),有些用原码(比如早期的 IBM 大型机)。如果 C 标准强行规定有符号溢出的行为,那在非补码机器上实现起来就会有额外开销,违背了 C 语言"不为你不用的东西付出代价"的原则。

优化原因:把有符号溢出定义为 UB,给了编译器很大的优化空间。比如:

for (int i = 0; i >= 0; i++) {
// 循环体
}

如果有符号溢出是 UB,编译器就可以假设 i 永远不会溢出变成负数(因为溢出是 UB,程序员有义务保证不发生),从而推断出这个循环是死循环,可以做相应的优化。如果溢出有明确定义(回绕),编译器就不能做这个假设了。

再举一个例子:

int foo(int x) {
return x + 1 > x; // 这个总是真的吗?
}

如果有符号溢出是 UB,编译器就可以直接把整个函数优化为 return 1;,因为 x + 1 > x 在不溢出的情况下恒真,而溢出是 UB 不用考虑。

无符号整数就不一样了——标准明确规定无符号算术是模 2^n 的,所以编译器不能做这种假设,必须老老实实生成加法指令。

📖 未定义行为是 C 语言设计哲学的核心体现:信任程序员,把正确性的责任交给程序员,换取最大的优化空间。理解 UB 是从"会写 C 代码"到"懂 C 语言"的必经之路——你不仅要知道"代码该怎么写",还要知道"哪些写法是标准不保证的"。


四、浮点数:IEEE 754 标准的精妙与陷阱

第三章另一个重点是浮点数类型:float、double、long double。

很多人都听过"浮点数不精确",但为什么不精确?误差到底有多大?这背后的 IEEE 754 标准到底是怎么回事?我来好好拆解一下。

4.1 科学计数法:浮点数的基本思想

浮点数的核心思想是科学计数法。比如 123.45 可以写成:

1.2345 × 10^2

三部分组成:尾数(significand/mantissa) 1.2345、基数(base) 10、指数(exponent) 2。

计算机用二进制,所以 IEEE 754 浮点数用的是二进制科学计数法:

(-1)^s × m × 2^e

  • s:符号位,0 正 1 负
  • m:尾数(有效数字),1 ≤ m < 2
  • e:指数

4.2 IEEE 754 单精度(float)的内存布局

一个 32 位的 float 在内存中是这样分布的:

1位 8位 23位
+—+———-+———————–+
| s | 指数 e | 尾数 m |
+—+———-+———————–+
31 30 23 22 0

但有两个精妙的设计:

1. 尾数的"隐含 1"

因为尾数 m 总是满足 1 ≤ m < 2(这叫"规格化数"),所以整数部分那个 1 是固定的,可以不存!23 位只存小数部分,相当于免费多了 1 位精度。所以 float 的有效数字实际上是 24 位(大约 7 位十进制数字)。

2. 指数的"偏置"表示

指数可以是正数也可以是负数,但如果用补码存,比较大小会比较麻烦。IEEE 754 采用了**偏置指数(biased exponent)**的方案:

  • 单精度偏置值:127
  • 存储的指数值 = 真实指数 + 127

比如真实指数是 -3,就存成 124(01111100);真实指数是 5,就存成 132(10000100)。

这样做的好处是:浮点数的大小比较可以直接用整数比较的方式来做——把两个 float 的位模式当作无符号整数比大小,结果和浮点数本身的大小关系是一致的(同号时)。这个设计让硬件实现浮点数比较变得非常简单。

4.3 为什么 0.1 + 0.2 ≠ 0.3?

经典问题,直接上答案:因为 0.1 在二进制中是无限循环小数。

就像 1/3 在十进制里是 0.3333… 写不完一样,0.1 转换成二进制:

0.1 × 2 = 0.2 → 0
0.2 × 2 = 0.4 → 0
0.4 × 2 = 0.8 → 0
0.8 × 2 = 1.6 → 1
0.6 × 2 = 1.2 → 1
0.2 × 2 = 0.4 → 0 ← 开始循环了

所以 0.1 的二进制是 0.0001100110011…(0011 循环),永远写不完。float 只有 23 位尾数位,只能截断存储,必然有误差。

验证一下:

#include <stdio.h>

int main() {
float a = 0.1f;
float b = 0.2f;
printf("0.1 + 0.2 = %.10f\\n", a + b);
printf("0.3 = %.10f\\n", 0.3f);
return 0;
}

输出大概是:

0.1 + 0.2 = 0.3000000119
0.3 = 0.3000000119

等等,两个输出一样?那 a + b == 0.3f 应该是 true 吧?不一定——这取决于具体的计算精度和编译器优化。在 x86 上,a + b 可能先用 80 位扩展精度计算,再存回 float,中间的舍入误差可能导致结果和直接写的 0.3f 不一样。

结论:永远不要用 == 比较浮点数是否相等。应该用"差值是否在可接受范围内"来判断:

#include <math.h>

int is_equal(float a, float b, float epsilon) {
return fabs(a b) < epsilon;
}

4.4 特殊值:无穷大、NaN、非规格化数

IEEE 754 还定义了一些特殊值,它们用全 0 或全 1 的指数来表示:

指数尾数含义
全 0 全 0 ±0
全 0 非 0 非规格化数(denormal)
全 1 全 0 ±∞
全 1 非 0 NaN(Not a Number)

非规格化数很有意思——当指数全 0、尾数非 0 时,尾数的隐含整数部分不是 1 而是 0,指数的值是 -126(而不是 -127)。这使得浮点数可以表示比规格化的最小值还小的数,实现了"逐级下溢"(gradual underflow),避免了两个很小的数相减直接变成 0 的精度损失。

NaN 也很特别:任何涉及 NaN 的运算结果都是 NaN,而且 NaN != NaN(自己和自己都不相等)。判断一个值是不是 NaN,要用 isnan() 函数,不能用 x == x 的技巧(虽然在很多平台上这也行,但不规范)。


深度思考:float 和 double 的精度到底差多少?

问:float 是 32 位,double 是 64 位,精度差了一倍吗?实际使用中什么时候该用 float,什么时候该用 double?

答:先看两者的参数对比:

类型总位数符号位指数位尾数位有效位数(十进制)指数范围
float 32 1 8 23(+1隐含) 约 7 位 -126 ~ +127
double 64 1 11 52(+1隐含) 约 15-16 位 -1022 ~ +1023

精度不是差一倍,而是差了大约 9 位十进制有效数字——float 大概 7 位,double 大概 15-16 位。这个差距非常大。

举个具体的例子:表示人民币金额时,假设最大金额是 100 万亿(10^14 元):

  • 用 float 的话,精度大约是 10^14 / 2^24 ≈ 600 万元——也就是说,误差可能有几百万元,完全不能用
  • 用 double 的话,精度大约是 10^14 / 2^53 ≈ 0.01 元——到分级别基本够用,但严格来说还是不推荐(金融领域应该用定点数或十进制浮点)

什么时候用 float,什么时候用 double?

  • 内存/显存紧张时用 float:比如图形学、深度学习训练中,GPU 显存是瓶颈,float 能省一半空间,速度也更快
  • 精度优先时用 double:科学计算、工程仿真、金融计算等对精度要求高的场景
  • C 语言默认是 double:注意!printf 里的 %f 对应的是 double(因为 float 传递给可变参数函数时会自动提升为 double),3.14 这样的字面量默认也是 double 类型,加 f 后缀才是 float

📖 float 与 double 的选择本质是"精度-空间-速度"的三维权衡。现代 CPU 上 double 和 float 的运算速度差异很小(都是一条指令),主要差异在内存带宽和缓存占用上——数据量小时无脑用 double,数据量大到内存装不下时再考虑 float 优化。


五、char 类型:有符号还是无符号?这是个问题

第三章里讲了 char 类型——它用来存储字符,但本质上其实是一个 1 字节的整数类型。

这里有一个很容易被忽略的细节:C 标准没有规定 char 是有符号还是无符号的。这是"实现定义"(implementation-defined)的——由编译器决定。

5.1 为什么 char 的符号性不确定?

历史原因:早期不同的机器对字符类型的处理不同。有些机器(比如基于 POWER 的旧系统、ARM 的某些编译器)把 char 当作无符号的,有些机器(比如 x86 上的 GCC、MSVC)把 char 当作有符号的。

C 标准选择了"不强制规定",这样在不同平台上编译器都可以选择最高效的实现方式。

但这就带来了可移植性问题:

#include <stdio.h>

int main() {
char c = 0xFF;
if (c < 0) {
printf("char is signed\\n");
} else {
printf("char is unsigned\\n");
}
return 0;
}

在 x86 的 GCC 上,这段代码会输出 “char is signed”——因为 0xFF 作为有符号 char 就是 -1。但在某些 ARM 编译器上,可能会输出 “char is unsigned”。

5.2 怎么处理?

如果你的代码依赖 char 的符号性(比如做数值计算时把 char 当 1 字节整数用),一定要明确指定:

  • 需要有符号:用 signed char
  • 需要无符号:用 unsigned char

纯字符处理的话,char 就行——反正 ASCII 码都在 0-127 范围内,有符号无符号都一样。

还可以用标准库的 CHAR_MIN 和 CHAR_MAX 宏(定义在 <limits.h> 里)来判断当前平台的 char 范围:

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

int main() {
printf("CHAR_MIN = %d\\n", CHAR_MIN);
printf("CHAR_MAX = %d\\n", CHAR_MAX);
// 如果 CHAR_MIN 是 0,说明 char 是无符号的
// 如果 CHAR_MIN 是 -128,说明 char 是有符号的
return 0;
}


深度思考:为什么 C 语言里有这么多"实现定义"和"未定义行为"?

问:C 标准里到处都是"实现定义"、“未指定”、“未定义行为”,为什么不全部规定死?这样不是更容易写出可移植的代码吗?

答:这其实是 C 语言最大的特点——也是最大的争议点。要理解这个设计,得回到 C 语言诞生的年代(1972 年)和它的定位。

1. 性能优先

C 语言的设计目标是"尽可能接近硬件,但又保持可移植性"。如果标准把所有细节都规定死,那在某些硬件上就要增加额外开销来满足标准。比如:

  • 规定 char 必须是有符号的 → 原生无符号 char 的平台上每次使用都要额外处理
  • 规定整数溢出必须回绕 → 非补码机器上要加额外的溢出检查

C 的哲学是:不为你不用的东西付出代价。如果某个行为在不同平台上本来就不同,那就不强制统一,让各平台用最高效的方式实现。

2. 编译器优化空间

把某些行为定义为 UB,等于告诉编译器:“你可以假设这种情况不会发生”。编译器可以基于这个假设做大量的优化。之前提到的有符号溢出优化就是一个例子。

3. 历史包袱

C 语言诞生时,计算机体系结构比现在多样得多——有补码的,有反码的,有 36 位字长的,有 9 位字节的……标准要兼容所有这些平台,就不可能规定得太死。

那么,实际写代码的时候怎么应对?

很简单:避开所有"实现定义"和"未定义行为"的坑。

类别含义应对策略
未定义行为(UB) 标准完全没规定,任何事情都可能发生 绝对不要写
实现定义 由编译器/平台决定具体行为,但必须有文档 需要可移植时避开,或根据预定义宏做条件编译
未指定行为 标准给出几个选项,编译器选一个,不用固定 不要依赖具体选择

📖 C 语言的"宽松标准"是一把双刃剑:它让 C 语言能在各种硬件上高效运行,也让程序员承担了更多的责任。好的 C 程序员不只是会写能跑的代码,更是清楚地知道哪些行为是标准保证的、哪些是碰都不能碰的雷区。这也是为什么有人说"C 程序员是对硬件理解最深刻的程序员"。


六、大小端:多字节数据的存储顺序

第三章讲了数据类型的大小,但有一个重要的话题书里没展开——多字节数据在内存中是怎么排列的? 也就是"大小端"问题。

6.1 什么是大小端?

以一个 4 字节的整数 0x12345678 为例,它的高位字节是 0x12,低位字节是 0x78。存到内存里有两种方式:

大端(Big Endian):高字节存在低地址

地址递增 →
+—-+—-+—-+—-+
| 12 | 34 | 56 | 78 |
+—-+—-+—-+—-+
低地址 高地址

小端(Little Endian):低字节存在低地址

地址递增 →
+—-+—-+—-+—-+
| 78 | 56 | 34 | 12 |
+—-+—-+—-+—-+
低地址 高地址

名字的由来很有意思——出自《格列佛游记》,小人国里两派人争论吃鸡蛋应该从大头(Big End)敲开还是从小头(Little End)敲开,吵得不可开交。大小端之争也是一样,各有各的道理,谁也说服不了谁。

6.2 为什么会有两种字节序?

大端的好处:

  • 符合人类阅读习惯(和写数字的顺序一致)
  • 先读到高字节就能知道数的正负和大致大小
  • 适合字符串和大数比较

小端的好处:

  • 低字节在低地址,做加法时从低字节开始进位,和计算顺序一致
  • 类型转换方便:int 转 short 直接取低 2 字节就行,地址不用变
  • x86 架构采用小端,现在最主流

现在的 PC 几乎都是小端(x86 / x86_64 / ARM 大部分情况)。但网络协议(TCP/IP)用的是大端字节序,所以网络编程里经常要用 htons / htonl / ntohs / ntohl 这些函数做转换——h 代表 host(主机字节序),n 代表 network(网络字节序),s 是 short,l 是 long。

6.3 用 C 代码判断大小端

怎么用程序判断当前平台是大端还是小端?经典面试题,方法是利用 union:

#include <stdio.h>

int is_little_endian(void) {
union {
int i;
char c;
} u;
u.i = 1;
return u.c; // 小端返回 1,大端返回 0
}

int main() {
if (is_little_endian()) {
printf("小端字节序\\n");
} else {
printf("大端字节序\\n");
}
return 0;
}

原理:union 的所有成员共享同一块内存。把 int 的值设为 1,如果是小端,最低字节就是 1,存在最低地址;char 读取最低地址的那个字节,就是 1。如果是大端,最低字节是 0,char 读到的就是 0。

⚠️ 注意:在 C 标准中,从 union 的不同成员读取数据(类型双关)是有争议的。C99 明确支持这种用法(6.5.2.3 脚注 95),但 C++ 标准中是未定义行为。C 语言里可以放心用,C++ 里推荐用 memcpy 或 std::bit_cast(C++20)。


深度思考:除了大小端,还有"中间端"吗?

问:我好像听说过什么"中端"或者"混合字节序",真的存在吗?

答:确实存在。字节序不只是"大端 / 小端"二元对立的,还有更复杂的情况。

PDP-11 的中间端(Middle Endian)

历史上 DEC 的 PDP-11 机器有一种很奇怪的存储方式:对于 32 位整数 0x12345678,它会存成 34 12 78 56。也就是说,它把 32 位数当成两个 16 位的"半字",每个半字内部是小端,但半字之间是大端排列。这种存储方式有时候也叫 PDP 字节序或者混合字节序。

现在这种机器已经很少见了,所以一般讨论字节序的时候还是默认只有大端和小端两种。

位序呢?字节内部的 bit 顺序需要考虑吗?

这个问题问得很深入。答案是:通常不需要。因为 CPU 对字节内部的位操作是封装好的——你用左移右移运算符,得到的结果在所有平台上都是一致的,硬件会处理好位序的细节。

只有当你需要做位级别的串行传输(比如 SPI 通信)、或者处理压缩格式的位域时,才需要关心字节内部的位序(叫 bit-endianness 或 bit numbering)。对于普通的 C 语言编程,这个问题基本遇不到。

📖 字节序问题的本质是:当数据被拆分成更小的单元存储或传输时,单元之间的排列顺序可能不一致。这个问题无处不在——字节之间有字节序,位之间有位序,网络传输有网络字节序,文件格式有文件字节序。理解大小端不只是为了过面试,更是理解"数据的表示不是唯一的"这个底层认知。


📝 总结

虽然只是第1-3章的入门内容,但往深了挖能挖出来的东西真的不少:

  • C 语言的本质:零开销抽象、靠近硬件、信任程序员。理解这些设计哲学比死记语法重要得多
  • 补码:不是什么"反码加1"的口诀,而是模运算的必然结果,是加法电路统一化的关键
  • IEEE 754:隐含 1、偏置指数、非规格化数,每一个设计细节都是为了在有限的位数里做到精度和范围的最优平衡
  • 未定义行为:C 语言最大的坑,也是 C 语言高效的原因。理解 UB 是入门和进阶的分水岭
  • 大小端:不只是面试题,而是理解"数据表示不是唯一的"这个底层认知的钥匙
  • 第一天的内容看似简单,但这些底层概念其实贯穿了整个 C 语言的学习。把这些基础打扎实了,后面学指针、学内存管理、学结构体都会顺很多。

    明天继续第4-5章:字符串格式化输入输出 + 运算符表达式和语句~

    点个赞收藏一下呗~有理解不对的地方欢迎评论区指正!


    📚 参考资料

    • C Primer Plus(第6版)中文版 — Stephen Prata 著,姜佑 译
    • IEEE Std 754-2008: IEEE Standard for Floating-Point Arithmetic
    • ISO/IEC 9899:2011 (C11 标准)
    • cppreference.com: C programming language reference
    • 深入理解计算机系统(CSAPP) — Randal E. Bryant 等著

    推荐标签:C语言、补码、IEEE754、大小端、数据类型、C Primer Plus 推荐分类:C/C++ / 学习笔记

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【C Primer Plus 精读】Day1
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!