先聊一个很多WSL2用户迟早会撞上的场景:你正高高兴兴在WSL2里编译驱动或者安装第三方内核模块,突然modprobe甩给你一句“Exec format error”,或者insmod抱怨“version magic ... should be ...”。再一看,内核版本6.6.66.6-microsoft-standard-WSL2和模块编译时对应的版本完全对不上。这种情况在近期非常高频,因为 WSL2 自身的内核更新周期和发行版仓库里linux-headers包的更新节奏经常错位,再加上不少人会为了 EtherCAT、IGC 网卡驱动或实时补丁去手动构建内核,版本不一致就成了绕不过去的一道坎。这篇文章我打算把踩过的坑、排查思路和几种有效修复路径完整写出来,适合正在用 WSL2 做嵌入式开发、驱动调试,或者只是想给 WSL2 装个自定义模块的同学参考。
1. WSL2为什么会出现内核和模块"对不上号"
1.1 WSL2的内核机制和传统Linux发行版不太一样
很多人对 WSL2 有个误解,觉得它就是个“性能好一点的虚拟机壳子”。实际上 WSL2 的架构是在轻量级 Hyper-V 虚拟机里跑一个真正的 Linux 内核,只不过这个内核由微软维护,通过wsl --update独立分发,而不是跟随 Ubuntu/Debian 的 apt 源更新。这就导致了一个非常典型的矛盾:你的用户空间(rootfs)可以是 Ubuntu 22.04,内核却是微软打的*-microsoft-standard-WSL2专用内核。
传统 Linux 发行版里,apt install linux-headers-$(uname -r)通常能直接拿到和当前内核严格匹配的头文件。但 WSL2 的发行版镜像里往往没有带这个包,或者带的版本和微软发布的最新内核不一致。你一旦需要编译内核模块,问题就暴露了:内核模块不是独立运行的应用程序,它必须和当前内核的版本魔法(vermagic)、配置选项、甚至编译时使用的工具链严格对齐,否则内核直接拒绝加载。
1.2 Linux内核模块的"版本魔法"机制
Linux 内核在编译模块时,会往模块文件里写一个叫 vermagic 的字符串,里面包含内核版本号、SMP 支持情况、抢占模式(PREEMPT/PREEMPT_RT)、架构等信息。加载模块时,内核会检查这个字符串和当前运行内核的 vermagic 是否完全一致。不一致就直接拒绝,dmesg里会给出类似这样的信息:
hello: version magic '6.6.66.6-microsoft-standard-WSL2 SMP preempt mod_unload ' should be '6.6.63.1-microsoft-standard-WSL2 SMP preempt mod_unload '如果你启用了 CONFIG_MODVERSIONS,还会额外校验 CRC 符号版本。所以表面上看是“内核版本和模块版本不一致”,本质上就是模块二进制文件与当前运行内核的接口不兼容。
1.3 实际项目中导致不一致的几种常见成因
从我接触过的案例看,问题通常出现在下面几条链路里:
- 微软推送了 WSL2 内核更新:
wsl --update之后uname -r变了,但你之前编译好的第三方模块没有跟着重新编译。 - 自己从 GitHub 构建了自定义内核:为了 IGC/EtherCAT 驱动或者 PREEMPT_RT 实时补丁,你用源码编译了内核并替换了默认内核,但之后编译模块时用的还是微软内核的头文件。
- 内核源码和内核头文件不同源:有人在
make modules_prepare时用了错误的 tag,或者从发行版仓库装了一个不匹配的linux-headers包。 - 在多个 WSL 发行版之间切换:Ubuntu 和 Debian 实例共用同一个 WSL2 内核,但各自的 apt 源里 headers 包进度不同,导致某一侧出现错位。
理解了这些成因,再去排查就会轻松很多。
2. 先定位:一字一句读报错,判断是哪种"不一致"
2.1 三个命令瞬间看清现状
遇到问题后第一件事不是去盲目重装,而是先弄清楚当前内核到底是什么、模块树里有什么、目标模块的 vermagic 到底是什么。下面这三个命令足矣:
uname -r ls /lib/modules/ modinfo /path/to/your-module.ko | grep vermagic以我最近一次排查为例,输出是:
$ uname -r 6.6.66.6-microsoft-standard-WSL2 $ ls /lib/modules/ 6.6.66.6-microsoft-standard-WSL2 6.6.63.1-microsoft-standard-WSL2看到ls /lib/modules/下面有两个目录,问题基本就明确了:当前跑的内核是6.6.66.6,但你编译模块时依赖的头文件却是6.6.63.1的。或者反过来,头文件是新的,内核还是旧的。无论哪边多哪边少,本质都一样——对不上。
2.2 解读vermagic报错信息的几个关键字段
vermagic字符串不是给你装饰用的,字段含义如下:
| 字段 | 示例 | 含义 |
|---|---|---|
| 内核版本 | 6.6.66.6-microsoft-standard-WSL2 | 版本号和发行标识 |
| SMP | SMP | 对称多处理器支持 |
| preempt | preempt/PREEMPT_RT | 内核抢占模式 |
| mod_unload | mod_unload | 是否支持模块卸载 |
如果报错信息里SMP、preempt这两个词两边不一样,说明除了版本号之外,内核配置也变了。这种情况常见于你从微软内核切换到自编译 PREEMPT_RT 内核的过程。dmesg中经常出现这种对比信息,一定要逐字读,差一个词都算不匹配。
2.3 一个典型排查案例:从报错到根因的完整链路
我在一次给 WSL2 编译 IGC 网卡驱动的过程中,遇到过的完整链路是这样的:
- 执行
sudo modprobe igc,直接报错modprobe: ERROR: could not insert 'igc': Exec format error。 dmesg | tail看到了版本魔法不匹配的信息。uname -r显示当前内核是6.6.66.6-microsoft-standard-WSL2。ls /lib/modules/发现没有6.6.66.6-microsoft-standard-WSL2目录,只有旧版本目录。- 结论:编译
igc.ko时用的是旧头文件,当前内核已经更新,必须获取新内核的头文件并重新编译模块。
这就是最常见的“微软更新了内核,但你的构建环境没有跟着更新”的情况。
2.4 确认内核配置差异
如果版本号相同但依然报 vermagic 不匹配,那就要对比内核配置了。用/boot/config-$(uname -r)或/proc/config.gz查看当前内核配置,再对比模块编译时生成的Module.symvers和.config。重点看CONFIG_PREEMPT、CONFIG_SMP、CONFIG_MODVERSIONS这三项。
zcat /proc/config.gz | grep -E "CONFIG_PREEMPT|CONFIG_SMP|CONFIG_MODVERSIONS"如果/proc/config.gz不存在,可能需要先安装configfs或直接从微软内核仓库获取对应的配置文件。内核配置差异比版本号差异更隐蔽,但也更致命,因为它不是看版本号大小能判断出来的。
3. 修复路径:按使用场景选最快方案
版本不一致的修复方案不能一刀切,得看你到底处于哪种使用场景。
3.1 场景A:用微软官方WSL2内核,只是想编译自己的模块
这是最常见的情况。你的目标是让编译环境与当前运行的microsoft-standard-WSL2内核精确匹配。首选办法是尝试直接安装配套的 headers 包:
sudo apt update sudo apt install linux-headers-$(uname -r)但在 WSL2 下,Ubuntu 的 apt 源往往没有这个包,因为微软内核不在发行版仓库里。此时不要死磕 apt,正确的做法是从微软 WSL2-Linux-Kernel 仓库拉取与当前内核版本对应的 tag,本地准备构建环境。当前内核版本如果是6.6.66.6-microsoft-standard-WSL2,就去 GitHub 找到对应的 tag(一般是linux-msft-wsl-6.6.66.6这类命名),然后执行:
git clone --depth 1 --branch linux-msft-wsl-6.6.66.6 https://github.com/microsoft/WSL2-Linux-Kernel.git cd WSL2-Linux-Kernel cp Microsoft/config-wsl .config make olddefconfig make modules_prepare执行完make modules_prepare后,/lib/modules/$(uname -r)/build这个软链接就会指向当前源码树,后续编译模块时make -C /lib/modules/$(uname -r)/build M=$(pwd) modules就能对齐。
3.2 场景B:自己编译了定制内核,模块也要跟着定制
如果你走的是自定义内核路线,比如为了 PREEMPT_RT 实时补丁或 IGC/EtherCAT 支持自己编译了内核,那么/lib/modules/$(uname -r)目录通常已经存在,因为你make modules_install时内核源码已经把自己注册为当前模块树了。这种情况不需要再去找微软头文件,但要注意内核源码必须是同一个构建目录,不能换了目录或重编了内核之后再用旧源码编译模块。最好的习惯是保留内核源码树,之后所有模块都在这个源码树下编译。
3.3 场景C:需要长期跟随内核升级维护模块
如果你的项目里有一些自研或第三方模块,需要跟着内核升来升去,手工重新编译很容易漏。解决方案就是 DKMS(Dynamic Kernel Module Support)。DKMS 会在内核版本改变后自动重新编译注册过的模块源码。配置好dkms.conf后,以后无论微软怎么更新 WSL2 内核,只要uname -r变了,DKMS 都会自动为新内核构建模块。
示例dkms.conf:
PACKAGE_NAME="my_mod" PACKAGE_VERSION="1.0" BUILT_MODULE_NAME[0]="my_mod" DEST_MODULE_LOCATION[0]="/kernel/drivers/misc/" AUTOINSTALL="yes" MAKE[0]="make -C ${kernel_source_dir} M=${dkms_tree}/${PACKAGE_NAME}/${PACKAGE_VERSION}/build modules"3.4 场景D:实时补丁、IGC等特定硬件的特殊处理
EtherCAT 主站和 IGC 网卡的组合是一个很典型的 WSL2 使用场景。很多人想用 WSL2 做实时控制验证,就跑去给 WSL2 编译带CONFIG_PREEMPT_RT补丁的 6.6.119 内核。这时问题会上升一个层级:因为 WSL2 本质是个虚拟机,实时性受限,很多人编译完自带 RT 补丁的内核后发现模块全都加载不上了,原因就是 RT 补丁会改变内核的 vermagic 中的抢占标志。解决方案有两种:
- 坚持用 RT 内核:所有模块都在 RT 内核源码树下编译,不要混用微软内核的模块。
- 降级到非 RT 定制内核:把 IGC 和 EtherCAT 相关驱动直接编进内核(
CONFIG_IGC=y),这样根本不需要外部模块,也就没有版本不匹配的问题。
下面这个表格可以帮你快速选型:
| 使用场景 | 推荐方案 | 备注 |
|---|---|---|
| 外围自研模块 | 官方内核 + 对应 tag 源码 | 需重新编译模块 |
| 内核特性定制 | 自编译内核 + 同源码模块 | 保持单一源码树 |
| 长期内核升级 | DKMS 管理模块 | 自动化重编 |
| 实时控制验证 | 内核内置驱动 | 避免外部模块依赖 |
4. 实操:让一个内核模块从"报错"到"正常加载"
文字堆再多不如跑一遍流程。接下来我完整演示一遍,在一个微软官方内核的 WSL2 环境中,从零准备编译环境到成功加载一个自定义模块。
4.1 准备与当前内核匹配的构建环境
我的当前内核是6.6.66.6-microsoft-standard-WSL2。第一步,确认模块树情况:
uname -r ls /lib/modules/$(uname -r)/build第一次大概率会提示没有这个目录,需要去拉内核源码并做modules_prepare。这里有个小技巧:不用每次从零git clone完整仓库,因为 WSL2-Linux-Kernel 仓库很大。用--depth 1和精确 tag 能省不少时间。
cd ~ git clone --depth 1 --branch linux-msft-wsl-6.6.66.6 https://github.com/microsoft/WSL2-Linux-Kernel.git cd WSL2-Linux-Kernel cp Microsoft/config-wsl .config make olddefconfig make modules_prepare sudo ln -sf ~/WSL2-Linux-Kernel /lib/modules/$(uname -r)/buildmake modules_prepare这一步做的事情是生成模块编译需要的头文件、Module.symvers和scripts目录中的工具。不需要完整编译内核,所以时间比make bzImage短得多。如果你的内核源码版本和当前运行内核版本完全一致,这步执行完构建环境就绪。
4.2 写一个最小的测试模块
构建环境准备好之后,写一个最简单的 hello 模块验证链路。
// hello.c #include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { printk(KERN_INFO "hello: module loaded, kernel aligned\n"); return 0; } static void __exit hello_exit(void) { printk(KERN_INFO "hello: module unloaded\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Minimal test module for WSL2");Makefile 写标准模板:
obj-m := hello.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean然后执行make。如果构建环境对版本,应该能看到hello.ko正常生成,没有任何 vermagic 预警。
4.3 加载验证和排错
模块编译完成后,按顺序执行:
sudo insmod hello.ko lsmod | grep hello sudo rmmod hello dmesg | tail如果看到类似:
hello: module loaded, kernel aligned hello: module unloaded说明整个链路是通的,内核版本和模块版本完全对齐。
如果insmod依然报Exec format error,不要慌,按排查链路重新走一遍:
dmesg | tail -20 modinfo hello.ko | grep vermagic报错信息里会明确告诉你加载时的 vermagic 期望值和这个模块的实际值。如果两个值不一样,建议优先确认KDIR指向的源码树 tag 是否和uname -r完全一致,别只靠软链接名称猜测。一个非常隐蔽的问题就是软链接指向了名字正确的目录,但里面其实是另一个 tag 的源码。
4.4 后续拆卸与清理
验证完成后,可以保留这个 hello 模块作为环境自检工具,也可以直接清理:
make clean sudo rm -f /lib/modules/$(uname -r)/build这里我不建议删掉/lib/modules/$(uname -r)/build,因为之后你还可能编译其他模块。保留这个软链接,下次直接写个 Makefile 就能用。
5. 版本对齐之外的坑,我在实际项目里踩过的
5.1 千万别忽略编译工具链的版本影响
内核模块对编译器版本非常敏感。如果你用 GCC 13 编译模块,但当前内核是用 GCC 12 构建的,有时即便版本魔法一致也会出现奇怪问题。WSL2 里尤其容易遇到,因为 Ubuntu 22.04 默认 GCC 11,Ubuntu 24.04 默认 GCC 13,而微软内核的官方构建环境可能用的是另一套工具链。要避免这类问题,编译模块前先看下内核源码的Makefile顶部注释和scripts/gcc-plugins相关信息,尽量保证主版本号接近。遇到可疑问题可以在 Makefile 里临时加HOSTCC和CC指定编译器试试。
5.2 .wslconfig 里自定义内核路径的坑
如果你用了自编译内核,一定要通过/mnt/c/Users/<用户名>/.wslconfig指定内核文件路径。这个文件配置是这样写的:
[wsl2] kernel=D:\\wsl-kernels\\bzImage-6.6.119-rt路径里必须是 Windows 绝对路径,而且要用双反斜杠。我见过有人把路径写成了D:/wsl-kernels/bzImage,结果 WSL2 启动时静默使用默认内核,导致uname -r和自己预期不一致,进而引发模块错乱。每次改完.wslconfig,都需要在 PowerShell 里执行wsl --shutdown再重新进入,否则不生效。
5.3 别让 Windows 端的 wsl --update 打乱节奏
wsl --update这个命令很好用,但它会直接升级微软官方内核。如果你已经按某个内核版本编译好了一批模块,结果手贱执行了更新,新内核起来后模块全废。我的做法是:要么用wsl --update --web-download时保持谨慎,要么干脆锁定自编译内核,并在.wslconfig中显式指定,这样 Windows Update 不会干扰你的内核版本。自编译内核的启动选项也不受--update影响,因为它直接读你指定的 bzImage 文件。
5.4 处理头文件不存在时,别把源码目录和构建目录搞混
/lib/modules/$(uname -r)/build本质是一个符号链接。如果你自己编译过内核并执行了make modules_install,系统会自动创建这个链接。但如果你只是拉了源码并执行了make modules_prepare,系统并不会自动创建链接,你需要手工建立。很多新手在这一步直接把/lib/modules/$(uname -r)/build软链接到了源码根目录,却忘了.config是否已经配置正确。如果make modules_prepare之前没有执行make olddefconfig,那么Module.symvers和autoconf.h可能是不完整的,编译模块时会出现各种离奇的 implicit declaration 报错。
5.5 EtherCAT/IGC 场景中的额外经验
最后专门说说 6.6.119 内核 + EtherCAT + IGC 这套组合。WSL2 里跑 EtherCAT 主站本身是一个验证性玩法,硬件实时性确实不如 bare metal,但做协议验证、拓扑调试和功能开发完全够用。问题在于:很多人从 6.6.63 升到 6.6.119 后直接modprobe ec_master就报版本魔法错误。如果你不想每次都重编模块,我建议把 EtherCAT 主站的ec_master.ko、ec_igc.ko注册进 DKMS,而不是手工 insmod。同时,IGC 驱动最好直接从内核源码里编成模块,并和主站模块一起放进 DKMS 列表。这样以后每次升级内核,只需两分钟让 DKMS 跑一遍重编译,不用再手动做版本对齐。
5.6 个人维护习惯上的建议
踩过几次坑之后,我现在维护 WSL2 内核模块环境遵循三个原则:第一,非必要不自编译内核,微软官方内核足够应付多数场景;第二,如果必须自编译,就固定 tag 并写一个环境变量脚本记录内核版本和源码路径,避免临时翻找;第三,所有自定义模块一律进 DKMS,再由/etc/modules-load.d/配置开机加载,彻底告别手工 insmod 和“版本不一致”。这套思路在个人项目和团队协助里都验证过,能省掉大量重复排错时间。
最后再分享一个平时排查会用到的实用技巧:判断 WSL2 里能不能编译内核模块,不用真的去写代码,直接看/lib/modules/$(uname -r)/build是否存在,以及该目录下Makefile的首行内容是不是你预期的内核版本。如果这个目录干净、版本正确,那make -C /lib/modules/$(uname -r)/build M=$(pwd) modules基本不会出幺蛾子。保持内核源码树、构建目录、实际运行内核三者始终指向同一个版本,是解决 WSL2 内核模块版本不一致问题的终极心法。