1. 项目背景与整体思路
拿到“龙芯k - 走马观碑组MPU驱动移植”这个任务时,我首先确认了一点:标题里的“MPU”指的是MPU6050这款六轴惯性传感器,而不是内存保护单元(Memory Protection Unit)。虽然缩写相同,但结合项目组做姿态采集、运动检测这类应用场景来看,MPU6050几乎是可以肯定的选择。MPU6050是InvenSense(现TDK)出品的六轴运动传感器,内部集成了三轴加速度计和三轴陀螺仪,通过I2C接口输出原始数据,在无人机飞控、平衡小车、手势识别、计步器这类嵌入式项目里用得非常多。
这次移植的目标平台是龙芯。龙芯采用LoongArch指令集架构,生态和x86、ARM不太一样,很多现成的驱动代码不能直接拿过来用。项目名里有“走马观碑组”,这是组内的产品代号,翻译成人话就是:这是一次完整的驱动移植任务,从内核Kconfig配置、设备树描述,到I2C驱动注册、寄存器初始化,再到App层的数据读取验证,全链条都要在龙芯平台上跑通。
整个移植的核心价值在于:把事情从“代码能编译过”推进到“传感器能读到真实数据”。很多搞嵌入式的朋友拿到厂商BSP包就无脑拷贝驱动文件,编译能过就以为完事了,实际上真正麻烦的是地址映射、时序匹配、寄存器初始化顺序这些看不见的细节。这篇博文我会把这个移植过程中踩过的坑、验证过的方法、能抄作业的代码片段全部整理出来,适合正在做龙芯平台适配、或者要在非ARM平台上移植I2C类传感器驱动的朋友参考。
2. 环境准备:先搭好硬件和工具链
2.1 龙芯模拟环境与传统交叉编译环境的取舍
开始移植之前,环境搭建是绕不开的一步。龙芯平台有真机环境,也有模拟环境。如果你手里暂时没有龙芯开发板,完全可以用QEMU模拟器先跑起来,把驱动框架和数据读取逻辑调通,之后再做真机适配。
我这次的做法是模拟环境和真机环境都准备了。模拟环境用的是QEMU的loongarch64 virt机型,跑的是Loongnix或社区维护的内核镜像。真机用的是一块龙芯2K1000评估板,BIOS固件已经更新到支持新内核的版本。这里有个经验值得说:模拟环境适合验证驱动逻辑本身的正确性,比如I2C设备的注册、读写时序、寄存器初始化流程,但模拟器对I2C控制器的行为模拟是有限度的,真机上很多时序相关的诡异现象,模拟环境里根本复现不出来,所以别指望只在模拟环境下就能把驱动完全搞定。
传统的交叉编译环境在龙芯平台上也适用,但要注意工具链选择。LoongArch架构的工具链分两种:一种是Loongson官方提供的交叉编译工具链,另一种是社区维护的上游GCC工具链。我推荐直接用官方工具链,版本匹配度更好。安装好工具链之后,需要把CROSS_COMPILE设置为loongarch64-linux-gnu-之类的前缀,同时指定ARCH=loongarch。这里有一个常见的坑:很多新手直接用ARCH=arm64的习惯去编LoongArch内核,结果Makefile直接报错,因为LoongArch在较新版本内核中才被正式接收,老的内核版本里根本没有这个架构目录。
2.2 内核源码准备与I2C子系统检查
驱动移植的第一步不是写代码,而是确认内核里I2C子系统是否完整可用。MPU6050挂在I2C总线上,Linux内核的I2C驱动框架是设备驱动模型的核心部分,包含I2C核心(i2c-core)、I2C总线控制器驱动(i2c-adapter)以及具体的设备驱动(i2c-driver)三层。
先在主干上确认内核版本。我这次用的内核是6.x主线版本,在LoongArch上已经能正常启动。把内核源码解压后,先跑一下make loongson3_defconfig生成默认配置,然后通过make menuconfig确认以下配置项已经打开:
CONFIG_I2C=y CONFIG_I2C_CHARDEV=y CONFIG_I2C_GPIO=y(或者对应的龙芯I2C控制器驱动) CONFIG_I2C_ALGOBIT=yCONFIG_I2C_CHARDEV这个选项很重要,它会把I2C控制器暴露成/dev/i2c-N设备节点,方便我们在用户态用i2cdetect、i2cdump这些工具去探测设备是否存在、寄存器数据是否正常,这在排障阶段极其有用。如果内核里这个选项没开,后面你连最基本的i2cdetect都跑不了,只能干瞪眼。
另外要检查龙芯SoC的I2C控制器驱动是否已经在内核里使能。2K1000芯片上有两个I2C控制器,一个主控一个从控,通常我们的MPU6050挂在主控的I2C0上。对应的驱动在drivers/i2c/busses/目录下,名字一般类似i2c-ls2x.c或i2c-loongson.c。确认对应配置项打开后,先编译内核并烧录到板子上,启动后用ls /dev/i2c*确认I2C设备节点正常存在,这一步是整个项目的地基,地基不稳后面全是空中楼阁。
3. 设备树描述:让硬件“长在”内核里
3.1 MPU6050硬件连接与I2C地址
MPU6050正常连接只需要四根线:VCC、GND、SCL、SDA。模块上通常还有一个AD0引脚,这个引脚的电位决定了传感器的I2C地址——拉低是0x68,拉高是0x69。绝大多数模块默认是拉低的,也就是0x68这个地址。调试时第一件事就是确认地址,因为很多问题都出在这:你以为设备在0x68,实际上硬件连接决定它在0x69,或者反过来。
此外还需要注意总线电压匹配。MPU6050模块的逻辑电压一般是3.3V,如果龙芯开发板的I2C拉电阻接的是1.8V,那就需要用电平转换模块。有些开发板虽然I2C引脚物理上是3.3V,但内部走线却经过了电平转换电路,直接连3.3V的传感器模块反而会把传感器烧了。这块板子拿到手,第一件事就是翻原理图,确认I2C总线的上拉电压到底是多少。
3.2 设备树节点的写法与匹配逻辑
在Linux内核的设备树体系中,MPU6050作为I2C从设备,它的描述方式是挂在某个I2C控制器节点下面的子节点。龙芯2K1000的设备树文件一般在arch/loongarch/boot/dts/loongson/目录下,文件名类似loongson2k1000.dtsi。在I2C控制器节点下面添加下面这段子节点:
&i2c0 { status = "okay"; clock-frequency = <400000>; mpu6050@68 { compatible = "invensense,mpu6050"; reg = <0x68>; interrupt-parent = <&gpio>; interrupts = <4 IRQ_TYPE_LEVEL_LOW>; mount-matrix = "0", "1", "0", "-1", "0", "0", "0", "0", "1"; }; };这里有几个字段值得细说。
compatible字段用于驱动和设备匹配,这是设备树机制的核心。当驱动中的of_match_table里声明了invensense,mpu6050这个字符串,内核就会在设备注册时把驱动和设备绑定起来。很多人在自己写的驱动里随便定义一个字符串,想着“只要我自己写代码时一致就行”,这恰恰是最容易埋雷的地方——因为Linux内核里已经有一个现成的MPU6050驱动了,标准做法是直接用官方的compatible值,让内核的驱动自动加载,别自己造轮子去重新发明一套。
clock-frequency设置I2C总线频率为400KHz(快速模式)。MPU6050数据手册里写明它支持400KHz的I2C时钟,但实际使用中如果线材长了、或者上拉电阻选大了,高速模式下信号上升沿会变缓,导致通信不稳定。新手经常遇到的现象是:低速100KHz时很正常,一改成400KHz就读写异常。这时候优先排查的是硬件走线和上拉电阻,而不是怀疑驱动代码。
mount-matrix是旋转矩阵,描述传感器在板子上的安装方向。如果传感器是正着焊的,这段声明甚至可以省略;但如果传感器是横着放或者倒着放的,必须用矩阵把坐标轴“掰正”,否则后面读出来的加速度和角速度方向全是错的,姿态解算结果一塌糊涂。
4. 驱动代码的移植与适配
4.1 现有内核驱动与自研驱动的选择
刚才提到了内核里已经存在现成的MPU6050驱动,位于drivers/iio/imu/inv_mpu6050/目录下。做移植的第一选择就是把这个驱动编译进内核或编译成模块,而不是从零写一个。Linux内核的IIO子系统(Industrial I/O)天然支持这类传感器,提供了统一的读写接口、缓冲区和事件机制,省去自己造轮子的很多麻烦。
但这里有个现实问题:龙芯平台的设备树匹配和中断处理方式跟其他平台不完全一样,而且这个驱动依赖iio_trigger、industrialio-buffer、kfifo等模块,Kconfig和Makefile里都要逐项使能。我的建议是:先试着编译这个现成驱动,如果编译出错再考虑精简自研。经验之谈,很多平台移植的最终方案都是自研精简版,因为现成的IIO驱动依赖项太多,就算编译过了,在龙芯这种生态相对独立的平台上调试起来也很费劲,各种子系统交互反而让问题复杂化。
4.2 自研精简驱动的框架拆解
为了让大家看得明白,下面是我最终在项目里使用的精简版驱动框架,适用于在龙芯平台上做驱动移植的场景。核心思路是:不依赖IIO子系统,直接注册一个miscdevice,给用户态提供read接口,每次读取时从MPU6050的寄存器里拿到原始加速度和角速度数据,打包成结构体返回给App。
先看i2c_driver注册部分:
#include <linux/module.h> #include <linux/i2c.h> #include <linux/delay.h> #include <linux/miscdevice.h> #include <linux/uaccess.h> #define MPU6050_ADDR 0x68 #define MPU6050_WHO_AM_I 0x75 #define MPU6050_PWR_MGMT_1 0x6B #define MPU6050_ACCEL_XOUT_H 0x3B #define MPU6050_GYRO_XOUT_H 0x43 struct mpu6050_data { s16 accel_x, accel_y, accel_z; s16 gyro_x, gyro_y, gyro_z; }; static struct i2c_client *mpu6050_client; static int mpu6050_read_regs(struct i2c_client *client, u8 reg, u8 *buf, int len) { struct i2c_msg msgs[2]; msgs[0].addr = client->addr; msgs[0].flags = 0; msgs[0].buf = ® msgs[0].len = 1; msgs[1].addr = client->addr; msgs[1].flags = I2C_M_RD; msgs[1].buf = buf; msgs[1].len = len; return i2c_transfer(client->adapter, msgs, 2); }这段代码里的i2c_transfer是关键。之前有个同事问我,为什么不能直接用i2c_smbus_read_i2c_block_data?其实完全可以用,i2c_smbus_*系列函数在底层也是走i2c_transfer封装的,区别在于SMBus协议对单次传输长度、PEC校验有额外限制。从MPU6050连续读14个字节寄存器(加速度6字节+温度2字节+陀螺仪6字节)在标准SMBus下没问题,所以用SMBus封装其实更省事。
继续看probe和read接口:
static int mpu6050_probe(struct i2c_client *client) { u8 val = 0; mpu6050_client = client; /* 验证设备ID */ mpu6050_read_regs(client, MPU6050_WHO_AM_I, &val, 1); dev_info(&client->dev, "MPU6050 WHO_AM_I = 0x%02x\n", val); /* 复位传感器 */ val = 0x80; i2c_smbus_write_byte_data(client, MPU6050_PWR_MGMT_1, val); msleep(100); /* 退出睡眠模式,选择PLL为时钟源 */ val = 0x01; i2c_smbus_write_byte_data(client, MPU6050_PWR_MGMT_1, val); msleep(10); return 0; }这里需要注意初始化顺序。MPU6050上电后默认是睡眠状态,必须先向PWR_MGMT_1寄存器写0x01来唤醒,并把时钟源设置为内部PLL(即寄存器里的CLKSEL=001)。如果这一步漏了,后面读出来的加速度和陀螺仪数据永远是0,而且传感器芯片发热异常。如果PWR_MGMT_1不小心写了0x40(复位位BIT6),芯片会一直处于复位状态,问题表现同样是什么都读不到。
4.3 数据读取接口与用户态验证
为用户态提供read系统调用的接口如下:
static ssize_t mpu6050_read(struct file *file, char __user *buf, size_t count, loff_t *ppos) { struct mpu6050_data data; u8 raw[14]; int ret; ret = mpu6050_read_regs(mpu6050_client, MPU6050_ACCEL_XOUT_H, raw, 14); if (ret < 0) { dev_err(&mpu6050_client->dev, "read sensor data failed\n"); return ret; } data.accel_x = (s16)((raw[0] << 8) | raw[1]); data.accel_y = (s16)((raw[2] << 8) | raw[3]); data.accel_z = (s16)((raw[4] << 8) | raw[5]); data.gyro_x = (s16)((raw[8] << 8) | raw[9]); data.gyro_y = (s16)((raw[10] << 8) | raw[11]); data.gyro_z = (s16)((raw[12] << 8) | raw[13]); if (copy_to_user(buf, &data, sizeof(data))) return -EFAULT; return sizeof(data); }用户态App拿到这14字节原始数据之后,要做的是换算成真实物理量。MPU6050的原始数据是有量程的,加速度计默认量程是±2g,陀螺仪默认量程是±250°/s,对应的灵敏度分别是16384 LSB/g和131 LSB/°/s。比如原始加速度计的值是16384,那就代表当前加速度是1g;原始陀螺仪值是131,代表当前角速度是1°/s。
这里有个新手特别容易犯的错误:把原始数据直接当物理量去用,结果发现静止时z轴加速度不是16384而是别的数,就怀疑传感器坏了。其实这是因为MPU6050出厂前并没有统一校准,芯片个体之间存在零偏差异,静止时读数在16000到17000之间波动都是正常的。真正的用法是对芯片做一次零偏校准,把静止时的平均值作为补偿值记录下来,运行时减去这个值。
5. 龙芯平台上的编译与调试实录
5.1 编译成内核模块的完整流程
在龙芯平台上编译驱动模块,建议以外部模块的方式编译,这样不用把驱动源码打进内核源码树,调试迭代也快。内核模块的Makefile写成这样:
obj-m += mpu6050_drv.o KERNEL_DIR ?= /path/to/loongarch-kernel CROSS_COMPILE ?= loongarch64-linux-gnu- ARCH ?= loongarch all: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) modules clean: $(MAKE) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) -C $(KERNEL_DIR) M=$(PWD) clean如果你编译的是loongson3_defconfig内核,编译模块时需要保证你当前的模块源码目录里没有残留其他架构的.o文件,否则会链接出莫名其妙的问题。我的习惯是每次换架构编译之前,先make mrproper彻底清理源码树。
编译完成之后会生成mpu6050_drv.ko文件,把这个文件拷贝到龙芯板子的文件系统里。要注意内核版本一致性:如果板子上跑的内核跟你编译时的内核源码版本不一致,模块会被拒绝加载,报错version magic不匹配。解决方法是板子跑完内核后执行uname -r查看版本,然后在编译模块时用同一个源码版本、同一个配置。
5.2 加载模块与基础调试
模块加载的命令很简单:
insmod mpu6050_drv.ko加载之后用dmesg查看内核日志,正常情况下能看到类似下面的输出:
mpu6050_drv: MPU6050 WHO_AM_I = 0x68WHO_AM_I寄存器的值正常情况下是0x68(不对应I2C地址,是固定的芯片ID)。如果这个值读出来是0x00或者0xFF,说明I2C通信链路有问题——要么接线错了,要么地址不对,要么总线被拉死。
然后可以读设备节点。自研驱动注册的miscdevice会生成/dev/mpu6050节点,用一个小程序测试读取:
cat /dev/mpu6050 | hexdump -C注意:cat读一次会持续输出二进制数据,建议写一个简短的C程序用open+read读几次就退出,或者用dd限制读取字节数。我这里简单用dd验证:
dd if=/dev/mpu6050 bs=14 count=3 | hexdump -C输出里应该能看到6组有符号16位数,数值会随着板子的晃动而变化。如果数值恒定不动,多半是传感器数据寄存器读到的是固定值或者全是0,需要检查初始化是否成功;如果数值在静止时也在疯狂跳动,大概率是电源噪声或者I2C时序不稳定。
5.3 在龙芯模拟环境里做快速验证
真机调试之前,我习惯先在QEMU龙芯模拟环境里跑一遍同样的驱动代码。QEMU模拟环境下,I2C总线的行为是用软件模拟出来的,所以不需要真机也能验证设备树匹配、probe流程、寄存器读写逻辑。
具体操作是:先启动QEMU的LoongArch虚拟机,然后和真机一样,把编译好的.ko拷贝进去,用insmod加载。模拟环境里没有真实的MPU6050硬件,i2c_transfer调用会返回收到NACK的错误码。为了在模拟环境里测试驱动逻辑,可以在驱动初始化时加一个“仿真模式”的开关:如果WHO_AM_I读了三次都是失败,就创建一个软件模拟的数据缓冲,用一组仿真数据填充。这样就能把驱动的probe流程、设备节点创建、read接口的数据打包逻辑全部跑通,等真机到手后再关掉仿真模式做真实硬件联调。
这个方法我强烈推荐给所有做平台移植的开发者。模拟环境虽然不能覆盖所有硬件细节,但对于驱动框架和上层逻辑的调试效率提升是几何级别的。
6. 常见问题与排查技巧实录
6.1 典型故障速查表
整个移植过程中,我遇到过的问题可以整理成下面这个速查表:
| 故障现象 | 可能原因 | 排查方法 |
|---|---|---|
i2cdetect扫描不到0x68地址 | 接线错误、AD0引脚电平不对、I2C总线被拉死 | 万用表测SCL/SDA电平;确认AD0接地;拔掉设备看总线恢复 |
WHO_AM_I读到0x00 | I2C地址不对或时序错误 | 检查设备树reg字段与硬件AD0是否一致;用i2cdump看寄存器 |
WHO_AM_I读到0xFF | I2C总线没有应答,设备未供电 | 确认VCC引脚电压;确认模块地线与板子共地 |
| probe成功但数据全为0 | 传感器处于睡眠或复位状态 | 检查PWR_MGMT_1寄存器值;重新执行唤醒序列 |
| 数据在静止时跳变剧烈 | 电源噪声、I2C信号质量差、量程配置不当 | 检查供电滤波电容;降低I2C频率到100KHz;配置数字低通滤波器 |
| 编译模块时报version magic不匹配 | 内核版本或配置不一致 | 确保模块编译用的源码与板子运行内核一致 |
| 设备树节点匹配不上,probe不被调用 | compatible字符串不匹配 | 用/sys/firmware/devicetree/base检查实际设备树内容;确认设备树文件已更新并重启 |
6.2 用I2C工具做寄存器级排障
如果驱动加载正常但数据不对,不要闷头改代码,先回到寄存器层面做排查。在系统里安装i2c-tools(apt install i2c-tools或者opkg install i2c-tools),然后用下面几个命令:
# 扫描I2C总线上挂载的设备 i2cdetect -y 0 # 查看指定设备的全部寄存器 i2cdump -y 0 0x68 # 修改指定寄存器的值(例如配置电源管理寄存器) i2cset -y 0 0x68 0x6B 0x01i2cdump的输出会列出MPU6050所有寄存器当前的值。正常情况下,0x75寄存器(WHO_AM_I)应该是0x68,0x68~0x6B这一段电源管理寄存器区域的值应该符合初始化配置。如果看到某些寄存器值在变化(比如加速度数据寄存器区域),说明传感器数据传输链路已经通了;如果全是0xFF或0x00,那就回到硬件层排查。
这里强调一个细节:写寄存器的时候注意不要碰PWR_MGMT_1的BIT5(睡眠位)。很多人在调试时为了省电会把传感器的睡眠位打开,结果忘记关掉,导致后面所有读操作返回0。这个坑我已经踩过不止一次了。
6.3 内核动态调试开关的用法
驱动代码里我习惯加dev_dbg级别的调试打印,然后通过内核的动态调试机制控制开关:
# 打开该文件的所有动态调试 echo 'file mpu6050_drv.c +p' > /sys/kernel/debug/dynamic_debug/control注意这个操作要在debugfs挂载之后执行,也就是先执行mount -t debugfs none /sys/kernel/debug。动态调试的好处是线上环境默认不打日志,不会产生大量内核输出影响性能;只有需要排查时才打开,定位问题后立刻关掉。
7. 数据质量优化与后续扩展
7.1 量程与数字滤波器的配置选择
MPU6050提供了可配置的加速度计量程(±2g/±4g/±8g/±16g)和陀螺仪量程(±250/±500/±1000/±2000 °/s),以及一个内置的数字低通滤波器(DLPF)。量程配置在寄存器ACCEL_CONFIG(0x1C)和GYRO_CONFIG(0x1B)中。
选择量程时有一个权衡:量程越大,满量程值对应的ADC位数越分散,分辨率越低;量程越小,分辨率越高但容易饱和。做无人机飞控通常选±4g和±500°/s;做静止姿态检测选±2g就行;如果做手势识别这类动态场景,建议直接上±8g甚至±16g,因为挥手瞬间的加速度轻松超过2g。
DLPF的截止频率配置在寄存器CONFIG(0x1A)的低三位,值从0到6对应不同的截止频率。默认值是0,对应260Hz,噪声很大。做姿态解算时建议把DLPF设到20Hz~42Hz之间(对应寄存器值4~5),能显著降低高频噪声。这里给个经验值:
/* 配置量程:加速度计±4g,陀螺仪±500°/s */ i2c_smbus_write_byte_data(client, 0x1C, 0x08); i2c_smbus_write_byte_data(client, 0x1B, 0x08); /* 配置DLPF为20Hz截止频率 */ i2c_smbus_write_byte_data(client, 0x1A, 0x04);7.2 原始数据到工程量的换算与滤波
前面提到原始数据要除以灵敏度才能得到物理量。完整的换算公式是:
加速度(g) = 原始值 / 16384(±2g量程时) 角速度(°/s) = 原始值 / 131(±250°/s量程时)实际使用中,我建议在用户态再做一次滑动平均滤波或低通滤波。内核态做滤波也不是不行,但会占用额外的CPU时间,对于龙芯这种嵌入式平台来说,用户态做数据后处理更灵活。简单的一阶低通滤波实现:
filtered = alpha * raw + (1 - alpha) * filtered_prev;alpha取0.1~0.3之间。这个滤波对高频振动非常有效,代价是数据会有几毫秒到十几毫秒的延迟,做实时控制时要根据系统需求权衡。
7.3 从单一传感器到姿态解算的扩展
拿到稳定的加速度和角速度数据之后,下一步就是姿态解算。最常用的算法是Mahony互补滤波和Madgwick四元数滤波,都能把加速度计和陀螺仪数据融合成横滚角、俯仰角和偏航角。
这里给一个简单的基础公式,用来从加速度计数据计算横滚角和俯仰角:
roll = atan2(accel_y, accel_z) * 180 / PI; pitch = atan2(-accel_x, sqrt(accel_y*accel_y + accel_z*accel_z)) * 180 / PI;注意这个公式只在静态或准静态情况下准确,一旦有持续的线性加速度(比如车在加速),加速度计测到的就不只是重力加速度,算出来的角度就会漂移。这个时候就得引入陀螺仪数据和融合算法。做无人机或机器人项目的话,建议直接上Madgwick库,C语言实现不到200行,跑在龙芯上没有丝毫压力。
在龙芯平台做这个移植项目,我个人的体会是:MPU6050本身并不复杂,复杂的是把它放到一个不熟悉的平台上时,各种基础组件的配置问题会轮番出现。设备树的一个标点、内核配置的一个选项、I2C时序的一个细微差别,都可能让整个驱动跑不起来。但反过来,把这些坑都趟过一遍之后,龙芯平台上的I2C设备驱动你基本就玩得很转了,以后再接任何I2C传感器,整个流程都是复用的。
最后再分享一个小技巧:在项目开发初期,先把驱动编译成模块,每次测试都走insmod/rmmod流程,不要动不动就重新烧内核。内核模块热插拔的开发效率比全量烧录高一个数量级。等驱动完全稳定之后,再把它打进内核镜像或者做成开机自动加载,省时省力还安全。