手机app流量统计(BPF&XDP指南-工具链(2)LLVM)

Posted

篇首语:宁愿跑起来被拌倒无数次 也不要规规矩矩走一辈子。本文由小常识网(cha138.com)小编为大家整理,主要介绍了手机app流量统计(BPF&XDP指南-工具链(2)LLVM)相关的知识,希望对你有一定的参考价值。

手机app流量统计(BPF&XDP指南-工具链(2)LLVM)

写作本文时,LLVM 是唯一提供 BPF 后端的编译器套件。gcc 目前仅部分支持,但不如LLVM完善。


BPF后端在LLVM3.7版本就已经合入了,主流的发行版在对 LLVM 打包的时候就默认启用了 BPF 后端。因此,在大部分发行版上安 装 clang 和 llvm 就可以将 C 代码编译为 BPF 对象文件了。



典型的工作流


  1. 用 C 编写 BPF 程序
  2. 用 LLVM 将 C 程序编译成对象文件(ELF)
  3. 用户空间 BPF ELF 加载器(例如 iproute2)解析对象文件
  4. 加载器通过 bpf() 系统调用将解析后的对象文件注入内核
  5. 内核验证 BPF 指令,然后对其执行即时编译(JIT),返回程序的一个新文件描述符
  6. 利用文件描述符 attach 到内核子系统(例如网络子系统)


某些子系统还支持将 BPF 程序 offload 到硬件(例如网卡)。


2.2.1 BPF Target(目标平台)



查看 LLVM 支持的 BPF target:


$ llc --versionLLVM (http://llvm.org/):LLVM version 3.8.1Optimized build.Default target: x86_64-unknown-linux-gnuHost CPU: skylakeRegistered Targets:  [...]  bpf        - BPF (host endian)  bpfeb      - BPF (big endian)  bpfel      - BPF (little endian)  [...]


默认情况下,bpf target 使用编译时所在的 CPU 的大小端格式,即,如果 CPU 是小 端,BPF 程序就会用小端表示;如果 CPU 是大端,BPF 程序就是大端。这也和 BPF 的运 行时行为相匹配,这样的行为比较通用,而且大小端格式一致可以避免一些因为格式导致的 架构劣势。


BPF 程序可以在大端节点上编译,在小端节点上运行,或者相反,因此对于交叉编译, 引入了两个新目标 bpfeb 和 bpfel。注意前端也需要以相应的大小端方式运行。


在不存在大小端混用的场景下,建议使用 bpf target。例如,在 x86_64 平台上(小端 ),指定 bpf 和 bpfel 会产生相同的结果,因此触发编译的脚本不需要感知到大小端 。


下面是一个最小的完整 XDP 程序,实现丢弃包的功能(xdp-example.c):


#include <linux/bpf.h>#ifndef __section# define __section(NAME)                  \\__attribute__((section(NAME), used))#endif__section("prog")    int xdp_drop(struct xdp_md *ctx)    return XDP_DROP;char __license[] __section("license") = "GPL";


用下面的命令编译并加载到内核:


$ clang -O2 -Wall -target bpf -c xdp-example.c -o xdp-example.o# ip link set dev em1 xdp obj xdp-example.o


以上命令将一个 XDP 程序 attach 到一个网络设备,需要是 Linux 4.11 内核中支持 XDP 的设备,或者 4.12+ 版本的内核。


LLVM(>= 3.9) 使用正式的 BPF 机器值(machine value),即 EM_BPF(十进制 247 ,十六进制 0xf7),来生成对象文件。在这个例子中,程序是用 bpf target 在 x86_64 平台上编译的,因此下面显示的大小端标识是 LSB (和 MSB 相反):


$ file xdp-example.oxdp-example.o: ELF 64-bit LSB relocatable, *unknown arch 0xf7* version 1 (SYSV), not stripped


readelf -a xdp-example.o能够打印 ELF 文件的更详细信息,有时在检查生成的 section header、relocation entries 和符号表时会比较有用。


如果需要从头开始编译clang和LLVM内核,可以参考下面的命令。


$ git clone https://github.com/llvm/llvm-project.git$ cd llvm-project$ mkdir build$ cd build$ cmake -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD="BPF;X86" -DBUILD_SHARED_LIBS=OFF -DCMAKE_BUILD_TYPE=Release -DLLVM_BUILD_RUNTIME=OFF  -G "Unix Makefiles" ../llvm$ make -j $(getconf _NPROCESSORS_ONLN)$ ./bin/llc --versionLLVM (http://llvm.org/):LLVM version x.y.zsvnOptimized build.Default target: x86_64-unknown-linux-gnuHost CPU: skylakeRegistered Targets:  bpf    - BPF (host endian)  bpfeb  - BPF (big endian)  bpfel  - BPF (little endian)  x86    - 32-bit X86: Pentium-Pro and above  x86-64 - 64-bit X86: EM64T and AMD64$ export PATH=$PWD/bin:$PATH   # add to ~/.bashrc


确保--version中提到了Optimized build,否则当LLVM处于调试模式时,程序的编译时间将显著增加(增加10倍甚至更多)。


2.2.2 调试信息(DWARF、BTF)


若是要 debug,clang 可以生成下面这样的汇编器输出:


$ clang -O2 -S -Wall -target bpf -c xdp-example.c -o xdp-example.S$ cat xdp-example.S    .text    .section    prog,"ax",@progbits    .globl      xdp_drop    .p2align    3xdp_drop:                             # @xdp_drop# BB#0:    r0 = 1    exit    .section    license,"aw",@progbits    .globl    __license               # @__license__license:    .asciz    "GPL"


LLVM 从 6.0 开始,还包括了汇编解析器(assembler parser)的支持。可以直接使用 BPF 汇编指令编程,然后使用 llvm-mc 将其汇编成一个目标文件。 例如,可以将前面的 xdp-example.S 重新变回对象文件:


$ llvm-mc -triple bpf -filetype=obj -o xdp-example.o xdp-example.S


DWARF 格式和 llvm-objdump


较新版本(>= 4.0)的 LLVM 编译时加上 -g将调试信息以 dwarf 格式存储到对象文件中。


$ clang -O2 -g -Wall -target bpf -c xdp-example.c -o xdp-example.o$ llvm-objdump -S --no-show-raw-insn xdp-example.oxdp-example.o:        file format ELF64-BPFDisassembly of section prog:xdp_drop:;     0:        r0 = 1; return XDP_DROP;    1:        exit


llvm-objdump 工具能够用编译的 C 源码对汇编输出添加注解。这里 的例子过于简单,没有几行 C 代码;但注意上面的 0 和 1 行号,这些行号直接对应到内核的校验器日志(见下面的输出)。这意味着假如 BPF 程序被校验器拒绝了, llvm-objdump能帮助你将 BPF 指令关联到原始的 C 代码,对于分析来说非常有用。


# ip link set dev em1 xdp obj xdp-example.o verbProg section 'prog' loaded (5)! - Type:         6 - Instructions: 2 (0 over limit) - License:      GPLVerifier analysis:0: (b7) r0 = 11: (95) exitprocessed 2 insns


从上面的校验器分析可以看出,llvm-objdump 的输出和内核中的 BPF 汇编是相同的。


去掉 -no-show-raw-insn 选项还可以以十六进制格式在每行汇编代码前面打印原始的 struct bpf_insn:


$ llvm-objdump -S xdp-example.oxdp-example.o:        file format ELF64-BPFDisassembly of section prog:xdp_drop:;    0:       b7 00 00 00 01 00 00 00     r0 = 1; return foo();   1:       95 00 00 00 00 00 00 00     exit


LLVM IR


对于 LLVM IR 调试,BPF 的编译过程可以分为两个步骤:首先生成一个二进制 LLVM IR 临 时文件 xdp-example.bc,然后将其传递给 llc:


$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc $ llc xdp-example.bc -march=bpf -filetype=obj -o xdp-example.o


生成的 LLVM IR 还可以 dump 成可读的格式:


$ clang -O2 -Wall -emit-llvm -S -c xdp-example.c -o -


BTF


LLVM 能将调试信息(例如对程序使用的数据的描述)attach 到 BPF 对象文件。默认情况下使用 DWARF 格式。


BPF 使用了一个高度简化的版本,称为 BTF (BPF Type Format)。生成的 DWARF 可以转换成 BTF 格式,然后通过 BPF 对象加载器加载到内核。内核验证 BTF 数据的正确性, 并跟踪 BTF 数据中包含的数据类型。


这样的话,就可以用键和值对 BPF map 打一些注解存储到 BTF 数据中,这 样下次 dump map 时,除了 map 内的数据外还会打印出相关的类型信息。这对内省( introspection)、调试和格式化打印都很有帮助。注意,BTF 是一种通用的调试数据格式,因此任何从 DWARF 转换成的 BTF 数据都可以被加载(例如,内核 vmlinux DWARF 数 据可以转换成 BTF 然后加载)。后者对于未来 BPF 的跟踪尤其有用。


将 DWARF 格式的调试信息转换成 BTF 格式需要用到 elfutils (>= 0.173) 工具。 如果没有这个工具,那需要在 llc 编译时打开 -mattr=dwarfris 选项:


$ llc -march=bpf -mattr=help |& grep dwarfris  dwarfris - Disable MCAsmInfo DwarfUsesRelocationsAcrossSections.  [...]


使用 -mattr=dwarfris 是因为 dwarfris (dwarf relocation in section) 选项禁用了 DWARF 和 ELF 的符号表之间的 DWARF cross-section 重定位,因为 libdw 不支持 BPF 重定位。不打开这个选项的话,pahole 这类工具将无法正确地从对象中 dump 结构。


elfutils (>= 0.173) 实现了合适的 BPF 重定位,因此没有打开 -mattr=dwarfris 选项也能正常工作。它可以从对象文件中的 DWARF 或 BTF 信息 dump 结构。目前 pahole 使用 LLVM 生成的 DWARF 信息,但未来它可能会使用 BTF 信息。


pahole


将 DWARF 转换成 BTF 格式需要使用较新的 pahole 版本(>= 1.12),然后指定 -J 选项。 检查所用的 pahole 版本是否支持 BTF(注意,pahole 会用到 llvm-objcopy,因此 也要检查后者是否已安装):


$ pahole --help | grep BTF -J, --btf_encode           Encode as BTF


生成调试信息还需要前端的支持,在 clang 编译时指定 -g 选项,生成源码级别的调试信息。注意,不管 llc 是否指定了 dwarfris 选项,-g 都是需要指定的。生成目标文件的完整示例:


$ clang -O2 -g -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc$ llc xdp-example.bc -march=bpf -mattr=dwarfris -filetype=obj -o xdp-example.o


或者,只使用 clang 这一个工具来编译带调试信息的 BPF 程序(同样,如果有合适的 elfutils 版本,dwarfris 选项可以省略):


$ clang -target bpf -O2 -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o


成功编译后,可以使用pahole根据DWARF信息正确 dump BPF 程序的数据结构:


$ pahole xdp-example.ostruct xdp_md         __u32                      data;                 /*     0     4 */        __u32                      data_end;             /*     4     4 */        __u32                      data_meta;            /*     8     4 */        /* size: 12, cachelines: 1, members: 3 */        /* last cacheline: 12 bytes */;


通过选项-J选项 pahole最终可以从DWARF生成BTF。在对象文件中,DWARF 数据将仍然伴随着新加入的 BTF 数据一起保留。完整的 clang 和 pahole 示例:


$ clang -target bpf -O2 -Wall -g -c -Xclang -target-feature -Xclang +dwarfris -c xdp-example.c -o xdp-example.o $ pahole -J xdp-example.o


readelf


通过 readelf 工具可以看到多了一个 .BTF段。


$ readelf -a xdp-example.o[...]  [18] .BTF              PROGBITS         0000000000000000  00000671[...]


BPF 加载器(例如 iproute2)会检测和加载 BTF section,因此给 BPF map 注释( annotate)类型信息。


2.2.3 BPF 指令集


LLVM 默认用 BPF 基础指令集生成代码, 以确保生成的对象文件也能被稍老的 LTS 内核(例如 4.9+)加载。 但 LLVM 提供了一个 BPF 后端选项 -mcpu,用来指定特定的 BPF 指令集版本, 即 BPF 基础指令集之上的指令集扩展(instruction set extensions),以生成更高效和 体积更小的代码。


下面代码可以查询当前支持的-mcpu 类型:


$ llc -march bpf -mcpu=helpAvailable CPUs for this target:  generic - Select the generic processor.  probe   - Select the probe processor.  v1      - Select the v1 processor.  v2      - Select the v2 processor.[...]


  • generic processor 是默认的 processor,也是 BPF v1 基础指令集
  • v1 和 v2 processor 通常在交叉编译 BPF 的环境下比较有用,即编译 BPF 的平台和最终执行 BPF 的平台不同(因此 BPF 内核特性可能也会不同)。


推荐使用 -mcpu=probe ,这也是 Cilium 内部在使用的类型。使用这种类型时, LLVM BPF 后端会向内核询问可用的 BPF 指令集扩展,如果找到可用的,就会使用相应的指令集来编译 BPF 程序。


使用 llc 和 -mcpu=probe 的完整示例:


$ clang -O2 -Wall -target bpf -emit-llvm -c xdp-example.c -o xdp-example.bc $ llc xdp-example.bc -march=bpf -mcpu=probe -filetype=obj -o xdp-example.o


2.2.4 指令和寄存器字长(64/32 位)


通常来说,LLVM IR 生成是架构无关的。但使用 clang 编译时是否指定 -target bpf 是有小区别的,取决于不同的平台架构(x86_64、arm64 或其他),-target 的 默认配置可能不同。


引用内核文档 Documentation/bpf/bpf_devel_QA.txt:


  • BPF 程序可以嵌套 include 头文件,只要头文件中都是文件作用域的内联汇编代码( file scope inline assembly codes)。大部分情况下默认 target 都可以处理这种情况, 但如果 BPF 后端汇编器无法理解这些汇编代码,那 bpf target 会失败。
  • 如果编译时没有指定 -g,那额外的 elf 段(sections)(例如 .eh_frame 和 .rela.eh_frame)可能会以默认 target 格式出现在对象文件中,但不会是 bpf target。
  • 默认 target 可能会将一个 C switch 声明转换为一个 switch 表的查找和跳转操作。 由于 switch 表位于全局的只读 section,因此 BPF 程序的加载会失败。 bpf target 不支持 switch 表优化。clang 的 -fno-jump-tables 选项可以禁止生成 switch 表。
  • 如果 clang 指定了 -target bpf,那指针或 long/unsigned long 类型将永远 是 64 位的,不管底层的 clang 可执行文件或默认的 target(或内核)是否是 32 位。但如果使用的是 native clang target,那 clang 就会根据底层的架构约定来编译这些类型,这意味着对于 32 位的架构,BPF 上下文中的指针或 long/unsigned long 类型会是 32 位的,但此时的 BPF LLVM 后端仍 然工作在 64 位模式。


native target 主要用于跟踪内核中的 struct pt_regs,这个结构体对 CPU 寄存器进行映射,或者是跟踪其他一些能感知 CPU 寄存器位宽的内核结构体。除此之外的其他场景,例如网络场景,都建议使用 clang -target bpf。


另外,LLVM 从 7.0 开始支持 32 位子寄存器和 BPF ALU32 指令。另外,新加入了一个代码生成属性 alu32。当指定这个参数时,LLVM 会尝试尽可能地使用 32 位子寄存器,例如当涉及到 32 位操作时。32 位子寄存器及相应的 ALU 指令组成了 ALU32 指令。例如, 对于下面的示例代码:


$ cat 32-bit-example.c    void cal(unsigned int *a, unsigned int *b, unsigned int *c)          unsigned int sum = *a + *b;      *c = sum;    


使用默认的代码生成选项,产生的汇编代码如下:


$ clang -target bpf -emit-llvm -S 32-bit-example.c$ llc -march=bpf 32-bit-example.ll$ cat 32-bit-example.s    cal:      r1 = *(u32 *)(r1 + 0)      r2 = *(u32 *)(r2 + 0)      r2 += r1      *(u32 *)(r3 + 0) = r2      exit


可以看到默认使用的是 r 系列寄存器,这些都是 64 位寄存器,这意味着其中的加法都是 64 位加法。现在,如果指定 -mattr=+alu32 强制要求使用 32 位,生成的汇编代码 如下:


$ llc -march=bpf -mattr=+alu32 32-bit-example.ll$ cat 32-bit-example.s    cal:      w1 = *(u32 *)(r1 + 0)      w2 = *(u32 *)(r2 + 0)      w2 += w1      *(u32 *)(r3 + 0) = w2      exit


可以看到这次使用的是 w 系列寄存器,这些是 32 位子寄存器。


最终生成的代码中使用 32 位子寄存器可能会减小类型扩展指令的数量。另外,它对 32 位架构的内核 eBPF JIT 编译器也有所帮助,因为 原来这些编译器都是用 32 位模拟 64 位 eBPF 寄存器,其中使用了很多 32 位指令来操作 高 32 bit。即使写 32 位子寄存器的操作仍然需要对高 32 位清零,但只要确保从 32 位 子寄存器的读操作只会读取低 32 位,那只要 JIT 编译器已经知道某个寄存器的定义只有 子寄存器读操作,那对高 32 位的操作指令就可以避免。


2.2.5 C BPF 代码注意事项


用 C 语言编写 BPF 程序不同于用 C 语言做应用开发,有一些陷阱需要注意。本节列出了 二者的一些不同之处。


1. 所有函数都需要内联(inlined)、没有函数调用(对于老版本 LLVM)或共享库调用


BPF 不支持共享库(Shared libraries)。但是,可以将常规的库代码(library code)放到头文件中,然后在主程序中 include 这些头文件,例如 Cilium 就大量使用了这种方式 (可以查看 bpf/lib/ 文件夹)。另外,也可以 include 其他的一些头文件,例如内核 或其他库中的头文件,复用其中的静态内联函数(static inline functions)或宏/定义( macros / definitions)。


内核 4.16+ 和 LLVM 6.0+ 之后已经支持 BPF-to-BPF 函数调用。对于任意给定的程序片段 ,在此之前的版本只能将全部代码编译和内联成一个顺序的 BPF 指令序列。在这种情况下,最佳实践就是为每个库函数都使用一个像 __inline 一样的注解,下面的例子中会看到。推荐使用 always_inline,因为编译器可能会对只注解为 inline 的长函数仍然做 uninline 操 作。


如果是后者,LLVM 会在 ELF 文件中生成一个重定位项(relocation entry),BPF ELF 加载器(例如 iproute2)无法解析这个重定位项,因此会产生一条错误,因为对加载器来说只有 BPF maps 是合法的、能够处理的重定位项。


#include <linux/bpf.h>#ifndef __section# define __section(NAME)                  \\   __attribute__((section(NAME), used))#endif#ifndef __inline# define __inline                         \\   inline __attribute__((always_inline))#endifstatic __inline int foo(void)    return XDP_DROP;__section("prog")int xdp_drop(struct xdp_md *ctx)    return foo();char __license[] __section("license") = "GPL";


2. 多个程序可以放在同一 C 文件中的不同 section


BPF C 程序大量使用 section annotations。一个 C 文件典型情况下会分为 3 个或更多个 section。BPF ELF 加载器利用这些名字来提取和准备相关的信息,以通过 bpf()系统调用加载程序和 maps。例如,查找创建 map 所需的元数据和 BPF 程序的 license 信息时,iproute2 会分别使用 maps 和 license 作为默认的 section 名字。注意在程序创建时 license section 也会加载到内核,如果程序使用的是兼容 GPL 的协议,这些信息就可以启用那些 GPL-only 的辅助函数,例如 bpf_ktime_get_ns() 和 bpf_probe_read() 。


其余的 section 名字都是和特定的 BPF 程序代码相关的,例如,下面经过修改之后的代码包含两个程序 section:ingress 和 egress。这个非常简单的示例展示了不同 section之间可以共享 BPF map 和常规的静态内联辅助函数( 例如 account_data())。


示例程序


这里将原来的 xdp-example.c 修改为 tc-example.c,然后用 tc 命令加载,attach 到 一个 netdevice 的 ingress 或 egress hook。该程序对传输的字节进行计数,存储在一 个名为 acc_map 的 BPF map 中,这个 map 有两个槽,分别用于 ingress hook 和 egress hook 的流量统计。


#include <linux/bpf.h>#include <linux/pkt_cls.h>#include <stdint.h>#include <iproute2/bpf_elf.h>#ifndef __section# define __section(NAME)                  \\   __attribute__((section(NAME), used))#endif#ifndef __inline# define __inline                         \\   inline __attribute__((always_inline))#endif#ifndef lock_xadd# define lock_xadd(ptr, val)              \\   ((void)__sync_fetch_and_add(ptr, val))#endif#ifndef BPF_FUNC# define BPF_FUNC(NAME, ...)              \\   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME#endifstatic void *BPF_FUNC(map_lookup_elem, void *map, const void *key);struct bpf_elf_map acc_map __section("maps") =     .type           = BPF_MAP_TYPE_ARRAY,    .size_key       = sizeof(uint32_t),    .size_value     = sizeof(uint32_t),    .pinning        = PIN_GLOBAL_NS,    .max_elem       = 2,;static __inline int account_data(struct __sk_buff *skb, uint32_t dir)    uint32_t *bytes;    bytes = map_lookup_elem(&acc_map, &dir);    if (bytes)            lock_xadd(bytes, skb->len);    return TC_ACT_OK;__section("ingress")int tc_ingress(struct __sk_buff *skb)    return account_data(skb, 0);__section("egress")int tc_egress(struct __sk_buff *skb)    return account_data(skb, 1);char __license[] __section("license") = "GPL";


这个例子还展示了其他一些很有用的东西,在开发过程中要注意。


首先,include 了内核头文件、标准 C 头文件和一个特定的 iproute2 头文件 iproute2/bpf_elf.h,后者定义了struct bpf_elf_map。iproute2 有一个通用的 BPF ELF 加载器,因此 struct bpf_elf_map的定义对于 XDP 和 tc 类型的程序是完全一样的 。


其次,程序中每条 struct bpf_elf_map 记录定义一个 map,这个记录包含了生成一个(ingress 和 egress 程序需要用到的)map 所需的全部信息(例如 key/value 大小)。这个结构体的定义必须放在 maps section,这样加载器才能找到它。可以用这个结构体声明很多名字不同的变量,但这些声明前面必须加上 __section("maps") 注解。


结构体 struct bpf_elf_map 是特定于 iproute2 的。不同的 BPF ELF 加载器有不同的格式,例如,内核源码树中的 libbpf(主要是 perf 在用)就有一个不同的规范 (结构体定义)。iproute2 保证 struct bpf_elf_map 的后向兼容性。Cilium 采用的 是 iproute2 模型。


另外,这个例子还展示了 BPF 辅助函数是如何映射到 C 代码以及如何被使用的。这里首先定义了一个宏 BPF_FUNC,接受一个函数名 NAME 以及其他的任意参数。然后用这个宏声明了一 个 NAME 为 map_lookup_elem 的函数,经过宏展开后会变成 BPF_FUNC_map_lookup_elem 枚举值,后者以辅助函数的形式定义在 uapi/linux/bpf.h 。当随后这个程序被加载到内核时,校验器会检查传入的参数是否是期望的类型,如果是, 就将辅助函数调用重新指向某个真正的函数调用。另外, map_lookup_elem() 还展示了 map 是如何传递给 BPF 辅助函数的。这里,maps section 中的 &acc_map 作为第一个参数传递给 map_lookup_elem()。


由于程序中定义的数组 map (array map)是全局的,因此计数时需要使用原子操作,这里是使用了 lock_xadd()。LLVM 将 __sync_fetch_and_add() 作为一个内置函数映射到 BPF 原子加指令,即 BPF_STX | BPF_XADD | BPF_W(根据字长实际选择指令)。


另外,struct bpf_elf_map 中的 .pinning 字段初始化为 PIN_GLOBAL_NS,这意味 着 tc 会将这个 map 作为一个节点(node)钉(pin)到 BPF 伪文件系统。默认情况下, 这个变量 acc_map 将被钉到 /sys/fs/bpf/tc/globals/acc_map。


  • 如果指定的是 PIN_GLOBAL_NS,那 map 会被放到 /sys/fs/bpf/tc/globals/。 globals 是一个跨对象文件的全局命名空间。只要指定了 PIN_GLOBAL_NS,不同的 C 文件都可以像上面一样定义各自的 acc_map。在这种情况下,这个 map 会在不同 BPF 程序之间共享。
  • 如果指定的是 PIN_OBJECT_NS,tc 将会为对象文件创建一个它的本地目录。
  • PIN_NONE 表示 map 不会作为节点(node)钉(pin)到 BPF 文件系统,因此当 tc 退出时这个 map 就无法从用户空间访问了。同时,这还意味着独立的 tc 命令会创建出独 立的 map 实例,因此后执行的 tc 命令无法用这个 map 名字找到之前被钉住的 map。


  1. 在路径 /sys/fs/bpf/tc/globals/acc_map 中, acc_map 是源代码中定义的map名。


因此,在加载 ingress 程序时,tc 会先查找这个 map 在 BPF 文件系统中是否存在,不存在就创建一个。创建成功后,map 会被钉到 BPF 文件系统,因此当 egress 程序通过 tc 加载之后,它就会发现这个 map 存在了,接下来会复用这个 map 而不是再创建一个新的。在 map 存在的情况下,加载器还会确保 map 的属性是匹配的, 例如 key/value 大小等等。


就像 tc 可以从同一 map 获取数据一样,第三方应用也可以用 bpf 系统调用中的 BPF_OBJ_GET 命令创建一个指向某个 map 实例的新文件描述符,然后用这个描述符来查看/更新/删除 map 中的数据。


上面的代码可以通过 clang 编译和 iproute2 加载:


$ clang -O2 -Wall -target bpf -c tc-example.c -o tc-example.o# tc qdisc add dev em1 clsact# tc filter add dev em1 ingress bpf da obj tc-example.o sec ingress# tc filter add dev em1 egress bpf da obj tc-example.o sec egress# tc filter show dev em1 ingressfilter protocol all pref 49152 bpffilter protocol all pref 49152 bpf handle 0x1 tc-example.o:[ingress] direct-action id 1 tag c5f7825e5dac396f# tc filter show dev em1 egressfilter protocol all pref 49152 bpffilter protocol all pref 49152 bpf handle 0x1 tc-example.o:[egress] direct-action id 2 tag b2fd5adc0f262714# mount | grep bpfsysfs on /sys/fs/bpf type sysfs (rw,nosuid,nodev,noexec,relatime,seclabel)bpf on /sys/fs/bpf type bpf (rw,relatime,mode=0700)# tree /sys/fs/bpf//sys/fs/bpf/+-- ip -> /sys/fs/bpf/tc/+-- tc|   +-- globals|       +-- acc_map+-- xdp -> /sys/fs/bpf/tc/4 directories, 1 file


以上步骤指向完成后,当包经过 em 设备时,BPF map 中的计数器就会递增。


3. 不允许全局变量


出于第 1 条中提到的原因(只支持 BPF maps 重定位),BPF 不能使用全局变量 ,而常规 C 程序中是可以的。


但是,我们有间接的方式实现全局变量的效果:BPF 程序可以使用一个 BPF_MAP_TYPE_PERCPU_ARRAY 类型的、只有一个槽(slot)的、可以存放任意类型数据的 BPF map。这可以实现全局变量的效果,BPF 程序在执行期间不会被内核抢占,因此可以用单个 map entry 作为一个 scratch buffer 使用,存储临时数据。例如扩展 BPF 栈的限制(512 字节)。这种方式在尾调用中也是可以工作的,因为尾调用执行期间也不会被抢占。


另外,如果要在不同次 BPF 程序执行之间保持状态,使用常规的 BPF map 就可以了。


4. 不支持常量字符串或数组(const strings or arrays)


BPF C 程序中不允许定义 const 字符串或其他数组,原因和第 1 点及第 3 点一样,即 ,ELF 文件中生成的重定位项(relocation entries)会被加载器拒绝,因为不符合加载器的 ABI(加载器也无法修复这些重定位项,因为这需要对已经编译好的 BPF 序列进行大范围的重写)。


将来 LLVM 可能会检测这种情况,提前将错误抛给用户。现在可以用下面的trace_printk()辅助函数来作为短期解决方式:


static void BPF_FUNC(trace_printk, const char *fmt, int fmt_size, ...);#ifndef printk# define printk(fmt, ...)                                      \\    (                                                         \\        char ____fmt[] = fmt;                                  \\        trace_printk(____fmt, sizeof(____fmt), ##__VA_ARGS__); \\    )#endif


有了上面的定义,程序就可以自然地使用这个宏,例如 printk("skb len:%u\\n", skb->len);。 输出会写到 trace pipe,用 tc exec bpf dbg 命令可以获取这些打印的消息。


不过,使用 trace_printk() 辅助函数也有一些不足,因此不建议在生产环境使用。每次调用这个辅助函数时,常量字符串(例如 "skb len:%u\\n")都需要加载到 BPF 栈,但这个辅助函数最多只能接受 5 个参数,因此使用这个函数输出信息时只能传递三个参数。


因此,虽然这个辅助函数对快速调试很有用,但(对于网络程序)还是推荐使用 skb_event_output() 或 xdp_event_output() 辅助函数。这两个函数接受从 BPF 程序传递自定义的结构体类型参数,然后将参数以及可选的包数据(packet sample)放到 perf event ring buffer。例如,Cilium monitor 利用这些辅助函数实现了一个调试框架,以及在发现违反网络策略时发出通知等功能。这些函数通过一个无锁的、内存映射的、 per-CPU 的 perf ring buffer 传递数据,因此要远快于 trace_printk()。


5. 使用 LLVM 内置的函数做内存操作


因为 BPF 程序除了调用 BPF 辅助函数之外无法执行任何函数调用,因此常规的库代码必须实现为内联函数。另外,LLVM 也提供了一些可以用于特定大小(这里是 n)的内置函数 ,这些函数永远都会被内联:


#ifndef memset# define memset(dest, chr, n)   __builtin_memset((dest), (chr), (n))#endif#ifndef memcpy# define memcpy(dest, src, n)   __builtin_memcpy((dest), (src), (n))#endif#ifndef memmove# define memmove(dest, src, n)  __builtin_memmove((dest), (src), (n))#endif


LLVM 后端中的某个问题会导致内置的 memcmp() 有某些边界场景下无法内联,因此在这个问题解决之前不推荐使用这个函数。


6. (目前还)不支持循环


内核中的 BPF 校验器除了对其他的控制流进行图验证之外,还会对所有程序路径执行深度优先搜索,确保其中不存在循环。这样做的目的是确保程序永远会结束。


但可以使用 #pragma unroll 指令实现常量的、不超过一定上限的循环。下面是一个例子 :


#pragma unroll    for (i = 0; i < IPV6_MAX_HEADERS; i++)         switch (nh)         case NEXTHDR_NONE:            return DROP_INVALID_EXTHDR;        case NEXTHDR_FRAGMENT:            return DROP_FRAG_NOSUPPORT;        case NEXTHDR_HOP:        case NEXTHDR_ROUTING:        case NEXTHDR_AUTH:        case NEXTHDR_DEST:            if (skb_load_bytes(skb, l3_off + len, &opthdr, sizeof(opthdr)) < 0)                return DROP_INVALID;            nh = opthdr.nexthdr;            if (nh == NEXTHDR_AUTH)                len += ipv6_authlen(&opthdr);            else                len += ipv6_optlen(&opthdr);            break;        default:            *nexthdr = nh;            return len;            


另外一种实现循环的方式是:用一个 BPF_MAP_TYPE_PERCPU_ARRAY map 作为本地 scratch space(存储空间),然后用尾调用的方式调用函数自身。虽然这种方式更加动态,但目前 最大只支持 34 层(原始程序,外加 33 次尾调用)嵌套调用。


将来 BPF 可能会提供一些更加原生、但有一定限制的循环。


7. 尾调用的用途


尾调用能够从一个程序调到另一个程序,提供了在运行时(runtime)原子地改变程序行为的灵活性。为了选择要跳转到哪个程序,尾调用使用了 程序数组 map( BPF_MAP_TYPE_PROG_ARRAY),将 map 及其索引(index)传递给将要跳转到的程序。跳转动作一旦完成,就没有办法返回到原来的程序;但如果给定的 map 索引中没有程序(无法跳转),执行会继续在原来的程序中执行。


例如,可以用尾调用实现解析器的不同阶段,可以在运行时(runtime)更新这些阶段的新解析特性。


尾调用的另一个用处是事件通知,例如,Cilium 可以在运行时(runtime)开启或关闭丢弃包的通知,其中对 skb_event_output() 的调用就是发生在被尾调用的程序中。因此,在常规情况下,执行的永远是从上到下的路径( fall-through path),当某个程序被加入到相关的 map 索引之后,程序就会解析元数据, 触发向用户空间守护进程发送事件通知。


程序数组 map 非常灵活, map 中每个索引对应的程序可以实现各自的动作。 例如,attach 到 tc 或 XDP 的 root 程序执行初始的、跳转到程序数组 map 中索引为 0 的程序,然后执行流量抽样(traffic sampling),然后跳转到索引为 1 的程序,在那个程序中应用防火墙策略,然后就可以决定是丢地包还是将其送到索引为 2 的程序中继续处理,在后者中,可能可能会被 mangle 然后再次通过某个接口发送出去。在程序数据 map 之中是可以随意跳转的。当达到尾调用的最大调用深度时,内核最终会执行 fall-through path。


一个使用尾调用的最小程序示例:


[...]#ifndef __stringify# define __stringify(X)   #X#endif#ifndef __section# define __section(NAME)                  \\   __attribute__((section(NAME), used))#endif#ifndef __section_tail# define __section_tail(ID, KEY)          \\   __section(__stringify(ID) "/" __stringify(KEY))#endif#ifndef BPF_FUNC# define BPF_FUNC(NAME, ...)              \\   (*NAME)(__VA_ARGS__) = (void *)BPF_FUNC_##NAME#endif#define BPF_JMP_MAP_ID   1static void BPF_FUNC(tail_call, struct __sk_buff *skb, void *map,                     uint32_t index);struct bpf_elf_map jmp_map __section("maps") =     .type           = BPF_MAP_TYPE_PROG_ARRAY,    .id             = BPF_JMP_MAP_ID,    .size_key       = sizeof(uint32_t),    .size_value     = sizeof(uint32_t),    .pinning        = PIN_GLOBAL_NS,    .max_elem       = 1,;__section_tail(BPF_JMP_MAP_ID, 0)int looper(struct __sk_buff *skb)    printk("skb cb: %u\\n", skb->cb[0]++);    tail_call(skb, &jmp_map, 0);    return TC_ACT_OK;__section("prog")int entry(struct __sk_buff *skb)    skb->cb[0] = 0;    tail_call(skb, &jmp_map, 0);    return TC_ACT_OK;char __license[] __section("license") = "GPL";


加载这个示例程序时,tc 会创建其中的程序数组(jmp_map 变量),并将其钉到 BPF 文件系统中全局命名空间下名为的 jump_map 位置。而且,iproute2 中的 BPF ELF 加载器也会识别出标记为 __section_tail() 的 section。 jmp_map 的 id 字段会 跟__section_tail() 中的 id 字段(这里初始化为常量 JMP_MAP_ID)做匹配,因此程序能加载到用户指定的索引,在上面的例子中这个索引是 0。然后,所有的尾调用 section 将会被 iproute2 加载器处理,关联到 map 中。这个机制并不是 tc 特有的, iproute2 支持的其他 BPF 程序类型(例如 XDP、lwt)也适用。


生成的 elf 包含 section headers,描述 map id 和 map 内的条目:


$ llvm-objdump -S --no-show-raw-insn prog_array.o | lessprog_array.o:   file format ELF64-BPFDisassembly of section 1/0:looper:       0:       r6 = r1       1:       r2 = *(u32 *)(r6 + 48)       2:       r1 = r2       3:       r1 += 1       4:       *(u32 *)(r6 + 48) = r1       5:       r1 = 0 ll       7:       call -1       8:       r1 = r6       9:       r2 = 0 ll      11:       r3 = 0      12:       call 12      13:       r0 = 0      14:       exitDisassembly of section prog:entry:       0:       r2 = 0       1:       *(u32 *)(r1 + 48) = r2       2:       r2 = 0 ll       4:       r3 = 0       5:       call 12       6:       r0 = 0       7:       exi


在这个例子中,section 1/0 表示 looper() 函数位于 map 1 中,在 map 1 内的位置是 0。


被钉住 map 可以被用户空间应用(例如 Cilium daemon)读取,也可以被 tc 本身读取,因为 tc 可能会用新的程序替换原来的程序,此时可能需要读取 map 内容。 更新是原子的。


tc 执行尾调用 map 更新(tail call map updates)的例子:


$ tc exec bpf graft m:globals/jmp_map key 0 obj new.o sec foo


如果 iproute2 需要更新被钉住的程序数组,可以使用 graft 命令。上面的例子中指向的是 globals/jmp_map,那 tc 将会用一个新程序更新位于 index/key 为 0 的 map, 这个新程序位于对象文件 new.o 中的 foo section。


8. BPF 最大栈空间 512 字节


BPF 程序的最大栈空间是 512 字节,在使用 C 语言实现 BPF 程序时需要考虑到这一点。 但正如在第 3 点中提到的,可以通过一个只有一条记录的 BPF_MAP_TYPE_PERCPU_ARRAY map 来绕过这限制,增大 scratch buffer 空间。


9. 尝试使用 BPF 内联汇编


LLVM 6.0 以后支持 BPF 内联汇编,在某些场景下可能会用到。下面这个玩具示例程序( 没有实际意义)展示了一个 64 位原子加操作。


由于文档不足,要获取更多信息和例子,目前可能只能参考 LLVM 源码中的 lib/Target/BPF/BPFInstrInfo.td 以及 test/CodeGen/BPF/。测试代码:


#include <linux/bpf.h>#ifndef __section# define __section(NAME)                  \\   __attribute__((section(NAME), used))#endif__section("prog")int xdp_test(struct xdp_md *ctx)    __u64 a = 2, b = 3, *c = &a;    /* just a toy xadd example to show the syntax */    asm volatile("lock *(u64 *)(%0+0) += %1" : "=r"(c) : "r"(b), "0"(c));    return a;char __license[] __section("license") = "GPL";


上面的程序会被编译成下面的 BPF 指令序列:


Verifier analysis:0: (b7) r1 = 21: (7b) *(u64 *)(r10 -8) = r12: (b7) r1 = 33: (bf) r2 = r104: (07) r2 += -85: (db) lock *(u64 *)(r2 +0) += r16: (79) r0 = *(u64 *)(r10 -8)7: (95) exitprocessed 8 insns (limit 131072), stack depth 8


10. 用 #pragma pack禁止结构体填充(struct padding)


现代编译器默认会对数据结构进行内存对齐,以实现更加高效的访问。结构体成员会被对齐到数倍于其自身大小的内存位置,不足的部分会进行填充,因此结构体最终的大小可能会比预想中大。


struct called_info     u64 start;  // 8-byte    u64 end;    // 8-byte    u32 sector; // 4-byte; // size of 20-byte ?printf("size of %d-byte\\n", sizeof(struct called_info)); // size of 24-byte// Actual compiled composition of struct called_info// 0x0(0)                   0x8(8)//  ↓________________________↓//  |        start (8)       |//  |________________________|//  |         end  (8)       |//  |________________________|//  |  sector(4) |  PADDING  | <= address aligned to 8//  |____________|___________|     with 4-byte PADDING.


内核中的 BPF 校验器会检查栈边界,BPF 程序不会访问栈边界外的空间,或者是未初始化的栈空间。如果将结构体中填充出来的内存区域作为一个 map 值进行访问,那调用 bpf_prog_load() 时就会报 invalid indirect read from stack 错误。


示例代码:


struct called_info     u64 start;    u64 end;    u32 sector;;struct bpf_map_def SEC("maps") called_info_map =     .type = BPF_MAP_TYPE_HASH,    .key_size = sizeof(long),    .value_size = sizeof(struct called_info),    .max_entries = 4096,;SEC("kprobe/submit_bio")int submit_bio_entry(struct pt_regs *ctx)    char fmt[] = "submit_bio(bio=0x%lx) called: %llu\\n";    u64 start_time = bpf_ktime_get_ns();    long bio_ptr = PT_REGS_PARM1(ctx);    struct called_info called_info =             .start = start_time,            .end = 0,            .sector = 0    ;    bpf_map_update_elem(&called_info_map, &bio_ptr, &called_info, BPF_ANY);    bpf_trace_printk(fmt, sizeof(fmt), bio_ptr, start_time);    return 0;


通过bpf_load_program() 加载时,会产生下面的错误输出:


bpf_load_program() err=130: (bf) r6 = r1...19: (b7) r1 = 020: (7b) *(u64 *)(r10 -72) = r121: (7b) *(u64 *)(r10 -80) = r722: (63) *(u32 *)(r10 -64) = r1...30: (85) call bpf_map_update_elem#2invalid indirect read from stack off -80+20 size 24


在 bpf_prog_load() 中会调用 BPF 校验器的 bpf_check() 函数,后者会调用 check_func_arg() -> check_stack_boundary() 来检查栈边界。从上面的错误可以看出 ,struct called_info 被编译成 24 字节,错误信息提示从 +20 位置读取数据是“非法的间接读取”。从我们更前面给出的内存布局图中可以看到, 地址 0x14(20) 是填充(PADDING )开始的地方。这里再次画出内存布局图以方便对比:


// Actual compiled composition of struct called_info// 0x10(16)    0x14(20)    0x18(24)//  ↓____________↓___________↓//  |  sector(4) |  PADDING  | <= address aligned to 8//  |____________|___________|     with 4-byte PADDING.


check_stack_boundary() 会遍历每一个从开始指针出发的 access_size (24) 字节, 确保它们位于栈边界内部,并且栈内的所有元素都初始化了。因此填充的部分是不允许使用的,所以报了 “invalid indirect read from stack” 错误。要避免这种错误,需要将结构体中的填充去掉。这是通过 #pragma pack(n) 原语实现的:


#pragma pack(4)struct called_info     u64 start;  // 8-byte    u64 end;    // 8-byte    u32 sector; // 4-byte; // size of 20-byte ?printf("size of %d-byte\\n", sizeof(struct called_info)); // size of 20-byte// Actual compiled composition of packed struct called_info// 0x0(0)                   0x8(8)//  ↓________________________↓//  |        start (8)       |//  |________________________|//  |         end  (8)       |//  |________________________|//  |  sector(4) |             <= address aligned to 4//  |____________|                 with no PADDING.


在 struct called_info 前面加上 #pragma pack(4) 之后,编译器会以 4 字节为单位进行对齐。上面的图可以看到,这个结构体现在已经变成 20 字节大小,没有填充了。


但是,去掉填充也是有弊端的。例如,编译器产生的代码没有原来优化的好。去掉填充之后 ,处理器访问结构体时触发的是非对齐访问(unaligned access),可能会导致性能下降。 并且,某些架构上的校验器可能会直接拒绝非对齐访问。


不过,我们也有一种方式可以避免产生自动填充:手动填充。我们简单地在结构体中加入一 个 u32 pad 成员来显式填充,这样既避免了自动填充的问题,又解决了非对齐访问的问 题。


struct called_info     u64 start;  // 8-byte    u64 end;    // 8-byte    u32 sector; // 4-byte    u32 pad;    // 4-byte; // size of 24-byte ?printf("size of %d-byte\\n", sizeof(struct called_info)); // size of 24-byte// Actual compiled composition of struct called_info with explicit padding// 0x0(0)                   0x8(8)//  ↓________________________↓//  |        start (8)       |//  |________________________|//  |         end  (8)       |//  |________________________|//  |  sector(4) |  pad (4)  | <= address aligned to 8//  |____________|___________|     with explicit PADDING.


11. 通过未验证的引用(invalidated references)访问包数据


某些网络相关的 BPF 辅助函数,例如 bpf_skb_store_bytes,可能会修改包的大小。校验器无法跟踪这类改动,因此它会将所有之前对包数据的引用都视为过期的(未验证的) 。因此,为避免程序被校验器拒绝,在访问数据之外需要先更新相应的引用。


来看下面的例子:


struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);if (ip4->protocol == IPPROTO_TCP)     // do something


校验器会拒绝这段代码,因为它认为在 skb_store_bytes 执行之后,引用 ip4->protocol 是未验证的(invalidated):


R1=pkt_end(id=0,off=0,imm=0) R2=pkt(id=0,off=34,r=34,imm=0) R3=inv0R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=4294967295,var_off=(0x0; 0xffffffff))R8=inv4294967162 R9=pkt(id=0,off=0,r=34,imm=0) R10=fp0,call_-1...18: (85) call bpf_skb_store_bytes#919: (7b) *(u64 *)(r10 -56) = r7R0=inv(id=0) R6=ctx(id=0,off=0,imm=0) R7=inv(id=0,umax_value=2,var_off=(0x0; 0x3))R8=inv4294967162 R9=inv(id=0) R10=fp0,call_-1 fp-48=mmmm???? fp-56=mmmmmmmm21: (61) r1 = *(u32 *)(r9 +23)R9 invalid mem access 'inv'


要解决这个问题,必须更新(重新计算) ip4 的地址:


struct iphdr *ip4 = (struct iphdr *) skb->data + ETH_HLEN;skb_store_bytes(skb, l3_off + offsetof(struct iphdr, saddr), &new_saddr, 4, 0);ip4 = (struct iphdr *) skb->data + ETH_HLEN;if (ip4->protocol == IPPROTO_TCP)     // do something

相关参考

怎么更新手机app软件(苹果更新App Store审查指南)

IT之家10月25日消息,随着iOS16.1和iPadOS16.1正式版的发布,苹果正在更新向为iPhone和iPad创建应用程序的开发人员提供的AppStore审核指南。苹果现在要求应用为App应用审核团队提供对应用程序的完全访问权限,并为包含基于帐户的功...

手机自带电子罗盘嘛(手机自带的“指南针”APP,跟传统的指南针原理一样吗?)

...地球磁场的方向来实现指南,但测量的方式不一样,毕竟手机里不可能内置小磁针。手机内置的指南针其实是一个三维电子罗盘,它可以在三个方向上测量地磁场,这样即使手机并非水平也可以算出地磁场方向。目前的主流是用...

手机罗盘是什么功能(手机自带的“指南针”APP,跟传统的指南针原理一样吗?)

...地球磁场的方向来实现指南,但测量的方式不一样,毕竟手机里不可能内置小磁针。手机内置的指南针其实是一个三维电子罗盘,它可以在三个方向上测量地磁场,这样即使手机并非水平也可以算出地磁场方向。目前的主流是用...

手机的电子罗盘是什么功能啊(手机自带的“指南针”APP,跟传统的指南针原理一样吗?)

...地球磁场的方向来实现指南,但测量的方式不一样,毕竟手机里不可能内置小磁针。手机内置的指南针其实是一个三维电子罗盘,它可以在三个方向上测量地磁场,这样即使手机并非水平也可以算出地磁场方向。目前的主流是用...

手机有学生模式吗(无智能手机学生如何乘坐公共交通?指南来了→)

...乘坐公共交通仍需扫“场所码”。那么问题来了,无智能手机或不方便使用手机的中小学生,如何乘坐公共交通上学?来看市交通委的解答↓根据我市疫情防控有关要求,自9月1日起,乘坐公交、地铁、轮渡时,无智能手机的学...

怎么查看手机应用流量统计(如何准确有效侦测、分析网络流量)

随着企业向云迁移、5G落地、物联网设备激增、网络爆炸、网络流量变得海量复杂,企业经常会遇到网络拥塞和服务质量低等一系列问题,加强网络管理和改善网络的运行己成为当务之急,如何有效识别、监测、分析网络流量成...

手机屏幕录像怎么弄(这里有一份最全的录屏指南,包含iOS、安卓、鸿蒙、Mac、Win系统)

手机与电脑是我们工作生活中常用的工具,但是很多人都不知道怎么录屏。这里有一份最全的录屏指南,包含iOS、安卓、鸿蒙、Mac、Win系统,一起来学习一下吧。一、iOS系统录屏苹果手机录屏隐藏的较深,首先需要进入iPhone【设...

手机app怎么更新版本(用友开发者中心升级这份移动开发入门必备指南请收好)

用友开发者中心以YonBuilder低代码开发为核心,提供可视化+低代码+全代码的一站式开发能力,企业组织和个人开发者可实现业务应用的快速开发。YonBuilder基于用友BIP强大的中台支撑能力,在元数据驱动和运行框架的统一模型架...

引流推广存在的问题(2022引流推广方法,价格,疯狂引流爆流量指南)

2022年,注定是引流推广比较难的一年。但有些人依据靠引流推广赚的盆满钵满,他们是如何做到的呢?今天这篇文章主要为你们解决了两个问题:1、引流推广方法2、引流推广价格一、引流推广方法有哪些?目前主流的引流推广...

海康威视摄像头怎么使用(海康双目客流量摄像机安装配置指南)

商场、景区、车站、展览馆等场所大家常常能看到客流量摄像机,客流量摄像机作为智能摄像机家族中的一员,在很多重要场合都发挥着不可替代的作用。本期给大家带来海康智能摄像机专题,首先我们来看一下海康威视双目客...