跳到主要内容

14.2 UTS 命名空间的实现

上一节我们提到,内核并不关心命名空间的「名字」,它只靠 inode 号来区分不同的实例。这听起来很极简,但极简是设计的最高境界。

在冲向最复杂的网络命名空间之前,我们需要先找个软柿子捏。UTS 命名空间就是那个软柿子。

为什么选它开刀?因为它涉及到了命名空间实现的所有核心要素:结构体定义、引用计数、如何关联到进程、以及系统调用如何适配。搞懂了这几十行代码,后面看网络命名空间时就不会觉得那是天书了。

从一个结构体开始

为了实现 UTS 命名空间,内核引入了一个叫 uts_namespace 的结构体。

你可以把它想象成一张「身份证」——这张身份证上只记录了最基本的信息:我是谁(nodename),我属于哪个域(domainname)。

struct uts_namespace {
struct kref kref;
struct new_utsname name;
struct user_namespace *user_ns;
unsigned int proc_inum;
};

这张「身份证」的设计非常讲究,我们逐个字段拆解一下:

  • kref:这是一个引用计数。 内核里有很多种计数器,UTS 命名空间用的是比较通用的 kref,通过 kref_get()kref_put() 来管理生命周期。 这里有个冷知识:UTS 和 PID 命名空间用的是 kref,而其他四个命名空间用的是更底层的 atomic_t。这属于历史遗留问题,理解了就行,不用太纠结。

  • name:这才是真正的肉。 它是一个 new_utsname 结构体,里面存着 nodename(主机名)和 domainname(域名)。这就是我们要隔离的核心数据。

  • user_ns:指向用户命名空间。 命名空间不是孤岛,UTS 需要知道它属于哪个用户上下文,这是权限控制的基础。

  • proc_inum:我们在上一节强调过的 proc inode 编号。 内核不靠字符串名字区分命名空间,全靠这个唯一的数字 ID。

进程如何找到它?

光有结构体还不行,进程得能拿到它。还记得上一节提到的 nsproxy 吗?那个「中间代理人」现在派上用场了。

struct nsproxy {
...
struct uts_namespace *uts_ns;
...
};

当进程想查询自己的主机名时,它会通过 current->nsproxy->uts_ns 这条路径摸到这张「身份证」。这个指针一旦指向了不同的 uts_namespace,进程就活在了一个新的隔离环境里。

到底隔离了什么数据?

让我们剥开 uts_namespace 的核心,看看 new_utsname 到底长什么样。这才是 UTS 命名空间的本质:

struct new_utsname {
char sysname[__NEW_UTS_LEN + 1];
char nodename[__NEW_UTS_LEN + 1];
char release[__NEW_UTS_LEN + 1];
char version[__NEW_UTS_LEN + 1];
char machine[__NEW_UTS_LEN + 1];
char domainname[__NEW_UTS_LEN + 1];
};

这里面的 nodename 就是我们熟悉的 hostname,domainname 是 NIS 域名。

注意,虽然这个结构体里包含了 sysname(操作系统名)、release(内核版本)等信息,但 UTS 命名空间只允许你修改 nodenamedomainname。其他的字段是全局只读的——你没法通过 UTS 命名空间假装自己运行在一个不同的内核版本上,这在逻辑上讲不通。

系统调用的变迁

结构体有了,数据也有了,现在关键问题是:系统调用怎么改?

如果没有命名空间,gethostname() 系统调用只需要从一个全局变量里读字符串就行了。但有了命名空间后,它必须知道「读的是当前进程那个命名空间里的名字」。

内核提供了一个辅助函数 utsname(),专门用来做这件事:

static inline struct new_utsname *utsname(void)
{
return &current->nsproxy->uts_ns->name;
}

这就很简单了:谁调用,就返回谁的 new_utsname 指针

现在我们可以看真正的 gethostname() 系统调用实现了。这是教科书级别的「如何适配现有代码到命名空间」:

SYSCALL_DEFINE2(gethostname, char __user *, name, int, len)
{
int i, errno;
struct new_utsname *u;

if (len < 0)
return -EINVAL;
down_read(&uts_sem);

第一步,拿锁。uts_sem 是读写信号量,防止在我们读的时候别人在改名字。

u = utsname();
i = 1 + strlen(u->nodename);
if (i > len)
i = len;
errno = 0;

第二步,拿到当前进程的 new_utsname 对象,计算名字长度。如果用户给的 buffer 太小,就截断;否则全复制。

if (copy_to_user(name, u->nodename, i))
errno = -EFAULT;
up_read(&uts_sem);
return errno;
}

第三步,把数据拷回用户空间,解锁,结束。

整个过程没有任何黑魔法。唯一的变化就是以前读的是「全局变量 init_uts_ns.name」,现在读的是 current->nsproxy->uts_ns->name

同样的逻辑也适用于 sethostname()uname() 等系统调用。只要把「全局引用」替换成「通过 current 指针的间接引用」,命名空间的隔离就自动生效了。

最后的细节:Procfs 适配

做完系统调用还没完,用户通常还通过 /proc 文件系统来查看和修改主机名。

UTS 命名空间必须保证 /proc/sys/kernel/hostname 这个文件在不同命名空间里显示不同的内容。

内核里有一张表 uts_kern_table(定义在 kernel/utsname_sysctl.c),专门管这个。你可以看到里面有些条目的权限是 0444(只读,比如 ostype),而 hostnamedomainname 的权限是 0644(可读写)。

当你读写这些 proc 文件时,内核会调用 proc_do_uts_string() 方法。这个函数内部会巧妙地再次调用我们刚才见过的 utsname() 逻辑,确保操作的是当前命名空间的数据,而不是全局的。

总结一下

UTS 命名空间之所以是「软柿子」,是因为它隔离的数据非常单纯:就是两个字符串。

但这小小的改动牵扯出一整套机制:

  1. 要有专门的结构体(uts_namespace)来容纳数据;
  2. 要有引用计数(kref)来管理生命周期;
  3. 要在 nsproxy 里挂载指针,让进程能找到它;
  4. 要改造所有读取这些数据的系统调用和 proc 接口,让它们从「全局读」变成「进程上下文读」。

理解了这个流程,再看接下来的网络命名空间,你就会发现它们只是把这两个字符串换成了成百上千个网络设备和路由表而已——骨架是一模一样的。