ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

嵌入式Linux开发从芯片到应用:交叉编译、设备树与驱动入门

嵌入式Linux开发从芯片到应用:交叉编译、设备树与驱动入门 1. 从一块板子到跑起应用中间到底隔了几层刚接手一块嵌入式 Linux 开发板的人八成都会经历这么一段板子插上电串口终端刷出一大屏启动日志最后停在login:你按照文档输了个 root进去了。看起来挺顺利但真让你改点东西比如换个屏幕、加个传感器、改一下启动时自动跑的程序立刻就懵了——文件在哪儿改哪个编译出来的东西为什么放到板子上就跑不动这些问题背后其实不是某个具体技术没学会而是层次感没建立起来。嵌入式 Linux 开发和常见的应用开发、后端开发最大的区别就在这儿你面对的不是一个操作系统已经装好、你只管写业务代码的环境而是从芯片上电的第一个字节开始整条链路都归你管。PC 上跑程序操作系统、驱动、文件系统都是现成的嵌入式这边这些东西得你自己配、自己裁、自己塞进去。所以基础概念理不清后面每一步都是黑盒出错只能靠猜。这篇内容就是把这层从芯片到应用的地图铺开把嵌入式 Linux 开发里那些绕不过去的概念——交叉编译、工具链、Bootloader、内核、设备树、根文件系统、驱动模型——按它们真实的工作顺序串一遍。适合同行里刚转过来的朋友也适合做了几年单片机、想往 Linux 方向走的工程师。我不打算写成名词解释手册而是按这个概念为什么存在、它在启动流程的哪一环、动手时你会怎么碰到它这个思路来讲能动手的地方都给上命令行和代码。1.1 一张分层表先把位置感建立起来嵌入式 Linux 系统从上到下大致可以切成五层每一层都有自己的产物和调试手段。很多人学得吃力就是因为把不同层的问题混在一起想。比如屏幕不亮这个问题可能是内核没驱动、可能设备树里引脚配错、也可能只是应用层的显示程序没启动层没分清排查就是瞎撞。层级核心职责典型产物常用调试手段硬件层SoC、DDR、Flash、外设电气连接原理图、PCB、数据手册万用表、示波器、逻辑分析仪Bootloader初始化 DDR、加载内核、传参u-boot.bin、SPL、u-boot.img串口日志、md/mw内存读写内核层进程调度、内存管理、驱动、协议栈zImage/Image、*.kodmesg、/proc、/sys根文件系统提供 shell、库、配置、启动脚本rootfs.ext4、rootfs.tarls、strace、df应用层业务逻辑、界面、通信你的可执行文件、脚本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 的系统调用接口gnueabihfC 库和 ABI 约定gnu指 glibceabi是嵌入式应用二进制接口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_COMPILEarm-linux-gnueabihf- arm-linux-gnueabihf-gcc -vCROSS_COMPILE这个环境变量值得单独说。Linux 内核和 U-Boot 的 Makefile 都会读它用它加上gcc、ld、objcopy等后缀去拼出实际要调用的工具名。所以你一旦设了这个变量后面敲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 的思路很聪明把ls、cp、ifconfig、vi、sh等几百个常用命令全部塞进一个可执行文件靠传入的 argv[0] 判断你要执行哪个功能再通过符号链接暴露出去。这样整个/bin目录可能只占几百 KB。# BusyBox 交叉编译与安装的典型流程 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- defconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- menuconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- -j8 make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- CONFIG_PREFIX/home/me/rootfs installCONFIG_PREFIX指定安装目录。安装完之后/home/me/rootfs下就有了bin、sbin、usr、linuxrc这些东西。但注意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_DEVTMPFS和CONFIG_DEVTMPFS_MOUNT。启动脚本这块BusyBox 的 init 会读/etc/inittab里面最关键的一行通常长这样# /etc/inittab 典型内容 ::sysinit:/etc/init.d/rcS ::askfirst:-/bin/sh ::ctrlaltdel:/sbin/rebootrcS就是启动时执行的初始化脚本很多人把自己的程序挂在这里。写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 up2.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 consolettyS0,115200 root/dev/nfs rw nfsroot192.168.1.100:/home/me/nfsroot,tcp ip192.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里加上,vers3要么去主机上放开 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/是 SPIdrivers/net/是网卡。你要移植一个传感器先看看同类器件在哪个目录里有没有现成驱动有的话改一改往往比从零写快十倍。内核配置的入口是make menuconfig它的底层是.config文件而.config的基础是arch/arm/configs/下的*_defconfig。厂商通常会给一份自己的 defconfig比如myboard_defconfig。make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- myboard_defconfig make ARCHarm CROSS_COMPILEarm-linux-gnueabihf- menuconfig make ARCHarm CROSS_COMPILEarm-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编译后的二进制内核真正读取的东西.dtbooverlay运行时叠加的描述片编译工具是dtc内核构建时会自动调用。一份最小可用的设备树长这样/dts-v1/; / { model My Board; compatible myvendor,myboard, myvendor,my-soc; #address-cells 1; #size-cells 1; chosen { bootargs consolettyS0,115200 root/dev/mmcblk0p2 rw; }; memory80000000 { device_type memory; reg 0x80000000 0x20000000; /* 起始 0x80000000大小 512MB */ }; soc { compatible simple-bus; #address-cells 1; #size-cells 1; ranges; uart0: serialff1a0000 { 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是应用层和内核之间的接口约定。应用调用open、read、write、close最终会被 VFS 分发到你的mychar_fops里对应的函数指针上。所以这个结构体填了哪些你的设备就支持哪些操作没填的调用会返回-EINVAL。copy_to_user和copy_from_user是绝对不能省的一步。内核空间和用户空间的内存不能直接互访必须用这两个函数做拷贝而且它们内部会做地址合法性检查。有些新人为了图快直接memcpy(buf, kbuf, count)在 x86 上可能侥幸跑通换到 ARM 上带 MMU 的环境立刻 oops。alloc_chrdev_region让内核动态分配主设备号比写死一个数字好得多因为写死了可能跟系统里已有驱动冲突。用动态分配以后装完模块看dmesg拿到主设备号再mknod创建设备节点# 板子上操作 insmod mychar.ko dmesg | tail -5 # 假设打印 major248 mknod /dev/mychar c 248 0 echo hello /dev/mychar cat /dev/mycharpr_info的输出能直接在dmesg里看到是因为打印等级够高。内核日志等级从 0 到 7数字越小越严重。KERN_INFO是 6KERN_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 consolettyS0,115200 earlyprintk ignore_loglevel initcall_debug loglevel8earlyprintk让内核在串口驱动还没初始化完的时候就能打印这对卡在早期的问题非常关键。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_a0x1c/0x40这种形式。其中func_a是函数名0x1c是偏移量。你可以用arm-linux-gnueabihf-addr2line -e vmlinux 0x80123456把地址转成源码行号非常精确。4.3 用户空间的排查strace 与 /proc如果内核已经跑起来、rootfs 也挂上了但你的程序行为不对那就换到用户空间的手段。strace是第一个该想到的工具它能打印出程序执行的所有系统调用。程序启动了但没反应用strace ./myapp一看往往就发现卡在某个open返回ENOENT文件不存在或者connect一直超时。# 只跟踪文件相关调用减少噪音 strace -e traceopenat,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 zImage不make 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很多内核崩溃是间歇性的靠肉眼盯着终端看很容易错过关键几行。存下来慢慢翻比盯着屏幕猜强太多。这套方法我在好几个项目里都用过尤其是处理跑几个小时才崩一次的问题时日志文件几乎就是唯一的线索。
返回列表