从零开始学习 Linux SPI 驱动开发(基于 IMX6ULL + TLC5615 DAC)
从零开始学习 Linux SPI 驱动开发基于 IMX6ULL TLC5615 DAC文章目录从零开始学习 Linux SPI 驱动开发基于 IMX6ULL TLC5615 DAC[TOC]1. 什么是 SPI硬件信号与连接2. SPI 四种模式CPOL / CPHA3. Linux SPI 子系统框架4. 设备树中如何描述 SPI 设备5. 最简单的 SPI 字符设备驱动框架6. 实战TLC5615 DAC 驱动完整编写6.1 TLC5615 芯片及数据格式6.2 完整驱动代码含注释6.3 驱动代码关键点解析7. 编译、装载与测试7.1 编译驱动7.2 更新设备树7.3 装载驱动与测试8. 常见问题与内核错误分析8.1 错误cannot set clock freq: 2 (base freq: 60000000)8.2 内核 paging request 崩溃9 完整流程框架以写入数值 200 为例9.1 用户空间程序 dac_test 工作9.2 C 库到系统调用9.3 VFS 层找到对应的字符设备驱动9.4 驱动 spi_drv_write 内部执行细节9.5内核 SPI 核心层spi_sync_transfer9.6SPI 控制器驱动硬件操作9.7 回到驱动层及用户空间9.8 硬件端 TLC5615 响应10. 总结与面试自测题面试自测题附答案1. 什么是 SPI硬件信号与连接SPISerial Peripheral Interface是一种全双工、同步串行总线由摩托罗拉提出。基本信号有 4 根线信号名方向相对于主控作用SCK主 → 从时钟信号由主机产生MOSI主 → 从主机发送从机接收Master Out Slave InMISO主 ← 从从机发送主机接收Master In Slave OutCS/SS主 → 从片选信号低有效选中某个从设备你的板子上已经引出了这些信号J2 上标注了SPI1 MOSI、SPI1 MISO、SPI1 SCLK、SPI1 CS0J7 的 19、20、21、22 脚也是SPI1 SCLK、SPI1 CS0、SPI1 MOSI、SPI1 MISO这意味着你的 IMX6ULL 开发板通过排针把 SPI1 控制器的引脚都引出来了你可以直接拿杜邦线外接 SPI 设备比如这次要驱动的 TLC5615 DAC 小板子。通信过程概括主机拉低 CS 选中从机然后产生时钟。在每个时钟边沿主机从 MOSI 线移出一位数据同时从 MISO 线移入一位数据。传输完一个或多个字节后主机拉高 CS 结束会话。2. SPI 四种模式CPOL / CPHASPI 没有官方标准不同从设备对时钟极性和相位的要求不一样于是有了 4 种模式由两个参数决定CPOL时钟极性CPOL0空闲时 SCK 为低电平CPOL1空闲时 SCK 为高电平CPHA时钟相位CPHA0在第一个时钟边沿采样数据CPHA1在第二个时钟边沿采样数据组合起来模式CPOLCPHA空闲 SCK采样边沿000低上升沿101低下降沿210高下降沿311高上升沿在 Linux 设备树或spi_board_info中通过spi-cpha和spi-cpol属性来指定。例如dtsspi-cpha; spi-cpol;不加时默认模式 0。TLC5615 数据手册要求 CPOL0, CPHA1即模式 1吗其实很多 DAC 只是要求在 SCK 上升沿移入数据需要查手册。实验中如果不稳定很可能就是模式没配对。我们的例子里暂未加这两个属性内核会按模式 0 工作但为了严谨应该根据芯片手册填写。3. Linux SPI 子系统框架Linux 把 SPI 架构分成三层像搭积木一样SPI 控制器驱动spi_master或新版本叫spi_controller直接与 SoC 的硬件 SPI 外设打交道。像spi_imx就是 IMX6ULL 的 SPI 控制器驱动已经在内核里写好了我们不用管。SPI 设备struct spi_device描述一个挂载在 SPI 总线上的从设备。它保存着该设备的片选索引、最大频率、模式等。这些信息主要来自设备树。SPI 设备驱动struct spi_driver我们写的驱动负责与具体的从设备交互。内核通过compatible属性把它和设备树中的节点绑定起来。一次 SPI 数据传输的核心数据结构struct spi_transfer描述一次传输的细节发送缓冲区、接收缓冲区、长度、速度等。struct spi_message将多个spi_transfer链接成一个原子操作在全部传输完成后才释放 CS。spi_sync_transfer(spi, xfers, num)同步接口提交传输并阻塞等待完成。这是我们驱动中最常用的函数。辅助函数cstatic inline int spi_read(struct spi_device *spi, void *buf, size_t len) { struct spi_transfer t { .rx_buf buf, .len len, }; return spi_sync_transfer(spi, t, 1); }就是一个只读的同步封装简单明了。4. 设备树中如何描述 SPI 设备看你的设备树片段arch/arm/boot/dts/100ask_imx6ull-14x14.dtsdtsecspi1 { pinctrl-names default; pinctrl-0 pinctrl_ecspi1; fsl,spi-num-chipselects 2; cs-gpios gpio4 26 GPIO_ACTIVE_LOW, gpio4 24 GPIO_ACTIVE_LOW; status okay; dac: dac { compatible 100ask,spidev; reg 0; spi-max-frequency 1000000; }; };逐行解释ecspi1引用 SPI1 控制器节点。pinctrl-0 pinctrl_ecspi1;指定引脚复用为 SPI 功能已在别处定义。cs-gpios指定两个片选引脚分别是 GPIO4_26 和 GPIO4_24低有效。控制器会根据reg编号自动选择对应 GPIO。status okay;启用该控制器。子节点dac代表一个挂在 ecspi1 上的 SPI 设备。reg 0表示使用第 0 个片选即 GPIO4_26。spi-max-frequency 1000000限制最大时钟为 1 MHz。compatible 100ask,spidev;这是最关键的匹配字符串。内核会用它与所有spi_driver的of_match_table比较相等时就会调用驱动的probe函数并把该节点生成的spi_device传进去。你终端里执行ls /sys/bus/spi/devices/spi0.0能看到driver、modalias、of_node等说明设备spi0.0已正确创建并与驱动绑定。5. 最简单的 SPI 字符设备驱动框架我们不只是让内核能识别设备还要让用户空间程序比如dac_test能打开、写入、读取这个 SPI 设备。套路是在probe中注册一个字符设备生成/dev/myspi节点。结构体骨架cstatic struct spi_driver my_spi_driver { .driver { .name 100ask_spi_drv, .owner THIS_MODULE, .of_match_table myspi_dt_match, // 与设备树 compatible 匹配 }, .probe spi_drv_probe, .remove spi_drv_remove, };probe函数完成三件事保存spi_device *指针通常放到全局变量或spi_set_drvdata。申请字符设备号绑定file_operations。创建设备类并在/dev下生成节点。你没贴出来的file_operations里面read / write就是最终与硬件通信的地方。6. 实战TLC5615 DAC 驱动完整编写6.1 TLC5615 芯片及数据格式TLC5615 是一个 10 位 DAC它接受 12 位或 16 位输入序列。图片给出了格式12 位模式高 10 位是数据最低 2 位是无用位sub-LSB。16 位模式高 4 位无意义接着 10 位数据最后 2 位 sub-LSB 无用位。我们要驱动它输出一个模拟电压只需要给它发送合适的数字值即可。为了方便我们可以固定使用16 位模式即发送两个字节高 4 位随便填但一般会考虑到对齐后面跟着左移后的 10 位数据。在老师提供的驱动代码write函数里数据转换如下cerr copy_from_user(val, buf, size); // 得到用户空间的 16 位值实际只用到低 10 位 val 2; // 数据左移 2 位把 10 位数据挪到 D11..D2 位置低 2 位为 sub-LSB val 0x0fff; // 屏蔽高 4 位确保发送 12 位有效数据为何val 2因为 10 位数据在芯片的 16 位帧里位于 “4 dummy bits 10 data bits 2 extra bits” 结构。如果我们把 10 位数据放在一个 16 位字的高 10 位val 2就是把它移到 D15…D6 吗 不实际上代码里val是unsigned shortcopy_from_user(val, buf, 2)得到的是原始的用户值。左移 2 位再与0x0fff相与结果是一个 12 位的值其高 10 位是数据低 2 位为 0。再把它拆成两个字节发送ker_buf[0] val 8; ker_buf[1] val;那么对于 16 位帧来说我们发送了两个字节共 16 位高字节是val的高 8 位低字节是val低 8 位。结果就是前 4 位为 0因为val 0x0fff清除了高 4 位接着 10 位数据最后 2 位为 0。这完全符合 16 位输入序列格式。6.2 完整驱动代码含注释下面是整合了老师源码的完整驱动程序我将write方法配上详尽注释并实现一个简单的readDAC 通常不需要读这里返回错误。你可以直接使用。c#include linux/spi/spi.h #include linux/module.h #include linux/fs.h #include linux/errno.h #include linux/kernel.h #include linux/major.h #include linux/init.h #include linux/device.h #include linux/slab.h #include linux/uaccess.h static int major 0; static struct class *my_spi_class; static struct spi_device *g_spi; static ssize_t spi_drv_write(struct file *file, const char __user *buf, size_t size, loff_t *offset) { int err; unsigned short val; // 用户写来的值, 2 字节 unsigned char ker_buf[2]; // 内核中将要发送的 2 字节 struct spi_transfer t; // 我们约定一次必须写入 2 字节 (一个 DAC 数值) if (size ! 2) return -EINVAL; // 从用户空间读取 2 字节到 val err copy_from_user(val, buf, size); if (err) return -EFAULT; /* * TLC5615 数据格式16 位模式 * 高 4 位: 无关位 * 接着 10 位: 有效数据 (D9~D0) * 最低 2 位: sub-LSB 位 (通常填 0) * * 我们先把用户给的 val (假设其低 10 位有效) 左移 2 位 * 使 10 位数据位于 12 位字段的高 10 位然后清除高 4 位。 * 最终 val 是一个 12 位的值再分为两个字节发送结果 * 字节0: 0000xxxx (前4位为0) 字节1: xxxxxx00 (后2位为0) * 满足芯片要求。 */ val 2; // 10-bit data → D11..D2 val 0x0fff; // 屏蔽高4位确保前4位为0 ker_buf[0] (val 8) 0xff; // 高字节 ker_buf[1] val 0xff; // 低字节 memset(t, 0, sizeof(t)); t.tx_buf ker_buf; t.len 2; err spi_sync_transfer(g_spi, t, 1); if (err) { pr_err(spi_sync_transfer failed: %d\n, err); return err; } return size; } static ssize_t spi_drv_read(struct file *file, char __user *buf, size_t size, loff_t *offset) { // TLC5615 是纯写入设备不支持读取直接返回错误 return -ENOTSUP; } static int spi_drv_open(struct inode *inode, struct file *file) { return 0; } static int spi_drv_release(struct inode *inode, struct file *file) { return 0; } static const struct file_operations spi_drv_fops { .owner THIS_MODULE, .open spi_drv_open, .release spi_drv_release, .write spi_drv_write, .read spi_drv_read, }; static int spi_drv_probe(struct spi_device *spi) { g_spi spi; // 保存 spi_device供读写使用 major register_chrdev(0, 100ask_spi, spi_drv_fops); if (major 0) { pr_err(Failed to register chrdev\n); return major; } my_spi_class class_create(THIS_MODULE, 100ask_spi_class); if (IS_ERR(my_spi_class)) { unregister_chrdev(major, 100ask_spi); return PTR_ERR(my_spi_class); } device_create(my_spi_class, NULL, MKDEV(major, 0), NULL, myspi); pr_info(myspi device created, major%d\n, major); return 0; } static int spi_drv_remove(struct spi_device *spi) { device_destroy(my_spi_class, MKDEV(major, 0)); class_destroy(my_spi_class); unregister_chrdev(major, 100ask_spi); return 0; } static const struct of_device_id myspi_dt_match[] { { .compatible 100ask,spidev }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, myspi_dt_match); static struct spi_driver my_spi_driver { .driver { .name 100ask_spi_drv, .owner THIS_MODULE, .of_match_table myspi_dt_match, }, .probe spi_drv_probe, .remove spi_drv_remove, }; module_spi_driver(my_spi_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(YourName); MODULE_DESCRIPTION(SPI DAC driver for TLC5615);6.3 驱动代码关键点解析模块入口module_spi_driver(my_spi_driver);是宏自动生成module_init和module_exit里面调用spi_register_driver和spi_unregister_driver。比手写更简洁。compatible 匹配设备树里有compatible 100ask,spidev;驱动of_match_table包含同样的字符串所以它们会绑定。字符设备注册老用法register_chrdev一次占用 256 个次设备号简单够用。主设备号动态分配传入 0。数据传输spi_sync_transfer第一个参数是g_spi它指向该设备对应的spi_device。内核自动使用设备树中指定的spi-max-frequency、片选等。我们不需要手动控制 CS核心层会帮我们开关cs-gpios。7. 编译、装载与测试7.1 编译驱动把上述代码保存为spi_drv.c在同一目录编写MakefilemakefileKERN_DIR /path/to/your/kernel/source # 例如 ~/100ask_imx6ull-sdk/Linux-4.9.88 obj-m spi_drv.o all: make -C $(KERN_DIR) M$(PWD) modules clean: make -C $(KERN_DIR) M$(PWD) clean然后执行make生成spi_drv.ko。7.2 更新设备树根据操作截图编辑arch/arm/boot/dts/100ask_imx6ull-14x14.dts在ecspi1内加入dac节点你已添加。在内核源码目录执行make dtbs。将新生成的100ask_imx6ull-14x14.dtb复制到/boot目录或开发板的启动分区。重启开发板。7.3 装载驱动与测试在开发板终端bashinsmod spi_drv.ko查看是否成功生成/dev/myspibashls -l /dev/myspi你的截图里还能看到/sys/bus/spi/devices/spi0.0以及dac_test应用程序。dac_test应该是接收两个参数/dev/myspi和 一个数值例如bash./dac_test /dev/myspi 100这会让 DAC 输出与数字 100 对应的模拟电压。你那几条执行记录bash./dac_test /dev/myspi 1000bash./dac_test /dev/myspi 500bash./dac_test /dev/myspi 200说明驱动工作正常能多次写入不同值控制电压后面 LED 亮度会变化。8. 常见问题与内核错误分析8.1 错误cannot set clock freq: 2 (base freq: 60000000)你截图里出现过textspi_imx 2008000.ecspi: cannot set clock freq: 2 (base freq: 60000000)这是因为你传给驱动的最大频率可能太小比如 2 Hz或者某个spi_transfer的speed_hz设得不成比例。但你的设备树里spi-max-frequency 10000001 MHz不应出现这个错误。可能原因之前测试时修改过频率但没有更新 dtb。或者驱动程序里额外设置了t.speed_hz 2;之类。解决办法检查设备树频率确保不要极端值spi-max-frequency取 100000 ~ 按芯片最大能力。8.2 内核paging request崩溃你图中textUnable to handle kernel paging request at virtual address 8010e710常见原因访问了非法指针比如g_spi还是 NULL 时就调用了spi_sync_transfer或者copy_from_user的缓冲区不合法。确保驱动在probe之后才被读写并且g_spi被正确赋值。9 完整流程框架以写入数值 200 为例9.1 用户空间程序dac_test工作解析命令行参数argv[1] /dev/myspiargv[2] 200。调用open(/dev/myspi, O_WRONLY)打开设备文件。将字符串200转换为整数200atoi或strtol。准备 2 字节数据因为 TLC5615 是 10 位 DAC用户程序可能直接将200当成 16 位无符号数写入也就是准备unsigned short val 200;然后调用write(fd, val, 2)向设备写入 2 个字节。注你的截图中./dac_test /dev/myspi 100等用法与上述逻辑一致。9.2 C 库到系统调用write(fd, val, 2)触发SYS_write系统调用陷入内核ARM 上通过swi/svc指令。内核根据系统调用号进入sys_write()然后调用vfs_write()。9.3 VFS 层找到对应的字符设备驱动vfs_write()从fd对应的struct file中取出file-f_op即spi_drv_fops。调用file-f_op-write(file, buf, count, pos)也就是我们的spi_drv_write。buf指向用户空间栈上的val地址需要copy_from_usercount为 2。9.4 驱动spi_drv_write内部执行细节cunsigned short val; unsigned char ker_buf[2]; struct spi_transfer t;长度检查size ! 2→ 返回-EINVAL这里为 2通过。拷贝用户数据copy_from_user(val, buf, 2)将用户空间的两个字节复制到内核变量val。此时val 200无论主机字节序内核内部视为 16 位无符号数。数据格式转换符合 TLC5615 的 16 位帧要求val 2;→200 2 800二进制0000 0011 0010 0000十六进制0x320val 0x0fff;→0x320 0xfff 0x320确保只保留低 12 位防止溢出此时 12 位值0x320的组成高 4 位0x3是 dummy 位无关位中间 10 位0x320 2 200是有效数据低 2 位为 0sub-LSB。拆分为两个字节ker_buf[0] (val 8) 0xff;→0x03ker_buf[1] val 0xff;→0x20构造 SPI 传输cmemset(t, 0, sizeof(t)); t.tx_buf ker_buf; // 发送缓冲区 t.len 2; // 发送 2 字节发起同步 SPI 传输cerr spi_sync_transfer(g_spi, t, 1);g_spi是在probe中保存的spi_device指针它包含了从设备树继承的片选号、最大频率等信息。如果发送成功返回 0发生的字节数size2返回给用户空间。9.5内核 SPI 核心层spi_sync_transferspi_sync_transfer内部创建一个spi_message将spi_transfer添加进去然后调用spi_sync(spi, message)。spi_sync为此次传输设置一个等待队列然后将spi_message提交给 SPI 控制器驱动并阻塞等待传输完成。实际的传输由struct spi_controller的transfer_one_message方法完成这里对应spi_imx驱动IMX6ULL 的 SPI 控制器驱动。9.6SPI 控制器驱动硬件操作片选控制控制器驱动根据spi_device的片选信息来自设备树cs-gpios gpio4 26 ...;将GPIO4_26 拉低选中 TLC5615。配置时钟根据设备树中的spi-max-frequency 1000000配置 SCK 为 1 MHz并根据模式 0未添加spi-cpha/cpol设置空闲低电平、上升沿采样等。启动传输将ker_buf的两个字节0x03,0x20通过 MOSI 引脚顺序移出。控制器自动产生 16 个时钟脉冲每当时钟边沿到来时发送一位数据。由于本次只写不收MISO 线上的数据被忽略控制器不会将接收到的数据存入任何缓冲区或者即使接收也不处理。等待完成硬件发送完所有位后触发中断中断服务程序通知 SPI 核心传输结束。释放片选控制器将 GPIO4_26 拉高结束本次 SPI 会话。spi_sync_transfer被唤醒返回成功状态。9.7 回到驱动层及用户空间spi_drv_write获得err 0返回size2。vfs_write将返回值 2 一路返回给用户空间的write()调用。用户程序dac_test收到write返回 2之后可能调用close(fd)关闭设备。9.8 硬件端 TLC5615 响应TLC5615 在 16 个时钟周期内收到了完整的一帧 16 位数据0x03、0x20。根据芯片数据手册它解析出 10 位有效数据200同时忽略高 4 位和低 2 位。内部 DAC 寄存器更新模拟输出电压变为Vout Vref × (200 / 1024)。你实验板上的 LED 亮度随之变化因为 LED 接在 DAC 输出端你的截图备注“DAC效果展示LED灯”。整个链条总结为用户敲命令 → 用户程序写设备节点 → 系统调用陷入内核 → VFS 调用驱动 write → 驱动转换数据并调用spi_sync_transfer→ SPI 核心阻塞等待 → IMX SPI 控制器拉低 CS、产生时钟、发送两字节 → 完成后拉高 CS、唤醒等待 → write 返回 → 用户程序退出 → DAC 芯片更新电压输出。任何一环出问题都可以对照这个框架进行精准排查。10. 总结与面试自测题这篇教程带你走完了从 SPI 物理总线到底层驱动的全过程认识了四根 SPI 信号线和硬件连接。理解了 CPOL/CPHA 决定的四种模式。掌握了 Linux SPI 子系统的分层模型关键结构体spi_device、spi_driver、spi_transfer。学会了在设备树中添加 SPI 从设备节点并通过compatible与驱动绑定。完成了字符设备驱动的编写使用spi_sync_transfer发送数据。分析了 TLC5615 的数据格式并在write函数中实现了位操作转换。最后在开发板上实际装载并成功控制 DAC 输出。面试自测题附答案1. SPI 总线有几根线分别是什么答4 根SCK时钟、MOSI主发从收、MISO主收从发、CS片选通常低有效。2. 什么是 CPOL 和 CPHA它们在设备树中如何指定答CPOL 定义空闲时钟电平0 低 1 高CPHA 定义数据采样边沿0 第一边沿1 第二边沿。设备树中通过spi-cpol和spi-cpha布尔属性设置。3. 在 Linux SPI 子系统中spi_transfer和spi_message的关系是什么答spi_transfer描述单次传输TX/RX 缓冲区、长度。spi_message是多个spi_transfer的集合保证它们被原子地执行CS 在整条消息期间保持有效。4. 设备树中compatible属性有什么作用答它告诉内核设备的型号内核用它来匹配对应的驱动程序。驱动程序通过of_match_table声明自己支持的 compatible 列表匹配成功后调用 probe 函数。5. 驱动中的probe函数主要做哪些事情答①获取并保存spi_device②初始化硬件如配置时钟、复位③注册字符设备/创建 sysfs 接口④创建设备节点让用户空间可以操作。6.copy_from_user和copy_to_user为什么是必需的答因为用户空间和内核空间的内存是隔离的不能直接解引用用户指针。这两个函数会处理地址合法性检查和缺页异常安全地拷贝数据。7. 如果板子上有两个同样的 SPI 设备分别挂在 CS0 和 CS1驱动应该怎么做才能同时支持答不能在驱动中用单一全局变量g_spi指向设备。应当将spi_device填到file的private_data或者用spi_set_drvdata和container_of从spi_device获取私有结构。每次 open 时根据次设备号或 inode 定位到对应的spi_device避免覆盖。8. TLC5615 的 12 位帧与 16 位帧在使用上有何区别代码中val 2; val 0x0fff;换成val 4再发两个字节会怎样答本驱动直接按 16 位帧发送代码中的移位和掩码实现了高 4 位为 0、接着 10 位数据、低 2 位为 0 的正确帧格式。如果改成val 4会导致数据偏移到更高位超出 12 位范围发送的时序不符合芯片要求输出电压会出错。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2558791.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!