ZCU106 VCU Linux驱动转裸机驱动篇(二)

VCU Linux驱动转裸机驱动

前言

上一篇说到了上层函数调用硬件驱动,驱动文件libCommon/HardwareDriver.c中,这一片讲一下C++上层控制逻辑,注意大部分数据传输都是调用的的PostMessage~

开始

ZCU106 VCU Linux驱动转裸机驱动篇(二)_第1张图片

系统配置信息

1、首先进入主函数:

  ConfigFile cfg;
  SetDefaults(cfg);

  auto& FileInfo = cfg.FileInfo;
  auto& Settings = cfg.Settings;
  auto& StreamFileName = cfg.BitstreamFileName;
  auto& RecFileName = cfg.RecFileName;
  auto& RunInfo = cfg.RunInfo;

  ParseCommandLine(argc, argv, cfg);

  DisplayVersionInfo();

设置默认参数,然后我们看一下config配置了啥

/*************************************************************************//*!
   \brief Whole configuration file
*****************************************************************************/
AL_INTROSPECT(category = "debug") struct ConfigFile
{
  // \brief YUV input file name(s) 输入文件名
  std::string YUVFileName;
  // \brief Output bitstream file name 输出文件名字
  std::string BitstreamFileName;
  // \brief Reconstructed YUV output file name
  std::string RecFileName;
  // \brief Name of the file specifying the frame numbers where scene changes
  // happen 命令行配置文件
  std::string sCmdFileName;

  // \brief Name of the file specifying the region of interest per frame is specified
  // happen
  std::string sRoiFileName;
  // \brief Folder where qp tables files are located, if load qp enabled.
  std::string sQPTablesFolder;

#if AL_ENABLE_TWOPASS
  // \brief Name of the file that reads/writes video statistics for TwoPassMode
  std::string sTwoPassFileName;
#endif
  // \brief Information relative to YUV input file (from section INPUT) 文件信息
  TYUVFileInfo FileInfo;
  // \brief FOURCC Code of the reconstructed picture output file
  TFourCC RecFourCC;//fourcc code
  // \brief Sections RATE_CONTROL and SETTINGS
  AL_TEncSettings Settings;
  // \brief Section RUN
  TCfgRunInfo RunInfo;
  // \brief control the strictness when parsing the configuration file
  bool strict_mode;
};

其中runinfo如下:

typedef AL_INTROSPECT (category = "debug") struct tCfgRunInfo
{
  bool bUseBoard;
  SCHEDULER_TYPE iSchedulerType;
  bool bLoop;//循环编码
  int iMaxPict;//最大的编码帧数
  unsigned int iFirstPict;//第一张帧数
  unsigned int iScnChgLookAhead;
  std::string sMd5Path;
  int eVQDescr;
  IpCtrlMode ipCtrlMode;
  std::string logsFile = "";
  bool trackDma = false;
  bool printPictureType = false;
  AL_64U uInputSleepInMilliseconds;
}TCfgRunInfo;

全局默认设置信息,配置信息见encode_example.cfg,说的比较清楚,这里把我自己觉得比较重要的放在里面:

void SetDefaults(ConfigFile& cfg)
{
  cfg.BitstreamFileName = "Stream.bin";//默认输出stram.bin
  cfg.RecFourCC = FOURCC(NULL);
  AL_Settings_SetDefaults(&cfg.Settings);
  cfg.FileInfo.FourCC = FOURCC(I420);
  cfg.FileInfo.FrameRate = 0;//0fps
  cfg.FileInfo.PictHeight = 0;//0 pixel height
  cfg.FileInfo.PictWidth = 0;//0 width width
  cfg.RunInfo.bUseBoard = true;
  cfg.RunInfo.iSchedulerType = SCHEDULER_TYPE_MCU;//mcu 控制
  # Loop : specifies whether the encoder should loop back to the beginning of the YUV input stream when it reaches the end of the file
  cfg.RunInfo.bLoop = false;//关闭回环编码
  cfg.RunInfo.iMaxPict = INT_MAX; // ALL
  cfg.RunInfo.iFirstPict = 0;//第一张是0frame
  cfg.RunInfo.iScnChgLookAhead = 3;
  cfg.RunInfo.ipCtrlMode = IPCTRL_MODE_STANDARD;
  cfg.RunInfo.uInputSleepInMilliseconds = 0;
  cfg.strict_mode = false;
}

然后编码器设置信息

typedef AL_INTROSPECT (category = "debug") struct t_EncSettings
{
  // Stream
  AL_TEncChanParam tChParam[MAX_NUM_LAYER];
  bool bEnableAUD;
  bool bEnableFillerData;
  uint32_t uEnableSEI;

  AL_EAspectRatio eAspectRatio; /*!< specifies the display aspect ratio */
  AL_EColourDescription eColourDescription;
  AL_EScalingList eScalingList;
  bool bDependentSlice;

  bool bDisIntra;
  bool bForceLoad;
  int32_t iPrefetchLevel2;
  uint16_t uClipHrzRange;
  uint16_t uClipVrtRange;
  AL_EQpCtrlMode eQpCtrlMode;
  int NumView;
  int NumLayer;
  uint8_t ScalingList[4][6][64];
  uint8_t SclFlag[4][6];
  uint8_t DcCoeff[8];
  uint8_t DcCoeffFlag[8];
  bool bEnableWatchdog;
#if AL_ENABLE_TWOPASS
  int LookAhead;
  int TwoPass;
#endif
}AL_TEncSettings;

设置默认函数在下面,这里面的东西比较细,然后在一张配置文件里面讲的比较详细,然后直接在里面给注释好了,注意带#只能在脚本中用,这里不一样的

void AL_Settings_SetDefaults(AL_TEncSettings* pSettings)
{
  assert(pSettings);
  Rtos_Memset(pSettings, 0, sizeof(*pSettings));
# Width, Height: frame width/height in pixels
# width and height shall be multiple of 8 pixels
  pSettings->tChParam[0].uWidth = 0;
  pSettings->tChParam[0].uHeight = 0;
# Profile : specifies the standard/profile to which the bitstream conforms
# allowed values : AVC_BASELINE, AVC_MAIN, AVC_HIGH, AVC_HIGH10, AVC_HIGH_422,
#                  HEVC_MAIN, HEVC_MAIN10, HEVC_MAIN_422_10...
  pSettings->tChParam[0].eProfile = AL_PROFILE_HEVC_MAIN;
  pSettings->tChParam[0].uLevel = 51;//最高53,越高编码性能越好
  pSettings->tChParam[0].uTier = 0; // MAIN_TIER
  pSettings->tChParam[0].eOptions = AL_OPT_LF | AL_OPT_LF_X_SLICE | AL_OPT_LF_X_TILE;
  pSettings->tChParam[0].eOptions |= AL_OPT_RDO_COST_MODE;
# BitDepth : specifies the bit depth of the luma and chroma samples in the encoded stream
# Format : FOURCC format of input file
# typical file formats       : I420, I422, I0AL, I2AL...
# hardware supported formats : NV12, NV16, P010, P210... (depends of the hw ip)
  pSettings->tChParam[0].ePicFormat = AL_420_8BITS;
  pSettings->tChParam[0].uSrcBitDepth = 8;
# GopCtrlMode : specifies the Group Of Pictures configuration
# allowed values : DEFAULT_GOP, LOW_DELAY_P, LOW_DELAY_B, PYRAMIDAL_GOP
# default value  : DEFAULT_GOP
  pSettings->tChParam[0].tGopParam.eMode = AL_GOP_MODE_DEFAULT;
  pSettings->tChParam[0].tGopParam.uFreqIDR = 0x7FFFFFFF;
# Gop.Length : GOP length in frames including the I picture. 0 = Intra only,这里比较重要,我需要说一下,h265视频压缩时可以只用帧内压缩,此时跟图像压缩一样了,这里就是设置编码多少幅图像再次用一张关键帧,设置为0就是关闭帧内压缩,这里最好用25,因为25帧人眼就看不出啥来,压缩图像还是0
#Gop.FreqIDR : minimum number of frames between two IDR pictures (IDR insertion depends on the position of the GOP boundary)
# allowed values : positive value or -1 to disable IDR insertion
  pSettings->tChParam[0].tGopParam.uGopLength = 30;
  pSettings->tChParam[0].tGopParam.eGdrMode = AL_GDR_OFF;

  AL_Settings_SetDefaultRCParam(&pSettings->tChParam[0].tRCParam);

  pSettings->tChParam[0].iTcOffset = -1;
  pSettings->tChParam[0].iBetaOffset = -1;

  pSettings->tChParam[0].eColorSpace = UNKNOWN;
# NumSlices : number of row-based slices used for each frame
  pSettings->tChParam[0].uNumCore = NUMCORE_AUTO;
  pSettings->tChParam[0].uNumSlices = 1;

  pSettings->uEnableSEI = SEI_NONE;
  pSettings->bEnableAUD = true;
  pSettings->bEnableFillerData = true;
  pSettings->eAspectRatio = AL_ASPECT_RATIO_AUTO;
  pSettings->eColourDescription = COLOUR_DESC_BT_470_PAL;
# QPCtrlMode : specifies how to generate the QP per coding unit
# allowed values : UNIFORM_QP, AUTO_QP, LOAD_QP, LOAD_QP | RELATIVE_QP
# default value  : UNIFORM_QP
  pSettings->eQpCtrlMode = UNIFORM_QP;// ADAPTIVE_AUTO_QP;
  pSettings->tChParam[0].eLdaCtrlMode = AUTO_LDA;

  pSettings->eScalingList = AL_SCL_DEFAULT;

  pSettings->bForceLoad = true;
  pSettings->tChParam[0].pMeRange[SLICE_P][0] = -1; // Horz
  pSettings->tChParam[0].pMeRange[SLICE_P][1] = -1; // Vert
  pSettings->tChParam[0].pMeRange[SLICE_B][0] = -1; // Horz
  pSettings->tChParam[0].pMeRange[SLICE_B][1] = -1; // Vert
  pSettings->tChParam[0].uMaxCuSize = 5; // 32x32
  pSettings->tChParam[0].uMinCuSize = 3; // 8x8
  pSettings->tChParam[0].uMaxTuSize = 5; // 32x32
  pSettings->tChParam[0].uMinTuSize = 2; // 4x4
  pSettings->tChParam[0].uMaxTransfoDepthIntra = 1;
  pSettings->tChParam[0].uMaxTransfoDepthInter = 1;

  pSettings->NumLayer = 1;
  pSettings->NumView = 1;
  pSettings->tChParam[0].eEntropyMode = AL_MODE_CABAC;
  pSettings->tChParam[0].eWPMode = AL_WP_DEFAULT;
  pSettings->tChParam[0].eSrcMode = AL_SRC_NVX;

#if AL_ENABLE_TWOPASS
  pSettings->LookAhead = 0;
  pSettings->TwoPass = 0;
#endif
  pSettings->tChParam[0].eVideoMode = AL_VM_PROGRESSIVE;
}

插曲
我们会发现会有大量篇幅在整这个FOURCC,这到底是个啥?
FourCC全称Four-Character Codes,代表四字符代码 (four character code), 它是一个32位的标示符,其实就是typedef unsigned int FOURCC;是一种独立标示视频数据流格式的四字符代码。
ok看一下定义

typedef uint32_t TFourCC;
#define FOURCC(A) ((TFourCC)(((uint32_t)((# A)[0])) \
                             | ((uint32_t)((# A)[1]) << 8) \
                             | ((uint32_t)((# A)[2]) << 16) \
                             | ((uint32_t)((# A)[3]) << 24)))

实际就是把字符串转化为32位~
继续
然后把几个重要的信息给拿出来了,引用一下,然后后面修改

  auto& FileInfo = cfg.FileInfo;//编码的文件信息,主要时宽、高、然后大小,我呢见编码yuv是nv12还是16等等
  auto& Settings = cfg.Settings;//编码配置信息,这个解析命令行的cfg文件,不然用默认的配置
  auto& StreamFileName = cfg.BitstreamFileName;//输出文件名
  auto& RecFileName = cfg.RecFileName;//记录文件名
  auto& RunInfo = cfg.RunInfo;//运行信息

设置完默认信息之后开始解析命令行以及配置文件,解析cfg文件最主要在下面

  if(g_Verbosity)
    cerr << warning.str();

  if(cfg.FileInfo.PictWidth > UINT16_MAX)
    throw runtime_error("Unsupported picture width value");

  if(cfg.FileInfo.PictHeight > UINT16_MAX)
    throw runtime_error("Unsupported picture height value");
//设置编码图像宽高
  AL_SetSrcWidth(&cfg.Settings.tChParam[0], cfg.FileInfo.PictWidth);
  AL_SetSrcHeight(&cfg.Settings.tChParam[0], cfg.FileInfo.PictHeight);
//设置编码的图像位宽、格式等
  if(ipbitdepth != -1)
  {
    AL_SET_BITDEPTH(cfg.Settings.tChParam[0].ePicFormat, ipbitdepth);
  }

  cfg.Settings.tChParam[0].uSrcBitDepth = AL_GET_BITDEPTH(cfg.Settings.tChParam[0].ePicFormat);

  if(AL_IS_STILL_PROFILE(cfg.Settings.tChParam[0].eProfile))
    cfg.RunInfo.iMaxPict = 1;

然后就是打印版本信息
然后设置默认setting参数,然后还是整cfg文件哪些东西
ok到此文件信息配置完毕,接下来用编码器编码了

获取编码器实例

首先获取编码器的控制信息

  function<AL_TIpCtrl* (AL_TIpCtrl*)> wrapIpCtrl = GetIpCtrlWrapper(RunInfo);

  auto pIpDevice = CreateIpDevice(!RunInfo.bUseBoard, RunInfo.iSchedulerType, Settings, wrapIpCtrl, RunInfo.trackDma, RunInfo.eVQDescr);

  if(!pIpDevice)
    throw runtime_error("Can't create IpDevice");

首先看一下function是个啥,这里给一个例子:

    #include 
    #include 
    
     int f(int a, int b)
     {
       return a+b;
     }
     
     int main()
     {
     	std::function<int(int, int)>func = f;
     	cout<<func(1, 2)<<endl;      // 3
     	system("pause");
     	return 0;
     }

ok,function是一个通用的多态函数包装器。 std :: function的实例可以存储,复制和调用任何可调用的目标 :包括函数,lambda表达式,绑定表达式或其他函数对象,以及指向成员函数和指向数据成员的指针。
也就是说上面的wrapIpCtrl是一个函数,可以直接用,然后其参数是一个指针,指向一个指针,AL_TIpCtrl* (AL_TIpCtrl*),打开后面的获取函数来看一下:

function<AL_TIpCtrl* (AL_TIpCtrl*)> GetIpCtrlWrapper(TCfgRunInfo& RunInfo)
{
  function<AL_TIpCtrl* (AL_TIpCtrl*)> wrapIpCtrl;
  switch(RunInfo.ipCtrlMode)
  {
  default:
  //这里是一个lambda表达式
    wrapIpCtrl = [](AL_TIpCtrl* ipCtrl) -> AL_TIpCtrl*
                 {
                   return ipCtrl;
                 };
    break;
  }

  return wrapIpCtrl;
}

上面其实一个lamba表达式,看一下c++lambda表达式定义形式:

*[函数对象参数] (操作符重载函数参数) mutable 或 exception 声明 -> 返回值类型 {函数体}*

也就是说返回类型是AL_TIpCtrl*,函数参数是AL_TIpCtrl* ipCtrl,那其实就是这样的:

warpIpCtrl(param a){
	return a;
}

真绕啊~
然后下面就是创建ip实例了:

shared_ptr<CIpDevice> CreateIpDevice(bool bUseRefSoftware, int iSchedulerType, AL_TEncSettings& Settings, function<AL_TIpCtrl* (AL_TIpCtrl*)> wrapIpCtrl, bool trackDma, int eVqDescr)
{
  (void)bUseRefSoftware, (void)Settings, (void)wrapIpCtrl, (void)eVqDescr, (void)trackDma;
//wc你就这一种不直接默认得了???
  if(iSchedulerType == SCHEDULER_TYPE_MCU)
    return createMcuIpDevice();

  throw runtime_error("No support for this scheduling type");
}

费劲。。。再接着走:

static unique_ptr<CIpDevice> createMcuIpDevice()
{
  auto device = make_unique<CIpDevice>();
//设备创建dma区域,这里是一个智能指针,然后在这里reset并初始化,源码不解释了
  device->m_pAllocator.reset(createDmaAllocator("/dev/allegroIP"), &AL_Allocator_Destroy);

  if(!device->m_pAllocator)
    throw runtime_error("Can't open DMA allocator");
//这里创建mcu调度器,这里讲一下
  device->m_pScheduler = AL_SchedulerMcu_Create(AL_GetHardwareDriver(), device->m_pAllocator.get());

  if(!device->m_pScheduler)
    throw std::runtime_error("Failed to create MCU scheduler");

  return device;
}

展开创建mcu调度器函数

static const TSchedulerVtable McuSchedulerVtable =
{
  &destroy,
  &createChannel,
  &destroyChannel,
  &encodeOneFrame,
  &putStreamBuffer,
  &getRecPicture,
  &releaseRecPicture,
};

TScheduler* AL_SchedulerMcu_Create(AL_TDriver* driver, AL_TAllocator* pDmaAllocator)
{
  AL_TSchedulerMcu* scheduler = Rtos_Malloc(sizeof(*scheduler));

  if(!scheduler)
    return NULL;
  scheduler->vtable = &McuSchedulerVtable;
  scheduler->driver = driver;
  scheduler->allocator = pDmaAllocator;
  return (TScheduler*)scheduler;
}

这里的rtos_malloc其实还是封装了标准库:

void* Rtos_Malloc(size_t zSize)
{
  return malloc(zSize);
}

然后这里的driver就是输入我们打开的ip,就是我们底层的ip控制层了,dma暂时先不研究~

static AL_DriverVtable hardwareDriverVtable =
{
  &Open,
  &Close,
  &PostMessage,
};

static AL_TDriver hardwareDriver =
{
  &hardwareDriverVtable
};

AL_TDriver* AL_GetHardwareDriver()
{
  return &hardwareDriver;
}

所以这里需要画个图~
ZCU106 VCU Linux驱动转裸机驱动篇(二)_第2张图片
其中vtable其实就是调用driver发送消息给mcu进行编解码,然后这里看一个createChannel先

static AL_ERR createChannel(AL_HANDLE* hChannel, TScheduler* pScheduler, AL_TEncChanParam* pChParam, TMemDesc* pEP1, AL_TISchedulerCallBacks* pCBs)
{
  AL_ERR errorCode = AL_ERROR;
  AL_TSchedulerMcu* schedulerMcu = (AL_TSchedulerMcu*)pScheduler;

  Channel* chan = Rtos_Malloc(sizeof(*chan));

  if(!chan)
  {
    errorCode = AL_ERR_NO_MEMORY;
    goto channel_creation_fail;
  }

  Rtos_Memset(chan, 0, sizeof(*chan));

  chan->driver = schedulerMcu->driver;
  chan->fd = AL_Driver_Open(chan->driver, deviceFile);

  if(chan->fd < 0)
  {
    perror("Can't open driver");
    goto driver_open_fail;
  }

  struct al5_channel_config msg = { 0 };
  setChannelParam(&msg.param, pChParam, pEP1);
  chan->outputRec = pChParam->eOptions & AL_OPT_FORCE_REC;

  AL_EDriverError errdrv = AL_Driver_PostMessage(chan->driver, chan->fd, AL_MCU_CONFIG_CHANNEL, &msg);

  if(errdrv != DRIVER_SUCCESS)
  {
    if(errdrv == DRIVER_ERROR_NO_MEMORY)
      errorCode = AL_ERR_NO_MEMORY;

    /* the ioctl might not have been called at all,
     * so the error_code might no be set. leave it to AL_ERROR in this case */
    if((errdrv == DRIVER_ERROR_CHANNEL) && (msg.status.error_code != 0))
      errorCode = msg.status.error_code;

    goto fail;
  }

  assert(msg.status.error_code == 0);

  setChannelFeedback(pChParam, &msg.status);
  setCallbacks(chan, pCBs);
  chan->shouldContinue = 1;
  chan->thread = Rtos_CreateThread(&WaitForStatus, chan);

  if(!chan->thread)
    goto fail;

  SetChannelInfo(&chan->info, pChParam);
  *hChannel = (AL_HANDLE)chan;
  return AL_SUCCESS;

  fail:
  AL_Driver_Close(schedulerMcu->driver, chan->fd);
  driver_open_fail:
  Rtos_Free(chan);
  channel_creation_fail:
  *hChannel = AL_INVALID_CHANNEL;
  return errorCode;
}

其中的AL_TEncChanParam是分配的物理地址以及转化的虚拟地址
注意重点来了
设置通道信息,这里是将信息传入到msg结构体中

void setChannelParam(struct al5_params* msg, AL_TEncChanParam* pChParam, TMemDesc* pEP1)
{
  static_assert(sizeof(*pChParam) <= sizeof(msg->opaque_params), "Driver channel_param struct is too small");
  msg->size = 0;//这里size=0说明是msg消息刚开始
  write(msg, pChParam, sizeof(*pChParam));//写入msg中,
  uint32_t uEp1VirtAddr = 0;//虚拟地址

  if(pEP1)
    uEp1VirtAddr = pEP1->uPhysicalAddr + DCACHE_OFFSET;//虚拟地址
  write(msg, &uEp1VirtAddr, sizeof(uEp1VirtAddr));
}

把消息传入,msg结构体如下:

typedef AL_INTROSPECT (category = "debug") struct __AL_ALIGNED__ (4) AL_t_EncChanParam
{
  int iLayerID;

  /* Encoding resolution */
  uint16_t uWidth;
  uint16_t uHeight;


  AL_EVideoMode eVideoMode;
  /* Encoding picture format */
  AL_EPicFormat ePicFormat;
  AL_EColorSpace eColorSpace;
  AL_ESrcMode eSrcMode;
  /* Input picture bitdepth */
  uint8_t uSrcBitDepth;

  /* encoding profile/level */
  AL_EProfile eProfile;
  uint8_t uLevel;
  uint8_t uTier;

  uint32_t uSpsParam;
  uint32_t uPpsParam;

  /* Encoding tools parameters */
  AL_EChEncOption eOptions;
  int8_t iBetaOffset;
  int8_t iTcOffset;

  int8_t iCbSliceQpOffset;
  int8_t iCrSliceQpOffset;
  int8_t iCbPicQpOffset;
  int8_t iCrPicQpOffset;


  uint8_t uCuQPDeltaDepth;
  uint8_t uCabacInitIdc;

  uint8_t uNumCore;
  uint16_t uSliceSize;
  uint16_t uNumSlices;

  /* L2 prefetch parameters */
  uint32_t uL2PrefetchMemOffset;
  uint32_t uL2PrefetchMemSize;
  uint16_t uClipHrzRange;
  uint16_t uClipVrtRange;


  /* MV range */
  int16_t pMeRange[2][2];  /*!< Allowed range for motion estimation */

  /* encoding block size */
  uint8_t uMaxCuSize;
  uint8_t uMinCuSize;
  uint8_t uMaxTuSize;
  uint8_t uMinTuSize;
  uint8_t uMaxTransfoDepthIntra;
  uint8_t uMaxTransfoDepthInter;

  // For AVC
  AL_EEntropyMode eEntropyMode;
  AL_EWPMode eWPMode;

  /* Gop & Rate control parameters */
  AL_TRCParam tRCParam;
  AL_TGopParam tGopParam;
  bool bSubframeLatency;
  AL_ELdaCtrlMode eLdaCtrlMode;

} AL_TEncChanParam;

也就是说我们配置的时候也要把这个结构体写进入,然后写入虚拟地址
继续
创建互斥锁以及条件变量

AL_EVENT Rtos_CreateEvent(bool bInitialState)
{
  evt_t* pEvt = (evt_t*)Rtos_Malloc(sizeof(evt_t));

  if(pEvt)
  {
    pthread_mutex_init(&pEvt->Mutex, 0);
    pthread_cond_init(&pEvt->Cond, 0);
    pEvt->bSignaled = bInitialState;
  }
  return (AL_EVENT)pEvt;
}

emm比较简单,自己看吧
然后创建销毁~,这里不太一样,有用到了lambda表达式

  auto scopeMutex = scopeExit([&]() {
    Rtos_DeleteEvent(hFinished);
  });

输入是一个全局变量的引用,也就是hFinished,然后把lambda表达式传输scopeExit中,看一下这个函数

template<typename Lambda>
class ScopeExitClass
{
public:
  ScopeExitClass(Lambda fn) : m_fn(fn)
  {
  }

  ~ScopeExitClass()
  {
    m_fn();
  }

private:
  Lambda m_fn;
};

创建一个类,并把传入的函数设置为私有函数,也就时说scopeMutex是个类,然后他的rtos_DetleEvent(hFinshed)是自己的私有函数,ok浸提你先到这里

END

字数太多了,转下一篇

你可能感兴趣的:(zynq,VCU,ARM)