💻 CSC3060 Week 1-2 Introduction to Computer System
有一位伟人曾经说过:“学计算机的有三类人:图灵 (你知道我指的是什么)、冯诺依曼、以及明日方舟玩家/管人痴。”
Week 1 Introduction
Computer System = Hardware + System Software
We want computers to …
- do more (more transistors, more cores …)
- run faster (concurrency, optimization, cache …)
本科目偏向于软件,比如代码如何被编译,如何优化代码等等。课本为 CS: APP (v3)。
Why Binary?
为什么冯·诺依曼选择了二进制?
从技术角度思考,当代芯片技术 CMOS 大致符合以下动态功耗:P ≈ k·C·U2·f (负载电容 x 电压方 x 交变频率的倍数),使用远超 1V 的电路有指数级增大的漏电、击穿和熔断风险,加之芯片技术不断微缩,高电压的稳定性和对冷却系统的要求完全不现实。因此我们只用 0~1V 左右的电信号表达 ”信息“。
在此前提下,由于电压误差十分常见,所以噪音裕量 (noise margin) 是必要的——比如 “0” 跟 “1” 不能粗糙的定义为 0.1V 跟 0.2V,因为电压噪音让两者在现实操作中难以分辨。在此情况下,运行最稳定的就是二进制,即 0V 附近表达 “0”,1V 附近表达 “1”,噪音难以让两者 “互串”。
除此之外,电子管、继电器、晶体管天然都是 ”开关“ 双稳态 (bistable) 设备,而自然界大部分东西都是双稳态的,甚至包括人类的逻辑——布尔代数——你很难想象比 ”是非“ 更精简直白的逻辑系统了。
归根结底,“宇宙想要毁灭计算机”,因为 稳定性 (确定性) 是反熵增定律 的,而二进制最具确定性、最黑白分明不过了。
硬件:CPU
-
CPU (处理器) 由一个或多个叫做 “core (核)” 的最小处理单元组成,核至少可以独立处理一条进程,所以“多核并行,单核并发”,后续展开阐述。
-
CPU 并非连续运作,而是按照 “fetch, decode, execute” (从内存/缓存取指令、解译命令、运算或访问)的循环周期性工作。最小时间单位为 cycle,以 GHz (频率)计算,比如 3GHz 的 CPU每秒处理 3x109 个 cycle。
-
CPU 由以下基本部分组成:
PC (program counter) 是 CPU 的状态存储部件,属于特殊的指令寄存器,存储指向主存中某条命令的指针,同时只能被一个进程的一个命令占用,CPU 处理 PC 指向的命令,然后更新 PC 指向下一个命令……它不连接数据存储模块,因为经手数据流。
Registers 寄存器:存储一个 “字” (即 n-bit 计算机上的 n bit 二进制数) 的 “最小最快” 存储结构;
Register file 寄存器文件:一些通用寄存器组成的临时数据存储部件,一共只有几百字节,它们直接连接着 ALU;
ALU 是计算与逻辑模块,对寄存器文件的一或多个寄存器的值进行计算;
SRAM L1~L3 高速缓存:连接着 Register file
bus interface 是 CPU 暴露给总线的接口,用于通信。
硬件:Bus (总线)
-
CPU、内存、I/O 设备都用一系列物理层的导线 “bus” 进行通信,传送 “字” 大小的字节块,常见三类信号:数据线 (data)、地址线 (address)、控制线 (control)。缓慢的访存和更加慢的 I/O 会造成总线 “堵车”。这个问题促使 cache、DMA 等技术诞生。
-
Direct Memory Access 在 “设备 ↔ 内存”之间搬数据,不能执行指令;能“绕过 CPU 内的总线,但绕不过 I/O 和内存总线”。
硬件:记忆分区
Memory Hierarchy
| 存储类型 | 访问时间 | 位置 |
|---|---|---|
| L0 Registers 寄存器 | ~1 cycle | CPU |
| L1 Cache | ~4 cycles | CPU |
| L2 Cache | ~10 cycles | CPU |
| L4 DRAM 主存 (内存) | ~100–300 cycles | I/O 桥之外 |
| L5 Local SSD (如磁盘) | ~105 cycles | I/O 桥之外 |
| L6 Web Server | \ | 计算机之外 |
记忆阶级性 hierarchy:上层可用作其下层的缓存 (如 DRAM 是 SSD 的缓存区),从下到上;存储量变小变贵,但速度变快。
分区的原因:主存缓慢、两个局部性、隔离进程。
多级缓存的意义:通过增大空间,消除时间上的访问不确定性 (延长缓存有效时间)。
主存 (DRAM)
主存是 CPU 外,临时的存储模块。电脑的文件未使用时都在磁盘里,只有使用时会缓存到主存。
为什么主存这么慢? DRAM = Dynamic RAM (动态随机存储器):使用电容的 “满/空” 表示二进制 bit,电容会漏电,所以必须周期性刷新 (refresh)。主存慢、便宜、规模大。
因此我们需要 CPU 缓存来缓冲寄存器文件和主存的时间鸿沟。
CPU 缓存 (SRAM)
CPU Cache 是位于 CPU 与主存 (DRAM) 之间的小而快的存储,SRAM 使用 flip-flop circuit 的双稳态表示二进制 bit。缓存快、昂贵、规模小。
- 通过把最可能用到的数据提前放在 CPU 附近防止缓慢的 DRAM 掐死 CPU 的高速运行。 所谓“最可能” 的依据——时间与空间局部性:
- Temporal Locality:访问 A 后的一段时间很可能再次访问 A
- Spatial Locality:访问 A 后,其邻居,如 A-1、A+1 都很可能被访问
Program Lifetime
- 源代码(ASCII text):
printf("hello, world\n");]
- 编译 (compilation):
gcc -o hello hello.c
分为四步: pre-processor:宏展开 (macro expansion); process “include”, “define”; 去注释, .c→.i
compiler:查错 (semantic check), ASCII(.i)→assembly(.s)
assembler:assembly(.s)→binary(.o)
linker:pack object files and libraries into executable, .o→**.exe**
- 实际上编译器是会跳过汇编语言直接编译到二进制,除非编译时加上 “-S” flag。
- 产出可执行文件 (二进制命令) 储存在磁盘上 (Unix: hello, Linux: hello.exe)
- 运行:
./hello
以下为具体过程
┌──────────────────────┐
│ User │ 物理层:输入 "./hello" shell 命令
└──────────┬───────────┘
┌──────────▼───────────┐
│ Shell (bash/zsh) │ 应用层:命令解释器解析用户输入的 shell 命令并请求 OS 执行
└──────────┬───────────┘
┌──────────▼───────────┐
│ OS Kernel │ 软件层:OS kernel 将文件用 DMA 搬到主存,kernel 负责执行调度并发
└──────────┬───────────┘
┌──────────▼───────────┐
│ Device Drivers │ 软硬交界:驱动程序让 OS 可以与硬件通信,负责读入写出
└──────────┬───────────┘
┌──────────▼───────────┐
│ Hardware (CPU/IO) │ 硬件层:CPU 把 "hello, world\n" 搬到寄存器并命令显示器显示
└──────────-───────────┘
- ./hello 键入后,shell 请求 OS 处理这行命令,命令从 I/O 主线经过 I/O 桥再从系统主线进入 CPU 的主线接口,经过内主线存储到寄存器;CPU 接下来直接经过 I/O 桥和内存桥存储 “hello” 到主存;通过 DMA,磁盘 L5 中的 hello 文件按页被临时缓存到主存 L4 ;主存中的命令再被各层 SRAM (cache) 传递到寄存器,并命令 I/O 桥另一侧的显示器显示结果。
- shell 是连接用户输入和 OS 的软件,Unix: zsh 和 Linux: cmd 都属于不同种类的 shell
- OS 花费很多时间在搬数据,而非算术计算
- 主存和寄存器的时间鸿沟导致 “缓存” 的必要性,“命中缓存” 是优化的核心
软件:操作系统
OS 是调度并管理硬件资源的软件层,将硬件抽象建模为可操作的虚拟对象:
-
Process (进程):对 CPU、内存、I/O 的抽象,进程可能由多条线程组成。
-
Thread (线程):共享虚拟内存的进程的分支,故而交流快但互相干扰。
-
Concurrency (并发):多进程概念上同时存在,比如交替进行。单核处理器通过 OS kernel 进行 context switching 实现多线并发(concurrency),也就是多个进程切换交替占用 CPU。context switching:在每个进程运行时切换到其对应上下文 (参数/环境等等),实现独占 CPU 的假象。
-
Parallelism (并行):多进程实际上同时进行 (PLP/TLP),是一种特殊的并发。多核或多处理器可以真正地同时运行多个进程,不一定需要切换。多核 = 进程/线程级并行。
-
注:以下是非进程级虚拟并行,完全不是同个概念
ILP (指令级并行):“手脚同时做多件事情”。无需多核,比如多个处理单元并行指令 (如 ALU,MLU 同时计算加法和乘法) 。
DLP (单指令数据级并行):“用复写纸同时做多件类似的事情”。无需多核,比如利用 SIMD 同时计算数组所有元素的两倍。
-
Hyperthreading (超线程):每个单核被暴露为多个虚拟核 (逻辑 CPU),可以辅助并发、并行的进行。
-
-
Virtual Memory(虚拟内存):对主存+磁盘的抽象, 虚拟地址空间存储在磁盘上
- 虚拟内存的分区是“语义与安全模型”,与硬件层的寄存器、缓存、主存等毫无对应关系
高地址
┌─────────────────┐0x ffff ffff ffff ffff
│ Kernel Space │ ← 内核,对程序代码不可见
├─────────────────┤
│ Stack │ ← 函数调用、局部变量
│ ↓ │
│ │
│ ↑ │
│ Library │ ← 共享库在内存中的映射,如 ”math“
│ │
│ ↑ │
│ Heap │ ← malloc / new 手动生成的长期变量
├─────────────────┤
│ R&W Data │ ← global / static 变量,来自程序
├─────────────────┤
│ Read Only Text │ ← 程序代码
├─────────────────┤0x 0000 0000 0000 0fff
│ empty │ ← 空指针预留空间 (page 0)
└─────────────────┘0
低地址
- 堆向上 (变大)分配,栈向下 (变小) 分配,中间是未分配的虚拟地址;虚拟地址空间按 页 (page) 管理,第 0 页 (0x0000 0000 ~ 0x0000 0FFF) 不映射到任何物理内存,这段空间表示程序错误。
- 虚拟内存通常是按 Byte 寻址,即每次读 8 bits,但也有 word(32b)/double-word(64b)。一台 “n-bit 计算机” 有 2n Byte 的理论虚拟存储空间。
- 根据内存阶级性,L4-DRAM 是 (L5-磁盘) 虚拟内存的缓存,只保存 “正在用的 page”
- 虚拟地址空间可以大于物理内存。64-bit 计算机并没有 264 B 的物理内存。
- 允许多个进程共享数据:不同进程虚拟地址不同,可以映射到相同的 DRAM 物理页,这也是 concurrency 的一种。
- File(文件):把 I/O 设备抽象为连续的字节
Amdahl’s Law
如果一个系统中占比 α 的部分效率提升 k 倍,总效率变化仍显著受剩余部分拖累: $$T_{new} = (1-\alpha)T_{old} + \frac{\alpha T_{old}}{k},\ \ \text{Speedup} = \frac{1}{(1-\alpha) + \alpha/k}$$
-
优化代码应当优先修改最慢的部分,而不是优化已经较快的字段
要想显著加速整个系统,必须提升全系统中相当大的部分的速度:
$$S_{k\to \infty} = \frac{1}{1- \alpha}$$
- 摩尔定律无法将晶体管数量和计算机效率联系起来,因为物理资源的增加和效率没有直接关系
Week 2 Storage of Data
对字节寻址机而言,所有数据都是一串字节,数据类型只是给编译器看的。按字节寻址即每次读 8 bits,将 64 位计算机的内存抽象为一个 264 B 即 16 EB 的虚拟空间,所有的文件都可以视为其中的字节块。(32-bit 则为 4 GB)
进制
对十六进制敏感是读懂汇编语言和内存表达的关键…
-
一个十六进制位等于 4 比特 (二进制位)
-
“1111” 默认是十进制数,"**0x**1111" 才是表达十六进制数的写法。
-
“位向量” 指一个二进制数从右到左写成向量:42→11010BIN→[1, 1, 0, 1, 0]。
-
一个字节为 0000 0000BIN ~ 1111 1111BIN 或者 0DEC ~ 255DEC 或者 0x00 ~ 0xff
-
十六进制数中的字母数字 a~f 是大小写不敏感的,可以写成 0000~1111 的**四位**二进制数
进制转换:
- HEX→BIN:**从右到左**把每一位展开为四位二进制数
- BIN→HEX:从右到左每四位写成一个二进制数
- HEX→DEC:将第 n 位分别乘以 16n 并加起来
- DEC→HEX:用 Euclid’s Method 反复除以 16 并将余数从右到左排列
整数的表达
- 在 C 语言中,整数 (如 0x42) 默认是有符号的,除非写成 0x42u 或 0x42U。
- 整数型数据 (int, long…) 直接用二进制串存储数据,比如 12345 在 (大端法) 内存中就是 “00 00 30 39”。
- 整数型分两种:unsigned or signed,对于 n Byte 的整数类型,前者可存 0~28n-1;后者牺牲最高位给负数,可存 -28n-1~28n-1-1,最高位为 1 的都是负数。
Unsigned Integer
B2Uω() 是二进制转化 ω 位无符号整数的**双射函数**:
$$B2U_ω(\vec x) = \sum_{i=0}^{ω-1}x_i·2^ω $$
-
E.g. 1B 的 unsigned char,可以表达 0 ~ 255;对于 n B 的无符号整数,最大数是 0xF…F (n/4 个 F)。
-
整数型数据在达到二进制位数限制后不再有效,称为溢出 (overflow),因为计算机直接将超出的最高位丢弃。
Signed Integer (补码)
- 1B 的 char,则表达 -128 ~ +127,C 语言中,常用**补码(2’s complement)** 记录负数——相反数是对应的二进制数取补并加一。比如 1 是 0000 0001,那 -1 就是 1111 1111; 127 是 0111 1111,那 -127 就是 1000 0001,-128 就是 1000 0000。
- 手算一般用十六进制:0x3039 —取反→ 0xCFC6 —+1→ 0xCFC7,你可以看取反将每一位 n 变成 F-n 即可。
- ⚠️ 补码的优点:补码的本质是 “要求一个数加自己是 0”。所有减法当做 “加负数” 来计算,溢出恰好会得到正确值:比如对于 char
3-2 = 3+(-2) → 0011 + 1110 = 10001 → 1。
B2Tω() 是二进制转化 ω 位有符号 (补码) 整数的双射函数:
$$B2T_ω(\vec x) = -x_{ω-1}2^{ω-1}+ {\sum_{i=0}^{ω-2}x_i·2^ω}$$
-
对于 n B 的补码整数,最小数是 0x80… (n/4-1 个 0);最大数是 0x7F… (n/4-1 个 F)。
-
也存在其他表示负数的方法:inverse (只取反不加 1)、signed magnitude (直接将第一位作为符号位:0正 1负) 但都**没有补码的计算优点**
天坑:强转换
- SignExtension:如果将一个数据类型提升 (e.g. char→int) 但不改变有无符号,只需复制最高位并填充空余位即可,比如 char: 10000101 等于 int: 1111…10000101。
- Truncation:反之将数据类型降级 (位宽减小) 会直接截断超出的位,这是一种 **UB **(未定义行为)。
在 C 语言中 signed ↔ unsigned 的混用是常见且危险的逻辑错误,因为两者的二进制-十进制映射不相同。
-
TypeCast 只改变解释数据的方法,不一定改变二进制值:比如
int a = -4242; auto b = (unsigned) a;a 和 b 都对应了~(0x00001092)+1 = 0xFFFFEF6E,b 被作为无符号整数解释成 4294963054。即,负数补码在 unsigned 里会变成大正数。
-
⚠️ 极危险操作:当一个运算同时包含 signed 和 unsigned 数时:C 会把 signed 隐式转换成 unsigned 再进行运算。
史诗级 bug . . .
if (int i < vec.size()) // 若 i 是负数,因为 size() 是 size_t,强转换数值起飞
for (int i = n-1; i >= 0; i--) // 若 n 或 i 是 unsigned,都死循环
unsigned len = a - b; // a < b 时数值爆炸
⚠️ 索引、下标统一用 unsigned;循环迭代器统一用 signed;有无符号切忌混用;比较数值前显示转换提醒自己。
浮点数的表达
| type | sizeof(~) | composition | composition size |
|---|---|---|---|
| float | 4 Byte | sign, exponent + 127, fraction | 1b + 8b + 23b |
| double | 8 Byte | sign, exponent + 1023, fraction | 1b + 11b + 52b |
比如 (float)12345.0,可以表达为 11000000111001BIN,即 1.1000000111001x213,所以拆解开来为:
0 (代表正数) |13+“偏移量 (bias)” = 140 → 10001100 | “小数位” 10000001110010000000000
故单精度浮点数 12345.0 在 (大端法) 内存中就是 “46 40 E4 00”。
- 偏移量:因为指数有正负,所以给 n Byte 的数据加上 2n-1-1,以平移到自然数域。
字符串
在 C 中,字符串就是 字符映射到 ASCII 表形成的数组。
比如字符串 “AaZz” 在内存上就是 ”41 61 5A 7A 00“,因为这个字符串相当于字符数组 ”char[A, a, Z, z, \0]“。(你或许注意到我没有做 ”大端法“ 的标记,这是因为字面量没有位权重,我们后续再展开说)
代码
在不同机器上,同个代码文件编译出的二进制指令是完全不同的,在计算机视角下的程序不过是字节序列,没有关于源码的信息时,二进制代码很难在不同 OS, ISA, calling convension 的机器间移植,称为 ABI incompatible。
字数据大小
- n-bit 计算机的所有指针都占用 n 个 bit——“许多程序员假设一个声明为 int 类型的对象能被用来存储一个指针。这在大多 32 位的机器上能正常工作,但是在一台64 位的机器上却会导致问题。“
gcc -m32 hello.c编译后可以在 32/64-bit 机上运作,但换成-m64flag 时只能在 64-bit 机上运行。这种 ”向后兼容“ 是必要的,确保计算机改新换代时文件数据的可移植性。- LP64 和 ILP64 之争:从 32 到 64 bit 的大迁徙中,ILP64 的支持方认为 int 应当是 8B,由于程序员喜欢混用 32 位机的整型和指针型 (位宽相同),64 位机只有保持两者相同位宽才能移植;而 LP64 一方则认为这会过大开销内存并降低性能。很明显,最终 LP64 被广泛接纳了。
寻址和字节序
C 中,指针 (n-bit 机显然需要 n-bit 的指针) 指向对应数据的第一个字节所在位置,比如:
int val = 4242;
auto* ptr = &val;
假设 &val 分配在 0x0010,那么 val 占据了 0x0010~0x0013 共 4 Byte 的虚拟内存,ptr 为 0x0010。对如上这样跨越多个字节的数据类型,将其分为多个 (两位十六进制数) 字节段 —— 4242DEC 即 0x0000 109A,会被拆成 4 个字节段,存在两种 ”字节序“:
| 储存时的字节序 | 0x0010 | 0x0011 | 0x0012 | 0x0013 | 常见于: |
|---|---|---|---|---|---|
| 大端法 (big endian) | 00 | 00 | 10 | 9A | TCP/IP, IBM, Motorola |
| 小端法 (little endian) | 9A | 10 | 00 | 00 | Intel (x86), IOS, Android |
所谓“大小端”就是把数据按位权由大到小还是由小到大排列,故字面量如字符串没有大小端的说法!
很多当代的处理器**硬件部分**是兼容双端法 (bi-endian) 的,但操作系统固定了字节顺序。比如手机的 ARM 芯片一旦选定 OS,如最常见的 IOS 和 Andoid OS,就只能运行小端了。
字节序被编译器隐藏,对一般程序员不可见。但在以下情况下,字节序必须考虑:
-
网络通信:大小端机不可直接互发二进制串,需要统一用网络协议的大端法通信,否则歧义。
-
汇编程序:阅读对二进制程序进行反汇编的结果
4004d3: 01 05 43 0b 20 00 add %eax,0x200b43(%rip),其大致对应 ”指令的内存地址: 指令 43 0b 20 00 汇编解释“ 显然,小端机的字节序是反直觉的,字面数据应当为 0x200b43。 -
强制转换:以下代码展示了 C 语言的指针特权,可通过将内存地址 typecast 为
unsigned char*逐字节读取,无关原本是什么类型,皆可作为 “字节数组” 看待:
#include <stdio.h>
typedef unsigned char *bytePtr;
void showBytes(bytePtr start, size_t len) {
size_t i;
for (i = 0; i < len; i++) printf("%.2x", start[i]);
printf("\n");
}
void showInt(int x) show_bytes((bytePtr)&x, sizeof(int)); // typecast
void showFloat(float x) show_bytes((bytePtr)&x, sizeof(float);
运行以上代码,假设 x = 4242 即 0x3039,在小端机上读出来是反直觉的 “39 30 00 00”,而在大端机上读出来则是正常的 “00 00 30 39”。
布尔代数
对此话题生疏者,请参见:CSC3001 布尔代数基础。
计算机根本上只需要实现一种逻辑门 (即布尔运算符):NAND (⊼ 与非),因为 {NAND} 是 “完备” 的,其他运算符都可以用它实现,比如:
| ~A | A | B | A & B | A ^ B |
|---|---|---|---|
| A ⊼ A | (~A) ⊼ (~B) | (A ⊼ B) ⊼ (A ⊼ B) | (A ⊼ (A ⊼ B)) ⊼ (B ⊼ (A ⊼ B)) |
- a & (b|c) = (a&b)|(a&c);a|(b&c) = (a|b) & (a|c)
- a ^ a=0;(a ^ b) ^ a=b
例:不用临时变量的数据交换:
void swap(int* x, int* y) {
*y = *x ^ *y;
*x = *x ^ *y;
*y = *x ^ *y;
}
位运算
⚠️ 位运算三大铁律:
- 不可出现浮点数 (比如说不能用 pow())
- 用 uint32_t/uint64_t 这种定宽整数
- 记住当前数据类型到底有多少位,小心 UB
在学习 CSC3001 时,布尔纯粹是一种数字或逻辑运算,在 CSE 的语境下,布尔代数更多是位级运算 (bitwise calculation)。⚠️ 我们把全集和某有限数据集的 SOP 从右往左写,称作位向量,然后对位向量进行布尔运算。比如对于 128-维 ASCII 全集,用 128 位 0/1 从右往左表示一个字符集是否存在每个 ASCII 字符;又比如对于全集 {1, 2, 3, 4, 5},{2, 1, 5} 可以被表达为 “10011”。
移位运算
-
左移 n (
<< n) 将数据向左移动 n 个二进制位 (即 n/4 个十六进制位),并在**左边抹去超出位数上限的部分,在右边空余位补 0**。比如生成 n 位网络掩码:
uint32_t mask = 0xFFFFFFFFu << (32 - n);(如 24 位掩码 0xFFFFFF00)。int x = 1 << 31;是不合法的,因为 1 被移动到了 整型的符号位。由此可见位运算**最好别用 signed**。 -
右移 n (
>> n) 分为 算数右移 (Java>>,左边复制最高位) 和 逻辑右移 (Java>>>,左边补 0)。几乎所有编译器都**对有符号数使用算术右移,但是对无符号数 ⚠️ 必须使用逻辑右移**,这是因为算数右移会把负数移成正数。
-5>>2 →
- 左右移都是左结合且优先度低于四则运算的,如
1+2 << a+1 >> 3相当于((1+2)<<(a+1))>>3。 - 如果移动位数
n超出数据类型位宽l,CPU 只会位移n mod l。然而**在 C 中位移超出位宽是违法的,属于 UB (未定义行为)**。
(可选) 网络掩码
如果你对 “位向量掩码” 抑或者 “网络掩码” 有兴趣,请点击对应的链接。以下简单展示 IPv4 中网络掩码的功能:网络掩码是网络不崩溃的核心,它将被掩盖的所有 IP 抽象为 “网络”。互联网通信时,互联网路由器只需要判断目标在自己的哪个 “方向” (哪个网络),具体在哪里只有边缘设备 (直接与 PC 相连的路由器) 才关心。
⚠️ 简单来说,路由器记录了其相邻的网络号,依靠比较掩码与这些网络号来寻找目标大致在哪个网络中,然后交由那个子网路由器重复这个操作。
所有 PC (终端) 都处于边缘设备 (如家用路由器) 下,假设这台路由器 X 声明其子网叫做 192.168.50.0,那 X 对于其子网就是第一台设备 “192.168.50.1”,X 分配给 PC 的子网地址假设是 192.168.50.123。如果 X 的子网少于 255 台设备,显然最后八位二进制数足够表示所有子网设备。那么,这个子网就可以使用 24 位掩码,即 255.255.255.0。你可以看出,⚠️ 掩码越大,子网越小,因为掩码掩盖的部分是子网的主机命名空间。将掩码与 PC 的子网地址进行 AND 操作,就会隐藏 PC 在 X 中的**主机号**,得到我们一开始定义的 (子) 网络号 192.168.50.0,如下:
IP = 11000000.10101000.00000001.01111011/24
(24-bit) mask = 11111111.11111111.11111111.00000000
subnet IP = IP & mask = 11000000.10101000.00000001.00000000/24
这么做的目的是什么呢?现在假设 X 之上还有一级路由器 W,其子网为 192.168.2.0,并使用 23 位掩码 (即最多可以有 511 台设备),颁布给 X 路由器的子网地址假设叫 192.168.2.2。有一条信息要经由 W 寻址到 PC,那么**对于 W 来说,PC 的主机号 “123” 毫无意义**_——⚠️ 路由器通过掩码和自己的相邻层级网络号比对,即 “最长前缀匹配 (LPM) ”,只确定目的地址所属的网络方向,而不关心具体主机的身份。
router W: 我处于公网的 14.14.14.1, 我有一个子网 192.168.2.0/23
router X: 我在 W 的子网地址是 192.168.2.2/23, 我有一个子网 192.168.50.0/24
PC: 我在 X 的子网地址是 192.168.50.123/24
W 眼中:10.0.0.0/8 → 方向 A
172.16.0.0/12 → 方向 B
192.168.0.0/16 → 方向 C
......
192.168.50.0/24 → 方向 X
所以只需要去找 X,至于 “123”,W 看不见也用不着
逻辑运算
- C 语言中,所有非 0 参数都 True,诸如 0, \0, NULL, nullptr 假;反之如 42, “0”, “null” 真。比如:
!! 0x42等于真 (0x1)。 - 逻辑运算符: &&(和), ||(或), !(非),切勿与位运算混淆!它们将变量当做布尔值,不关心 “位”。比如:
int a = 2, b = 10;
printf("%d, %d\n", a & b, a && b);
以上代码会打印 “2, 1” (0010 & 1010 = 10;2 && 10 = 1)。
- ⚠️ 逻辑运算的特性:逻辑运算对表达式从左往右算,如果算到一半能确定整体的布尔值,就不会算下去。这个性质导致 && 常用于if/while 条件语句的短路器,比如
ptr && *ptr == 10永远不会解指空指针;i < n && !arr[i]绝对不会下标越界。