面灵AI→

佰维存储 固件开发 笔试

轮次
笔试
时间
2026-10
来源
牛客网

《面试题目》

  1. 编程题:实现 my_memcpy,不使用库函数。
  2. 编程题:实现双向链表的删除节点和头部插入节点。
  3. volatile 的含义和作用是什么?
  4. 用宏定义实现 MIN。
  5. 结构体大小如何计算?
  6. 变量地址转换相关题目。
  7. ISR(中断服务程序)和普通函数有什么区别?
  8. 实现位操作函数。
  9. 统计二进制数中 1 的个数。

《参考解析》

my_memcpy 的核心是「按字节拷贝、返回目标地址、处理内存重叠」。基础写法是:把 void* 转成 unsigned char*,循环 n 次逐字节赋值,最后返回原始 dst。要主动补三处工程细节:① 类型检查要比较长度是否超过 size_t 范围(sizeof(size_t) < sizeof(dst) 时应只拷贝 ~0UL 字节,这是 glibc 的一个防御);② 重叠——memcpy 的语义是 restrict 的,重叠区域行为未定义,重叠要用 memmove,实现时判方向:dst < src 或 dst >= src + n 可以正向拷,dst > src && dst < src + n 要反向从尾部拷,否则会被自己覆盖;③ 性能可以按字长(unsigned long/SIMD)对齐后批量拷贝,但对齐头和尾部仍需逐字节处理。面试官通常想听的就是「能不能想到重叠」这一句。

双向链表的头部插入与删除节点要注意几个边界。头部插入:新建节点,node->next = head; node->prev = NULL; if (head) head->prev = node; head = node;,注意先判原来的 head 是否为空。删除给定节点:若节点有 prev 就 prev->next = node->next、否则说明删的是头节点要更新头指针;若节点有 next 就 next->prev = node->prev;最后 free。不要忘记两条指针链都要维护——只改 next 不改 prev 是这类题最常见的错误,也是面试官必查的点。如果链表结构带哨兵头节点,两个函数都能省掉空指针判断,可以主动提一句这个设计取舍。

volatile 告诉编译器「这个变量的值可能在程序控制之外被改变,每次访问都必须真正读写内存,不许把它缓存进寄存器、不许优化掉看似冗余的读写、不许调整它与其它 volatile 访问之间的顺序」。嵌入式里的必用场景有三类:① 硬件寄存器映射的地址(状态寄存器轮询 while (REG & BUSY);,不加 volatile 编译器可能优化成死循环或只读一次);② 中断与主循环共享的变量(volatile uint8_t flag; 在 ISR 里置位、主循环里检查);③ 多线程/多任务共享的简单标志。关键是要说清它的边界:volatile 不保证原子性、也不提供内存屏障和同步语义,i++ 这类读-改-写操作在中断并发下仍会丢更新,要真正安全得用临界区(关中断)或原子操作;它也不能替代 memory barrier 来约束多核乱序。把它和 const 的用法区分开(const volatile 表示「程序只读、但硬件会改」,常见于只读状态寄存器)是常见追问。

宏定义 MIN 的标准答案是 #define MIN(a, b) ((a) < (b) ? (a) : (b)),每个参数和整体都要加括号,否则 MIN(x & 0xF, y) 之类的传参会因优先级出错。但要主动指出宏的三个缺陷:参数被求值两次(MIN(i++, j) 会自增两次,这是真 bug)、没有类型检查、调试时无法断点。现代 C 里更好的做法是 static inline 函数,由编译器保证求值一次并获得类型检查;如果一定要用宏,可以用 GNU 的 __typeof__ + 临时变量做成 ({ __typeof__(a) _a = (a); __typeof__(b) _b = (b); _a < _b ? _a : _b; }),或者用 _Generic 做类型分发。这题考的是「知道宏的坑」而不是「会写宏」。

结构体大小由「成员大小 + 对齐填充」决定,规则是每个成员的偏移必须是 min(自身对齐, 编译器对齐上限) 的整数倍,结构体总大小必须是「最大成员对齐值」的整数倍。举例:struct { char a; int b; char c; } 在 4 字节对齐下是 12(1 + 3 填充 + 4 + 1 + 3 尾部填充),把 c 挪到 b 前面变成 8。所以调整成员顺序可以减小结构体——这是嵌入式省 RAM 的常用手段。可以用 #pragma pack(1) 或 __attribute__((packed)) 取消填充,但代价是对齐访问可能变成非对齐访问,在部分架构(如某些 ARM 配置)上会触发异常或性能下降,所以只用在协议报文这类必须紧凑的场景,并且要说明「打包传输、解包后再用」。空结构体在 C 中大小为 0(C++ 中为 1),柔性数组成员 char data[] 不占大小,这些都是常见追问点。

变量地址转换这类题一般考指针与整型的互转和层级。要能说清:int a; int *p = &a; 里 p 是变量 a 的地址,*p 是它的值;(uintptr_t)p 可把指针转成整数(uintptr_t 是保证能容纳指针的整型,比 int 可移植);嵌套的 int **pp 是指向指针的指针,多用于需要修改调用方指针的场景。嵌入式里常考的是「把整数当地址用」:volatile uint32_t *reg = (volatile uint32_t *)0x40000000; 然后 *reg = value;,一定要带 volatile。还有「结构体指针按字节访问」((uint8_t *)&s)[i]、「地址转函数指针」void (*f)(void) = (void (*)(void))addr;(Bootloader 跳转 App 就是这么做的)。答题时强调未对齐访问和严格别名(strict aliasing)的坑会更完整。

ISR 与普通函数的区别是嵌入式面试的必考题,按「调用时机、约束、写法」三块答。中断服务程序由硬件中断异步触发,不按调用栈由程序显式调用,因此:① 必须尽量短——中断期间同级中断被屏蔽,长时间占用会丢中断、影响系统实时性;耗时逻辑要「上半部做标记、下半部交给任务/主循环处理」。② 不能阻塞——不能调用 malloc/free、不能拿可能睡眠的锁、不能做浮点或复杂库调用(视平台而定)、不能 printf(串口输出极慢)。③ 返回值通常是 void、不能有调用者,也不可重入地调用自己;要访问共享数据需加临界区保护(关中断或用 volatile + 原子标志)。④ 在 RTOS 里必须用带 FromISR 后缀的 API(如 xQueueSendFromISR),并在退出时按需触发一次任务切换(portYIELD_FROM_ISR),因为中断上下文里直接调用会破坏调度器。⑤ 中断入口通常还要清中断标志位,否则会反复进入。

统计二进制中 1 的个数有四个层次。① 逐位检查:while (n) { count += n & 1; n >>= 1; },O(位数)。② n &= n - 1 消掉最低位的 1,循环次数等于 1 的个数——这是最优的常规写法,面试首推。③ 查表法:按字节建 256 项的计数表,适合批量处理。④ 硬件/编译器内置:GCC/Clang 的 __builtin_popcount 通常直接映射到 CPU 的 POPCNT 指令。要主动提醒两个坑:有符号数右移是算术右移,负数会补 1 导致死循环,所以应该用 unsigned 或改用 n &= n - 1 的写法;以及题目若问「判断是不是 2 的幂」,直接用 n > 0 && (n & (n - 1)) == 0 一行解决。位操作函数那一题通常是要求实现「置位、清位、取反」或「提取连续位域」,写法是 n |= (1u << pos)、n &= ~(1u << pos)、(n >> pos) & ((1u << width) - 1),注意 1u 的 u 后缀和宽度为 32 时 (1u << 32) 是未定义行为。