嵌入式软件工程师秋招知识点梳理
前言
- 本文目标:梳理ARM/ZYNQ相关方向的嵌入式软件工程师的秋招面试准备
嵌入式软件基础
C语言基础知识
位操作
常用位操作
运算符 名称 功能 示例 (假设 x=5, 0b0101) & AND 按位与 (常用于清零或提取特定位) x & 3 (0b0011) -> 1 (0b0001) | OR 按位或 (常用于置位特定位) x | 2 (0b0010) -> 7 (0b0111) ^ XOR 按位异或 (常用于翻转特定位) x ^ 1 (0b0001) -> 4 (0b0100) ~ NOT 按位取反 ~x -> …11111010 << 左移 将所有位向左移动,低位补 0 (等效于乘以 2^n) x << 2 -> 20 (0b10100) >> 右移 将所有位向右移动 (等效于除以 2^n) x >> 1 -> 2 (0b0010) 位域:位域允许你将一个结构体成员的宽度定义到位级别,而不是字节级别
- 不可移植,位域内部位的排布由编译器决定
- 位域操作非原子性
volatile
- 字面意思是 易变的 ,其修饰的变量可能以无法预测的形式改变,禁止编译器进行优化,每次操作需要到内存读取
- const和volatile可以用在同一个变量,const仅仅意味着我的程序不能修改这个变量,但是他可能还会被硬件修改(只读的硬件寄存器)
堆栈
- Linux中栈向低地址生长,堆向高地址生长
- 栈的特性方便进行函数调用和局部变量管理,而堆提供了动态内存分配的功能
C++系统编程
RAII
- C++ 最重要的特性之一,也是与 C 语言在资源管理上的根本区别
- 机制描述:将资源的生命周期与一个对象的生命周期绑定
- 在对象的构造函数中获取资源(如打开文件、申请内存、锁住一个互斥量)
- 在对象的析构函数中释放资源
- 当对象离开作用域时(例如函数返回、或者发生异常),它的析构函数会被编译器自动调用,从而保证资源一定会被释放
- C++中析构函数的存在保证了无论函数从哪里返回,资源释放都得到了保证,代码更简洁、更安全
- 类的公有私有以及多态等机制是完美的封装硬件的工具
- 虽然C语言没有析构函数,但我们可以通过GCC的
__attribute__((cleanup(...)))
扩展来模拟RAII- 或者在错误处理路径上使用goto语句跳转到统一的清理代码块,这在Linux内核中是非常常见的模式
零成本抽象
- 核心思想:你不为你用不到的东西付出代价,而且你用到的东西,其抽象层次的代价也应该为零或最小
- C++的模板实例化,可以让代码在编译的时候,只选择编译用到的函数,减小了资源消耗
- 内联优化使得编写C++面向对象代码的效率几乎等同于C语言的效率,使用了面向对象的特性的同时不增加消耗
- 经典范例:
- C++ 的
std::sort
对比 C 的qsort
- std::sort 使用模板,在编译期就知道要比较的类型,可以直接内联比较操作,生成极快的代码
- qsort 需要一个函数指针,运行时每次比较都有一次间接函数调用开销
- C++ 的
unique_ptr
- 创建指针:
auto ptr1 = std::make_unique<MyClass>(arg1, arg2);
- 核心思想:独占所有权的指针,即这块内存,同一时间只能有一个管理者
- 工作机制:
unique_ptr
包装了一个裸指针,但禁止了拷贝构造和拷贝赋值操作- 当
unique_ptr
对象本身被销毁时(离开作用域),它会在其析构函数中自动调用delete
来释放它所管理的内存 - 它虽然不能被拷贝,但可以被:移动 (move).移动操作会将内存的所有权从一个
unique_ptr
转移给另一个,原来的unique_ptr
会变为空(不再管理任何内存)
- 性能:几乎是零成本抽象.在大多数编译器实现中,一个 unique_ptr 的大小和一个裸指针完全相同,没有额外的性能开销
- 使用场景:
- 工厂函数:工厂函数是一种设计模式,指的是专门用来创建和返回对象的函数,而不是直接使用构造函数或new操作符
- 工厂函数创建对象,调用者获得所有权,使用独占指针明确了所有权的转移,也不用关注资源的释放
- 作为类的成员:如果一个类 A 包含一个指向类 B 实例的指针,并且 A 完全拥有 B 的生命周期,那么就应该使用
unique_ptr
- 工厂函数:工厂函数是一种设计模式,指的是专门用来创建和返回对象的函数,而不是直接使用构造函数或new操作符
shared_ptr
- 创建指针:
auto ptr2 = std::make_shared<MyClass>(arg1, arg2);
- 核心思想:共享所有权的指针,这块内存,可以被多个管理者共同拥有
- 工作机制:
shared_ptr
内部除了包含一个指向托管对象的裸指针外,还包含一个指向:控制块 的指针.控制块里存储着引用计数- 每当有一个新的
shared_ptr
拷贝或赋值给它时,引用计数加 1 - 每当有一个
shared_ptr
被销毁时,引用计数减 1 - 当引用计数减到 0 时,最后一个
shared_ptr
会负责释放被管理的对象内存和控制块本身
- 性能开销
- 内存开销:shared_ptr 的大小是裸指针的两倍(一个指向对象,一个指向控制块)
- 运行时开销:引用计数的增减必须是原子操作,以保证线程安全.原子操作会比普通指令慢
- 应用场景:仅在必要的时候使用:确实需要多个独立的对象共同管理同一个资源的生命周期,且无法确定谁会最后:存活 时
- 异步回调:一个网络请求发出后,需要将某些上下文数据传递给回调函数.这个上下文数据可能在请求发起方和回调处理方都需要访问,使用
shared_ptr
可以确保在回调执行完毕前,数据不会被销毁
- 异步回调:一个网络请求发出后,需要将某些上下文数据传递给回调函数.这个上下文数据可能在请求发起方和回调处理方都需要访问,使用
weak_ptr
- 指向由
shared_ptr
管理的对象,但不增加引用计数,它本身不控制对象的生命周期 - 核心作用: 打破
shared_ptr
之间的循环引用 (Circular Reference).- 循环引用场景:
- 如果对象A持有一个指向B的
shared_ptr
- 同时对象B也持有一个指向A的
shared_ptr
- 那么A和B的引用计数永远不会变为0
- 即使外界已经没有任何指针指向它们,也会导致内存泄漏.
- 如果对象A持有一个指向B的
- 解决方案: 将其中一方(或双方)的
shared_ptr
改为weak_ptr
- 循环引用场景:
- 使用方式:
weak_ptr
不能直接访问对象,必须先通过调用.lock()
方法- 如果对象还存在,
.lock()
会返回一个有效的shared_ptr
; - 如果对象已被销毁,则返回一个空的
shared_ptr
移动语义
- 左值 (lvalue):有持久存储位置的表达式,有名字,有地址,可以出现在赋值号左边;例子:变量名、数组元素、解引用指针
- 右值 (rvalue):临时的、没有持久存储的表达式,通常是字面量或临时计算结果,只能出现在赋值号右边;例子:常量、表达式结果
- 引用:引用是对象别名,指针指向地址,可以将引用和常量指针划等号(编译期底层使用常量指针实现的引用)
std::move
:什么也不做,只是告诉编译期把这个左值当作右值- 右值的使用场景:
- 函数返回的是右值,赋值的时候会触发移动构造,如果没有才深拷贝;现代C++编译器直接在目标位置构造对象,连移动都省了
- 缓冲区内存管理使用右值
- 容器拷贝使用右值
- 字符串的转移也推荐使用右值
- 硬件资源权限的转移也推荐使用右值
std::array
- 定义:一个封装了静态大小 C 风格数组的、固定大小的容器.
- 核心特点:
- 大小在编译期确定:std::array<int, 10> arr; 10 必须是编译期常量.
- 内存位置:和 C 数组一样,如果作为局部变量,它在栈上分配.没有动态内存分配的开销.
- 行为像容器:提供了 .size(), .begin(), .end() 等标准容器接口,可以方便地与 STL 算法(如 std::sort)配合使用,支持范围 for 循环.
- 安全性:提供了 .at(i) 成员函数进行带边界检查的访问
- 优点:
- C 数组传递给函数时会:退化 为指针,丢失长度信息;std::array 则不会
- vector 的大小是动态的,数据在堆上;array 的大小是静态的,数据通常在栈上
- 应用场景:任何需要固定大小缓冲区的地方
Guard 类
- 本质:
- 一个遵循 RAII 模式的、专门用于管理非内存资源(如锁、文件句柄、数据库连接、硬件状态)的小类
- 使用意义:
- Guard解决了 成对操作必须匹配 的问题
- Guard是针对某种需要状态切换的临时资源 在使用的时候需要从A->B->A,如锁 这种类在出现异常时需要恢复状态,但是过多的if内部写回复状态代码比较笨拙易漏,GUARD则会在离开作用域自动将状态切回去
静态多态
动态多态:
- 定义:父类定义接口,子类各自实现
- 使用:用父类指针/引用操作不同子类对象
- 特点:运行时根据实际对象类型决定调用哪个方法
- 缺点: 有运行时开销(间接调用、vtable 查询),且会阻止编译器内联优化
静态多态
- 通过模板实现,一个模板,多种类型,编译时确定调用哪个函数
奇异递归模板模式:派生类把自己传给基类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 模板基类接受派生类作为模板参数
template<typename Derived>
class Base {
public:
void interface() {
// 通过static_cast调用派生类的实现
static_cast<Derived*>(this)->implementation();
}
};
// 派生类继承时把自己作为模板参数传给基类
class Derived : public Base<Derived> {
public:
void implementation() {
std::cout << Derived的实现 << std::endl;
}
};- 继承父类时,就已经告诉了编译器,我继承的父类他的模板参数是什么
- 编译的时候编译器就会知道父类的函数对应的是哪个子类的版本
原子操作
- 原子操作指的是一个不可被中断的操作
std::atomic
是一个模板类,用来将普通变量(如 int, bool, 指针等)包装成一个原子对象,确保对它的所有操作都是原子的
条件变量
- 条件变量允许一个或多个线程等待某个特定条件成立,不是锁,是通知者,必须与互斥锁配合使用
- 它的核心是解决了忙等待的低效率问题,提供了一种阻塞-唤醒机制
- 使用流程
- 消费者:
- 获取互斥锁.
- 检查条件(队列是否为空).
- 如果条件不满足(队列为空),就调用 cv.wait(lock).
- 这一步是关键:线程会原子地释放锁,并让自己进入睡眠状态
- 生产者:
- 获取同一个互斥锁.
- 准备数据(向队列中放入元素),使得条件满足.
- 调用 cv.notify_one() 来按门铃,唤醒一个正在睡眠的消费者.
- 释放互斥锁
- 消费者被唤醒后:
- 它会从 wait() 函数中醒来,并自动重新获取之前释放的那个锁.
- 再次检查条件(防止虚假唤醒),确认满足后,开始处理数据
- 消费者:
C++多线程编程
- 线程的创建:使用
<thread>
头文件中的std::thread
类 - 数据通信
- 单生产单消费:无锁环形缓冲区,只有生产者可以修改尾指针,只有消费者可以修改头指针
- 使用原子操作来加载和存储头和尾,可以确保对双方的透明
boost::lockfree::spsc_queue
- 多生产者单消费者
- 对于生产端有加锁的必要,消费端没有
- 要么使用队列,条件变量通知消费端,入队时上锁
- 要么使用链表,链表使用原子类型,加入数据到链表时使用原子的比较并交换
- 多生产多消费者
- 使用一个有界(固定大小)的容器,如 std::vector 或 std::array,并将其作为一个环形缓冲区来管理.
- 使用一个 std::mutex 来保护整个数据结构
- 核心优化点:使用两个条件变量
cv_not_full_
:当队列已满时,生产者在此等待cv_not_empty_
:当队列为空时,消费者在此等待- 这种分离可以避免 惊群效应 和不必要的唤醒
- 当生产者添加一个元素后,它只通知一个可能在等待的消费者 (cv_not_empty_),而不会唤醒其他也在等待的生产者
- 单生产单消费:无锁环形缓冲区,只有生产者可以修改尾指针,只有消费者可以修改头指针
- 线程管理
thread.join()
: 主线程等待子线程执行完毕.在程序退出前,必须对所有创建的线程调用join()
或detach()
,否则程序会异常终止.join()
是确保资源被正确清理的常用方式.thread.detach()
: 将子线程与主线程分离,子线程在后台独立运行.这是一种 阅后即焚 的模式,需要开发者自行管理其生命周期,在嵌入式系统中要慎用,容易导致资源泄漏或僵尸线程.
std::lock_guard
vsstd::unique_lock
- 二者都是为了解决上锁忘记解锁的问题
- 条件变量必须要与unique_lock一起使用
- 当你需要与条件变量(std::condition_variable)配合实现线程等待与唤醒,或者需要在一个锁的生命周期内手动、灵活地释放和重新加锁时,就应该使用std::unique_lock
- 死锁
- 两个或多个并发线程(或进程)中的每一个都在等待另一个线程持有的资源,而自己已经持有的资源又不释放
- 死锁四个条件
- 互斥,持有等待,非抢占,循环等待链
- 破坏死锁
- 按序加锁:必须先持有锁1再持有锁2
lambda
语法结构
1
2
3[ captures ] ( parameters ) -> return_type {
// 函数体
};[]
捕获列表- 什么都不写,不捕获外部变量
[=]
表示,以拷贝的方式,捕获所有在 Lambda 中用到的外部变量[&]
表示,以 引用 的方式,捕获所有在 Lambda 中用到的外部变量,警惕lambda生命周期和外部变量的周期长度
当一个函数逻辑很短小、只在一个地方使用、并且需要方便地访问当前上下文时建议使用lambda
socket
- 用于网络通信的编程接口 (API),用于底层网络协议(如 TCP/UDP)进行数据收发
- 工作流程:
- socket() -> bind() -> listen() -> accept() -> read()/write() -> close()
- accept():这是一个阻塞函数
- 一旦有连接,accept() 就会返回一个全新的 Socket 描述符 conn_fd
- 这个新描述符是和特定客户端通信的专用通道
- socket() -> bind() -> listen() -> accept() -> read()/write() -> close()
- accept() 和 read() 都是阻塞的.如果一个服务器要同时处理成千上万的连接,为每个连接都开一个线程会耗尽系统资源
- 可以通过 fcntl() 将一个 Socket 设置为非阻塞模式
- 在这种模式下,如果调用 read() 时没有数据,它不会暂停,而是立即返回一个错误
- 缺点:你需要在一个 while(true) 循环里不断地尝试 read(),这会造成 CPU 空转
- 可以通过 fcntl() 将一个 Socket 设置为非阻塞模式
- I/O 多路复用 (I/O Multiplexing)
- 只用一个线程,同时监视大量的 Socket 描述符
高级网络与并发模型
Proactor 设计模式
- 核心思想:将 I/O 操作的发起和 I/O 操作的完成进行解耦
- 工作流程:
- 应用程序(我的服务器)发起一个异步操作(比如 async_read),并提供一个回调函数 (Completion Handler)
- 立即返回,继续做其他事情:
- 操作系统(由 Boost.Asio 底层的 epoll 代理)在后台完成 I/O 操作
- 当操作真正完成后,io_context 会在一个指定的线程(来自我们的线程池)中调用之前注册的那个回调函数
- Proactor 像点外卖:你(应用)通过手机 App(async_read)下好单,提供了所有信息(回调函数、缓冲区),然后就什么都不用管了:商家(内核)自己做饭、打包,最后外卖小哥(io_context)把现成的饭菜(完成的数据)直接送到你手上:你只关心 结果 ,不关心 过程
Reactor 设计模式
- 核心思想
- 同步 I/O 事件的分发:将所有 I/O 事件的监听和分发集中到一个单独的组件(Reactor)中,实现对多个并发事件的非阻塞、同步处理:
- 核心比喻:一个总机接线员:所有电话都打到总机,总机负责记录哪个电话响了,然后通知对应的人去接听,而不是让每个人都守着自己的电话:
- Reactor 像堂食:你(应用)告诉服务员(epoll) 想点菜了就叫我 (注册事件):服务员看到你举手(事件就绪),跑过来说 您好可以点了 (分发事件):你还得亲自翻开菜单,告诉他 我要一个宫保鸡丁 (执行 read()):你关心的是 时机 , 动作 还得自己做:
Boost.Asio
io_context:双重角色的核心引擎
定义:Boost.Asio 的核心事件中心与任务调度器:
双重角色:
- 底层是 Reactor:内部封装 epoll,负责高效监听 I/O 事件是否就绪:
- 上层是 Proactor:向用户提供异步接口:当 epoll 通知事件就绪后,io_context 代替用户完成 I/O 操作(如 read),然后将完成结果和回调函数打包成一个任务:
一句话总结:io_context用epoll的 Reactor 机制,为我们模拟出了 Proactor 的编程体验:
io_context.run() 与线程池
- run() 的作用:
- 任何调用 io_context.run() 的线程,都会变成一个事件处理工作线程
- 它的任务就是不断从 io_context 的完成队列中取出并执行回调任务
- 优势:I/O 监听(epoll)与回调处理(线程池)分离:回调任务可以被并发执行,提升 CPU 密集型任务的处理能力:线程数量固定,避免了 一个连接一个线程 模型的高昂开销:
- run() 的作用:
strand:无锁化的顺序保证
- 问题:多线程并发执行回调,如何保证对同一个连接的操作不会发生数据竞争?
- 解决方案:使用 strand:
- 核心比喻:strand 就像一条 逻辑上的单行道 :
- 工作原理:将属于同一个连接的所有回调函数都绑定到同一个 strand 上:strand 会确保,即使在多线程环境中,这些被绑定的回调也永远不会并发执行,而是按顺序、依次执行
- 价值:在享受线程池带来的全局高并发的同时,以一种比互斥锁 (mutex) 更轻量、更高效的方式,保证了单个连接内的逻辑串行化
交叉编译工具链
- 核心目的:在一台x86 架构的电脑上,开发和编译出能在 ARM 架构上运行的程序
- 核心组件
- 编译器:gcc, g++;负责将 C/C++ 源代码翻译成目标平台的汇编代码(预处理
.c
->.i
,编译.i
->.s
) - 二进制工具集:
- 汇编器 (Assembler) as:将汇编代码转换成机器码(
.s
->.o
) - 链接器 (Linker) ld:将汇编器生成的多个机器码文件 (.o) 和库文件链接成可执行文件
- 关键点:它链接的是目标平台的库,而不是主机的库
- 静态链接:将你的程序所依赖的库函数或对象的代码副本,直接合并到最终生成的可执行文件中,可执行文件不依赖外部库,启动速度快,可能浪费空间
- 动态链接:链接阶段,并不将库代码拷贝到可执行文件中,真正的链接工作被推迟到程序运行时 (runtime),可执行文件依赖外部库,linux动态链接只会到默认的路径下搜索
/usr/lib
- 汇编器 (Assembler) as:将汇编代码转换成机器码(
- C 标准库 (libc):交叉工具链必须包含为目标平台编译好的C库
- glibc (GNU C Library):功能完整,兼容性好,但体积较大.桌面 Linux 系统常用
- musl libc / uClibc:轻量级 C 库,专为嵌入式系统设计,体积小,静态链接友好.
- 内核头文件:C 库的某些功能(如系统调用)需要与特定版本的内核交互,因此需要包含相应的内核头文件
- 调试器:工具链中包含的 gdb 也是交叉版的,它能在主机上运行,但能理解目标平台的指令集和调试信息,用于远程调试
- 编译器:gcc, g++;负责将 C/C++ 源代码翻译成目标平台的汇编代码(预处理
Makefile/CMake
Makefile是自动化构建工具,描述项目文件之间依赖关系以及如何生成可执行文件,make可以解析Makefile
CMake是跨平台的构建系统生成器,本身不编译代码,而是读取CMakeLists根据系统环境生成本地构建系统(比如linux上会生成Makefile)
Makefile核心规则
目标文件:依赖文件1 依赖文件2
生成目标的命令
CMake工作时解析顶层的CMakeLists,在内存中构建一个项目的内部表示,然后稍后转换为Makefile等文件,随后可以使用make进行构建
处理交叉编译
- Makefile:通常约定一个 CROSS_COMPILE 变量作为工具链的前缀,在指定gcc,g++时使用这个前缀来找到对应的工具链
- CMake:一般使用一个专门的
.cmake
文件来管理,使用set命令TOOLCHAIN_PATH
,CMAKE_SYSROOT
来指定交叉编译链以及头文件和库文件的搜索路径(不再从默认的搜索路径/usr下寻找而是从你指定的)
处理库依赖
- Makefile: 硬编码写入Makefile
- CMake:使用
target_include_directories
,target_link_libraries
来指定目标库,只会对目标的target生效
GDB 远程调试
- GDB远程分为客户端和服务端,服务端位于开发板客户端,客户端位于宿主机,通过GDB远程串行协议进行连接
- 客户端提供高级命令实现查看对战以及变量值,服务端非常轻量级,负责处理客户端的指令
- Q:客户端为什么也需要一份可执行文件? A:客户端=大脑+源码,服务器=执行代理
- 调试流程:
- 在开发板也就是服务端,
./gdbserver :1234 ./my_app
: 1234 告诉 gdbserver 在本地所有 IP 地址的 1234 端口进行监听- 执行后,gdbserver 会打印监听信息,然后阻塞,等待 GDB 客户端连接.此时 my_app 已被加载到内存但尚未执行
- 在宿主机也就是客户端,
aarch64-linux-gnu-gdb ./my_app
:必须使用交叉 GDB (aarch64-linux-gnu-gdb),并且加载本地带符号的 my_app 文件,这样 GDB 才能关联源码 - 在客户端的提示下输入IP端口进行连接:
(gdb) target remote <target_ip>:1234
- 随后就可以使用GDB命令进行调试
- 在开发板也就是服务端,
- 高频指令
- b
(break) - 设置断点 - r (run) / c (continue) - 运行与继续
- n (next) - 单步步过
- s (step) - 单步步入
- p
(print) - 打印变量 - bt (backtrace) - 查看调用栈
- b
Shell 脚本
grep
:从文本(或文件中)找到指定模式的行grep -r pattern /path/to/dir
:递归的搜索某个路径以及子路径的文件中是否有指定模式的文本-i
:忽略大小写-v
:反向匹配,不包含指定模式的行
sed 's/old/new/g'
:在管道中对文本进行替换s
是替换g
代表替换所有匹配项,不加则只替换第一个
- 管道
command1 | command2
:将 command1 的标准输出 (stdout) 连接到 command2 的标准输入 (stdin) - 重定向
command > file.log 2>&1
:把标准输出和标准错误都输出到file.log>
: 将标准输出重定向到 file.log (会覆盖文件原有内容)2>
: 将标准错误输出 (stderr) 重定向,2是标准错误&1
: & 表示这是一个文件描述符,1 代表标准输出
$?
:上一个命令的退出状态码$#, $1, $@
:$#
: 传递给脚本的参数个数$1
,$2
…: 第一个、第二个参数$@
: 所有参数的列表
[ -f $FILE ] / [ -d $DIR ] / [ -e $PATH ]
-f (is file), -d (is directory), -e (exists)
:检查文件/目录是否存在
[ $VAR1 == $VAR2 ] / [ -n $VAR ] / [ -z $VAR ]
:== (字符串相等),-n (is not empty),-z (is empty)[ $NUM1 -eq $NUM2 ]
:eq (equal), -ne (not equal), -gt (greater than), -lt (less than).用于整数比较
裸机开发
- 裸机 (Bare-Metal):指的是嵌入式软件直接运行在硬件之上,没有任何操作系统的支持
- RTOS (Real-Time Operating System):是一种轻量级的操作系统,其核心是任务调度器
- 它在裸机之上增加了一个薄薄的抽象层,提供了任务管理、时间管理、任务间通信(如信号量、消息队列)等服务
- 将程序的功能拆分成多个独立的 任务 (Task) ,并为每个任务分配一个优先级,优先级抢占
- Linux :追求的是高吞吐量和公平性,而非严格的实时性
- 分时公平
进程通信IPC
进程是独立运行的程序,线程是一个程序内部的子任务
- 只有要求强隔离性和强稳定性时,才建议使用进程
IPC方式梳理
IPC 方式 核心原理 优点 缺点 一句话场景 管道 (Pipe) 内核维护的单向字节流缓冲区. 简单,符合流式编程思想. 匿名管道只能用于父子进程;半双工. 重定向命令行工具的输入输出 (ls | grep) 命名管道 (FIFO) 文件系统中的特殊文件,作为管道. 无亲缘关系的进程可用. 依然是半双工,速度一般. 不相关进程间的简单数据流传递. 消息队列 内核维护的带类型的消息链表. 可选择性接收消息,解耦性好. 消息大小和队列总容量有限制. 传递短小的、结构化的控制命令. 信号 (Signal) 异步通知机制,类似 中断 . 简单,内核支持,实时性高. 传递的信息量极少(只有一个信号编号). 通知某个进程发生了特定事件(如 kill -9). 信号量 (Semaphore) 一个计数器,用于控制对共享资源的访问. 能实现复杂的同步逻辑. 本身不传递数据,只用于同步. 控制共享内存的访问,或限制并发数量. 共享内存 将同一物理内存映射到多个进程. 速度最快,无内核拷贝开销. 必须自己用信号量等工具做同步,实现复杂. 进程间大批量数据(如视频帧)的高速传输. 套接字 (Socket) 本地化的网络编程接口 (Unix Domain Socket). API 成熟,支持双向通信,模型清晰 (C/S). 性能不如共享内存,有数据拷贝. 在本地实现 Client/Server 模型的复杂交互. - 信号都是系统预置的,几十种有特殊含义的信号
- 信号量用于处理复杂生产消费场景的计数器
硬件与接口知识
Zynq MPSoC 架构
- 结构划分
- PS:负责处理复杂的控制流、运行操作系统(如 Linux)、执行网络协议栈、文件系统、用户界面等上层应用
- PL:负责处理高速、并行的计算密集型任务,如数字信号处理 (DSP)、图像算法、自定义高速接口协议等
- 核心组件
- APU (Application Processing Unit):MPSoc的PS端拥有四核 ARM Cortex-A53,64 位处理器
- 7000系列则是双核 32 位 ARM Cortex-A9
- RPU (Real-Time Processing Unit):
- 双核 ARM Cortex-R5F: 这是一个实时性、可靠性、安全性极高的处理器,通常用于裸机或实时操作系统 (RTOS),负责对延迟和可靠性要求极高的任务,如电机控制、安全监控
- 7000 系列没有 RPU
- PMU (Platform Management Unit):平台管理单元.一个 MicroBlaze 软核,负责整个芯片的上电顺序、功耗管理和安全
- PL端:查找表LUT,触发器,DSP,BRAM等
- APU (Application Processing Unit):MPSoc的PS端拥有四核 ARM Cortex-A53,64 位处理器
- PS-PL 交互方式:最主要通过AXI总线,还有通过IRQ_F2P向PS端发送的中断
AXI总线
- 定义:高性能、高带宽、低延迟的片上总线 (On-Chip Bus) 协议
- 端口
- AXI General Purpose (GP) Port - 通用目的端口
- 特点: 32位,低/中带宽,主要用于控制和状态信息交互.
- 方向: PS 作为主设备 (Master),PL 作为从设备 (Slave).
- 工作流程: PS 上的 CPU 像访问普通内存地址一样,去读写 PL 中自定义模块的寄存器.这些寄存器被映射到 PS 的地址空间
- 协议:通常使用AXI-Lite,又是也可以使用Full-AXI
- AXI High Performance (HP) Port - 高性能端口
- 特点: 64/128位,高带宽,专门为PL 大规模读写 PS 的 DDR内存设计
- 工作流程: PL 中的一个模块(通常是 DMA 控制器)可以直接、独立地将大量数据从 PL 侧写入 DDR,或者从 DDR 读出数据,整个过程 CPU 不参与
- 协议:只使用AXI-Full
- AXI General Purpose (GP) Port - 通用目的端口
- 核心原理
- 一个完整的 AXI 内存映射 (Memory-Mapped) 传输,被分解成了五个并行的、独立的通道
- AXI作为交通枢纽,只做主从之间的转发,实际的数据存取都是对应的主或者从设备完成的
- 写地址通道 (Write Address Channel - AW):
- 谁发出: Master (如 CPU, DMA)
- 内容: AWADDR (要写入的地址), AWLEN (突发长度) 等控制信号
- 作用: Master 在这个通道上喊话:我要往地址 A 写 N 个数据
- 写数据通道 (Write Data Channel - W):
- 谁发出: Master.
- 内容: WDATA (要写入的数据), WSTRB (字节选通), WLAST (突发的最后一个数据).
- 作用: Master 在这个通道上把要写的数据:包裹 发出去
- 写响应通道 (Write Response Channel - B):
- 谁发出: Slave (如 BRAM Controller).
- 内容: BRESP (写操作是否成功).
- 作用: Slave 在这个通道上回话::数据已收到,操作成功/失败!
- 读地址通道 (Read Address Channel - AR)
- 谁发出: Master
- 内容: ARADDR (要读取的地址), ARLEN (突发长度) 等.
- 作用: Master 在这个通道上喊话::我需要从地址 B 读取 M 个数据!
- 读数据通道 (Read Data Channel - R):
- 谁发出: Slave.
- 内容: RDATA (读取到的数据), RRESP (读操作是否成功), RLAST (突发的最后一个数据).
- 作用: Slave 在这个通道上把数据:包裹 发给 Master
- 一个完整的 AXI 内存映射 (Memory-Mapped) 传输,被分解成了五个并行的、独立的通道
- 乱序处理 (Out-of-Order Execution)
- AXI 协议允许 Master 给每个传输请求打上一个 ID 标签
- Slave 可以不按照收到请求的顺序来完成它们,而是通过仲裁优先完成快速的请求
- AXI协议
- AXI4 (Full AXI / Memory Mapped):
- 特点: 拥有全部 5 个通道,功能最完整.支持突发传输 (Burst Transaction),即一次地址请求可以连续读写最多256个数据.
- 接口信号: IP 核上会有 M_AXI_ (主) 或 S_AXI_ (从) 前缀,后面跟着 AWVALID, AWADDR, WVALID, WDATA, BVALID, BREADY 等一大堆信号
- 应用场景:
- 高性能内存访问: 连接处理器和 DDR 内存
- DMA 数据传输: 连接 DMA IP 和内存
- 连接需要大块数据交换的自定义 IP
- AXI4-Lite
- 特点: AXI4 的轻量级简化版.它没有突发传输能力,一次地址请求只能读写一个数据单元(通常是 32 位).因此,它不需要像 AWLEN 这样的长度信号,通道信号也大大减少.
- 接口信号: S_AXI_LITE_ 或 M_AXI_LITE_.信号数量远少于 Full AXI
- 应用场景:
- 读写控制/状态寄存器: 这是它最主要、最普遍的用途.比如配置一个 IP 的工作模式,启动/停止它,读取它的状态.因为这些操作都是单次、低频的,完全不需要高带宽的突发传输.
- AXI DMA 的控制接口: DMA 的控制寄存器接口就是 AXI-Lite 的
- AXI4-Stream
- 特点: 没有地址概念,它被设计用来实现单向、高速、连续的数据流传输
- 核心思想: 不关心数据要去哪里(没有地址),只关心数据本身的流动,像一根水管一样
- 接口信号: S_AXIS_ (从) 或 M_AXIS_ (主).核心信号只有三个:
- TDATA: 传输的数据.
- TVALID:水来了 ,表示 TDATA 上的数据是有效的.
- TREADY:我准备好了 ,表示消费者可以接收数据.
- TVALID 和 TREADY同为高电平时,一次数据传输才真正发生(这被称为握手)
- 还有 TLAST (表示这是数据流的最后一个包)等可选信号
- 应用场景:
- 流水线式的数据处理: 非常适合连接一系列处理模块.比如:[摄像头数据源 IP] –(AXIS)–> [图像缩放 IP] –(AXIS)–> [颜色空间转换 IP] –(AXIS)–> [DMA IP]
- 连接高速 ADC/DAC
- AXI4 (Full AXI / Memory Mapped):
ARMv8 (A53) 架构
- 该架构引入了64位系统以及异常级别
- 异常级别:定义了一个层次化的特权体系,决定了软件在什么级别上运行,以及它拥有多大的系统控制权限
- EL0: 用户态
- 运行什么: 普通的应用程序
- 权限: 最低权限.代码不能直接访问硬件,不能修改系统关键设置.
- 如何获得服务: 必须通过系统调用 (System Call) 的方式,请求 EL1 的操作系统内核来为它服务
- EL1: 内核态 (Kernel Mode)
- 运行什么: 操作系统内核,比如 Linux Kernel.
- 权限: 拥有对系统大部分硬件和内存的直接控制权.负责管理所有系统资源(内存、进程、设备驱动等),并为 EL0 的应用程序提供服务
- EL2: 虚拟化层 (Hypervisor Mode)
- 运行什么: Hypervisor (虚拟机监控器),如 KVM, Xen
- 权限: 可以在 EL1 之上,创建和管理多个虚拟机 (VM).每个虚拟机都有自己独立的操作系统内核(运行在 EL1).
- 作用: 在一个物理硬件上同时运行多个隔离的操作系统.在嵌入式中可用于隔离安全关键的 OS 和普通 OS
- EL3: 安全监控层 (Secure Monitor Mode)
- 运行什么: Secure Monitor 固件,它是 ARM TrustZone 安全技术的基石
- 权限: 最高权限.负责在安全世界 (Secure World) 和普通世界 (Normal World) 之间进行切换
- 作用: 提供一个硬件隔离的安全执行环境 (TEE - Trusted Execution Environment),用于处理密码、密钥、指纹支付等敏感信息,即使普通世界的 Linux 内核被攻破,也无法访问到安全世界的数据
- EL0: 用户态
- AArch64 与 AArch32 执行状态
- AArch64: 这是 ARMv8 的原生 64 位执行状态
- AArch32: 为了兼容旧的 32 位 ARM 代码,ARMv8 保留了一个 32 位的执行状态
- 在 MPSoC 上,Linux 内核通常运行在 AArch64 状态下,但它有能力运行 32 位的用户程序(如果开启了兼容性支持)
- 系统启动流程
- 上电后,从EL3开始执行Boot ROM的代码
- 加载第一阶段引导程序FSBL,FSBL加载Uboot,U-Boot 通常运行在最高可用特权级(如 EL3 或 EL2)
- Uboot完成硬件初始化切换至EL1,跳转Linux内核入口点
- 内核启动用户程序,切换至EL0
M.2 接口
m.2
是一个接口,不是一种通信协议,多个协议都可以接该接口,可以承载多种不同的通信协议如 PCIe、SATA、USB- SATA是数据传输协议,NVME也是数据传输协议,也有SATA的接口,SATA协议+M2就是M2接口的SATA硬盘
- 对于NVMe的M2硬件基座,连接到APU遵循如下路径
- M2插槽的高速差分信号线连接PS端的MGT (Multi-Gigabit Transceiver),也叫 GTR 收发器,负责将高速模拟转化为并行数字
- MGT/GTR 是协议无关的: MGT 的本质是一个非常通用的高速串行物理层接口 (PHY).它本身只负责处理底层的电信号(串并转换、时钟恢复等),并不认识上层的 PCIe 还是 SATA 协议
- 协议由控制器决定: MGT/GTR 模块在芯片内部与一个协议控制器硬核 (Controller Hard IP) 相连,接的啥控制器就是啥协议
- 如果在PL端的MGT,会根据性能被称为GTH/GTY
- MGT和上层的硬核PCIE协议控制器相连,负责处理数据链路和事务层
- 芯片内部PCIE控制器通过AXI总线连接到中央互联矩阵 (AXI Interconnect),在AXI的仲裁下可以与DDR等进行交互
- M2插槽的高速差分信号线连接PS端的MGT (Multi-Gigabit Transceiver),也叫 GTR 收发器,负责将高速模拟转化为并行数字
- Petalinux开发中
- 需要有个设备树节点来使能PCIe控制器,这个使能如果在Vivado配置了,可以被Petalinux识别自动生成设备树
- Linux内核启动后,PCIe 子系统会根据设备树的信息去初始化 PS 端的 PCIe 控制器
- 然后,它会发起总线枚举 (Bus Enumeration),扫描 PCIe 总线上的设备
- PCIe 子系统扫描到 M.2 SSD 后,会读取其配置信息 (Vendor/Device ID),并为其匹配 nvme 核心驱动
- NVMe 驱动初始化成功后,会向 Linux 的块设备层注册一个新的磁盘设备
- 块设备层会在 /dev 目录下创建我们熟悉的设备节点,如 /dev/nvme0n1
阅读芯片手册
- 我会先用原理图确认物理连接,看‘能不能做’.再用数据手册确认性能规格,看‘能做得多好
- 当 BSP 和通用方案解决不了问题时,我会把手册当作最终的排错依据,直接核对关键的控制和状态寄存器
高速信号设计基础
- 低速信号 (如 UART @115200bps): 信号的上升/下降时间远小于信号本身的持续时间,可以近似看作理想的 0 和 1 电平跳变
- 高速信号 (如 PCIe Gen3 @8Gbps): 信号的波长已经和 PCB 走线的长度在同一个数量级.这时,PCB 走线不再是一根完美的:导线 ,而变成了一根传输线 (Transmission Line).信号在上面传播时,会出现各种波动效应,如反射、串扰、衰减
- 差分信号:使用一对信号线 (TX+/TX-) 来传输一个信号.这两条线上的信号相位相反(一条为高时,另一条为低),幅度相同
- 优点:
- 差分线抗背景干扰能力强
- 两条线信号相位相反减少电磁辐射
- 可以在较低的电压摆幅下工作
- 电压摆幅 (Voltage Swing) 指的是信号在逻辑高电平 (V_high) 和逻辑低电平 (V_low) 之间变化的电压范围
- 低电压摆幅速度更快,因为电压切换的幅值小
- 功耗更低,电压低了功耗就低
- 应用:几乎所有现代高速串行接口都使用差分信号,如 PCIe, SATA, USB 3.0, Ethernet (SGMII/RGMII), DisplayPort
- 优点:
- 特征阻抗:信号在传输线上传播时的瞬时电阻.由PCB走线的物理属性(线宽、与参考地平面的距离、介电常数)决定.对于差分信号,通常控制在 100 欧姆;对于单端信号,通常是 50 欧姆
- 阻抗匹配 :阻抗匹配是为了解决信号反射问题.高速信号在传输线上遇到阻抗不连续的点就会发生反射,干扰原始信号
- PCB 工程师通过精确控制走线的宽度和叠层结构来实现.在源端或末端,也常常会放置终端电阻 (Termination Resistors)来进行匹配
- 通过精确控制差分线对的阻抗为 100 欧姆(或单端线 50 欧姆),来保证信号能量的平顺传输
- 等长绕线:对于差分信号对(P线和N线)或一组并行总线 (如 DDR 的数据总线 DQ0-DQ7),要求它们的 PCB 走线长度几乎完全相等,用于保证信号同时到达
- 差分信号: P 线和 N 线的信号是严格同步、相位相反的
- 如果两根线一长一短,信号到达接收端的时间就会有偏差(称为时序偏斜,Skew)
- 这会导致它们的相位关系错乱,差分信号的优势(如共模抑制)就会大打折扣,甚至导致误码
- 并行总线:时钟信号 (DDR_CLK) 和数据信号 (DDR_DQ) 必须在几乎完全相同的时间到达内存颗粒的引脚
- 数据线之间长度不一,或者数据线与时钟线长度差太多,就会导致在时钟的上升沿采样到的数据是错误
- 差分信号: P 线和 N 线的信号是严格同步、相位相反的
SoC/FPGA 内部存储
- BRAM:是FPGA逻辑结构中的一部分,由成百上千个小的 RAM 块构成
- PL的一部分意味着也在SOC内
- OCM (On-Chip Memory):PS内部的一块SRAM
- CPU 访问它有专用的高速通道,不需要经过复杂的总线仲裁
- 与Cache不同,它由程序员精确控制,主要用于存放时间和确定性要求高的代码
- DDR:内存
QSPI Flash
- 定义:
- Flash: 掉电后不丢失
- SPI (Serial Peripheral Interface): 串行通信协议
- 使用 4 根线(时钟 SCLK, 主出从入 MOSI, 主入从出 MISO, 片选 CS)
- 一次只能传输1bit
- QSPI (Quad Serial Peripheral Interface): SPI的进化版,增加了数据线
- 一次可以传输4bit
- XIP(Execute-In-Place,就地执行)
- 传统IO:CPU 通过 QSPI 控制器,像操作普通 SPI 设备一样,发送 读 、 写 、 擦除 等命令
- XIP:将QSPI Flash映射到内存地址空间
内存映射 I/O (MMIO)
- 定义:MMIO是一种 CPU 与外设 (Peripherals) 进行通信的架构设计
- 外设的控制寄存器、状态寄存器以及数据缓冲区被分配到 CPU 的物理地址空间中
- 是现代嵌入式系统(特别是 ARM 架构)中与外设交互的唯一方式
- 系统内部有地址解码器,负责监听地址总线,将地址与实际的设备映射起来
- 软件访问MMIO
- Linux用户态访问
/dev/mem
- 使用UIO,调用mmap
- Linux用户态访问
- 在使用指针访问 MMIO 区域时,必须将指针声明为 volatile
操作系统与内核
Petalinux/Yocto
- 定义与背景
- Yocto Project: 构建嵌入式linux 的开源工具
- Petalinux: 这是 Xilinx/AMD 基于 Yocto Project 为其自家芯片(Zynq, Versal 等)定制的一套上层封装和工具集
- Petalinux ≈ Yocto Project + Xilinx BSP + Xilinx 定制工具 (petalinux-create, petalinux-config 等)
- 工作流程
- 硬件描述输入:
petalinux-config --get-hw-description
导入xsa,会自动生成匹配的Uboot和设备树 - 系统基础配置:
petalinux-config -c kernel
:裁剪内核,开启我需要的驱动(比如 PCIe/NVMe)petalinux-config -c rootfs
来选择需要集成到系统里的预置软件包
- 构建与部署:
petalinux-build
,编译系统生成镜像文件(如 image.ub, rootfs.tar.gz)petalinux-package --boot --fsbl images/linux/zynq_fsbl.elf --fpga images/linux/system.bit --u-boot --force
生成BOOT.BIN
- 集成自定义应用:在编译完毕的系统开发程序,开发完毕后添加到
project-spec/meta-user
下,配置后重新生成镜像, 默认包含用户App
- 硬件描述输入:
Device Tree (DTS)
定义与背景
- 设备树 (Device Tree) 用来描述硬件信息,以与操作系统无关的方式,将硬件的详细信息传递给操作系统内核
- DTS (Device Tree Source): .dts 或 .dtsi 文件,是人类可读的、描述硬件的源代码
- DTB (Device Tree Blob): .dtb 文件,是 DTS 经过编译后生成的、内核可以直接解析的二进制文件
- 为什么需要设备树
- 内核本身是通用的,为了避免不同的开发板的硬件配置使内核大小膨胀,使用设备树选择性的添加硬件文件
- 设备树可以独立于内核,修改设备树不需要编译内核
设备树的层次结构
层次拆分
- 顶层结构:
system-top.dts
:Petalinux 构建时使用的最顶层文件,通常由工具管理
1
2
3
4
5
6// system-top.dts (通常由工具管理)
/include/ system-conf.dtsi // 包含板级配置文件
/ {
// 这里可能会有一些顶层属性
};板级配置层:
system-conf.dtsi
:这个文件负责整合芯片级和用户级的配置1
2
3
4// system-conf.dtsi (工具生成)
/include/ zynqmp.dtsi // 1. 包含芯片原厂定义的 dtsi
/include/ pl.dtsi // 2. 包含从 XSA 生成的 PL 部分 dtsi
/include/ system-user.dtsi // 3. 包含用户自定义的 dtsi核心定义层:通常不需要修改
zynqmp.dtsi
:定义了 Zynq MPSoC PS 端所有的硬件IP核(CPU, I2C, SPI, PCIe控制器等);所有节点在这里都带有标签 (label),并且 status 通常是 disabledpl.dtsi
: Petalinux 根据你的 .xsa 文件自动生成.定义了你在 Vivado 中设计的 PL 端所有 AXI IP 核.同样,节点也都带有标签
用户自定义层:
system-user.dtsi
,用于修改和追加配置的地方/{...}
: 定义了硬件的物理层级结构.根下面有 soc,soc 下面有 i2c-controller,i2c-controller 下面有 i2c-device.这描述了谁挂在谁身上- 根节点外
&label{...}
:引用一个别处已经定义的节点进行覆盖(zynqmp.dtsi或pl.dtsi)
- 顶层结构:
用户设备树修改规则
- 当你的外设寄存器的设置需求与Vivado标准配置不同时(比如网卡芯片不同):
- 在核心定义层查找对应节点,获取label
- 在用户自定义层使用根节点外的label来覆盖设备的信息
- 对于非标准器件,比如通过GPIO,或者I2C总线连接的传感器模块
- 如果是一个 I2C 设备,就应该在 system-user.dtsi 中,引用对应总线的label,并在其中追加一个子节点来描述它
- 如果不属于标准总线,直接被GPIO控制,可以在根节点下创建一个描述节点
- 当你的外设寄存器的设置需求与Vivado标准配置不同时(比如网卡芯片不同):
语法规则
- 节点:代表系统中的一个设备或总线
label-name:node-name@unit-address { ... };
,其中label-name是可选的
- 属性:描述节点的内部配置
property-name = <value>;
- 核心属性
- compatible:它是一个字符串列表,格式为 manufacturer,model .用于内核匹配驱动
- reg:reg 属性的值是由一个或多个 <地址 大小> 对组成的.每一对所占用的 cell (32位单元) 数量,由父节点的
#address-cells
和#size-cells
决定,用于表述硬件的寄存器信息- 在 64 位系统的标准配置:
#address-cells = <2>;
,#size-cells = <2>;
reg = <0x0 0xff000000 0x0 0x1000>;
表示地址位占用两个cell,大小位占用两个cell
- 在 64 位系统的标准配置:
- interrupts:描述设备使用的中断及其属性
interrupts = <0 66 4>
:0是共享外设中断,66是终端号,4是高电平触发
- interrupt-controller 属性: 一个空的布尔属性,用于将一个设备节点标记为中断控制器
- #interrupt-cells 属性: 这是中断控制器节点的一个重要属性,它决定了中断描述符需要用多少个32位的单元(cell)来表示
#interrupt-cells = <1>
- interrupt-parent 属性: 设备节点通过此属性指定其中断信号所连接到的中断控制器
interrupt-parent = <&intc1>
- 如果一个节点没有 interrupt-parent 属性,它将从其父节点继承
- interrupts-extended 属性: 当一个设备需要连接到多个中断控制器时,应使用此属性
- status: okay或ok表示设备已使能;disabled表示设备被禁用
- 节点:代表系统中的一个设备或总线
内核解析规则
- 加载 DTB: U-Boot 把编译好的 .dtb 二进制文件加载到内存,并把地址告诉 Linux 内核
- 构建 device_node 树: 内核启动早期,会解析 DTB存储在内存
- 扫描与注册:内核扫描内存设备信息,扫描节点的compatible属性,如果扫描到了就把信息打包为
platform_device
结构体,并挂载到平台总线等待驱动匹配 - 驱动加载:驱动向驱动模型核心(Driver Core)提交内置的compatible属性
- 驱动匹配:驱动模型核心(Driver Core)匹配二者,匹配成功调用该驱动的probe函数
- 驱动的probe函数可以使用内核的API获取寄存器及中断等硬件信息
注意事项:
- 自定义的模块和自定义的驱动,都要写上相对应的compatible属性
- 设备树只声明硬件的寄存器地址,设置寄存器值是驱动在完成
Linux内核配置
- 为什么要配置内核?
- 使能硬件驱动
- 开启/关闭内核特性:比如,开启内核的调试功能 (如 kgdb),或者为了安全关闭一些不必要的网络协议支持
- 系统优化: 裁剪掉所有不需要的驱动和功能,可以极大地减小内核镜像体积,并加快系统启动速度
- petalinux配置内核的页面与Linux内核源码树的标准menuconfig一样,petalinux只是提供了一个入口
- 内核配置页解析
- General setup:内核最核心、最通用的配置.比如系统主机名、内核消息队列支持等
- Device Drivers:对应
drivers/
目录,几乎所有的硬件驱动配置都在这里 - File systems:对应
fs/
目录.这里包含了所有文件系统的支持,比如 EXT4, NFS, VFAT - Networking support:对应
net/
目录.这里是网络协议栈的配置,比如 TCP/IP, IPv6, Sockets 等- 和
Device Drivers -> Network device support
的区别:这里是协议层,那边是硬件驱动层
- 和
Linux驱动
驱动的分类
特性维度 | 内核驱动 (Kernel Driver) | 用户空间驱动 (Userspace Driver, e.g., UIO) | 混合驱动 (Hybrid Driver, e.g., DPDK) |
---|---|---|---|
运行空间 | 内核空间 (EL1) | 用户空间 (EL0) | 控制路径: 用户空间 数据路径: 内核(Bypass)+用户空间 |
核心思想 | 内核全面管理硬件,向用户提供抽象接口 | 内核只做:管道工 ,将硬件资源直接暴露给用户态 | 内核让出数据路径,用户态直接、高效地操作硬件 |
性能 | 高 (中断延迟低, 无需上下文切换) | 中/低 (中断延迟高, 存在上下文切换开销) | 极致 (绕过内核协议栈和中断, 零拷贝) |
开发调试 | 困难 (需遵循内核规范, 崩溃导致系统宕机) | 简单 (使用标准用户态工具, 崩溃仅影响自身进程) | 复杂 (需要理解底层硬件和特定框架) |
安全性 | 较低 (驱动 Bug 可能危及整个内核) | 高 (受操作系统内存保护, 无法破坏内核) | 中 (给予用户态进程极大权限, 可能误操作硬件) |
典型实现 | 字符/块/网络设备驱动 (.c /.ko ) |
UIO, VFIO | DPDK (网络), SPDK (存储) |
数据通路 | 应用 <—-> 系统调用 <—-> 内核驱动 <—-> 硬件 | 应用 <—-> mmap/read <—-> (轻量级内核通道) <—-> 硬件 | 应用 <—-> (绕过内核) <—-> 硬件 |
适用场景 | 通用、高性能硬件 (如: NVMe , Ethernet , SATA ) |
简单、低速的自定义硬件 (如: PL 端的 AXI GPIO , BRAM Controller ) |
极限性能的网络/存储应用 (如: 高性能路由器, 分布式存储) |
DMA 模式 | 所有 DMA 模式 (中断驱动, 流式 DMA) | 适合控制:智能 DMA 硬件 (如: AXI DMA) 不适合需要软件深度参与的流式 DMA |
专为高性能 DMA 设计 (轮询, 零拷贝) |
使能驱动的流程
- 硬件层:定义物理存在与连接
- 对于 SoC 内部/PL 侧 IP:
- 在 Vivado 中,使能 PS 端外设 (如 I2C0, SPI1),或在 Block Design 中添加 PL 侧的 AXI IP (如 AXI DMA),并完成引脚/接口的连接.
- 导出硬件描述 (.xsa),这份文件就是硬件的:数字身份证 .
- 将 .xsa 导入 Petalinux,系统会自动在 zynqmp.dtsi 和 pl.dtsi 中生成或更新对应的设备树节点
- 对于板级外部设备 (非 .xsa 可见):
- 这类设备(如通过 I2C 连接的传感器、通过 GPIO 控制的芯片)在Vivado开启对应IO即可
- 它们的物理存在需要我们在第二步中手动在设备树里进行描述.
配置层:使能设备并绑定驱动
- 在用户设备树 (system-user.dtsi) 中操作:
- 对于 SoC 内部/PL 侧 IP: 通过 &label 引用并设置 status = okay ;.
- 对于板级外部设备: 在其所连接的总线节点下新建子节点,并确保引脚已在 .xsa 中正确配置.
- 绑定驱动 (所有设备类型通用):
- 在目标节点中,设置 compatible 属性,指向内核自带驱动、UIO 或自定义驱动.
- 准备驱动代码:
- 方式 A - 集成到系统构建 (树内模块):
- 动作: 将驱动源码通过 Petalinux Recipe 的方式添加到内核源码树中,并修改 Kconfig/Makefile.
- 配置: 在 petalinux-config -c kernel 中,将该驱动选项编译进内核 (<*>) 或作为标准模块 (
). - 优点: 最终产物高度集成,自动化部署.
- 方式 B - 独立编译开发 (树外模块):
- 动作: 驱动源码独立存放,为其编写一个专用的 Makefile.
- 准备: 使用
petalinux-build --sdk
生成并安装包含内核头文件的 SDK. - 编译: 在 SDK 环境下,于驱动目录中直接 make,独立生成 .ko 文件.
- 优点: 开发调试效率极高,无需重构整个系统.
- 方式 A - 集成到系统构建 (树内模块):
- 在用户设备树 (system-user.dtsi) 中操作:
验证层:构建、部署与验证
构建系统 (petalinux-build): (主要针对方式 A)编译所有变更.
部署并启动: 将新镜像部署到开发板.
- 加载驱动:
- 对于方式 A: 驱动通常会被内核根据设备树自动加载.
- 对于方式 B: 需要手动将 .ko 文件拷贝到板子上,并使用
insmod ./my_driver.ko
手动加载.
- 加载驱动:
验证:
- dmesg, lsmod: 查看驱动加载信息和 probe 函数打印.
- /proc/device-tree, /dev, /sys: 确认设备节点和接口是否正确创建
内核驱动开发流程
前置准备
获取内核头文件:准备一个正确配置、已编译过的内核源码树(或至少是内核头文件),以提供编译所需的头文件和配置信息
编写Makefile:需要一个 Makefile 来调用内核的构建系统
树内模块
1
2# drivers/char/my_driver/Makefile (示例)
obj-$(CONFIG_MY_DRIVER) += my_driver.o- CONFIG_MY_DRIVER 是你在 Kconfig 文件里定义的配置选项.
- 这行代码的意思是::如果用户在 menuconfig 里选中了 CONFIG_MY_DRIVER,那么就把 my_driver.o 编译并链接到最终目标里
树外模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26# 1. 定义要编译的模块
# obj-m 是内核构建系统的一个特殊变量,表示要编译成 module (.ko 文件)
# 这里表示,我们最终的目标是 my_driver.ko,它由 my_driver.o 链接而成.
obj-m := my_driver.o
# 2. (可选) 定义模块的源文件
# 如果 my_driver.ko 是由多个 .c 文件组成的,就这样写:
# my_driver-objs := file1.o file2.o file3.o
# 3. 指定内核构建系统的位置
# -C $(KDIR) 告诉 make 命令, 请先切换到 KDIR 目录,
# 然后使用那里的顶层 Makefile 来指导编译 .
# KDIR 的值由 SDK 的环境变量自动提供,指向内核头文件目录.
KDIR := $(SDKTARGETSYSROOT)/usr/src/kernel
# 4. 告诉内核构建系统,我们的源码在哪里
# M=$(PWD) 告诉内核的顶层 Makefile, 你要编译的模块源码,
# 在我当前所在的这个目录 (PWD) 里 .
PWD := $(shell pwd)
# 5. 定义 make 命令的规则
default:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean- 输入 make 时,实际上是执行了 make -C /path/to/kernel/headers M=/path/to/my/driver modules
- 含义是:借用内核的构建系统,来编译我放在自己目录下的这个模块
模块加载与卸载
目标: 让内核能够加载 (insmod) 和卸载 (rmmod) 你的驱动
实现:
包含头文件 #include <linux/module.h>.
编写一个初始化函数,用module_init()宏来注册.这个函数在模块被加载时执行,只做一件事, 调用
platform_driver_register(&my_pdrv);
,用于向内核报道1
2
3
4static int __init my_driver_init(void) {
platform_driver_register(&my_pdrv);
}
module_init(my_driver_init);__init
: 用来修饰初始化函数 (module_init 注册的那个),对于静态编译进内核的驱动,当内核启动完成、所有__init 函数都执行完毕后,内核会释放该函数相关内存
编写一个退出函数,用module_exit()宏来注册.这个函数在模块被卸载时执行,只做一件事,调用
platform_driver_unregister(&my_pdrv);
1
2
3
4static void __exit my_driver_exit(void) {
platform_driver_unregister(&my_pdrv);
}
module_exit(my_driver_exit)- 用来修饰退出函数 (module_exit 注册的那个),如果你的内核被配置为不允许卸载模块,那么内核在链接时会直接丢弃所有被 __exit 修饰的函数
使用 MODULE_LICENSE( GPL ), MODULE_AUTHOR(…), MODULE_DESCRIPTION(…) 声明模块的基本信息
驱动与设备的绑定与解绑
目标: 将你的驱动逻辑与设备树中描述的物理设备绑定起来.
实现:
使用平台驱动模型 (Platform Driver).包含 #include <linux/platform_device.h>.
定义一个 of_device_id 数组,列出你的驱动支持的 compatible 字符串
1
2
3
4
5static const struct of_device_id my_driver_of_match[] = {
{ .compatible = my-company1,my-device1 },
{ .compatible = my-company2,my-device2 },
{ /* 末尾空元素是必须的 */ }
};定义一个 platform_driver 结构体,将你的 probe 和 remove 函数,以及 of_device_id 数组赋值给它.
1
2
3
4
5
6
7
8static struct platform_driver my_pdrv = {
.driver = {
.name = my_driver ,
.of_match_table = my_driver_of_match,
},
.probe = my_driver_probe,
.remove = my_driver_remove,
};在初始化函数 (my_driver_init) 中,调用 platform_driver_register(&my_pdrv) 来向内核注册你的平台驱动.
在退出函数 (my_driver_exit) 中,调用 platform_driver_unregister(&my_pdrv) 来注销
获取硬件资源
目标: 在
probe(struct platform_device *pdev)
函数中,从设备树获取硬件信息并建立连接- probe 函数: 当内核发现一个设备的 compatible 与你的驱动匹配时,自动调用此函数,负责设备初始化
- remove 函数: 当设备被移除或驱动被卸载时调用,用于释放资源
相关API
获取和映射寄存器地址:
platform_get_resource(pdev, IORESOURCE_MEM, 0)
: 从设备树的 reg 属性中获取物理地址资源- pdev: 内核传给 probe 函数的设备结构体指针,包含了设备的所有信息.
- type: 你想获取的资源类型.最常用的是 IORESOURCE_MEM (内存映射的寄存器) 和 IORESOURCE_IRQ (中断).
- num: 资源的索引.如果设备树的 reg 或 interrupts 属性里有多项,0 代表第一项,1 代表第二项,以此类推
devm_ioremap_resource(&pdev->dev, res)
: 将物理地址映射到内核的虚拟地址空间,返回一个可以直接读写的指针.(devm_ 开头的函数会自动管理内存,推荐使用).- dev: &pdev->dev,从 platform_device 中获取的通用设备结构体指针.
- res: platform_get_resource() 返回的资源结构体指针
读写寄存器:
readl(addr)
,writel(value, addr)
: 读写 32 位寄存器.
获取和申请中断:
platform_get_irq(pdev, 0)
: 从设备树的 interrupts 属性中获取中断号 (0是索引)- num: 中断资源的索引,0 代表 interrupts 属性里的第一组中断
devm_request_irq(&pdev->dev, irq_num, my_irq_handler, IRQF_TRIGGER_RISING, my_device_irq , my_device_data)
: 注册一个中断处理函数- dev: &pdev->dev.
- irq: platform_get_irq() 返回的中断号.
- handler: 中断处理函数的函数指针,比如 my_irq_handler.
- flags: 中断触发标志.最常用的是 IRQF_TRIGGER_RISING (上升沿), IRQF_TRIGGER_FALLING (下降沿).
- name: 这个中断的名字,会显示在 /proc/interrupts 文件中,用于调试.
- dev_id: 一个私有数据指针.当中断发生时,这个指针会原封不动地传给你的中断处理函数.通常我们会把设备的私有数据结构体指针传进去,这样在 ISR 里就能知道是哪个设备触发了中断
中断处理函数 (Interrupt Service Routine, ISR):
- 这是一个特殊的函数,当硬件中断发生时,CPU 会立即跳转到这里执行.
- 函数原型:
static irqreturn_t my_irq_handler(int irq, void *dev_id)
- 注意: ISR 必须执行得非常快不能进行任何可能导致睡眠的操作.通常它只做一些紧急的操作(如清除中断标志位),然后通过工作队列 (workqueue) 或tasklet 等机制,将耗时的:下半部 (bottom half) 处理推迟到正常上下文中执行
- 在驱动的 probe 函数里,你可以创建一个工作项 (work item),并指定一个处理函数
INIT_WORK(&my_work, my_work_handler_func);
- 在中断处理函数 (ISR) 的最后,你只需要调用
schedule_work(&my_work);
这个调用会立即返回,ISR 结束
- 在驱动的 probe 函数里,你可以创建一个工作项 (work item),并指定一个处理函数
保存设备上下文
目标:
- 在probe 函数中,我们将所有获取到的、与特定设备相关的资源(如映射后的寄存器地址、中断号、自旋锁、私有数据缓冲区等)打包到一个自定义的结构体中
- 将这个结构体的指针:附着到内核的设备模型上.
- 在驱动的其他函数(如 read, write, remove, irq_handler)中,就能方便地取回这些信息,从而操作正确的设备
实现方法
void platform_set_drvdata(struct platform_device *pdev, void *data)
- 作用: 将你的私有数据指针 data 设置 (关联) 到 platform_device 结构体中.这是一个通用的 void * 指针,可以存放任何类型的数据.
- pdev: 你的平台设备.
- data: 你要存放的私有数据结构体指针(比如 priv).
void *platform_get_drvdata(const struct platform_device *pdev)
- 作用: 从 platform_device 结构体中获取 (取回) 之前用 platform_set_drvdata 设置的私有数据指针.
- pdev: 你的平台设备.
- 返回值: 返回之前设置的那个 void * 指针,你需要将它强制类型转换回你自己的结构体类型
创建设备文件
目标:创建自定义的file_operations函数,在probe函数末尾绑定,便于用户操作
file_operations 结构体:这是一个包含函数指针的结构体,将标准的文件操作映射到你自己的驱动函数上
1
2
3
4
5
6
7
8static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_driver_open,
.release = my_driver_release,
.read = my_driver_read,
.write = my_driver_write,
.unlocked_ioctl = my_driver_ioctl,
};file_operations 函数
static int my_driver_open(struct inode *inode, struct file *filp)
- 何时调用: 当用户空间的程序执行 open( /dev/my_device , …) 时.
- 做什么:
- 做一些设备打开时的初始化工作(比如给硬件上电、检查设备状态).
- (重要) 可以将设备的私有数据结构体指针,存放到 filp->private_data 中.这样,在后续的 read/write 调用中,就可以通过 filp->private_data 方便地取回这个设备的上下文信息.
static int my_driver_release(struct inode *inode, struct file *filp)
- 何时调用: 当用户空间的程序执行close(fd)时(并且这是最后一个关闭该文件的进程).
- 做什么: 执行与 open 相反的操作,比如给硬件断电,释放 open 时申请的资源.
static ssize_t my_driver_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
- 何时调用: 用户程序执行 read(fd, …)
- 做什么:
- 从硬件读取数据.
- 使用
copy_to_user(buf, kernel_data, data_size)
,将从硬件读到的数据,安全地拷贝到用户空间提供的 buf 缓冲区中. - 返回成功拷贝的字节数.
static ssize_t my_driver_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
- 何时调用: 用户程序执行 write(fd, …).
- 做什么:
- 使用
copy_from_user(kernel_data, buf, data_size)
,将用户空间 buf 里的数据,安全地拷贝到内核的临时缓冲区. - 将拷贝来的数据写入到硬件.
- 返回成功写入的字节数.
- 使用
static long my_driver_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
- 何时调用: 用户程序执行 ioctl(fd, …).
- 做什么: 处理一些无法通过简单 read/write 完成的、设备特定的控制命令.比如,用一个 ioctl 命令来复位设备,或者设置设备的工作模式.cmd 是命令编号,arg 是传递的参数
相关API
int alloc_chrdev_region(dev_t *dev, unsigned int firstminor, unsigned int count, const char *name)
- 作用: 动态地向内核申请一个或多个未被使用的设备号.这是推荐的方式,可以避免与系统中已有的设备号冲突.
dev_t *dev
: [输出参数] 一个指向dev_t
类型变量的指针.如果函数执行成功,内核会把分配到的设备号写入这个变量.unsigned int firstminor
: 请求的起始次设备号.通常设置为0
.unsigned int count
: 你想申请的连续设备号的数量.对于一个只提供单个设备节点的驱动,这里写1
.const char *name
: 你的设备名.这个名字会显示在/proc/devices
文件中,用于标识这个主设备号被谁占用了.- 返回值: 成功返回
0
,失败返回一个负的错误码.
void cdev_init(struct cdev *cdev, const struct file_operations *fops)
- 作用: 初始化一个
cdev
结构体,并将它与你的file_operations
结构体关联起来.cdev
(character device) 结构体是字符设备在内核中的抽象表示. struct cdev *cdev
: [输入/输出参数] 指向你在驱动中定义的cdev
结构体变量的指针.const struct file_operations *fops
: 指向你已经定义好的file_operations
结构体变量的指针.
- 作用: 初始化一个
int cdev_add(struct cdev *cdev, dev_t dev, unsigned int count)
- 作用: 向内核正式注册这个字符设备,让它:活 起来.从此,对这个设备号的访问就会被导向你的
file_operations
. struct cdev *cdev
: 指向你已经cdev_init
过的cdev
结构体.dev_t dev
:alloc_chrdev_region()
分配到的那个设备号.unsigned int count
: 你要注册的连续设备数量,必须与alloc_chrdev_region
中申请的数量一致,通常是1
.- 返回值: 成功返回
0
,失败返回负的错误码.
- 作用: 向内核正式注册这个字符设备,让它:活 起来.从此,对这个设备号的访问就会被导向你的
struct class *class_create(struct module *owner, const char *name)
- 作用: 创建一个设备类 (
class
),它相当于/sys/class/
目录下的一个新文件夹.同一类设备可以放在这里,便于管理. struct module *owner
: 通常就是THIS_MODULE
宏.const char *name
: 你的设备类的名字,比如my_device_class
.- 返回值: 成功返回一个指向
class
结构体的指针,失败返回ERR_PTR
.
- 作用: 创建一个设备类 (
struct device *device_create(struct class *cls, struct device *parent, dev_t dev, void *drvdata, const char *fmt, ...)
- 作用: 在你创建的
class
下,创建一个具体的设备 (device
),并触发 udev 生成/dev
节点. struct class *cls
:class_create()
返回的那个类指针.struct device *parent
: 父设备指针.对于平台设备,通常是&pdev->dev
.对于独立设备,可以是NULL
.dev_t dev
: 我们分配到的设备号.void *drvdata
: 私有数据指针,可以传NULL
.const char *fmt, ...
: 设备节点的名字,可以使用printf
格式,例如my_device%d
.udev 会用它来创建/dev/my_device0
.- 返回值: 成功返回一个指向
device
结构体的指针.
- 作用: 在你创建的
unsigned long copy_to_user(void __user *to, const void *from, unsigned long n)
- 作用: 从内核空间安全地拷贝数据到用户空间.
void __user *to
: 用户程序传进来的目标缓冲区指针(比如read
系统调用的buf
参数).__user
是一个特殊的宏,提醒编译器和检查工具,这是一个不可信的用户空间地址.const void *from
: 你在内核空间准备好的源数据缓冲区.unsigned long n
: 要拷贝的字节数.- 返回值: 返回未能成功拷贝的字节数.如果返回
0
,表示全部拷贝成功.
unsigned long copy_from_user(void *to, const void __user *from, unsigned long n)
- 作用: 从用户空间安全地拷贝数据到内核空间.
void *to
: 你在内核空间准备的目标缓冲区.const void __user *from
: 用户程序传进来的源缓冲区指针(比如write
系统调用的buf
参数).unsigned long n
: 要拷贝的字节数.- 返回值: 同样,返回未能成功拷贝的字节数.如果返回
0
,表示全部拷贝成功.
Linux 启动流程
[上电] -> [BootROM] -> [FSBL] -> [U-Boot] -> [Linux Kernel] -> [Init Process] -> [用户应用]
- BootROM (芯片内固化的代码):唯一使命就是找到并唤醒 FSBL
- 触发:SoC 芯片上电或复位
- 执行者:芯片内部一块只读存储器(ROM)中的固化代码,由芯片制造商写入,无法修改
- 核心任务:
- 进行最最基础的硬件初始化(例如,部分时钟).
- 根据 启动模式 配置(通过外部引脚电平决定)去指定的启动设备(如 QSPI Flash、SD 卡、NAND Flash)的特定位置加载第一阶段引导加载程序 (FSBL).
- 验证 FSBL 的签名(如果开启了安全启动),然后将控制权交给 FSBL
- 第二阶段:FSBL (First Stage Boot Loader):唤醒硬件,为U-Boot 准备好运行环境
- 执行者:由硬件开发工具(如 Xilinx Vitis/Vivado)根据硬件设计自动生成的程序.
- 核心任务:
- 初始化关键硬件:主要是初始化PS (处理器系统) 部分,比如 DDR 内存控制器、时钟(PLL)、以及一些必要的片上外设.没有它,DDR 无法使用.
- (可选)加载 PL Bitstream:在 Zynq 中,FSBL 还可以负责加载 FPGA 的比特流文件,配置 PL (可编程逻辑) 部分.
- 加载下一阶段程序:将 U-Boot(或其它第二阶段引导程序)从启动设备加载到 DDR 内存中.
- 移交控制权:跳转到 U-Boot 在 DDR 中的入口地址,把:接力棒 传给 U-Boot.
- 第三阶段:U-Boot (Universal Boot Loader)
- 执行者:一个功能强大的开源 Bootloader,是嵌入式 Linux 世界的事实标准.
- 核心任务:
- 全面的硬件初始化:初始化更多、更复杂的外设,如网络接口(PHY)、USB 控制器、存储控制器(eMMC/SATA)等.
- 提供交互接口:通过串口提供一个命令行界面,允许工程师中断启动过程,进行调试、烧写固件、修改启动参数等.
- 加载操作系统:从启动设备中读取 Linux 内核(uImage/zImage)、设备树(.dtb)和可选的 RAM disk 文件,并将它们加载到 DDR 的指定地址.
- 传递启动参数:通过设备树 (Device Tree)和bootargs环境变量,告诉内核硬件相关信息以及启动方法(如根文件系统在哪里、串口终端是哪个)
- 移交控制权:以特定的方式(ARM 架构下,通常是将设备树的地址放入特定寄存器)跳转到 Linux 内核的入口点,正式启动操作系统.
- 第四阶段:Linux Kernel
- 执行者:Linux 内核自身.
- 核心任务:
- 自解压:内核镜像通常是压缩的,所以第一步是先在内存中把自己解压开.
- 内核初始化:初始化各个核心子系统,如内存管理(MMU)、进程调度、中断管理、定时器等.
- 解析设备树:读取 U-Boot 传过来的设备树信息,根据设备树信息来探测和初始化对应的设备驱动程序.例如,它在设备树里看到一个网络芯片的描述,就会去加载对应的驱动.
- 挂载根文件系统 (RootFS):根据 U-Boot 传递的 bootargs 中的 root= 参数,找到指定的存储设备分区,并将其挂载为根目录 /.
- 启动第一个用户进程:在根文件系统挂载成功后,内核在用户空间创建并执行第一个进程–init 进程(通常是 /sbin/init).
- 一句话总结:系统的:灵魂 ,接管硬件控制,并建立起软件运行的框架.
- 第五阶段:Init 进程与用户空间
- 执行者:init 进程(PID=1),它是所有用户进程的:祖先 .
- 核心任务:
- 读取自己的配置文件(如 /etc/inittab 或 systemd 的配置单元).
- 依次启动各种系统服务(如网络服务、SSH 服务、日志服务等).
- 最终,启动你的目标应用程序(例如 RTSP 服务器)或显示一个登录 Shell.
- 其他细节:
- BOOT.BIN通常包含:FSBL, Bitstream, U-Boot
- image.ub 是一个符合 U-Boot FIT (Flattened Image Tree) 格式的镜像文件,包含内核本身,设备树等
- boot.scr包含了Uboot启动自动化脚本
内核模块
- 定义:
- Linux 内核模块(LKM, Loadable Kernel Module)是一段特殊的目标代码,可以在系统正在运行时被动态地加载到内核空间
- 也可以在不需要时从内核中卸载,释放其占用的资源
- 内核模块的最终产物是一个后缀为 .ko (Kernel Object) 的文件
- 它是一种标准的 ELF (Executable and Linkable Format) 对象文件
- 加载过程
- 用户空间发起:通过 insmod 或 modprobe 命令发起加载请求.
- 系统调用:这些命令会触发系统调用(如 init_module),请求陷入内核态.
- 内核操作:
- 分配内核内存:内核为即将加载的模块分配一块专属的、连续的内核空间内存
- 加载代码和数据:将
.ko
文件从用户空间拷贝到内核空间内存 - 符号解析 (Symbol Resolution):将模块中未定义的外部符号(如 printk),链接到内核主程序已定义的符号地址上
- 执行初始化函数:每个模块都必须定义一个初始化函数(使用 module_init() 宏注册).在符号解析成功后,内核会调用这个函数
- 卸载过程 (Unloading):
- 用户空间发起:通过rmmod命令发起卸载请求
- 系统调用:触发delete_module系统调用.
- 内核操作:
- 检查引用计数:内核会检查该模块是否仍在使用中(比如设备文件是否被打开).如果引用计数不为零,卸载会失败,防止系统崩溃
- 执行清理函数:每个模块都必须定义一个清理函数(使用 module_exit() 宏注册).内核会调用它来注销驱动、释放设备节点、归还硬件资源等.
- 释放内存:将模块占用的内核内存释放,彻底从系统中移除
中断处理机制
[设备] -> [GIC 中断控制器] -> [CPU 核] -> [异常向量表] -> [内核通用分发器] -> [驱动的 ISR (顶半部)] -> [内核的软中断 (底半部)]
- 硬件阶段:信号的产生与路由
- 设备触发:外部设备(如一个 GPIO 控制器)的某个引脚电平发生变化,触发中断信号.
- 送达 GIC:物理信号线连接到 Zynq 内部的 GIC (中断控制器).GIC负责管理系统中所有的中断源
- GIC 的工作:
- 仲裁与优先级:如果多个中断同时到达,GIC 会根据预设的优先级,决定先处理哪一个.
- 屏蔽 (Masking):GIC 可以屏蔽掉某些不想处理的中断.
- 路由:在多核系统中(如 Zynq MPSoC 的 A53),GIC 负责将中断信号路由给一个或多个特定的 CPU 核.
- 通知 CPU:GIC 最终通过一根专用的物理线(IRQ 或 FIQ)向目标 CPU 核发出中断请求.
- 处理器阶段:打断与跳转
- CPU 响应:CPU 在执行完当前指令后,会检查 GIC 发来的中断请求线.
- 上下文保存 (硬件自动):一旦确认中断,CPU 会自动地做几件关键事情:
- 将当前程序计数器(PC)的值保存到 ELR_EL1 (异常链接寄存器).
- 将当前的处理器状态(CPSR)保存到 SPSR_EL1 (保存的程序状态寄存器).
- 进入特权模式(EL1,即内核态),并屏蔽后续的同级或低级中断.
- 跳转到向量表:CPU根据中断类型,跳转到异常向量表 (Exception Vector Table),由异常向量表跳转到通用分发器
- 内核软件阶段:分发与处理
- 通用分发器:
- 保存完整上下文:将所有通用寄存器(x0-x30)压入内核栈.
- 查询中断源:向 GIC 查询, 刚才到底是哪个中断号 (IRQ Number) 触发了我? .GIC 会返回具体的数字,比如 61 代表 GPIO 中断.
- 调用 ISR:内核根据中断源,在一个全局的中断处理函数指针数组中,找到驱动注册的中断处理函数并调用
- 通用分发器:
- 驱动处理阶段:两阶段处理 (Top Half / Bottom Half),这是 Linux 中断处理的精髓,为了保证系统整体的响应性,一个中断处理被刻意拆分为两部分:
- 顶半部 (Top Half / Hard IRQ):
- 身份:就是驱动里通过 request_irq() 注册的处理函数.
- 特点:在中断上下文中执行,此时中断是被屏蔽的,因此,它必须极快地执行完毕,否则会严重影响系统的其他部分.
- 任务:只做最紧急的事,比如:读取设备寄存器以清除中断标志位、从硬件 FIFO 中拷贝少量数据到内存、然后调度一个底半部任务,并立即返回.
- 底半部 (Bottom Half / Soft IRQ):
- 身份:由顶半部:预约 的一个延迟执行的任务,形式有 Softirq、Tasklet、Workqueue.
- 特点:它在普通内核上下文中执行,此时中断是打开的.它允许被更高优先级的中断打断.
- 任务:处理那些耗时、复杂的任务,比如:详细解析收到的数据包、将数据传递给上层应用、进行大量的计算等.
- 比喻:关上门后,你回到客厅,从容地拆开包裹,检查商品,然后把它放到储物柜里
- 顶半部 (Top Half / Hard IRQ):
- 其他细节
- 传统中断 (Legacy INTx):通过物理线触发中断,多设备共享,效率低
- MSI (Message Signaled Interrupts):设备不再用物理线,而是通过向一个特定的内存地址写入一个特定的数据来触发中断
- MSI-X:MSI 的增强版,允许一个物理设备虚拟出多个独立的中断源
- 对于 NVMe SSD 或多队列网卡至关重要,每个队列都可以有自己的中断,可以分发到不同的 CPU 核上处理,实现极高的并行性能
Linux 内存管理
- 定义
- 物理内存:真实存在的硬件资源,地址是物理地址,从 0 开始,是有限的
- 虚拟内存:内核为每个进程制造出来的一种假象.内核让每个进程都以为自己独占了整个系统的内存
- 作用
- 进程隔离与系统保护:
- 隔离:每个进程都有自己独立的虚拟地址空间,互不干扰
- 保护:一个进程发生内存访问错误,只导致自己崩溃,而不会影响到内核或其他进程
- 简化程序开发
- 不需要关心真实的物理内存还剩多少、哪块是空闲的
- 它们只需要在一个标准、统一的虚拟地址空间里布局代码段、数据段、堆栈即可
- 链接器可以总是假设程序从一个固定的虚拟地址开始
- 高效利用物理内存
- 虚拟内存允许将不常用的内存页(Page)换出到磁盘(Swap),在需要时再加载回来
- 不同的进程可以共享同一份物理内存;例如,所有 C 程序都会用到 libc.so 库,内核只需在物理内存中加载一份,然后把映射到所有需要它的进程的虚拟地址空间里即可
- 进程隔离与系统保护:
- 为了实现系统保护,Linux 将每个进程的虚拟地址分为内核空间和用户空间
- 用户空间:地址范围较低的部分.
- 特点:每个进程都有一套自己独立的页表来映射这部分空间.进程 A 和进程 B 的用户空间页表是完全不同的.
- 内容:存放进程的代码、全局变量、堆栈等.
- 访问:CPU 处于非特权模式(EL0)时只能访问这部分地址.
- 内核空间:地址范围较高的部分.
- 特点:这部分虚拟地址空间的映射关系是全局唯一的,被所有进程共享.无论哪个进程进入内核态,看到的内核空间都是同一个
- 内容:存放内核的代码、数据、以及用于管理所有进程和硬件的数据结构.
- 访问:只有当 CPU 通过系统调用 (System Call) 或中断进入特权模式(EL1)后,才能访问这部分地址
- 用户空间:地址范围较低的部分.
- CPU访问内存流程
- CPU把虚拟地址放到地址总线
- MMU(内存管理单元)获取虚拟地址,查询TLB(Translation Lookaside Buffer).TLB 是一个高速缓存,存放着最近用过的:虚拟地址 -> 物理地址 的映射关系
- 如果 TLB 里正好有这个虚拟地址的映射记录,MMU 立刻就能得到对应的物理地址
- 如果在 TLB 里没找到,就启动查询页表 (Page Table)
- MMU开始在内存中遍历页表,页表记录了所有虚拟页到物理页框的映射关系
- 查询是分级的,在 ARMv8 中通常是 4 级
- 如果最终找到了有效的映射,MMU 就得到了虚拟地址对应的物理地址,并存入TLB
- 如果在遍历页表过程中,发现某个页表项是无效的,MMU 会触发一个缺页异常,这是一个硬件中断
- 如果合法则从Swap空间交换回来,更新页表,重新执行刚刚失败的指令
- 如果非法内核就会给进程发送一个 SIGSEGV 信号,导致程序崩溃(Segmentation fault)
- MMU获得物理地址,发送给内存总线和内存控制器,内存控制器从DDR读取数据
- 数据从数据总线返回CPU
- Linux进程切换
- 内核通过切换 CPU 的用户空间页表基地址寄存器(TTBR0_EL1),来更换当前进程的虚拟地址空间映射
- 同时保持内核空间的页表基地址寄存器(TTBR1_EL1)不变,从而实现了用户空间的隔离和内核空间的共享
- 这个切换动作会导致 TLB 中旧进程的缓存失效
- 注意事项
- Page Fault :写时才分配
- C/C++ 中使用 malloc() 或 new,库函数会通过 brk() 或 mmap() 系统调用向内核申请内存
- 内核收到请求后,会修改该进程的页表,建立新的虚拟到物理的映射,但并不会立即分配物理内存
- 内核空间地址申请
- kmalloc():内核中最常用的方式.它申请的是物理地址连续的内存块
- vmalloc():申请的是虚拟地址连续,但物理地址不一定连续的内存块
- 设备寄存器映射
- PL 侧的 IP 核或片上外设(如 UART)的寄存器都位于固定的物理地址.内核驱动不能直接通过指针访问这些物理地址
ioremap()
函数,为这些物理地址在内核的虚拟地址空间中创建一个映射后才可以访问
异常处理
- 定义:任何导致处理器偏离正常指令顺序执行流程的事件
- 在 ARM 架构的官方定义中,中断只是异常的一种
- 中断:由外设异步产生的异常
- Linux 用异常处理机制来隔离错误、杀死进程
- 裸机用异常处理机制来捕获错误、停机待查
WDT看门狗
- 定义:是一个独立的硬件定时器,核心使命是监控主处理器的程序是否正常运行
- 这个定时器需要周期性的喂狗,如果程序跑飞没喂,会产生不可屏蔽的硬件复位信号,重启系统
- 应用场景:
- 裸机循环卡死
- RTOS任务死锁
- Linux内核异常
- 硬件异常导致程序跑飞
- 工作流程
- 在程序初始化阶段(main函数开始),初始化WDT
- 设置超时周期,使能WDT
- 在主程序中,保留一个可靠的能够代表系统监控的地方周期性的喂狗
- 超时没喂就会强制重启
- 在程序初始化阶段(main函数开始),初始化WDT
存储与文件系统
PCIe协议
- 定义:是一种高速、串行、点对点的计算机扩展总线标准.与老式的并行总线(如 PCI)不同,它使用一对或多对差分信号来传输数据
- PCIe 是目前连接 CPU 与高性能外设的事实标准.在嵌入式系统中,任何需要高带宽、低延迟的场景都离不开它
- 总线枚举:识别所有的PCIe总线上的设备,为设备分配唯一的、由总线号 (Bus)、设备号 (Device) 和功能号 (Function) 组成的BDF地址,该步骤是由Uboot进行执行
- 起始点:从PCIe Root Complex 直连的Bus 0开始.
- PCIe Root Complex集成在CPU内部,是PCIe控制器,延伸出来直连PCIe线就是Bus0
- 深度优先遍历:软件通过发送配置读写(Configuration Read/Write)TLP 包,以深度优先的算法遍历整个 PCIe 拓扑树,读取设备的配置空间
- 从Bus 0, Device 0, Function 0开始,主动发送Configuration Read TLP到每个可能的BDF地址
- 设备发现:如果设备存在,会收到包含有效Vendor ID的响应TLP,不存在则收到超时信息
- 桥(Bridge)处理:当发现一个设备的 Header Type 表明它是一个 PCIe 桥时,软件会为桥下联的次级总线(Secondary Bus)分配一个新的总线号,并递归地对该新总线继续进行枚举.
- 资源分配:在枚举过程中,软件会读取各设备的资源需求(如 BAR 空间大小、中断请求),并为其分配系统资源
- 起始点:从PCIe Root Complex 直连的Bus 0开始.
- 配置空间:配置空间是每个PCIe设备内部的一个标准化的 4KB 寄存器区域,是设备被分配的地址BDF指向的空间,以完成设备的识别、资源分配和功能启用
- 设备识别:操作系统利用Vendor ID(厂商ID), Device ID(设备ID)匹配并加载相应的设备驱动程序.
- 设备分类:Class Code(设备分类码)定义了设备的基本类型(如存储控制器、网络控制器),便于通用驱动或操作系统的识别.
- 拓扑结构:Header Type区分该功能是属于一个普通端点设备(Endpoint, Type 0 Header)还是一个 PCIe 桥(Bridge, Type 1 Header)
- 资源请求:Base Address Registers (BARs)用于声明设备需要映射的内存或 I/O 空间的大小和类型.
- 控制与状态:Command 和 Status 寄存器用于启用/禁用设备功能(如 Bus Master Enable、Memory Space Enable)和回报设备状态
- 设备基地址寄存器:Base Address Register(BAR)是位于配置空间头部的一组寄存器,用于将设备内部的本地内存、I/O 端口或控制寄存器映射到主机的物理地址空间中,是实现 MMIO (Memory Mapped I/O) 的核心机制
- 空间探测:软件首先向BAR 寄存器写入全 1
- 设备响应:设备硬件根据其所需的空间大小,将地址掩码(Size Mask)返回.例如,需要 1MB 空间,则返回 0xFFF00000.
- 大小计算:软件通过对读回的值进行位反转并加一 (~value + 1),即可计算出设备请求的地址空间大小.
- 地址分配:软件在系统物理地址空间中找到一块满足大小和对齐要求的空闲区域,将其物理基地址写回 BAR 寄存器.
- 映射建立:写入完成后,硬件映射建立.任何对这段被分配的主机物理地址范围的访问都会被Root Complex转化为对特定PCIe设备的访问
- TLP (Transaction Layer Packet):是 PCIe 总线上进行信息交换的基本数据单元.所有通信,包括内存访问、I/O 访问、配置和消息,都必须封装成 TLP 进行传输
- Header (包头):定义了 TLP 的类型和属性,是路由和处理 TLP 的依据
- Fmt 和 Type:定义 TLP 的格式和具体类型(如 MRd - 内存读, MWr - 内存写, CfgRd - 配置读, Cpl - 完成包, CplD - 带数据的完成包).
- Requester ID:发起请求的设备的 BDF.
- Address / Address64:目标内存或 I/O 地址.
- Length:传输的数据长度.
- Data Payload (数据载荷):对于写请求或带数据的完成包,这里包含实际传输的数据.
- ECRC (End-to-end CRC):可选的端到端数据完整性校验.
DMA
- 定义:DMA (Direct Memory Access):允许系统中的某些硬件子系统(外设)在无需CPU直接参与的情况下,直接读写内存
- DMA控制器代替CPU,处理繁重的数据搬运的工作
- 不使用DMA的情况(PIO模式):CPU通过load/store指令逐字从外设的数据寄存器读取数据,数据量大CPU陷入忙碌
- DMA工作流程
- CPU设置DMA控制器
- CPU会向DMA 控制器 (DMAC, DMA Controller)的一组寄存器写入信息,告诉它数据源地址,目的地址,传输长度,和传输方向以及模式
- DMAC接管总线(Bus Mastering)
- DMAC需要使用系统总线(如AXI)来访问内存和外设,向总线仲裁发出总线请求,在合适的情况下将总线分配给DMAC
- 数据传输
- DMAC接手系统总线变成Bus Master,进行数据传输,行为与CPU一致
- DMAC内部的计数器递减,减至0则传输完毕
- 传输完毕释放总线,向CPU发送中断信号
- CPU设置DMA控制器
- 网口的DMA
- 网卡驱动程序在主内存中预分配一大块连续的内存区域,作为接收缓冲区池 (RX Buffers Pool)
- 驱动还会分配另一块内存,用于存放一个接收描述符环形队列 (RX Descriptor Ring)
- 驱动会遍历这个描述符队列,为描述符关联空闲缓冲区池里的 buffer,填入buffer物理地址
- 驱动将接收描述符环形队列的物理基地址和队列长度,写入网卡控制器的寄存器中
- 网卡从物理链路上完整地接收到一个以太网帧,网卡的 DMA 引擎自动地作为 Bus Master,直接将数据包写入到buffer中,随后更新描述符状态
- 网卡使用中断合并,不会每接收一个包就中断一次
- CPU接收中断后,驱动根据描述符状态获取数据,传递给网络协议栈,并分配新的空闲的buffer给处理的描述符
NVMe协议
- 定义:专为 PCIe 固态硬盘 (SSD) 设计的、高性能、可伸缩的主机控制器接口规范
- AHCI/SATA:无论多少IO请求,都要排成队等待控制器依次处理(为一次只能服务一个磁头的机械硬盘设计的)
- 是为拥有海量并行通道的 NAND Flash 设计的
- NVMe 的核心就是基于内存环形队列的、高效的命令提交与完成机制
提交与完成队列
- NVMe 的通信基础是成对的队列:一个用于主机(Host)向控制器(SSD)提交命令,另一个用于控制器向主机报告命令的完成状态
- 定义:
- 提交队列 (Submission Queue, SQ):由主机(Host)写入,用于存放待处理的 I/O 命令.
- 完成队列 (Completion Queue, CQ):由控制器(SSD)写入,用于存放已完成命令的状态回报.
- 物理位置: SQ 和 CQ 都是主机驱动程序在主机 DDR 内存中分配的环形缓冲区.
- 成对工作: SQ 和 CQ 通常成对出现,构成一个独立的命令处理通道.主机通过写 SSD 的 BAR 寄存器来告知其各个队列的物理地址.
- 队列类型:
- 管理队列 (Admin Queue):固定只有一对 (Admin SQ/CQ),用于设备初始化和管理任务(如创建 I/O 队列、获取设备信息等).
- I/O 队列 (I/O Queues):可创建多达 65,535 对 (I/O SQ/CQ),专门用于数据传输命令(如
NVM Read
/NVM Write
).
- 并行性: 多 I/O 队列的设计允许多核 CPU 的每个核心无锁地操作各自的队列,从而实现大规模 I/O 并行处理,这是 NVMe 高性能的关键.
命令执行与完成机制
- NVMe 采用生产者-消费者模型管理队列指针,实现高效的异步通信.
- 提交命令 (主机作为生产者):
- 主机驱动在环形队列SQ中构建命令,维护尾指针
- 通过MMIO写操作,将更新后的 SQ 尾指针值写入 SSD 的提交队列门铃 (SQ Doorbell),通知 SSD 有新命令.
- 处理命令 (SSD 作为消费者):
- SSD 在收到门铃后,从其内部维护的 SQ 头指针 (Head Pointer) 开始,通过 DMA 读取新命令.
- 每处理一个命令,SSD 就更新其内部的 SQ 头指针.
- 提交命令 (主机作为生产者):
数据指针
- 由于现代操作系统采用虚拟内存,大块数据缓冲区在物理内存中通常不连续
- PRP 和 SGL 是 NVMe 定义的两种 Scatter-Gather DMA 机制,用于描述这些非连续的数据区.
- PRP (Physical Region Page):
- 它是一种描述符格式,其基本单位是物理内存页 (Page),是一个指向物理页地址列表的机制
- 命令中的 DPTR (Data Pointer) 字段,如果指向的是 PRP,那么它指向的是一个或多个页地址
- SGL (Scatter Gather List):
- 它是一种更通用的描述符格式,其基本单位是段 (Segment),它是一个指向[地址, 长度]列表的机制
- 命令中的 DPTR 字段,如果指向的是 SGL,那么它指向内存中的一个或多个 SGL 段描述符.
- 每个 SGL 段描述符都是一个[地址,长度]的二元组.
- PRP (Physical Region Page):
SSD 基本原理
- SSD使用NAND Flash作为永久性存储介质的存储设备
- 物理结构
- Page(页):是读取和写入的最小单位(通常 4KB - 16KB).
- Block(块):是擦除的最小单位(通常 2MB - 4MB),由许多个页组成(如 256 个页)
- 三大核心规则
- 读写不对称:可以按 页 为单位读写,但只能按 块 为单位擦除
- 先擦后写:你不能覆盖一个已经写入数据的页.如果想修改一个页,必须先将它所在的整个块擦除
- 有限的擦写寿命 (P/E Cycles):每个块能被擦除和编程(写入)的次数是有限的.这导致了 磨损 .
- SLC (1 bit/cell):寿命最长 (10万次),最贵.
- MLC (2 bits/cell):寿命、性能、成本均衡 (数千次).
- TLC (3 bits/cell):密度更高,成本低,寿命较短 (数百到上千次).
- QLC(4 bits/cell):密度最高,最便宜,寿命最短 (数百次)
- FTL(闪存转换层)是SSD 主控芯片的固件,为操作系统屏蔽 NAND 的物理特性,提供一个虚拟的、简单的块设备接口
- FTL 的核心是维护一张 LBA(Logical Block Address,逻辑块地址)到PPA (Physical Page Address物理页地址) 的映射表
- 查找空闲PPA(物理页)
- 写入数据到该PPA
- 更新LBA -> PPA映射表
- 将旧数据标记为失效
- 磨损均衡
- FTL会有意识地将写入操作均匀分布到所有的物理Block 上
- 当一个逻辑块地址被更新时,FTL不会在原地修改数据
- 它会找一个新的、干净的物理 Page 来写入新数据,然后更新映射表,让这个逻辑块地址指向新的物理地址
- 旧数据所在的 Page 则被标记为失效(invalid)
- 垃圾回收:磨损均衡策略导致了大量的失效数据散落在各个 Block 中(写的page小于擦除的块,失效的page散步在各个块)
- 选定目标: FTL 找到一个 垃圾 最多的 Block (失效数据比例最高的块) 作为回收目标
- 搬运有效数据: 读取该Block的有效数据
- 写入新家: 将有效数据写入到干净的 Block
- 更新映射: 更新 FTL 映射表,让相应的 LBA 指向这些数据的新物理地址
- 擦除旧块
- 垃圾回收会在SSD空闲块少的时候触发,因此SSD在快写满时性能下降
Linux 块设备层
是 Linux 内核中专门用于处理块设备(如 SSD、硬盘、SD 卡、eMMC)I/O 请求的一个核心子系统
- 位于文件系统和块设备驱动程序之间
核心作用
- 给上层文件系统提供不关心硬件的操作接口
- 通过IO调度器对海量IO进行合并排序,以降低延迟增加吞吐
I/O 请求的完整路径
用户空间发起write
VFS (Virtual File System, 虚拟文件系统)
- VFS 是内核的通用文件接口,它首先处理权限检查、文件描述符等与具体文件系统无关的事务
- 将请求传递给对应的具体文件系统
文件系统
- 文件系统计算待写入的数据应该放到哪几个逻辑块
- 创建一个或多个bio结构体.bio 是块设备层 I/O 的基本描述单位,它包含了目标设备、LBA、内存中的数据缓冲区地址、读/写方向等信息
- 将bio传递给块设备层
块设备层
块设备层接收到来自文件系统的 bio
通过IO调度器优化读写:排序并合并针对相邻逻辑块的写入请求
将bio打包成为request结构体,放到设备的请求队列
设备驱动(如NVMe)
- 从请求队列中取出request,将内核的通用request结构翻译成硬件能够识别的具体命令
- 构建具体命令,以NVMe为例,在内存构建具体的写命令放入SQ
- 驱动通过写PCIe控制器的寄存器(BAR空间映射的)向PCIe总线上的硬件设备,也就是SSD,发起一次PCIe写来唤醒门铃
硬件处理
- PCIe硬核封装TLP包,传输至M2插槽,传输至SSD控制器
- SSD控制器内部PCIe EndPoint解析TLP包,解析出这是对门铃寄存器SQ Doorbell的操作
- 门铃唤醒闪存转换层(FTL),FTL使用PCIe DMA从内存中获取具体的操作命令,并且通过PCIe DMA进行具体的操作
- 完成操作后SSD构建命令放入CQ,通过PCIe返回给驱动,通知驱动操作完成,驱动通知块设备层,逐层向上返回
请求完成返回路径
- 驱动的中断程序接收到了设备的完成信号,通知块设备层request已经完成
- 块设备层标记request和bio已经完成
- 文件系统收到完成通知产生用户空间write的返回
IO调度器(Linux)
- none(或 noop): 几乎不做任何事,直接将请求下发.适用于 NVMe SSD,因为它相信SSD内部的FTL调度更好
- mq-deadline: 为每个请求设置一个超时时间,在保证吞吐量的同时兼顾了公平性
文件系统
- 日志 (Journaling) 是一种文件系统技术,旨在保证文件系统的一致性 (Consistency),防止系统意外崩溃或断电时数据结构损坏
- 在修改数据前记录一下将要修改的操作
- 引入了额外的写操作和 I/O 同步点
- ext4三种日志模式
- data=journal (最安全,最慢):元数据和数据都会被完整地写入日志区,然后再被写入到文件系统的最终位置
- 数据(Data):文件的实际内容
- 元数据(Metadata):数据的各种属性和存储信息
- 引入了写放大
- data=ordered (默认模式,均衡):只有元数据被写入日志,数据块必须先于引用它的元数据被写入磁盘
- 引入了强同步点
- ata=writeback (最快,最不安全):只有元数据被写入日志,数据的写入和元数据的写入没有顺序保证
- 崩溃后可能出现损坏数据
- data=journal (最安全,最慢):元数据和数据都会被完整地写入日志区,然后再被写入到文件系统的最终位置
通信协议与数据处理
Pcap
- Pcap分为全局文件头和多个数据包
- 全局文件头:魔术字标明这是Pcap包,快照长度表示后面有多少数据包
- 数据包:每个数据包都有包头,包头包含
- 数据包被捕获的时间,分为两个字段,秒和小数部分
- 数据包的长度
- 数据包的原始长度,两个长度相等表示包是完整的
字节序
- 字节序
- 大端序 (Big-Endian): 高位字节 (Most Significant Byte, MSB) 存放在低地址,符合人类的阅读习惯
- 小端序 (Little-Endian): 低位字节 (Least Significant Byte, LSB) 存放在低地址,符合计算机处理的逻辑
- 网络协议一般都使用大端序,x86架构一般使用小端,arm一般小端但是可以配为大端
完整性校验
- Checksum校验和:将所有数据按字节/字算数相加,可能会加一些位操作
- 纠错能力弱,计算速度快
- IP,TCP/UDP协议头部校验和使用该类算法
- CRC:将要校验的数据流看作二进制多项式M(x),然后用预先约定好的、固定长度的生成多项式 (Generator Polynomial) G(x)去除它
- 数学原理保证了对于位错误的敏感
- 只要其长度小于生成多项式的阶数(如 CRC-32能检测所有长度小于 32 位的突发错误),就保证能100%检测出来
- CRC32查表法
- 预处理阶段:预先计算一个长度为256的数组
crc_table
,存放一个字节的256个可能值对应的CRC结果- 将字节值i作为初始CRC值(放在32位数的低8位)
- 进行8次移位和条件异或运算,模拟CRC硬件的工作过程
- 每次检查最低位:如果是1就右移后异或多项式
0xEDB88320
,如果是0就只右移 - 8次运算后得到的32位值就存储在
crc_table[i]
中
- 初始化:初始化32位变量
crc_reg
为初始值0xFFFFFFFF
- 处理数据流:遍历数据流的每一个字节
byte
,对于每个字节执行以下操作:- 取出
crc_reg
的低8位,与当前字节byte
进行异或,得到索引index = (crc_reg ^ byte) & 0xFF
- 将
crc_reg
右移 8 位:crc_reg = crc_reg >> 8
- 用右移后的
crc_reg
与crc_table[index]
进行异或:crc_reg = crc_reg ^ crc_table[index]
- 取出
- 最终处理:遍历完所有数据后,将
crc_reg
与0xFFFFFFFF
进行异或,得到最终的CRC32校验码 - 校验:将原始数据 + CRC码一起进行CRC32计算,数据无误,计算结果应等于固定的魔数
0x2144DF1C
- 预处理阶段:预先计算一个长度为256的数组
零拷贝
- 什么是零拷贝:避免不必要的数据拷贝,以降低 CPU 负载和内存带宽占用
- 传统拷贝,以网络发送为例:
- 用户程序调用read()系统调用,DMA将数据从硬盘读取到内核空间页缓存
- 数据从内核空间页缓存拷贝到用户空间应用缓冲区,read()返回
- 用户程序调用 send() 系统调用.数据从用户空间的应用程序缓冲区,被 CPU 拷贝回内核空间的 Socket 缓冲区
- DMA 控制器将数据从内核空间的 Socket 缓冲区,拷贝到网络接口卡 (NIC) 的发送缓冲区中,最终由硬件发送出去
- 零拷贝模式(特定应用场景的特定优化):
- 用户程序调用 sendfile(socket_fd, file_fd, …)
- 内核收到指令.DMA 控制器将数据从磁盘直接读取到内核空间的页缓存中(拷贝一次)
- 内核不将数据拷贝到用户空间.而是将页缓存中数据的位置和长度等描述信息,直接附加到 Socket 缓冲区中
- 这里没有移动数据,只移动了指针
- DMA 控制器根据 Socket 缓冲区中的描述信息,直接从内核空间的页缓存中,将数据拷贝到网络接口卡的缓冲区中
RTSP
- 定义
- RTSP (Real Time Streaming Protocol):不负责传输视频画面本身,只负责发送控制命令
- 通常走tcp
- RTP (Real-time Transport Protocol):把一帧帧的视频、一小段段的音频打包好,源源不断地传输
- 通常udp
- RTCP (RTP Control Protocol):周期性地在服务器和播放器之间发送传输质量报告
- 通常udp
- RTSP (Real Time Streaming Protocol):不负责传输视频画面本身,只负责发送控制命令
- SDP(Session Description Protocol):是纯文本标准化的格式,用于描述视频流的属性和参数
- 在 RTSP 交互流程中,SDP 是客户端了解即将要播放的是什么的唯一信息来源
- 客户端请求:客户端(如 VLC, OpenCV)向 RTSP 服务器发送 DESCRIBE 命令,请求媒体流的描述信息.
- 服务器响应:服务器以 SDP 格式的文本作为响应体,回复给客户端.
- 客户端解析:客户端收到 SDP 文本后,会逐行解析,提取出关键信息,并据此来初始化自己的播放器、解码器和网络接收模块
TCP/IP,UDP
- 层级关系
- TCP,UDP位与传输层,承担具体的传输任务
- IP位与网络层,负责寻址与路由(根据IP地址找到门牌号)
- UDP:无连接,不可靠,无流控,延迟低
- TCP:
- 三次握手
- 你能听见我吗?
- 我能听见你,你能听见我的回复吗?
- 我能听见你的回复
- 可靠传输:TCP会把包拆为段,每个段有序列号,用于保证是否收到数据以及超时重传
- 字节流:
- TCP隐藏了底层数据包的边界,应用程序看来就是一根水管
- 因此需要自己处理消息的边界(加标识符)
- 滑动窗口:接收方会告诉发送方接收缓冲区还有多大,使用滑动窗口控制发送速率
- 拥塞控制:TCP探测整个网络的拥堵情况控制发送速率
- 四次挥手
- 我要挂了哈
- 好的我知道你要挂了,等我说完你再挂
- 我说完了你挂吧
- 好拜拜
- 三次握手
HTTP
- 定义:构建于TCP协议上,提供超文本传输功能
- 无状态,服务器默认不保存两次请求之间的信息,每个请求都是独立的
WebSocket
- 传统 HTTP 的痛点:HTTP 是一个客户端拉取 (Client Pull)的协议
- 客户端不问,服务器就永远不会主动说话
- 这对于需要服务器主动推送信息的场景(如聊天室、股票行情、系统状态实时监控)来说,非常低效:
- WebSocket提供了一种在单个 TCP 连接上进行全双工 (Full-Duplex)、双向通信的协议
- HTTP 就像寄信,WebSocket 就像建立了一通电话
- WebSocket借用了 HTTP 协议来完成初始的握手连接,从而可以穿透绝大多数只允许 HTTP 流量的防火墙和代理服务器
CAN总线
- 定义:CAN 是一种专为恶劣环境设计的、基于消息 (Message-based) 的串行通信协议
- 允许多个微控制器(称为节点)在无需主机的情况下,通过一对双绞线进行相互通信
- 特点
- CAN总线是一个基于订阅的广播协议,所有节点都往总线发,节点根据自己订阅的ID选择性的接收数据
- 没有源地址目的地址,发送的内容使用ID来标识内容类型和优先级,ID越小优先级越高
- 非破坏性仲裁
- 载波侦听CSMA:CAN设备发送数据边写入边监听,每发送一位就监听一次是否和自己发的一样
- 发送从报文的最高位也就是ID开始发,发送的0会覆盖1,1不会覆盖0
- 当发送的时候发现自己发送1但是监听到0说明有高优先级在发给他让路