ext2 源代码解析之 “从路径名到目标结点” (一)
?
??? 兩個主要函數,path_init和path_walk,他們結合在一起根據給定的文件路徑名稱在內存中找到或者建立代表著目標文件或目錄的dentry和inode結構。注意,最終是信息是讀取到內存中的。其中有個函數__user_walk()將path_init()和path_walk()包裝在一起。本節中的所有代碼在fs/namei.c中。
?
1.分析外包裝“__user_walk()”
?
778/*779 * namei()780 *781 *is used by most simple commands to get the inode of a specified name.782 *Open, link etc use their own routines, but this is enough for things783 *like 'chmod' etc.784 *785 *namei exists in two versions: namei/lnamei. The only difference is786 *that namei follows links, while lnamei does not.787 *SMP-safe788 */789int __user_walk(const char *name, unsigned flags, struct nameidata *nd)790 {791 char *tmp;792 int err;793794 tmp = getname(name);795 err = PTR_ERR(tmp);//即使沒有出錯,仍然會進行轉化,所以下文要將err=0796 if (!IS_ERR(tmp)) {797 err = 0;798 if (path_init(tmp, flags, nd))799 err = path_walk(tmp,nd);800 putname(tmp);801 }802 return err;803 }
?
?
1.1對函數參數的解釋:
???name是用戶空間的路徑名稱:flags內容是一些標志位,定義在/include/linux/fs.h,如下:
?
1323 /* 1324 * The bitmask for a lookup event: 1325 * - follow links at the end 1326 * - require a directory 1327 * - ending slashes ok even fornonexistent files 1328 * - internal "there are morepath compnents" flag 1329 */ 1330 #define LOOKUP_FOLLOW (1) //symble link 1331 #define LOOKUP_DIRECTORY (2) // the target must be a directory 1332 #define LOOKUP_CONTINUE (4) 1333 #define LOOKUP_POSITIVE (8) 1334 #define LOOKUP_PARENT (16) 1335 #define LOOKUP_NOALT (32)?
??? 注意第一項,符號鏈接可以跨越設備,而普通鏈接不可以。內核提供了不同的系統調用link和symlink,分別用于兩種鏈接的建立關鍵數據結構.由于符號鏈接是可以跨越設備的,所以終點有可能懸空;而普通鏈接的終點是落實的。路徑中包含符號鏈接的時候,是否繼續往下搜索,規定如下:
??
82/* [Feb-Apr 2000 AV] Complete rewrite. Rules for symlinks:83 * inside the path - always follow.//路徑內部84 * in the last component increation/removal/renaming - never follow.//創建,刪除,重命名操作85 * if LOOKUP_FOLLOW passed - follow.86 * if the pathname has trailing slashes -follow.87 * otherwise - don't follow.88 * (applied in that order).89 */?
?
???? 最后一個參數,是臨時性的,僅僅用于存放返回結果。
?
655struct nameidata {656 struct dentry *dentry;657 struct vfsmount *mnt;658 struct qstr last;659 unsigned int flags;660 int last_type;661};?
?
??dentry記錄找到的內存中的dentry和inode信息,mnt記錄文件系統安裝信息,例如文件系統的安裝點,根節點等。
?
總結:一個函數與外界的信息交互,在數學模型中形如y=f(x),其中形參(一般形參和指針)和全局變量是輸入變量;函數返回值,全局變量和指針所指向的變量都是函數輸出。對于不改變指向值的指針,往往需要用const關鍵字。
1.2 執行流程分析:
???getname()在系統空間分配一個頁面,從用戶空間將文件名復制到這個頁面中,所以整個路徑名字可以長達4K,由于這塊空間是動態分配的,所以用完以后要通過putname釋放。?
?
關于err的內聯函數和解釋如下:
?
1300 /* 1301 * Kernel pointers have redundant information, so we can use a 1302 * scheme where we can return either an error code or a dentry 1303 * pointer with the same return value. 1304 * 1305 * This should be a per-architecture thing, to allow different 1306 * error and pointer decisions. 1307 */ 1308 static inline void *ERR_PTR(long error) 1309 { 1310 return (void *) error; 1311 } 1312 1313 static inline long PTR_ERR(const void*ptr) 1314 { 1315 return (long) ptr; 1316 } 1317 1318 static inline long IS_ERR(const void*ptr) 1319 { 1320 return (unsigned long)ptr > (unsigned long)-1000L; 1321 }?
注:函數返回值大于0xfffffc18的時候(就是-1000L),就說明出錯了。
查看getname函數:
?
108 static inline int do_getname(const char *filename, char *page)109 {110 int retval;111 unsigned long len = PATH_MAX + 1;112 113 if ((unsigned long) filename >= TASK_SIZE) {114 if (!segment_eq(get_fs(), KERNEL_DS))115 return -EFAULT;116 } else if (TASK_SIZE - (unsigned long) filename < PAGE_SIZE)117 len = TASK_SIZE - (unsigned long) filename;118 119 retval = strncpy_from_user((char *)page, filename, len);120 if (retval > 0) {121 if (retval < len)122 return 0;123 return -ENAMETOOLONG;124 } else if (!retval)125 retval = -ENOENT;126 return retval;127 }?
?
?
129 char * getname(const char * filename)130 {131 char *tmp, *result;132 133 result = ERR_PTR(-ENOMEM);134 tmp = __getname();135 if (tmp) {136 int retval = do_getname(filename, tmp);137 138 result = tmp;139 if (retval < 0) {140 putname(tmp);141 result = ERR_PTR(retval);142 }143 }144 return result;145 }
?#define ENOMEM 12
?
?#define __getname() ? ? kmem_cache_alloc(names_cachep, SLAB_KERNEL)?
涉及內存管理的部分,我們以后再來研究。
2 path_init函數(在fs/namei.c).
?
[path_init()]/*SMP-safe */691int path_init(const char *name, unsigned int flags, struct nameidata *nd)692 {// 這個函數的作用是:得到路徑起點,設置了dentry和mnt兩個變量,并進行初始化的相關設置693 nd->last_type = LAST_ROOT; /* if there are only slashes... */694 nd->flags = flags;695 if (*name=='/')696 return walk_init_root(name,nd);697 read_lock(current->fs->lock);//698 nd->mnt = mntget(current->fs->pwdmnt);699 nd->dentry = dget(current->fs->pwd);700 read_unlock(current->fs->lock);//701 return 1;702 }?
?
?函數調用關系圖如下:?
?
疑問:flags從何而來?
?
2.1 nameidata結構中的last_type字段:
??? 首先將nameidata結構中的last_type字段設置成LAST_ROOT,這個字段可能的值定義在fs.h中,如下:
?
1336 /* 1337 * Type of the last component on LOOKUP_PARENT 1338 */ 1339 enum {LAST_NORM, LAST_ROOT, LAST_DOT,LAST_DOTDOT, LAST_BIND};
? ? 在搜索過程中,這個字段的值會隨著當前搜索結果而改變。例如,如果成功到了目標文件,它就變成LAST_NORM;如果停留在最后一個"."上面,則變成LAST_DOT.
?
2.2 其他字段的賦值
?
I)相對路徑
?if后面的語句,是相對路徑的情況:nd->dentry = dget(current->fs->pwd)這句代碼有兩層意思:1)將nameidata的dentry設置成當前工作目錄的dentry結構,表示虛擬絕對路徑中,這個節點之前的節點都已經解決了 2)這個具體的dentry結構多了一個用戶,所以調用dget遞增它的共享計數,即是dentry中d_count的值。關于,Vfsmount結構pwdmnt,可以參考以后的文件系統的安裝和拆卸。
?
233 static __inline__ struct dentry *dget(struct dentry *dentry) 234 { 235 if (dentry) { 236 if(!atomic_read(&dentry->d_count)) 237 BUG();// 238 atomic_inc(&dentry->d_count); 239 } 240 return dentry; 241 }? #define atomic_read(v) ? ? ? ? ?((v)->counter)?
d_count 是atomic_t 類型
?typedef struct { volatile int counter; } atomic_t;
?#define atomic_inc(v) atomic_add(1,(v))
?
II)絕對路徑
??? if引導的是絕對路徑下的情況,通過walk_init_root()從根節點開始查找(fs/namei.c):
?
[path_init() >>> walk_init_root()]722/* SMP-safe */723static inline int724walk_init_root(const char *name, struct nameidata *nd)725 {726 read_lock(current->fs->lock);727 if (current->fs->altroot &&!(nd->flags & LOOKUP_NOALT)) {728 nd->mnt =mntget(current->fs->altrootmnt);729 nd->dentry =dget(current->fs->altroot);730 read_unlock(¤t->fs->lock);731 if(__emul_lookup_dentry(name,nd))732 return 0;733 read_lock(¤t->fs->lock);734 }735 nd->mnt = mntget(current->fs->rootmnt);736 nd->dentry = dget(current->fs->root);737 read_unlock(currentt->fs->lock);738 return 1;739 }?35 static inline struct vfsmount *mntget(struct vfsmount *mnt)
?36 {
?37 ? ? ? ? if (mnt)
?38 ? ? ? ? ? ? ? ? atomic_inc(&mnt->mnt_count);
?39 ? ? ? ? return mnt;
?40 }
?
??? 代碼解析:如果當前進程沒有通過chroot設置自己的根目錄,current->fs->altroot是0,所以nameidata中的兩個指針就進行if之外的相關設置;反之,要查看當初調用path_init()的參數flags中的標志位LOOKUP_NOTLT是否為1.通常情況下,這個標志為0,所以如果current->fs->altroot是1,就會通過__emul_lookup_dentry(name,nd)將nameidata中的指針設置成指向“替換”根目錄指針。因為linux 在i386處理器上不支持替換根目錄,所以current->fs->altroot總是NULL,我們也不在此介紹相關內容,讀者可以查看相關書籍。
?
??? 從path_init()成功返回的時候,nameidata結構中的指針dentry指向當前搜索路徑的起點,接下來通過path_walk()順著路徑名的指引進行搜索。下面分段解析這個函數(fs/namei.c):
?
3.path_walk()函數解析
?
3.1)這個函數比較長(大概200行),我們進行分段解析,它的完整代碼,我們可以查看附錄部分。
?
442 /*443 *Name resolution.444 *445 *This is the basic name resolution function, turning a pathname446 *into the final dentry.447 *448 *We expect 'base' to be positive and a directory.449 */ 450 int path_walk(const char * name, structnameidata *nd)451 {452 struct dentry *dentry;453 struct inode *inode;454 int err;455 unsigned int lookup_flags = nd->flags;456457 while (*name=='/')// 跳過多個連續的“/”458 name++;459 if (!*name)//name僅僅包含一個"/",則解析完成460 goto return_base;461462 inode = nd->dentry->d_inode;//注意dentry已經在init中設置完成463 if (current->link_count)// current is the type of task_struct464 lookup_flags = LOOKUP_FOLLOW;?
return_base;//return 0;?
?
???link_count是task_struct的成員之一,用于防止鏈接導致的循環搜索。另外,因為path_walk()的起點是一個目錄,所以一定有相應的inode存在,而不是空。
小結:以上代碼跳過路徑前面若干個“/”,并且給inode和flag初始化
3.2)接下來是對路徑節點所作的for循環,我們將其拆開來看
?
466 /* At this point we know we have a real path component. *//*beforethis point ,we only parse the / or the co*/467 for(;;) {468 unsigned long hash;469 struct qstr this;470 unsigned int c;471472 err = permission(inode,MAY_EXEC);//檢查對當前節點的訪問權限,注意此處是可執行!!!473 dentry =ERR_PTR(err);//translate long err into pointer474 if (err)475 break;476477 this.name = name;478 c = *(const unsigned char*)name;//name was a pointer to char,c is char name[0] >>> assci479 //#define init_name_hash() 0480 hash = init_name_hash();481 do {482 name++;483 hash =partial_name_hash(c, hash);//對c和hash進行2維hash484 c = *(const unsignedchar *)name;485 } while (c && (c !='/'));486 this.len = name - (const char*) this.name;487 this.hash =end_name_hash(hash);488/* remove trailing slashes? */490 if (!c)491 goto last_component;492 while (*++name == '/');493 if (!*name)494 gotolast_with_slashes;495
?40 /* Finally: cut down the number of bits to a int value (and try to avoid losing bits) */
?41 static __inline__ unsigned long end_name_hash(unsigned long hash)
?42 {
?43 ? ? ? ? if (sizeof(hash) > sizeof(unsigned int))
?44 ? ? ? ? ? ? ? ? hash += hash >> 4*sizeof(hash);
?45 ? ? ? ? return (unsigned int) hash;
?46 }
?
循環體中的局部變量this是一個qstr結構,用來存放路徑名中當前節點的hash值和節點名的長度,定義在include/linux/dcache.h之中
?
?
struct qstr {unsigned int hash;unsigned int len;const unsigned char *name; };?
其中,481-485 就是逐個字符計算出當前節點的hash值(hash的過程是,給定一個raw和一個hash函數,返回一個hash值),具體函數如下:
?
/* partial hash update function. Assumeroughly 4 bits per character */34static __inline__ unsigned long partial_name_hash(unsigned long c, unsignedlong prevhash)35 {36 prevhash = (prevhash << 4) | (prevhash >> (8*sizeof(unsignedlong)-4));37 return prevhash ^ c;38 } 最終的hash值,index的計算如下:40 /*Finally: cut down the number of bits to a int value (and try to avoid losingbits) */41static __inline__ unsigned long end_name_hash(unsigned long hash)42 {43 if (sizeof(hash) > sizeof(unsigned int))44 hash += hash >>4*sizeof(hash);45 return (unsigned int) hash;46 }?
路徑名的分隔符是“\”,這里緊跟著當前路徑名稱的字符有兩種可能
(1)是“/0”,表示結尾
(2)是“/”,這里又包含幾種情況,/后面有字符路徑和沒有字符路徑(比如ls usr/inlude/)
小結:以上代碼,對當前的節點進行hash值的計算,并且將指向路徑的指針移動到下一個節點所在的地方,如果節點解析完成,將進行跳轉。
?
3.3)接下來查看當前節點
?
I)當前節點是. or ..,需要進行特殊處理
?
從前面的分析知道,當前節點一定是目錄節點
?
496 /*497 * "." and".." are special - ".." especially so because it has498 * to be able to know aboutthe current root directory and499 * parent relationships.500 */501 if (this.name[0] == '.')switch (this.len) {502 default:503 break;// is itfor the hiden file?no! if the node is directory, it cannot be named like .name504 case 2:505 if(this.name[1] != '.')506 break;507 follow_dotdot(nd);//如果當前就是“..”,那么需要向上跑到已經到達節點nd->dentry的父目錄中去508 inode =nd->dentry->d_inode;509 /* fallthrough*///nomatter it is .. or ., we go to parse the next node510 case 1:511 continue;512 }?
下面,我們來看看 follow_dotdot(nd)
?
[path_walk >> follow_dotdot] 405 static inline void follow_dotdot(structnameidata *nd)406 {407 while(1) {408 struct vfsmount *parent;409 struct dentry *dentry;410 read_lock(current->fs->lock);411 if (nd->dentry ==current->fs->root &&412 nd->mnt ==current->fs->rootmnt) {//nd指向的fsmnt結構是否代表根設備413 read_unlock(¤t->fs->lock);414 break;415 }//當前節點就是root節點,所以父節點還是本身,保持不變 416 read_unlock(¤t->fs->lock);417 spin_lock(&dcache_lock);418 if (nd->dentry != nd->mnt->mnt_root){//沒有進行設備之間的跳躍419 dentry =dget(nd->dentry->d_parent);//完成了遞增計數器和賦值420 spin_unlock(&dcache_lock);421 dput(nd->dentry);422 nd->dentry =dentry;423 break;424 }//下面是跨越了設備的情況 425 parent=nd->mnt->mnt_parent; 426 if (parent == nd->mnt) {427 spin_unlock(&dcache_lock);428 break;429 }430 mntget(parent);431 dentry=dget(nd->mnt->mnt_mountpoint);432 spin_unlock(&dcache_lock);433 dput(nd->dentry);434 nd->dentry = dentry;435 mntput(nd->mnt);436 nd->mnt = parent;437 }438 while (d_mountpoint(nd->dentry) &&__follow_down(&nd->mnt, &nd->dentry))439 ;440 }441?
?
這里分三種情況,
?第一種,已到達節點就是本進程的根節點,此時,保持不變。對應第一個if語句。
?第二種,已到達節點的nd->dentry和它的父節點在同一個設備上。這種情況下,父節點的dentry結構必然已經建立在內存中,而且dentry結構中的指針d_patent就指向其父節點,所以往上走一層是簡單的事情。第二個if語句
?第三種,已經到達節點nd->dentry就是其所在設備的根節點,再往上就到達另外一個設備中。從文件系統的角度看,安裝點和所安裝設備的根目錄是等價的,而目錄樹的根目錄不等同于設備的根目錄,我們在當前設備的根目錄中,從這里往上一層,是目錄樹的上一層目錄而不是安裝點本身。當將一個存儲設備“安裝”到另外一個設備的某個節點的時候,內核會分配和設置一個fsmount結構,通過這個結構將兩個設備和兩個節點連接起來(參見后面“文件系統的安裝和拆卸”)。所以,每個安裝的設備都有一個fsmount結構,結構中有一個mnt_parent指向父設備,但是根設備的這個指針指向它自己,因為它已經沒有父設備了;另外一個指針mnt_mountpoint, 指向代表著安裝點的dentry。
?先檢查當前Vfs結構是否代表著根設備;如果是的話,立即通過423行的break語句結束while循環;反之,進行相關處理。(這里沒有解釋清楚,我們在學習了下一節以后再回頭看)。
? 回到path_walk的代碼之中,我們注意到“case2”沒有break語句, 所以會通過case 1的continue語句回到for循環開始的地方執行,繼續處理下一個節點。大多數節點不是以.命名的,我們來看看正常節點的處理流程。
?
我們來看以下dput:
?
?113 ?* @dentry: dentry to release?114 ?*115 ?* Release a dentry. This will drop the usage count and if appropriate116 ?* call the dentry unlink method as well as removing it from the queues and117 ?* releasing its resources. If the parent dentries were scheduled for relea ? ? se118 ?* they too may now get deleted.119 ?*120 ?* no dcache lock, please.121 ?*/123 void dput(struct dentry *dentry)124 {125 if (!dentry)126 return;127 128 repeat:129 if (!atomic_dec_and_lock(&dentry->d_count, &dcache_lock))130 return;131 132 /* dput on a free dentry? */133 if (!list_empty(&dentry->d_lru))134 BUG();135 /*136 * AV: ->d_delete() is _NOT_ allowed to block now.137 */138 if (dentry->d_op && dentry->d_op->d_delete) {139 if (dentry->d_op->d_delete(dentry))140 goto unhash_it;141 }142 /* Unreachable? Get rid of it */143 if (list_empty(&dentry->d_hash))144 goto kill_it;145 list_add(&dentry->d_lru, &dentry_unused);146 dentry_stat.nr_unused++;147 /*148 * Update the timestamp149 */150 dentry->d_reftime = jiffies;151 spin_unlock(&dcache_lock);152 return;153 154 unhash_it:155 list_del_init(&dentry->d_hash);156 157 kill_it: {158 struct dentry *parent;159 list_del(&dentry->d_child);160 /* drops the lock, at that point nobody can reach this dentry */161 dentry_iput(dentry);162 parent = dentry->d_parent;163 d_free(dentry);164 if (dentry == parent)165 ? ? ? ? ? ? ? ? ? ? ? ? return;166 ? ? ? ? ? ? ? ? dentry = parent;167 ? ? ? ? ? ? ? ? goto repeat;168 ? ? ? ? }169 }?
?
子函數解析:
?
13 int atomic_dec_and_lock(atomic_t *atomic, spinlock_t *lock)14 {15 int counter;16 int newcount;17 18 repeat:19 counter = atomic_read(atomic);//讀取atomic指向的值20 newcount = counter-1;21 22 if (!newcount)23 goto slow_path;24 25 asm volatile("lock; cmpxchgl %1,%2"26 :"=a" (newcount)27 :"r" (newcount), "m" (atomic->counter), "0" (counter));//what?28 29 /* If the above failed, "eax" will have changed */30 if (newcount != counter)//why?31 goto repeat;32 return 0;33 34 slow_path:35 spin_lock(lock);36 if (atomic_dec_and_test(atomic))37 return 1;38 spin_unlock(lock);39 return 0;40 }?
22 #define atomic_read(v) ((v)->counter)23 #define atomic_set(v,i) ((v)->counter = (i))?
100 #define atomic_dec_return(v) atomic_sub_return(1,(v)) 101 #define atomic_inc_return(v) atomic_add_return(1,(v)) 102 103 #define atomic_sub_and_test(i,v) (atomic_sub_return((i), (v)) == 0) 104 #define atomic_dec_and_test(v) (atomic_sub_return(1, (v)) == 0) 105 106 #define atomic_inc(v) atomic_add(1,(v)) 107 #define atomic_dec(v) atomic_sub(1,(v))?
II)當前節點是正常節點
?
/*514 * See if the low-levelfilesystem might want515 * to use its own hash..516 */517 if (nd->dentry->d_op&& nd->dentry->d_op->d_hash) {518 err =nd->dentry->d_op->d_hash(nd->dentry, &this);//使用自己的hash函數進行hash519 if (err < 0)520 break;521 }//有些文件系統通過dentry_operations結構中指針d_hash提供專用的hash函數522 /* This does the actuallookups.. */523 dentry =cached_lookup(nd->dentry, &this, LOOKUP_CONTINUE);//在內存中尋找該節點已經建立的dentry結構524 if (!dentry) {//沒有在內存中找到相應dentry525 dentry =real_lookup(nd->dentry, &this, LOOKUP_CONTINUE);//在磁盤上進行尋找526 err = PTR_ERR(dentry);527 if (IS_ERR(dentry))528 break;}530 /* Check mountpoints.. */531 while (d_mountpoint(dentry)&& __follow_down(&nd->mnt, &dentry))532 ;//what?533534 err = -ENOENT;535 inode = dentry->d_inode;536 if (!inode)537 goto out_dput;538 err = -ENOTDIR;539 if (!inode->i_op)540 goto out_dput;541542 if(inode->i_op->follow_link) {543 err =do_follow_link(dentry, nd);544 dput(dentry);//刪除對應dentry條目545 if (err)546 gotoreturn_err;547 err = -ENOENT;548 inode =nd->dentry->d_inode;549 if (!inode)550 break;551 err = -ENOTDIR;552 if (!inode->i_op)553 break;554 } else {555 dput(nd->dentry);556 nd->dentry =dentry;557 }558 err = -ENOTDIR;559 if(!inode->i_op->lookup)560 break;561 continue;562 /* here ends the main loop */?
?
???????對當前節點的搜索是通過cached_lookup()&real_lookup()兩個函數進行的。內核中有個雜湊表,dentry_hashtabel,是一個list_head指針數組,一旦在內存中建立目錄節點的dentry結構,就根據其節點名稱的hash值掛入hash表的某個隊列。當路徑名的某個節點變成path_walk()的當前節點時,位于其上游節點的dentry肯定都已經在內存的hash表中,如果在內存的相應結構中找不到相應節點的dentry結構,那么需要通過real_lookup()到磁盤目錄尋找,然后載入內存。
?
??????內存中還有一個隊列dentry_unused,存放共享計數為0,不再使用的dentry結構(有時,也需要把還在使用的dentry結構強制脫鏈,強迫從磁盤上訪問),已經脫鏈的那個數據結構由最后調用dput()使其共享計數變成0的進程負責將其釋放。
?
??? 事實上,dentry中有6個list_head,d_vfsmnt,d_hash,d_lru,d_child,d_subdirs,d_alias.注意,list_head可以作為隊列的頭部,也可以將其所在的數據結構掛入到某個隊列中。d_vfsmnt盡在dentry作為安裝點的時候才使用,一個dentry一旦建立就通過d_hash掛入dentry_hashtable的某個隊列里,當共享計數變成0的時候,通過d_lru掛入LRU隊列dentry_unused之中,dentry通過d_child掛入父節點的d_subdir之中,同時通過d_patrent結構(這不是一個head_list)指向父目錄的dentry結構,而它各自的子目錄的dentry結構都在它的d_subdir隊列中。
?
??? 一個dentry對應一個inode,但一個inode可能對應多個dentry。所以在inode中有個隊列i_dentry,目錄項功過dentry結構中的d_alias掛入相應的inode結構中的i_dentry隊列。此外dentry結構中還有指針d_sb,指向其鎖在設備超級快的super_block結構;指針d_op,指向特定系統的dentry_operations結構。
?
?
struct list_head {struct list_head *next, *prev; }; //acttrually, it is only a doubly linked list with no content?
(未完待續)
?
轉載于:https://www.cnblogs.com/jiangu66/p/3187156.html
總結
以上是生活随笔為你收集整理的ext2 源代码解析之 “从路径名到目标结点” (一)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 微信定时自动发送群消息的小工具-pyth
- 下一篇: mysql varbinary blob