Linux模塊
一、為什么要使用模塊
? ? ? 由于linux使用的是整體結構,不是模塊化的結構,整體結構實現的操作系統可擴展性差。linux為了擴展系統,使用了模塊的技術,模塊能夠從系統中動態裝入和卸載,這樣使得linux也具有很好的可擴展性。
?
二、linux中哪些代碼作為模塊實現,哪些直接編譯進內核?
? ? ? 當然我們是盡量把代碼編譯成模塊,這樣就可以根據需要進行鏈接,內核的代碼量也會少很多。幾乎所有的高層組件—文件系統、設備驅動程序、可執行格式、網絡層等等—都可以作為模塊進行編譯。
? ? ? 然而有些代碼確必須直接編譯進內核。這些代碼通常是對數據結構或者函數進行修改。如內核中已經定義好了的數據結構,如果要改變這個數據結構,那么只有從新編譯內核了。
?
三、管理模塊
? ? ?內核主要完成管理模塊的兩個任務。第一個任務是確保內核的其它部分可以訪問該模塊的全局符號,模塊還必須知道全局符號在內核及其它模塊中的地址。因此,在鏈接模塊時,一定要解決模塊間的引用關系。第二個任務是記錄模塊的使用情況,以便再其它模塊或者內核的其它部分正在使用這個模塊時,不能卸載這個模塊。
?
四、模塊使用的數據結構
? ? ? 每個模塊都用一個module描述符描述,并且鏈接到一個以modules變量為鏈表頭的雙向循環鏈表中。
module描述符:
struct module { enum module_state state; // 模塊內部狀態 struct list_head list; // 用于鏈接到鏈表中 char name[MODULE_NAME_LEN]; // 模塊名字 struct module_kobject mkobj; // 用于Sysfs的kobject struct module_param_attrs *param_attrs; // 指向模塊參數描述符 const struct kernel_symbol *syms; // 指向導出符號數組的指針 unsigned int num_syms; // 導出符號數 const unsigned long *crcs; // 指向導出符號CRC值數組指針 const struct kernel_symbol *gpl_syms; // GPL格式導出符號 unsigned int num_gpl_syms; const unsigned long * gpl_crcs; unsigned int num_exentries; // 模塊異常表項數 const struct exception_table_entry *extable; // 指向模塊異常表的指針 int (*init)( void ); // 模塊初始化方法 void *module_init; // 用于模塊初始化的動態內存區指針 void *module_core; // 用于模塊核心函數與數據結構的動態內存區指針 unsigned long init_size, core_size; // 模塊初始化動態內存區大小,模塊核心函數與數據結構的動態內存區大小 unsigned long init_text_size, core_text_size; // 模塊初始化的可執行代碼大小,模塊核心可執行代碼的大小,只在連接模塊時使用 struct mod_arch_specific arch; int unsafe ; int license_gplok; #ifdef CONFIG_MODULE_UNLOAD struct module_ref ref [NR_CPUS]; // 每cpu使用計數器變量 /* What modules depend on me? */ struct list_head modules_which_use_me; // 依賴于該模塊的模塊鏈表 struct task_struct *waiter; // 正在等待模塊被卸載的進程,即卸載模塊的進程 void (*exit)( void ); // 模塊退出的方法 #endif #ifdef CONFIG_KALLSYMS Elf_Sym *symtab; // proc/kallsysms文件中所列模塊ELF符號數組指針 unsigned long num_symtab; char *strtab; // proc/kallsysms文件中所列模塊ELF符號的字符串表 struct module_sect_attrs *sect_attrs; // 模塊分節屬性描述符數組指針 #endif void * percpu; char *args; // 模塊連接時使用的命令行參數 };
? ? ? module數據結構主要描述了模塊導出符號,模塊使用的動態內存,模塊的加載和釋放函數,模塊的引用等。
? ? ? 當裝載一個模塊到內核中時,必須用合適的地址替換在模塊對象代碼中引用的所有全局內核符號。這主要由insmod程序來完成。內核使用一些專門的內核符號表,用于保存模塊訪問的符號和相應的地址。它們在內核代碼分三節:__kstrtab節(保存符號名)、__ksymtab節(所有模塊可使用的符號地址)和__ksymtab_gpl節(GPL兼容許可證下發布的模塊可以使用的符號地址)。
? ? ? 已經裝載到內核中的模塊也可以導出自己的符號,這樣其它模塊就可以訪問這些符號。模塊符號部分表保存在模塊代碼段__ksymtab、__ksymtab_gpl和__kstrtab部分中。可以使用宏EXPOPT_SYMBOL和EXPORT_SYMPOL_GPL來導出符號。當模塊裝載進內核時,模塊的導出符號被拷貝到兩個內存數組中,而數組的地址保存在module描述符的syms和gpl_syms字段中。
? ? ? 一個模塊可以引用另一個模塊所導出的符號。module描述符中有個字段modules_which_use_me,它是一個依賴鏈表的頭部,該鏈表保存了使用該模塊的所有其他模塊。鏈表中每個元素都是一個module_use描述符,該描述符保存指向鏈表中相鄰元素的指針以及一個指向相應模塊對象的指針。只有依賴鏈表不為空,就不能卸載該模塊。
?
五、模塊的裝載
? ? ? 模塊的裝載主要通過sys_init_module服務例程來實現的,是由insmod外部程序通過系統調用來調用該函數。下面我們來分析sys_init_module函數:
asmlinkage long sys_init_module( void __user * umod, unsigned long len, const char __user * uargs) { struct module * mod; int ret = 0 ; … mod = load_module(umod, len, uargs); … if (mod->init != NULL) ret = mod->init(); // 調用模塊初始化函數初始化模塊 … mod ->state = MODULE_STATE_LIVE; module_free(mod, mod ->module_init); // 釋放初始化使用的內存 mod->module_init = NULL; mod ->init_size = 0 ; mod ->init_text_size = 0 ; … }
? ? ? 這個函數主要是調用load_module函數加載模塊代碼到內存中,并初始化該模塊對象mod;調用初始化模塊函數初始化模塊,釋放模塊中的初始化代碼動態內存空間。其中傳遞的參數umod是insmod程序在用戶態時將模塊文件拷貝到內存中的起始地址,len是模塊文件的大小,uargs是調用命令insmod時的命令行參數。
? ? ? 加載模塊的工作其實主要還是由函數load_module來完成,這個函數完成了將模塊文件從用戶空間加載到臨時內核空間,對模塊文件進行合法性檢查,并抽取出模塊文件中的核心函數和數據結構到內核的另一個動態內存區,并重定位模塊中的符號,初始化module對象,將mod對象加入到sysfs文件系統中。
static struct module *load_module( void __user * umod, unsigned long len, const char __user * uargs) { Elf_Ehdr * hdr; Elf_Shdr * sechdrs; char *secstrings, *args, *modmagic, *strtab = NULL; unsigned int i, symindex = 0 , strindex = 0 , setupindex, exindex, exportindex, modindex, obsparmindex, infoindex, gplindex, crcindex, gplcrcindex, versindex, pcpuindex; long arglen; struct module * mod; long err = 0 ; void *percpu = NULL, *ptr = NULL; /* Stops spurious gcc warning */ struct exception_table_entry * extable; … if (len > 64 * 1024 * 1024 || (hdr = vmalloc(len)) == NULL) // 超過64MB,或者分配內存失敗,否則分配一個臨時的內核空間來存放內核模塊 return ERR_PTR(- ENOMEM); if (copy_from_user(hdr, umod, len) != 0 ) { // 用空間將模塊目標代碼拷貝到內核 err = - EFAULT; goto free_hdr; } … // 省略的代碼為檢查模塊的合法性 sechdrs = ( void *)hdr + hdr->e_shoff; // 節的頭表 secstrings = ( void *)hdr + sechdrs[hdr->e_shstrndx].sh_offset; // 節點頭字符串表 sechdrs[ 0 ].sh_addr = 0 ; for (i = 1 ; i < hdr->e_shnum; i++ ) { if (sechdrs[i].sh_type != SHT_NOBITS // SHT_NOBITS表示該節點在文件中無內容 && len < sechdrs[i].sh_offset + sechdrs[i].sh_size) goto truncated; /* Mark all sections sh_addr with their address in the temporary image. */ sechdrs[i].sh_addr = (size_t)hdr + sechdrs[i].sh_offset; // 把每個節點的地址設置為在內存中對應的地址 if (sechdrs[i].sh_type == SHT_SYMTAB) { // 節點為符號表 symindex = i; strindex = sechdrs[i].sh_link; // 字符串表在節點頭表中的索引 strtab = ( char *)hdr + sechdrs[strindex].sh_offset; // 字符串表 } #ifndef CONFIG_MODULE_UNLOAD // 沒有定義模塊卸載 /* Don't load .exit sections */ // 不將.exit節加載到內存 if (strncmp(secstrings+sechdrs[i].sh_name, " .exit " , 5 ) == 0 ) sechdrs[i].sh_flags &= ~(unsigned long )SHF_ALLOC; #endif } modindex = find_sec(hdr, sechdrs, secstrings, " .gnu.linkonce.this_module " ); // .gnu.linkonce.this_module在節點頭表中的索引 … mod = ( void * )sechdrs[modindex].sh_addr; … // 省略代碼處理參數和處理每cpu變量 mod->state = MODULE_STATE_COMING; layout_sections(mod, hdr, sechdrs, secstrings); // 節的從新布局,合并所有帶有SHF_ALLOC標記的節,并計算每個節的大小和偏移量,包括計算初始化代碼和核心代碼的空間大小 ptr = module_alloc(mod->core_size); // 為模塊代碼分配動態內存 … memset(ptr, 0 , mod-> core_size); mod ->module_core = ptr; ptr = module_alloc(mod->init_size); // 為模塊初始化代碼分配動態內存 … memset(ptr, 0 , mod-> init_size); mod ->module_init = ptr; … for (i = 0 ; i < hdr->e_shnum; i++) { // 將臨時內核模塊的數據拷貝到新的動態內存中 void * dest; if (!(sechdrs[i].sh_flags & SHF_ALLOC)) continue ; if (sechdrs[i].sh_entsize & INIT_OFFSET_MASK) dest = mod-> module_init + (sechdrs[i].sh_entsize & ~ INIT_OFFSET_MASK); else dest = mod->module_core + sechdrs[i].sh_entsize; if (sechdrs[i].sh_type != SHT_NOBITS) memcpy(dest, ( void * )sechdrs[i].sh_addr, sechdrs[i].sh_size); sechdrs[i].sh_addr = (unsigned long )dest; // 更新節在內存中的地址 DEBUGP( " \t0x%lx %s\n " , sechdrs[i].sh_addr, secstrings + sechdrs[i].sh_name); } mod = ( void *)sechdrs[modindex].sh_addr; // mod指向新內存 module_unload_init(mod); // 初始化mod的卸載字段 // 修正符號表中的地址值 err = simplify_symbols(sechdrs, symindex, strtab, versindex, pcpuindex, mod); … for (i = 1 ; i < hdr->e_shnum; i++) { // 重定位各個節中的符號 const char *strtab = ( char * )sechdrs[strindex].sh_addr; unsigned int info = sechdrs[i].sh_info; if (info >= hdr-> e_shnum) continue ; if (!(sechdrs[info].sh_flags & SHF_ALLOC)) continue ; if (sechdrs[i].sh_type == SHT_REL) // 當前節是重定位節 err = apply_relocate(sechdrs, strtab, symindex, i,mod); if (err < 0 ) goto cleanup; } … vfree(hdr); // 釋放臨時分配的內核空間 … }
? ? ? 代碼中的simplify_symbols主要就是查找內核符號表,將模塊符號表中未決的符號修改為內核符號表中對應的符號的值,即符號對應的線性地址。apply_relocate函數主要就是通過模塊中重定位節的信息將模塊中需要重定位的符號地址重新定位。
?
六、模塊的卸載
? ? ? 模塊卸載主要完成對模塊是否可以卸載,先是檢查用戶是否有這個權限,如果沒有權限是不能卸載模塊的。如果有其它模塊在引用該模塊,也不能卸載該模塊,根據用戶給的模塊名到模塊鏈表中查找模塊,如果引用模塊的計數不為0,則阻塞當前進程,否則將模塊從modules鏈中刪除;如果模塊自定義了exit函數,則執行該函數,將模塊從文件系統sysfs注銷,釋放模塊占用的內存區。
asmlinkage long sys_delete_module( const char __user *name_user, unsigned int flags) { struct module * mod; char name[MODULE_NAME_LEN]; int ret, forced = 0 ; if (! capable(CAP_SYS_MODULE)) return - EPERM; if (strncpy_from_user(name, name_user, MODULE_NAME_LEN- 1 ) < 0 ) return - EFAULT; name[MODULE_NAME_LEN - 1 ] = ' \0 ' ; mod = find_module(name); // 查找模塊 if (!list_empty(&mod->modules_which_use_me)) { // 查看是否有其它模塊使用當前模塊 ret = - EWOULDBLOCK; goto out ; } if (mod->state != MODULE_STATE_LIVE) { // 判斷模塊是否是正常運行的 /* FIXME: if (force), slam module count and wake up waiter --RR */ DEBUGP( " %s already dying\n " , mod-> name); ret = - EBUSY; goto out ; } if ((mod->init != NULL && mod->exit == NULL) // 如果模塊有init卻沒有exit,則不能卸載模塊 || mod-> unsafe ) { forced = try_force(flags); if (! forced) { ret = - EBUSY; goto out ; } } mod ->waiter = current; // 卸載該模塊的進程 ret = try_stop_module(mod, flags, & forced); if (!forced && module_refcount(mod) != 0 ) // 等待模塊引用計數為0 wait_for_zero_refcount(mod); if (mod->exit != NULL) { // 調用該模塊定義的exit函數 up(& module_mutex); mod -> exit(); down( & module_mutex); } free_module(mod); // 將模塊從sysfs注銷和釋放模塊占用的內存 … }
在Linux下和Windows下遍歷目錄的方法及如何達成一致性操作
最近因為測試目的需要遍歷一個目錄下面的所有文件進行操作,主要是讀每個文件的內容,只要知道文件名就OK了。在Java中直接用File類就可以搞定,因為Java中使用了組合模式,使得客戶端對單個文件和文件夾的使用具有一致性,非常方便。但在C中就不一樣了,而且在不同的平臺下使用方法也不同。在Linux下實現該功能就非常方便,因為自帶有API庫,幾個函數用起來得心應手(雖然有些小問題,后面說),在Windows下實現就不是那么方便,雖然也有自己的API,但用法有些晦澀難懂,因為沒有封裝起來,需要自己一步一步進行操作,因為用的是Windows API庫函數所以如果對Windows編程不熟悉的話,照搬網上的代碼錯了也不易調試。為此,我把這些操作都封裝成類似Linux下的庫函數,一方面簡化透明了操作,另一方面(也許更重要)就是移植性,這樣將包含該功能的程序從Windows上移植到Linux下就無需改動代碼了(刪掉實現封裝的文件,因為Linux下自帶了),當然從Linux下移植到Windows下同樣方便(增加實現封裝的文件即可),這就是所謂的OCP原則吧(開放封閉原則,具體見: 程序員該有的藝術氣質—SOLID原則 )。好了,首先看下Linux下是如何實現這個功能的。
一、Linux下遍歷目錄的方法
?Linux下實現目錄操作的API函數都在頭文件dirent.h中,截取部分該文件內容如下:
/* * structure describing an open directory. */ typedef struct _dirdesc { int dd_fd; /* * file descriptor associated with directory */ long dd_loc; /* * offset in current buffer */ long dd_size; /* * amount of data returned by getdirentries */ char *dd_buf; /* * data buffer */ int dd_len; /* * size of data buffer */ long dd_seek; /* * magic cookie returned by getdirentries */ long dd_rewind; /* * magic cookie for rewinding */ int dd_flags; /* * flags for readdir */ struct pthread_mutex *dd_lock; /* * lock */ struct _telldir *dd_td; /* * telldir position recording */ } DIR; typedef void * DIR; DIR *opendir( const char * ); DIR *fdopendir( int ); struct dirent * readdir(DIR * );
void?? ? seekdir(DIR *, long);
long?? ? telldir(DIR *); void rewinddir(DIR * ); int closedir(DIR *);
struct dirent { long d_ino; /* inode number */ off_t d_off; /* offset to this dirent */ unsigned short d_reclen; /* length of this d_name */ unsigned char d_type; /* the type of d_name */ char d_name[ 1 ]; /* file name (null-terminated) */ };
關鍵部分就是 DIR 這個結構體的定義,包括文件描述符、緩沖區偏移、大小、緩沖區內容等,下面定義的就是具體的目錄操作函數了,有打開目錄、讀目錄、重置讀取位置、關閉目錄等,這里我所需要的就是打開、讀和關閉這三個最基本的目錄操作,下面是使用例子:
#include <stdio.h> #include <stdlib.h> #include < string .h> #include <dirent.h> #define MAX_LEN 65535 int main( void ) { DIR * dir; struct dirent * ptr; char * flow[MAX_LEN]; int num = 0 , i = 0 ; if ((dir=opendir( ". /data " )) == NULL) { perror( " Open dir error... " ); exit( 1 ); } // readdir() return next enter point of directory dir while ((ptr=readdir(dir)) != NULL) { flow[num ++] = ptr-> d_name; // printf("%s\n", flow[num - 1]); } for (i = 0 ; i < num; i++ ) { printf( " %s\n " , flow[i]); } closedir(dir); }
運行結果如下:
?
一看這結果就不對,輸出的都是同一個文件名(最后一個文件的文件名), 哪里出了問題呢?將代碼中 // ?printf("%s\n", flow[num - 1]); 這行注釋去掉再運行,發現注釋處輸出的是正確的,兩者都是輸出的flow數組元素怎么結果不一樣呢?經過調試發現是 flow[num ++] = ptr-> d_name; 這句代碼的問題,因為這是引用拷貝(地址拷貝),所有的flow元素全部指向同一個對象ptr-> d_name ,雖然ptr-> d_name 對象每次的內容不同(也就是前面正確輸出的原因),但所有內容都共享一個地址,用一個簡單的圖說明就是:
當然這個問題也比較好解決,也是比較常見的問題,用字符串拷貝或內存拷貝就行了,給flow每個元素重新申請一塊內存。
#include <stdio.h> #include <stdlib.h> #include < string .h> #include <dirent.h> #define MAX_LEN 65535 int main( void ) { DIR * dir; struct dirent * ptr; char * flow[MAX_LEN]; int num = 0 , i = 0 ; if ((dir=opendir( " ./data " )) == NULL) { perror( " Open dir error... " ); exit( 1 ); } // readdir() return next enter point of directory dir while ((ptr=readdir(dir)) != NULL) { flow[num] = ( char *)malloc( sizeof ( char )); strcpy(flow[num], ptr -> d_name); num ++ ; } for (i = 0 ; i < num; i++ ) { printf( " %s\n " , flow[i]); } closedir(dir); }
?最終結果就正確了。
二、Windows下遍歷目錄的方法
?在Windows下就比較麻煩了,所要用到的函數都在windows.h中,Windows編程本來就比較繁瑣,下面就不一一介紹所用到的函數了,直接給出封裝的過程。
1. 首先模擬Linux下自帶的頭文件dirent.h
不同的是DIR中去掉了一些不需要的屬性,及只定義了三個我所需要的操作(按需定義)。
// dirent.h
#ifndef _SYS_DIRENT_H #define _SYS_DIRENT_H typedef struct _dirdesc { int dd_fd; / * * file descriptor associated with directory */ long dd_loc; /* * offset in current buffer */ long dd_size; /* * amount of data returned by getdirentries */ char *dd_buf; /* * data buffer */ int dd_len; /* * size of data buffer */ long dd_seek; /* * magic cookie returned by getdirentries */ } DIR; # define __dirfd(dp) ((dp) -> dd_fd) DIR *opendir ( const char * ); struct dirent *readdir (DIR * ); void rewinddir (DIR * ); int closedir (DIR * ); #include <sys/types.h> struct dirent { long d_ino; /* inode number */ off_t d_off; /* offset to this dirent */ unsigned short d_reclen; /* length of this d_name */ unsigned char d_type; /* the type of d_name */ char d_name[ 1 ]; /* file name (null-terminated) */ }; #endif
?
2. 三個目錄操作函數的實現
當然這是最關鍵的部分,我不知道Linux下是怎么實現的(找了下沒找到),Windows下實現如下,主要是FindFirstFile()和FindNextFile()這兩個Windows函數,對Windows編程不精,也不好解釋什么,需要搞明白為啥這樣實現請上網搜或MSDN。
// dirent.c
#include <stdio.h> #include <windows.h> #include " dirent.h " static HANDLE hFind; DIR *opendir( const char * name) { DIR * dir; WIN32_FIND_DATA FindData; char namebuf[ 512 ]; sprintf(namebuf, " %s\\*.* " ,name); hFind = FindFirstFile(namebuf, & FindData ); if (hFind == INVALID_HANDLE_VALUE) { printf( " FindFirstFile failed (%d)\n " , GetLastError()); return 0 ; } dir = (DIR *)malloc( sizeof (DIR)); if (! dir) { printf( " DIR memory allocate fail\n " ); return 0 ; } memset(dir, 0 , sizeof (DIR)); dir ->dd_fd = 0 ; // simulate return return dir; } struct dirent *readdir(DIR * d) { int i; static struct dirent dirent; BOOL bf; WIN32_FIND_DATA FileData; if (! d) { return 0 ; } bf = FindNextFile(hFind,& FileData); // fail or end if (! bf) { return 0 ; } for (i = 0 ; i < 256 ; i++ ) { dirent.d_name[i] = FileData.cFileName[i]; if (FileData.cFileName[i] == ' \0 ' ) break ; } dirent.d_reclen = i; dirent.d_reclen = FileData.nFileSizeLow; // check there is file or directory if (FileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { dirent.d_type = 2 ; } else { dirent.d_type = 1 ; } return (& dirent); } int closedir(DIR * d) { if (!d) return - 1 ; hFind = 0 ; free(d); return 0 ; }
?
3. 使用方法
與Linux下使用一模一樣,不需要改動一句代碼就可應用,但卻發現了與Linux下自帶實現同樣的問題,即也是引用拷貝,如下。
?因為這是我們自己實現的代碼,所以字符串拷貝不是最佳解決方案,修改原實現代碼才是最好的方法,當然如果是為了可移植性,就不需要改動了,就用字符串拷貝這樣代碼到Linux下就不需要改動了。下面看如何修改原實現解決:
a. 首先定位問題,可以很明顯的知道是readdir這個函數的問題;
b. 然后找出問題根源,通過前面的分析可知問題的根源在于每次ptr->d_name使用的是同一內存地址,即ptr地址不變,而ptr是readdir返回的struct dirent指針,所以問題的根源在于readdir返回的dirent結構體地址問題,從上面代碼中可以看到static struct dirent dirent; 這句代碼,其中dirent的地址就是返回的地址,注意到dirent被定義為static,大家都知道C中static聲明的變量調用一次后地址就不變了,存在靜態存儲區,也就是每次readdir返回的地址都是不變的,但指向的內容每次都被覆寫,這就是問題所在;
c. 最后解決問題,知道問題根源后,問題就比較容易解決了,就是每次給dirent重新申請內存,看如下我的做法,注意我這里不能簡單的 struct dirent *dirent = (struct dirent *)malloc(sizeof(struct dirent) )就結束了,看前面dirent結構體定義中char d_name[1];這里我只給d_name一個內存空間,顯然不夠,所以也要給它申請內存,我這里是按需申請內存,如果定義為char d_name[256];這樣的就不需要了(一般文件名不是太長吧)。
struct dirent *readdir(DIR * d) { int i; BOOL bf; WIN32_FIND_DATA FileData; if (! d) { return 0 ; } bf =FindNextFile(hFind,& FileData); // fail or end if (! bf) { return 0 ; } struct dirent *dirent = ( struct dirent *)malloc( sizeof ( struct dirent)+ sizeof (FileData.cFileName)); for (i = 0 ; i < 256 ; i++ ) { dirent ->d_name[i] = FileData.cFileName[i]; if (FileData.cFileName[i] == ' \0 ' ) break ; } dirent ->d_reclen = i; dirent ->d_reclen = FileData.nFileSizeLow; // check there is file or directory if (FileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { dirent ->d_type = 2 ; } else { dirent ->d_type = 1 ; } return dirent; }
?最終Windows運行結果如下:
PS:不知道這里大家有沒有注意一個很小的細節,就是輸出的不同(用的是一個相同的目錄結構),Linux下輸出了當前目錄.和上層目錄..而Windows下只輸出了上層目錄..,當然這沒關系,因為我要的只是下面的文件名即可。OK,終于完成了,中間找bug花了不少時間,嘿嘿~~~
?
參考資料:
http://blog.csdn.net/lindabell/article/details/8181866
更多文章、技術交流、商務合作、聯系博主
微信掃碼或搜索:z360901061

微信掃一掃加我為好友
QQ號聯系: 360901061
您的支持是博主寫作最大的動力,如果您喜歡我的文章,感覺我的文章對您有幫助,請用微信掃描下面二維碼支持博主2元、5元、10元、20元等您想捐的金額吧,狠狠點擊下面給點支持吧,站長非常感激您!手機微信長按不能支付解決辦法:請將微信支付二維碼保存到相冊,切換到微信,然后點擊微信右上角掃一掃功能,選擇支付二維碼完成支付。
【本文對您有幫助就好】元
