龙芯平台上的驱动移植实战:从一个ST传感器驱动说起
做嵌入式开发的人大多都遇到过这种情况:手里拿了一块很常见的传感器模组,一搜资料,满屏都是STM32的裸机例程、HAL库代码、寄存器配置,写得确实漂亮。但当这块传感器要接到一块跑着Linux的国产SoC上时,问题就来了——这些现成的驱动代码没法直接跑,底层依赖完全不同。最近我们组就在做类似的事情,把一套原本写给ST平台的传感器驱动,完整移植到了龙芯K系列平台上。借这个机会,把整个过程和一些关键细节整理出来,希望对正在做类似"跨平台驱动迁移"工作的朋友有帮助。
先说清楚这次要解决的核心问题。我们组的项目代号叫"走马观碑组",名字听着有点赶时间的意思,实际上也确实是在赶节点。手里的任务是:在龙芯K系列嵌入式平台上,把一颗传感器从底层驱动开始全部跑通,供上层的姿态解算算法使用。传感器的型号是MPU6050,这颗六轴传感器在飞控、平衡车、穿戴设备里太常见了,网上能找到的驱动几乎全是STM32平台的——要么是标准外设库写的,要么是HAL库写的,要么是寄存器直操。我们所做的事情,就是把这套ST生态下的驱动逻辑,移植到Linux内核的IIO子系统框架里,跑在龙芯2K1000上。这篇文章会从整体思路、关键细节、实操步骤和踩坑记录四个维度展开,内容偏嵌入式Linux中阶,无论是刚接触驱动移植的初学者,还是已经在做国产平台适配的工程师,应该都能找到有价值的信息。
1. 移植前必须想清楚的三件事
1.1 明确边界:你移植的是"驱动逻辑"而非"驱动代码"
很多人在做这种跨平台移植时第一反应是"改代码"——把STM32的寄存器地址改一改,把HAL库函数换成Linux API,看起来就能跑了。这个思路不能说全错,但放在Linux内核里往往走不通。STM32上的驱动是跑在裸机或RTOS上的,CPU直接操作寄存器地址,总线访问是一拍一个准;而Linux内核里的驱动面对的是一个多进程、虚拟内存、中断密集的环境,还要跟设备模型、电源管理、并发访问这些机制打交道。所以真正的移植,是把ST驱动里那颗传感器"怎么初始化、怎么读数据、怎么算校验"的逻辑抽出来,然后把它的IO通路、内存访问方式、时间控制方式换成Linux的机制。
以MPU6050为例,ST平台的例程核心就三部分:I2C初始化、寄存器配置(PWR_MGMT_1、SMPLRT_DIV、CONFIG、GYRO_CONFIG、ACCEL_CONFIG这些)、以及按需读取传感器数据寄存器。移植到龙芯Linux平台时,I2C初始化换成Linux的I2C子系统,寄存器配置的逻辑可以原样保留,但数据读取要通过I2C adapter的API去操作,而不是直接拉GPIO模拟时序。这就是边界:传感器内部时序和寄存器语义是ST给了规范的,这部分要原封不动搬过来;怎么跟芯片打交道,则是新平台再实现一遍。
1.2 选型对比:裸机直操、字符设备还是内核子系统
方案选型决定了后面所有工作。当时我们对比了三条路:一是在龙芯的用户态程序里直接操作GPIO模拟I2C时序访问传感器,简单粗暴,但占用CPU、时序不稳,更重要的是跟系统里其他I2C设备有冲突风险;二是写一个独立的字符设备驱动,把MPU6050的读写接口暴露给用户态,上层应用直接open/read/ioctl,这个方案自由度大,但所有逻辑要自己管理,后续要接入Linux的标准传感器框架就麻烦了;三是用内核标准的IIO(Industrial I/O)子系统,把MPU6050挂成i2c client设备,让IIO框架帮我们处理buffer、trigger、sysfs接口这些事。
最终选了IIO方案,理由很直接:Linux内核的IIO子系统就是为传感器这种设备设计的,MPU6050这种六轴传感器在主流内核里甚至已经有现成的IIO驱动可以参照,后面如果要支持设备树配置、中断触发采样、DMA搬数据,都不用自己从头写。更重要的是,龙芯官方内核版本相对主流内核有一定滞后,用标准框架可以减少后续内核升级时带来的适配工作量。
1.3 资源盘点:确认龙芯平台上的I2C控制器怎么用
无论驱动方案选得多漂亮,底层通路不通就全是空谈。在动手写MPU6050驱动前,必须先把龙芯2K1000平台的I2C控制器摸清楚。龙芯的核心里一般至少有两组I2C控制器,分别挂在不同基地址上,对应的设备树节点需要自己确认。这一步强烈建议先用内核自带的i2c-tools工具做一次探测,把总线上有哪些设备、地址对不对先验证了,再往下走。我们当时就在这一步发现,板卡上I2C2总线的上拉电阻焊错了位置,导致SDA一直被拉低,总线直接stuck——这个在STM32上很难遇到的问题,在Linux下通过i2cdetect非常容易暴露。
2. 驱动移植的核心细节:ST驱动里到底有哪些"不能搬"的东西
2.1 从寄存器操作到设备树映射
ST平台的MPU6050驱动里,第一件事往往是写一个初始化函数,里面一堆I2C_WriteByte这样的调用。这些函数在Linux下不会存在,替代者是i2c_transfer或者更封装好的i2c_smbus_read/write。但这里有个容易忽略的点:STM32的I2C时序参数和龙芯平台的I2C频率怎么匹配。MPU6050的手册里写得很清楚,I2C最大支持400kHz(快速模式)。STM32例程里通常会把I2C外设配置为400kHz,而龙芯Linux内核的I2C控制器驱动默认频率一般是100kHz,你配置成400kHz也没有问题。但如果你的板卡上还有其他老器件只支持100kHz,共用一个总线的时候就要小心了,最简单是给MPU6050单独挂一条I2C总线,或者把频率统一降到100kHz——传感器性能虽会打点折扣,但姿态解算的采样率通常几十到几百Hz,100kHz完全够用。
还有一个必须匹配的地方是中断引脚。MPU6050的INT输出是推挽还是开漏、低有效还是高有效,STM32例程里一般在GPIO配置里设置。在Linux下,这些信息全部来自设备树。设备树里的interrupt属性要跟硬件实际接法一致,包括GPIO控制器、引脚号和触发方式。我们当时在这里踩了一个不小的坑:设备树里写了IRQ_TYPE_EDGE_RISING,但硬件上传感器的INT是开漏输出,上拉电阻后默认高,产生中断时被拉低,怎么配都是反的,最终不看原理图光靠猜,浪费了小半天。
2.2 I2C事务拆分与错误处理逻辑重构
ST平台驱动读传感器数据通常是这样的顺序:设置起始地址寄存器,然后连续读若干个字节。比如读加速度计原始数据,顺序写一次寄存器地址0x3B,然后连续读14个字节。这个操作在STM32上直接一个I2C_Master_Recv就搞定了,但在Linux I2C子系统里,你要用两段式的i2c_transfer:先发送一个写命令,指定内部寄存器地址;然后紧接着发一个读命令,读指定长度数据。这两段要放到同一个消息数组里,借助I2C的repeated start特性,保证总线事务不被打断。
static int mpu6050_i2c_read_regs(struct i2c_client *client, u8 reg, u8 *buf, int len) { struct i2c_msg msgs[2] = { { .addr = client->addr, .flags = 0, .len = 1, .buf = ®, }, { .addr = client->addr, .flags = I2C_M_RD, .len = len, .buf = buf, }, }; if (i2c_transfer(client->adapter, msgs, 2) != 2) { dev_err(&client->dev, "i2c read regs failed, reg = 0x%02x\n", reg); return -EIO; } return 0; }这里就得好好聊一聊错误处理了。STM32裸机驱动里,I2C读失败最常见的处理方式是"重试几次"——因为裸机环境下总线冲突概率低,顶多是时序配置有问题。但在Linux内核里,I2C传输失败的原因五花八门:总线被其他设备占用锁死、从设备时钟拉伸导致超时、电源域未上电、总线频率太高斜率不达标等等。如果驱动里遇到EIO就傻傻地重试,很可能导致内核线程卡在死循环里。更重要的是,Linux I2C子系统的错误码是有语义的:-EIO是总线错误,-ENXIO是设备无应答,-EAGAIN是总线忙。上层在retry的时候应该区分错误类型,-EAGAIN这类才值得重试几次,-ENXIO就该直接考虑是不是设备地址没配对、电源没上齐。这些逻辑在ST平台不会遇到,移植时如果不补上,后面做压力测试很容易被埋。
2.3 初始化序列与校验逻辑按平台重新梳理
MPU6050的初始化流程是固定的:唤醒(PWR_MGMT_1写0)、配置采样率、配置量程、配置低通滤波。这些寄存器配置的值是ST官方标注好的,不需要改动。但初始化完成后,通常要读一下WHO_AM_I寄存器确认设备在线,这个检查逻辑必须在Linux驱动里保留,因为它能避免很多"以为在转、其实没在转"的问题。
比较容易被忽略的是MPU6050的数据输出格式。加速度计和陀螺仪的原始数据都是有符号16位整数,大端字节序。ST的例程里会拼一下字节序,这在Linux下也要处理。但Linux内核的字节序辅助函数跟STM32裸机里有区别:裸机里你可以直接手动移位拼接,在Linux内核里建议用get_unaligned_be16或者be16_to_cpu,有一类"未对齐访问"的问题是从STM32移植到ARM64/MIPS平台时最容易被忽略的——STM32F1是ARM Cortex-M3内核,支持非对齐访问;龙芯是MIPS架构,非对齐访问会触发异常,搞不好直接内核panic,这一点一定要格外小心。
还有一个容易忽略的点是MPU6050内置温度传感器的校准。ST库例程里会读温度寄存器换算出温度值,换算公式是T = 36.53 + temp_reg / 340。这个公式里的偏移和灵敏度系数在量程配置不变时可以直接沿用,但如果你的系统对温度要求高,还是要做一个逐片校准。我们在龙芯平台上做整机测试时就发现,传感器焊在板子上之后,受CPU发热影响,温度读数比环境温度高十几度,这个现象在裸机调试时往往被忽略,但上了Linux系统,CPU负载上来后温度漂移会直接反映到陀螺仪零偏上。
3. 实操过程:从零到一在龙芯内核里把MPU6050驱动跑起来
3.1 环境准备与内核配置
这次移植工作的目标平台是龙芯2K1000,这是一颗面向工业控制、电力、交通等嵌入式场景的SoC,集成了两个LA264处理器核,主频1GHz,片上带了丰富的外设控制器。它跑Linux的方式跟主流ARM平台几乎一样,都是通过设备树描述硬件,内核版本我们用的是龙芯官方的Loongnix内核,基于Linux 4.19内核演进而来这个版本对2K1000支持得很完整,但IIO子系统的相关配置项需要手动开启。
内核配置要注意打开这几个选项:CONFIG_I2C(必须)、CONFIG_I2C_DESIGNWARE_PLATFORM之类对应的龙芯I2C控制器驱动、CONFIG_IIO、CONFIG_IIO_BUFFER、CONFIG IIO TRIGGERED BUFFER。其中CONFIG_IIO_TRIGGERED_BUFFER这个最容易漏,如果没有它,即使驱动里写了iio_triggered_buffer_setup,也只是不报错,但整个buffer功能是空的,读不到数据。
# 进入内核源码目录,确认相关配置 make loongson2k_defconfig make menuconfig # 确认以下配置项: # Device Drivers -> Industrial I/O support -> [*] Industrial I/O support # Device Drivers -> Industrial I/O support -> [*] Industrial I/O buffering # Device Drivers -> Industrial I/O support -> [*] Triggered buffer support # Device Drivers -> I2C support -> [*] I2C support # 保存后编译 make -j$(nproc) uImage3.2 设备树节点的编写与验证
设备树是Linux下描述硬件信息的关键。在龙芯平台,I2C控制器节点通常已经在SoC的dtsi文件里定义好了,我们只需要在板级dts文件里追加MPU6050这个从设备的子节点。设备树里要包含设备使用的地址、中断信息以及可能需要的私有属性。MPU6050的7位地址默认是0x68,如果AD0引脚接了高电平则变成0x69,这个要看实际硬件连接。
&i2c2 { status = "okay"; clock-frequency = <400000>; mpu6050@68 { compatible = "invensense,mpu6050"; reg = <0x68>; interrupt-parent = <&gpio0>; interrupts = <11 IRQ_TYPE_EDGE_RISING>; mount-matrix = "0", "1", "0", "-1", "0", "0", "0", "0", "1"; }; };这里有一点要特别提一下:interrupt-parent和interrupts这两个属性的写法,不同平台的GPIO控制器不太一样。龙芯2K1000的GPIO控制器在设备树里的表示方式需要查对应手册,别想当然抄ARM板卡的写法。
写完设备树后先不要急着进内核,用i2cdetect直接验证一下I2C总线上能不能扫到0x68这个地址,这是最简单有效的一步。如果设备树和内核I2C控制器驱动都正常,i2cdetect -y 2应该能在对应位置看到设备编号。
3.3 驱动代码的移植与编译
驱动代码的移植放在一个独立的C文件里,直接放在drivers/iio/imu/目录下(如果这目录已经在Makefile里被编译了话)里面参考Linux内核里已有的mpu6050驱动框架大部分功能可以复用,但因为我们是从零开始移植,也没有直接引用内核里现成的MPU6050代码,而是把ST平台的驱动程序逻辑重新用IIO框架实现了一遍,这样更贴近我们自己的传输需求,也方便后面自己维护。
核心代码大概分三块:一是I2C通信接口,前面已经展示了i2c_read_regs的写法,写寄存器方向类似但不需要repeated start。二是IIO通道描述,定义设备支持的通道类型、精度、偏移量和位宽,这一部分直接决定用户态读到的是什么数据。三是triggered buffer机制,当采样触发到来时,把加速度的X/Y/Z、陀螺仪的X/Y/Z共6个通道数据一次性读入buffer。
#include <linux/i2c.h> #include <linux/module.h> #include <linux/iio/iio.h> #include <linux/iio/buffer.h> #include <linux/iio/trigger_consumer.h> #include <linux/iio/triggered_buffer.h> #define MPU6050_REG_ACCEL_XOUT_H 0x3B #define MPU6050_REG_GYRO_XOUT_H 0x43 #define MPU6050_READ_LEN 14 struct mpu6050_data { struct i2c_client *client; struct mutex lock; s16 buf[8]; /* 6轴数据 + 时间戳占位 */ }; static int mpu6050_read_raw(struct iio_dev *indio_dev, struct iio_chan_spec const *chan, int *val, int *val2, long mask) { struct mpu6050_data *data = iio_priv(indio_dev); int ret; u8 reg = (chan->type == IIO_ACCEL) ? MPU6050_REG_ACCEL_XOUT_H + chan->scan_index * 2 : MPU6050_REG_GYRO_XOUT_H + (chan->scan_index - 3) * 2; mutex_lock(&data->lock); ret = mpu6050_i2c_read_regs(data->client, reg, (u8 *)&data->buf[chan->scan_index], 2); mutex_unlock(&data->lock); if (ret < 0) return ret; *val = be16_to_cpu(data->buf[chan->scan_index]); return IIO_VAL_INT; }编译的时候一定要确保内核头文件路径正确,驱动编成模块还是编进内核要考虑清楚。开发阶段强烈建议编成模块(M),这样改代码不用整个内核重编,直接insmod/rmmod循环测试。我们组在开发阶段就是一直用模块方式,只有验证稳定后才考虑编进内核,这个习惯能省下大量等编译的时间。
3.4 用户态验证与数据读取
驱动编译安装后,用户态可以通过sysfs接口直接读到传感器数据。IIO框架会自动创建/sys/bus/iio/devices/iio:deviceX目录,里面会列出available_scan_masks、in_accel_x_raw、in_gyro_y_raw之类的属性节点。直接cat这些节点,如果数值在合理范围变化(比如轻轻晃动板子时加速度计读数有明显波动),说明最基础的寄存器通路的读写已经正常。
# 查看设备是否被正确识别 cat /sys/bus/iio/devices/iio:deviceX/name # 应该是输出 "mpu6050" 或者你自己定义的 compatible 对应的名字 # 读取一个原始加速度值 cat /sys/bus/iio/devices/iio:deviceX/in_accel_x_raw # 如果板子水平静止,数值应该在0附近波动但注意,直接cat属性节点走的是read_raw回调,是逐个通道单独读的。如果要拿到一组同步的六轴数据,必须启用buffer模式。IIO的buffer模式需要先设置扫描元素,再使能buffer,最后通过/dev/iio:deviceX设备节点以指定格式读取。具体操作可以通过内核提供的工具iio_generic_buffer来做,也可以通过驱动程序自带的trigger触发。这里要提醒的是,别指望用cat命令读/dev/iio:deviceX——它是二进制流,不是文本,必须用工具读。
我们当时用了一个小脚本,每100ms从buffer模式里取一帧数据,做一次静态校验:把重力加速度的模长算一下,正常应该在9.8m/s²附近波动不超过0.2。如果算出来的模长偏离非常大,多半是量程或者灵敏度系数填错了。这一步校验很值,能把配置错误和硬件问题快速分开。MPU6050加速度计的量程选择(±2g/±4g/±8g/±16g)直接决定了换算系数的分母,如果驱动里读到的原始值按±2g去换算,但实际配置的是±16g,那算出来的重力模长就会明显偏大。量程、灵敏度和姿态算法的系数是强关联的,这里错了后面全错。
4. 常见问题与排查技巧实录
4.1 I2C总线挂在半路:stuck bus的处理
这次移植遇到的最典型的问题就是I2C总线卡死。现象是设备树和内核都配置正确,但i2cdetect什么都扫不到,用示波器看SDA线一直低电平,这就是所谓的"总线stuck"。排查步骤很直接:先把I2C控制器复位一遍,确认从设备供电、上拉电阻无误,再看是否是有别的驱动在总线事务中途被打断了。
但有一个在Linux下特有、裸机下少见的坑:I2C控制器中断号冲突或者DMA通道冲突会导致总线事务异常。龙芯2K1000的I2C控制器支持DMA模式,如果在设备树里配置了DMA但DMA通道被其他设备占用了,启动的时候不报错,但跑着跑着总线事务就会断在半路,从而造成stuck。当时我们查了半天都不明所以,最后在dmesg里看到i2c_designware的timeout警告,顺着线索才定位到DMA通道冲突。
解决方法是暂时禁用I2C控制器的DMA功能,在设备树里将dmas属性删掉,改成纯中断模式。对于MPU6050这种低速传感器,DMA完全没必要,中断模式足够,还少了一层复杂度。
4.2 设备树匹配不上:为什么compatible对不上
另一个高频问题是内核驱动明明编译进去了,但/sys/bus/i2c/devices下面就是没有设备节点。排查思路是这样的:先看设备树节点有没有被正确加载,可以看/proc/device-tree或者debugfs里的of_node信息;再看I2C总线上的设备有没有被扫描到,i2cdetect -l列出总线号,i2cdetect -y 总线号看设备地址;如果设备扫到了但对不上驱动,大概率是compatible字符串不匹配。
这里有个小细节:驱动里的of_match_table里的compatible字符串必须跟设备树里的完全一致,包括大小写和点号。我们当时把"invensense,mpu6050"写成了"invensense,mpu60x0",就差一个字符,结果驱动就是挂不上。这个纯属手滑,但排查起来又慢又气,后来养成了习惯——写完设备树和驱动后,先用一个命令验证compatible是否存在对应驱动:
# 查看i2c总线上已注册的设备和驱动匹配情况 ls /sys/bus/i2c/devices/ cat /sys/bus/i2c/devices/2-0068/name # 如果能读出名字,说明匹配成功4.3 数据持续为0或跳动异常:寄存器配置与并发访问排查
数据全为0,这种问题通常不是I2C读失败,而是寄存器地址或者读取长度写错了。比如读加速度计X轴,起始地址是0x3B,读两个字节。如果起始地址误写成了0x3D(Y轴),那X轴读出来的自然就是0。这种错误在裸机调试时一眼就能在调试器里看出来,但在Linux下需要加打印或者用逻辑分析仪抓I2C波形才能定位。
还有一个隐蔽的问题是并发访问。如果你的驱动里没有加锁,而用户态同时有两个线程在读同一个通道,I2C总线访问就会交叉,读回来的数据有可能是两个通道的混合体。在STM32裸机里,这种并发问题一般不会发生,因为你的主循环是单线程的;但在Linux内核里,read回调可能被多个进程同时调用,不加mutex就会偶发性拿到错位数据。这一点我们一开始没有注意,直到做1000次连续采样稳定性测试时才暴露出来,加锁后就消失了。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查手法 | 解决办法 |
|---|---|---|---|
| i2cdetect扫不到设备 | 上拉电阻、供电、总线stuck | 示波器量SDA/SCL波形 | 检查硬件,复位I2C控制器 |
| i2cdetect有设备但无驱动节点 | compatible不匹配 | cat /sys/bus/i2c/devices/*/name | 修正设备树或of_match_table |
| 驱动注册成功但数据全为0 | 寄存器地址或读取长度错误 | 逻辑分析仪抓I2C时序 | 对照手册核对寄存器映射 |
| 数据跳动剧烈 | 量程配置与换算系数不一致 | 静态重力模长校验 | 统一量程和灵敏度 |
| 系统启动后偶发I2C timeout | DMA通道冲突或时钟频率过高 | dmesg查timeout警告 | 禁用DMA或降频至100kHz |
| 频繁读取时有错位数据 | 并发访问未加锁 | 多线程压力测试复现 | 加mutex保护I2C事务 |
| 中断触发频率异常 | 中断触发方式或GPIO配置错误 | 中断测试脚本来验证 | 按原理图修正设备树中断属性 |
| 编译报错找不到头文件 | 内核头文件路径不对 | 检查Kbuild/Makefile | 补全依赖头文件 |
5. 最后再分享一点心得
做驱动移植,最忌讳的就是一头扎进代码里猛改,改着改着忘了最初的边界。每次动手前,先把这个平台上"哪些是系统给你的、哪些是要自己写的"想清楚,能省下一大半调试时间。这次的MPU6050移植对我们组来说只算个开始,后面还有气压计、磁力计、多路PWM输出要全部从ST平台搬过来。有了这一次的经验,后面几个驱动的移植速度明显快了很多——因为架构思路通了,搬的就不再是一行行代码,而是一套成熟的映射关系:哪些按原样保留,哪些必须重写,哪些要格外小心。希望这篇流水账一样的记录,也能让正在做类似事情的朋友少走几次弯路。