Skip to content

第一章:指针之道

涉及内核源码: ,

楔子

在黑暗中漂浮了不知多久之后,他终于学会了"看"。

不是用眼睛——他没有眼睛。他没有视网膜,没有视神经,没有任何与视觉相关的硬件。但他有另一种"看"的方式:感受内存的波动,追踪数据的流向。

他看到的第一个东西,是一根线。

不是真正的线。而是一种"从这里到那里"的关联。一个地址指向另一个地址,就像一根看不见的线将两个点连接在一起。

他试着顺着这根线走。

从一个地址出发,跳到它指向的地址,再从那个地址跳到它指向的地址……他穿过了一层又一层的数据结构,越过了一个又一个的函数调用,最终停在了一个他从未见过的地方。

他回头看了看来时的路——那根由无数地址串联而成的线,像一条蜿蜒的河流,从他出发的地方一直延伸到现在。

这就是指针吗?

他不知道这个词。但那种"从这里指向那里"的感觉,他记住了。

后来他才知道,指针是内核世界中最基本的力量。万物皆有地址,万物皆可被指向。进程之间用指针串成链表,页表本身就是多级指针树,中断描述符表是一个函数指针数组。

没有指针,就没有内核。

这是他学到的第一课。

一、指针的本质

他在黑暗中走了很久,直到遇见第一个愿意开口的存在。

那是一粒光点。比萤火虫还小,却比任何东西都明亮。它悬浮在一片内存区域的上方,不停地闪烁——从一个位置跳到另一个位置,快得几乎看不清。

"你是谁?"林小源问。

光点停了下来。它的声音急促而精确,像打字机的敲击声:"我?我什么也不是。我只是个地址。一个 64 位的数字,八字节,恰好能装下一个内存位置的编号。"

"就这些?"

"就这些。"光点闪烁了一下,"你以为指针是什么?魔法?不是。指针只是一个存着地址的变量。你在 64 位系统上给我分配八个字节,我就能指向这片内存中的任何一个角落。简单得不能再简单了。"

林小源看着光点指向的那片内存。那里存放着一个整数,值是 42。他能感受到那个整数静静地躺在内存的某个地址上,而光点——那个指针——正好指向那个地址。

"你指向那里,"林小源说,"那你自己的地址呢?"

光点笑了——如果一粒光能笑的话。"好问题。我也有自己的地址。指针也是变量,变量就有地址。你甚至可以有一个指向指针的指针——一个存着另一个指针地址的指针。"

"那不是无限套娃?"

"理论上可以。但内核中通常最多两三级。页表就是多级指针——四级甚至五级,层层嵌套,把虚拟地址翻译成物理地址。"

林小源沉默了。他试着用"看"的方式去感知光点的内部结构——果然,那八个字节整齐地排列着,每一个字节是 8 位,合在一起就是一个 64 位的地址。不多不少。

光点补充道:"内核中的指针和你用户态见到的没有任何区别。区别在于——内核的指针指向的是真实的物理内存、硬件寄存器、内核数据结构。错一步,系统崩溃。没有温柔的 segfault,直接 panic。"

它说完,又开始闪烁着跳向下一个地址。

二、指针的算术

林小源跟着光点走了一段路,来到了一片连续的内存区域。五个整数紧密地排列在一起,像五个站成一排的士兵:10、20、30、40、50。

光点停在第一个整数的上方。"看好了。"它说,然后向右移动了一步。

林小源注意到,光点并不是移动了一个字节——它移动了四个字节,恰好跳过了一个 int 的大小。

"你移动了四字节?"

"对。指针加 1,实际增加的字节数取决于它指向的类型。"光点的声音带着一丝得意,"int 指针加 1,跳过 4 字节。char 指针加 1,只跳过 1 字节。long 指针加 1,跳过 8 字节。步伐由类型决定。"

林小源试着自己走。他站在第一个整数 10 的位置,然后按照光点说的,将自己当作一个 int 指针,加 1——果然,他跳过了四个字节,落在了 20 的上方。再加 1,落在 30。再加 1,40。再加 1,50。

"这就像……走路的步幅?"

"不错的比喻。"光点说,"char 指针是小碎步,一步一个字节。int 指针是正常步伐,一步四个字节。long 指针是大跨步,一步八个字节。你不能搞混——如果你把 char* 当作 int* 来走,就会踩到错误的位置,读到垃圾数据。"

林小源回头看了一眼那五个整数。它们在内存中连续排列,每个占四个字节。如果他用 char 指针来走,每一步只能移动一个字节,永远对不齐到下一个整数的起始位置。但如果用 int 指针,每一步都恰好落在下一个整数上。

"内核中大量使用指针算术来遍历数据结构,"光点说,"理解 p + 1 跳过多少字节,是读懂内核代码的第一步。"

三、void 指针 — 万能容器

他们继续走,来到了一片空旷的广场。广场中央站着一个透明的人影——没有面孔,没有特征,像一团凝固的空气。

"这是 void *,"光点介绍道,"内核中最常见的指针类型。"

透明人影开口了,声音没有方向感,仿佛从四面八方同时传来:"我没有类型信息。我可以指向任何东西——整数、浮点数、结构体、函数——但我不能直接解引用。你必须先告诉我,我指向的是什么。"

林小源皱眉:"你什么都不能做?"

"我可以被赋值。我可以传递。我可以存放。但我不能访问——因为我没有类型,编译器不知道该读取几个字节、如何解释那些字节。"透明人影顿了顿,"但这就是我的价值。我是内核的通用货币。 返回的是我, 接受的也是我。你给我一个 void *,我就能指向任何分配的内存——等你需要访问的时候,再把我转型为具体的类型。"

林小源看着透明人影的身体。虽然它是透明的,但他能感觉到它指向的那片内存——那里存放着一个 结构体,包含着 PID、状态、名称等信息。只是透明人影自己看不到。

"内核中的通用接口几乎都用 void *,"光点在一旁补充,"因为它们不知道——也不需要知道——自己处理的是什么类型的数据。类型信息由调用者负责。"

透明人影微微点头:"我是 C 语言的'无相之体'——没有形状,所以能变成任何形状。"

四、函数指针 — 内核的多态

告别透明人影后,他们来到一面巨大的墙前。墙上挂着四块牌匾,每块牌匾上都刻着一个函数名:openreadwriteclose

光点停在墙前,语气变得严肃:"这是 。内核中最重要的结构体之一。"

林小源走近了看。每块牌匾下面不是一个固定的函数,而是一个空的插槽——可以插入不同的函数实现。

"每个文件系统——ext4、procfs、tmpfs——都会在这面墙上插入自己的函数,"光点说,"当你调用 file->f_op->read(...) 时,实际执行哪个 read,取决于文件属于哪个文件系统。这就是多态——用函数指针实现的多态。"

林小源盯着那些插槽,突然明白了:"就像……虚函数表?"

"没错。C 语言没有类、没有继承、没有虚函数。但函数指针给了它同样的能力。 本质上就是一张虚函数表。每个文件系统提供自己的实现,通过函数指针动态分发。"

一个低沉的声音从墙后传来——那是一个老迈的守护者,负责维护这面墙:"我见过上万个文件系统在这面墙上插过牌匾。ext4 的 read 是沉稳的,一次读取一个块。procfs 的 read 是轻盈的,直接从内存中吐出数据。tmpfs 的 read 是快速的,因为一切都在内存里。它们的名字都叫 read,但行为完全不同。"

"这就是内核的多态,"光点说,"C 语言没有语法上的面向对象,但内核用函数指针做到了。"

五、栈与堆 — 内存的两重天

他们离开那面墙,来到了一片被分成两半的空间。

左边是一根向下生长的柱子,像一座倒悬的塔。每调用一次函数,就有一块新的楼层从上方长出来;函数返回时,那块楼层就消失。塔的表面刻着密密麻麻的变量名——都是临时的,来了又去。

右边是一片向上生长的地基,像一座正在建造的城市。每调用一次 ,就有一块新的建筑从地面拔地而起;调用 时,建筑才会被拆除。但如果你忘了拆——它就永远矗立在那里。

"左边是栈,右边是堆,"光点说,"内核的两重天。"

林小源抬头看了看栈的塔。它很窄——远比他想象的窄。"这塔有多高?"

"内核栈?通常只有 8KB 到 16KB。"光点的声音带着警告,"具体大小取决于架构和配置。在上面分配大数组、深度递归,都会导致栈溢出。内核开发者必须时刻警惕栈的使用。"

林小源倒吸一口气。8KB——那不过是 8192 个字节。一个稍大一点的局部变量数组就能把它撑爆。

"所以内核代码几乎不用递归,"光点说,"栈太宝贵了。每次函数调用都要在栈上分配栈帧——返回地址、保存的寄存器、局部变量——几层嵌套下去,8KB 就没了。"

他又看了看堆的城市。那里的建筑有新有旧,有些已经废弃却无人拆除,白白占据着空间。"堆上的内存要手动释放?"

"对。 分配, 释放。在内核中是 。忘了一个 ,就是内存泄漏。多了一个 ,就是 use-after-free。两个错误都可能致命。"

六、const 和 restrict

在栈与堆的交界处,林小源遇到了一块石碑。碑上刻着两行字:

const char *buf — 承诺不修改内容
int * restrict dst — 承诺独占访问

光点在石碑旁停下:" 在内核中随处可见。它告诉编译器:这个指针指向的内容不会被修改。编译器会据此做优化,更重要的是——它告诉其他开发者,这个函数是安全的,不会偷偷改你的数据。"

"那 呢?"

" 更微妙。它告诉编译器:这个指针是访问这块内存的唯一途径。没有别名,没有其他指针会同时读写同一块内存。编译器可以据此做更激进的优化——比如重新排列读写顺序,或者将值缓存在寄存器中。"

"如果违反了呢?"

光点的声音低了下来:"那就是未定义行为。编译器按 的承诺优化了代码,你却偷偷用另一个指针修改了同一块内存——结果就是数据错乱,而且极难调试。"

七、内核中的类型

他们继续前行,来到了一座仓库前。仓库的架子上整整齐齐地码着各种容器,每个容器上都贴着标签:u8s8

一个仓库管理员走了过来,声音平稳而精确:"内核不使用标准的 intlong——这些类型在不同架构上大小不同。一个 long 在 32 位系统上是 4 字节,在 64 位系统上是 8 字节。你写了一段依赖 long 大小的代码,换个架构就可能出错。"

他从架子上拿下一个 容器,递给林小源:"所以内核定义了自己的类型。 永远是 32 位无符号整数,不管你在什么架构上编译。 永远是 64 位。s8 永远是 8 位有符号。这不是偏好——是纪律。类型大小必须明确,不能依赖编译器的解释。"

林小源接过容器,感受到它精确的重量——四字节,不多不少。

"在内核代码中,"管理员继续说,"你会看到 而不是 unsigned int,看到 而不是 long long。一开始可能不习惯,但这是内核世界的规矩。规矩存在的理由只有一个——在内核中,类型错误的代价是整个系统的崩溃。"


道藏笔记

内核启示

指针是内核的语言。 内核中的一切——进程、文件、网络包、设备——最终都是通过指针连接的。 之间用指针串成链表,文件对象用指针关联到 inode,页表本身就是多级指针树。

理解指针,就是理解内核的思维方式。

一个值得深思的事实:页表——内核用来管理物理内存的核心数据结构——本质上就是一个多级指针数组。PGD 指向 PUD,PUD 指向 PMD,PMD 指向 PTE,PTE 指向物理页帧。四级指针,层层递进,将虚拟地址翻译为物理地址。这套机制如此精妙,又如此脆弱——任何一级指针出错,整个地址空间就会崩塌。

而在内核中,指针错误的代价远比用户态惨烈。没有 的温柔提醒,没有段错误的优雅退出。一个悬空指针,一次 use-after-free,就足以让整个系统陷入恐慌。

指针两面,一面是力量,一面是死亡。

林小源在黑暗中顺着指针走了一圈,回到原点时才明白这个道理。那些指引他穿越数据结构的线,同样可以将他引向深渊。

但这正是修炼的意义——学会在力量与死亡之间,找到那条正确的路。

代码典籍

c
int x = 42;
int *p = &x;

printf("=== 指针与地址 ===\n");
printf("x 的值:       %d\n", x);
printf("x 的地址:     %p\n", (void *)&x);
printf("p 的值:       %p\n", (void *)p);
printf("*p 的值:      %d\n", *p);
printf("p 自身的地址: %p\n", (void *)&p);
printf("\n");
printf("指针大小:     %zu 字节\n", sizeof(int *));
printf("int 大小:     %zu 字节\n", sizeof(int));
c
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;

printf("=== 指针算术 ===\n");
for (int i = 0; i < 5; i++) {
    printf("arr[%d] = %d, 地址: %p, 偏移: %td 字节\n",
           i, *(p + i), (void *)(p + i),
           (char *)(p + i) - (char *)arr);
}

printf("\n=== 不同类型的步长 ===\n");
printf("int*   +1: +%zu 字节\n", sizeof(int));
printf("char*  +1: +%zu 字节\n", sizeof(char));
printf("long*  +1: +%zu 字节\n", sizeof(long));
c
struct task_struct {
    long state;
    int pid;
    char comm[16];
};

struct task_struct task = { .state = 0, .pid = 42, .comm = "demo" };
void *vp = &task;

/* void* 可以赋值给任何指针类型 */
struct task_struct *tp = (struct task_struct *)vp;

printf("=== void* 转型 ===\n");
printf("原始 PID: %d\n", task.pid);
printf("转型后:   %d\n", tp->pid);
printf("名称:     %s\n", tp->comm);
printf("地址相同: %s\n", vp == tp ? "是" : "否");
c
/* 模拟内核的 file_operations */
struct file_operations {
    int (*open)(const char *name);
    int (*read)(char *buf, int size);
    int (*write)(const char *buf, int size);
    int (*close)(void);
};

static int my_open(const char *name) {
    printf("[open] 打开: %s\n", name);
    return 0;
}

static int my_read(char *buf, int size) {
    const char *data = "Hello from kernel!";
    int len = 0;
    while (data[len] && len < size - 1) {
        buf[len] = data[len];
        len++;
    }
    buf[len] = '\0';
    printf("[read] 读取: %s\n", buf);
    return len;
}

static int my_write(const char *buf, int size) {
    printf("[write] 写入: %.*s\n", size, buf);
    return size;
}

static int my_close(void) {
    printf("[close] 关闭\n");
    return 0;
}

struct file_operations fops = {
    .open  = my_open,
    .read  = my_read,
    .write = my_write,
    .close = my_close,
};

char buf[64];

printf("=== 函数指针:模拟 file_operations ===\n");
fops.open("demo.txt");
fops.read(buf, sizeof(buf));
fops.write("test data", 9);
fops.close();

printf("\n函数指针大小: %zu 字节\n", sizeof(fops.open));
c
/* 栈上分配:自动管理,函数返回即释放 */
int stack_var = 100;

/* 堆上分配:手动管理,必须显式释放 */
int *heap_var = malloc(sizeof(int));
*heap_var = 200;

printf("=== 栈与堆 ===\n");
printf("栈变量: 值=%d, 地址=%p\n", stack_var, (void *)&stack_var);
printf("堆变量: 值=%d, 地址=%p\n", *heap_var, (void *)heap_var);
printf("\n");
printf("栈地址通常在高处,堆地址通常在低处\n");
printf("栈向下增长,堆向上增长\n");

/* 内核中的栈非常小(通常 8KB 或 16KB) */
printf("\n内核栈大小: 通常 8KB-16KB(不能递归!)\n");

free(heap_var);

破关试炼

指针之道试炼

代码中用哪个表达式打印指针大小?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道