嵌入式全栈面试指南:TCP/IP、C 语言基础、STM32 外设与 RT‑Thread

发布于:2025-06-09 ⋅ 阅读:(14) ⋅ 点赞:(0)

作为嵌入式工程师,面试时往往不仅要展示基础编程能力,还要兼具网络协议、硬件驱动、实时操作系统(RTOS)等方面的知识深度。本文将从TCP/IP 协议C 语言核心基础STM32 IO 与外设驱动RT‑Thread 及其多任务/IPC四大模块进行全面讲解,并在每个模块末尾附上常见面试题,助你系统备考。


一、TCP/IP 协议

1.1 TCP/IP 五层模型概述

嵌入式网络开发中,掌握 TCP/IP 的分层架构和每层的关键功能至关重要。常见的五层模型如下:

  1. 链路层(Link Layer)

    • 负责在物理链路上传输数据帧,常见技术包括以太网(Ethernet)、Wi‑Fi、PPP 等。

    • 主要功能:MAC 地址,帧封装/解封装,差错检测(FCS/Cyclic Redundancy Check)。

  2. 网络层(Internet Layer)

    • 典型协议:IPv4/IPv6、ICMP、ARP/ND。

    • 主要功能:IP 地址分配与管理、路由选路、分片与重组、差错报告与诊断。

    • IPv4 地址与子网掩码

      • 32 位二进制表示,通常用点分十进制(如 192.168.1.10)。

      • 子网掩码(如 /24)决定网络前缀和主机部分,计算网络地址与广播地址。

    • IPv6:128 位长度,支持更多设备,改进了分片及头部扩展机制。

    • ARP(地址解析协议):通过广播查询,将 IP 地址映射为 MAC 地址。

    • ICMP(Internet Control Message Protocol):用于网络诊断与错误报告(如 ping 命令、TTL 超时、目的地不可达等)。

  3. 传输层(Transport Layer)

    • TCP(Transmission Control Protocol)

      • 三次握手(Three‑way Handshake)

        1. 客户端向服务器发送 SYN 包,随机初始序列号 X;

        2. 服务器收到后,回复 SYN‑ACK,序列号 Y,确认号 X+1;

        3. 客户端收到后,发送 ACK,确认号 Y+1,连接建立。

      • 可靠传输:序列号、确认应答(ACK)、重传超时(RTO)、窗口滑动、拥塞控制(慢启动、拥塞避免、快速重传、快速恢复)。

      • 四次挥手(Four‑way Teardown)

        1. 主动关闭方发送 FIN;

        2. 对端回复 ACK;

        3. 对端再发送 FIN;

        4. 主动方回复 ACK,完成连接断开。

      • 流量控制 vs 拥塞控制

        • 流量控制(Window Size):由接收方告知发送方的缓冲区剩余大小,避免接收方处理不过来。

        • 拥塞控制:根据网络拥塞程度动态调整发送速率,常见算法包括慢启动(cwnd 从 1 MSS 开始倍增)、拥塞避免(cwnd 加性增长)、快速重传与快速恢复。

    • UDP(User Datagram Protocol)

      • 无连接、无状态,开销小,仅包含源/目的端口、长度、校验和。

      • 常用于对实时性要求高而对丢包不敏感的应用,如语音/视频流、DNS 查询、DHCP。

  4. 会话层与表示层(往往合并到应用层讨论)

    • 负责会话建立、保持与拆除,以及数据的表示转换、加密/解密、压缩/解压缩等。

    • 在大多数 TCP/IP 资料中,会话层与表示层功能被归并到应用层。

  5. 应用层(Application Layer)

    • 各种常见协议:HTTP/HTTPS、FTP、SMTP/POP3/IMAP、DNS、Telnet、SSH、MQTT 等。

    • 负责为最终用户或应用程序提供网络服务,例如 Web 浏览、邮件收发、文件传输、远程终端等。


面试常见问题
  1. TCP 三次握手过程是什么?如果只进行两次握手会发生什么?

    • 回答要点:

      • 三次握手确保双方同步初始序列号,并确认双方均处于可接收状态。

      • 如果少一次,双方无法确认彼此处于就绪状态,可能导致数据丢失或误判连接已建立。

  2. TCP 四次挥手与三次挥手有什么区别?为什么需要四次?

    • 回答要点:

      • 四次挥手中,主动关闭方首次发送 FIN 后,进入 FIN_WAIT_1;对方回复 ACK,进入 CLOSE_WAIT。

      • 当对方数据发送完毕后,才会发送自己的 FIN;这样才能保证双向数据传输的完整。

      • 如果只挥手三次,会导致一端未正确释放资源或丢失最后的数据。

  3. TCP 流量控制与拥塞控制的区别是什么?

    • 回答要点:

      • 流量控制(Receiver Window)是由接收端根据自身缓冲能力告知发送端限制发送速率,避免接收端缓冲区溢出。

      • 拥塞控制则由发送方根据网络反馈(丢包、超时等)动态调整窗口大小(cwnd),以避免网络拥塞。

  4. 如何计算给定 IP 与子网掩码下的网络地址与广播地址?

    • 给定 IP:192.168.10.37,子网掩码 /26(255.255.255.192)。

      • 二进制:IP 11000000.10101000.00001010.00100101

      • 子网掩码 11111111.11111111.11111111.11000000

      • 网络地址 11000000.10101000.00001010.00000000 = 192.168.10.0

      • 广播地址 11000000.10101000.00001010.00111111 = 192.168.10.63

  5. UDP 为什么适合 DNS、视频流等应用?

    • 回答要点:

      • UDP 无连接、无握手,无需维护状态,发送延迟低、头部开销小(8 字节)。

      • 对于实时性要求高且容忍少量丢包的应用场景,UDP 更高效。

  6. ARP 与 ICMP 的作用是什么?

    • ARP:在局域网内把 IP 地址映射为 MAC 地址,发送 ARP 请求广播,目标主机回复 MAC。

    • ICMP:提供网络诊断与差错报告(如目标不可达、TTL 超时等),是 IP 协议的补充。


二、C 语言基础

面向嵌入式开发,C 语言是核心。掌握好数据结构与指针,才能编写高效、可维护的驱动与算法。以下模块均针对零基础读者讲解,并附常见面试问题。


2.1 结构体(struct

2.1.1 概念与定义
  • 定义:结构体是一种用户自定义的数据类型,可以将多个不同类型的成员组合成一个整体,类似现实中“一个学生”包含姓名、年龄、成绩等属性。

  • 语法

    struct Student {
        char name[32];
        int age;
        float score;
    };
    
    • struct Student 定义了一个名为 Student 的类型,该类型中包含三个成员:name(字符数组)、age(整型)、score(浮点型)。

2.1.2 声明与使用
  • 声明结构体变量

    struct Student s1;               // 在栈上分配一个 Student 实例
    struct Student *p = &s1;         // 定义一个指向结构体的指针
    
  • 初始化结构体

    struct Student s2 = { "Alice", 20, 95.5f };
    strcpy(s1.name, "Bob");
    s1.age = 22;
    s1.score = 88.0f;
    
  • 访问成员

    printf("Name: %s, Age: %d, Score: %.2f\n", s2.name, s2.age, s2.score);
    printf("Name via pointer: %s\n", p->name);
    
    • 结构体成员访问符:对于变量 s,使用 s.member;对于指针 p,使用 p->member,相当于 (*p).member

2.1.3 内存对齐与 sizeof
  • 内存对齐(Padding):为了让 CPU 更高效地访问数据,结构体成员会按照其“自然对齐”方式存放,如果前一个成员大小不能满足下一个成员的对齐需求,编译器会在成员之间插入“填充字节”。

    • 例如:

      struct A {
          char c;     // 占 1 字节
          int x;      // 占 4 字节,需要 4 字节对齐
      };
      
      • 在大多数 32 位平台上,sizeof(struct A) = 8,而不是 5。因为 c 占 1 字节后,编译器会在 c 后插入 3 个填充字节,使 x 从地址偏移 4 开始存储,然后 x 占 4 字节,总共 8 字节。

  • 字段排列优化:如果将 int 放在前面再放 char,可以减少填充。例如:

    struct B {
        int x;
        char c;
    };
    
    • sizeof(struct B) = 8(一般仍然是 8,因为结构体整体会被对齐到最大成员边界)。但如果在后面再加一个 char d,就能观察到差别:

      struct C {
          int x;    // 4 字节
          char c;   // 1 字节
          char d;   // 1 字节
          // 之后会插入 2 字节填充,使整体长度为 8 字节
      };
      
  • 如何查询

    printf("sizeof(struct A) = %zu\n", sizeof(struct A)); // 8
    
2.1.4 面试常问
  1. 结构体 struct A { char c; int x; };struct B { int x; char c; }; 谁更省空间,为什么?

    • 需要解释内存对齐的概念,展示 sizeof 的不同。

  2. 如何将结构体作为函数参数传递?按值 vs 按引用?优缺点?

    • 传值会生成新的拷贝,适合结构体较小、无需修改原始对象;传引用(传指针)效率更高,但需要注意指针是否为空以及避免悬空指针。

  3. 什么是匿名结构体?什么时候使用?

    • 匿名结构体指在定义时不命名结构体类型,直接在变量声明时指定字段布局。主要用于临时定义、或作为联合体/其他复杂数据结构的成员。


2.2 联合体(union

2.2.1 概念与定义
  • 定义:联合体与结构体类似,但其所有成员共享同一段内存空间,也就是说只能同时存储其中一个成员的值。

  • 语法

    union Data {
        int i;
        float f;
        char str[20];
    };
    
    • union Data 的大小等于其最大成员的大小(必要时再加上填充字节,保证对齐)。

2.2.2 使用示例
#include <stdio.h>
#include <string.h>

union Data {
    int i;
    float f;
    char str[20];
};

int main(void) {
    union Data d;
    d.i = 42;
    printf("d.i = %d\n", d.i);

    // 将 f 覆盖 i 所在的内存
    d.f = 3.14f;
    printf("d.f = %.2f, d.i (被覆盖) = %d\n", d.f, d.i);

    // 将 str 写入,覆盖整个内存
    strcpy(d.str, "Hello");
    printf("d.str = %s, d.f = %.2f, d.i = %d\n", d.str, d.f, d.i);
    return 0;
}
  • 由于 ifstr 共享相同内存,最后写入 str 之后,d.fd.i 的值不再可靠。

2.2.3 应用场景
  • 节省内存:当同一时刻只需要存储其中一个成员时,使用联合体能减少内存占用。

  • 协议解析:在处理网络或串口协议时,报文格式可按联合体定义,通过赋值不同字段进行解析。

  • 变长参数/类型转化:可在同一数据区以不同方式访问,比如将 32 位浮点数与 32 位整数通过联合体共享,再做位操作或直接查看二进制表示。

2.2.4 サイズ计算
  • union Data 为例,假设 sizeof(int)=4sizeof(float)=4sizeof(char[20])=20,那么 sizeof(union Data) = 20(最大成员)+ 填充到符合最大成员对齐(如果有必要)。

2.2.5 面试常问
  1. 结构体与联合体有什么本质区别?

    • 结构体成员每个都有自己内存空间,联合体成员共享同一区域。

  2. 联合体的大小如何计算?

    • 等于最大成员的尺寸(必要时加填充以满足对齐)。

  3. 举例说明联合体在协议解析中的应用。

    • 例如:某 32 位寄存器,联合体可定义成 union { uint32_t all; struct { uint8_t b0, b1, b2, b3; } bytes; } u;

    • 通过 u.all 访问整寄存器,通过 u.bytes.b0~b3 分别访问每 8 位。


2.3 枚举(enum

2.3.1 概念与定义
  • 定义:枚举是一组具名的整型常量集合,使代码更具可读性,避免大量魔法数字。

  • 语法

    enum Weekday {
        MON = 1,
        TUE,    // 2
        WED,    // 3
        THU,    // 4
        FRI,    // 5
        SAT,    // 6
        SUN     // 7
    };
    
2.3.2 使用示例
#include <stdio.h>

enum Weekday { MON = 1, TUE, WED, THU, FRI, SAT, SUN };

int main(void) {
    enum Weekday today = WED;
    if (today == WED) {
        printf("It's Wednesday!\n");
    }
    printf("Numeric value of SUN = %d\n", SUN); // 输出 7
    return 0;
}
  • 如果不显式赋值,默认从 0 开始递增。例如:

    enum Color { RED, GREEN, BLUE };
    // RED=0, GREEN=1, BLUE=2
    
2.3.3 底层类型与限制
  • 在 C 语言中,枚举常量本质是整型 (int)。部分编译器支持将枚举设置为其他底层类型(如 unsigned int),但标准 C 规范要求枚举占至少 int 大小。

  • 枚举值如果超出 int 范围,会引发编译警告或错误。

2.3.4 面试常问
  1. 枚举底层是什么数据类型?可以指定其他底层类型吗?

    • 标准 C 下枚举等价于 int(大小取决于编译器与 ABI,但必须至少能表示所有枚举成员的值)。

  2. 枚举与 #define 定义常量有什么区别?

    • 枚举是类型安全的,具有作用域,调试时可显示符号名称;#define 只是预处理替换,无类型检查。

  3. 如何将枚举值转为字符串?

    • 常见做法:定义一个对应字符串数组,例如:

      const char* WeekdayStr[] = { "Invalid", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" };
      // 根据枚举值索引访问
      printf("%s\n", WeekdayStr[today]);
      
    • 或者使用 switch 语句映射。


2.4 单向链表(Linked List)

2.4.1 概念
  • 定义:链表是一种常用的动态数据结构,由若干节点组成。每个节点包含一个数据域(data)和一个指向下一个节点的指针域(next)。

  • 特点

    • 长度可动态增减,不需连续内存;

    • 插入、删除操作时间复杂度为 O(1) (若已知插入位置的前驱);

    • 查找和随机访问时间复杂度为 O(n)。

2.4.2 数据结构定义
struct Node {
    int data;
    struct Node* next;
};
  • data:存储数据,可以是任何类型,例如整型、结构体、联合体等。

  • next:指向下一个节点;若为 NULL,表示链表结束。

2.4.3 基本操作
2.4.3.1 创建新节点
#include <stdlib.h>

struct Node* createNode(int val) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    if (newNode == NULL) {
        // 分配失败
        return NULL;
    }
    newNode->data = val;
    newNode->next = NULL;
    return newNode;
}
2.4.3.2 在头部插入节点
struct Node* insertAtHead(struct Node* head, int val) {
    struct Node* newNode = createNode(val);
    if (newNode == NULL) return head; // 分配失败,返回原头
    newNode->next = head;
    return newNode;  // 新节点成为新的头节点
}
2.4.3.3 在尾部插入节点
struct Node* insertAtTail(struct Node* head, int val) {
    struct Node* newNode = createNode(val);
    if (newNode == NULL) return head;
    if (head == NULL) {
        // 链表为空,新节点为头
        return newNode;
    }
    struct Node* p = head;
    while (p->next != NULL) {
        p = p->next;
    }
    p->next = newNode;
    return head;
}
2.4.3.4 删除给定值节点
struct Node* deleteNode(struct Node* head, int val) {
    if (head == NULL) return NULL;
    // 如果头节点需要删除
    if (head->data == val) {
        struct Node* temp = head->next;
        free(head);
        return temp;
    }
    struct Node* prev = head;
    struct Node* curr = head->next;
    while (curr != NULL) {
        if (curr->data == val) {
            prev->next = curr->next;
            free(curr);
            return head;
        }
        prev = curr;
        curr = curr->next;
    }
    // 未找到值为 val 的节点
    return head;
}
2.4.3.5 打印链表
#include <stdio.h>

void printList(struct Node* head) {
    struct Node* p = head;
    while (p != NULL) {
        printf("%d -> ", p->data);
        p = p->next;
    }
    printf("NULL\n");
}
2.4.4 链表反转
  • 迭代法

    struct Node* reverseList(struct Node* head) {
        struct Node* prev = NULL;
        struct Node* curr = head;
        struct Node* next;
        while (curr != NULL) {
            next = curr->next;   // 保存下一个节点
            curr->next = prev;   // 反转指针
            prev = curr;         // prev 前移
            curr = next;         // curr 前移
        }
        return prev;            // prev 指向新头节点
    }
    
  • 递归法

    struct Node* reverseListRecursive(struct Node* head) {
        if (head == NULL || head->next == NULL) {
            return head;        // 空链表或只剩一个节点
        }
        struct Node* newHead = reverseListRecursive(head->next);
        head->next->next = head; // 当前节点反转指向
        head->next = NULL;       // 原来指向下一个的链接清空
        return newHead;
    }
    
2.4.5 检测环与找环入口
  • 快慢指针法(Floyd 判圈算法)

    bool hasCycle(struct Node* head) {
        struct Node* slow = head;
        struct Node* fast = head;
        while (fast != NULL && fast->next != NULL) {
            slow = slow->next;
            fast = fast->next->next;
            if (slow == fast) {
                return true;    // 相遇,说明有环
            }
        }
        return false;           // fast 到达 NULL,说明无环
    }
    
    // 找环入口
    struct Node* detectCycle(struct Node* head) {
        struct Node* slow = head;
        struct Node* fast = head;
        bool found = false;
        while (fast != NULL && fast->next != NULL) {
            slow = slow->next;
            fast = fast->next->next;
            if (slow == fast) {
                found = true;
                break;
            }
        }
        if (!found) return NULL;  // 无环
        slow = head;
        while (slow != fast) {
            slow = slow->next;
            fast = fast->next;
        }
        return slow;  // 环的入口节点
    }
    
2.4.6 面试常问
  1. 如何在单链表中插入/删除节点?写出伪代码。

    • 要点:

      • 插入:维护前驱节点和后继节点;头部插入或尾部插入时要特殊处理。

      • 删除:找到待删节点的前驱,修改指针并释放内存。

  2. 如何反转一个单链表?请分别使用迭代和递归法。

    • 迭代法:三个指针 prevcurrnext,不断反转指针。

    • 递归法:先反转剩余链表,再将当前节点接到尾部。

  3. 如何检测链表是否有环?如何找环的入口?

    • 快慢指针法。相遇后,将慢指针移回头节点,两指针每次一步相遇即环入口。

  4. 链表和数组的区别是什么?优缺点分别是什么?

    • 链表:动态大小,不连续,插入/删除快;随机访问慢。

    • 数组:连续内存,随机访问快;大小固定,插入/删除慢。


2.5 环形队列(Circular Queue)

2.5.1 概念
  • 定义:用固定长度的数组实现队列结构,通过“首尾相连”的方式让插入/删除在循环下标中进行,从而充分利用数组空间。

  • 应用场景:串口接收缓冲、实时数据流缓存(Audio/Video)、生产者-消费者环形缓冲区等。

2.5.2 数据结构定义与变量
#define MAX_SIZE  5

int queue[MAX_SIZE];   // 存储队列元素
int front = 0;         // 指向队头元素的位置
int rear  = 0;         // 指向下一个可插入的位置
  • front == rear 时,队列为空;当 (rear + 1) % MAX_SIZE == front 时,队列为满。

  • 注意:为了区分空与满,需要让队列实际最多只能存放 MAX_SIZE - 1 个元素。

2.5.3 常见操作
2.5.3.1 判断队列是否为空
int isEmpty(void) {
    return front == rear;
}
2.5.3.2 判断队列是否为满
int isFull(void) {
    return (rear + 1) % MAX_SIZE == front;
}
2.5.3.3 入队(enqueue)
int enqueue(int val) {
    if (isFull()) {
        // 队满
        return -1;
    }
    queue[rear] = val;
    rear = (rear + 1) % MAX_SIZE;
    return 0;
}
2.5.3.4 出队(dequeue)
int dequeue(int* val) {
    if (isEmpty()) {
        // 队空
        return -1;
    }
    *val = queue[front];
    front = (front + 1) % MAX_SIZE;
    return 0;
}
2.5.3.5 查看队头元素
int peek(int* val) {
    if (isEmpty()) {
        return -1;
    }
    *val = queue[front];
    return 0;
}
2.5.4 示例演示
#include <stdio.h>

int main(void) {
    int data;
    enqueue(10);
    enqueue(20);
    enqueue(30);
    int ret = dequeue(&data);
    if (ret == 0) {
        printf("Dequeued: %d\n", data); // 10
    }
    enqueue(40);
    enqueue(50);
    // 此时队列满,再enqueue将失败
    if (enqueue(60) == -1) {
        printf("Queue is full, cannot enqueue 60\n");
    }
    // 继续出队
    while (!isEmpty()) {
        dequeue(&data);
        printf("%d ", data);
    }
    printf("\n");
    return 0;
}
2.5.5 面试常问
  1. 环形队列为什么要浪费一个空间来区分满/空?

    • 由于 front == rear 同时代表队空和队满,为避免混淆,需要让队满时保持 (rear + 1) % MAX_SIZE == front

  2. 如何修改设计让环形队列不浪费空间?

    • 可增加一个 size 变量存储当前元素个数,或者使用布尔标志位指示上次执行的是入队还是出队操作。

  3. 如果想让环形队列动态扩容,该如何实现?

    • 当队满时,分配更大数组(例如原大小的两倍),并将旧数组元素依次复制到新数组的线性区域,然后重置 front = 0rear = oldCapacity


2.6 指针进阶

指针是 C 语言中最具力量但也最容易出错的概念,嵌入式开发更需精通。以下四种指针相关概念是面试常见热点。

2.6.1 基础指针概念
  • 定义:指针是一个变量,用于存储另一个变量的地址

    int x = 10;
    int* p = &x;  // p 保存 x 的地址
    printf("x = %d, *p = %d\n", x, *p); // 解引用 *p 得到 x 的值
    
  • 解引用(Dereference):通过 *p 访问指针指向地址上的值。

2.6.2 函数指针(Pointer to Function)
  • 定义:函数指针存储函数的入口地址,可通过该指针调用函数。

  • 语法

    // 定义一个返回 int,带两个 int 参数的函数指针类型
    int (*func)(int, int);
    
    // 普通函数
    int add(int a, int b) {
        return a + b;
    }
    
    int main(void) {
        func = add;           // 将函数地址赋给指针
        int result = func(3, 4);  // 调用 add(3,4)
        printf("3 + 4 = %d\n", result);
        return 0;
    }
    
  • 应用场景

    • 回调函数:在事件驱动或中断服务程序中,将特定函数地址注册到框架中,在满足条件时调用。

    • 状态机:用函数指针数组表示不同状态的处理函数,简化分支语句。

    • 插件式设计:不同算法实现满足同一接口,可动态指向不同函数。

2.6.3 指针函数(Function Returning Pointer)
  • 定义:函数返回一个指针类型的值。

  • 示例

    int* getMax(int* a, int* b) {
        return (*a > *b) ? a : b;
    }
    
    int main(void) {
        int x = 10, y = 20;
        int* pMax = getMax(&x, &y);  // 返回指向更大值的指针
        printf("Max value = %d\n", *pMax);
        return 0;
    }
    
  • 注意事项

    • 不能返回指向局部变量的指针,因为函数退出后其栈空间被释放,悬空指针(dangling pointer)。

    • 可以返回指向全局变量或动态分配内存的指针。

2.6.4 指针数组(Array of Pointers) vs 数组指针(Pointer to Array)
  • 指针数组:数组中的元素均为指针类型。

    // 定义一个指针数组,存放三个字符串指针
    char* names[3] = { "Alice", "Bob", "Charlie" };
    
    for (int i = 0; i < 3; i++) {
        printf("%s\n", names[i]);
    }
    
    • names 本身是一个数组,数组中每个元素类型为 char*

  • 数组指针:一个指针,指向一个固定长度的数组。

    int arr[5] = { 1, 2, 3, 4, 5 };
    // 定义一个指向含 5 个 int 的数组的指针
    int (*p)[5] = &arr;
    
    // 访问 arr 中的元素
    printf("%d\n", (*p)[2]);  // 输出 3
    
    • p 的类型是 “指向长度为 5 的 int 数组的指针”。

  • 区别

    1. int *a[5]a 是一个长度为 5 的数组,数组元素类型为 int*。可以容纳 5 个整型指针。

    2. int (*a)[5]a 是一个 “指向包含 5 个 int 的数组” 的指针。指针 a 本身只占 4 或 8 字节,指向一个整型数组。

2.6.5 指针与二维数组的关系
  • 如果有 int matrix[3][4];

    • matrix 本身可转换成 int (*)[4](指向长度为 4 的整型数组)。

    • matrix[i] 本身类型是 int[4],可转换成 int* 指向第 i 行首元素。

2.6.6 面试常问
  1. 解释 int *a[5]int (*a)[5] 的区别,并给出示例。

    • 要点:分别写声明,说明 a 是指针数组或数组指针。

  2. 为什么不能返回指向局部变量的指针?请举例说明。

    • 示例:

      int* foo(void) {
          int x = 10;
          return &x;  // 悬空指针
      }
      
    • x 在函数结束后被释放,指针失效。

  3. 写出函数指针声明,并用它实现一个简单的加减乘除计算器。

    #include <stdio.h>
    
    typedef int (*OpFunc)(int, int);
    
    int add(int a, int b) { return a + b; }
    int sub(int a, int b) { return a - b; }
    int mul(int a, int b) { return a * b; }
    int divide(int a, int b) { return (b != 0) ? a / b : 0; }
    
    int main(void) {
        OpFunc ops[4] = { add, sub, mul, divide };
        char op;
        int x, y;
        printf("Enter expression (e.g., 3 + 4): ");
        scanf("%d %c %d", &x, &op, &y);
        int index;
        switch (op) {
            case '+': index = 0; break;
            case '-': index = 1; break;
            case '*': index = 2; break;
            case '/': index = 3; break;
            default:
                printf("Invalid operator\n");
                return -1;
        }
        int result = ops[index](x, y);
        printf("%d %c %d = %d\n", x, op, y, result);
        return 0;
    }
    

三、STM32 硬件基础

STM32 系列单片机功能丰富,常见外设包括 GPIO、UART、I2C、SPI、CAN 等。理解各外设的寄存器配置与时序,是驱动开发的基础。


3.1 STM32 GPIO(通用输入/输出)

GPIO(General Purpose Input/Output)是芯片上最基础的外围接口,用于连接按键、LED、传感器、总线等。

3.1.1 模式 (Mode)
  • 输入模式 (Input)

    • 浮空输入(Floating Input)

    • 上拉输入(Pull‑Up Input):内部带上拉电阻

    • 下拉输入(Pull‑Down Input):内部带下拉电阻

    • 模拟输入(Analog):用于 ADC、DAC

  • 输出模式 (Output)

    • 推挽输出(Push‑Pull Output)

    • 开漏输出(Open‑Drain Output):需要外部上拉电阻

  • 复用功能 (Alternate Function)

    • 用于映射给外设(如 UART、SPI、I2C、CAN、TIM/PWM 等)

  • 速率 (Speed)

    • 2 MHz、10 MHz、50 MHz(不同系列略有差异),决定输出切换速度和功耗

3.1.2 寄存器(以 STM32F1 系列为例)
  • RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOx, ENABLE):使能 GPIOx 时钟

  • GPIOx_CRL / GPIOx_CRH

    • 每个引脚占用 4 位配置:MODE[1:0] + CNF[1:0]

    • MODE:决定输出速率或输入模式

    • CNF:决定输入/输出类型(推挽/开漏/浮空/上拉下拉/复用)

  • GPIOx_IDR:输入数据寄存器,读取引脚状态

  • GPIOx_ODR:输出数据寄存器,写入高/低电平

  • GPIOx_BSRR / GPIOx_BRR:位设置/复位寄存器,用于原子置位/复位输出引脚

3.1.3 配置示例

以 STM32F103 为例,将 PA0 配置为上拉输入,将 PA1 配置为推挽输出:

#include "stm32f10x.h"

void GPIO_Config(void) {
    // 1. 使能 GPIOA 时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    GPIO_InitTypeDef GPIO_InitStructure;

    // 配置 PA0 为上拉输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;     // 上拉输入
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;  // 对输入无效,但必须写一个值
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 配置 PA1 为复用推挽(假设作为 UART TX)
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;  // 推挽输出
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
}

int main(void) {
    GPIO_Config();
    while (1) {
        // 反转 PA1 输出
        GPIOA->BSRR = GPIO_Pin_1;       // 置位
        for (volatile int i = 0; i < 1000000; i++);
        GPIOA->BRR = GPIO_Pin_1;        // 复位
        for (volatile int i = 0; i < 1000000; i++);
    }
}
3.1.4 面试常问
  1. 如何配置 GPIO 为上拉输入、下拉输入、推挽输出、开漏输出?

    • 需要解释 CRL/CRHMODECNF 的具体取值,如:

      • 浮空输入:MODE = 00, CNF = 01

      • 上拉/下拉输入:MODE = 00, CNF = 10 ,并通过 ODR 写 1 使能上拉或写 0 使能下拉

      • 推挽输出:MODE = 10(2 MHz)或 11(50 MHz),CNF = 00

      • 开漏输出:MODE = 1011CNF = 01

  2. 开漏输出与推挽输出的区别是什么?在哪些场景下使用开漏?

    • 推挽输出:高电平时内部拉到 VDD,低电平时拉到 GND;标准 GPIO 输出。

    • 开漏输出:低电平时拉到 GND,高电平时浮空,由外部上拉电阻(或上拉电路)拉到高电平。

    • 场景:I2C 总线、多个总线设备共用一条线时需要开漏与上拉,或者需要对外部设备进行 3.3V/5V 兼容时。

  3. 如何实现 GPIO 翻转 (toggle)?

    • 可读 ODR 寄存器后写反,或使用 BSRR/BRR:

      if (GPIOA->ODR & GPIO_Pin_1) {
          GPIOA->BRR = GPIO_Pin_1;   // 复位
      } else {
          GPIOA->BSRR = GPIO_Pin_1;  // 置位
      }
      
    • STM32F1 也提供 GPIOA->ODR ^= GPIO_Pin_1; 混编时要注意读后写。


3.2 UART(通用异步收发传输器)

UART(Universal Asynchronous Receiver/Transmitter)是嵌入式常用的串口通信接口,既可作为调试打印,也可用于与传感器、模块或其他 MCU 通信。

3.2.1 UART 基本概念
  • 帧格式

    • 起始位 (Start Bit):1 位低电平,标志帧开始。

    • 数据位 (Data Bits):通常 8 位,也可配置为 7、9 位。

    • 奇偶校验位 (Parity Bit):可选,偶校验/奇校验/无校验。

    • 停止位 (Stop Bit):1 位或 2 位高电平,标志帧结束。

  • 波特率 (Baud Rate):单位“比特/秒”,例如 9600、115200。

  • 常见寄存器(以 STM32F1 为例):

    • USARTx_SR:状态寄存器,包含 TXE(发送数据寄存器空)、TC(传输完成)、RXNE(接收寄存器非空)等标志位。

    • USARTx_DR:数据寄存器,写入发送数据,读取接收数据。

    • USARTx_BRR:波特率寄存器,用于配置 USART 时钟与分频值(割分 16)。

    • USARTx_CR1/CR2/CR3:控制寄存器,例如 UE(使能 UART)、TE (发送使能)、RE (接收使能)、PCE (奇偶校验使能)、PS (校验选择) 等。

3.2.2 UART 初始化示例(HAL 库)
#include "stm32f1xx_hal.h"

UART_HandleTypeDef huart1;

void UART1_Init(void) {
    // 1. 使能 GPIOA、USART1 时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_USART1_CLK_ENABLE();

    // 2. 配置 PA9(TX), PA10(RX)
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_9;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;       // 复用推挽
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    GPIO_InitStruct.Pin = GPIO_PIN_10;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;       // 浮空输入
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 配置 UART 参数
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    if (HAL_UART_Init(&huart1) != HAL_OK) {
        // 初始化失败
        while (1);
    }
}

int main(void) {
    HAL_Init();
    UART1_Init();
    uint8_t msg[] = "Hello, STM32 UART!\r\n";
    while (1) {
        HAL_UART_Transmit(&huart1, msg, sizeof(msg) - 1, HAL_MAX_DELAY);
        HAL_Delay(1000);
    }
}
3.2.3 数据收发方式
  1. 阻塞方式 (Blocking)

    • 发送时,函数 HAL_UART_Transmit 阻塞,直到 TXE 标志置位,写入 DR,再等待 TC(Transmission Complete)或者仅等待 TXE 即可继续。

    • 接收时,函数 HAL_UART_Receive 阻塞,直到 RXNE 标志置位,从 DR 读取数据。

  2. 中断方式 (Interrupt)

    • 打开接收中断 __HAL_UART_ENABLE_IT(&huart1, UART_IT_RXNE),在 USART1_IRQHandler 中判断 __HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE),读取 DR 并存入缓冲。

    • 发送可使用中断方式:在缓冲区中准备数据后调用 HAL_UART_Transmit_IT,TXE 中断 ISR 中依次写入数据直到发送完毕。

  3. DMA 方式

    • 将 UART 与 DMA 通道绑定,硬件自动搬运数据到/从 DR,减少 CPU 占用,适合大块数据传输。

    • 需要初始化 DMA 通道的源地址(内存)和目标地址(UART DR),以及传输长度。

3.2.4 波特率计算
  • 波特率公式(以 STM32F1 系列为例):

    USARTDIV = Fclk / (16 × BaudRate)
    BRR = Mantissa(USARTDIV) << 4 | Fraction(USARTDIV × 16 % 16)
    
    • 例如,假设 PCLK2 = 72 MHz,BaudRate = 115200:

      USARTDIV = 72,000,000 / (16 × 115,200) ≈ 39.0625
      Mantissa = 39
      Fraction = 0.0625 × 16 = 1
      BRR = 39 << 4 | 1 = 0x271
      
3.2.5 面试常问
  1. UART 波特率如何计算?TXE、TC、RXNE 标志位代表什么意思?

    • 需要说出 USARTDIV 计算公式,并解释 TXE = 发送数据寄存器空,表示可以写新的数据;TC = 传输完成,表示数据已全部发送;RXNE = 接收寄存器非空,表示有数据可读。

  2. 如何使用中断方式接收字符?

    • 打开 UART_IT_RXNE 中断,编写 ISR:

      void USART1_IRQHandler(void) {
          if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE)) {
              uint8_t byte = (uint8_t)huart1.Instance->DR;  // 读取数据
              // 处理接收 data
          }
      }
      
    • 配置优先级及使能中断。

  3. 阻塞方式与非阻塞方式(中断、DMA)的优缺点是什么?

    • 阻塞简单,但占用 CPU;中断方式响应及时,但上下文切换开销;DMA 方式对 CPU 占用最小,但硬件配置复杂,且有局限(DMA 通道数量有限)。


3.3 I2C(Inter‑Integrated Circuit)

I2C 是一个广泛用于短距离、低速的串行通信总线,常用于连接传感器、EEPROM、RTC、LCD 等外设。

3.3.1 I2C 基本概念
  • 总线结构:双线制,SCL(时钟)和 SDA(数据),使用开漏输出 + 上拉电阻。

  • 通信方式:主从架构,一条总线上可以挂多个从设备,从设备通过地址进行区分。

  • 时钟频率:常见 100 kHz(标准模式),400 kHz(快速模式),1 MHz(高速模式,需要特别配置)。

3.3.2 I2C 时序与协议
  1. 启动条件(START)

    • 在 SCL 高电平时,SDA 从高拉低,产生一个 START 信号,表示传输开始。

  2. 发送从机地址 + 读写位

    • 主机发送 7 位或 10 位地址,再加上第 8 位表示读/写(0=写,1=读)。

    • 从机检测到地址匹配后,在第 9 个时钟周期拉低 SDA 表示 ACK,应答信号。

  3. 数据传输

    • 对于写操作:主机将数据字节发送到 SDA,总共 8 个时钟沿,数据在时钟上升/下降沿有效(取决于 CPHA 模式)。

    • 对于读操作:从机将数据放到 SDA,总共 8 位后,主机在第 9 个时钟沿上拉低 SDA 表示 ACK。

  4. 停止条件(STOP)

    • 在 SCL 高电平时,SDA 从低拉高,产生 STOP 信号,表示传输结束。

  5. NACK

    • 如果接收方(主机或从机)在第 9 个时钟周期拉开 SDA (高电平),则为 NACK,表示拒绝或结束传输。

  6. 仲裁与冲突

    • 如果总线上有多个主机,若在同一时钟周期一个主机输出 1,另一个输出 0,则输出为 0(开漏结构)。发送为 1 的主机会检测到冲突并停止发送,地址更小者(高优先级)继续传输。

3.3.3 STM32 I2C 外设寄存器(以 STM32F1 系列为例)
  • RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE)RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE):使能 GPIOB、I2C1 时钟

  • GPIO 配置:SCL、SDA 必须配置为 AF 开漏,外部或内部上拉。

  • I2C_CR1 / I2C_CR2:控制寄存器,PE(外设使能)、START(生成 START 条件)、STOP(生成 STOP 条件)、ACK(应答使能)等。

  • I2C_OAR1 / I2C_OAR2:自身地址寄存器,仅从机模式下使用。

  • I2C_DR:数据寄存器,读取或写入一个字节。

  • I2C_SR1 / I2C_SR2:状态寄存器,包含 SB(启动成功)、ADDR(地址发送/匹配完成)、BTF(字节传输完成)、TXE(数据寄存器空)、RXNE(接收寄存器非空)、AF(应答失败)、BERR(总线错误)、ARLO(仲裁丢失)等标志。

  • I2C_CCRI2C_TRISE:配置 SCL 时钟周期、上升时间等,决定 I2C 波特率。

3.3.4 初始化与读写流程
3.3.4.1 初始化(标准模式,100 kHz)
#include "stm32f10x.h"

void I2C1_Init(void) {
    // 1. 使能 GPIOB 时钟和 I2C1 时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);

    // 2. 配置 PB6 (SCL),PB7 (SDA) 为 AF 开漏
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStruct);

    // 3. 复位 I2C1
    I2C_DeInit(I2C1);

    // 4. 配置 I2C1 参数
    I2C_InitTypeDef I2C_InitStruct;
    I2C_InitStruct.I2C_ClockSpeed = 100000;              // 100 kHz
    I2C_InitStruct.I2C_Mode = I2C_Mode_I2C;
    I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;       // 标准模式
    I2C_InitStruct.I2C_OwnAddress1 = 0x00;                // 主机模式,地址可不关心
    I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;              // 使能应答
    I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
    I2C_Init(I2C1, &I2C_InitStruct);

    // 5. 使能 I2C1
    I2C_Cmd(I2C1, ENABLE);
}
3.3.4.2 发送单字节函数
int I2C1_WriteByte(uint8_t slaveAddr, uint8_t regAddr, uint8_t data) {
    // 1. 发送 START
    I2C_GenerateSTART(I2C1, ENABLE);
    // 等待 SB 标志
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_SB));
    // 2. 发送从机地址 + 写位
    I2C_Send7bitAddress(I2C1, slaveAddr << 1, I2C_Direction_Transmitter);
    // 等待 ADDR 标志
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_ADDR));
    // 清除 ADDR 标志(读 SR1、SR2)
    (void)I2C1->SR2;

    // 3. 发送寄存器地址
    I2C_SendData(I2C1, regAddr);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_TXE));

    // 4. 发送数据
    I2C_SendData(I2C1, data);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_TXE));

    // 5. 发送 STOP
    I2C_GenerateSTOP(I2C1, ENABLE);
    return 0;
}
3.3.4.3 读取单字节函数
int I2C1_ReadByte(uint8_t slaveAddr, uint8_t regAddr, uint8_t* data) {
    // 1. 发送 START
    I2C_GenerateSTART(I2C1, ENABLE);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_SB));

    // 2. 发送从机地址 + 写位
    I2C_Send7bitAddress(I2C1, slaveAddr << 1, I2C_Direction_Transmitter);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_ADDR));
    (void)I2C1->SR2;

    // 3. 发送寄存器地址
    I2C_SendData(I2C1, regAddr);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_TXE));

    // 4. 发送 Re‑START
    I2C_GenerateSTART(I2C1, ENABLE);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_SB));

    // 5. 发送从机地址 + 读位
    I2C_Send7bitAddress(I2C1, slaveAddr << 1, I2C_Direction_Receiver);
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_ADDR));
    // 配置 NACK,以便最后一个字节读取后发送 NACK
    I2C_AcknowledgeConfig(I2C1, DISABLE);
    (void)I2C1->SR2;

    // 6. 等待 RXNE 标志
    while (!I2C_GetFlagStatus(I2C1, I2C_FLAG_RXNE));
    // 7. 读取数据
    *data = I2C_ReceiveData(I2C1);

    // 8. 发送 STOP
    I2C_GenerateSTOP(I2C1, ENABLE);
    // 恢复 ACK 设置
    I2C_AcknowledgeConfig(I2C1, ENABLE);
    return 0;
}
3.3.5 面试常问
  1. 为什么 I2C 总线要使用开漏 (Open‑Drain) 模式?

    • 因为多个设备同时挂载在同一对线 SCL/SDA 上,需要共享总线,高电平由外部上拉电阻提供,开漏模式可避免总线冲突。

  2. 如何处理 I2C 总线仲裁(Arbitration)?

    • 当多个主机同时发起通信,若某个主机在发送某个时钟周期想输出 1,而看到总线实际为 0(开漏结构),则说明有更高优先级(数字 0 表示优先)主机在发送,仲裁失败的主机需立即停止发送。

  3. I2C 通信中 ACK/NACK 的作用是什么?从机如何产生应答?

    • ACK(拉低 SDA)表示接收方已成功接收一个字节并准备继续;NACK(高电平)表示不接收或已经传输完毕。应答在第 9 个时钟沿由 SCL 主机拉高时,从机根据 ACK/ NACK 位线状态产生。

  4. I2C 常见错误:总线挂起、应答失败,如何排查?

    • 确保总线无短路与开路;检查上拉电阻阻值(4.7kΩ~10kΩ);查看 I2C_SR1 中 AF(Acknowledge Failure)位是否置位;在调试中可先用示波器观察 SCL/SDA 时序;检查地址是否匹配。


3.4 SPI(Serial Peripheral Interface)

SPI 是全双工高速串行通信协议,常用于需高带宽的外设,如 SD 卡、Flash 存储、LCD 驱动芯片、传感器等。

3.4.1 SPI 基本概念
  • 四条信号线

    • SCLK(Serial Clock):时钟信号,由主机产生。

    • MISO(Master In Slave Out):主机输入,从机输出。

    • MOSI(Master Out Slave In):主机输出,从机输入。

    • NSS/CS(Chip Select/Slave Select):由主机控制,用于选中某个从机,SCK/MOSI/MISO 仅对选中的从机有效。

  • 全双工:在一个时钟周期里,主机向从机发送 1 位的同时,从机也向主机发送 1 位数据。

  • 时钟模式 (Mode)

    • CPOL(Clock Polarity)和 CPHA(Clock Phase)决定,共有 4 种模式:

      1. 模式 0:CPOL=0, CPHA=0,时钟空闲低电平,数据在上升沿采样

      2. 模式 1:CPOL=0, CPHA=1,时钟空闲低电平,数据在下降沿采样

      3. 模式 2:CPOL=1, CPHA=0,时钟空闲高电平,数据在下降沿采样

      4. 模式 3:CPOL=1, CPHA=1,时钟空闲高电平,数据在上升沿采样

  • 数据帧长度:通常 8 位,也可支持 16 位、发送长度可配置。

3.4.2 STM32 SPI 外设寄存器(以 STM32F1 为例)
  • RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE):使能 GPIOA 与 SPI1 时钟

  • GPIO 配置

    • SCKMOSI 设置为复用推挽输出,MISO 设置为浮空输入;如果使用硬件 NSS,可配置为复用推挽。

  • SPI_CR1

    • CPOLCPHA:时钟相位/极性选择;

    • BR[2:0]:波特率分频,设置 SCK = PCLK2 / (2 ^ (BR + 1));

    • MSTR:主/从模式;

    • DFF:数据帧格式 8 位或 16 位;

    • LSBFIRST:低位先传输或高位先传输;

    • SSMSSI:软件管理 NSS;

    • CRCEN:CRC 校验使能。

  • SPI_CR2

    • RXNEIETXEIE:接收/发送空中断使能;

    • SSOE:SS 输出使能,用于主机自动管理 NSS 引脚。

  • SPI_SR

    • TXE:发送缓冲区空(可以写新数据);

    • RXNE:接收缓冲区非空(可读取数据);

    • BSY:SPI 正在通信中;

    • OVR:溢出标志。

  • SPI_DR:读写数据寄存器。

3.4.3 初始化示例

以 SPI1 主机模式,CPOL=0、CPHA=0,8 位数据,波特率 PCLK2/16 为例:

#include "stm32f10x.h"

void SPI1_Init(void) {
    // 1. 使能 GPIOA 和 SPI1 时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE);

    // 2. 配置 GPIO: PA5 (SCK), PA7 (MOSI) 为复用推挽输出;PA6 (MISO) 为浮空输入
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 配置 SPI
    SPI_InitTypeDef SPI_InitStruct;
    SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex;
    SPI_InitStruct.SPI_Mode = SPI_Mode_Master;
    SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b;
    SPI_InitStruct.SPI_CPOL = SPI_CPOL_Low;
    SPI_InitStruct.SPI_CPHA = SPI_CPHA_1Edge;
    SPI_InitStruct.SPI_NSS = SPI_NSS_Soft;
    SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16;
    SPI_InitStruct.SPI_FirstBit = SPI_FirstBit_MSB;
    SPI_InitStruct.SPI_CRCPolynomial = 7;
    SPI_Init(SPI1, &SPI_InitStruct);

    // 4. 使能 SPI
    SPI_Cmd(SPI1, ENABLE);
}
3.4.4 读写数据示例
uint8_t SPI1_Transfer(uint8_t data) {
    // 等待 TXE = 1(发送缓冲区空)
    while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);
    // 发送数据
    SPI_I2S_SendData(SPI1, data);
    // 等待 RXNE = 1(接收缓冲区有数据)
    while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET);
    // 读取接收的数据并返回
    return SPI_I2S_ReceiveData(SPI1);
}

int main(void) {
    SPI1_Init();
    uint8_t sendByte = 0xAA;
    uint8_t recvByte = SPI1_Transfer(sendByte);
    while (1) {
        // 不断循环发送 0xAA 并收到从机返回数据
        recvByte = SPI1_Transfer(sendByte);
        HAL_Delay(100);
    }
}
3.4.5 面试常问
  1. SPI 四种工作模式 (Mode 0~3) 的区别是什么?

    • 取决于时钟极性 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,采样上升沿

  2. 为什么 SPI 是全双工?主机同时发送时从机也发?

    • SPI 总线在每个时钟周期内,主机会在 MOSI 上输出 1 位数据,同时 SCK 下降沿将数据传给从机;从机在同一时钟周期也在 MISO 上输出 1 位数据给主机,因此收发同时进行。

  3. 硬件 NSS 管脚 vs 软件 NSS 管理有何优缺点?

    • 硬件 NSS: SPI 控制器自动管理 NSS 引脚,防止时序失误,但要求外设必须支持。

    • 软件 NSS: 由 GPIO 操作 NSS 引脚,灵活但需要手动控制,可能在多从机场景下更易出现问题。

  4. 如何判断接收到的数据是否有效?

    • 查看 RXNE 标志,且在读 DR 之前需要确保不发生溢出(查看 OVR 标志)。


3.5 CAN(Controller Area Network)

CAN 总线是一种面向嵌入式实时系统的通信协议,广泛应用于汽车电子、工业控制等领域。它对实时性和抗干扰有严格要求。

3.5.1 CAN 基本概念
  • 帧类型

    • 标准数据帧(11 位标识符)

    • 扩展数据帧(29 位标识符)

    • 远程帧(Remote Frame)

    • 错误帧与过载帧(专用诊断)

  • 帧结构(以标准数据帧为例):

    1. SOF(Start of Frame,1 位)

    2. Arbitration Field(11 位 ID + 1 位 RTR)

    3. Control Field(IDE、DLC 长度)

    4. Data Field(0~8 字节)

    5. CRC Field(15 位 CRC + 1 位 CRC Delimiter)

    6. ACK Field(ACK Slot + ACK Delimiter)

    7. EOF(End of Frame,7 位)

    8. IFS(Inter Frame Space)

  • 位时序

    • 每位分为同步段(Sync Segment)、传播段(Prop Segment)、相位缓冲段 1 (Phase Segment 1)、相位缓冲段 2 (Phase Segment 2)。

    • 波特率 = PCLK / ((SJW + BS1 + BS2) × prescaler)。

    • 仲裁机制:当多个节点同时发送时,ID 位为 0 的节点具有更高优先级,若节点检测到总线电平与自己发送电平冲突(发送 1 而电平为 0),则退出发送。

  • 过滤与屏蔽

    • 接收节点可通过 32 位或 16 位过滤器设置,只接收指定 ID 或掩码匹配到的帧。

    • 剩余帧丢弃,不传到应用层。

3.5.2 STM32 CAN 外设寄存器(以 STM32F1 为例)
  • RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE):使能 CAN1 时钟

  • GPIOA/IOPB 配置:Rx 引脚配置为浮空输入,Tx 引脚配置为复用推挽

  • CAN_MCR (Master Control Register)

    • SLEEP:睡眠模式

    • INRQ:初始化请求

    • TXFP:选择发送完成中断优先级

    • ABOM:自动总线关闭

    • AWUM:自动唤醒

    • NART:禁止自动重传

  • CAN_BTR (Bit Timing Register):配置波特率、相位缓冲、同步跳转宽度 SJW、采样点。

  • CAN_TSR (Transmit Status Register):发送邮箱状态(TME0/TME1/TME2 表示邮箱空闲),TXOK 等。

  • CAN_RF0R / CAN_RF1R (Receive FIFO Registers):接收 FIFO 状态(满/半满/消息挂起)。

  • CAN_IER (Interrupt Enable Register):使能中断(MailBox Empty, FIFO 0/1 Message Pending, FIFO 0/1 Overrun, Error)。

  • CAN_FMR / CAN_FM1R / CAN_FS1R / CAN_FFA1R / CAN_FA1R / CAN_FiR1:滤波器模式寄存器、缩放寄存器、FIFO 分配寄存器、激活寄存器、标识符寄存器。

3.5.3 初始化示例

下面示例演示如何初始化 CAN1 为 500 kbps,滤波器配置为接收所有 ID:

#include "stm32f10x.h"

void CAN1_Init(void) {
    // 1. 使能 CAN1 和 GPIOA 时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    // 2. 配置 PA11 (CAN_RX) 为浮空、PA12 (CAN_TX) 为复用推挽
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_11;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_12;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 3. 进入 CAN 初始化模式
    CAN_InitTypeDef CAN_InitStruct;
    CAN_InitStruct.CAN_TTCM = DISABLE;
    CAN_InitStruct.CAN_ABOM = DISABLE;
    CAN_InitStruct.CAN_AWUM = DISABLE;
    CAN_InitStruct.CAN_NART = DISABLE;
    CAN_InitStruct.CAN_RFLM = DISABLE;
    CAN_InitStruct.CAN_TXFP = DISABLE;
    CAN_InitStruct.CAN_Mode = CAN_Mode_Normal;    // 正常模式

    // 4. 配置波特率(36 MHz PCLK1, prescaler=6, BS1=8, BS2=3, SJW=1)
    CAN_InitStruct.CAN_SJW = CAN_SJW_1tq;
    CAN_InitStruct.CAN_BS1 = CAN_BS1_8tq;
    CAN_InitStruct.CAN_BS2 = CAN_BS2_3tq;
    CAN_InitStruct.CAN_Prescaler = 6;   // (36 MHz) / (6 × (1+8+3)) ≈ 500 kbps

    CAN_Init(CAN1, &CAN_InitStruct);

    // 5. 配置滤波器:32 位标识符掩码模式,ID=0, Mask=0 (接收所有帧)
    CAN_FilterInitTypeDef CAN_FilterInitStruct;
    CAN_FilterInitStruct.CAN_FilterNumber = 0;
    CAN_FilterInitStruct.CAN_FilterMode = CAN_FilterMode_IdMask;
    CAN_FilterInitStruct.CAN_FilterScale = CAN_FilterScale_32bit;
    CAN_FilterInitStruct.CAN_FilterIdHigh = 0x0000;
    CAN_FilterInitStruct.CAN_FilterIdLow = 0x0000;
    CAN_FilterInitStruct.CAN_FilterMaskIdHigh = 0x0000;
    CAN_FilterInitStruct.CAN_FilterMaskIdLow = 0x0000;
    CAN_FilterInitStruct.CAN_FilterFIFOAssignment = CAN_FIFO0;
    CAN_FilterInitStruct.CAN_FilterActivation = ENABLE;
    CAN_FilterInit(&CAN_FilterInitStruct);
}
3.5.4 发送与接收示例
3.5.4.1 发送标准帧
int CAN1_SendMessage(uint16_t stdId, uint8_t* data, uint8_t len) {
    CanTxMsg TxMessage;
    TxMessage.StdId = stdId;              // 标准 ID
    TxMessage.ExtId = 0x01;               // 如果使用扩展帧,此字段有效
    TxMessage.IDE   = CAN_ID_STD;         // 标准帧
    TxMessage.RTR   = CAN_RTR_DATA;       // 数据帧
    TxMessage.DLC   = len;                // 数据长度 (0~8)
    for (uint8_t i = 0; i < len; i++) {
        TxMessage.Data[i] = data[i];
    }
    // 发送到邮箱 0,并返回邮箱号 (0~2)
    uint8_t mailbox = CAN_Transmit(CAN1, &TxMessage);
    // 等待发送完成
    while (CAN_TransmitStatus(CAN1, mailbox) != CANTXOK);
    return 0;
}
3.5.4.2 接收标准帧
void CAN1_FIFO0_IRQHandler(void) {
    if (CAN_GetITStatus(CAN1, CAN_IT_FMP0)) {
        CanRxMsg RxMessage;
        // 从 FIFO0 读取数据
        CAN_Receive(CAN1, CAN_FIFO0, &RxMessage);
        // 处理接收到的 RxMessage
        // ...
        CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0);
    }
}

void CAN1_Receive_Init(void) {
    // 使能 FIFO0 消息挂起中断
    CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE);
    // 配置 NVIC
    NVIC_InitTypeDef NVIC_InitStruct;
    NVIC_InitStruct.NVIC_IRQChannel = USB_LP_CAN1_RX0_IRQn;
    NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
    NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStruct);
}
3.5.5 面试常问
  1. CAN 与 UART/I2C/SPI 最大区别是什么?为什么适合汽车环境?

    • CAN 支持多主总线仲裁、自动重传、差错检测与纠正、对总线冲突免疫性强、实时性好。适合噪声大、节点多的汽车环境。

  2. 怎样计算 CAN 波特率?波特率由哪些参数决定?

    • 波特率 = PCLK1 / ((SJW + BS1 + BS2) × Prescaler),参数包括同步跳转宽度 SJW,位段 BS1、BS2,以及分频 Prescaler。

  3. CAN 位仲裁原理是什么?如何保证高优先级节点获胜?

    • 在仲裁场景下,每个节点依次发送 ID 位,若节点输出 1,而总线电平为 0,则说明其他节点输出了 0(优先级更高),本节点立即停止发送,等待下次再仲裁。

  4. 如何配置滤波器以只接收感兴趣的 ID?

    • 通过设定 FilterIdHigh/LowMaskIdHigh/Low,在掩码匹配时只保留匹配位。例如,只接收 ID=0x123,可以将 FilterIdHigh = (0x123 << 5) & 0xFFFFMaskIdHigh = 0xFFE0

  5. CAN 错误处理机制有哪些?

    • 包括 CRC 错误、格式错误、位填充错误、确认错误等。出现错误时,节点会发送错误帧并进入错误状态机,有错误主动节点会减少拥塞。


四、RTOS —— 以 RT‑Thread 为例

RTOS(Real‑Time Operating System)是嵌入式系统中实现多任务、定时与同步不可或缺的软件层。RT‑Thread 作为国产开源 RTOS,具有抢占调度、丰富组件、社区活跃等特点,广泛应用于各类物联网与实时设备项目。


4.1 RT‑Thread 架构概述

  • 内核层:提供线程/任务管理、中断管理、同步与通信、定时器、内存管理等基本服务。

  • 组件层:基于内核构建的中间件(文件系统、网络协议栈、图形引擎、设备驱动框架、FinSH Shell)。

  • 硬件抽象层(HAL/BSP):针对具体芯片的驱动与 Board Support Package,向上提供一致的接口。

  • 应用层:用户编写的业务代码与应用程序,依赖底层服务。

示意图:

┌───────────────────────────────────────────────────────┐
│                     应用层 (User App)                │
│  ┌─────────────────────────────────────────────────┐  │
│  │           RT‑Thread 组件 (FS, Net, GUI, Shell)    │  │
│  └─────────────────────────────────────────────────┘  │
│  ┌─────────────────────────────────────────────────┐  │
│  │              RT‑Thread 内核 (Kernel)             │  │
│  │ · 线程/任务管理 (RT-Thread Objects, TCB)         │  │
│  │ · 时间管理 (SysTick, 定时器)                     │  │
│  │ · 中断管理 (中断向量表, 中断优先级)               │  │
│  │ · 同步与通信 (Semaphore, Mutex, Event, Mailbox) │  │
│  │ · 内存管理 (堆、内存池)                          │  │
│  └─────────────────────────────────────────────────┘  │
│  ┌─────────────────────────────────────────────────┐  │
│  │               硬件抽象层 (HAL/BSP)               │  │
│  │ · 中断驱动 (NVIC, SysTick, 外设中断)             │  │
│  │ · 时钟配置, GPIO, UART, I2C, SPI, CAN, Timer     │  │
│  └─────────────────────────────────────────────────┘  │
└───────────────────────────────────────────────────────┘

4.2 线程/任务调度

4.2.1 线程概念
  • 线程 (Thread):最小的可调度单元,称为任务 (Task) 或线程。

  • 每个线程拥有独立的线程控制块 (TCB) 和栈 (Stack),共用全局数据和堆区空间。

  • RT‑Thread 中,线程对象类型为 struct rt_thread,通过线程名、优先级、时间片、入口函数等属性进行描述。

4.2.2 调度策略
  • 抢占式优先级调度(Priority‑Based Preemptive Scheduling)

    • 每个线程指定一个优先级,数值越小优先级越高(即时响应)。

    • 同一优先级线程可通过时间片轮转共享 CPU。

    • 如果有更高优先级线程变为就绪 (Ready),立即抢占当前正在运行的线程。

  • 时间片轮转(Round‑Robin)

    • 对于同一优先级的多个就绪线程,RT‑Thread 默认开启时间片机制。

    • 系统每个时钟节拍 (tick) 触发一次时钟中断,减少当前线程的时钟片计数,当计数耗尽时,调度器切换到同优先级下一个就绪线程。

4.2.3 上下文切换
  • 触发条件

    • 线程自己调用阻塞或挂起(如 rt_thread_delayrt_sem_takert_thread_suspend 等)。

    • 中断服务例程 (ISR) 结束后调用 rt_hw_context_switch 或在时钟节拍 (SysTick) 触发时钟中断,内核判断是否需要调度。

    • 有更高优先级线程从阻塞状态变为就绪状态(如发信号量、消息到达)。

  • 保存与恢复

    • 内核在进入 ISR 前,由硬件自动保存部分寄存器(R0–R3, R12, LR, PC, xPSR)到栈。

    • 在 ISR 末尾,调用内核 rt_hw_context_switch 保存当前线程的其余寄存器(如 R4–R11、SP)到 TCB,再从即将运行的线程的 TCB 恢复寄存器。

4.2.4 线程创建与启动
#include <rtthread.h>

#define THREAD_STACK_SIZE 1024
#define THREAD_PRIORITY   10
#define THREAD_TIMESLICE  5

static void thread_entry(void* parameter) {
    while (1) {
        rt_kprintf("Thread running: %s\n", (char*)parameter);
        rt_thread_delay(RT_TICK_PER_SECOND);  // 延时 1 秒
    }
}

int main(void) {
    rt_thread_t thread = rt_thread_create("my_thread", 
                                          thread_entry, 
                                          (void*)"Hello RT‑Thread",
                                          THREAD_STACK_SIZE,
                                          THREAD_PRIORITY,
                                          THREAD_TIMESLICE);
    if (thread != RT_NULL) {
        rt_thread_startup(thread);
    }
    return 0;
}
  • rt_thread_create 参数:

    1. 线程名称

    2. 线程入口函数

    3. 入口参数

    4. 线程栈大小

    5. 优先级

    6. 时间片长度(以系统节拍计数)

  • rt_thread_startup 将线程状态从 SUSPENDREADY,等待调度执行。

4.2.5 面试常问
  1. 什么是抢占式调度,与协作式调度有什么区别?

    • 抢占式:内核可强制切换到更高优先级线程,保证实时性,但上下文切换成本较高;

    • 协作式:线程自行让出 CPU(调用 delayyield 等),实现简单,但若线程不主动让出,可能造成系统卡死。

  2. 如何配置同一优先级线程之间的时间片?

    • rt_thread_create 中指定时间片长度;同一优先级线程运行时,每次时钟节拍(SysTick)会减少其时间片,当剩余时间片耗尽且仍有同一优先级其他就绪线程时,切换到下一个同优先级线程。

  3. 如果一个高优先级线程长时间占用 CPU,不让出,会怎样?如何避免?

    • 低优先级线程将长期得不到执行,产生优先级反转问题或饥饿;

    • 需要在高优先级线程中调用 rt_thread_delay 或阻塞 API,让出 CPU;或使用互斥锁时启用优先级继承机制。

  4. 系统上下文切换过程中,哪些寄存器由硬件保存,哪些由软件保存?

    • 硬件自动保存 R0–R3, R12, LR, PC, xPSR;软件(RTOS)需保存 R4–R11, SP 到 TCB。


4.3 同步与通信:信号量 (Semaphore)、邮箱 (Mailbox)

4.3.1 信号量 (Semaphore)
  • 二值信号量 (Binary Semaphore)

    • 取值仅为 0 或 1,可用于 “互斥(Mutex)” 或 “事件通知”。

    • 当值为 1 时,表示资源可用;当值为 0 时,表示资源已被占用。

  • 计数信号量 (Counting Semaphore)

    • 取值范围可大于 1,用于控制多个相同资源的数量。

    • 典型场景:多连接资源池、任务并发控制。

  • API

    rt_err_t rt_sem_init(rt_sem_t* sem, const char* name, rt_uint32_t value, rt_uint8_t flag);
    rt_err_t rt_sem_take(rt_sem_t* sem, rt_int32_t timeout);
    rt_err_t rt_sem_release(rt_sem_t* sem);
    
    • sem:信号量控制块;

    • name:信号量名称,最长 8 字节;

    • value:初始计数;

    • flag:取值方式(通常使用 RT_IPC_FLAG_FIFO)。

    • timeout:等待时长,单位系统节拍,可设置为 RT_WAITING_FOREVER 阻塞等待。

  • 使用示例(事件通知):

    #include <rtthread.h>
    
    static rt_sem_t sem;
    
    // 线程 A:等待信号量,处理事件
    static void thread_A(void* parameter) {
        while (1) {
            // 阻塞等待信号量
            if (rt_sem_take(sem, RT_WAITING_FOREVER) == RT_EOK) {
                rt_kprintf("Thread A: Got semaphore, processing event...\n");
            }
        }
    }
    
    // 线程 B:触发事件,释放信号量
    static void thread_B(void* parameter) {
        while (1) {
            rt_thread_mdelay(2000);         // 模拟事件发生周期
            rt_kprintf("Thread B: Event occurred, release semaphore\n");
            rt_sem_release(sem);
        }
    }
    
    int main(void) {
        sem = rt_sem_create("evt_sem", 0, RT_IPC_FLAG_FIFO);
        if (sem == RT_NULL) {
            return -1;
        }
    
        rt_thread_t tA = rt_thread_create("tA", thread_A, RT_NULL, 512, 10, 5);
        rt_thread_startup(tA);
        rt_thread_t tB = rt_thread_create("tB", thread_B, RT_NULL, 512, 11, 5);
        rt_thread_startup(tB);
    
        return 0;
    }
    
4.3.2 邮箱 (Mailbox)
  • 概念:邮箱用于在不同线程间传递 32 位指针(通常是指向消息结构体的指针),内部维护一个指针数组作为循环缓冲。

  • 特点

    • 发送 rt_mb_send 将指针值放入邮箱;若邮箱已满,发送线程可阻塞或立即失败;

    • 接收 rt_mb_recv 从邮箱取出指针;若邮箱为空,接收线程可阻塞到有消息或超时。

  • API

    rt_err_t rt_mb_init(rt_mailbox_t* mb, const char* name, void** start_addr, rt_size_t size);
    rt_err_t rt_mb_send(rt_mailbox_t* mb, rt_ubase_t value);
    rt_err_t rt_mb_recv(rt_mailbox_t* mb, rt_ubase_t* value, rt_int32_t timeout);
    rt_err_t rt_mb_urgent(rt_mailbox_t* mb, rt_ubase_t value);
    
    • start_addr:预先分配的 void* 数组空间,容量 size

    • rt_mb_send:若邮箱满可等待或立即返回。

    • rt_mb_urgent:将消息放到邮箱头部,优先级高。

    • rt_mb_recv:阻塞读取指针值到 *value

  • 使用示例(线程 C 作为生产者,线程 D 作为消费者):

    #include <rtthread.h>
    #include <stdlib.h>
    
    #define MB_SIZE  4
    
    static rt_mailbox_t mb;
    static void* mb_pool[MB_SIZE];
    
    // 消费者线程
    static void thread_consumer(void* parameter) {
        rt_uint32_t value;
        while (1) {
            if (rt_mb_recv(mb, &value, RT_WAITING_FOREVER) == RT_EOK) {
                int* data = (int*)value;
                rt_kprintf("Consumer: Received %d\n", *data);
                free(data);  // 处理完后释放内存
            }
        }
    }
    
    // 生产者线程
    static void thread_producer(void* parameter) {
        while (1) {
            int* pdata = (int*)malloc(sizeof(int));
            *pdata = rand() % 100;
            rt_kprintf("Producer: Send %d\n", *pdata);
            while (rt_mb_send(mb, (rt_ubase_t)pdata) == -RT_EFULL) {
                rt_thread_mdelay(100);  // 邮箱满时等待
            }
            rt_thread_mdelay(500);
        }
    }
    
    int main(void) {
        mb = rt_mb_create("mailbox", (rt_uint8_t*)mb_pool, MB_SIZE, RT_IPC_FLAG_FIFO);
        if (mb == RT_NULL) {
            return -1;
        }
    
        rt_thread_t producer = rt_thread_create("producer", thread_producer, RT_NULL, 512, 10, 5);
        rt_thread_startup(producer);
        rt_thread_t consumer = rt_thread_create("consumer", thread_consumer, RT_NULL, 512, 11, 5);
        rt_thread_startup(consumer);
        return 0;
    }
    
4.3.3 信号量 vs 邮箱 vs 消息队列
  • 信号量 (Semaphore)

    • 仅传递“信号”或“资源计数”,不传递实际数据;

    • 二值信号量用于事件通知或互斥,计数信号量用于控制资源数量。

  • 邮箱 (Mailbox)

    • 专门用于传递指针,大小固定(4 字节/8 字节指针),效率高;

    • 只能传递指针大小,传递数据需先分配缓冲区或使用全局变量。

  • 消息队列 (Message Queue)

    • 可传递任意长度的消息(结构体、数据包),系统内部管理缓冲区;

    • 功能更丰富,但占用更多内存,延迟稍高。

4.3.4 面试常问
  1. 信号量与互斥锁 (Mutex) 有什么区别?二值信号量和互斥锁何时使用?

    • 互斥锁带优先级继承,专门用来保护临界区;二值信号量没有优先级继承,常用于事件通知。

  2. 邮箱 (Mailbox) 与消息队列 (Message Queue) 的区别与适用场景?

    • 邮箱传递指针,效率高但只能发送 4/8 字节;消息队列支持任意长度消息,可存放多个数据单元但占用更多资源。

  3. 在中断服务例程 (ISR) 中如何发送信号量或消息到邮箱?有什么注意事项?

    • 需要使用 “从 ISR 上下文” 的 API,例如 rt_sem_release_from_isrrt_mb_send_from_isr,并在 ISR 末尾调用 rt_hw_context_switch_to 触发调度。

  4. 什么是优先级反转 (Priority Inversion),RT‑Thread 如何解决?

    • 低优先级线程持有资源,高优先级线程等待,且中优先级线程抢 CPU,导致高优先级线程饥饿。RT‑Thread 在使用互斥锁时启用优先级继承(Priority Inheritance),避免反转问题。

  5. 信号量申请时对阻塞时间(timeout)为 0、RT_WAITING_FOREVER、其他值的区别?

    • timeout = 0:非阻塞模式,无立即获取时直接返回错误;

    • timeout = RT_WAITING_FOREVER:无限期阻塞,直到获取到信号量;

    • 其他值:在规定时间(单位节拍)内等待,超时返回错误。


五、面试高频题集锦

下面按照各模块,将常见面试题一并列出,便于针对性练习。


5.1 TCP/IP 与网络层

  1. TCP 三次握手和四次挥手过程?少一次会怎样?

  2. TCP 如何保障可靠传输?描述确认应答、重传、滑动窗口、拥塞控制等机制。

  3. UDP 有哪些应用场景?如果想在 UDP 上实现可靠传输,需要做什么?

  4. 如何计算子网掩码下的网络地址与广播地址?什么是 CIDR?

  5. 简述 DNS 查询过程:递归查询 vs 迭代查询。

  6. ARP 的作用是什么?ICMP 有哪些常见报文类型?


5.2 C 语言基础

  1. 结构体和联合体的区别?sizeof(struct)sizeof(union) 如何计算?

  2. 枚举 (enum) 与宏 #define 的区别?如何将枚举值转换为字符串?

  3. 写一个函数反转单链表,分别给出迭代和递归版本。

  4. 如何检测链表有环?如果有环,如何找到环的入口节点?

  5. 环形队列的核心算法是什么?为什么要浪费一个格子来区分满/空?

  6. int *a[5]int (*a)[5] 的区别?请分别声明并访问其中的元素。

  7. 函数指针如何声明?如何用函数指针实现回调?举例说明。

  8. 为什么不能返回指向局部变量的指针?示例说明可能产生的后果。

  9. 在链表插入/删除节点时需要注意什么?如何避免内存泄漏?


5.3 STM32 外设与 GPIO

  1. 如何配置 GPIO 为上拉输入、下拉输入、推挽输出、开漏输出?需要修改哪些寄存器?

  2. UART 波特率计算公式是什么?TXE、TC、RXNE 含义?阻塞、中断、DMA 三种方式如何选择?

  3. I2C 时序图如何画?如何在 STM32 上生成 START/STOP?NACK 怎么产生与处理?

  4. SPI 四种模式 (CPOL/CPHA) 有什么区别?为什么 SPI 是全双工?

  5. **CAN 波特率如何计算?

    • 分频器 Prescaler = 6,SJW = 1,BS1 = 8,BS2 = 3,PCLK1=36 MHz → 500 kbps

    • 位时钟 = PCLK1 / Prescaler = 6 MHz,位时间 = (SJW + BS1 + BS2) = 1 + 8 + 3 = 12 TQ → 6 MHz / 12 = 500 kHz = 500 kbps

  6. CAN 仲裁原理是什么?为什么 ID 数值小的优先级高?

  7. 为什么 I2C 要用开漏?如何处理多主仲裁与冲突?

  8. 如何在 STM32 中设定中断优先级?抢占优先级与子优先级有什么区别?


5.4 RTOS 与 RT‑Thread

  1. RTOS 与裸机编程(Bare‑Metal)的区别?举例说明。

  2. 什么是抢占式调度?与协作式调度有何区别?

  3. 同一优先级线程如何进行时间片轮转?其时间片长度如何配置?

  4. 信号量、互斥锁 (Mutex)、邮箱 (Mailbox)、消息队列 (Message Queue) 区别与应用场景?

  5. 什么是优先级反转?RT‑Thread 如何避免优先级反转?

  6. 如何在中断服务例程 (ISR) 中发送信号量或消息到邮箱?需要调用哪些 API?需要注意什么?

  7. 怎样实现定时任务?软件定时器与硬件定时器有何不同?

  8. 如何查看/调试 RT‑Thread 的内部对象(线程、信号量、邮箱等)?


六、总结与学习建议

  • 分层系统化学习

    1. 网络层:先理解 TCP/IP 五层模型,再深入 TCP 流控/拥塞控制、UDP 特性。

    2. 编程基础:从 C 语言基本数据类型 → 结构体/联合体/枚举 → 数据结构(链表、环形队列)→ 指针进阶。

    3. 硬件驱动:先熟悉 GPIO,再分别学习 UART、中断、中断优先级 → I2C、SPI、CAN 等总线协议。

    4. RTOS:理解多任务基础、抢占调度、时钟与上下文切换 → 同步与通信(电信号 semaphore、邮箱、消息队列) → 资源管理(内存、定时器、驱动框架)。

  • 实战练习

    • 硬件实机调试:用一块 STM32 开发板,搭建最小系统(串口输出、LED 灯闪烁、按键中断),加深对 GPIO 与中断的理解。

    • 外设驱动:在示波器上观察 I2C/SPI/CAN 总线波形;用示波器校验时序是否符合协议规范;编写正确的硬件驱动代码。

    • RTOS Demo:使用 RT‑Thread,创建多个线程,实现“LED 闪烁”、“串口打印”、“I2C 读传感器” Demo;在其中使用信号量和邮箱进行线程同步与通信。

  • 面试答题技巧

    1. 画图辅助说明:例如画出 TCP 三次握手时序图、I2C 开始/停止时序图、SPI 时钟相位图、线程切换时机图等,直观清晰。

    2. 展现代码能力:能快速写出关键函数原型与伪代码,如链表反转、环形队列入队/出队、函数指针声明、UART 初始化步骤。

    3. 理解原理胜于死记:面试官往往会问“为什么这样做”或“如果发生错误怎么排查”,要有原理基础才能回答。

    4. 结合项目经验:若在项目中使用过 RTOS,描述实际案例,如使用信号量解决任务同步,或者使用邮件队列实现数据传输。


通过本文对TCP/IP、C 语言基础、STM32 外设、RT‑Thread的系统性梳理,并附带各模块的高频面试题答案思路,希望帮助你在嵌入式面试中从容应对。祝你学习顺利、面试成功!