先回答一个很多人第一次听到“内核移植”都会问的问题:内核移植是不是要从零写一个 Linux 内核?还真不是。芯片厂商和开发板厂商通常已经帮你做好了 80% 的底层适配,你要做的是把这一份“为某款 CPU 平台定制好的内核工程”拿到自己的板子上,改配置、裁剪、重新编译,让它能稳定启动、外设可用、能跑你的业务程序。这个过程中你会接触到交叉编译、设备树、defconfig、U-Boot 启动参数、根文件系统挂载等一整套嵌入式 Linux 开发的基础操作。
我这次用的平台是一块基于 NXP i.MX6ULL 的 LV12 开发板,核心板加底板的结构,属于嵌入式 Linux 学习里非常典型的入门级硬件。i.MX6ULL 是 Cortex-A7 单核处理器,主频 800MHz 级别,资源不算奢华,但是做 Linux 内核移植、驱动开发、项目原型验证绰绰有余。整个移植过程我整理成了这一篇笔记,记录的是“LV12 linux 内核移植”这个系列第 10 期的完整过程:从源码准备、环境搭建,到内核配置、编译、下载启动,再到常见问题排查。如果你是刚开始接触嵌入式 Linux 的开发者,这篇内容可以帮你省下不少翻论坛和手册的时间。
1. 移植前先弄清三件事:移植的本质、平台选型、环境准备
1.1 到底在移植什么
“内核移植”四个字听起来像是一项浩大的工程,但放到现代嵌入式 Linux 开发里,它的核心动作其实是配置和编译。Linux 内核是一个体积很大的软件项目,它通过 Kconfig 和 Makefile 这套机制,允许你在编译之前先通过配置文件决定哪些功能要、哪些功能不要,然后针对你所选的 CPU 架构和具体板卡生成对应的二进制镜像。
站在 ARM Linux 的角度,一次完整的内核移植通常包含这几个部分:
- 架构级适配:确定 CPU 架构、ARM 指令集、大小端、页大小等基础参数。
- SoC 级适配:针对具体芯片(这里就是 i.MX6ULL)初始化内存、外设控制器、中断控制器、时钟树等。
- 板级适配:通过设备树(Device Tree)描述这块板子用了哪些外设、哪些引脚、哪些时序参数。
- 驱动配置:在内核配置菜单里选好需要的驱动(网卡、串口、LCD、触摸、SD 卡等),编译成内置或模块。
- 启动与启动参数:让内核能在 U-Boot 引导下找到根文件系统并完成挂载。
你不需要、也不可能从零开始写一个能跑的内核。NXP 官方维护的 Linux BSP 已经把 i.MX6ULL 的大部分底层代码写好了,你的任务是“选择一个对的起点,然后把它做减法改造成你的需求”。
1.2 为什么选厂家 BSP 而不是主线内核
很多新手会纠结:到底是用 Linux 主线内核(kernel.org 上的 mainline),还是用芯片厂商提供的 BSP 内核?我个人的建议是,在 LV12 这种学习板上,直接用开发板和芯片厂商配合好的 BSP 内核,不要轻易去碰主线内核。
原因很现实。主线内核版本迭代快,对具体某款开发板的支持取决于提交补丁的时间和维护活跃度。i.MX6ULL 在主线里确实有基本支持,但部分外设驱动(比如某些 LCD 屏的时序、PHY 芯片的复位逻辑、板载 codec 的上下电顺序)可能没有人专门维护,需要你自己去填坑。而 BSP 内核是 NXP 和板卡厂商在产品上验证过的版本,比如我们用的 4.1.15 内核,配合 NXP 提供的交叉编译工具链,编译出来就能跑的概率非常高。
用 BSP 的另一个好处是生态和学习资料多。网上大量 i.MX6ULL 相关教程、驱动例程,基本都是基于 3.x/4.x 内核的,照着做不容易卡住。你先把 BSP 这套流程跑通,后面如果真有性能或安全需求再移植主线内核,那时候你的基础已经足够应付差异了。
1.3 环境准备:交叉编译器不是越新越好
内核移植第一步不是解压源码,而是准备开发环境。我用的宿主机是一台安装了 Ubuntu 22.04 的虚拟机,分配了 4 核 CPU 和 8GB 内存,编译 4.1.15 内核大概需要 5 到 10 分钟,完全可以接受。
交叉编译器选择几个关键点:
- 版本匹配:老内核对应老工具链,新内核对应新工具链,这不是玄学,而是因为内核代码和编译器之间的兼容性有明确约束。4.1.15 内核我推荐 arm-linux-gnueabihf-gcc 4.9.4 或相近版本,实测编译过程中基本不会出现因为编译器太新而导致的奇怪报错。
- 前缀统一:建议把交叉编译器的 bin 目录加入 PATH,然后在编译时通过环境变量指定前缀。最稳的方案是写进一个环境变量脚本,每次打开终端先 source 一下。
- 32 位库:老版本的工具链经常是 32 位程序,Ubuntu 22.04 上需要安装对应兼容库,否则会提示“cannot execute binary file”。
环境准备好之后,我习惯先跑一遍arm-linux-gnueabihf-gcc -v确认编译器可执行,再解压内核源码。这一步看起来简单,但很多人第一次编译失败,恰恰是交叉编译器没配好、直接调用了本机 gcc 导致的。内核顶层 Makefile 里的CROSS_COMPILE一旦留空,它会默认去用 x86 的 gcc,编译 ARM 内核必然报一堆难以理解的错误。
2. 核心文件逐个拆解:Makefile、defconfig、设备树
2.1 内核源码顶层 Makefile:两个变量定乾坤
拿到内核源码后,第一件要改的文件就是顶层 Makefile。打开文件,在开头附近找到:
ARCH ?= $(SUBARCH) CROSS_COMPILE ?= $(CONFIG_CROSS_COMPILE:"%"=%)默认情况下这两个变量是空的或者继承宿主机架构,交叉编译 ARM 内核时必须改成:
ARCH ?= arm CROSS_COMPILE ?= arm-linux-gnueabihf-有的教程会告诉你直接在命令行里传参,比如make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-,这样当然也行,但每次敲命令都写这一串比较累,而且容易漏。我的习惯是直接改 Makefile,让这两个变量固定下来。反正这个内核源码工程就是给 ARM 开发板用的,改死反而更不容易出错。
这里要顺带解释一下ARCH=arm的作用。Linux 内核源码里针对不同 CPU 架构有独立的目录,arch/arm/、arch/x86/、arch/arm64/等等。ARCH变量就是告诉 Kconfig 和 Makefile 去读哪个架构下的配置和编译规则。CROSS_COMPILE则是给所有编译动作加一个前缀,让编译系统调用的是交叉编译器而不是本机 gcc。
2.2 defconfig 到底怎么选
配置内核有两种主流方式:一是make menuconfig图形化界面手工勾选,二是直接使用厂商预先写好的默认配置文件,即 defconfig。对于 LV12 这款 i.MX6ULL 板子,NXP BSP 里已经提供了很多现成的 defconfig 文件,常见的有imx_v7_mfg_defconfig、imx_v7_defconfig。两者区别不大,前者启用了制造模式(MFG)相关支持,用于厂内烧录场景,后者是通用配置。学习开发一般用imx_v7_defconfig就行。
我第一次做内核移植的时候,对 defconfig 的理解有个误区:以为 defconfig 是一个完整的、最终版的配置。实际上它只是“最小起点”,里面只启用了默认项,很多板级具体外设的支持需要后续通过 menuconfig 补充,或者通过设备树描述后由内核自动发现。
执行配置的命令:
make imx_v7_defconfig这一步会生成.config文件,也就是真正的内核配置文件。执行完可以打开.config看看内容,里面有大量的CONFIG_XXX=y/m/n选项,y 表示编译进内核镜像,m 表示编译成模块,n 表示不编译。后面的 menuconfig 操作改的就是这个文件。
2.3 dts 设备树:管引脚、管外设、管启动
设备树可以说是 ARM Linux 内核移植里最容易迷糊、也最关键的部分。它的本质是一种描述硬件信息的数据结构,用来替代早期 ARM Linux 里充斥内核源码的“板级文件”。以前每出来一款开发板,就要往内核里加一个 board 文件,在新内核里,这些信息被统一放到.dts文件里,由设备树编译器dtc编译成.dtb二进制文件,再由 U-Boot 启动时传递给内核。
LV12 这块板子的设备树文件可以在 BSP 的arch/arm/boot/dts/目录下找到,名字一般是imx6ull-14x14-evk.dts或者开发板厂商自定义的名字。设备树里重点看这几个节点:
- cpu:CPU 配置,一般不需要动。
- memory:内存起始地址和大小,开发板如果改了 DDR 容量要同步修改。
- ** chosen**:启动参数传递,包括
bootargs中 console 串口设置。 - iomuxc:引脚复用配置,随处可见的
MX6UL_PAD_GPIO1_IO01__GPIO1_IO01之类就是在这里定义的,用来把芯片引脚复用为 GPIO、UART、I2C 等功能。 - &uart1、&usdhc1、&fec1这类引用节点:通过
pinctrl和具体外设绑定,配置波特率、时钟、中断等。
刚开始看不懂设备树很正常,我建议的学法是“按需去改”:屏幕不亮,就去找 LCD 相关的节点;网卡不通,就找 FEC 以太网的节点;按键不稳定,就找 GPIO 中断的配置。有目标地改,比从头到尾通读设备树规范高效得多。
3. 手把手编译与下载验证流程
3.1 第一次编译:先烧掉所有坑
当你改好了 Makefile、选好了 defconfig、也检查了设备树后,就可以开始编译了。完整的编译命令如下:
make -j4 zImage make dtbs分开执行比一次性make更直观,因为make默认会编译所有架构需要的镜像格式,包括 zImage、uImage、modules 等,新手会遇到杂七杂八的路径问题。先单独编译 zImage 能让你集中处理内核本体的问题,编译 dtbs 则是把设备树源文件编译成二进制文件,这个过程很快。
编译过程中常见的第一类错误是头文件找不到,比如:
error: asm/bitsperlong.h: No such file or directory这个基本就是交叉编译器没匹配好,或者你的工具链里的include路径有问题。可以检查一下make时的CROSS_COMPILE变量是否生效。
第一类容易踩的坑是编译版本不匹配,内核源码里有的文件依赖较老的 GNU 扩展语法,新版 GCC 会报multiple definition或隐式函数声明错误。我曾经用 GCC 9 编译 4.1.15 内核,出现了大量error: implicit declaration of function,换回 4.9.4 之后一遍过。所以如果你用的也是老 BSP 内核,编译器版本一定不要追求新。
编译成功后,在arch/arm/boot/目录下会生成zImage,在arch/arm/boot/dts/目录下生成对应的.dtb文件。这两个文件就是内核移植的主体产物。
3.2 网络下载启动:TFTP/NFS 三分钟跑起来
内核编译出来之后,接下来要验证它能不能在板子上启动。常见的方式有三种:SD 卡启动、EMMC 烧录启动、网络启动。这里我强烈推荐网络启动,尤其是在调试阶段。
网络启动的总体思路是:U-Boot 已经跑起来并初始化了网络硬件,然后通过 TFTP 协议从宿主机下载zImage和.dtb到内存指定地址,再通过 NFS 挂载根文件系统。这样每次改内核只需要重新编译并在宿主机更新 TFTP 目录里的文件,不用反复拔插 SD 卡,也不用反复烧写 EMMC,调试效率高出好几倍。
我的宿主机环境大致这样准备:
- TFTP 服务:使用
tftpd-hpa,根目录设为/tftpboot,把zImage和.dtb拷到这个目录。 - NFS 服务:使用
nfs-kernel-server,导出一个目录作为根文件系统,比如/home/xxx/rootfs。 - 开发板和宿主机联网:同一局域网,板子 IP 设为
192.168.1.110,宿主机 IP 设为192.168.1.10。
U-Boot 环境变量可以这样配置:
setenv ipaddr 192.168.1.110 setenv serverip 192.168.1.10 setenv bootcmd 'tftp 0x80800000 zImage; tftp 0x83000000 imx6ull-lv12.dtb; bootz 0x80800000 - 0x83000000' setenv bootargs 'console=ttymxc0,115200 root=/dev/nfs nfsroot=192.168.1.10:/home/xxx/rootfs,proto=tcp rw ip=dhcp' saveenv boot解释一下几个细节。地址0x80800000和0x83000000是 i.MX6ULL 上约定俗成的内存加载地址,前者装内核镜像,后者装设备树,不能随便乱用,否则可能和 U-Boot 自身占用的内存重叠导致启动失败。bootz是启动 zImage 的命令格式,镜像和 dtb 之间用-占位,表示没有 initramfs。
如果一切顺利,串口会输出内核启动日志,最终进入文件系统 shell。如果卡在Starting kernel ...,大概率是 dtb 和内核不匹配,或者内存地址配置有问题;如果文件系统挂载失败,重点查 NFS 配置和网络连接。
3.3 从内核到驱动:一个最小模块的编译与安装
内核启动验证通过之后,建议马上做一个小实验:编译一个最小内核模块。这一步的意义在于验证内核源码的模块编译环境是否完整,同时也为你后续真正的驱动开发打通流程。
模块代码很简单,写一个mytest.c:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> static int __init mytest_init(void) { printk(KERN_INFO "mytest module init\n"); return 0; } static void __exit mytest_exit(void) { printk(KERN_INFO "mytest module exit\n"); } module_init(mytest_init); module_exit(mytest_exit); MODULE_LICENSE("GPL");对应的 Makefile:
obj-m := mytest.o KERN_DIR := /home/xxx/linux-imx-rel_imx_4.1.15_2.1.0_ga PWD := $(shell pwd) all: make -C $(KERN_DIR) M=$(PWD) modules clean: make -C $(KERN_DIR) M=$(PWD) clean注意KERN_DIR必须指向已经编译过、并且make modules_prepare已完成的内核源码目录。执行make后会生成mytest.ko。把它拷贝到板子的文件系统里,执行insmod mytest.ko,用dmesg | tail就能看到打印信息,rmmod mytest则能卸载模块。
这一步能跑通,说明你的内核源代码树、模块编译机制、文件系统环境都是健康的。后面开发串口驱动、GPIO 驱动、SPI 驱动,流程框架都是这样,无非是把file_operations结构体里的read/write等接口函数实现得更完整。
4. 移植后的板级适配与驱动验证思路
4.1 设备树改动如何影响外设:以 LCD 和网络为例
内核能启动只是第一步,板子上的外设能不能全部工作,才是移植工作量的重头戏。LV12 这类开发板常见的适配目标包括 LCD 显示、电阻/电容触摸、以太网、SD 卡、USB Host、音频 codec 等。
拿 LCD 来说,设备树里通常要关注三个层面的信息:
- pinctrl:引脚复用设置为 LCD 的 RGB 数据线、行场同步信号、时钟和使能信号。
- display-timings:单屏的像素时钟、水平/垂直前肩后肩、同步极性等参数。
- backlight:背光 GPIO 的极性、PWM 通道等。
如果屏幕不亮,显示驱动本身一般没问题,多数是这几项配置和实际屏的规格书对不上。比如像素时钟设置过快,屏幕会闪或不同步;同步极性反了,会画面偏左或偏下。我的经验是拿到一块新屏,先从规格书里把 timings 参数逐个填进设备树,然后通过调整clock-frequency和hback-porch/vback-porch来微调显示效果。
网卡适配同样是高频任务。i.MX6ULL 的板载网卡通常是内部 MAC 加外部 PHY 芯片的结构,设备树里需要指定使用哪个 MDIO 总线、PHY 的地址、复位 GPIO、中断触发方式等。常见的网络不通问题,很多不是驱动代码问题,而是phy-mode配置错误,比如实际硬件是 RGMII 但你配成了 RMII,或者 PHY 复位 GPIO 的上下电时序没对齐。
4.2 一个实用的驱动验证方向:拦截文件读写操作
不少人在学完基础的内核移植后,会想做一些真正“有业务价值”的驱动实验。结合 Linux 驱动开发的热门方向,我建议可以尝试写一个基于file_operations的字符设备驱动,用自己的 read/write 逻辑去覆盖系统调用,从而拦截应用程序对某个文件的操作。这也是很多安全类、监控类项目里常见的技术思路,纯私有实现和名称为“xx防护/xx审计”的软件里都有类似的影子。
实现上并不复杂。你只需要注册一个字符设备,在file_operations结构体里实现专属的.read和.write方法,然后在其中加入自己的统计、过滤或日志记录逻辑。比如你想统计某个进程对/dev/mychardev的读写次数,可以这样写核心逻辑:
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { // 这里可以做读写次数统计、数据镜像等操作 return 0; } static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { // 这里可以记录写入的数据,或者决定是否放行 return count; } static struct file_operations my_fops = { .owner = THIS_MODULE, .read = my_read, .write = my_write, };这个实验的价值在于让你真正理解 Linux 应用层系统调用和内核驱动之间的对应关系:应用调用open/read/write/close,内核通过 VFS 层找到对应驱动的file_operations回调函数。移植内核之后,你手里已经有了一个能跑的系统,这两者连起来理解,整个知识链条就通了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
整理了一份内核移植过程中的高频问题表,都是我在同型号板子上以及帮别人排查时遇到过的问题。
| 现象 | 大概率原因 | 处理建议 |
|---|---|---|
编译报错找不到asm/bitsperlong.h | 编译器没走交叉编译,或工具链 include 路径错误 | 检查 ARCH 和 CROSS_COMPILE,确认编译器路径 |
| 编译报错隐式函数声明 | 编译器太新,与老内核不兼容 | 换 4.9.x 版本交叉编译器 |
启动卡在Starting kernel ... | dtb 与内核不匹配,或加载地址不对 | 检查内存地址和 dtb 文件,重新生成 dtb |
| 内核起不来,串口无输出 | U-Boot 引导参数错误,或内核 console 参数错 | 检查console=ttymxc0与实际串口对应关系 |
| 文件系统挂载失败 | NFS 导出目录权限,或root=/dev/nfs参数错误 | 宿主机导出目录加no_subtree_check,insecure |
| 屏幕白屏或不显示 | 设备树时序不对或引脚复用错误 | 核对屏规格书和 pinctrl 配置 |
| 网卡 ping 不通 | PHY 地址或 phy-mode 配置错误 | 查看 PHY 芯片手册,核对设备树 PHY 节点 |
模块加载Invalid module format | 模块编译用的内核源码和当前运行内核不一致 | 用当前内核源码重新编译模块 |
| 内核启动到一半挂死 | 内存配置错误,或驱动 probe 时访问了无效地址 | 检查内存大小、设备树 reg 属性 |
5.2 一点个人排查习惯
做内核移植调试久了,我养成了几个习惯,对提升排查效率帮助很大。
第一,日志分级。串口是内核移植初期最重要的调试手段,务必把console=参数设对,并且养成看完整启动日志的习惯。有的问题在日志前面几行就有提示,只是很多新手只盯着最后的 panic 信息。
第二,小步快跑。不要指望一次修改一大片配置然后启动成功。改一次设备树或内核配置,只动一个点,然后编译、下载、验证。这样出了问题很容易定位是哪一个改动引起的。
第三,文件系统从 NFS 开始。能用 NFS 就不用 SD 卡启动,能少烧写就少烧写。等内核稳定下来,功能验证得差不多了,再做固化到 EMMC 的步骤。这一步能帮你节省大量重复时间。
第四,备份每一步可用的成果。内核源码树、设备树、U-Boot 环境变量、配置文件这些,建议在测量验证通过后及时保存并备注。后面的工作很可能在某个改动后变得不可用,你还能回退到上一版恢复现场。
6. 经验与思考:移植只是开始,不是终点
内核移植跑通之后,很多人会有一种“任务完成”的感觉,但我个人的体会是:真正有价值的开发工作,其实是内核移植之后才开始的。你自己确认过设备树、搭建过启动流程、验证过驱动模块,这些经验会让你在后续开发驱动、定位硬件问题上少走很多弯路。
这个阶段我建议延伸做几个练习:
- 试着在同样的 BSP 源码下,换一块不同尺寸的 LCD 屏,改动设备树并成功点亮。
- 试着把一个简单的 GPIO 按键驱动从静态编译改成模块加载,模拟真实驱动开发流程。
- 试着读一遍
imx6ull.dtsi和imx6ull-14x14-evk.dts,把里面主要的节点和板子上的硬件对应起来。
你在这一阶段积累的“板子硬件对应关系”和“内核编译调试习惯”,比单纯记住几条命令有用得多。看多了之后你会发现,所谓移植,就是弄清楚这三件事:内核如何知道这块板子长什么样(设备树),内核如何被加载(U-Boot 与 bootargs),内核如何把外设驱动跑起来(defconfig 与驱动)。后续无论是换芯片平台、换开发板、还是升级内核版本,只要抓住这三点,你都能快速上手。
最后再给大家一个小技巧:每次拿到一个新的 BSP 内核,先不要急着改配置,第一时间用厂商默认的 defconfig 和默认的设备树完整编译一次,并尝试启动。如果这一步就失败,说明 BSP 本身或者你的环境有问题,别急着往下走;如果成功了,记得归档第一次成功的产物和日志,后面所有改动都基于这个“已知可用”的基线来做。这个习惯能让你在后面的开发里,始终有一个清晰的参考点和回退路径。