eBPF HashMap 與 padding 的坑

發表於2024-03-03

前言

上一篇文章《ebpf-go 初體驗》中,我們提到了一個小插曲,就是當 map 的 key 這樣寫的時候 struct tuple key = {ip, bpf_ntohs(sport)},map 的 key 看起來會重複,有些令人詫異,於是我用另外一臺機器 B 測了下(核心 6.6,clang 14.0.0)。發現了報錯:"invalid indirect read from stack R2 off",順藤摸瓜找到了這篇檔案1 ,才反應過來:我們的 struct tuple 是不規整的,需要 padding,而不同的架構/編譯器對 padding 的處理又是一樣的,從而導致了不同的結果。

那麼這個 padding 究竟是怎麼導致看起來重複的 key 的呢?這就得看看 ebpf 的 hashmap 的實現原理了。

ebpf hashmap 核心原理

bpf 的 map 的操作都在 syscall2 中,從其中的 map_update_elem 下手,找到 bpf_map_update_value,然後是 map->ops->map_update_elem,找到 hashmap 對應的實現就在此3(per cpu4 的我們先不看),核心如下:

static long htab_map_update_elem(struct bpf_map *map, void *key, void *value,
                 u64 map_flags)
{
    
    hash = htab_map_hash(key, key_size, htab->hashrnd);
    b = __select_bucket(htab, hash);
    head = &b->head;

    l_old = lookup_elem_raw(head, hash, key, key_size);
    l_new = alloc_htab_elem(htab, key, value, key_size, hash, false, false,
                l_old);
    /* add new element to the head of the list, so that
     * concurrent search will find it before old elem
     */
    hlist_nulls_add_head_rcu(&l_new->hash_node, head);
}

如果你學過 Java 就知道:一個 Objec 要能成為 hashmap 的 key,必須得有 hashcodeequals 方法。這也是hashmap 的核心,與語言無關。那麼上面的程式碼如何體現的呢?

首先,htab_map_hash 計算 key 的雜湊值,主要實現是 jhash25,這裡就不展開了。
然後 equasls 體現在 lookup_elem_raw 中,用的是 memcmp 也就是:二進位制相等。

所以我猜測:key 雖然看起來相同,但是二進位制是不同的。接下來自然是驗證一番。

驗證

c 程式碼可以這樣寫

struct tuple key = {ip,r_sport};

char serialized[sizeof(struct tuple)];
__builtin_memcpy(serialized, &key, sizeof(struct tuple));
for (int i = 0; i < sizeof(struct tuple); i++) {
    bpf_printk("0x%x ",serialized[i]);
}

go 程式碼可以這樣寫

iter := objs.PktCountMap.Iterate()
for iter.Next(&key, &val) {
  const sz = int(unsafe.Sizeof(counterTuple{}))
  var asByteSlice []byte = (*(*[sz]byte)(unsafe.Pointer(&key)))[:]

  var sb strings.Builder 
  for _, b := range asByteSlice {
      sb.WriteString(fmt.Sprintf("0x%x ", b))
  }
 
  sourceIP := key.Addr
  sourcePort := key.Port
  packetCount := val
  log.Printf("%d/%s:%d(%s) => %d\n", sourceIP, int2ip(sourceIP), sourcePort, sb.String(), packetCount)
}

採用上文的環境進行測試,go 程式碼輸出

tuple num: 5
16777343/127.0.0.1:4000(0x7f 0x0 0x0 0x1 0xa0 0xf 0x0 0x0 ) => 4
16777343/127.0.0.1:4000(0x7f 0x0 0x0 0x1 0xa0 0xf 0xff 0xff ) => 3
16777343/127.0.0.1:4002(0x7f 0x0 0x0 0x1 0xa2 0xf 0x0 0x0 ) => 4
16777343/127.0.0.1:4002(0x7f 0x0 0x0 0x1 0xa2 0xf 0xff 0xff ) => 3
16777343/127.0.0.1:4001(0x7f 0x0 0x0 0x1 0xa1 0xf 0xff 0xff ) => 3

bpf 輸出

bpf_trace_printk: Process a packet of tuple from 16777343|127.0.0.1:41487|4002
bpf_trace_printk: 0x7f 
bpf_trace_printk: 0x0 
bpf_trace_printk: 0x0 
bpf_trace_printk: 0x1 
bpf_trace_printk: 0xffffffa2 
bpf_trace_printk: 0xf 
bpf_trace_printk: 0x0 
bpf_trace_printk: 0x0 

bpf_trace_printk: Process a packet of tuple from 16777343|127.0.0.1:41487|4002
bpf_trace_printk: 0x7f 
bpf_trace_printk: 0x0 
bpf_trace_printk: 0x0 
bpf_trace_printk: 0x1 
bpf_trace_printk: 0xffffffa2 
bpf_trace_printk: 0xf 
bpf_trace_printk: 0xffffffff 
bpf_trace_printk: 0xffffffff 

可見雖然 tuple 內部的兩個欄位一樣,但是 padding 的兩個位元組卻不一樣,導致在 hashmap 中存了兩個看起來一樣的 key。
機器 B 應該是直接沒有初始化 padding,導致了報錯。當我把機器 B 的 clang 升級到 15.0.7 後,它的 padding 又穩定相同了,沒有再出現看起來相同的 key。可見這個 padding 不具備可移植性。

解法

padding 的值不能依賴編譯器去處理,最推薦的做法是這樣的:

struct tuple key;
__builtin_memset(&key,0,sizeof(key));
key.addr = ip;
key.port = r_sport;

讓 padding 被顯示地初始化,確保不會出現各種奇奇怪怪的錯誤(無論是得到錯誤的結果還是直接執行不起來)。

PS:觀測 padding 可以用下面這個工具:

pahole counter_bpfel.o -C tuple

struct tuple {
        __u32                      addr;                 /*     0     4 */
        __u16                      port;                 /*     4     2 */

        /* size: 8, cachelines: 1, members: 2 */
        /* padding: 2 */
        /* last cacheline: 8 bytes */
};

參考

相關文章