一、参考资料
“微软开发者文档中心”有详细RDP文档。如果嫌在线看麻烦,可在“Protocols”下载微软打包好的zip。
[MS-RDPBCGR]: Remote Desktop Protocol: Basic Connectivity and Graphics Remoting。要没意外,它应该是第一篇要看的RDP文档。
[MS-CSSP]: Credential Security Support Provider (CredSSP) Protocol。为了安全,RDP使用的是SSL增强版协议:CredSSP。图1指出了CredSSP何时被使用。
[MS-RDPECLIP]: Remote Desktop Protocol: Clipboard Virtual Channel Extensio。RDP以着静态虚拟通道方法扩展出更多功能,剪贴板可说是必须支持的扩展通道(通道名:cliprdr)。
二、顶层逻辑
可把整个client归为四个步骤。一是解析命令行参数,二是连接server、启动后台线程,三是不断运行、直到核心线程结束,四是退出app。
2.1 解析命令行参数
int freerdp_client_settings_parse_command_line(rdpSettings* settings, int argc, char** argv, BOOL allowUnknown);
freerdp_client_settings_parse_command_line负责从argc、argv解析命令行参数,结果存储在rdp_settings类型的变量context->settings。
/u:macbookpro15.2 /p:000000 /v:192.168.1.102
以上是三个命令行必须给出的参数。/u、/p分别指示了要登陆到server的账号和密码,/v是server的IP地址。经过解析,形成的settings是以下样子。
struct rdp_settings { UINT32 ServerPort: 3389 char* ServerHostname: 192.168.1.102 char* Username: macbookpro15.2 char* Password: 000000 ... char* HomePath: C:\Users\ancientcc char* ConfigPath: C:\Users\ancientcc\AppData\Roaming\freerdp };
由于命令行没给出RDP端口,用默认的3389。
2.2 连接server、启动后台线程
int freerdp_client_start(rdpContext* context)
它会调用wfreerdp_client_start,后者主要执行3个任务。
- 调用RegisterClassEx注册Windows窗口。
- 创建并运行键盘输入处理线程:wf_keyboard_thread,句柄赋给keyboardThread成员变量。
- 创建并运行client核心处理线程:wf_client_thread,句柄赋给thread成员变量。它的运行过程基本就是client的整个生命周期。
为什么FreeRDP没把主线程作为核心线程?猜测是和3389端口用阻塞式读写有关。当使用阻塞式,并且不能保证网络质量一直很好,就会导致主线程阻塞,从而造成界面上非常不好的用户体验。
2.3 不断等待,直到核心线程wf_client_thread退出
核心线程调用freerdp_connect连接server后,就进入while循环。一旦退出while循环,意味着线程结束。
while (1) { DWORD tmp = freerdp_get_event_handles(context, &handles[nCount], 64 - nCount); ... if (MsgWaitForMultipleObjects(nCount, handles, FALSE, 1000, QS_ALLINPUT) == WAIT_FAILED) { break; } if (!freerdp_check_event_handles(context)) { if (client_auto_reconnect(instance)) { break; } } quit_msg = FALSE; while (PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE)) { ... } if (quit_msg) { break; } }
freerdp_check_event_handles是主要处理函数,内部依次调用freerdp_check_fds、freerdp_channels_check_fds。
freerdp_check_fds。检查端口(主要是3389)是有否要接收的数据,有就接收并处理。数据包括图像(Server Fast-Path Update PDU发来)、虚拟通道PUD(像server执行“粘贴”产生File Contents Request的Virtual Channel),等。可能要支持好多个虚拟通道,它在收到虚拟通道PUD、提取出通道私有数据后,不是立即执行私有动作,而是把私有数据封装到一个wStream,接着创建一个wMessage,让wMessage,wParam指向这个wStream,最后调用MessageQueue_Dispatch投递到该通道的消息队列(剪贴板是rdpdrPlugin.queue)。至于后绪私有处理,每个通道有个专门线程(剪贴板是rdpdr_virtual_channel_client_thread),它从消息队列取出消息,并处理。
虚拟通道线程不是在收到server发来的MCS Connect Response PDU with GCC后就立即创建,而是要等到连接完成后的freerdp_channels_post_connect。
不论是在wf_client_thread内处理,还是通道专门线程内处理,处理后如果须要向server发应答,像File Contents Request须要有File Contents Response,并不是立即发送,而是形成消息(wMessage),然后调用MessageQueue_Dispatch投递到消息队列channels->queue(类型:wMessageQueue)。
freerdp_channels_check_fds。从channels->queue取出消息,并逐个处理。
执行freerdp_check_event_handles处理完socket事件后,调用PeekMessage处理操作系统消息。
2.4 退出app
int freerdp_client_stop(rdpContext* context); void freerdp_client_context_free(rdpContext* context);
freerdp_client_stop断开和server连接,freerdp_client_context_free释放相关资源。
三、连接阶段
freerdp_connect会依次调用四个函数,rdp_client_connect、wf_post_connect、freerdp_channels_post_connect(创建虚拟通道线程)和update_post_connect。第一个函数rdp_client_connect处理了图1的整个过程。

图1左侧是FreeRDP中函数,rdp_client_connect用状态机管理整个连接过程。进入rdp_client_connect时的状态是CONNECTION_STATE_INITIAL。调用winpr_InitializeSSL、nego_init后进入CONNECTION_STATE_NEGO,这中间没有任何网络收发。nego_connect会创建连接向3389的会话socket,然后以着非加密TCP发送CR请求,并处理CC应答。最后发送第一个NLA请求,之后状态机进入CONNECTION_STATE_NLA。后面就是一个while循环,直到连接完成进入CONNECTION_STATE_ACTIVE。
对连向3389的连接,FreeRDP把它设为阻塞模式,并要求接收缓冲区字节数至少32K。图2是用wireshark抓取的最前几个包,client是192.168.1.100,server是192.168.1.102。

- 191:X.224 Connect Request。连接阶须发的第一个包:简称CC。server返回CR。当中数据以原值发送,不知为何wireshark把它们显示为TLSV1.2。
- 193:tls_do_handshake。SSL握手协议中Client Hello
- 195:tls_do_handshake。SSL握手协议中Client Key Exchange, Change Cipher Spec, Encrypted Handshake Message
- 197:(CredSSP)TSRequest [SPNEGO token]。CredSSP协议的第一个包。此包开始发送的数据经过SSL加密。
- 199:(CredSSP)TSRequest [SPNEGO encrypted (client hash of public key)]。
- 201:(CredSSP)TSRequest [SPNEGO encrypted (user credentials)]。
- 203:MCS Connect Initial PDU with GCC Conference Create Request
四、接收图像、发送输入事件
RDP目标是实现用户(client)和远程计算机系统(server)间交互,方法是通过将图像数据从server传输到client,并将输入事件从client传输到server,然后server“回放”这些事件。图像和输入事件就构成了RDP要传输的基础数据,这里说下它们在client是如何产生,并如何处理。输入事件分成鼠标和键盘。
4.1 图像
来自于server发来的Fast-Path Update PDU,freerdp_check_fds负责接收并处理,参考“2.3 不断等待,直到核心线程wf_client_thread退出”。处理方法是解析当中的各个矩形,然后渲染到client窗口。
4.2 鼠标
client核心线程执行“while (PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE))”,提取出鼠标相关消息,像表示鼠标移动的WM_MOUSEMOVE。根据相关参数形成Client Fast-Path Input Event PDU,并发向server。
4.3 键盘
wf_keyboard_thread键盘线程执行“while ((status = GetMessage(&msg, NULL, 0, 0)) != 0)”,提取出键盘相关消息,像表示按下按键的WM_KEYDOWN。根据相参数形成Client Fast-Path Input Event PDU,并发向server。
五、通道
通道分内置通道和虚拟通道。内置通道固定两个:I/O通道(I/O Channel)和消息通道(Message Channel),虚拟通道是有专门pdf定义的扩展通道,像剪贴板(cliprdr)、设备重定向(rdpdr: RDP Device Redirector)。
通道有两种重要属性,一是通道名称,二是通道号(也叫通道标识)。
虚拟通道才有通道名称,名称必须是ANSI,并且最长7个字符。通道名称由扩展功能内定,比如要用于剪贴板的必须叫“clipdr”,server收到名称“clipdr”的通道后,就自动把这条通道的功能设为剪贴板。
所有通道都有通道号,对client来说,所有通道号来自server。固定和虚拟通道的所有通道号来自server发来的MCS Connect Response PDU with GCC。其中I/O和虚拟通道来自Server Network Data,消息通道来自Server Message Channel Data。对通道号,除固定和虚拟通道,还存在个叫initiator的,可认为是此次MCS会话的数字标识,它来自发送Join Request前会收到的MCS Attach-User Confirm PDU。下面以图3中有三条虚拟通道场景为例,加深理解server如何知道此次要用哪些虚拟通道,以及client如何收到通道号。

在连接阶段,client向server发Client MCS Connect Initial PDU with GCC Conference Create Request,告知自个有什么功能时需提供要使用的虚拟通道信息,具体是放在Client Network Data部分。

03 c0 2c 00: header。0xc003表示头是CS_NET,后面长度是44字节。4字节header,4字节虚拟通道数,36字节是3个通道的信息。 03 00 00 00: 通道数。有3个虚拟通道。 72 64 70 64 72 00 00 00 00 00 80 c0: 第一条通道。名称rdpdr,options=0xc0800000。 72 64 70 73 6e 64 00 00 00 00 00 c0: 第二条通道。名称rdpsnd,options=0xc0000000。 63 6c 69 70 72 64 72 00 00 00 a0 c0: 第二条通道。名称cliprdr,options=0xc00a0000。
server收到MCS Connect Initial PDU with GCC Conference Create Request,回应MCS Connect Response PDU with GCC Conference Create Response,在Server Network Data部分指出了这些虚拟通道要被赋与的通道号。另外在MCSChannelId给出了I/O通道的通道号。I/O通道号可能总是1003,于是FreeRDP没有从这取值,而是直接用宏MCS_GLOBAL_CHANNEL_ID。也是在这个PUD,“Server Message Channel Data”给出了消息通道的通道号。
在client要把通道加入到此次会话前,client还须要从MCS Attach-User Confirm PDU解析出initiator通道号。有了所有通道号后,client调用Join Request PDU加入指定通道,每加一条,server回应一个Join Confirm PDU,1次initiator加5个通道就有6对Join Request/Confirm PUD。FreeRDP加入这些通道的次序:initiator、I/O通道、消息通道、虚拟通道。
虚拟通道是RDP提供的可扩展传输机制的核心,具体如何实现扩展,让分析一种常用虚拟通道:剪贴板。
六、剪贴板扩展(cliprdr)
剪贴板是双向实现。即在client复制后,可粘贴到server;同样的,在server复制后,可粘贴到client。正是这原因,[MS-RDPECLIP] Figure 2: Data transfer using the shared clipboard在描述数据传输序列时,标签用的是Shared Clipboard Owner和Local Clipboard Owner,而不是client和server。下面的示例假设在client复制,粘贴到server。
6.1 client执行“复制”

图5是复制“pixels-0”、“pixels-1”这两个文件。执行复制后,client向server发出Format List PDU。

02 00 00 00 4c 00 00 00: header。msgType: 0x0002, msgFlags: 0x0000, dataLen: 0x4c a7 c0 00 00 --> 57 00 00 00: 第一种格式。formatId=0xc0a7, name=FileGroupDescriptorW 7f c0 00 00 --> 73 00 00 00: 第二种格式。formatId=0xc07f, name=FileContents
CB_USE_LONG_FORMAT_NAMES标记影响Format List PDU如何编码格式中的name,是使用Short Format还是Long Format,“true”时使用Long Format,否则Short Fomrat。它们区别是fomrat中的name字段。Long时会传整个name,Short传固定15字符,不足15的补0,超过15的截断。Short时pdf写32字节的依据是(15 + 1) * 2。1是终止字符,2是因为unicode。目前来说,CB_USE_LONG_FORMAT_NAMES一般总是“true”。
formatId值以及name是怎么来的?它们和操作系统如何实现剪贴板密切相关,示例运行在win10,就直接来自Winapi,像RegisterClipboardFormat、EnumClipboardFormats、或GetClipboardFormatNameA。可得出结论,对同样的“File List”,Win10上是(0xc0a7, FileGroupDescriptorW),到linux、Android、iOS可能就不这个了,这就造成一个问题,双方怎么知道这是“File List”?
Within the context of the Remote Desktop Protocol: Clipboard Virtual Channel Extension, the File List format type uses the following hard-coded Clipboard Format name: "FileGroupDescriptorW". --[MS-RDPECLIP] 1.3.1.2 Clipboard Format。
RDP硬性规定是File List时,name必须是“FileGroupDescriptorW”,至于此时的id不作限制。于是FreeRDP借用了一个叫formatMapping的结构,来保存是同一格式name时,双方是什么id。

从图7可以看出,对name=FileGroupDescriptorW时,远端用的id是0xc0f7,本地用的是0xc0a7。就是这么奇怪,同样是win10,同样是FileGroupDescriptorW,电脑A得出id是0xc0a7,B还可能是0xc0f7。而且对电脑A,这次启动是0xc0a7,下次就可能变成0xc0a6。不要幻想对同一操作系统,FileGroupDescriptorW对应的formatId是不变值。
流程上说,发出Format List PDU目的是要让server知道,剪贴板可以用了,内容是文件。server收到pdu后,解析出剪贴板数据,并“复制”到本地剪贴板,应答Format List Response PDU。因为内容是文件,当在桌面或资源管理器右健弹出菜单时,“粘贴”可用。打个某个文件,文件内右键弹出菜单时,“粘贴”则不可用了。
6.2 server执行“粘贴”
示例执行“粘贴”后,要触发10(2+4+4)个PDU。
6.2.1 Format Data Request

04 00 00 00 04 00 00 00: header。msgType: 0x0004, msgFlags: 0x0000, dataLen: 0x4 a7 c0 00 00: requestedFormatId=0xc0a7
requestedFormatId=0xc0a7,表示server接下要粘贴的格式是“FileGroupDescriptorW”,即希望得到文件的描述信息。对此client须要把文件描述信息通过Format Data Response PDU发送给server。
6.2.2 Format Data Response PDU

05 00 01 00 a4 04 00 00: header。msgType: 0x0005, msgFlags: 0x0001, dataLen: 0x4a4
数据太多,截图还没能够显示完第一个描述符。高亮部分是第一个文件的文件名,CLIPRDR_FILEDESCRIPTOR留给文件名固定是259个有效字符加一个终止字符。
client根据request指定的requestedFormatId=0xc0a7在剪切板中找到指定格式资源,依据之前“复制”的内容,它须要返回pixel-0、pixel-1这两个文件的描述。文件描述是CLIPRDR_FILEDESCRIPTOR结构,包括该文件时间戳、文件长度、短文件名。这里要记住,第一个文件是pixel-0,第二个文件是pixel-1,接下的File Contents Request中的lindex字段须要遵从这个次序。
6.2.3: lindex=0时获取文件长度File Contents Request PDU

08 00 00 00 18 00 00 00: header。msgType: 0x0008, msgFlags: 0x0000, dataLen: 0x18 01 00 00 00: streamId=1 00 00 00 00: lindex=0 01 00 00 00: dwFlags=0x1(FILECONTENTS_SIZE) 00 00 00 00 00 00 00 00: nPositinLow=0, nPositinHigh=0 08 00 00 00: cbRequested=8。
dwFlags是FILECONTENTS_SIZE,表示此次要获取的是文件长度。streamId是唯一标识,它怎么来的?——由发出File Contents Request PDU一方产生,唯一要求是唯一,因为唯一,当后面收到File Contents Response PDU后,由内中的streamId可知道它是回向哪个Request。举个例子,wfreerdp.exe用了CliprdrStream这个对像的指针。随后的File Contents Response PDU中的streamId须保持是同一值,表示那个reponse这回这个request的。lindex是此个资源在Format Data Response PDU中返回的FILEGROUPDESCRIPTOR数组中的索引,此处是0,即要获取pixel-0的文件长度。
6.2.4: lindex=0时返回文件长度File Contents Response PDU

09 00 01 00 0c 00 00 00: header。msgType: 0x0009, msgFlags: 0x0001, dataLen: 0xc 01 00 00 00: streamId=1 10 40 00 00 00 00 00 00: requestedFileContentsData=8
requestedFileContentsData部分存的是文件长度,pixel-0正是16400字节。
6.2.5: lindex=0时获取文件内容File Contents Request PDU

08 00 00 00 18 00 00 00: header。msgType: 0x0008, msgFlags: 0x0000, dataLen: 0x18 01 00 00 00: streamId=1 00 00 00 00: lindex=0 02 00 00 00: dwFlags=0x2(FILECONTENTS_RANGE) 00 00 00 00 00 00 00 00: nPositinLow=0, nPositinHigh=0 00 01 00 00: cbRequested=65536。
nPositin=0、dwFlags是FILECONTENTS_RANGE,表示此次要获取的是从位置0开始的文件内容,最多读取65536个字节。lindex是0,即要获取pixel-0的文件内容。
6.2.6: lindex=0时返回文件内容File Contents Response PDU

09 00 01 00 14 40 00 00: header。msgType: 0x0009, msgFlags: 0x0001, dataLen: 0x4014
高度4字节是streamId=1,后面的16400字节正是pixel-0文件的内容。
6.2.7: lindex=1时获取文件长度File Contents Request PDU
参考6.2.3。lindex换成1,希望改为获取pixel-1文件长度。由于换了另外个资源,stremId也可能跟着变。
6.2.8: lindex=1时返回文件长度File Contents Response PDU
参考6.2.4。lindex换成1,改为返回pixel-1文件长度。
6.2.9: lindex=1时获取文件内容File Contents Request PDU
参考6.2.5。lindex换成1,希望改为获取pixel-1文件内容。
6.2.10: lindex=1时返回文件内容File Contents Response PDU
参考6.2.6。lindex换成1,改为返回pixel-1文件内容。
综上所述,在执行“粘贴”时,RDP是通过剪贴板格式(requestedFormatId)找出要粘贴的是哪个资源。对一种格式,剪贴板中只会最多存在一个资源,当然,这个资源可以是多个文件,像File List。要粘贴某个文件,至少要发两个File Contents Reqeust PUD,一是获取文件长度,二是获取文件内容,一旦文件长度超过65536字节,就要发多个获取内容的PUD。
6.3 可能存在的BUG
FreeRDP client处理剪贴板有个BUG:client复制文件到server时正常,但从server复制文件到client时只创建了文件,没有复制回内容。以下通过修改CliprdrStream_New解决这BUG。
static CliprdrStream* CliprdrStream_New(ULONG index, void* pData, const FILEDESCRIPTORW* dsc) { ... if (((instance->m_Dsc.dwFlags & FD_FILESIZE) == 0) && !isDir) { // Format List Response PUD得到的FILEDESCRIPTORW中,文件长度字段是0,于是专门发一个File Contents Request PDU去得到文件长度。 /* get content size of this stream */ if (cliprdr_send_request_filecontents(clipboard, (void*)instance, instance->m_lIndex, FILECONTENTS_SIZE, 0, 0, 8) == CHANNEL_RC_OK) { success = TRUE; } instance->m_lSize.QuadPart = *((LONGLONG*)clipboard->req_fdata); free(clipboard->req_fdata); } else { // Format List Response PUD得到的FILEDESCRIPTORW已给出文件长度。但源码少了以下这两个赋值,导致instance->m_lSize值总是0,没有准确反映出文件长度。或许吧,即使FILEDESCRIPTORW已有文件长度,最好还是再发File Contents Request。至少mstsc.exe是这么做的。 instance->m_lSize.LowPart = dsc->nFileSizeLow; // fixed instance->m_lSize.HighPart = dsc->nFileSizeHigh; // fixed success = TRUE; } ... }
七、读取一h264编码帧时的调用栈实例

图14来自自写的kdesktop,kDesktop是个在freerdp源码上改出的rdp客户端。一直到rdpgfx_decode_AVC444,都是freerdp函数。图中显示了rdpgfx_decode_AVC444快要结束时,和h264编码相关的两个变量。一个是h264.bitstream[0].data,指示存储一帧h264编码的数据块地址,另一个是h264.bitstream[0].length,指示该数据块字节数。对kdesktop,有了这编码帧后,调用webrtc提供的h264模块解码该帧,在windows,webrtc用的是ffmpeg。
从图14可看出,接收并解码h264编码图像是在drdynvc_virtual_channel_client_thread线程。从该调用栈,也能看到函数rdpgfx_on_data_received在当中的位置。