
做Linux驱动开发这行当最绕不开的一条主线就是“设备是怎么一步步被内核认领然后为上层提供服务的”。从裸板点亮一个LED到在完整SoC上驱动一块I2C传感器再到把CAN总线接入系统中间隔着内核模块、设备树、总线驱动模型这几座大山。很多初学者最容易卡住的地方是单独看某一章都懂但把这些东西串起来就懵了。这篇文章我想沿着一条真实项目里最常见的路径把Linux设备驱动开发从内核模块到设备树、再到I2C/CAN的完整链路拆开讲一遍重点讲清楚各环节之间是怎么衔接的。我从最早写字符设备驱动到后来在ARM平台上调I2C触摸屏、CAN收发器再到把自研设备挂到设备树上踩过的坑不算少。这里不会堆砌API文档而是按一套实际可落地的流程把每个关键节点的原理、代码、配置和排错经验串联起来。无论你是刚从单片机转Linux驱动还是已经在写驱动但一直对设备树和总线模型一知半解这篇文章应该能帮你把整条路径理顺。1. 先把地基打牢内核模块与字符设备驱动框架驱动的最基本形态就是内核模块。虽然现在很多驱动代码直接编进内核但开发阶段几乎都是用模块来调试改代码、加载、卸载、看日志循环往复效率最高。理解内核模块是你进入Linux驱动世界的第一个门槛。1.1 模块的“入口”和“出口”不是随便写的每个内核模块都有两个函数加载入口和卸载出口。用宏来声明这是固定套路。#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init my_driver_init(void) { printk(KERN_INFO my_driver: module loaded\n); return 0; } static void __exit my_driver_exit(void) { printk(KERN_INFO my_driver: module unloaded\n); } module_init(my_driver_init); module_exit(my_driver_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(A simple Linux kernel module);__init和__exit这两个标记很多人不理解其实它们是为了省内存。__init标记的函数在初始化完成后所占内存会被内核释放掉因为驱动加载后大概率不会再调用这个入口。__exit同理如果驱动被编进内核而不是模块卸载路径根本不存在这个函数就直接被丢弃。我之前见过有人把__init宏去掉运行完全正常但内核启动日志里会多一点警告信息而且白白占用一小段内存。在嵌入式设备上内存就是资源养成写__init的好习惯是专业素养。1.2 file_operations用户态和内核态之间的桥字符设备的核心是file_operations结构体它把用户态调用的open、read、write、ioctl等系统调用映射到内核态的具体函数。static int my_dev_open(struct inode *inode, struct file *filp) { pr_info(my_dev: open\n); return 0; } static ssize_t my_dev_read(struct file *filp, char __user *buf, size_t count, loff_t *offset) { char kernel_buf[64] hello from kernel\n; size_t len strlen(kernel_buf); if (count len) return -EINVAL; if (copy_to_user(buf, kernel_buf, len)) return -EFAULT; return len; } static ssize_t my_dev_write(struct file *filp, const char __user *buf, size_t count, loff_t *offset) { char kernel_buf[128]; if (count sizeof(kernel_buf)) return -EINVAL; if (copy_from_user(kernel_buf, buf, count)) return -EFAULT; kernel_buf[count] \0; pr_info(my_dev: received %s\n, kernel_buf); return count; } static const struct file_operations my_dev_fops { .owner THIS_MODULE, .open my_dev_open, .read my_dev_read, .write my_dev_write, };这里有个新手极易踩的坑直接用copy_from_user往内核缓冲区拷贝。如果用户态传过来的指针是野指针或者地址范围不合法驱动会直接导致内核崩溃。copy_from_user和copy_to_user内部会做地址合法性检查并且处理页面缺失的情况所以凡是涉及用户态指针的读写一律走这两个函数不要图省事用memcpy。另一个注意点就是“用户态和内核态的数据边界”。用户态传进来的缓冲区指针在内核态不能直接解引用因为进程的地址空间映射不同这就是为什么必须有copy_to_user/copy_from_user这层中转。1.3 从module_init到class_create一条完整注册链一个字符设备要想在/dev下看到节点并且让应用层能打开它注册流程比想象中要多几个步骤。#include linux/fs.h #include linux/cdev.h #include linux/device.h #define MY_DEV_MAJOR 0 /* 0表示动态分配主设备号 */ static int major; static struct cdev my_cdev; static struct class *my_class; static struct device *my_device; static int __init my_driver_init(void) { dev_t dev_num; int ret; /* 1. 动态分配设备号 */ if (MY_DEV_MAJOR 0) { ret alloc_chrdev_region(dev_num, 0, 1, my_dev); if (ret 0) { pr_err(failed to alloc chrdev region\n); return ret; } major MAJOR(dev_num); } else { dev_num MKDEV(MY_DEV_MAJOR, 0); ret register_chrdev_region(dev_num, 1, my_dev); if (ret 0) { pr_err(failed to register chrdev region\n); return ret; } major MY_DEV_MAJOR; } /* 2. 初始化cdev并添加到内核 */ cdev_init(my_cdev, my_dev_fops); my_cdev.owner THIS_MODULE; ret cdev_add(my_cdev, dev_num, 1); if (ret 0) { pr_err(failed to add cdev\n); unregister_chrdev_region(dev_num, 1); return ret; } /* 3. 创建class用于在/sys/class下生成目录 */ my_class class_create(my_dev_class); if (IS_ERR(my_class)) { pr_err(failed to create class\n); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_class); } /* 4. 创建设备节点自动出现在/dev下 */ my_device device_create(my_class, NULL, dev_num, NULL, my_dev); if (IS_ERR(my_device)) { pr_err(failed to create device\n); class_destroy(my_class); cdev_del(my_cdev); unregister_chrdev_region(dev_num, 1); return PTR_ERR(my_device); } pr_info(my_driver: loaded, major%d\n, major); return 0; }很多人在Linux 6.4之后编译旧代码会遇到class_create报错因为新内核把class_create改成了只需要传一个参数不再传THIS_MODULE。这是内核API演进造成的差异遇到编译错误先查内核版本对应的API变更这是驱动开发的基本功。device_create最后一个参数就是/dev下设备节点的名字。这里用的是my_dev如果你希望给设备节点加索引比如my_dev0、my_dev1那就要用device_create配合类似MKDEV(major, minor)的方式去创建多个次设备号。1.4 动态加载与调试的实用技巧编译模块时Makefile有个经典范式obj-m : my_dev.o KERNEL_DIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNEL_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERNEL_DIR) M$(PWD) clean这里有个关键点$(MAKE) -C $(KERNEL_DIR) M$(PWD) modules这一行-C指定内核源码目录M指定模块源码目录二者缺一不可。如果你在Ubuntu这类发行版上开发需要先安装linux-headers-$(uname -r)包否则/lib/modules/$(uname -r)/build这个软链是不存在的。加载模块用insmod查看日志用dmesg。但insmod不会自动解决模块依赖如果模块之间有依赖关系要用modprobe。modprobe会扫描/lib/modules/$(uname -r)下的模块依赖文件.modules.dep自动加载依赖模块。调试驱动时的第一篇日志永远是dmesg。但要注意在开发板上如果同时跑了很多应用dmesg会被大量刷屏用dmesg | tail -n 50只看最近输出效率会高很多。2. 设备树硬件拓扑的“说明书”也是驱动和设备的“媒人”如果说内核模块是驱动的代码形态那设备树就是驱动和硬件之间的“胶水”。设备树解决了ARM Linux时代最大的痛点没有标准机制来描述板级硬件差异导致内核里堆满了各种板级补丁代码维护难度爆炸。2.1 设备树为什么会出现早期ARM Linux的BSP板级支持包里每次换一块新板子就要改arch/arm/mach-xxx下面的代码重新编译内核。同一个SoC可能被几十家厂商做成成百上千种开发板每个板子的GPIO分配、时钟配置、外设地址都不同全部写成C代码每次改动都要重编内核而且代码互相污染严重。设备树的思路是把硬件描述从内核代码中剥离出来用独立的数据文件来描述“这块板子上有什么硬件、挂在什么地址、用哪个中断”。内核启动时会解析设备树根据compatible属性匹配驱动。换板子就换设备树不用重编内核驱动代码也能做到“一次编写多处运行”。你可以把设备树理解成一张“硬件接口表”。驱动代码是“服务流程”设备树是“客户需求单”。服务流程不需要知道客户具体是谁只看需求单上的接口信息。2.2 节点、属性、compatible三件事搞懂设备树语法设备树文件后缀是.dts头文件是.dtsi。.dtsi通常放SoC级的内容比如串口控制器的基地址、中断控制器等.dts放板级的内容比如外接的传感器、触摸屏、LED等。/dts-v1/; #include rk3568.dtsi / { model My RK3568 Board; compatible my,rk3568-board; chosen { stdout-path uart2; }; leds { compatible gpio-leds; status-led { label status; gpios gpio0 RK_PB7 GPIO_ACTIVE_HIGH; default-state on; }; }; my_i2c_sensor48 { compatible my,pressure-sensor; reg 0x48; interrupt-parent gpio1; interrupts RK_PA3 IRQ_TYPE_EDGE_FALLING; }; }; i2c3 { status okay; clock-frequency 400000; touchscreen38 { compatible my,touchscreen; reg 0x38; irq-gpios gpio3 RK_PC2 GPIO_ACTIVE_LOW; }; };节点命名格式是名字地址后面跟的是该设备在总线上的地址对I2C设备是7位从机地址对内存映射设备是物理基地址。地址不是必须的但推荐写因为同一总线下同类型设备地址不能重复这个地址也是驱动用来区分实例的锚点。compatible是最核心的属性它决定了驱动和设备怎么配对。内核驱动注册时会通过of_match_table里的compatible字符串去匹配设备树节点的compatible属性。字符串匹配规则是从左往右选最具体的比如节点写my,pressure-sensor, pressure-sensor驱动程序可以先匹配my,pressure-sensor如果匹配不上再退而匹配pressure-sensor。reg、interrupts、gpios这些是设备树节点的“资源”驱动通过platform_get_resource、of_property_read_u32等API来获取。我见过很多新手在节点里写了GPIO属性驱动里却硬编码GPIO号这是完全错误的做法。硬编码意味着换板子就得改驱动设备树的价值就没了。2.3 硬件描述与驱动匹配的完整链路一个设备从设备树到驱动被探测的完整链路是这样的内核启动时解析设备树为每个节点创建struct device挂在相应的总线上。比如I2C子节点会创建struct i2c_client平台设备会创建struct platform_device。设备驱动模块加载时会注册自己的driver内核会去总线上遍历所有挂着但没绑定的device比对compatible字符串匹配成功就调用驱动的probe函数。probe函数中驱动根据设备树节点的信息来配置硬件。比如读reg拿到I2C从地址读interrupts拿到中断号再通过request_irq注册中断处理函数。这里的核心是platform_driver和platform_device的配对过程。对大多数“挂内部总线”的设备来说它们都会注册为platform_driver设备树中的普通节点也会被转换为platform_device。驱动模型的匹配机制把这个过程完全自动化了。static const struct of_device_id my_dt_match[] { { .compatible my,pressure-sensor, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_dt_match); static struct platform_driver my_platform_driver { .probe my_dev_probe, .remove my_dev_remove, .driver { .name my_dev, .of_match_table my_dt_match, }, }; module_platform_driver(my_platform_driver);module_platform_driver这个宏是做两件事的简写注册platform_driver到平台总线并在模块卸载时注销。用这个宏的前提是驱动里必须有probe和remove两个函数这是平台驱动的基本结构。我在RK3568上调驱动时最常遇到的别扭事就是设备树节点写了驱动也注册了但probe就是不执行。这种问题十有八九是compatible字符串两边不一致。设备树里的compatible是多行时编译后是多个字符串驱动里要用同一个字符串去匹配写错一个字符就全部失效。2.4 调试设备树的常用手段设备树调试最常见的手段是从/proc/device-tree下查看运行时设备树。这个目录是设备树在内存中的展开形式里面每个节点对应一个目录每个属性对应一个文件。# 查看某个节点下有什么属性 ls /proc/device-tree/my_i2c_sensor48/ # 查看compatible属性内容十六进制显示 hexdump -C /proc/device-tree/my_i2c_sensor48/compatible另一个实用技巧是反编译设备树二进制文件。如果你的开发板提供了.dtb文件可以用dtc工具反编译成.dts源码dtc -I dtb -O dts -o output.dts my_board.dtb在调试“设备树下中断号”问题时/proc/interrupts是排查核心中断的重要手段。看看你的设备中断有没有注册上是否被其他设备抢占了中断号一目了然。3. I2C子系统把协议栈和驱动拆开看I2C大概是嵌入式开发中最常碰到的总线了触摸屏、温度传感器、EEPROM、PMIC全是I2C设备。Linux的I2C驱动框架设计得比较优雅分成了适配器adapter、核心层core和设备驱动client driver三层。三层各司其职新设备接入时只需要关心client driver这一层其他两层基本不用动。3.1 I2C物理层和时序I2C只有两根线时钟SCL和数据SDA。通信由主机发起先发START信号SCL为高时SDA拉低然后发送7位从机地址加一位读写标志从机收到地址后拉低SDA作为ACK应答。后面的数据字节都是9个时钟周期前8位数据第9位ACK。结束通信时发送STOP信号SCL为高时SDA拉高。I2C时序看起来简单但有几个容易出问题的细节。一个是SDA线电平变化必须发生在SCL为低时如果SCL为高时SDA不小心变化了会被识别成START或STOP信号导致通信错乱。另一个是速度参数标准模式100KHz快速模式400KHz高速模式3.4MHz。设计板子IO时上拉电阻阻值很重要上拉太小灌电流过大太大会让沿变缓影响时序。我实际调I2C时最麻烦的不是代码而是硬件信号质量。只要波形上升沿太缓就会导致从机识别不到数据。用示波器看SDA和SCL波形是排查这类问题的第一选择。如果看到上升沿拉成圆弧大概率是上拉电阻阻值偏大或者总线挂载设备过多需要调整上拉阻值或降低总线速度。3.2 内核I2C子系统的三层结构Linux的I2C子系统可以拆成三条路径来看适配器层对应I2C控制器硬件负责在物理总线上产生时序。以struct i2c_adapter来抽象它的核心是struct i2c_algorithm其中定义master_xfer函数指针负责具体的事务传输。SoC的I2C控制器驱动基本上芯片原厂都已经写好了基本不用动。核心层提供统一的传输接口屏蔽适配器差异。驱动调用来数据传输时核心层会找到一个合适的适配器并把消息变成底层算法能处理的格式。核心层还负责管理设备与驱动的匹配。设备驱动层就是针对具体芯片写的驱动比如at24EEPROM驱动、bmp280气压传感器驱动。这层只需要注册自己的设备和驱动通过核心层提供的i2c_transfer接口收发数据。这三层结构解决的核心问题是“隔离变化”。芯片读写的业务逻辑封装在设备驱动层控制器时序的差异封装在适配器层中间层做匹配和路由。所以换了一款新传感器只需要写一个设备驱动层控制器适配器完全不用关心。相反换了一颗新主控芯片只要适配器层驱动写好了市面上已有的所有I2C设备驱动都能直接复用。3.3 从设备树到i2c_client的注册过程I2C设备不再通过platform_driver来匹配而是通过i2c_driver。当设备树中某个I2C控制器节点下面存在子节点时I2C核心会在总线注册时解析这些子节点自动创建i2c_client。static const struct i2c_device_id my_i2c_id[] { { my-pressure-sensor, 0 }, { } }; MODULE_DEVICE_TABLE(i2c, my_i2c_id); static const struct of_device_id my_i2c_of_match[] { { .compatible my,pressure-sensor, }, { } }; MODULE_DEVICE_TABLE(of, my_i2c_of_match); static struct i2c_driver my_i2c_driver { .driver { .name my-pressure-sensor, .of_match_table my_i2c_of_match, }, .probe my_i2c_probe, .remove my_i2c_remove, .id_table my_i2c_id, }; module_i2c_driver(my_i2c_driver);probe函数中通过struct i2c_client *client来获取设备树中的信息。client-addr就是从设备树reg属性解析出来的从机地址client-irq则是中断信息。static int my_i2c_probe(struct i2c_client *client, const struct i2c_device_id *id) { struct my_sensor_dev *dev; if (!i2c_check_functionality(client-adapter, I2C_FUNC_I2C)) return -EOPNOTSUPP; dev devm_kzalloc(client-dev, sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-client client; i2c_set_clientdata(client, dev); /* 初始化传感器 */ my_sensor_init(client); /* 注册miscdevice或input device等 */ return 0; }i2c_check_functionality用于确认适配器是否支持某种传输模式。比如有些模拟I2C的GPIO适配器不支持SMBus的块传输如果用i2c_smbus_read_block_data就返回错误。这个检查在驱动里是一个好习惯能避免遇到不兼容适配器时崩溃。3.4 编写I2C客户端驱动的数据收发I2C设备驱动中最常做的操作就是读寄存器和写寄存器。以EEPROM为例写操作先发设备地址再发送寄存器地址然后发送数据字节读操作则要先写寄存器地址再重新开始一次读操作。static int my_i2c_write_reg(struct i2c_client *client, u8 reg, u8 val) { u8 buf[2] { reg, val }; struct i2c_msg msg { .addr client-addr, .flags 0, .len 2, .buf buf, }; return i2c_transfer(client-adapter, msg, 1); } static int my_i2c_read_reg(struct i2c_client *client, u8 reg, u8 *val) { struct i2c_msg msgs[2]; u8 reg_buf reg; /* 写寄存器地址 */ msgs[0].addr client-addr; msgs[0].flags 0; msgs[0].len 1; msgs[0].buf reg_buf; /* 读数据 */ msgs[1].addr client-addr; msgs[1].flags I2C_M_RD; msgs[1].len 1; msgs[1].buf val; return i2c_transfer(client-adapter, msgs, 2); }i2c_transfer的返回值是成功传输的消息数量。如果返回1而你期望的是2说明读时序的第二段没完成这在时序设计有误时经常出现。不能只看返回值是不是负数要让返回值等于消息数量才算完全成功。有一个经典的I2C“坑”组合事务中不同消息之间保持总线占用即不产生STOP信号。这在i2c_transfer的连续消息数组中是默认行为第一个消息结束后不会发STOP而是发一个重复的START。这正好对上很多传感器读寄存器时的“伪写后读”时序。如果你自己在用户态用i2c-tools模拟这个行为需要非常小心很多用户态工具不会自动维持总线占用。4. CAN驱动SocketCAN框架下的另一种玩法CAN总线在汽车电子和工业控制里用得非常多。Linux对CAN的支持没有一个独立字符设备框架而是复用了网络设备框架形成了一套叫SocketCAN套接字CAN的机制。初学CAN驱动的人常被这个设计吓到其实理解了之后会发现这个设计非常聪明。4.1 为什么CAN驱动走的是网络设备框架CAN设备本质上是一个“多主机通信接口”它和以太网一样属于“网络层设备”。Linux网络设备框架已经成熟地解决了很多问题数据收发队列、中断处理、流量控制、多路复用等。CAN走网络设备框架意味着应用层可以像操作网络套接字一样操作CAN口用标准的socket接口来收发报文。# 设置CAN口波特率为500K ip link set can0 up type can bitrate 500000 # 查看CAN口状态 ip -details link show can0 # 用candump监听所有报文 candump can0 # 用cansend发送一帧报文 cansend can0 123#DEADBEEF感受到没有can0就像一个网口一样被管理。这就是SocketCAN的设计哲学。通过socket接口收发CAN帧应用代码和驱动代码的解耦非常彻底。对驱动开发来说CAN驱动要做的事情是注册一个struct net_device实现netdev_ops里的ndo_open、ndo_stop、ndo_start_xmit等函数并处理中断收发和错误状态。这套流程跟写一个简易网卡驱动极其相似。4.2 CAN控制器的设备树配置以常见的MCP2515 SPI转CAN芯片为例对应的设备树节点长得像这样spi1 { status okay; mcp2515: can0 { compatible microchip,mcp2515; reg 0; clocks mcp2515_osc; interrupt-parent gpio4; interrupts RK_PC4 IRQ_TYPE_LEVEL_LOW; spi-max-frequency 10000000; bosch,mode can; }; }; mcp2515_osc: mcp2515_osc { compatible fixed-clock; #clock-cells 0; clock-frequency 16000000; };MCP2515挂在SPI总线上但它的本质是一个CAN控制器所以在设备树里不是挂在SPI控制器下就完事驱动会根据compatible识别出它是CAN设备在probe中调用alloc_candev来注册一个CAN网络设备。很多SoC自带CAN控制器比如STM32MP1系列和瑞芯微的部分型号。这类原生CAN控制器一般直接挂在内部总线上设备树节点会简单很多can1 { status okay; pinctrl-names default; pinctrl-0 can1_pins; };原生CAN的一大优势是不用现场总线速率转换速率直接由CAN控制器硬件CLK分频得到不需要外部晶振。所以设备树里通常只需要确认引脚复用和状态即可。4.3 从注册到收发的完整路径CAN驱动中注册net_device的关键函数是alloc_candev。它专门为CAN设备分配网络设备结构体并额外分配CAN私有数据结构。static int mcp251x_probe(struct spi_device *spi) { struct net_device *net; struct mcp251x_priv *priv; net alloc_candev(sizeof(struct mcp251x_priv), 1); if (!net) return -ENOMEM; priv netdev_priv(net); priv-net net; priv-spi spi; spi_set_drvdata(spi, priv); net-netdev_ops mcp251x_netdev_ops; net-flags | IFF_ECHO; return register_candev(net); }netdev_ops里最关键的是ndo_open和ndo_start_xmit。ndo_open做CAN控制器硬件初始化设置波特率、开启中断、启动CAN控制器。ndo_start_xmit负责把上层传入的skb转换成CAN帧通过SPI发送到控制器。static netdev_tx_t mcp251x_start_xmit(struct sk_buff *skb, struct net_device *net) { struct mcp251x_priv *priv netdev_priv(net); struct can_frame *frame (struct can_frame *)skb-data; int ret; ret mcp251x_write_frame(priv, frame); if (ret) { netdev_err(net, failed to write frame, err%d\n, ret); return NETDEV_TX_BUSY; } net-stats.tx_packets; net-stats.tx_bytes frame-can_dlc; dev_kfree_skb(skb); return NETDEV_TX_OK; }接收侧则在中断处理函数中读取CAN控制器数据构造skb和can_frame然后调用netif_rx把收到的帧交给网络协议栈。static irqreturn_t mcp251x_can_irq(int irq, void *dev_id) { struct net_device *net dev_id; struct mcp251x_priv *priv netdev_priv(net); struct sk_buff *skb; struct can_frame *frame; int ret; while (!mcp251x_irq_empty(priv)) { skb alloc_can_skb(priv-net, frame); if (!skb) { net-stats.rx_dropped; continue; } ret mcp251x_read_frame(priv, frame); if (ret) { kfree_skb(skb); net-stats.rx_errors; continue; } net-stats.rx_packets; net-stats.rx_bytes frame-can_dlc; netif_rx(skb); } return IRQ_HANDLED; }应用层通过socket收发CAN数据时内核协议栈会在can_rcv函数中根据CAN ID分发数据。CAN报文在Linux内部走网络协议栈是一件令新手费解的事但一旦理解了netif_rx的作用整个路径也就清楚了驱动只负责把数据包送到网络协议栈剩下的按协议类型分发工作交给内核。5. 从内核模块到设备树的一条完整链路串联前面四部分分别拆开讲了内核模块、设备树、I2C、CAN但实际项目里这些知识是交织在一起的。比如你在一颗RK3568的板子上接了一个I2C接口的陀螺仪还要通过CAN和一个电机控制器通信这时要打通全链路需要做的事大概是这样的设计阶段查看原理图确定陀螺仪挂在哪组I2C总线上从机地址是啥CAN控制器是SPI外挂还是SoC内置。把这些信息记录成硬件接口表。内核配置确认内核开启了对应I2C控制器的驱动、CAN子系统、SocketCAN核心。设备树配置在I2C控制器节点下添加陀螺仪子节点配置compatible、reg等属性在SPI节点下添加CAN控制器节点配置中断、时钟等。驱动代码为实现陀螺仪逻辑在既有I2C设备驱动框架中增加一个client driver专门负责寄存器初始化和数据读取接口。CAN部分如果采用现成芯片如MCP2515则直接复用内核已有驱动一般不需要自己写完整CAN驱动。如果控制器是自研需要按netdev_ops结构补齐收发和中断逻辑。应用对接设备驱动注册成功后用户态程序通过/dev下的设备节点与陀螺仪通信CAN则通过socket与can0通信。这里的关键是设备节点和应用层协议的设计把数据怎么读、怎么解析、报错怎么处理明确下来。很多团队在第一步就翻车。硬件工程师把I2C设备挂在了I2C5上但内核配置里I2C5控制器根本没使能CAN控制器是SPI外挂的但SPI引脚被复用成了GPIO。这些硬编码在设备树或驱动里的寄存器配置肉眼很难发现需要结合原理图和内核日志交叉确认。6. 常见问题与排查技巧实录驱动开发中问题排查占用的时间往往远大于编码时间。以下是我在项目里实际遇到并解决的几种高频问题。现象可能原因排查思路insmod报Unknown symbol模块中引用的符号没有导出或者依赖模块未加载用nm查看模块符号确认依赖先加载依赖模块设备树节点存在但驱动probe不执行compatible不匹配或节点status设为disabled检查/proc/device-tree下节点内容对比compatibleI2C传输返回-6ENXIO从机没有ACK地址错误或设备未供电用i2cdetect -y bus扫描地址检查电源和上拉I2C传输返回-70EREMOTEIO传输超时可能是总线上存在干扰或设备被拉死用示波器看波形是否有毛刺或电平异常CAN接口ip link set up失败波特率配置不对或者控制器没有正确复位检查设备树时钟频率确认引脚复用是否正确中断频繁触发CPU占用高CAN接收中断处理里循环读取条件不对检查中断返回前是否清除了标志是否有持续中断源6.1 排查I2C问题的第一步i2cdetect当I2C设备地址不确定或者怀疑设备是否有响应时i2cdetect是最快的验证手段。# 列出当前系统所有I2C总线 i2cdetect -l # 扫描某个总线上的设备地址不写入寄存器 i2cdetect -y 3执行后如果看到48这样的数字出现在网格里说明地址0x48上有设备并且设备正常ACK。如果全空说明设备可能没接线、没供电或者总线地址不对。i2cdetect也能帮忙确认有没有地址冲突这是多设备挂同一总线时非常常见的坑。6.2 排查CAN问题的第一步看启动日志和ip命令CAN设备驱动加载后首先要确认的是网络设备节点有没有注册成功。# 检查是否有can0 ifconfig -a # 检查内核启动日志中CAN驱动打印 dmesg | grep -i can如果can0存在尝试设置波特率并启动。如果启动失败dmesg里面通常会有驱动打印的错误信息比如芯片复位失败、SPI通信失败等。CAN总线的物理连接错误不一定会在启动时报错而是要等到收发时才体现。如果发数据时显示NO-CARRIER说明CAN收发器可能没有终端电阻或者总线没有其他节点在线。CAN标准规定总线两端必须接120欧姆终端电阻少接一个就会让收发器输出异常。6.3 设备树改动后没生效怎么办设备树改完后最大的困惑往往是“为什么我改了没反应”。原因大概率是系统加载的还是旧的设备树。在ARM平台设备树在启动时由bootloader加载。如果你改了.dts文件重新编译后生成的.dtb必须烧写到正确分区或者通过bootloader的加载命令更新。很多情况下你改了文件系统的设备树文件但bootloader根本不从那个位置加载而是从另一个分区读取这就是“改了没用”的直接原因。验证当前生效的设备树用这个命令# 查看当前运行的设备树有没有你的节点 ls /proc/device-tree/ | grep my_sensor只要有节点说明设备树确实被加载了。如果节点在但驱动不工作那问题基本在驱动侧或者硬件侧而不是设备树没生效。6.4 调试驱动的几个小工具除了标准的dmesg和/proc外还有一些工具在驱动调试中非常好用。内核的dynamic_debug机制可以在不重编内核的情况下动态打开某段代码的调试输出# 打开drivers/i2c目录下的动态调试信息 echo file drivers/i2c/* p /sys/kernel/debug/dynamic_debug/controltrace-cmd和ftrace可以跟踪设备驱动的probe调用、中断处理函数等核心路径。有时候驱动卡死或者反复执行用ftrace能快速定位。perf工具则能帮助查看驱动中哪个函数耗时最多。对实时性要求高的CAN通信这个能力至关重要。7. 从字符设备到子系统驱动开发的通用方法写完模块、设备树、I2C、CAN这几个典型场景后你会发现Linux驱动开发其实是围绕一套统一思路展开的先定总线模型再写设备驱动再用设备树设定硬件资源最后用应用层接口衔接业务逻辑。这个思路打通之后换一个外设、换一颗芯片本质都是重复这套框架而已。7.1 驱动开发的“数据流”思维无论哪种外设驱动做的事情都可以概括成两条数据流。一条是“用户态到硬件”的控制流应用层调用接口驱动把请求翻译成总线时序硬件执行。另一条是“硬件到用户态”的事件流硬件产生中断或数据驱动在中断上下文快速完成处理然后通过某种机制通知应用层。这两条流设计得好不好直接决定了驱动质量。很多驱动稳定性差就是因为收发数据共享了同一个锁或者同一个缓冲区中断上下文和进程上下文互相踩踏。一个严格的做法是接收侧用独立的环形缓冲区中断处理函数只负责写缓冲区并唤醒等待队列进程上下文才做耗时处理。我在MCP2515的CAN驱动中踩过相似的坑中断处理函数里直接调用SPI传输函数结果SPI传输带睡眠等待而中断上下文不允许睡眠导致内核报“BUG: scheduling while atomic”。后面改成把SPI收发放到工作队列里问题才解决。凡是中断上下文禁止睡眠的地方都不能直接调用可能睡眠的API这是Linux驱动的铁律。7.2 读内核源码比看文档重要驱动开发和普通应用开发最大的不同是内核API变化无常网上很多文章写的是老版本代码拿到新内核上编译直接报错。最靠谱的参考资料永远是内核源码本身。比如你在网上搜到一段struct class *cls class_create(THIS_MODULE, hello)的代码在Linux 6.4之前的版本是正确的但6.4之后class_create只剩一个参数了。这种情况看源码一目了然网上搜索浪费时间还不一定搜对。# 在源码里查找函数定义 grep -r class_create kernel/include/linux/device/class.h # 搜索内核版本变更记录 git log --oneline -- kernel/include/linux/device/class.h读内核源码时不要从头看到尾核心索引文件已经足够。比如I2C的i2c-core.h、i2c.hCAN的net/can/af_can.c设备树的of.h这些头文件往往包含了数据结构、API声明和大量注释比任何第三方教程都准确。7.3 从驱动到整个系统的视角驱动开发做到后面拼的往往不是代码能力而是对整个系统的理解。你得知道自己写的驱动占了多少内存、中断频率会不会太高、DMA缓冲区该怎么分配、和电源管理框架怎么配合、系统休眠唤醒时驱动要做什么。在省电场景下驱动必须实现suspend和resume回调在系统休眠时把外设进入低功耗模式唤醒时恢复。如果没有正确实现这两个回调系统休眠后外设可能还处于运行状态白白耗电或者唤醒后外设配置丢失无法正常工作。设备树、时钟框架、引脚复用、中断控制器、电源域这些子系统组合起来才算一个完整的嵌入式Linux平台。驱动开发者如果对这些子系统有全局认识调试问题时定位速度会成倍提升。8. 给新入行驱动开发的几个实在建议文章最后基于我自己踩过的坑给刚开始做Linux驱动开发的朋友一些建议。第一个建议是从“抄”开始。不要上来就写一个巨复杂的驱动先去内核源码里找功能最接近的驱动读懂它的probe、读写函数、中断处理在它基础上改。内核里的drivers/i2c/chips已并入其他目录、drivers/misc、drivers/net/can下大量现成驱动都是很好的学习模板。第二个建议是学会用“二分法”缩小问题范围。设备不起来先确认是硬件问题还是软件问题。硬件问题用示波器量波形软件问题先确认设备树有没有生效再确认驱动有没有probe再确认传输数据对不对。不要在一个环节上死磕太久尤其是不要连续几个小时改代码重新编译先停下来收集信息。第三个建议是有意识积累一个“常用工具包”。我的工具包里包括i2cdetect、i2cget、i2cset、candump、cansend、dtc、hexdump、devmem2、trace-cmd。这些工具能应对大多数驱动调试场景。其中devmem2可以在用户态直接读写物理地址验证寄存器配置查可疑映射解决大问题。最后一个建议是保持对内核版本的敏感。Linux内核从5.x到6.x驱动API变动很快。你在一个版本上调通的代码到另一个版本可能编译不过甚至运行行为都变了。开发时尽量固定内核版本升级时要重点关注内核自带的驱动变更说明Documentation/目录下的变更日志和驱动维护者的提交说明。我见过太多人拿着老代码硬编译新内核改宏定义改到崩溃最后发现实际上新内核已经提供了新接口来解决他的问题。驱动开发这条路入门不难真正难的是在复杂的系统交互中保持清醒的头脑。每次出问题时多问几个“为什么”从原理上理解清楚而不是靠“试错法”碰运气你会进步得更快。