嵌入式面试必考的三大通信+存储+启动知识,从原理到代码到面试话术一网打尽,附追问预判和回答模板。

面试官:“SPI和I2C有什么区别?什么时候用哪个?”

你:“SPI速度快,I2C线少…”

面试官:“还有呢?”

你:“呃…就这些?”

面试官:(微笑,在简历上画了个圈)

别慌,这是90%的人都会挂的追问。 SPI、I2C、Flash、Bootloader这四个东西,面试问到的概率极高——因为它们代表了嵌入式开发的三个核心能力:通信、存储、启动

这篇文章帮你把这三个知识点讲透,附带面试追问预判和回答模板。

💬 你面试被问过这几个问题吗?评论区说说你被问懵的瞬间!


1. SPI vs I2C 对比

问题:SPI和I2C有什么区别?什么时候用哪个?

回答

先看对比表:

对比项 SPI I2C
线数 4根(MOSI/MISO/SCK/CS) 2根(SDA/SCL)
速度 最高几十MHz 标准100kHz,快速400kHz,高速3.4MHz
全双工 ✅ 支持 ❌ 半双工
寻址 靠片选CS,一对一 7位/10位地址,一对多
时钟 主设备产生 主设备产生
协议复杂度 简单,无应答 复杂,有ACK/NACK
距离 板级,<30cm 板级,<1m

一句话总结:SPI是"专线电话",速度快但占线多;I2C是"总线电话",线少但要排队。

什么时候用哪个?

┌─────────────────────────────────────────────────────────┐
│  用SPI的场景:                                            │
│  • 需要高速传输(屏幕刷新、SD卡读写)                        │
│  • 设备少,不需要省引脚                                    │
│  • 对时序要求严格(ADC、DAC)                               │
├─────────────────────────────────────────────────────────┤
│  用I2C的场景:                                            │
│  • 引脚紧张(传感器、EEPROM、OLED)                         │
│  • 设备多,需要总线挂载多个设备                               │
│  • 速度要求不高(配置寄存器、读取状态)                        │
└─────────────────────────────────────────────────────────┘

面试官可能的追问

Q:SPI的四种模式(CPOL/CPHA)是什么?

CPOL决定时钟空闲电平,CPHA决定数据采样时刻:

  • Mode 0:CPOL=0, CPHA=0 → 空闲低电平,第一个边沿采样
  • Mode 1:CPOL=0, CPHA=1 → 空闲低电平,第二个边沿采样
  • Mode 2:CPOL=1, CPHA=0 → 空闲高电平,第一个边沿采样
  • Mode 3:CPOL=1, CPHA=1 → 空闲高电平,第二个边沿采样

实际用的时候看设备数据手册,大多数设备用Mode 0或Mode 3。

Q:I2C的ACK/NACK机制是什么?

发送方发完一个字节后,释放SDA线(拉高)。接收方在第9个时钟周期把SDA拉低表示ACK(收到了),保持高电平表示NACK(没收到或不想收)。

应用场景:主设备读取从设备数据时,最后一个字节发NACK表示"读完了,不用再发了"。

Q:I2C总线挂了多个设备,怎么排查通信失败?

  1. 用逻辑分析仪抓波形:看SDA/SCL的电平是否正常
  2. 检查地址:用I2C扫描程序(遍历0x01~0x7F,看谁ACK)
  3. 检查上拉电阻:I2C是开漏输出,必须有上拉电阻(4.7kΩ常用)
  4. 检查时序:快速模式400kHz,设备跟不上会NACK

代码示例:I2C扫描程序

/* 扫描I2C总线上所有设备 */
void i2c_scan(I2C_HandleTypeDef *hi2c) {
    printf("I2C设备扫描开始...\r\n");
    uint8_t count = 0;

    for (uint8_t addr = 1; addr < 127; addr++) {
        /* 尝试发送地址,看有没有ACK */
        if (HAL_I2C_IsDeviceReady(hi2c, addr << 1, 1, 10) == HAL_OK) {
            printf("  发现设备: 0x%02X\r\n", addr);
            count++;
        }
    }

    if (count == 0) {
        printf("  未发现任何设备!检查接线和上拉电阻\r\n");
    } else {
        printf("  共发现 %d 个设备\r\n", count);
    }
}

2. Flash读写原理

问题:STM32的Flash怎么读写?有什么注意事项?

回答

STM32的Flash分两种用途:

  1. 程序存储:存放代码,掉电不丢失
  2. 数据存储:可以用来存配置参数(代替EEPROM)

关键点:Flash只能写0,不能写1。要写1必须先擦除(整页擦除,变成全1)。

┌─────────────────────────────────────────────────────────┐
│  Flash特性:                                              │
│  • 读:随时可以读,速度很快(CPU直接访问)                     │
│  • 写:只能把1改成0,不能把0改成1                             │
│  • 擦:必须整页擦除(1KB或2KB一页),变成全1                    │
│  • 寿命:擦写次数有限(STM32F103约1万次)                     │
│  • 速度:擦除最慢(几十ms),写较快(几十μs/字)                │
└─────────────────────────────────────────────────────────┘

面试官可能的追问

Q:Flash和EEPROM有什么区别?

对比项 Flash EEPROM
擦除单位 整页(1KB/2KB) 单字节
写入速度 较慢 较快
寿命 1万次 100万次
成本 低(集成在MCU里) 高(外挂芯片)
适用场景 存大块数据、参数少改 频繁改写小数据

实际项目中:如果参数改写不频繁(比如设备序列号、校准参数),用Flash省钱;如果要频繁存日志、计数器,用EEPROM或FRAM。

Q:写Flash前为什么要擦除?

因为Flash只能把1改成0。比如原来存的是0xFF(全1),你可以写入0x00;但如果原来是0x00,你写0xFF是写不进去的,必须先擦除变成0xFF。

Q:STM32的Flash读写有什么坑?

  1. 不能在Flash擦写时执行代码:Flash擦写期间CPU会暂停(除非用双Bank)
  2. 中断向量表:如果Bootloader占了低地址,要重定位中断向量表
  3. 对齐要求:STM32F103写入必须半字(16位)对齐
  4. 掉电保护:写到一半掉电会丢数据,要加备份机制

代码示例:STM32F103 Flash读写

/* Flash写入(HAL库) */
#include "stm32f1xx_hal_flash.h"

/* 写入参数到Flash最后一页 */
#define FLASH_PARAM_ADDR    0x0800FC00  /* Flash最后一页地址(假设64KB Flash) */

HAL_StatusTypeDef flash_write_params(uint16_t *data, uint16_t len) {
    HAL_StatusTypeDef status = HAL_OK;
    FLASH_EraseInitTypeDef erase;
    uint32_t error;

    /* 1. 解锁Flash */
    HAL_FLASH_Unlock();

    /* 2. 擦除最后一页 */
    erase.TypeErase = FLASH_TYPEERASE_PAGES;
    erase.PageAddress = FLASH_PARAM_ADDR;
    erase.NbPages = 1;
    status = HAL_FLASHEx_Erase(&erase, &error);
    if (status != HAL_OK) {
        HAL_FLASH_Lock();
        return status;
    }

    /* 3. 写入数据(半字对齐) */
    for (uint16_t i = 0; i < len; i++) {
        status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD,
                                   FLASH_PARAM_ADDR + i * 2,
                                   data[i]);
        if (status != HAL_OK) break;
    }

    /* 4. 锁定Flash */
    HAL_FLASH_Lock();
    return status;
}

/* 读取参数(直接访问地址) */
void flash_read_params(uint16_t *buf, uint16_t len) {
    uint16_t *addr = (uint16_t *)FLASH_PARAM_ADDR;
    for (uint16_t i = 0; i < len; i++) {
        buf[i] = addr[i];
    }
}

3. Bootloader原理

问题:什么是Bootloader?怎么设计一个简单的Bootloader?

回答

Bootloader是启动加载器,在应用程序运行之前执行,负责:

  1. 初始化硬件
  2. 判断是否需要更新固件
  3. 如果需要,从串口/USB/网络接收新固件并写入Flash
  4. 跳转到应用程序执行
┌─────────────────────────────────────────────────────────┐
│  Bootloader工作流程:                                      │
│                                                          │
│  上电 → Bootloader启动 → 检查更新标志                      │
│         ↓                     ↓                          │
│    有更新请求              无更新请求                       │
│         ↓                     ↓                          │
│    接收新固件              跳转到APP                        │
│    写入Flash               ↓                             │
│    清除标志               APP运行                          │
│    跳转到APP              ↓                              │
│                                                          │
│  Flash布局:                                              │
│  ┌──────────┬──────────────────────────────────┐         │
│  │Bootloader│          Application             │         │
│  │  (16KB)  │          (剩余空间)                │         │
│  │0x08000000│0x08004000                        │         │
│  └──────────┴──────────────────────────────────┘         │
└─────────────────────────────────────────────────────────┘

面试官可能的追问

Q:Bootloader和APP怎么跳转?

  1. 设置主堆栈指针(MSP)为APP的初始SP值(Flash第一个字)
  2. 获取APP的复位中断向量(Flash第二个字)
  3. 跳转到该地址执行
/* 跳转到APP */
void jump_to_app(uint32_t app_addr) {
    /* 检查APP地址是否有效(栈指针在SRAM范围内) */
    if ((*(uint32_t *)app_addr & 0x2FF00000) == 0x20000000) {
        /* 设置主堆栈指针 */
        __set_MSP(*(uint32_t *)app_addr);
        /* 获取复位中断向量 */
        uint32_t jump_addr = *(uint32_t *)(app_addr + 4);
        /* 跳转 */
        ((void (*)(void))jump_addr)();
    }
}

Q:APP的中断向量表怎么处理?

Bootloader占了Flash低地址,APP的中断向量表偏移了。需要:

  1. 在APP的main函数开头,重定位中断向量表:
    SCB->VTOR = 0x08004000;  /* APP起始地址 */
    
  2. 或者在Bootloader跳转前设置:
    SCB->VTOR = APP_ADDR;
    

Q:怎么判断是否需要进入Bootloader?

常见方法:

  1. 检查标志位:Flash中存一个标志,APP要更新时写入"需要更新"
  2. 检查按键:上电时检测某个按键是否按下
  3. 检查串口命令:上电后等100ms,收到特定命令就进入更新模式
  4. 检查APP有效性:Flash APP区域全0xFF说明没有APP,进入Bootloader

Q:串口升级的协议怎么设计?

简单协议示例:

帧头(2字节) + 命令(1字节) + 长度(2字节) + 数据(N字节) + 校验(1字节)

命令定义:
0x01: 握手(PC→MCU,MCU回复版本号)
0x02: 擦除(PC→MCU,MCU擦除APP区域)
0x03: 写入(PC→MCU,发送固件数据)
0x04: 校验(PC→MCU,MCU回复CRC)
0x05: 跳转(PC→MCU,MCU跳转到APP)

代码示例:简单的串口Bootloader框架

/* Bootloader主循环 */
int main(void) {
    HAL_Init();
    SystemClock_Config();
    MX_USART1_UART_Init();

    /* 检查是否需要进入Bootloader */
    uint8_t boot_flag = *(uint8_t *)BOOT_FLAG_ADDR;

    if (boot_flag == 0xAA || is_app_valid() == 0) {
        /* 进入Bootloader模式 */
        printf("[BOOT] 进入Bootloader模式\r\n");
        bootloader_loop();
    } else {
        /* 跳转到APP */
        printf("[BOOT] 跳转到APP\r\n");
        jump_to_app(APP_ADDR);
    }

    while(1);
}

/* Bootloader循环:接收固件 */
void bootloader_loop(void) {
    uint8_t cmd;
    while (1) {
        if (HAL_UART_Receive(&huart1, &cmd, 1, 100) == HAL_OK) {
            switch (cmd) {
                case CMD_HANDSHAKE:
                    send_version();
                    break;
                case CMD_ERASE:
                    erase_app_area();
                    break;
                case CMD_WRITE:
                    receive_and_write();
                    break;
                case CMD_VERIFY:
                    verify_crc();
                    break;
                case CMD_JUMP:
                    jump_to_app(APP_ADDR);
                    break;
            }
        }
    }
}

总结:三个知识点怎么串起来理解?

┌───────────┐    ┌───────────┐    ┌───────────┐
│  SPI/I2C  │    │   Flash   │    │Bootloader │
│   通信    │    │   存储    │    │   启动    │
│           │    │           │    │           │
│ 外设怎么   │    │ 数据怎么   │    │ 程序怎么   │
│ 连接通信   │    │ 持久保存   │    │ 更新升级   │
└─────┬─────┘    └─────┬─────┘    └─────┬─────┘
      │               │               │
      └───────────────┼───────────────┘
                      ▼
              一个完整的嵌入式系统:
              能通信(SPI/I2C)+ 能存储(Flash)+ 能升级(Bootloader)

面试回答模板:先说是什么 → 再说为什么需要 → 最后说你在哪里用过。有项目经验加分巨大。


📌 下期预告:嵌入式面试高频题第7弹——RTOS任务调度、中断与任务同步、内存管理

👉 关注我不迷路,持续更新嵌入式面试题 + FreeRTOS笔记

💬 评论区来聊聊:

  • SPI和I2C你更常用哪个?遇到过什么通信问题?
  • 你用过Flash存参数吗?遇到过什么坑?
  • 你做过Bootloader吗?用的什么协议?

每一条评论我都会回复,大家一起进步!

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐