BIGO 音视频 SDK 开发一面面经
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 说说移动语义和右值引用是什么?有什么作用?
- 移动拷贝或者移动构造是怎么触发的呢?没有声明移动拷贝或构造的对象在移动时会发生什么?
- delete 和 free 的区别是什么?
- free 或者 delete 传入的是一个指针,内存管理器是怎么知道释放多大的内存呢?
- 进程虚拟地址空间布局是怎样的?说说函数调用时栈是怎么工作的?
- 进程和线程的区别是什么?Linux 线程的创建数量有上限吗?
- 了解线程池吗?了解 CacheLine 吗?了解无锁编程吗?
- 条件变量怎么使用?以生产者消费者模型为例讲述一下。
- 从键盘输入到游戏中角色移动经历了哪些过程?
- MVP 矩阵是什么?游戏中角色呈现到屏幕上经过了哪些过程?
- 说说
.o文件如何形成的?详细讲述下链接的过程? - 详细说说动态链接如何工作的?PLT 和 GOT 是做什么的?
- 用 AI 写代码用得多吗?怎么用的呢?身边的人呢?
《参考解析》
1. 移动语义与右值引用的作用:右值引用(T&&)让「即将消亡的临时对象」在类型系统里可被识别,从而可以安全地把它持有的资源(堆内存、文件句柄、socket、GPU 纹理)直接「窃取」过来,而不是深拷贝一份再析构原件。音视频链路里这一点很实在:一帧 1080p 的 YUV420 大约 3MB,一次不必要的拷贝在高帧率下就是几百 MB/s 的 memcpy 和额外延迟,移动构造把成本压到几次指针交换。移动语义的另一半是配合 std::unique_ptr、容器搬迁和工厂函数,让「值语义的接口」不再必然意味着拷贝。需要注意的是移动不改变对象语义的约束:被移动后的源对象处于「有效但未指定」状态,只保证能安全析构和重新赋值,不要假设它的内容还是原来的。
2. 移动构造的触发条件与隐式生成规则:触发移动的典型场景是用右值初始化或赋值同类型对象——返回临时对象(NRVO 失败时)、std::move(x)、push_back(std::move(v))、容器扩容搬迁可移动元素。反之,具名变量是左值,不加 move 一律走拷贝;编译器还会做拷贝消除,T a = T(); 这类场景根本不会调用任何构造/移动。隐式生成规则是最容易踩的坑:只有当类没有用户声明的拷贝构造、拷贝赋值、移动操作和析构函数时,编译器才会隐式生成移动构造与移动赋值;一旦你自己写了析构函数(哪怕函数体是空的)或拷贝操作,移动操作就不再生成,此时 std::move 会因为右值可以绑定到 const T& 而静默退化成拷贝,性能问题不报错、只在压测时暴露。另外,若某个成员不可移动(如持有 const 引用成员或不可移动类型),隐式移动会被定义为删除。这就是「Rule of Five」的由来:要么一个都不写,要么五个一起写清楚。
3. delete 与 free 的区别:二者不是同一层的东西。delete 是 C++ 运算符,语义是「先调用析构函数、再归还内存」;free 是 C 库函数,只把内存还给分配器,完全不碰对象生命周期。new 实际上等于 operator new(分配原始内存)+ 构造函数,delete 等于析构函数 + operator delete,两者都可以按类重载——音视频 SDK 常靠自定义 operator new/delete 接内存池、对齐分配或 GPU 可映射内存。配对关系必须严格:new 对 delete、new[] 对 delete[],数组形式会多做一步逐元素析构,混用是未定义行为(实践中可能只析构首元素或直接踩坏堆)。通过基类指针删除派生对象时,基类析构必须是 virtual,否则只析构基类部分,派生类里那些持有 DMA buffer、解码器上下文的成员会整片泄漏。失败语义也不同:new 失败抛 bad_alloc(可 nothrow 版本返回空指针),malloc 失败直接返回 nullptr。最后,delete nullptr 是安全的空操作,但同一指针 delete 两次是未定义行为,删除后应立刻置空。
4. 分配器如何知道要释放多大内存:free/delete 拿到的只有一个指针,大小信息由分配器自己记在指针旁边。glibc 的 malloc 给每块分配加一个 chunk 头:返回给用户的指针指向 payload,紧挨着它前面若干字节(64 位下通常 8 或 16 字节)就是 chunk 的 size 字段,其中低位还被借用来标记「前一块是否正在使用」(PREV_INUSE),free 时先往前偏移读出 size,再据此判断能否与前后空闲块合并、放回哪个 bin(小块的 tcache/fastbin,大块走 unsorted bin,超阈值的大块由 mmap 单独分配、free 时直接 munmap 归还内核)。C++ 侧还叠了一层:C++14 起有 sized deallocation,编译器可能调用 operator delete(void*, size_t) 把大小传下去,但默认的全局 operator delete 通常仍直接转调 free,所以照样依赖分配器头部;只有自己重载了 operator new/delete,才有机会用自己的元数据换掉它,这也是游戏与音视频引擎常做的事。两个直接推论:一,用户可见的可用字节(malloc_usable_size)大于请求值,因为要算上头部与对齐填充;二,越界写一个字节就可能踩坏相邻 chunk 的头部,轻则 free 崩溃,重则形成堆溢出攻击面——new[] 还额外在数组前存一个 cookie 记录元素个数,delete[] 靠它决定析构几次。
5. 进程虚拟地址空间布局与函数调用栈:64 位 Linux 下用户空间从低到高大致是:保留的低地址区、代码段 .text、只读数据 .rodata、已初始化数据 .data、未初始化数据 .bss、向下增长前先被 brk 管理的堆、以及由 mmap 分配的区域(共享库、线程栈、大于阈值的 malloc、显存映射),栈从高地址向下增长,默认上限 8MB(ulimit -s),再往上是命令行参数与环境变量,最高处是内核空间。函数调用时,调用方按 ABI 把前六个整型/指针参数放进 rdi、rsi、rdx、rcx、r8、r9(浮点走 XMM 寄存器),多出的压栈;call 指令把返回地址压栈,被调函数序言再保存调用方的 rbp 并把 rbp 指向当前帧,然后为局部变量下移 rsp 并做 16 字节对齐;返回时 leave 恢复 rsp/rbp、ret 弹出返回地址。音视频里有两条由此而来的纪律:大缓冲区(一帧图像、一整个 GOP)绝不能放栈上,栈只有 8MB 且写成递归会直接爆栈;栈帧越浅越利于缓存,解码/滤镜链路上常见的做法是把大对象放堆或内存池、栈上只留指针和标量。
6. 进程与线程的区别、Linux 线程数量上限:进程是资源分配单位,有独立虚拟地址空间、文件描述符表、信号处理表和凭证;线程是调度单位,同一进程内的线程共享地址空间、fd 表、全局变量和已映射的共享库,各自独享栈、寄存器上下文、TLS 和 errno。切换成本上,线程切换不用换页表(TLB 基本保留),进程切换要换 CR3 并导致 TLB 失效(靠 PCID/ASID 缓解);协程更进一步,切换完全在用户态、不陷入内核,适合音视频里「海量并发连接但每路计算量小」的场景。通信方式要分开记:进程之间用管道/FIFO、消息队列、共享内存(最快,需配信号量或 futex 同步)、信号、信号量、域套接字与网络套接字;线程之间直接共享内存,靠互斥量、条件变量、读写锁、原子量这些同步原语协调,需要隔离时用线程局部存储。Linux 线程数量没有一个写死的常量,而是被四五个可调参数共同钳制:内核的 threads-max(按物理内存推算默认值)、每线程默认 8MB 的栈与一段 guard page 会同时消耗虚拟内存和 vm.max_map_count 的映射数、kernel.pid_max(默认 32768,可放大到 400 万以上)、以及容器里 cgroup 的 pids.max。实践上限通常是几万个量级,超过之后 fork/clone 返回 EAGAIN,所以高并发服务都是「少量线程 + 事件循环」而不是一路一个线程。
7. 线程池、CacheLine 与无锁编程:线程池的价值是把线程创建/销毁的固定成本挪到启动阶段,并给并发度设上限防止过载;关键设计点是有界任务队列(无界队列在过载时会把内存吃光,正确做法是队列满时按拒绝策略快速失败或阻塞生产者)、核心与最大线程数、空闲回收与优雅退出。音视频场景还有一种更专用的形态:每路流配固定的处理线程 + 无锁队列,避免锁竞争引入的抖动。CacheLine 是 CPU 与内存之间传输的最小单位,x86 上通常 64 字节;如果两个线程分别写同一 cacheline 内的不同变量,硬件一致性协议(MESI)会让这条 line 在两个核心之间来回弹跳、反复失效,这就是伪共享,实测能把计数器类操作拖慢十几倍。对策是用 alignas(64) 加填充把热点变量拉开,或改成每线程本地累加、最后归并。无锁编程的基本工具是 std::atomic 与 CAS,难点不在写而在两处:内存序(relaxed/acquire/release/seq_cst 要对得上,写错往往只在弱内存序的 ARM 上暴露)和安全的内存回收(ABA 问题用版本号或 tagged pointer 化解,节点回收用 hazard pointer、RCU 或 epoch 机制)。要清醒的是无锁不等于更快:竞争激烈时 CAS 自旋会白烧 CPU,而基于 futex 的 mutex 在争抢时会主动让出内核调度,反而更划算;音视频里收益最确定的是 SPSC 环形队列这种「生产者消费者各一个线程」的形态。
8. 条件变量与生产者消费者模型:条件变量永远和互斥量搭配使用,因为「判断条件」与「进入等待」必须是一个原子步骤,否则会在两者之间丢通知。wait(lock) 的内部行为是:原子地释放锁并挂起,被唤醒后重新加锁再返回,所以判断谓词必须放在 while 循环里——这既防虚假唤醒,也防「被唤醒时条件又被别人抢走了」。生产者一侧是:加锁 → while (队列满) not_full.wait(lock) → 入队 → not_empty.notify_one();消费者对称地等 not_empty、出队后通知 not_full。两个细节决定实现是否可靠:必须先在持锁状态下修改状态、再发通知(否则通知可能早于对方的判断发生而丢失);退出时要先置停止标志再 notify_all(),否则阻塞在 wait 上的线程永远醒不来。notify_one 与 notify_all 的取舍看唤醒成本:单生产者单消费者用 one 就够,多消费者且唤醒后可能抢不到任务时会退化成空转,此时用 all 更稳。C++20 之后有些场景可以不用条件变量,改用 std::atomic 的 wait/notify 或 std::counting_semaphore,语义更直接、少一把锁。
9. 从键盘输入到角色移动的完整链路:键盘按下先由硬件产生扫描码,内核 input 子系统翻译成键值并经 evdev 暴露,窗口系统(X11/Wayland/Windows 消息队列)把它转成按键事件投递给应用,引擎的事件循环收到后写进输入状态表。逻辑侧每个固定步长(如 60Hz 的 tick)读取当前按键状态,映射成速度或位移,做加速度、碰撞检测与动画状态机切换,更新角色的变换矩阵;这里的关键设计是输入采样与逻辑帧解耦——输入事件异步缓存,逻辑帧统一消费,否则帧率波动会直接改变手感。渲染侧把新变换提交到渲染队列,GPU 依次做顶点变换(MVP)、光栅化、片元着色与后处理,最后由 swapchain 呈现,配合双/三缓冲和垂直同步避免撕裂。端到端延迟来自四段:输入到被采样、等待下一个逻辑帧、渲染队列与 GPU 执行、显示器扫描输出,所以「低延迟」不靠单一环节优化,而是减少预渲染帧数(降低排队深度)、提高输入轮询频率、缩短渲染管线并用帧率上限换取稳定。如果是云游戏或串流 SDK,这条链还要多两跳:控制消息要编码后经网络上行到云端,画面以视频流编码下行,网络 RTT 与编解码延迟占主导,只能靠客户端预测、回滚与丢帧策略掩盖。
10. MVP 矩阵与角色上屏的完整过程:M(Model)把顶点从物体的局部坐标系变换到世界坐标系,包含平移、旋转、缩放,决定角色站在场景哪里、朝向如何;V(View)把世界坐标变换到以相机为原点的相机空间,本质是相机自身世界变换的逆矩阵;P(Projection)把相机空间压到裁剪空间,透视投影由垂直视场角、宽高比、近远裁剪面算出,正交投影则用于 UI 和 2D。三者按 P * V * M 相乘后左乘顶点,得到带 w 分量的裁剪坐标,再经透视除法(除以 w)落到归一化设备坐标,然后视口变换映射到屏幕像素。实践上有三个必踩点:矩阵乘序与左右手坐标系、行主序与列主序(OpenGL 列主序、HLSL 常按行主序,传参时要转置)、法线要用模型矩阵的逆转置变换,否则非等比缩放下光照会歪。上屏的完整链条是:顶点数据与索引 → 顶点着色器做 MVP 与骨骼蒙皮 → 图元装配与裁剪(-w ≤ x,y,z ≤ w)→ 光栅化 → 深度测试与模板测试 → 片元着色(纹理采样、PBR 光照)→ 混合与后处理(泛光、色调映射)→ 交换链呈现。
11. 目标文件如何形成、链接做了什么:一个 .cpp 变成 .o 要过四步:预处理展开宏、包含头文件、处理条件编译;编译器做词法/语法分析生成中间表示,再优化并生成目标平台汇编;汇编器把汇编翻译成机器码,输出可重定位目标文件 .o。.o 的 ELF 结构里装着若干节——.text 代码、.data 已初始化数据、.bss 零初始化数据(只占运行期空间,不占文件体积)、.rodata 只读常量、符号表 .symtab(记录每个符号是定义还是引用、全局还是局部、绑定类型)、重定位表 .rela.text(列出指令里哪些位置在最终地址确定后必须改写)以及调试信息节。链接分三步:第一是符号解析,把每个未定义引用绑定到某个目标文件里的定义,重复定义的强符号报错(弱符号与 common 另有规则);第二是段合并与地址分配,按链接脚本把各 .o 的同名节合并成段,确定每段在虚拟地址空间的起始地址;第三是重定位,遍历重定位表,把占位值改写成符号的最终地址或相对偏移,并按重定位类型处理 PC 相对寻址、绝对地址、GOT/PLT 项。静态库 .a 只是 .o 的归档,链接器从左到右扫描、只把真正被引用到的成员拉进来,所以库的摆放顺序会影响能否解析符号;--gc-sections 能删掉未被引用的段,LTO 则把优化推迟到链接期跨文件进行。
12. 动态链接与 PLT/GOT:动态链接把符号地址的确定推迟到程序加载时甚至首次调用时。共享库以位置无关代码编译,代码段里不出现绝对地址,所有对外部符号的访问都改成「从一张表里取地址」——这张表就是 GOT(全局偏移表),位于可写的数据段,加载时由动态链接器 ld.so 用库加载后的实际地址填好,数据类引用通过它间接寻址。函数调用则多一层 PLT(过程链接表),位于代码段,每个外部函数对应一个条目:第一次调用跳到 PLT 表头的公共桩,进入动态链接器解析真实地址,再把结果写回该函数对应的 GOT 项,之后每次调用都直接跳 GOT 里缓存好的地址,这就是惰性绑定。惰性绑定换来更快的启动(不解析没调用的符号),代价是首次调用有一次解析开销,而且 GOT 必须可写——攻击者若能改写 GOT 就等于劫持控制流,所以现在默认开启 RELRO(把 GOT 在重定位后改为只读),-z now 则强制立即绑定、把全部符号在启动时解析完,用启动时间换掉这个攻击面。动态链接的收益很直接:多个进程共享同一份库代码的物理内存、库升级不必重新编译程序、dlopen 支持插件化;代价是启动时的重定位、符号可见性与 interpose 带来的不确定性(用 -fvisibility=hidden 收紧导出),以及跨库调用比静态链接多一次跳转。