☰
嵌入式Linux内核移植实战:基于i.MX6ULL的配置、编译与设备树启动
2026/9/29 1:46:08 网站建设 项目流程

先回答一个很多人第一次听到“内核移植”都会问的问题:内核移植是不是要从零写一个 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 本身或者你的环境有问题,别急着往下走;如果成功了,记得归档第一次成功的产物和日志,后面所有改动都基于这个“已知可用”的基线来做。这个习惯能让你在后面的开发里,始终有一个清晰的参考点和回退路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询