ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零开始Linux内核移植:硬件摸底、设备树与启动调试全攻略

从零开始Linux内核移植:硬件摸底、设备树与启动调试全攻略 1. 拿到新板子先别急着敲命令硬件摸底才是内核移植的真正起点做 Linux 内核移植最忌讳的一件事就是拿到一块新板子之后直接make menuconfig然后开始盲改配置。内核移植的本质不是把代码编过而是让 Linux 在你这块具体的硬件上正确认识自己。所以第一步不是打开终端而是打开芯片手册和原理图。以最常见的 Cortex-A 系列 SoC 为例你在移植之前需要搞清楚四件事CPU 架构ARMv7 还是 ARMv8、内存映射DDR 起始地址和大小、串口控制器地址早期调试输出全靠它、中断控制器类型GIC 版本。这四件事如果错了后面的所有工作都无从谈起。串口地址尤其关键因为内核启动早期还没有设备驱动模型串口输出完全依赖底层代码中硬编码的寄存器地址地址填错了内核就像一块砖头你根本不知道它跑到哪里去了。我见过不少新手拿到一个项目第一反应是去网上找一个相近的开发板配置然后直接改个板级文件就开始编译。这种做法在十年前可能还能碰运气放到现在各种 SoC 的差异化越来越大几乎必挂。正确做法是花半天时间仔细看三份资料SoC 芯片手册的 Memory Map 章节、核心板原理图的 DDR 和电源部分、底板原理图的串口和网口部分。这三份资料看完了你对这块板子的脾气基本就有数了。还有一个很容易被忽略的动作确认你到底需要哪个内核版本。芯片厂商的 BSP 通常会指定一个大版本范围比如 4.1 或者 5.4 或者 6.1。如果你的项目不是必须追新最好选一个长期维护版本而不是最新版。原因很朴素老版本的资料多、坑少、编译工具链好配新版本虽然功能强但你在移植阶段用不上那些新功能反而要考虑各种编译器和内核 API 变化带来的兼容问题。硬件摸底这一步做完真正的移植工作才刚开始。但我要说这一步省下来的时间会在后面十倍百倍地还回来。1.1 从零开始的信息收集清单我把移植前需要收集的信息整理成了一份清单照着做基本不会漏SoC 型号与内核版本精确到具体型号比如 i.MX6ULL、STM32MP157、全志 H3。不同型号的同一系列外设地址也可能不同。CPU 架构与字节序Cortex-A7/A9/A53 都是 ARMv7/ARMv8 架构小端模式确认你的工具链是否匹配。DDR 起始地址与大小通常芯片手册的 Memory Map 里有明确的地址区间比如 0x80000000 起始。调试串口信息串口基地址、中断号、使用的 UART 编号比如 UART1 还是 UART2、默认波特率。存储介质类型eMMC、SD 卡、NAND Flash、NOR Flash直接影响后续 rootfs 挂载和 bootargs 的设计。以太网 PHY 与 MAC 型号比如 LAN8720A、AR8031后面调网络驱动要用的。其他外设列表LCD 接口、触摸芯片、USB Host/Device、音频 Codec这些决定了你要开哪些驱动。这些信息在你后面的设备树编写、内核配置裁剪、U-Boot 启动参数设置中全部都要用到。我习惯的做法是建一个文本文件把这些信息一条一条记下来随时查阅。好记性不如烂笔头内核移植的中间状态太多了没有记录纯靠脑子大概率会在某个深夜怀疑自己是不是改错了什么。1.2 确认你的编译工具链是 32 位还是 64 位这是一个非常基础但异常关键的区分。ARMv7 架构比如 Cortex-A7/A9需要用 32 位工具链常见名称是arm-linux-gnueabihf-其中hf表示硬浮点ARMv8 架构比如 Cortex-A53/A55既支持 32 位也支持 64 位64 位工具链名称通常是aarch64-linux-gnu-。工具链选错的最直接表现是编译过程中报出大量无法识别的指令错误或者编出来的内核在启动时直接 Illegal instruction。注意32 位和 64 位不仅仅是位数问题ARMv8 在 64 位模式下使用 AArch64 指令集与 32 位模式下的 AArch32 指令集完全不兼容。如果你的板子是 A53 核想跑 32 位内核也可以但要确保 U-Boot 也是以 32 位模式启动的而且工具链必须是 arm-linux-gnueabihf 系列。这个判断千万不要想当然。我看过有人拿aarch64-linux-gnu-gcc去编 ARMv7 的板子折腾了两天最后发现是工具链架构不匹配。花五分钟确认gcc -v的输出能省下两天的排查时间。2. 交叉编译环境搭建的版本陷阱工具链和内核版本不是越新越好很多 Linux 内核移植教程会把环境搭建一笔带过好像装个工具链谁不会似的。但事实是环境搭建这一环节的坑一点都不比后面少。内核版本和工具链版本的匹配关系直接决定了你会不会在编译时遇到一堆莫名其妙的报错。先说结论GCC 10 以下的版本编译 Linux 4.x 和 5.x 系列比较稳妥GCC 11 及以上的版本编译老内核时经常会触发一些因为编译告警被提升为错误的问题。比如老的 4.1 内核用 GCC 12 编译-Werror相关的告警判定会让整个编译中断你得去改代码或者降编译器。这是最让人头疼的情况之一因为报错信息晦涩难懂新手根本想不到是 GCC 版本问题。工具链的选择优先级我建议是这样芯片厂商 SDK 自带的工具链排第一其次选 Linaro 出品的工具链再其次才是 Ubuntu 源里直接apt install的版本。为什么因为芯片厂商出厂前做 BSP 测试用的就是那套工具链兼容性最好Linaro 常年做 ARM 工具链的优化和适配口碑稳定。Ubuntu 源里的版本虽然方便但不保证和你手里的内核版本兼容。安装工具链之后第一件事是验证能不能用。用arm-linux-gnueabihf-gcc -v查看版本号再编译一个简单的 Hello World 测试程序用file命令确认生成的二进制是 ARM 架构的。这个验证动作看起来多余但它能一次性排除工具链安装失败、环境变量没配对、交叉编译模式没生效三个问题。2.1 制作一个干净的工作目录和脚本化环境我强烈建议建立统一的工作目录结构而不是把源码到处乱放。比如mkdir -p ~/kernel-porting/{toolchain,kernel,uboot,rootfs,images,logs} cd ~/kernel-porting/kernel # 将内核源码解压或 clone 在这里 cd ~/kernel-porting/uboot # U-Boot 源码放这里然后再准备一个环境变量脚本env.sh内容大概这样export ARCHarm export CROSS_COMPILE~/kernel-porting/toolchain/bin/arm-linux-gnueabihf- export INSTALL_MOD_PATH~/kernel-porting/rootfs每次打开新终端就source env.sh一下。这样做的好处是你不需要每次都手敲ARCHarm CROSS_COMPILE...而且终端里的历史记录不会因为各种临时变量变得乱七八糟。我看过太多人把内核源码放在桌面、下载目录、临时目录这种地方每次重新开机路径都可能变然后编译脚本里路径写死导致各种 No such file or directory。这不是技术问题是管理习惯问题但它真的会浪费大量时间。2.2 内核版本源码获取方式内核源码的获取有三种常见方式从kernel.org下载官方 tar.xz 包、从芯片厂商的 GitHub/BSP 仓库拉取、从 Linux 内核官方 Git 仓库 clone。对移植任务来说我优先推荐芯片厂商 BSP 提供的内核因为里面通常已经包含了专门为这家 SoC 适配的 board 级文件、设备树源文件、驱动补丁这些是上游内核里不一定有的。如果不得不从上游内核自己适配要特别注意内核里已经存在的 defconfig 和设备树。比如上游内核自带了imx_v6_v7_defconfig和一堆imx6ul-*.dts这些就是你的起始参考不要从零开始写。找到最相近的配置在此基础上改比从头造轮子快得多。从 Git 仓库拉取时建议先切到该系列内核的长期维护分支比如linux-5.4.y分支而不是直接 checkout master。原因很明显master 的 API 变化太快今天能用明天可能就编译不过而长期维护分支相对稳定资料也多。3. 内核配置的本质是最小可启动从 defconfig 到 menuconfig 的有限裁剪内核移植的配置阶段最容易犯的错误是贪多——把网上找来的所有配置一股脑全开然后指望它一次启动成功。事实恰恰相反配置开得越多启动失败的排查范围就越大你根本分不清是内存初始化的问题、驱动的 panic 还是个别配置项导致的直接崩溃。我推荐的做法是目标是先跑出一个最小可启动内核。所谓最小就是这个内核能完成串口输出、中断处理、定时器、基本内存管理这四个核心任务再加一个存储介质驱动比如 eMMC 或 SD 卡驱动用于挂载 rootfs就足够了。其他所有外设驱动全部先关掉后面再一个一个开回来。这样每一步的变化都是可控的出了问题也能快速定位是哪个子系统引起的。具体配置时逻辑顺序是先找到 SoC 对应的 defconfig然后基于它做裁剪。defconfig 相当于一个推荐的默认配置它把该 SoC 的大部分板级通用的选项都打开了。你不需要从零make menuconfig慢慢勾选那会疯掉的。以 i.MX6ULL 为例你可以在内核源码目录里执行make ARCHarm imx_v6_v7_defconfig这条命令会生成一个基础的.config文件。然后你再执行make ARCHarm menuconfig打开图形化界面针对你的具体需求进行调整。菜单界面里可以搜索关键字比如直接输入/然后搜索 UART 就能找到串口相关的配置项非常方便。3.1 几个必须手工确认的关键配置项不管用什么 defconfig 起步有几项配置我建议你手工确认一下因为它直接关系到能否启动内核调试打印等级CONFIG_CONSOLE_LOGLEVEL_DEFAULT设为 7 或 8确保所有 info 级别的启动信息都能输出到串口。否则你会遇到启动到一半就沉默了但根本不知道它是在哪一步沉默的情况。Early printkCONFIG_DEBUG_LL CONFIG_EARLY_PRINTCONFIG这两个配置打开后内核在极其早期阶段就能通过串口输出调试信息。这是设备树还没解析、内存还没完全初始化时唯一的调试手段强烈建议开启。initramfsCONFIG_BLK_DEV_INITRD如果你没有现成的 rootfs建议先用 initramfs 跑一个最小的根文件系统出来验证内核本身能启动然后再切换到真实的存储介质 rootfs。DMA 内存区域大小CONFIG_VMSPLIT 或 CMA对于内存小于 512MB 的小型板卡注意 CMA 区域的大小预留太少会影响显示等连续内存需求大的驱动。这些配置项在 menuconfig 里搜索时的关键字各不相同比如 DEBUG_LL 有时候藏在 Kernel hacking 菜单下面新手不容易找到。但一旦找到并开启对你后续调试的帮助是巨大的。3.2 配置裁剪时如何识别必选配置和可选配置很多新手在裁剪内核时看不到底层的依赖关系纯粹是看着名字差不多就勾上。这里有一个基本逻辑内核配置项几乎都有自己的依赖关系一个配置项下方显示的depends on就是这个配置的前置条件。比如你要开某个网卡驱动前提是网络协议栈已经打开要开 LCD 驱动前提是 DRM 或 FB 子系统已经打开。这些依赖关系在 menuconfig 里会自动处理它会阻止你在不满足条件时打开某些项或者自动帮你打开依赖项。识别必选配置有一条捷径直接看设备树里你要使能的外设节点需要的驱动类型然后去内核源码的drivers/目录下搜索对应的 Kconfig。比如你板子上的串口控制器是 8250 兼容的那你就去drivers/tty/serial/8250/Kconfig看看 8250 的支持选项叫什么名字再去 menuconfig 里搜索确认。这个方法听起来慢但实际用起来非常高效而且能让你对内核源码结构变得非常熟悉。4. 设备树编写内核移植中最容易卡壳的黑盒子如果说硬件摸底是地基内核配置是框架那设备树就是连接框架和硬件的接线图。设备树写不对内核要么识别不到外设要么启动直接 panic。而设备树又不像 C 代码那样有清晰的编译报错语法正确但语义错误的情况极其常见。设备树的基本概念不需要背记住一句话设备树就是描述这块板子上有哪些硬件、硬件接在哪个地址、需要用什么驱动的数据结构。内核启动时读取这个文件把硬件信息和驱动匹配上然后完成初始化。一个最小可启动的设备树至少需要包含这些节点/根节点、cpusCPU 描述、memory内存布局、chosen启动参数、soc或直接挂在根下的各外设节点。以一款常见的 Cortex-A7 双核板子为例一个能跑起串口的最小设备树大概长这样/dts-v1/; / { model MyBoard; compatible vendor,myboard, vendor,soc; #address-cells 1; #size-cells 1; memory80000000 { device_type memory; reg 0x80000000 0x20000000; }; chosen { bootargs consolettymxc0,115200 root/dev/mmcblk0p2 rw; }; cpus { #address-cells 1; #size-cells 0; cpu0 { compatible arm,cortex-a7; reg 0; }; cpu1 { compatible arm,cortex-a7; reg 1; }; }; soc { compatible simple-bus; #address-cells 1; #size-cells 1; ranges; uart1: serial02020000 { compatible fsl,imx6ul-uart, fsl,imx6q-uart; reg 0x02020000 0x4000; interrupts 0 26 4; clocks uart_clk; clock-names uart; status okay; }; }; };上面这段代码已经非常简化了很多时钟和 pinctrl 节点都省略了。但你可以从中看出几个关键点compatible是驱动匹配的关键字reg描述寄存器的基地址和长度interrupts描述中断号clocks引用时钟节点。设备树不是随便写写就行的每个属性值的含义都有严格规定需要参考内核源码里的Documentation/devicetree/bindings/目录。这个目录里有每一类硬件节点的详细说明是编写设备树的第一手资料。4.1 dts、dtsi 与 dtc 编译的协作关系一个完整的设备树源文件通常由.dts文件和被它 include 进来的.dtsi文件组成。前者定义这个板子特有的东西后者定义这颗 SoC 共有的东西。比如imx6ull.dtsi里包含了对 CPU、中断控制器、各类外设控制器的完整描述而myboard.dts里只需要 include 它然后裁剪、修改、补充板级差异。编写设备树时不需要重复定义 dtsi 里已有的节点只需要用节点名的方式引用并修改属性即可#include imx6ull.dtsi uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; };这种组织方式的好处是 SoC 层面的定义可以跨板卡复用不用每个板子都复制一份庞大的公共描述。设备树写完后编译成.dtb文件使用的工具是dtc。在你编译内核时设备树会作为内核构建的一部分自动编译一般在arch/arm/boot/dts/目录下生成 dtb 文件。你也可以单独编译一个设备树make ARCHarm dtbs或者用dtc手动编译dtc -I dts -O dtb -o myboard.dtb myboard.dts调试的时候经常需要反向操作——把 dtb 反编译成可读的 dts看看实际加载的设备树长什么样dtc -I dtb -O dts -o dumped.dts myboard.dtb这个反编译操作在排查我明明改了设备树但内核行为没变这种问题时特别好用能直接确认 U-Boot 加载的到底是哪个 dtb。4.2 设备树里最常见的移植坑时钟与 pinmux如果让我给设备树移植的坑排个序时钟配置绝对是第一名pinmux引脚复用紧随其后。很多板子的外设驱动加载失败root cause 根本不是驱动本身的问题而是时钟没给对或引脚没复用好。时钟树的复杂性在于它是一套树状结构一个外设的时钟可能来源于多个父时钟的分频组合而设备树里给出的时钟引用必须和设备树中定义的 clock node 形成一致的拓扑关系。排查时钟问题最有效的方法是在内核启动日志中搜索clk关键字比如常见的 clk: failed to reparent mux 或者 clk: couldnt set clock rate。看到这类错误优先检查设备树里的clocks属性写的是不是正确的 clock phandle。pinmux 的问题则通常表现为寄存器读出来是 0xff 或者乱地址。排查方向是确认设备树中 pinctrl 节点里pinmux的配置值是否参考了芯片手册里的 IO MUX 章节。这个值每个引脚都不同填错一个 digit 硬件就是不通。遇到这类问题时最靠谱的参考不是网上零散的教程而是芯片厂商 SDK 里自带的设备树和 U-Boot 里的相关代码。比如 NXP 的 Linux BSP 里有完整的 dts直接对照着抄是最快的。抄的时候注意确认板级差异尤其是晶振频率、PHY 地址、GPIO 编号这些可能因板而异的参数其他内核相关的配置可以直接沿用。5. 编译、烧录与启动调试从 U-Boot 到内核的完整链路排查法设备树完成之后就是编译内核、生成镜像、烧录启动这一整套流程了。这一步看似流程化但每一环节都可能出问题而且问题往往不是孤立出现的可能是 U-Boot 参数写错导致内核启动失败也可能是 rootfs 类型和内核配置不匹配导致挂载失败。我习惯把启动流程的排查分成四个阶段来定位每个阶段都有明确的标志性输出阶段标志性输出如果失败排查方向U-Boot 阶段U-Boot 版本信息、板子信息U-Boot 是否编译正确、环境变量、SD/eMMC 烧录是否正确内核早期启动Starting kernel ... 后面的串口输出串口参数、early printk 是否正确、dtb 是否传递成功内核核心启动能看到 Booting Linux on physical CPU各种驱动初始化信息设备树匹配、内存布局、驱动 panicrootfs 挂载VFS: Mounted root 以及用户态启动rootfs 类型、bootargs 的 root 参数、存储介质驱动上面这个表格的用法是先看清启动当前停在了哪一阶段再针对性排查。千万不要一上来就怀疑内核配置有问题启动停在 U-Boot 阶段根本还没轮到内核登场呢。5.1 编译产物的含义与烧录位置内核编译完成后你关心的产物主要有三个zImage或者uImage、*.dtb设备和arch/arm/boot/下的内核镜像。其中 zImage 是压缩后的内核镜像uImage 是在 zImage 基础上加了一个 U-Boot 头部的版本两者二选一烧录即可。设备树的 dtb 文件和内核镜像通常是分开烧录、分开加载的。这里有一个我一直强调的细节U-Boot 通过bootz或bootm命令启动内核时需要显式指定内核镜像地址、dtb 地址和 initrd 地址。如果 U-Boot 配置中加载 dtb 的内存在地址上覆盖了内核镜像的加载地址启动就会异常。这种问题极其隐蔽因为 U-Boot 和内核都不一定会立刻报错但启动到一半就会出现随机的 panic 或系统崩溃。排查办法是检查 U-Boot 环境变量里的loadaddr、fdt_addr这些值确保它们在不同的内存区间比如内核放在0x82000000dtb 放在0x88000000不要重叠。5.2 Starting kernel 之后就没有输出的排查链路这是所有移植开发者最熟悉的噩梦U-Boot 打了 Starting kernel ...然后串口就安静了什么反应都没有。出现这种情况时先不要怪内核你需要按顺序排查一个五个环节的链路串口参数是否正确U-Boot 里 consolettymxc0,115200 这个参数既要看 ttymxc 编号是否和设备树里的 serial 节点一致也要看波特率是否是 115200。很多时候是 ttymxc0 和 ttymxc1 的编号错位导致内核的 printk 输出到了另一个串口。early printk 有没有打开如果 CONFIG_DEBUG_LL 和 CONFIG_EARLY_PRINTCONFIG 是打开的那理论上内核在解压完、极其早期的汇编代码阶段就能有输出。如果此时还没有输出问题极可能在设备树、内存初始化、或者内核镜像与 dtb 不匹配。dtb 是否真的被传给了内核U-Boot 的bootz 0x82000000 - 0x88000000命令中-表示不使用 initrd但 dtb 地址如果传成了 NULL 或者错误地址内核就不知道硬件长什么样。可以在 U-Boot 里用fdt addr 0x88000000、fdt print确认 dtb 是否在内存中正确加载。内存参数是否匹配如果你的板子是 512MB 内存但设备树里写的是 1GB内核会在启动早期初始化内存时直接卡死或 panic。确认设备树里的reg 0x80000000 0x20000000和你板子实际焊接的内存大小一致。内核解压是否成功zImage 是自解压的如果解压过程失败症状同样是完全没有输出。但这种情况比较少见通常伴随 U-Boot 的 CRC 校验错误。排查这五步能覆盖掉 90% 以上的内核启动无任何输出的场景。我自己的经验是绝大多数最终都定位到 dtb 地址不对或者设备树里内存大小填错了。5.3 rootfs 挂载失败的常见原因与 root 参数写法就算内核能正常启动到 VFS 阶段还有一个高频故障点rootfs 挂不上。这通常体现在日志里出现 VFS: Unable to mount root fs on unknown-block 或者 Kernel panic - not syncing: VFS: Unable to mount root fs。排查思路是看看 bootargs 里的root参数和你实际的存储介质是否匹配。eMMC 一般用/dev/mmcblk0p2SD 卡用/dev/mmcblk0p1或p2NAND 用root/dev/mtdblockXUSB 存储用/dev/sdaX。这个路径写错了内核根本找不到根文件系统。另外rootfstype 参数也经常被忽略。如果你的 rootfs 是 ext4但内核里没把 ext4 文件系统驱动编进去同样会挂载失败。在移植前期建议把 ext4、ext3、vfat 这几个常用文件系统直接编进内核而不是以模块方式避免在区分驱动没编和参数写错之间浪费时间。提示排查 rootfs 问题时第一个动作永远是往上翻日志找 Waiting for root device 之前的内核打印。它会明确告诉你内核尝试了哪些设备、为什么失败。6. 内核起来之后的工作驱动验证与后续扩展的正确打开方式很多教程写到内核可以正常启动、挂载 rootfs就结束了仿佛移植大功告成。但真实的项目里内核启动只是第一步后续的驱动验证、性能调优、功能扩展才是重头戏。启动完成后我建议按下面的顺序逐一验证基础驱动串口收发是否正常、网卡能不能 ping 通网关、存储读写速度是否正常、GPIO 控制、看门狗、RTC。验证每个驱动时先看内核启动日志里有没有对应驱动注册成功的提示再用实际工具测试功能。比如网卡用ifconfig和pingGPIO 用sysfs接口/sys/class/gpio/或新版内核的gpiod接口。这里我要重点提一下 file_operations 这个话题因为最近在热词里看到很多人搜内核动态加载 file_operations 拦截 read write。这说明内核移植完成之后很多人的下一个需求就是对内核的 IO 路径做拦截和定制。这种需求通常会以两种方式实现一种是在现有驱动里挂接 file_operations 结构体通过替换其中的 read/write 函数指针对数据流做处理另一种是使用内核安全相关的框架如 LSM在系统调用层做拦截。如果你的项目要往这个方向发展我的建议是先确保你对 Linux 设备模型和 VFS 层的数据结构足够熟悉再动手改驱动否则很容易把内核改得不稳定。6.1 性能验证与稳定性测试的基本动作驱动的功能通了并不意味着可以交差了。嵌入式项目里稳定性往往比功能更重要。我习惯用下面几个手段来做基本验证长时运行测试让板子连续跑 24-48 小时同时周期性记录系统和负载、内存占用、内核 log 里是否有异常。很多内存泄漏、驱动脏数据类问题需要长时间运行才能暴露。内存压力测试连续执行大量内存分配和释放操作确认内核的物理内存管理没有异常。常用工具是stress或者memtester。网络吞吐测试如果有网口用iperf3打一下吞吐量确认网口驱动没有因为描述符耗尽或中断处理问题导致性能大幅下降。串口长时间收发跑一个循环回环测试确认长时间串口收发没有数据丢失或缓冲区溢出。这些测试看起来非常基础但很多项目的内核移植完成其实只是内核能启动而已距离真正可以量产还差着十万八千里。6.2 我自己对内核移植这件事的体会做了这么多年嵌入式底层开发我越来越觉得内核移植本身其实是一个框架性工作它考验的不是你会不会敲命令、能不能编译过而是你对硬件运行逻辑、内核启动流程、设备树机制这三条线的综合理解能力。移植成功一次你对整个系统的认知会提高一个层次后面不管是做驱动开发、系统优化还是功能定制都会顺手很多。最后再说一个小技巧在整个移植过程中每次修改完代码或配置都把修改内容记录在一个文本里标注修改时间和原因。内核移植的过程经常会来回尝试不同的方案如果不记录你可能会在三天后忘了自己当初为什么把某个配置项改成了这个值。而这些记录在你以后做版本升级、维护项目、或者给别人交接时都是最宝贵的资产。
返回列表