openBmc KVM基本框架和原理
一、总体框架
openBmc KVM功能实现的总体框架如图1所示,由obmc_ikvm、bmcweb、webui三大部分构成。其中,obmc_ikvm和bmcweb组成VNC Server,webui为VNC Client。VNC Server与VNC Client间通过Websocket协议通信,而obmc_ikvm与bmcweb间通过TCP套接字机制通信。
二、obmc_ikvm的工作流程
作为KVM框架中的主要部分,obmc_ikvm是直接与服务器进行交互的角色。一方面,它读取服务器的视频画面并通过bmcweb发送到VNC Client,另一方面它以虚拟键鼠设备的身份,向服务器映射VNC Client进行的键鼠操作。看到这里应该能够知道,视频和键鼠数据在传输的过程中的方向是相反的。
obmc_ikvm程序源代码实现了三个类:input类,video类和server类,分析这几个类的命名我们可能会不由自主的去猜想它们对应的功能。确实就像它们的名字所表示的那样,input类实现虚拟键鼠功能、video类实现视频的传输功能而server类负责这些功能之间的调度。另外还有两个重要的类:args和manager。同样,顾名思义,args这个类是负责读取和解析命令行参数的(从执行程序的命令中读取和解析用户输入的参数供程序使用)而manager类是obmc-ikvm程序的管理函数,它调用input类和video类的成员函数进行视频流和键鼠的传输。相信阅读到这里你会对obmc_ikvm的框架有了基本了解。下面我们看看这个程序的主函数:
#include "ikvm_args.hpp"
#include "ikvm_manager.hpp"
int main(int argc, char* argv[])
{
ikvm::Args args(argc, argv);
ikvm::Manager manager(args);
manager.run();
return 0;
}
可以看到,程序首先读取命令函参数对Manager进行初始化,随后运行了manager.run()函数。那么我们继续分析manager类和其成员函数manager.run()。
//manager类的初始化
Manager::Manager(const Args& args) :
continueExecuting(true), serverDone(false), videoDone(true),
input(args.getKeyboardPath(), args.getPointerPath(), args.getUdcName()),
video(args.getVideoPath(), input, args.getFrameRate(),
args.getSubsampling()),
server(args, input, video)
{}
·····
//run函数
void Manager::run()
{
//创建新线程run,独立线程中执行rfb更新操作
std::thread run(serverThread, this);
while (continueExecuting)
{
if (server.wantsFrame())
{
video.start();
video.getFrame();
server.sendFrame();
}
else
{
video.stop();
}
if (video.needsResize())
{
videoDone = false;
waitServer();
video.resize();
server.resize();
setVideoDone();
}
else
{
setVideoDone();
waitServer();
}
}
run.join();
}
/*
serverThread函数是run函数中创建的新线程的执行函数,在此函数中,
不断运行server类的成员函数run,执行rfb更新操作
*/
void Manager::serverThread(Manager* manager)
{
while (manager->continueExecuting)
{
manager->server.run();
manager->setServerDone();
manager->waitVideo();
}
}
//调用到的其他的一些函数是用于两个线程之间的同步的辅助函数
可以看到,在manager类的初始化过程中,程序依据args提供的参数依次初始化了input、video和server三个类的实例。接着manager.run()函数调用这些类提供的函数进行视频和键鼠的传输。
2.1 视频流传输
openBmc原生的的视频传输是基于V4L2框架的,其本质是在用户空间用户空间打开/dev/video0设备后进行图像数据的循环捕获和流式传输。obmc_ikvm接收一个V4L2_BUF_TYPE_VIDEO_CAPTURE类型的缓冲区作为视频源,通过一系列的操作实现向客户端发送视频。主要代码如下:
while (continueExecuting)
{
if (server.wantsFrame())
{
video.start();
video.getFrame();
server.sendFrame();
}
else
{
video.stop();
}
if (video.needsResize())
{
videoDone = false;
waitServer();
video.resize();
server.resize();
setVideoDone();
}
else
{
setVideoDone();
waitServer();
}
}
你一定会发现,这段代码是manager.run()函数的部分。其工作逻辑很清晰,这里就不多说啦。
2.2 虚拟键鼠模拟
实际上,在manager.run()中,还创建了一个独立的新的线程:
std::thread run(serverThread, this);
void Manager::serverThread(Manager* manager)
{
while (manager->continueExecuting)
{
manager->server.run();
manager->setServerDone();
manager->waitVideo();
}
}
在这个线程中循环运行server类的成员函数run:
void Server::run()
{
rfbProcessEvents(server, processTime);
if (server->clientHead)
{
frameCounter++;
if (pendingResize && frameCounter > video.getFrameRate())
{
doResize();
pendingResize = false;
}
}
}
server.run()进行rfb事件的处理,具体来说它调用了rfbProcessEvents()函数进行rfb事件的更新。rfbProcessEvents
函数是属于LibVNCServer库的一部分,这是一个开源的库,用于实现VNC服务器功能。在VNC架构中,服务器发送屏幕的图像更新到客户端,客户端发送回键盘和鼠标事件。rfbProcessEvents
函数在这个过程中起到了核心作用,它负责处理从客户端接收的各种事件和服务器端的响应,这其中就包括了键鼠事件。
到了这里,其实可以对前面提到的“视频和键鼠是两个方向的传输”做出进一步的解释:manager.run()的两个线程就对应了视频和键鼠两个功能,两个线程中的数据传输的方向应该是相反的。
好的,对于鼠标和键鼠的模拟,我们已经搞定了”输入“的部分,也就是说我们已经通过 rfbProcessEvents()函数获得了VNC Client的键鼠操作。自然而然,我们现在需要做的事情是在服务器上模拟出这些键鼠的操作,这部分的工作obmc_ikvm程序通过USB Gadget实现。鼠标和键盘都属于USB HID设备类型,USB Gadget是一种在嵌入式系统中实现USB设备功能的技术,因此采用USB Gadget将开发板模拟成USB HID设备,客户端通过rfb协议发送keyevent和pointerevent数据包到服务器端,服务器端收到数据包后模拟相应的键盘按键和鼠标操作。关于Gadget设备的知识,感兴趣的同学可以看看我相关的推文。
- 创建和配置USB Gadget
#create USBhid.sh
echo 0x0100 > bcdDevice #配置固件版本号
echo 0x0200 > bcdUSB #配置USB规范版本号
echo 0x0104 > idProduct #配置USB设备产品ID
echo 0x1d6b > idVendor #配置USB设备的厂商ID
mkdir strings/0x409 #创建用于存放于存放USB设备的描述符字符串的目录,0x409表示英文的语言代码
echo "OpenBMC" > strings/0x409/manufacturer #表示设备的制造厂商
echo "virtual_input" > strings/0x409/product #标识设备的产品名称
echo "OBMC0001" > strings/0x409/serialnumber #标识USB设备的序列号
#键盘设备
mkdir functions/hid.0 #创建用于存放USB Gadget设备的功能模块的目录
echo 1 > functions/hid.0/protocol #指定HID设备协议为键盘协议
echo 8 > functions/hid.0/report_length #指定HID设备的输入报告长度,这里设置为8字节
echo 1 > functions/hid.0/subclass #指定HID设备的子类别为键盘协议的子类别
#写入HID设备的报告描述符,定义HID设备的按键输入格式
echo -ne '\x05\x01\x09\x06\xa1\x01\x05\x07\x19\xe0\x29\xe7\x15\x00\x25\x01\x75\x01\x95\x08\x81\x02\x95\x01\x75\x08\x81\x03\x95\x05\x75\x01\x05\x08\x19\x01\x29\x05\x91\x02\x95\x01\x75\x03\x91\x03\x95\x06\x75\x08\x15\x00\x25\x65\x05\x07\x19\x00\x29\x65\x81\x00\xc0' > functions/hid.0/report_desc
#鼠标设备
mkdir functions/hid.1 #创建用于存放USB Gadget设备的功能模块的目录
echo 2 > functions/hid.1/protocol #指定HID设备协议为鼠标协议
echo 5 > functions/hid.1/report_length #指定HID设备的输入报告长度,这里设置为5字节
echo 1 > functions/hid.1/subclass #指定HID设备的子类别为鼠标协议的子类别
#写入HID设备的报告描述符,定义HID设备鼠标的输入格式,包括鼠标的移动和点击等事件
echo -ne '\x05\x01\x09\x02\xa1\x01\x09\x01\xa1\x00\x05\x09\x19\x01\x29\x03\x15\x00\x25\x01\x95\x03\x75\x01\x81\x02\x95\x01\x75\x05\x81\x03\x05\x01\x09\x30\x09\x31\x35\x00\x46\xff\x7f\x15\x00\x26\xff\x7f\x65\x11\x55\x00\x75\x10\x95\x02\x81\x02\xc0\xc0' > functions/hid.1/report_desc
#创建配置文件夹
mkdir configs/c.1
mkdir configs/c.1/strings/0x409
echo 0x80 > configs/c.1/bmAttributes #配置USB Gadget设备通过外部电源供电
echo 200 > configs/c.1/MaxPower #配置USB Gadget设备最大负载时需要200毫安的电流
echo "" > configs/c.1/strings/0x409/configuration #作为配置项的标识符
#将HID功能模块链接到USB Gadget设备的配置项中,这样,USB Gadget设备就具备了HID设备的功能,可以模拟键盘和鼠标等输入设备
ln -s functions/hid.0 configs/c.1
ln -s functions/hid.1 configs/c.1
- 连接至主机
cd /sys/kernel/config/usb_gadget/obmc_hid
echo fc400000.usb > UDC
- 实现虚拟键鼠操作
在系统中运行上面的create USBhid.sh
脚本后,会在系统中创建/dev/hidg0
和/dev/hidg1
两个设备节点,分别对应键盘和鼠标设备。键鼠操作的实现基于通过设备节点获取文件描述符,再通过读写文件描述符实现虚拟键鼠的操作。create USBhid.sh
属于configfs的应用,它是一种Gadget设备的创建方法。接下来涉及到的代码,均在ikvm_input.cpp文件中。
//input类初始化
Input::Input(const std::string& kbdPath, const std::string& ptrPath,
const std::string& udc) :
keyboardFd(-1),
pointerFd(-1), keyboardReport{0}, pointerReport{0}, keyboardPath(kbdPath),
pointerPath(ptrPath), udcName(udc)
{
hidUdcStream.exceptions(std::ofstream::failbit | std::ofstream::badbit);
hidUdcStream.open(hidUdcPath, std::ios::out | std::ios::app);
}
在input类的初始化过程中,根据命令行参数,程序初始化了KeyboardPath
和pointerPath
。keyboardFd
和pointerFd
是键盘和鼠标的文件描述符,keyboardReport
和pointerReport
是键鼠的hid报文。udcName
和hidUdcStream
用于使能Gadget
,即将Gadget
绑定到UDC
。
//获取文件描述符
if (!keyboardPath.empty())
{
keyboardFd =
open(keyboardPath.c_str(), O_RDWR | O_CLOEXEC | O_NONBLOCK);
if (keyboardFd < 0)
{
log<level::ERR>("Failed to open input device",
entry("PATH=%s", keyboardPath.c_str()),
entry("ERROR=%s", strerror(errno)));
elog<Open>(xyz::openbmc_project::Common::File::Open::ERRNO(errno),
xyz::openbmc_project::Common::File::Open::PATH(
keyboardPath.c_str()));
}
}
if (!pointerPath.empty())
{
pointerFd = open(pointerPath.c_str(), O_RDWR | O_CLOEXEC | O_NONBLOCK);
if (pointerFd < 0)
{
log<level::ERR>("Failed to open input device",
entry("PATH=%s", pointerPath.c_str()),
entry("ERROR=%s", strerror(errno)));
elog<Open>(xyz::openbmc_project::Common::File::Open::ERRNO(errno),
xyz::openbmc_project::Common::File::Open::PATH(
pointerPath.c_str()));
}
}
获取到文件描述符后,向其中写入hid报文就能够模拟键鼠操作,这部分操作由keyevent()和pointerevent()实现。但是我们从VNC Client获取的数据还不是能够直接向文件描述符写入的形式,因此在这两个函数中还进行了相关的解析和转换。我对关键部分进行了简单注释,这里不再进行更多解释。
//keyEvent
void Input::keyEvent(rfbBool down, rfbKeySym key, rfbClientPtr cl)
{
Server::ClientData* cd = (Server::ClientData*)cl->clientData;
Input* input = cd->input;
bool sendKeyboard = false;
if (input->keyboardFd < 0)
{
return;
}
if (down)
{
uint8_t sc = keyToScancode(key);//转换为hid报文
if (sc)
{
if (input->keysDown.find(key) == input->keysDown.end())
{
for (unsigned int i = 2; i < KEY_REPORT_LENGTH; ++i)
{
if (!input->keyboardReport[i])
{
input->keyboardReport[i] = sc;
input->keysDown.insert(std::make_pair(key, i));
sendKeyboard = true;
break;
}
}
}
}
else
{
uint8_t mod = keyToMod(key);//转换为hid报文
if (mod)
{
input->keyboardReport[0] |= mod;
sendKeyboard = true;
}
}
}
else
{
auto it = input->keysDown.find(key);
if (it != input->keysDown.end())
{
input->keyboardReport[it->second] = 0;
input->keysDown.erase(it);
sendKeyboard = true;
}
else
{
uint8_t mod = keyToMod(key);
if (mod)
{
input->keyboardReport[0] &= ~mod;
sendKeyboard = true;
}
}
}
if (sendKeyboard)
{
input->writeKeyboard(input->keyboardReport);//将hid报文写入描述符
}
}
//pointerevet
void Input::pointerEvent(int buttonMask, int x, int y, rfbClientPtr cl)
{
Server::ClientData* cd = (Server::ClientData*)cl->clientData;
Input* input = cd->input;
Server* server = (Server*)cl->screen->screenData;
const Video& video = server->getVideo();
if (input->pointerFd < 0)
{
return;
}
if (buttonMask > 4) //转换为hid报文
{
input->pointerReport[0] = 0;
if (buttonMask == 8)
{
input->pointerReport[5] = 1;
}
else if (buttonMask == 16)
{
input->pointerReport[5] = 0xff;
}
}
else
{
input->pointerReport[0] = ((buttonMask & 0x4) >> 1) |
((buttonMask & 0x2) << 1) |
(buttonMask & 0x1);
input->pointerReport[5] = 0;
}
if (x >= 0 && (unsigned int)x < video.getWidth())
{
uint16_t xx = (uint16_t)(x * (SHRT_MAX + 1) / video.getWidth());
memcpy(&input->pointerReport[1], &xx, 2);
}
if (y >= 0 && (unsigned int)y < video.getHeight())
{
uint16_t yy = (uint16_t)(y * (SHRT_MAX + 1) / video.getHeight());
memcpy(&input->pointerReport[3], &yy, 2);
}
rfbDefaultPtrAddEvent(buttonMask, x, y, cl);
input->writePointer(input->pointerReport); //将hid报文写入描述符
}
2.3 与bmcweb的通信
bmcweb是obmc_ikvm的另一个直接交互的对象(另一个是服务器),上面的我们提到的获取键鼠事件和发送视频,其直接对象都是bmcweb,而最终的对象是VNC Client,也就是webui。obmc_ikvm与bmcweb的通信是通过TCP套接字来实现的,obmc_ikvm程序与bmcweb程序直接通过TCP套接字机制进行“本地通信”或称为“进程间通信”。
具体来说,两个程序可以通过绑定到本地主机的IP地址(如 127.0.0.1
,也称为回环地址)上的端口来建立TCP连接。在这里,obmc_ikvm作为服务器,监听端口5900;bmcweb作为客户端,连接到服务器的5900端口。在bmcweb中,这个实例是hostsocket。