ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux底层基石:冯诺依曼体系、操作系统与进程全解析

Linux底层基石:冯诺依曼体系、操作系统与进程全解析 先跟各位说个事这篇不是什么大牛的源码解析就是我自己学 Linux 时候的一份手写笔记整理。内容集中在三个绕不开的基石概念——冯诺依曼体系结构、操作系统、进程。标题里的“2.4”是我自己的笔记编号代表第二阶段第四讲你可以当成一个普通人在吃透这三块时的思考过程。为什么会从这三个点切入因为很多初学者一上来就敲ls、cd背了几十条命令结果问一句“进程到底是什么”“操作系统凭什么能管住CPU”就卡壳。命令只是表面真正要理解的是命令背后的那套机制。这篇文章适合刚接触 Linux、准备系统学一遍或者正在准备面试的朋友我会把硬件、操作系统、进程这三层怎么衔接讲透再补上实操中容易踩的坑。1. 先从一张硬件全家福说起冯诺依曼体系结构1.1 为什么学 Linux 要先啃冯诺依曼我说实话第一次看到“冯诺依曼体系结构”这个词的时候心里想的是这不就是计算机组成原理的内容吗跟 Linux 有什么关系后来才发现这个想法太天真了。Linux 是一个操作系统操作系统的作用就是管理硬件那你连硬件的连接方式和访问规则都不知道怎么理解它管理了什么比如后面学进程、学内存管理时你会频繁遇到“CPU 只能从内存取指令”“CPU 不和外设直接打交道”这些结论它们全都源自冯诺依曼体系结构。不懂这套底层架构你看到的知识点全是散的懂了之后所有东西都能串成一条线。所以我把这部分放在笔记第一讲不是为了凑计算机基础的课而是为了给后面所有内容打地基。1.2 五大部件与存储器的中心地位冯诺依曼体系结构是 1945 年提出的今天你随便拆开一台个人电脑除了结构上的一些改良基本逻辑还是这一套计算机由五大部件组成——输入设备、输出设备、存储器、运算器、控制器。这里最容易忽略的一点是现代 CPU 把“运算器”和“控制器”都集成在一块芯片里了所以很多资料简化描述为三大部分CPU、内存、I/O 设备。记忆方式和理解方式都可以压缩成这样。我建议你在脑子里画一张这样的连接图输入设备 ── 内存 ── CPU运算器控制器 输出设备 ── 内存注意这个图里有个非常关键的信息内存处于绝对的中心位置。输入设备不直接给 CPU 数据输出设备也不直接从 CPU 那里拿结果所有数据都要先经过内存。为什么这么设计原因有二一是 CPU 的速度太快了外设的速度太慢了如果把 CPU 和键盘、鼠标直接对接CPU 大部分时间都在等慢速设备等于一个博士生被迫迁就幼儿园小朋友的语速。二是程序和数据必须先加载进内存CPU 才能一条一条地取指令执行这就是著名的“存储程序”思想。1.3 CPU 的离谱规矩只跟内存打交道关于冯诺依曼体系结构最反直觉的一条规则是CPU 只和内存通信不直接访问外设。这句话听起来抽象我给它翻译成人话你在键盘上按了一个字符CPU 是不知道这件事的。键盘硬件把字符数据放到内存的某个位置然后给 CPU 发个通知通常通过中断机制CPU 这才去内存里读那个数据。反过来CPU 想控制显示屏显示一个字也不是直接把数据扔给显示器而是先把要显示的数据写到内存里的一块特定区域显示器硬件自己会去那个区域取数据这就是“内存映射 I/O”的基础思路。我当年想不通一个问题“那我写代码时直接操作显卡寄存器算怎么回事”后来明白了那是更高的权限级别下做的一种映射处理本质上还是把硬件地址映射到内存地址空间里绕不开冯诺依曼这条主线。这个认识直接影响你怎么学 Linux操作系统大量的“劳动”就是在内存和外设之间搬运数据、调度顺序。理解了 CPU 只认内存你再看top命令里那些 CPU 使用率、内存占用率就不会觉得那只是两个孤立的数字。1.4 数据流跑一圈你就知道计算机在忙什么一个最简单的“键盘输入字符、屏幕输出显示”流程完整走下来是这样的键盘这个输入设备收到按键信号硬件把它转成 ASCII 码放到内存里的缓冲区。CPU 从内存里读到“有数据到了”的标志位然后去读那个数据。CPU 对数据做处理比如判断、转换把结果写到内存里的输出缓冲区。显示器硬件从输出缓冲区取数据显示到屏幕上。就这么个过程已经足够解释计算机 90% 的日常活动。唯一的区别是真正的操作系统不会让 CPU 亲自去轮询“键盘有没有按键”而是利用中断机制告诉 CPU“我有事了你来看看”。这就是零拷贝、DMA 这些后续概念的地基。你现在坐在这台电脑前鼠标动一下、网页刷一下、视频放一段背后全是这条数据流的无数遍循环。搞懂这个循环你就搞懂了计算机最底层的运转模式。注意冯诺依曼体系结构最大的瓶颈就是“总线带宽”——CPU 得通过总线去内存搬数据而 CPU 处理速度远快于总线传输速度导致 CPU 经常“空等”。现代计算机用缓存Cache来缓解这个问题但瓶颈的根源并没有消失。这也是为什么“CPU 有 8 核却跑不满”这类问题常常出在访存上面试时如果你能聊到这一层会显得理解明显比背诵党深。2. 操作系统硬件和软件之间的管理者2.1 没有操作系统的世界底层现实我不止一次听说过“要不要自己写个操作系统”的段子。在没有操作系统的情况下你要让 CPU 执行你的代码得亲手完成这些事把程序二进制加载到内存的指定位置初始化各种硬件设备设置好中断向量表自己处理键盘、鼠标、打印机、网卡的每一个中断保证不同程序之间不会互相踩踏数据。听起来像是“荒野求生”。也就是说没有操作系统每个程序员都得变成计算机专家还得忍受低效和混乱。操作系统存在的第一性原因就是不想让人类面对这种巨复杂的机器。2.2 操作系统的三个核心 KPI管理资源 提供抽象 保护如果让我用一句话概括操作系统的职责那就是它是硬件的资源管理者同时是软件的公共服务平台。再拆细一点就是三件事。第一管理资源。CPU、内存、磁盘、网络这些都是有限的资源操作系统要决定它们先给谁用、用多久、用完之后怎么回收。这里的调度策略五花八门后面学进程调度、内存换页时都会遇到。第二提供抽象。程序员不需要知道键盘的具体型号、屏幕的像素协议操作系统把硬件包装成一个又一个简洁的接口。比如你写文件时只管打开一个file.txt至于这个文件是存在机械硬盘还是固态硬盘实现了什么坏道处理统统不需要你操心。这就是“一切皆文件”思想在 Linux 里的源头力量。第三保护。多个程序同时跑谁都不能随便去读另一个程序的内存谁也不能把系统搞崩。操作系统通过用户态、内核态的隔离来实现这一点接下来单独说说。2.3 用户态与内核态为什么程序不能乱翻硬件Intel 的 CPU 提供了特权级别Linux 主要使用其中的两个用户态低特权和内核态高特权。用户态下程序只能访问自己地址空间那一亩三分地很多硬件指令直接被禁用内核态下代码可以访问所有内存、执行所有特权指令。操作系统跑在内核态普通应用程序跑在用户态。两者之间怎么切换靠一条特殊指令实现“陷入内核”trap配合中断机制完成切换。你说为什么非要这么麻烦很简单如果所有程序都能直接改硬件状态那一个写着烂代码的进程完全可以把系统搞崩一台同时跑几十个程序的服务器就是灾难现场。CPU 用特权级把普通程序“圈养”起来这是保护机制的基础。我印象深刻的一次实测我在用户态下用 GDB 去读别的进程的/proc/pid/mem文件结果权限报错无论如何都读不了。当时感觉那层“看不见的墙”是真实存在的。2.4 系统调用与库函数一条通往底层的路那么用户态程序需要访问硬件比如读文件、发网络包怎么办答案是系统调用system call。系统调用是操作系统内核对外提供的一组“服务窗口”open、read、write、fork、execve、wait……它们像酒店的前台客人不直接冲进厨房所有需求都通过前台转达。这里经常有人混淆库函数和系统调用。我举个例子C 语言的printf是库函数它内部最终会调用write系统调用但printf还自带格式化、错误处理、缓冲区管理这些功能而write就是赤裸裸的“把这段数据写到这个文件描述符里”没有任何花活。用墙来比喻库函数是你家楼里的公共设施系统调用是通往市政管网的接口。在 Linux 里查看一个系统调用的规则非常简单man 2 open man 2 fork注意是第 2 章节man 3通常是库函数。我初学时老搞混看到man open出来的是个库函数说明还以为自己记错了。2.5 在 Linux 里体验一次系统调用纸上谈兵没意思我建议你亲手验证一次“库函数调用系统调用”这件事。写一个最小程序#include unistd.h int main() { write(1, hello from syscall\n, 19); return 0; }编译运行gcc syscall_demo.c -o syscall_demo ./syscall_demo然后用strace观察它做了什么系统调用strace ./syscall_demo你会看到输出的前几行里有execve、write、exit_group这些调用。那一刻你会直观地看到原来一个“hello world”背后也有这么多系统层面的劳动。提示strace是排查线上问题的利器。如果某个程序启动失败或者卡住strace -p PID可以看它正在哪个系统调用上阻塞。面试题里常说的“进程为什么卡住了”十有八九都能在这个输出里找到答案。3. 进程程序活起来的样子3.1 程序 vs 进程一个是菜谱一个是炒菜过程先区分两个最容易混淆的概念程序和进程。程序是静态的它就是一个躺在磁盘上的文件比如/usr/bin/vi。进程是动态的它是你把程序跑起来之后由内核创建、调度、消亡的一个“执行过程”。我用炒菜来类比程序是菜谱菜谱放在书架上积灰什么都不会发生进程是你照着菜谱洗菜、切菜、下锅、起锅的整个过程。同一个菜谱可以同时被十个人照着做这就对应了同一个程序可以被多个进程执行比如你开了 10 个终端每个终端一个bash。这个类比还能帮你理解为什么进程有“状态”炒菜时可能正开火运行态也可能等锅里的水烧开等待/阻塞态还可能在排队等灶台就绪态。3.2 进程控制块PCB/task_struct进程的身份证进程不是凭空存在的抽象概念内核为每个进程维护一个数据结构在 Linux 里叫task_struct统称“进程控制块PCB”。它记录的东西可多了进程标识符PID进程状态程序计数器CPU 下一次要执行哪条指令内存地址空间描述打开的文件列表信号处理相关数据父子进程关系。这就是进程的“身份证”。内核的调度器不会去“感知”什么进程实体它只管来回调度这些 PCB。你可以把 PCB 想象成图书馆里的图书卡片书本身在书架上但管理员依赖的是卡片信息。在用户视角怎么看到 PCB 的映射产物看/proc目录ls /proc/ppid # 具体看某个进程的信息目录 cat /proc/$$/status | head -20在/proc下每个进程都有一份以 PID 命名的目录里面装着它的运行时信息。这个目录机制非常强大很多排查工具就是直接读/proc拿到进程的第一手资料的。3.3 进程创建fork 到底干了什么进程到底怎么来的在 Linux 下绝大多数进程都是通过fork系统调用创建出来的。fork的精髓在于它被调用一次却返回两次。父进程收到返回值是子进程的 PID子进程收到返回值是 0。这个设计让无数初学者过火我当年也栽过。看最小示例#include stdio.h #include unistd.h int main() { pid_t pid fork(); if (pid 0) { // 出错 perror(fork); } else if (pid 0) { // 子进程走这里 printf(child: pid%d, my parent%d\n, getpid(), getppid()); } else { // 父进程走这里 printf(parent: pid%d, my child%d\n, getpid(), pid); } return 0; }保存、编译、运行你会看到类似输出parent: pid1000, my child1001 child: pid1001, my parent1000很多人觉得费解说“fork 怎么知道我是父还是子”关键看返回值。内核在 fork 的时候把父进程的整个虚拟内存空间复制了一份写时拷贝技术让它没那么昂贵然后把两条执行流继续从 fork 返回处往下跑。我在笔记里特意标注了一个巨坑fork 之后父子进程的执行顺序是随机的谁先跑谁后跑由调度器决定不要根据输出顺序来判断谁是父谁是子。如果你想控制先后需要自己用wait或者信号去同步。3.4 进程状态机从运行到睡眠再到僵尸进程不是一个固定状态它在生命周期里反复横跳。Linux 里常见的状态在ps命令标出来是这些Rrunning/runqueue正在运行或处于可运行队列Ssleeping可中断睡眠通常是等待 I/ODdisk sleep不可中断睡眠等磁盘时常见很难被信号打断Tstopped/traced暂停或正在被调试Zzombie僵尸子进程结束了但父进程还没处理它的退出信息Iidle空闲内核线程。拿一个正在sleep(100)的进程来说你ps -l它会看到 S 状态用kill -STOP打它它变成 T如果它已经结束而父进程没有回收它变成 Z。这里我建议你记住一条判断准则最短命的反而是 R跑着跑着就没了长期占据列表里僵着不动的往往就是 Z 或者 D 这种“疑难杂症”。实战排障时看到一堆 D 状态说明底层 I/O 出问题了看到 Z 说明父进程没适时收尸。3.5 查看进程的第一批命令纸上概念再丰满也得落地到命令。ps是进程查看的元老常用组合ps aux # 显示所有进程包含 CPU、内存占比 ps -ef # 显示完整命令和父子关系 ps -l # 当前终端下带状态的进程列表pstree可以直观看到进程树pstree -ptop是动态刷新版按P按 CPU 排序按M按内存排序按q退出。我自己的习惯是先ps aux | grep 云进程名找到 PID再用top -p PID单盯这一个进程的状态变化。这套组合拳日常排查 80% 的场景够用。4. 进程相关高频问题与排查面试 实操向4.1 僵尸进程和孤儿进程两种没人管的进程如果你用过top多多少少见过Z状态。僵尸进程是指子进程已经终止但系统还没把它从进程表里移除因为父进程没有调用wait来“读取”它的退出状态。僵尸进程不占 CPU 不占内存但它占着一个 PID如果大量堆积就会造成 PID 资源枯竭。我自己踩过一次上个项目里有个父进程长期不退出每处理一个任务就 fork 一个子进程子进程结束后也没 clean跑了三天后进程表里多了两千多个僵尸新任务直接启动失败日志里满是“Resource temporarily unavailable”。排查手法很简单ps aux | awk $8 ~ /Z/ {print $2, $11}然后去查这些僵尸的父进程ps -o ppid -p PID问题基本出在父进程没调用wait/waitpid或者父进程本身就成了孤儿。孤儿进程更委婉一点父进程先死了比如被kill -9子进程还没死。这时候内核会把这些孤儿挂到 1 号进程systemd 或 init名下由它来收殓。所以孤儿进程比僵尸进程“命好”一般不会造成资源堆积。4.2 wait 与 waitpid让家长承担起责任前面反复提到的wait就是解决僵尸的官方姿势。父进程调用wait会阻塞自己直到有子进程退出waitpid则可以指定等待某个 PID还能加WNOHANG选项实现非阻塞轮询。举一个最简单但非常实用的 C 代码#include sys/wait.h #include unistd.h #include stdio.h int main() { pid_t pid fork(); if (pid 0) { sleep(2); // 子进程假装工作 printf(child exit\n); return 0; } waitpid(pid, NULL, 0); // 父进程等它 printf(parent reap\n); return 0; }把这段跑下来你会在终端里看到子进程先打印、父进程后打印并且进程不在 Z 状态。如果把waitpid那行注释掉ps里马上能看到一个 Z 的“贵客”。这里有个高级技巧如果父进程要做一堆事不想阻塞等子进程可以用signal(SIGCHLD, handler)的方式在信号处理函数里waitpid(-1, NULL, WNOHANG)循环回收。这是服务器编程里的常见套路面试喜欢问这个。4.3 进程间通信IPC管道、消息队列、共享内存、信号量一次说清进程与进程之间怎么交换数据这是操作系统里经典中的经典常见手段整理成一张表方式特点适用场景匿名管道pipe亲缘进程间使用单向流水线父进程给子进程写字条命名管道FIFO文件系统里有路径不要求亲缘关系两个不相关进程的简单通信消息队列内核维护消息链表可带类型需要结构化消息并且不想占用共享内存时共享内存一块内存多进程映射速度最快大流量数据频繁读写信号量专职做同步与互斥不传数据防止多个进程同时改数据信号signal异步通知机制比如 CtrlC进程收到外部“事件提醒”Socket支持跨主机通信网络通信本机也可用 Unix Socket我刚学的时候盯着表背了半天后来发现一个规律管道和消息队列是“搬运数据”共享内存是“把数据摊开一起看”信号量是“管秩序”信号是“按门铃”Socket 是“跨城的快递”。理解这个分工之后再遇到场景自然就能选出工具。实操里最简单的体验是命令行管道ps aux | grep nginx这个|就是匿名管道左边进程的输出成为右边进程的输入。你天天在用管道原来它就是 IPC。4.4 修改进程名称与守护进程从命令到后台化看热词里有“linux 修改进程名称”其实俩路径。一是代码层面用prctl(PR_SET_NAME, newname)修改进程的comm字段top/ps里显示的名字会变。二是启动层面Shell 里给进程外包一层exec -a newname 命令也能改显示名。实操中更多是后者用于自定义监控名。再讲守护进程daemon比如sshd、nginx就是常驻后台的服务进程。一个进程要变成守护进程标准套路是fork 一次让父进程退出、调用setsid()创建新会话、改变工作目录到/、重定向标准输入输出到/dev/null。我在初学阶段抄过一个“老式 daemon 化”脚本发现它非常容易让人理解为什么守护进程的调试那么难——因为它的标准输出被丢进了黑洞出错了你都不知道。日常操作里你不需要手写 daemon用系统命令更靠谱systemctl start 服务名 # 系统服务方式 nohup 命令 /tmp/a.log 21 # 临时后台跑4.5 常用命令速查表ps/top/kill/jobs把零碎命令整理成速查表我自己的私藏版本如下需求命令查看所有进程ps aux查找指定进程ps aux | grep xxx查看进程数ps aux | wc -l查看父子关系树pstree -p动态监控 CPU/内存top发送信号kill -信号 PID正常停止进程kill PIDSIGTERM强制杀掉进程kill -9 PIDSIGKILL暂时挂起前台进程CtrlZ查看后台任务jobs后台任务恢复执行fg %1/bg %1这里必须补一句安全常识kill -9是最后手段它不给进程善后机会可能造成数据丢失或文件损坏。正常流程是先killSIGTERM让进程自己退出、释放资源不行了再考虑kill -9。我见不少新手把-9当默认选项真踩了坑才知道后悔。5. 我的学习建议与踩坑记录5.1 3 个最值得动手的小实验第一用fork造一个进程树。写一个小程序让父进程连续 fork 两个孩子每个孩子再输出自己的 PID 和父进程 PID然后执行pstree -p | grep 进程名。这个实验对建立“进程树”“父与子”“会话”这些概念帮助极大。第二主动制造并回收一个僵尸进程。先写出上一节那个不调用wait的版本运行后ps -o pid,stat,comm看到 Z 状态再改成带waitpid的版本对比状态。亲眼见过 Z 之后以后排查会快很多。第三用管道把三个命令串起来ps aux | grep bash | wc -l数数当前 bash 进程数量。然后用strace -e tracepipe -f bash再执行同样的管道观察底层发生了多少次pipe系统调用。5.2 我踩过的几个坑fork 子进程里的 printf 缓冲区问题我印象最深的一个坑在 fork 之前用了一次printf但不带\nfork 之后父子进程各跑各的结果这条“没有换行”的内容被输出了两遍。原因在于printf默认是行缓冲数据没遇到换行之前留在用户空间的缓冲区里fork 会把父进程的用户空间复制给子进程于是缓冲区也被复制了。到程序最终退出时缓冲区 flush两边各输出一次看起来就像 printf 被执行了两次。解决方案是 fflushfflush(stdout); fork();刚开始不明白“fork 不是只复制进程吗怎么连 stdout 的缓冲区都复制了”后来搞懂了stdout缓冲区就在进程的地址空间里自然被一起复制。这个小坑足够把“内存空间复制”这个概念刻进脑子。另一个常见的坑是进程状态判断。我见过有人用kill -0 PID去判断进程是否存活kill -0确实不会发信号但如果进程是僵尸这个操作仍然返回成功。所以要判断“是否真正活着”不能只看有没有 PID还要确认状态不是 Z。5.3 如何延伸到内存管理、信号、多线程这三块学完之后我很自然地把视野往前推内存管理可以看/proc/pid/maps感受虚拟地址空间的长相再回头看冯诺依曼里“CPU 只访问内存”如何和虚拟内存结合。信号可以动手写个SIGUSR1的处理函数体会进程之间的异步通知。多线程要理解“线程是调度的最小单位进程是资源分配的最小单位”然后去查线程和进程在 PCB 层面的差异。我的个人体会是这三者不是孤立章节它们会反复把冯诺依曼体系结构搬出来。比如 DMA 和内存映射需要硬件架构进程调度需要 CPU 和内核态配合线程同步需要内存互斥原理。学到最后你会发现整个 Linux 就是一个建立在冯诺依曼骨架上、由操作系统调度、由进程承载的庞大系统。最后再分享一个小技巧别只看文章把你自己的“显卡”“内存”“进程”串成一个故事。当你能够给一个零基础的朋友讲明白“键盘按键如何变成屏幕上的字符、这中间经过了哪些进程协作”时这一章才算真正学透了。
返回列表