基于ZYNQ VMDA的HDMI显示驱动练习

之前已经完成了z7020的bring up,系统起来了之后自然就要做外设的驱动了。作为一块开发板,自然少不了屏幕。鉴于这次使用的是zynq,那么就来完成一个最基础的direct frambuffer + vdma + hdmi驱动。

实现功能

这次实现的主要是zynq–>HDMI的部分。作为自己编写的第一个实际驱动,想要实现的功能也比较简单,EDID暂且不去读取,只要能够通过HDMI输出一个标准VESC信号即可。通过调整驱动内设定的时序(或者直接读取device tree中的attribute),改变输出时序,就可以在普通的显示器或者我们之前买好的屏幕上显示。

硬件部分

简单描述一下硬件部分的构成。
《基于ZYNQ VMDA的HDMI显示驱动练习》

软件部分

软件结构

由于我们使用的是zynq,所以涉及到PS和PL两个部分。首先通过框图了解一下结构。

PL部分

由于对FPGA了解不深,这里只对PL部分做简单描述(实际上也比较基础)。PS跟PL通信都是通过AXI总线进行的。我把HDMI分为了两个部分:
– 图像信号
PL部分使用vdma提供了一个framebuffer的缓冲,linux的framebuffer中的数据会通过AXI写入到vdma的buffer中。vdma输出的图像信号再输入steam to video out这个IP,再输出给rgb2dvi。
– 时序产生
图像的时钟信号采用dynclk产生,时序采用video timing controlloer这个IP产生,输出的时钟信号与之前vdma输出的图像信号一起送入stream to video out这个IP,产生RGB的图像输出。
上述提到的IP中,图像信号采用AXI stream方式写入,而各个IP的控制则使用的是AXI-LITE。各寄存器的地址可以在address editor中设定。这里就不具体描述各个IP的配置方法了,附上IP的框图,以及地址配置信息的截图。
《基于ZYNQ VMDA的HDMI显示驱动练习》
《基于ZYNQ VMDA的HDMI显示驱动练习》

linux驱动

完成了PL部分的配置之后,接下来就要实现linux部分的驱动了。结合我们之前PL部分所说,我们所需要实现的有以下几个设备的驱动:framebuffer, VDMA, video timing controller, dynclk。乍一看上去非常吓人,先别急,其实大部分的设备xilinx官方已经为我们提供了驱动,我们需要完成的是在device tree中添加相应的device,以及在我们自己的HDMI driver中配置各设备。

framebuffer

首先我们要了解的是,虽然我们给这次的驱动起名为HDMI驱动,但是事实上我们在Linux系统中的显示数据都最后输出给了基于framebuffer子系统的显示设备。所以我们首先需要了解framebuffer子系统。
framebuffer为系统提供了一个显示接口,它将显示缓冲区进行抽象,允许应用程序在图形模式下直接对显示缓冲区进行操作。通常来说framebuffer设备会在dev下提供一个设备文件,将它映射到进程空间之后就可以直接进行读写操作,写入的数据就会直接反应在屏幕上。我们通过以下的方法初始化一个framebuffer设备:
首先初始化struct fb_info
fb_info结构体描述了一个framebuffer设备,在调用register_framebuffer接口之前,我们需要填充其中的成员。

drvdata->info.device = &pdev->dev;
drvdata->info.par = drvdata;//private data
/*visual address of buffer*/
drvdata->info.screen_buffer = drvdata->fb_virtual;//virtual address of screen mem

drvdata->info.fbops = &xilinx_vdma_fb_ops;//frame options
drvdata->info.fix = xilinx_vdma_fb_fix;//显示相关固定参数
drvdata->info.fix.smem_start = drvdata->fb_phy; /*screen mem start address(physical address)*/
drvdata->info.fix.smem_len = drvdata->fb_conf.resolution_height * drvdata->fb_conf.resolution_width * BITS_PER_PIXEL / 8;
drvdata->info.fix.line_length = drvdata->fb_conf.resolution_width * BITS_PER_PIXEL / 8;

drvdata->info.pseudo_palette = drvdata->pseudo_palette;//存放调色盘所用的数组
drvdata->info.flags = FBINFO_DEFAULT;
drvdata->info.var = xilinx_fb_var;//显示相关变量
drvdata->info.var.height = drvdata->fb_conf.resolution_height;//高度
drvdata->info.var.width = drvdata->fb_conf.resolution_width;//宽度

drvdata->info.var.xres = drvdata->fb_conf.resolution_width;//x分辨率
drvdata->info.var.yres = drvdata->fb_conf.resolution_height;//y分辨率
drvdata->info.var.xres_virtual = drvdata->fb_conf.resolution_width;//x虚拟分辨率
drvdata->info.var.yres_virtual = drvdata->fb_conf.resolution_height;//y虚拟分辨率

drvdata->info.var.rotate = FB_ROTATE_CW;//旋转

ret = register_framebuffer(&drvdata->info);

我们重点关注下面这些成员:

  • screen_buffer需要填充显存映射在内核空间中的虚拟地址(关于这个地址如何得到我们放在vdma中再说)
  • fbops包含了一些我们可以使用ioctl操作的函数,和其他的字符设备一样,不过这里framebuffer子系统已经帮我们定义好了大部分的ioctl操作。我们并不需要实现所有的成员函数,这里我实现了其中几个比较基本的功能:
static struct fb_ops xilinx_vdma_fb_ops = {
    .owner          = THIS_MODULE,

    .fb_setcolreg       = xilinx_vdma_fb_setcolreg,
    .fb_fillrect        = cfb_fillrect,
    .fb_copyarea        = cfb_copyarea,
    .fb_imageblit       = cfb_imageblit,
}; 

fb_setcolreg是调色板颜色索引表的实现,过去的时候为了节省空间,采用索引的方法将有限的颜色和RGB之间建立对应的关系。当使用的是16位,24位,32位色彩的时候并不需要使用调色板。现在其实大部分的程序已经不会调用到这个函数了,唯一一个比较常见的是fb_console,也就是将console信息直接的显示到我们的屏幕上,通常来说console共有17种颜色。
fb_fillrect是用来实现矩形填充,假如我们的控制器支持类似的绘图指令,使用指令可以加速绘图。同理fb_copyarea是用来进行区域的复制,fb_imageblit用来进行位图的绘制。这里我们的显示控制器并不支持这些特殊的指令,所以我们填充了framebuffer提供的通用函数。
xilinx_vdma_fb_setcolreg函数的实现参考了标准的例程,事实上,这个函数的作用就是根据当前颜色深度,计算出17种console需要使用的值,并将他们放在pseudo_palette当中,console需要显示不同颜色的时候就直接调用所对应的regno就可以了。

#define BITS_PER_PIXEL 32  /*color deepth of the screen*/
#define RED_SHIFT  16
#define GREEN_SHIFT    8
#define BLUE_SHIFT 0
static int xilinx_vdma_fb_setcolreg(unsigned int regno, unsigned int red, unsigned int green, 
    unsigned int blue, unsigned int transp, struct fb_info *fbi) 
{
    u32 *palette = fbi->pseudo_palette;

    if(regno >= PALETTE_ENTRIES_NO)
        return -EINVAL;

    if(fbi->var.grayscale)
    {
        /* Convert color to grayscale.
         * grayscale = 0.30*R + 0.59*G + 0.11*B
         */
        blue = (red * 77 + green * 151 + blue * 28 + 127) >> 8;
        green = blue;
        red = green;
    }

    /* fbi->fix.visual is always FB_VISUAL_TRUECOLOR */

    /* We only handle 8 bits of each color. */
    red >>= 8;
    green >>= 8;
    blue >>= 8;
    palette[regno] = (red << RED_SHIFT) | (green << GREEN_SHIFT) |
        (blue << BLUE_SHIFT);

    return 0;
}
  • fb_info.fix里设定了一些运行过程中无法更改的参数
static const struct fb_fix_screeninfo xilinx_vdma_fb_fix = {
    .id =       "Xilinx VDMA",
    .type =     FB_TYPE_PACKED_PIXELS,
    .visual =   FB_VISUAL_TRUECOLOR,
    .accel =    FB_ACCEL_NONE
}; 
#define FB_TYPE_PACKED_PIXELS      0   /* Packed Pixels    */
#define FB_TYPE_PLANES         1   /* Non interleaved planes */
#define FB_TYPE_INTERLEAVED_PLANES 2   /* Interleaved planes   */
#define FB_TYPE_TEXT           3   /* Text/attributes  */
#define FB_TYPE_VGA_PLANES     4   /* EGA/VGA planes   */
#define FB_TYPE_FOURCC         5   /* Type identified by a V4L2 FOURCC */

#define FB_VISUAL_MONO01       0   /* Monochr. 1=Black 0=White */
#define FB_VISUAL_MONO10       1   /* Monochr. 1=White 0=Black */
#define FB_VISUAL_TRUECOLOR        2   /* True color   */
#define FB_VISUAL_PSEUDOCOLOR      3   /* Pseudo color (like atari) */
#define FB_VISUAL_DIRECTCOLOR      4   /* Direct color */
#define FB_VISUAL_STATIC_PSEUDOCOLOR   5   /* Pseudo color readonly */
#define FB_VISUAL_FOURCC       6   /* Visual identified by a V4L2 FOURCC */

id其实就是为framebuffer设备起个名字,有关type和visual的作用,这里用一张图来表示
《基于ZYNQ VMDA的HDMI显示驱动练习》
我们得到色彩信息之后,首先需要转换为每个像素点的色彩,然后再写入到显存中。type和visual就是用来配置这两次转换关系的。FB_TYPE_PACKED_PIXELS就是直接将像素点色彩写入到显存中,算是最常用的一种模式,FB_VISUAL_TRUECOLOR表示直接显示色彩不需要进行色彩的转换。
fb_info.fix.smem_start是显存的物理地址,fb_info.fix.smem_len是显存的总长,fb_info.fix.line_length是每行的长度。这部分根据自己的情况填写就好了。
fb_info.var中保存的是显示相关的变量,height&width表示显示器的物理上的高度和宽度,单位是毫米,这里我简单的使用分辨率来替代。xres&yres表示实际显示的分辨率,xres_virtual&yres_virtual表示虚拟分辨率。关于虚拟分辨率,通常来说我们配置成跟显示分辨率一样的就可以了,我了解到有两种情况需要扩大虚拟分辨率的:一种是实际显存分辨率和显示分辨率不符,一种是采用双缓冲技术,这两种情况都还需要我们去实现fb_pan_display函数,以实现移动实际显示的内容在显存中的位置。
最后我们使用register_framebuffer将我们配置好的framebuffer设备注册到内核中。

vdma

vdma我们使用xilinx官方提供的驱动,在device tree中添加以下节点:

axi_vdma_0: dma@43000000 {
    #dma-cells = <1>;
    clock-names = "s_axi_lite_aclk","m_axi_mm2s_aclk"; 
    clocks = <&clkc 15>, <&clkc 16>;
    compatible = "xlnx,axi-vdma-1.00.a";
    interrupt-names = "mm2s_introut";
    interrupt-parent = <&intc>;
    interrupts = <0 30 4>;
    reg = <0x43000000 0x10000>;
    xlnx,addrwidth = <0x20>;
    xlnx,flush-fsync = <0x1>;
    xlnx,num-fstores = <0x1>;
    phandle = <0x6>;
    dma-channel@43000000 {
        compatible = "xlnx,axi-vdma-mm2s-channel";
        interrupts = <0 30 4>;
        xlnx,datawidth = <0x20>;
        xlnx,device-id = <0x0>;
    };
};

有关于device tree中各个属性的配置方法,我们可以去参照Kernel document中xilinx给我们提供的文档,其中注意下reg是我们之前在vivado中配置的地址,由于我们只使用了mm2s channel(也就是只需要向屏幕写入图像,不需要读取),所以clock中只需要添加m_axi_mm2s_aclk就可以了,s_axi_lite_aclk是配置vdma所用,必须要添加。interrupt同理,只需要添加mm2s的即可。xlnx,flush-fsync表示在帧同步的时候是否清空dna channel中的数据。xlnx,num-fstores表示硬件上设定的framebuffer数量,我们之前在vivado中配置成了1,那么这里也填1即可。
VDMA的配置我将它分为两个部分,首先使用xilinx给我们提供的一些接口来配置VDMA模块,然后使用kernel的通用API,申请DMA资源。对于framebuffer来说,写入的显存地址其实就是我们申请的DMA虚拟地址。具体的代码如下(简化了一些错误处理代码,这样看起来更清晰一些):

static int vdma_init(struct platform_device *pdev)
{
    struct xilinx_vdma_fb_drvdata *drvdata;
    enum dma_ctrl_flags flags = 0;
    struct dma_async_tx_descriptor *txd = NULL;
    dma_cookie_t tx_cookie = 0;
    static struct dma_interleaved_template dma_tmplt; 
    int ret;
    drvdata = platform_get_drvdata(pdev);
    drvdata->mm2s_dma_chan = dma_request_slave_channel(&pdev->dev, "vdma0");

    memset(&drvdata->vdma_config, 0, sizeof(struct xilinx_vdma_config));
    drvdata->vdma_config.coalesc = 255; // Interrupt coalescing threshold
    drvdata->vdma_config.park = 1;
    ret = xilinx_vdma_channel_set_config(drvdata->mm2s_dma_chan, &drvdata->vdma_config);

    drvdata->fb_virtual = dma_alloc_coherent(&pdev->dev, PAGE_ALIGN(drvdata->fb_conf.resolution_height * drvdata->fb_conf.resolution_width * BITS_PER_PIXEL / 8),
        &drvdata->fb_phy, GFP_KERNEL);

    memset_io(drvdata->fb_virtual, 0, drvdata->fb_conf.resolution_height * drvdata->fb_conf.resolution_width * BITS_PER_PIXEL / 8);

    dmaengine_terminate_all(drvdata->mm2s_dma_chan);

    memset(&dma_tmplt, 0, sizeof(dma_tmplt));
    dma_tmplt.src_start = drvdata->fb_phy;
    dma_tmplt.dir = DMA_MEM_TO_DEV;
    dma_tmplt.numf = drvdata->fb_conf.resolution_height;
    dma_tmplt.src_sgl = 0;
    dma_tmplt.src_inc = 1;
    dma_tmplt.dst_inc = 0;
    dma_tmplt.dst_sgl = 0;

    dma_tmplt.sgl[0].size = drvdata->fb_conf.resolution_width * BITS_PER_PIXEL / 8; 
    dma_tmplt.sgl[0].icg = 0;
    dma_tmplt.frame_size = 1;

    txd = dmaengine_prep_interleaved_dma(drvdata->mm2s_dma_chan, &dma_tmplt, 0);
    tx_cookie = dmaengine_submit(txd);
    dma_async_issue_pending(drvdata->mm2s_dma_chan);
    return 0;
}

首先我们使用dma_request_slave_channel,获得我们在device tree中指定的dma channel。然后我们开始配置vdma,在device data中加入struct xilinx_vdma_config, 结构体的定义如下:

/**
 * struct xilinx_vdma_config - VDMA Configuration structure
 * @frm_dly: Frame delay
 * @gen_lock: Whether in gen-lock mode
 * @master: Master that it syncs to
 * @frm_cnt_en: Enable frame count enable
 * @park: Whether wants to park
 * @park_frm: Frame to park on
 * @coalesc: Interrupt coalescing threshold
 * @delay: Delay counter
 * @reset: Reset Channel
 * @ext_fsync: External Frame Sync source
 * @vflip_en:  Vertical Flip enable
 */
struct xilinx_vdma_config {
    int frm_dly;
    int gen_lock;
    int master;
    int frm_cnt_en;
    int park;
    int park_frm;
    int coalesc;
    int delay;
    int reset;
    int ext_fsync;
    bool vflip_en;
};

事实上我在代码中只配置了park和coalesc,最后使用dmaengine_prep_interleaved_dma接口完成配置。
完成了基本配置之后,我们只需要使用kernel给我们提供的API申请DMA buffer在和操作所用的虚拟地址就可以了,和其他平台并无差别。这里简单说一下,显存的映射属于coherent dma的范畴,因为申请一次之后只有在关闭设备的时候才需要去释放,所以使用dma_alloc_coherent函数,进行DMA映射操作,这里需要映射的长度就是整个显存的大小了,但是需要注意的是,由于我们的framebuffer可能会使用mmap直接映射到userspace,所以这里申请的内存大小一定要是以PAGE_SIZE为单位的,所以这里使用PAGE_ALIGN跟PAGE_SIZE对齐。由于xilinx没有实现dmaengine_prep_dma_cyclic,所以我们只能使用DMA的交叉传输模式,事实上交叉传输模式一般适用于多个不同地址的DMA传输,通过这种方式我们可以将他们连接起来。这里我们需要用struct dma_interleaved_template来描述DMA传输,其中src_start和dst_start分别是我们传输的源地址和目的地址,dir表示传输方向,我们需要从内存传输到显存当中,所以使用DMA_MEM_TO_DEV。src_inc和dst_inc表示源和目的地址是否需要递增。src_sgl和dst_sgl表示sgl中的icg位是否生效,这关系到读取或者写入是否连续。最后我们使用dmaengine_prep_interleaved_dma将传输的信息配置下去,并获得一个传输描述符(struct dma_async_tx_descriptor)。

/**
 * struct dma_interleaved_template - Template to convey DMAC the transfer pattern
 *   and attributes.
 * @src_start: Bus address of source for the first chunk.
 * @dst_start: Bus address of destination for the first chunk.
 * @dir: Specifies the type of Source and Destination.
 * @src_inc: If the source address increments after reading from it.
 * @dst_inc: If the destination address increments after writing to it.
 * @src_sgl: If the 'icg' of sgl[] applies to Source (scattered read).
 *      Otherwise, source is read contiguously (icg ignored).
 *      Ignored if src_inc is false.
 * @dst_sgl: If the 'icg' of sgl[] applies to Destination (scattered write).
 *      Otherwise, destination is filled contiguously (icg ignored).
 *      Ignored if dst_inc is false.
 * @numf: Number of frames in this template.
 * @frame_size: Number of chunks in a frame i.e, size of sgl[].
 * @sgl: Array of {chunk,icg} pairs that make up a frame.
 */
struct dma_interleaved_template {
    dma_addr_t src_start;
    dma_addr_t dst_start;
    enum dma_transfer_direction dir;
    bool src_inc;
    bool dst_inc;
    bool src_sgl;
    bool dst_sgl;
    size_t numf;
    size_t frame_size;
    struct data_chunk sgl[1];
};

最后用dmaengine_submit将描述符放在传输队列当中,并使用dma_async_issue_pending启动传输。dmaengine_submit返回的是一个Cookie,可以监控跟踪DMA传输,这里我们并没有使用。

dynclk

xilinx并没有为我们提供dynclk的驱动,但是digilent公司(好像是xlinx的合作伙伴)为我们写好了这个驱动。dynclk是一个标准的基于clock子系统的驱动,首先我们需要在device tree中添加以下节点:

axi_dynclk_0: axi_dynclk@43c10000 {
            compatible = "digilent,axi-dynclk";
            #clock-cells = <0x0>;//只提供一个clk输出
            reg = <0x43c10000 0x10000>;//地址信息
            clocks = <&clkc 0xf>;
            clock-output-names = "dynclk";//输出时钟名称
        };

此外我们还需要在HDMI device的节点中指定clocks为上面加入的dynclk节点和clock-names为dynclk。在HDMI的驱动中,我们在device data中加入struct clk,并通过devm_clk_get来根据device tree中的clock-name和clocks来获取clock句柄。使用clk_prepare_enable启动clock,clk_round_rate检查我们需要配置频率是否支持,并最终用clk_set_rate配置频率。

v-tc

v-tc就是video timing controller的缩写。同样首先我们在device tree中添加节点,这里除了需要用xlnx,generator属性表明device用来产生时序,其他没有什么特殊的地方。

v_tc_0: v_tc@43c00000 {
    compatible = "xlnx,v-tc-6.1";
    interrupt-names = "irq";
    interrupt-parent = <&intc>;
    interrupts = <0 31 4>;
    reg = <0x43c00000 0x10000>;
    clocks = <&axi_dynclk_0>;
    xlnx,generator;
};

然后依旧是驱动中的配置,v-tc是用来生成图像时序的,如果你这里是希望在自己的显示器上显示图像,那么通常来说参照VESA标准时序就可以正常显示了。如果是特殊的屏幕,则需要 自己查找手册当中对于时序的要求。我这次使用的是一块手机屏,拿不到对应的手册,所以时序是github上搜的类似分辨率屏幕的时序,也是可以使用的。时序的定义主要包含屏幕的分辨率,前后沿以及同步这几个参数(前后沿加上同步也叫消隐)。代码中,我们使用xvtc_of_get从device tree中获取xvtc设备节点,然后配置xvtc_config中的时序参数,最后使用xvtc_generator_start完成配置。

static int xvtc_init(struct platform_device *pdev)
{
    struct xilinx_vdma_fb_drvdata *drvdata;
    drvdata = platform_get_drvdata(pdev);
    drvdata->vtc_device = xvtc_of_get(pdev->dev.of_node);

    drvdata->vtc_config.hblank_start = vtc_parameter[1].width;
    drvdata->vtc_config.vblank_start = vtc_parameter[1].height;
    drvdata->vtc_config.hsync_start = vtc_parameter[1].hps;
    drvdata->vtc_config.hsync_end = vtc_parameter[1].hpe;
    drvdata->vtc_config.vsync_start = vtc_parameter[1].vps;
    drvdata->vtc_config.vsync_end = vtc_parameter[1].vpe;
    drvdata->vtc_config.hsize = vtc_parameter[1].hmax;
    drvdata->vtc_config.vsize = vtc_parameter[1].vmax;

    xvtc_generator_start(drvdata->vtc_device, &drvdata->vtc_config);
    return 0;
}

参考资料:

  1. Linux common clock framework(1)_概述
  2. Linux DMA Engine framework(2)_功能介绍及解接口分析
  3. Linux Framebuffer Driver Writing HOWTO
  4. DMA Engine API Guide

发表评论

电子邮件地址不会被公开。 必填项已用*标注