ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux驱动开发:内核模块、设备树与I2C/CAN实战

嵌入式Linux驱动开发:内核模块、设备树与I2C/CAN实战 一个驱动工程师的日常通常是从一张原理图开始的。拿到一款新板子上面有传感器、OLED屏、编码器、电机控制器它们有的走I2C有的走CAN而你要做的事情就是让Linux系统意识到这些设备的存在并且让上层的应用程序能顺畅地读写它们。这条路径听起来像是“编译一下内核、改一下设备树”就能搞定但实际上真正决定你效率的是对内核模块、设备树、I2C子系统以及CAN子系统这几条线的完整理解。这篇文章不打算做成教科书式的长篇大论而是把我在多个项目里趟过的路梳理出来从内核模块怎么写到设备树怎么配置再到I2C和CAN设备驱动怎么落地以及调试时到底该看哪里、排查问题的顺序是什么。如果你正打算入行嵌入式Linux驱动开发或者已经在做相关项目但总感觉“差点意思”这篇文章应该能帮你把整条链路串起来。我会尽可能把实操中的细节和踩坑经历写出来而不是停留在概念层面。1. 设备驱动开发的整体路径为什么内核模块、设备树、I2C/CAN是一条线1.1 应用工程师与驱动工程师看到的是同一个设备的两个侧面很多刚接触Linux驱动的人会问既然Linux内核已经有那么多现成的驱动为什么还要自己写这个问题背后其实是应用开发与驱动开发的视角差异。应用工程师眼中的设备是/dev/i2c-1、/dev/can0这样的节点他们调用read()、write()、ioctl()压根不关心底层是哪个厂家、哪颗芯片。而驱动工程师眼中的设备是物理地址、寄存器、中断号、时钟频率、总线时序。同一个传感器在裸机平台上可能只需要操作几个寄存器但在Linux上你要让它被设备模型管理起来要对接I2C核心要处理电源域、复位脚、中断触发方式还要考虑并发访问和休眠唤醒。这也是为什么驱动开发不能只学C语言和内核API你得懂硬件。查原理图、看数据手册、用示波器抓波形这些能力在驱动调试里比写代码更关键。没有硬件视角驱动代码写得再漂亮也可能在板子上跑不起来。1.2 一条需求的完整旅程从设备树到用户态我们假设一个常见的需求板子上有一颗I2C接口的温度传感器比如LM75需要让上层应用周期读取温度。这条路完整走一遍大概是这样的看原理图确认这颗芯片挂在哪个I2C控制器上地址是多少是7位地址还是10位地址有没有中断脚、复位脚、使能脚。在设备树里描述这颗芯片把它作为I2C控制器节点的子节点挂上去并填好compatible、reg、interrupt等属性。内核启动时I2C核心解析设备树发现这个子节点于是创建一个i2c_client设备。驱动侧先看内核里有没有现成的驱动比如lm75驱动如果有只需要确认compatible匹配即可如果没有就自己实现一个i2c_driver在probe函数里初始化设备并向内核注册一个hwmon或miscdevice接口。应用层就能通过/sys/class/hwmon/hwmonX/temp1_input或/dev/xxx读到温度值。在这个过程中内核模块、设备树、I2C子系统三个知识点全部用上了。CAN设备的路径也类似只是总线类型换成了CAN控制器数据链路走的是SocketCAN而不是I2C的读写字序列。1.3 平台差异与国产化带来的个性化问题最近几年国产化平台的使用频率越来越高瑞芯微RK3568、RK3588全志T113复旦微FMQL系列等都成了嵌入式项目里的常见主控。这些平台的共性问题是SDK基本都是“芯片原厂魔改过的内核”设备树的组织方式各不相同。瑞芯微喜欢把dtsi拆得很细而复旦微的FMQL系列做得像Zynq的Petalinux风格。最近被很多人搜的“linux fmsh-fmqlmp.dtsi i2c emio”“如何将ad9361原有设备树移到新建petalinux工程里”本质上是同一个问题拿到一个不熟悉的SoC怎么把它的设备树在平台上适配好。所以学习设备驱动不能只盯着通用的LDD3还得能读懂各家BSP里的dtsi文件。理解了设备树语法再去看瑞芯微、复旦微、Xilinx的文档就能省下大量东翻西找的时间。2. 内核模块驱动的基本载体2.1 一个最小模块的完整结构内核模块是Linux驱动的基本载体它的形式是一个.ko文件可以在运行中的内核里动态加载和卸载。字符设备、I2C设备、CAN设备最终都表现为一个或多个模块即使你没编译成模块而是编进内核镜像代码结构也是一样的。一个最小模块长这样#include linux/init.h #include linux/module.h #include linux/kernel.h static int __init demo_init(void) { printk(KERN_INFO demo: module loaded\n); return 0; } static void __exit demo_exit(void) { printk(KERN_INFO demo: module unloaded\n); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name youexample.com); MODULE_DESCRIPTION(A minimal demo module);注意几个细节__init和__exit是内核提供的节区属性宏。__init标记的函数在模块加载完成后就会释放掉节省内存__exit只在模块卸载时使用如果把代码编译进内核而不是模块__exit函数会被忽略。MODULE_LICENSE(GPL)不只是法律声明它还关系到你能使用哪些内核导出符号。很多内核函数声明为EXPORT_SYMBOL_GPL如果你的模块不是GPL协议链接阶段会报“unknown symbol”错误。module_init与module_exit是模块的注册入口不是函数名本身。新手常犯的错误是把函数名命名为init_module然后又在文件里写module_init(init_module)其实这是历史遗留的兼容写法现在规范一点直接用module_init(入口函数名)就行。2.2 Makefile交叉编译与模块参数编译内核模块不是用gcc直接编译而是通过内核的构建系统。常见Makefile写法obj-m : demo.o KERNELDIR : /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M$(PWD) clean如果是交叉编译加两行ARCH ? arm64 CROSS_COMPILE ? aarch64-linux-gnu-注意KERNELDIR指向的必须是与目标板运行内核版本一致的内核源码目录或内核头文件目录。最常见的加载失败原因就是模块编译用的内核版本和正在运行的版本不一致导致version magic不匹配。模块参数也是驱动开发里很实用的机制。比如你写了一个红外人感模块驱动希望红外检测阈值可以通过insmod时指定就可以这样定义static int threshold 50; module_param(threshold, int, 0644); MODULE_PARM_DESC(threshold, detection threshold value);这样就可以用insmod ir_drv.ko threshold80来覆盖默认值。如果权限位是0644运行后还能在/sys/module/ir_drv/parameters/threshold里动态修改配合echo 100 threshold就能立即生效非常适合现场调试。2.3 加载卸载与依赖关系管理加载模块时有人喜欢insmod xxx.ko有人喜欢modprobe xxx。区别在于insmod只加载你指定的模块不做依赖解析modprobe会按依赖关系自动加载多个模块前提是执行过depmod生成了模块依赖信息。按顺序说insmod demo.ko加载单个模块不处理依赖。rmmod demo卸载模块。注意不是rmmod demo.ko直接写模块名就行。modprobe demo会查找/lib/modules/$(uname -r)/下的modules.dep先加载依赖模块再加载本模块。lsmod看当前模块列表信息实际来自/proc/modules。modinfo demo.ko查看模块信息包括作者、描述、依赖和参数。调试阶段我更推荐insmod/rmmod少一层依赖干扰错误直观发布阶段或机器启机自加载才用modprobe因为能自动处理总线驱动与设备驱动的顺序问题比如某些驱动依赖i2c-core写进/etc/modules并用modprobe加载会更稳。2.4 printk日志级别与动态调试驱动世界没有printf调试基本靠printk。printk的日志级别可以从高到低排KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG。想让自己打印的内容稳定出现在串口终端上最省心的写法是printk(KERN_ERR ...)或printk(KERN_WARNING ...)。KERN_INFO级别通常会被 console 的日志级别过滤掉你在地铁上盯着串口拼命敲回车也看不到输出急出一身汗最后发现只是级别问题。查看和设置console日志级别有两个办法cat /proc/sys/kernel/printk echo 8 /proc/sys/kernel/printk/proc/sys/kernel/printk里的四个值分别对应console log level当前串口能打印的最低级别、default message log level、minimum console log level、default console log level。调试期直接把第一个值设成8所有printk都能打出来。更进阶的方式是使用内核的dynamic debug即pr_debug()。启用后可以在运行时通过debugfs精细控制打印哪些文件、哪些函数而不是重新编译整个模块。不过对新手来说先掌握printk级别和dmesg过滤就已经比大多数人强了。2.5 模块开发中常见的坑与排查思路第一个坑是“unknown symbol”。模块插入时提示某个符号找不到十有八九是内核没有导出该符号或者导出范围是EXPORT_SYMBOL_GPL而你的模块不是GPL。解决方式是查内核源码里的EXPORT_SYMBOL确认接口是否可用或者换一个公共API。第二个坑是“module verification failed”。内核开启了模块签名校验而你编译的模块没有签名。常见于严格的安全方案或安卓内核关掉CONFIG_MODULE_SIG或在BSP里配置系统证书。工作上如果拿到的SDK默认开了签名我还建议先查BSP文档里怎么生成和导入签名私钥别急着改内核配置否则量产阶段出问题很难解释。第三个坑是内存越界。驱动代码运行在内核态一次非法访问直接导致系统重启而不像应用程序那样报段错误。排查时只能靠逐步打印缩小范围或者用KASAN如果平台打开了来辅助。日常开发里但凡数组操作、缓冲区拷贝我习惯写完后立刻自查一遍size别迷信“就几行代码而已不会出问题”。第四个坑是并发。这个放到第7章面试题里展开但这里必须提醒驱动里一旦有共享资源就应考虑自旋锁、互斥锁或原子操作不要因为“我的设备很慢不会并发”就偷懒。3. 设备树现代嵌入式Linux的硬件清单3.1 为什么设备树成为了“规定动作”早期ARM Linux硬件信息是一堆C代码写在arch/arm/arch-xxx/mach-xxx.c里换一块板子或者调整一个GPIO都要重新编译内核代码越积越多各种板级文件互相复制。设备树Device Tree出现后硬件描述信息和内核代码分离了。一块板子上的CPU型号、内存基址、外设地址、中断号、引脚复用、时钟频率都以节点node和属性property的形式写在后缀为.dts、.dtsi的文本里编译生成.dtb文件启动时由bootloader传递给内核。用大白话说设备树就是硬件的“配置清单”内核负责“接单插件”设备树负责告诉内核“你有哪些硬件、它们长在哪、各自什么脾气”。我刚接触时觉得设备树是个老大难原因是有太多的厂商属性。后来想明白设备树只是把原来裸机/板级文件里的寄存器地址、中断号、GPIO编号翻译成了结构化的描述思路其实和单片机开发里“定义一个宏表示引脚号”是相通的。3.2 DTS、DTSI、DTB的关系与结构在Android或BSP SDK里你通常会看到许多.dtsi文件。i在这里表示include也就是“被包含的公共片段”。芯片厂商会把CPU、中断控制器、UART、I2C、SPI等芯片内部控制器节点写进soc.dtsi或rk3568.dtsi这类文件里这些节点被称为“SoC级设备描述”。板卡厂商则会在自己的.dts文件里include这些dtsi再补充板级信息比如某颗外部芯片挂在哪个I2C控制器下、GPIO按键编号、LED定义等。它们的关系是这样的.dtsi公共描述可以被多个板卡复用。.dts具体板卡的描述一般 include 了若干.dtsi并覆盖或追加节点。.dtb由.dts及其include链编译生成的二进制文件。.dtbo设备树overlay允许在运行时叠加修改常见于树莓派、多版本扩展板方案。看一段简化的设备树节点/ { model Demo Board; compatible vendor,demo-board; leds { compatible gpio-leds; work_led { label work; gpios gpio4 21 GPIO_ACTIVE_HIGH; default-state on; }; }; i2c1 { status okay; clock-frequency 100000; lm7548 { compatible nxp,lm75; reg 0x48; }; }; };这个例子里leds是根节点下的自定义子节点而i2c1表示在引用并修改来自dtsi的i2c1节点。lm7548是I2C控制器节点下的子设备reg填的是7位I2C地址0x48compatible用来和驱动匹配。3.3 常用属性compatible、reg、interrupts、pinctrl、reset几个必须吃透的属性compatible字符串数组格式一般是厂商,型号内核驱动通过它来匹配设备。比如nxp,lm75。匹配顺序是“从头到尾逐一尝试”所以厂商一般把更具体的型号写在前面把通用型号写在后面。reg设备地址。对I2C/SPI设备来说它表示总线地址对SoC内部外设来说它表示寄存器基地址和长度通常与#address-cells、#size-cells配合使用。不知道这些cell怎么填时去父节点找#address-cells的值即可。interrupts中断号与触发方式。例如interrupts 29 IRQ_TYPE_LEVEL_LOW;表示接到中断控制器29号低电平触发。具体第一个数字的含义要看父中断控制器怎么定义。pinctrl-0、pinctrl-names引脚复用配置。设备树里常见写法是pinctrl-0 i2c1_xfer;后面的i2c1_xfer是一个在pinctrl节点里通过rockchip,pins或pinctrl-single,pins等属性定义的引脚组。reset-gpios复位脚描述。很多传感器、触摸IC、以太网PHY都有一个复位脚驱动probe期间需要控制复位时序。最近被搜索很多的“linux 设备树设置复位信号时间”实际就是指reset-gpios配合GPIO subsystem的延时释放。这类时序不是设备树直接“设置时间”而是驱动里根据设备树获取GPIO后自己安排高/低电平释放延时。常见实现是devm_gpiod_get_optional()和gpiod_set_value()配合fsleep()/msleep()。3.4 设备树编译与运行时检查设备树写好后编译过程一般是SDK构建系统自动完成的但调试时你得知道手动工具.dts转.dtbdtc -I dts -O dtb -o xxx.dtb xxx.dts。.dtb反编译成可读的dtsdtc -I dtb -O dts -o xxx.dts xxx.dtb。查看内核运行时实际使用的设备树/proc/device-tree目录节点即目录、属性即文件。查看某个节点的状态cat /proc/device-tree/soc/i2c1/status。如果你发现设备树修改后启动没生效先确认bootloader用的是不是新的dtb。很多板子启动时bootloader会覆盖或选择另一份dtb或者用存储分区里的dtb而不是你编译的那份。这个问题在调试瑞芯微板子时我碰到过好几次每次改完dts都要反复确认实际加载的是哪个dtb。3.5 设备树相关的实战经验与疑难问题Petalinux工程里怎么迁移AD9361设备树搜到这个问题的通常是在Xilinx/Zynq平台上开发FPGAARM联合方案。AD9361的设备树节点通常要在PL侧的地址范围内定义迁移时要同时改pl.dtsi、AXI IIC/SPI控制器节点以及aliases里的编号否则驱动加载顺序和控制器编号会乱。核心思路是先确认你在新工程里的PL地址映射有没有变再看AD9361驱动源码里到底拿的是哪个节点。RK3568的dtsi组织方式瑞芯微的内核把rk3568.dtsi和rk3568-evb.dtsi拆开板级dts通常只有极少内容很多管脚复用都在dtsi里已经定义好。如果你想占用某个引脚先在这个dtsi里搜索引脚的pinctrl名字避免和其他设备冲突。GPIO冲突是设备树调试里最常见的启动异常明明I2C设备在设备树里写得没问题但probe就是不调用或者控制器直接注册失败。我遇到过一次是因为某个引脚同时被spi节点的pinctrl和i2c节点的pinctrl引用两个设备注册时互相抢占引脚系统日志里出现大片“pin ... already requested”的警告。排查方式就是搜索dtsi里的pinctrl定义确保一个引脚只归属于一个外设。4. I2C子系统从协议到驱动的完整拆解4.1 I2C物理层与协议要点I2C总线两根线SDA数据线和SCL时钟线都是开漏结构需要上拉电阻。开漏带来一个好处多设备可以“线与”任何设备都能把总线拉低这是硬件层仲裁的基础。协议层面一次传输通常包括起始条件STARTSCL高电平时SDA从高变低。地址字节7位从机地址1位读写标志。写是0读是1。ACK/NACK每字节传输后接收方拉低SDA表示ACK。如果你读到NACK通常是地址不对或设备没上电。数据字节按MSB在前排列。停止条件STOPSCL高电平时SDA从低变高。这段时间网上问得很活跃的几个问题i2c时序图怎么看、usart/uart/i2c/spi区别、i2c读写多个字节的完整时序、i2c 7位地址与8位地址怎么换算。我的建议是先把单片机上用GPIO模拟I2C的时序代码跑一遍比看理论强得多。纸上谈兵再多不如用示波器或者逻辑分析仪抓一次自己写的代码生成的实际波形。I2C还有个很容易被忽视的机制叫“时钟拉伸clock stretching”。就是某些从设备有点慢会主动把SCL拉低要求主设备暂停等它准备好再继续。Linux I2C核心对时钟拉伸的容忍度一般在配置里控制但如果你设计的从机设备在某种条件下会拉伸很久就可能在对方驱动里触发超时。4.2 Linux I2C核心模型adapter、client、driverLinux I2C子系统把角色拆成了三类adapterI2C控制器对应SoC上的I2C外设硬件。例如RK3568上的I2C0、I2C1等。client挂在I2C总线上的从设备。它由设备树节点或手动创建包含地址和驱动匹配信息。driver处理client的业务逻辑。驱动通过i2c_add_driver()注册自己的i2c_driver结构体内核用compatible字段把driver和client匹配起来。三者关系有点像adapter是“电线杆”client是“路灯”driver是“修路灯的师傅”。电线杆提供电力系统路灯挂在上面师傅负责让路灯亮起来。用户态看到的/dev/i2c-0、/dev/i2c-1是adapter的字符设备接口由i2c-dev模块提供不是所有I2C设备都要创建这样的节点。真正要访问设备时用户态通过这个节点直接发I2C消息而设备驱动则通过内核API发消息两种方式可以同时存在但要注意并发。4.3 写一个I2C从设备驱动一个实际的框架假设我们有一枚带温度采集功能的I2C芯片寄存器地址0x00是温度寄存器读回16位数据取高11位就是实际温度。驱动里至少要有这些内容#include linux/i2c.h #include linux/module.h #include linux/init.h #include linux/slab.h static int my_sensor_read_temp(struct i2c_client *client, int *temp) { u8 reg 0x00; struct i2c_msg msg[2]; u8 buf[2]; int ret; msg[0].addr client-addr; msg[0].flags 0; msg[0].len 1; msg[0].buf reg; msg[1].addr client-addr; msg[1].flags I2C_M_RD; msg[1].len 2; msg[1].buf buf; ret i2c_transfer(client-adapter, msg, 2); if (ret ! 2) { dev_err(client-dev, i2c read failed: %d\n, ret); return -EIO; } *temp ((buf[0] 8 | buf[1]) 5); return 0; } static int my_sensor_probe(struct i2c_client *client) { int temp; int ret; ret my_sensor_read_temp(client, temp); if (ret 0) return ret; dev_info(client-dev, probed, temperature%d\n, temp); return 0; } static void my_sensor_remove(struct i2c_client *client) { dev_info(client-dev, removed\n); } static const struct of_device_id my_sensor_of_match[] { { .compatible vendor,my-sensor }, { } }; MODULE_DEVICE_TABLE(of, my_sensor_of_match); static struct i2c_driver my_sensor_driver { .driver { .name my_sensor, .of_match_table my_sensor_of_match, }, .probe my_sensor_probe, .remove my_sensor_remove, .id_table NULL, }; module_i2c_driver(my_sensor_driver); MODULE_LICENSE(GPL);几个关键点i2c_transfer可以一次传递多条消息这是多字节I2C读写的常用方式。第一条消息写寄存器地址第二条消息读回数据。返回值和发送消息数相等才说明全部成功。比如上面期望2条消息只有ret 2才算成功。MODULE_DEVICE_TABLE会把匹配表导出供给模块自动加载机制使用配合modprobe能实现设备插入时自动加载对应驱动。设备树里对应节点要写compatible vendor,my-sensor;和of_device_id保持一致。老内核里probe是int (*probe)(struct i2c_client *)新内核比如6.x把probe拆成了probe_new或带i2c_device_id的版本写的时候注意你的内核版本。4.4 用户态调试I2Ci2c-dev和i2c-tools在没有写驱动之前或者驱动写得有问题时先用i2c-tools从用户态探探路能极大节省时间。常用命令# 列出所有I2C adapter i2cdetect -l # 扫描总线1上的所有从设备地址 i2cdetect -y -r 1 # 读取地址0x48设备的寄存器0x00 i2cget -y 1 0x48 0x00 # 写入寄存器 i2cset -y 1 0x48 0x00 0x3c如果你用树莓派、香橙派这类板子经常遇到i2cdetect扫不出设备。第一反应不是怀疑芯片坏了而是先确认i2c-dev模块加载了吗、总线号对不对、GPIO复用对不对、上拉电阻接了吗、设备地址算对了没。我见过不少情况芯片地址其实是7位0x48但在数据手册表格里写的是8位0x90直接用会踩大坑。换算方法是8位地址左移一位最低位是读写位。所以0x90这个写地址对应7位地址0x48而i2cdetect探测的是7位地址。还有一个实用的实例很多人搜索“ssd1306 设备树”因为0.96寸OLED屏特别常见。SSD1306通常通过I2C接在MCU或Linux板卡上设备树里基本是i2c1 { status okay; ssd13063c { compatible solomon,ssd1306fb-i2c; reg 0x3c; width 128; height 64; }; };但要注意内核社区对ssd1306fb的维护情况随内核版本有变化。在某些内核里可能需要你自己从用户态直接写I2C命令来刷新显存。这时i2c-dev就显得很重要你甚至不需要专门写一个内核驱动直接在用户态程序里用open(/dev/i2c-1)ioctl(I2C_SLAVE)write()就能把整屏点阵数据发过去并且不影响上层Python/C应用。4.5 I2C调试中的疑难杂症与解法探测地址正确但读寄存器都是0xFF先确认设备供电和复位脚再看SDA/SCL有没有接反。第一次读写成功后面全部NACK大概率是设备进入了奇怪的电源状态或者你的读写时序触发了设备保护。可以试试发送软件复位命令或断电重启。多字节读长度超过8字节时数据错乱很多传感器的读缓冲有限一次最多读N字节或者需要先指定“起始寄存器地址”。如果你要连续读一大段先查数据手册的burst-read限制。时钟频率设置过高导致通信不稳定设备树里clock-frequency写成了400000400kHz但某从设备最高只支持100000100kHz就会出现偶发失败。处理方式是统一按最慢设备的总线规格来。EMIO与FPGA I2C在Zynq/复旦微这类平台上I2C控制器可能走EMIO引脚设备树里除了i2c...节点还要检查pinctrl里对应的EMIO引脚配置否则驱动虽然注册了物理引脚却是悬空的。5. CAN子系统从协议到SocketCAN5.1 CAN协议要点仲裁、错误帧与CAN FDCANController Area Network最早是汽车行业搞出来的总线现在工业控制、运动控制、医疗器械里也用得非常普遍。数据链路层的核心特点是“多主竞争、报文仲裁”多个节点同时发送时ID小的报文优先。报文的ID不只是“地址”还承载着优先级信息。错误处理机制也很强任何节点检测到错误都会发出错误帧全局节点都能发现并统计错误计数严重时节点会自动进入bus-off状态。传统CAN最大数据场是8字节CAN FD则允许最多64字节并且数据段可以切换更高速率。这在需要传输固件升级、标定数据、大块状态数据的场合非常香。CAN FD和CAN的兼容性一般认为CAN FD控制器能收普通CAN帧但普通CAN控制器收到CAN FD帧会报错所以混用时要特别小心。5.2 SocketCAN把CAN当网络接口用的Linux方案Linux上使用CAN不是像UART那样打开一个ttyS设备而是通过SocketCAN框架把CAN控制器实现为一个网络接口设备can0、can1。这种设计的最大好处应用层可以用标准的socket API来收发CAN帧不必去记一整套特殊的字符设备协议。一个最简单的发送例子#include linux/can.h #include linux/can/raw.h #include sys/socket.h #include net/if.h #include sys/ioctl.h int sock; struct sockaddr_can addr; struct ifreq ifr; struct can_frame frame; sock socket(PF_CAN, SOCK_RAW, CAN_RAW); strcpy(ifr.ifr_name, can0); ioctl(sock, SIOCGIFINDEX, ifr); addr.can_family AF_CAN; addr.can_ifindex ifr.ifr_index; bind(sock, (struct sockaddr *)addr, sizeof(addr)); frame.can_id 0x123; frame.can_dlc 2; frame.data[0] 0x11; frame.data[1] 0x22; write(sock, frame, sizeof(frame));配置can0这类接口常用命令是ip# 设置500kbps并启动 ip link set can0 type can bitrate 500000 ip link set can0 up # 查看状态 ip -details link show can0 # 关闭 ip link set can0 down如果控制器支持CAN FD还可以ip link set can0 type can bitrate 500000 dbitrate 2000000 fd ondbitrate是数据段速率fd on表示启用CAN FD模式。虽然不同厂家的iproute2版本在参数细节上有差异但这个基本格式在大多数Linux发行版和BSP里都能用。5.3 CAN设备驱动的实现思路外置控制器MCP2515为例很多嵌入式平台没有内置CAN控制器就外挂一颗MCP2515通过SPI接口和主控通信。这种情况下CAN驱动本质上是一个“SPI设备驱动网络设备驱动”的混合体。驱动的整体逻辑是SPI子系统把一个mcp251x的client设备创建出来mcp251x_probe()里分配一个net_device注册中断设置netdev_ops然后调用register_candev()把CAN网络接口注册到内核。数据收发时ndo_start_xmit负责把应用层下发的skb整理成MCP2515的SPI命令写进芯片接收中断里则读取芯片RX缓冲组装成skb再通过netif_rx()交给SocketCAN。设备树节点大概是spi2 { status okay; mcp25150 { compatible microchip,mcp2515; reg 0; spi-max-frequency 10000000; clocks mcp251x_osc; interrupt-parent gpio4; interrupts 18 IRQ_TYPE_EDGE_FALLING; }; };这里clocks很关键。MCP2515需要外部晶振或时钟信号如果设备树里没给时钟探测时容易卡在“复位失败”。很多时候原理图上用的是无源晶振那还需要检查平台有没有对应的时钟源可以挂给这颗芯片。5.4 位时间与采样点这就是CAN通信质量的胜负手CAN总线速率不是简单一句话的事。波特率、采样点、位时间都会直接影响总线通信质量。Linux CAN配置里bitrate只是结果底层需要把位时间拆成若干time quantumtq再分配给同步段、传播段、相位缓冲段1/2等。一般配置时不需要自己算这些细粒度而是用ip link set can0 type can bitrate 500000 sample-point 0.75。但面试和项目移植时考官或老工程师很爱问为什么采样点要尽量靠后因为采样点越接近位时间末端越能容忍总线长度、收发器延迟带来的信号畸变。采样点太早容易采到不稳定状态太晚又可能和后一位冲突。绝大多数平台推荐的经典配置是速率为500kbps、采样点75%。如果条件允许用CAN分析仪抓一下波形看看采样点设成75% vs 85%对错误帧率的影响印象会非常深。5.5 CAN调试常用工具和故障排查Linux的can-utils提供一套非常好用的命令行工具配合SocketCAN可以快速验证链路。# 抓取总线上的所有帧 candump can0 # 发送一帧 cansend can0 123#1122 # 连续发送用于压力测试 cansequence can0 # 统计总线负载、错误计数 candump -l can0 canbusload can0500000排查顺序一般是ip -details link show can0看有没有进入bus-off看错误计数bus-error是否持续增长。如果只有发送没有接收先看回环测试ip link set can0 type can loopback on自己发自己收能收到就说明本节点协议栈没问题。波特率不对的典型现象双方都配成500k但A发的帧B经常报错用CAN分析仪或者两边candump对比ID和时间戳然后查采样点。终端电阻CAN总线标准要求两端各一个120欧终端电阻。有的人贪方便不接电阻短距离调试可能没问题但距离一旦拉长信号反射就会导致偶发错误帧很难定位。我自己在实验室调试时被这个坑过后来习惯性把关掉终端电阻的板子标上标签。6. 驱动调试工具与故障定位方法6.1 工欲善其事我常备的调试命令清单驱动开发调试看起来是在写代码其实更多时间花在“看懂系统在干什么”。以下是我自己平时必用的一些命令整理成清单供参考# 内核日志 dmesg -w dmesg | grep -i i2c # 查看模块信息 lsmod modinfo xxx.ko # 查看设备树运行时状态 ls /proc/device-tree/ cat /proc/device-tree/model cat /proc/device-tree/soc/i2c1/status # 查看设备与驱动的匹配情况 ls /sys/bus/i2c/devices/ ls /sys/bus/i2c/drivers/ # 直接读写物理寄存器 devmem 0xfe010000 32 # 查看中断计数 cat /proc/interrupts # 跟踪系统调用 strace -f -e open,ioctl,read,write ./my_appdevmem在调试没有现成寄存器访问接口的场景下几乎是救命稻草。比如怀疑某个外设的时钟没开可以直接读SOC手册里的CRU寄存器确认时钟门控和分频值。6.2 定位问题的分层思维不要一上来就改代码驱动问题最忌埋头改代码。我现在的习惯是分层排查从外到内硬件层先看原理图和信号。用万用表量电压、上拉电阻、设备供电用示波器抓I2C/CAN波形确认通信过程是否有波形产生、电平是否正常。设备树层确认节点有没有被解析/proc/device-tree里有没有对应节点status是不是okay地址、中断号、GPIO号和原理图是否一致。驱动注册层/sys/bus/i2c/drivers/下有没有你的驱动/sys/bus/i2c/devices/下有没有你的设备probe是否被调用。如果设备在但probe没跑多半是compatible不匹配或驱动没注册。用户态验证层用i2c-tools或candump直接从用户态收发判断是驱动问题还是应用问题。协议与应用层数据格式、字节序、CRC、多维数组长度等问题很多“驱动不通”的假象最后发现是应用层解析写错了。这个顺序看起来很基础但我带过的新同事里至少一半人会在第2、3层之间反复横跳最后发现是硬件接错。6.3 常见问题速查表以下表格来自我多个项目里的实际踩坑记录不完全覆盖所有情况但按这个表排查能解决大部分驱动联调里出现的初级问题现象常见原因排查方向模块加载报version magic不匹配内核源码版本与目标板不一致用目标板内核重新编译模块probe没有被调用compatible不匹配查设备树compatible与of_match_table设备树改了但没生效bootloader加载了旧dtb确认启动日志里的dtb路径i2c扫描不到设备上拉电阻缺失/地址换算错误量电平、确认7位地址i2c读写偶发失败时钟频率过高调低clock-frequency中断不断触发触发方式配错调整IRQ_TYPE为边沿或电平GPIO复用冲突多个节点引用同一引脚搜索dtsi中pinctrl定义CAN只有发送没有接收回环模式开启/终端电阻缺失关回环检查120欧电阻CAN错误计数暴涨波特率或采样点不一致用分析仪对比双方配置寄存器读回全是0时钟没开/电源没上devmem读CRU和电源域寄存器7. Linux驱动岗位的面试考点与学习建议7.1 面试官最爱的几个经典问题在网上搜“linux面试题”你大概率会看到大量“Linux常用命令”和“网络配置”类的话题。但真正针对驱动方向的面试考点会更集中、更底层。我自己被问过、也问过别人的高频题包括字符设备、平台设备、I2C/CAN设备驱动的区别是什么字符设备面向字节流对应file_operations平台设备platform device是Linux设备模型里的“虚拟总线”抽象很多SoC内部外设挂在这条总线上I2C/CAN设备则是不同总线子系统下的具体实现各自有注册和匹配机制。设备树节点是怎么和驱动匹配的内核通过of_match_table中的compatible字符串和设备树节点里的compatible属性进行匹配。匹配成功后驱动框架会调用probe函数。中断上下文和进程上下文有什么区别中断上下文不能睡眠、不能使用可能引起调度的API比如kmalloc(GFP_KERNEL)进程上下文可以用mutex。驱动里稍不注意在中断上下文用了msleep或kmalloc(GFP_KERNEL)轻则报错重则内核panic。自旋锁和互斥锁怎么选临界区短、不会睡眠时用自旋锁临界区可能睡眠、需要长时间占用时用互斥锁。这里考的是并发意识和Linux内核锁的使用边界。I2C传输时i2c_msg数组是怎么回事一次多段消息传递可以包含若干段不同地址、不同长度、读或写的消息由i2c_transfer一并处理完成。这是I2C设备驱动里最常见的知识点之一。什么是DMA什么时候用DMA驱动里凡是高频大数据量搬运都应考虑DMA而不是CPU一字节一字节拷。但DMA还涉及Cache一致性问题简单的项目可以先避开。7.2 驱动项目经验怎么写才有说服力很多人简历上写“熟悉Linux驱动开发”但项目经历只有干巴巴一句“负责XX驱动调试”。面试官最想看到的是你如何把一个驱动项目拆解成“需求—方案—实现—验证”的完整过程。举个例子我指导过的一个人写“I2C温湿度传感器驱动”他自己原来只写“使用I2C驱动采集温湿度”我建议他改成“项目背景某工控板需要以10Hz频率采集SHT30温湿度并上报。实现设备树新增节点配置I2C总线时钟100kHz驱动使用i2c_transfer周期性读取并通过miscdevice暴露/dev/sht30。期间发现设备在低温下偶发NACK确认为上电时序不满足规格后在设备树power-supply/reset中补充延时控制问题解决。”比起堆名词这种表述能体现完整的工程思维也方便面试官顺着你的项目展开提问一问一个准。7.3 从驱动到系统优化的扩展路径真实项目里驱动开发往往不是终点。很多同事和我聊过他们学完设备驱动后下一步方向是“系统裁剪优化”或“算法嵌入式部署”。其实这两件事都和驱动深度相关裁剪内核时你要知道哪些驱动可以移除、哪些config必须保留做性能调优时你要能看懂中断分布、确认I2C总线占用率、找出CPU负载里驱动部分占比多少。这也就是为什么连那些看着和驱动无关的热搜词比如“linux系统安装python”“linux命令大全”最终都会再次指向扎实的Linux系统基础。驱动开发的真正核心从来不是背会几个API而是建立“硬件—内核—应用”这三层之间来回推导的直觉。拿到一个设备你能快速判断它属于哪类总线、需要配置哪些资源、驱动注册后会触发哪些回调系统跑飞后你能通过日志快速缩小排查范围。这种能力只有在真实设备和反复调试里才能涨起来别怕踩坑把每次失败原因记录成自己的问题清单你会比大多数只会看文档的人成长得更快。
返回列表