汇川嵌入式 软件开发一面 面经
1.TCP和UDP有什么区别?各自适用什么场景?
TCP和UDP是传输层的两种协议,特点完全不同。
TCP是面向连接的,需要三次握手建立连接,四次挥手断开连接。提供可靠传输,保证数据顺序和完整性,有确认和重传机制。是面向字节流的,数据作为连续的流传输。有流量控制和拥塞控制机制。头部开销大,至少20字节。
UDP是无连接的,直接发送数据,不需要建立连接。不保证可靠性,可能丢包、乱序、重复。是面向数据报的,每个数据包独立传输。没有流量控制和拥塞控制。头部开销小,只有8字节。速度快,实时性好。
// TCP服务器示例 int listen_fd = socket(AF_INET, SOCK_STREAM, 0); // SOCK_STREAM表示TCP bind(listen_fd, ...); listen(listen_fd, 5); int client_fd = accept(listen_fd, ...); recv(client_fd, buffer, size, 0); // UDP服务器示例 int sock_fd = socket(AF_INET, SOCK_DGRAM, 0); // SOCK_DGRAM表示UDP bind(sock_fd, ...); recvfrom(sock_fd, buffer, size, 0, ...);
适用场景:
- TCP适合需要可靠传输的场景:文件传输、网页浏览、邮件、数据库连接
- UDP适合实时性要求高的场景:视频直播、语音通话、在线游戏、DNS查询
工业控制中,Modbus TCP用TCP保证可靠性,而一些实时控制协议用UDP保证低延迟。
2.TCP为什么会发生粘包?如何解决?
TCP是面向字节流的,没有消息边界的概念。发送方连续发送多个数据包,接收方可能一次性接收到多个包的数据,这就是粘包。
发生粘包的原因:
- TCP的Nagle算法会将小包合并发送,提高效率
- 接收方来不及处理,多个包的数据积累在接收缓冲区
- 发送方发送速度快,接收方处理速度慢
解决方法:
固定长度:每个消息固定长度,不足的用填充字符补齐。简单但浪费空间。
// 固定长度100字节
char buffer[100];
while (true) {
recv(sock, buffer, 100, 0); // 每次接收固定100字节
process_message(buffer);
}
消息头+长度:在消息前加一个固定长度的头部,包含消息长度信息。
struct MessageHeader {
uint32_t length; // 消息体长度
};
// 发送
MessageHeader header;
header.length = data.size();
send(sock, &header, sizeof(header), 0);
send(sock, data.c_str(), data.size(), 0);
// 接收
MessageHeader header;
recv(sock, &header, sizeof(header), 0);
char* buffer = new char[header.length];
recv(sock, buffer, header.length, 0);
分隔符:用特殊字符(如\n、\r\n)分隔消息。简单但需要转义特殊字符。
// 发送
std::string msg = "Hello\n";
send(sock, msg.c_str(), msg.size(), 0);
// 接收
std::string buffer;
char c;
while (recv(sock, &c, 1, 0) > 0) {
if (c == '\n') {
process_message(buffer);
buffer.clear();
} else {
buffer += c;
}
}
应用层协议:使用成熟的协议如HTTP、Protobuf等,它们已经解决了粘包问题。
工业控制中,Modbus协议就是用固定格式+CRC校验来解决粘包和数据完整性问题。
3.TCP的MSS是什么?为什么还会分片?
MSS(Maximum Segment Size)是TCP协议中一次传输的最大数据量,在三次握手时协商确定。MSS的目的是避免IP层分片,因为IP分片会降低效率。
MSS的计算:MSS = MTU - IP头部(20字节) - TCP头部(20字节)。以太网MTU通常是1500字节,所以MSS通常是1460字节。
以太网帧:1500字节(MTU) - IP头部:20字节 - TCP头部:20字节 = TCP数据:1460字节(MSS)
为什么还会分片:
路径MTU不一致:数据包经过的网络路径上,不同链路的MTU可能不同。虽然发送方设置了MSS,但中间路由器的MTU可能更小,仍然需要分片。
IP分片:如果TCP段加上IP头部后超过了路径MTU,IP层会进行分片。虽然TCP协商了MSS,但无法完全避免IP分片。
应用层数据过大:应用层一次发送的数据可能远大于MSS,TCP会将数据分成多个段发送,这不是分片,而是分段。
// 发送大数据 char data[10000]; send(sock, data, 10000, 0); // TCP会自动分成多个段发送
避免分片的方法:
- 设置合适的MSS值
- 使用路径MTU发现(PMTUD)
- 应用层控制数据大小
工业控制中,通常使用较小的数据包,避免分片带来的延迟和丢包风险。
4.内存泄漏的原因有哪些?如何避免?
内存泄漏是指分配的内存没有被释放,导致可用内存逐渐减少,最终可能导致系统崩溃。
常见原因:
忘记释放内存:new了但没有delete,malloc了但没有free。
void func() {
int* p = new int(10);
// 忘记delete p
} // 内存泄漏
异常导致未释放:函数中途抛出异常,跳过了delete语句。
void func() {
int* p = new int(10);
if (error) {
throw std::exception(); // 跳过delete
}
delete p;
}
循环引用:shared_ptr的循环引用导致引用计数永远不为0。
class Node {
public:
std::shared_ptr<Node> next;
};
auto node1 = std::make_shared<Node>();
auto node2 = std::make_shared<Node>();
node1->next = node2;
node2->next = node1; // 循环引用,内存泄漏
容器中的指针:容器存储裸指针,容器销毁时指针指向的对象没有释放。
std::vector<int*> vec; vec.push_back(new int(10)); // vec销毁时,只释放vector本身,不释放指针指向的内存
避免方法:
使用智能指针:unique_ptr、shared_ptr自动管理内存,不需要手动delete。
void func() {
std::unique_ptr<int> p = std::make_unique<int>(10);
// 函数返回时自动释放
}
使用RAII:资源获取即初始化,用对象生命周期管理资源。
class FileHandler {
FILE* file;
public:
FileHandler(const char* name) { file = fopen(name, "r"); }
~FileHandler() { if (file) fclose(file); }
};
使用容器管理对象:用vector而不是vector<T*>,容器自动管理对象生命周期。
std::vector<std::unique_ptr<int>> vec; vec.push_back(std::make_unique<int>(10)); // vec销毁时,自动释放所有unique_ptr
使用内存检测工具:Valgrind、AddressSanitizer可以检测内存泄漏。
valgrind --leak-check=full ./myapp
代码审查:重点检查new/delete、malloc/free是否配对。
嵌入式开发中,内存泄漏特别危险,因为内存有限,长时间运行后可能导致系统崩溃。要特别注意内存管理。
5.Go语言和C/C++有什么区别?
Go和C/C++都是系统级编程语言,但设计理念和特性不同。
内存管理:Go有垃圾回收(GC),自动管理内存,不需要手动释放。C/C++需要手动管理内存,容易出现内存泄漏。Go的GC简化了开发,但有性能开销和停顿时间。
并发模型:Go原生支持并发,goroutine是轻量级线程,channel用于通信。C/C++需要使用pthread或std::thread,并发编程更复杂。
// Go的并发
go func() {
// 并发执行
}()
ch := make(chan int)
ch <- 10 // 发送
x := <-ch // 接收
// C++的并发
std::thread t([]() {
// 并发执行
});
t.join();
语法简洁:Go语法简单,没有类、继承、泛型(Go 1.18后支持),学习曲线平缓。C++语法复杂,有很多高级特性。
编译速度:Go编译速度很快,适合快速迭代。C++编译速度慢,大型项目编译可能需要很长时间。
性能:C/C++性能更好,没有GC开销,更接近硬件。Go性能略低,但对大多数应用足够。
生态:C/C++生态成熟,有大量库和工具。Go生态较新,但发展很快,特别是在云计算、微服务领域。
适用场景:
- C/C++:系统编程、嵌入式、游戏、高性能计算
- Go:Web服务、微服务、云计算、网络编程
嵌入式开发通常用C/C++,因为资源受限,需要精确控制内存和性能。Go的GC和运行时开销对嵌入式不太友好。
6.list、map、set的区别是什么?
这三个是STL中常用的容器,底层实现和特点不同。
list是双向链表,元素在内存中不连续。插入删除效率高O(1),但随机访问效率低O(n)。不支持下标访问,只能通过迭代器遍历。
std::list<int> lst; lst.push_back(1); lst.push_front(2); lst.insert(lst.begin(), 3); // O(1)插入 // lst.at(0); // 错误,不支持下标访问
map是红黑树实现的关联容器,存储键值对,按键自动排序。查找、插入、删除都是O(log n)。键唯一,不能重复。
std::map<std::string, int> m;
m["apple"] = 1;
m["banana"] = 2;
m.find("apple"); // O(log n)查找
// 遍历时按键排序
for (auto& p : m) {
cout << p.first << ": " << p.second << endl;
}
set也是红黑树实现,存储唯一的元素,自动排序。查找、插入、删除都是O(log n)。元素唯一,不能重复。
std::set<int> s; s.insert(3); s.insert(1); s.insert(2); s.find(2); // O(log n)查找 // 遍历时自动排序:1, 2, 3
对比:
list |
双向链表 |
无序 |
O(n) |
O(1) |
O(1) |
不支持 |
map |
红黑树 |
有序 |
O(log n) |
O(log n) |
O(log n) |
不支持 |
set |
红黑树 |
有序 |
O(log n) |
O(log n) |
O(log n) |
不支持 |
还有unordered_map和unordered_set,基于哈希表实现,查找O(1),但无序。
选择建议:
- 需要频繁插入删除,用list
- 需要键值对映射,用map或unordered_map
- 需要去重和排序,用set
- 追求查找速度,用unordered_map或unordered_set
7.你用过哪些工业通信协议?说说它们的结构
我了解和使用过几种工业通信协议。
Modbus是最常用的工业协议,简单可靠。有Modbus RTU(串口)和Modbus TCP(以太网)两种。
Modbus RTU帧结构:
[从站地址(1字节)] [功能码(1字节)] [数据(N字节)] [CRC校验(2字节)]
功能码:
- 0x03:读保持寄存器
- 0x06:写单个寄存器
- 0x10:写多个寄存器
// Modbus RTU读取示例
uint8_t frame[] = {
0x01, // 从站地址
0x03, // 功能码:读保持寄存器
0x00, 0x00, // 起始地址
0x00, 0x0A, // 寄存器数量
0x00, 0x00 // CRC校验(需要计算)
};
Modbus TCP在Modbus RTU基础上加了MBAP头部:
[事务ID(2字节)] [协议ID(2字节)] [长度(2字节)] [单元ID(1字节)] [功能码+数据]
RS232/RS485是物理层协议,常用于PLC通信。RS232是点对点通信,RS485支持多点通信。
RS232参数:
- 波特率:常用9600、19200、115200
- 数据位:8位
- 停止位:1位
- 校验位:无校验或偶校验
// 串口配置 struct termios options; cfsetispeed(&options, B9600); // 波特率9600 options.c_cflag |= CS8; // 8数据位 options.c_cflag &= ~PARENB; // 无校验 options.c_cflag &= ~CSTOPB; // 1停止位
CAN总线用于汽车和工业控制,支持多主通信,实时性好。
CAN帧结构:
[帧起始] [仲裁段(ID)] [控制段] [数据段(0-8字节)] [CRC] [ACK] [帧结束]
Profibus是西门子的现场总线,速度快,实时性好。
EtherCAT是基于以太网的实时协议,速度很快(微秒级),适合运动控制。
工业协议的特点:
- 可靠性高:有校验机制(CRC、奇偶校验)
- 实时性好:确定性的通信延迟
- 抗干扰强:差分信号、屏蔽电缆
- 简单实用:协议简单,易于实现
8.你接触过哪些设计模式?详细说说其中一个
我接触过几种常用的设计模式,详细说说单例模式。
单例模式保证一个类只有一个实例,并提供全局访问点。适合全局唯一的对象,如配置管理器、日志系统、数据库连接池等。
实现方式:
懒汉式(延迟初始化):第一次使用时才创建实例。C++11后用局部静态变量实现最简单,线程安全。
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance; // C++11保证线程安全
return instance;
}
void doSomething() {
// 业务逻辑
}
private:
Singleton() {} // 私有构造函数
~Singleton() {}
Singleton(const Singleton&) = delete; // 禁止拷贝
Singleton& operator=(const Singleton&) = delete; // 禁止赋值
};
// 使用
Singleton::getInstance().doSomething();
饿汉式(立即初始化):程序启动时就创建实例。线程安全,但可能浪费资源。
class Singleton {
public:
static Singleton& getInstance() {
return instance;
}
private:
Singleton() {}
static Singleton instance; // 静态成员
};
Singleton Singleton::instance; // 程序启动时初始化
双重检查锁定(不推荐):C++11前的线程安全实现,复杂且容易出错。
class Singleton {
public:
static Singleton* getInstance() {
if (instance == nullptr) {
std::lock_guard<std::mutex> lock(mtx);
if (instance == nullptr) {
instance = new Singleton();
}
}
return instance;
}
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
这是一个全面的嵌入式面试专栏。主要内容将包括:操作系统(进程管理、内存管理、文件系统等)、嵌入式系统(启动流程、驱动开发、中断管理等)、网络通信(TCP/IP协议栈、Socket编程等)、开发工具(交叉编译、调试工具等)以及实际项目经验分享。专栏将采用理论结合实践的方式,每个知识点都会附带相关的面试真题和答案解析。
查看20道真题和解析