fpga学习——zynq图像处理中的DVP流接口封装

fpga学习——zynq图像处理中的DVP流接口封装之前文章介绍了基于zynq的图像处理架构问题。其中,作为开发者,需要重点关注图像传感器接口、处理算法、显示接口,这些模块。现在我们一同学习用于视频数据接口的DVP模块,并将其封装成AXI-stream接口便于直接和VDMAIP通信。DVP_AXIstreamIPv1.0使用说明1.设计概述•用于cmos传感器视频数据采集,将cmos输出的8位视频数据拼接成RGB565模式•AXI_stream主机接口,用于和PS端内存的数据交互•基于vivado18.3软件设计2.模块分析

大家好,又见面了,我是你们的朋友全栈君。

之前文章介绍了基于zynq的图像处理架构问题。其中,作为开发者,需要重点关注图像传感器接口、处理算法、显示接口,这些模块。现在我们一同学习用于视频数据接口的DVP模块,并将其封装成AXI-stream接口便于直接和VDMA IP通信。

DVP_AXI stream IP v1.0使用说明

1.设计概述

•用于cmos传感器视频数据采集,将cmos输出的8位视频数据拼接成RGB 565模式

•AXI_stream主机接口,用于和PS端内存的数据交互
•基于vivado 18.3软件设计

2.模块分析

此设计包括DVP模块及AXI_stream 协议部分。DVP模块负责将采集的8位视频数据及行、场同步信号按照相应时序转换成16位RGB模式输出,DVP模块独立封装,在顶层模块中调用。AXI_stream 部分用于产生相应的AXI_stream接口信号,设计采用AXI_stream主机模式。模块框架图如下图所示。

fpga学习——zynq图像处理中的DVP流接口封装

图1.DVP_AXI stream IP 核模块框架图

DVP模块:

DVP模块是实现视频数据采集的主要部分。

等到初始化摄像完成且行场同步信号出现,释放清零信号,开始写入数据;利用采样计数器对采样数据计数,计数值在行同步信号有效时加1,否则清零;见以下代码。

//在HREF为高电平时,计数输出数据个数

//565模式下的计数器

always@(posedge PCLK or posedge Rst_p)

if(Rst_p)

Hcount_1 <= 0;

else if(r_Href)

Hcount_1 <= Hcount_1 + 1’d1;

else

Hcount_1 <= 0;

cmos输出采样的数据是8位的,需要将其转换成16位的RGB565数据模式输出。根据采样计数器的计数值奇偶情况输出数据,在计数值为偶数时,将采样的8位数据存到待输出像素数据的高字节,在计数值为奇数时,将数据存到输出像素数据的低字节。见以下代码。

begin 

   if(!Hcount_1[0])

r_DataPixel[15:8] <= r_Data;

   else 

r_DataPixel[7:0] <= r_Data;

end

在此数据拼接过程中,相当于每两个时钟像素完成了一次数据输出,需要指定输出数据有效的标志,以避免错误的数据输出。在将两个单字节采样数据拼接成一个两个字节数据的过程中,第一个时刻的采样数据给到输出数据的高字节,第二个时刻的采样数据给到输出数据的低字节,并且采样计数器从0开始计数,因此指定当采样计数器为奇数时数据有效。见以下代码。

begin 

if(Hcount_1[0] && r_Href)

              r_DataValid <= 1;

      else

              r_DataValid <= 0;

end

为保证传图的稳定性,传感器开始工作时舍弃前10帧,见以下代码。

/*帧计数器,对每次系统开始运行后的前10帧图像进行计数*/

  always@(posedge PCLK or posedge Rst_p)

  if(Rst_p)  

    FrameCnt <= 0;

  else if({
r_Vsync,Vsync}== 2’b01)begin

    if(FrameCnt >= 10)

      FrameCnt <= 4’d10;

    else

      FrameCnt <= FrameCnt + 1’d1;

    end

  else

    FrameCnt <= FrameCnt;

  /*舍弃每次系统开始运行后的前10帧图像的数据,以确保输出图像稳定*/

  always@(posedge PCLK or posedge Rst_p)

  if(Rst_p)

    dump_frame <= 0;

  else if(FrameCnt >= 10)

    dump_frame <= 1’d1;

  else

dump_frame <= 0;

AXI_stream 接口部分:

   此部分主要作用是产生AXI_stream 接口相关的信号。

   根据AXI_stream时序,主机和从机之间需要建立握手信号以传输数据。利用主机的数据有效标志信号(m_axis_video_tvaild)以及从机的响应信号(m_axis_video_tready)实现握手协议。

   m_axis_video_tlast在AXI_stream中为传输的一个数据包的的边缘,在这里可给定为一行的结束,可由对行同步信号的边沿检测确定;m_axis_video_tuser为用户自定义的数据包边界信号,这里给定为一帧的开始,可由对帧同步信号的边沿检测得到。

   由于DVP模块由cmos产生的像素时钟产驱动,而AXI_stream接口的数据传输由系统时钟驱动,在模块中添加异步fifo保证信号的同步性。使用这个fifo有两个目的:

  1. 处理跨时钟域问题。

    2.为视频数据输入和AXI_stream 流数据输出的缓冲。

结合数据的有效信号、从机的响应信号来确定异步fifo的读写使能。

xpm_fifo_async_inst (

      .rst              (~cmos_aresetn),

      .wr_clk           (cmos_pclk),

      .wr_en            (s_axis_tvalid & fifo_ready),

      .din              ({
s_axis_tdata,s_axis_tlast,s_axis_tuser}),

      .full             (full),

      .overflow         (),

      .prog_full        (),

      .wr_data_count    (),

      .almost_full      (),

      .wr_ack           (),

      .wr_rst_busy      (),

      .rd_clk           (m_axis_video_aclk),

      .rd_en            (m_axis_video_tready & ~empty & fifo_ready_maxis),

      .dout          ({
m_axis_video_tdata,m_axis_video_tlast,m_axis_video_tuser}),

      .empty            (empty),

      .underflow        (),

      .rd_rst_busy      (),

      .prog_empty       (),

      .rd_data_count    (),

      .almost_empty     (),

      .data_valid       (),

      .sleep            (1’b0),

      .injectsbiterr    (1’b0),

      .injectdbiterr    (1’b0),

      .sbiterr          (),

      .dbiterr          ()

)

3.端口说明

fpga学习——zynq图像处理中的DVP流接口封装

 图2.DVP_AXI stream IP 核端口示意图

fpga学习——zynq图像处理中的DVP流接口封装

注:在实际使用时,cmos传感器根据设置输出相应的的采样时钟,如1280*720p时为84Mhz。

4.功能仿真

编写TestBench文件,模拟cmos采样信号的输入,观察输出波形,分析功能是否达到要求。

首先生成时钟,总共两路时钟,一路是的输入的采样时钟,这里用50MHz代替,一路是AXI_stream接口的驱动时钟,这里用100Mhz代替。此外,产生行、场同步信号的同时利用循环产生像素数据。这里简化了“一帧”的像素数量,简化为每帧12行,每行16个数据,循环出15帧TestBench 主要内容如下:

//产生时钟

  initial cmos_pclk = 1;

  always # 10 cmos_pclk = ~cmos_pclk;//cmos像素时钟设置为50M,实际使用时84M等

  initial m_axis_video_aclk = 1;

  always # 5 m_axis_video_aclk = ~m_axis_video_aclk;//AXIS接口驱动时钟设置为100M,实际使用时150M等

//产生行、场同步信号和视频数据,这里简化了“一帧”的像素数量,简化为每帧12行,每行16个数据,循环出15帧

parameter WIDTH = 16;

    parameter HIGHT = 12;

    integer i,j;

    initial begin

    m_axis_video_aresetn = 0;  

    m_axis_video_tready=0; 

    cmos_vsync=0;

    cmos_href=0;

    cmos_d=8’hff;

    #200;

    m_axis_video_aresetn = 1; 

    m_axis_video_tready=1; 

    #200;

    repeat(15)begin

      cmos_vsync = 1;

      #100;

      cmos_vsync = 0;

      #200;

      for(i=0;i<HIGHT;i=i+1)

      begin

        for(j=0;j<WIDTH;j=j+1)

      begin

          cmos_href=1;

          cmos_d=cmos_d1;

          #20;

       end

        cmos_href=0;

        #100;

       end

       end

        $stop;

       end

endmodule

fpga学习——zynq图像处理中的DVP流接口封装

图3.DVP_AXI stream 模块功能仿真波形图 

5.使用说明

此设计输出的是RGB 565模式,AXI_stream主机接口用于与PS端的数据交互,通过vivado自带的VDMA IP进行视频流数据的内存读写。此外,实际应用时,用于HDMI接口的显示模块输入的是RGB888模式的24位数据,可在此IP后接入vivado自带的视频流位宽转换IP——AXI4_Stream_Subset_Converter,将RGB565转换为RGB888模式输出。端口连接如下图所示。

fpga学习——zynq图像处理中的DVP流接口封装

 图4.DVP_AXI stream IP与AXI4_Stream_Subset_Converter IP的连接

有需要工程的朋友可以联系我,感谢各位同学,欢迎指正,一块学习进步!

源代码如下:

1.top


`timescale 1ns / 1ps
module userIP_ov5640
#(
   parameter BUFFER_DEPTH = 4096
)
(
    input                                        cmos_vsync,       //cmos vsync
    input                                        cmos_href,        //cmos hsync refrence
    input                                        cmos_pclk,        //cmos pxiel clock
    input   [9:0]                                cmos_d,           //cmos data

    // AXI4-Stream signals
    input                                        m_axis_video_aclk,     // AXI4-Stream clock
    input                                        m_axis_video_aresetn,  // AXI4-Stream reset, active low
    output [15:0]                                m_axis_video_tdata,    // AXI4-Stream data
    output                                       m_axis_video_tvalid,   // AXI4-Stream valid
    input                                        m_axis_video_tready,   // AXI4-Stream ready
    output                                       m_axis_video_tuser,    // AXI4-Stream tuser (SOF)
    output                                       m_axis_video_tlast,    // AXI4-Stream tlast (EOL)
    output[1:0]                                  m_axis_video_tkeep     // AXI4-Stream tkeep
    
    );
assign        m_axis_video_tkeep = 2'b11;
wire[15:0]    cmos_d_16bit;
wire          cmos_href_16bit;
reg[7:0]      cmos_d_d0;
reg           cmos_href_d0;
reg           cmos_vsync_d0;
reg           cmos_vsync_d1;
wire          cmos_hblank;
reg           s_axis_tlast;
reg           s_axis_tuser;
wire          s_axis_tready;
reg           cmos_hblank_d0;
reg           cmos_hblank_d1;
reg           cmos_href_16bit_d0;
reg           cmos_href_16bit_d1;
reg[15:0]     cmos_d_16bit_d0;
reg[15:0]     cmos_d_16bit_d1;
wire          s_axis_tvalid = cmos_href_16bit_d1 & cmos_hblank_d1 & s_axis_tready;   //dvp输出有效数据到axis接口的标志信号
wire[15:0]    s_axis_tdata = cmos_d_16bit_d1;                                       //dvp输出的数据连接到axis接口
reg[31:0]     reset_cnt;
reg[31:0]     fifo_ready_cnt;
reg           fifo_ready;
reg           cmos_aresetn;
//reg           axis_reset;
reg           fifo_ready_maxis;
 
 //信号同步
always@(posedge m_axis_video_aclk)
begin
   // axis_reset <= cmos_aresetn;
    fifo_ready_maxis <= fifo_ready;
end

//产生DVP复位信号
always@(posedge cmos_pclk)
begin
    if(reset_cnt < 32'd200_000_000)   //延时复位,可改变大小
    begin
        reset_cnt <= reset_cnt + 32'd1;
        cmos_aresetn <= 1'b0;
    end
    else
    begin
        cmos_aresetn <= 1'b1;
    end
end

always@(posedge cmos_pclk)
begin
    if(cmos_aresetn == 1'b0)
    begin
        fifo_ready_cnt <= 32'd0;
        fifo_ready <= 1'b0;
    end
    else if(fifo_ready_cnt < 32'd100_000_000)
    begin
        fifo_ready_cnt <= fifo_ready_cnt + 32'd1;
        fifo_ready <= 1'b0;
    end
    else
    begin
        fifo_ready <= 1'b1;
    end
end

always@(posedge cmos_pclk)
begin
    if(cmos_aresetn == 1'b0)
    begin
        cmos_d_d0 <= 8'd0;
        cmos_href_d0 <= 1'b0;
        cmos_vsync_d0 <= 1'b0;
        cmos_vsync_d1 <= 1'b0;
    end
    else
    begin
        cmos_d_d0 <= cmos_d[9:2];
        cmos_href_d0 <= cmos_href;
        cmos_vsync_d0 <= cmos_vsync;
        cmos_vsync_d1 <= cmos_vsync_d0;
    end    
end
//例化DVP模块
DVP DVP0
(
  . Rst_p(~cmos_aresetn),                        
  . PCLK(cmos_pclk),                  //像素时钟
  . Vsync(cmos_vsync_d0),             //帧同步
  . Href(cmos_href_d0),               //行刷新
  . Data(cmos_d_d0),                  //采样数据         

  .ImageState(),                     //采样状态
  .DataValid(cmos_href_16bit),       //数据有效标志
  .DataPixel(cmos_d_16bit),          //并行数据输出
  .DataHs(cmos_hblank),              //行同步信号输出          
  .DataVs(),                         //帧同步信号输出
  .Xaddr(),                          //行方向数据采样地址
  .Yaddr()                           //列方向数据采样地址
);
always@(posedge cmos_pclk)
begin
    if(cmos_aresetn == 1'b0)
    begin
        cmos_hblank_d0 <= 1'b0;
        cmos_hblank_d1 <= 1'b0;
        cmos_d_16bit_d0 <= 1'b0;
        cmos_d_16bit_d1 <= 1'b0;
        cmos_href_16bit_d0 <= 1'b0;
        cmos_href_16bit_d1 <= 1'b0;
        s_axis_tlast <= 1'b0;
    end
    else
    begin
        cmos_hblank_d0 <= cmos_hblank;
        cmos_hblank_d1 <= cmos_hblank_d0;
        cmos_d_16bit_d0 <= cmos_d_16bit;
        cmos_d_16bit_d1 <= cmos_d_16bit_d0;
        cmos_href_16bit_d0 <= cmos_href_16bit;
        cmos_href_16bit_d1 <= cmos_href_16bit_d0;
        s_axis_tlast <= cmos_hblank_d0 & ~cmos_hblank;  //s_axis_tlast一行的开始
    end    
end

always@(posedge cmos_pclk)
begin
    if(cmos_aresetn == 1'b0)
        s_axis_tuser <= 1'b0;
    else if(cmos_vsync_d1 == 1'b1 && cmos_vsync_d0 == 1'b0) //标志着一帧的最开始的数据
        s_axis_tuser <= 1'b1;
    else if(s_axis_tuser == 1'b1 && s_axis_tvalid == 1'b1) //数据有效后s_axis_tuser 置零,s_axis_tuser 只保持几个周期的高电平
        s_axis_tuser <= 1'b0;
end


wire empty;
wire full;
assign m_axis_video_tvalid = ~empty & m_axis_video_tready;
assign s_axis_tready = ~full;

//使用原语进行异步fifo例化
//使用这个fifo有两个目的:处理跨时钟域问题作为视频数据输入和AXI_stream 流数据输出的缓冲
xpm_fifo_async # (

  .FIFO_MEMORY_TYPE          ("auto"),           //string; "auto", "block", or "distributed";
  .ECC_MODE                  ("no_ecc"),         //string; "no_ecc" or "en_ecc";
  .RELATED_CLOCKS            (0),                //positive integer; 0 or 1
  .FIFO_WRITE_DEPTH          (BUFFER_DEPTH),     //positive integer
  .WRITE_DATA_WIDTH          (18),               //positive integer
  .WR_DATA_COUNT_WIDTH       (12),               //positive integer
  .PROG_FULL_THRESH          (10),               //positive integer
  .FULL_RESET_VALUE          (0),                //positive integer; 0 or 1
  .USE_ADV_FEATURES          ("0707"),           //string; "0000" to "1F1F";
  .READ_MODE                 ("fwft"),            //string; "std" or "fwft";
  .FIFO_READ_LATENCY         (0),                //positive integer;
  .READ_DATA_WIDTH           (18),               //positive integer
  .RD_DATA_COUNT_WIDTH       (12),               //positive integer
  .PROG_EMPTY_THRESH         (10),               //positive integer
  .DOUT_RESET_VALUE          ("0"),              //string
  .CDC_SYNC_STAGES           (2),                //positive integer
  .WAKEUP_TIME               (0)                 //positive integer; 0 or 2;

) xpm_fifo_async_inst (

      .rst              (~cmos_aresetn),
      .wr_clk           (cmos_pclk),
      .wr_en            (s_axis_tvalid & fifo_ready),
      .din              ({s_axis_tdata,s_axis_tlast,s_axis_tuser}),
      .full             (full),
      .overflow         (),
      .prog_full        (),
      .wr_data_count    (),
      .almost_full      (),
      .wr_ack           (),
      .wr_rst_busy      (),
      .rd_clk           (m_axis_video_aclk),
      .rd_en            (m_axis_video_tready & ~empty & fifo_ready_maxis),
      .dout             ({m_axis_video_tdata,m_axis_video_tlast,m_axis_video_tuser}),
      .empty            (empty),
      .underflow        (),
      .rd_rst_busy      (),
      .prog_empty       (),
      .rd_data_count    (),
      .almost_empty     (),
      .data_valid       (),
      .sleep            (1'b0),
      .injectsbiterr    (1'b0),
      .injectdbiterr    (1'b0),
      .sbiterr          (),
      .dbiterr          ()

);
endmodule

 

`timescale 1ns / 1ps

///coms数据采集模块
///

module DVP#(
          parameter coms_mode=16
)
(
  input Rst_p,                        
  input PCLK,                          //像素时钟
  input Vsync,                         //帧同步
  input Href,                          //行刷新
  input [7:0] Data,                    //采样数据         

  output reg ImageState,                     //采样状态
  output DataValid,                          //数据有效标志
  output [coms_mode-1:0] DataPixel,          //并行数据输出
  output DataHs,                             //行同步信号输出          
  output DataVs,                             //帧同步信号输出
  output [11:0] Xaddr,                       //行方向数据采样地址
  output [11:0] Yaddr                        //列方向数据采样地址
);

//内部寄存器
  reg r_Vsync;
  reg r_Href;
  reg [7:0] r_Data;

  reg [coms_mode-1:0]r_DataPixel;
  reg r_DataValid;
  reg r_DataHs;    
  reg r_DataVs;
  reg [12:0]Hcount_1;                        //565模式下的采样数据计数器
  reg [11:0]Vcount;                          //使用Vcount计数器对HREF信号的高电平进行计数,统计一帧图像中的每一行图像的行号
  reg [3:0] FrameCnt;                        //帧计数器,前10帧舍弃
  reg [12:0]Hcount_2;                        //888模式下的采样数据计数器
  reg [12:0]Xaddr_cnt;                       //行方向数据采样地址计数器
  reg dump_frame;                            //帧计数器计满10帧的标志

  //等到初始化摄像完成且头场同步信号出现,释放清零信号,开始写入数据
  always@(posedge PCLK or posedge Rst_p)
  if (Rst_p)  
    ImageState <= 1'b1;
  else if(r_Vsync)
    ImageState <= 1'b0;

  //对DVP接口的数据使用寄存器打一拍,以用信号边沿检测功能
  always@(posedge PCLK)
  begin
    r_Vsync <= Vsync;
    r_Href  <= Href;
    r_Data  <= Data;
  end

  //在HREF为高电平时,计数输出数据个数
  //565模式下的计数器
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
    Hcount_1 <= 0;
  else if(r_Href)
    Hcount_1 <= Hcount_1 + 1'd1;
  else
    Hcount_1 <= 0;
	
   //888模式下的计数器
    always@(posedge PCLK or posedge Rst_p)
    if(Rst_p)
      Hcount_2 <= 0;
    else if(r_Href)begin
      if (Hcount_2==2)
      Hcount_2<=0;
      else
      Hcount_2 <= Hcount_2 + 1'd1;
      end  
    else
      Hcount_2 <= 0;

  /*565模式下:根据计数器的计数值奇数和偶数的区别,在计数器为偶数时,
  将DVP接口数据端口上的数据存到输出像素数据的高字节,在计
  数器为奇数时,将DVP接口数据端口上的数据存到输出像素数据
  的低字节*/
  //888模式下,根据计数器的值控制r/g/b的数据,计数器位0时数据给datapixel的高位.....
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
      r_DataPixel <= 0;
  else if ((coms_mode==24)&&r_Href)begin 
      if (Hcount_2==0)
			r_DataPixel[23:16] <= r_Data;
	  else if (Hcount_2==1)
			r_DataPixel[15:8] <= r_Data;
	  else if (Hcount_2==2)
			r_DataPixel[7:0] <= r_Data;
	  end
  else begin 
	   if(!Hcount_1[0])
			r_DataPixel[15:8] <= r_Data;
	   else 
			r_DataPixel[7:0] <= r_Data;
	    end

  /*rgb565在行计数器计数值为奇数,且HREF高电平期间,产生输出;
  rgb888模式,计数到2有效数据有效信号*/
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
         r_DataValid <= 0;
   else if ((coms_mode==24))begin
         if ((Hcount_2==2)&&r_Href)
           r_DataValid <= 1;
         else
           r_DataValid <= 0;
         end
     else 
       begin if(Hcount_1[0] && r_Href)
              r_DataValid <= 1;
               else
                 r_DataValid <= 0;
        end
    
  always@(posedge PCLK)
  begin
    r_DataHs <= r_Href;
    r_DataVs <= ~r_Vsync;
  end

  /*使用Vcount计数器对HREF信号的高电平进行计数,统计
  一帧图像中的每一行图像的行号*/
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
    Vcount <= 0;
  else if(r_Vsync)
    Vcount <= 0;
  else if({r_Href,Href} == 2'b01)
    Vcount <= Vcount + 1'd1;
  else
    Vcount <= Vcount;

  /*输出X地址*/  
  assign Yaddr = Vcount;

  /*对于RGB565模式,由于一行N个像素的图像输出2N个数据,所以Hcount_1计数值为N的2倍,将该计数值除以2后即可作为Xaddr输出;
  对于RGB88模式,由于一行N个像素的图像输出3N个数据,所以Xaddr可以通过对Hcount_2计数满2确定*/
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
    Xaddr_cnt <= 0;
  else if(r_Href)begin
    if(Hcount_2==2)
       Xaddr_cnt <=Xaddr_cnt+1;
    else
          Xaddr_cnt <=Xaddr_cnt;
     end
   else 
         Xaddr_cnt <=0;
  assign Xaddr = (coms_mode==24)?Xaddr_cnt:Hcount_1[12:1];

  /*帧计数器,对每次系统开始运行后的前10帧图像进行计数*/
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)  
    FrameCnt <= 0;
  else if({r_Vsync,Vsync}== 2'b01)begin
    if(FrameCnt >= 10)
      FrameCnt <= 4'd10;
    else
      FrameCnt <= FrameCnt + 1'd1;
  end
  else
    FrameCnt <= FrameCnt;

  /*舍弃每次系统开始运行后的前10帧图像的数据,以确保输出图像稳定*/
  always@(posedge PCLK or posedge Rst_p)
  if(Rst_p)
    dump_frame <= 0;
  else if(FrameCnt >= 10)
    dump_frame <= 1'd1;
  else
    dump_frame <= 0;

  assign DataPixel = r_DataPixel;
  assign DataValid = r_DataValid & dump_frame;
  assign DataHs = r_DataHs & dump_frame;
  assign DataVs = r_DataVs & dump_frame;

endmodule

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

发布者:全栈程序员-用户IM,转载请注明出处:https://javaforall.cn/138592.html原文链接:https://javaforall.cn

【正版授权,激活自己账号】: Jetbrains全家桶Ide使用,1年售后保障,每天仅需1毛

【官方授权 正版激活】: 官方授权 正版激活 支持Jetbrains家族下所有IDE 使用个人JB账号...

(0)


相关推荐

发表回复

您的电子邮箱地址不会被公开。

关注全栈程序员社区公众号