首页
/ 深入 Go sys/unix 系统调用代码生成:从 mkall.sh 到 zsyscall/ztypes 生成文件的完整实践

深入 Go sys/unix 系统调用代码生成:从 mkall.sh 到 zsyscall/ztypes 生成文件的完整实践

2026-09-05 20:18:52作者:沈韬淼Beryl

本文以 Go 官方仓库中 vendored 在 src/cmd/vendor/golang.org/x/sys/unix/README.md 的构建文档为主体,系统讲解 sys/unix 包如何通过代码生成体系,把 C 头文件中的系统调用号、错误常量与内核数据结构转换为按 GOOS/GOARCH 组织的 z* 生成文件。读完本文,你将能够理解旧/新两套生成构建系统(header 驱动与 Docker 容器驱动)的分工、mkall.sh/mkerrors.sh 等构建脚本的实际行为,以及为某个架构新增系统调用、常量或类型时应修改哪些组件文件。

sys/unix 包在 Go 仓库中的定位

sys/unix 包提供对底层操作系统原始系统调用接口(raw system call interface)的访问。它不直接暴露给普通业务代码,而是为 Go 运行时、标准库以及工具链提供跨 Unix 平台的底层原语。

在当前 Go 仓库中,这个包以 vendored 依赖的形式出现在 src/cmd 模块下,即路径 src/cmd/vendor/golang.org/x/sys/unix。从该目录的文件列表可以确认,vendor 副本只保留了编译该包运行时行为所需的文件:手写的 syscall_*.goasm_*_*.s、以及一批 z* 前缀的生成文件,外加两个构建脚本 mkall.shmkerrors.sh。上游模块中的生成器程序(mksyscall.gomksysnum.gomkpost.gointernal/mkmerge 等)并未随 vendor 副本保留,本文后续会结合生成文件头部的命令注释来说明它们各自的角色。

需要特别注意的是 README 中给出的一条约束:若使用新构建系统(Docker),其中的脚本/程序不能在宿主机上直接调用,必须从容器内部发起。这一点在 mkerrors.sh 中有强制保护——当 GOOS=linux 且环境变量 GOLANG_SYS_BUILD 不为 docker 时,脚本直接报错退出并提示参考 README:

In the Docker based build system, mkerrors should not be called directly.
See README.md

两套构建系统:header 驱动与 Docker 驱动

README 明确指出,为新的架构/OS 组合移植 Go、或为既有组合新增系统调用/类型/常量,都有一些需要手工完成的工作,但工具链已经自动化了其中大部分。当前存在两套生成体系,且正按 OS 逐个迁移到可复现的容器化构建:

旧构建系统(当前用于 GOOS != "linux"

旧体系以本机安装的 C 头文件为输入生成 Go 文件,这意味着:

  • 某个 GOOS/GOARCH 组合的文件必须在装有对应 OS 与架构的系统上生成;
  • 不同系统上生成的代码可能不同,差异来自头文件本身的差异。

为控制这种漂移,README 提出两条纪律:只在未修改过头文件的安装环境上生成;并记录文件所基于的 OS 版本(例如 Darwin 14 与 Darwin 15),让每次 OS 升级只对应一次变更,便于追踪。

操作方式为:正确设置 GOOSGOARCH 后运行 mkall.sh,即可为当前系统生成文件;mkall.sh -n 只打印将要执行的命令而不执行。依赖为 bash 与 go。

mkall.sh 的参数解析印证了这套语义:-n 把执行器 run 切换为 cat、把命令前缀 cmd 切换为 echo,随后整条生成流水线(mkall.sh)只负责把各生成命令拼接出来,再统一交给 $run 执行。此外该脚本还提供一个 README 未单独展开的实用参数 -syscalls:它遍历所有 zsyscall*go 文件,把每个文件首行注释中记录的生成命令重新执行一遍并 gofmt 回写(mkall.sh)——这正是“每个生成文件头部记录其生成命令”这一约定(下文多处可见 Code generated by the command above; see README.md. DO NOT EDIT.)带来的可追溯性。

在旧体系下,mkall.shGOOSARCH 逐个分支配置各平台参数,例如 mkall.sh 中:

  • freebsd_386mkerrors-m32mksyscall 使用 -l32mksysnum 从 FreeBSD 源码树的 sys/kern/syscalls.master(stable/12 分支)拉取系统调用表;
  • freebsd_arm/netbsd_arm/openbsd_* 等 32 位 arm 目标:mktypes 额外加 -fsigned-char,注释说明这是为了让“裸系统调用 API 在各平台保持一致”;
  • darwin_*openbsd_*:除常规生成外还会执行 mkasmgo run mkasm.go),产出与生成 syscall 配套的汇编 stub,例如 zsyscall_darwin_amd64.szsyscall_openbsd_amd64.s

mkerrors.sh 还体现了对生成环境确定性的细节处理:unset LANG 并固定 LC_ALL=CLC_CTYPE=C,默认编译器为 cc(AIX 用 gcc),Solaris 下把 /usr/gnu/bin 前置到 PATH 以强制使用 GNU 版本工具。

新构建系统(当前用于 GOOS == "linux"

新体系用 Docker 容器直接从内核与各系统库的源码 checkout 生成 Go 文件,带来两个关键收益:任何支持 Docker 的平台都能一次性生成新体系覆盖的所有文件;生成结果不再依赖执行者本机安装了什么。

其组织结构为:

  • 各 OS 专属文件放在 ${GOOS} 目录中,构建由 ${GOOS}/mkall.go 程序协调;
  • 内核或系统库升级时,修改 ${GOOS}/Dockerfile 以 checkout 新的源码版本。

执行前提是在 amd64/Linux 系统上并正确设置 GOOS/GOARCH,然后运行 mkall.shmkall.sh -n 同样可以预演命令。依赖为 bash、go、docker。

mkall.sh 中 linux 分支的实现与 README 描述完全一致:

if [[ "$GOOS" = "linux" ]]; then
	# Use the Docker-based build system
	set -e
	$cmd docker build --tag generate:$GOOS $GOOS
	$cmd docker run --rm --interactive --tty --volume $(cd -- "$(dirname -- "$0")/.." && pwd):/build generate:$GOOS
	exit
fi

即先在 ${GOOS}(即 linux)目录下构建镜像 generate:linux,再把上级目录挂载进容器执行。由于 linux 走容器分支,mkall.sh 中针对 aix/darwin/freebsd/netbsd/openbsd/solaris/illumos 的 case 分支全部服务于旧体系。

从生成文件头部可以交叉验证容器化流程。zsysnum_linux_amd64.go 首行记录的生成命令为:

// go run linux/mksysnum.go -Wall -Werror -static -I/tmp/amd64/include -m64 /tmp/amd64/include/asm/unistd.h

/tmp/amd64/include 正是容器内把 amd64 内核头文件 checkout 后的路径——这正是 README 所说“新体系下 mksysnum 在容器内解析头文件”的实物证据,同时也说明 vendor 副本中看不到 linux/ 目录(含 mkall.goDockerfile)是因为它们属于上游模块,不是运行时依赖。

组件文件详解

README 的 “Component files” 一节描述了代码生成涉及的各类文件,并给出修改指引。下面逐个结合本仓库中的实际文件展开。

asm 文件:系统调用分发

手写的汇编文件 asm_${GOOS}_${GOARCH}.s 实现系统调用分发,包含三个入口点:

  func Syscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)
  func Syscall6(trap, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, err uintptr)
  func RawSyscall(trap, a1, a2, a3 uintptr) (r1, r2, err uintptr)

前两者是标准入口,差别仅在于能向内核传递的参数个数(3 个对 6 个);第三个供 ForkExec 包装器做底层使用,与前两者的关键区别是不会通知调度器“当前正在执行系统调用”。移植 Go 到新架构/OS 时,每个 GOOS/GOARCH 组合都必须实现这个文件。本仓库的 vendor 副本中可以找到全套实现,如 asm_linux_amd64.sasm_bsd_amd64.sasm_aix_ppc64.s 等。

mksysnum:生成系统调用号常量

mksysnum 是一个 Go 程序(新体系位于 ${GOOS}/mksysnum.go,旧体系位于 mksysnum_${GOOS}.go)。它读取包含系统调用号声明的头文件列表,解析后产出对应的 Go 数值常量,写入 zsysnum_${GOOS}_${GOARCH}.go

zsysnum_linux_amd64.go 为例,生成结果就是标准的 syscall 号表:

const (
	SYS_READ    = 0
	SYS_WRITE   = 1
	SYS_OPEN    = 2
	SYS_CLOSE   = 3
	...
	SYS_IOCTL   = 16
)

README 指出,新增系统调用号通常只需“在足够新的目标 OS 上跑一遍构建”(新体系则是更新容器内的源码 checkout),但视 OS 不同,有时需要修改 mksysnum 的解析逻辑。旧体系下 mksysnum 的输入形态在 mkall.sh 中可见:传入一个 syscalls.master 文件的 URL,由程序自行抓取解析。

mksyscall.go:从 //sys 注释生成系统调用

syscall.gosyscall_${GOOS}.gosyscall_${GOOS}_${GOARCH}.go手写 Go 文件,分别实现针对 unix 通用、具体 OS、具体 OS/架构组合的系统调用。其中两类内容:

  1. 需要特殊处理的系统调用,直接写成普通 Go 函数;
  2. 可生成的系统调用,以 //sys 注释形式声明原型。

mksyscall.go 程序解析这些 //sys//sysnb 注释,将其转换为可执行的 syscall 包装函数。关键约束是:注释中原型的名称必须与 zsysnum_${GOOS}_${GOARCH}.go 中的某个 syscall 号匹配。原型名可以导出(首字母大写)也可以不导出。

vendor 副本中的 syscall_linux.go 文件头注释直接点明了这种“一个文件两种身份”的用法:

// This file is compiled as ordinary Go code,
// but it is also input to mksyscall,
// which parses the //sys lines and generates system call stubs.
// Note that sometimes we use a lowercase //sys name and
// wrap it in our own nicer implementation.

README 给出的“新增系统调用”路径在源码中有典型样本。syscall_linux.go 展示了不导出的 //sys 原型 + 自定义包装的模式:

//sys	FanotifyInit(flags uint, event_f_flags uint) (fd int, err error)
//sys	fanotifyMark(fd int, flags uint, mask uint64, dirFd int, pathname *byte) (err error)

func FanotifyMark(fd int, flags uint, mask uint64, dirFd int, pathname string) (err error) {
	if pathname == "" {
		return fanotifyMark(fd, flags, mask, dirFd, nil)
	}
	p, err := BytePtrFromString(pathname)
	if err != nil {
		return err
	}
	return fanotifyMark(fd, flags, mask, dirFd, p)
}

这里导出的 FanotifyMarkstring 参数转换为内核所需的 *byte,再把裸调用交给未导出的 fanotifyMark。若想让接口形态与裸 syscall 不同,通常就采用这种“未导出 //sys + 手写 wrapper”的做法;而 syscall_linux.go 还展示了用 = 常量 显式指定 trap 号的写法:

//sys	ioctl(fd int, req uint, arg uintptr) (err error) = SYS_IOCTL
//sys	ioctlPtr(fd int, req uint, arg unsafe.Pointer) (err error) = SYS_IOCTL

生成端的产物是 zsyscall_${GOOS}_${GOARCH}.gozsyscall_linux_amd64.go 首两行记录了生成命令与禁改声明,其后的每个函数都对应一个 //sys 原型,通过 Syscall/Syscall6 分发并把错误号经 errnoErr 转换:

// go run mksyscall.go -tags linux,amd64 syscall_linux.go syscall_linux_amd64.go syscall_linux_alarm.go
// Code generated by the command above; see README.md. DO NOT EDIT.

//go:build linux && amd64

...

func Fallocate(fd int, mode uint32, off int64, len int64) (err error) {
	_, _, e1 := Syscall6(SYS_FALLOCATE, uintptr(fd), uintptr(mode), uintptr(off), uintptr(len), 0, 0)
	if e1 != 0 {
		err = errnoErr(e1)
	}
	return
}

可以看到 //sys 原型名(Fallocate)经 mksyscall 处理后映射到了 zsysnum_linux_amd64.go 中的 SYS_FALLOCATE 常量,并生成了 6 参数版本的分发调用。错误路径使用的 errnoErr 定义在 syscall_unix.go,它对 EAGAIN/EINVAL/ENOENT 等高频错误做了预装箱以避免运行时分配——这些错误常量正是下面 zerrors 文件的产物,可见四条生成链在运行期是互相咬合的。

types 文件:godef 管线生成内核数据结构

每个 OS 有一个手写 Go 文件(新体系为 ${GOOS}/types.go,旧体系为 types_${GOOS}.go),其中包含标准 C 头文件,并为相应 C 类型创建 Go 类型别名;文件先被喂给 godef 得到 Go 兼容定义,再经 mkpost.go 格式化并剔除隐藏/私有标识符,最终写入 ztypes_${GOOS}_${GOARCH}.go

ztypes_linux_amd64.go 的首行完整记录了这条管线:

// cgo -godefs -objdir=/tmp/amd64/cgo -- -Wall -Werror -static -I/tmp/amd64/include -m64 linux/types.go | go run mkpost.go

产物包含指针/长整型大小常量与传给 syscall 的 C 结构体定义:

const (
	SizeofPtr  = 0x8
	SizeofLong = 0x8
)

type (
	_C_long int64
)

type Timespec struct {
	Sec  int64
	Nsec int64
}

README 指出准备这个文件最难的部分是:搞清楚该包含哪些头文件、以及需要 #define 哪些宏才能拿到真正传给内核 syscall 的数据结构——一些 C 库出于二进制兼容预置了替代版本,会在 syscall 进出时做翻译,但几乎总存在某个 #define 可以取回“真实”结构。mkerrors.sh 中的 includes_Darwin 块就是这类宏技巧的实例:_DARWIN_C_SOURCEKERNEL_DARWIN_USE_64_BIT_INODE__APPLE_USE_RFC_3542#define 前置在头文件包含之前,确保生成的是内核态数据结构而非兼容层版本。

新增类型的操作:在文件顶部按需补充 include,再加一行类型别名;若类型在不同架构上差异显著,可能需要用 #if/#elif 宏。README 给出的示例为 types_darwin.golinux/types.go(位于上游模块,vendor 副本未包含,见文末说明)。

mkerrors.sh:错误号、信号与杂项常量

mkerrors.sh 用于生成系统的各类常量,不限于错误号/错误串,还包括信号号和大量杂项常量。机制是:

  1. 常量来源是 includes_${uname} 变量列出的一组 include 文件;
  2. 用正则从中筛出目标 #define,生成对应 Go 常量;
  3. 错误号与错误串来自 #include <errno.h>,信号号与信号串来自 #include <signal.h>
  4. 所有常量由一个 C 程序 _errors.c 打印出来,最终写入 zerrors_${GOOS}_${GOARCH}.go

mkerrors.shincludes_AIXincludes_Darwinincludes_DragonFlyincludes_FreeBSD 等变量正对应 includes_${uname} 的写法,每个变量即“该 OS 要参与常量提取的头文件清单 + 必要的 #define 前置”。

产物 zerrors_linux_amd64.go 头部的两行注释同时暴露了两次生成(mkerrors.sh 组织命令行,内部经 cgo -godefs 编译打印):

// mkerrors.sh -Wall -Werror -static -I/tmp/amd64/include -m64
// Code generated by the command above; see README.md. DO NOT EDIT.

//go:build amd64 && linux

// Code generated by cmd/cgo -godefs; DO NOT EDIT.
// cgo -godefs -- -Wall -Werror -static -I/tmp/amd64/include -m64 _const.go

新增常量的操作:把包含该常量的头文件加入相应变量,必要时调整正则以匹配目标常量;README 特别提醒正则不要过宽,避免误匹配到不想要的常量。

internal/mkmerge:跨架构公共代码归并

internal/mkmerge 程序从上述各架构专属生成文件中提取重复的 constfunctype 声明,合并进每个 OS 的公共文件。归并步骤:

  1. 构造在所有架构专属文件中完全相同的公共代码集合;
  2. 将这部分公共代码写入合并后的文件;
  3. 从各架构专属文件中移除公共代码。

这也解释了生成文件中“共享 + 专属”并存的结构,例如 zerrors_linux.go(linux 公共部分)与各 zerrors_linux_${GOARCH}.go(架构差异部分)、zsyscall_linux.go 与各 zsyscall_linux_${GOARCH}.go 在目录中成对出现。

生成文件清单:四类 z* 文件及其来源

把 README 的 “Generated files” 一节整理为速查表,并映射到仓库中真实存在的示例文件:

生成文件 内容 生成器 仓库中的实例
zerrors_${GOOS}_${GOARCH}.go 系统错误号、错误串、信号号与全部杂项常量 mkerrors.sh zerrors_linux_amd64.go
zsyscall_${GOOS}_${GOARCH}.go 该 GOOS/GOARCH 下全部生成的系统调用 mksyscall.go zsyscall_linux_amd64.go
zsysnum_${GOOS}_${GOARCH}.go 该 GOOS/GOARCH 全部系统调用号的数值常量表 mksysnum zsysnum_linux_amd64.go
ztypes_${GOOS}_${GOARCH}.go 传给(或返回自)syscall 的 Go 类型 godefs(types 文件)+ mkpost.go ztypes_linux_amd64.go

补充两个来自 mkall.sh 的额外生成物:OpenBSD 平台还会由 mksysctl_openbsd.go 生成 zsysctl_${GOOSARCH}.go(sysctl 常量表,见 zsysctl_openbsd_*.go 系列文件);darwin/openbsd 等由 mkasm.go 生成配套 .s 分发 stub(如 zsyscall_openbsd_amd64.s)。

移植与扩展速查:修改哪些文件、怎么验证

综合 README 的操作指引与仓库中的脚本行为,可以把日常修改路径归纳为:

新增一个系统调用

  1. 优先做法:在 syscall_${GOOS}.go/syscall_${GOOS}_${GOARCH}.go 中新增一条 //sys 原型(导出名即导出 API),重跑生成;
  2. 需要自定义接口时:写未导出 //sys 原型 + 手写包装(模式见 syscall_linux.goFanotifyMarkFchmodat 中“新 syscall 失败再回退旧 syscall”的兼容写法);
  3. 验证:生成文件中对应函数应出现,且 trap 参数引用了 zsysnum_* 中的常量。

新增一个常量:把目标头文件加入 mkerrors.shincludes_${uname} 变量,必要时收紧正则,避免误匹配。

新增一个类型:在 ${GOOS}/types.go(旧体系 types_${GOOS}.go)补 include 与类型别名行,架构差异大时用 #if/#elif;确认生成进 ztypes_${GOOS}_${GOARCH}.go

内核/系统库升级(linux):修改 linux/Dockerfile 中的源码 checkout 版本,然后在 amd64/Linux 上运行 mkall.sh 重新生成全部 linux 组合;mkall.sh -n 可先预演。

旧体系平台:在对应 OS/架构的“干净”安装上设置 GOOS/GOARCH 后运行 mkall.sh,并记录所基于的 OS 版本。

vendor 副本的边界说明

为避免误用,最后明确本仓库中该目录的实际边界:

  • vendor 副本包含:README.mdmkall.shmkerrors.sh、全部 syscall_*.go/asm_*_*.s 手写与生成文件、全部 z* 生成文件;
  • vendor 副本不包含:各生成器 Go 程序(mksyscall.gomksysnum.gomkpost.gomkasm.gomksysctl_openbsd.go)、internal/mkmerge、各 OS 的 types.go/types_${GOOS}.go 以及 linux 的 linux/ 目录(mkall.goDockerfile)。

这与 vendor 机制“只保留编译所需文件”的行为一致:本文引用的各生成文件首行命令注释(如 go run mksyscall.go ...cgo -godefs ... | go run mkpost.go)记录的是上游模块内的真实生成命令,可在生成文件中直接查证,而生成器本身需要到上游 golang.org/x/sys 模块中查看。

适用前提:以上所有构建流程都要求先正确设置 GOOS/GOARCH;linux 组合额外要求 amd64/Linux 宿主机与 Docker;旧体系要求各平台本机装有未修改的头文件。若你只需要使用 sys/unix 的 API,则无需参与任何生成流程,直接使用已提交的 z* 文件即可。

登录后查看全文
热门项目推荐
相关项目推荐