首页
社区
课程
招聘
远程控制之原理和实战
2023-6-12 11:11 20988

远程控制之原理和实战

2023-6-12 11:11
20988

按理来说,本人不该发表此类专业文章,鄙人零星碎片化的开发经历,让本人斗胆向诸位网友,在远控方面做一点演示说明,谈论一点自己的认识。

程序工程代码地址:点击此处下载

程序分为两个部分,控制端和被控端,他们之间通过网络来连接和交互,其工作过程大体如下:

被控端每隔20毫秒截屏,图像经过压缩,通过tcp网络传输给控制端,控制端对接收到的视频帧实时刷新显示;被控端实时接收控制端的对屏幕的操作消息(主要是键盘的按键、鼠标的位置和动作等),并在本端模拟这些键盘鼠标操作。

控制端代码主要在RemoteControlRecver.cpp和RemoteControlListener.cpp中,被控端代码主要在RemoteControlProc.cpp和RemoteControl.cpp中。

程序是自己对远控的一点探索和demo演示,实现过程吻合本文思路,虽然离商用有很远距离,但从实际的使用效果看,已经具备了远控的基础功能和效果。

(一)原理

经典的远控,比如国外的TeamViewer、国内近几年出现的ToDesk,功能强大且精密复杂,但这并不说明它的高不可攀(远控软件,除非算法上的突破,无论理论和工程技术,恐怕都无法为设计开发者赢得博士学位),它的原理无非就是:将被控端的屏幕实时复制到主控端并保持刷新,主控端就像使用本地的屏幕一样,使用菜单、键盘输入、鼠标点击等视觉交互,主控端在该屏幕上的键盘和鼠标操作,通过网络传输给被控端,转化为被控端相应的键盘和鼠标操作

当然这是极为简略的描述,实践中要考虑很多其他因素,比如对网络流量的考虑:如果每一帧画面都是截屏数据,要保证动画的连贯逼真,每秒至少要传输24帧以上的画面,每一帧画面如果采用24位真彩色、屏幕分辨率假定是最常用的1920x1080,此时,未压缩前的大小是6220800字节,压缩后一般最少也有150-200kb左右(压缩率跟画面的像素有关,一般来讲,常见的图像压缩算法,越是像素排列无规则、相对速度运动越快、变化率越大的像素值,压缩比越低),这时每秒的带宽压力要达到80M/b(10MB)以上,有些网络环境下,这是一个恐怖的数字,实际环境可能达不到,因此,如何压缩减少视频传输流量,画面的高清显示、提高反应速度和控制的丝滑程度,是此类软件的核心技术之一。

一般来说,要实现此类软件的敏捷开发,最快捷的方式是使用第三方开发包,比如大名鼎鼎的ffmpeg,此开发包中有多种方案可以实现高效的视频传输,如h264、h265协议接口,此类接口可以将传输数据减少1到2个数量级,实际测试数据流量在每秒几百kb甚至100kb/s以下,已经可以满足实际需要,但是,这样的网络流量或者实际显示画面的综合效果并不够优秀,从测试中发现,微软自带的远程控制软件mstsc.exe,在100kb以内的网速下,画面显示依然清晰、控制依然保持流畅,这就不是第三方开发包可以轻易达到的。另外此类第三方接口中没有键盘鼠标消息的处理,很多定制化需求不能被满足,还需要对开发包进一步定制和开发。

如果对第三方开发包不太满意,那就只有自己动手手撸代码了。

(二)实现细节

魔鬼隐藏在细节中。从经验上来说,就算很简单的理论描述,工程实践中也会有很多细节需要填坑夯实,理学在前挖坑,工程学在后填坑,这也许就是工程学(比如软件工程)存在的意义吧。

下面就是对思路的细节描述。

主控端的具体代码逻辑如下:
每一个被控客户端的远程连接,控制端需要创建两个线程,一个负责与被控端网络通信,一个负责窗口显示刷新和窗口消息。
网络通信线程有两个执行节点,一个节点是执行recv函数,接收被控端的截屏数据;另一个执行节点是执行send函数,发送显示窗口的键盘鼠标消息。窗口显示和消息处理线程主要是实时刷新和显示被控端的截屏,切入点是依靠窗口的WM_PAINT消息,每当网络通信线程接收到一帧截屏后会调用InvalidateRect(参数是显示窗口的HWND句柄),此时窗口程序会执行刷新过程。此线程另外一个功能是,捕获主控端的在显示窗口中的键盘鼠标消息,并存放在全局变量中,这样网络通信线程就可以读取和发送给被控端,被控端模拟点击和键盘输入,将收到的键盘鼠标消息转换为本地的键盘鼠标操作。

另外需要注意的是,两个线程中,资源申请和释放、连接控制等主要是在显示刷新窗口线程中完成的,在主控和被控之间因各种原因断开连接时,要保证所有的资源都有效释放。

两个线程共用的客户端结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct {
    SOCKET                  hSockClient;        //被控客户端socket
    sockaddr_in             stAddrClient;       //被控地址
    HWND                    hwndWindow;         //窗口线程的窗口句柄,据此可以保存和找到该线程,并交互鼠标键盘消息
    char* lpClientBitmap;                       //被控的屏幕像素地址
    char* dibits;                               //被控像素处理内存缓冲
    int                     bufLimit;           //像素地址块分配大小
    int                     lpbmpDataSize;//像素实际大小
    int                     dataType;   //像素块的类型,有两种,一种是截屏,一种是屏幕刷新值
    UNIQUECLIENTSYMBOL      unique;     //被控的信息
    STREMOTECONTROLPARAMS   param;      //被控的屏幕宽度高度位数等显示信息
}REMOTE_CONTROL_PARAM, * LPREMOTE_CONTROL_PARAM;

被控端程序比较简单,主要是在一个循环中,获取截屏数据,发送给控制端,然后接收控制端的键盘鼠标消息,并将这些键盘鼠标消息转换为本地的键盘鼠标消息。

代码中的api采用了函数指针的调用方式,去掉前面的lp前缀就可以理解了。主要的功能模块如下:

1. 被控端截屏发送给控制端。从网上的资料来看,截屏功能的实现方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
int GetScreenFrame(int ibits, char* szScreenDCName, int left, int top, int ScrnResolutionX, int ScrnResolutionY, char* lpBuf, char** lppixel, int* pixelsize) {
 
    int iRes = 0;
 
    HWND hwnd = lpGetDesktopWindow();
 
    HDC hdc = lpGetDC(hwnd);
 
    //HDC hdc = lpCreateDCA(szScreenDCName, 0, 0, 0);
 
    //HDC hdc = lpGetDC(0);
    if (hdc == 0)
    {
        writeLog("GetScreenFrame lpCreateDCA error:%d\r\n", GetLastError());
        return FALSE;
    }
 
    HDC hdcmem = lpCreateCompatibleDC(hdc);
 
    HBITMAP hbitmap = lpCreateCompatibleBitmap(hdc, ScrnResolutionX, ScrnResolutionY);
 
    lpSelectObject(hdcmem, hbitmap);
 
    iRes = lpBitBlt(hdcmem, 0, 0, ScrnResolutionX, ScrnResolutionY, hdc, 0, 0, SRCCOPY);
 
    if (hbitmap == 0)
    {
        lpReleaseDC(0, hdc);
        lpDeleteDC(hdcmem);
        lpDeleteObject(hbitmap);
 
        writeLog("GetScreenFrame lpCreateCompatibleBitmap error:%d\r\n", GetLastError());
        return FALSE;
    }
 
    int wbitcount = 0;
    if (ibits <= 1) {
        wbitcount = 1;
    }
    else if (ibits <= 4) {
        wbitcount = 4;
    }
    else if (ibits <= 8) {
        wbitcount = 8;
    }
    else if (ibits <= 16) {
        wbitcount = 16;
    }
    else if (ibits <= 24) {
        wbitcount = 24;
    }
    else {
        wbitcount = 32;
    }
 
    DWORD dwpalettesize = 0;
    if (wbitcount <= 8)
    {
        dwpalettesize = (1 << wbitcount) * sizeof(RGBQUAD);
    }
 
    DWORD dwbmbitssize = ((ScrnResolutionX * wbitcount + 31) / 32) * 4 * ScrnResolutionY;
 
    DWORD dwBufSize = dwbmbitssize + dwpalettesize + sizeof(BITMAPINFOHEADER) + sizeof(BITMAPFILEHEADER);
 
    LPBITMAPFILEHEADER bmfhdr = (LPBITMAPFILEHEADER)lpBuf;
    bmfhdr->bfType = 0x4d42;
    bmfhdr->bfSize = dwBufSize;
    bmfhdr->bfReserved1 = 0;
    bmfhdr->bfReserved2 = 0;
    bmfhdr->bfOffBits = (DWORD)sizeof(BITMAPFILEHEADER) + (DWORD)sizeof(BITMAPINFOHEADER) + dwpalettesize;
 
    LPBITMAPINFOHEADER lpbi = (LPBITMAPINFOHEADER)(lpBuf + sizeof(BITMAPFILEHEADER));
    lpbi->biSize = sizeof(BITMAPINFOHEADER);
    lpbi->biWidth = ScrnResolutionX;
    lpbi->biHeight = ScrnResolutionY;
    lpbi->biPlanes = 1;
    lpbi->biBitCount = wbitcount;
    lpbi->biCompression = BI_RGB;
    lpbi->biSizeImage = 0;
    lpbi->biXPelsPerMeter = 0;
    lpbi->biYPelsPerMeter = 0;
    lpbi->biClrUsed = 0;
    lpbi->biClrImportant = 0;
 
    char* lpData = lpBuf + sizeof(BITMAPINFOHEADER) + sizeof(BITMAPFILEHEADER) + dwpalettesize;
 
    iRes = lpGetDIBits(hdcmem, hbitmap, 0, ScrnResolutionY, lpData, (BITMAPINFO*)lpbi, DIB_RGB_COLORS);
 
    lpDeleteDC(hdcmem);
    lpDeleteObject(hbitmap);
    lpReleaseDC(0, hdc);
 
    if (iRes == 0)
    {
        writeLog("lpGetDIBits error:%d\r\n", GetLastError());
        return FALSE;
    }
 
    *lppixel = lpData;
    *pixelsize = dwbmbitssize;
    return dwBufSize;
}

上面有几点需要啰嗦几句:
(1) windows上gdi二维图像api都是用DC句柄来实现的。测试发现,GetDC(0)等同于CreateDC("display",0, 0, 0),也等同于GetDC(GetDesktopWindow()),这几种用法都是用来获取桌面的DC。

(2) CreateCompatibleBitmap函数中的HDC要使用桌面的HDC,而不能是新创建的内存hdcmem,这是一个隐蔽的知识盲点,microsoft的解释如下:
图片描述
大意是,CreateCompatibleBitmap产生的hBitmap位图中的位数和颜色跟使用的hdc参数中的保持一致,而使用CreateCompatibleDC函数创建的HDC默认都是2位的位图。

(3) GetDIBits函数有文档中未指明的知识盲点。比如lpbi参数指向的BITMAPINFO,在8位256色颜色模式下,要给调色板留下空间,调色板一般需要另外的1024字节大小的空间,否则调用此api会发生内存越界异常。另外,此函数如果不知道如何填写BITMAPINFO位图参数,可以在第一次调用时,lpData参数为空,调用后,函数会自动填充BITMAPINFO结构的参数,然后第二次调用此函数,即可得到相应参数的位图数据。

BITMAPINFO结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct tagBITMAPINFOHEADER{
        DWORD      biSize;
        LONG       biWidth;
        LONG       biHeight;
        WORD       biPlanes;
        WORD       biBitCount;
        DWORD      biCompression;
        DWORD      biSizeImage;
        LONG       biXPelsPerMeter;
        LONG       biYPelsPerMeter;
        DWORD      biClrUsed;
        DWORD      biClrImportant;
} BITMAPINFOHEADER, FAR *LPBITMAPINFOHEADER, *PBITMAPINFOHEADER;
 
typedef struct tagBITMAPINFO {
    BITMAPINFOHEADER    bmiHeader;
    RGBQUAD             bmiColors[1];
} BITMAPINFO, FAR *LPBITMAPINFO, *PBITMAPINFO;

该函数的官方文档如下:
图片描述
注意这里的描述,如果lpvBits参数有效,那么前6个参数必须初始化,并且扫描线的数值必须是Dword对齐。前6个参数指的是biSizeImage之前的6个参数,biSizeImage的计算比较复杂,不论位图的颜色深度是多少位,扫描线长度必须要4字节对齐。测试中还发现扫描线的行数并不需要dword对齐。

文档中说,函数调用时hbitmap参数不能被SelectObject选中,测试中发现,hbitmap即使已经被调用了SelectObject函数被选中,调用时也可以成功。

截图支持8位、16位、24位、32位颜色值,测试程序使用的16位色。从视觉效果上,16位色跟24位,观看起来区别很小,特别是24位色(32位色相对于24位色只是增加了alpha值透明度),已经超过人的眼睛对颜色的识别程度的上限,再高的颜色值已经没有意义。由于传输的是像素值,而不是跟jpeg或者其他视频流算法中使用的近似压缩值(或者近似压缩块),所以画面的清晰度是很好的,这也是相比较于ffmpeg等第三方开发包使用h264、h265压缩视频流的优势。

另外,建议查看文档的英文版,中文版好多翻译不准确或者非常不严谨,长期依赖中文翻译,会导致开发水平得不到提高。

2. 数据压缩传输。采用zip压缩,压缩参数设置为最大化压缩,压缩比估值大概是6-20倍。如果是8位的位图帧,分辨率1920x1080,一帧压缩后大约是60-120KB;如果是16位,压缩后大约为100-300KB;32位的话,大约是150-600KB。当然这样的压缩比仍然难以满足实际需求,此文在第三个话题中会详细介绍如何减少视频流量,最终可以将网络流量降低到平均100KB/S。
3. 截屏帧的显示刷新。主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
else if (mapit->second->dataType == REMOTE_CLIENT_SCREEN)
{
    char* lpClientBitmap = mapit->second->lpClientBitmap;
    HDC  hdcScr = CreateDCA("DISPLAY", NULL, NULL, NULL);
    HDC hdcSource = CreateCompatibleDC(hdcScr);
    LPBITMAPFILEHEADER pBMFH = (LPBITMAPFILEHEADER)lpClientBitmap;
    void* pDibts = (void*)(lpClientBitmap + pBMFH->bfOffBits);
    LPBITMAPINFOHEADER pBMIH = (LPBITMAPINFOHEADER)(lpClientBitmap + sizeof(BITMAPFILEHEADER));
    DWORD dwDibtsSize = ((pBMIH->biWidth * pBMIH->biBitCount + 31) / 32) * 4 * pBMIH->biHeight;
    char* pRemoteSrnData = 0;
    HBITMAP hRemoteBM = CreateDIBSection(0, (LPBITMAPINFO)pBMIH, DIB_RGB_COLORS, (void**)&pRemoteSrnData, 0, 0);
    if (hRemoteBM)
    {
        memcpy(pRemoteSrnData, pDibts, dwDibtsSize);
        HBITMAP hSrcBM = (HBITMAP)SelectObject(hdcSource, hRemoteBM);
 
        int iX = pBMIH->biWidth;
        int iY = pBMIH->biHeight;
        RECT stRect = { 0 };
        int iRet = GetClientRect(hWnd, &stRect);
        //iRet = BitBlt(hdcDst, 0, 0, stRect.right - stRect.left, stRect.bottom - stRect.top,hdcSrc, 0, 0, SRCCOPY);
        iRet = StretchBlt(hdcDst, 0, 0, stRect.right - stRect.left, stRect.bottom - stRect.top, hdcSource, 0, 0, iX, iY, SRCCOPY);
        DeleteObject(hSrcBM);
    }
    else {
        WriteLog("RemoteControl CreateDIBSection error:%u\r\n", GetLastError());
    }
 
    DeleteObject(hRemoteBM);
    DeleteDC(hdcScr);
    DeleteDC(hdcSource);
 
    mapit->second->dataType = 0;
 
    EndPaint(hWnd, &stPS);
    return TRUE;
}

代码中,lpClientBitmap指向接收到的内存中的bmp文件,调用CreateDIBSection函数是为了创建一个类似于此bmp文件格式和参数的hbitmap句柄后,然后将bmp文件的像素值拷贝到句柄指向的像素内存地址中,并使用StretchBlt将像素值显示在当前窗口中的客户区中,因为客户端和服务器的窗口大小可能不一样,所以使用StretchBlt实现缩放而不是BitBlt函数。
同时要注意,CreateDIBSection函数第一个参数为0,0即相当于GetDC(0),代表桌面窗口的HDC,这点在官方文档中并未说明,但是可以直接使用。

4. 键盘鼠标消息。控制消息的收发和视频帧是顺序关系,而不是异步关系,被控端每发送一帧截屏后接收控制端的键盘鼠标消息,并将此消息模拟为本机的键盘鼠标操作;与此同时,控制端每接收一帧截屏后发送键盘鼠标消息。由于网络通信使用阻塞模式,此时一定要保证,被控端的先发送和后接收、控制端先接受再发送,主控和被控任何的执行分支都要分别执行这两对代码节点,否则会造成网络收发的死锁。另外,控制端如果发现,键盘鼠标的位置和动作跟上次的值相同,就会向被控端发送一个REMOTE_DUMMY_PACKET数据包,告诉被控端,控制端没有控制消息给你,你可以适当的增加截屏延时(一次增加10毫秒),以便减少网络消耗。主要的键盘鼠标结构体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
typedef struct {
    int screenx;
    int screeny;
    int bitsperpix;
}STREMOTECONTROLPARAMS, * LPSTREMOTECONTROLPARAMS;
 
typedef struct {
    POINT pos;
    POINT size;
}REMOTECONTROLMOUSEPOS, * LPREMOTECONTROLMOUSEPOS;
 
typedef struct {
    unsigned char key;
    unsigned char shiftkey;
}REMOTECONTROLKEY, * LPREMOTECONTROLKEY;
 
typedef struct {
    int delta;
    int xy;
}REMOTECONTROLWHEEL, * LPREMOTECONTROLWHEEL;
 
 
typedef struct {
    DWORD       dwType;
    POINT       stPT;
    DWORD       dwTickCnt;
    int         iDelta;
}STMOUSEACTION, * LPMOUSEACTION;

该结构由几个全局变量存放:键盘按键值,鼠标左键、中键、右键是否有点击动作,鼠标滚轮的滚动距离,鼠标的坐标位置等。控制端的窗口程序监听鼠标键盘消息,将这些消息填充为上述结构体,并由通信线程发送给被控端。

被控端通过keybd_event和mouse_event将收到的控制信息转换为本机的键盘鼠标消息。例子代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
if (dwCommand == REMOTE_MOUSE_POS)
{
    DWORD dwDataSize = lphdr->packlen;
    if (dwDataSize = sizeof(POINT) + sizeof(POINT))
    {
        LPREMOTECONTROLPOS pos = (LPREMOTECONTROLPOS)(lpBuf + sizeof(NETWORKPACKETHEADER));
        POINT stServerCurrent = pos->pos;
        POINT stPtServerMax = pos->size;
 
        int iXLocalMax = ScrnResolutionX;
        int iYLocalMax = ScrnResolutionY;
 
        POINT stPtLocalCurrent = { 0 };
        if (stPtServerMax.x != 0 && stPtServerMax.y != 0)
        {
            stPtLocalCurrent.x = (iXLocalMax * stServerCurrent.x) / stPtServerMax.x;
            stPtLocalCurrent.y = (iYLocalMax * stServerCurrent.y) / stPtServerMax.y;
            //mouse_event(MOUSEEVENTF_ABSOLUTE|MOUSEEVENTF_MOVE,stPtLocalCurrent.x,stPtLocalCurrent.y,0,0);
            lpSetCursorPos(stPtLocalCurrent.x, stPtLocalCurrent.y);
        }
    }
 
    actionInterval(&dwSleepTimeValue);
 
    //checkTime(&dwSleepTimeValue);
    continue;
}
else if (dwCommand == REMOTE_KEYBOARD)
{
    if (lphdr->packlen == 2)
    {
        LPREMOTECONTROLKEY lpkey = (LPREMOTECONTROLKEY)(lpBuf + sizeof(NETWORKPACKETHEADER));
        unsigned char key = lpkey->key;
        unsigned char keyshift = lpkey->shiftkey;
 
        //unsigned char szKeyboardState[256];
        //memmove(szKeyboardState,pData,256);
        //iRet = SetKeyboardState(pData);
 
        if (keyshift)
        {
            keybd_event(VK_SHIFT, 0, 0, 0);
            keybd_event(key, 0, 0, 0);
            keybd_event(key, 0, KEYEVENTF_KEYUP, 0);
            keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0);
        }
        else {
            keybd_event(key, 0, 0, 0);
            keybd_event(key, 0, KEYEVENTF_KEYUP, 0);
        }
    }
    actionInterval(&dwSleepTimeValue);
 
    //checkTime(&dwSleepTimeValue);
 
    continue;
}
else if (dwCommand == REMOTE_LEFTBUTTONDOWN || dwCommand == REMOTE_LEFTBUTTONDOUBLECLICK ||
    dwCommand == REMOTE_RBUTTONDOWN || dwCommand == REMOTE_RBUTTONDOUBLECLICK)
{
    DWORD dwDataSize = lphdr->packlen;
    if (dwDataSize = sizeof(POINT) + sizeof(POINT))
    {
        LPREMOTECONTROLPOS pos = (LPREMOTECONTROLPOS)(lpBuf + sizeof(NETWORKPACKETHEADER));
        POINT stServerCurrent = pos->pos;
        POINT stPtServerMax = pos->size;
 
        int iXLocalMax = ScrnResolutionX;
        int iYLocalMax = ScrnResolutionY;
 
        POINT stPtLocalCur = { 0 };
        if (stPtServerMax.x != 0 && stPtServerMax.y != 0)
        {
            stPtLocalCur.x = (iXLocalMax * stServerCurrent.x) / stPtServerMax.x;
            stPtLocalCur.y = (iYLocalMax * stServerCurrent.y) / stPtServerMax.y;
            lpSetCursorPos(stPtLocalCur.x, stPtLocalCur.y);
            if (dwCommand == REMOTE_LEFTBUTTONDOWN)
            {
                mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
                mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
            }
            else if (dwCommand == REMOTE_RBUTTONDOWN)
            {
                mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
                mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
            }
            else if (dwCommand == REMOTE_RBUTTONDOUBLECLICK)
            {
                mouse_event(MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
                lpSleep(0);
                mouse_event(MOUSEEVENTF_RIGHTDOWN | MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
            }
            else if (dwCommand == REMOTE_LEFTBUTTONDOUBLECLICK)
            {
                mouse_event(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
                lpSleep(0);
                mouse_event(MOUSEEVENTF_LEFTDOWN | MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
            }
        }
    }
 
    actionInterval(&dwSleepTimeValue);
    //checkTime(&dwSleepTimeValue);
    continue;
}
else if (dwCommand == REMOTE_MOUSEWHEEL)
{
    DWORD dwDataSize = lphdr->packlen;
    if (dwDataSize = sizeof(DWORD) + sizeof(DWORD))
    {
        LPREMOTECONTROLWHEEL wheel = (LPREMOTECONTROLWHEEL)(lpBuf + sizeof(NETWORKPACKETHEADER));
        int key = wheel->delta & 0xffff;
        int delta = wheel->delta >> 16;
        int x = wheel->xy & 0xffff;
        int y = wheel->xy & 0xffff0000;
 
        mouse_event(MOUSEEVENTF_WHEEL, x, y, delta, 0);
        lpSleep(0);
    }
 
    actionInterval(&dwSleepTimeValue);
 
    //checkTime(&dwSleepTimeValue);
    continue;
    //to action more faster not to sleep
}
else if (dwCommand == RECV_DATA_OK || dwCommand == REMOTE_DUMMY_PACKET)
{
    freeInterval(&dwSleepTimeValue);
}
else if (dwCommand == REMOTECONTROL_END)
{
    writeLog("remotecontrol shutdown by server\r\n");
    break;
}
else
{
    writeLog("RemoteControlProc unrecognized command:%u\r\n", dwCommand);
    //break;
}
 
checkTime(&dwSleepTimeValue);

(三)减少网络流量的努力

测试中发现,实际的网速有可能比想象中偏低,比如很多服务器网络带宽只有几百kb/s,上述依靠传输截屏帧的显示方式,按照40ms一帧的延时,8位位图数据帧,经过zip压缩后,网络流量可以平均减少10倍左右也就是大约1~2MB/s左右,依然无法满足实际需求,经过考虑,采用了如下几种改善措施:

  1. 客户端每次截屏后动态延时。当服务端没有鼠标键盘等控制信息后,发送延时消息REMOTE_DUMMY_PACKET给客户端,客户端每收到一个这样的数据包,截屏延时自动增加10ms,最长到2000ms为止,而收到键盘鼠标等控制消息后,延时立刻恢复到默认的REMOTE_CLIENT_SCREEN_MIN_INTERVAL值,这样可以显著的减少网络流量。
  2. 将截屏数据帧的格式由bmp转化为jpeg。bmp是原始像素值格式,jpeg是压缩后的像素格式,同时jpeg压缩算法是对每一块像素块进行压缩,经测试,在保证图像质量80%以上的条件下,其压缩比大约在10-40倍,但是这对zip压缩的优势并不明显(zip的压缩比平均在6-30倍左右),而且jpeg使用起来也较为繁琐,所以弃之未用。当然,如果改为jpeg格式的话,每一帧截屏的网络流量还能减少大约25%。
  3. 发送截屏帧前,执行屏幕像素比对操作。对每一帧截屏的所有像素值保存副本,每次截屏后跟上次的截屏数值比对,如果改变的像素值数量超过一帧像素总数的一半,就发送整个的截屏帧,否则只发送发生变化的像素值(格式是像素位置和像素的值,所有像素点在发送缓冲区中线性排列,并在zip压缩后传输);如果跟上一帧相比所有,像素点均未发生任何变化(这才是绝大多数情况),则发送延时消息。测试发现,在很短的截屏周期内,被控屏幕每一帧画面,不可能每一个像素都发生变化,改变的只有很少一小部分,甚至80%的监控周期内,整个屏幕的所有像素值未发生任何变化,此时不需要传送任何像素信息。这里还有一个常识,在windows下的截屏是没有鼠标的,因此,鼠标的点击和移动,也不会导致截屏像素点的任何改变。经测试发现,中度操作时,平均每个截屏周期,屏幕上变化的像素值,少的只有几十几百个字节,多的也只有几十kb,此种方法将发送截屏的概率下降了90%以上。当然,我觉得还有一种类似的方法,就是监听WM_PAINT消息,将重绘的像素块发送给主控端,这种方式跟像素值比对原理相同,只是实现方式不同。此段主要代码逻辑如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
if (mapit->second->dataType == REMOTE_PIXEL_PACKET)
{
    char* lpClientBitmap = mapit->second->lpClientBitmap;
    RECT rect;
    GetClientRect(hWnd, &rect);
    HDC  hdcScr = CreateDCA("DISPLAY", 0, 0, 0);
    HDC hdcSource = CreateCompatibleDC(hdcScr);
    HBITMAP hbmp = CreateCompatibleBitmap(hdcScr, mapit->second->param.screenx, mapit->second->param.screeny);
    SelectObject(hdcSource, hbmp);
    result = StretchBlt(hdcSource, 0, 0, mapit->second->param.screenx, mapit->second->param.screeny, hdcDst, 0, 0,
        rect.right - rect.left, rect.bottom - rect.top, SRCCOPY);
    if (result)
    {
        char buf[0x1000];
        LPBITMAPINFO lpbmpinfo = (LPBITMAPINFO)buf;
        memset(lpbmpinfo, 0, sizeof(BITMAPINFO));
        lpbmpinfo->bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
        lpbmpinfo->bmiHeader.biBitCount = mapit->second->param.bitsperpix;
        lpbmpinfo->bmiHeader.biPlanes = 1;
        lpbmpinfo->bmiHeader.biWidth = mapit->second->param.screenx;
        lpbmpinfo->bmiHeader.biHeight = mapit->second->param.screeny;
        //DWORD dwbmbitssize = ((lpbmpinfo->bmiHeader.biWidth * lpbmpinfo->bmiHeader.biBitCount + 31) / 32) * 4 * lpbmpinfo->bmiHeader.biHeight;
        lpbmpinfo->bmiHeader.biSizeImage = 0;
        char* data = mapit->second->dibits;
 
        result = GetDIBits(hdcSource, hbmp, 0, lpbmpinfo->bmiHeader.biHeight, data, lpbmpinfo, DIB_RGB_COLORS);
        if (result && result != ERROR_INVALID_PARAMETER)
        {
            int byteperpix = mapit->second->param.bitsperpix / 8;
            int itemsize = (sizeof(DWORD) + mapit->second->param.bitsperpix / 8);
            int cnt = mapit->second->lpbmpDataSize / itemsize;
            for (int i = 0; i < cnt; i++)
            {
                int index = itemsize * i;
                int offset = *(DWORD*)(lpClientBitmap + index);
                if (offset > mapit->second->bufLimit)
                {
                    WriteLog("pixel offset error :%u\r\n", offset);
                    break;
                }
                if (byteperpix == 4)
                {
                    DWORD color = *(DWORD*)(lpClientBitmap + index + sizeof(DWORD));
                    *(DWORD*)(data + offset) = color;
                }
                else if (byteperpix == 3)
                {
                    char* color = lpClientBitmap + index + sizeof(DWORD);
                    memcpy(data + offset, color, 3);
                }
                else if (byteperpix == 2)
                {
                    WORD color = *(WORD*)(lpClientBitmap + index + sizeof(DWORD));
                    *(WORD*)(data + offset) = color;
                }
                else if (byteperpix == 1)
                {
                    unsigned char color = *(lpClientBitmap + index + sizeof(DWORD));
                    *(data + offset) = color;
                }
            }
            result = SetDIBits(hdcSource, hbmp, 0, mapit->second->param.screeny, data, lpbmpinfo, DIB_RGB_COLORS);
            if (result)
            {
                result = StretchBlt(hdcDst, 0, 0, rect.right - rect.left, rect.bottom - rect.top, hdcSource, 0, 0,
                    mapit->second->param.screenx, mapit->second->param.screeny, SRCCOPY);
                if (result)
                {
 
                }
                else {
                    WriteLog("RemoteControl StretchBlt error:%u\r\n", GetLastError());
                }
            }
            else {
                WriteLog("RemoteControl SetDIBits error:%u\r\n", GetLastError());
            }
        }
        else {
            WriteLog("RemoteControl GetDIBits error:%u\r\n", GetLastError());
        }
 
        DeleteObject(hbmp);
        DeleteDC(hdcScr);
        DeleteDC(hdcSource);
    }
    else {
        WriteLog("RemoteControl StretchBlt error:%d\r\n", GetLastError());
    }
 
    mapit->second->dataType = 0;
 
    EndPaint(hWnd, &stPS);
    return TRUE;
}

此处第一个StretchBlt函数的作用是,将主控端显示窗口大小转化为适合被控端宽度高度的大小,原因是,被控端无法得知主控端显示窗口的大小,被控发送的像素值位置是对本窗口的偏移值,如果两边窗口大小不一致,那么被控端的像素位置值就失去了意义。此时通过StretchBlt函数转换后,就可以将像素值直接写入转换后的hbitmap,并再次调用StretchBlt函数,将客户端的窗口大小调整为主控端显示窗口的大小。此处也用到了SetDIBits和GetDIBits函数,该函数上边已经讲过了,功能比较强大,但是使用起来比较复杂。此处有个内存越界的bug,也就是像素的偏移值会大于整个截屏的像素个数总数,会导致WriteLog那一行的执行,原因应该是,两边的窗口大小不一致,若被控的窗口比较大,而主控端窗口比较小,主控端的缓冲区是按照主控窗口大小分配的,转换坐标后,有可能发生内存溢出。

像素值比对函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
int ScreenFrameChecker(char* backup, char* color, int colorlen, char* buf, int bytesperpix) {
 
    int counter = 0;
 
    if (bytesperpix == 4)
    {
        DWORD* lpback = (DWORD*)backup;
        DWORD* lpcolor = (DWORD*)color;
 
        for (int i = 0; i < colorlen / bytesperpix; i++)
        {
            if (lpback[i] != lpcolor[i]) {
 
                lpback[i] = lpcolor[i];
 
                char* lppixel = buf + counter * (sizeof(DWORD) + bytesperpix);
                *(DWORD*)lppixel = i * 4;
 
                *(DWORD*)(lppixel + sizeof(DWORD)) = lpcolor[i];
 
                counter++;
            }
        }
    }
    else if (bytesperpix == 3)
    {
        char* lpback = (char*)backup;
        char* lpcolor = (char*)color;
 
        for (int i = 0; i < colorlen / bytesperpix; i += 3)
        {
            if (lpback[i] != lpcolor[i] || lpback[i + 1] != lpcolor[i + 1] || lpback[i + 2] != lpcolor[i + 2]) {
 
                lpback[i] = lpcolor[i];
                lpback[i + 1] = lpcolor[i + 1];
                lpback[i + 2] = lpcolor[i + 2];
 
                char* lppixel = buf + counter * (sizeof(DWORD) + bytesperpix);
                *(DWORD*)lppixel = i;
 
                *(lppixel + sizeof(DWORD)) = lpcolor[i];
                *(lppixel + sizeof(DWORD) + 1) = lpcolor[i + 1];
                *(lppixel + sizeof(DWORD) + 2) = lpcolor[i + 2];
 
                counter++;
            }
        }
    }
    else if (bytesperpix == 2)
    {
        WORD* lpback = (WORD*)backup;
        WORD* lpcolor = (WORD*)color;
 
        for (int i = 0; i < colorlen / bytesperpix; i++)
        {
            if (lpback[i] != lpcolor[i]) {
 
                lpback[i] = lpcolor[i];
 
                char* lppixel = buf + counter * (sizeof(DWORD) + bytesperpix);
                *(DWORD*)lppixel = i * 2;
 
                *(WORD*)(lppixel + sizeof(DWORD)) = lpcolor[i];
 
                counter++;
            }
        }
    }
    else if (bytesperpix == 1)
    {
        char* lpback = (char*)backup;
        char* lpcolor = (char*)color;
 
        for (int i = 0; i < colorlen / bytesperpix; i++)
        {
            if (lpback[i] != lpcolor[i]) {
 
                lpback[i] = lpcolor[i];
 
                char* lppixel = buf + counter * (sizeof(DWORD) + bytesperpix);
                *(DWORD*)lppixel = i;
 
                *(char*)(lppixel + sizeof(DWORD)) = lpcolor[i];
 
                counter++;
            }
        }
    }
    else {
        return FALSE;
    }
 
    return counter;
 
}

ScreenFrameChecker函数分8位、16位、24位、32位4种颜色深度值,跟上一帧比对截屏像素值,返回发生变化的像素值个数。为了加快速度,也可以按照每种规则,比如每次8个字节比对,或者使用simd指令优化,每次比对16字节。

(四)不足和缺陷

  1. 实例代码中未实现主控端和被控端的剪贴板的共享,以及文件复制粘贴操作
  2. 使用截屏方式抓取屏幕内容,然后压缩通过网络发送,这种机制太慢了。通过QueryPerformanceCounter高精度时钟测试,一帧截屏时间开销大概率在20-40毫秒之间,加上解压和网络传输的时间损耗,这样的一帧画面时间消耗可能超过50毫秒甚至100毫秒。这样的速度对应于每秒20帧以上的速率来说,将是一个致命缺陷,无法达到设计要求,这可能是因为线程切换或者API接口本身的问题,最好能将一帧抓屏时间消耗减少到10毫秒以内。
  3. 压缩算法使用zip,zip本身时间消耗非常少,测试几百kb的数据、压缩率最高时平均时间消耗在5毫秒左右,zip压缩是无损压缩,图像数据并不需要无损压缩,假如有其他合适的压缩算法,有较小的像素损失和较好的时间消耗,可能会有几毫秒的效率提高,但是不能算一个核心问题
  4. 其他很多细节处理不足。

[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

最后于 2023-6-26 18:16 被satadrover编辑 ,原因:
收藏
点赞17
打赏
分享
最新回复 (25)
雪    币: 5163
活跃值: (3250)
能力值: ( LV2,RANK:15 )
在线值:
发帖
回帖
粉丝
樂樂 2023-6-12 21:39
2
0
感谢大佬分享 学习一下
雪    币: 1915
活跃值: (1902)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
xuezhimeng 2023-6-13 08:37
3
0
DC方式截图在win10 win11下很慢,win7也慢,可以设置下变快。
雪    币: 19410
活跃值: (29069)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
秋狝 2023-6-13 09:48
4
1
感谢分享
雪    币: 2939
活跃值: (1968)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
vcdemon 2023-6-13 09:51
5
0
一直有个疑问。都是远控,为什么灰鸽子之类的会被杀,而向日葵这些却没事呢?
雪    币: 223
活跃值: (80)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
OVERLAND 2023-6-13 10:29
6
0
vcdemon 一直有个疑问。都是远控,为什么灰鸽子之类的会被杀,而向日葵这些却没事呢?
代码中的特征被杀软标记了。
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-13 10:48
7
0
xuezhimeng DC方式截图在win10 win11下很慢,win7也慢,可以设置下变快。
能指教一下该怎么设置吗?
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-13 10:49
8
0
vcdemon 一直有个疑问。都是远控,为什么灰鸽子之类的会被杀,而向日葵这些却没事呢?
杀毒软件有一个对软件的认证机制,提交给杀毒认证过的就不杀了
雪    币: 8000
活跃值: (2632)
能力值: ( LV9,RANK:180 )
在线值:
发帖
回帖
粉丝
layerfsd 4 2023-6-13 14:19
9
1

这个文章里,很多描述都是错的或者带误导性的。我挑两个:

1、从测试中发现,微软自带的远程控制软件mstsc.exe,在100kb以内的网速下,画面显示依然清晰、控制依然保持流畅,这就不是第三方开发包可以轻易达到的。

建议作者去看看freerdp这个github仓库,里面有rdp协议的具体描述,包括引用于msdn的内容。

2、采用截图发送的方式,这应该是上个世纪九十年代的做法,即使开源的木马,都有一些基于差分方式获取前后两帧截图差异,然后传输差异的方式,来简单减少流量,知名的“黑洞”在这一块基本做到了极致。同时代的rdp协议,没remoterfx(win7)和提供dxgi接口之前,性能没有比这种远程工具要好,即使默认使用mirror驱动,因为rdp工作在虚拟显卡下;


当然还有一些常识性的误导,“远控软件,除非算法上的突破,无论理论和工程技术,恐怕都无法为设计开发者赢得博士学位”,如果要实现一个商业级别的远控工具,我只举windows系统做例子,你要解决如下问题:

1、最基础的,屏幕的差分获取和编码,不管是通过系统接口(dxgi、gdi+,mirror),当然现在很多方案都基于webrtc魔改,但一上了2k和4k,这就不是只抄代码能抄来的,涉及到太多的优化;例如延时要求在30ms下;

2、网络传输架构,这不是多布几个机房就能解决的,当然机房不够,一定是不行;

还有其他很多工程上和技术上需要解决的问题,你真的能实现一个商业级别的远控工具,水几篇博士论文,一点问题都没有。



最后于 2023-6-13 14:34 被layerfsd编辑 ,原因: mei
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-13 15:07
10
0
layerfsd 这个文章里,很多描述都是错的或者带误导性的。我挑两个:1、从测试中发现,微软自带的远程控制软件mstsc.exe,在100kb以内的网速下,画面显示依然清晰、控制依然保持流畅,这就不是第三方开发包可以 ...
多谢指点
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-13 15:35
11
0
layerfsd 这个文章里,很多描述都是错的或者带误导性的。我挑两个:1、从测试中发现,微软自带的远程控制软件mstsc.exe,在100kb以内的网速下,画面显示依然清晰、控制依然保持流畅,这就不是第三方开发包可以 ...
mstsc网速低时表现确实可以,可能是我测试不充分。
雪    币: 487
活跃值: (400)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
zhusg 2023-6-13 22:57
12
0
不错,值得借鉴,要是网络部分用UDP传输就更好了
雪    币: 1915
活跃值: (1902)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
xuezhimeng 2023-6-14 08:23
13
0
satadrover 能指教一下该怎么设置吗?
DwmEnableComposition  不过在win10 win11下没效果的。
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-14 09:39
14
0
zhusg 不错,值得借鉴,要是网络部分用UDP传输就更好了
thanks
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-14 09:39
15
0
xuezhimeng DwmEnableComposition 不过在win10 win11下没效果的。
雪    币: 40
活跃值: (89)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
CCDmichael 2023-6-14 11:48
16
0
看看各大云计算厂家的VDI云桌面图像传输协议 ,比如阿里云用的是思杰的云桌面,用的是VDP协议,图像传输不仅仅是压缩差异对比了,还是智能识别桌面图像哪些是文字,哪些是图像,用的压缩算法也不一样,办公场景,微软rdp和云桌面vdp一般有256KB就足够了。
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-14 14:04
17
0
CCDmichael 看看各大云计算厂家的VDI云桌面图像传输协议 ,比如阿里云用的是思杰的云桌面,用的是VDP协议,图像传输不仅仅是压缩差异对比了,还是智能识别桌面图像哪些是文字,哪些是图像,用的压缩算法也不一样,办公场 ...
雪    币: 5249
活跃值: (1807)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
wanttobeno 2023-6-17 08:52
18
0
(GPT)常见的远程桌面技术协议有以下几种:

RDP协议:RDP是Remote Desktop Protocol(远程桌面协议)的缩写,是微软开发的一种远程桌面协议,常用于Windows系统下的远程桌面访问。

VNC协议:VNC是Virtual Network Computing(虚拟网络计算)的缩写,也是一种远程桌面协议,跨平台支持较好。

SPICE协议:SPICE是Simple Protocol for Independent Computing Environments(独立计算环境的简单协议)的缩写,是用于虚拟化环境的远程协议。

Citrix协议:Citrix是一种商用的远程桌面协议,主要用于虚拟化环境以及企业级应用。
雪    币: 128
活跃值: (2132)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
powerpcer 2023-6-18 06:00
19
0
网速低时表现确实可以,主要是看你希望的FRAME RATE 是多少,不整DESKTOP 傳,傳差異部分,在FRAME RATE 低的情況下,CPU 不吃太多。
但若想看影片。就有問題了,這東西跟用什麼PROTOCOL 無關,主要是
你找出差異的方式有關,mstsc.exe 好,又不吃CPU,主因為是它有mirror driver當底,而不是GDI+ , DXGI 這類笨方式。
其實解決了mirror driver, 你走了7成的路了。

雪    币: 1114
活跃值: (379)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
ranshu 2023-6-18 17:40
20
0
感谢分享。
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-18 18:35
21
0
powerpcer 网速低时表现确实可以,主要是看你希望的FRAME RATE 是多少,不整DESKTOP 傳,傳差異部分,在FRAME RATE 低的情況下,CPU 不吃太多。 但若想看影片。就有問題了,這東西跟用什 ...
多谢指教
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
mb_qumgoffv 2023-6-24 20:08
22
0
雪    币: 222
活跃值: (260)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
fgfxf 2023-6-25 14:33
23
0

被控端截图bitmap -> bmp  ->  avi -> mp4编码 -> tcp/ip传输  -> mp4解码  -> bmp  ->控制端显示出来
我在改版的大灰狼里见过这种方式,远控的作者起了个名字叫“家庭娱乐影音算法”

最后于 2023-6-25 14:35 被fgfxf编辑 ,原因:
雪    币: 222
活跃值: (260)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
fgfxf 2023-6-25 14:34
24
0
作者的文章虽然有不足,但也是技术含量很高,很不错的学习资料。
雪    币: 2478
活跃值: (3002)
能力值: ( LV5,RANK:70 )
在线值:
发帖
回帖
粉丝
satadrover 2023-6-25 16:55
25
0
fgfxf 作者的文章虽然有不足,但也是技术含量很高,很不错的学习资料。
多谢夸奖,愿共同进步。
游客
登录 | 注册 方可回帖
返回