如何向 Linux 中加入一个 IO 扩展芯片

发布于:2025-03-17 ⋅ 阅读:(14) ⋅ 点赞:(0)

前言

  1. 个人邮箱:zhangyixu02@gmail.com
  2. 实习期的第一个小任务,适配一个 I2C IO 扩展芯片,算是师傅试试我的水平吧。
  3. 一开始我个人觉得一个 I2C IO扩展芯片很简单,程序改了一遍又一遍,师傅一直不满意,最终导致我有一点点不满了(没表现出来,怂,哈哈哈哈)。因为我觉得就一个这么简单的芯片驱动,我写的感觉很好了啊,为什么一直不满意。
  4. 后面改了两天都一直不满意,最终扔给我一个 Linux 内置的 IO 扩展芯片的示例给我看,我才明白为什么一直不满意。
  5. 我们要明白一个道理,IO 扩展芯片是需要给其他驱动设备使用的,因此 IO 扩展芯片要给其他设备使用,因此不能简单的将 IO 扩展芯片利用杂项设备给应用层提供接口,而是应该注册进入 GPIO 子系统,让其他驱动设备使用。这才是驱动层应该做的事情,彻底屏蔽底层差异性,给上层提供一个安全可靠的接口。
  6. 本文使用到的 IO 扩展芯片手册:TCA9535 芯片手册

硬件理解

  1. 要编写一个芯片的驱动程序,首先要做的是阅读芯片手册,简单理解芯片实现原理。

从机地址

  1. 首先我们需要知道这个芯片的地址如何确定,查看芯片手册 7.5.2 Device Address 章节即可知道,A0、A1、A2 决定 I2C 从机地址的低三位地址,并且这是 7 位地址芯片。

在这里插入图片描述

Control 寄存器

  1. 了解了地址,我们就需要看要操作的寄存器。整体而言还是很简单的,只有 8 个寄存器,同时存在一个 0x04 和 0x05 作为极性翻转寄存器,这里用不到。
  2. 关于寄存器的详细介绍,我们需要查阅 7.6.1 Register Descriptions 即可。基本上只需要看如下两个寄存器介绍即可。
  3. 极性反转寄存器:当寄存器配置为0,那么I2C输入的是0,TCA9535输出的也是0。如果寄存器置1,那么I2C输入的是0,TCA9535输出的是1。该寄存器默认不进行反转。
  4. 输入输出配置寄存器:配置为1表示输入,配置为0表示输出。
    在这里插入图片描述

INT 引脚

实现原理简介

  1. 这个是中断引脚,当我们需要使用到中断功能的时候,该引脚就会使用到。不过我们的项目中没有使用该功能,因此直接接一个 10 K Ω \Omega Ω 上拉电阻即可。
  2. 虽然用不到,但还是对该引脚做一个简单的介绍,并且说明为什么不推荐使用该功能。
  3. 我们查看 Figure 7-1. Logic Diagram (Positive Logic) 这个芯片的简单概括图,可知:
    • 中断引脚由输入寄存器 + IO 引脚值通过异或门获取,即当输入寄存器IO 引脚值存在不同时,将会给 INT 引脚一个高电平的触发信号,通知出现了中断。
    • Read Pluse 应该就是 SCL 信号,只不过是只有当对应的 IO 为输入的时候,SCL 就是 Read Pluse。如果 IO 为输出,SCL 就是 Write Pluse。
    • 因为 输入寄存器 是一个 D 触发器,当 SCL 为低电平时,Q 不受 D 端输入信号的控制,保持原状态不变。因此,我们就可以理解为什么上面的输入寄存器IO 引脚值会存在不同的情况了。
    • SCL 为高电平,此时 Q 接受 D 端输入的信号,其状态翻到和 D 的状态相同。因此,Input Port Register Data 就是我们读取到的寄存器值,不过我们看 Input Port Register Data 下面似乎还有一个 Polarity Inversion Register,这个就是用来做极性反转的,因此我们读取到的数据,并不一定是实际电平数据,而是逻辑电平!但如果我们没有设置极性反转,那么读取到的电平值就是实际电平值。
    • SCL 为高电平时,因为此时 Q 和 D 电平值是一样的,理论上 INT 引脚状态会恢复。但是 7.3.3 Interrupt ( INT) Output 章节说了,INT 复位发生在 SCL 信号上升沿之后的应答(ACK)位。那么,这里的这个设计感觉很矛盾,这又如何理解呢?
    • 很简单,如下图给的只是简略图,并不完善,异或门之后的 To INT 还有其他电路做电平钳位,只有到从机发送 ACK 的时候,电平钳位才会取消。而这里的 INT 引脚恢复,只是清除对应的标志位,也就是 7.3.3 Interrupt ( INT) Output 章节的 Because each 8-bit port is read independently, the interrupt caused by port 0 is not cleared by a read of port 1, or the interrupt caused by port 1 is not cleared by a read of port 0.(由于每个 8 位端口是独立读取的,因此读取端口 1 不会清除由端口 0 引起的中断,读取端口 0 也不会清除由端口 1 引起的中断)

在这里插入图片描述

  1. 有了上述的理解,我们再来看看时序图进一步加深理解。这里就自行分析了,我写出来会很麻烦,你们还不一定看的懂。

在这里插入图片描述

INT 触发

  1. 通过上述的实现原理简介后,我们最后做一下总结:
    • 只有对应端口配置为输入才会引起 INT 引脚电平拉低。
    • INT 会检测芯片上的 16 个 IO 中所有配置为输入的 IO。所有配置为输入的 IO 的电平变化都有可能导致 INT 触发中断。
    • INT 触发中断的体现为,高电平变成低电平。
    • INT 引脚为双边沿触发中断。

中断重置

  1. 如下两个很重要,是你看懂上述时序图的关键:
    • 只有当我们从产生中断的端口读取数据时,INT 引脚才会重新恢复到高电平。
    • 重置只发生在 ACK 位。

异常

  1. 如果将 I/O 从输出更改为输入,而引脚的状态与输入端口寄存器的内容不匹配,可能会导致误中断发生。
  2. 在发送 ACK 命令后,INT 引脚会复位,在这个短暂的复位期间,如果 IO 端口出现了电平变化,可能导致中断丢失的情况。

经典电路图

在这里插入图片描述

软件实现

中断

  1. 我驱动程序中没有实现,这里只给一个简单思路:
    • INT 引脚挂载在一个 GPIO 中断引脚中。
    • 每次读取 IO 值,都要在主机中缓冲。
    • GPIO 配置为下降沿中断检测,当触发中断,将所有配置为输入的 IO 端口都读一遍。
    • 将读取到的值与之前记录的缓存值进行对比,最终得到实际产生中断的 IO 端口是哪一个。
    • 需要注意:这里设计到中断映射的问题,具体内容建议阅读 kernel/drivers/gpio/gpio-pca953x.c 文件。

寄存器值缓存

  1. 因为 IO 扩展芯片的寄存器不是 CPU 可直接访问的,因此为了保证我们不会向配置为输入的 IO 端口误写入值,或者覆盖其他 IO 的配置值,因此我们需要缓存 IO 扩展芯片内部寄存器值。例如下面我创建一个结构体用户缓存寄存器值。
struct tca9535_i2c {
    // ...
};
  1. 不过这里存在一个挑战,但主机异常复位,但是 TCA7535 没有复位,导致芯片内部记录的寄存器状态和 TCA7535 不一致,因此在装载驱动初期,需要读取 TCA7535 寄存器中的值进行填充。
  2. 不过我驱动代码里面没有这样处理,因为偷懒,哈哈哈哈哈。

从机检测机制

  1. 在驱动加载中,我们需要检测能否正常与从机通讯。因为 I2C 通讯过程中,主机异常复位可能会导致 TCA7535 钳位 SDA 线。
  2. 因此,每次 probe 函数最开始都尝试读取 1 byte 数据。如果 SDA 被 TCA7535 钳位,那么 start 信号无法发出来,那么下面的检测机制将不会通过。
char test_buf;
ret = i2c_master_recv(client,&test_buf,sizeof(test_buf));
if (ret < 0) {
    pr_err("%s: no device!\n",  __func__);
    return ret;
}

数据安全性问题

锁机制

  1. 因为 Linux 是多进程和多线程的,因此要考虑同步与互斥的问题。因为驱动程序读操作比写操作更多,所以我采用读写锁进行互斥保护。
struct tca9535_i2c {
    rwlock_t rwlock;                /*< 读写锁 */
    // ...
};    

read_lock(&tca9535_i2c_dev->rwlock);
// 读操作 
read_unlock(&tca9535_i2c_dev->rwlock);

write_lock(&tca9535_i2c_dev->rwlock);
// 写操作
write_unlock(&tca9535_i2c_dev->rwlock);

static int tca9535_probe(struct i2c_client *client,const struct i2c_device_id *devid)
{
    // ...
    // 3. 初始化读写锁
    rwlock_init(&tca9535_i2c_dev_p->rwlock);
    // ...
}

死锁检测

  1. 因为 IO 扩展芯片是位于 GPIO 子系统中,多个进程之间锁存在依赖关系,那么就很可能会产生死锁的情况。我们此时就需要通过 lockdep 工具帮我们动态检测是否存在内核死锁风险。
  2. 不过需要注意,该机制在多个 IO 扩展性芯片处于同一层级的不同分支会存在误报情况。如果是位于设备树的不同层级,那么就可以避免。理由如下:
    • Lockdep 使用子类编号来区分不同的锁实例,以便更精细地跟踪锁的使用情况。
    • 当 I2C IO 扩展器位于设备树的同一层级但在不同分支时,Lockdep 可能会为这些扩展器分配相同的子类编号。
rwlock_init(&tca9535_i2c_dev_p->rwlock);
lockdep_set_subclass(&tca9535_i2c_dev_p->rwlock,i2c_adapter_depth(client->adapter));

私有数据

  1. 因为驱动和设备是多对一的关系,当前的驱动程序可能会被多个设备匹配上,因此,使用全局变量是非常不安全的。
  2. 因此我们需要在 probe 函数中通过动态申请内存,然后再使用私有数据方式保证数据安全性。
static int my_gpio_probe(struct i2c_client *client, const struct i2c_device_id *id) 
{
	// 局部变量是动态申请,因此不用考虑数据安全性问题
	struct tca9535_i2c *my_chip;
	// 动态申请内存
	my_chip = devm_kzalloc(&client->dev, sizeof(*my_chip), GFP_KERNEL);
	// ...其余代码...
	// 使用私有数据
	i2c_set_clientdata(client, my_chip);
}

驱动加载顺序

  1. 其他模块可能会依赖,因此该 IO 扩展芯片的驱动加载应该要靠前。
  2. 因此,在注册是时候建议使用 subsys_initcall 宏提前加载。
static struct i2c_driver tca9535_driver = {
    .probe = tca9535_probe,
    .remove = tca9535_remove,
    .id_table = tca9535_ids,
    .driver = {
        .owner = THIS_MODULE,
        .name = "tca9535",
        .of_match_table = dts_table,
    },
};

static int __init tca9535_init(void)
{
    return i2c_add_driver(&tca9535_driver);
}

static void __exit tca9535_exit(void)
{
    i2c_del_driver(&tca9535_driver);
}

/* 在 i2c 的 postcore 初始化调用之后注册,
 * 并在可能依赖于这些 GPIO 的子系统初始化调用之前注册 */
subsys_initcall(tca9535_init);
module_exit(tca9535_exit);

加入 GPIO 子系统

  1. 加入 GPIO 子系统挺容易,只需要对指定的结构体进行补充,然后注册即可。
  2. 如下为 GPT 生成的简单框架。
#include <linux/module.h>
#include <linux/gpio/driver.h>
#include <linux/i2c.h>

struct my_gpio_chip {
    struct gpio_chip chip;
    struct i2c_client *client;
    // 其他必要的成员变量
};

static int my_gpio_get_direction(struct gpio_chip *chip, unsigned offset) {
    // 实现获取 GPIO 方向的逻辑
    return 0; // 返回 0 表示输出,1 表示输入
}

static int my_gpio_direction_input(struct gpio_chip *chip, unsigned offset) {
    // 实现设置 GPIO 为输入的逻辑
    return 0;
}

static int my_gpio_direction_output(struct gpio_chip *chip, unsigned offset, int value) {
    // 实现设置 GPIO 为输出的逻辑
    return 0;
}

static int my_gpio_get(struct gpio_chip *chip, unsigned offset) {
    // 实现读取 GPIO 状态的逻辑
    return 0;
}

static void my_gpio_set(struct gpio_chip *chip, unsigned offset, int value) {
    // 实现设置 GPIO 状态的逻辑
}

static int my_gpio_probe(struct i2c_client *client, const struct i2c_device_id *id) {
    struct my_gpio_chip *my_chip;
    int ret;

    my_chip = devm_kzalloc(&client->dev, sizeof(*my_chip), GFP_KERNEL);
    if (!my_chip)
        return -ENOMEM;

    /* 初始化I2C锁 */
    mutex_init(&chip->i2c_lock);
    /*
     * In case we have an i2c-mux controlled by a GPIO provided by an
     * expander using the same driver higher on the device tree, read the
     * i2c adapter nesting depth and use the retrieved value as lockdep
     * subclass for chip->i2c_lock.
     *
     * REVISIT: This solution is not complete. It protects us from lockdep
     * false positives when the expander controlling the i2c-mux is on
     * a different level on the device tree, but not when it's on the same
     * level on a different branch (in which case the subclass number
     * would be the same).
     *
     * TODO: Once a correct solution is developed, a similar fix should be
     * applied to all other i2c-controlled GPIO expanders (and potentially
     * regmap-i2c).
     */
    lockdep_set_subclass(&chip->i2c_lock,
                 i2c_adapter_depth(client->adapter));

    my_chip->client = client;
    my_chip->chip.label = dev_name(&client->dev);
    my_chip->chip.parent = &client->dev;
    my_chip->chip.owner = THIS_MODULE;
    my_chip->chip.base = -1; // 自动分配基地址
    my_chip->chip.ngpio = 16; // 假设芯片有 16 个 GPIO 引脚
    my_chip->chip.can_sleep = true;
    my_chip->chip.get_direction = my_gpio_get_direction;
    my_chip->chip.direction_input = my_gpio_direction_input;
    my_chip->chip.direction_output = my_gpio_direction_output;
    my_chip->chip.get = my_gpio_get;
    my_chip->chip.set = my_gpio_set;

    ret = devm_gpiochip_add_data(&client->dev, &my_chip->chip, my_chip);
    if (ret)
        return ret;

    i2c_set_clientdata(client, my_chip);
    return 0;
}

static const struct i2c_device_id my_gpio_id[] = {
    { "my_gpio_chip", 0 },
    { }
};

static struct i2c_driver my_gpio_driver = {
    .driver = {
        .name = "my_gpio_chip",
    },
    .probe = my_gpio_probe,
    .id_table = my_gpio_id,
};

module_i2c_driver(my_gpio_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A GPIO driver for my I/O expander");

最终驱动代码

代码

  1. 后续补充完整代码

设备树

调试

  1. 查看驱动加载情况,建议在 subsys_initcall 中的函数里面加入一条打印信息,然后 dmesg 判断驱动是否加入了内核。
static int __init tca9535_init(void)
{
	printk("tca9535_init hello\n");
    return i2c_add_driver(&tca9535_driver);
}

static void __exit tca9535_exit(void)
{
    i2c_del_driver(&tca9535_driver);
}

/* 在 i2c 的 postcore 初始化调用之后注册,
 * 并在可能依赖于这些 GPIO 的子系统初始化调用之前注册 */
subsys_initcall(tca9535_init);
module_exit(tca9535_exit);
  1. 之后调用如下命令查控制器是否注册成功。
cat /sys/kernel/debug/gpio

在这里插入图片描述

  1. 最后调用如下命令,配合万用表进行测试。
# 导出引脚编号 441 的 GPIO
$ echo 441 > /sys/class/gpio/export
# 注销引脚编号 441 的 GPIO
# echo 258 > /sys/class/gpio/unexport
#------------------------ 输出测试 ------------------------
# 设置输出方向
$ echo out > /sys/class/gpio/gpio441/direction
# 设置为高电平
$ echo 1 > /sys/class/gpio/gpio441/value
# 设置为低电平
$ echo 0 > /sys/class/gpio/gpio441/value
#------------------------ 输入测试 ------------------------
# 设置输入方向
$ echo in > /sys/class/gpio/gpio441/direction
# 读取 gpio441 的状态
$ cat /sys/class/gpio/gpio441/value

参考

  1. 知乎:逻辑门讲解:与门、或门、非门、与非门、或非门、异或门、同或门等
  2. C站:D触发器