1. 项目概述从零到一让i.MX6ULL与ICM-20608“对话”搞嵌入式Linux驱动开发尤其是传感器驱动SPI总线是绕不开的一道坎。这次的项目就是基于NXP的i.MX6ULL这颗经典的工业级应用处理器为其编写一个能够通过标准Linux SPI驱动框架稳定读取InvenSense ICM-20608六轴三轴陀螺仪三轴加速度计运动传感器数据的驱动程序。ICM-20608在无人机、姿态检测、物联网设备里很常见性能不错接口也标准拿它来练手SPI驱动再合适不过。对于刚接触Linux驱动的朋友来说这个项目能帮你打通几个关键环节第一理解Linux内核中标准的SPI子系统框架知道驱动、设备和总线是如何协同工作的这比裸机直接操作寄存器要规范得多第二掌握如何为一个具体的SPI设备这里是ICM-20608编写符合内核规范的驱动包括设备树的配置、驱动程序的注册、数据的读写第三学会如何将读取的原始数据通常是寄存器值转换成有物理意义的单位比如度/秒、g并在用户空间通过标准的接口如/sys或/dev节点访问这些数据。整个过程就像在Linux内核这个庞大的生态里为你手上的硬件“上户口”并教会系统如何与它交流。2. 核心思路与框架选型为什么是标准的Linux SPI框架在动手写代码之前得先想清楚路子怎么走。最直接也最“野”的方法就是在应用层或者一个内核模块里直接去操作i.MX6ULL的SPI控制器寄存器模拟出SPI时序去读写ICM-20608。这种方法能最快跑通但缺点一大堆代码不可移植换块板子就得重写、无法享受内核提供的电源管理、中断管理、并发保护等高级功能、而且容易和系统中其他SPI设备冲突。所以成熟的方案必然是拥抱Linux内核的标准SPI框架。这个框架把SPI系统分成了三层SPI控制器驱动负责具体SoC如i.MX6ULL的SPI硬件控制。这部分通常由芯片原厂NXP提供已经集成在内核里了。我们的工作是基于它。SPI核心层内核提供的中间层实现了SPI总线、设备、驱动的匹配逻辑提供通用的API如spi_sync_transfer。SPI设备驱动这就是我们要写的部分。它描述一个具体的SPI从设备ICM-20608实现其特定的读写逻辑、数据处理和向上层如IIO子系统的接口。我们的核心工作就是编写这个“SPI设备驱动”。框架的好处是我们只需要关注ICM-20608本身的特性寄存器地址、数据格式、供电时序等至于怎么通过i.MX6ULL的硬件发出SPI波形、DMA怎么搬运、时钟怎么分频这些脏活累活都交给内核和控制器驱动去处理。这样写出来的驱动只要设备树描述正确在任何搭载了i.MX6ULL且内核支持其SPI控制器的板子上都能直接使用极大地提升了代码的复用性和可维护性。注意在嵌入式Linux中硬件描述的重心已从过去的板级文件Board File全面转向设备树Device Tree。我们将主要在设备树中描述SPI总线、ICM-20608的连接关系片选、速率、模式而不是在C代码里写死。3. 硬件连接与设备树DTS配置详解写驱动先得把硬件怎么连的告诉内核。i.MX6ULL有多个SPI控制器如ecspi1,ecspi2等我们假设使用ecspi1其对应的设备树节点通常已经在imx6ull.dtsi中定义好了。我们需要在自己的板级设备树文件如imx6ull-myboard.dts中启用这个控制器并在其下添加我们的ICM-20608设备子节点。3.1 硬件连接原理图分析以常见的连接方式为例ICM-20608的VDD接3.3V。GND接地。CS片选接i.MX6ULL的某个GPIO例如GPIO1_IO09。SCK时钟接ECSPI1_SCLK。SDI主入从出即ICM-20608的DO接ECSPI1_MISO。SDO主出从入即ICM-20608的DI接ECSPI1_MOSI。AD0I2C地址选择脚通常接地或接高电平这里我们只使用SPI模式可以忽略或固定接死。这里的关键是片选CS引脚。虽然i.MX6ULL的SPI控制器硬件本身提供了片选线SS0, SS1等但在实际项目中为了布线灵活或使用更多设备我们经常用普通的GPIO来充当软件片选。这需要在设备树和驱动中明确配置。3.2 设备树节点编写实战设备树的配置是驱动能否成功匹配的关键。下面是一个详细的配置示例/* 首先确保ecspi1控制器节点是使能的并且时钟、dmas等属性正确。通常在.dtsi中已有我们可能需要覆盖或添加pinctrl */ ecspi1 { #address-cells 1; #size-cells 0; pinctrl-names default; pinctrl-0 pinctrl_ecspi1; /* 需要在pinctrl_hog或其它节点中定义这个pinctrl组 */ cs-gpios gpio1 9 GPIO_ACTIVE_LOW; /* 指定软件片选使用的GPIO */ status okay; /* 启用控制器 */ /* 定义ICM-20608设备子节点 */ icm20608: icm206080 { compatible invensense,icm20608; /* 用于匹配驱动的关键字符串 */ reg 0; /* 片选编号与cs-gpios顺序对应这里0表示使用cs-gpios的第一个GPIO */ spi-max-frequency 10000000; /* SPI通信最大频率单位HzICM-20608最高支持10MHz */ spi-cpol; /* 时钟极性CPOL 1 */ spi-cpha; /* 时钟相位CPHA 1 */ /* 即SPI Mode 3这是ICM-20608数据手册要求的模式 */ interrupt-parent gpio1; /* 如果使用中断引脚则需配置 */ interrupts 10 IRQ_TYPE_EDGE_RISING; /* 假设中断接在GPIO1_IO10上 */ /* 有些设计还会配置一个复位引脚reset-gpios和供电控制引脚vdd-supply */ }; };关键参数解析compatible “invensense,icm-20608”这是最重要的属性。驱动代码里会有一个同名的of_device_id表内核就是靠这个字符串把设备节点和我们的驱动绑定起来的。reg 0在SPI总线下这个值通常表示片选索引。它和cs-gpios属性配合使用。cs-gpios列表中的第一个GPIO对应索引0。spi-max-frequency必须根据传感器手册设置不能超过其最大额定值。过高的速率会导致通信失败。spi-cpol;和spi-cpha;这两个属性同时存在表示SPI模式3CPOL1 CPHA1。务必与ICM-20608数据手册严格一致模式不对数据根本读不出来。interruptsICM-20608的INT引脚可以配置为在数据就绪时产生中断。使用中断可以避免CPU不断轮询降低功耗。这里需要配置中断号和触发方式。实操心得设备树编译后生成.dtb文件需要正确烧录到板子上。调试时可以通过cat /proc/device-tree/...或在用户空间用libfdt库来检查节点属性是否正确加载。一个常见的坑是pinctrl配置错误导致SPI引脚功能没有正确复用可以用devmem2工具直接读取对应的IOMUX控制器寄存器来验证。4. 驱动程序设计从模块初始化到数据读取设备树准备好了接下来就是驱动代码的核心部分。我们将创建一个标准的Linux内核模块。4.1 驱动模块的骨架与探测Probe函数驱动的入口是模块的init函数它主要做一件事向SPI总线注册一个驱动结构体。#include linux/module.h #include linux/spi/spi.h #include linux/iio/iio.h // 我们将使用IIO子系统来暴露传感器数据 /* 定义设备树匹配表 */ static const struct of_device_id icm20608_of_match[] { { .compatible “invensense,icm20608” }, {} }; MODULE_DEVICE_TABLE(of, icm20608_of_match); /* SPI设备驱动结构体 */ static struct spi_driver icm20608_driver { .driver { .name “icm20608”, .of_match_table icm20608_of_match, .owner THIS_MODULE, }, .probe icm20608_probe, // 设备匹配成功后的探测函数 .remove icm20608_remove, // 设备移除或模块卸载时的清理函数 }; module_spi_driver(icm20608_driver); // 这个宏简化了注册和注销操作当内核启动或我们的模块被加载并且设备树中存在compatible为“invensense,icm-20608”的节点时内核就会调用我们注册的.probe函数——icm20608_probe。这是驱动初始化的主战场。struct icm20608_data { struct spi_device *spi; struct mutex lock; // 保护并发访问的锁 // 可以添加校准数据、工作队列、中断处理等结构 }; static int icm20608_probe(struct spi_device *spi) { struct icm20608_data *data; struct iio_dev *indio_dev; int ret; /* 1. 分配IIO设备结构。IIO是内核用于ADC、传感器等的标准框架。*/ indio_dev devm_iio_device_alloc(spi-dev, sizeof(*data)); if (!indio_dev) return -ENOMEM; /* 2. 获取我们自定义的私有数据结构指针 */ data iio_priv(indio_dev); >/* 读取一个寄存器 */ static int icm20608_read_reg(struct icm20608_data *data, u8 reg, u8 *val) { int ret; struct spi_transfer t[] { { .tx_buf reg, .len 1, }, { .rx_buf val, .len 1, }, }; struct spi_message m; reg | 0x80; // 设置最高位为1表示读操作。具体看ICM-20608手册。 spi_message_init(m); spi_message_add_tail(t[0], m); spi_message_add_tail(t[1], m); mutex_lock(data-lock); // 加锁防止并发访问冲突 ret spi_sync(data-spi, m); mutex_unlock(data-lock); if (ret 0) dev_err(data-spi-dev, “read reg 0x%x failed: %d\n”, reg, ret); return ret; } /* 写入一个寄存器 */ static int icm20608_write_reg(struct icm20608_data *data, u8 reg, u8 val) { int ret; u8 buf[2] { reg 0x7F, val }; // 写操作最高位为0 struct spi_transfer t { .tx_buf buf, .len 2, }; struct spi_message m; spi_message_init(m); spi_message_add_tail(t, m); mutex_lock(data-lock); ret spi_sync(data-spi, m); mutex_unlock(data-lock); if (ret 0) dev_err(data-spi-dev, “write reg 0x%x failed: %d\n”, reg, ret); return ret; }注意事项spi_sync是阻塞式的会等待传输完成。在多线程或中断上下文中调用时要特别注意锁的使用和上下文是否可睡眠。我们的probe和后续的数据读取通常在进程上下文可以使用mutex。如果在中斷上半部不可睡眠需要通信则需使用spi_async异步接口。4.3 传感器初始化与配置流程在icm20608_hw_init函数中我们需要按照数据手册的步骤配置ICM-20608的工作模式、量程、采样率等。static int icm20608_hw_init(struct icm20608_data *data) { int ret; u8 val; /* 1. 复位设备 */ ret icm20608_write_reg(data, ICM20608_REG_PWR_MGMT_1, 0x80); // 触发设备复位 if (ret) return ret; msleep(100); // 等待复位完成手册要求至少100ms /* 2. 唤醒设备选择时钟源 */ ret icm20608_write_reg(data, ICM20608_REG_PWR_MGMT_1, 0x01); // 唤醒使用内部晶振 if (ret) return ret; msleep(50); /* 3. 配置加速度计量程和滤波器 */ // 例如配置为±16g 加速度计低通滤波器184Hz ret icm20608_write_reg(data, ICM20608_REG_ACCEL_CONFIG, 0x18); if (ret) return ret; /* 4. 配置陀螺仪量程和滤波器 */ // 例如配置为±2000dps陀螺仪低通滤波器184Hz ret icm20608_write_reg(data, ICM20608_REG_GYRO_CONFIG, 0x18); if (ret) return ret; /* 5. 配置采样率分频器 */ // 设置采样率 1kHz / (1 SMPLRT_DIV)。例如设为9则采样率约100Hz。 ret icm20608_write_reg(data, ICM20608_REG_SMPLRT_DIV, 9); if (ret) return ret; /* 6. 禁用所有FIFO和传感器到FIFO的路径简单模式下 */ ret icm20608_write_reg(data, ICM20608_REG_FIFO_EN, 0x00); if (ret) return ret; /* 7. 配置中断如果需要 */ // ret icm20608_write_reg(data, ICM20608_REG_INT_ENABLE, 0x01); // 使能数据就绪中断 /* 8. 最后使能加速度计和陀螺仪的数据输出 */ ret icm20608_write_reg(data, ICM20608_REG_PWR_MGMT_2, 0x00); // 使能所有轴 if (ret) return ret; msleep(50); // 等待稳定 return 0; }关键点解析复位和唤醒这是必须的步骤确保传感器从已知状态开始工作。量程选择ACCEL_CONFIG和GYRO_CONFIG寄存器决定了传感器的灵敏度和测量范围。范围越小灵敏度越高但容易饱和。需要根据应用场景选择。采样率SMPLRT_DIV和DLPF_CFG在ACCEL_CONFIG2和GYRO_CONFIG中共同决定了实际的数据输出率。更高的速率意味着更大的数据量和功耗。延时寄存器配置后尤其是上电、复位、切换模式后必须留有足够的稳定时间msleep否则紧接着的读写操作可能会失败。5. 数据读取与IIO接口实现硬件初始化后我们需要提供一种标准的方式让用户空间程序能获取到传感器数据。Linux内核的IIOIndustrial I/O子系统就是为此而生。它为我们定义好了struct iio_info和struct iio_chan_spec我们只需要填充这些结构。5.1 定义IIO通道Channels通道描述了每一个可读的数据点。对于ICM-20608我们有3轴加速度和3轴角速度共6个通道。#define ICM20608_SCALE_ACCEL (2.0 * 9.80665 / 32768.0) // 假设±16g灵敏度16384 LSB/g转为 m/s² #define ICM20608_SCALE_GYRO (2000.0 / 32768.0) // 假设±2000dps灵敏度16.4 LSB/dps转为 dps static const struct iio_chan_spec icm20608_channels[] { /* 加速度计 X, Y, Z 轴 */ { .type IIO_ACCEL, .modified 1, .channel2 IIO_MOD_X, .info_mask_separate BIT(IIO_CHAN_INFO_RAW) | BIT(IIO_CHAN_INFO_SCALE), .address ICM20608_REG_ACCEL_XOUT_H, // 寄存器基地址 .scan_index 0, .scan_type { .sign ‘s’, .realbits 16, .storagebits 16, .endianness IIO_BE, // ICM-20608数据是大端格式 }, }, // ... 类似定义Y轴address ICM20608_REG_ACCEL_YOUT_H和Z轴 /* 陀螺仪 X, Y, Z 轴 */ { .type IIO_ANGL_VEL, .modified 1, .channel2 IIO_MOD_X, .info_mask_separate BIT(IIO_CHAN_INFO_RAW) | BIT(IIO_CHAN_INFO_SCALE), .address ICM20608_REG_GYRO_XOUT_H, .scan_index 3, .scan_type { .sign ‘s’, .realbits 16, .storagebits 16, .endianness IIO_BE, }, }, // ... 类似定义Y轴和Z轴 };5.2 实现数据读取回调函数我们需要实现iio_info结构体中的read_raw回调函数。当用户空间读取/sys/bus/iio/devices/iio:deviceX/in_accel_x_raw等文件时内核最终会调用这个函数。static int icm20608_read_raw(struct iio_dev *indio_dev, struct iio_chan_spec const *chan, int *val, int *val2, long mask) { struct icm20608_data *data iio_priv(indio_dev); __be16 raw_data; // 注意是大端16位数据 int ret; switch (mask) { case IIO_CHAN_INFO_RAW: // 请求读取原始数据 mutex_lock(data-lock); // 一次性读取两个字节高8位和低8位寄存器 ret icm20608_read_reg_16be(data, chan-address, raw_data); mutex_unlock(data-lock); if (ret) return ret; *val (s16)be16_to_cpu(raw_data); // 转换为主机字节序并赋值 return IIO_VAL_INT; case IIO_CHAN_INFO_SCALE: // 请求读取比例因子 if (chan-type IIO_ACCEL) { // 返回加速度计比例因子val是整数部分val2是小数部分微调 *val 0; *val2 ICM20608_SCALE_ACCEL * 1000000; // 转为微单位 return IIO_VAL_INT_PLUS_MICRO; } else if (chan-type IIO_ANGL_VEL) { // 返回陀螺仪比例因子 *val 0; *val2 ICM20608_SCALE_GYRO * 1000000; return IIO_VAL_INT_PLUS_MICRO; } return -EINVAL; } return -EINVAL; } /* 辅助函数读取16位大端数据 */ static int icm20608_read_reg_16be(struct icm20608_data *data, u8 reg, __be16 *val) { int ret; u8 buf[2] { reg | 0x80, 0 }; // 读命令 struct spi_transfer t[] { { .tx_buf buf, .len 1 }, { .rx_buf buf, .len 2 }, // 注意这里len2读取两个字节 }; struct spi_message m; spi_message_init(m); spi_message_add_tail(t[0], m); spi_message_add_tail(t[1], m); ret spi_sync(data-spi, m); if (ret 0) *val (buf[0] 8) | buf[1]; // 组合成16位数据注意ICM-20608是高字节在前 return ret; }最后将回调函数集赋值给iio_infostatic const struct iio_info icm20608_info { .read_raw icm20608_read_raw, // 还可以实现.write_raw用于设置量程等、.debugfs_reg_access等回调 };6. 编译、加载与用户空间测试驱动代码完成后需要编写对应的Kconfig和Makefile将其集成到内核构建系统中。这里假设我们以内核模块.ko文件的形式进行开发测试。6.1 内核模块编译配置在驱动源码目录创建Kconfigconfig ICM20608 tristate “InvenSense ICM-20608 SPI 6-axis motion sensor” depends on SPI IIO help Say Y here to build support for the InvenSense ICM-20608 connected via SPI. This driver can also be built as a module. If so, the module will be called icm20608.在驱动源码目录创建Makefileobj-$(CONFIG_ICM20608) icm20608.o然后在内核源码顶层目录执行make menuconfig在Device Drivers - Industrial I/O support - Inertial measurement units下找到并选中InvenSense ICM-20608 SPI 6-axis motion sensor编译为模块M。编译命令假设使用交叉编译工具链arm-linux-gnueabihf-export ARCHarm export CROSS_COMPILEarm-linux-gnueabihf- make -j4 modules编译后会在对应目录生成icm20608.ko文件。6.2 加载模块与调试信息将.ko文件和正确的.dtb文件拷贝到开发板文件系统。在开发板终端操作# 1. 加载模块 insmod icm20608.ko # 2. 查看内核日志确认探测是否成功 dmesg | tail -20 # 期望看到类似信息 # [ 12.345678] icm20608 spi0.0: ICM-20608 probed successfully # 3. 查看IIO设备是否生成 ls /sys/bus/iio/devices/ # 应该能看到一个类似 iio:device0 的目录 cd /sys/bus/iio/devices/iio\:device0 ls # 会看到 in_accel_x_raw, in_accel_scale, in_anglvel_x_raw, in_anglvel_scale 等文件6.3 用户空间数据读取测试可以直接通过cat命令读取原始数据然后进行计算。# 读取X轴加速度原始值是一个有符号整数 cat /sys/bus/iio/devices/iio:device0/in_accel_x_raw # 输出例如 1024 # 读取比例因子单位通常是 m/s^2 或 dps cat /sys/bus/iio/devices/iio:device0/in_accel_scale # 输出例如 0.000598 (对应我们计算的ICM20608_SCALE_ACCEL) # 计算实际物理值 # 实际值 原始值 * 比例因子 # 例如 1024 * 0.000598 ≈ 0.612 m/s²更常见的是使用iio_info和iio_readdev等来自libiio库的工具或者自己编写小程序通过sysfs接口读取并实时处理这些数据。7. 常见问题排查与实战心得在实际操作中你几乎一定会遇到问题。下面是一些典型问题的排查思路和我的经验总结。问题现象可能原因排查步骤与解决方案insmod后dmesg无任何输出或提示probe failed1. 设备树compatible不匹配。2. SPI控制器未使能或引脚复用冲突。3. 电源或硬件连接问题。1. 检查/proc/device-tree/下对应节点是否存在compatible属性是否正确。2. 检查控制器节点status是否为“okay”。用cat /sys/kernel/debug/gpio查看片选GPIO状态。3. 用万用表测量传感器供电和信号线。能探测到设备但读取数据全是0或0xFF1. SPI模式CPOL/CPHA设置错误。2. 通信速率过高。3. 传感器未正确初始化未唤醒。1.这是最高频的坑用示波器或逻辑分析仪抓取SCK和MOSI波形与数据手册时序图对比确认是Mode 3。2. 在设备树中降低spi-max-frequency比如先试1MHz。3. 在驱动probe函数中增加打印确认每一步寄存器写入都成功并检查PWR_MGMT_1寄存器是否已唤醒设备。读取的数据跳动剧烈噪声大1. 电源噪声。2. 传感器未放置在稳定表面。3. 未启用或配置合适的低通滤波器。1. 为传感器电源增加滤波电容如100nF和10uF并联。2. 静止时加速度计Z轴应约为1g9.8m/s²其他轴接近0。陀螺仪各轴应接近0。以此判断。3. 检查ACCEL_CONFIG2和CONFIG寄存器中的DLPF_CFG位根据需求配置合适的滤波器带宽。用户空间读取数据慢或不稳定1. 每次读取都进行完整的SPI传输和锁操作开销大。2. 未使用中断而是轮询。1. 对于需要高速连续读取的应用如姿态解算应考虑启用传感器的FIFO然后驱动中实现触发缓冲区Triggered Buffer模式让IIO框架管理数据缓冲区用户空间通过read系统调用一次读取多组数据。2. 配置数据就绪中断DRDY在中断处理函数中读取数据或通知用户空间。这需要实现IIO的触发缓冲区和事件接口复杂度较高但性能最好。同时使用多个SPI设备时冲突1. 片选信号冲突。2. 驱动并发访问未加锁。1. 确保设备树中每个SPI设备子节点的reg片选索引和cs-gpios定义唯一且正确。2. 在驱动的读写函数中必须使用mutex_lock保护spi_sync操作防止多个进程或线程同时操作SPI总线导致数据错乱。几个重要的实操心得示波器/逻辑分析仪是你的最佳搭档在调试SPI通信问题时没有什么比直接看波形更直观的了。检查SCK空闲电平CPOL、数据采样边沿CPHA、片选时序、数据位是否正确能快速定位90%的通信故障。从最低速开始初期调试先把SPI频率设到最低比如100kHz确保基础通信正常再逐步提高速率。善用内核日志在驱动的关键路径probe、init、read/write添加dev_dbg、dev_info、dev_err打印。通过dmesg -w实时观察结合printk日志级别/proc/sys/kernel/printk进行动态调试。理解IIO框架的价值虽然前期学习成本稍高但使用IIO框架后你的驱动立刻就能与大量现有的用户空间工具和库如libiio、graphical IIO client兼容也方便集成到更高级的中间件如ROS的imu_tools中避免了重复造轮子。注意字节序像ICM-20608这样输出16位数据的传感器一定要确认数据字节序大端还是小端。我们的例子中是大端IIO_BE需要用be16_to_cpu进行转换。如果弄反了数据看起来就是完全错误且无规律的。驱动开发是一个需要耐心和细致排查的过程尤其是与硬件打交道。从设备树的一个字符错误到SPI模式的一个位设置不对都可能导致整个链路失败。但当你在终端看到正确的加速度和角速度数据输出时那种成就感也是实实在在的。这个基于i.MX6ULL和ICM-20608的SPI驱动项目完整地走通了从硬件描述、驱动框架、设备初始化到用户空间接口的整个流程掌握了这个再面对其他SPI传感器你会发现思路都是相通的无非是寄存器地址、数据格式和初始化序列的不同而已。