1. 内核模块:驱动开发的起点与内核态思维
1.1 驱动在系统里的位置,模块机制解决了什么问题
做嵌入式Linux驱动开发,很多人第一反应是“写代码控制硬件”,但真正上手才发现,驱动开发的核心不是把寄存器读出来、写进去,而是先搞懂你写的代码到底活在系统的哪一层。整个软件栈从上到下大概是:应用层(用户态)-> C库 -> 系统调用 -> VFS/协议层 -> 驱动框架 -> 硬件。驱动就是贴着硬件那一层,负责把寄存器操作、中断响应、数据收发这些脏活累活封装成标准接口,让上层不用关心你用的到底是I2C还是SPI,也不用管芯片是瑞芯微还是全志。
那内核模块呢?它的本质就是“可以在运行时加载进内核的代码段”。Linux内核本身是单内核(monolithic kernel),所有核心功能都编译成一个大的vmlinux镜像,但驱动如果也全部编进去,每换一个硬件就得重新编译整个内核,非常痛苦。模块机制就解决了这个问题:驱动以.ko文件存在,系统启动后按需insmod加载,用不到就rmmod卸载,不需要重启。这跟应用层的动态链接库原理类似,区别在于它运行在内核态,权限更高、崩溃后果也更严重——应用段错误只是一个进程挂掉,内核模块写错一个地址,直接整个系统panic。
对初学者来说,模块是进入驱动开发最好的敲门砖,因为它的工程结构最简单,不依赖具体硬件也能跑起来,非常适合先把“内核态编程”的手感练出来。而对做产品的工程师来说,模块化是基本盘:调试阶段把驱动编成模块可以快速迭代,量产阶段再把稳定的部分编入内核,启动时直接初始化,减少文件系统依赖。
1.2 最小模块代码与编译流程
先看一个最小可用的模块,这段代码我建议新手逐行理解,不要光复制:
#include <linux/init.h> #include <linux/module.h> #include <linux/kernel.h> static int __init hello_init(void) { pr_info("hello_driver: module loaded\n"); return 0; } static void __exit hello_exit(void) { pr_info("hello_driver: module unloaded\n"); } module_init(hello_init); module_exit(hello_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A minimal Linux driver module");module_init和module_exit是入口与出口声明,__init和__exit是段属性宏,告诉内核:初始化函数在加载完成后就可以释放内存,不用一直占着。MODULE_LICENSE不是摆设,如果声明为非GPL,很多内核导出的符号(比如一些EXPORT_SYMBOL_GPL的函数)你是无法使用的,实际工程里统一写GPL最省事。
编译模块用的不是普通gcc,而是内核的Kbuild系统。Makefile长这样:
obj-m := hello_driver.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean关键点是-C $(KDIR),它会切到内核源码目录去执行内核的顶层Makefile,然后用M=$(PWD)指定你的模块源码位置。所以编译模块的前提是你安装了对应内核版本的头文件或完整源码。交叉编译场景下,KDIR要指向目标平台的Linux源码树,并且提前设置好交叉编译工具链,比如ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu-。
加载验证流程是三板斧:
sudo insmod hello_driver.ko dmesg | tail sudo rmmod hello_driver如果看到hello_driver: module loaded,说明模块加载成功。一个常见的坑是insmod后没有任何输出,因为pr_info走的是内核日志缓冲,不直接打到终端,必须用dmesg查看。有些板子上还需要dmesg -n 8或加log_buf_len启动参数,不然早期日志会被覆盖。
1.3 模块、字符设备与自动化生成框架
模块只是“一段能加载的内核代码”,它本身不提供任何对应用可见的接口。要让用户态程序真正访问你的硬件(比如read、write、ioctl),通常要把模块注册成一个设备。字符设备是最常见的一种——数据按字节流读写,比如串口、GPIO、传感器,都属于字符设备。
注册一个字符设备的核心步骤是:
- 分配设备号:
register_chrdev_region(静态指定)或alloc_chrdev_region(动态分配)。动态分配的好处是不会跟已有设备号冲突。 - 初始化cdev结构体并添加到内核:
cdev_init+cdev_add。 - 创建设备类和设备节点:
class_create+device_create,这样/dev/xxx会自动出现,应用层才能open。
这些步骤每写一个驱动都要重复一遍,所以后来有了miscdevice框架——它帮你管理了主设备号(统一用10),你只需要注册一个struct miscdevice,填上次设备号和file_operations即可。对于大多数简单外设,直接用它就够了,省去一堆样板代码。再往后,Linux 6.x时代出现了auxiliary_bus、device_create自动设备节点等机制,几个驱动框架越来越标准化,但思路没变:内核里每增加一个设备,核心都是“设备模型”的挂载与文件操作接口的注册。
从这种演进可以看出,驱动开发的工程化程度很高。新手别一上来就想写一个完整的网卡驱动或者GPU驱动,路径应该是:最小模块 -> 简单字符设备 -> 绑定真实硬件 -> 接入子系统框架(I2C、CAN、SPI等)。这个顺序能从“我会写代码”过渡到“我理解内核的设计”。
2. 设备树:让驱动与硬件解耦的配置语言
2.1 设备树的基本结构与匹配流程
在设备树(Device Tree,DT)普及之前,ARM Linux内核里充满了大量的板级硬编码代码,每换一块板子就要改平台代码,社区维护成本极高。设备树的核心思路是:把“硬件有什么、长什么样、接在哪个地址”这类信息从C代码里剥离出来,用一种类似目录树的文本格式描述,编译成dtb(device tree blob)后传给内核。驱动代码只负责实现功能,至于具体跑在哪块板子上,由设备树决定。
这跟应用开发里“配置与代码分离”的思路一致,但在内核里更彻底。比如同样是I2C总线控制器,芯片厂家(如瑞芯微RK3568)写一份驱动,板厂在自己的dts里声明“I2C2上挂了某个触摸屏,地址0x38”,两者通过compatible这个字符串搭上线。
设备树的基本结构节点包括:根节点/、cpus、memory、soc等。每个节点有compatible——这是驱动匹配的钥匙;reg——寄存器地址或设备地址;interrupts——中断号与触发方式;status——设备是否启用,okay或disabled。一个典型节点的样子:
&i2c2 { status = "okay"; clock-frequency = <400000>; touchscreen@38 { compatible = "goodix,gt911"; reg = <0x38>; interrupt-parent = <&gpio3>; interrupts = <5 IRQ_TYPE_LEVEL_LOW>; reset-gpios = <&gpio3 6 GPIO_ACTIVE_LOW>; }; };驱动侧匹配时,of_match_table里写{ .compatible = "goodix,gt911" },内核遍历挂在I2C总线上的设备,发现节点compatible匹配,就调用驱动的probe函数。
2.2 从零添加一个设备节点的完整配置实例
实际干活的时候,你大概率不会从空文件写dts,而是在芯片厂商提供的dtsi基础上修改。以RK3568的开发板为例,芯片原厂会提供rk3568.dtsi,里面定义好了各个控制器节点,板级文件myboard.dts再通过#include引入,并覆盖(override)需要修改的节点。
给一个外设添加设备节点的标准流程大概是:
- 原理图确认:这个设备挂在哪条I2C/SPI总线上?地址是多少?复位脚、中断脚接到哪个GPIO?有没有使能脚需要拉高?
- 在dtsi中找到对应控制器节点,确认它默认是
disabled还是okay,通常需要在自己板级dts里重新打开。 - 在控制器节点下新增子节点,填写compatible、reg、中断、GPIO等属性。
- 检查引脚复用(pinmux):比如I2C2的引脚默认可能被配置成GPIO或UART功能,必须在
pinctrl里设置为i2c2功能,否则总线不通。 - 编译dtb、烧录、启动后用
ls /proc/device-tree确认节点是否展开,再用i2cdetect扫描设备是否在线。
我经常看到初学者只做第3步,结果读写失败,查了半天才发现是pinmux没配。这里给个通用检查顺序:先看电压和接线,再看pinmux,然后看时钟是否开启,最后才是驱动代码。
一个比较隐蔽的坑是GPIO属性与复位时序。热词里提到“linux 设备树设置复位信号时间”,就是典型场景。比如传感器上电后,复位脚要拉低10ms再拉高,否则芯片不进入工作状态。你可以在驱动probe里用gpiod_set_value和udelay/msleep实现,但如果在设备树里接了reset-gpios,内核的gpio子系统会帮你管理,配合reset-delay-ms等厂商自定义属性更规范。这类时序问题排查起来很难,一是因为示波器不一定随时都有,二是因为它只在冷启动特定阶段出现。我的经验是:把上电时序相关的操作放在probe里集中处理,并且用dev_dbg打印每一步的状态,方便后续加延时调试。
2.3 设备树编译、反编译与运行时查看
设备树源文件后缀是.dts,包含文件是.dtsi,它们的编译工具叫dtc(device tree compiler)。在内核源码里执行:
make dtbs # 或单独编译 ./scripts/dtc/dtc -I dts -O dtb -o myboard.dtb myboard.dts日常调试经常需要反编译:
dtc -I dtb -O dts -o dump.dts myboard.dtb这样可以快速确认烧进去的dtb到底长什么样,有没有被编译选项或config影响。很多问题其实在源头就能发现:比如你改了dts源码但没重新编译dtb,烧进去的永远是旧版本。
系统启动后,运行时的设备树在/proc/device-tree或更规范的/sys/firmware/devicetree/base下,直接以目录树形式展开,可以ls、cat查看属性。比如:
ls /proc/device-tree/soc/i2c@fe560000/ cat /proc/device-tree/soc/i2c@fe560000/status如果节点没出现,说明dtb里根本没编译进去,或者节点被disabled了。如果节点出现了但设备没工作,再用ls /sys/bus/i2c/devices/看有没有生成对应的设备,驱动有没有绑定上。这一层层往下查,基本能定位是设备树问题还是驱动问题。
2.4 设备树调试中常踩的坑
第一个坑是地址冲突。Soc内部的很多外设控制器会映射到一段地址空间,比如I2C控制器的寄存器地址段是0xfe560000,如果你在dts里写错了范围,或者与其他节点重叠,内核在request_mem_region时会直接失败,驱动probe根本走不到。
第二个坑是GPIO编号混乱。在新版内核里,GPIO一般用gpio3 5这样的形式,但老代码里可能有gpio = <&gpio3 5 GPIO_ACTIVE_LOW>和gpios两种写法,混用会导致解析失败。统一使用-gpios后缀的属性名(如reset-gpios、irq-gpios),并开启CONFIG_OF_GPIO。
第三个坑是interrupt-cells与触发类型不匹配。有些中断控制器用#interrupt-cells = <3>,有些用<2>,需要看具体控制器的绑定文档。触发类型在dts里写错了不会编译报错,但会导致中断完全收不到,或者收到假中断风暴。这类问题我建议先在用户态用cat /proc/interrupts观察中断计数,如果设备操作后计数不变,多半是设备树中断配置的问题。
3. I2C驱动开发:从用户态试探到内核态驱动
3.1 用户态先用i2c-tools验证通路
在写第一行内核态I2C驱动代码之前,我强烈建议先用用户态工具把硬件链路打通。这能帮你区分三类问题的边界:是硬件连接问题、设备树配置问题,还是驱动逻辑问题,还是寄存器读写问题。有一句老话叫“先确认是硬件问题还是软件问题”,I2C尤其如此,因为总线时序非常依赖上拉电阻和电压匹配。
i2c-tools是标配工具包,包含i2cdetect、i2cget、i2cset、i2cdump这几个命令。先扫描总线:
i2cdetect -l i2cdetect -y 2第一条命令列出系统里所有I2C适配器,第二条扫描总线2上的所有设备地址。如果扫描列表里出现预期的地址(比如0x3c的OLED屏、0x38的触摸屏),说明硬件通路基本正常。如果什么都没扫到,先别怀疑工具,查一下dts里controller节点的status是不是okay,pinmux有没有配对,再用示波器量SDA/SCL波形。
拿到地址后,可以用i2cget单字节读、i2cset写寄存器,验证设备是否按datasheet响应。比如SSD1306 OLED初始化序列里的命令可以用i2cset逐条发送,屏幕亮了就说明从设备的地址和基础协议没问题。
这一步对后面写驱动帮助极大,因为你可以拿着已验证过的寄存器序列去对照内核驱动里的初始化代码,基本上是一一对应的关系。很多驱动写不好的原因,不是代码能力,而是从未做过这种底层验证,直接把datasheet想象成代码。
3.2 内核态I2C client驱动的标准骨架
I2C子系统在Linux里分层非常清晰:adapter(I2C控制器)、client(挂在上面的从设备)、driver(从设备的驱动)。适配器由芯片厂家的I2C控制器驱动注册,多数情况下你不用碰;你要写的是client和driver的绑定关系。
一个标准I2C驱动骨架如下:
#include <linux/i2c.h> #include <linux/module.h> #include <linux/of.h> static int my_i2c_probe(struct i2c_client *client, const struct i2c_device_id *id) { // 设备树里的属性读取 int ret = 0; u32 value = 0; ret = device_property_read_u32(&client->dev, "some-flag", &value); if (ret) value = 0; // 默认值 // 读寄存器示例 u8 reg = 0x00; u8 data = 0; data = i2c_smbus_read_byte_data(client, reg); if (data < 0) { dev_err(&client->dev, "read reg 0x%02x failed\n", reg); return data; } dev_info(&client->dev, "probe ok, reg value = 0x%02x\n", data); return 0; } static void my_i2c_remove(struct i2c_client *client) { dev_info(&client->dev, "remove\n"); } static const struct of_device_id my_i2c_of_match[] = { { .compatible = "myvendor,mydevice" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_i2c_of_match); static struct i2c_driver my_i2c_driver = { .driver = { .name = "my_i2c_driver", .of_match_table = my_i2c_of_match, }, .probe_new = my_i2c_probe, .remove = my_i2c_remove, }; module_i2c_driver(my_i2c_driver); MODULE_LICENSE("GPL");of_match_table是设备树匹配的关键,它的compatible必须与dts节点里的完全一致。probe函数里我用了device_property_read_u32,这是通用属性读取接口,与具体总线无关,可以同时兼容设备树和ACPI平台,推荐优先使用。
i2c_smbus_read_byte_data/i2c_smbus_write_byte_data是SMBus风格的封装,适合单字节读写。对于块读写或连续寻址的设备,可以直接用i2c_transfer构造i2c_msg数组,控制更精细。例如读传感器多字节数据:
struct i2c_msg msgs[2] = { { .addr = client->addr, .flags = 0, // 写 .len = 1, .buf = ®, }, { .addr = client->addr, .flags = I2C_M_RD, // 读 .len = 6, .buf = rxbuf, }, }; ret = i2c_transfer(client->adapter, msgs, 2);这种写法隐藏了一个问题:某些I2C设备在读写之间会有地址自动递增,如果没有正确处理,读出来的数据就是乱的。面向寄存器读取的设备,先写寄存器地址再读数据是标准操作,但要注意有些设备要求带寄存器地址写操作和读操作必须连续(用repeat start),这和i2c_transfer两个msg在大多数控制器上的行为一致,但在个别控制器上会变成stop-start,导致设备跳帧。遇到这种诡异问题时,得先用逻辑分析仪抓时序确认控制器的实际行为。
3.3 中断与等待队列:不要用忙等处理事件
I2C驱动如果只是简单读寄存器,probe里做一遍初始化就够了。但真实外设往往需要异步事件通知,比如触摸屏有触摸事件、加速度计有运动检测、温湿度传感器可以配置阈值报警。这些场景下,驱动必须处理中断。
中断申请用devm_request_irq,中断号可以从设备树里解析——irq_of_parse_and_map或更通用的fwnode_irq_get。中断触发方式由设备树里的interrupts属性决定。
中断处理函数要遵守一个原则:顶半部(hardirq)里不能做耗时操作,要尽快返回,耗时工作放到底半部(tasklet或workqueue)里。I2C读写本身不能直接在hardirq里做,因为I2C事务可能睡眠(如果底层控制器使用进程上下文等待)。所以常见模式是:中断里schedule_work或queue_work,然后在work函数里做I2C读取、上报事件。
与用户态通信的常见方式有几种:miscdevice+ read/write/ioctl 手动实现;input子系统提供标准输入事件上报;或者IIO子系统提供统一的传感器接口。具体选哪个,取决于设备的归类。按键、触摸屏上报用input_report_key,传感器用IIO,一般外设用miscdevice最灵活。选型标准就是看你的设备跟内核里现成子系统的语义是否匹配,不匹配不要硬套,硬套会让上层应用适配变得扭曲。
另外一个很多新手会犯的错,是在probe里直接做长时间I2C通信。I2C总线在某些状态下会等待总线仲裁,直接阻塞会导致系统卡顿。如果初始化序列确实长(比如几十条配置寄存器),正确做法是放到probe里同步执行但保持单次操作短小,或者干脆放到工作队列异步初始化,先返回probe成功,再慢慢配置。
3.4 I2C调试的常用技巧
I2C驱动调试时,最痛苦的是设备“偶发”读写失败。我遇到过几种典型情况:
一种是总线频率过高。设备树里clock-frequency配了400kHz(fast mode),但板子走线较长、上拉电阻偏大,信号质量差,设备有时应答有时不应答。降到100kHz后一切正常。这种情况不一定是芯片不行,单纯是板级硬件没达标。量产阶段遇到率高,初期开发经常被忽略。
另一种是漏了i2c_delay。有些设备datasheet写明两次操作之间需要若干微秒,比如写EEPROM后要等内部写周期完成(一般5ms),如果驱动里连续读写,第二个事务就会失败。解决方案有:读出设备状态寄存器的写完成标志,或直接加msleep。
还有一种是重复寄存器地址冲突。多个client驱动都试图操作同一个adapter,在没有正确加锁的情况下,i2c_transfer会被打断。标准做法是使用i2c_transfer替代直接操作adapter的底层函数,它内部有总线锁保护。如果你实在要搞原子性事务,可以用i2c_lock_bus一次锁住多个答案。
调试工具方面,逻辑分析仪是I2C调试的利器。现在几十块钱的逻辑分析仪配合sigrok/PulseView,就能解码I2C协议,直接看你发的地址、寄存器地址、数据是否跟预期一致。如果没有逻辑分析仪,可以用内核的i2c-trace或者打开CONFIG_I2C_DEBUG_CORE,它会打印I2C传输的调用栈和消息内容。不过它打印量大,适合定位协议栈层面的问题,不适合抓信号时序。
4. CAN总线驱动与SocketCAN应用路径
4.1 CAN子系统架构与SocketCAN标准接口
CAN总线在汽车电子、工业控制里用得极广,跟I2C/SPI这种板内总线相比,它的特点是可以长时间远距离传输、多节点通信、有完善的错误检测与仲裁机制。简单说,I2C是用来“读一个传感器”的,CAN是用来“一辆车里的多个ECU互相通信”的。
Linux的CAN支持叫SocketCAN,思路非常巧妙——它把CAN接口设计成了类似网络套接字的使用方式。应用开发者可以用socket(AF_CAN, SOCK_RAW, CAN_RAW)打开一个socket,然后像收发网络包一样收发CAN报文,不用关心底层是USB转CAN设备、SPI转CAN芯片,还是SoC内置的CAN控制器。
这种设计让CAN应用开发门槛大幅降低,也意味着驱动开发者的活是:把硬件控制器驱动好,注册成一个标准的struct net_device,让上层SocketCAN框架能识别和操作。
CAN驱动的架构分三块:控制器驱动(通常也走struct can_priv)、CAN协议层(内核已实现)、以及总线状态管理。芯片原厂通常已经写好控制器驱动,比如RK3568内部的CAN控制器(基于Bosch M_CAN或类似IP),你要做的主要是设备树配置、引脚复用、时钟和速率参数,而不是从零写一个控制器驱动。只有在使用外部CAN控制器芯片(如MCP2515通过SPI连接)时,才需要自己写一个完整的CAN网络设备驱动,那种情况下可以复用内核里的spi-mcp251x.c驱动改改,或者参考can-dev框架。
4.2 CAN节点设备树配置与控制器初始化
以RK3568为例,设备树里启用CAN节点通常是这样的:
&can1 { status = "okay"; pinctrl-0 = <&can1m0_pins>; pinctrl-names = "default"; };这里的pinctrl可能隐藏着最大的坑。CAN是差分信号,收发器(transceiver)需要把控制器的TX/RX转成CAN_H和CAN_L,所以还要确认CAN收发器的使能脚、参考电平、终端电阻。有些板子上收发器使能脚悬空或者默认低电平,总线上没有任何波形,问题其实根本不在内核驱动,而在硬件默认状态。
控制器初始化时,波特率是从用户态设置的,不是编译进内核的。这是SocketCAN跟传统CAN驱动一个很大的区别。你ip link set can0 up type can bitrate 500000,内核才会根据bitrate参数计算位时序并配置控制器。
有些应用需要listen-only模式,比如做总线监控工具,可以用ip link set can0 up type can bitrate 500000 listen-only on。数据帧、远程帧的收发都走SocketCAN的接口层,驱动本身的任务是保证控制器中断正常、FIFO不溢出、错误状态能上报。
4.3 用户态用SocketCAN工具联调
驱动配置好之后,验证CAN通的步骤很简单:
modprobe can_dev modprobe can_raw ip link set can0 type can bitrate 500000 ip link set can0 up然后安装can-utils,用cansend发送、candump监听:
candump can0 & cansend can0 123#DEADBEEF如果你用另一个CAN设备(或者同一块板子的can0和can1对接)监听,会看到123那条帧成功收到。这个验证过程对驱动调试很重要:如果cansend一直报错,错误码如果是ENETDOWN大概率是接口没起来;EBUSY可能是总线繁忙;ECOMM往往是控制器持续报错进入了总线关闭状态,需要查硬件接线和终端电阻。
CAN总线有一个必须遵守的物理层规则:总线两端要各接一个120欧终端电阻。如果没接终端电阻,数据收发经常时好时坏,而且错误计数会持续增长。这些问题不解决,任何驱动层面的努力都是白费。
4.4 CAN错误处理与波特率参数计算
CAN控制器硬件上会自动处理错误帧检测、错误计数,驱动要做的是把错误状态反映到上层。SocketCAN里有ip -details link show can0可以查看当前错误状态。比如:
ip -details link show can0输出里能看到state ERROR-ACTIVE、restart-ms、berr-counter等。如果错误计数持续升高,说明总线上有问题。可能是波特率不匹配、终端电阻缺失、节点间地电位差过大。
波特率参数在SocketCAN里可以用以下几个参数配置:
ip link set can0 type can bitrate 500000 sample-point 0.875 \ tq 50 prop-seg 6 phase-seg1 7 phase-seg2 2 sjw 1如果你直接用bitrate,内核会从iproute2的波特率表里选择一套默认位时序参数。对于标准应用这已经足够。如果需要定制,就得理解位时序中tq(时间量子)、sample-point(采样点)的关系。采样点放在位时间75%到87.5%之间是业内常用值,太靠后会因为线缆传播延迟导致采样错误。
我在实际项目里遇到过一种情况:两块板子,A板收发正常,B板上同一套代码can0却疯狂报错。最后发现B板的CAN收发器型号不同,它的延迟比A板大,导致采样点偏早。把sample-point从默认调低一点就通了。这类调参没有捷径,只能多做几组对照,并且一定要看厂家的参考设计和勘误表。
5. 子系统之外的必修课:裁剪移植与综合调试
5.1 内核裁剪与根文件系统精简
驱动开发做到中后期,你一定会遇到一个需求:这套系统要量产,启动速度要快,占用空间要小,安全等级要提高。这时候就需要做内核裁剪(system tailoring)和根文件系统精简。
内核裁剪的基本原则不是“删代码”,而是“关闭CONFIG”。通过make menuconfig打开图形化配置界面,把不需要的子系统、驱动、文件系统支持关掉。比如你的产品只用到I2C、CAN、GPIO,完全没有USB Host、Wi-Fi、蓝牙需求,那就在Device Drivers里把它们disable掉,内核体积能明显下降。
裁剪时要注意依赖关系。比如说你禁用了CONFIG_DRM,有些图形相关的驱动会自动消失,这是正常的;但你如果禁用了CONFIG_NET,那CAN就没法用了,因为SocketCAN是基于网络协议栈的。我的建议是:裁剪过程中反复执行make ARCH=arm64 defconfig的对比版本来跟踪选项差异,用diff管理config版本,别稀里糊涂删了不该删的。
根文件系统精简可以从buildroot入手。buildroot用配置文件定义需要包含哪些库、工具、应用,最终生成一个极简的根文件系统镜像。如果使用Yocto,则可以用IMAGE_FEATURES和PACKAGE_*变量精确控制。很多新手误以为根文件系统越“完整”越好,实际量产时恰恰相反:能不要的都不要,比如gcc、make、perl这种开发工具绝对不能进量产镜像,嵌入式环境下的包管理器也应该去掉,攻击面大幅度下降。
5.2 从参考板移植设备树的通用步骤
实际产品开发中,很少有从空白开始写设备树的场景。更常见的路径是:找到一个芯片原厂的参考开发板(比如RK3568的EVB),在上面把系统跑通,再迁移到自己的硬件上。
移植设备树我总结了一套五步法:
- 用原厂的dts做基准,保证内核能在这个dts下正常启动,这是所有工作的前提。
- 对照自己的硬件原理图,逐项检查内存颗粒型号、DDR容量、存储介质(eMMC还是SD卡。)、以太网PHY型号。这几项的差异最致命,配错了系统根本起不来。
- 关掉自己的硬件上没有的外设(status = "disabled"),比如参考板上的HDMI、PCIe等,避免启动时驱动尝试初始化不存在的硬件而产生耗时的超时。
- 添加自己板子独有的外设节点,写清楚compatible和reg。
- 烧录后逐个外设验证,每验证一个就把一个节点的status从disabled改成okay,千万别一次全打开,不然出问题很难定位是哪一块导致的。
有一个坑值得单独说:如果参考板的核心板和自己画的板子在DDR颗粒上不同,光改dts里的reg属性是不够的,DDR初始化参数通常在内核启动前的loader阶段(如U-Boot)完成,设备树只描述容量。所以这类改动要跨u-boot和设备树两层联调。我自己踩过这种坑,当时反复改内核dts里的memory节点根本没效果,后来才发现是U-Boot里DDR训练参数的问题。
5.3 驱动工程师的调试工具箱
一个成熟驱动工程师的工作台通常这类装备:逻辑分析仪一台,示波器一台,JTAG调试器一只,以及一个打包了常用调试命令的Linux主机。软件调试工具方面,devmem和devkmem用来直接读写物理内存,在确认寄存器状态时非常方便。
devmem 0xfe560000 32 0x0000ffff devmem 0xfe560004devmem不是万能的,它直接绕过设备的驱动层,可能导致驱动内部状态与硬件不一致,所以只建议在不影响系统稳定性的前提下使用。另一个好用的工具是ftrace,它可以跟踪内核里的函数调用。在I2C/CAN驱动调试中,我经常用它确认驱动里某个函数到底有没有被调用:
echo function > /sys/kernel/debug/tracing/current_tracer echo i2c_transfer > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace/sys/kernel/debug在量产内核里通常关闭,开CONFIG_DEBUG_FS和CONFIG_DYNAMIC_DEBUG后一段时间的开发调试会轻松很多。另外/sys/kernel/debug/dri、/sys/kernel/debug/gpio等子目录对特定子系统也很有用。
再推荐一个非常经典的动态调试机制——dynamic_debug。在编译时开启了CONFIG_DYNAMIC_DEBUG后,可以通过下面命令动态开启某文件的调试输出:
echo "file drivers/i2c/i2c-core-base.c +p" > /sys/kernel/debug/dynamic_debug/control这样不用重新编译内核就能看到对应文件里的dev_dbg输出,比加printk再编译快一个量级。
5.4 常见问题的排查思路汇总
把我在驱动开发里碰到的典型问题汇总成一张速查表,供实际操作时对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| insmod加载失败,lkm提示“Invalid module format” | 内核版本不匹配,或未开启模块支持 | 用uname -r核对KDIR路径 |
| probe没有被调用 | compatible不匹配,或节点disabled | 查看/proc/device-tree确认节点状态 |
| I2C扫描不到设备 | 接线错误、上拉电阻缺失、pinmux错误 | 示波器看SDA/SCL波形 |
| I2C读写时好时坏 | 时钟频率过高、电源不稳定、总线竞争 | 降到100kHz试,查电源纹波 |
| CAN启动报错 | 波特率不匹配、终端电阻缺失 | 用ip -details link show查错误计数 |
| 中断一直触发 | 设备树触发方式配错、IRQF_TRIGGER不一致 | 比较设备树与request_irq参数 |
| 系统启动后dtb加载失败 | dts语法错误、dtc编译告警被忽略 | 用make dtbs检查告警 |
| 内核panic但日志丢失 | 串口没接到console、log_buf太短 | 加console=ttyS0启动参数 |
| GPIO操作无效 | 引脚复用被占用、gpiochip编号不对 | cat /sys/kernel/debug/gpio确认状态 |
这张表解决的是“从现象到方向”的问题,真正定位时还要结合dmesg、逻辑分析仪、动态调试输出逐层验证。凡是能用逻辑分析仪看物理信号的,就不要只靠猜。凡是要改硬件的,先看原理图和勘误表。
6. 一些实际工程上的体会
最后根据自己的开发经验聊几点心得,供正在往嵌入式Linux驱动方向走的朋友参考。
第一,驱动开发的本质不是写代码,而是读文档和读寄存器。datasheet读不懂,代码写得再漂亮也没用。尤其I2C/CAN这类时序型协议,认真阅读芯片手册里的时序图、电气特性、寄存器定义,能省掉大量的盲目尝试。
第二,要习惯“分层排查”。遇到一个功能没打通,先画一条链路图:应用 -> 接口层 -> 驱动 -> 总线 -> 硬件。然后从最接近硬件的那一层开始验证。用户态能做的验证绝不动内核态,内核态能做的验证绝不重新编译整个系统。以I2C为例,先用i2c-tools确认,再写驱动;以CAN为例,先配上位时序,再分析消息收发。每层通了再往上走。
第三,版本管理非常重要。内核源码和dts文件要纳入git管理,而且每次改动都要有记录。很多时候驱动写好了过几天又坏了,一查发现是dts被同事合并分支时覆盖了。没有版本管理,这种事你要白干好几天。
最后,这个领域的学习曲线是真实的,但正反馈也很强。当你看到自己写的第一条I2C消息被设备正确响应、第一条CAN帧在总线上被另一个节点收到时,那种感觉和“跑通一个Web页面”完全不同。它意味着你在跟真实的物理世界打交道,你的代码真的驱动了一颗芯片、一根总线、一台机器。从最简单的内核模块起步,到设备树、I2C、CAN,一条路走下来,你会逐渐熟悉内核的设计哲学:一切皆为文件、配置与代码分离、层次分明、机制与策略分离。这套体系不是一天建成的,但搞懂底层逻辑之后,再换芯片平台、再学SPI/USB/PCIe驱动,就只是换一套接口和协议的问题了。