前言
tty这个名称源于电传打字节的简称,在linux表示各种终端,终端通常都跟硬件相对应。比如对应于输入设备键盘鼠标,输出设备显示器的控制终端和串口终端。也有对应于不存在设备的pty驱动。在如此众多的终端模型之中,linux是怎么将它们统一建模的呢?这就是我们今天要讨论的问题。
tty驱动概貌
tty架构如下所示:
如上图所示,用户空间主要是通过系统调用与tty core交互。tty core根据用空间操作的类型再选择跟line discipline和tty driver交互。
例如,设置硬件的ioctl指令就直接交给tty_driver处理。read和write操作就会交给 line discipline处理。
Line discipline是线路规程的意思。正如它的名字一样,它表示的是这条终端”线程”的输入与输出规范设置。主要用来进行输入/输出数据的预处理。
处理之后,就会将数据交给tty driver ,它将字符转换成终端可以理解的字串。将其传给终端设备。
值得注意的是,这个架构没有为tty driver 提供read操作。也就是说tty core 和line discipline都没有办法从tty driver里直接读终端信息。这是因为tty driver对应的hardware并不一定是输入数据和输出 数据的共同负载者。
例如控制终端,输出设备是显示器,输入设备是键盘。基于这样的原理。在line discipline中有一个输入缓存区,并提供了一个名叫receive_buf()的接口函数。对应的终端设备只要调用line discipine的receiver_buf函数,将数据写入到输入缓存区就可以了。如果一个设备同时是输入设备又是输出设备。那在设备的中断处理中调用receive_buf()将数据写入即可.
tty驱动接口分析
tty_init()
/* *Ok,nowwecaninitializetherestofthettydevicesandcancount *onmemoryallocations,interruptsetc.. */ int__inittty_init(void) { tty_sysctl_init(); cdev_init(&tty_cdev,&tty_fops); if(cdev_add(&tty_cdev,MKDEV(TTYAUX_MAJOR,0),1)|| register_chrdev_region(MKDEV(TTYAUX_MAJOR,0),1,"/dev/tty")< 0) panic("Couldn't register /dev/tty driver "); device_create(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 0), NULL, "tty"); cdev_init(&console_cdev, &console_fops); if (cdev_add(&console_cdev, MKDEV(TTYAUX_MAJOR, 1), 1) || register_chrdev_region(MKDEV(TTYAUX_MAJOR, 1), 1, "/dev/console") < 0) panic("Couldn't register /dev/console driver "); consdev = device_create_with_groups(tty_class, NULL, MKDEV(TTYAUX_MAJOR, 1), NULL, cons_dev_groups, "console"); if (IS_ERR(consdev)) consdev = NULL; #ifdef CONFIG_VT vty_init(&console_fops); #endif return 0; }
tty_init主要做了以下工作:
初始化 tty 子系统的 sysctl 相关设置,包括注册 sysctl 参数、创建 sysctl 目录等。
初始化 tty 设备的字符设备对象,并将其与 tty 设备操作函数 tty_fops 绑定。同时,创建一个名为 "tty" 的 tty 设备节点,并将其设备号设置为 MKDEV(TTYAUX_MAJOR, 0)。
初始化控制台设备的字符设备对象,并将其添加到字符设备系统中。同时,创建一个名为 "console" 的控制台设备节点,并将其设备号设置为 MKDEV(TTYAUX_MAJOR, 1)。该控制台设备节点还将在 sysfs 中创建一个名为 "console" 的目录,并在该目录下创建多个属性文件,用于控制控制台的一些属性。
如果内核支持虚拟终端,则初始化虚拟终端。
这里我们看到了熟悉的cdev_init(),device_create()之类的函数,这正是字符设备的创建流程。因此,我们说串口驱动也是一个字符设备驱动。
而在serial8250_init()中,会调用platform_driver_register()去注册serial8250_isa_driver,在设备树节点和serial8250_isa_driver name匹配的时候,就会进入probe流程。因此,也可以说串口驱动是总线设备驱动模型。
tty_alloc_driver
/*UseTTY_DRIVER_*flagsbelow*/ #definetty_alloc_driver(lines,flags) __tty_alloc_driver(lines,THIS_MODULE,flags)
__tty_alloc_driver()用于分配一个 tty 驱动程序的数据结构 struct tty_driver,并对其一些常用字段进行初始化。
/** *__tty_alloc_driver--allocatettydriver *@lines:countoflinesthisdrivercanhandleatmost *@owner:modulewhichisrepsonsibleforthisdriver *@flags:someofTTY_DRIVER_*flags,willbesetindriver->flags * *Thisshouldnotbecalleddirectly,someoftheprovidedmacrosshouldbe *usedinstead.UseIS_ERRandfriendson@retval. */ structtty_driver*__tty_alloc_driver(unsignedintlines,structmodule*owner, unsignedlongflags) { structtty_driver*driver; unsignedintcdevs=1; interr; if(!lines||(flags&TTY_DRIVER_UNNUMBERED_NODE&&lines>1)) returnERR_PTR(-EINVAL); /*分配一个structtty_driver结构体,并对其中的一些字段进行初始化,包括num、owner、flags等*/ driver=kzalloc(sizeof(structtty_driver),GFP_KERNEL); if(!driver) returnERR_PTR(-ENOMEM); kref_init(&driver->kref); driver->magic=TTY_DRIVER_MAGIC; driver->num=lines; driver->owner=owner; driver->flags=flags; /*如果TTY_DRIVER_DEVPTS_MEM标志位没有被设置,那么函数会分配driver->ttys和driver->termios,否则不需要分配*/ if(!(flags&TTY_DRIVER_DEVPTS_MEM)){ driver->ttys=kcalloc(lines,sizeof(*driver->ttys), GFP_KERNEL); driver->termios=kcalloc(lines,sizeof(*driver->termios), GFP_KERNEL); if(!driver->ttys||!driver->termios){ err=-ENOMEM; gotoerr_free_all; } } /*如果TTY_DRIVER_DYNAMIC_ALLOC标志位没有被设置,那么函数会分配driver->ports,否则不需要分配*/ if(!(flags&TTY_DRIVER_DYNAMIC_ALLOC)){ driver->ports=kcalloc(lines,sizeof(*driver->ports), GFP_KERNEL); if(!driver->ports){ err=-ENOMEM; gotoerr_free_all; } cdevs=lines; } /*函数会根据lines的值分配相应数量的driver->cdevs*/ driver->cdevs=kcalloc(cdevs,sizeof(*driver->cdevs),GFP_KERNEL); if(!driver->cdevs){ err=-ENOMEM; gotoerr_free_all; } returndriver; err_free_all: kfree(driver->ports); kfree(driver->ttys); kfree(driver->termios); kfree(driver->cdevs); kfree(driver); returnERR_PTR(err); }
tty_register_driver
tty_register_driver用于注册 tty 驱动程序的,被 tty 驱动程序调用以将自己注册到内核中。
/* *Calledbyattydrivertoregisteritself. */ inttty_register_driver(structtty_driver*driver) { interror; inti; dev_tdev; structdevice*d; /*确认是否要内核动态分配主设备号*/ if(!driver->major){ /*函数调用alloc_chrdev_region函数来动态分配主设备号,并将分配的主设备号和次设备号保存在driver->major和driver->minor_start字段中*/ error=alloc_chrdev_region(&dev,driver->minor_start, driver->num,driver->name); if(!error){ driver->major=MAJOR(dev); driver->minor_start=MINOR(dev); } }else{ /*已经预先分配了主设备号,函数调用register_chrdev_region函数来注册设备号*/ dev=MKDEV(driver->major,driver->minor_start); error=register_chrdev_region(dev,driver->num,driver->name); } if(error< 0) goto err; /*判断是否设置了 TTY_DRIVER_DYNAMIC_ALLOC 标志位*/ if (driver->flags&TTY_DRIVER_DYNAMIC_ALLOC){ /*需要动态分配tty设备号,函数调用tty_cdev_add函数来添加tty设备号,并将每个tty设备的字符设备注册到内核中*/ error=tty_cdev_add(driver,dev,0,driver->num); if(error) gotoerr_unreg_char; } mutex_lock(&tty_mutex); /*将driver添加到链表tty_drivers中*/ list_add(&driver->tty_drivers,&tty_drivers); mutex_unlock(&tty_mutex); /*判断TTY_DRIVER_DYNAMIC_DEV标志位是否设置*/ if(!(driver->flags&TTY_DRIVER_DYNAMIC_DEV)){ for(i=0;i< driver->num;i++){ /*需要注册固定的tty设备号,函数在循环中调用tty_register_device函数来注册每个tty设备号,并将每个tty设备注册到内核中*/ d=tty_register_device(driver,i,NULL); if(IS_ERR(d)){ error=PTR_ERR(d); gotoerr_unreg_devs; } } } /*注册/proc/tty/drivers目录中的信息*/ proc_tty_register_driver(driver); /*将driver结构体中的flags字段设置为TTY_DRIVER_INSTALLED,表示该驱动程序已经被成功注册到内核中*/ driver->flags|=TTY_DRIVER_INSTALLED; return0; err_unreg_devs: for(i--;i>=0;i--) tty_unregister_device(driver,i); mutex_lock(&tty_mutex); list_del(&driver->tty_drivers); mutex_unlock(&tty_mutex); err_unreg_char: unregister_chrdev_region(dev,driver->num); err: returnerror; }
tty_register_driver()函数操作比较简单。就是为tty_driver创建字符设备。然后将字符设备的操作集指定为tty_fops。并且将tty_driver 挂载到tty_drivers链表中。这个链表中是以设备号为关键字找到对应的driver。
特别的。如果没有定义TTY_DRIVER_DYNAMIC_DEV。还会在sysfs中创建一个类设备。这样主要是为了udev管理设备。
tty_unregister_device
tty_unregister_device用于注销一个 tty 设备。该函数的作用是销毁设备节点和字符设备,以便于释放与该 tty 设备相关的资源,例如内存和设备文件等.
/** *tty_unregister_device-unregisterattydevice *@driver:thettydriverthatdescribesthettydevice *@index:theindexinthettydriverforthisttydevice * *Ifattydeviceisregisteredwithacalltotty_register_device()then *thisfunctionmustbecalledwhenthettydeviceisgone. * *Locking:?? */ voidtty_unregister_device(structtty_driver*driver,unsignedindex) { device_destroy(tty_class, MKDEV(driver->major,driver->minor_start)+index); if(!(driver->flags&TTY_DRIVER_DYNAMIC_ALLOC)){ cdev_del(driver->cdevs[index]); driver->cdevs[index]=NULL; } }
tty_unregister_device所做工作如下:
调用 device_destroy 函数来销毁 tty 设备对应的设备节点。接受两个参数:第一个参数 tty_class 表示 tty 类,第二个参数是 tty 设备的设备号,其中 MKDEV(driver->major, driver->minor_start) + index 表示 tty 设备的设备号,driver->major 表示 tty 设备的主设备号,driver->minor_start 表示 tty 设备的次设备号的起始值,index 表示 tty 设备的索引
如果该 tty 驱动程序不是动态分配的,则调用 cdev_del 函数来注销该 tty 设备对应的字符设备。
get_tty_driver
get_tty_driver作用是在用户空间的应用程序使用 tty 设备时,获取对应的 tty 驱动程序的信息。
/** *get_tty_driver-finddeviceofatty *@dev_t:deviceidentifier *@index:returnstheindexofthetty * *Thisroutinereturnsattydriverstructure,givenadevicenumber *andalsopassesbacktheindexnumber. * *Locking:callermustholdtty_mutex */ staticstructtty_driver*get_tty_driver(dev_tdevice,int*index) { structtty_driver*p; /**/ list_for_each_entry(p,&tty_drivers,tty_drivers){ dev_tbase=MKDEV(p->major,p->minor_start); if(device< base || device >=base+p->num) continue; *index=device-base; returntty_driver_kref_get(p); } returnNULL; }
首先使用 list_for_each_entry 循环遍历全局链表 tty_drivers,该链表中保存了所有已经注册的 tty 驱动程序。对于每个 tty 驱动程序,函数将其设备号的起始值和结束值计算出来,如果给定设备号不在这个范围内,则继续遍历下一个 tty 驱动程序。
如果给定设备号在某个 tty 驱动程序的范围内,则计算出该设备号对应的 tty 设备的索引值,并调用 tty_driver_kref_get 函数来获取该 tty 驱动程序的引用计数。函数返回该 tty 驱动程序的结构体指针,并将找到的 tty 设备的索引值保存到 index 参数中。
需要注意的是,函数在访问全局链表 tty_drivers 时,需要持有互斥锁 tty_mutex。因为多个应用程序可能同时访问同一个 tty 驱动程序,如果没有互斥锁保护,可能会导致并发问题。
tty_open
从注册的过程可以看到,所有的操作都会对应到tty_fops中。Open操作对应的操作接口是tty_open(),用于打开一个 tty 设备。函数的作用是在用户空间的应用程序使用 tty 设备时,打开对应的 tty 设备,并初始化相应的数据结构。
/** *tty_open-openattydevice *@inode:inodeofdevicefile *@filp:filepointertotty * *tty_openandtty_releasekeepupthettycountthatcontainsthe *numberofopensdoneonatty.Wecannotusetheinode-count,as *differentinodesmightpointtothesametty. * *Open-countingisneededforptymasters,aswellasforkeeping *trackofseriallines:DTRisdroppedwhenthelastclosehappens. *(Thisisnotdonesolelythroughtty->count,now.-Ted1/27/92) * *Thetermiosstateofaptyisresetonfirstopensothat *settingsdon'tpersistacrossreuse. * *Locking:tty_mutexprotectstty,tty_lookup_driverandtty_init_dev. *tty->countshouldprotecttherest. *->siglockprotects->signal/->sighand * *Note:thetty_unlock/lockcaseswithoutarefareonlysafedueto *tty_mutex */ staticinttty_open(structinode*inode,structfile*filp) { structtty_struct*tty; intnoctty,retval; structtty_driver*driver=NULL; intindex; dev_tdevice=inode->i_rdev; unsignedsaved_flags=filp->f_flags; nonseekable_open(inode,filp); retry_open: /*分配一个tty结构体*/ retval=tty_alloc_file(filp); if(retval) return-ENOMEM; /*检查文件的标志位,如果包含O_NOCTTY标志,则禁止将该tty设备设置为控制终端*/ noctty=filp->f_flags&O_NOCTTY; index=-1; retval=0; /*尝试打开当前的tty设备*/ tty=tty_open_current_tty(device,filp); if(!tty){ mutex_lock(&tty_mutex); /*根据设备号来查找对应的tty驱动程序,并初始化该tty设备,将找到的tty驱动程序保存到driver变量中*/ driver=tty_lookup_driver(device,filp,&noctty,&index); if(IS_ERR(driver)){ retval=PTR_ERR(driver); gotoerr_unlock; } /*checkwhetherwe'rereopeninganexistingtty*/ /*查找对应的tty设备,并将找到的tty设备结构体指针保存到tty变量中*/ tty=tty_driver_lookup_tty(driver,inode,index); if(IS_ERR(tty)){ retval=PTR_ERR(tty); gotoerr_unlock; } if(tty){ /*如果找到了该tty设备,则需要重新打开该tty设备*/ mutex_unlock(&tty_mutex); retval=tty_lock_interruptible(tty); tty_kref_put(tty);/*dropkreffromtty_driver_lookup_tty()*/ if(retval){ if(retval==-EINTR) retval=-ERESTARTSYS; gotoerr_unref; } retval=tty_reopen(tty); if(retval< 0) { tty_unlock(tty); tty = ERR_PTR(retval); } } else { /* Returns with the tty_lock held for now */ /*需要初始化该 tty 设备*/ tty = tty_init_dev(driver, index); /*为该 tty 设备分配一个 tty 结构体,并对其进行初始化*/ mutex_unlock(&tty_mutex); } tty_driver_kref_put(driver); } if (IS_ERR(tty)) { retval = PTR_ERR(tty); if (retval != -EAGAIN || signal_pending(current)) goto err_file; tty_free_file(filp); schedule(); goto retry_open; } /*将该 tty 设备与文件结构体相关联*/ tty_add_file(tty, filp); check_tty_count(tty, __func__); /*如果该 tty 设备是一个伪终端主设备,则需要将 noctty 标志设置为 1*/ if (tty->driver->type==TTY_DRIVER_TYPE_PTY&& tty->driver->subtype==PTY_TYPE_MASTER) noctty=1; tty_debug_hangup(tty,"(ttycount=%d) ",tty->count); /*调用tty设备的open函数*/ if(tty->ops->open) retval=tty->ops->open(tty,filp); else retval=-ENODEV; filp->f_flags=saved_flags; if(retval){ tty_debug_hangup(tty,"error%d,releasing... ",retval); tty_unlock(tty);/*needtocalltty_releasewithoutBTM*/ tty_release(inode,filp); if(retval!=-ERESTARTSYS) returnretval; if(signal_pending(current)) returnretval; schedule(); /* *Needtoresetf_opincaseahanguphappened. */ if(tty_hung_up_p(filp)) filp->f_op=&tty_fops; gotoretry_open; } clear_bit(TTY_HUPPED,&tty->flags); read_lock(&tasklist_lock); spin_lock_irq(¤t->sighand->siglock); if(!noctty&& current->signal->leader&& !current->signal->tty&& tty->session==NULL){ /* *Don'tletaprocessthatonlyhaswriteaccesstothetty *obtaintheprivilegesassociatedwithhavingattyas *controllingterminal(beingabletoreopenitwithfull *accessthrough/dev/tty,beingabletoperformpushback). *Manydistributionssetthegroupofallttysto"tty"and *grantwrite-onlyaccesstoallterminalsforsetgidtty *binaries,whichshouldnotimplyfullprivilegesonallttys. * *Thiscouldtheoreticallybreakoldcodethatperformsopen() *onawrite-onlyfiledescriptor.Inthatcase,itmightbe *necessarytoalsopermitthisif *inode_permission(inode,MAY_READ)==0. */ if(filp->f_mode&FMODE_READ) __proc_set_tty(tty); } spin_unlock_irq(¤t->sighand->siglock); read_unlock(&tasklist_lock); tty_unlock(tty); return0; err_unlock: mutex_unlock(&tty_mutex); err_unref: /*afterlockstoavoiddeadlock*/ if(!IS_ERR_OR_NULL(driver)) tty_driver_kref_put(driver); err_file: tty_free_file(filp); returnretval; }
函数所作工作如下:
在打开 tty 设备时,该函数会检查文件的标志位,如果包含 O_NOCTTY 标志,则禁止将该 tty 设备设置为控制终端。这是因为如果一个进程打开一个 tty 设备并将其设置为控制终端,其他进程就无法再将该 tty 设备设置为控制终端,这可能会导致一些问题。
如果打开当前的 tty 设备失败,则需要根据设备号来查找对应的 tty 驱动程序,并初始化该 tty 设备。在查找 tty 驱动程序时,需要调用 tty_lookup_driver 函数来查找对应的 tty 驱动程序,并将找到的 tty 驱动程序保存到 driver 变量中。如果找不到对应的 tty 驱动程序,则返回错误码。
如果找到了对应的 tty 驱动程序,则调用 tty_driver_lookup_tty 函数来查找对应的 tty 设备,并将找到的 tty 设备结构体指针保存到 tty 变量中。如果找到了该 tty 设备,则需要重新打开该 tty 设备。否则,需要初始化该 tty 设备。在初始化 tty 设备时,需要调用 tty_init_dev 函数来为该 tty 设备分配一个 tty 结构体,并对其进行初始化。
在打开 tty 设备之后,函数会调用 tty_add_file 函数将该 tty 设备与文件结构体相关联。此外,如果该 tty 设备是一个伪终端主设备,则需要将 noctty 标志设置为 1。
最后,函数会调用 tty 设备的 open 函数,如果存在的话,来进行一些特定的操作。如果 open 函数返回错误码,则需要释放该 tty 设备并返回错误码。如果 open 函数返回 -ERESTARTSYS,则需要重新打开该 tty 设备。如果有中断发生,也需要重新打开该 tty 设备。
tty_write
tty_write()作用是将用户数据写入 tty 设备,并通过线路规则(line discipline)进行处理。
线路规则是 tty 设备的一种机制,用于处理和转换从用户进程到内核和设备的数据流。在写入 tty 设备之前,需要获取该 tty 设备的线路规则,并调用其 write 方法进行处理。
/** *tty_write-writemethodforttydevicefile *@file:ttyfilepointer *@buf:userdatatowrite *@count:bytestowrite *@ppos:unused * *Writedatatoattydeviceviathelinediscipline. * *Locking: *Locksthelinedisciplineasrequired *Writestothettydriverareserializedbytheatomic_write_lock *andarethenprocessedinchunkstothedevice.Thelinediscipline *writemethodwillnotbeinvokedinparallelforeachdevice. */ staticssize_ttty_write(structfile*file,constchar__user*buf, size_tcount,loff_t*ppos) { structtty_struct*tty=file_tty(file); structtty_ldisc*ld; ssize_tret; if(tty_paranoia_check(tty,file_inode(file),"tty_write")) return-EIO; if(!tty||!tty->ops->write|| (test_bit(TTY_IO_ERROR,&tty->flags))) return-EIO; /*Shorttermdebugtocatchbuggydrivers*/ if(tty->ops->write_room==NULL) printk(KERN_ERR"ttydriver%slacksawrite_roommethod. ", tty->driver->name); ld=tty_ldisc_ref_wait(tty); if(!ld->ops->write) ret=-EIO; else ret=do_tty_write(ld->ops->write,tty,file,buf,count); tty_ldisc_deref(ld); returnret; }
tty_write()所作工作如下:
首先从文件指针中获取 tty_struct 数据结构的指针,表示要写入的 tty 设备。
检查传入的 tty_struct 指针是否有效,以及是否有其他进程正在访问该 tty 设备。如果出现问题,返回输入/输出错误码 -EIO。
检查 tty_struct 指针是否有效、tty 设备是否支持写操作,以及是否已经出现了输入/输出错误。如果出现问题,返回输入/输出错误码 -EIO。
检查 tty 设备是否实现了 write_room 方法,如果没有,则输出错误信息。
获取 tty 设备的线路规则(line discipline),并等待获取成功。
检查线路规则的 write 方法是否存在,如果不存在,返回输入/输出错误码 -EIO。否则,调用 do_tty_write 函数,将数据写入 tty 设备。
释放线路规则引用计数器。
返回写入操作的结果,如果写入成功,则返回写入的字节数;否则,返回相应的错误码。
tty_read
/** *tty_read-readmethodforttydevicefiles *@file:pointertottyfile *@buf:userbuffer *@count:sizeofuserbuffer *@ppos:unused * *Performthereadsystemcallfunctiononthisterminaldevice.Checks *forhungupdevicesbeforecallingthelinedisciplinemethod. * *Locking: *Locksthelinedisciplineinternallywhileneeded.Multiple *readcallsmaybeoutstandinginparallel. */ staticssize_ttty_read(structfile*file,char__user*buf,size_tcount, loff_t*ppos) { inti; structinode*inode=file_inode(file); structtty_struct*tty=file_tty(file); structtty_ldisc*ld; if(tty_paranoia_check(tty,inode,"tty_read")) return-EIO; if(!tty||(test_bit(TTY_IO_ERROR,&tty->flags))) return-EIO; /*Wewanttowaitforthelinedisciplinetosortoutinthis situation*/ ld=tty_ldisc_ref_wait(tty); if(ld->ops->read) i=ld->ops->read(tty,file,buf,count); else i=-EIO; tty_ldisc_deref(ld); if(i>0) tty_update_time(&inode->i_atime); returni; }
tty_read()实现终端设备文件读操作的函数 。
获取 tty_struct 结构体、inode 和 line discipline 对象的指针。
调用 tty_paranoia_check() 函数检查 tty_struct 结构体是否可用。如果检查失败,返回 -EIO。
检查 tty_struct 结构体是否为空或者 TTY_IO_ERROR 标志位已经设置。如果是,则返回 -EIO。
获取 line discipline 对象的引用,确保它不会在 tty_read() 函数执行期间被卸载。
检查 line discipline 的 read() 方法是否可用。如果可用,则调用该方法进行读取操作,并将返回的字节数保存在变量 i 中。如果不可用,返回 -EIO。
释放 line discipline 的引用。
如果读取操作成功,调用 tty_update_time() 函数更新 inode 的访问时间。
返回读取的字节数。
小结
在这一节里,只对tty的构造做一个分析,具体的比如线路规程的内容我们了解知道就好,这里不做深入分析。
-
显示器
+关注
关注
21文章
4980浏览量
139997 -
Linux
+关注
关注
87文章
11304浏览量
209530 -
串口
+关注
关注
14文章
1554浏览量
76527 -
函数
+关注
关注
3文章
4331浏览量
62625
原文标题:【驱动】串口驱动分析(二)-tty core
文章出处:【微信号:嵌入式与Linux那些事,微信公众号:嵌入式与Linux那些事】欢迎添加关注!文章转载请注明出处。
发布评论请先 登录
相关推荐
评论