Simple-char
本篇是linux下一個簡單的字符設備驅動,一起來動手吧。下面的話,老鳥可以跳過了直接從《需求描述》章節看起,新手可以試着看看。
前言
在嵌入式行業,有很多從業者。我們工作的主旋律是拿開源代碼,拿廠家代碼,完成產品的功能,提升產品的性能,進而解決各種各樣的問題。或者是維護一個模塊或方向,一搞就是好幾年。
時間長了,中年潤髮現我們對從零開始編寫驅動、應用、算法、系統、協議、文件系統等缺乏經驗。沒有該有的廣度和深度。中年潤也是這樣,工作了很多年,都是針對某個問題點修修補補或者某個模塊的局部刪刪改改。很少有機會去獨自從零開始編寫一整套完整的代碼。
當然,這種現狀對於企業來說是比較正常的,可以降低風險。但是對於員工本身,如果缺乏必要的規劃,很容易工作多年卻還是停留在單點的層面,而喪失了提升到較高層面的機會。隨着時間的增長很容易喪失競爭力。
另外,根據中年潤的經驗,絕大多數公司對於0-5年經驗從業者的定位主要是積極的問題解決者。而對於5-10經驗從業者的定位主要是積極的系統規劃者和引領者。在這種行業規則下,中年潤認爲,每個從業者都應該問自己一句,“5年後,我是否具備系統化把控軟件的能力呢?”。
當前的這種行業現狀,如果我們不做出一點改變,是沒有辦法突破的。有些東西,僅僅知道是不夠的,還需要深思熟慮的思考和必要的訓練,簡單來說就是要知行合一。
也許有讀者會有疑惑?這不就是重複造輪子麼?我們確實是在重複造輪子,因爲別人會造輪子那是別人的能力,我們自己會造輪子是我們自己的能力。在行業中,有太多的定製化需求是因爲輪子本身有原生性缺陷,我們無法直接使用,或者需要對其進行改進,或者需要抽取開源代碼的主體思想和框架,根據公司的需要定製自己的各項功能。設想,如果我們具備這種能力,必然會促使我們在行業中脫穎而出,而不是工作很多年一直在底層搬磚。底層搬磚沒什麼不好,問題是當有更廉價更激情的勞動力湧進來的時候,我們這些老的搬磚民工也就失去了價值。我們不會天天重複造輪子,我們需要通過造幾個輪子使得自己具備造輪子的能力,從而更好的適應這個環境,適應這個世界。
針對當前行業現狀,中年潤經過深思熟慮,想爲大家做點實實在在的事情,希望能夠幫助大家在鞏固基礎的同時提升系統化把控軟件的能力。當然,中年潤的水平也有限,有些觀點也只是一家之談,希望大家獨立思考,謹慎採用,如果寫的有錯誤或者不對的地方還請讀者們批評斧正,我們一起共同進步。
在這裏簡單介紹下中年潤,中年潤現在就職於一家大型國際化公司,工作經驗6年,碩士畢業。曾經擔任過組內的項目主管,項目經理,也曾經組建過新團隊,帶領大家衝鋒陷陣。在工作中,有做的不錯的地方,也有失誤的地方,有激情的時刻,也有失落的時刻。現在偏安一隅,專心搞技術,目前個人規劃的技術方向是嵌入式和AI基礎設施建設,以及嵌入式和AI的融合發展。
最後,說了這麼多,中年潤希望,在未來的日子裏和未知的領域裏,你我同行,爲我們的美好生活而努力奮鬥。
總體目標
本篇文章的目標是介紹如何從自頂向下從零編寫linux下的簡單的字符設備驅動。着力從總體思路,需求端,分析端,實現端,詳盡描述一個完整需求的開發流程,是中年潤多年經驗的提煉,希望讀者能夠有所收穫。最後的實戰目標,請讀者儘量完成,這樣讀者才能形成自己的思路。
本示例採用arm920架構,天祥電子生產的tx2440a開發板,核心爲三星的s3c2440。Linux版本爲2.6.31,是已經移植好的版本。編譯器爲arm920t-eabi-4.1.2.tar。
總體思路
總體思路是嚴格遵循需求的開發流程來,不遺漏任何思考環節。讀者在閱讀時請先跟中年潤的思路走一遍,然後再拋棄中年潤的思路,按照自己的思路走一遍,如果遇到困難請先自己思考,實在不會再來參考中年潤的思路和實現。
中年潤在寫代碼的的總體思路如下:
需求描述—能夠詳細完整的描述一個需求。
需求分析—根據需求描述,提取可供實現的功能,需要有定量或者定性的指標。(從宏觀上確定需要什麼功能)。
需求分解—根據需求分析,考慮要實現需求所需要做的工作(根據宏觀確定的功能,拆分成小的可單獨實現的功能)。
編寫思路—根據需求分解從總體上描述應該如何編寫代碼,(解決怎麼在宏觀上實現)。
詳細步驟—根據編寫思路,落實具體步驟,(解決怎麼在微觀上實現)。
編寫框架—根據編寫思路,實現總體框架(實現編寫思路里主體框架,細節內容留在具體代碼裏編寫)。
具體代碼—根據編寫框架和詳細步驟,編寫每一個函數裏所需要實現的小功能,主要是實現驅動代碼,測試代碼。
Makefile—用來編譯驅動代碼。
目錄結構—用來說明當完成編碼後的結果。
測試步驟—說明如何對驅動進行測試,主要是加載驅動模塊,執行測試代碼。
執行結果—觀察執行結果是否符合預期。
結果總結—回顧本節的思路,知識點,api,結構體。
實戰目標—說明如何根據本文檔訓練。
請大家儘量按照自頂向下的學習思路來學習和實戰,因爲我們所有工作的動力都是我們心中的需求。這些步驟僅僅是我們達到目標所要走過的路。目錄起到提綱挈領的重要作用,寫的時候要實時看下提綱,看有沒有偏離自己的方向。
需求描述
使用linux提供的字符設備api接口,編寫一個簡單的字符設備驅動,能夠在加載和卸載模塊時打印一句話,能夠在打開和關閉該字符設備時打印一句話,能夠在讀取和寫入該設備時打印一句話。試圖給初學linux下驅動編程的人們一種直觀的感受。
需求分析
根據《需求描述》,從宏觀上提取可供實現的功能,我們需要做以下幾件工作。
1需要使用linux提供的字符設備api。
2需要能夠加載模塊和卸載模塊。
3需要能打開、關閉、讀、寫一個字符設備。
4需要能在上述所有動作里加一句打印語句。
需求分解
根據《需求分析》的結果,將宏觀確定的功能拆分成小的可單獨實現的功能,我們需要做以下幾件工作。
1需要有跟加載動作配套的函數。
2需要有跟卸載動作配套的函數。
3需要有一個配套的字符設備。
4需要有跟字符配套的打開、關閉、讀、寫函數。
5需要一個能打印語句的函數。
編寫思路
編寫思路主要用來搭建代碼框架,解決在宏觀上如何用代碼實現驅動的功能。
0搭建基礎框架
0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE
編寫跟加載動作配套的入口函數,在入口函數中所做的工作如下
1入口函數
1.1註冊一個字符設備
1.1.1定義file_operations結構體
1.1.2編寫打開,關閉,讀,寫操作函數
1.2創建一個類
1.3創建一個設備節點
編寫跟卸載動作配套的出口函數,在出口函數中所作的工作如下
2出口函數
2.1卸載字符設備
2.2刪除設備節點
2.3銷燬這個類
詳細步驟
詳細步驟主要用來在代碼框架裏填充必要的細節代碼,解決在微觀上如何用代碼實現驅動各個小功能。
0搭建基礎框架
0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE
編寫跟加載動作配套的入口函數,在入口函數中所做的工作如下
1入口函數
1.1註冊一個字符設備
1.1.1定義file_operations結構體
1.1.2編寫操作函數
1.2創建一個類
1.3創建一個設備節點
編寫跟卸載動作配套的出口函數,在出口函數中所作的工作如下
2出口函數
2.1卸載字符設備
2.2刪除設備節點
2.3銷燬這個類
編寫操作函數
3編寫file_operations裏的操作函數
3.1編寫打開函數
3.2編寫關閉函數
3.3編寫讀函數
3.4編寫寫函數
3.5編寫ioctl函數
編寫框架
根據編寫思路,實現總體框架(實現編寫思路里主體框架,細節內容留在具體代碼裏編寫)。
/* 本文件名字爲simple_char_skel.c*/
/* 本文件是依照simple_char 驅動<編寫思路>章節編寫,本文件
* 的目的是編寫代碼框架,不做具體細節的編寫
*/
/* 本頭文件是linux2.6.31內核所提供的,其他版本按需調整 */
/* 0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/irq.h>
#include <mach/regs-gpio.h>
#include <mach/hardware.h>
#include <linux/device.h>
#include <linux/gpio.h>
#define DEVICE_NAME "simple_char"
static int simple_char_major = 0;
static struct class *simple_char_class;
static int simple_char_open(struct inode *inode, struct file *file)
{
return 0;
}
static int simple_char_release(struct inode *inode, struct file *file)
{
return 0;
}
static int simple_char_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
return 0;
}
static int simple_char_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
return 0;
}
static int simple_char_ioctl( struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg)
{
return 0;
}
/* 1.1.1定義file_operations結構體 */
static struct file_operations simple_char_fops =
{
.owner = THIS_MODULE,
.open = simple_char_open,
.release = simple_char_release,
.read = simple_char_read,
.write = simple_char_write,
.ioctl = simple_char_ioctl,
};
/* 1入口函數 */
static int __init simple_char_init(void)
{
/* 1.1註冊一個字符設備 */
simple_char_major = register_chrdev(0, DEVICE_NAME, &simple_char_fops);
if (simple_char_major < 0)
{
printk(DEVICE_NAME " can't register major number\n");
return simple_char_major;
}
/* 1.2創建一個類 */
/* 方便在/dev 目錄下面建立設備節點*/
simple_char_class = class_create(THIS_MODULE, DEVICE_NAME);
if(IS_ERR(simple_char_class))
{
printk("class_create failed\n");
return -1;
}
/* 1.3創建一個設備節點 */
/* 節點名爲DEVICE_NAME */
device_create(simple_char_class, NULL, MKDEV(simple_char_major, 0), NULL, DEVICE_NAME);
printk(DEVICE_NAME" init ok, major = %d\n",simple_char_major);
return 0;
}
/* 2出口函數 */
static void __exit simple_char_exit(void)
{
printk(DEVICE_NAME"exit ok\n");
/* 2.1卸載字符設備 */
unregister_chrdev(simple_char_major, DEVICE_NAME);
/* 2.2刪除設備節點 */
device_destroy(simple_char_class, MKDEV(simple_char_major, 0));
/* 2.3銷燬這個類 */
class_destroy(simple_char_class);
}
/* 0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE */
module_init(simple_char_init);
module_exit(simple_char_exit);
MODULE_LICENSE("GPL");
驅動代碼
根據《編寫框架》《詳細步驟》章節,編寫每一個函數裏所需要實現的小功能。
/* 本文件名字爲simple_char.c*/
/* 本文件是依照simple_char 驅動<詳細步驟>章節編寫,本文件
* 的目的是編寫具體代碼,不介紹框架
*/
/* 本頭文件是linux2.6.31內核所提供的,其他版本按需調整 */
/* 0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE */
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/init.h>
#include <linux/delay.h>
#include <asm/irq.h>
#include <mach/regs-gpio.h>
#include <mach/hardware.h>
#include <linux/device.h>
#include <linux/gpio.h>
#define PRINTK_DEBUG 0
#define DEVICE_NAME "simple_char"
static int simple_char_major = 0;
static struct class *simple_char_class;
/* 3.1編寫打開函數 */
static int simple_char_open(struct inode *inode, struct file *file)
{
printk("simple_char_open called\n");
return 0;
}
/* 3.2編寫關閉函數 */
static int simple_char_release(struct inode *inode, struct file *file)
{
printk("simple_char_release called\n");
return 0;
}
/* 3.3編寫讀函數 */
static int simple_char_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
printk("simple_char_read called\n");
return 10;
}
/* 3.4編寫寫函數 */
static int simple_char_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
#if PRINTK_DEBUG
msleep(1);
#endif
printk("simple_char_write called\n");
#if PRINTK_DEBUG
msleep(1);
#endif
return 10;
}
/* 3.5編寫ioctl函數 */
static int simple_char_ioctl( struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg)
{
printk("simple_char_ioctl called\n");
return 0;
}
/* 1.1.1定義file_operations結構體 */
static struct file_operations simple_char_fops =
{
/* 3編寫file_operations裏的操作函數 */
.owner = THIS_MODULE,
.open = simple_char_open,
.release = simple_char_release,
.read = simple_char_read,
.write = simple_char_write,
.ioctl = simple_char_ioctl,
};
/* 1入口函數 */
static int __init simple_char_init(void)
{
/* 1.1註冊一個字符設備 */
simple_char_major = register_chrdev(0, DEVICE_NAME, &simple_char_fops);
if (simple_char_major < 0)
{
printk(DEVICE_NAME " can't register major number\n");
return simple_char_major;
}
/* 1.2創建一個類 */
/* 方便在/dev 目錄下面建立設備節點*/
simple_char_class = class_create(THIS_MODULE, DEVICE_NAME);
if(IS_ERR(simple_char_class))
{
printk("class_create failed\n");
return -1;
}
/* 1.3創建一個設備節點 */
/* 節點名爲DEVICE_NAME */
device_create(simple_char_class, NULL, MKDEV(simple_char_major, 0), NULL, DEVICE_NAME);
printk(DEVICE_NAME" init ok, major = %d\n",simple_char_major);
return 0;
}
/* 2出口函數 */
static void __exit simple_char_exit(void)
{
printk(DEVICE_NAME" exit ok\n");
/* 2.1卸載字符設備 */
unregister_chrdev(simple_char_major, DEVICE_NAME);
/* 2.2刪除設備節點 */
device_destroy(simple_char_class, MKDEV(simple_char_major, 0));
/* 2.3銷燬這個類 */
class_destroy(simple_char_class);
}
/* 0.1編寫代碼框架,頭文件,修飾出口函數,修飾入口函數,聲明LICENSE */
module_init(simple_char_init);
module_exit(simple_char_exit);
MODULE_LICENSE("GPL");
測試代碼
測試代碼編寫思路如下:
1打開/dev/simple_char文件
2讀文件
3寫文件
4關閉文件
測試代碼如下:
/* 本文件是simple_char_test.c,是根據simple-char驅動的
* <測試代碼>章節編寫,主要任務是用來測試
* simple_char 驅動
*/
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define DELAY_1000_US 0
int main(int argc, char **argv)
{
int fd = -1;
int ret = -1;
char buf[10] = {0};
fd = open("/dev/simple_char", O_RDWR,666);
if (fd < 0) {
printf("open device,%d\n",__LINE__);
goto errout;
}
ret = read(fd,buf,sizeof(buf));
if (ret == 10) {
printf("read succeed \n");
#if DELAY_1000_US
usleep(1000);
#endif
} else {
printf("read device failed at %d\n",__LINE__);
goto errout;
}
ret = write(fd,buf,sizeof(buf));
if (ret == 10) {
printf("write succeed \n");
#if DELAY_1000_US
usleep(1000);
#endif
} else {
printf("write device failed at %d\n",__LINE__);
goto errout;
}
ret = close(fd);
if (ret != 0) {
printf("close device failed at %d\n",__LINE__);
goto errout;
}
return 0;
errout:
return -1;
}
Makefile
KERN_DIR = /home/linux/tools/linux-2.6.31_TX2440A
all:
make -C $(KERN_DIR) M=`pwd` modules
clean:
make -C $(KERN_DIR) M=`pwd` modules clean
rm -rf modules.order
obj-m += simple_char.o
目錄結構
代碼編寫完成的目錄結構如下所示。直接執行make即可生成.ko文件。
.
├── Makefile(用來編譯驅動代碼)
├── simple_char.c(驅動代碼)
├── simple_char_skel.c(驅動框架代碼)
├── simple_char_test(測試驅動的可執行程序)
└── simple_char_test.c(驅動測試代碼)
測試步驟
0 在linux下的makefile +180行處配置好arch爲arm,cross_compile爲arm-linux-(或者arm-angstrom-linux-gnueabi-)
1 在menuconfig中配置好內核源碼的目標系統爲s3c2440
2 在pc上將驅動程序編譯生成.ko,命令:make
3 在pc上將測試程序編譯生成elf可執行文件,
編譯
arm-angstrom-linux-gnueabi-gcc -o simple_char_test simple_char_test.c
如果無法執行,修改可執行文件權限
chmod 777 simple_char_test
4 掛載nfs,這樣就可以在開發板上看到pc端的.ko文件和測試文件
mount -t nfs -o nolock,vers=2 192.168.0.105:/home/linux/nfs_root /mnt/nfs
5 insmod simple_char.ko,觀察現象
6執行./simple_char_test,觀察現象
7打開或者關閉驅動代碼和測試代碼中的宏開關,觀察現象
執行結果
加載模塊的時候打印如下語句,這句話是在驅動的init函數中的。
[root@TX2440A 00simple-char]# insmod simple_char.ko
simple_char init ok, major = 253
加載模塊後會發現在/dev/下面有一個simple_char字符設備
[root@TX2440A 00simple-char]# ls /dev/simple_char
/dev/simple_char
關閉延時開關DELAY_1000_US後的log如下
[root@TX2440A 00simple-char]# ./simple_char_test
simple_char_open called
simple_char_read called
read succeed simple_char_write called
simple_char_release called
write succeed
可以發現調用流程跟simple_char_test.c中的使用流程相同,都是open—read—write—close。只是在驅動中打印的語句和應用層打印的語句的順序有些異常。
(異常流程)驅動層open—驅動層read—應用層read成功—驅動層write—驅動層關閉—應用層write成功
(正常流程)驅動層open—驅動層read—應用層read成功—驅動層write—應用層write成功—驅動層關閉
打開延時開關DELAY_1000_US後的log如下
[root@TX2440A 00simple-char]# ./simple_char_test
simple_char_open called
simple_char_read called
read succeed
simple_char_write called
write succeed
simple_char_release called
可以發現調用流程跟simple_char_test.c中的使用流程相同,都是open—read—write—close,而且是正常流程。
(正常流程)驅動層open—驅動層read—應用層read成功—驅動層write—應用層write成功—驅動層關閉
問題:爲什麼會出現異常流程呢?
請看《問題彙總》章節
卸載模塊時打印如下所示,這句話是在驅動的exit函數中的。
[root@TX2440A 00simple-char]# rmmod simple_char
simple_char exit ok
結果總結
在本篇文章中,中年潤跟讀者分享了簡單的字符設備驅動的編寫思路和方法,其中貫穿始終的有幾個函數和關鍵數據結構,它們分別是:
struct file_operations
register_chrdev
class_create
device_create
unregister_chrdev
device_destroy
class_destroy
請讀者盡力去了解這些函數的作用,入參,返回值。
問題彙總
問題:爲什麼會出現異常流程呢?
(異常流程)驅動層open—驅動層read—應用層read成功—驅動層write—驅動層關閉—應用層write成功
(正常流程)驅動層open—驅動層read—應用層read成功—驅動層write—應用層write成功—驅動層關閉
這個測試代碼中年潤執行了很多遍,依然是如《執行結果》章節一樣,爲什麼我們先write,而應用層的write反而在驅動層關閉函數之後被打印出來呢?首先我們要回答幾個問題。
1任何打印都需要緩衝區和串口硬件的參與,都需要時間
2如果在程序執行過程中,應用層已經將打印的內容放入緩衝區,等待被打印到硬件,此時printf函數已經返回,又執行到了驅動裏的printk,那麼是先打印應用層的數據還是先打印驅動中的數據呢?
3中年潤嘗試了幾種測試方法來說明這個問題
3.1嘗試只在simple_char_test.c應用層代碼里加延時,對應DELAY_1000_US開關
加上延時後相當於讓應用層打印的速度不那麼快,不那麼快的進入下一個動作,讓硬件有充分的時間反應。打印結果如下所示
[root@TX2440A 00simple-char]# ./simple_char_test
simple_char_open called
simple_char_read called
read succeed
simple_char_write called
write succeed
simple_char_release called
流程變得正常了
3.2嘗試只在simple_char.c驅動層代碼裏的write函數里加延時(對應PRINTK_DEBUG開關),
加上延時後相當於讓驅動的打印不那麼快的返回,打印結果如下所示:
[root@TX2440A 00simple-char]# ./simple_char_test
simple_char_open called
simple_char_read called
read succeed
simple_char_write called
write succeed
simple_char_release called
綜上所述只要一方等另一方完全輸出,那麼數據的打印流程就是對的。也說明了以下幾點:
1 linux2.6.31的內核只負責將緩衝區的數據輸出而不管其順序如何,且因爲只有一個串口硬件,所以當都要輸出的時候,應該是遵循誰先到誰輸出。
2所以,如果printf("write succeed \n");中的字符在printk("simple_char_write called\n");這句話之後到達硬件,則應該按照到達的先後順序輸出。
3因爲本篇文章的主要目的是講解linux下的簡單字符設備驅動,因此,上述問題在這裏不過分深究。關鍵還是中年潤精力有限。上述問題中年潤暫且記下,等到中年潤寫uart驅動的時候我們回過頭來再詳細分析。到那個時候,中年潤勢必要把printf、printk,linux下的tty、線路規程、串口驅動、uart驅動徹底給大家分析透徹。
實戰目標
1請讀者根據《需求描述》章節,獨立編寫需求分析和需求分解。
2請讀者根據需求分析和需求分解,獨立編寫編寫思路和詳細步驟。
3請讀者根據編寫思路,獨立寫出編寫框架。
4請讀者根據詳細步驟,獨立編寫驅動代碼和測試代碼。
5請讀者根據《Makefile》章節,獨立編寫Makefile。
6請讀者根據《測試步驟》章節,獨立進行測試。
7請讀者拋開上述練習,自頂向下從零開始再編寫一遍驅動代碼,測試代碼,makefile
8如果無法獨立寫出7,請重複練習1-6,直到能獨立寫出7。
參考資料
《linux設備驅動開發祥解》
《TX2440開發手冊及代碼》
《韋東山嵌入式教程》
《魚樹驅動筆記》
《s3c2440a》芯片手冊英文版和中文版
致謝
感謝在嵌入式領域深耕多年的前輩,感謝中年潤的家人,感謝讀者。沒有前輩們的開拓,我輩也不能站在巨人的肩膀上看世界;沒有家人的鼎力支持,我們也不能集中精力完成自己的工作;沒有讀者的關注和支持,我們也沒有充足的動力來編寫和完善文章。看完中年潤的文章,希望讀者學到的不僅僅是如何編寫代碼,更進一步能夠學到一種思路和一種方法。
爲了省去驅動開發者蒐集各種資料來寫驅動,中年潤後續有計劃按照本模板編寫linux下的常見驅動,敬請讀者關注。
聯繫方式
微信羣:見文章最底部,因微信羣有效期只有7天,感興趣的同學可以加下。微信羣裏主要是爲初學者答疑解惑,也可以進行技術和非技術的交流。如果微信羣失效了,大家可以加qq羣,我會在qq羣裏分享微信羣的二維碼。同時也歡迎和中年潤志同道合的中年人尤其是中年碼農的加入。
微信訂閱號:自頂向下學嵌入式
公衆號微信:EmbeddedAIOT
CSDN博客:chichi123137
CSDN博客網址:https://blog.csdn.net/chichi123137
QQ郵箱:[email protected]
QQ羣:766756075
更多原創文章請關注微信公衆號。另外,中年潤還代理銷售韋東山老師的視頻教程,歡迎讀者諮詢。在中年潤這裏購買了韋東山老師的視頻教程,除了能得到韋東山官方的技術支持外,還能獲得中年潤細緻入微的技術和非技術的支持和幫助。歡迎大家選購哦。