我们了解微控制器图形子系统的功能

你好!



在本文中,我想谈谈在微控制器上使用小部件实现图形用户界面的功能,以及如何同时拥有熟悉的用户界面和良好的FPS。注意,我不想集中在任何特定的图形库上,而是集中在一般的东西上-内存,处理器缓存,dma等。由于我是Embox团队的开发人员,因此示例和实验将在此RT OS上进行。





之前我们已经讨论过在微控制器上运行Qt库。动画看上去非常流畅,但是即使用于存储固件,其存储器成本也很高-代码是从外部QSPI闪存执行的。当然,当需要一个复杂且多功能的界面(它也知道如何制作某种动画)时,硬件资源的成本就可以算是合理的(特别是如果您已经为Qt开发了此代码)。



但是,如果您不需要Qt的所有功能怎么办?如果您有四个按钮,一个音量控制和几个弹出菜单,该怎么办?同时,您希望它“看起来不错并且工作迅速” :)然后,建议使用更轻便的工具,例如lvgl或类似工具。



不久前,在我们的Embox项目中,移植Nuklear-这个项目是创建一个非常轻量级的包含一个标头的库,并允许您轻松创建简单的GUI。我们决定使用它来创建一个小型应用程序,在该应用程序中将有一个带有一组图形元素的小部件,并且可以通过触摸屏进行控制。



选择了带有Cortex-M7和触摸屏的STM32F7-Discovery。



首先优化。节省记忆



因此,选择了图形库,因此选择了平台。现在让我们了解什么是资源。值得注意的是,主存储器SRAM的速度是外部SDRAM的许多倍,因此,如果屏幕尺寸允许,那么最好将帧缓冲器放入SRAM中。我们的屏幕分辨率为480x272。如果我们想要每个像素4字节的颜色,那么我们将获得大约512 KB。同时,内部RAM的大小仅为320,并且很明显视频存储将在外部。另一种选择是将颜色位深度减少到16(即2个字节),从而将内存消耗减少到256 KB,这已经可以放入主RAM中了。



您可以尝试做的第一件事就是节省所有费用。让我们制作一个256 Kb的视频缓冲区,将其放在RAM中并放入其中。我们立即遇到的问题是直接绘制到视频内存中时发生的场景“闪烁”。Nuklear从头开始重新绘制整个场景,因此,每次首先填充整个屏幕,然后绘制小部件,然后在其中放置一个按钮,在其中放置文本,依此类推。结果,肉眼可以看到如何重绘整个场景,并且图片“闪烁”。即,不会保存内部存储器中的简单位置。



中间缓冲区。编译器优化。FPU



在我们对先前的方法(在内部存储器中放置)进行了一些摆弄之后,立即想到了X Server和Wayland的存储器。是的,确实,事实上,窗口管理器正在处理来自客户端的请求(只是我们的自定义应用程序),然后将元素收集到最终场景中。例如,Linux内核通过evdev驱动程序将事件从输入设备发送到服务器。服务器进而确定要解决该事件的客户端。接收到事件(例如,在触摸屏上按下)的客户执行其内部逻辑-他们突出显示按钮,显示新菜单。进一步(对于X和Wayland而言稍有不同),客户端本身或服务器会将更改绘制到缓冲区。然后,合成器将所有部分放在一起以绘制到屏幕上。这里足够简单和示意图说明在这里



显然,我们需要类似的逻辑,但是我们真的不想为了小应用程序而将X Server推入stm32。因此,让我们尝试不只是在视频内存中绘制,而是在普通内存中绘制。渲染完整个场景后,它将缓冲区复制到视频内存中。



小部件代码
        if (nk_begin(&rawfb->ctx, "Demo", nk_rect(50, 50, 200, 200),
            NK_WINDOW_BORDER|NK_WINDOW_MOVABLE|
            NK_WINDOW_CLOSABLE|NK_WINDOW_MINIMIZABLE|NK_WINDOW_TITLE)) {
            enum {EASY, HARD};
            static int op = EASY;
            static int property = 20;
            static float value = 0.6f;

            if (mouse->type == INPUT_DEV_TOUCHSCREEN) {
                /* Do not show cursor when using touchscreen */
                nk_style_hide_cursor(&rawfb->ctx);
            }

            nk_layout_row_static(&rawfb->ctx, 30, 80, 1);
            if (nk_button_label(&rawfb->ctx, "button"))
                fprintf(stdout, "button pressed\n");
            nk_layout_row_dynamic(&rawfb->ctx, 30, 2);
            if (nk_option_label(&rawfb->ctx, "easy", op == EASY)) op = EASY;
            if (nk_option_label(&rawfb->ctx, "hard", op == HARD)) op = HARD;
            nk_layout_row_dynamic(&rawfb->ctx, 25, 1);
            nk_property_int(&rawfb->ctx, "Compression:", 0, &property, 100, 10, 1);

            nk_layout_row_begin(&rawfb->ctx, NK_STATIC, 30, 2);
            {
                nk_layout_row_push(&rawfb->ctx, 50);
                nk_label(&rawfb->ctx, "Volume:", NK_TEXT_LEFT);
                nk_layout_row_push(&rawfb->ctx, 110);
                nk_slider_float(&rawfb->ctx, 0, &value, 1.0f, 0.1f);
            }
            nk_layout_row_end(&rawfb->ctx);
        }
        nk_end(&rawfb->ctx);
        if (nk_window_is_closed(&rawfb->ctx, "Demo")) break;

        /* Draw framebuffer */
        nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);

        memcpy(fb_info->screen_base, fb_buf, width * height * bpp);




本示例创建一个200 x 200像素的窗口,并在其中绘制图形。最终场景本身被绘制到fb_buf缓冲区中,该缓冲区已分配给SDRAM。然后在最后一行,简单地调用memcpy。一切都会无休止地重复。



如果我们仅构建并运行此示例,我们将获得大约10-15 FPS。这当然不是很好,因为即使用眼睛也能看到。此外,由于Nuklear渲染代码包含大量浮点计算,因此我们最初启用了它的支持,如果没有它,FPS将会更低。第一个也是最简单的(免费)优化当然是-O2编译器标志。



让我们构建并运行相同的示例-我们获得20 FPS。更好,但仍然不足以胜任这份工作。



启用处理器缓存。直写模式



在继续进行进一步的优化之前,我要说的是我们将rawfb插件用作Nuklear的一部分,该插件直接绘制到内存中。因此,内存优化看起来非常有前途。首先想到的是缓存。



在较旧的Cortex-M版本中,例如Cortex-M7(我们的情况),内置了附加的处理器缓存(指令缓存和数据缓存)。通过系统控制模块的CCR寄存器启用它。但是随着缓存的加入,出现了新的问题-缓存和内存中的数据不一致。有几种管理缓存的方法,但是在本文中,我将不再赘述,因此,我认为,我将继续介绍一种最简单的方法。要解决缓存/内存不一致问题,您可以简单地将所有可用内存标记为“不可缓存”。这意味着所有对该内存的写操作将始终转到内存,而不是缓存。但是,如果我们以这种方式标记所有内存,那么缓存中也将毫无意义。还有另一种选择。这是一种“直通”模式,其中,所有标记为直写的内存写操作都会同时发送到缓存,并在内存中。这会产生写开销,但另一方面,会大大加快读取速度,因此结果将取决于特定的应用程序。



对于Nuklear来说,直写模式非常好-性能从20 FPS提升到45 FPS,它本身已经相当不错且流畅。效果当然很有趣,我们甚至尝试禁用直写模式,而不关注数据不一致,但是FPS仅上升到50 FPS,也就是说,与直写相比没有明显增加。由此得出的结论是,我们的应用程序需要大量的读取操作,而不是写入操作。问题是,当然在哪里?可能是由于rawfb代码中的转换次数众多,该转换经常访问内存以读取下一个系数或类似的内容。



双缓冲(到目前为止,使用中间缓冲)。启用DMA



我不想停在45 FPS,所以我们决定做进一步的实验。下一个想法是双重缓冲。这个想法是众所周知的,并且通常很简单。我们使用一台设备将场景绘制到一个缓冲区,而另一台设备从另一缓冲区显示场景。如果您查看前面的代码,则可以清楚地看到一个循环,在该循环中,场景首先被绘制到缓冲区中,然后使用memcpy将内容复制到视频存储器中。很明显,memcpy使用CPU,即渲染和复制是按顺序进行的。我们的想法是可以使用DMA并行进行复制。换句话说,当处理器绘制新场景时,DMA将先前的场景复制到视频存储器中。



Memcpy替换为以下代码:



            while (dma_in_progress()) {
            }

            ret = dma_transfer((uint32_t) fb_info->screen_base,
                    (uint32_t) fb_buf[fb_buf_idx], (width * height * bpp) / 4);
            if (ret < 0) {
                printf("DMA transfer failed\n");
            }

            fb_buf_idx = (fb_buf_idx + 1) % 2;


在此输入fb_buf_idx-缓冲区的索引。 fb_buf_idx = 0是前缓冲区,fb_buf_idx = 1是后缓冲区。 dma_transfer()函数接受目标,源和32位字的数量。然后,DMA会填充所需的数据,然后继续下一个缓冲区的工作。



尝试使用此机制后,性能提高到约48 FPS。比memcpy()稍好,但仅略有改善。我并不是说DMA没用,但是在这个特定示例中,缓存对全局的影响更好。



在DMA的性能比预期的差一点惊奇之后,我们想到了使用多个DMA通道的“优秀”的想法。重点是什么?一次可以在stm32f7xx上加载到DMA的数据数为256 KB。同时,请记住,我们的屏幕为480x272,视频内存约为512 KB,这意味着您似乎可以将数据的前一半放在一个DMA通道中,而后一半放在第二个DMA通道中。一切似乎都很好。但是性能从48 FPS下降到25-30 FPS。也就是说,我们将返回尚未启用缓存的情况。用什么可以连接?实际上,由于对SDRAM内存的访问是同步的,因此即使该内存也称为同步动态随机存取内存(SDRAM),因此此选项只会添加其他同步,无需根据需要并行写入存储器。经过一番反思,我们意识到这里没有什么奇怪的,因为内存是一个,并且对一个微电路(在一条总线上)产生了写入和读取周期,并且由于添加了另一个源/接收器,所以仲裁器可以解决总线上的调用,您需要混合来自不同DMA通道的命令周期。



双缓冲。与LTDC合作



从中间缓冲区复制当然是好的,但是我们发现,这还不够。让我们看一下另一个明显的改进-双缓冲。在绝大多数现代显示控制器中,您可以设置所用视频存储器的地址。因此,您可以避免完全复制,而只需将视频内存地址重新排列到准备好的缓冲区中,屏幕控制器将通过DMA自行以最佳方式获取数据。这是真正的双重缓冲,没有以前的中间缓冲区。当显示控制器可以具有两个或多个缓冲区时,还有一个选项,这基本上是相同的-我们写入一个缓冲区,另一个缓冲区由控制器使用,而无需复制。



stm32f74xx中的LTDC(LCD-TFT显示控制器)具有两个硬件覆盖层-第1层和第2层,其中第2层叠加在第1层上。每个层都是可独立配置的,可以分别启用或禁用。我们试图仅启用第1层,并在前缓冲区或后缓冲区上重新排列视频内存地址。也就是说,我们给显示一个,此时绘制另一个。但是,在切换叠加层时会出现明显的抖动。



当我们使用两层且其中一层打开/关闭时,即当每一层都有其自己的视频内存地址(不变)且通过打开一层而关闭另一层来更改缓冲区时,我们尝试了该选项。变化还导致抖动。最后,我们在未关闭图层的情况下尝试了该选项,但是将Alpha通道设置为零0或最大值(255),也就是说,我们控制了透明度,使其中一层不可见。但是这种选择没有达到期望,颤抖仍然存在。



原因尚不清楚-文档说可以实时进行图层状态更新。我们做了一个简单的测试-我们关闭了缓存,浮点,在屏幕中央绘制了一个带有绿色正方形的静态图片,与第1层和第2层相同,然后开始循环切换级别,以期获得静态图片。但是我们又一次发生了同样的动摇。



显然这是另外一回事。然后我们想起了内存中帧缓冲区地址的对齐方式。由于缓冲区是从堆中分配的,并且其地址未对齐,因此我们将其地址对齐了1 KB-我们得到了预期的图像而没有抖动。然后他们在文档中发现LTDC会分批减去64个字节的数据,并且数据的不均匀性会严重降低性能。在这种情况下,帧缓冲区开始的地址及其宽度都必须对齐。为了进行测试,我们将480x4的宽度更改为470x4,该宽度不能被64字节整除,并且具有相同的抖动。



结果,我们将两个缓冲区对齐了64个字节,确保宽度也对齐了64个字节,并且运行了nuklear-抖动消失了。起作用的解决方案如下所示。而不是通过完全禁用第1层或第2层来在各层之间切换,而应使用透明性。也就是说,要禁用该级别,请将其透明度设置为0,然后将其启用-255。



        BSP_LCD_SetTransparency_NoReload(fb_buf_idx, 0xff);

        fb_buf_idx = (fb_buf_idx + 1) % 2;

        BSP_LCD_SetTransparency(fb_buf_idx, 0x00);


我们的FPS为70-75!比原来的15好得多。



应该注意的是,该解决方案通过透明控制起作用,禁用级别之一的选项和重新设置级别地址的选项使FPS的图像抖动大40-50,这是我们目前未知的原因。另外,继续,我将说这是此板的解决方案。



通过DMA2D填充硬件场景



但这不是限制,我们增加FPS的最后优化是硬件场景填充。在此之前,我们以编程方式进行了填充:

nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);


现在,让我们告诉rawfb插件,无需填充场景,而只需绘制即可:

nk_rawfb_render(rawfb, nk_rgb(30,30,30), 0);


我们将仅使用DMA2D控制器在硬件中以相同的颜色0xff303030填充场景。DMA2D的主要功能之一是复制或填充RAM中的矩形。这里的主要便利是,这不是连续的存储器,而是一个矩形区域,该区域位于内存中,且带有断点,这意味着无法立即完成普通的DMA。在Embox中,我们尚未使用此设备,因此,我们仅使用STM32Cube工具-BSP_LCD_Clear(uint32_t Color)函数。它在DMA2D中编程整个屏幕的填充颜色和大小。



垂直消隐期(VBLANK)



但是,即使以80 FPS的速度运行,仍然存在一个明显的问题-小部件的一部分在屏幕上移动时会出现小的“中断”。也就是说,小部件似乎被分成3个(或更多)并排移动的部分,但有一点延迟。原来原因是视频内存更新不正确。更准确地说,以错误的时间间隔进行更新。



显示控制器具有VBLANK之类的属性,它也是VBI或Vertical Blanking Period它表示相邻视频帧之间的时间间隔。或更准确地说,是前一个视频帧的最后一行与下一个视频帧的第一行之间的时间。在此间隔内,不会有新数据传输到显示器,图片是静态的。因此,可以安全地更新VBLANK中的视频内存。



实际上,LTDC控制器具有一个中断,该中断被配置为在处理下一个帧缓冲区线(LTDC线中断位置配置寄存器(LTDC_LIPCR))之后触发。因此,如果我们将此中断配置为最后一个行号,那么我们将仅获得VBLANK间隔的开始。至此,我们进行了必要的缓冲区切换。



由于采取了这些措施,图片恢复了正常,空白消失了。但是与此同时,FPS从80下降到60。让我们了解造成这种现象的原因是什么。



文档中可以找到以下公式:



          LCD_CLK (MHz) = total_screen_size * refresh_rate,


其中total_screen_size = total_width x total_height。LCD_CLK是显示控制器将像素从视频内存加载到屏幕的频率(例如,通过显示串行接口(DSI))。但是refresh_rate已经是屏幕本身的刷新率,即它的物理特性。事实证明,知道屏幕的刷新率及其尺寸后,您可以配置显示控制器的频率。在检查了STM32Cube创建的配置的寄存器后,我们发现它可以将控制器调整到60 Hz屏幕。所以这一切都在一起。



在我们的示例中有关输入设备的一些知识



让我们回到我们的应用程序,看看触摸屏是如何工作的,因为如您所知,现代的界面意味着交互性,即与用户的交互。



一切都安排在这里很简单。渲染场景之前,即将在主程序循环中处理来自输入设备的事件:



        /* Input */
        nk_input_begin(&rawfb->ctx);
        {
            switch (mouse->type) {
            case INPUT_DEV_MOUSE:
                handle_mouse(mouse, fb_info, rawfb);
                break;
            case INPUT_DEV_TOUCHSCREEN:
                handle_touchscreen(mouse, fb_info, rawfb);
                break;
            default:
                /* Unreachable */
                break;
            }
        }
        nk_input_end(&rawfb->ctx);


处理来自触摸屏的事件的过程非常复杂,发生在handle_touchscreen()函数中:



handle_touchscreen
static void handle_touchscreen(struct input_dev *ts, struct fb_info *fb_info,
        struct rawfb_context *rawfb) {
    struct input_event ev;
    int type;
    static int x = 0, y = 0;

    while (0 <= input_dev_event(ts, &ev)) {
        type = ev.type & ~TS_EVENT_NEXT;

        switch (type) {
        case TS_TOUCH_1:
            x = normalize_coord((ev.value >> 16) & 0xffff, 0, fb_info->var.xres);
            y = normalize_coord(ev.value & 0xffff, 0, fb_info->var.yres);
            nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 1);
            nk_input_motion(&rawfb->ctx, x, y);
            break;
        case TS_TOUCH_1_RELEASED:
            nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 0);
            break;
        default:
            break;
        }

    }
}




实际上,这是将输入设备事件转换为Nuklear可以理解的格式的地方。实际上,仅此而已。



在另一个板上启动



收到相当不错的结果后,我们决定在另一块板上重现它们。我们还有另一个类似的板子-STM32F769I-DISCO。有相同的LTDC控制器,但屏幕分辨率为800x480。发射后,它的速度为25 FPS。也就是说,性能明显下降。帧缓冲区的大小很容易解释这一点-它几乎是其三倍。但是主要问题却有所不同-图像非常失真,当小部件应该放在一个位置时没有静态图像。



原因尚不清楚,因此我们来看了STM32Cube的标准示例。有一个针对此特定板使用双缓冲的示例。在此示例中,与更改透明度的方法不同,开发人员只需将指针移至VBLANK中断上的帧缓冲区即可。我们早些时候已经在第一块板上尝试了这种方法,但是它没有用。但是将这种方法用于STM32F769I-DISCO,我们从25 FPS得到了相当平滑的图像变化。



高兴的是,我们在第一块板上再次测试了此方法(带有重新排列的指针),但在高FPS时仍然无法使用。结果,一层透明(60 FPS)的方法可以在一块板上工作,而另一面具有重新排列指针(25 FPS)的方法可以工作。在讨论了这种情况之后,我们决定推迟统一,直到对图形堆栈进行更深入的研究为止。



结果



因此,让我们总结一下。所示示例代表了微控制器的一种简单而通用的GUI模式-一些按钮,音量控件或其他功能。该示例缺少与事件相关的任何逻辑,因为重点放在图形上。在性能方面,我们获得了相当不错的FPS值。



为优化性能而积累的细微差别得出的结论是,现代微控制器中的图形变得越来越复杂。现在,就像在大型平台上一样,您需要监视处理器高速缓存,将某些东西放置在外部存储器中,并将某些东西放置在更快的存储器中,使用DMA,使用DMA2D,监视VBLANK等。一切都开始看起来像大型平台,也许这就是为什么我已经多次提到X Server和Wayland的原因。



也许最未优化的部分之一就是渲染本身,我们从头开始重新绘制整个场景。我不能说它是如何在其他微控制器库中完成的,也许这个阶段是内置在库本身中的。但是基于与Nuklear一起工作的结果,似乎在这个地方需要X Server或Wayland的类似物,当然,重量更轻,这又使我们想到了小型系统重复大型系统的思路。 结果,



UPD1

不需要更改透明度的方法。在两块板上,通用代码都可以工作-通过v-sync交换缓冲区地址。而且,透明的方法也是正确的,根本没有必要。



UPD2

我想对所有建议使用三重缓冲的人表示非常感谢,我们还没有完全理解。但是现在您可以看到,这是一种经典方法(特别是对于高帧率的FPS显示器),除其他外,这将使我们摆脱由于等待v同步而导致的滞后(即软件明显领先于图片时)。我们尚未遇到此问题,但这只是时间问题。在此特别感谢您对三重缓冲的讨论贝齐策夫贝拉夫



我们的联系人:



Github:https

://github.com/embox/embox通讯:embox-ru [at] googlegroups.com

电报聊天:t.me/embox_chat



All Articles