作为嵌入式工程师,面试时往往不仅要展示基础编程能力,还要兼具网络协议、硬件驱动、实时操作系统(RTOS)等方面的知识深度。本文将从TCP/IP 协议 、C 语言核心基础 、STM32 IO 与外设驱动 、RT‑Thread 及其多任务/IPC四大模块进行全面讲解,并在每个模块末尾附上常见面试题,助你系统备考。
一、TCP/IP 协议
1.1 TCP/IP 五层模型概述
嵌入式网络开发中,掌握 TCP/IP 的分层架构和每层的关键功能至关重要。常见的五层模型如下:
-
链路层(Link Layer)
-
负责在物理链路上传输数据帧,常见技术包括以太网(Ethernet)、Wi‑Fi、PPP 等。
-
主要功能:MAC 地址,帧封装/解封装,差错检测(FCS/Cyclic Redundancy Check)。
-
-
网络层(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 超时、目的地不可达等)。
-
-
传输层(Transport Layer)
-
TCP(Transmission Control Protocol)
-
三次握手(Three‑way Handshake):
-
客户端向服务器发送 SYN 包,随机初始序列号 X;
-
服务器收到后,回复 SYN‑ACK,序列号 Y,确认号 X+1;
-
客户端收到后,发送 ACK,确认号 Y+1,连接建立。
-
-
可靠传输:序列号、确认应答(ACK)、重传超时(RTO)、窗口滑动、拥塞控制(慢启动、拥塞避免、快速重传、快速恢复)。
-
四次挥手(Four‑way Teardown):
-
主动关闭方发送 FIN;
-
对端回复 ACK;
-
对端再发送 FIN;
-
主动方回复 ACK,完成连接断开。
-
-
流量控制 vs 拥塞控制:
-
流量控制(Window Size):由接收方告知发送方的缓冲区剩余大小,避免接收方处理不过来。
-
拥塞控制:根据网络拥塞程度动态调整发送速率,常见算法包括慢启动(cwnd 从 1 MSS 开始倍增)、拥塞避免(cwnd 加性增长)、快速重传与快速恢复。
-
-
-
UDP(User Datagram Protocol)
-
无连接、无状态,开销小,仅包含源/目的端口、长度、校验和。
-
常用于对实时性要求高而对丢包不敏感的应用,如语音/视频流、DNS 查询、DHCP。
-
-
-
会话层与表示层(往往合并到应用层讨论)
-
负责会话建立、保持与拆除,以及数据的表示转换、加密/解密、压缩/解压缩等。
-
在大多数 TCP/IP 资料中,会话层与表示层功能被归并到应用层。
-
-
应用层(Application Layer)
-
各种常见协议:HTTP/HTTPS、FTP、SMTP/POP3/IMAP、DNS、Telnet、SSH、MQTT 等。
-
负责为最终用户或应用程序提供网络服务,例如 Web 浏览、邮件收发、文件传输、远程终端等。
-
面试常见问题
-
TCP 三次握手过程是什么?如果只进行两次握手会发生什么?
-
回答要点:
-
三次握手确保双方同步初始序列号,并确认双方均处于可接收状态。
-
如果少一次,双方无法确认彼此处于就绪状态,可能导致数据丢失或误判连接已建立。
-
-
-
TCP 四次挥手与三次挥手有什么区别?为什么需要四次?
-
回答要点:
-
四次挥手中,主动关闭方首次发送 FIN 后,进入 FIN_WAIT_1;对方回复 ACK,进入 CLOSE_WAIT。
-
当对方数据发送完毕后,才会发送自己的 FIN;这样才能保证双向数据传输的完整。
-
如果只挥手三次,会导致一端未正确释放资源或丢失最后的数据。
-
-
-
TCP 流量控制与拥塞控制的区别是什么?
-
回答要点:
-
流量控制(Receiver Window)是由接收端根据自身缓冲能力告知发送端限制发送速率,避免接收端缓冲区溢出。
-
拥塞控制则由发送方根据网络反馈(丢包、超时等)动态调整窗口大小(cwnd),以避免网络拥塞。
-
-
-
如何计算给定 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
-
-
-
UDP 为什么适合 DNS、视频流等应用?
-
回答要点:
-
UDP 无连接、无握手,无需维护状态,发送延迟低、头部开销小(8 字节)。
-
对于实时性要求高且容忍少量丢包的应用场景,UDP 更高效。
-
-
-
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 字节。
- 在大多数 32 位平台上,
-
-
字段排列优化 :如果将
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 面试常问
-
结构体
struct A { char c; int x; };
与struct B { int x; char c; };
谁更省空间,为什么?- 需要解释内存对齐的概念,展示
sizeof
的不同。
- 需要解释内存对齐的概念,展示
-
如何将结构体作为函数参数传递?按值 vs 按引用?优缺点?
- 传值会生成新的拷贝,适合结构体较小、无需修改原始对象;传引用(传指针)效率更高,但需要注意指针是否为空以及避免悬空指针。
-
什么是匿名结构体?什么时候使用?
- 匿名结构体指在定义时不命名结构体类型,直接在变量声明时指定字段布局。主要用于临时定义、或作为联合体/其他复杂数据结构的成员。
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;
}
- 由于
i
、f
、str
共享相同内存,最后写入str
之后,d.f
和d.i
的值不再可靠。
2.2.3 应用场景
-
节省内存:当同一时刻只需要存储其中一个成员时,使用联合体能减少内存占用。
-
协议解析:在处理网络或串口协议时,报文格式可按联合体定义,通过赋值不同字段进行解析。
-
变长参数/类型转化:可在同一数据区以不同方式访问,比如将 32 位浮点数与 32 位整数通过联合体共享,再做位操作或直接查看二进制表示。
2.2.4 サイズ计算
- 以
union Data
为例,假设sizeof(int)=4
、sizeof(float)=4
、sizeof(char[20])=20
,那么sizeof(union Data)
= 20(最大成员)+ 填充到符合最大成员对齐(如果有必要)。
2.2.5 面试常问
-
结构体与联合体有什么本质区别?
- 结构体成员每个都有自己内存空间,联合体成员共享同一区域。
-
联合体的大小如何计算?
- 等于最大成员的尺寸(必要时加填充以满足对齐)。
-
举例说明联合体在协议解析中的应用。
-
例如:某 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 面试常问
-
枚举底层是什么数据类型?可以指定其他底层类型吗?
- 标准 C 下枚举等价于
int
(大小取决于编译器与 ABI,但必须至少能表示所有枚举成员的值)。
- 标准 C 下枚举等价于
-
枚举与
#define
定义常量有什么区别?- 枚举是类型安全的,具有作用域,调试时可显示符号名称;
#define
只是预处理替换,无类型检查。
- 枚举是类型安全的,具有作用域,调试时可显示符号名称;
-
如何将枚举值转为字符串?
-
常见做法:定义一个对应字符串数组,例如:
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 面试常问
-
如何在单链表中插入/删除节点?写出伪代码。
-
要点:
-
插入:维护前驱节点和后继节点;头部插入或尾部插入时要特殊处理。
-
删除:找到待删节点的前驱,修改指针并释放内存。
-
-
-
如何反转一个单链表?请分别使用迭代和递归法。
-
迭代法:三个指针
prev
、curr
、next
,不断反转指针。 -
递归法:先反转剩余链表,再将当前节点接到尾部。
-
-
如何检测链表是否有环?如何找环的入口?
- 快慢指针法。相遇后,将慢指针移回头节点,两指针每次一步相遇即环入口。
-
链表和数组的区别是什么?优缺点分别是什么?
-
链表:动态大小,不连续,插入/删除快;随机访问慢。
-
数组:连续内存,随机访问快;大小固定,插入/删除慢。
-
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 面试常问
-
环形队列为什么要浪费一个空间来区分满/空?
- 由于
front == rear
同时代表队空和队满,为避免混淆,需要让队满时保持(rear + 1) % MAX_SIZE == front
。
- 由于
-
如何修改设计让环形队列不浪费空间?
- 可增加一个
size
变量存储当前元素个数,或者使用布尔标志位指示上次执行的是入队还是出队操作。
- 可增加一个
-
如果想让环形队列动态扩容,该如何实现?
- 当队满时,分配更大数组(例如原大小的两倍),并将旧数组元素依次复制到新数组的线性区域,然后重置
front = 0
、rear = 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 数组的指针"。
-
区别:
-
int *a[5]
:a
是一个长度为 5 的数组,数组元素类型为int*
。可以容纳 5 个整型指针。 -
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 面试常问
-
解释
int *a[5]
和int (*a)[5]
的区别,并给出示例。- 要点:分别写声明,说明
a
是指针数组或数组指针。
- 要点:分别写声明,说明
-
为什么不能返回指向局部变量的指针?请举例说明。
-
示例:
int* foo(void) { int x = 10; return &x; // 悬空指针 }
-
x
在函数结束后被释放,指针失效。
-
-
写出函数指针声明,并用它实现一个简单的加减乘除计算器。
#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 面试常问
-
如何配置 GPIO 为上拉输入、下拉输入、推挽输出、开漏输出?
-
需要解释
CRL/CRH
中MODE
和CNF
的具体取值,如:-
浮空输入:
MODE = 00
,CNF = 01
-
上拉/下拉输入:
MODE = 00
,CNF = 10
,并通过ODR
写 1 使能上拉或写 0 使能下拉 -
推挽输出:
MODE = 10
(2 MHz)或11
(50 MHz),CNF = 00
-
开漏输出:
MODE = 10
或11
,CNF = 01
-
-
-
开漏输出与推挽输出的区别是什么?在哪些场景下使用开漏?
-
推挽输出:高电平时内部拉到 VDD,低电平时拉到 GND;标准 GPIO 输出。
-
开漏输出:低电平时拉到 GND,高电平时浮空,由外部上拉电阻(或上拉电路)拉到高电平。
-
场景:I2C 总线、多个总线设备共用一条线时需要开漏与上拉,或者需要对外部设备进行 3.3V/5V 兼容时。
-
-
如何实现 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 数据收发方式
-
阻塞方式 (Blocking)
-
发送时,函数
HAL_UART_Transmit
阻塞,直到 TXE 标志置位,写入DR
,再等待 TC(Transmission Complete)或者仅等待 TXE 即可继续。 -
接收时,函数
HAL_UART_Receive
阻塞,直到 RXNE 标志置位,从DR
读取数据。
-
-
中断方式 (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 中依次写入数据直到发送完毕。
-
-
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 面试常问
-
UART 波特率如何计算?TXE、TC、RXNE 标志位代表什么意思?
- 需要说出
USARTDIV
计算公式,并解释 TXE = 发送数据寄存器空,表示可以写新的数据;TC = 传输完成,表示数据已全部发送;RXNE = 接收寄存器非空,表示有数据可读。
- 需要说出
-
如何使用中断方式接收字符?
-
打开
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 } }
-
配置优先级及使能中断。
-
-
阻塞方式与非阻塞方式(中断、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 时序与协议
-
启动条件(START)
- 在 SCL 高电平时,SDA 从高拉低,产生一个 START 信号,表示传输开始。
-
发送从机地址 + 读写位
-
主机发送 7 位或 10 位地址,再加上第 8 位表示读/写(0=写,1=读)。
-
从机检测到地址匹配后,在第 9 个时钟周期拉低 SDA 表示 ACK,应答信号。
-
-
数据传输
-
对于写操作:主机将数据字节发送到 SDA,总共 8 个时钟沿,数据在时钟上升/下降沿有效(取决于 CPHA 模式)。
-
对于读操作:从机将数据放到 SDA,总共 8 位后,主机在第 9 个时钟沿上拉低 SDA 表示 ACK。
-
-
停止条件(STOP)
- 在 SCL 高电平时,SDA 从低拉高,产生 STOP 信号,表示传输结束。
-
NACK
- 如果接收方(主机或从机)在第 9 个时钟周期拉开 SDA (高电平),则为 NACK,表示拒绝或结束传输。
-
仲裁与冲突
- 如果总线上有多个主机,若在同一时钟周期一个主机输出 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_CCR
、I2C_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 面试常问
-
为什么 I2C 总线要使用开漏 (Open‑Drain) 模式?
- 因为多个设备同时挂载在同一对线 SCL/SDA 上,需要共享总线,高电平由外部上拉电阻提供,开漏模式可避免总线冲突。
-
如何处理 I2C 总线仲裁(Arbitration)?
- 当多个主机同时发起通信,若某个主机在发送某个时钟周期想输出 1,而看到总线实际为 0(开漏结构),则说明有更高优先级(数字 0 表示优先)主机在发送,仲裁失败的主机需立即停止发送。
-
I2C 通信中 ACK/NACK 的作用是什么?从机如何产生应答?
- ACK(拉低 SDA)表示接收方已成功接收一个字节并准备继续;NACK(高电平)表示不接收或已经传输完毕。应答在第 9 个时钟沿由 SCL 主机拉高时,从机根据 ACK/ NACK 位线状态产生。
-
I2C 常见错误:总线挂起、应答失败,如何排查?
- 确保总线无短路与开路;检查上拉电阻阻值(4.7kΩ~10kΩ);查看
I2C_SR1
中 AF(Acknowledge Failure)位是否置位;在调试中可先用示波器观察 SCL/SDA 时序;检查地址是否匹配。
- 确保总线无短路与开路;检查上拉电阻阻值(4.7kΩ~10kΩ);查看
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 种模式:-
模式 0:CPOL=0, CPHA=0,时钟空闲低电平,数据在上升沿采样
-
模式 1:CPOL=0, CPHA=1,时钟空闲低电平,数据在下降沿采样
-
模式 2:CPOL=1, CPHA=0,时钟空闲高电平,数据在下降沿采样
-
模式 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 配置:
SCK
、MOSI
设置为复用推挽输出,MISO
设置为浮空输入;如果使用硬件 NSS,可配置为复用推挽。
-
SPI_CR1
:-
CPOL
、CPHA
:时钟相位/极性选择; -
BR[2:0]
:波特率分频,设置 SCK = PCLK2 / (2 ^ (BR + 1)); -
MSTR
:主/从模式; -
DFF
:数据帧格式 8 位或 16 位; -
LSBFIRST
:低位先传输或高位先传输; -
SSM
和SSI
:软件管理 NSS; -
CRCEN
:CRC 校验使能。
-
-
SPI_CR2
:-
RXNEIE
、TXEIE
:接收/发送空中断使能; -
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 面试常问
-
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,采样上升沿
-
-
-
为什么 SPI 是全双工?主机同时发送时从机也发?
- SPI 总线在每个时钟周期内,主机会在 MOSI 上输出 1 位数据,同时 SCK 下降沿将数据传给从机;从机在同一时钟周期也在 MISO 上输出 1 位数据给主机,因此收发同时进行。
-
硬件 NSS 管脚 vs 软件 NSS 管理有何优缺点?
-
硬件 NSS: SPI 控制器自动管理 NSS 引脚,防止时序失误,但要求外设必须支持。
-
软件 NSS: 由 GPIO 操作 NSS 引脚,灵活但需要手动控制,可能在多从机场景下更易出现问题。
-
-
如何判断接收到的数据是否有效?
- 查看
RXNE
标志,且在读DR
之前需要确保不发生溢出(查看OVR
标志)。
- 查看
3.5 CAN(Controller Area Network)
CAN 总线是一种面向嵌入式实时系统的通信协议,广泛应用于汽车电子、工业控制等领域。它对实时性和抗干扰有严格要求。
3.5.1 CAN 基本概念
-
帧类型:
-
标准数据帧(11 位标识符)
-
扩展数据帧(29 位标识符)
-
远程帧(Remote Frame)
-
错误帧与过载帧(专用诊断)
-
-
帧结构(以标准数据帧为例):
-
SOF(Start of Frame,1 位)
-
Arbitration Field(11 位 ID + 1 位 RTR)
-
Control Field(IDE、DLC 长度)
-
Data Field(0~8 字节)
-
CRC Field(15 位 CRC + 1 位 CRC Delimiter)
-
ACK Field(ACK Slot + ACK Delimiter)
-
EOF(End of Frame,7 位)
-
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 面试常问
-
CAN 与 UART/I2C/SPI 最大区别是什么?为什么适合汽车环境?
- CAN 支持多主总线仲裁、自动重传、差错检测与纠正、对总线冲突免疫性强、实时性好。适合噪声大、节点多的汽车环境。
-
怎样计算 CAN 波特率?波特率由哪些参数决定?
- 波特率 = PCLK1 / ((SJW + BS1 + BS2) × Prescaler),参数包括同步跳转宽度 SJW,位段 BS1、BS2,以及分频 Prescaler。
-
CAN 位仲裁原理是什么?如何保证高优先级节点获胜?
- 在仲裁场景下,每个节点依次发送 ID 位,若节点输出 1,而总线电平为 0,则说明其他节点输出了 0(优先级更高),本节点立即停止发送,等待下次再仲裁。
-
如何配置滤波器以只接收感兴趣的 ID?
- 通过设定
FilterIdHigh/Low
和MaskIdHigh/Low
,在掩码匹配时只保留匹配位。例如,只接收 ID=0x123,可以将FilterIdHigh = (0x123 << 5) & 0xFFFF
,MaskIdHigh = 0xFFE0
。
- 通过设定
-
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_delay
、rt_sem_take
、rt_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
参数:-
线程名称
-
线程入口函数
-
入口参数
-
线程栈大小
-
优先级
-
时间片长度(以系统节拍计数)
-
-
rt_thread_startup
将线程状态从SUSPEND
→READY
,等待调度执行。
4.2.5 面试常问
-
什么是抢占式调度,与协作式调度有什么区别?
-
抢占式:内核可强制切换到更高优先级线程,保证实时性,但上下文切换成本较高;
-
协作式:线程自行让出 CPU(调用
delay
、yield
等),实现简单,但若线程不主动让出,可能造成系统卡死。
-
-
如何配置同一优先级线程之间的时间片?
- 在
rt_thread_create
中指定时间片长度;同一优先级线程运行时,每次时钟节拍(SysTick)会减少其时间片,当剩余时间片耗尽且仍有同一优先级其他就绪线程时,切换到下一个同优先级线程。
- 在
-
如果一个高优先级线程长时间占用 CPU,不让出,会怎样?如何避免?
-
低优先级线程将长期得不到执行,产生优先级反转问题或饥饿;
-
需要在高优先级线程中调用
rt_thread_delay
或阻塞 API,让出 CPU;或使用互斥锁时启用优先级继承机制。
-
-
系统上下文切换过程中,哪些寄存器由硬件保存,哪些由软件保存?
- 硬件自动保存
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 面试常问
-
信号量与互斥锁 (Mutex) 有什么区别?二值信号量和互斥锁何时使用?
- 互斥锁带优先级继承,专门用来保护临界区;二值信号量没有优先级继承,常用于事件通知。
-
邮箱 (Mailbox) 与消息队列 (Message Queue) 的区别与适用场景?
- 邮箱传递指针,效率高但只能发送 4/8 字节;消息队列支持任意长度消息,可存放多个数据单元但占用更多资源。
-
在中断服务例程 (ISR) 中如何发送信号量或消息到邮箱?有什么注意事项?
- 需要使用 "从 ISR 上下文" 的 API,例如
rt_sem_release_from_isr
或rt_mb_send_from_isr
,并在 ISR 末尾调用rt_hw_context_switch_to
触发调度。
- 需要使用 "从 ISR 上下文" 的 API,例如
-
什么是优先级反转 (Priority Inversion),RT‑Thread 如何解决?
- 低优先级线程持有资源,高优先级线程等待,且中优先级线程抢 CPU,导致高优先级线程饥饿。RT‑Thread 在使用互斥锁时启用优先级继承(Priority Inheritance),避免反转问题。
-
信号量申请时对阻塞时间(timeout)为 0、
RT_WAITING_FOREVER
、其他值的区别?-
timeout = 0
:非阻塞模式,无立即获取时直接返回错误; -
timeout = RT_WAITING_FOREVER
:无限期阻塞,直到获取到信号量; -
其他值:在规定时间(单位节拍)内等待,超时返回错误。
-
五、面试高频题集锦
下面按照各模块,将常见面试题一并列出,便于针对性练习。
5.1 TCP/IP 与网络层
-
TCP 三次握手和四次挥手过程?少一次会怎样?
-
TCP 如何保障可靠传输?描述确认应答、重传、滑动窗口、拥塞控制等机制。
-
UDP 有哪些应用场景?如果想在 UDP 上实现可靠传输,需要做什么?
-
如何计算子网掩码下的网络地址与广播地址?什么是 CIDR?
-
简述 DNS 查询过程:递归查询 vs 迭代查询。
-
ARP 的作用是什么?ICMP 有哪些常见报文类型?
5.2 C 语言基础
-
结构体和联合体的区别?
sizeof(struct)
与sizeof(union)
如何计算? -
枚举 (
enum
) 与宏#define
的区别?如何将枚举值转换为字符串? -
写一个函数反转单链表,分别给出迭代和递归版本。
-
如何检测链表有环?如果有环,如何找到环的入口节点?
-
环形队列的核心算法是什么?为什么要浪费一个格子来区分满/空?
-
int *a[5]
与int (*a)[5]
的区别?请分别声明并访问其中的元素。 -
函数指针如何声明?如何用函数指针实现回调?举例说明。
-
为什么不能返回指向局部变量的指针?示例说明可能产生的后果。
-
在链表插入/删除节点时需要注意什么?如何避免内存泄漏?
5.3 STM32 外设与 GPIO
-
如何配置 GPIO 为上拉输入、下拉输入、推挽输出、开漏输出?需要修改哪些寄存器?
-
UART 波特率计算公式是什么?TXE、TC、RXNE 含义?阻塞、中断、DMA 三种方式如何选择?
-
I2C 时序图如何画?如何在 STM32 上生成 START/STOP?NACK 怎么产生与处理?
-
SPI 四种模式 (CPOL/CPHA) 有什么区别?为什么 SPI 是全双工?
-
**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
-
-
CAN 仲裁原理是什么?为什么 ID 数值小的优先级高?
-
为什么 I2C 要用开漏?如何处理多主仲裁与冲突?
-
如何在 STM32 中设定中断优先级?抢占优先级与子优先级有什么区别?
5.4 RTOS 与 RT‑Thread
-
RTOS 与裸机编程(Bare‑Metal)的区别?举例说明。
-
什么是抢占式调度?与协作式调度有何区别?
-
同一优先级线程如何进行时间片轮转?其时间片长度如何配置?
-
信号量、互斥锁 (Mutex)、邮箱 (Mailbox)、消息队列 (Message Queue) 区别与应用场景?
-
什么是优先级反转?RT‑Thread 如何避免优先级反转?
-
如何在中断服务例程 (ISR) 中发送信号量或消息到邮箱?需要调用哪些 API?需要注意什么?
-
怎样实现定时任务?软件定时器与硬件定时器有何不同?
-
如何查看/调试 RT‑Thread 的内部对象(线程、信号量、邮箱等)?
六、总结与学习建议
-
分层系统化学习
-
网络层:先理解 TCP/IP 五层模型,再深入 TCP 流控/拥塞控制、UDP 特性。
-
编程基础:从 C 语言基本数据类型 → 结构体/联合体/枚举 → 数据结构(链表、环形队列)→ 指针进阶。
-
硬件驱动:先熟悉 GPIO,再分别学习 UART、中断、中断优先级 → I2C、SPI、CAN 等总线协议。
-
RTOS:理解多任务基础、抢占调度、时钟与上下文切换 → 同步与通信(电信号 semaphore、邮箱、消息队列) → 资源管理(内存、定时器、驱动框架)。
-
-
实战练习
-
硬件实机调试:用一块 STM32 开发板,搭建最小系统(串口输出、LED 灯闪烁、按键中断),加深对 GPIO 与中断的理解。
-
外设驱动:在示波器上观察 I2C/SPI/CAN 总线波形;用示波器校验时序是否符合协议规范;编写正确的硬件驱动代码。
-
RTOS Demo:使用 RT‑Thread,创建多个线程,实现"LED 闪烁"、"串口打印"、"I2C 读传感器" Demo;在其中使用信号量和邮箱进行线程同步与通信。
-
-
面试答题技巧
-
画图辅助说明:例如画出 TCP 三次握手时序图、I2C 开始/停止时序图、SPI 时钟相位图、线程切换时机图等,直观清晰。
-
展现代码能力:能快速写出关键函数原型与伪代码,如链表反转、环形队列入队/出队、函数指针声明、UART 初始化步骤。
-
理解原理胜于死记:面试官往往会问"为什么这样做"或"如果发生错误怎么排查",要有原理基础才能回答。
-
结合项目经验:若在项目中使用过 RTOS,描述实际案例,如使用信号量解决任务同步,或者使用邮件队列实现数据传输。
-
通过本文对TCP/IP、C 语言基础、STM32 外设、RT‑Thread 的系统性梳理,并附带各模块的高频面试题 与答案思路,希望帮助你在嵌入式面试中从容应对。祝你学习顺利、面试成功!