2021年8月

一开始看内存安全的时候,了解到从电路上窃取信息这种操作是真实存在的,专业术语称作Bus Snooping总线嗅探。然后当时就理所当然地认为,嗅探属于网络攻击的一种,应该尽可能防范。

然而gem5又给我上了一课(没错还是它)。在阅读代码的时候,看到src/mem/packet.hh的注释里提到了Snoop Flags。这是个啥?难道gem5还支持安全攻击建模的,之前咋没听说过?

查阅资料后,咕果搜索结果中出现了缓存一致性这个关键词。进一步了解后发现,嗅探是当今多核时代下,一种朴素而常见的、确保缓存一致性的通信手段。

根据英文wiki,对于内存来说,有两种常见的内存一致性的级别:

一是Cache Coherent缓存一致。在这种一致性级别下,要求对于单一内存地址的数据,满足程序所有线程的执行顺序(反过来想,我们是否可以理解为,某些内存地址的数据操作是不需要遵循全局顺序的,比如只有单个线程使用的私有内存区块?)。另一种定义是,如果对于同一内存地址的写操作是顺序排列的(即同一时刻只有一个写操作),那么该系统具有缓存一致性。

另一个则是Sequentially Cosistent顺序一致。在这种一致性级别下,要求程序的整个内存区块中的内存操作都必须满足所有线程的总体执行顺序。

然后看到了一篇介绍缓存一致性的文章(原文/译文),了解到了在MESI缓存一致性协议中,是如何通过总线嗅探来实现缓存区块的状态转移的。

MESI协议的名称由协议本身定义状态机的四个状态首字母缩写组成,其中MModified、SShared、IInvalid三个状态分别对应单核系统下缓存的三种状态:脏(dirty)、干净(clean)和无效(invalid)。MESI遵循了缓存一致性的原则,对于同一内存地址,同一时刻只允许一个线程具有写的权利(处于M状态)。

由于要尽可能地提升多核系统的工作效率,经典设计方案是为每个核分配一块独占的缓存空间,而这也是导致传统缓存状态机出现问题的关键所在:若是继续使用单核的强同步策略(写入即同步)的话,同步缓存的操作将大幅降低处理器运行效率;不使用同步策略的话,则会带来经典的写前读/写后读问题。

MESI协议则可以视作一种弱同步策略,仅在特殊事件发生的时候执行同步操作。这个特殊事件直接映射成了MESI中的第四个状态EExclusive,也就是独占状态。MESI规定,只有在进入E状态后,才能进入M状态。在MESI中,需要嗅探的内容就是进入这个E状态的内存操作。

关于MESI协议的具体内容可以参见wiki,这里就懒得写了

翻译自 http://iram.cs.berkeley.edu/kozyraki/project/ee241/report/crossbar.html

The memory crossbar connects the multiple Load/Store units of the processor to the multiple memory sections. The L/S units are capable of issuing either a load or a store on every cycle, and as a result, the memory sections should, ideally, be capable of processing these requests at the same rate. In order words, if there are n L/S units, each issuing one memory accesses per cycle, then the crossbar must have a peak bandwidth of at least n words per cycle.

内存交叉开关是一种用于将多个处理器(内存)读写单元与多个内存区块相连接的结构。由于单个(内存)读写单元可在单个时钟周期内完成一次读或写操作,我们可以认为,在理想状态下,被连接的(单个)内存区块也应该具有相同的请求处理速度(即每时钟周期处理一个读或写请求)。也可以理解为,如果(在使用了内存交叉开关的内存系统中)CPU有n个(内存)读写单元,每个单元每时钟周期可完成一次读或写请求的话,那么在该存储交叉开关处至少有等价于每周期n次读写请求的最大带宽。

largexbar.gif

上图是该文章给出的内存交叉开关一种实现的图例。图中上侧为内存读写单元,下侧为内存模块,中部的横线表示总线,一条线就是一根完整的总线;这里有每种四根则表示有四组相同的总线,实心点表示内存读写单元,叉则表示开关。由于总线只有单一的所有权,一个周期内则只能供一个内存读写单元和一个内存模块使用,也正是上文最大n倍单总线吞吐量的原理。

还是神奇的gem5。

这次是在学习底层实现的时候,看到的船新POSIX API:

    if (sharedBackstore.empty()) {
        shm_fd = -1;
        map_flags =  MAP_ANON | MAP_PRIVATE;
    } else {
        DPRINTF(AddrRanges, "Sharing backing store as %s\n",
                sharedBackstore.c_str());
        shm_fd = shm_open(sharedBackstore.c_str(), O_CREAT | O_RDWR, 0666);
        if (shm_fd == -1)
               panic("Shared memory failed");
        if (ftruncate(shm_fd, range.size()))
               panic("Setting size of shared memory failed");
        map_flags = MAP_SHARED;
    }

    ...

    uint8_t* pmem = (uint8_t*) mmap(NULL, range.size(),
                                    PROT_READ | PROT_WRITE,
                                    map_flags, shm_fd, 0);

由于这块代码是从gem5中内存模块复制出来的,其功能是配置内存模块维护被模拟系统数据所需的储存空间(初始化),可以预见的是单词分配的内存空间大小至少是MB,甚至GB级别的。第一眼看去有点眼花,还在想Linux的shared memory什么时候能够配置GB级别的共享内存块了?仔细一看才发现,这里用的是shm_open而不是shmget,只是名字长得像,功能有很大差别= =,前者在fnctl.h,后者在shm.h中定义。(P.S. 在APUE第三版上翻了老半天都没找到,我觉得应该不是我的问题

比较一下shmgetshm_open的函数签名:

int shmget(key_t key, size_t size, int shmflg);
int shm_open(const char *name, int oflag, mode_t mode);

不同于shmget在创建共享内存块的时候需要声明块大小,且存在比较小气的最大限制(Linux每块默认不大于4KB,MacOS和Solaris还是比较大气,大了几个数量级),shm_open的语法更偏向于open,创建的是一个默认大小为0的空文件,关于文件大小、文件内容需要开发者后续调用其它syscall来修改。

那么shm_open实际上做了什么呢?实际上是创建了一个基于内存的文件描述符。根据man7上的文档介绍,Linux上的实现方法是建立了一个基于内存的tmpfs,并挂载到/dev/shm目录下。调用shm_open的时候,会对应地在目录下用文件的形式进行相应的操作。

如上文所述,shm_open以后,文件的大小为0,用常规方法往文件写入数据并不能如我们所愿,将文件“撑”大。这时需要使用ftruncate系统调用解决该问题,由操作系统完成对文件大小的调整。

由于shm_open系统调用与进程无关,在不同程序中可以使用相同的操作获取同一块共享内存,达到无关进程内存共享的目的。这与文件系统类似,第一次调用建立文件,后面的重复调用只需要修改文件引用值即可。由shm_open创建的文件描述符多与mmap结合运用,直接将fd用于mmap中,其余按照需要配置即可,本文开头的代码段给出了较好的示范。

当一个进程不再需要使用这段共享内存的时候,使用shm_unlink函数,把获得的fd作为参数传过去即可。当最后一个引用关闭之后,共享内存对象由系统销毁。

这里顺便说一下上面的代码段中,mmap使用的一个小细节。当满足条件sharedBackstore.empty() == false的时候,fd设置为-1之后,直接执行了mmap操作。-1显然是一个无效的文件描述符,但是为什么程序还能正常运行?

在StackOverflow上搜索后,万能网友的给出了答案:此时的fd-1是允许的,而这是与mmap的参数map_flagsMAP_ANON(或MAP_ANONYMOUS)这个flag共用时的一个特殊情况。在声明了MAP_ANON/MAP_ANONYMOUS的情况下,该共享内存块仅对当前进程及其子进程有效,其他进程无法访问。某些POSIX实现要求这种情况下,fd一定要传-1,而这也只是特殊情况,并不代表在fd=-1的情况下mmap具有特殊的逻辑。

在看C4P的时候书上提到,有这么一段代码:

    char ch;
    cin >> ch;
    while(ch != '#')
    {
        ...
        cin >> ch;
    }

如果在循环中cin到一个char类型变量中,会使程序陷入死循环。当时就在想,如果拿不到合法的数据的话,不是应该丢掉非法输入,等待下一个合法输入的到来么。后来在网上查找相关信息后,发现C和C++对于这一异常情况有着不同的处理思路。

下文假设有如下定义:int var;

在C语言中,使用scanf("%d", &var);从输入流读取一个整型数据(浮点型同理)。在这种情况下,如果读取到的数据无法解析为对应类型的有效值的话,scanf的返回值为0,表示读入整型数据失败。在这种情况下,scanf不会修改对应的目标变量,下次scanf调用从当前失败的位置继续开始。

在C++中,使用的语句是cin >> var;。如果读取的数据无法转换为var的类型(这里是int)的话,根据C++规范版本,对var有两种不同的处理方式:在C++11之前,var的值不变;C++11及以后,var置零。同时,从此往后的cinfailbit置1,所有对cin的调用均失败返回,且不修改对应变量值。要解决这种情况,需要调用cin.clear();方法重置cin的异常状态,然后才可以继续正常使用cin

从此可以得知,上述代码在C++11的条件下的确会产生无限循环的问题:第一次调用失败时,ch置零,不等于#,循环继续;而后续由于cin处于fail状态,不再产生任何有效的数据读取,使得ch始终维持为0,进而导致无限循环的产生。可以用如下程序验证:

#include <iostream>

using namespace std;

int i = -1;
char ch = '?';

void cpp()
{
    cin >> i;
    cout << i << endl;

    cin >> ch;
    cout << ch << endl;
}

void c()
{
    scanf("%d", &i);
    printf("%d\n", i);

    scanf("%c", &ch);
    printf("%c\n", ch);
}

int main()
{
    // or cpp() to test the c++-style input
    c();

    return 0;
}

参考:
https://www.zhihu.com/question/54476315