内核和用户空间之间的通信是编写BPF程序的基础。内核程序和用户空间程序代码都可以访问映射,但它们使用的API签名不同。本节我们将介绍这些接口实现的语法和具体细节。
创建映射后,你可能要做的第一件事就是保存内容。内核提供了帮助函数bpf_map_update_elem来实现该功能。内核程序需要从bpf/bpf_helpers.h文件加载bpf_map_update_elem函数,而用户空间程序则需要从tools/lib/bpf/bpf.h文件加载,所以内核程序访问的函数签名与用户空间访问的函数签名是不同的。之所以有如此区别,是因为内核程序可以直接访问映射,而用户空间程序需要使用文件描述符来引用映射。当然,内核程序和用户空间程序调用该函数的行为也略有不同。在内核上运行的代码可以直接访问内存中的映射,并原子性地更新元素。但是,在用户空间中运行的代码要发送消息到内核,需要先复制值再进行更新映射,这使得更新操作不是原子性的。当操作成功时,该函数返回0;如果失败,将返回负数,并将失败原因写入全局变量errno中。在本章后面我们将根据不同上下文给出具体的失败场景。
内核中的bpf_map_update_elem函数有四个参数。首先是一个指向已定义映射的指针。第二个是指向要更新的键的指针。因为内核不知道更新键的类型,所以该参数定义为指向void的不透明指针,这意味着我们可以传入任意数据。第三个参数是我们要存入的值。此参数使用与键参数相同的语义。随后我们会有更多示例来说明如何使用不透明指针。该函数的第四个参数是更新映射的方式,此参数有三个值:
·如果传递0,表示如果元素存在,内核将更新元素;如果不存在,则在映射中创建该元素。
·如果传递1,表示仅在元素不存在时,内核创建元素。
·如果传递2,表示仅在元素存在时,内核更新元素。
为方便记忆,三个值被定义成常量。如BPF_ANY表示0,BPF_NOEXIST表示1,BPF_EXIST表示2。
我们使用3.1节中定义的映射来编写一些示例。在第一个示例中,我们向映射中添加一个新值。此时,因为映射是空的,任何更新行为都是可以的。
int key, value, result; key = 1, value = 1234; result = bpf_map_update_elem(&my_map, &key, &value, BPF_ANY); if (result == 0) printf("Map updated with new element\n"); else printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));
在该示例中,我们使用strerror函数来表示errno变量中定义的错误集。我们可以使用man strerror查找手册页了解该函数的更多信息。
现在,让我们尝试在BPF映射中创建相同键的元素:
int key, value, result; key = 1, value = 5678; result = bpf_map_update_elem(&my_map, &key, &value, BPF_NOEXIST); if (result == 0) printf("Map updated with new element\n"); else printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));
因为我们已经在映射中创建了一个键为1的元素,所以调用bpf_map_update_elem的返回值将为-1,errno值将设置为EEXIST。程序将在屏幕上打印如下内容:
Failed to update map with new value: -1 (File exists)
同样,我们可以更改程序,尝试更新尚不存在的元素:
int key, value, result; key = 1234, value = 5678; result = bpf_map_update_elem(&my_map, &key, &value, BPF_EXIST); if (result == 0) printf("Map updated with new element\n"); else printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));
因为使用的标志是BPF_EXIST,所以函数结果将再次为-1。内核将errno变量设置为ENOENT,程序输出如下所示:
Failed to update map with new value: -1 (No such file or directory)
上述示例描述了如何从内核程序更新映射。当然,我们也可以从用户空间程序来更新映射。用户空间程序可以使用帮助函数实现上述示例的功能。唯一的区别是使用文件描述符访问映射,而不是直接使用映射的指针。需要记住的是,用户空间程序始终使用文件描述符来访问映射。在我们的例子中,将my_map参数替换为全局文件描述符map_data[0].fd。代码如下所示:
int key, value, result; key = 1, value = 1234; result = bpf_map_update_elem(map_data[0].fd, &key, &value, BPF_ANY); if (result == 0) printf("Map updated with new element\n"); else printf("Failed to update map with new value: %d (%s)\n", result, strerror(errno));
尽管我们在映射中保存的数据类型与使用的映射类型直接相关,但是无论使用哪种映射类型,我们都使用与上面的示例相同的方法对映射进行访问。接下来,我们将讨论每种映射类型的键和值类型。在此之前,让我们先看看如何对映射中保存的数据进行操作。
目前为止,我们已经将新元素写入BPF映射。接下来,我们可以在代码中开始读取这些映射。在了解bpf_map_update_element函数后,你会发现读取BPF映射的API看起来很熟悉。
BPF根据程序执行的位置提供了两个不同的帮助函数用来读取映射元素。这两个帮助函数名都为bpf_map_lookup_elem。与更新帮助函数一样,它们仅在第一个参数上有所不同。内核程序使用映射引用作为第一个参数,用户空间程序将映射的文件描述符作为帮助函数的第一个参数。两种方法都返回整数,表示操作失败或成功,这点与更新帮助函数一样。帮助函数的第三个参数是指向程序变量的指针,该变量将保存从映射中读取的值。我们将继续使用上一节中的代码演示两个示例。
第一个示例演示在内核中运行的BPF程序,该BPF程序读取BPF映射中的值:
int key, value, result; // value is going to store the expected element's value key = 1; result = bpf_map_lookup_elem(&my_map, &key, &value); if (result == 0) printf("Value read from the map: '%d'\n", value); else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));
如果通过bpf_map_lookup_elem读取映射元素返回负数,errno变量将被设置为错误信息。例如,如果我们试图读取之前没有插入的值,内核将返回“not found”错误信息,用ENOENT表示。
第二个示例与上面的示例类似,只是这次是从用户空间程序读取映射:
int key, value, result; // value is going to store the expected element's value key = 1; result = bpf_map_lookup_elem(map_data[0].fd, &key, &value); if (result == 0) printf("Value read from the map: '%d'\n", value); else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno));
如你所见,bpf_map_lookup_elem中的第一个参数将替换为映射的文件描述符。帮助函数的行为与上面示例的行为相同。
至此,我们介绍了如何读取BPF映射中的信息。后续我们将介绍通过不同的工具来简化数据访问,使数据访问更加简单。接下来,我们要讨论从BPF映射中删除数据。
在BPF映射上执行的第三项操作是删除元素。与读写BPF映射元素相同,BPF为我们提供了两个帮助函数来删除元素,函数名都是bpf_map_delete_element。与前面的示例一样,对于内核运行的程序,帮助函数直接使用BPF映射的引用。对于用户空间程序,帮助函数使用BPF映射的文件描述符。
第一个示例演示在内核中运行的BPF程序,该BPF程序将删除插入映射中的值:
int key, result; key = 1; result = bpf_map_delete_element(&my_map, &key); if (result == 0) printf("Element deleted from the map\n"); else printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));
如果要删除的元素不存在,将返回负值。在这种情况下,errno变量中写入“not found”错误信息,用ENOENT表示。
第二个示例是在用户空间中运行的BPF程序,该程序将删除插入映射中的值:
int key, result; key = 1; result = bpf_map_delete_element(map_data[0].fd, &key); if (result == 0) printf("Element deleted from the map\n"); else printf("Failed to delete element from the map: %d (%s)\n", result, strerror(errno));
你可以看到我们再次将第一个参数更改为映射的文件描述符。这个行为与其他内核帮助函数的行为保持一致。
到目前为止,我们学习了BPF映射创建/读取/更新/删除(CRUD)的操作。除此之外,内核还提供了实现其他常见操作的一些附加功能。接下来我们将讨论其中的一些功能。
本节中,我们将介绍对BPF映射进行的最后一个操作,即在BPF程序中查找任意元素。在某些情况下,我们可能不知道查找元素的键值是什么,或者我们只想查看映射中的内容。为此,BPF提供了bpf_map_get_next_key指令。该指令不像之前的帮助函数,该指令仅适用于用户空间上运行的程序。
这个帮助函数以明确的方式对映射上的元素进行迭代,但是,它的行为不如大多数编程语言中的迭代器那么直观。它需要三个参数,第一个参数是映射的文件描述符,这点与其他用户空间程序的帮助函数相同。接下来的两个参数有所不同,根据官方文档,第二个参数key是要查找的标识符,第三个参数next_key是映射中的下一个键。我更喜欢调用第一个参数lookup_key,这样更显而易见。当调用该帮助函数时,BPF会使用用户传入的键值作为查找键lookup_key,在该映射中查找元素,然后,使用相邻的键设置为next_key参数。因此,如果你想知道哪个键位于键1之后,需要将1设置为lookup key,如果映射上有与之相邻的键,BPF将其设置为next_key参数的值。
在演示bpf_map_get_next_key如何工作之前,我们先向映射中添加一些元素:
int new_key, new_value, it; for (it = 2; it < 6 ; it++) { new_key = it; new_value = 1234 + it; bpf_map_update_elem(map_data[0].fd, &new_key, &new_value, BPF_NOEXIST); }
如果你要打印映射中的所有值,也可以使用bpf_map_get_next_key和映射中不存在的查找键,这迫使BPF从开头遍历映射:
int next_key, lookup_key; lookup_key = -1; while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) { printf("The next key in the map is: '%d'\n", next_key); lookup_key = next_key; }
下面是该代码打印的输出:
The next key in the map is: '1' The next key in the map is: '2' The next key in the map is: '3' The next key in the map is: '4' The next key in the map is: '5'
在循环结束之前,我们将下一个键赋予lookup_key。以此,可以遍历映射的全部元素。当bpf_map_get_next_key到达映射的尾部时,返回值为负数,errno变量设置为ENOENT。这将终止循环执行。
bpf_map_get_next_key能在映射的任意位置上查找键。同时,如果你仅想知道指定键的下一个键,无须从映射的开头开始遍历。
bpf_map_get_next_key可以发挥的作用还不止如此。你需要注意的另一个行为是许多编程语言会在迭代映射元素前复制映射的值。这样当程序中的其他代码对映射进行修改时,可以阻止未知错误,尤其是从映射中删除元素,这是特别危险的。BPF使用bpf_map_get_next_key在遍历映射前不复制映射的值。如果程序正在遍历映射元素,程序的其他代码删除了映射中的元素,当遍历程序尝试查找的下一个值是已删除元素的键时,bpf_map_get_next_key将重新开始查找,下面是示例代码:
int next_key, lookup_key; lookup_key = -1; while(bpf_map_get_next_key(map_data[0].fd, &lookup_key, &next_key) == 0) { printf("The next key in the map is: '%d'\n", next_key); if (next_key == 2) { printf("Deleting key '2'\n"); bpf_map_delete_element(map_data[0].fd &next_key); } lookup_key = next_key; }
下面是程序打印的输出:
The next key in the map is: '1' The next key in the map is: '2' Deleteing key '2' The next key in the map is: '1' The next key in the map is: '3' The next key in the map is: '4' The next key in the map is: '5'
这个行为不是很直观,所以在使用bpf_map_get_next_key时请牢记。
在本章中我们介绍的大多数映射类型的行为类似于数组,所以当你想要访问保存在BPF映射中的信息时,遍历操作是一种关键操作。然而,这里还有一些附加功能可以用来访问BPF映射,我们将在下面介绍。
内核为BPF映射提供的另一个功能是bpf_map_lookup_and_delete_elem。此功能是在映射中查找指定的键并删除元素。同时,程序将该元素的值赋予一个变量。当我们使用队列和栈映射时,这个功能将派上用场,这部分将在本章后续介绍。然而,这个函数不仅仅适用于这两种映射类型。下面让我们看一个示例演示如何使用它,我们将使用之前示例中定义的映射:
int key, value, result, it; key = 1; for (it = 0; it < 2; it++) { result = bpf_map_lookup_and_delete_element(map_data[0].fd, &key, &value); if (result == 0) printf("Value read from the map: '%d'\n", value); else printf("Failed to read value from the map: %d (%s)\n", result, strerror(errno)); }
在这个示例中,我们尝试两次从映射中提取相同的元素。在第一个迭代中,该代码将打印映射中元素的值。因为我们使用的是bpf_map_lookup_and_delete_element,第一次迭代还将删除映射中的元素。第二次循环尝试获取元素时,该代码将会失败,errno变量设置为“not found”错误信息,用ENOENT表示。
到现在为止,也许你还没注意到,如果并发操作访问BPF映射中相同的信息将会发生什么。接下来我们将介绍并发访问。
使用BPF映射的挑战之一是许多程序可以同时并发访问相同的映射。这可能会在BPF程序中产生竞争条件,并使访问映射的行为不可预测。为了防止竞争条件,BPF引入了BPF自旋锁的概念,可以在操作映射元素时对访问的映射元素进行锁定,自旋锁仅适用于数组、哈希、cgroup存储映射。
内核中有两个帮助函数与自旋锁一起使用:bpf_spin_lock锁定、bpf_spin_unlock解锁。这两个帮助函数的工作原理是使用充当信号的数据结构访问包括信号的元素,当信号被锁定后,其他程序将无法访问该元素值,直至信号被解锁。同时,BPF自旋锁引入了一个新的标志,用户空间程序可以使用该标志来更改该锁的状态。该标志为BPF_F_LOCK。
使用自旋锁,我们需要做的第一件事是创建要锁定访问的元素,然后为该元素添加信号:
struct concurrent_element { struct bpf_spin_lock semaphore; int count; }
我们将这个结构保存在BPF映射中,并在元素中使用信号防止对元素不可预期的访问。现在,我们可以声明持有这些元素的映射。该映射必须使用BPF类型格式(BPF Type Format,BTF)进行注释,以便验证器知道如何解释BTF。BTF可以通过给二进制对象添加调试信息,为内核和其他工具提供更丰富的信息。因为代码将在内核中运行,我们可以使用libbpf的内核宏来注释这个并发映射:
struct bpf_map_def SEC("maps") concurrent_map = { .type = BPF_MAP_TYPE_HASH, .key_size = sizeof(int), .value_size = sizeof(struct concurrent_element), .max_entries = 100, }; BPF_ANNOTATE_KV_PAIR(concurrent_map, int, struct concurrent_element);
在BPF程序中,我们可以使用这两个锁帮助函数保护这些元素防止竞争条件。映射元素的信号已被锁定,程序就可以安全地修改元素的值:
int bpf_program(struct pt_regs *ctx) { int key = 0; struct concurrent_element init_value = {}; struct concurrent_element *read_value; bpf_map_create_elem(&concurrent_map, &key, &init_value, BPF_NOEXIST); read_value = bpf_map_lookup_elem(&concurrent_map, &key); bpf_spin_lock(&read_value->semaphore); read_value->count += 100; bpf_spin_unlock(&read_value->semaphore); }
这个示例初始化包括一个元素的并发映射,该元素可以对值的访问进行锁定。然后,从映射中获取值并锁定其信号,防止count值发生数据竞争。值被使用完成后,将释放锁以便其他映射可以安全地访问该元素。
在用户空间上,我们可以使用标志BPF_F_LOCK保存并发映射中元素的引用。我们可以在bpf_map_update_elem和bpf_map_lookup_elem_flags两个帮助函数中使用此标志。该标志允许你就地更新元素而无须担心数据竞争。
对于更新哈希映射、更新数组和cgroup存储映射,BPF_F_LOCK的行为略有不同。对于后两种类型,更新是就地发生,在执行更新之前,元素必须存在于映射中。对于哈希映射,如果元素不存在,程序将锁定映射元素的存储桶,然后插入新的元素。
自旋锁并非总是必需的。如果你只是对映射中的元素进行汇聚,则不需要自旋锁。然而,当你在映射上执行多项操作,希望确保并发程序不会更改映射中的元素,从而保持原子性时,自旋锁是有用的。
在本节中,你已经了解了使用BPF映射可以执行的操作。然而,到目前为止,我们仅使用一种映射类型。BPF包含诸多映射类型,在不同情况下,可以选择使用不同的映射类型。接下来,我们将介绍BPF定义的所有映射类型并演示具体示例,以便你能够了解如何在不同情况下使用它们。