• S02_CH12_ AXI_Lite 总线详解


    S02_CH12_ AXI_Lite 总线详解

    12.1前言

    ZYNQ拥有ARM+FPGA这个神奇的架构,那么ARM和FPGA究竟是如何进行通信的呢?本章通过剖析AXI总线源码,来一探其中的秘密。

    12.2 AXI总线与ZYNQ的关系

    AXI(Advanced eXtensible Interface)本是由ARM公司提出的一种总线协议,Xilinx从6系列的FPGA开始对AXI总线提供支持,此时AXI已经发展到了AXI4这个版本,所以当你用到Xilinx的软件的时候看到的都是“AIX4”的IP,如Vivado打包一个AXI IP的时候,看到的都是Create a new AXI4 peripheral。

    到了ZYNQ就更不必说了,AXI总线更是应用广泛,双击查看ZYNQ的IP核的内部配置,随处可见AXI的身影。

    12.3 AXI总线和AXI接口以及AXI协议

    总线、接口和协议,这三个词常常被联系在一起,但是我们心里要明白他们的区别。

    总线是一组传输通道,是各种逻辑器件构成的传输数据的通道,一般由由数据线、地址线、控制线等构成。接口是一种连接标准,又常常被称之为物理接口。

    协议就是传输数据的规则。

    12.3.1 AXI总线概述

    在ZYNQ中有支持三种AXI总线,拥有三种AXI接口,当然用的都是AXI协议。其中三种AXI总线分别为:

    AXI4:(For high-performance memory-mapped requirements.)主要面向高性能地址映射通信的需求,是面向地址映射的接口,允许最大256轮的数据突发传输;

    AXI4-Lite:(For simple, low-throughput memory-mapped communication )是一个轻量级的地址映射单次传输接口,占用很少的逻辑单元。

    AXI4-Stream:(For high-speed streaming data.)面向高速流数据传输;去掉了地址项,允许无限制的数据突发传输规模。

    首先说AXI4总线和AXI4-Lite总线具有相同的组成部分:

    (1)读地址通道,包含ARVALID, ARADDR, ARREADY信号;

    (2)读数据通道,包含RVALID, RDATA, RREADY, RRESP信号;

    (3)写地址通道,包含AWVALID,AWADDR, AWREADY信号;

    (4)写数据通道,包含WVALID, WDATA,WSTRB, WREADY信号;

    (5)写应答通道,包含BVALID, BRESP, BREADY信号;

    (6)系统通道,包含:ACLK,ARESETN信号。

    AXI4总线和AXI4-Lite总线的信号也有他的命名特点:

    读地址信号都是以AR开头(A:address;R:read)

    写地址信号都是以AW开头(A:address;W:write)

    读数据信号都是以R开头(R:read)

    写数据信号都是以W开头(W:write)

    应答型号都是以B开头(B:back(answer back))

    了解到总线的组成部分以及命名特点,那么在后续的实验中您将逐渐看到他们的身影。每个信号的作用暂停不表,放在后面一一介绍。

    而AXI4-Stream总线的组成有:

    (1)ACLK信号:总线时钟,上升沿有效;

    (2)ARESETN信号:总线复位,低电平有效

    (3)TREADY信号:从机告诉主机做好传输准备;

    (4)TDATA信号:数据,可选宽度32,64,128,256bit

    (5)TSTRB信号:每一bit对应TDATA的一个有效字节,宽度为TDATA/8

    (6)TLAST信号:主机告诉从机该次传输为突发传输的结尾;

    (7)TVALID信号:主机告诉从机数据本次传输有效;

    (8)TUSER信号 :用户定义信号,宽度为128bit。

    对于AXI4-Stream总线命名而言,除了总线时钟和总线复位,其他的信号线都是以T字母开头,后面跟上一个有意义的单词,看清这一点后,能帮助读者记忆每个信号线的意义。如TVALID = T+单词Valid(有效),那么读者就应该立刻反应该信号的作用。每个信号的具体作用,在后面分析源码时再做分析

    12.3.2 AXI接口介绍

    三种AXI接口分别是:

    AXI-GP接口(4个):是通用的AXI接口,包括两个32位主设备接口和两个32位从设备接口,用过改接口可以访问PS中的片内外设。

    AXI-HP接口(4个):是高性能/带宽的标准的接口,PL模块作为主设备连接(从下图中箭头可以看出)。主要用于PL访问PS上的存储器(DDR和On-Chip RAM)

    AXI-ACP接口(1个):是ARM多核架构下定义的一种接口,中文翻译为加速器一致性端口,用来管理DMA之类的不带缓存的AXI外设,PS端是Slave接口。

    我们可以双击查看ZYNQ的IP核的内部配置,就能发现上述的三种接口,图中已用红色方框标记出来,我们可以清楚的看出接口连接与总线的走向:

    wps63CB.tmp

    12.3.3 AXI协议概述

    讲到协议不可能说是撇开总线单讲协议,因为协议的制定也是要建立在总线构成之上的。虽然说AXI4,AXI4-Lite,AXI4-Stream都是AXI4协议,但是各自细节上还是不同的。

    总的来说,AXI总线协议的两端可以分为分为主(master)、从(slave)两端,他们之间一般需要通过一个AXI Interconnect相连接,作用是提供将一个或多个AXI主设备连接到一个或多个AXI从设备的一种交换机制。当我们添加了zynq以及带AXI的IP后再进行自动连线时vivado会自动帮我们添加上这个IP,大家应该是不陌生了。

    AXI Interconnect的主要作用是,当存在多个主机以及从机器时,AXI Interconnect负责将它们联系并管理起来。由于AXI支持乱序发送,乱序发送需要主机的ID信号支撑,而不同的主机发送的ID可能相同,而AXI Interconnect解决了这一问题,他会对不同主机的ID信号进行处理让ID变得唯一。

    wps63CC.tmp

    AXI协议将读地址通道,读数据通道,写地址通道,写数据通道,写响应通道分开,各自通道都有自己的握手协议。每个通道互不干扰却又彼此依赖。这也是AXI高效的原因之一。

    12.3.4  AXI协议之握手协议

    AXI4所采用的是一种READY,VALID握手通信机制,简单来说主从双方进行数据通信前,有一个握手的过程。传输源产生VLAID信号来指明何时数据或控制信息有效。而目地源产生READY信号来指明已经准备好接受数据或控制信息。传输发生在VALID和READY信号同时为高的时候。VALID和READY信号的出现有三种关系。

    (1) VALID先变高READY后变高。时序图如下:

    wps63CD.tmp

    在箭头处信息传输发生。

    (2) READY先变高VALID后变高。时序图如下:

    wps63CE.tmp

    同样在箭头处信息传输发生。

    (3) VALID和READY信号同时变高。时序图如下:

    wps63DF.tmp

    在这种情况下,信息传输立马发生,如图箭头处指明信息传输发生。

    需要强调的是,AXI的五个通道,每个通道都有握手机制,接下来我们就来分析一下AXI-Lite的源码来更深入的了解AXI机制。

    12.3.5 突发式读写

    1、突发式读的时序图如下:

    wps63E0.tmp

    当地址出现在地址总线后,传输的数据将出现在读数据通道上。设备保持VALID为低直到读数据有效。为了表明一次突发式读写的完成,设备用RLAST信号来表示最后一个被传输的数据。

    2、 突发式写时序图如下:wps63E1.tmp

    这一过程的开始时,主机发送地址和控制信息到写地址通道中,然后主机发送每一个写数据到写数据通道中。当主机发送最后一个数据时,WLAST信号就变为高。当设备接收完所有数据之后他将一个写响应发送回主机来表明写事务完成。

    12.4 AXI4-Lite详解

    12.4.1 AXI4-Lite源码查看

    Step1:要看到AXI-Lite的源码,我们先要自定义一个AXI-Lite的IP,新建工程之后,选择,菜单栏->Tools->Creat and Package IP:

    wps63F2.tmp

    Step2:选择Next

    wps63F3.tmp

    Step3:选择Create AXI4 Peripheral,然后Next:

    wps6403.tmp

    Step4:默认,选择Next

    wps6404.tmp

    Step5:注意这里接口类型选择Lite,选择Next:

    wps6405.tmp

    Step6:选择Edit IP,点击Finish:

    wps6416.tmp

    Step7:此后,Vivado会新建一个工程,专门编辑该IP,通过该工程,我们就可以看到Vivado为我们生成的AXI-Lite的操作源码:

    wps6417.tmp

    12.4.2 AXI-Lite 源码分析

    当打开顶层文件的时,映入眼帘的是一堆AXI的信号,这些信号是否似曾相识?

    input wire  s00_axi_aclk,

    input wire  s00_axi_aresetn,

    input wire [C_S00_AXI_ADDR_WIDTH-1 : 0] s00_axi_awaddr,

    input wire [2 : 0] s00_axi_awprot,

    input wire  s00_axi_awvalid,

    output wire  s00_axi_awready,

    input wire [C_S00_AXI_DATA_WIDTH-1 : 0] s00_axi_wdata,

    input wire [(C_S00_AXI_DATA_WIDTH/8)-1 : 0] s00_axi_wstrb,

    input wire  s00_axi_wvalid,

    output wire  s00_axi_wready,

    output wire [1 : 0] s00_axi_bresp,

    output wire  s00_axi_bvalid,

    input wire  s00_axi_bready,

    input wire [C_S00_AXI_ADDR_WIDTH-1 : 0] s00_axi_araddr,

    input wire [2 : 0] s00_axi_arprot,

    input wire  s00_axi_arvalid,

    output wire  s00_axi_arready,

    output wire [C_S00_AXI_DATA_WIDTH-1 : 0] s00_axi_rdata,

    output wire [1 : 0] s00_axi_rresp,

    output wire  s00_axi_rvalid,

    input wire  s00_axi_rready

    没错笔者曾在《AXI总线概述》这节中提到了他们,这次通过源码分析再次隆重介绍它们。

    地址通道

    数据通道

    ARVALID

    读地址有效。此信号表明该信道此时能有效读出地址和控制信息

    RVALID

    读数据有效。此信号表明该信道此时能有效读出数据

    ARADDR

    读地址

    RDATA

    读数据

    ARREADY

    读地址准备好了。该信号指示从器件准备好接受一个地址和相关联的控制信号

    RREADY

    读数据准备好了。该信号指示从器件准备好接收数据

    ARPROT

    保护类型。这个信号表示该事务的特权和安全级别,并确定是否该事务是一个数据存取或指令的访问

    RRESP

    读取响应。这个信号表明读事务处理的状态。

    地址通道

    数据通道

    应答通道

    AWVALID

    写地址有效。这个信号表示该主信令有效的写地址和控制信息。

    WVALID

    写有效。这个信号表示有效的写数据和选通信号都可用。

    BVALID

    写响应有效。此信号表明写命令的有效写入响应。

    AWADDR

    写地址

    WDATA

    写数据

    BREADY

    响应准备。该信号指示在主主机可以接受一个响应信号

    AWREADY

    写地址准备好了。该信号指示从器件准备好接受一个地址和相关联的控制信号

    WSTRB

    写选通。这个信号表明该字节通道持有效数据。每一bit对应WDATA一个字节

    BRESP

    写响应。这个信号表示写事务处理的状态。

    AWPROT

    写通道保护类型。这个信号表示该事务的特权和安全级别,并确定是否该事务是一个数据存取或指令的访问

    WREADY

    写准备好了。该信号指示从器件可以接受写数据。

    Vivado为我们生成的AXI-Lite的操作源码,是一个例子,我只需要读懂他,然后稍加修改,就可以为我们所用。

    我们先来看一段WDATA相关的代码:

    always @( posedge S_AXI_ACLK )

    begin

      if ( S_AXI_ARESETN == 1'b0 )

        begin

          slv_reg0 <= 0;

          slv_reg1 <= 0;

          slv_reg2 <= 0;

          slv_reg3 <= 0;

        end

      else begin

        if (slv_reg_wren)

          begin

            case ( axi_awaddr[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB] )

              2'h0:

                for ( byte_index = 0; byte_index <= (C_S_AXI_DATA_WIDTH/8)-1; byte_index = byte_index+1 )

                  if ( S_AXI_WSTRB[byte_index] == 1 ) begin

                    // Respective byte enables are asserted as per write strobes

                    // Slave register 0

                    slv_reg0[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

                  end  

              2'h1:

                for ( byte_index = 0; byte_index <= (C_S_AXI_DATA_WIDTH/8)-1; byte_index = byte_index+1 )

                  if ( S_AXI_WSTRB[byte_index] == 1 ) begin

                    // Respective byte enables are asserted as per write strobes

                    // Slave register 1

                    slv_reg1[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

                  end  

              2'h2:

                for ( byte_index = 0; byte_index <= (C_S_AXI_DATA_WIDTH/8)-1; byte_index = byte_index+1 )

                  if ( S_AXI_WSTRB[byte_index] == 1 ) begin

                    // Respective byte enables are asserted as per write strobes

                    // Slave register 2

                    slv_reg2[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

                  end  

              2'h3:

                for ( byte_index = 0; byte_index <= (C_S_AXI_DATA_WIDTH/8)-1; byte_index = byte_index+1 )

                  if ( S_AXI_WSTRB[byte_index] == 1 ) begin

                    // Respective byte enables are asserted as per write strobes

                    // Slave register 3

                    slv_reg3[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

                  end  

              default : begin

                          slv_reg0 <= slv_reg0;

                          slv_reg1 <= slv_reg1;

                          slv_reg2 <= slv_reg2;

                          slv_reg3 <= slv_reg3;

                        end

            endcase

          end

      end

    end   

    这段程序的作用是,当PS那边向AXI4-Lite总线写数据时,PS这边负责将数据接收到寄存器slv_reg。而slv_reg寄存器有0~3共4个。至于赋值给哪一个由

    axi_awaddr[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB]决定,根据宏定义其实就是由axi_awaddr[3:2] (写地址中不仅包含地址,而且包含了控制位,这里的[3:2]就是控制位)决定赋值给哪个slv_reg。

    PS调用写函数时,如果不做地址偏移的话,axi_awaddr[3:2]的值默认是为0的,举个例子,如果我们自定义的IP的地址被映射为0x43C00000,那么我们Xil_Out32(0x43C00000,Value)写的就是slv_reg0的值。如果地址偏移4位,如

    Xil_Out32(0x43C00000 + 4,Value) 写的就是slv_reg1的值,依次类推。

    分析时只关注slv_reg0(其他结构上也是一模一样的):

    for ( byte_index = 0; byte_index <= (C_S_AXI_DATA_WIDTH/8)-1; byte_index = byte_index+1 )

      if ( S_AXI_WSTRB[byte_index] == 1 ) begin

    slv_reg0[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

      end

    其中,C_S_AXI_DATA_WIDTH的宏定义的值为32,也就是数据位宽,S_AXI_WSTRB就是写选通信号,S_AXI_WDATA就是写数据信号。

    存在于for循环中的最关键的一句:

    slv_reg0[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];

    当byte_index = 0的时候这句话就等价于:

    slv_reg0[7:0] <= S_AXI_WDATA[7:0];

    当byte_index = 1的时候这句话就等价于:

    slv_reg0[15:8] <= S_AXI_WDATA[15:8];

    当byte_index = 2的时候这句话就等价于:

    slv_reg0[23:16] <= S_AXI_WDATA[23:16];

    当byte_index = 3的时候这句话就等价于:

    slv_reg0[31:24] <= S_AXI_WDATA[31:24];

    也就是说,只有当写选通信号为1时,它所对应S_AXI_WDATA的字节才会被读取。

    读懂了这段话之后,我们就知道了,如果我们想得到PS写到总线上的数据,我们只需要读取slv_reg0的值即可。

    那如果,我们想写数据到总线让PS读取该数据,我们该怎么做呢?我们继续来看有关RADTA读数据代码:

    // Output register or memory read data

    always @( posedge S_AXI_ACLK )

    begin

      if ( S_AXI_ARESETN == 1'b0 )

        begin

          axi_rdata  <= 0;

        end

      else

        begin    

          // When there is a valid read address (S_AXI_ARVALID) with

          // acceptance of read address by the slave (axi_arready),

          // output the read dada

          if (slv_reg_rden)

            begin

              axi_rdata <= reg_data_out;     // register read data

            end   

        end

    end

    观察可知,当PS读取数据时,程序会把reg_data_out复制给axi_rdata(RADTA读数据)。我们继续追踪reg_data_out:

    always @(*)

    begin

          // Address decoding for reading registers

          case ( axi_araddr[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB] )

            2'h0   : reg_data_out <= slv_reg0;

            2'h1   : reg_data_out <= slv_reg1;

            2'h2   : reg_data_out <= slv_reg2;

            2'h3   : reg_data_out <= slv_reg3;

            default : reg_data_out <= 0;

          endcase

    end

    和前面分析的一样此时通过判断axi_awaddr[3:2]的值来判断将那个值给reg_data_out上,同样当PS调用读取函数时,这里axi_awaddr[3:2]默认是0,所以我们只需要把slv_reg0替换成我们自己数据,就可以让PS通过总线读到我们提供的数据。

    这里可能有的读者会问了,slv_reg0不是总线写过来的数据吗?因为笔者说过这个程序是Vivado为我们提供的例子,它这么做无非是想验证我写出去的值和我读进入的值相等。但是他怎么写确实会对初看代码的人造成困扰。

    最后笔者提出一个问题,为什么写通道要比读通道多了一列应答通道,这是为什么呢?

    首先,你要知道这个应答信号是干什么用的?

    wps6427.tmp

    写应答,主要是回复主机你这个写过程是没有问题的,那读为什么不需要这个过程呢?

    wps6428.tmp

    这时因为主机在读取数据时,从机可以直接通过读数据通道给主机反馈信息,因此就没有必要再来开辟一个单独的应答通道了。

    小结:

    如果我们想读AXI4_Lite总线上的数据时,只需关注slv_reg的数据,我们可自行添加一段代码,如:

    reg [11:0]rlcd_rgb;

        always @( posedge S_AXI_ACLK )

                begin

                  if ( S_AXI_ARESETN == 1'b0 )

                    begin

                        rlcd_rgb  <= 12'd0;

                    end

                  else

                    begin

                        rlcd_rgb <= slv_reg0[11:0];

                    end

                end  

        assign lcd_rgb = rlcd_rgb;

    如果我们想对AXI4_Lite信号写数据时,我们只需修改对reg_data_out的赋值,如:

    //写总线测试修改!!!!!!!!!

            wire[31:0]wlcd_xy;// = {10'd0,lcd_xy};

            assign wlcd_xy = {10'd0,lcd_xy};

            assign slv_reg_rden = axi_arready & S_AXI_ARVALID & ~axi_rvalid;

            always @(*)

            begin

                  // Address decoding for reading registers

                  case ( axi_araddr[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB] )

                    2'h0   : reg_data_out <= wlcd_xy;//slv_reg0;   

                    2'h1   : reg_data_out <= slv_reg1;

                    2'h2   : reg_data_out <= slv_reg2;

                    2'h3   : reg_data_out <= slv_reg3;

                    default : reg_data_out <= 0;

                  endcase

            end

    最后强调下如果我们自定义的IP的地址被映射为0x43C00000,那么我们Xil_Out32(0x43C00000,Value)写的就是slv_reg0的值。如果地址偏移4位,如

    Xil_Out32(0x43C00000 + 4,Value) 写的就是slv_reg1的值,依次类推。

    目前这里只有4个寄存器,那是因为之前选择的是4个,其实我们可以定义的更多:

    wps6439.tmp

    在ps的头文件里可以看到我们自定义的IP的地址是有个范围的

    #define XPAR_MYIPFREQUENCY_0_S00_AXI_BASEADDR 0x43C00000

    #define XPAR_MYIPFREQUENCY_0_S00_AXI_HIGHADDR 0x43C0FFFF

    理论上只要基地址 + 偏移量不要超过HIGHADDR即可。

    12.5 观察AXI4-Lite总线信号

    在第十章,我们封装了一个AXI_Lite的GPIO,通过本章的分析,我们在第十章工程的基础上通过添加一个ila核的方式,来具体看看AXI_Lite总线的信号。

    Step1:做好第十章工程的备份,然后直接打开第十章的工程。

    Step2:单击IP icon wps643A.tmp 添加 ila CORE

    wps643B.tmp

    Step3:双击打开ILA CORE

    wps644C.tmp

    Step4:双击打开ILA CORE

    General Options设置如下

    wps644D.tmp

    Probe_Ports设置如下,之后单击OK

    wps644E.tmp

    Step5:连接Probe0到GPIO_LED。

    Step6:连接CLK接口到FCLK_CLK0接口

    Step7:选中Processing_System7_0_axi_periph和GPIO_LITE_ML_0之间的S_AXI总线。

    Step8:右击选择Mark Debug

    wps645E.tmp

    Step9:接下来依然是,右键单击Block文件,文件选择Generate the Output Products。

    Step10:继续右键单击Block文件,选择Create a HDL wrapper,根据Block文件内容产生一个HDL 的顶层文件,并选择让vivado自动完成。

    Setp11:单击Run Synthesis,如果有 Save 对话框弹出选择保存。

    Setp12:综合结束后选择Synthesized Design option单击 OK。

    Step13:在如下对话框中找到Unassigned debug nets(如果对话框没有出现选择 菜单->Window > Debug)

    wps645F.tmp

    Step14:右击 Unassigned Debug Nets 选择Set up Debug… 之后单击 Next

    Step15:删除红色错误的信号然后单击Next 到结束

    wps6460.tmp

    Step16:生成Bit文件。

    12.6 加载到SDK

    Step1:导出硬件。

    Step2:右击工程,选择Debug as ->Debug configuration。

    Step3:选中system Debugger,双击创建一个系统调试。

    wps6471.tmp

    Step4:设置系统调试。

    wps6472.tmp

    Step5:回到VIVADO单击Open Target->Auto Connect

    wps6473.tmp

    Step6:加载完成后的界面

    wps6483.tmp

    Step7:选择菜单->window->Debugprobes 选择AXI_WVALID和AXI_AWVALID做为触发信号

    wps6484.tmp

    Step8:设置触发条件为1

    wps6485.tmp

    Step9:设置触发位置为512

    wps6496.tmp

    Step10:单击箭头所指向启动触发

    wps6497.tmp

    Step11:进入等待触发状态

    wps6498.tmp

    Step12:单击运行wps64A9.tmp后VIVADO  HW_ILA2 窗口采集到波形输出,可以看到AXI总线的工作时序。

    wps64AA.tmp

    Step13:HW_ILA1 窗口采集到的数据是GPIO_LED的值为0x02,同时可观察到开发板上的LED2亮起。

    wps64AB.tmp

    12.7 本章小结

    通过本章的学习,我们首先得认识到总线和接口以及协议的区别,其次通过分析AXI4-Lite,AXI4-Stream,AXI4总线的从机代码,对AXI协议有一定的认识,那么在后面学习AXI的一些IP时就不会有恐惧的心理。

    最后,我们再理一理AXI总线和AXI接口的关系。在ZYNQ中,支持AXI4-Lite,AXI4和AXI4-Stream三种总线协议,这前面已经说过了,要注意的是PS与PL之间的接口(AXI-GP接口,AXI-HP接口以及AXI-ACP接口)却只支持AXI-Lite和AXI协议这两种总线协议。也就是说PL这边的AXI-Stream的接口是不能直接与PS对接的,需要经过AXI4或者AXI4-Lite的转换。比如后面将用到的VDMA IP ,它就实现了在PL内部AXI4到AXI-Stream的转换,VDMA利用的接口就是AXI-HP接口。

  • 相关阅读:
    vue-router query和params参数的区别
    vue打包成app后,背景图片不显示
    vue打包成app后,点击手机上的物理返回按钮后直接退出app
    Echarts dataZoom 区域缩放
    CSS3 实现别样图型
    Echarts 不能百分比显示或显示有问题
    循环(数组循环、获取json数据循环)、each()循环详解
    页面数据加载完成时,显示loading页面.数据加载完,loading隐藏.
    获取可视区域高度赋值给div(解决document.body.clientHeight的返回值为0的问题)
    移动端rem布局,用户调整手机字体大小或浏览器字体大小后导致页面布局出错问题
  • 原文地址:https://www.cnblogs.com/milinker/p/6474706.html
Copyright © 2020-2023  润新知