裸露金属锈蚀泛型

2020-08-24 02:53:26

我有幸与经验丰富的固件开发人员共事;他们知道红区的大小,并经常将咖啡转换为链接器脚本和指针取消引用。换句话说,梅尔斯和宙斯锤子是世界上的锤子。

说到我们贸易中的工具,他们中的许多人都很好奇,很有经验。他们中的一些人-包括我自己-探索得足够远,把语法主义抛在脑后,转向理想主义,顽固地将美丽的圆钉塞进工业方孔。嘿,也许他们重整旗鼓是有原因的,但试一试也没什么坏处。

他们中的大多数人都不是那样的人。普通的久经沙场的固件开发人员已经积累了一种对抽象的健康不信任,可能是因为看着闪亮的柏拉图式构造崩溃和燃烧,留下了令人痛苦的真实和具体的错误痕迹。这是发人深省的,不得不在足够多的vtable和模板化代码中追逐tinyMCU上的硬故障,让Herb Sutter吐出尖括号。难怪现代方法会遇到一些阻力,除非LOAD和商店看得很清楚。

早在2014年,当有人向我建议一种名为Rust的新兴语言在嵌入式领域显示出希望时,我也有这种感觉。当然不会,我想,水平太高了。尽管我已经在使用它了,但我根深蒂固的本能告诉我不要相信支持函数式编程的语言,也不要相信敢于对我的内存管理方式发表意见的语言。哈!这表明你的哲学家用完了叉子,而你的叉子变成了SIGSEGV。

通过过去五年的实验,我从好奇、乐观到确信铁锈是构建工业级、防弹裸机软件的理想语言。除此之外,我已经意识到,即使是基础语言提供的最高级别的结构也适用于固件开发,这与其他语言非常不同,这些语言跨越了广泛的范例(我现在看着你,C++)。我有这种感觉的原因有几个:

Rust的安全性保证和一般的严格性大大缩短了调试时间,因此不需要花费太多时间来开发与硬件原语相对应的高级构造的思维导图。

类型系统在加强局部推理和防止泄漏抽象方面非常出色。在没有运行时成本的情况下构建解耦系统是很容易的。

最近,我有机会在专业环境下参与了一个Rust STM32F412项目,目标之一是在我的公司培养一个原始知识库。Loadstone项目是一个32KB的安全引导加载器,目标是医疗行业的裸机设备。

虽然用你的FOR、你的*mut U8和你的不安全包去找一个C开发人员更熟悉的Rust子集会更容易一些,也不会让我的同事们头疼得多,但我还是决定不做任何手脚,自由使用泛型、迭代器适配器、TypeState编程和其他会让2010年的Cuervo哭得热血沸腾的东西,拥抱最接近的Kernighan和Ritchie版本。

一个真正的协作项目的压力教会了我很多,由于几个熟练的局外人的批评,许多假设得到了完善,他们像局外人经常做的那样,对我认为理所当然的事情有特权的观点。

在代码审查中经常提到的一个话题是泛型。也许还在从SFINAE的噩梦中恢复过来,一些同事不确定是否可以使用泛型来分组行为,而我们通常会为这些行为编写单独的实现。这些担忧往往属于以下三类之一:

第一个问题很容易消除,因为它通常来自于对静态调度的不熟悉。我们正在做的任何事情中都没有vtable或heap分配,我保证!第二个担忧是有道理的,但我发现它在实践中可以忽略不计;我计划发表另一篇博客文章,给出一些具体的基准。

最后一个问题是最主观的,因此也是最难辩驳的,所以我决定在这个博客系列中集中讨论它。我将回顾两个类似的闪存驱动程序的设计过程,并希望展示通用编程如何使工作更容易,结果更适合居住,即使在贫瘠、不健康、崎岖的裸机固件世界中也是如此。

编译时是反对自由使用泛型的另一个常见且非常有效的论点。然而,对于像这样占用空间较少的嵌入式项目来说,这并不是一个大问题。

闪存是电子非易失性存储器。它在消费电子产品中无处不在;任何时候,当你关掉一个小设备,它会记住一些东西--无论是它的设置、歌曲、文档,甚至是它自己的程序--你都有可能要感谢闪存。我们将会看到两个不同的NOR闪存芯片,因为Loadstone的第一个演示要求我们同时运行:

您可能已经知道闪存的用途,但非固件开发人员可能不知道闪存是奇怪的。您不能简单地向NOR闪存地址写入一个字节,先生,那是不礼貌的。虽然blob offlash内存会很高兴地将1变成0,但相反的操作将会静悄悄地失败。

你可以把每1个比特(也不是闪光的擦除状态)看作是你可以吹灭的一支点燃的蜡烛。然而,在这个比喻中,你没有得到一个打火机来重新点燃它们;你得到的是一个火焰喷射器。在不涉及相关硬件原理的情况下,NOR闪存的设计要求您批量擦除(即设置为1)内存,块大小通常比最小可寻址内存大几个数量级。在大多数芯片上,你甚至会有三个方向的不匹配:你的读、写和擦除大小不相等。呃.。

正如您可以想象的那样,这使得写入闪存驱动程序有点麻烦,特别是因为即使是最小的写入操作也会变成读/写周期。写入单个字节需要读取目标地址周围的最小可擦除块(其本身可能需要多次读取),潜在地擦除整个块,然后写回与所需字节合并的原始数据。

正如你也可以想象的,除了写这个驱动程序的人,没有人愿意关心这件事。即使在裸机软件的极简主义世界中,高效的协作也依赖于开发人员整理这些锋利的边缘,提供统一或隐藏与更大设计无关的任何硬件方面的界面。因此,第一次尝试闪存接口应该只是提供一种读取和写入存储器范围的方法。

Pub特征读写{type error;type address;fn read(&;mut self,address::address,bytes:&;mut[U8])->;result<;(),self::error>;;fn write(&;mut self,address:address,bytes:&;[U8])->;result<;(),self::error>;;fn range(。Self)->;(Self::Address,Self::Address);FN擦除(&;mut Self)->;Result<;(),Self::Error>;}。

以上就是我作为闪存驱动程序的通用接口聚合在一起的内容。如果您不熟悉Rust泛型,那么上面的代码不是要继承的类型,也不是要继承的开放类。它更像是一个Haskell类型类;一个具体类型要实现的一组要求,在本例中以相关类型(错误和地址)和方法签名(读、写、范围和擦除)的形式描述。

即使在这个早期阶段,也必须做出一些权衡。敏锐的、饱受硬故障创伤的读者会注意到,这个界面并不能很好地解决时间敏感的问题。所有方法都是分块的,并且将读/写周期抽象化将自然导致不确定的写入时间;如果只需要切换位,则写可能需要很少的时间,或者如果它横跨需要擦除操作的两个大扇区,则可能需要很长的时间。Read中的bytes输出参数也可能会让您觉得不太生疏,而返回VEC<;u8>;通常是惯用的。不幸的是,我们没有堆可用,所以如果你想把你的字节带回家,你必须带上你自己的包。

事实上,编写通用接口是很困难的。这一点对于我们正在解决的问题是有意义的,但是一定要记住您的项目的要求!

啊,简单地说,这个词很棘手。手中有了数据表,写起来可能会更容易。它甚至可能为我们节省几个字节。但总而言之,我认为从这里开始的好处是值得的,缺点是:

使编写双重测试和利用静态分派进行单元测试变得容易。这种方法是我在编写C语言时最怀念的,我被迫求助于链接时间替换,或者用太卑鄙的预处理器来做一些事情,甚至在这里都不提。

将您的设计从一开始就解耦,让协作变得轻松。其他开发人员可以立即开始使用此接口,它的抽象性足以让您相信,随着更多硬件知识的出现,它不会被更改。

首先,抽象会迫使您仔细思考每个实现都有哪些行为是常见的,而哪些行为是不常见的,这不会让您自暴自弃。

但最大的原因,也是C++和Rust不同之处之一,是您可以在本地对此接口进行推理的事实。这源于C++模板系统和铁锈特性之间不明显的差异。其中C++模板类型检查在实例化时进行,而Rust特征类型检查在定义时进行。

这是什么意思?这意味着在Rust中不可能出现令人费解的、在库中有7英尺深的C++模板错误,因为编译器不需要超出接口来证明它是正确使用的。

使用C++模板的类似界面可能会将两个开发人员送到不同的路径上,一个开发具体的闪存驱动程序实现,另一个开发依赖闪存的更高级别的结构,但当任何一方出现细微的不兼容性时,他们之间的墙就会坍塌,将一个开发人员的工作细节泄露到另一个开发人员的错误消息中。相比之下,锈迹斑斑的墙是坚固的。

是的,我知道C++20的概念和约束有助于对某些要求进行编码,并使错误变得更好。不幸的是,它们不能强制所有行为都受到约束,所以C++模板系统仍然是鸭型的。如果你的鸭子不嘎嘎叫,你就会喝到尖括号错误汤。

现在我们对我们的界面很满意,是时候撸起袖子开始工作了。我们将从stm32f412 MCU引用开始。我们首先要知道的是我们的写入、读取和擦除大小。谢天谢地,我们在3.3节的开头发现了它的位置。

好的,我们的最小写入是一个字节,我们的最大读取是4个32位字,我们的最小擦除是一个(到目前为止还没有定义的)扇区。下一步是找到记忆地图,计算出一个扇区是什么,以及它的大小。

如你所见,我们的扇区大小有些不平衡。这在MCU闪存芯片中很常见,在那里你可能会存储少量的杂项数据。最后有三个特殊用途的部分我们并不关心。我们知道的足够多,可以开始为我们的司机打下基础:

#[派生(Default,Copy,Clone,Debug,PartialOrd,PartialEq,Ord,Eq)]发布结构地址(Pub U32);发布结构McuFlash{stm32f4::stm32f412::Flash,}#[派生(Copy,Clone,Debug,PartialEq)]发布枚举块{main,SystemMemory,OneTimeProgramable,Optiontes,}#[派生(Copy,Clone,Debug,PartialEq)]发布枚举块{main,SystemMemory,OneTimeProgramable,Optiontes,}#。Number_of_Sectors],}Const Memory_Map:MemoryMap=MemoryMap{Sector:[Sector::New(Block::Main,Address(0x0800_0000),KB!(16)),Sector::New(Block::Main,Address(0x0800_4000),KB!(16)),Sector::New(Block:Main,Address(0x0800_8000),KB!(16)),Sector::New(Block::Main,KB!(16)),Sector::New(Block::Main,Address(0x0800_4000),KB!(16))。Sector::New(Block::Main,Address(0x0801_0000),KB!(64)),Sector::New(Block::Main,Address(0x0802_0000),KB!(128)),Sector::New(Block::Main,Address(0x0804_0000),KB!(128)),Sector::New(Block::Main,Address(0x0806_0000),KB!(128)),Sector::New(Block::Main,Address(0x0806_0000),KB!(128))。Sector::New(Block::Main,Address(0x080A_0000),KB!(128)),Sector::New(Block::Main,Address(0x080C_0000),KB!(128)),Sector::New(Block::Main,Address(0x080E_0000),KB!(128)),Sector::New(Block::SystemMemory,Address(0x1FFF_0000),KB!(32)),Sector::New(Block::OptionBytes,Address(0x1FFF_C000),16),],};

虽然说块包含扇区会更正确,但我们将块定义为扇区的属性,因为除了对扇区类型进行分类外,它们没有太多用处。

Const将memory_map结构标记为编译时构造。这意味着编译器可以根据优化条件自由地内联它或将其放入静态内存中,并且它可以在常量表达式中使用。

现在我们遇到了一个岔路口。我通常的直觉是完全开发和测试这个驱动程序,然后转到下一个驱动程序。然而,我们正在试验一种抽象优先的方法,所以让我们早点绕道,把外部微米驱动程序带到类似的地方。

泛型不仅在接口级别有用!图书馆内部的私人行为也可以受益。对于这个项目,它的回报是积极主动,及早发现可能的重复来源。

好的,到目前为止一切顺利。对于我们目前的目的,我们可以忽略扇区粒度,而将4KB视为最小擦除大小。环顾四周,我们发现没有最大读取大小,但我们的写入被限制为一次只能写入一页:

我们可以立即看到我们用于MCU闪存的方法是如何不起作用的;我们不能简单地将256个扇区内联到一个表中。这也有一个乐透的结构--部门和子部门是均匀分布的--这使其本身成为一种计划性的表现形式。

有几种方法可以对此进行编码。起初,我试图使用常量表达式定义数组,但此时常量表达式的表达能力有限,使得代码有点不美观。相反,我决定使用迭代器解决方案:

#[派生(Default,Copy,Clone,Debug,PartialOrd,PartialEq,Eq,Ord)]发布结构地址(发布u32);发布结构内存映射{}发布结构扇区(Usize);发布结构子扇区(Usize);发布结构页面(Usize);常量base_address:address=address(0x0000_0000);const page_per_subsector:usize=16;const。常量SUBSCADE_SIZE:usize=page_size*Pages_per_Sector;Const Sector_Size:usize=subsector_size*subsectors_per_sector;const memory_size:usize=number_of_sectors*sector_size;const number_of_sectors:usize=256;const number_of_subsectors:usize=number_of_sectors*subsectors_per_sector;const number_of_page:usize=number_of_subsectors*page_per。实施内存映射{pub fn Sectors()->;Iml Iterator<;Item=Sector>;{(0.。扇区数量)。Map(Sector)}pub fn subsectors()->;Iml Iterator<;Item=Subsector>;{(0..。子行业的数量)。Map(Subsector)}pub FN Pages()->;Iml Iterator<;Item=Page>;{(0..。页数)。Map(Page)}pub const fn location()->;address{base_address}pub const fn end()->;address{address(base_address.。0+memory_size as u32)}pub const fn size()->;usize{memory_size}}Impll Sector{pub FN Subsectors(&;self)->;Impll Iterator<;Item=SubSector>;{((sel.。0*子扇区_每_扇区)..。((1+自我。0)*SubSectors_Per_Sector))。Map(Subsector)}Pub FN Pages(&;Self)->;Iml Iterator<;Item=Page>;{((Self.。0*Pages_Per_Sector)..。((1+自我。0)*Pages_Per_Sector)。MAP(Page)}发布FN位置(&;self)->;地址{BASE_ADDRESS+SELF。0*self::size()}pub fn end(&;self)->;地址{sel.。Location()+Self::Size()}发布fn位于(Address:Address)->;Option<;Self>;{MemoryMap::Sectors()。FIND(|s|S.包含(地址))}pub const fn size()->;usize{Sector_Size}}//[..]。Subsector和PAGE的类似实现。

存在类型使签名更清晰,因为使用此函数的人都不关心内部使用的迭代器适配器。

我愿意认为,在遥远的未来的某个时候,当我们拥有生锈动力的飞行汽车时,我们将能够制作你在MemoryMap实现常量下看到的所有函数。不幸的是,迭代器在Const FN中还不是公平的游戏,更不用说存在类型了。

似乎我们在寻求同时开发这两个驱动程序方面做得不是很好。我们刚刚开始,我们已经有了两个完全不同的内存映射表示。此外,擦除粒度也不同,MCU闪存按扇区擦除,而Micron芯片则乐于下行子扇区。

我们应该放弃,为两个完全独立的驱动程序实现而努力吗?我说我们不应该。这就是Rust的用武之地,它给了我们惊人的工具,让我们后退一步,专注于真正重要的事情。让我们忘掉我们学到的所有具体芯片细节,勾勒出我们正在研究的抽象细节。

我们对包含地址的区域有一些概念。这可以是矢量、子扇区或页面。这种区别是武断的,所以对我们来说无关紧要。

好的,这看起来简单多了。让我们将其建模到我们的箱子中的某个私有位置,在那里我们可以存储闪存驱动程序常用的功能:

酒吧特征地址:Ord+copy{}酒吧特征区域<;A:地址>;{fn包含(&;self,address:A)->;bool;}。

美好的是,上面的表示不会失去任何一般性。尽管我们已经承诺了对区域是什么的某些解释,即使这些表示表现得非常不同,但我们知道它们在概念上都符合上述特征,所以我们可以安全地将它们用作我们未来唯一的模型。

在下一篇文章中,我们将看到如此简单的表示如何足以让我们编写大量有用的代码,从而使每个最终的具体驱动程序成为硬件细节的简明表示,最实际的行为存在于它们共同的抽象层中。

我会尽快在这里找到下一条的链接,所以请保持关注!我在社交媒体上不是很活跃,所以如果有任何问题、建议、批评或仇恨邮件,请发送到我的电子邮件或帖子的红线。