多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

计算机系统漫游:从比特到进程,一篇文章打通底层原理

计算机系统漫游:从比特到进程,一篇文章打通底层原理 聊计算机系统这个话题很多朋友的第一反应是这跟我日常写业务代码有什么关系我调接口、写CRUD、部署服务不也干得好好的这问题我太熟悉了——早几年我也是这么想的。直到有一天线上服务出现诡异的内存飙高我用尽各种业务层的排查手段都无解最后被迫往下挖了一层才在一个极其隐蔽的字节对齐问题上找到根因。那一刻我突然意识到所谓计算机系统不是一门考试科目而是所有程序运行其上的那台真实机器。你对它理解多深决定了你在遇到问题时能往哪一层去找答案。这篇计算机系统漫游不是课程讲义而是一条完整的主线从你按下电源键开始到程序在屏幕上输出一行字符结束中间信息经过哪些硬件、哪些机制、哪些软件编排我会一层层拆开讲清楚。适合刚学完一门编程语言、想补充底层认知的同学也适合写了几年代码但总觉得差一口气的开发者。读完之后你至少能回答一个问题当你运行一个程序这台机器里到底发生了什么。1. 按下电源键之后系统漫游的第一站很多人觉得启动就是屏幕一亮没什么可研究的。但实际上从通电到你能敲命令中间经过了一条非常精密的生产线。理解了这条生产线后面理解进程、内存、操作系统的角色都会顺很多。1.1 固件不是操作系统按下电源键CPU先执行的不是你的操作系统代码而是一段存放在主板固件芯片里的程序——通常叫BIOS或者UEFI。这段程序的任务心很明确先让硬件处于一个已知的、可操作的状态然后去找操作系统内核。你可能会问为什么CPU不能直接执行硬盘上的内核代码因为它不知道内核在哪也不知道怎么跟硬盘控制器打交道。固件的作用就是充当冷启动向导它内部有一套最基本的硬件驱动逻辑能识别磁盘、内存然后把控制权交给引导程序。这里有个值得注意的细节现代UEFI比传统BIOS做得多它支持从分区表里直接加载引导程序也支持安全启动校验签名。但无论哪个它们本质都不是操作系统只是一个引导链条中的一环。这个链条大致是固件自检并初始化硬件引导加载程序如GRUB读取磁盘上的内核镜像内核镜像被解压到内存内核接管CPU、内存、中断等所有硬件资源内核挂载根文件系统启动第一个用户态进程1.2 内核接管之后这个接管过程不是一步到位的。现代内核启动时会先建立页表、初始化内存管理子系统再注册各种驱动。这段时间里屏幕上通常什么都还没有你以为机器卡了其实是内核在无声地做大量初始化。我当年调试一块嵌入式板卡时遇到过启动卡死的问题。用串口日志一看发现系统停在了某个驱动的初始化上。当时我连内核打印到哪一步了都不太会看挨了不少折腾。后来才明白启动日志里的每一行都对应着子系统初始化的顺序学会看这些日志是基本功。内核完成初始化后会启动第一个用户态进程。在Linux上这个进程号通常是1叫init或者systemd。它负责拉起各种服务最终给你一个可以敲指令的shell。从你按下电源键到看到命令行提示符一次系统漫游的入口就这样打开了。2. 数字与文本的底层真容一切都在与比特打交道不管你在键盘上敲的是什么文件、图片、程序、网络包到了机器里全部都会变成比特串。这个降维打击式的事实是理解计算机系统最关键的一道门槛。跨过它你再看很多问题会豁然开朗。2.1 比特、字节与一切皆数计算机最底层的单元是比特bit值只有0和1。单个比特没有信息量但当8个比特组成一个字节byte256种组合足以编码一个英文字符、一个很小的整数。你可能觉得256太少了但别忘了字节只是最小寻址单元它可以把多个字节拼起来形成32位、64位甚至更长的数。这就引出一个关键点同一个比特模式在不同视角下含义完全不同。二进制序列01000001在ASCII编码下是字符A在无符号整数下是65。而同样四个字节的序列按照整数解释和按浮点标准IEEE 754解释结果更是八竿子打不着。计算机本身不知道这是文字还是这是数字全部由程序和指令的解读方式决定。2.2 从ASCII到UTF-8的坑早年计算机用ASCII用一个字节表示字符英文世界够用但中文、日文这些表意文字就尴尬了。后来有了GBK、GB2312等方案一个汉字占两个字节。再后来统一标准UTF-8成为主流它用变长编码ASCII范围内仍是单字节其他字符用2到4个字节表示。变长编码的好处是节省空间且兼容ASCII代价是处理子串、截断时需要小心。我在实际工作中就踩过这样的坑一个用substr截字符串的功能在英语环境下表现正常中文环境下偶尔出现乱码甚至接口报错。根因就是按字节截取切到了多字节字符的中间。理解编码的底层逻辑后这类问题就再也不神秘了——你不只是在文本层工作你也在操作字节。如果你写过C语言一定见过用指针遍历字符串的操作。本质上程序读取的就是连续字节序列字符串处理函数也是按字节去扫描、匹配结束标志。所以字符在计算机底层只是大家对一段字节的约定而已。2.3 地址一切内存操作的入口除了数据本身还有一个底层概念必须掌握地址。内存在硬件上是一排可寻址的存储单元CPU读写内存必须给出地址。你写程序时的指针、引用本质上保存的就是地址。地址空间的大小决定了一个系统能寻址多少内存——32位系统理论最大4GB64位系统则大到几乎用不完。这也解释了为什么多年前从32位升级64位并不仅仅是数字变大它会改变指针占用字节数、内存布局、各种数据结构对其方式以及ABI层面的一系列约定。搞懂了地址你就理解了为什么内存溢出、野指针这些问题是底层问题——它们发生在机器最直接操作的层面上。3. 存储层级为何计算机在快与慢之间反复横跳你可能有过这种体验程序某段代码跑得飞快换个数据量却慢如蜗牛。这背后除了算法复杂度还有一个常被忽略的玩家——存储层级。现代计算机不是一大块均匀的内存而是一套从快到慢、从贵到便宜的分层体系。3.1 寄存器、缓存、内存、磁盘的时差一个典型系统的存储层级从上到下大致是层级典型容量访问延迟量级特点CPU寄存器几十到几百字节不到1纳秒和CPU同频L1/L2缓存几十KB到几MB1纳秒到几纳秒靠近CPU核心L3缓存几MB到几十MB十几纳秒多核共享主存RAM几GB到几十GB几十到上百纳秒程序的主要运行空间固态硬盘几百GB到几TB几十微秒到几百微秒持久化机械硬盘几TB几毫秒容量大但最慢注意这里的量级差异非常夸张寄存器和内存之间差了大约两个数量级内存和固态硬盘之间又差两到三个数量级机械硬盘则慢得可以用毫秒计算。一个程序如果频繁访问磁盘那它的性能瓶颈完全不由CPU决定而由这个时差决定。3.2 缓存为何有效局部性原理缓存能起作用依赖一个非常重要的观察——局部性。程序在一段时间内访问的数据往往集中在某个地址范围附近循环里反复使用的变量、顺序访问的数组、频繁调用的同一个函数这些都是局部性表现。缓存策略的本质就是把最近可能用到的数据提前搬到离CPU更近的地方。我用一个生活化类比你做饭时常用的调料不会放在仓库里而是放在手边。如果每次用盐都要去仓库取一次动作就全慢了。缓存就是那个手边调料架而局部性就是接下来大概率还会用到这些调料的预判。3.3 写出对缓存友好的代码理解了缓存的脾气你就能理解很多性能优化的底层原因。比如遍历一个二维数组时按行访问比按列访问快得多因为数组按行存储顺序访问正好命中连续的缓存行而按列访问则是跳跃性的每一个元素都可能导致一次缓存行换入性能自然天差地别。我做数据计算程序时实测过一个2000乘以2000的矩阵按行求和耗时十几毫秒按列求和却能慢上几十倍。同样的数据量代码只是循环顺序换了一下结果差距惊人。知道这一点你在写热点代码时就该下意识地考虑我访问的数据在内存里是不是连续排列的。存储层级理论最大的实践意义不是让你天天去算缓存命中率而是让你形成一种感觉程序慢未必是CPU不行可能是你的数据访问方式在跟存储层级对着干。4. 操作系统所有程序共同居住的城市管理方为了让你能同时开一堆应用机器里住着一个大管家——操作系统。它管CPU、管内存、管磁盘、管网络。没有它任何两个程序都会互相踩踏。这个系统太宏大我挑几个跟你写代码最相关的关键机制讲。4.1 进程操作系统眼中的运行中程序一个程序运行起来操作系统会为它创建一个进程。进程不只是正在运行的程序它还包含独立的地址空间代码段、数据段、堆、栈以及一堆资源的描述符。操作系统给每个进程一种独占整台机器的错觉——你写的程序只管自己是0号进程就好不用关心别人。这种独占是通过上下文切换实现的。CPU每过一小段时间就把当前进程的寄存器状态、指令指针等保存起来切换去运行另一个进程。切换过程由操作系统内核调度器控制速度极快你感觉不到。但对性能敏感的程序来说频繁的上下文切换意味着CPU时间片浪费在保存和恢复上这也是为什么高并发服务要用线程池、减少无谓调度的原因之一。4.2 虚拟内存让每个进程都以为内存是自己的比进程更巧妙的一个设计是虚拟内存。每个进程看到的地址空间从0开始远远大于物理内存的实际容量可以到几TB甚至更大而且进程之间互不干扰。操作系统在背后把虚拟地址映射到物理内存这个映射关系存放在页表里。虚拟内存的意义不只是隔离它还解决了多个程序共享物理内存时的复杂性。你写代码时拿到的指针是虚拟地址CPU看到的也是虚拟地址只有当真正访问内存时硬件才借助页表把它翻译成物理地址。如果发现页表里没有对应映射就会触发缺页异常操作系统再决定从磁盘换入还是直接报段错误。这一层理解对排查运维问题特别有用你看到内存占用高未必是程序真的吃了那么多物理内存可能只是虚拟内存映射得很大实际驻留的物理页面并不多。top -u里的VSZ和RSS区别就在这里。4.3 文件系统与用户态/内核态在操作系统管理下磁盘不再是裸扇区而是一棵目录树。你把数据抽象成文件极大地方便了使用。但我要提醒一点文件IO、网络IO这些操作都要经过系统调用从用户态陷入内核态让内核代你去操作硬件。这个陷入的过程有时间和权限成本所以程序员会追求减少系统调用次数比如批量写入、内存映射文件。从安全角度来看用户态和内核态的隔离是最重要的防线。普通程序无法直接操作硬件、无法改写别人的内存一切都要通过内核这个关卡。很多底层攻击是在想办法突破这道关卡这也是安全领域一直在忙的事。对于应用开发者理解这些机制最实在的价值在于很多奇怪的现象——程序突然卡住、内存莫名其妙涨、IO很慢——本质上都能在操作系统这一层找到解释。5. 从源代码到可执行文件工具箱里的一场接力赛你用高级语言写的代码机器一点都看不懂。它只认识机器指令。把人读的代码变成机器执行的指令这是一条编译器链条的接力赛。谁也别想跳过它因为计算机系统的每一个字节都是按某种既定格式来的稍有错位就运行不起来。5.1 预处理、编译、汇编、链接以经典的C程序为例gcc并不是一个编译器它是一整套工具的入口。整个过程大致分四步预处理展开头文件、处理宏定义、处理条件编译指令编译把C源码翻译成汇编代码进行大量优化分析汇编把汇编代码转成机器指令的目标文件.o链接把多个目标文件和库文件合并解析符号引用生成可执行文件很多初学者会把编译和链接混在一起。两者的差异在实践中非常明显编译阶段每个源文件是独立翻译的各自不知道别的文件里有什么函数链接阶段才把跨文件的符号引用一个个对上。你遇到 undefined reference 错误时问题就出在链接阶段而不是编译阶段。5.2 动态链接的妙处与坑可执行文件里的代码不一定全在文件里。现代系统大量使用动态链接也就是程序运行到需要某个库函数时才把共享库加载进内存。好处是节省磁盘和内存空间一个C标准库被几十个进程共享物理内存里只放一份坏处是环境变了可能找不到匹配的库也就是经典的 cannot open shared object file 错误。我自己部署服务时遇到过类似问题本地编译跑得好好的一放到干净的服务器上就报缺少某个库的版本。排查一番后发现是依赖了系统里没有的动态库。最终的解决办法是尽量用静态链接或者把依赖库一起打包避免环境差异的坑。5.3 汇编与机器指令再往下走一层如果继续往下扒汇编指令到底是什么本质上每条指令由操作码和操作数组成。比如 把内存某地址的值加上1 这样一条操作在机器里就是一个确定的字节序列。CPU根据当前指令指针指向的地址取指令、译码、执行再更新指令指针循环往复。这个过程听起来单调但它就是计算机运行的本质。理解到这个层面你再看到CPU执行了多少条指令性能分析中的cycle数就不会觉得那是玄学而是一笔笔可以追溯的账。很多系统级调试工具perf、gprof的原理也无非是把程序的执行映射到指令级、函数级去统计。这一节的结论很简单编译器把源码变成指令指令被CPU逐条执行。你写的每一行代码最后都是一堆比特在硬件里流动。6. 并发与并行多核时代如何真正榨干机器性能如果你问现代计算机系统和二十年前最大的区别是什么我第一反应是多核。单核时代程序变快只能靠优化指令本身现在机器有一堆计算单元等着你用前提是你得会用。6.1 并发与并行一对容易混的概念这两个词经常被混用其实是两回事。并发是多个任务在逻辑上同时推进可能是一个CPU来回切换并行是多个任务在物理上同时执行必须有多核硬件支撑。比如你一边听歌一边写代码这两个进程在系统看来是并发运行的而一个多线程程序把不同线程调度到不同核心上那才是真正的并行。对应用层开发者来说并发是如何组织你的业务逻辑让系统能高效处理大量请求并行则是如何把计算拆分到多核上真正同时跑。前者更多靠异步、事件循环、协程后者需要多线程、多进程或者并行计算框架。6.2 锁、原子操作与性能权衡多核带来一个绕不开的课题多个线程同时读写同一个变量怎么保证不出错“加锁”是最直接的办法但锁会引入竞争竞争激烈时线程大部分时间都在等待性能不升反降。这也解释了为什么多线程一定更快是个被高估的说法——数据拆分不好线程数越高锁竞争越严重最终基准测试曲线像过山车一样往下掉。比锁更轻的一种手段是原子操作比如用CAS比较并交换实现无锁数据结构。无锁编程在底层领域很常见但它要求极其严谨的内存序理解稍有不慎就是难以复现的线上诡异问题。给我的建议是绝大多数人够不上无锁的门槛先用简单的锁把正确性保证好再用性能分析工具决定值不值得优化。我个人的经验是并发问题是最难排查的一类问题因为它经常只有在特定时序条件下才出现本地复现不了。而理解操作系统如何调度线程、如何同步内存是缩小排查范围的关键能力。6.3 从性能分析工具反向理解系统这里分享一个习惯遇到性能问题先不猜用工具说话。Linux下最常用的性能工具是perf它可以采样程序的函数调用和CPU事件如果你想看指令级的热点perf annotate能直接给出汇编级别的统计。另一个常用的是top/htop看CPU和内存的瞬时状态再配合pidstat观察线程级消耗。这些工具之所以好用是因为它们把操作系统和硬件的运行状态暴露成数据让你从感觉走向测量。用perf分析一个CPU密集程序时你会看到热点集中在一两个函数上甚至集中在某几行代码上。优化优先级瞬间就清楚了先把这几行改好收益最大。这也是底层认知对工程实践最直接的赋能——别人还在瞎猜你已经能用系统视角定位问题了。7. 漫游之后怎么把系统认知变成日常生产力聊了这么多原理最后回到那个最实际的问题这些东西对我的日常工作有什么可量化、可落地的帮助我的答案是帮助非常大但前提是你读的时候想着对应到自己的实际环境而不是只当科普看。7.1 系统漫游教给你的第一件事分层排查线上出问题新手喜欢一头扎进代码里翻来翻去有系统观的人会先判断问题在哪一层。是CPU的例行采样就高还是内存疯狂换页还是磁盘IO队列拉满还是网络连接堆积这一层判断一出来排查范围立刻缩小到原来的十分之一。我处理过一个告警接口偶尔超时代码层面看不到任何异常。后来我用top观察发现每次超时都对应着一段IO等待再往下查发现是另一个批处理任务把磁盘带宽吃满了。如果没有系统视角这个问题可能又要排查好几天。7.2 数据结构和算法之外的系统复杂度很多人学了算法、数据结构在刷题网站上游刃有余但一到真实系统就感到一种说不清的受限感。原因就在于真实系统的性能曲线是由存储层级、操作系统调度、缓存行为、IO机制共同决定的不是复杂度分析能完全覆盖的。刷题时O(n)永远是O(n)真实系统中O(n)的常数项可能天差地别而这个常数项很大程度上是由底层机制决定的。要验证这一点你可以做一个实验写一个程序顺序读一个大文件然后随机读同样大小的数据比较两者耗时。在机械硬盘上差距可能是几十倍在固态硬盘上也有明显差距这就是底层逻辑写给上层代码的隐藏性能账本。7.3 学习路径建议怎么把漫游变成巡游我的建议是三条路线并行第一条找一本系统级教材沿着数字表示、汇编、处理器、存储、虚拟内存、系统编程这条主线系统读一遍。读的时候不要贪快每章配合做点小实验。第二条带着问题学。比如我的程序为什么内存高 为什么这个进程杀不掉 顺着问题用工具挖挖不动了再去看原理记忆深刻得多。第三条尽量亲手写一点汇编、读一读内核文档不需要成为内核开发者但要在指令到底怎么流经CPU这个点上打通。这一步只要通了后面全是坦途。我个人体会是计算机系统的学习像爬山中途很累但一旦站到山顶再看山下的每个功能模块你会觉得一切都连起来了。之后你再写代码背后多了一双看见机器底层在干什么的眼睛这种视角会不知不觉改变你的工程判断力。8. 一个真实案例系统认知如何帮我省下整整一天说一个我印象比较深的事也许能让你更有体感。有次线上服务出现诡异现象某个接口报错率极低但偶尔会超时重启一下就好一阵子过几小时又现原形。我最初怀疑是代码里有偶发竞态反复审查业务逻辑什么都没发现。后来静下心把系统层的指标拉了一圈才注意到内存的剩余量在缓慢但持续地下降。最终定位到是一段被缓存的历史数据越积越多触发了频繁的垃圾回收和CPU毛刺进而拖慢了请求响应。排查过程里用到的几个系统命令现在回想起来都很基础free -m看内存余量vmstat看CPU等待和换页top看进程状态。但如果没有“程序运行在操作系统之上、操作系统运行在硬件之上”这个完整认知框架我很可能顺着业务逻辑越走越偏最后还要被线上故障教育一次。这类经验告诉我系统漫游不是一门能考试的知识它是一张地图。上面标记着硬件、操作系统、运行时、应用层之间的每一条路。没有这张图你只能在自己那一层里反复打转有了这张图你才能在任何一层快速找到出口。
返回列表