Skip to content

构建和部署实战指南

前言:代码写完了,该让它跑起来了

前面几章我们花了很大篇幅讲硬件原理、内存映射机制、硬件抽象层设计和字符设备实现。说实话,光看这些理论真的很虚,代码不跑起来,永远不知道哪里会炸。现在到了最激动人心的时刻——把代码编译成模块,部署到开发板上,看着 LED 听从我们的指挥亮灭。

第一步:理解 Makefile 的结构

在开始编译之前,我们先看看 Makefile 是怎么组织的。说实话,很多人(包括我们以前)都是直接复制粘贴 Makefile,改个文件名就开始编译,对里面到底发生了什么一知半解。这种做法在简单项目里可能没问题,但一旦出问题就两眼一抹黑。

我们先看第一部分:

makefile
# Kernel module definition
obj-m := chardev_led_v1_01_driver.o
chardev_led_v1_01_driver-y := chardev_led_v1_01_main.o led_hw.o

这两行告诉内核构建系统:我们要编译一个模块叫 chardev_led_v1_01_driver,它由两个目标文件组成:chardev_led_v1_01_main.oled_hw.o。注意这里 .o 后缀的文件名不要加 .c,构建系统会自动找到对应的 .c 文件。

这里有个细节很多人容易搞错:obj-m 里的 m 代表 module,意思是这是一个可加载模块。如果你写的是 obj-y,那这个代码会被直接编译进内核镜像,不能作为单独的 .ko 文件加载。对于我们这种开发阶段经常需要修改代码的场景,obj-m 是更合适的选择。

接下来是路径配置:

makefile
# ── 项目配置 ────────────────────────────────────────
PROJECT_ROOT := $(shell realpath $(CURDIR)/../..)
ARCH := arm
CROSS_COMPILE := arm-none-linux-gnueabihf-

# 内核源码路径
KDIR := $(PROJECT_ROOT)/third_party/linux-${KERNEL_TYPE}
KOBJ := $(PROJECT_ROOT)/out/${KERNEL_TYPE}

# 输出目录
OUTPUT_DIR := $(PROJECT_ROOT)/out/driver_artifacts/chardev_led_v1_01/alpha-board

这里定义了一些路径和架构配置。ARCH 指定目标架构是 ARM,CROSS_COMPILE 指定交叉编译工具链前缀。这两个参数非常重要,如果填错了,编译出来的模块要么跑不起来,要么直接炸掉。

KDIR 指向内核源码目录,KOBJ 是内核编译输出目录。为什么要分开这两个?因为内核编译会在源码目录下生成大量中间文件,如果你直接在源码目录里编译,会把源码目录弄得很乱。通过 O 参数指定一个独立的输出目录,可以保持源码目录的干净。

最后是编译规则:

makefile
modules:
	@mkdir -p $(OUTPUT_DIR)
	$(MAKE) -C $(KDIR) M=$(CURDIR) O=$(KOBJ) \
		ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules
	@cp *.ko $(OUTPUT_DIR)/ 2>/dev/null || true

这一段是核心。-C $(KDIR) 切换到内核源码目录,M=$(CURDIR) 告诉内核构建系统我们的模块源码在哪里,O=$(KOBJ) 指定输出目录。最后把生成的 .ko 文件拷贝到我们的输出目录。

理解了这些,当编译出问题的时候,你就知道该去哪里找原因了。

第二步:编译驱动模块

我们的项目提供了构建脚本,使用起来比直接调用 make 更方便。你可能会问,为什么不直接用 make?因为脚本帮我们处理了一些繁琐的细节,比如检测内核类型、创建输出目录、拷贝文件等。

bash
cd /home/charliechen/imx-forge
scripts/driver_helper/build_driver.sh chardev_led_v1_01 alpha-board

这条脚本会自动处理内核类型检测、编译、拷贝等操作。如果一切顺利,你会看到类似这样的输出:

🔨 编译chardev_led_v1_01驱动...
make[1]: Entering directory '/home/charliechen/imx-forge/third_party/linux-mainline'
  CC [M]  /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_main.o
  CC [M]  /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/led_hw.o
  MODPOST /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/Module.symvers
  CC [M]  /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.mod.o
  LD [M]  /home/charliechen/imx-forge/driver/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.ko
make[1]: Leaving directory '/home/charliechen/imx-forge/third_party/linux-mainline'
✓ 驱动编译完成: out/driver_artifacts/chardev_led_v1_01/alpha-board/chardev_led_v1_01_driver.ko

但说实话,第一次编译很少能这么顺利。我们总结了一些常见的坑,希望能帮你节省点时间。

第一个坑是找不到头文件。 这通常有几个原因:要么是 #include 路径写错了,要么是内核源码目录不完整,要么是你用的内核版本和目标板子不匹配。内核构建系统默认只会搜索内核源码目录下的 include 和架构相关的 include,如果你需要额外的头文件,需要在 Makefile 里用 ccflags-y 指定。

第二个坑是内核版本不匹配。 内核模块对版本依赖非常严格,必须用和目标内核完全匹配的源码编译。如果你在板子上跑的是 5.15.0,但你编译模块用的是 5.14.0 的源码,加载的时候会提示 "Invalid module format"。这种情况下,唯一的解决办法就是找到匹配的内核源码重新编译。

第三个坑是交叉编译工具链问题。 如果你的 CROSS_COMPILE 前缀不对,或者工具链不在 PATH 里,编译就会失败。一个简单的验证方法是直接运行 arm-none-linux-gnueabihf-gcc -v,看看能不能找到这个命令。

第三步:部署到开发板

编译成功后,.ko 文件位于 out/driver_artifacts/chardev_led_v1_01/alpha-board/ 目录下。现在需要把它部署到开发板。

我们的项目提供了部署脚本:

bash
scripts/driver_helper/deploy_driver.sh chardev_led_v1_01 alpha-board

对于现在咱们是NFS调试阶段,那就部署到NFS上就好。

第四步:加载和测试驱动

加载驱动

登录到开发板,进入存放 .ko 文件的目录,执行:

bash
insmod chardev_led_v1_01_driver.ko

如果成功,不会有任何输出。这是 Linux 的哲学:没有消息就是好消息。但如果你是个强迫症患者,可以用 lsmod 检查:

bash
lsmod | grep chardev

你应该能看到 chardev_led_v1_01_driver 在列表里。

查看内核日志

驱动加载时会打印一些初始化信息,我们可以用 dmesg 查看:

bash
dmesg | tail -20

你应该能看到类似这样的输出:

[ 1234.567890] IMX6U_CCM_CCGR1    = 0xf5d1000 (phys: 0x20c406c)
[ 1234.567891] SW_MUX_GPIO1_IO03  = 0xf5d2000 (phys: 0x20e0068)
[ 1234.567892] SW_PAD_GPIO1_IO03  = 0xf5d3000 (phys: 0x20e02f4)
[ 1234.567893] GPIO1_DR           = 0xf5d4000 (phys: 0x209c000)
[ 1234.567894] GPIO1_GDIR         = 0xf5d5000 (phys: 0x209c004)
[ 1234.567895] CCGR1 raw value: 0x00000000
[ 1234.567896] CCGR1 new raw value: 0x0c000000
[ 1234.567897] Setting SW_MUX_GPIO1_IO03 = 0x5
[ 1234.567898] GPIO1_GDIR set to 0x00000008
[ 1234.567899] GPIO1_DR init set to 0x00000008 (LED OFF)
[ 1234.567900] LED Init OK!
[ 1234.567901] AES_LED load successfully!

这些日志告诉我们硬件初始化的每一步都成功了。从 CCM 时钟寄存器到 GPIO 复用、_pad 配置、方向设置,每一步都有对应的打印。这种调试信息在开发阶段非常宝贵,能帮你快速定位问题。

创建设备节点

驱动加载成功后,还需要创建设备节点才能被用户空间访问。对于使用老 API 的驱动,这一步是必须的:

bash
mknod /dev/led c 200 0

这条命令创建一个字符设备文件(c),主设备号 200,次设备号 0。主设备号必须和驱动里注册的一致(CHARDEV_MAJOR = 200)。

你还可以调整权限,让非 root 用户也能访问:

bash
chmod 666 /dev/led

说实话,每次加载驱动后都要手动执行这些命令真的很烦。这也是为什么新 API 引入了自动创建设备节点的机制,但我们后面再讲这个。

测试 LED 控制

现在可以开始测试了:

bash
# 点亮 LED
printf '1' > /dev/led

# 熄灭 LED
printf '0' > /dev/led

# 查询状态
cat /dev/led

如果一切正常,你会看到 LED 随着命令亮灭,cat /dev/led 会输出 '1''0' 表示当前状态。到这一刻,恭喜你,你的第一个字符设备驱动成功运行了!

第五步:排查常见问题

我们在折腾过程中遇到过各种问题,这里挑一些比较有代表性的分享一下。

insmod 提示 "Invalid module format"

这通常意味着模块版本和内核版本不匹配。内核模块对版本依赖非常严格,必须用和目标内核完全匹配的源码编译。解决方法:确认 KDIR 指向的内核源码和开发板运行的内核版本一致。你可以用 uname -r 在板子上查看内核版本,然后在主机上确认源码目录的版本。

insmod 时出现 "Unknown symbol"

这是因为驱动依赖的某个符号(函数或变量)在当前内核配置下没有被编译进去。检查 dmesg 输出,看具体是哪个符号找不到。如果是你自己在代码里调用了一个不存在的函数,需要换一个实现方式。

我们遇到过一次,驱动里用了 gpio_set_value,但内核配置没开启 GPIO 支持,结果加载时就报这个错。这种情况下,要么改内核配置重新编译内核,要么换一种方式控制 GPIO。

LED 不亮也不灭

这个问题可能的原因就多了。首先确认硬件连接没问题(LED 正确接到 GPIO1_IO03),然后用 dmesg 查看驱动日志,看初始化是否成功。检查寄存器地址是否正确,时钟是否开启,引脚复用是否配置正确。

一个有用的调试技巧是在 led_set_status() 里多加一些打印,看看函数是否被调用,寄存器读写是否成功。有时候你以为代码没问题,但实际跑起来跟你想象的不一样。

rmmod 时卡住或报错

这通常意味着有进程还在使用设备。检查是否有程序打开着 /dev/led 没有关闭,或者是否有后台进程在访问设备。用 lsof /dev/led 可以查看哪些进程在占用设备。

我们遇到过一次,测试程序异常退出了,但没有正确关闭文件描述符,导致 rmmod 一直卡住。最后只能 kill -9 杀掉那个进程,然后才能卸载驱动。

第六步:性能考虑(暂时不用太在意)

对于 LED 控制这种简单功能,性能基本不是问题。但如果你的驱动需要频繁操作寄存器,有一些优化技巧值得了解。

第一个是减少寄存器访问次数。如果可能,把多次读写合并成一次。比如配置多个位时,先构造好完整的值,一次性写进去,而不是每配置一个位就写一次。

第二个是考虑使用缓存。如果频繁读取同一个寄存器,可以在驱动里缓存它的值,避免重复读取。但要注意缓存一致性——如果寄存器值可能被硬件改变,缓存就会失效。

第三个是避免频繁的用户态/内核态切换。如果需要连续执行多个操作,考虑实现一个 ioctl 接口,一次性完成所有操作,而不是多次 write() 调用。

对于我们的 LED 驱动,这些优化都不必要,因为控制频率很低。但了解这些原则对以后开发更复杂的驱动有帮助。

卸载驱动

测试完成后,记得清理:

bash
rmmod chardev_led_v1_01_driver
rm /dev/led

rmmod 会调用驱动的 exit 函数,清理硬件资源和注销设备。你应该能在 dmesg 里看到类似这样的输出:

[ 2345.678901] === chardev_led_v1_01 rmmod progress ===
[ 2345.678902] Deinit the LED Hardware
[ 2345.678903] ========================

本章小结

到这里,我们已经完成了从硬件理解到驱动实现,再到编译部署的完整流程。你掌握的不仅仅是一个 LED 驱动,更是一套可以复用到其他驱动的开发流程和方法论。

回顾一下,我们学到了什么:

第一,理解 Makefile 的结构很重要。不要只是复制粘贴,知道每个参数的作用,出问题的时候才能快速定位。

第二,编译过程可能会遇到各种坑,大多数是路径、版本、工具链相关的问题。耐心排查,这些问题都有明确的解决方案。

第三,部署和测试是验证代码正确性的关键步骤。内核日志是你的好朋友,多打印信息能帮你快速定位问题。

下一步,你可以尝试修改代码,实现更复杂的功能。比如支持亮度调节(PWM)、支持闪烁模式、通过 ioctl 实现更多控制命令。或者,你可以去看看新 API 的实现,了解现代字符设备驱动的标准写法。

驱动开发的大门已经打开了,接下来就是不断实践和探索了。祝你在内核驱动的世界里玩得开心!

相关文档

Built with VitePress