嵌入式Linux开发从芯片到应用:交叉编译、设备树与驱动入门
2026/9/18 5:51:22 网站建设 项目流程

1. 从一块板子到跑起应用,中间到底隔了几层

刚接手一块嵌入式 Linux 开发板的人,八成都会经历这么一段:板子插上电,串口终端刷出一大屏启动日志,最后停在login:,你按照文档输了个 root,进去了。看起来挺顺利,但真让你改点东西,比如换个屏幕、加个传感器、改一下启动时自动跑的程序,立刻就懵了——文件在哪儿?改哪个?编译出来的东西为什么放到板子上就跑不动?这些问题背后,其实不是某个具体技术没学会,而是层次感没建立起来。

嵌入式 Linux 开发和常见的应用开发、后端开发最大的区别就在这儿:你面对的不是一个"操作系统已经装好、你只管写业务代码"的环境,而是从芯片上电的第一个字节开始,整条链路都归你管。PC 上跑程序,操作系统、驱动、文件系统都是现成的;嵌入式这边,这些东西得你自己配、自己裁、自己塞进去。所以基础概念理不清,后面每一步都是黑盒,出错只能靠猜。

这篇内容就是把这层"从芯片到应用"的地图铺开,把嵌入式 Linux 开发里那些绕不过去的概念——交叉编译、工具链、Bootloader、内核、设备树、根文件系统、驱动模型——按它们真实的工作顺序串一遍。适合同行里刚转过来的朋友,也适合做了几年单片机、想往 Linux 方向走的工程师。我不打算写成名词解释手册,而是按"这个概念为什么存在、它在启动流程的哪一环、动手时你会怎么碰到它"这个思路来讲,能动手的地方都给上命令行和代码。

1.1 一张分层表,先把位置感建立起来

嵌入式 Linux 系统从上到下大致可以切成五层,每一层都有自己的"产物"和"调试手段"。很多人学得吃力,就是因为把不同层的问题混在一起想。比如"屏幕不亮"这个问题,可能是内核没驱动、可能设备树里引脚配错、也可能只是应用层的显示程序没启动,层没分清,排查就是瞎撞。

层级核心职责典型产物常用调试手段
硬件层SoC、DDR、Flash、外设电气连接原理图、PCB、数据手册万用表、示波器、逻辑分析仪
Bootloader初始化 DDR、加载内核、传参u-boot.binSPLu-boot.img串口日志、md/mw内存读写
内核层进程调度、内存管理、驱动、协议栈zImage/Image*.kodmesg/proc/sys
根文件系统提供 shell、库、配置、启动脚本rootfs.ext4rootfs.tarlsstracedf
应用层业务逻辑、界面、通信你的可执行文件、脚本gdb、日志、top

这张表建议你打印出来贴墙上。遇到问题时先问自己一句:这属于哪一层?比如串口一个字符都不出,那问题必然在 Bootloader 之前的硬件或 Bootloader 本身,跟内核、根文件系统一点关系都没有;反过来,如果你已经能进 shell 但某个设备打不开,那大概率是驱动或设备树的问题,别去折腾 U-Boot。

另外一个容易混淆的点是:Bootloader、内核、根文件系统这三样东西,最终都是放在同一块存储介质上的。常见的 eMMC、NAND Flash、NOR Flash,甚至 SD 卡,都会被划分成若干分区,各自存放不同的镜像。分区的划分方式由 SoC 的启动 ROM 和 Bootloader 约定,比如很多 ARM 平台是这样切的:

# 典型分区布局(以 8GB eMMC 为例,单位按 512 字节扇区) # 0x000000 - 0x1FFFFF : Bootloader 区域(SPL + U-Boot) # 0x200000 - 0x3FFFFF : 设备树 dtb # 0x400000 - 0x9FFFFF : 内核镜像 # 0xA00000 - 0x1FFFFFF : 根文件系统

每个分区的大小不是随便定的。举个例子,内核镜像zImage通常 3~8MB,压缩后能到 2~5MB,所以给 6MB 是比较保险的;根文件系统如果是 BusyBox 裁剪版,20MB 就够用,但如果你把 Qt 库、字体、Python 解释器都塞进去,200MB 都不奇怪。做分区表之前先把自己要装的东西体积算清楚,留出 20% 余量,这个习惯能省掉很多次"刷完镜像起不来"的返工。

1.2 交叉编译:嵌入式开发绕不过去的第一道坎

写嵌入式 Linux 代码,你在电脑上敲的编译命令,生成的却不是电脑能跑的程序,而是给板子跑的。这就是交叉编译,它是整个领域最基础也最容易出岔子的概念。

为什么不能直接在板子上编译?原因很朴素:一块典型的嵌入式板子,CPU 主频几百兆到一两个 G,内存 128MB 到 1GB,存储可能就 8GB eMMC。而编译一个 Linux 内核,用 GCC 跑一遍动辄需要几个 G 内存和几十 G 磁盘空间,耗时几十分钟到几个小时。板子根本扛不住这种负载,就算勉强跑起来,开发效率也没法接受。所以行业里的通用做法是:用性能充足的 x86 主机编译,通过串口、网口、USB 把产物送到目标板运行

交叉编译工具链的名字里藏着一整套信息,学会读它能避免大量"编译过了但跑不起来"的坑。以arm-linux-gnueabihf-gcc为例,拆开看:

  • arm:目标 CPU 架构,说明产出的是 ARM 指令
  • linux:目标操作系统,决定链接的是 Linux 的系统调用接口
  • gnueabihf:C 库和 ABI 约定,gnu指 glibc,eabi是嵌入式应用二进制接口,hf表示硬浮点
  • 最后的gcc:工具名本身

最容易踩的坑就在hf上。软浮点和硬浮点生成的函数调用约定不一样,如果你用软浮点工具链编出来的.so拿去给硬浮点的系统用,链接阶段可能不报错,运行时直接段错误。还有一种情况是 ABI 版本对不上,比如工具链是gnueabi(软浮点)但内核和根文件系统是按硬浮点做的,那所有浮点运算相关代码都可能出问题。所以拿到一块板子,第一步不是写代码,而是搞清楚它官方的 SDK 用的是哪个工具链前缀,然后全程统一用它,别混着来。

2. 工具链、环境与根文件系统:把地基打稳

概念清楚之后,实操的第一个战场是开发环境。这块儿看着不起眼,但新手卡在这里的时间往往比写代码多。我见过太多人卡在"编译出来的可执行文件拷到板子上提示No such file or directory",其实文件明明在那儿——问题出在动态链接器路径上。这类问题全部源于对工具链和根文件系统之间关系的理解不够。

2.1 工具链从哪儿来,怎么挑

工具链的来源大致三种:SoC 厂商提供的 SDK 自带、社区现成的(比如 Buildroot、Yocto 生成)、自己用 Crosstool-NG 构建。对绝大多数项目,直接用厂商 SDK 自带的工具链是最稳的选择,因为厂商已经把内核版本、glibc 版本、ABI 都对好了,你只需要把bin目录加到 PATH 里就行。

# 解压厂商工具链并加入环境变量(路径按实际改) tar -xvf gcc-arm-linux-gnueabihf-xxx.tar.xz -C /opt/ export PATH=/opt/gcc-arm-linux-gnueabihf-xxx/bin:$PATH export CROSS_COMPILE=arm-linux-gnueabihf- arm-linux-gnueabihf-gcc -v

CROSS_COMPILE这个环境变量值得单独说。Linux 内核和 U-Boot 的 Makefile 都会读它,用它加上gccldobjcopy等后缀去拼出实际要调用的工具名。所以你一旦设了这个变量,后面敲make就可以直接编,不用每次都传CROSS_COMPILE=...。建议把它写进~/.bashrc,但注意:如果你同时要维护多个不同架构的项目(ARM32、ARM64、RISC-V),不要全局设死,否则编译时忘了改就可能编出错误架构的东西。

C 库的选择是另一个值得琢磨的点:

C 库体积兼容性适用场景
glibc大(几 MB 到十几 MB)最好,POSIX 完整资源充足、需要跑复杂应用
uClibc-ng中(几百 KB 到 1MB)较好中等资源、传统嵌入式
musl小(几百 KB)静态链接友好容器化、静态编译、轻量系统

选哪套,本质上是在体积和兼容性之间做权衡。板子有 512MB 内存、跑 Qt 界面,那 glibc 没什么好犹豫的;如果是一颗只有 64MB 内存的 Cortex-A7,跑一个采集上报的小程序,musl 加静态链接能让你的 rootfs 瘦一大圈。有个经验:不要中途换 C 库,因为编译出来的每一个 .so 都依赖特定 libc 的符号版本,混用会出现一堆莫名其妙的未定义符号。

2.2 根文件系统:它到底装了什么

根文件系统(rootfs)是最容易被忽视、出问题后最难查的一层。简单说,它是内核启动完成后挂载的第一个文件系统,里面装着让系统"像个系统"的全部东西:/bin下的命令、/lib下的动态库、/etc下的配置、/dev下的设备节点、/proc/sys这两个由内核生成的虚拟目录。

一个最小可用的嵌入式 rootfs,用 BusyBox 就能搭出来。BusyBox 的思路很聪明:把lscpifconfigvish等几百个常用命令全部塞进一个可执行文件,靠传入的 argv[0] 判断你要执行哪个功能,再通过符号链接暴露出去。这样整个/bin目录可能只占几百 KB。

# BusyBox 交叉编译与安装的典型流程 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- defconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j8 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- CONFIG_PREFIX=/home/me/rootfs install

CONFIG_PREFIX指定安装目录。安装完之后,/home/me/rootfs下就有了binsbinusrlinuxrc这些东西。但注意:BusyBox 只给了命令,不给配置文件和设备节点,你还得手动补上:

cd /home/me/rootfs mkdir -p dev proc sys etc/init.d tmp var lib sudo mknod dev/console c 5 1 sudo mknod dev/null c 1 3

这两条mknod不是可选项。/dev/console是内核启动到 init 阶段要打开的字符设备,主设备号 5、次设备号 1;/dev/null主设备号 1、次设备号 3。少了它们,你大概率会看到内核打印完最后一行日志后卡住,连登录提示都没有。更省事的办法是用devtmpfs——内核挂载它之后会自动创建设备节点,但前提是你得在内核配置里打开CONFIG_DEVTMPFSCONFIG_DEVTMPFS_MOUNT

启动脚本这块,BusyBox 的 init 会读/etc/inittab,里面最关键的一行通常长这样:

# /etc/inittab 典型内容 ::sysinit:/etc/init.d/rcS ::askfirst:-/bin/sh ::ctrlaltdel:/sbin/reboot

rcS就是启动时执行的初始化脚本,很多人把自己的程序挂在这里。写rcS有个容易被忽略的细节:后台程序和前台程序的处理方式不一样。如果你想在启动时跑一个常驻的后台服务,一定记得加&,否则rcS会卡在那一步,后面的东西永远不执行,表现出来就是"系统启动了但是别的服务都不在"。

# /etc/init.d/rcS 示例 #!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t tmpfs none /tmp ifconfig eth0 192.168.1.100 netmask 255.255.255.0 up /usr/bin/my_daemon & # 后台服务,必须加 & /sbin/ifconfig lo up

2.3 NFS 挂载根文件系统:开发期效率翻倍的操作

开发阶段反复往 Flash 里刷 rootfs 很浪费时间,尤其每次只是个脚本改了两个字。所以行业里的标准做法是:内核和 rootfs 都放在主机上,板子通过 NFS 或 TFTP 从网络加载。这样你改完文件立刻生效,板子重启一下就行。

U-Boot 里设置启动参数,关键是bootargs

# U-Boot 命令行下设置网络启动参数 setenv ipaddr 192.168.1.50 setenv serverip 192.168.1.100 setenv bootargs 'console=ttyS0,115200 root=/dev/nfs rw nfsroot=192.168.1.100:/home/me/nfsroot,tcp ip=192.168.1.50:192.168.1.100:192.168.1.1:255.255.255.0::eth0:off' setenv bootcmd 'tftp 0x80000000 zImage; tftp 0x81000000 dtb; bootz 0x80000000 - 0x81000000' saveenv

这里有个细节要解释:nfsroot后面那串 IP 的格式是客户端IP:服务器IP:网关:掩码:主机名:网卡:自动配置,中间留空表示用默认值。写错了内核会挂载失败,然后 panic。另一个常见坑是 NFS 版本,老一点的 U-Boot 和内核默认走 NFSv2,而新版主机的 nfsd 可能只开了 v3/v4,这时候要么在nfsroot里加上,vers=3,要么去主机上放开 v2 支持。

提示:NFS 挂载的 rootfs 权限要和主机上的属主一致(一般是 root),否则会出现"能读不能写"或"脚本没有执行权限"。调试期用rw挂载,比只读挂载省心得多。

3. 内核、设备树与驱动:真正的分水岭

如果前三章是"入门",那内核和设备树这块儿就是嵌入式 Linux 工程师的分水岭。会改配置、会编内核、会写个最简单的字符设备驱动,这三件事做到,你才算是真正进入这个领域——前面的都是准备工作。

3.1 内核源码目录结构,先认路再动手

拿到 Linux 内核源码,第一反应往往是"这也太大了"。确实,主线内核解压后好几个 G,几万个文件。但你要动的目录其实就那几个,先记住它们。

目录作用你什么时候会碰
arch/arm/boot生成 zImage、编译产物编译内核时
arch/arm/boot/dts设备树源文件改板级硬件描述时
drivers/各类驱动加驱动、改驱动时
include/linux/内核头文件写驱动引用 API 时
init/启动流程、main.c想理解启动顺序时
kernel/调度、时间、中断等核心深挖内核机制时
mm/内存管理分析 OOM、内存布局时
Documentation/官方文档全靠它查参数含义

对嵌入式开发者来说,drivers/arch/arm/boot/dts/是命中率最高的两个。驱动按类别分目录,drivers/char/是字符设备,drivers/i2c/是 I2C 从设备驱动,drivers/spi/是 SPI,drivers/net/是网卡。你要移植一个传感器,先看看同类器件在哪个目录里有没有现成驱动,有的话改一改往往比从零写快十倍。

内核配置的入口是make menuconfig,它的底层是.config文件,而.config的基础是arch/arm/configs/下的*_defconfig。厂商通常会给一份自己的 defconfig,比如myboard_defconfig

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- myboard_defconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage dtbs -j8

关于裁剪,有个经验值得分享:别一上来就追求最小内核。新手常见做法是把能关的全关,结果系统启动直接 panic,然后花两天找原因。正确的顺序是:先用厂商 defconfig 保证能跑起来,再一个个模块地关,每关一批就重启验证一次。关掉某项后如果出问题,dmesg里通常会有提示,比如缺了某个CONFIG_导致的初始化失败。

判断一个选项该编进内核还是编成模块(.ko),有几个实用标准:

  • 启动阶段就要用的(存储控制器、根文件系统所在的总线、显示),必须=y
  • 用完可以随时装卸的(USB 网卡、特定传感器),编成=m
  • 不确定的,先=m,跑通了再决定要不要合进去

3.2 设备树:把硬件描述从代码里搬出来

在设备树出现之前,ARM Linux 的硬件信息是硬编码在arch/arm/mach-xxx/里的。结果就是:每换一块板子,哪怕 SoC 一样、只是接的器件不同,都得改内核源码、重新编译。这在 ARM 阵营碎片化的硬件生态下是场灾难。设备树的出现就是为了解决这个问题——把"板子上有什么硬件、怎么连的"从内核代码里抽出来,变成一份独立的数据文件

设备树的文件家族有这么几个扩展名:

  • .dts:板级设备树源文件,一块板子一份
  • .dtsi:可被包含的头文件,通常放 SoC 的公共描述
  • .dtb.dts编译后的二进制,内核真正读取的东西
  • .dtbo:overlay,运行时叠加的描述片

编译工具是dtc,内核构建时会自动调用。一份最小可用的设备树长这样:

/dts-v1/; / { model = "My Board"; compatible = "myvendor,myboard", "myvendor,my-soc"; #address-cells = <1>; #size-cells = <1>; chosen { bootargs = "console=ttyS0,115200 root=/dev/mmcblk0p2 rw"; }; memory@80000000 { device_type = "memory"; reg = <0x80000000 0x20000000>; /* 起始 0x80000000,大小 512MB */ }; soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ranges; uart0: serial@ff1a0000 { compatible = "myvendor,my-uart"; reg = <0xff1a0000 0x1000>; interrupts = <0 42 4>; clocks = <&clk_uart0>; status = "okay"; }; }; };

compatible是设备树里最重要的属性,它是驱动和硬件之间的媒人。内核启动时会解析设备树,把每个节点按compatible字符串去匹配已注册的驱动。驱动里声明了of_device_id表,表里的字符串和 dts 里的对得上,驱动的 probe 函数才会被调用。对不上,设备就永远不会被初始化,而且不会有任何报错——这是新手最容易被坑的地方之一。

reg = <0x80000000 0x20000000>这种形式要会算。它表示"起始地址 + 长度"两个 32 位值,换算成十进制就是 0x20000000 = 536870912 字节 = 512MB。写错了会导致内核只用一部分内存,或者访问到不存在的地址直接崩。interrupts里的数字含义由中断控制器决定,GIC 一般是<类型 中断号 触发方式>,触发方式 4 表示高电平触发。

3.3 一个能跑的最小字符设备驱动

概念说再多,不如写一遍。下面这个驱动不涉及具体硬件,纯粹演示字符设备驱动的骨架,理解了它,再去看真实驱动就是加料的事。

#include <linux/module.h> #include <linux/fs.h> #include <linux/cdev.h> #include <linux/uaccess.h> #define DEV_NAME "mychar" #define BUF_SIZE 128 static dev_t devno; static struct cdev my_cdev; static char kbuf[BUF_SIZE]; static int klen = 0; static int mychar_open(struct inode *inode, struct file *filp) { pr_info("mychar: open\n"); return 0; } static ssize_t mychar_read(struct file *filp, char __user *buf, size_t count, loff_t *ppos) { if (*ppos >= klen) return 0; if (count > klen - *ppos) count = klen - *ppos; if (copy_to_user(buf, kbuf + *ppos, count)) return -EFAULT; *ppos += count; return count; } static ssize_t mychar_write(struct file *filp, const char __user *buf, size_t count, loff_t *ppos) { if (count > BUF_SIZE) count = BUF_SIZE; if (copy_from_user(kbuf, buf, count)) return -EFAULT; klen = count; pr_info("mychar: wrote %zu bytes\n", count); return count; } static int mychar_release(struct inode *inode, struct file *filp) { pr_info("mychar: release\n"); return 0; } static const struct file_operations mychar_fops = { .owner = THIS_MODULE, .open = mychar_open, .read = mychar_read, .write = mychar_write, .release = mychar_release, }; static int __init mychar_init(void) { int ret; ret = alloc_chrdev_region(&devno, 0, 1, DEV_NAME); if (ret < 0) { pr_err("mychar: alloc_chrdev_region failed\n"); return ret; } cdev_init(&my_cdev, &mychar_fops); my_cdev.owner = THIS_MODULE; ret = cdev_add(&my_cdev, devno, 1); if (ret < 0) { unregister_chrdev_region(devno, 1); return ret; } pr_info("mychar: major=%d minor=%d\n", MAJOR(devno), MINOR(devno)); return 0; } static void __exit mychar_exit(void) { cdev_del(&my_cdev); unregister_chrdev_region(devno, 1); pr_info("mychar: removed\n"); } module_init(mychar_init); module_exit(mychar_exit); MODULE_LICENSE("GPL");

配套的 Makefile 也是模板化的,改一下路径就能用:

obj-m += mychar.o KDIR := /home/me/kernel-src ARCH := arm CROSS := arm-linux-gnueabihf- all: $(MAKE) -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS) clean

几个必须理解的点:

file_operations应用层和内核之间的接口约定。应用调用openreadwriteclose,最终会被 VFS 分发到你的mychar_fops里对应的函数指针上。所以这个结构体填了哪些,你的设备就支持哪些操作,没填的调用会返回-EINVAL

copy_to_usercopy_from_user绝对不能省的一步。内核空间和用户空间的内存不能直接互访,必须用这两个函数做拷贝,而且它们内部会做地址合法性检查。有些新人为了图快,直接memcpy(buf, kbuf, count),在 x86 上可能侥幸跑通,换到 ARM 上带 MMU 的环境立刻 oops。

alloc_chrdev_region让内核动态分配主设备号,比写死一个数字好得多,因为写死了可能跟系统里已有驱动冲突。用动态分配以后,装完模块看dmesg拿到主设备号,再mknod创建设备节点:

# 板子上操作 insmod mychar.ko dmesg | tail -5 # 假设打印 major=248 mknod /dev/mychar c 248 0 echo "hello" > /dev/mychar cat /dev/mychar

pr_info的输出能直接在dmesg里看到,是因为打印等级够高。内核日志等级从 0 到 7,数字越小越严重。KERN_INFO是 6,KERN_ERR是 3。如果控制台等级设得低,pr_info就不会显示在串口上但会进环形缓冲区。排查时用dmesg -n 8临时放开全部等级,比改代码重新编译快。

4. 调试与排查:从"什么都没有"到定位问题

嵌入式调试最难受的一点是:板子上没有图形界面,没有键盘鼠标,出问题时经常连个错误提示都没有。所以排查这件事,靠的不是运气,而是分阶段定位的方法论。

4.1 分阶段定位法:先把问题范围切小

我习惯把整个启动链路切成六个检查点,从前往后逐个确认。只要当前这个点没通过,就完全不去考虑后面的环节,这样能避免大量无用功。

阶段观察现象不通过的常见原因
上电电源指示灯亮,电流正常供电不足、DDR 虚焊
启动 ROM串口出现第一行乱码或字符波特率不对、串口线接错
Bootloader出现 U-Boot 版本号和倒计时启动介质里没有镜像、镜像校验失败
内核加载出现Starting kernel ...内核地址、dtb 地址传错
内核启动刷大段内核日志设备树错误、时钟/引脚配置缺失
挂载 rootfs出现Please press Enter或登录提示rootfs 路径错、NFS 不通、init 缺失

实际排查时,串口是最重要的工具,没有之一。我遇到过不少人用 USB 转 TTL 线接板子但没接 GND,或者 TX/RX 接反,结果全程"黑屏",怀疑板子坏了。接串口线之前,先用万用表确认一下电平:3.3V TTL 和 5V TTL 混接可能烧芯片,RS232 电平和 TTL 电平接错更是直接没反应。

波特率的问题也值得说。嵌入式默认基本是 115200,但也有用 921600 或者 1500000 的。如果串口终端出来一堆乱码,第一反应应该是波特率试错,而不是怀疑硬件。乱码的形态也有讲究:如果乱码是均匀的方块,一般是波特率差一两档;如果是完全无规律的单字符,可能是数据位、停止位设置不对(常见组合是 8N1)。

4.2 内核启动卡住,怎么往下挖

内核阶段的问题最复杂,因为它涉及的东西多。有几个参数能显著提升可观测性,建议在开发期就加上:

# bootargs 里加调试参数 setenv bootargs 'console=ttyS0,115200 earlyprintk ignore_loglevel initcall_debug loglevel=8'

earlyprintk让内核在串口驱动还没初始化完的时候就能打印,这对"卡在早期"的问题非常关键。initcall_debug会打印每个初始化函数的调用和耗时,如果内核卡在某个 initcall 上,日志会停在那一行,你立刻知道是哪个子系统出问题——是时钟、是 pinctrl、还是某个设备 probe 时死循环。

以下几个典型症状,我做了张对照表:

症状大概率原因排查动作
内核日志停在时钟初始化附近晶振频率和设备树不匹配核对clocks节点和原理图晶振值
probe 时报-EPROBE_DEFER依赖的资源还没就绪检查 regulator、clk 是否已注册,通常顺序问题
打印Unable to handle kernel NULL pointer驱动空指针解引用看 oops 最后几行调用栈,定位到具体函数
内核 panic 提示VFS: Unable to mount root fsrootfs 参数或介质不对核对root=和分区号,确认驱动已编入内核
挂载成功但卡在Freeing unused kernel memoryinit 程序缺失或权限不对检查/sbin/init/linuxrc是否存在且可执行

一个非常实用的技巧:oops 信息里最后一行往往是最有价值的。ARM 平台的 oops 会打印 PC 值和一堆寄存器,但更直观的是下面的调用栈回溯,通常会显示func_a+0x1c/0x40这种形式。其中func_a是函数名,0x1c是偏移量。你可以用arm-linux-gnueabihf-addr2line -e vmlinux 0x80123456把地址转成源码行号,非常精确。

4.3 用户空间的排查:strace 与 /proc

如果内核已经跑起来、rootfs 也挂上了,但你的程序行为不对,那就换到用户空间的手段。

strace是第一个该想到的工具,它能打印出程序执行的所有系统调用。程序"启动了但没反应",用strace ./myapp一看,往往就发现卡在某个open返回ENOENT(文件不存在),或者connect一直超时。

# 只跟踪文件相关调用,减少噪音 strace -e trace=openat,read,write,connect ./myapp

/proc目录是内核暴露出来的信息窗口。几个最常用的:

  • /proc/devices:当前已注册的字符设备和块设备主设备号列表,写驱动时对号用
  • /proc/interrupts:每个中断号被触发的次数,判断中断有没有正常工作
  • /proc/meminfo:内存总量、可用量、Slab 占用,排查内存泄漏
  • /proc/cmdline:内核实际接收到的启动参数,验证 bootargs 有没有传对

/sys目录同样重要。它把设备、驱动、总线之间的关系以文件树的形式呈现。想看某个驱动有没有成功绑定设备,直接ls /sys/bus/platform/drivers/看目录下有没有软链接指向你的设备节点就行。这个方法比翻日志快得多。

注意:strace会让程序运行明显变慢,有些对时序敏感的应用(比如靠硬件定时器精确采样的)在 strace 下会行为异常,这时候不要盯着结果下结论。

5. 学习路线与实际踩坑记录

最后聊聊路线和经验。我不太喜欢那种"三个月掌握嵌入式"的口号,因为这门手艺的成长曲线是台阶式的:会跑例程是一个台阶,能改设备树是一个台阶,能写驱动、能定位内核崩溃又是一个台阶。每上一个台阶,需要补的知识面都会变。

5.1 分阶段的学习路线

我的建议是按下面的顺序推进,每一步都以"能做出东西"为验收标准,而不是"看懂了多少"。

第一阶段:把板子跑起来,能改能编

先熟悉串口工具、镜像烧写、U-Boot 命令。验收标准是:你能自己把内核重新编译一次,烧进去,看到版本号变了。这个阶段不需要懂内核代码,重点是熟悉流程和建立信心。常见工具链是虚拟机里的 Ubuntu 加交叉编译器,共享目录、TFTP 服务器、NFS 服务器配好。

第二阶段:读得懂设备树,改得动 dts

拿一个现成的 dts,把里面的节点逐个对照原理图看一遍。验收标准是:你能自己加一个 GPIO 控制的 LED 节点,并在/sys/class/leds/下看到它。这一步能让你彻底理解设备树和驱动的匹配逻辑。

第三阶段:写一个字符设备驱动,理解并发与中断

不要一开始就写复杂的驱动。从最简单的字符设备开始,然后逐步加:加 ioctl 支持、加阻塞读写、加中断处理、加 poll 支持。验收标准是:你的驱动能在多进程同时读写时不崩。这个阶段会自然逼你学自旋锁、信号量、等待队列。

第四阶段:能定位内核崩溃和性能问题

学会读 oops、会用 ftrace、会看/proc下的各种统计。验收标准是:给你一个会随机崩的驱动,你能在两小时内定位到问题行。

5.2 那些文档里不写但一定会踩的坑

这几条是我自己以及周围同行反复踩过的,写下来能帮你省不少时间。

第一,工具链版本混乱。很多人系统里装了三四套工具链,PATH 里有以前的项目残留配置。结果编译出来的东西 ABI 对不上,报错信息还特别模糊。解决方法:每个项目启动时先which arm-linux-gnueabihf-gcc确认用的是哪一个,必要时在项目目录下写个env.sh统一设置。

第二,忽略了内核版本和驱动 API 的对应关系。Linux 内核的内部 API 是不保证稳定的,一个在 4.19 上能编的驱动,拿到 5.10 上可能一堆编译错误。网上抄代码一定要确认它对应的内核版本,看到struct file_operations里用了已经删除的成员,或者probe函数签名变了,别硬改,去内核源码里找同类驱动参考。

第三,用printk打日志忘了加\n内核日志缓冲区是按行缓冲的,没有换行符可能导致日志不刷出,或者和下一条日志粘在一起,看日志时极其误导。养成习惯,每条打印结尾都加\n

第四,修改设备树后忘了重新编译 dtbs。make zImagemake dtbs,烧进去的还是旧的设备树,改了半天以为没生效。我把编译命令固定成make zImage dtbs -j8,避免这个低级但高发的错误。

第五,NFS 挂载时根目录权限被改。在主机上解压 rootfs 压缩包时用了sudo,导致整个目录属主变成 root,但板子上的应用以普通用户运行,没有写权限。要么统一用 root 身份调试,要么在主机上把属主改成对应用户。

第六,忽视了电源和时钟这两个"隐形"因素。很多时候驱动 probe 失败,代码看起来完全没问题,最后发现是某个外设的时钟没使能,或者供电 regulator 被别的驱动关掉了。排查时先cat /sys/kernel/debug/clk/clk_summary看看时钟频率是不是 0,再看/sys/class/regulator/里的状态,比死磕代码有效得多。

第七,跨界面的编译缓存问题。内核和模块分两次编译(先编内核,再编模块),如果中途改了内核配置,模块必须重新编,否则会出现disagrees about version of symbol这类错误。彻底清理一遍make clean再编,虽然费时间但能省更多时间。

关于"看完教程还是不会做"这件事,我个人的体会是:嵌入式 Linux 的知识点在文档里都是散的,教程讲的是别人的板子、别人的场景。真正让你进步的是把手上这块板子从零配到一个能跑自己业务程序的完整系统,中间遇到的每个报错都是知识落地的机会。我自己的习惯是维护一个错题本,每条记录格式是"现象—原因—验证方法",积累到几十条之后,排查速度会有一个明显的变化。

再分享一个实用的小技巧:调试期尽量把printk的日志和用户程序的日志都往串口打,同时在主机上用tee存一份带时间戳的文件。

# 主机端记录串口日志 picocom -b 115200 /dev/ttyUSB0 --imap lfcrlf | tee -a serial_$(date +%m%d_%H%M).log

很多内核崩溃是间歇性的,靠肉眼盯着终端看很容易错过关键几行。存下来慢慢翻,比盯着屏幕猜强太多。这套方法我在好几个项目里都用过,尤其是处理"跑几个小时才崩一次"的问题时,日志文件几乎就是唯一的线索。

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

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

立即咨询