良许Linux教程网 干货合集 Linux i2c子系统(一) :编写i2c设备驱动

Linux i2c子系统(一) :编写i2c设备驱动

I2C Bus(Inter-Integrated Circuit Bus) 最早是由Philips半导体(现被NXP收购)开发的两线时串行总线,常用于微控制器与外设之间的连接。本文以linux3.14.0为参考, 讨论Linux中的i2c驱动模型并利用这个模型写一个mpu6050的驱动, 最后在应用层将mpu6050中的原始数据读取出来

i2c子系统框架

下图就是我理解的i2c驱动框架示意图, 类似中断子系统, i2c子系统中也使用一个对象来描述一个物理实体, 设备对象与驱动分离, 驱动结合设备对象对硬件设备的描述才可以驱动一个具体的物理设备, 体现了分离的设计思想, 实现了代码的复用, 比如:

  • 一个i2c控制器就对应一个i2c_board_info, 它驱动就是s3c2410_i2c_driver, 他们通过platform_bus_type协调工作。
  • 一个i2c总线上的设备就对应内核中的一个i2c_client类型的对象, 它的驱动就是的i2c_driver, 二者通过i2c_bus_type协调工作。
  • 同样是抽象的思路, 对于i2c总线本身, 内核也使用i2c_bus_type来描述。

事实上, 对于任何一种总线, 内核都有一个bus_type类型的对象与之对应, 但是platform_bus_type并没有对应的实际的物理总线, 这也就是platform总线也叫虚拟总线的原因.

除了分离,i2c子系统也体现的软件分层的设计思想, 整个i2c子系统由3层构成:设备驱动层–i2c核心–控制器驱动

img
img

除了经典的分层与分离模型,我们也可以看到一个有意思的现象——Linux 的应用程序不但可以通过设备驱动来访问i2c从设备,还可以通过一个并没有直接挂接到i2c_bus_type的i2c_cleint来找到主机控制器进而访问任意一个i2c设备, 这是怎么回事呢? 我会在下一篇说^-^

核心结构和方法简述

核心结构

  • i2c_adapter对象实现了一组通过一个i2c控制器发送消息的所有信息, 包括时序, 地址等等, 即封装了i2c控制器的”控制信息”。它被i2c主机驱动创建, 通过clien域和i2c_client和i2c_driver相连, 这样设备端驱动就可以通过其中的方法以及i2c物理控制器来和一个i2c总线的物理设备进行交互
  • i2c_algorithm描述一个i2c主机的发送时序的信息,该类的对象algo是i2c_adapter的一个域,其中的master_xfer()注册的函数最终被设备驱动端的i2c_transfer()回调。
  • i2c_client描述一个挂接在硬件i2c总线上的设备的设备信息,即i2c设备的设备对象,与i2c_driver对象匹配成功后通过detected和i2c_driver以及i2c_adapter相连,在控制器驱动与控制器设备匹配成功后被控制器驱动通过i2c_new_device()创建。
  • i2c_driver描述一个挂接在硬件i2c总线上的设备的驱动方法,即i2c设备的驱动对象,通过i2c_bus_type和设备信息i2c_client匹配,匹配成功后通过clients和i2c_client对象以及i2c_adapter对象相连
  • i2c_msg描述一个在设备端和主机端之间进行流动的数据, 在设备驱动中打包并通过i2c_transfer()发送。相当于skbuf之于网络设备,urb之于USB设备。

img
img

核心方法

  • **i2c_transfer()**是i2c核心提供给设备驱动的发送方法, 通过它发送的数据需要被打包成i2c_msg, 这个函数最终会回调相应i2c_adapter->i2c_algorithm->master_xfer()接口将i2c_msg对象发送到i2c物理控制器

核心结构与方法详述

i2c_adapter

我首先说i2c_adapter, 并不是编写一个i2c设备驱动需要它, 通常我们在配置内核的时候已经将i2c控制器的设备信息和驱动已经编译进内核了, 就是这个adapter对象已经创建好了, 但是了解其中的成员对于理解i2c驱动框架非常重要, 所有的设备驱动都要经过这个对象的处理才能和物理设备通信

//include/linux/i2c.h
425 struct i2c_adapter {
426         struct module *owner;
427         unsigned int class;               /* classes to allow probing for */
428         const struct i2c_algorithm *algo; /* the algorithm to access the bus */
429         void *algo_data;
430
431         /* data fields that are valid for all devices   */
432         struct rt_mutex bus_lock;
433
434         int timeout;                    /* in jiffies */
435         int retries;
436         struct device dev;              /* the adapter device */
437
438         int nr;
439         char name[48];
440         struct completion dev_released;
441
442         struct mutex userspace_clients_lock;
443         struct list_head userspace_clients;
444
445         struct i2c_bus_recovery_info *bus_recovery_info;
446 };

struct i2c_adapter –428–>这个i2c控制器需要的控制算法, 其中最重要的成员是master_xfer()接口, 这个接口是硬件相关的, 里面的操作都是基于具体的SoCi2c寄存器的, 它将完成将数据发送到物理i2c控制器的”最后一公里” –436–>表示这个一个device, 会挂接到内核中的链表中来管理, 其中的 –443–>这个节点将一个i2c_adapter对象和它所属的i2c_client对象以及相应的i2c_driver对象连接到一起

下面是2个i2c-core.c提供的i2c_adapter直接相关的操作API, 通常也不需要设备驱动开发中使用,

i2c_add_adapter

这个API可以将一个i2c_adapter类型的对象注册到内核中, 源码我就不贴了, 下面是他们的调用关系,我们可以从中看到一个adapter对象和系统中的i2c_driver对象以及i2c_client对象的匹配流程。 首先,我们在驱动中构造一个i2c_adapter对象的时候,对其中的相关域进行初始化,这里我们最关心它的父设备

//drivers/i2c/buses/i2c-s3c2410.c
1072 static int s3c24xx_i2c_probe(struct platform_device *pdev)
1073 {    
1140         i2c->adap.dev.parent = &pdev->dev;  
1210 }

得到了这样一个i2c_adapter对象,我们就可以调用这个API将它注册到内核,调用关系如下:

i2c_add_adapter() 1 └──i2c_register_adapter(adapter) 2 ├──adap->dev.bus = &i2c_bus_type; 3 ├──adap->dev.type = &i2c_adapter_type; 4 │ └──i2c_adapter_attr_groups 5 │ └── i2c_adapter_attr_group 6 │ └── i2c_adapter_attrs 7 │ └── &dev_attr_new_device.attr 8 │ └──DEVICE_ATTR(new_device, S_IWUSR, NULL, i2c_sysfs_new_device); 9 │ └──i2c_sysfs_new_device() 10 │ └──list_add_tail(&client->detected, &adap->userspace_clients); 11 └──device_register(&adap-dev); 12 ├──device_initialize(dev); 13 │ ├──/* /sys/devices/ / 14 │ ├──struct kset devices_kset; 15 │ ├──dev->kobj.kset = devices_kset; 16 │ ├──kobject_init(&dev->kobj, &device_ktype); 17 │ └──set_dev_node(dev, -1); 18 └──device_add(dev); 19 ├──parent=get_device(dev->parent); 20 ├──kobj = get_device_parent(dev, parent); 21 │ └──return &parent->kobj; 22 ├──dev->kobj.parent = kobj; 23 ├──set_dev_node(dev, dev_to_node(parent)); 24 ├──kobject_add(&dev->kobj, dev->kobj.parent, NULL); 25 │ ├──kobject_add_varg(kobj, parent, fmt, args); 26 │ ├──kobj->parent = parent; 27 │ ├──kobject_add_internal(kobj); 28 │ ├──parent = kobject_get(kobj->parent); 29 │ ├──kobj_kset_join(kobj); 30 │ │ ├──kset_get(kobj->kset) 31 │ │ └──list_add_tail(&kobj->entry, &kobj->kset->list); 32 │ ├──kobj->parent = parent; 33 │ └──create_dir(kobj); 34 ├──device_create_file(dev, &dev_attr_uevent); 35 ├──device_create_sys_dev_entry(dev); 36 ├──devtmpfs_create_node(dev); 37 ├──device_add_class_symlinks(dev); 38 ├──device_add_attrs(dev); 39 ├──bus_add_device(dev); 40 ├──bus_probe_device(dev); 41 ├──klist_add_tail(&dev->p->knode_parent,&parent->p->klist_children); 42 └──klist_add_tail(&dev->knode_class,&dev->class->p->klist_devices);

调用关系就是这样了,下面我简单解释一下这个树

i2c_add_adapter() –10–>将i2c_adapter对象中的userspace_clients与匹配到的client对象中detected连接到一起 –15–>将/sys/devices的kset赋值给i2c_adapter->device->kobject->parent,即建立i2c_adapter对象和/sys/devices的父子关系, 参见”Linux设备管理(一)_kobject, kset,ktype分析” –20–>获取device->parent的kobject对象 –22–>将device->parent的kobject对象作为device->kobject->parent, 形成device->kobject->parent == device->parent->kobject –31–>将这个device->kobject挂接到device->kset->list链表中, 由此可见, kobject->kset指向的kset对象和kobject->entry挂接到的kset对象可以不是一个, 与”Linux设备管理(一)_kobject, kset,ktype分析“那种情况不同.

i2c_del_adapter()

从内核中删除一个adapter

i2c_client

在i2c设备端,驱动开发的主要工作和平台总线一样:构建设备对象和驱动对象,我用的开发板上的i2c总线上挂接的设备是mpu6050,接下来我就以我的板子为例,讨论如何编写i2c设备端的驱动。 同样这里的设备对象也可以使用三种方式构建:平台文件,模块和设备树。 本文采用设备树的方式构建设备对象,我们可以参考内核文档**”Documentations/devicetree/bindings/i2c/i2c-s3c2410.txt”以及设备树中的样板来编写我们的设备树节点, 我们在设备树中可不会写mpu6050内部寄存器的地址,因为这些寄存器地址SoC看不到**。

/{ 109 i2c@138B0000 { 110 #address-cells = ; 111 #size-cells = ; 112 samsung,i2c-sda-delay = ; 113 samsung,i2c-max-bus-freq = ; 114 pinctrl-0 =; 115 pinctrl-names=”default”; 116 status=”okay”; 117 mpu6050@68{ 118 compatible=”invensense,mpu6050″; 119 reg=; 120 }; 121 };

/ –109–>即我们SoC上的i2c控制器的地址 –116–>这个一定要okay,其实是对”./arch/arm/boot/dts/exynos4.dtsi +387″处的status = “disabled”的重写,相同的节点的不同属性信息都会被合并,相同节点的相同的属性会被重写 –117–>设备子节点,/表示板子,它的子节点node1表示SoC上的某个控制器,控制器中的子节点node2表示挂接在这个控制器上的设备(们)。68即是设备地址。 –118–>这个属性就是我们和驱动匹配的钥匙,一个字符都不能错 –119–>这个属性是从设备的地址,我们可以通过查阅手册”MPU-6050_DataSheet_V3_4″得到

写了这个设备节点,内核就会为我们在内核中构造一个i2c_client对象并挂接到i2c总线对象的设备链表中以待匹配,这个设备类如下

//include/linux/i2c.h
217 struct i2c_client {                          
218         unsigned short flags;           /* div., see below              */
219         unsigned short addr;            /* chip address - NOTE: 7bit    */
220                                         /* addresses are stored in the  */
221                                         /* _LOWER_ 7 bits               */
222         char name[I2C_NAME_SIZE];
223         struct i2c_adapter *adapter;    /* the adapter we sit on        */
224         struct device dev;              /* the device structure         */
225         int irq;                        /* irq issued by device         */
226         struct list_head detected;
227 };

–219–>设备地址 –223–>表示这个client从属的i2c主机对应的adapter对象,驱动方法中使用这个指针发送数据 –224–>表示这是一个device –225–>中断使用的中断号 –226–>将所有i2c_client连在一起的节点

i2c_driver

和平台总线类似,i2c驱动对象使用i2c_driver结构来描述,所以,编写一个i2c驱动的本质工作就是构造一个i2c_driver对象并将其注册到内核。我们先来认识一下这个对象

//include/linux/i2c.h
161 struct i2c_driver {
162         unsigned int class;
167         int (*attach_adapter)(struct i2c_adapter *) __deprecated;
170         int (*probe)(struct i2c_client *, const struct i2c_device_id *);
171         int (*remove)(struct i2c_client *);
174         void (*shutdown)(struct i2c_client *);
175         int (*suspend)(struct i2c_client *, pm_message_t mesg);
176         int (*resume)(struct i2c_client *);
183         void (*alert)(struct i2c_client *, unsigned int data);
188         int (*command)(struct i2c_client *client, unsigned int cmd, void *arg);
190         struct device_driver driver;
191         const struct i2c_device_id *id_table;
194         int (*detect)(struct i2c_client *, struct i2c_board_info *);
195         const unsigned short *address_list;
196         struct list_head clients;                            
197 };

struct i2c_driver –170–>探测函数,匹配成功之后执行,会将匹配到的i2c_client对象传入,完成申请资源,初始化,提供接口等工作。 –171–>移除函数,设备消失时会调用,驱动模块被rmmod时也会先被调用,完成和probe相反的操作。 –174–>这三个方法都是电源管理相关的接口 –190–>表明这是一个设备的驱动类,和platform一样,用于匹配设备树的of_match_table域在这里 –191–>用于使用平台文件或模块编写设备信息时进行匹配使用,相当于platform_driver中的id_table。 –197–>用于将所有i2c_driver联系到一起的节点

那么接下来就是填充对象了,我们这里使用的是设备树匹配,所以of_match_table被填充如下。

struct of_device_id mpu6050_dt_match[] = {
   {.compatible = "invensense,mpu6050"},
   {},
};
struct i2c_device_id mpu6050_dev_match[] = {};

然后将这两个成员填充到i2c_driver对象如下,这个阶段我们可以在mpu6050_probe中只填写prink来测试我们的驱动方法对象是否有问题。

struct i2c_driver mpu6050_driver = {
   .probe = mpu6050_probe,
   .remove = mpu6050_remove,
   .driver = {
       .owner = THIS_MODULE,
       .name = "mpu6050drv",
       .of_match_table = of_match_ptr(mpu6050_dt_match),
   },
   .id_table = mpu6050_dev_match,
};

使用下述API注册/注销驱动对象,这个宏和module_platform_driver一样是内核提供给我们一个用于快速实现注册注销接口的快捷方式,写了这句以及模块授权,我们就可以静待各种信息被打印了

module_i2c_driver(mpu6050_driver);

i2c_msg

如果测试通过,我们就要研究如何找到adapter以及如何通过找到的adapter将数据发送出去。没错,我说的i2c_msg

68 struct i2c_msg {                                                                                                  
69         __u16 addr;     /* slave address                        */
70         __u16 flags;
71 #define I2C_M_TEN               0x0010  /* this is a ten bit chip address */
72 #define I2C_M_RD                0x0001  /* read data, from slave to master */
73 #define I2C_M_STOP              0x8000  /* if I2C_FUNC_PROTOCOL_MANGLING */
74 #define I2C_M_NOSTART           0x4000  /* if I2C_FUNC_NOSTART */
75 #define I2C_M_REV_DIR_ADDR      0x2000  /* if I2C_FUNC_PROTOCOL_MANGLING */
76 #define I2C_M_IGNORE_NAK        0x1000  /* if I2C_FUNC_PROTOCOL_MANGLING */
77 #define I2C_M_NO_RD_ACK         0x0800  /* if I2C_FUNC_PROTOCOL_MANGLING */
78 #define I2C_M_RECV_LEN          0x0400  /* length will be first received byte */
79         __u16 len;              /* msg length                           */
80         __u8 *buf;              /* pointer to msg data                  */
81 };  

struct i2c_msg –69–>从机地址 –70–>操作标志,I2C_M_RD为读(0),写为1 –79–>有效数据长度 –80–>装载有效数据的头指针

我们知道,i2c总线上传入数据是以字节为单位的,而我们的通信类别分为两种:读and写,对于写,通常按照下面的时序

Mater S I2CAddr+WriteBit InternalRegisterAddr DATA DATA P
Slave ACK ACK ACK ACK

对于读,通常是按照下面的时序

Mater S I2CAddr+WriteBit InternalRegisterAddr S I2CAddr+ReadBit ACK NACK P
Slave ACK ACK ACK DATA DATA

i2c子系统为了实现这种通信方法,为我们封装了i2c_msg结构,对于每一个START信号,都对应一个i2c_msg对象,实际操作中我们会将所有的请求封装成一个struct i2c_msg[],一次性将所有的请求通过**i2c_transfer()**发送给匹配到的client的从属的adapter,由adapter根据相应的algo域以及master_xfer域通过主机驱动来将这些请求发送给硬件上的设备

实例

这是一个通过i2c总线来访问mpu6050的驱动

//mpu6050_common.h
#define MPU6050_MAGIC 'K'

union mpu6050_data
{
   struct {
       short x;
       short y;
       short z;
   }accel;
   struct {
       short x;
       short y;
       short z;
   }gyro;
   unsigned short temp;
};

#define GET_ACCEL _IOR(MPU6050_MAGIC, 0, union mpu6050_data)
#define GET_GYRO  _IOR(MPU6050_MAGIC, 1, union mpu6050_data)
#define GET_TEMP  _IOR(MPU6050_MAGIC, 2, union mpu6050_data)
//mpu6050_drv.h

#define SMPLRT_DIV      0x19    //陀螺仪采样率,典型值:0x07(125Hz)
#define CONFIG          0x1A    //低通滤波频率,典型值:0x06(5Hz)
#define GYRO_CONFIG     0x1B    //陀螺仪自检及测量范围,典型值:0x18(不自检,2000deg/s)
#define ACCEL_CONFIG        0x1C    //加速计自检、测量范围及高通滤波,典型值:0x18(不自检,2G,5Hz)
#define ACCEL_XOUT_H        0x3B
#define ACCEL_XOUT_L        0x3C
#define ACCEL_YOUT_H        0x3D
#define ACCEL_YOUT_L        0x3E
#define ACCEL_ZOUT_H        0x3F
#define ACCEL_ZOUT_L        0x40
#define TEMP_OUT_H      0x41
#define TEMP_OUT_L      0x42
#define GYRO_XOUT_H     0x43
#define GYRO_XOUT_L     0x44
#define GYRO_YOUT_H     0x45
#define GYRO_YOUT_L     0x46
#define GYRO_ZOUT_H     0x47    //陀螺仪z轴角速度数据寄存器(高位)
#define GYRO_ZOUT_L     0x48    //陀螺仪z轴角速度数据寄存器(低位)
#define PWR_MGMT_1      0x6B    //电源管理,典型值:0x00(正常启用)
#define WHO_AM_I        0x75    //IIC地址寄存器(默认数值0x68,只读)
#define SlaveAddress        0x68    //MPU6050-I2C地址寄存器
#define W_FLG           0
#define R_FLG           1
//mpu6050.c
struct mpu6050_pri {
   struct cdev dev;
   struct i2c_client *client;
};
struct mpu6050_pri dev;
static void mpu6050_write_byte(struct i2c_client *client,const unsigned char reg,const unsigned char val)
{
   char txbuf[2] = {reg,val};
   struct i2c_msg msg[2] = {
       [0] = {
           .addr = client->addr,
           .flags= W_FLG,
           .len = sizeof(txbuf),
           .buf = txbuf,
       },
   };
   i2c_transfer(client->adapter, msg, ARRAY_SIZE(msg));
}
static char mpu6050_read_byte(struct i2c_client *client,const unsigned char reg)
{
   char txbuf[1] = {reg};
   char rxbuf[1] = {0};
   struct i2c_msg msg[2] = {
       [0] = {
           .addr = client->addr,
           .flags = W_FLG,
           .len = sizeof(txbuf),
           .buf = txbuf,
       },
       [1] = {
           .addr = client->addr,
           .flags = I2C_M_RD,
           .len = sizeof(rxbuf),
           .buf = rxbuf,
       },
   };

   i2c_transfer(client->adapter, msg, ARRAY_SIZE(msg));
   return rxbuf[0];
}
static int dev_open(struct inode *ip, struct file *fp)
{
   return 0;
}
static int dev_release(struct inode *ip, struct file *fp)
{
   return 0;
}
static long dev_ioctl(struct file *fp, unsigned int cmd, unsigned long arg)
{
   int res = 0;
   union mpu6050_data data = {{0}};
   switch(cmd){
   case GET_ACCEL:
       data.accel.x = mpu6050_read_byte(dev.client,ACCEL_XOUT_L);
       data.accel.x|= mpu6050_read_byte(dev.client,ACCEL_XOUT_H)break;
   case GET_GYRO:
       data.gyro.x = mpu6050_read_byte(dev.client,GYRO_XOUT_L);
       data.gyro.x|= mpu6050_read_byte(dev.client,GYRO_XOUT_H)"gyro:x %d, y:%d, z:%d\n",data.gyro.x,data.gyro.y,data.gyro.z);
       break;
   case GET_TEMP:
       data.temp = mpu6050_read_byte(dev.client,TEMP_OUT_L);
       data.temp|= mpu6050_read_byte(dev.client,TEMP_OUT_H)"temp: %d\n",data.temp);
       break;
   default:
       printk(KERN_INFO "invalid cmd");
       break;
   }
   printk("acc:x %d, y:%d, z:%d\n",data.accel.x,data.accel.y,data.accel.z);
   res = copy_to_user((void *)arg,&data,sizeof(data));
   return sizeof(data);
}

struct file_operations fops = {
   .open = dev_open,
   .release = dev_release,
   .unlocked_ioctl = dev_ioctl,
};

#define DEV_CNT 1
#define DEV_MI 0
#define DEV_MAME "mpu6050"

struct class *cls;
dev_t dev_no ;

static void mpu6050_init(struct i2c_client *client)
{
   mpu6050_write_byte(client, PWR_MGMT_1, 0x00);
   mpu6050_write_byte(client, SMPLRT_DIV, 0x07);
   mpu6050_write_byte(client, CONFIG, 0x06);
   mpu6050_write_byte(client, GYRO_CONFIG, 0x18);
   mpu6050_write_byte(client, ACCEL_CONFIG, 0x0);
}
static int mpu6050_probe(struct i2c_client * client, const struct i2c_device_id * id)
{
   dev.client = client;
   printk(KERN_INFO "xj_match ok\n");
   cdev_init(&dev.dev,&fops);
   
   alloc_chrdev_region(&dev_no,DEV_MI,DEV_CNT,DEV_MAME);
   
   cdev_add(&dev.dev,dev_no,DEV_CNT);
   
   mpu6050_init(client);

   /*自动创建设备文件*/
   cls = class_create(THIS_MODULE,DEV_MAME);
   device_create(cls,NULL,dev_no,NULL,"%s%d",DEV_MAME,DEV_MI);
   
   printk(KERN_INFO "probe\n");
   
   return 0;
}

static int mpu6050_remove(struct i2c_client * client)
{
   device_destroy(cls,dev_no);
   class_destroy(cls);
   unregister_chrdev_region(dev_no,DEV_CNT);
   return 0;
}

struct of_device_id mpu6050_dt_match[] = {
   {.compatible = "invensense,mpu6050"},
   {},
};

struct i2c_device_id mpu6050_dev_match[] = {};
struct i2c_driver mpu6050_driver = {
   .probe = mpu6050_probe,
   .remove = mpu6050_remove,
   .driver = {
       .owner = THIS_MODULE,
       .name = "mpu6050drv",
       .of_match_table = of_match_ptr(mpu6050_dt_match),
   },
   .id_table = mpu6050_dev_match,
};
module_i2c_driver(mpu6050_driver);
MODULE_LICENSE("GPL");

通过上面的驱动, 我们可以在应用层操作设备文件从mpu6050寄存器中读取原始数据, 应用层如下

int main(int argc, char * const argv[])
{
   int fd = open(argv[1],O_RDWR);
   if(-1== fd){
       perror("open");
       return -1;
   }
   union mpu6050_data data = {{0}};
   while(1){
       ioctl(fd,GET_ACCEL,&data);
       printf("acc:x %d, y:%d, z:%d\n",data.accel.x,data.accel.y,data.accel.z);
       ioctl(fd,GET_GYRO,&data);
       printf("gyro:x %d, y:%d, z:%d\n",data.gyro.x,data.gyro.y,data.gyro.z);
       ioctl(fd,GET_TEMP,&data);
       printf("temp: %d\n",data.temp);
       sleep(1);
   }
   return 0;
}

最终可以获取传感器的原始数据如下 img

以上就是良许教程网为各位朋友分享的Linu系统相关内容。想要了解更多Linux相关知识记得关注公众号“良许Linux”,或扫描下方二维码进行关注,更多干货等着你 !

img
本文由 良许Linux教程网 发布,可自由转载、引用,但需署名作者且注明文章出处。如转载至微信公众号,请在文末添加作者公众号二维码。
良许

作者: 良许

良许,世界500强企业Linux开发工程师,公众号【良许Linux】的作者,全网拥有超30W粉丝。个人标签:创业者,CSDN学院讲师,副业达人,流量玩家,摄影爱好者。
上一篇
下一篇

发表评论

联系我们

联系我们

公众号:良许Linux

在线咨询: QQ交谈

邮箱: yychuyu@163.com

关注微信
微信扫一扫关注我们

微信扫一扫关注我们

关注微博
返回顶部