跳转至

嵌入式开发

介绍

我叫许文泽, 今年25岁, 本科毕业于华东理工大学, 目前在悉尼大学攻读计算机科学硕士, 成绩位于专业前5%. 我具备扎实的计算机基础, 软件开发能力和工程实践经验, 希望未来从事嵌入式软件开发相关工作.

在科研方面, 我主要参与语音, 多模态数据处理和深度学习相关研究, 相关工作发表于 ACPR 2025 和 ICME 2025. 其中, ACPR 2025 论文基于最优传输理论提出了一个语音文本对齐方法, 重点解决不同语言和不同数据集场景下模型泛化能力不足的问题; 同时, 我参与的 ICME 2025 研究工作, 通过双信息适配器结构增强模型对语气, 语调等副语言信息的理解能力. 此外, 我还申请了一项关于多模态数据处理的发明专利.

这些科研经历让我具备了较强的算法理解能力, 代码实现能力和复杂问题分析能力. 在研究过程中, 我不仅关注方法设计, 也需要完成数据处理, 模型训练, 实验调试, 性能分析和结果验证等工作, 因此对程序运行效率, 资源占用, 系统稳定性和实验复现都有较强意识. 这些能力与嵌入式软件开发中强调的严谨实现, 问题定位, 性能优化和稳定性保障具有较高一致性.

在工程实践方面, 我曾在马上消费金融担任算法工程师, 参与语音与多模态模型相关项目研发, 覆盖需求分析, 模块设计, 算法实现, 测试验证到部署上线的完整流程, 具备较完整的工程落地经验. 在项目中, 我注重代码质量, 运行效率和系统稳定性, 也积累了较强的问题排查和协同开发能力. 同时, 我曾在信雅达科技担任全栈工程师, 负责后端 API 开发和前端交互优化, 并接触 Docker, K8S 等工程工具, 进一步提升了我对软件系统开发流程, 接口设计和部署环境的理解.

未来我希望将自己的科研训练和工程实践经验应用到嵌入式开发场景中, 进一步深入 C/C++, Linux, 系统调试, 资源管理和性能优化等方向. 我认为自己具备较好的学习能力, 工程实现能力和问题解决能力, 能够较快适应嵌入式软件开发岗位对稳定性, 实时性和系统效率的要求.

C++

快排

快排是一种基于分治思想的排序算法, 每次选择一个基准值pivot, 通过一次划分操作把数组分为左右两个部分, 使得左边元素都不大于基准值, 右边元素都不小于基准值. 然后递归地对左右子数组继续进行快排, 直到区间长度为0或者1, 平均时间复杂度是O(n logn), 最坏情况是O(n^2), 空间复杂度主要来自递归栈, 平均是O(log n).

智能指针

智能指针是用来自动管理内存生命周期的对象, 定义在里面, 核心思想是RAII, 资源在对象构造的时候获取, 对对象析构的时候自动释放, 避免手动new/delete导致的内存泄露.

  • std::unique_ptr: 独占所有权, 不能拷贝, 只能移动
  • std::shared_ptr: 共享所有权, 引用计数为0的时候释放资源
  • std::weak_ptr: 弱引用, 不增加引用技术, 常用于解决循环引用, 如果两个对象互相持有std::shared_ptr, 就可能出现循环引用, 导致引用计数永远不为0, 内存无法释放

如何比较两个float

不要直接用==比较两个float, 因为浮点数有精度误差, 应该使用误差范围比较, 使用std::fabs获取两者的差的绝对值, 然后判断是否小于某个阈值, 如果小于某个阈值, 说明两个float是相等的.

创建进程

Linux下C++创建进程最常用的是fork()+exec()+waitpid(): fork()复制当前进程, 父进程返回子进程PID, 子进程中返回0, 失败返回-1; 子进程通常调用exec系列函数加载并执行新程序, exec成功后会替换子进程的进程映像, 后续代码不再执行, 失败才会返回, 所以失败后一般使用_exit(127)退出; 父进程可以通过waitpid()等待子进程结束并回收资源, 避免僵尸进程.

如果只是执行shell命令, 可以使用system. 简单启动外部程序可以使用posix_spawn().

创建线程

C++创建进程常用std::thread, 创建std:;thread对象的时候传入线程函数, 线程会立即开始执行, 主线程可以调用join()等待子线程结束, 也可以调用detach()将线程分离, 让它在后台独立运行. 需要注意的是, std:;thread对象在析构前必须已经被join()或者detach()处理, 否则程序会调用std::terminate终止.

创建线程池

C++标准库没有提供线程池类, 通常用std::thread, 任务队列, std::mutexstd::condition_variable实现. 程序启动的时候先创建固定数量的工作线程, 这些线程循环从任务队列中取任务执行; 主线程通过submit()把任务放入队列, 并通知某个工作线程, 线程池析构的时候设置停止标志, 唤醒所有线程并join()回收.

数组名和指针的区别

  • 类型不同: 一个是数组类型, 一个是指针类型
  • sizeof结果不同: 一个是整个数组的大小, 一个是指针本身大小
  • 数组名不能被修改, 指针可以
  • 作为函数参数的时候, 数组会退化为指针

左值和右值的区别

在C++中, 左值有明确的内存位置, 可以取地址; 右值通常是临时值, 不能长期存在.

局部变量, 静态变量, 全局变量在作用域, 生命周期和内存存储位置上的区别

变量类型 作用域 生命周期 内存存储位置
局部变量 只在定义它的代码块或函数内有效 进入作用域时创建, 离开作用域时销毁 通常在栈区
静态局部变量 只在定义它的函数或代码块内有效 程序开始到程序结束 BSS(未初始化)/DATA区(初始化)
全局变量 从定义位置开始, 到文件结束都可访问; 其他文件可通过 extern 访问 程序开始到程序结束 BSS(未初始化)/DATA区(初始化)
静态全局变量 只在当前源文件内有效 程序开始到程序结束 BSS(未初始化)/DATA区(初始化)

结构体大小主要考虑哪些因素

结构体大小不是简单地把所有成员大小相加, 还要考虑内存对齐和填充. 结构体总大小要补齐到最大对齐要求的整数倍.

C语言中全局变量和局部变量可以同名吗

可以的, 当他们同名的时候, 局部变量会遮蔽全局变量, C++里面可以通过::a直接访问被遮蔽的全局变量.

移动语义

比如一个对象内部有一块堆内存, 传统的拷贝会重新申请内存并复制内容, 移动语义可以直接把这块内存的所有权转移给新对象. C++11移动语义通过右值引用T&&实现, 可以把临时对象或不再使用对象中的资源直接转移给新对象, 避免不必要的拷贝. std::move可以把左值转为右值引用, 从而触发移动构造函数.

单例模式

单例模式就是保证一个类在程序中只有一个实例, 并提供一个全局访问点, C++中常用局部静态变量实现. 通过getInstance()返回唯一的实例.

多态

多态是同一个函数调用, 根据对象类型不同, 表现出不同的行为. 运行时多态需要满足三个条件:

  • 有继承关系
  • 父类中有虚函数virtual
  • 通过父类指针或引用调用虚函数

底层通过vtablevptr实现, 类中有虚函数的时候, 对象内部会有一个虚函数指针vptr, vptr指向该类的虚函数表, 调用虚函数的时候, 会根据对象真实类型区虚函数表里找对应的函数.

重载的时候, 对返回值类型是否有要求

没有要求, 不能仅仅通过返回值类型不同来重载函数, 重载要求函数名相同, 参数列表不同; 返回值可以不同, 但是必须建立在参数列表不同的基础上.

static的作用

  1. 局部变量前面的static: 改变生命周期, 贯穿整个程序的运行, 作用域仍然是函数内部, 多次调用函数的时候, 值会被保留.
  2. 全局变量前面的static: 改变可见范围, 只在当前的源文件中使用, 其他的C++文件无法通过extern关键字访问.
  3. 函数前面的static: 改变可见范围, 只在当前的源文件中调用, 其他文件无法链接到它.
  4. 静态成员变量: 只属于类本身, 不属于某个对象, 所有对象共享一份.
  5. 静态成员函数: 只属于类本身, 不属于某个对象, 所有对象共享一份, 没有this指针, 只能直接访问静态成员, 不能直接访问普通成员.

volatile的作用

volatile表示这个变量的值可能在程序看不到的地方改变, 所以编译器不要随便优化它的读写, 每次读取的时候, 都要真的去内存里都, 而不是寄存器里面的缓存值.

volatile不等于线程安全, 不能保证原子性, 线程安全应该使用std::atomic.

register的作用

register用来建议编译器, 把变量放到寄存器里面, 提高访问速度, 而不是普通内存中. 现在基本不常用, 现代编译器的优化能力很强, 会自己决定哪些变量放寄存器, 通常比程序员手动指定更好.

栈里面主要存的是啥

栈里面主要存的是局部变量, 函数参数, 返回地址, 保存的寄存器.

关于寄存器, 假设有两个函数:

void foo() {
    int x = 10;
    bar();
    cout << x;
}

void bar() {
    int y = 20;
}

在函数调用的时候, 编译器要保证: 调用bar()之后, foo()还能继续正确运行. 因此, 有些寄存器的值需要在调用之前保存起来, 调用结束的时候再恢复.

类和结构体的差异

  • 默认成员访问权限: 一个是public, 一个private
  • 默认继承方式: 一个是public, 一个是private

结构体和联合体的差异

结构体中, 每个成员都有自己的内存; 联合体, 所有成员共用同一块内存. 换句话说, 结构体可以保存多个成员, 但是联合体不可以保存多个成员. 结构体的大小要考虑内存对齐, 而联合体的大小是最大的成员变量. 结构体通常用于表示组合数据, 联合体通常用于表示同一块数据的不同解释, 比如说SDL_Event这个的实现, 这个union用于表示是什么事件.

常见的容器有哪些

C++常见容器主要来自STL标准模版库.

  1. 顺序容器

    • vector: 元素按照顺序存放. 内存连续, 尾部插入快, 中间插入/删除慢, O(1)
    • array: 大小固定, 内存连续, 比普通数组更安全, 更方便, O(1)
    • deque: 双端队列, 头尾插入删除都快, 适合在头部和尾部都频繁插入删除的场景, O(1)
    • list: 双向链表, 中间插入/删除快, 不支持随机访问, O(n)
    • forward_list: 单向链表, 比list更省空间, 但是只能往一个方向遍历, O(n)
  2. 关联容器, 底层实现红黑树

    • set: 集合, 元素不重复, 自动排序, O(log n)
    • map: 键值对, key不重复, 自动按照key排序, O(log n)
    • multiset: 允许重复元素的set, O(log n)
    • multimap: 允许重复key的map, O(log n)
  3. 无序关联容器, 底层实现哈希表

    • unordered_list: 无序集合, 元素不重复, O(n)
    • unordered_map: 无序键值对, O(n)
    • unordered_multiset: 无序版本的multiset, O(n)
    • unordered_multimap: 无序版本的multimap, O(n)
  4. 容器适配器

    • stack: 栈, 后进先出, LIFO, 底层实现deque
    • queue: 队列, 先进先出, FIFO, 底层实现deque
    • priority_queue: 优先队列, 默认最大值优先出来, 底层实现vector+堆

哈希冲突的概念, 如何解决

哈希表的核心思想就是位置=hash(key)%table_size; 哈希冲突就是不同的key经过哈希函数计算之后, 得到了同一个存储位置. 解决哈希冲突的访问有两种方法: 1. 链地址法, 如果多个元素映射到同一个位置, 那么就在这个位置挂一个链表或者其他结构(unordered_mapunordered_set就是这种实现方法); 2. 开放寻址法, 如果当前位置被占了, 就继续在数组里找下一个空位置.

装载因子表示哈希表有多满: 装载因子 = 元素个数 / 桶的数量.

gdb用到哪些命令

要使用gdb, 要在编译的时候使用-g使用编译器生成调试信息, -g会把源码行号, 变量名, 函数名, 类型信息等信息放到可执行文件里面, 不加-g, 变量名, 源码行号, 局部变量可能看不到.

g++ -g main.cpp -o main
gdb ./main
  1. 启动程序: 使用run启动程序, 可以在run后面加上命令行参数, 比如run arg1 arg2
  2. 查看源码: 使用list命令查看源码, list默认显示当前行的前后10行代码, 也可以指定行号或者函数名, 比如list 20显示第20行附近的代码, list foo显示函数foo附近的代码
  3. 设置断点: 使用break命令设置断点, 比如break 20在第20行设置断点, break foo在函数foo的入口设置断点
  4. 删除断点: 使用delete命令删除断点, 比如delete 1删除编号为1的断点, delete删除所有断点
  5. 单步调试: 使用step命令单步调试, 进入函数内部; 使用next命令单步调试, 不进入函数内部
  6. 继续执行: 使用continue命令继续执行程序, 直到下一个断点或者程序结束
  7. 执行完成当前函数: 使用finish命令执行完当前函数, 返回到调用该函数的地方
  8. 跳出循环或执行到某一行: 使用until命令, 比如until 30执行到第30行, until foo执行到函数foo的结尾
  9. 打印变量的值: 使用print命令打印变量的值, 比如print x打印变量x的值, print *ptr打印指针ptr指向的值
  10. 持续打印变量的值: 使用display命令持续打印变量的值, 比如display x每次停下来都会打印变量x的值
  11. 打印数组的前10个元素: 使用print array@10打印数组array的前10个元素
  12. 查看变量类型: 使用ptype查看变量的类型, 比如ptype x查看变量x的类型
  13. 查看调用栈: 使用backtrace查看调用栈, backtrace full查看调用栈和每一帧的局部变量
  14. 切换栈帧: 使用frame命令切换栈帧, 比如frame 0切换到当前栈帧, frame 1切换到上一个栈帧
  15. 退出gdb: 使用quit命令退出gdb

什么是纯虚函数, 和一般的虚函数的区别

普通虚函数就是基类有一个默认方法, 子类想改可以改. 纯虚函数就是基类只规定必须有这个功能, 具体怎么做是由子类决定的, 类似于Java中的interface. 例如:

class Shape {
    public:
    virtual double area() = 0;
}

普通虚函数基类里面是有默认实现的:

class Animal {
public:
    virtual void speak() {
        cout << "Some animal sound" << endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        cout << "Woof" << endl;
    }
};

并且, 若一个类至少含有一个纯虚函数, 那么这个类就是抽象类, 不能被实例化.

大小端

大小端指的是: 一个多字节数据在内存里面存放的时候, 高位字节和低位字节的排列顺序. 如对于一个4字节整数0x12345678, 在小端机器上, 它在内存中的存储顺序是0x78 0x56 0x34 0x12; 在大端机器上, 它在内存中的存储顺序是0x12 0x34 0x56 0x78.

大端序可以理解为正常阅读顺序, 小端序则反过来. 主流的系统基本上都是小端, 比如x86架构的CPU. 大端序在网络协议中比较常见.

newmalloc的区别

  • malloc只是分配内存, new分配内存+调用构造函数
  • free只是释放内存, delete释放内存+调用析构函数

malloc返回的是void*, 需要强制类型转换; new返回的是正确类型的指针, 不需要强制类型转换. malloc需要手动指定字节数量, new根据类型自动计算需要的字节数量. malloc分配失败返回NULL, new分配失败抛出std::bad_alloc异常. malloc是C标准库函数, new是C++运算符.

C++内存结构

高地址
┌────────────────────┐
│       栈区 stack    │  局部变量, 函数参数, 返回地址
├────────────────────┤
│                    │
│       内存映射区    │  动态库, mmap, 共享内存
│                    │
├────────────────────┤
│       堆区 heap     │  new / malloc 分配的内存
├────────────────────┤
│       BSS 区        │  未初始化的全局变量, 静态变量
├────────────────────┤
│       数据区 data   │  已初始化的全局变量, 静态变量
├────────────────────┤
│      只读数据区     │  字符串常量, const 全局常量
├────────────────────┤
│      代码区 text    │  程序指令
└────────────────────┘
低地址
  • 代码区: 存放程序的机器指令, 也就是编译之后的函数代码. 通常只读, 可执行, 大小在程序运行的时候基本固定
  • 只读数据区: 存放只读常量, 比如字符串字面量, 比如const char* p = "hello";, hello通常放在只读数据区, p这个指针变量放在哪里, 看它是局部变量还是全局变量.
  • 数据区data: 存放已经初始化的全局变量和静态变量
  • BSS区: 存放未初始化的全局变量和静态变量, 或者初始化为0的全局/静态变量
  • 堆区: 堆区用于动态内存分配, new, malloc出来的内存都在堆区, 程序员手动申请和释放, 空间较大, 生命周期由程序控制, 容易出现内存泄露
  • 栈区: 存放局部变量, 函数参数, 返回地址, 保存的寄存器. 由编译器自动管理, 函数调用的时候分配, 函数返回的时候释放, 速度快, 空间相对较小.
  • 内存映射区: 用于存放动态链接库, mmap映射文件, 共享内存等等, 如动态库libc.so, libstdc++.so会被映射到这个区域.

栈的作用

保存局部变量, 保存函数参数, 保存返回地址, 保存函数调用现场.

strcpymemcpy的区别

  • strcpy是字符串复制函数, 以'\0'为结束标志, 只能复制字符串, 不能复制二进制数据, 需要保证目标缓冲区足够大, 否则会发生缓冲区溢出.
  • memcpy是内存复制函数, 可以复制任意类型的数据, 需要指定要复制的字节数, 不会检查目标缓冲区是否足够大, 如果字节数超过了目标缓冲区的大小, 也会发生缓冲区溢出.

动态链接库和静态链接库的区别

  • 静态链接库: 会在编译链接阶段被打爆进入最终的可执行文件. 例如gcc main.io libfoo.a -o main, 生成的main可执行文件中已经包含了libfoo.a里面的代码. 优点是运行的时候不需要libfoo.a, 部署简单, 不容易因为系统缺少库而运行失败, 版本更可控; 缺点是可执行文件体积较大, 多个程序使用同一个库, 代码重复, 库更新后, 需要重新链接甚至重新发布程序.
  • 动态链接库: 动态库不会直接完整打包进可执行文件, 而是在程序启动或者运行过程中加载. 例如gcc main.io -lfoo -o main, 生成的main可执行文件中没有包含libfoo.so的代码, 运行的时候需要系统中有libfoo.so, 程序会在运行时加载这个库. 优点是可执行文件体积较小, 多个程序可以共享同一个库, 库更新后, 不需要重新链接程序; 缺点是部署复杂, 可能因为系统缺少库或者库版本不兼容而运行失败.

嵌入式

这里主要讲的是芯片间通信总线, 主要用于SoC和外设芯片之间的通信, 例如传感器, Flash, 屏幕, 音频等等.

波特率

波特率Baud Rate可以策略理解为每秒传输多少个bit. 常见的波特率有: 9600, 19200, 38400, 115200等等. 波特率越高, 数据传输速度越快, 但是对信号质量的要求也越高.

MCU

MCU是Microcontroller Unit的缩写, 中文叫微控制器. 它是一个集成了CPU, 内存, 外设等功能的芯片, 通常用于嵌入式系统中.

对比项 MCU CPU
定位 控制设备 运行复杂系统
集成度 高, 内部带 Flash/RAM/外设 通常需要外部内存和芯片组
性能 较低到中等 较高
功耗 较高
成本
系统 可裸机/RTOS 常跑 Linux/Windows
典型用途 控制器, 传感器, 电机 电脑, 手机, 服务器

UART

UART是最常见, 最简单的串行通信接口之一. 全名为Universal Asynchronous Receiver/Transmitter. 中文名叫通用异步收发器. 通过TX, RX, GND三根线, 让两个设备按照约定好的速度一位一位的传输数据. 全双工.

连接的时候, 要交叉链接:

设备 A TX  ---->  设备 B RX
设备 A RX  <----  设备 B TX
GND        ----   GND

UART发送数据的时候不是直接丢8个bit, 而是会加一些辅助位, 常见的格式是1位起始位+8位数据位+1位停止位, 经常写成8N1, 表示8个数据位, 没有校验位, 1个停止位, 比如发送一个字节, UART实际上的格式是:

起始位  数据位              停止位
  0     01000001             1

平时UART线路空闲的时候通常是高电平1, 当要开始发送数据的时候, 先拉为0, 这就是起始位, 接收方看到线路突然从1变为0, 就知道数据要来了, 然后按照约定的波特率, 每隔一定时间采样一次线路的电平, 采样8次, 就得到了8位数据, 最后再采样一次停止位, 如果是1, 就说明数据接收成功.

异步通信就是不使用共同的时钟线, 而是发送方和接收方提前约定好速度, 再通过起始位, 停止位等方式完成数据同步. 假设两个人打电话聊天, 你不用没说一个字都敲一下铃提醒对方, 对方只要知道你说话的大概节奏, 就能听懂, 你说一句, 对方听一句, 这就有点像异步通信. UART没有专门的CLK时钟线.

IIC

IIC是一种两线制同步串行总线. 半双工. 中文名称为集成电路总线. IIC只需要两根信号线:

  • SCL: Serial Clock, 串行时钟线
  • SDA: Serial Data, 串行数据线

IIC不是像UART那种交叉的接法, 而是:

单片机 SCL  ----  模块 SCL
单片机 SDA  ----  模块 SDA
单片机 GND  ----  模块 GND
单片机 VCC  ----  模块 VCC

IIC是同步通信, 因为它有一根专门的时钟线SCL. SDA上的数据, 跟着SCL的节奏变化和读取.

IIC通信里面通常有两种角色, 主机master和从机slave. 主机发起通信, 控制始终; 从机被主机访问, 响应数据. 一般情况下, 单片机=主机, 传感器/OLED/EEPROM=从机.

IIC很大的优点是, 一组SCL和SDA可以挂多个设备, 比如:

           +---- OLED 屏
SCL -------+
SDA -------+---- 温湿度传感器
           +
           +---- EEPROM
           +
           +---- RTC 时钟

这些设备共用同一组SCL和SDA. 那么主机怎么知道和谁通信? 靠的是设备地址. 每个IIC设备都有一个唯一的地址, 主机在通信的时候会先发送设备地址, 只有地址匹配的设备才会响应. 所以假设单片机要读取温度传感器的数据, 大概过程是:

步骤 谁控制 SDA 作用
START 主机 通知通信开始
发送从机地址 + 写位 主机 选择要通信的设备
ACK 从机 表示地址正确, 设备存在
发送寄存器地址 主机 告诉从机要写哪个位置
ACK 从机 表示寄存器地址收到
发送数据 主机 写入真正的数据
ACK 从机 表示数据收到
STOP 主机 通信结束
  • 主机写从机

    • SCL低电平: 主机准备SDA
    • SCL高电平: 从机读取SDA
  • 主机读从机

    • SCL低电平: 从机准备SDA
    • SCL高电平: 主机读取SDA

读写是通过一开始发送的读写位来区分的, 读写位在发送从机地址的时候就已经确定了.

SPI

SPI, Serial Peripheral Interface, 串行外设接口. SPI是一种全双工的同步串行通信协议. SPI通常需要4根线:

  • SCLK: Serial Clock, 串行时钟线
  • MOSI: Master Out Slave In, 主输出从输入线
  • MISO: Master In Slave Out, 主输入从输出线
  • CS/SS/NSS: Chip Select / Slave Select, 片选线

拓扑图如:

          +---- Flash
SCK  -----+---- OLED
MOSI -----+---- 传感器
MISO -----+---- 传感器/Flash 等

CS1  ---------- Flash
CS2  ---------- OLED
CS3  ---------- 传感器

假设单片机要和一个Flash芯片通信:

  • 单片机 = master
  • Flash芯片 = slave

通信流程大概是:

1. 主机把 CS 拉低, 选中 Flash
2. 主机通过 SCK 产生时钟
3. 主机通过 MOSI 发送数据
4. 从机通过 MISO 返回数据
5. 通信结束后, 主机把 CS 拉高

SPI比IIC通常更快, 因为它有独立的数据发送和接收线, 所以SPI可以一边发, 一边收, 这叫做全双工通信. 和IIC的区别:

对比 SPI IIC
线数量 较多 较少
数据线 MOSI, MISO SDA
时钟线 SCK SCL
选设备方式 CS 片选 地址
速度 通常更快 通常较慢
通信方式 全双工 半双工

IIC/SPI/UART区别

项目 UART SPI I2C
全称 Universal Asynchronous Receiver/Transmitter Serial Peripheral Interface Inter-Integrated Circuit
通信方式 异步串行 同步串行 同步串行
时钟线 无独立时钟 有, SCLK 有, SCL
数据线 TX, RX MOSI, MISO SDA
典型线数 2 根 4 根或更多 2 根
双工能力 全双工 全双工 半双工
主从结构 点对点为主 一主多从常见 一主多从, 多主也可
地址机制 无内置地址 片选 CS 区分设备 7 位 / 10 位地址
速度 中等 较高 中低到中等
硬件复杂度 中等 中等
协议复杂度 低到中 中等
典型距离 较长, 低速可更长 短距离板级通信 短距离板级通信

CAN

CAN总线, Controller Area Network, 是一种常用于汽车, 工业控制, 机器人, 电池管理系统等场景的多主机, 差分, 广播式串行总线.

CAN通信一般用两根线, CAN_H和CAN_L, 他们是一对差分线, 可以理解为CAN_H和CAN_L的电压差就是要传的信息. Node1, Node2, Node3表示CAN总线上的设备, 他们都接在同一对线上.

显性0, 隐性1

CAN总线遵循"显性0, 隐性1"的准则, 当CAN发送显性0的时候, CAN_H电压升高, CAN_L电压降低, 两根线之间出现明显电压差; 当CAN发送隐性1的时候, CAN_H=CAN_L, 两根线电压很小. 如果一个节点发0, 另一个节点发1, 最终总线上表现出来的是0.

仲裁机制

这和仲裁相关, CAN允许多个节点同时开始发消息. 如果两个节点同时发, 谁先发? CAN的规则是ID越小, 优先级越高, 因为ID是一位一位发送出去比较的. 例如, 节点A要发100, 节点B要发000, 第一位由于B是0, 所以结果是0, 所以A自动退出, B继续发. 所以谁的ID更早出现0, 谁更容易赢, 所以ID数值越小, 优先级越高.

CAN的仲裁叫做"非破坏性仲裁", 多个节点同时发送的时候, 优先级最高的报文继续发送, 其他节点自动停止, 不会破坏总线数据.

帧类型

CAN主要有四类帧:

  • 数据帧: 发送数据
  • 远程帧: 请求别人发送某个ID的数据
  • 错误帧: 节点检测到错误时发送
  • 过载帧: 请求延迟下一帧发送

远程帧现在用的很少, 主要是数据帧.

数据帧格式

标准数据帧共11位ID.

  • SOF: Start of Frame
  • Identifier: 报文ID, 决定优先级
  • RTR: 远程请求位, 数据帧为0, 远程帧为1
  • IDE: 标准帧/扩展帧标志
  • DLC: 数据长度, 经典CAN为0-8个字节
  • Data: 数据字段
  • CRC: 循环冗余校验
  • ACK: 接收节点确认位
  • EOF: End of Frame

扩展数据帧共29位ID. 额外的18位ID.

外设

GPIO

GPIO是芯片, 单片机, 树莓派, 开发板上的一种引脚, 用来和外部设备交互.

  • 输入: 读取外部信号, 例如读取按钮是否被按下
  • 输出: 控制外部设备, 例如点亮LED, 控制蜂鸣器, 继电器, 电机驱动

常见的状态有:

  • 高电平: 通常表示1
  • 低电平: 通常表示0

PWM

PWM, Pulse with modulation, 脉冲宽度调制是一种通过快速切换数字信号的高低电平, 来模拟信号的技术. 核心概念有:

  • 频率: 每秒切换的周期数
  • 占空比: 一个周期内高电平所占的比例

可用于调节LED亮度, 控制舵机角度, 电机调速, 音频输出. PWM本质上是一种GPIO输出的特殊格式, 许多MCU的GPIO引脚支持硬件PWM, 也可以用软件模拟.

ADC

Analog to Digital Converter, 模数转换器, 将现实世界的模拟信号->数字信号, 让芯片能够读懂连续变化的物理量, 例如, 读取温度传感器输出的电压, 转换为数字值, 关键参数如分辨率, 采样率.

DAC

和ADC相反, 将数字信号->模拟信号, 让芯片能输出连续变化的电压. 如播放音频, 输出波形信号, 关键参数包括分辨率, 输出电压范围.

外设设计思路

驱动器常见结构

通常会分为这么几层:

应用层
设备驱动层: LCD_DrawPoint(), Flash_Read(), Sensor_GetData()
总线抽象层: I2C_Read(), SPI_Write(), GPIO_Set()
MCU HAL/寄存器层
硬件外设

不同I2C_Read()有不同的HAL实现, 对于不同的MCU只需要更换HAL层就可以了.

传感器驱动设计思路

以STEM32为例, 以SHT30温湿度传感器为例, 它是一个常见的I2C传感器, 目标是MCU读取温度和湿度.

  1. 硬件链接

    SHT30        MCU
    VCC   --->   3.3V
    GND   --->   GND
    SCL   --->   I2C_SCL
    SDA   --->   I2C_SDA
    

    SHT30通过I2C和MCU通信.

  2. 驱动设计思路

    SHT30的驱动大概分为3个函数:

    SHT30_Init();          // 初始化
    SHT30_ReadData();      // 读取原始数据
    SHT30_GetTempHumi();   // 换算成温湿度
    

    整体流程:

    MCU 初始化 I2C
    发送测量命令给 SHT30
    等待测量完成
    读取 6 个字节数据
    把原始数据换算成温度和湿度
    
  3. 驱动代码示例

#include "sht30.h"

#define SHT30_ADDR  (0x44 << 1)

extern I2C_HandleTypeDef hi2c1;

uint8_t SHT30_ReadData(float *temperature, float *humidity)
{
    uint8_t cmd[2] = {0x2C, 0x06};
    uint8_t data[6];

    uint16_t raw_temp;
    uint16_t raw_humi;

    // 1. 发送测量命令
    if (HAL_I2C_Master_Transmit(&hi2c1, SHT30_ADDR, cmd, 2, 100) != HAL_OK)
    {
        return 1;
    }

    // 2. 等待测量完成
    HAL_Delay(20);

    // 3. 读取 6 字节数据
    if (HAL_I2C_Master_Receive(&hi2c1, SHT30_ADDR, data, 6, 100) != HAL_OK)
    {
        return 2;
    }

    // 4. 合成原始温度数据
    raw_temp = (data[0] << 8) | data[1];

    // 5. 合成原始湿度数据
    raw_humi = (data[3] << 8) | data[4];

    // 6. 换算成真实温度和湿度
    *temperature = -45.0f + 175.0f * raw_temp / 65535.0f;
    *humidity = 100.0f * raw_humi / 65535.0f;

    return 0;
}

头文件sht30.h:

#ifndef __SHT30_H
#define __SHT30_H

#include "main.h"

uint8_t SHT30_ReadData(float *temperature, float *humidity);

#endif

main.c里面这样用:

float temp;
float humi;

while (1)
{
    if (SHT30_ReadData(&temp, &humi) == 0)
    {
        printf("Temp: %.2f C, Humi: %.2f %%\r\n", temp, humi);
    }
    else
    {
        printf("SHT30 read error\r\n");
    }

    HAL_Delay(1000);
}

Linux

字符设备

Linux中很多硬件设备都会被抽象为一个文件, 例如:

/dev/led
/dev/key
/dev/ttyS0

用户程序可以像操作普通文件一样操作设备:

open("/dev/led", O_RDWR);
write(fd, ...);
read(fd, ...);
ioctl(fd, ...);
close(fd);

这类设备就叫做字符设备. 驱动里面一般会实现这些接口:

struct file_operations fops = {
    .open = xxx_open,
    .read = xxx_read,
    .write = xxx_write,
    .unlocked_ioctl = xxx_ioctl,
    .release = xxx_release,
};

典型的字符设备包括:

  • 串口
  • LED控制设备
  • GPIO控制设备
  • I2C/SPI访问接口
  • ...

简单的说, 字符设备关注的是: 用户程序如何访问设备.

设备树

设备树就是给Linux内核看的硬件说明书, 它描述:

  • 有什么硬件
  • 硬件地址是多少
  • 用哪个中断
  • 接了哪些GPIO/时钟/引脚
  • 是否启用

常见的文件:

  • .dts: 源文件
  • .dtsi: 公共包含文件
  • .dtb: 编译后的二进制文件

平台设备

平台设备主要用于描述SoC内部那些不能自动发现的硬件, 比如SPI从设备

对象 能不能自动发现 是否写设备树
SPI 控制器 不能
SPI 从设备 不能
USB 控制器 通常不能
U盘 不用

SPI从设备要写在设备树里面:

spi0: spi@10020000 {
    compatible = "vendor,my-spi";
    reg = <0x10020000 0x1000>;
    status = "okay";

    flash@0 {
        compatible = "jedec,spi-nor";
        reg = <0>;                 // CS0
        spi-max-frequency = <50000000>;
    };
};

USB 控制器也要写设备树; 但是USB 外设可以被 USB 协议自动枚举.

内核模块

内核模块是可以动态加载到Linux内核中的代码, 无需重启系统或者重新编译内核, 常见用途有:

  • 设备驱动
  • 文件系统
  • 网络协议
  • 内核调试/监控功能

特点有:

  • 动态性: 运行时按照需要加载或者写在
  • 内核态运行: 拥有最高权限, 直接访问硬件
  • 扩展内核功能: 不修改内核本身的情况下添加功能

常见命令:

lsmod          # 查看已加载模块
modprobe xxx   # 加载模块 (自动处理依赖)
insmod xxx.ko  # 直接加载模块文件
rmmod xxx      # 卸载模块
modinfo xxx    # 查看模块信息

优点是灵活, 节省内存, 便于开发调试; 缺点是模块奔溃会导致整个内核奔溃.

sysfs, procfs

它们看起来像普通文件目录, 但里面的文件大多数不在磁盘上, 而是内核动态生成的. 用户态程序通过 cat/read/write/ioctl 等方式访问它们, 本质上是在和内核交互.

  • procfs: 挂载在/proc, 主要用来暴露进程信息, 内核运行状态, 系统参数, 内核统计信息
  • sysfs: 挂载在/sys, 主要用来暴露Linux的设备模型, 包括设备, 驱动, 总线, 类别, 模块, 内核对象
/dev    -> 设备节点,主要用于真正操作设备
/proc   -> 查看进程和内核状态
/sys    -> 查看/配置设备和驱动属性

RTOS

RTOS是Real-Time Operating System, 中文叫做实时操作系统. 它是一种专门为了及时响应任务设计的操作系统, 重点不是跑得快, 而是在规定时间内一定完成. 普通操作系统(例如Windows, macOS)更加关注多任务体验, UI, 吞吐量, 用户交互; RTOS更加关注响应时间, 任务调度精准, 稳定性, 低延迟.

特点

  • 实时性: 分为两类
    • 硬实时: 超时 = 系统失败, 例如飞机控制, ABS刹车, 心脏起搏器
    • 软实时: 偶尔超时还能接受, 例如视频会议, 音频播放, 游戏
  • 任务调度

    RTOS会按照优先级调度任务, 例如, 刹车控制最高, 空调显示低, 蓝牙日志更低. 高优先级任务可以抢占低优先级任务, 这叫做抢占式调度.

  • 低延时

    RTOS会尽量减少: 中断响应时间, 上下文切换时间, 调度延迟.

  • 资源占用小

    很多RTOS运行在MCU上面, RAM几十KB, Flash几百KB, 非常轻量

常见RTOS

工业/嵌入式里常见的有FreeRTOS, Zephyr, VxWorks, RT-Thread, ThreadX, QNX.

Linux不是RTOS, 默认的Linux不是严格实时, 更加偏向通用OS, 但是可以通过PREEMPT_RT patch和实时内核变为类实时系统.

任务调度算法

  • 抢占式调度

    当一个更高优先级任务就绪的时候, 系统会立即中断当前正在运行的低优先级任务, 把CPU交给高优先级任务. 特点是实时性好, 响应速度快, 适合对时间要求严格的系统, 需要频繁进行上下文切换.

  • 时间片轮转调度

    时间片调度通常用于相同优先级的多个任务. 系统给每个任务分配一个固定运行时间, 称为时间片. 时间片用完后, 切换到同优先级的下一个任务. 特点是同优先级任务之间公平, 防止某个任务长期占用CPU, 常用于普通任务调度, 时间片太短会增加切换开销, 太长会降低响应性.

上下文切换

CPU同一时刻只能执行一个任务, 但是RTOS中可能有很多任务, 例如, 任务A: 采集传感器数据; 任务B: 控制电机; 任务C: 处理通信数据. RTOS需要在这些任务之间来回切换, 让它们看起来像是在同时运行.

任务运行的时候, CPU内部有很多关键状态, 例如:

  • 程序计数器PC: 记录任务执行到哪一条指令
  • 栈指针SP: 记录任务当前栈的位置
  • 通用寄存器: 保存临时变量, 中间结果
  • 状态寄存器: 保存CPU当前状态
  • 任务控制块TCB: 保存任务相关信息

当任务被暂停的时候, RTOS会将这些信息保存起来; 当任务再次运行的时候, RTOS就会恢复这些信息, 使得任务可以从上次暂停的位置继续执行.

信号量

  • 二值信号量

    只有两种状态, 0和1. 例如中断通知任务:

    任务 A 等待信号量
    外设中断发生
    中断服务函数释放信号量
    任务 A 被唤醒并执行
    

    比如串口收到数据后, 中断释放信号量, 通知任务开始处理数据.

  • 计数信号量

    计数信号量可以大于1, 用来表示某种资源的数量. 例如管理3个缓冲区:

    系统中有 3 个空闲缓冲区
    计数信号量初值 = 3
    任务申请一个缓冲区, 信号量减 1
    任务释放一个缓冲区, 信号量加 1
    

    如果信号量变为0, 说明没有空闲缓冲区, 任务需要等待.

互斥锁

互斥锁用来保护共享资源, 防止多个任务同时访问. 共享资源可以是全局变量, 外设寄存器, 文件系统, 打印接口, 通信总线, 如I2C等.

任务 A 获取互斥锁
任务 A 使用串口
任务 A 释放互斥锁
任务 B 获取互斥锁
任务 B 使用串口
任务 B 释放互斥锁

优先级反转/继承

优先级反转就是:

低优先级任务 L 获取了互斥锁
高优先级任务 H 也需要这个锁, 但被阻塞
中优先级任务 M 一直运行
导致 H 长时间无法执行

优先级继承就是: 当高优先级任务等待低优先级任务释放锁时, RTOS 会临时提高低优先级任务的优先级, 让它尽快执行并释放锁.

L 持有锁
H 等待锁
RTOS 临时提高 L 的优先级
L 尽快运行并释放锁
H 获得锁继续执行

事件标志组

事件标志组用于任务等待一个或者多个事件的发生. 他可以被理解为一组二进制标志位:

bit0: 按键事件
bit1: 串口接收完成
bit2: 网络连接成功
bit3: 传感器数据准备好

每一位代表一个事件. 事件标志组的作用, 适合处理:

  • 等待多个事件中的任意一个
  • 等待多个事件全部发生
  • 多任务事件通知
  • 状态组合判断

等待任意事件: OR

等待 bit0 或 bit1

含义是:

按键事件发生
串口接收完成

只要其中一个发生, 任务就可以继续运行.


等待全部事件: AND

等待 bit1 和 bit2

含义是:

串口接收完成
并且
网络连接成功

两个事件都满足后, 任务才继续运行.

消息队列

消息队列是一种任务间通信机制, 可以在任务之间传递一条或者多条消息. 发送任务把消息放进队列, 接收任务从队列中取消息.

消息队列的特点有: 可存多条消息, 先进先出, 可阻塞等待, 可用于任务同步, 可传递数据.

串口中断接收到数据
把数据放入消息队列
通信任务从队列取数据
解析协议

邮箱

邮箱也是任务间通信的方式, 但它常常只保存一条消息. 可以理解为, 邮箱是容量为1的消息队列. 邮箱中通常存放的是一个数据, 一个指针, 一个事件信息, 一个结构体地址.

消息队列/邮箱和信号量的区别

前者只表示事件发生了; 后者可以表示事件发生了, 并且通常带有具体数据.

中断上下文和任务上下文

任务上下文 (Task Context)是 普通任务运行时所处的执行环境.

包含的状态:

  • CPU寄存器 (PC, SP, LR, 通用寄存器)
  • 任务私有栈
  • TCB中保存的所有信息

特点:

  • 可以被调度器抢占或挂起
  • 可以调用阻塞API (等待信号量/队列/延时等)
  • 拥有独立的栈空间
  • 上下文切换时完整保存/恢复

中断上下文 (Interrupt Context)是CPU响应中断时所处的执行环境, 运行ISR (中断服务程序).

特点:

  • 不属于任何任务, 借用被打断任务的栈 (或独立中断栈)
  • 禁止调用阻塞API — ISR不能等待, 否则系统崩溃
  • 执行时间必须极短
  • 通常只做标志位设置/发送通知, 将处理推迟到任务

操作系统

死锁

死锁是因为多个进程在执行过程中, 互相等待对方持有的资源而无法继续执行. 死锁产生必须同时满足4个条件:

  • 互斥条件: 一个资源一次只能被一个进程占用
  • 请求并保持条件: 已经持有资源的进程可以继续请求其他资源, 并且不释放已有资源
  • 不可剥夺条件: 资源不能被强制抢走, 只能由持有者主动释放
  • 循环等待: 多个进程形成环形等待关系

只要这4个条件同时成立, 就产生死锁.

如何解决死锁问题呢?

  1. 预防死锁

    破坏死锁的4个必要条件中的至少一个. 让资源可以共享使用; 一次性申请所有的资源; 请求新资源失败的时候, 主动释放已有资源; 规定资源的申请顺序, 避免循环等待.

  2. 避免死锁

    通过仔细地资源分配和进程调度, 使系统永远不会进入死锁状态, 例如银行家算法.

  3. 解除死锁

    当发现有进程死锁后, 立即把他从死锁状态中解除出来, 例如剥夺资源, 撤销进程.

物理地址和虚拟地址, 如何映射

物理地址是内存条上真实存在的地址, 是实际硬件上的地址, 每个存储单元都有一个唯一的物理地址. 虚拟地址是程序生成的地址, 当程序访问内存的时候, 它使用的是虚拟地址.

CPU里面有一个硬件叫做MMU, 它负责把虚拟地址转换为物理地址, 转换的时候会查一张表, 叫做页表. 页表记录的是虚拟页->物理页. 如果程序方位的地址是0x1234, 假设页表的大小是0x1000, 若页表告诉我们虚拟页1->物理页8, 那么物理地址就是物理页8的起始物理地址+偏移0x234 = 0x8234.

  • 虚拟地址 = 虚拟页号地址 + 页内偏移
  • 物理地址 = 物理页号 + 页内偏移

字符设备和块设备的区别

  • 字符设备按照字节流的方式访问数据. 也就是说, 数据通常是一个字节一个字节, 一个字符一个字符地读写, 像水流一样连续. 比如键盘输入h e l l o, 程序只能按照顺序读取这些输入. 常见的字符设备有: 键盘, 鼠标, 串口等.
  • 块设备按照固定大小的数据块访问磁盘, 常见块的大小可能是512字节, 4KB等等, 典型的块设备就是磁盘. 程序可以随机访问磁盘上的数据块, 不需要按照顺序访问. 块设备通常有缓存机制, 可以提高性能. 文件系统一般建立在块设备上.

IO多路复用机制(select, epoll)

IO多路复用让一个线程同时监听多个IO对象, 比如多个socket, 文件描述符, 一旦其中某些可以读写, 就通知程序去处理. 例如, 同时监听fd1, fd2, fd3, 哪个fd有数据, 就处理哪一个.

Linux中常见的IO多路复用机制有select, poll, epoll.

  • select = 每次把整张名单交给内核, 内核检查完后还要自己扫一遍

    单个进程监视的文件描述符的数量有上限, 由FD_SETSIZE定义, 默认是1024. select采用轮询的方式对文件描述符名单进行扫描, 文件描述符越多, 效率越低. 由于select每次都会改变内核中的fd集合, 因此每次调用select都要从用户空间向内核空间复制fd集合, 复制的时间复杂度是O(n), n是文件描述符的数量.

    优点是可移植性比较好, 几乎在所有的操作系统上都有支持. 可设置的监听时间timeout精度更好, 可精确到微妙; 缺点是监听的fd存在上限, 不能根据用户需求更改, 要求在内核和用户空间之间复制整个集合, 开销大, 轮询的处理方式效率较低.

    timeout的作用是:

    应用进程调用 select
    内核检查 fd
    如果有 fd 就绪: 立即返回
    如果没有 fd 就绪:
            等待
            有 fd 就绪 或 timeout 到期
    返回用户态
    
  • poll = 每次把整张名单交给内核, 内核检查完后还要自己扫一遍, 没有文件描述符数量限制

  • epoll = 只把变化的文件描述符交给内核, 内核检查完后直接返回结果, 不需要自己扫一遍, 没有文件描述符数量限制, 性能更好

    先用epoll_ctl把fd注册到内核, 然后循环调用epoll_wait, 内核直接返回已经就绪的fd.

TCP和UDP的区别

TCP是面向链接的协议, 提供可靠的顺序传输, 有重传机制, 有流量控制机制, 有拥塞控制机制, 传输单位为字节流, 头部大小较大, 速度相对较慢, 适合准确性优先的场景.

UDP是无连接的协议, 不保证可靠性, 不保证顺序, 没有重传机制, 没有流量控制机制, 没有拥塞控制机制, 传输单位为数据报, 头部大小较小, 速度相对较快, 适合实时性优先的场景.

  1. 连接导向: TCP中, 通信双方在传输数据之前, 要经过三次握手的过程, 保证双方正常通信.
  2. 可靠性: TCP提供可靠数据传输, 每个数据包有一个序号, 接收方收到数据之后应该发送ACK, 发送方在一段时间内没有收到则重传
  3. 流量控制: TCP使用滑动窗口来控制数据的发送速率, 接收方通过窗口大小来告知发送方自己的接收能力
  4. 拥塞控制: TCP通过拥塞窗口和慢启动来控制网络拥塞, 当网络出现拥塞的时候, TCP会减小发送窗口, 降低发送速率, 避免网络恶化.

三次握手:

  • 第一次握手: 客户端发送SYN. SYN=1, seq=x, 客户端请求建立连接, 并告诉服务器, 我的初始序列号是x, 此时客户端状态变为SYN_SENT. 服务器收到后, 可以确认, 客户端的发送能力正常, 服务器的接收能力正常
  • 第二次握手: 服务器收到客户端的SYN之后, 回复一个报文: SYN=1, ACK=1, seq=y, ack=x+1, 含义是我收到了SYN请求, 你的序列号x我也收到了, 我的初始序列号是y, 接下来我希望你发送x+1. 此时服务器状态变为SYN_RCVD, 客户端收到后, 可以确认, 客户端的发送能力正常, 客户端的接收能力正常, 服务器的发送能力正常, 服务器的接收能力正常
  • 第三次握手: 客户端收到服务器的SYN+ACK之后, 再发送一个确认报文, ACK=1, seq=x+1, ack=y+1, 含义是我收到了你的确认, 也收到了你的初始序列号y, 接下来我希望你发送y+1. 客户端和服务端进入到ESTABLISHED状态.

TCP头部最小20个字节, 最大60个字节, 包括源端口号, 目的端口号, 序列号, 确认号, 首部长度, 保留位, 标志位, 窗口大小, 校验和, 紧急指针, 选项, 填充.

堆和栈的区别

  • 栈: 有系统自动管理, 函数调用的时候分配, 函数结束的时候自动释放
  • 堆: 由程序员主动申请和释放, 生命周期更加灵活, 但是更容易出错

进程和线程的区别

  • 进程: 资源分配的单位, 有独立内存空间, 创建, 切换开销大, 进程间通信麻烦, 一个进程奔溃不影响其他进程.
  • 线程: CPU调度执行的单位, 共享进程内存, 创建, 切换开销小, 线程间通信更方便, 一个线程奔溃可能导致整个进程奔溃.

程序的局部性原理

程序运行的时候, 访问内存并不是完全随机的, 而是有一定规律的, 这个规律叫做局部性原理.

  1. 时间局部性: 最近访问的数据, 之后还有可能再次访问.
  2. 空间局部性: 访问了某个地址之后, 附近的地址很可能也会被访问.

CPU缓存, 虚拟内存, 分页机制都利用了局部性原理.

什么是中断

中断就是CPU正在执行程序的时候, 突然收到一个事件通知, 于是先暂停当前程序, 转去处理这个事件. 处理完之后, 再回来继续执行原来的程序.

常见的中断来源包括:

  • 外部设备: 键盘, 鼠标, 硬盘, 网卡
  • 定时器: 时间片用完, 切换进程
  • 异常: 除零错误, 缺页异常
  • 系统调用: 程序请求操作系统服务

什么是中断服务程序

中断服务程序, ISR是计算机中用来处理软硬件中断请求的特殊程序. 当某个事件发生的时候, 比如: 键盘按键, 鼠标移动, 定时器到点, 网卡收到数据, 磁盘读写完成的时候, CPU会暂停当前正在执行的程序, 转去执行对应的中断服务程序. 处理完成后, 再回到原来的程序执行.

ISR主要负责:

  1. 识别中断来源
  2. 处理紧急事件
  3. 保存和恢复现场
  4. 通知系统后续处理

它的特点是, 响应快, 执行时间短, 优先级高, 自动触发, 需要保护现场.

有哪些命令查看系统的内存使用情况

  • free, 能显示总内存, 已使用内存, 完全空闲内存, 被缓冲区和缓存占用的内存, 估计还可以给程序使用的内存, 交换分区的使用情况
  • top: 动态查看内存和进程, 可以查看系统总内存, 已用内存, 空闲内存, swap使用情况, 哪些进程占用内存多
  • htop: htop显示更友好, 可以看到CPU使用率, 内存使用率, Swap使用率, 每个进程占用的内存
  • /proc/meminfo: 这是Linux内核提供的内存信息, 内容比较详细.
  • vmstat
  • ps aux --sort=-%mem | head

软件开发完成后, 通常使用哪些方法或者工具来调试程序

  • 查看日志: 查看程序运行过程和错误信息
  • 打印调试: 临时输出变量值, 判断代码是否执行
  • 调试器: 设置断点, 单步执行, 查看变量和调用栈
  • 测试工具: 单元测试, 接口测试复现和验证问题
  • 内存检查工具: 排查内存泄露, 越界访问等问题, 例如valgrind
  • 性能分析工具: 查CPU高, 内存高, 程序运行慢的问题

进程使用的是虚拟地址空间还是物理地址空间

进程访问的地址是虚拟地址, 不是直接访问物理内存地址.

什么时候使用互斥锁, 什么时候使用信号量

互斥锁用于保证共享资源, 保证同一时间只有一个线程能访问. 比如说保护共享变量, 保护共享数据结构, 临界区互斥访问, 资源只有一个. 互斥锁强调的是所有权.

信号量用于控制同时访问某类资源的线程数量. 它不一定只允许一个线程进入, 而是允许最多N个线程同时进入. 场景比如说限制10个数据库的连接, 限制最多5个任务的同时执行, 用信号量表示队列中有多少可消费元素, 一个线程释放信号, 另一个线程等待信号. 它表示的是可用资源数量.

有没有多线程开发的经历, 多线程开发需要注意哪些方面

  • 线程安全: 多个线程访问共享数据的时候要加锁或者使用原子变量
  • 避免死锁: 多把锁要保持固定加锁顺序, 尽量减少持锁时间
  • 锁粒度要合适: 不要在持锁期间做耗时操作, 比如IO, 网络请求
  • 线程生命周期要管好: 线程创建后要join或者合理detach
  • 线程池: 实际项目中一般不会频繁创建和销毁进程, 更常见的是使用线程池