先说个现场。前段时间我给一个基于 io_uring 的小工具做编译环境迁移,make 刚跑起来就报了一条让人血压升高的错误:
fatal error: linux/time_types.h: No such file or directory第一反应是依赖没装齐,毕竟 liburing 这种库对系统环境本来就敏感,缺个头文件挺常见。但补了一圈包之后发现,报错原封不动地躺在那。后来查下来,问题根本不在 liburing 本身,而在系统里的内核头文件版本太旧,压根没有linux/time_types.h这个文件。这问题在低版本内核环境、老容器镜像、交叉编译工具链里反复出现,而且不同发行版表现还不一样,值得专门写一篇把它讲透。
这个报错的本质是“内核头文件版本和 liburing 对内核接口的预期不匹配”。linux/time_types.h是 Linux 内核 UAPI 头文件的一部分,从 5.x 开始被用户态程序广泛引用,但很多系统默认安装的 linux-libc-dev 或 kernel-headers 包并没有跟上,导致编译找不到头文件。下面我会把原因、定位方法和完整解决方案一条条说清楚。
1. 先把 liburing 和这个报错的来龙去脉说清楚
1.1 liburing 是什么,为什么值得用
liburing 是 io_uring 的用户态封装库,而 io_uring 是 Linux 5.1 内核引入的异步 I/O 接口,专门解决传统epoll、aio在高并发读写场景下的性能瓶颈。简单理解,io_uring 在内核和用户态之间维护两个环形队列(SQ 提交队列、CQ 完成队列),应用要发 I/O 请求就把描述符塞进 SQ,内核处理完把结果写进 CQ,全程减少系统调用次数,甚至配合io_uring_enter把系统调用压到一次。
liburing 的价值在于它把这套机制包装成了普通人能用的 API。裸写 io_uring 需要对内核结构体了如指掌,还得自己处理内存屏障、SMP 缓存一致性问题,而 liburing 提供了io_uring_queue_init、io_uring_prep_read、io_uring_submit这一套语义清晰的上层接口,开发效率高一个量级。现在很多高性能网络框架、存储引擎都拿它做底层轮转,我自己平时写日志采集、做磁盘基准测试也习惯优先用 liburing。
但这里有一个容易被忽略的特点:liburing 虽然面向用户态,代码里却直接依赖内核的 UAPI 头文件。这不是 liburing 的设计缺陷,而是因为它必须和内核共享一套结构体定义(比如struct io_uring_params、struct io_uring_sqe),这些定义就放在内核头文件目录里。所以 liburing 编译时跟你系统里装的 linux 头文件版本强绑定。
1.2 linux/time_types.h 到底是个什么东西
linux/time_types.h是 Linux 内核 UAPI(User API)头文件之一,位于内核源码树的include/uapi/linux/time_types.h。它定义了一组用户态可见的时间类型,核心成员是__kernel_timespec和__kernel_itimerspec。
为什么内核要单独抽一个 time_types.h 出来,而不是继续用老一套?这跟 5.x 内核在时间接口上的“拨乱反正”有关。
老内核里,用户态和时间打交道用的是struct timespec,这个结构体有两个字段:time_t tv_sec和long tv_nsec,问题就出在time_t和long的位数在不同架构、不同 ABI 下不一样。32 位和 64 位系统里time_t宽度不同,直接导致同一个结构体在不同平台上的内存布局不同。内核很早就想彻底放弃这个结构,但因为历史兼容包袱太重不敢一步到位。
后来内核引入了__kernel_timespec:
struct __kernel_timespec { __kernel_time64_t tv_sec; long long tv_nsec; };tv_sec明确用 64 位宽度,tv_nsec也固定是 64 位 long long,彻底摆脱了平台差异。这套新时间类型被io_uring等新接口采用,而内核为了把这些定义系统化地暴露给用户态,就把它们集中收进了linux/time_types.h。从编译依赖的角度看,任何一个使用 io_uring 或新版时间接口的程序,都可能直接或间接 include 到这个头文件。
现在问题就好理解了:头文件本身没问题,liburing 的代码也没问题,问题出在你的编译环境里没有这个头文件。
1.3 为什么不同发行版踩坑程度不一样
这个报错的触发条件不是“有新内核就一定没事”,而是取决于你系统里安装的内核头文件包版本。我用表格做一个直观对比:
| 发行版 | 默认内核头文件包 | 常见踩坑原因 |
|---|---|---|
| Ubuntu / Debian | linux-libc-dev | 包版本低于 5.1 时没有 time_types.h;镜像源长期未更新也会中招 |
| CentOS / RHEL | kernel-headers | 默认安装路径和版本差异大,老系统(7.x/8.x)极易缺失 |
| Fedora | kernel-headers | 比较新,一般没事,但跨版本升级可能不完整 |
| Alpine / 精简 Docker 镜像 | linux-headers(apk 包) | 依赖极小化,特别容易漏装 headers 包 |
| 交叉编译工具链 | 各工具链自带 sysroot | sysroot 内头文件版本与目标板卡内核严重脱节 |
Ubuntu 和 Debian 系的坑主要在 linux-libc-dev 的版本上。很多同学以为“头文件是内核自带的”,其实用户态编译用的是独立的 libc-dev 包,它跟运行内核不是同一套版本线。CentOS 8 上 kernel-headers 默认带的 UAPI 头文件属于 4.18 时代的内核,自然就没有 time_types.h。
还有一个高发场景是 Docker 容器。容器镜像精简到只剩编译器,理论上编译代码需要自己装内核头文件,但很多人会用宿主机挂载的/usr/include进行编译,或者直接把宿主机头文件拷贝进容器,版本一旦错位就报错。
2. 报错的几种典型场景和快速定位方法
2.1 我踩坑时的现场还原
那个小工具本身不复杂,README 写得很清楚,先克隆 liburing,然后在其目录下make,再用make install装系统库。前两步在另一台 Ubuntu 20.04 机器上都是正常的,但目标机器装的是 CentOS 7。运行 make 时底下的命令是:
gcc -D_GNU_SOURCE -O2 -g -Wall -I./src/include -c src/queue.c -o queue.oqueue.c里引用了 io_uring 的核心数据结构,间接把linux/time_types.h拉进了编译链。CentOS 7 自带的 kernel-headers 覆盖不到这个 UAPI 头文件,于是直接 fatal error。
我当时的第一反应是查头文件是否存在:
ls -l /usr/include/linux/time_types.h结果如下:
ls: cannot access /usr/include/linux/time_types.h: No such file or directory再查内核头文件包版本:
rpm -q kernel-headers输出的是kernel-headers-3.10.0-1160,瞬间真相大白。3.10 内核时代根本没有这个头文件,哪怕 liburing 代码写得再完美,缺了这个基础文件就是编不过。
2.2 快速定位“到底缺谁”的三步检查
遇到这类编译错误,别急着改代码,先做三件事确认根因。
第一步,确认内核版本和头文件包版本是否匹配:
uname -r rpm -q kernel-headers # Ubuntu 用 dpkg -l linux-libc-dev第二版,确认缺失的头文件范围。有时候不只缺 time_types.h,还会连带缺linux/io_uring.h、asm/barrier.h等,因为 UAPI 头文件存在依赖链。可以用 find 批量搜一遍:
find /usr/include -name 'io_uring*.h' -o -name 'time_types.h'第三步,弄清楚编译期真正搜索的 include 路径。gcc 默认路径顺序和直觉可能不一样,尤其自定义安装过交叉工具链时更是如此。直接让编译器的预处理过程暴露真相:
echo '#include <linux/time_types.h>' | gcc -E -v -x c - 2>&1 | grep 'search starts here'连续输出里就能看到 gcc 的搜索路径列表,对照着看是哪个目录缺失。一般定位出是/usr/include还是交叉工具链的 sysroot 缺失,事情就清楚一半了。
2.3 一个容易误判的坑:容器和交叉编译环境
容器里运行一个带编译任务的流水线,报错linux/time_types.h找不到,第一反应是执行apt-get install linux-headers-$(uname -r),结果可能出现两个问题。
第一个问题是容器基于精简镜像,根本没有 apt 源或者无法安装匹配内核版本的 headers。容器是共享宿主机内核的,uname -r显示的是宿主机的内核版本,但你安装的包却属于容器镜像的基础系统,两者完全不在一条线。
第二个问题是执行uname -r后拿到一个版本号,但那个发行版源里根本找不到对应的linux-headers-$(uname -r)。我试过一次在 Alpine 容器里跑基于 liburing 的编译任务,默认源里根本没有 linux-headers,还得先apk add linux-headers。
交叉编译场景更麻烦。ARM 目标板上跑的内核是 5.10,而交叉工具链 sysroot 里带的是 4.19 头文件,编译时直接报缺失。这时候不是改几行代码能解决的,得给工具链换一套匹配目标板的头文件 sysroot,或者用headers_install从内核源码生成适配的 UAPI 头文件。
3. 完整解决方案,按场景挑一个用
3.1 方案 A:安装匹配当前环境的内核头文件包
最直接的解法就是升级或补齐内核头文件包。Ubuntu / Debian 系统执行:
sudo apt update sudo apt install linux-libc-dev装完后直接验证:
ls -l /usr/include/linux/time_types.h如果系统源里的 linux-libc-dev 太旧,还需要先升级整个系统或手动摘取新版包。CentOS / RHEL / Fedora 系列:
sudo yum install kernel-headers # 或 sudo dnf install kernel-headers安装完成后同样验证一下头文件是否存在。
这个方案最省心,但注意两点。第一,linux-libc-dev安装的是用户态编译依赖的内核 UAPI 头文件,它不要求跟当前运行内核完全一致,但不能差距太大,否则可能出现结构体定义和实际内核行为不匹配的问题。第二,如果你用的系统连官方源都覆盖不了新内核的 UAPI 头文件,那这个方法不适用。
3.2 方案 B:给 make 显式指定正确的 include 路径
如果你的系统里已经有新版内核头文件,只是没被编译器默认路径覆盖到,那直接给编译命令指定 include 路径就行。比如内核头文件在/usr/src/kernel-headers-5.14/include/uapi,可以这样编译 liburing:
make CPPFLAGS="-I/usr/src/kernel-headers-5.14/include/uapi"如果是从内核源码树直接编译,还需要把arch目录也加进搜索路径,因为部分 UAPI 头文件与架构相关:
make CPPFLAGS="-I/lib/modules/$(uname -r)/build/include/uapi -I/lib/modules/$(uname -r)/build/arch/x86/include/uapi"这个方案适合那些不方便动系统全局头文件的场景。比如你在一台机器上同时维护多个项目,每个项目依赖不同版本的内核头文件,全局替换/usr/include肯定会出乱子,不如把 include 路径隔离在各项目的编译参数里。
装完 headers 或者指定 include 路径后,如果 liburing 之前编译出现过中途失败,最好先清理再重编:
make clean make make install我自己实际踩过不 clean 直接重编的坑,一些.o文件没触发依赖重建,导致链接期出现结构体位不对的诡异报错,clean 一步就能避免。
3.3 方案 C:升级或降级 liburing 版本,借助其自适应逻辑
liburing 本身在持续适配各内核版本,部分较新版本会额外提供兼容逻辑,比如在无法获取新头文件时降级使用旧的时间结构定义。我在实际项目中就试过,有些旧版 liburing 在编译时会检测#ifdef __kernel_timespec,但这个检测的前提同样是头文件能被找到才不会报 fatal error,因此版本策略只能作为辅助手段。
一般来说,如果环境里的内核头文件只有轻微落后,新版 liburing 可能帮你避开 time_types.h 的直接依赖;但如果是 CentOS 7 这种内核版本差了一大截的场景,升级 liburing 也无法改变 UAPI 头文件缺失的事实。
相反,如果新环境里 liburing 要求的内核版本太高,你还可以考虑降级使用一个相对旧但稳定的 liburing 版本。比如liburing-2.1对内核 4.x 的适配就比liburing-2.3好,毕竟 io_uring 特性是逐步迭代的,版本越新,需要的内核基础越高。
建议做法是,先对照 repo 里的CHANGELOG看看版本说明,再决定是升还是降。不要盲目追新,稳定才是实际运行环境的第一诉求。
3.4 方案 D:本地补一个符合要求的 time_types.h(兜底方案)
这个方案只适合极其受限的环境,比如没法安装新头文件、没法指定 include 路径、也没法升级 liburing。手动创建一个最小替换头文件:
sudo mkdir -p /usr/local/include/linux sudo vim /usr/local/include/linux/time_types.h内容可以写:
#ifndef _LINUX_TIME_TYPES_H #define _LINUX_TIME_TYPES_H #include <linux/types.h> struct __kernel_timespec { __kernel_time64_t tv_sec; long long tv_nsec; }; struct __kernel_itimerspec { struct __kernel_timespec it_interval; struct __kernel_timespec it_value; }; #endif然后把/usr/local/include加进搜索路径:
export CPPFLAGS="-I/usr/local/include"要注意,这个头文件的内容必须与目标运行内核的实际 ABI 一致。比如有些架构或配置下tv_nsec的类型定义可能不是long long,如果你补的头文件类型写错,编译能过,运行期数据也会错乱,这属于更隐蔽的坑。
我不太推荐直接往/usr/include/linux/里写文件,那样会污染系统全局,多个项目同时编译时容易互相干扰。放在/usr/local/include下并通过 CPPFLAGS 显式指定,影响范围可控,后面排查也方便。
3.5 如果只是跑 liburing 示例代码,怎么做最省事
很多人遇到 time_types.h 报错时,并不是在编 liburing 库本身,而是在编它的examples/目录示例。这种情况下可以不用全局折腾头文件,直接进 liburing 源码目录,用仓库自带的构建方式和 include 逻辑试试:
cd liburing make examples新版 liburing 的 Makefile 一般会把src/include和系统头文件统一纳入路径,如果还是报同样的错,那就还是需要先解决系统级缺头文件的问题。
实测下来,examples/目录的要求往往比 liburing 库本身更严,因为它依赖部分 io_uring 高级特性,这时候优先建议用方案 A 更新系统头文件包,而不是绕开示例程序。
4. 常见问题与实操心得
4.1 更新内核头文件后,liburing 必须重新编译
很多人以为头文件更新后,之前编译失败的项目就能直接跑起来。实际上,如果 liburing 库本身之前最后一次编译成功是在头文件版本 A 下,后来又切换到版本 B,那么最好重新执行 clean 后的完整编译,再make install。因为 C 语言里结构体定义一旦变化,所有依赖这些头文件的.o文件都得重编,只更新头文件不重新编库,运行时就会出现结构体字段错位。轻则指针读错数据,重则直接段错误。
我自己就吃过一次教训。某次把内核头文件从 4.18 切到 5.14 后,liburing 没重新编译,程序一跑就挂,排查半天才发现是结构体 ABI 不匹配。后来养成了习惯:每次切换头文件版本,第一时间make clean && make && make install,不要抱着侥幸心理。
4.2 交叉编译时用 headers_install 生成匹配目标板的 UAPI 头文件
交叉编译场景下,最稳妥的办法是从目标板同版本内核源码生成一套干净的 UAPI 头文件。内核源码树有一条专门命令:
make ARCH=arm64 headers_install INSTALL_HDR_PATH=/opt/sysroot/usr这会把内核的 UAPI 头文件按规范整理到/opt/sysroot/usr/include,只保留用户态需要的部分,不包含内核内部实现细节。之后交叉编译 liburing 时指定 sysroot:
make CROSS_COMPILE=aarch64-linux-gnu- \ CPPFLAGS="-I/opt/sysroot/usr/include" \ LDFLAGS="-L/opt/sysroot/usr/lib"这样生成的库和示例才跟目标板真正匹配。注意headers_install一定要匹配目标板内核版本,比如目标板跑 5.10,就用 5.10 内核源码生成,否则交叉编译产物拿到板子上可能还是运行异常。
4.3 三条我反复用到的排查技巧
第一条,用 grep 找出真正 include time_types.h 的文件。遇到编译错误时,第一反应不要是“去下载一个头文件”,而是查哪一行代码引入了它:
grep -r "time_types.h" /path/to/liburing/src一般落在io_uring.h或liburing.h里,这会让你理解依赖链是从哪里断掉的。
第二条,用 strace 看编译进程实际读取头文件的路径,判断 gcc 到底去哪里找文件。虽然用-v也能看搜索路径,但 strace 更细粒度,还能发现权限问题:
strace -f -e openat make 2>&1 | grep time_types第三条,建立多套 sysroot,按项目隔离头文件版本。比如/opt/sysroot-5.4、/opt/sysroot-5.14,不同项目配不同 CPPFLAGS。虽然前期构建成本高,但维护多个老项目的效率会直线上升,也避免了全局头文件互相污染的问题。
4.4 内核版本与 liburing 版本的适配建议
库的版本不是越新越好,还得看你的目标内核。以下是我个人总结的对应关系,仅供参考:
| 内核版本 | 推荐的 liburing 版本 | 备注 |
|---|---|---|
| Linux 5.1 - 5.4 | liburing-0.7 或 0.8 | 初期 io_uring 特性有限,旧版库够用 |
| Linux 5.5 - 5.10 | liburing-1.x | 提供更稳定的 API,特性适配较好 |
| Linux 5.11 - 5.15 | liburing-2.0 ~ 2.2 | 适配了较新的固定文件和 buffer 相关特性 |
| Linux 5.16+ | liburing-2.3+ | 完整支持新特性,示例代码要求更高 |
如果你的生产内核对某些 io_uring 特性支持不全,比如IORING_SETUP_SQPOLL在部分版本上有已知性能问题,那么即便 liburing 2.4 能用,也不建议直接上最新版。在真实业务里,稳定优先于新奇特性,这一点我在数据库场景踩过的坑比较深。
5. 再补一个在线排查技巧:别忽略 liburing 的自动检测宏
liburing 的代码里有一段对时区类型支持的编译期检测,逻辑大致是:
#if defined(__x86_64__) || defined(__aarch64__) || ...或者通过__kernel_timespec是否可用来判断结构体支持程度。在环境无法补头文件的情况下,可以临时向编译器手动传递部分宏定义,让它走兼容分支。但这条路非常不干净,一旦宏控制的结构体布局和目标内核不一致,编译能过但运行时行为就是未定义的。除非你非常清楚自己在做什么,否则别随便加宏硬编。
如果你只是想在编译期确认 liburing 哪段逻辑依赖了 time_types.h,可以直接对预处理结果搜一下:
gcc -E -I./src/include src/liburing.c | grep -n "__kernel_timespec" | head预处理输出里的引用位置会很清晰地暴露依赖点,让你知道这次缺失到底是几处引用,以及可能影响哪些 API。这种快速检查在你想确认“是不是只有 time_types.h 一个问题”时特别有用,因为很多时候底层还有一连串隐藏的缺失,逐一排查才不会反复。
最后再分享一个经验:这个报错刚出现时,我都习惯先怀疑 liburing 代码不够新,后来发现大部分场景都是编译环境的内核头文件系统落后,或是交叉编译工具链使用的 sysroot 版本和目标板脱节。现在不管是本地环境还是 CI 环境,我都会在编译前先显式检查头文件版本,用一句简单的test -f /usr/include/linux/time_types.h提前拦截问题,比等编译器报错后再折腾要省时间得多。liburing 这个库本身写得很实用,缺头文件不是库的错,是你构建环境需要跟上内核演进的信号,把它当成常态就好,思路清晰了,问题也就不难解决。