001-linux-core-mechanisms

从“学过”到“理解”:Linux 开发中的内存、文件与网络机制

Linux 开发学习笔记 · 第 001 篇
整理日期:2026-10-19
适合已接触 C 语言、进程、线程和 Socket,希望理清底层关系的读者。

写 Linux 程序时,许多问题并不来自语法,而是来自对“对象在哪里、谁在共享它、它能活多久”的误解。例如:两个进程打开同一个文件,是否共享读取位置?线程各有自己的栈,是否意味着其他线程不能访问栈上的变量?TCP 保证可靠传输,为什么应用还要保存没有收齐的消息?

本文沿着编译链接、文件引用、线程内存、并发同步和 TCP 接收五条线索,梳理这些容易混淆的机制。示例以 Linux 上的 C 程序为背景;标注为片段或伪代码的内容用于说明原理,不是完整工程。

1. C 程序如何变成可执行程序

1.1 四个阶段分别做什么

hello.c ──预处理──> hello.i ──编译──> hello.s ──汇编──> hello.o ──链接──> hello
阶段 主要工作 典型输出
预处理 展开头文件与宏,处理条件编译 预处理后的源代码 .i
编译 分析代码并进行优化,生成汇编代码 汇编文件 .s
汇编 将汇编代码转换为机器码等目标文件内容 可重定位目标文件 .o
链接 组合目标文件和库,解析符号引用,处理重定位 可执行文件或共享库

可以用 GCC 分别观察这些产物:

gcc -E hello.c -o hello.i
gcc -S hello.i -o hello.s
gcc -c hello.s -o hello.o
gcc hello.o -o hello

其中 -E 停在预处理阶段,-S 停在生成汇编阶段,-c 生成目标文件而不链接。日常使用的 gcc hello.c -o hello 会自动串起这些步骤。参见 GCC 编译阶段与选项说明。

1.2 为什么 .o 通常不能直接运行

.o 虽然已经包含机器码,但它通常仍是可重定位目标文件:外部函数或变量的引用可能尚未解析,也没有完成可执行文件所需的布局和启动支持。

例如,某个文件调用了 add(),编译器知道函数声明就可以生成调用相关的目标代码;链接器随后还必须找到 add() 的定义。

因此,下面的错误通常发生在链接阶段:

undefined reference to `add'

它表示链接器找不到满足该符号引用的定义。排查时可以依次检查:

  • 是否只写了声明,没有实现函数。
  • 包含实现的 .c 或 .o 是否参与了链接。
  • 对应库是否加入链接,静态库的链接顺序是否合适。
  • C 与 C++ 混合编译时,声明和定义的链接约定是否一致。

1.3 静态链接、动态链接与程序启动

静态链接会把静态库中需要的目标代码纳入最终产物;动态链接则让程序保留对共享库的依赖,在运行时完成相应的加载和符号处理。

以常见的动态链接 ELF 程序为例,启动过程可以简化为:

执行程序
  → 内核读取 ELF 信息
  → 根据 PT_INTERP 找到动态加载器
  → 动态加载器加载依赖的共享库、完成必要的重定位等工作
  → 进入程序启动代码
  → 调用 main()

这里没有展开延迟符号绑定等细节;关键是:启动动态链接程序,除了可执行文件本身,还需要匹配的运行环境。

所以,“文件明明存在,执行却提示 No such file or directory”并不矛盾:缺失的可能是 ELF 指定的解释器,也就是动态加载器。脚本的解释器缺失也会产生类似问题。execve(2) 明确列出了这些 ENOENT 情形。

在目标设备上可以检查:

file ./app
readelf -l ./app
readelf -d ./app

关注处理器架构、程序解释器路径,以及动态依赖条目。若是交叉编译得到的程序,还要核对目标系统的 ABI、C 库和加载器是否匹配。错误架构通常是另一类执行格式问题;共享库缺失也常由加载器报告,不能把所有启动失败都归结为同一个原因。

2. 文件描述符背后的三层关系

2.1 描述符只是入口,偏移量不在这个整数里

对于普通文件,可以把关系理解为三层:

层次 表示什么 需要记住的内容
进程的文件描述符表 fd 是表项索引,例如 3 表项引用内核对象,描述符本身是整数
打开文件描述(open file description) 某一次打开所建立的内核状态 保存当前文件偏移量、文件状态标志等
文件对象 被访问的文件及其数据 同一个文件可以被多次独立打开

成功的 open() 会建立新的打开文件描述。是否共享偏移量,取决于是否引用同一个打开文件描述。 参见 open(2)。

独立 open 与先 open 再 fork 的文件引用关系对比

2.2 两次独立 open():偏移量独立

考虑下面的代码片段,假设文件内容为 ABCDE,所有调用均成功:

int fd1 = open("data.txt", O_RDONLY);
int fd2 = open("data.txt", O_RDONLY);
char a, b;

read(fd1, &a, 1);  // a == 'A'
read(fd2, &b, 1);  // b == 'A'

两次打开指向同一个文件,却对应两个打开文件描述。第一次读取只推进 fd1 所引用的偏移量,不影响 fd2。

这一点与“是不是同一个进程”无关:同一进程独立打开两次,偏移量同样独立。

2.3 先 open() 再 fork():继承的描述符共享偏移量

fork() 后,父子进程各有自己的描述符表,但子进程继承的描述符引用与父进程对应描述符相同的打开文件描述,因此共享偏移量。参见 fork(2)。

如果先打开 ABCDE,再创建子进程,且通过同步保证父进程先读 1 字节、子进程再读 1 字节,那么父进程读到 A,子进程接着读到 B。没有安排执行顺序时,不应猜测哪一个进程先读到哪个字符。

场景 是否共享偏移量 原因
同一进程独立 open() 两次 否 两个打开文件描述
两个进程分别独立 open() 否 两个打开文件描述
先 open(),再 fork() 是 继承的引用指向同一个打开文件描述
dup() 复制已有描述符 是 新描述符仍引用同一个打开文件描述

还要区分“偏移量独立”与“文件内容独立”:两次打开的读取位置可以互不影响,但它们访问的文件内容仍然相同。

3. 线程共享什么,堆和栈分别保存什么

3.1 各有自己的栈,不等于内存相互隔离

同一进程中的线程共享虚拟地址空间,包括代码、全局对象和动态分配的内存等;线程通常各有自己的栈,并拥有独立的执行状态。它们也共享进程的文件描述符。参见 pthreads(7)。

“线程有自己的栈”描述的是函数调用和自动对象的组织方式,并不意味着栈内存受到线程之间的访问隔离。把有效的栈上对象地址传给另一线程后,对方可以通过指针访问它,但必须处理生命周期和同步问题。

同一进程内两个线程的栈、共享堆与指针关系

3.2 指针变量和指针指向的对象是两回事

看一个完整的小例子:

#include <stdlib.h>

static int *create_value(void)
{
    int local = 10;
    int *p = malloc(sizeof *p);
    if (p == NULL) {
        return NULL;
    }
    *p = local;
    return p;
}

int main(void)
{
    int *value = create_value();
    if (value == NULL) {
        return EXIT_FAILURE;
    }
    /* 这里仍可访问 *value,其值为 10。 */
    free(value);
    return EXIT_SUCCESS;
}

其中有三个不同的对象:

对象 典型存放位置或存储期 生命周期
local 自动存储期,通常在栈上 本次函数调用结束时结束
指针变量 p 自动存储期,通常在栈上 本次函数调用结束时结束
malloc() 分配的对象 动态分配区域,通常称为堆 不因函数返回而释放,由 free() 释放

返回 p 是把指针值交给调用者,不是让局部指针变量继续存在。与之相反,返回 &local 后再解引用就会访问生命周期已经结束的对象。

编译器可能把局部变量放在寄存器中,甚至优化掉变量。因此,讨论程序正确性时,应优先判断存储期与生命周期,不要只背“局部变量一定在栈上”。

3.3 普通局部变量与 static 局部变量

void example(void)
{
    int x = 0;         // 每次调用都有自己的自动对象
    static int y = 0;  // 同一个静态存储期对象
    /* 此处仅展示声明,没有进行并发修改。 */
}

两个线程分别调用该函数时,各次调用中的 x 是不同对象;y 则是同一个对象。static 局部变量的名字只在局部作用域可见,但对象具有静态存储期,并不会在函数返回时消失。

如果两个线程都修改 y,仍然需要同步。作用域限制名字在哪里能被使用,不会自动赋予对象线程安全性。

4. 为什么 counter++ 需要同步

4.1 一行代码不等于一个原子操作

对普通共享整数执行 counter++,可以用“读取、加一、写回”解释更新丢失的直觉:

时刻 线程 A 线程 B counter
1 读到 0 — 0
2 — 读到 0 0
3 计算并写入 1 — 1
4 — 计算并写入 1 1

两次递增最后只留下 1。不过,这只是帮助理解的示意,并不是 C 语言允许结果的完整列表。

多个线程对普通对象发生未同步的冲突访问,且至少一个是写操作时,可能构成数据竞争;C 中的数据竞争会导致未定义行为。 不能把它仅理解成“偶尔少加一次”。把变量改为 volatile 也不能替代互斥锁或原子操作。

4.2 互斥锁保护一段需要保持一致的操作

下面是使用 POSIX 互斥锁的核心片段,假设锁操作均成功:

#include <pthread.h>

static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static int counter = 0;

static void increment(void)
{
    pthread_mutex_lock(&lock);
    ++counter;
    pthread_mutex_unlock(&lock);
}

当相关线程都遵守同一把锁的约定时,受保护的操作不会相互穿插。读取共享状态也要遵守对应的同步约定;不能一边加锁写,另一边在并发期间不加锁读。

互斥锁适合保护多步逻辑,例如“检查余额 → 扣减余额 → 更新记录”,使整个临界区中的状态变化保持一致。

4.3 原子操作保护特定操作,不会自动合并整段业务

C11 可以使用原子对象:

#include <stdatomic.h>

static atomic_int counter = 0;

static void increment(void)
{
    atomic_fetch_add(&counter, 1);
}

这里的递增是一个原子读改写操作。不过,下面的“先检查再扣减”仍然不是整体原子的:

/* 反例:每一步是原子的,但两步之间可以插入另一个线程。 */
if (atomic_load(&stock) > 0) {
    atomic_fetch_sub(&stock, 1);
}

假设 stock 是初值为 1 的原子整数,两个线程都可能先看到它大于 0,再分别扣减,最终变成 -1。解决这一类问题,可以用互斥锁保护整体操作,或正确设计比较交换循环。

选择同步方式时,先问:我要保护的是一次计数变化,还是多个步骤共同维持的约束?

5. TCP 接收:收到的是字节流,不是一条条消息

5.1 一次发送不对应一次接收

TCP 提供有序、可靠的字节流,不保留应用每次写入的消息边界。参见 tcp(7)。

发送端先发送 HELLO,再发送 WORLD,接收端可能分几次读到:

情况一:HELLOWORLD
情况二:HE | LLOW | ORLD
情况三:HELLO | WORLD

这些情况都不改变字节的顺序,却说明应用必须自行约定消息边界。

send() 用于向连接发送数据,返回值可能小于请求发送的长度,因此发送端也需要记录进度并继续发送。一次成功调用并不证明对端业务已经处理完成。

recv() 用于读取当前可获取的数据。通常不应假设它会填满传入的缓冲区;接收端必须依据实际返回的字节数累计。参见 recv(2)。

5.2 用长度字段定义消息边界

约定如下协议:

[4 字节无符号整数 N,采用网络字节序][N 字节消息体]

这里 N 只表示消息体长度,不包含前面的 4 字节。实际协议还需要规定最大消息长度,以及是否允许长度为 0 的消息。

例如,一条消息的消息体为 HELLO:

00 00 00 05 | 48 45 4C 4C 4F
长度:5     | H  E  L  L  O

如果第一次 recv() 只读到前两个字节,应先保存它们。第二次收到更多数据后,才有机会凑齐长度字段;知道长度后,还要判断消息体是否完整。

TCP 接收缓冲区与长度前缀消息解析流程

5.3 接收缓冲区必须跨多次调用保留

下面是以“4 字节长度 + 消息体”为例的伪代码。缓冲区属于当前连接,不能每次 recv() 后都重新清空。

为当前连接维护 buffer,初始为空

当有数据可读时:
    调用 recv,使用非零大小的接收空间

    如果返回 n > 0:
        将这 n 字节追加到 buffer

        循环解析:
            如果 buffer 少于 4 字节:
                结束本轮解析,保留所有字节

            从前 4 字节按网络字节序解析 N
            如果 N 超过协议上限:
                按协议错误处理并关闭连接

            如果 buffer 中还不足 4 + N 字节:
                结束本轮解析,保留所有字节

            取出一条完整消息并处理
            消费前 4 + N 字节,保留剩余字节
            继续循环,尝试解析下一条消息

    如果返回 0:
        对端已关闭发送方向
        若 buffer 留有不完整消息,则按协议处理截断
        停止接收,不再等待这条字节流补齐

    如果返回 -1:
        EINTR:重试
        EAGAIN / EWOULDBLOCK:等待下一次可读事件
        其他错误:记录错误并结束当前接收流程

实现为 C 代码时,解析长度可以先用 memcpy() 拷贝到 uint32_t,再用 ntohl() 转换,避免直接把任意缓冲区地址强转成整数指针。还应在分配内存或计算 4 + N 之前检查长度与整数溢出,并限制连接缓冲区的总量。

如果使用非阻塞 Socket,应结合事件机制处理“暂时无数据”;这不代表连接已经关闭。生产程序还需要超时、资源回收和业务错误处理,这里只保留消息拼接的主线。

5.4 为什么不能丢掉部分消息,等待 TCP 重发

必须区分两个层次:

情况 所属层次 处理方式
网络传输中的丢失、乱序 TCP 传输层 协议栈负责重传、排序等机制
应用只读到了半个长度字段或部分消息体 应用层协议解析 保存已读字节,继续接收并拼接

TCP 不知道应用约定的“4 字节长度字段”。数据已经被应用读走后,应用主动丢弃它们,也不会触发 TCP 为这次丢弃重新交付这些字节。

如果消息体应为 100 字节,而本次只收到 60 字节,就保存这 60 字节,继续等待剩余 40 字节。 若下一次收到的不仅有剩余 40 字节,还包含下一条消息的开头,就消费当前完整消息,并保留后面的字节继续解析。

6. 用场景题检查理解

题目

  1. 两个进程分别 open() 同一个普通文件,会因为进程不同而共享或不共享偏移量吗?
  2. 父进程先打开文件,再 fork()。父进程读取后,子进程继承的描述符对应的读取位置是否会变化?
  3. 两个线程调用同一个函数,函数里的 int x 与 static int y 分别有几个对象?
  4. 函数通过 malloc() 创建对象并返回指针,函数结束后对象是否还存在?
  5. 原子变量上的“检查库存大于零,再扣减”是否天然保证库存不为负?
  6. 长度字段只收到了 2 字节,应丢弃还是保存?如果一口气收到两条完整消息,又该如何处理?

参考答案

  1. 独立 open() 会建立不同的打开文件描述,因此偏移量独立。 判断依据是引用关系,不是进程数量。
  2. 会。 两者引用同一个打开文件描述,普通读取会推进共享偏移量。
  3. 两次独立调用中的普通自动对象 x 不同;y 是同一个静态存储期对象,并发修改需要同步。
  4. 仍然存在,直到被释放。 局部指针变量的生命周期结束,不等于动态分配对象被释放。
  5. 不能。 分别原子不等于整体原子,应使用锁或正确的条件更新机制。
  6. 保存已收字节并继续接收;收齐多条消息时循环解析。 消费完整消息后保留剩余数据。

理解这些问题时,可以始终追问四件事:当前访问的是哪个对象?多个执行者是否引用同一个对象?对象还在生命周期内吗?当前操作依靠什么保证完整性或一致性?

posted @ 2026-10-19 23:10  雪花哗哗  阅读(4)  评论(0)    收藏  举报