我认为您的更新程序不安全。根据self-modifying-code blog post,与 x86 不同,ARM 的指令缓存与数据缓存不一致。
非跳转的第一条指令仍然可以被缓存,因此另一个线程可以进入该块。当执行到达块的第二个 i-cache 行时,可能会重新加载并看到部分修改状态。
还有另一个问题:中断(或上下文切换)可能会导致仍在执行旧版本的线程中逐出/重新加载缓存行。 就地重写指令块要求您确保所有其他线程中的执行在您修改内容后都已退出该块,以便新线程不会进入它。即使使用一致的 I-cache(如 x86),即使代码块适合单个缓存行。
我认为没有任何方法可以在 ARM 上同时确保就地重写既安全又高效。
如果没有一致的 I 缓存,您也无法保证其他线程会在这种设计下及时看到代码更改,而无需在每次运行之前从 L1I 缓存中刷新块等极其昂贵的事情。
使用一致的 I-cache(x86 样式),您可以等待足够长的时间,以等待另一个线程完成旧版本执行的任何可能延迟。即使该块不执行任何 I/O 或系统调用,缓存未命中和上下文切换也是可能的。如果它以实时优先级运行,尤其是在禁用中断的情况下,那么最差缓存只是缓存未命中,即不是很长。否则我不会打赌少于一两个时间片(可能是 10 毫秒)的任何东西都是非常安全的。
These slides have a nice overview of ARM caches, mostly focusing on ARMv8.
我实际上将引用 another slide (about virtualizing ARM) 来获取此要点摘要,但我建议阅读 ELC2016 幻灯片,而不是虚拟化幻灯片。
软件在某些情况下需要注意缓存:可执行代码加载/生成
- 需要对统一点进行 D-cache 清理 + I-cache 失效
- 可能来自 ARMv8 上的用户空间
- 需要 ARMv7 上的系统调用
D-cache 可以在有或没有回写的情况下失效(所以请确保你清理/刷新而不是丢弃!)。您可以并且应该通过虚拟地址触发此操作(而不是一次刷新整个缓存,并且绝对不要为此使用 set/way 刷新)。
如果您在使 I-cache 无效之前没有清理 D-cache,则在 L2 中丢失代码后,代码提取可能会直接从主内存中提取到非一致的 I-cache 中。 (没有在任何统一缓存中分配陈旧的行,MESI 会阻止,因为 L1D 的行处于修改状态)。 无论如何,在架构上需要清理 L1D 到 PoU,并且无论如何都发生在非性能关键的写入线程中,所以最好只是这样做而不是试图推理它是否安全用于特定的 ARM 微体系结构。请参阅 cmets 了解 @Notlikethat 为消除我对此的困惑所做的努力。
有关从用户空间清除 I-cache 的更多信息,请参阅How clear and invalidate ARM v7 processor cache from User Mode on Linux 2.6.35。 GCC 的 __clear_cache() 函数和 Linux sys_cacheflush 仅适用于 mmapped 和 PROT_EXEC 的内存区域。
不要就地修改:使用新位置
在您计划拥有整个检测代码块的地方,放置一个间接跳转(或保存/恢复lr 和一个函数调用,如果您无论如何都要有一个分支)。每个块都有自己的跳转目标变量,可以自动更新。这里的关键是,间接跳转的目的地是数据,因此它与写入线程中的存储是一致的。
由于您以原子方式更新指针,消费者线程要么跳转到旧代码块,要么跳转到新代码块。
现在您的问题是确保没有核心在其 i-cache 中具有新位置的陈旧副本。 考虑到上下文切换的可能性,包括当前核心,如果上下文切换不'不要完全刷新 i-cache。
如果您为新块使用足够大的位置环形缓冲区,以便它们闲置足够长的时间以被驱逐,那么在实践中可能永远不会出现问题。不过,这听起来很难证明。
如果与其他线程运行这些动态修改块的频率相比更新频率较低,那么在写入新块后让发布线程在其他线程中触发缓存刷新可能足够便宜,但是之前更新间接跳转指针指向它。
强制其他线程刷新其缓存:
Linux 4.3 及更高版本有一个membarrier() system call,它将在系统返回之前在系统中的所有其他内核上运行内存屏障(通常带有处理器间中断)(从而屏蔽所有进程的所有线程)。另请参阅 this blog post 描述一些用例(如用户空间 RCU)和 mprotect() 作为替代方案。
不过,它似乎不支持刷新指令缓存。如果您正在构建自定义内核,您可以考虑添加对新的cmd 或flag 值的支持,这意味着刷新指令缓存而不是(或同时)运行内存屏障。也许flag 值可能是一个虚拟地址?这仅适用于地址适合 int 的架构,除非您调整系统调用 API 以查看新 cmd 的 flag 的完整寄存器宽度,但仅适用于现有 @ 的 int 值987654343@.
除了破解 membarrier() 之外,您还可以向消费者线程发送信号,并让他们的信号处理程序刷新 i-cache 的适当区域。这是异步的,所以生产者线程不知道何时可以安全地重用旧块。
IDK 如果munmap()ing 它会起作用,但它可能比必要的成本更高(因为它必须修改页表并使相关的 TLB 条目无效)。
其他策略
您可以通过在共享变量中发布单调递增的序列号来做一些事情(具有发布语义,因此它是按指令写入排序的)。然后消费者线程根据线程本地最高可见的序列号检查序列号,如果有新内容,则使 i-cache 无效。这可以是每个块或全局的。
这并不能直接解决检测运行旧块的最后一个线程何时离开它的问题,除非那些每线程最高可见计数器实际上不是线程本地的:仍然是每线程但生产者线程可以看看他们。它可以扫描它们以查找任何线程中的最低序列号,如果该序列号高于未引用块时的序列号,则现在可以重用它。注意false sharing:不要为它使用unsigned long 的全局数组,因为您希望每个线程的私有变量与其他线程本地内容位于单独的缓存行中。
另一种可能的技术:如果只有一个消费者线程,则生产者将跳转目标指针设置为指向不会更改的块(因此不需要刷新 i-cache)。该块(在消费者线程中运行)为 i-cache 的相应行执行缓存刷新,然后再次修改跳转目标指针,这次指向应该每次运行的块。
对于多个消费者线程,这有点笨拙:也许每个消费者都有自己的私有跳转目标指针,而生产者更新所有这些?