|
Deki 0.19.0
面向嵌入式显示屏和桌面的模块化 C++ 2D 引擎
|
在设备上运行的代码中,每一次分配都要经过引擎。 无论是引擎的运行时代码还是包的运行时代码,都不得使用 new[]、malloc 或 heap_caps_*。Deki::Memory 是统筹者;Deki::Buffer<T> 则几乎总是你需要的东西。
这不是代码风格上的偏好,并且由 tools/ci/check_allocations.py 强制执行。这条规则必须成立的三个原因:
<deki/providers/Buffer.h>。一个拥有所有权的数组,通过 Deki::Memory 分配,并会自行释放。
唯一没有自动化的是*内存是否到手*:在创建缓冲区的地方检查一次 if (!buffer),而不是在其后的每个分支中逐层回退。
帧 arena 同样可以省去失败处理,但它必须预先确定大小。一个足以容纳单次全屏渐变烘焙的 arena 会预留 150 KB,无论项目是否用到渐变;在只有几百 KB 堆的芯片上,这相当于为一个可能并不存在的功能占用了一半内存。arena 在有余量的地方才划算。这里的大多数分配都是长期存在的,并且本来就归某个对象所有:字形表、像素数据、粒子列、帧缓冲区,这正是 RAII 所服务的,而 arena 不是。
<deki/providers/Memory.h>。没有默认值,也不做任何推断。 每个分配调用都要指定一个区域,省略它就无法编译。
| 区域 | 位置 |
|---|---|
| Deki::Memory::Internal | 内部 RAM。速度快,也是稀缺的那一个 |
| Deki::Memory::External | 开发板有 PSRAM 时使用 PSRAM。以 MB 计,但明显更慢:它要通过 SPI 访问 |
Deki::Memory::AllocateDma(bytes, region) 额外加上一个约束:DMA 引擎必须能够读取该内存块。这与放置位置是正交的:无论开发板将帧缓冲区放在内部还是 PSRAM 中,它都必须可被访问,因此它是一个单独的入口点,而不是第三个区域。
引擎自带两个区域。这个集合是开放的:
包将自己的区域放在自己的命名空间中,而不是放在 Deki::Memory 中。它本来也做不到(类是封闭的),而这正是关键所在。两个都想要 Rtc 常量的开发板包不会冲突,因为语言本身会把它们分开。常量放在哪里从来都不重要:区域的身份是其名称的哈希值,所以把它从一个命名空间移到另一个命名空间不会带来任何变化。
Region() 在编译期用引擎其余部分使用的同一个 FNV-1a 对名称进行哈希,因此区域是一个常量表达式,提供者可以对它使用 switch。
两个包命名了同一个区域,就表示同一个区域。正是这样,两个开发板包无需协调就能在 "rtc" 上达成一致,就像两个包都要求 spi 能力一样。无法应对的情况是两个包用同一个词表示*不同*的东西,所以对不是你发明的名称要加上限定:particles.scratch、esp32.rtc。由于分配头只存储 id,调试构建会报告哈希值相同的两个不同名称。
包可以*命名*一个区域;只有开发板的 IMemoryProvider 才能提供它。不认识 esp32.rtc 的提供者会返回 nullptr,失败信息中会写出该区域的名称:
它不会被替换为其他区域。对 Rtc 来说这一点尤其重要:它的意义在于在深度睡眠后保留数据,因此如果悄悄换成普通 RAM,数据就会恰好在本应保留的那一刻丢失。
目标将其拥有的区域作为能力公布,前缀为 memory::
| 能力 | 来源 |
|---|---|
| memory:internal | 每个平台 - 推导得出 |
| memory:external | 平台的 psramSize - 推导得出 |
| memory:esp32.rtc | 开发板的 provides,因为只有它知道 |
因此,需要某个区域的包只需声明它,构建就会在任何东西运行之前,按名称拒绝缺少该区域的目标:
这两部分不会产生偏差,因为 Region("esp32.rtc") 和 "memory:esp32.rtc" 是同一个标记加上前缀,而不是由某人保持同步的两份列表。能力名称使用小写字母以及 .、-、: 和 _;声明 "WiFi" 的平台会在加载时收到警告,因为精确字符串匹配会悄无声息地永远不生效。
如果所选区域已满,分配会失败并返回 null。它不会在另一个区域中重试。这是有意为之:
失败时会记录分配的内容、大小、区域,以及未尝试回退。每个调用方本来就必须处理 null。
没有外部 RAM 的开发板会把所有内容解析到内部。这不是回退;那是唯一存在的区域。
Deki::Memory(<deki/providers/Memory.h>),用于 Buffer<T> 无法覆盖的情况:
每个分配调用都接受一个 AllocContext,而几乎没有人需要写它:
在调试构建中,默认参数会捕获调用位置,因此失败信息如下:
没有需要传入的名称,也没有传入名称的方法。手写的标签是同一次分配的第二个身份,必须有人让它与第一个保持一致,而编译器早已知道答案。同一个函数中的两次分配通过行号区分:
它指明的是调用方,而不是引擎。 Buffer<T>::Allocate 和 Memory::Allocate 都接受上下文并将其转发,而不是重新使用默认值,因此记录的位置是*你*写下调用的地方,而不是 Buffer.h。有一个测试专门断言这一点,因为如果搞错了,引擎中的每次分配都会报告一个看似合理的文件,结果是自信满满却毫无用处。
不复制任何内容。 每个成员都指向一个具有静态存储期的字符串字面量,这也是调试泄漏表可以持有该指针的原因。
捕获在每种构建中都是开启的。它在 ESP32-S3 固件上占用 4,272 字节,即 1.5 MB 镜像的 0.28%,每个调用位置约 40 字节,便宜到不值得考虑。
对于需要收回这部分闪存的构建,定义 DEKI_STRIP_ALLOC_CONTEXT。AllocContext 会变成一个空结构体,二进制文件中不会出现任何路径或函数名,失败时只报告它试图做什么,而不声称来源:
注意,去除和未去除的构建布局不同,因此引擎和调用它的包必须保持一致,这与 MemoryStats 已有的约束相同。
<deki/providers/Vector.h>。Buffer<T> 适用于分配时就已知的数量。当数量会变化且没有固定上限时,在设备上 std::vector 就是错误的选择:push_back 会调用 operator new,而在关闭异常的情况下它无法报告内存耗尽,于是中止,开发板重启。
Deki::Vector 就是这种形态,只改了在这里唯一重要的一点:所有可能分配的操作都返回 bool:
优先使用 Buffer。这里大多数“动态”数据最后都会发现有一个项目早已知道的上限,而固定分配不会像反复增长那样让小堆产生碎片。
这些会调用 operator new,Deki::Buffer<T> 无法替代它。在设备构建中,引擎会替换这些运算符本身(src/providers/OperatorNew.cpp),这会带来两处变化:
这些确实经过引擎:operator new 会调用 IMemoryProvider::AllocateRaw,因此开发板能看到设备上的每一次分配,并可以用自己的堆来承担全部分配。
但它*不是* Memory::Allocate。如果是的话,每个超出小字符串缓冲区的 std::string 前面都会多一个 16 字节的头部,vector 即将覆盖的内存会被 memset,小内存块也要付出 ESP32 提供者的 16 字节 SIMD 对齐开销,而 ESP-IDF 是通过多分配来实现对齐分配的,所以这比头部浪费得更多。AllocateRaw 没有这些:自然对齐、没有头部,就是堆返回的那个指针。
实测,160 万次成对的 48 字节分配,引擎为 -O2:
| ns/op | |
|---|---|
| malloc + free | 47-52 |
| 经由提供者 | 46-51 - 无法区分 |
| 经由提供者,并启用 DEKI_MEMORY_TRACK_RAW | 63-71 (+35%) |
所以转发是免费的,并且始终开启;统计字节数则需要主动开启,因为没有头部时,内存块大小必须从堆中获取,而在 Windows 上这意味着在锁下由 _msize 调用 HeapSize。在追查内存去向时用 DEKI_MEMORY_TRACK_RAW 构建,Memory::LogStats 就会报告容器占用了多少;没有它时,那一行会说明这一点,而不是打印一个误导性的零。
随头部一起放弃的还有它的魔数,因此容器内存的重复释放和以前一样无法诊断,就像这条路径还是普通 malloc 的时候。凡是引擎能够描述的分配都经过 Allocate,并保留这项防护。
这里有意不决定放置位置。像已移除的 Auto 规则那样按大小分流,会悄悄把一个大的 std::vector 移到 PSRAM 中,让热循环变慢,而原因在代码中完全看不出来。想要外部内存的调用方会指定 Deki::Memory::External 并使用 Deki::Buffer;operator new 无从得知,所以它不去猜。
它只在 __cpp_exceptions 未定义的地方编译,也就是恰好存在中止问题的那些构建。主机和编辑器构建保留标准运算符。超对齐形式(align_val_t)留给 libstdc++,以便它们保持成对匹配。
所以:对于运行时会变化的数量,使用 Deki::Vector<T> 并处理 false。对于大小来自文件、场景或屏幕的任何东西,使用 Deki::Buffer<T>。std::vector 仍然适用于编辑器和工具代码;而在设备上,它的内存耗尽现在是一次有日志记录的中止,而不是无声的中止。
<deki/providers/IMemoryProvider.h>。平台提供一个实现,并在 Memory::Initialize() 之前交给引擎:
该接口报告外部 RAM(HasExternalRAM、总量、空闲量),并从各个区域分配:AllocateExternal / FreeExternal、AllocateInternal / FreeInternal 以及 AllocateDMA。
每个与放置相关的虚函数都是纯虚函数,必须全部实现。 提供者要说明每个区域在其平台上的含义,即使答案是“在这里和其他区域一样”。在 AllocateDMA 中写 return malloc(size); 的桌面提供者,就做出了一个可以阅读和检查的声明。
AllocateDMA 是最重要的那个。如果提供者可以从 AllocateInternal 继承它,那么在两者不同的任何芯片上,都会把显示外设无法访问的内存交给它:悄无声息的数据损坏,构建中也没有任何东西会指出这一点。
出于同样的原因,GetAvailableInternalRAM 也是必须实现的:随意返回 0 的提供者会让启动日志报告开发板内存已耗尽。
纯虚函数与已实现的虚函数占用相同的 vtable 槽位,因此这不需要升级 ABI 版本。它只是让提供者在做出决定之前无法编译。
AllocateRaw / FreeRaw / GetRawBlockSize 是第四对函数外加一个查询:全局 operator new 所经由的、按自然对齐分配的普通分配器,这样开发板的堆既能为引擎放置的一切服务,也能为 std::vector 和标量 new 服务。它们默认使用 malloc/free,在平台无法回答时 GetRawBlockSize 返回 0,这只会影响 LogStats 的准确性。这一对要么都重写,要么都不重写。
AllocateDMA 与 AllocateInternal 分开,是因为在 ESP32-S3 上大部分内部 RAM 可被 DMA 访问,但并非全部;显示外设读取不可访问的帧缓冲区时,得到的是悄无声息的数据损坏,而不是错误。ESP32 提供者会显式请求 MALLOC_CAP_DMA。重写 AllocateInternal/FreeInternal 中任意一个的人必须两个都重写:这一对必须匹配(ESP32 提供者使用 16 字节对齐的 heap_caps_aligned_alloc,这也让 blit 调度器能够启用 S3 的 SIMD 内核,以及 heap_caps_free)。
IMemoryProvider 是包 ABI 的一部分,因此它的虚函数由 tools/ci/check_abi.py 锁定。添加、删除或重新排序其中任何一个都会移动 vtable 槽位,并且需要在同一个提交中提升 kDekiReflectionAbiVersion。
在运行时代码中发现 new[]、delete[]、malloc/calloc/realloc 和 heap_caps_* 时失败,既包括引擎自身的代码,也包括每个包的代码。引擎中的一次分配会影响每块开发板上的每个项目,而包中的分配只影响安装了它的项目,所以如果说有什么区别,引擎才是更重要的那一半;这个 lint 的第一个版本没有包括引擎,结果立刻证明了这一点:ProjectSettings 一直在用裸 realloc 保存其设备端的设置数据块。
跳过的部分:editor/、tests/、generated/、third_party/,以及引擎的 platforms/editor 和 platforms/desktop 层,这些都运行在主机上,在那里异常是开启的,堆也是桌面系统的堆。按路径豁免的部分(因为它们是技术栈的最底层,而不是被允许违反规则的代码):providers/Memory.cpp、providers/IMemoryProvider.h、所有 *MemoryProvider.*,以及 providers/OperatorNew.cpp,后者必须定义 operator new[],这样其他任何地方都不需要定义它。
注释和字符串字面量在匹配前会被清空,因此解释这条规则的注释不会触发它。
确实无法避免的情况可以使用豁免,但必须写明理由;没有理由的豁免不算数,这样 diff 中总能看出哪些例外是经过论证的:
它已经证明了自己的价值:它在 Standard2DRenderer 中发现了一个对 Deki::Memory 缓冲区使用 delete[] 的地方,而之前的人工排查漏掉了它。