Maxw的小站

Maxw学习记录

伪终端(Pseudoterminals)

  • 伪终端是一对虚拟设备,称为 master(主)和 slave(从)。
  • 这对设备提供了一个双向通信的 IPC 通道,可以在两端传递数据。
  • 伪终端的关键在于:slave 设备的接口行为和真实终端一样,这样可以让面向终端的程序连接到 slave 端,而另一个程序通过 master 端驱动它。
  • 驱动程序写入 master 的输出会经过终端驱动的常规输入处理(如回车转为换行),然后作为输入传递给连接在 slave 的终端程序。终端程序写入 slave 的内容也会经过输出处理后传递给驱动程序。
  • 换句话说,驱动程序扮演了传统终端用户的角色。
  • 伪终端广泛用于 X Window 系统下的终端窗口、telnet、ssh 等网络登录服务的实现。

日期与时间(Date and Time)

  • 进程关心两类时间:
    1. 实时时间(real time):以某个标准点(如日历时间)或进程生命周期的某个固定点(如启动时刻)为基准。UNIX 系统的日历时间以自 1970 年 1 月 1 日 0 点(UTC)以来的秒数计,这一时刻称为 Epoch。
    2. 进程时间(process time/CPU time):进程自启动以来所用的 CPU 时间,包括:
      • 系统 CPU 时间:在内核态执行(如系统调用)所用时间
      • 用户 CPU 时间:在用户态执行普通代码所用时间
  • time 命令可以显示进程的实时时间、系统 CPU 时间和用户 CPU 时间。

客户端-服务器架构(Client-Server Architecture)

  • 客户端-服务器应用由两部分组成:
    • 客户端(client):向服务器发送请求消息,请求服务
    • 服务器(server):接收请求,执行操作,并返回响应消息
  • 客户端和服务器之间可能有多轮请求-响应的对话。
  • 通常,客户端与用户交互,服务器则提供对某些共享资源的访问。常见情况是多个客户端进程与一个或少数几个服务器进程通信。
  • 客户端和服务器可以在同一台主机,也可以在通过网络连接的不同主机上。它们之间通过 IPC 机制进行通信。
  • 将服务封装在单一服务器中的好处包括:
    • 效率:集中管理资源(如打印机)比每台机器都配备资源更经济。
    • 控制、协调与安全:集中资源便于统一管理、协调访问和安全控制(如防止多个客户端同时修改同一数据)。
    • 异构环境兼容:在网络中,客户端和服务器可以运行在不同硬件和操作系统平台上。

实时(Realtime)

  • 实时应用(realtime applications)是指需要对输入做出及时响应的应用。通常,这类输入来自外部传感器或专用输入设备,输出则用于控制外部硬件。
  • 典型例子包括:自动化装配线、银行ATM、飞机导航系统等。
  • 许多实时应用要求快速响应,但其本质特征是:系统必须保证在触发事件后的一定时间内完成响应(有严格的截止时间)。
  • 实现高实时性(尤其是极短响应时间)需要操作系统的支持。大多数操作系统并不原生支持实时性,因为实时需求与多用户分时系统的需求可能冲突。传统 UNIX 不是实时操作系统,但有实时变种。Linux 也有实时版本,且新内核正逐步支持原生实时应用。
  • POSIX.1b 标准为实时应用定义了一系列扩展,包括:异步I/O、共享内存、内存映射文件、内存锁定、实时时钟和定时器、可选调度策略、实时信号、消息队列和信号量等。虽然大多数 UNIX 实现并非严格意义上的实时系统,但现在普遍支持这些扩展中的部分或全部。
  • “real time”指日历时间或经过时间,“realtime”专指具备上述响应能力的操作系统或应用。

/proc 文件系统

  • 和其他一些 UNIX 实现类似,Linux 提供了 /proc 文件系统,它是一组挂载在 /proc 目录下的目录和文件。
  • /proc虚拟文件系统,它以文件和目录的形式向用户提供内核数据结构的接口,便于查看和修改各种系统属性。
  • /proc 下有一组以 /proc/PID 命名的目录(PID为进程ID),用于查看系统中每个进程的信息。
  • /proc 文件内容通常为人类可读的文本,可被 shell 脚本解析。程序可以直接打开、读取或写入这些文件。大多数情况下,只有特权进程才能修改 /proc 下的文件内容。
  • 本书在介绍 Linux 编程接口时,也会介绍相关的 /proc 文件。更多信息见第12.1节。需要注意,/proc 文件系统不是标准规定的,具体细节是 Linux 特有的。

内存映射(Memory Mappings)

  • mmap() 系统调用可以在进程的虚拟地址空间中创建新的内存映射。
  • 映射分为两类:
    1. 文件映射(file mapping):将文件的一部分映射到进程的虚拟内存,映射后可以像操作内存一样访问文件内容,所需页面会自动从文件加载。
    2. 匿名映射(anonymous mapping):不对应任何文件,映射区域的内容初始化为0。
  • 不同进程之间可以共享同一内存映射(如两个进程映射同一文件区域,或子进程继承父进程的映射)。
  • 如果映射是私有的(private),对映射内容的修改不会影响其他进程,也不会写回文件;如果是共享的(shared),修改会被其他进程看到,并同步到文件。
  • 内存映射的用途包括:初始化进程代码段、分配新内存、文件I/O(内存映射I/O)、进程间通信(共享映射)等。

静态库与共享库(Static and Shared Libraries)

静态库(Static libraries)

  • 静态库(archive)是包含一组已编译函数的文件,便于程序开发和维护。
  • 使用静态库时,链接器会将需要的目标模块从库中复制到最终的可执行文件中,称为静态链接
  • 典型例子:/usr/lib/libm.a(数学库的静态库,扩展名为 .a
  • 缺点:
    • 每个可执行文件都包含一份库函数代码,浪费磁盘空间。
    • 多个程序同时运行时,每个都需加载自己的库函数副本,浪费内存。
    • 如果库函数更新,所有用到该函数的程序都必须重新链接。

共享库(Shared libraries)

  • 共享库为了解决静态库的问题而设计。
  • 程序链接共享库时,链接器只在可执行文件中记录需要的共享库,运行时由动态链接器加载和链接共享库。
  • 典型例子:/lib/x86_64-linux-gnu/libm.so.6(数学库的共享库,扩展名为 .so),/lib/x86_64-linux-gnu/libc.so.6(C 标准库的共享库)
  • 优点:
    • 只需在内存中保留一份共享库代码,所有程序共享,节省内存和磁盘空间。
    • 更新共享库后,所有程序下次运行时自动使用新版本,无需重新链接。

进程间通信与同步(Interprocess Communication and Synchronization)

在运行中的 Linux 系统中,存在大量进程,其中许多进程彼此独立运行。但有些进程需要协作完成任务,这就需要进程间通信(IPC)和同步机制。

  • 最简单的通信方式是通过读写磁盘文件,但这种方式通常太慢且不灵活。

  • 因此,Linux(和所有现代 UNIX 系统)提供了丰富的进程间通信机制,包括:

    • 信号(signals):用于通知进程某个事件发生。
    • 管道(pipes)和命名管道(FIFOs):用于在进程间传递数据(如 shell 中的 | 操作符)。
    • 套接字(sockets):可用于同一主机或不同主机间的进程数据传输。
    • 文件锁(file locking):允许进程锁定文件的某些区域,防止其他进程读取或修改内容。
    • 消息队列(message queues):用于在进程间交换消息(数据包)。
    • 信号量(semaphores):用于进程间的同步操作。
    • 共享内存(shared memory):允许多个进程共享一块内存区域,任何进程对其内容的更改都能被其他进程立即看到。

信号(Signals)

  • 信号常被称为“软件中断”。信号的到来通知进程某个事件或异常情况发生。
  • 信号有多种类型,每种类型代表不同的事件或条件。每种信号类型由一个整数标识,并有类似 SIGxxxx 的符号名。
  • 信号可以由内核、其他进程(有权限时)或进程自身发送。例如,内核会在以下情况下向进程发送信号:
    • 用户在键盘上输入中断字符(通常是 Ctrl+C)
    • 进程的某个子进程终止
    • 进程设置的定时器(闹钟)到期
    • 进程试图访问无效内存地址
  • 在 shell 中,可以用 kill 命令向进程发送信号;在程序中可以用 kill() 系统调用实现同样的功能。

当进程收到信号时,会根据信号类型采取以下动作之一: - 忽略信号 - 被信号杀死 - 被挂起,直到收到特定信号后恢复

对于大多数信号类型,程序可以选择忽略信号(如果默认动作不是忽略),或者设置信号处理函数(signal handler)。信号处理函数是程序员自定义的函数,在信号送达进程时自动调用,用于处理相应的事件。

信号从产生到送达进程之间的这段时间,称为信号“挂起”(pending)。通常,挂起信号会在进程下次被调度运行时立即送达,如果进程正在运行则立即送达。但也可以通过将信号加入进程的信号屏蔽字(signal mask)来阻塞信号。如果信号在被阻塞时产生,它会一直处于挂起状态,直到被解除阻塞(即从信号屏蔽字中移除)后才送达。

线程(Threads)

  • 在现代 UNIX 实现中,每个进程可以拥有多个执行线程。
  • 可以将线程理解为一组共享同一虚拟内存(以及其他属性)的“轻量级进程”。每个线程执行相同的程序代码,并共享数据区和堆区,但每个线程有自己的栈(用于存放局部变量和函数调用信息)。
  • 线程之间可以通过共享的全局变量进行通信。线程库还提供了条件变量(condition variables)和互斥锁(mutexes)等原语,用于线程间的通信和同步,特别是对共享变量的访问控制。
  • 线程也可以使用前面介绍的进程间通信(IPC)和同步机制进行通信。
  • 使用线程的主要优点:
    • 线程间共享数据(通过全局变量)非常方便。
    • 某些算法用多线程实现比多进程实现更自然。
    • 多线程应用可以充分利用多处理器硬件,实现并行处理。

进程组与 Shell 作业控制(Process Groups and Shell Job Control)

  • shell 启动的每个程序都会在新进程中运行。例如,下面的管道命令会创建三个进程:
    1
    $ ls -l | sort -k5n | less
  • 除 Bourne shell 外,所有主流 shell 都支持“作业控制”功能,允许用户同时执行和管理多个命令或管道。
  • 在支持作业控制的 shell 中,管道中的所有进程会被放入一个新的进程组(job)中。对于单条命令,也会创建只包含一个进程的进程组。
  • 每个进程组中的进程都有相同的进程组标识符(process group ID),该 ID 通常等于组内某个进程(称为进程组领导者)的进程 ID。
  • 内核支持对进程组的操作(如发送信号),shell 利用这一特性实现对整个管道作业的挂起、恢复等控制。

程序(Programs)

  • 程序通常有两种形式:
    1. 源代码:人类可读的文本,使用如 C 这样的编程语言编写。
    2. 二进制机器码:计算机可执行的指令。源代码需经过编译和链接,才能变为机器码。
  • 脚本(script)是包含命令的文本文件,由 shell 或其他命令解释器直接处理。
  • “程序”这两个含义通常可以互换,因为编译和链接会将源代码转换为等价的二进制代码。

过滤器(Filters)

  • 过滤器是指一类程序:从标准输入(stdin)读取数据,处理后将结果写到标准输出(stdout)。
  • 常见过滤器有:catgreptrsortwcsedawk 等。

命令行参数(Command-line arguments)

  • 在 C 语言中,程序可以通过 main 函数的参数访问命令行参数:
    1
    int main(int argc, char *argv[])
    • argc:参数个数
    • argv:参数字符串数组,argv[0] 通常是程序名

进程(Processes)

  • 进程是正在运行的程序实例。
  • 当程序被执行时,内核会将其代码加载到虚拟内存,分配变量空间,并建立记录进程信息的数据结构(如进程ID、终止状态、用户ID、组ID等)。
  • 内核负责在进程间分配和管理有限资源(如内存、CPU、网络带宽等),进程结束时,资源会被回收。

进程内存布局

进程的内存逻辑上分为以下几个部分(段): - Text:程序指令(代码段) - Data:静态变量 - Heap:动态分配内存区域 - Stack:函数调用和局部变量的栈空间

进程创建与程序执行

  • 进程可通过 fork() 系统调用创建新进程。调用 fork() 的为父进程,新创建的为子进程。
  • 子进程会复制父进程的数据、堆和栈(代码段通常只读并共享)。
  • 子进程可以继续执行父进程的代码,也可以通过 execve() 系统调用加载并执行新程序。execve() 会用新程序的代码和数据替换原有的段。
  • C 标准库还提供了一系列以 exec 开头的函数,都是对 execve() 的封装,统称为 exec()

进程ID与父进程ID

  • 每个进程有唯一的进程ID(PID)。
  • 每个进程还有一个父进程ID(PPID),表示哪个进程创建了它。

进程终止与终止状态

  • 进程可以通过 _exit() 系统调用(或相关的 exit() 库函数)主动终止,也可以被信号杀死。
  • 进程终止时会返回一个终止状态(小的非负整数),父进程可以通过 wait() 系统调用获取。
  • 约定:终止状态为 0 表示成功,非零表示出错。大多数 shell 用变量 $? 保存上一个程序的终止状态。 整理与翻译如下:

进程的用户和组标识(凭证)

每个进程都关联有多个用户ID(UID)和组ID(GID),包括:

  • 实际用户ID(real UID)和实际组ID(real GID):标识进程所属的用户和组。新进程从父进程继承这些ID。登录 shell 的实际UID和GID来自系统密码文件(/etc/passwd)中的相应字段。
  • 有效用户ID(effective UID)和有效组ID(effective GID):这两个ID(加上补充组ID)用于判断进程访问受保护资源(如文件、进程间通信对象)时的权限。通常有效ID与实际ID相同。更改有效ID可以让进程临时获得其他用户或组的权限。
  • 补充组ID(supplementary group IDs):标识进程所属的其他组。新进程从父进程继承这些ID,登录 shell 的补充组ID来自系统组文件(/etc/group)。

特权进程

  • 在传统 UNIX 系统中,有效用户ID为0(即超级用户 root)的进程被称为特权进程,可以绕过内核的权限检查。
  • 其他用户运行的进程称为非特权进程,其有效用户ID非0,必须遵守内核的权限规则。
  • 进程可以通过由特权进程创建(如 root 启动的 shell),或通过 set-user-ID 机制(程序文件设置了 setuid 位,进程获得该文件所有者的有效UID)获得特权。

能力(Capabilities)

  • 从 Linux 2.2 内核开始,传统上属于超级用户的权限被细分为多个能力(capabilities)
  • 每个特权操作都对应一个能力,进程只有拥有相应能力才能执行该操作。
  • 传统的超级用户进程(有效UID为0)等价于拥有所有能力的进程。
  • 只授予进程部分能力,可以让它执行部分特权操作,同时限制其他操作。
  • 能力名称以 CAP_ 开头,如 CAP_KILL

init 进程

  • 系统启动时,内核会创建一个特殊进程 init(所有进程的“父进程”),其程序文件为 /sbin/init
  • 系统中所有进程都是由 init 或其子孙进程通过 fork() 创建的。
  • init 进程的进程ID永远为1,拥有超级用户权限,不能被杀死(即使是超级用户也不行),只会在系统关机时终止。
  • init 的主要任务是创建和管理系统运行所需的各种进程。

守护进程(Daemon processes)

  • 守护进程是一类特殊用途的进程,由系统创建和管理,其特点包括:
    • 生命周期长:通常在系统启动时启动,直到系统关闭才结束。
    • 后台运行:没有控制终端,无法直接读取输入或输出到终端。
  • 常见的守护进程有 syslogd(记录系统日志)、httpd(提供网页服务)等。

环境变量列表(Environment list)

  • 每个进程都有一个环境变量列表,存储在进程的用户空间内存中。每个环境变量由名称和值组成。
  • 通过 fork() 创建新进程时,子进程会继承父进程的环境变量列表。这为父进程向子进程传递信息提供了机制。
  • 进程用 exec() 执行新程序时,可以选择继承原有环境变量,或指定新的环境变量。
  • 在大多数 shell 中,用 export 命令(C shell 用 setenv)创建环境变量,例如:
    1
    export MYVAR='Hello world'
  • C 程序可以通过外部变量 char **environ 访问环境变量,也可用相关库函数获取和修改环境变量。
  • 环境变量用途广泛,如 HOME(用户主目录)、PATH(shell 查找命令的目录列表)等。

资源限制(Resource limits)

  • 每个进程都会消耗资源,如打开的文件数、内存、CPU 时间等。
  • 进程可用 setrlimit() 系统调用设置资源消耗的上限。每种资源限制有两个值:
    • 软限制(soft limit):进程实际可用的资源上限。
    • 硬限制(hard limit):软限制可调整的最大值。
  • 非特权进程可以将软限制调整到硬限制以内的任意值,但只能降低硬限制,不能提高。
  • 新进程通过 fork() 创建时,会继承父进程的资源限制设置。
  • shell 可用 ulimit 命令(C shell 用 limit)调整资源限制,这些设置会被子进程继承。

Shell(壳/命令行解释器)

Shell 是一种专门的程序,用于读取用户输入的命令,并根据这些命令执行相应的程序。Shell 也被称为命令解释器(command interpreter)。

  • 登录 Shell(login shell):指用户首次登录时系统为其启动的运行 Shell 的进程。

在某些操作系统中,命令解释器是内核的一部分;但在 UNIX 系统中,Shell 是一个普通的用户进程。UNIX 系统支持多种 Shell,不同用户(甚至同一用户的不同会话)可以同时使用不同的 Shell。

用户与用户组(Users and Groups)

用户(Users)

  • 系统中的每个用户都有唯一的登录名(username)和对应的数字用户ID(UID)。
  • 每个用户的信息都记录在 /etc/passwd 文件的一行中,内容包括:
    • 用户名
    • 用户ID(UID)
    • 所属的第一个用户组的组ID(GID)
    • 家目录(home directory):用户登录后进入的初始目录
    • 登录Shell:用于解释用户命令的程序名
    • 密码(通常为加密形式),但出于安全考虑,密码一般存储在只有特权用户可读的 shadow 密码文件中。

用户组(Groups)

  • 为了管理和控制对文件及其他系统资源的访问,用户可以被组织到用户组中。
  • 例如,一个项目组的成员可以被加入同一个用户组,以便共享文件。
  • 早期 UNIX 系统中,一个用户只能属于一个组。BSD 及后来的 UNIX 和 POSIX 标准允许用户同时属于多个组。
  • 每个用户组的信息记录在 /etc/group 文件的一行中,内容包括:
    • 组名(唯一)
    • 组ID(GID)
    • 用户列表:以逗号分隔的用户名列表,表示属于该组的用户(不包括那些通过 /etc/passwd 文件的 GID 字段已属于该组的用户)

超级用户(Superuser)

  • 系统中有一个特殊用户,称为超级用户(superuser),拥有系统内的特殊权限。
  • 超级用户的用户ID为0,通常用户名为 root。
  • 超级用户可以绕过系统的所有权限检查。例如,可以访问系统中的任何文件、向任何进程发送信号等。
  • 系统管理员使用超级用户账户执行各种系统管理任务。

单一目录层次结构、目录、链接与文件

Linux 内核维护着一个单一的分层目录结构来组织系统中的所有文件(与 Windows 等操作系统每个磁盘有独立目录树不同)。这个层次结构的顶端是根目录 /,所有文件和目录都是根目录的子孙。

文件类型

  • 普通文件(regular file):普通数据文件。
  • 目录(directory):特殊文件,内容是文件名和对应文件引用的表。
  • 其他类型:还包括设备文件、管道、套接字、符号链接等。

目录与链接

  • 目录是特殊文件,内容是“文件名+引用”的表,这种关联称为链接(link)
  • 一个文件可以有多个链接(即多个名字),可以存在于同一目录或不同目录。
  • 目录可以包含指向文件和其他目录的链接,形成树状层次结构。
  • 每个目录至少有两个特殊条目:
    • . :指向自身
    • .. :指向父目录(根目录的 .. 也指向自身)
  • 符号链接(软链接,soft link)是一个特殊文件,内容是另一个文件的路径名。
  • 当系统调用中指定路径时,内核会自动解析(跟随)符号链接,直到找到目标文件(递归解析,内核会限制递归次数以防死循环)。
  • 如果符号链接指向的目标不存在,则称为悬挂链接(dangling link)
  • 硬链接(hard link)软链接(soft link)是两种不同的链接类型。

文件名(Filenames)

  • 在大多数 Linux 文件系统中,文件名最长可达 255 个字符。
  • 文件名可以包含除斜杠(/)和空字符(\0)以外的任意字符。
  • 建议只使用字母、数字、点(.)、下划线(_)和连字符(-),即字符集 [-._a-zA-Z0-9],这被称为 SUSv3 标准的“可移植文件名字符集”。
  • 避免使用不在可移植字符集内的字符,因为这些字符在 shell、正则表达式等环境下可能有特殊含义。如果必须使用,需要用反斜杠()转义,否则可能无法正确使用。
  • 避免以连字符(-)开头的文件名,因为在 shell 命令中可能被误认为是选项。

路径名(Pathnames)

  • 路径名是由可选的开头斜杠(/)和一系列用斜杠分隔的文件名组成的字符串。
  • 除最后一个部分外,路径中的每个部分都应是目录(或能解析为目录的符号链接),最后一个部分可以是任意类型的文件。
  • 路径名分为两类:
    • 绝对路径名:以斜杠(/)开头,从根目录开始定位文件。例如 /home/mtk/.bashrc/usr/include/
    • 相对路径名:不以斜杠开头,相对于当前工作目录。例如,从 usr 目录访问 types.h 可用 include/sys/types.h,从 avr 目录访问 .bashrc 可用 ../mtk/.bashrc
  • 路径名可以包含 ..,表示上一级目录。

当前工作目录(Current working directory)

  • 每个进程都有一个当前工作目录(current working directory),即进程在目录树中的“当前位置”,相对路径名都是基于这个目录解析的。
  • 进程的当前工作目录由父进程继承。登录 shell 的初始工作目录由 /etc/passwd 文件中的 home 字段指定。
  • 可以用 cd 命令更改当前工作目录。

文件所有权与权限(File ownership and permissions)

  • 每个文件都有一个关联的用户ID(UID)和组ID(GID),分别表示文件的所有者和所属组。
  • 文件的所有权决定了哪些用户可以访问该文件。
  • 系统将用户分为三类:
    1. 文件所有者(user)
    2. 与文件组ID匹配的组成员(group)
    3. 其他所有用户(other)
  • 每类用户有三种权限(共九个权限位):
    • 读(read):允许读取文件内容
    • 写(write):允许修改文件内容
    • 执行(execute):允许执行该文件(如程序或脚本)
  • 目录的权限含义略有不同:
    • 读:允许列出目录内容(文件名)
    • 写:允许修改目录内容(添加、删除、重命名文件)
    • 执行(或称搜索):允许访问目录中的文件(前提是对文件本身也有权限)

文件 I/O 模型

I/O 的通用性

UNIX 系统 I/O 模型的一个显著特点是I/O 的通用性。这意味着同一组系统调用(如 open()read()write()close() 等)可以用于所有类型的文件,包括设备文件。内核会将应用程序的 I/O 请求转换为相应的文件系统或设备驱动操作,从而对目标文件或设备进行实际的 I/O 操作。因此,使用这些系统调用的程序可以对任何类型的文件进行操作。

内核本质上只提供一种文件类型:按字节顺序排列的字节流。对于磁盘文件、磁盘和磁带设备,可以通过 lseek() 系统调用实现随机访问。

许多应用和库将换行符(ASCII 码 10,linefeed)视为一行文本的结束和下一行的开始。UNIX 系统没有专门的文件结束符(EOF),文件结束通过 read() 返回无数据来检测。

文件描述符(File descriptors)

I/O 系统调用通过文件描述符(file descriptor)来引用已打开的文件。文件描述符是一个(通常很小的)非负整数。通常通过 open() 调用获得文件描述符,open() 需要一个路径名参数,指定要进行 I/O 操作的文件。

当进程由 shell 启动时,通常会继承三个已打开的文件描述符: - 0:标准输入(standard input),进程从中读取输入 - 1:标准输出(standard output),进程向其写入输出 - 2:标准错误(standard error),进程向其写入错误信息和异常通知

在交互式 shell 或程序中,这三个描述符通常都连接到终端。在 stdio 库中,它们分别对应于 stdinstdoutstderr

stdio 库

C 语言程序通常使用标准 C 库中的 I/O 函数(即 stdio 库)进行文件 I/O。常用的 stdio 函数包括 fopen()fclose()scanf()printf()fgets()fputs() 等。这些 stdio 函数是建立在底层 I/O 系统调用(如 open()close()read()write() 等)之上的。

树莓派镜像下载、配置与自定义内核安装流程

1. 下载并写入指定树莓派镜像

为避免因购买或借用的树莓派设备时间不同导致的系统版本不一致,建议大家统一下载指定的树莓派镜像作为起点。

  • 将 MicroSD 卡插入 USB 读卡器,并连接到你的电脑。
  • 下载并安装最新版 Raspberry Pi Imager。
  • 下载课程指定的树莓派镜像:2022-01-28-raspios-bullseye-armhf.zip(约1.2GB,解压后近4GB)。
  • 解压 zip 文件,得到 .img 镜像文件。
  • 打开 Raspberry Pi Imager,选择“CHOOSE OS”→“Use custom”,选中刚才解压的 .img 文件。
  • 选择“CHOOSE SD CARD”,选中你的 MicroSD 卡(注意不要选错,否则会清空数据)。
  • 进入高级设置(齿轮图标,或 Windows 下用 Ctrl+Shift+X),建议修改主机名为唯一值(如包含你的用户名),以免与他人冲突。
  • 勾选“Enable SSH”,选择“Use password authentication”,设置用户名和强密码(建议不要用默认密码)。
  • 勾选“Set locale settings”,设置时区(如美国中部用 America/Chicago),键盘布局选 us。
  • 点击“Save”,然后点击“WRITE”写入镜像。
  • 写入完成后,卸载 MicroSD 卡,插入树莓派并开机。

2. 首次启动与 SSH 连接

  • 树莓派首次启动可能需要几分钟,启动后通过 SSH 连接(如 ssh piuser@pihost,用户名和主机名为你设置的)。
  • 若主机名无法解析,可在路由器管理页面查找树莓派的 IP 地址,再用 ssh 连接。

3. 初始设置与系统升级

  • 首次登录会看到“Welcome to the Raspberry Pi”向导,按提示设置国家、语言、时区、键盘等。
  • 可能会再次提示设置密码,可直接关闭。
  • 为防止欢迎界面反复出现,运行:
    1
    sudo apt purge piwiz
  • 升级系统和驱动:
    1
    2
    sudo apt-get update
    sudo apt-get upgrade
  • 升级完成后重启树莓派。

4. 网络设置与信息收集

  • 连接 WiFi,打开终端,运行:
    1
    ifconfig wlan0
    记录 MAC 地址(ether 后面的六组十六进制数)。
  • 运行:
    1
    hostname -I
    记录 IP 地址。

5. 传输并安装自编译内核与模块

  • 用 ssh 连接学校提供的linux工作平台,进入你编译内核的目录:
    1
    cd /project/scratch01/compile/"your username"/linux_source
  • 打包模块和内核启动文件:
    1
    2
    tar -C modules/lib -czf modules.tgz modules
    tar -C linux/arch/arm -czf boot.tgz boot
  • 在树莓派上新建 linux_source 目录,进入后用 sftp 下载上述两个压缩包:
    1
    2
    3
    4
    5
    sftp [学校统一登陆平台key]@shell.cec.学校曾用简称.edu
    cd /project/scratch01/compile/"your username"/linux_source
    get modules.tgz
    get boot.tgz
    quit
  • 备份 /usr/lib/modules/boot 目录(或 /lib/modules,视系统而定):
    1
    2
    sudo cp -r /usr/lib/modules ~/Desktop/modules_backup
    sudo cp -r /boot ~/Desktop/boot_backup
  • 解压并安装新内核和模块:
    1
    2
    3
    4
    5
    6
    7
    8
    tar -xzf modules.tgz
    tar -xzf boot.tgz
    cd modules
    sudo cp -rd * /usr/lib/modules # 或 /lib/modules
    cd ..
    sudo cp boot/dts/*.dtb /boot/
    sudo cp boot/dts/overlays/*.dtb* /boot/overlays
    sudo cp boot/dts/overlays/README /boot/overlays
  • 树莓派 3B+:
    1
    sudo cp boot/zImage /boot/kernel7.img
  • 树莓派 4/4B:
    1
    sudo cp boot/zImage /boot/kernel7l.img

6. 验证新内核

  • 重启树莓派,运行:
    1
    uname -a
    检查输出是否包含你设置的本地版本字符串、编译日期等。
  • 若未生效,编辑 /boot/config.txt,在 [pi4] 段落前加一行:
    1
    arm_64bit=0
    再重启并用 uname -a 检查。

7. 备份与后续建议

  • 建议用 svn、git 等工具备份你的代码,也可在多台树莓派间做冗余,防止系统崩溃或锁死导致数据丢失。
  • sudo passwd root更新root密码(忘记密码用)
  • sudo raspi-config中可以更改主机名
  • 在树莓派的桌面环境中,可以使用快捷键 Ctrl + Alt + T 快速打开终端

用户管理

  • sudo adduser 新用户名创建新用户
  • usermod -aG sudo username将username用户加入sudoers组
  • 执行visudo命令并在文件中添加username ALL=(ALL) NOPASSWD:ALL-赋予username用户执行所有sudo命令权限,不需要密码提示
  • sudo userdel --remove --force pi删除默认账号

树莓派 Linux 内核源码下载与编译流程

1. 下载适用于树莓派的内核源码

一般项目可以直接去 kernel.org 下载 Linux 源码,但本课程针对树莓派,需要用树莓派官方维护的内核版本(在 https://github.com/raspberrypi)。

在你的 /project/scratch01/compile/user-name/ 目录下,新建 linux_source 文件夹用于存放源码和编译文件:

1
2
mkdir linux_source
cd linux_source

下载指定版本的树莓派内核源码(此过程可能需要20-30分钟):

1
wget https://github.com/raspberrypi/linux/archive/raspberrypi-kernel_1.20210527-1.tar.gz

解压源码包:

1
tar -xzf raspberrypi-kernel_1.20210527-1.tar.gz

解压后会得到一个新目录,建议用 mv 命令重命名为 linux,便于后续操作。解压完成后请删除 .tar.gz 文件以节省空间。

进入 linux 目录,运行以下命令查看内核版本:

1
make kernelversion

并用文本编辑器(如 emacs、vim、nano)打开 Makefile,查看前几行定义的内核版本常量,记录 NAME 常量的值。


2. 针对树莓派 4/4B 的设备树修改

如果你使用的是 Raspberry Pi 4 或 4B,需要修改设备树文件 arch/arm/boot/dts/bcm2711.dtsi,找到 arm-pmu 条目,将 compatible 行改为:

1
compatible = "arm,cortex-a72-pmu", "arm,cortex-a15-pmu", "arm,armv8-pmuv3";

3. 配置交叉编译环境

添加交叉编译器和新版 gcc 到 PATH(并将以下两行添加到 ~/.bashrc 文件末尾,确保下次登录自动生效):

1
2
module add arm-rpi
module add gcc-8.3.0

4. 配置内核

对于 Raspberry Pi 3B+,运行:

1
make -j8 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- bcm2709_defconfig

对于 Raspberry Pi 4/4B,运行:

1
make -j8 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- bcm2711_defconfig

这会生成树莓派的默认内核配置。


5. 自定义内核配置

进入菜单配置界面:

1
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig
  • 在 "General setup" -> "Local version" 里,添加你的唯一标识(如 -v7-v7l 后面加你的名字,无空格)。
  • 修改 "Preemption Model" 选项为 "Preemptible Kernel (Low-Latency Desktop)",以获得更低延迟的抢占模型。
  • 启用 ARM 性能监控单元驱动("Kernel Performance Events and Counters"),并确保 "Profiling support" 也已启用。
  • 任选一个有趣的选项,按 H 键查看简介,记录该选项的名称、简介和符号(symbol),并简述为何选择 "Preemptible Kernel (Low-Latency Desktop)"(提示:该模式适合需要低延迟响应的场景,如桌面或实时应用)。

保存并退出配置。


6. 编译内核

记录编译开始和结束时间:

1
date>>time.txt; make -j8 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage modules dtbs; date>>time.txt

编译完成后,创建用于存放模块的目录:

1
mkdir ../modules

安装内核模块:

1
make -j8 ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- INSTALL_MOD_PATH=../modules modules_install

7. 回答与说明

  • cat time.txt 查看编译所用时间。
  • 说明为何要用交叉编译器:因为 linuxlab 服务器的架构与树莓派不同,必须用交叉编译器生成适用于 ARM 架构的内核和模块。

总结
本流程涵盖了树莓派专用 Linux 内核源码的下载、解压、配置、定制、编译和模块安装,并介绍了如何设置交叉编译环境和设备树修改。通过这些步骤,你可以为树莓派编译和定制属于自己的 Linux 内核。

与众不同的“野兽”:内核与用户空间的区别

Linux 内核与普通用户空间程序相比,有许多独特之处。这些差异并不一定让内核开发更难,但确实让它与用户空间开发大不相同。内核开发有一些“特殊规则”,有些显而易见,有些则不那么直观。主要区别包括:

  1. 内核无法使用 C 标准库(libc)和标准 C 头文件。
  2. 内核使用 GNU C 语言编写。
  3. 内核没有用户空间的内存保护机制。
  4. 内核中不能轻易执行浮点运算。
  5. 内核每个进程的栈空间很小且固定。
  6. 由于内核有异步中断、支持抢占和多处理器(SMP),因此同步和并发问题非常重要。
  7. 可移植性很重要。

下面简要解释这些差异:


没有 libc 或标准头文件

与用户空间程序不同,内核不会链接标准 C 库(libc)或其他外部库。主要原因是速度和体积——完整的 C 库太大、效率太低,不适合内核使用。

不用担心,内核自己实现了很多常用的 libc 函数。例如,常见的字符串操作函数在 lib/string.c 中实现,只需包含 <linux/string.h> 头文件即可使用。

头文件

内核源码只能包含内核源码树中的头文件,不能引用外部头文件或库。
- 基础头文件位于源码根目录的 include/ 目录下。例如,<linux/inotify.h> 实际路径为 include/linux/inotify.h。 - 架构相关的头文件位于 arch/<architecture>/include/asm,如 x86 架构下为 arch/x86/include/asm,引用时用 <asm/ioctl.h>

没有 printf(),用 printk()

内核没有 printf(),但提供了类似的 printk() 用于内核日志输出。例如:

1
printk("Hello world! A string '%s' and an integer '%d'\n", str, i);
printf() 不同,printk() 可以指定优先级(priority flag),用于 syslogd 判断消息显示位置。例如:
1
printk(KERN_ERR "this is an error!\n");
注意:KERN_ERR 和消息之间没有逗号,这是因为优先级标志是字符串宏,编译时会自动拼接。

GNU C

和大多数 Unix 内核一样,Linux 内核主要用 C 语言编写。但它并不是严格的 ANSI C,而是大量使用了 gcc(GNU 编译器套件)提供的各种语言扩展。内核开发者会用到 ISO C99 和 GNU C 的扩展特性,这使得 Linux 内核基本只能用 gcc 编译(近年 Intel C 编译器也支持了大部分 gcc 特性,可以编译内核)。目前推荐使用 gcc 4.4 或更高版本。

C99 的扩展比较常见,而 GNU C 的扩展则是 Linux 内核代码区别于普通 C 项目的重要原因。下面介绍几个常见的 GNU C 扩展:

内联函数(Inline Functions)

C99 和 GNU C 都支持内联函数(inline function)。内联函数会在每个调用点直接插入函数体,避免了函数调用和返回的开销(如寄存器保存/恢复),有利于编译器整体优化调用者和被调用者的代码。但缺点是会增加代码体积和内存消耗。

内核开发者通常只对小型、对性能要求高的函数使用内联。大函数或不常用的函数不建议内联。

内联函数的声明方式如下:

1
static inline void wolf(unsigned long tail_size)
  • static inline 关键字用于定义内联函数。
  • 内联函数的声明要在使用前,否则编译器无法内联。
  • 通常内联函数会放在头文件中(因为是 static,不会导出符号)。

内核更倾向于用内联函数而不是复杂的宏,因为内联函数有类型安全和可读性好等优点。

内联汇编(Inline Assembly)

gcc 支持在 C 代码中嵌入汇编指令,这在与硬件密切相关的内核代码中很有用。用法如下:

1
2
3
unsigned int low, high;
asm volatile("rdtsc" : "=a" (low), "=d" (high));
/* low 和 high 现在分别保存了 64 位 tsc 的低 32 位和高 32 位 */
  • asm 关键字用于插入汇编代码。
  • 这种用法主要用于体系结构相关或对性能极致要求的代码。
  • 大部分内核代码还是用 C 语言编写,汇编只用于底层和关键路径。

分支预测注解(Branch Annotation)

gcc 提供了分支预测指令,可以告诉编译器某个条件分支更可能被执行,从而优化生成的代码。内核通过 likely()unlikely() 宏来使用这些特性。

例如:

1
2
3
4
5
6
7
if (unlikely(error)) {
/* 这里假设 error 很少为真 */
}

if (likely(success)) {
/* 这里假设 success 几乎总为真 */
}
- 只有在分支方向非常明确时才建议使用这些宏。 - 如果预测正确,可以提升性能;预测错误则可能降低性能。 - 在内核中,unlikely() 常用于错误处理分支,因为大多数情况下不会出错。

没有内存保护

当用户空间程序非法访问内存时,内核可以捕获错误,发送 SIGSEGV 信号并终止该进程。但如果内核自身非法访问内存,后果就不可控了(毕竟,谁来保护内核呢?)。内核中的内存违规会导致 oops(严重内核错误)。因此,在内核中绝不能非法访问内存,比如解引用 NULL 指针——在内核里,这样的错误代价更高!

另外,内核内存是不可换页的(not pageable),即内核占用的每一字节物理内存都不能被换出。你在内核里多用一点内存,系统可用物理内存就少一点。每次想给内核加新功能时,请记住这一点!

不能(轻易)使用浮点运算

用户空间进程使用浮点指令时,内核会负责从整数模式切换到浮点模式。具体做法依赖于体系结构,通常是通过陷阱(trap)实现的。

但内核自身不能像用户空间那样方便地使用浮点运算,因为内核无法轻松地“陷阱”自己。内核里用浮点数需要手动保存和恢复浮点寄存器等操作,非常麻烦。简而言之:不要在内核里用浮点运算! 除极少数特殊情况外,内核代码中基本没有浮点操作。

小而固定的栈空间

用户空间可以在栈上分配大量变量,包括大结构体和大数组,因为用户空间的栈很大且可以动态增长。但内核栈既不大也不能动态扩展,而是小且固定的。

  • 栈的具体大小依赖于体系结构。例如 x86 架构下,栈大小可在编译时配置为 4KB 或 8KB。
  • 通常,32 位系统为 8KB,64 位系统为 16KB,每个进程有自己的内核栈,这个大小是固定的。

因此,内核开发时要避免在栈上分配大对象。

同步与并发

内核容易出现竞态条件(race condition)。与单线程的用户空间程序不同,内核有多种并发访问共享资源的情况,必须通过同步机制防止竞态:

  • Linux 是抢占式多任务操作系统,进程会被调度器随时切换,内核需要在这些任务间同步。
  • Linux 支持对称多处理(SMP),多个处理器上的内核代码可能同时访问同一资源。
  • 中断是异步发生的,可能在访问资源时被打断,导致中断处理程序也访问同一资源。
  • 内核本身是可抢占的,内核代码可能被抢占,切换到另一个访问同一资源的代码。

常见的同步机制有自旋锁(spinlock)和信号量(semaphore)。后续章节会详细介绍。

可移植性的重要性

虽然用户空间程序不一定要追求可移植性,但 Linux 作为一个可移植操作系统,必须保证代码能在多种体系结构上正确编译和运行。体系结构相关的代码要放在专门的目录下,体系结构无关的代码要保持通用。

一些基本规则包括:保持字节序中立、支持 64 位、不要假设字长或页面大小等。后续章节会详细讨论可移植性。

内核源码相关

获取内核源码

当前的 Linux 源代码总是可以在 http://www.kernel.org 官方网站上以完整的 tarball(用 tar 命令创建的归档文件)和增量补丁的形式获得。 可以利用Elixir Cross Referencer网站在线查看源码

使用 Git

你可以用 Git 获取 Linus 主线最新的源码树:

1
git clone git://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux-2.6.git
检出后,可以用如下命令更新你的源码树到 Linus 的最新版本:
1
git pull

安装内核源码

内核 tarball 以 GNU zip(gzip)和 bzip2 两种格式发布。bzip2 是默认且推荐的格式,因为它通常压缩得更好。bzip2 格式的内核包名为 linux-x.y.z.tar.bz2,其中 x.y.z 是内核版本号。下载源码后,解压和解包很简单。如果你的 tarball 是 bzip2 压缩的,运行:

1
tar xvjf linux-x.y.z.tar.bz2
如果是 gzip 压缩的,运行:
1
tar xvzf linux-x.y.z.tar.gz
这会将源码解压到 linux-x.y.z 目录。如果你用 git 获取源码,就不需要下载 tarball,只需运行 git clone,Git 会自动下载并解包最新源码。

源码安装与开发位置

内核源码通常安装在 /usr/src/linux。但你不应该在这个目录下开发,因为你的 C 库可能会链接到这里的内核版本。此外,修改内核源码不应需要 root 权限——建议在你的 home 目录下开发,只在安装新内核时用 root。即使安装新内核,也不要动 /usr/src/linux

使用补丁

在 Linux 内核社区,补丁是交流的通用语言。你会以补丁的形式分发你的代码更改,也会以补丁的形式接收别人的代码。增量补丁可以让你轻松地从一个内核版本升级到下一个,无需每次都下载完整的大包,只需应用增量补丁即可,节省带宽和时间。要应用增量补丁,在内核源码目录下运行:

1
patch -p1 < ../patch-x.y.z
通常,补丁是针对前一个版本的源码生成的。

内核源码树结构简介

Linux 内核源码树(source tree)被划分为多个目录,每个目录下又包含许多子目录。下表列出了源码树根目录下的主要目录及其说明:

目录(Directory) 说明(Description)
arch 架构相关源码(不同CPU架构的实现)
block 块设备I/O层
crypto 加密API
Documentation 内核源码文档
drivers 设备驱动
firmware 某些驱动需要用到的设备固件
fs 虚拟文件系统(VFS)及各类文件系统实现
include 内核头文件
init 内核启动与初始化代码
ipc 进程间通信代码
kernel 核心子系统(如调度器等)
lib 辅助函数库
mm 内存管理子系统及虚拟内存
net 网络子系统
samples 示例和演示代码
scripts 构建内核用的脚本
security Linux安全模块
sound 声音子系统
usr 早期用户空间代码(如initramfs)
tools 内核开发相关工具
virt 虚拟化基础设施

源码树根目录下的一些文件

  • COPYING:内核许可证(GNU GPL v2)。
  • CREDITS:内核主要开发者名单。
  • MAINTAINERS:各子系统和驱动的维护者名单。
  • Makefile:内核主 Makefile,用于编译和构建整个内核。

配置内核(Configuring the Kernel)

因为 Linux 源码是开放的,所以你可以在编译前根据自己的需求进行配置和定制。实际上,你可以只为你需要的功能和驱动编译支持。配置内核是编译前的必经步骤。由于内核功能丰富、支持的硬件种类繁多,配置选项也非常多。

内核配置选项(Configuration Options)

内核配置通过一系列以 CONFIG_ 开头的选项控制,例如 CONFIG_SMP 控制对称多处理(SMP)支持。设置该选项即启用 SMP,未设置则禁用。配置选项既决定编译哪些文件,也通过预处理指令影响源码。

  • 布尔型(Boolean):只有 yes 或 no 两种状态。比如 CONFIG_PREEMPT
  • 三态(Tristate):yes、no 或 module。module 表示编译为可动态加载的模块(.ko 文件);yes 表示直接编译进内核镜像。
  • 字符串或整数:用于指定某些参数值,比如数组大小,这些不会影响编译流程,而是作为宏被源码访问。

发行版内核与自定义内核

各大 Linux 发行版(如 Ubuntu、Fedora)自带的内核都是预编译好的,通常会启用大部分常用功能,并把绝大多数驱动编译为模块,以便支持各种硬件。但如果你想深入学习或开发内核,还是需要自己编译内核,并选择合适的模块。

配置工具

内核提供了多种配置工具:

  • 文本命令行工具

    1
    make config
    逐项询问每个选项,适合有耐心的用户。

  • 基于 ncurses 的图形界面

    1
    make menuconfig
    推荐使用,界面友好,选项分门别类。

  • 基于 GTK+ 的图形界面

    1
    make gconfig
    适合喜欢图形界面的用户。

这些工具会把配置选项分为不同类别(如“处理器类型与特性”),你可以浏览、修改各项配置。

快速生成默认配置

如果你不想从零开始配置,可以用默认配置:

1
make defconfig
这会生成一个适合你当前架构的默认配置(比如 i386 下据说是 Linus 的配置),适合新手快速上手。之后可以再根据自己的硬件调整配置。

配置文件的位置与管理

所有配置选项最终会保存在源码根目录下的 .config 文件中。你也可以直接编辑这个文件(很多内核开发者都这么做),只需搜索并修改对应的配置项即可。

如果你用现有的 .config 文件,或者升级到新内核后想沿用旧配置,可以用:

1
make oldconfig
它会根据新内核的选项补全或更新你的配置。

复制当前内核配置

如果当前内核启用了 CONFIG_IKCONFIG_PROC,你可以直接从 /proc/config.gz 拷贝当前内核的配置:

1
2
zcat /proc/config.gz > .config
make oldconfig
这样可以方便地克隆当前系统的内核配置。

编译内核

配置好后,只需一条命令即可编译内核:

1
make
自 2.6 版本起,无需再手动运行 make dep 或分别编译 bzImage、modules,默认的 Makefile 规则会自动处理所有依赖和构建流程。

降低编译输出噪音(Minimizing Build Noise)

在编译内核时,终端会输出大量信息。为了减少这些“噪音”,但又能看到警告和错误,可以将 make 的标准输出重定向到文件:

1
make > ../detritus

如果需要查看详细输出,可以阅读该文件。由于警告和错误信息会输出到标准错误(stderr),通常你不需要关心标准输出。实际上,你可以直接把输出丢到“黑洞”:

1
make > /dev/null

这样所有无用的输出都会被丢弃,只在终端显示警告和错误。


并行编译(Spawning Multiple Build Jobs)

make 支持并行编译,可以同时运行多个编译任务,大大加快多核系统上的编译速度。方法如下:

1
make -jN

其中 N 是并行任务数。通常建议每个处理器核心分配1~2个任务。例如,16核机器可以这样:

1
make -j32 > /dev/null

此外,使用如 distccccache 等工具也能进一步提升编译速度。


安装新内核(Installing the New Kernel)

内核编译完成后,需要安装。安装方式依赖于你的硬件架构和引导程序(boot loader),请查阅对应引导程序的文档。

以 x86 + grub 为例:

  1. arch/i386/boot/bzImage 复制到 /boot,并命名为如 vmlinuz-version
  2. 编辑 /boot/grub/grub.conf,为新内核添加启动项。

如果使用 LILO:

  1. 编辑 /etc/lilo.conf,添加新内核项。
  2. 重新运行 lilo 命令。

安装模块(Installing Modules)

模块的安装是自动且与架构无关的。只需以 root 权限运行:

1
make modules_install
这会把所有编译好的模块安装到 /lib/modules 下的对应目录。

System.map 文件

编译过程中还会在源码根目录生成 System.map 文件。它是一个符号查找表,用于将内核符号映射到内存地址,在调试时可以把地址转换为函数或变量名。

总结一下课堂笔记,不然总是放在不知道哪个作业文件夹里找不到

操作系统与内核概述

内核是操作系统的最核心部分,负责管理硬件资源(如CPU、内存、硬盘等),并为上层软件提供服务。内核运行在“内核空间”,拥有对硬件的完全控制权,而普通应用程序运行在“用户空间”,只能通过内核提供的接口访问硬件。

应用程序与内核的交互主要通过系统调用完成。比如:

  • 当你在 C 语言程序里调用 printf("hello\n") 时,实际上发生了多层调用。printf() 负责格式化和缓冲数据,最终会调用 write() 函数,把数据写到终端。write() 是一个库函数,它最终会触发 write 系统调用,由内核把数据真正输出到屏幕。
  • 类似地,open() 库函数几乎只做一件事,就是调用 open 系统调用,让内核帮你打开一个文件。
  • 还有一些库函数,比如 strcpy(),只是单纯地在内存中复制数据,根本不会和内核打交道。

简单来说: - 你写的应用程序通过库函数(如 printf()open())间接或直接调用系统调用(如 writeopen),由内核完成实际的硬件操作。 - 有些库函数(如 strcpy())只在用户空间工作,不需要内核参与。

内核还负责处理中断。比如你敲键盘时,键盘控制器会发出中断信号,内核收到后会执行相应的中断处理程序,把你输入的内容读出来。

操作系统结构图

在任何给定时刻,每个处理器正在做以下三件事中的一件:

  • 在用户空间,在进程上下文中执行用户代码
  • 在内核空间,在进程上下文中,代表特定进程执行操作(包括空闲进程)
  • 在内核空间,在中断上下文中,不与任何进程关联,处理中断

Linux 与经典 Unix 内核设计

  • 单一内核/宏内核(Monolithic Kernel)
    经典 Unix 和 Linux 都采用单一内核设计。单一内核就是把所有核心功能(如进程管理、内存管理、文件系统、驱动等)都放在一个大程序里,在同一个内存空间中运行。这样做的好处是:
    • 内核内部各部分可以直接调用彼此的函数,通信效率高,性能好。
    • 设计和实现相对简单。
  • 微内核(Microkernel)
    微内核把内核功能拆分成多个独立的“服务器”,有的在内核空间,有的在用户空间。它们之间通过消息传递(IPC)通信。优点是:
    • 各部分相互隔离,一个崩溃不会影响其他部分,系统更稳定。
    • 更容易替换和扩展功能。 缺点是:
    • 消息传递比直接函数调用慢,频繁切换上下文会影响性能。
    • 实际上,很多微内核系统(如 Windows NT、Mach)为了性能,后来又把大部分服务放回了内核空间。
  • Linux 的做法
    Linux 是单一内核,但吸收了微内核的一些优点,比如模块化设计、支持内核线程、可以动态加载内核模块等。
    • Linux 所有核心功能都在内核空间,通信用直接函数调用,性能高。
    • 同时,Linux 也很灵活,可以按需加载或卸载功能模块。

Linux 虽然继承了 Unix 的理念和 API,但并不基于任何特定 Unix 变体,因此可以灵活选择或创新最佳技术方案。

  • 动态内核模块:Linux 支持内核模块的动态加载和卸载,增强了灵活性和可扩展性。
  • 对称多处理器(SMP)支持:Linux 从早期就支持 SMP,而许多传统 Unix 系统最初并不支持。
  • 抢占式内核:Linux 内核支持抢占,允许内核任务被中断,提高了实时性和响应速度,而大多数传统 Unix 内核不是抢占式的。
  • 线程与进程统一:Linux 内核不区分线程和进程,所有进程本质上是一样的,只是有些进程共享资源。
  • 面向对象的设备模型:Linux 采用了现代的设备管理方式,如设备类、热插拔和 sysfs。
  • 精简与创新:Linux 有选择地实现功能,忽略了被认为设计不佳或无实际价值的传统 Unix 特性(如 STREAMS)。
  • 开放与自由:Linux 的功能集来源于开放的开发模式,只有经过充分论证、设计清晰、实现扎实的功能才会被采纳。

Linux 内核版本

  • 稳定版 (Stable): 次版本号为偶数 (如 2.4, 2.6, 4.18, 5.10)。适合生产环境部署,主要更新是错误修复和新驱动。
  • 开发版 (Development): 次版本号为奇数 (如 2.5, 3.1)。代码快速变化,包含实验性功能,不稳定。
  • 版本号格式: 主版本.次版本.修订版本[.稳定版本] (如 2.6.30.1)。
    • 主版本.次版本 定义内核系列 (如 2.6)。
    • 修订版本 表示同一系列内的主要发布。
    • .稳定版本 (可选) 表示在主要发布后的小更新,专注于关键错误修复。

62.不同路径

想清楚要怎么推导每个格子,可以怎么推导

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int uniquePaths(int m, int n) {
if(m == 1 || n == 1)return 1;
vector<vector<int>> dp(m, vector<int>(n, 1));
for(int i = 1;i<m;i++){
for(int j = 1;j<n;j++){
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};

63. 不同路径 II

问题分析

  1. 起点(0,0)未特殊处理
    • i=0, j=0时(起点),您的代码会进入else分支(因为不满足前三个条件)。
    • else分支中,它计算dp[i][j] = dp[i-1][j] + dp[i][j-1],但i-1 = -1j-1 = -1非法索引),导致未定义行为(崩溃或错误结果)。
  2. 边界条件不完整
    • 当起点有障碍物时,虽能设为0,但后续计算仍可能依赖无效索引。
    • 初始化dp为全1是冗余的,且可能掩盖问题(实际值会被覆盖)。

修复后的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
vector<vector<int>> dp(m, vector<int>(n, 0));
for(int i = 0; i<m;i++){
for(int j = 0; j<n;j++){
if(obstacleGrid[i][j] == 1){
dp[i][j] = 0;
}else if(i == 0 && j == 0){
dp[i][j] = 1;
}else if(i == 0){
dp[i][j] = dp[i][j-1];
}else if(j == 0){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
}
return dp[m-1][n-1];
}
};

343. 整数拆分 (可跳过)

我直接用贪心法,辗转减去3来写了 动态规划思路如下:

  • 遍历 \(j\)\(1 \leq j < i\)),比较 \((i - j) \times j\)\(dp[i - j] \times j\),取最大值。
  • j * (i - j) 是把整数拆分为两个数相乘。
  • j * dp[i - j] 是把整数拆分为两个及以上的数相乘。
  • 如果用 dp[i - j] * dp[j],则相当于强制把一个数拆成四份及以上。

递推公式为: dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

为什么还要比较 dp[i] 呢?
因为每次计算 dp[i] 时,都要保证它是当前的最大值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int integerBreak(int n) {
if(n <= 3)return n-1;
int pd = 1;
while(n > 3){
if(n == 4){
pd = pd * 4;
n = 0;
}else{
n = n - 3;
pd = pd * 3;
}
}
if(n > 0){
pd = pd * n;
}
return pd;
}
};

96.不同的二叉搜索树 (可跳过)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int numTrees(int n) {
if(n <= 2)return n;
vector<int> dp(n+1, 0);
dp[0] = 1;// 方便计算
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i<=n ;i++){
int cnt = 0;
for(int j = 1; j<=i; j++){
cnt += dp[j-1] * dp[i-j];
}
dp[i] = cnt;
}
return dp[n];
}
};
0%