🕐 2026.04.30 | CVTE 技术面 | 面试时长 38 分钟 | 岗位: 嵌入式系统软件工程师

这场一共面了 38 分钟,前半段主要是理论知识拷打,后半段是 15 分钟允许 AI 辅助的串口接收代码题。

因为当时没做完整记录,下面主要是凭印象回忆整理。


🙋 自我介绍

这部分直接略过。


📚 理论知识

进程和线程的区别

上来就问操作系统,这个确实有点把我打懵了。当时大概只答了:进程有独立内存空间,进程之间不能直接通信、切换开销更大;线程是进程里的执行单元,共享进程空间,线程之间通信更直接。

参考答案
  • 进程:系统进行资源分配的最小单位,拥有独立的地址空间
  • 线程:CPU 调度的最小单位,是进程中的一个执行流
  • 区别
    • 进程有独立的内存空间,线程共享所属进程的内存空间
    • 进程切换开销大,线程切换开销小
    • 进程间通信需要 IPC,线程间通信更直接
    • 一个进程崩溃通常不影响别的进程,一个线程崩溃可能导致整个进程崩溃

系统资源分配的最小单位是什么?

这个我当时是直接猜的进程。

参考答案

进程

  • 进程:资源分配的最小单位
  • 线程:CPU 调度的最小单位

进程间通信方式

我答了:有名管道、无名管道、共享内存、消息队列、信号量。

参考答案

常见进程间通信方式有:

  • 无名管道
  • 有名管道(FIFO)
  • 共享内存
  • 消息队列
  • 信号量
  • 信号
  • Socket

线程同步

不会。

参考答案

线程同步常见方式有:

  • 互斥锁 mutex
  • 信号量 semaphore
  • 条件变量 condition variable
  • 读写锁 rwlock
  • 自旋锁 spinlock
  • 原子操作 atomic

线程同步的结构

这个也不会。

参考答案

如果这里问的是线程同步常用的结构 / 原语,一般可以答:

  • 互斥锁
  • 信号量
  • 条件变量
  • 读写锁
  • 自旋锁
  • 屏障(barrier)

如果偏工程实现一点,也可以提:

  • 环形缓冲区
  • 线程安全队列
  • 无锁队列

中断怎么进行的

我不会官方术语,就拿单片机定时器中断举例说明了一下。

参考答案

中断的大致流程:

  1. 外设产生中断请求
  2. CPU 响应中断
  3. 保存现场
  4. 跳转到中断服务函数 ISR
  5. 执行中断处理逻辑
  6. 恢复现场
  7. 返回原程序继续执行

中断的优点

我只答了增强实时性、可以进行优先级划分、能保证数据处理更稳定。

参考答案

中断的优点主要有:

  • 提高实时性
  • 避免 CPU 轮询浪费资源
  • 可以进行优先级管理
  • 提高系统响应效率
  • 有利于异步事件处理

串口数据格式

忘了。

参考答案

串口一帧数据通常包括:

  • 起始位
  • 数据位
  • 校验位(可选)
  • 停止位

常见配置如 8N1

  • 8 位数据位
  • 无校验
  • 1 位停止位

IIC 数据传送流程

忘了,当时模模糊糊随便说了一点。

参考答案

IIC 通信基本流程:

  1. 主机发送起始信号 START
  2. 主机发送从机地址 + 读写位
  3. 从机应答 ACK
  4. 主机发送或接收数据
  5. 每发送 1 字节都要有应答位
  6. 通信结束后主机发送停止信号 STOP

IIC 空闲时 SDA 和 SCL 的状态

忘了,随便答的。

参考答案

IIC 空闲时:

  • SDA = 高电平
  • SCL = 高电平

因为 IIC 一般是开漏输出,靠上拉电阻拉高。


学过哪些排序

我答了冒泡、归并、插入、希尔。

参考答案

常见排序包括:

  • 冒泡排序
  • 插入排序
  • 选择排序
  • 希尔排序
  • 归并排序
  • 快速排序
  • 堆排序

冒泡排序介绍

我答的是双重 for 循环依次比较,时间复杂度是 O(n^2)。居然没继续追问后面那些排序。

参考答案

冒泡排序的核心思想:

  • 通过相邻元素比较与交换,把较大或较小的元素逐步“冒”到一端
  • 需要双重循环
  • 时间复杂度:
    • 平均:O(n^2)
    • 最坏:O(n^2)
  • 空间复杂度:O(1)
  • 属于稳定排序

💻 15 分钟代码手搓

题目原文

🎓 嵌入式底层开发实战考题:UART 通信协议解析与防御性编程

考试说明
允许使用 AI 辅助编程 / 搜索资料,但要求你能够透彻解释数据帧在各种异常边缘情况(Corner Cases)下的流转逻辑。
业务背景:你正在开发一个环境采集设备。主控制器通过串口(UART)连接多个温湿度传感器节点。每个传感器节点每隔 10 秒采集一次数据,并通过串口向主控制器发送一帧数据。

协议描述

  • 包头(2 Bytes):0xAA 0x55
  • 长度(1 Byte):N(有效载荷的字节数)
  • 有效载荷(N Bytes):数据内容
  • 校验和(1 Byte):CS
    • 计算规则:长度字节与所有有效载荷字节的累加和,取低 8 位
    • 即:(Len + Payload) & 0xFF

示例数据帧AA 55 05 01 41 01 2C 02 76
其中 05 为长度,01 41 01 2C 02 为载荷,76 为校验和。

第一部分:代码 Review 与极端场景漏洞分析

以下是一位实习生编写的串口字节解析代码。他采用了经典的“单字节状态机(FSM)”模式,在理想情况下处理正常数据没有问题。

但在真实工业现场(存在电磁干扰)测试中,主控经常会“莫名其妙地丢失整段数据”。经过抓包比对,发现了以下几个会导致代码解析彻底崩溃的测试用例。

问题 1:请仔细阅读以下 C 语言代码,指出至少 3 个致命缺陷,并详细解释为什么在遇到【测试用例 A】和【测试用例 B】时,该状态机会导致有效数据丢失。

#include <stdint.h>
#include <stdio.h>

// 实习生定义的单字节状态机
typedef enum { STATE_HEAD1, STATE_HEAD2, STATE_LEN, STATE_PAYLOAD, STATE_CS } rx_state_t;

rx_state_t state = STATE_HEAD1;
uint8_t payload_buf[256];
uint8_t expected_len = 0;
uint8_t payload_idx = 0;

void uart_rx_byte_handler(uint8_t byte) {
switch (state) {
case STATE_HEAD1:
if (byte == 0xAA) state = STATE_HEAD2;
break;

case STATE_HEAD2:
if (byte == 0x55) state = STATE_LEN;
else state = STATE_HEAD1;
break;

case STATE_LEN:
expected_len = byte;
payload_idx = 0;
state = STATE_PAYLOAD;
break;

case STATE_PAYLOAD:
payload_buf[payload_idx++] = byte;
if (payload_idx == expected_len) state = STATE_CS;
break;

case STATE_CS:
uint8_t cs = expected_len;
for (int i = 0; i < expected_len; i++) {
cs += payload_buf[i];
}
if (cs == byte) {
// 校验通过,处理业务
printf("Valid frame received!\n");
}
// 无论校验对错,状态机强行复位
state = STATE_HEAD1;
break;
}
}

触发 Bug 的测试用例

  • 测试用例 A:连续头部干扰
    收到的字节流为:AA AA 55 05 01 41 01 2C 02 76
  • 测试用例 B:半包与有效包重叠
    收到的字节流为:AA 55 05 01(由于干扰中断)
    紧接着又发来一个完整包:AA 55 05 01 41 01 2C 02 76
    合并后的字节流为:AA 55 05 01 AA 55 05 01 41 01 2C 02 76

第二部分:防粘包与容错重构

问题 2:请彻底重构数据解析逻辑。

要求

  • 抛弃存在隐患的单字节 FSM 逻辑,编写一段极其鲁棒的 uart_rx_byte_handler
  • 必须完美解决粘包、半包问题
  • 在【测试用例 B】的情况下,能准确丢弃前面残缺的 4 个字节,完整救出后面隐藏的有效帧
  • (加分项)说明你的设计时间复杂度;如果在低端 MCU 上运行,频繁的内存拷贝是否会成为瓶颈;是否有零拷贝(Zero-Copy)优化方案

我的作答思路

这题我直接塞给 Claude,5 分钟左右就给出了解法;同时我自己也在看代码,总共花了不到 10 分钟。整体来说不算难,我自己找到了 4 个缺陷,AI 帮我补到了 5 个,但面试官不给看解析,只能看题目回答并且还会提问没有修复的情况会导致什么问题,就只回答了四个。

🧩 代码缺陷分析

缺陷 1:STATE_HEAD2 没有处理“重叠头部”

原代码:

case STATE_HEAD2:
if (byte == 0x55) state = STATE_LEN;
else state = STATE_HEAD1;
break;

如果当前状态已经看到一个 0xAA,下一字节又是 0xAA,这其实非常关键:

  • 第二个 0xAA 可能就是新包头的第 1 个字节
  • 但实习生代码直接 state = STATE_HEAD1
  • 更糟的是,这个字节没有被重新消费

所以它把一个潜在的新起点白白丢掉了。

为什么测试用例 A 会丢包?

字节流如下:

AA AA 55 05 01 41 01 2C 02 76

逐字节分析:

  1. 第 1 个 AAHEAD1 -> HEAD2
  2. 第 2 个 AA:不是 55,进入 else,状态回到 HEAD1

问题在于:这个 AA 本来应该被当作新帧头重新识别,但代码直接把它丢了。接下来 55 落到 HEAD1 状态,而 HEAD1 只认 AA,所以 55 也被丢弃。

后面的整帧因此彻底失去同步,完整有效包被整体丢弃。这就是典型的头部重叠失配问题

这个缺陷我讲解的很详细,没有被提问

缺陷 2:校验失败后强制复位,但没有重同步能力

原代码:

if (cs == byte) {
printf("Valid frame received!\n");
}
state = STATE_HEAD1;

无论校验成功还是失败,状态机都只做一件事:回到 STATE_HEAD1

这意味着:

  • 当前字节如果本身可能是下一帧起点,不会被利用
  • 已缓存的 payload 中如果埋着 AA 55,不会被重新挖出来
  • 遇到半包、错长、错校验后,不能从已有数据窗口内部恢复同步

这在抗干扰串口解析里是致命的。工业现场的噪声不是“丢一个整包然后世界恢复干净”,而是错误字节和正确数据混在一起

这个缺陷也是,讲的很清楚没有被提问

缺陷 3:长度字段完全没有合法性检查

原代码:

expected_len = byte;
payload_idx = 0;
state = STATE_PAYLOAD;

这里至少有两个问题:

  • Len = 0 没有特殊处理
    按协议,N = 0 时应该直接进入 STATE_CS 等待校验字节,而不是进入 STATE_PAYLOAD
    现在的实现会导致本应是 CS 的字节被当作 payload[0],后续流全部错位。

  • 没有长度上限约束
    虽然 payload_buf[256]uint8_t 看起来能装下 0~255,但真实协议通常会有业务最大长度,比如 32 / 64。
    如果噪声把长度打成 200,状态机就会盲目等待 200 字节,期间即使出现真正的 AA 55 头部,也无法恢复同步,导致后续多帧连续丢失。

本质上,错误长度会把状态机拖进长时间失步状态

提问:如果没有检查会发生什么呢?

会出现数组越界问题,因为缓冲数组payload_buf只有256位,如歌长度超过256位就会导致访问错误

缺陷 4:STATE_PAYLOAD 期间不检测包头,无法从半包里抢救后续真包

这是【测试用例 B】崩溃的核心原因。

原代码:

payload_buf[payload_idx++] = byte;
if (payload_idx == expected_len) state = STATE_CS;

含义就是:只要进入 PAYLOAD,就死等 expected_len 个字节收满。

如果这期间流里出现了:

AA 55 ...

也只会被当成旧包 payload 的一部分吞掉。

为什么测试用例 B 会丢包?

合并流:

AA 55 05 01 AA 55 05 01 41 01 2C 02 76

拆开看:

  • 前 4 字节是残包头:
    • AA 55 -> 进入 STATE_LEN
    • 05 -> expected_len = 5
    • 01 -> 收到 payload[0]

此时理论上旧包还差 4 个 payload + 1 个 CS,但实际上后面已经来了一个完整新包:

AA 55 05 01 41 01 2C 02 76

FSM 的处理方式会是:

  • AApayload[1]
  • 55payload[2]
  • 05payload[3]
  • 01payload[4]
  • 达到 expected_len = 5 后切到 STATE_CS
  • 下一字节 41 被当成 CS 做校验

计算出来的校验当然不等于 41,于是状态机复位到 HEAD1

问题是:

  • 真包的 AA 55 05 01 已经被当成旧包 payload 吞掉了
  • 真包的第 5 个字节 41 又被当成旧包 CS 吞掉了
  • 后面的 01 2C 02 76 已经失去头部上下文

结果就是:后面那个完整有效包也一起被毁掉了

提问:如果没有校验会发生什么呢?

没有校验机制会导致例如测试用例B的半包情况,会将完整包当作前一个半包的数据写入,从而丢失整个数据包

🔧 问题 2:重构解析逻辑

题目要求“抛弃存在隐患的单字节 FSM”,最稳妥的方案就是:

流缓冲区 + 滑动窗口找头 + 长度判定 + 完整帧校验 + 消费已确认字节

它的思路不是“按状态吃字节”,而是:

  1. 每收到一个字节,先放入接收缓冲区
  2. 在缓冲区里扫描 AA 55
  3. 找到后先判断后面是否至少有长度字节
  4. 再判断整帧是否已经收完整
  5. 完整则做校验
  6. 校验成功就处理,并从缓冲区移除整帧
  7. 校验失败则只丢 1 个字节,再继续重新同步

这种方案能比较稳定地应对:

  • 粘包
  • 半包
  • 错位
  • 噪声干扰
  • 假头部

🧾 我的代码

#include <stdint.h>
#include <stdio.h>
#include <string.h>

#define FRAME_HEAD1 0xAA
#define FRAME_HEAD2 0x55

/*
这里给两个长度概念:
协议长度字段是 1 字节,理论范围 0~255
业务上建议限制最大 payload,避免噪声导致超长等待
下面设成 64,你可按项目改成 32 / 128 等。
*/
#define MAX_PAYLOAD_LEN 64
#define RX_BUF_SIZE 256

typedef struct {
uint8_t buf[RX_BUF_SIZE];
uint16_t len;
} uart_parser_t;

static uart_parser_t g_parser;

static uint8_t calc_checksum(uint8_t payload_len, const uint8_t *payload)
{
uint16_t sum = payload_len;
uint16_t i;

for (i = 0; i < payload_len; i++) {
sum += payload[i];
}

return (uint8_t)(sum & 0xFF);
}

static void dump_bytes(const uint8_t *data, uint16_t len)
{
uint16_t i;
for (i = 0; i < len; i++) {
printf("%02X ", data[i]);
}
printf("\n");
}

static void parser_reset(uart_parser_t *parser)
{
parser->len = 0;
}

static void consume_bytes(uart_parser_t *parser, uint16_t n)
{
if (n >= parser->len) {
parser->len = 0;
return;
}

memmove(parser->buf, parser->buf + n, parser->len - n);
parser->len = (uint16_t)(parser->len - n);
}

static void handle_valid_frame(const uint8_t *payload, uint8_t payload_len)
{
printf("[OK] Valid frame received, len=%u, payload=", payload_len);
dump_bytes(payload, payload_len);
}

static void parse_rx_buffer(uart_parser_t *parser)
{
while (parser->len >= 2) {
uint16_t head_pos;
int found_head = 0;

for (head_pos = 0; head_pos + 1 < parser->len; head_pos++) {
if (parser->buf[head_pos] == FRAME_HEAD1 &&
parser->buf[head_pos + 1] == FRAME_HEAD2) {
found_head = 1;
break;
}
}

if (!found_head) {
if (parser->len > 0 && parser->buf[parser->len - 1] == FRAME_HEAD1) {
parser->buf[0] = FRAME_HEAD1;
parser->len = 1;
} else {
parser->len = 0;
}
return;
}

if (head_pos > 0) {
consume_bytes(parser, head_pos);
}

if (parser->len < 3) {
return;
}

{
uint8_t payload_len = parser->buf[2];
uint16_t full_frame_len;
uint8_t received_cs;
uint8_t computed_cs;

if (payload_len > MAX_PAYLOAD_LEN) {
consume_bytes(parser, 1);
continue;
}

full_frame_len = (uint16_t)(2 + 1 + payload_len + 1);

if (parser->len < full_frame_len) {
return;
}

received_cs = parser->buf[3 + payload_len];
computed_cs = calc_checksum(payload_len, &parser->buf[3]);

if (received_cs == computed_cs) {
handle_valid_frame(&parser->buf[3], payload_len);
consume_bytes(parser, full_frame_len);
} else {
printf("[WARN] Checksum mismatch, resync\n");
consume_bytes(parser, 1);
}
}
}
}

void uart_rx_byte_handler(uint8_t byte)
{
uart_parser_t *parser = &g_parser;

if (parser->len >= RX_BUF_SIZE) {
if (parser->buf[parser->len - 1] == FRAME_HEAD1) {
parser->buf[0] = FRAME_HEAD1;
parser->len = 1;
} else {
parser->len = 0;
}
}

parser->buf[parser->len++] = byte;
parse_rx_buffer(parser);
}

static void feed_stream(const uint8_t *data, uint16_t len, const char *title)
{
uint16_t i;

printf("========== %s ==========\n", title);
printf("Input : ");
dump_bytes(data, len);

for (i = 0; i < len; i++) {
uart_rx_byte_handler(data[i]);
}

printf("Remain: ");
dump_bytes(g_parser.buf, g_parser.len);
printf("\n");
}

static void build_frame(uint8_t *out,
uint8_t payload_len,
const uint8_t *payload,
uint16_t *out_len)
{
uint16_t i;
uint8_t cs = calc_checksum(payload_len, payload);

out[0] = FRAME_HEAD1;
out[1] = FRAME_HEAD2;
out[2] = payload_len;

for (i = 0; i < payload_len; i++) {
out[3 + i] = payload[i];
}

out[3 + payload_len] = cs;
*out_len = (uint16_t)(2 + 1 + payload_len + 1);
}

int main(void)
{
uint8_t payload1[] = {0x01, 0x41, 0x01, 0x2C, 0x02};
uint8_t valid_frame[16];
uint16_t valid_frame_len = 0;

uint8_t case_a[] = {
0xAA, 0xAA, 0x55, 0x05, 0x01, 0x41, 0x01, 0x2C, 0x02, 0x76
};

uint8_t case_b[] = {
0xAA, 0x55, 0x05, 0x01,
0xAA, 0x55, 0x05, 0x01, 0x41, 0x01, 0x2C, 0x02, 0x76
};

uint8_t case_sticky[32];
uint16_t frame1_len, frame2_len;
uint8_t payload2[] = {0x10, 0x20, 0x30};
uint8_t frame1[16];
uint8_t frame2[16];

parser_reset(&g_parser);

build_frame(valid_frame, (uint8_t)sizeof(payload1), payload1, &valid_frame_len);
feed_stream(valid_frame, valid_frame_len, "Normal valid frame");

parser_reset(&g_parser);
feed_stream(case_a, (uint16_t)sizeof(case_a), "Case A: repeated AA noise before real header");

parser_reset(&g_parser);
feed_stream(case_b, (uint16_t)sizeof(case_b), "Case B: broken half-packet overlaps a valid frame");

parser_reset(&g_parser);
build_frame(frame1, (uint8_t)sizeof(payload1), payload1, &frame1_len);
build_frame(frame2, (uint8_t)sizeof(payload2), payload2, &frame2_len);
memcpy(case_sticky, frame1, frame1_len);
memcpy(case_sticky + frame1_len, frame2, frame2_len);
feed_stream(case_sticky, (uint16_t)(frame1_len + frame2_len), "Sticky packets: two valid frames back-to-back");

return 0;
}

💭 一点感受

理论知识部分被拷打得挺狠,代码题反而还好。靠 AI 加上自己一起看逻辑,做出来还算顺利。

整体观感就是:八股被拷打,代码题还行。