Julia Evans 发布的文章

我一直在慢慢地撰写关于 Git 工作原理的文章。尽管我曾认为自己对 Git 非常了解,但像往常一样,当我尝试解释某事的时候,我又学到一些新东西。

现在回想起来,这些事情都不算太令人吃惊,但我以前并没有清楚地思考过它们。

事实是:

  • “索引”、“暂存区” 和 -cached 是一回事
  • 隐匿文件就是一堆提交
  • 并非所有引用都是分支或标签
  • 合并提交不是空的

下面我们来详细了解这些内容。

“索引”、“暂存区” 和 -cached 是一回事

当你运行 git add file.txt,然后运行 git status,你会看到类似以下的输出:

$ git add content/post/2023-10-20-some-miscellaneous-git-facts.markdown
$ git status
Changes to be committed:
    (use "git restore --staged <file>..." to unstage)
    new file:   content/post/2023-10-20-some-miscellaneous-git-facts.markdown

人们通常称这个过程为“暂存文件”或“将文件添加到暂存区”。

当你使用 git add 命令来暂存文件时,Git 在后台将文件添加到其对象数据库(在 .git/objects 目录下),并更新一个名为 .git/index 的文件以引用新添加的文件。

Git 中的这个“暂存区”事实上有 3 种不同的名称,但它们都指的是同一个东西(即 .git/index 文件):

  • git diff --cached
  • git diff --staged
  • .git/index 文件

我觉得我早该早点认识到这一点,但我之前并没有,所以在这里提醒一下。

隐匿文件就是一堆提交

当我运行 git stash 命令来保存更改时,我一直对这些更改究竟去了哪里感到有些困惑。事实上,当你运行 git stash 命令时,Git 会根据你的更改创建一些提交,并用一个名为 stash 的引用来标记它们(在 .git/refs/stash 目录下)。

让我们将此博客文章隐匿起来,然后查看 stash 引用的日志:

$ git log stash --oneline
6cb983fe (refs/stash) WIP on main: c6ee55ed wip
2ff2c273 index on main: c6ee55ed wip
... some more stuff

现在我们可以查看提交 2ff2c273 以查看其包含的内容:

$ git show 2ff2c273  --stat
commit 2ff2c273357c94a0087104f776a8dd28ee467769
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 14:49:20 2023 -0400

    index on main: c6ee55ed wip

    content/post/2023-10-20-some-miscellaneous-git-facts.markdown | 40 ++++++++++++++++++++++++++++++++++++++++

毫不意外,它包含了这篇博客文章。这很合理!

实际上,git stash 会创建两个独立的提交:一个是索引提交,另一个是你尚未暂存的改动提交。这让我感到很振奋,因为我一直在开发一款工具,用于快照和恢复 Git 仓库的状态(也许永远不会发布),而我提出的设计与 Git 的隐匿实现非常相似,所以我对自己的选择感到满意。

显然 stash 中的旧提交存储在 reflog 中。

并非所有引用都是分支或标签

Git 文档中经常泛泛地提到 “引用”,这使得我有时觉得很困惑。就个人而言,我在 Git 中处理 “引用” 的 99% 时间是指分支或 HEAD,而剩下的 1% 时间是指标签。事实上,我以前完全不知道任何不是分支、标签或 HEAD 的引用示例。

但现在我知道了一个例子—— stash 是一种引用,而它既不是分支也不是标签!所以这太酷啦!

以下是我博客的 Git 仓库中的所有引用(除了 HEAD):

$ find .git/refs -type f
.git/refs/heads/main
.git/refs/remotes/origin/HEAD
.git/refs/remotes/origin/main
.git/refs/stash

人们在本帖回复中提到的其他一些参考资料:

  • refs/notes/*,来自 git notes
  • refs/pull/123/headrefs/pull/123/head` 用于 GitHub 拉取请求(可通过 git fetch origin refs/pull/123/merge 获取)
  • refs/bisect/*,来自 git bisect

合并提交不是空的

这是一个示例 Git 仓库,其中我创建了两个分支 xy,每个分支都有一个文件(x.txty.txt),然后将它们合并。让我们看看合并提交。

$ git log --oneline
96a8afb (HEAD -> y) Merge branch 'x' into y
0931e45 y
1d8bd2d (x) x

如果我运行 git show 96a8afb,合并提交看起来是“空的”:没有差异!

git show 96a8afb
commit 96a8afbf776c2cebccf8ec0dba7c6c765ea5d987 (HEAD -> y)
Merge: 0931e45 1d8bd2d
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 14:07:00 2023 -0400

    Merge branch 'x' into y

但是,如果我单独比较合并提交与其两个父提交之间的差异,你会发现当然差异:

$ git diff 0931e45 96a8afb   --stat
    x.txt | 1 +
    1 file changed, 1 insertion(+)
$ git diff 1d8bd2d 96a8afb   --stat
    y.txt | 1 +
    1 file changed, 1 insertion(+)

现在回想起来,合并提交并不是实际上“空的”(它们是仓库当前状态的快照,就像任何其他提交一样),这一点似乎很明显,只是我以前从未思考为什么它们看起来为空。

显然,这些合并差异为空的原因是合并差异只显示冲突 —— 如果我创建一个带有合并冲突的仓库(一个分支在同一文件中添加了 x,而另一个分支添加了 y),然后查看我解决冲突的合并提交,它看起来会像这样:

$ git show HEAD
commit 3bfe8311afa4da867426c0bf6343420217486594
Merge: 782b3d5 ac7046d
Author: Julia Evans <[email protected]>
Date:   Fri Oct 20 15:29:06 2023 -0400

    Merge branch 'x' into y

diff --cc file.txt
index 975fbec,587be6b..b680253
--- a/file.txt
+++ b/file.txt
@@@ -1,1 -1,1 +1,1 @@@
- y
    -x
++z

这似乎是在告诉我,一个分支添加了 x,另一个分支添加了 y,合并提交通过将 z 替代冲突解决了它。但在前面的示例中,没有冲突,所以 Git 并未显示任何差异。

(感谢 Jordi 告诉我合并差异的工作原理)

先这样吧

些写到这里吧,也许我将在学到更多 Git 知识时撰写另一篇关于 Git 的知识的博客文章。

(题图:MJ/03bfecc3-944e-47a0-a4fd-575293d2ba92)


via: https://jvns.ca/blog/2023/10/20/some-miscellaneous-git-facts/

作者:Julia Evans 选题:lujun9972 译者:KaguyaQiang 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

大家好!前段时间我写了一篇关于“如何用 Go 语言建立一个简易的 DNS 解析器”的帖子。

那篇帖子里我没写有关“如何生成以及解析 DNS 查询请求”的内容,因为我觉得这很无聊,不过一些伙计指出他们不知道如何解析和生成 DNS 查询请求,并且对此很感兴趣。

我开始好奇了——解析 DNS 花多大功夫?事实证明,编写一段 120 行精巧的 Ruby 语言代码组成的程序就可以做到,这并不是很困难。

所以,在这里有一个如何生成 DNS 查询请求,以及如何解析 DNS 响应报文的速成教学!我们会用 Ruby 语言完成这项任务,主要是因为不久以后我将在一场 Ruby 语言大会上发表观点,而这篇博客帖的部分内容是为了那场演讲做准备的。?

(我尽量让不懂 Ruby 的人也能读懂,我只使用了非常基础的 Ruby 语言代码。)

最后,我们就能制作一个非常简易的 Ruby 版本的 dig 工具,能够查找域名,就像这样:

$ ruby dig.rb example.com
example.com    20314    A    93.184.216.34

整个程序大概 120 行左右,所以 并不 算多。(如果你想略过讲解,单纯想去读代码的话,最终程序在这里:dig.rb。)

我们不会去实现之前帖中所说的“一个 DNS 解析器是如何运作的?”,因为我们已经做过了。

那么我们开始吧!

如果你想从头开始弄明白 DNS 查询是如何格式化的,我将尝试解释如何自己弄明白其中的一些东西。大多数情况下的答案是“用 Wireshark 去解包”和“阅读 RFC 1035,即 DNS 的规范”。

生成 DNS 查询请求

步骤一:打开一个 UDP 套接字

我们需要实际发送我们的 DNS 查询,因此我们就需要打开一个 UDP 套接字。我们会将我们的 DNS 查询发送至 8.8.8.8,即谷歌的服务器。

下面是用于建立与 8.8.8.8 的 UDP 连接,端口为 53(DNS 端口)的代码。

require 'socket'
sock = UDPSocket.new

sock.bind('0.0.0.0', 12345)
sock.connect('8.8.8.8', 53)

关于 UDP 的说明

关于 UDP,我不想说太多,但是我要说的是,计算机网络的基础单位是“ 数据包 packet ”(即一串字节),而在这个程序中,我们要做的是计算机网络中最简单的事情:发送 1 个数据包,并接收 1 个数据包作为响应。

所以 UDP 是一个传递数据包的最简单的方法。

它是发送 DNS 查询最常用的方法,不过你还可以用 TCP 或者 DNS-over-HTTPS。

步骤二:从 Wireshark 复制一个 DNS 查询

下一步:假设我们都不知道 DNS 是如何运作的,但我们还是想尽快发送一个能运行的 DNS 查询。获取 DNS 查询并确保 UDP 连接正常工作的最简单方法就是复制一个已经正常工作的 DNS 查询!

所以这就是我们接下来要做的,使用 Wireshark (一个绝赞的数据包分析工具)。

我的操作大致如下:

  1. 打开 Wireshark,点击 “ 捕获 capture ” 按钮。
  2. 在搜索栏输入 udp.port == 53 作为筛选条件,然后按下回车。
  3. 在我的终端运行 ping example.com(用来生成一个 DNS 查询)。
  4. 点击 DNS 查询(显示 “Standard query A example.com”)。 (“A”:查询类型;“example.com”:域名;“Standard query”:查询类型描述)
  5. 右键点击位于左下角面板上的 “ 域名系统(查询) Domain Name System (query) ”。
  6. 点击 “ 复制 Copy ” ——> “ 作为十六进制流 as a hex stream ”。
  7. 现在 b96201000001000000000000076578616d706c6503636f6d0000010001 就放到了我的剪贴板上,之后会用在我的 Ruby 程序里。好欸!

步骤三:解析 16 进制数据流并发送 DNS 查询

现在我们能够发送我们的 DNS 查询到 8.8.8.8 了!就像这样,我们只需要再加 5 行代码:

hex_string = "b96201000001000000000000076578616d706c6503636f6d0000010001"
bytes = [hex_string].pack('H*')
sock.send(bytes, 0)

# get the reply
reply, _ = sock.recvfrom(1024)
puts reply.unpack('H*')

[hex_string].pack('H*') 意思就是将我们的 16 位字符串转译成一个字节串。此时我们不知道这组数据到底是什么意思,但是很快我们就会知道了。

我们还可以借此机会运用 tcpdump ,确认程序是否正常进行以及发送有效数据。我是这么做的:

  1. 在一个终端选项卡下执行 sudo tcpdump -ni any port 53 and host 8.8.8.8 命令
  2. 在另一个不同的终端指标卡下,运行 这个程序ruby dns-1.rb

以下是输出结果:

$ sudo tcpdump -ni any port 53 and host 8.8.8.8
08:50:28.287440 IP 192.168.1.174.12345 > 8.8.8.8.53: 47458+ A? example.com. (29)
08:50:28.312043 IP 8.8.8.8.53 > 192.168.1.174.12345: 47458 1/0/0 A 93.184.216.34 (45)

非常棒 —— 我们可以看到 DNS 请求(”这个 example.com 的 IP 地址在哪里?“)以及响应(“在93.184.216.34”)。所以一切运行正常。现在只需要(你懂的)—— 搞清我们是如何生成并解析这组数据的。

步骤四:学一点点 DNS 查询的格式

现在我们有一个关于 example.com 的 DNS 查询,让我们了解它的含义。

下方是我们的查询(16 位进制格式):

b96201000001000000000000076578616d706c6503636f6d0000010001

如果你在 Wireshark 上搜索,你就能看见这个查询它由两部分组成:

  • 请求头b96201000001000000000000
  • 语句本身076578616d706c6503636f6d0000010001

步骤五:制作请求头

我们这一步的目标就是制作字节串 b96201000001000000000000(借助一个 Ruby 函数,而不是把它硬编码出来)。

(LCTT 译注: 硬编码 hardcode 指在软件实现上,将输出或输入的相关参数(例如:路径、输出的形式或格式)直接以常量的方式撰写在源代码中,而非在运行期间由外界指定的设置、资源、数据或格式做出适当回应。)

那么:请求头是 12 个字节。那些个 12 字节到底意味着什么呢?如果你在 Wireshark 里看看(亦或者阅读 RFC-1035),你就能理解:它是由 6 个 2 字节大小的数字串联在一起组成的。

这六个数字分别对应查询 ID、标志,以及数据包内的问题计数、回答资源记录数、权威名称服务器记录数、附加资源记录数。

我们还不需要在意这些都是些什么东西 —— 我们只需要把这六个数字输进去就行。

但所幸我们知道该输哪六位数,因为我们就是为了直观地生成字符串 b96201000001000000000000

所以这里有一个制作请求头的函数(注意:这里没有 return,因为在 Ruby 语言里,如果处在函数最后一行是不需要写 return 语句的):

def make_question_header(query_id)
  # id, flags, num questions, num answers, num auth, num additional
  [query_id, 0x0100, 0x0001, 0x0000, 0x0000, 0x0000].pack('nnnnnn')
end

上面内容非常的短,主要因为除了查询 ID ,其余所有内容都由我们硬编码写了出来。

什么是 nnnnnn?

可能能想知道 .pack('nnnnnn') 中的 nnnnnn 是个什么意思。那是一个向 .pack() 函数解释如何将那个 6 个数字组成的数据转换成一个字节串的一个格式字符串。

.pack 的文档在 这里,其中描述了 n 的含义其实是“将其表示为” 16 位无符号、网络(大端序)字节序’”。

(LCTT 译注: 大端序 Big-endian :指将高位字节存储在低地址,低位字节存储在高地址的方式。)

16 个位等同于 2 字节,同时我们需要用网络字节序,因为这属于计算机网络范畴。我不会再去解释什么是字节序了(尽管我确实有 一幅自制漫画尝试去描述它)。

测试请求头代码

让我们快速检测一下我们的 make_question_header 函数运行情况。

puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")

这里运行后输出 true 的话,我们就成功了。

好了我们接着继续。

步骤六:为域名进行编码

下一步我们需要生成 问题本身(“example.com 的 IP 是什么?”)。这里有三个部分:

  • 域名(比如说 example.com
  • 查询类型(比如说 A 代表 “IPv4 Address”)
  • 查询类(总是一样的,1 代表 INternet)

最麻烦的就是域名,让我们写个函数对付这个。

example.com 以 16 进制被编码进一个 DNS 查询中,如 076578616d706c6503636f6d00。这有什么含义吗?

如果我们把这些字节以 ASCII 值翻译出来,结果会是这样:

076578616d706c6503636f6d00
 7 e x a m p l e 3 c o m 0

因此,每个段(如 example)的前面都会显示它的长度(7)。

下面是有关将 example.com 翻译成 7 e x a m p l e 3 c o m 0 的 Ruby 代码:

def encode_domain_name(domain)
  domain
    .split(".")
    .map { |x| x.length.chr + x }
    .join + "\0"
end

除此之外,,要完成问题部分的生成,我们只需要在域名结尾追加上(查询)的类型和类。

步骤七:编写 make\_dns\_query

下面是制作一个 DNS 查询的最终函数:

def make_dns_query(domain, type)
  query_id = rand(65535)
  header = make_question_header(query_id)
  question =  encode_domain_name(domain) + [type, 1].pack('nn')
  header + question
end

这是目前我们写的所有代码 dns-2.rb —— 目前仅 29 行。

接下来是解析的阶段

现在我尝试去解析一个 DNS 查询,我们到了硬核的部分:解析。同样的,我们会将其分成不同部分:

  • 解析一个 DNS 的请求头
  • 解析一个 DNS 的名称
  • 解析一个 DNS 的记录

这几个部分中最难的(可能跟你想的不一样)就是:“解析一个 DNS 的名称”。

步骤八:解析 DNS 的请求头

让我们先从最简单的部分开始:DNS 的请求头。我们之前已经讲过关于它那六个数字是如何串联在一起的了。

那么我们现在要做的就是:

  • 读其首部 12 个字节
  • 将其转换成一个由 6 个数字组成的数组
  • 为方便起见,将这些数字放入一个类中

以下是具体进行工作的 Ruby 代码:

class DNSHeader
  attr_reader :id, :flags, :num_questions, :num_answers, :num_auth, :num_additional
  def initialize(buf)
    hdr = buf.read(12)
    @id, @flags, @num_questions, @num_answers, @num_auth, @num_additional = hdr.unpack('nnnnnn')
  end
end

注: attr_reader 是 Ruby 的一种说法,意思是“使这些实例变量可以作为方法使用”。所以我们可以调用 header.flags 来查看@flags变量。

我们也可以借助 DNSheader(buf) 调用这个,也不差。

让我们往最难的那一步挪挪:解析一个域名。

步骤九:解析一个域名

首先,让我们写其中的一部分:

def read_domain_name_wrong(buf)
  domain = []
  loop do
    len = buf.read(1).unpack('C')[0]
    break if len == 0
    domain << buf.read(len)
  end
  domain.join('.')
end

这里会反复读取一个字节的数据,然后将该长度读入字符串,直到读取的长度为 0。

这里运行正常的话,我们在我们的 DNS 响应头第一次看见了域名(example.com)。

关于域名方面的麻烦:压缩!

但当 example.com 第二次出现的时候,我们遇到了麻烦 —— 在 Wireshark 中,它报告上显示输出的域的值为含糊不清的 2 个字节的 c00c

这种情况就是所谓的 DNS 域名压缩,如果我们想解析任何 DNS 响应我们就要先把这个实现完。

幸运的是,这没那么难。这里 c00c 的含义就是:

  • 前两个比特(0b11.....)意思是“前面有 DNS 域名压缩!”
  • 而余下的 14 比特是一个整数。这种情况下这个整数是 120x0c),意思是“返回至数据包中的第 12 个字节处,使用在那里找的域名”

如果你想阅读更多有关 DNS 域名压缩之类的内容。我找到了相关更容易让你理解这方面内容的文章: 关于 DNS RFC 的释义

步骤十:实现 DNS 域名压缩

因此,我们需要一个更复杂的 read_domain_name 函数。

如下所示:

domain = []
loop do
  len = buf.read(1).unpack('C')[0]
  break if len == 0
  if len & 0b11000000 == 0b11000000
    # weird case: DNS compression!
    second_byte = buf.read(1).unpack('C')[0]
    offset = ((len & 0x3f) << 8) + second_byte
    old_pos = buf.pos
    buf.pos = offset
    domain << read_domain_name(buf)
    buf.pos = old_pos
    break
  else
    # normal case
    domain << buf.read(len)
  end
end
domain.join('.')

这里具体是:

  • 如果前两个位为 0b11,那么我们就需要做 DNS 域名压缩。那么:

    • 读取第二个字节并用一点儿运算将其转化为偏移量。
    • 在缓冲区保存当前位置。
    • 在我们计算偏移量的位置上读取域名
    • 在缓冲区存储我们的位置。

可能看起来很乱,但是这是解析 DNS 响应的部分中最难的一处了,我们快搞定了!

一个关于 DNS 压缩的漏洞

有些人可能会说,有恶意行为者可以借助这个代码,通过一个带 DNS 压缩条目的 DNS 响应指向这个响应本身,这样 read_domain_name 就会陷入无限循环。我才不会改进它(这个代码已经够复杂了好吗!)但一个真正的 DNS 解析器确实会更巧妙地处理它。比如,这里有个 能够避免在 miekg/dns 中陷入无限循环的代码

如果这是一个真正的 DNS 解析器,可能还有其他一些边缘情况会造成问题。

步骤十一:解析一个 DNS 查询

你可能在想:“为什么我们需要解析一个 DNS 查询?这是一个响应啊!”

但每一个 DNS 响应包含它自己的原始查询,所以我们有必要去解析它。

这是解析 DNS 查询的代码:

class DNSQuery
  attr_reader :domain, :type, :cls
  def initialize(buf)
    @domain = read_domain_name(buf)
    @type, @cls = buf.read(4).unpack('nn')
  end
end

内容不是太多:类型和类各占 2 个字节。

步骤十二:解析一个 DNS 记录

最让人兴奋的部分 —— DNS 记录是我们的查询数据存放的地方!即这个 “rdata 区域”(“记录数据字段”)就是我们会在 DNS 查询对应的响应中获得的 IP 地址所驻留的地方。

代码如下:

class DNSRecord
  attr_reader :name, :type, :class, :ttl, :rdlength, :rdata
  def initialize(buf)
    @name = read_domain_name(buf)
    @type, @class, @ttl, @rdlength = buf.read(10).unpack('nnNn')
    @rdata = buf.read(@rdlength)
  end

我们还需要让这个 rdata 区域更加可读。记录数据字段的实际用途取决于记录类型 —— 比如一个“A” 记录就是一个四个字节的 IP 地址,而一个 “CNAME” 记录则是一个域名。

所以下面的代码可以让请求数据更可读:

def read_rdata(buf, length)
  @type_name = TYPES[@type] || @type
  if @type_name == "CNAME" or @type_name == "NS"
    read_domain_name(buf)
  elsif @type_name == "A"
    buf.read(length).unpack('C*').join('.')
  else
    buf.read(length)
  end
end

这个函数使用了 TYPES 这个哈希表将一个记录类型映射为一个更可读的名称:

TYPES = {
  1 => "A",
  2 => "NS",
  5 => "CNAME",
  # there are a lot more but we don't need them for this example
}

read.rdata 中最有趣的一部分可能就是这一行 buf.read(length).unpack('C*').join('.') —— 像是在说:“嘿!一个 IP 地址有 4 个字节,就将它转换成一组四个数字组成的数组,然后数字互相之间用 ‘.’ 联个谊吧。”

步骤十三:解析 DNS 响应的收尾工作

现在我们正式准备好解析 DNS 响应了!

工作代码如下所示:

class DNSResponse
  attr_reader :header, :queries, :answers, :authorities, :additionals
  def initialize(bytes)
    buf = StringIO.new(bytes)
    @header = DNSHeader.new(buf)
    @queries = ([email protected]_questions).map { DNSQuery.new(buf) }
    @answers = ([email protected]_answers).map { DNSRecord.new(buf) }
    @authorities = ([email protected]_auth).map { DNSRecord.new(buf) }
    @additionals = ([email protected]_additional).map { DNSRecord.new(buf) }
  end
end

这里大部分内容就是在调用之前我们写过的其他函数来协助解析 DNS 响应。

如果 @header.num_answers 的值为 2,代码会使用了 ([email protected]_answers).map 这个巧妙的结构创建一个包含两个 DNS 记录的数组。(这可能有点像 Ruby 魔法,但我就是觉得有趣,但愿不会影响可读性。)

我们可以把这段代码整合进我们的主函数中,就像这样:

sock.send(make_dns_query("example.com", 1), 0) # 1 is "A", for IP address
reply, _ = sock.recvfrom(1024)
response = DNSResponse.new(reply) # parse the response!!!
puts response.answers[0]

尽管输出结果看起来有点辣眼睛(类似于 #<DNSRecord:0x00000001368e3118>),所以我们需要编写一些好看的输出代码,提升它的可读性。

步骤十四:对于我们输出的 DNS 记录进行美化

我们需要向 DNS 记录增加一个 .to_s 字段,从而让它有一个更良好的字符串展示方式。而者只是做为一行方法的代码在 DNSRecord 中存在。

def to_s
  "#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}"
end

你可能也注意到了我忽略了 DNS 记录中的 class 区域。那是因为它总是相同的(IN 表示 “internet”),所以我觉得它是个多余的。虽然很多 DNS 工具(像真正的 dig)会输出 class

大功告成!

这是我们最终的主函数:

def main
  # connect to google dns
  sock = UDPSocket.new
  sock.bind('0.0.0.0', 12345)
  sock.connect('8.8.8.8', 53)

  # send query
  domain = ARGV[0]
  sock.send(make_dns_query(domain, 1), 0)

  # receive & parse response
  reply, _ = sock.recvfrom(1024)
  response = DNSResponse.new(reply)
  response.answers.each do |record|
    puts record
  end

我不觉得我们还能再补充什么 —— 我们建立连接、发送一个查询、输出每一个回答,然后退出。完事儿!

$ ruby dig.rb example.com
example.com   18608   A   93.184.216.34

你可以在这里查看最终程序:dig.rb。可以根据你的喜好给它增加更多特性,就比如说:

  • 为其他查询类型添加美化输出。
  • 输出 DNS 响应时增加“授权”和“可追加”的选项
  • 重试查询
  • 确保我们看到的 DNS 响应匹配我们的查询(ID 信息必须是对的上的!)

另外如果我在这篇文章中出现了什么错误,就 在推特和我聊聊吧。(我写的比较赶所以可能还是会有些错误)

(题图:MJ/449d049d-6bdd-448b-a61d-17138f8551bc)


via: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/

作者:Julia Evans 选题:lujun9972 译者:Drwhooooo 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

大家好!今天我和一个朋友讨论 Git 的工作原理,我们感到奇怪,Git 是如何存储你的文件的?我们知道它存储在 .git 目录中,但具体到 .git 中的哪个位置,各个版本的历史文件又被存储在哪里呢?

以这个博客为例,其文件存储在一个 Git 仓库中,其中有一个文件名为 content/post/2019-06-28-brag-doc.markdown。这个文件在我的 .git 文件夹中具体的位置在哪里?过去的文件版本又被存储在哪里?那么,就让我们通过编写一些简短的 Python 代码来探寻答案吧。

Git 把文件存储在 .git/objects 之中

你的仓库中,每一个文件的历史版本都被储存在 .git/objects 中。比如,对于这个博客,.git/objects 包含了 2700 多个文件。

$ find .git/objects/ -type f | wc -l
2761
注意:.git/objects 包含的信息,不仅仅是 “仓库中每一个文件的所有先前版本”,但我们暂不详细讨论这一内容。

这里是一个简短的 Python 程序(find-git-object.py),它可以帮助我们定位在 .git/objects 中的特定文件的具体位置。

import hashlib
import sys

def object_path(content):
    header = f"blob {len(content)}\0"
    data = header.encode() + content
    sha1 = hashlib.sha1()
    sha1.update(data)
    digest = sha1.hexdigest()
    return f".git/objects/{digest[:2]}/{digest[2:]}"

with open(sys.argv[1], "rb") as f:
    print(object_path(f.read()))

此程序的主要操作如下:

  • 读取文件内容
  • 计算一个头部(blob 16673\0),并将其与文件内容合并
  • 计算出文件的 sha1 校验和(此处为 e33121a9af82dd99d6d706d037204251d41d54
  • 将这个 sha1 校验和转换为路径(如 .git/objects/e3/3121a9af82dd99d6d706d037204251d41d54

运行的方法如下:

$ python3 find-git-object.py content/post/2019-06-28-brag-doc.markdown
.git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54

术语解释:“内容寻址存储”

这种存储策略的术语为“ 内容寻址存储 content addressed storage ”,它指的是对象在数据库中的文件名与文件内容的哈希值相同。

内容寻址存储的有趣之处就是,假设我有两份或许多份内容完全相同的文件,在 Git 的数据库中,并不会因此占用额外空间。如果内容的哈希值是 aabbbbbbbbbbbbbbbbbbbbbbbbb,它们都会被存储在 .git/objects/aa/bbbbbbbbbbbbbbbbbbbbb 中。

这些对象是如何进行编码的?

如果我尝试在 .git/objects 目录下查看这个文件,显示的内容似乎有一些奇怪:

$ cat .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54
x^A<8D><9B>}s<E3>Ƒ<C6><EF>o|<8A>^Q<9D><EC>ju<92><E8><DD><9C><9C>*<89>j<FD>^...

这是怎么回事呢?让我们来运行 file 命令检查一下:

$ file .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54
.git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54: zlib compressed data

原来,它是压缩的!我们可以编写一个小巧的 Python 程序—— decompress.py,然后用 zlib 模块去解压这些数据:

import zlib
import sys

with open(sys.argv[1], "rb") as f:
    content = f.read()
    print(zlib.decompress(content).decode())

让我们来解压一下看看结果:

$ python3 decompress.py .git/objects/8a/e33121a9af82dd99d6d706d037204251d41d54
blob 16673---
title: "Get your work recognized: write a brag document"
date: 2019-06-28T18:46:02Z
url: /blog/brag-documents/
categories: []
---
... the entire blog post ...

结果显示,这些数据的编码方式非常简单:首先有 blob 16673\0 标识,其后就是文件的全部内容。

这里并没有差异性数据(diff)

这里有一件我第一次知道时让我感到惊讶的事:这里并没有任何差异性数据!那个文件是该篇博客文章的第 9 个版本,但 Git 在 .git/objects 目录中存储的版本是完整文件内容,而并非与前一版本的差异。

尽管 Git 实际上有时候会以差异性数据存储文件(例如,当你运行 git gc 时,为了提升效率,它可能会将多个不同的文件封装成 “打包文件”),但在我个人经验中,我从未需要关注这个细节,所以我们不在此深入讨论。然而,关于这种格式如何工作,Aditya Mukerjee 有篇优秀的文章 《拆解 Git 的打包文件》。

博客文章的旧版本在哪?

你可能会好奇:如果在我修复了一些错别字之前,这篇博文已经存在了 8 个版本,那它们在 .git/objects 目录中的位置是哪里?我们如何找到它们呢?

首先,我们来使用 git log 命令来查找改动过这个文件的每一个提交:

$ git log --oneline  content/post/2019-06-28-brag-doc.markdown
c6d4db2d
423cd76a
7e91d7d0
f105905a
b6d23643
998a46dd
67a26b04
d9999f17
026c0f52
72442b67

然后,我们选择一个之前的提交,比如 026c0f52。提交也被存储在 .git/objects 中,我们可以尝试在那里找到它。但是失败了!因为 ls .git/objects/02/6c* 没有显示任何内容!如果有人告诉你,“我们知道有时 Git 会打包对象来节省空间,我们并不需过多关心它”,但现在,我们需要去面对这个问题了。

那就让我们去解决它吧。

让我们开始解包一些对象

现在我们需要从打包文件中解包出一些对象。我在 Stack Overflow 上查找了一下,看起来我们可以这样进行操作:

$ mv .git/objects/pack/pack-adeb3c14576443e593a3161e7e1b202faba73f54.pack .
$ git unpack-objects < pack-adeb3c14576443e593a3161e7e1b202faba73f54.pack

这种直接对库进行手术式的做法让人有些紧张,但如果我误操作了,我还可以从 Github 上重新克隆这个库,所以我并不太担心。

解包所有的对象文件后,我们得到了更多的对象:大约有 20000 个,而不是原来的大约 2700 个。看起来很酷。

find .git/objects/ -type f | wc -l
20138

我们回头再看看提交

现在我们可以继续看看我们的提交 026c0f52。我们之前说过 .git/objects 中并不都是文件,其中一部分是提交!为了弄清楚我们的旧文章 content/post/2019-06-28-brag-doc.markdown 是在哪里被保存的,我们需要深入查看这个提交。

首先,我们需要在 .git/objects 中查看这个提交。

查看提交的第一步:找到提交

经过解包后,我们现在可以在 .git/objects/02/6c0f5208c5ea10608afc9252c4a56c1ac1d7e4 中找到提交 026c0f52,我们可以用下面的方法去查看它:

$ python3 decompress.py .git/objects/02/6c0f5208c5ea10608afc9252c4a56c1ac1d7e4
commit 211tree 01832a9109ab738dac78ee4e95024c74b9b71c27
parent 72442b67590ae1fcbfe05883a351d822454e3826
author Julia Evans <[email protected]> 1561998673 -0400
committer Julia Evans <[email protected]> 1561998673 -0400

brag doc

我们也可以用 git cat-file -p 026c0f52 命令来获取相同的信息,这个命令能起到相同的作用,但是它在格式化数据时做得更好一些。(-p 选项意味着它能够以更友好的方式进行格式化)

查看提交的第二步:找到树

这个提交包含一个。树是什么呢?让我们看一下。树的 ID 是 01832a9109ab738dac78ee4e95024c74b9b71c27,我们可以使用先前的 decompress.py 脚本查看这个 Git 对象,尽管我不得不移除 .decode() 才能避免脚本崩溃。

$ python3 decompress.py .git/objects/01/832a9109ab738dac78ee4e95024c74b9b71c27

这个输出的格式有些难以阅读。主要的问题在于,该提交的哈希(\xc3\xf7$8\x9b\x8dO\x19/\x18\xb7}|\xc7\xce\x8e…)是原始字节,而没有进行十六进制的编码,因此我们看到 \xc3\xf7$8\x9b\x8d 而非 c3f76024389b8d。我打算切换至 git cat-file -p 命令,它能以更友好的方式显示数据,我不想自己编写一个解析器。

$ git cat-file -p 01832a9109ab738dac78ee4e95024c74b9b71c27
100644 blob c3f76024389b8d4f192f18b77d7cc7ce8e3a68ad    .gitignore
100644 blob 7ebaecb311a05e1ca9a43f1eb90f1c6647960bc1    README.md
100644 blob 0f21dc9bf1a73afc89634bac586271384e24b2c9    Rakefile
100644 blob 00b9d54abd71119737d33ee5d29d81ebdcea5a37    config.yaml
040000 tree 61ad34108a327a163cdd66fa1a86342dcef4518e    content <-- 这是我们接下来的目标
040000 tree 6d8543e9eeba67748ded7b5f88b781016200db6f    layouts
100644 blob 22a321a88157293c81e4ddcfef4844c6c698c26f    mystery.rb
040000 tree 8157dc84a37fca4cb13e1257f37a7dd35cfe391e    scripts
040000 tree 84fe9c4cb9cef83e78e90a7fbf33a9a799d7be60    static
040000 tree 34fd3aa2625ba784bced4a95db6154806ae1d9ee    themes

这是我在这次提交时库的根目录中所有的文件。看起来我曾经不小心提交了一个名为 mystery.rb 的文件,后来我删除了它。

我们的文件在 content 目录中,接下来让我们看看那个树:61ad34108a327a163cdd66fa1a86342dcef4518e

查看提交的第三步:又一棵树

$ git cat-file -p 61ad34108a327a163cdd66fa1a86342dcef4518e
040000 tree 1168078878f9d500ea4e7462a9cd29cbdf4f9a56    about
100644 blob e06d03f28d58982a5b8282a61c4d3cd5ca793005    newsletter.markdown
040000 tree 1f94b8103ca9b6714614614ed79254feb1d9676c    post <-- 我们接下来的目标!
100644 blob 2d7d22581e64ef9077455d834d18c209a8f05302    profiler-project.markdown
040000 tree 06bd3cee1ed46cf403d9d5a201232af5697527bb    projects
040000 tree 65e9357973f0cc60bedaa511489a9c2eeab73c29    talks
040000 tree 8a9d561d536b955209def58f5255fc7fe9523efd    zines

还未结束……

查看提交的第四步:更多的树……

我们要寻找的文件位于 post/ 目录,因此我们需要进一步探索:

$ git cat-file -p 1f94b8103ca9b6714614614ed79254feb1d9676c
.... 省略了大量行 ...
100644 blob 170da7b0e607c4fd6fb4e921d76307397ab89c1e    2019-02-17-organizing-this-blog-into-categories.markdown
100644 blob 7d4f27e9804e3dc80ab3a3912b4f1c890c4d2432    2019-03-15-new-zine--bite-size-networking-.markdown
100644 blob 0d1b9fbc7896e47da6166e9386347f9ff58856aa    2019-03-26-what-are-monoidal-categories.markdown
100644 blob d6949755c3dadbc6fcbdd20cc0d919809d754e56    2019-06-23-a-few-debugging-resources.markdown
100644 blob 3105bdd067f7db16436d2ea85463755c8a772046    2019-06-28-brag-doc.markdown <-- 我们找到了!!!

在此,2019-06-28-brag-doc.markdown 之所以位于列表最后,是因为在发布时它是最新的博文。

查看提交的第五步:我们终于找到它!

经过努力,我们找到了博文历史版本所在的对象文件!太棒了!它的哈希值是 3105bdd067f7db16436d2ea85463755c8a772046,因此它位于 git/objects/31/05bdd067f7db16436d2ea85463755c8a772046

我们可以使用 decompress.py 来查看它:

$ python3 decompress.py .git/objects/31/05bdd067f7db16436d2ea85463755c8a772046 | head
blob 15924---
title: "Get your work recognized: write a brag document"
date: 2019-06-28T18:46:02Z
url: /blog/brag-documents/
categories: []
---
... 文件的剩余部分在此 ...

这就是博文的旧版本!如果我执行命令 git checkout 026c0f52 content/post/2019-06-28-brag-doc.markdown 或者 git restore --source 026c0f52 content/post/2019-06-28-brag-doc.markdown,我就会获取到这个版本。

这样遍历树就是 git log 的运行机制

我们刚刚经历的整个过程(找到提交、逐层遍历目录树、搜索所需文件名)看似繁琐,但实际上当我们执行 git log content/post/2019-06-28-brag-doc.markdown 时,背后就是这样在运行。它需要逐个检查你历史记录中的每一个提交,在每个提交中核查 content/post/2019-06-28-brag-doc.markdown 的版本(例如在这个案例中为 3105bdd067f7db16436d2ea85463755c8a772046),并查看它是否自上一提交以来有所改变。

这就是为什么有时 git log FILENAME 会执行的有些缓慢 —— 我的这个仓库中有 3000 个提交,它需要对每个提交做大量的工作,来判断该文件是否在该提交中发生过变化。

我有多少个历史版本的文件?

目前,我在我的博客仓库中跟踪了 1530 个文件:

$ git ls-files | wc -l
1530

但历史文件有多少呢?我们可以列出 .git/objects 中所有的内容,看看有多少对象文件:

$ find .git/objects/ -type f | grep -v pack | awk -F/ '{print $3 $4}' | wc -l
20135

但并不是所有这些都代表过去版本的文件 —— 正如我们之前所见,许多都是提交和目录树。不过,我们可以编写一个小小的 Python 脚本 find-blobs.py,遍历所有对象并检查是否以 blob 开头:

import zlib
import sys

for line in sys.stdin:
    line = line.strip()
    filename = f".git/objects/{line[0:2]}/{line[2:]}"
    with open(filename, "rb") as f:
        contents = zlib.decompress(f.read())
        if contents.startswith(b"blob"):
            print(line)
$ find .git/objects/ -type f | grep -v pack | awk -F/ '{print $3 $4}' | python3 find-blobs.py | wc -l
6713

于是,看起来在我的 Git 仓库中存放的旧文件版本有 6713 - 1530 = 5183 个,Git 会为我保存这些文件,以备我想着要恢复它们时使用。太好了!

就这些啦!

这个 gist 中附上了全部的此篇文章所用代码,其实没多少。

我以为我已经对 Git 的工作方式了如指掌,但我以前从未真正涉及过打包文件,所以这次探索很有趣。我也很少思考当我让 git log 跟踪一个文件的历史时,它实际上有多大的工作量,因此也很开心能深入研究这个。

作为一个有趣的后续:我提交这篇博文后,Git 就警告我仓库中的对象太多(我猜 20,000 太多了!),并运行 git gc 将它们全部压缩成打包文件。所以现在我的 .git/objects 目录已经被压缩得十分小了:

$ find .git/objects/ -type f | wc -l
14

(题图:MJ/319a396c-6f3f-4891-b051-261312c8ea9a)


via: https://jvns.ca/blog/2023/09/14/in-a-git-repository--where-do-your-files-live-/

作者:Julia Evans 选题:lujun9972 译者:ChatGPT 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

今天我在想 —— 当你在 Linux 上运行一个简单的 “Hello World” Python 程序时,发生了什么,就像下面这个?

print("hello world")

这就是在命令行下的情况:

$ python3 hello.py
hello world

但是在幕后,实际上有更多的事情在发生。我将描述一些发生的情况,并且(更重要的是)解释一些你可以用来查看幕后情况的工具。我们将用 readelfstraceldddebugfs/procltraceddstat。我不会讨论任何只针对 Python 的部分 —— 只研究一下当你运行任何动态链接的可执行文件时发生的事情。

0、在执行 execve 之前

要启动 Python 解释器,很多步骤都需要先行完成。那么,我们究竟在运行哪一个可执行文件呢?它在何处呢?

1、解析 python3 hello.py

Shell 将 python3 hello.py 解析成一条命令和一组参数:python3['hello.py']

在此过程中,可能会进行一些如全局扩展等操作。举例来说,如果你执行 python3 *.py ,Shell 会将其扩展到 python3 hello.py

2、确认 python3 的完整路径

现在,我们了解到需要执行 python3。但是,这个二进制文件的完整路径是什么呢?解决办法是使用一个名为 PATH 的特殊环境变量。

自行验证:在你的 Shell 中执行 echo $PATH。对我来说,它的输出如下:

$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin

当执行一个命令时,Shell 将会依序在 PATH 列表中的每个目录里搜索匹配的文件。

对于 fish(我的 Shell),你可以在 这里 查看路径解析的逻辑。它使用 stat 系统调用去检验是否存在文件。

自行验证:执行 strace -e stat bash,然后运行像 python3 这样的命令。你应该会看到如下输出:

stat("/usr/local/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/local/bin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/sbin/python3", 0x7ffcdd871f40) = -1 ENOENT (No such file or directory)
stat("/usr/bin/python3", {st_mode=S_IFREG|0755, st_size=5479736, ...}) = 0

你可以观察到,一旦在 /usr/bin/python3 找到了二进制文件,搜索就会立即终止:它不会继续去 /sbin/bin 中查找。

对 execvp 的补充说明

如果你想要不用自己重新实现,而运行和 Shell 同样的 PATH 搜索逻辑,你可以使用 libc 函数 execvp(或其它一些函数名中含有 pexec* 函数)。

3、stat 的背后运作机制

你可能在思考,Julia,stat 到底做了什么?当你的操作系统要打开一个文件时,主要分为两个步骤:

  1. 它将 文件名 映射到一个包含该文件元数据的 inode
  2. 它利用这个 inode 来获取文件的实际内容

stat 系统调用只是返回文件的 inode 内容 —— 它并不读取任何的文件内容。好处在于这样做速度非常快。接下来让我们一起来快速了解一下 inode。(在 Dmitry Mazin 的这篇精彩文章 《磁盘就是一堆比特》中有更多的详细内容)

$ stat /usr/bin/python3
  File: /usr/bin/python3 -> python3.9
  Size: 9           Blocks: 0          IO Block: 4096   symbolic link
Device: fe01h/65025d    Inode: 6206        Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2023-08-03 14:17:28.890364214 +0000
Modify: 2021-04-05 12:00:48.000000000 +0000
Change: 2021-06-22 04:22:50.936969560 +0000
 Birth: 2021-06-22 04:22:50.924969237 +0000

自行验证:我们来实际查看一下硬盘上 inode 的确切位置。

首先,我们需要找出硬盘的设备名称:

$ df
...
tmpfs             100016      604     99412   1% /run
/dev/vda1       25630792 14488736  10062712  60% /
...

看起来它是 /dev/vda1。接着,让我们寻找 /usr/bin/python3 的 inode 在我们硬盘上的确切位置(在 debugfs 提示符下输入 imap 命令):

$ sudo debugfs /dev/vda1
debugfs 1.46.2 (28-Feb-2021)
debugfs:  imap /usr/bin/python3
Inode 6206 is part of block group 0
    located at block 658, offset 0x0d00

我不清楚 debugfs 是如何确定文件名对应的 inode 的位置,但我们暂时不需要深入研究这个。

现在,我们需要计算硬盘中 “块 658,偏移量 0x0d00” 处是多少个字节,这个大的字节数组就是你的硬盘。每个块有 4096 个字节,所以我们需要到 4096 * 658 + 0x0d00 字节。使用计算器可以得到,这个值是 2698496

$ sudo dd if=/dev/vda1 bs=1 skip=2698496 count=256 2>/dev/null | hexdump -C
00000000  ff a1 00 00 09 00 00 00  f8 b6 cb 64 9a 65 d1 60  |...........d.e.`|
00000010  f0 fb 6a 60 00 00 00 00  00 00 01 00 00 00 00 00  |..j`............|
00000020  00 00 00 00 01 00 00 00  70 79 74 68 6f 6e 33 2e  |........python3.|
00000030  39 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |9...............|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000060  00 00 00 00 12 4a 95 8c  00 00 00 00 00 00 00 00  |.....J..........|
00000070  00 00 00 00 00 00 00 00  00 00 00 00 2d cb 00 00  |............-...|
00000080  20 00 bd e7 60 15 64 df  00 00 00 00 d8 84 47 d4  | ...`.d.......G.|
00000090  9a 65 d1 60 54 a4 87 dc  00 00 00 00 00 00 00 00  |.e.`T...........|
000000a0  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|

好极了!我们找到了 inode!你可以在里面看到 python3,这是一个很好的迹象。我们并不打算深入了解所有这些,但是 Linux 内核的 ext4 inode 结构 指出,前 16 位是 “模式”,即权限。所以现在我们将看一下 ffa1 如何对应到文件权限。

  • ffa1 对应的数字是 0xa1ff,或者 41471(因为 x86 是小端表示)
  • 41471 用八进制表示就是 0120777
  • 这有些奇怪 - 那个文件的权限肯定可以是 777,但前三位是什么呢?我以前没见过这些!你可以在 inode 手册页 中找到 012 的含义(向下滚动到“文件类型和模式”)。这里有一个小的表格说 012 表示 “符号链接”。

我们查看一下这个文件,确实是一个权限为 777 的符号链接:

$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 9 Apr  5  2021 /usr/bin/python3 -> python3.9

它确实是!耶,我们正确地解码了它。

4、准备复刻

我们尚未准备好启动 python3。首先,Shell 需要创建一个新的子进程来进行运行。在 Unix 上,新的进程启动的方式有些特殊 - 首先进程克隆自己,然后运行 execve,这会将克隆的进程替换为新的进程。

自行验证: 运行 strace -e clone bash,然后运行 python3。你应该会看到类似下面的输出:

clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f03788f1a10) = 3708100

3708100 是新进程的 PID,这是 Shell 进程的子进程。

这里有些工具可以查看进程的相关信息:

  • pstree 会展示你的系统中所有进程的树状图
  • cat /proc/PID/stat 会显示一些关于该进程的信息。你可以在 man proc 中找到这个文件的内容说明。例如,第四个字段是父进程的PID。

新进程的继承

新的进程(即将变为 python3 的)从 Shell 中继承了很多内容。例如,它继承了:

  1. 环境变量:你可以通过 cat /proc/PID/environ | tr '\0' '\n' 查看
  2. 标准输出和标准错误的文件描述符:通过 ls -l /proc/PID/fd 查看
  3. 工作目录(也就是当前目录)
  4. 命名空间和控制组(如果它在一个容器内)
  5. 运行它的用户以及群组
  6. 还有可能是我此刻未能列举出来的更多东西

5、Shell 调用 execve

现在我们准备好启动 Python 解释器了!

自行验证:运行 strace -f -e execve bash,接着运行 python3。其中的 -f 参数非常重要,因为我们想要跟踪任何可能产生的子进程。你应该可以看到如下的输出:

[pid 3708381] execve("/usr/bin/python3", ["python3"], 0x560397748300 /* 21 vars */) = 0

第一个参数是这个二进制文件,而第二个参数是命令行参数列表。这些命令行参数被放置在程序内存的特定位置,以便在运行时可以访问。

那么,execve 内部到底发生了什么呢?

6、获取该二进制文件的内容

我们首先需要打开 python3 的二进制文件并读取其内容。直到目前为止,我们只使用了 stat 系统调用来获取其元数据,但现在我们需要获取它的内容。

让我们再次查看 stat 的输出:

$ stat /usr/bin/python3
  File: /usr/bin/python3 -> python3.9
  Size: 9           Blocks: 0          IO Block: 4096   symbolic link
Device: fe01h/65025d    Inode: 6206        Links: 1
...

该文件在磁盘上占用 0 个块的空间。这是因为符号链接(python3.9)的内容实际上是存储在 inode 自身中:在下面显示你可以看到(来自上述 inode 的二进制内容,以 hexdump 格式分为两行输出)。

00000020  00 00 00 00 01 00 00 00  70 79 74 68 6f 6e 33 2e  |........python3.|
00000030  39 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |9...............|

因此,我们将需要打开 /usr/bin/python3.9 。所有这些操作都在内核内部进行,所以你并不会看到其他的系统调用。

每个文件都由硬盘上的一系列的 构成。我知道我系统中的每个块是 4096 字节,所以一个文件的最小大小是 4096 字节 —— 甚至如果文件只有 5 字节,它在磁盘上仍然占用 4KB。

自行验证:我们可以通过 debugfs 找到块号,如下所示:(再次说明,我从 Dmitry Mazin 的《磁盘就是一堆比特》文章中得知这些步骤)。

$ debugfs /dev/vda1
debugfs:  blocks /usr/bin/python3.9
145408 145409 145410 145411 145412 145413 145414 145415 145416 145417 145418 145419 145420 145421 145422 145423 145424 145425 145426 145427 145428 145429 145430 145431 145432 145433 145434 145435 145436 145437

接下来,我们可以使用 dd 来读取文件的第一个块。我们将块大小设定为 4096 字节,跳过 145408 个块,然后读取 1 个块。

$ dd if=/dev/vda1 bs=4096 skip=145408 count=1 2>/dev/null | hexdump -C | head
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  c0 a5 5e 00 00 00 00 00  |..>.......^.....|
00000020  40 00 00 00 00 00 00 00  b8 95 53 00 00 00 00 00  |@.........S.....|
00000030  00 00 00 00 40 00 38 00  0b 00 40 00 1e 00 1d 00  |[email protected]...@.....|
00000040  06 00 00 00 04 00 00 00  40 00 00 00 00 00 00 00  |........@.......|
00000050  40 00 40 00 00 00 00 00  40 00 40 00 00 00 00 00  |@.@.....@.@.....|
00000060  68 02 00 00 00 00 00 00  68 02 00 00 00 00 00 00  |h.......h.......|
00000070  08 00 00 00 00 00 00 00  03 00 00 00 04 00 00 00  |................|
00000080  a8 02 00 00 00 00 00 00  a8 02 40 00 00 00 00 00  |..........@.....|
00000090  a8 02 40 00 00 00 00 00  1c 00 00 00 00 00 00 00  |..@.............|

你会发现,这样我们得到的输出结果与直接使用 cat 读取文件所获得的结果完全一致。

$ cat /usr/bin/python3.9 | hexdump -C | head
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  c0 a5 5e 00 00 00 00 00  |..>.......^.....|
00000020  40 00 00 00 00 00 00 00  b8 95 53 00 00 00 00 00  |@.........S.....|
00000030  00 00 00 00 40 00 38 00  0b 00 40 00 1e 00 1d 00  |[email protected]...@.....|
00000040  06 00 00 00 04 00 00 00  40 00 00 00 00 00 00 00  |........@.......|
00000050  40 00 40 00 00 00 00 00  40 00 40 00 00 00 00 00  |@.@.....@.@.....|
00000060  68 02 00 00 00 00 00 00  68 02 00 00 00 00 00 00  |h.......h.......|
00000070  08 00 00 00 00 00 00 00  03 00 00 00 04 00 00 00  |................|
00000080  a8 02 00 00 00 00 00 00  a8 02 40 00 00 00 00 00  |..........@.....|
00000090  a8 02 40 00 00 00 00 00  1c 00 00 00 00 00 00 00  |..@.............|

关于魔术数字的额外说明

这个文件以 ELF 开头,这是一个被称为“ 魔术数字 magic number ”的标识符,它是一种字节序列,告诉我们这是一个 ELF 文件。在 Linux 上,ELF 是二进制文件的格式。

不同的文件格式有不同的魔术数字。例如,gzip 的魔数是 1f8b。文件开头的魔术数字就是 file blah.gz 如何识别出它是一个 gzip 文件的方式。

我认为 file 命令使用了各种启发式方法来确定文件的类型,而其中,魔术数字是一个重要的特征。

7、寻找解释器

我们来解析这个 ELF 文件,看看里面都有什么内容。

自行验证:运行 readelf -a /usr/bin/python3.9。我得到的结果是这样的(但是我删减了大量的内容):

$ readelf -a /usr/bin/python3.9
ELF Header:
    Class:                             ELF64
    Machine:                           Advanced Micro Devices X86-64
...
->  Entry point address:               0x5ea5c0
...
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
  INTERP         0x00000000000002a8 0x00000000004002a8 0x00000000004002a8
                 0x000000000000001c 0x000000000000001c  R      0x1
->      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
        ...
->        1238: 00000000005ea5c0    43 FUNC    GLOBAL DEFAULT   13 _start

从这段内容中,我理解到:

  1. 请求内核运行 /lib64/ld-linux-x86-64.so.2 来启动这个程序。这就是所谓的动态链接器,我们将在随后的部分对其进行讨论。
  2. 该程序制定了一个入口点(位于 0x5ea5c0),那里是这个程序代码开始的地方。

接下来,让我们一起来聊聊动态链接器。

8、动态链接

好的!我们已从磁盘读取了字节数据,并启动了这个“解释器”。那么,接下来会发生什么呢?如果你执行 strace -o out.strace python3,你会在 execve 系统调用之后观察到一系列的信息:

execve("/usr/bin/python3", ["python3"], 0x560af13472f0 /* 21 vars */) = 0
brk(NULL)                       = 0xfcc000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=32091, ...}) = 0
mmap(NULL, 32091, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f718a1e3000
close(3)                        = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 l\0\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=149520, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f718a1e1000
...
close(3)                        = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3

这些内容初看可能让人望而生畏,但我希望你能重点关注这一部分:openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libpthread.so.0" ...。这里正在打开一个被称为 pthread 的 C 语言线程库,运行 Python 解释器时需要这个库。

自行验证:如果你想知道一个二进制文件在运行时需要加载哪些库,你可以使用 ldd 命令。下面展示的是我运行后的效果:

$ ldd /usr/bin/python3.9
    linux-vdso.so.1 (0x00007ffc2aad7000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2fd6554000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2fd654e000)
    libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
    libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
    libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)

你可以看到,第一个列出的库就是 /lib/x86_64-linux-gnu/libpthread.so.0,这就是它被第一个加载的原因。

关于 LD\_LIBRARY\_PATH

说实话,我关于动态链接的理解还有些模糊,以下是我所了解的一些内容:

  • 动态链接发生在用户空间,我的系统上的动态链接器位于 /lib64/ld-linux-x86-64.so.2. 如果你缺少动态链接器,可能会遇到一些奇怪的问题,比如这种 奇怪的“文件未找到”错误
  • 动态链接器使用 LD_LIBRARY_PATH 环境变量来查找库
  • 动态链接器也会使用 LD_PRELOAD 环境变量来覆盖你想要的任何动态链接函数(你可以使用它来进行 有趣的魔改,或者使用像 jemalloc 这样的替代品来替换默认内存分配器)
  • strace 的输出中有一些 mprotect,因为安全原因将库代码标记为只读
  • 在 Mac 上,不是使用 LD_LIBRARY_PATH(Linux),而是 DYLD_LIBRARY_PATH

你可能会有疑问,如果动态链接发生在用户空间,我们为什么没有看到大量的 stat 系统调用在 LD_LIBRARY_PATH 中搜索这些库,就像 Bash 在 PATH 中搜索那样?

这是因为 ld/etc/ld.so.cache 中有一个缓存,因此所有之前已经找到的库都会被记录在这里。你可以在 strace 的输出中看到它正在打开缓存 - openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

完整的 strace 输出 中,我仍然对动态链接之后出现的一些系统调用感到困惑(什么是 prlimit64?本地环境的内容是如何介入的?gconv-modules.cache 是什么?rt_sigaction 做了什么?arch_prctl 是什么?以及 set_tid_addressset_robust_list 是什么?)。尽管如此,我觉得已经有了一个不错的开头。

旁注:ldd 实际上是一个简单的 Shell 脚本!

在 Mastodon 上,有人 指出ldd 实际上是一个 Shell 脚本,它设置了 LD_TRACE_LOADED_OBJECTS=1 环境变量,然后启动程序。因此,你也可以通过以下方式实现相同的功能:

$ LD_TRACE_LOADED_OBJECTS=1 python3
    linux-vdso.so.1 (0x00007ffe13b0a000)
    libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f01a5a47000)
    libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f01a5a41000)
    libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f2fd6549000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f2fd6405000)
    libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f2fd63d6000)
    libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f2fd63b9000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2fd61e3000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f2fd6580000)

事实上,ld 也是一个可以直接运行的二进制文件,所以你也可以通过 /lib64/ld-linux-x86-64.so.2 --list /usr/bin/python3.9 来达到相同的效果。

关于 init 和 fini

让我们来谈谈这行 strace 输出中的内容:

set_tid_address(0x7f58880dca10) = 3709103

这似乎与线程有关,我认为这可能是因为 pthread 库(以及所有其他动态加载的库)在加载时得以运行初始化代码。在库加载时运行的代码位于 init 区域(或者也可能是 .ctors 区域)。

自行验证:让我们使用 readelf 来看看这个:

$ readelf -a /lib/x86_64-linux-gnu/libpthread.so.0
...
  [10] .rela.plt         RELA             00000000000051f0  000051f0
       00000000000007f8  0000000000000018  AI       4    26     8
  [11] .init             PROGBITS         0000000000006000  00006000
       000000000000000e  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000006010  00006010
       0000000000000560  0000000000000010  AX       0     0     16
...

这个库没有 .ctors 区域,只有一个 .init。但是,.init 区域都有些什么内容呢?我们可以使用 objdump 来反汇编这段代码:

$ objdump -d /lib/x86_64-linux-gnu/libpthread.so.0
Disassembly of section .init:

0000000000006000 <_init>:
    6000:       48 83 ec 08             sub    $0x8,%rsp
    6004:       e8 57 08 00 00          callq  6860 <__pthread_initialize_minimal>
    6009:       48 83 c4 08             add    $0x8,%rsp
    600d:       c3

所以它在调用 __pthread_initialize_minimal。我在 glibc 中找到了 这个函数的代码,尽管我不得不找到一个较早版本的 glibc,因为在更近的版本中,libpthread 不再是一个独立的库

我不确定这个 set_tid_address 系统调用是否实际上来自 __pthread_initialize_minimal,但至少我们知道了库可以通过 .init 区域在启动时运行代码。

这里有一份关于 .init 区域的 elf 手册的笔记:

$ man elf

.init 这个区域保存着对进程初始化代码有贡献的可执行指令。当程序开始运行时,系统会安排在调用主程序入口点之前执行该区域中的代码。

在 ELF 文件中也有一个在结束时运行的 .fini 区域,以及其他可以存在的区域 .ctors / .dtors(构造器和析构器)。

好的,关于动态链接就说这么多。

9、转到 \_start

在动态链接完成后,我们进入到 Python 解释器中的 _start。然后,它将执行所有正常的 Python 解析器会做的事情。

我不打算深入讨论这个,因为我在这里关心的是关于如何在 Linux 上运行二进制文件的一般性知识,而不是特别针对 Python 解释器。

10、写入字符串

不过,我们仍然需要打印出 “hello world”。在底层,Python 的 print 函数调用了 libc 中的某个函数。但是,它调用了哪一个呢?让我们来找出答案!

自行验证:运行 ltrace -o out python3 hello.py

$ ltrace -o out python3 hello.py
$ grep hello out
write(1, "hello world\n", 12) = 12

看起来它确实在调用 write 函数。

我必须承认,我对 ltrace 总是有一些疑虑 —— 与我深信不疑的 strace 不同,我总是不完全确定 ltrace 是否准确地报告了库调用。但在这个情况下,它似乎有效。并且,如果我们查阅 cpython 的源代码,它似乎在一些地方确实调用了 write() 函数,所以我倾向于相信这个结果。

什么是 libc?

我们刚刚提到,Python 调用了 libc 中的 write 函数。那么,libc 是什么呢?它是 C 的标准库,负责许多基本操作,例如:

  • malloc 分配内存
  • 文件 I/O(打开/关闭文件)
  • 执行程序(像我们之前提到的 execvp
  • 使用 getaddrinfo 查找 DNS 记录
  • 使用 pthread 管理线程

在 Linux 上,程序不一定需要使用 libc(例如 Go 就广为人知地未使用它,而是直接调用了 Linux 系统调用),但是我常用的大多数其他编程语言(如 node、Python、Ruby、Rust)都使用了 libc。我不确定 Java 是否也使用了。

你能通过在你的二进制文件上执行 ldd 命令,检查你是否正在使用 libc:如果你看到了 libc.so.6 这样的信息,那么你就在使用 libc。

为什么 libc 重要?

你也许在思考 —— 为何重要的是 Python 调用 libc 的 write 函数,然后 libc 再调用 write 系统调用?为何我要着重提及 libc 是调用过程的一环?

我认为,在这个案例中,这并不真的很重要(根据我所知,libc 的 write 函数与 write 系统调用的映射相当直接)。

然而,存在不同的 libc 实现,有时它们的行为会有所不同。两个主要的实现是 glibc(GNU libc)和 musl libc。

例如,直到最近,musl 的 getaddrinfo 并不支持 TCP DNS这是一篇关于这个问题引发的错误的博客文章

关于 stdout 和终端的小插曲

在我们的程序中,stdout(1 文件描述符)是一个终端。你可以在终端上做一些有趣的事情!例如:

  1. 在终端中运行 ls -l /proc/self/fd/1。我得到了 /dev/pts/2 的结果。
  2. 在另一个终端窗口中,运行 echo hello > /dev/pts/2
  3. 返回到原始终端窗口。你应会看到 hello 被打印出来了!

暂时就到这儿吧!

希望通过上文,你对 hello world 是如何打印出来的有了更深的了解!我暂时不再添加更多的细节,因为这篇文章已经足够长了,但显然还有更多的细节可以探讨,如果大家能提供更多的细节,我可能会添加更多的内容。如果你有关于我在这里没提到的程序内部调用过程的任何工具推荐,我会特别高兴。

我很期待看到一份 Mac 版的解析

我对 Mac OS 的一个懊恼是,我不知道如何在这个级别上解读我的系统——当我打印 “hello world”,我无法像在 Linux 上那样,窥视背后的运作机制。我很希望看到一个深度的解析。

我所知道的一些在 Mac 下的对应工具:

  • ldd -> otool -L
  • readelf -> otool
  • 有人说你可以在 Mac 上使用 dtrussdtrace 来代替 strace,但我尚未有足够的勇气关闭系统完整性保护来让它工作。
  • strace -> sc_usage 似乎能够收集关于系统调用使用情况的统计信息,fs_usage 则可以收集文件使用情况的信息。

延伸阅读

一些附加的链接:

(题图:MJ/b87ed0a2-80d6-49cd-b2bf-1ef822485e3f)


via: https://jvns.ca/blog/2023/08/03/behind--hello-world/

作者:Julia Evans 选题:lujun9972 译者:ChatGPT 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

我经常写关于我发现难以学习的技术的文章。不久前,我的朋友 Sumana 向我提出了一个有趣的问题 - 为什么这些东西学起来那么难?为什么它们看起来如此神秘?

以 DNS 为例。我们从 80 年代 开始使用 DNS(已经超过 35 年了!)。它在互联网上的每个网站中都使用。而且它相当稳定 - 在很多方面,它的工作方式与 30 年前完全相同。

但是我花了好几年的时间才弄清楚如何自信地调试 DNS 问题,我也见过很多其他程序员在调试 DNS 问题上苦苦挣扎。那么到底发生了什么呢?

以下是关于为什么学习排除 DNS 问题很困难的几点思考。

(我不会在这篇文章中详细解释 DNS,更多关于 DNS 如何工作的信息,请参阅 《用一个周末实现一个 DNS》 或 我的 DNS 方面的博文

并不是因为 DNS 非常难

当我最终学会如何排除 DNS 问题时,我的反应是“什么,就这样吗???这并不难!”我感觉有点被骗了!我可以在 几个小时 内向你解释关于 DNS 令我感到困惑的一切事情。

那么 - 如果 DNS 并不是那么复杂,为什么我花了这么多年的时间才弄清楚如何排除相当基本的 DNS 问题(比如“即使我已经正确设置了,我的域名仍无法解析”或者“dig 命令和我的浏览器的 DNS 结果不一致,为什么?”)?

而且,在发现 DNS 学习困难方面,我并不孤单!我与许多经验丰富的程序员朋友讨论过多年来的 DNS 问题,其中很多人要么:

  • 不敢轻易对其网站进行简单的 DNS 更改
  • 或对 DNS 工作原理的基本事实感到困惑(比如记录是 拉取的而非推送的
  • 或对 DNS 基础知识了解得很好,但却和我一样存在一些知识盲点(负缓存和 dig 命令及浏览器如何以不同方式进行 DNS 查询的细节)

因此,如果我们都面临着 DNS 的相同困扰,到底发生了什么?为什么对许多人来说学习 DNS 如此困难?

以下是我的一些看法。

很多系统是隐藏的

当你在计算机上发起 DNS 请求时,基本的过程如下:

  1. 你的计算机向一个名为“解析器”的服务器发起请求。
  2. 解析器检查其缓存,并向一些称为“权威名称服务器”的其它服务器发起请求。

以下是你看不到的一些内容:

  • 解析器的缓存。里面有什么内容?
  • 在你的计算机上进行 DNS 请求的库代码是哪个(是否是 libc 的 getaddrinfo 函数?如果是,它是来自 glibc、musl 还是苹果?是你的浏览器的 DNS 代码吗?还是其他自定义的 DNS 实现?)所有这些选项的行为略有不同,并且有不同的配置、缓存方法、可用功能等等。例如,musl DNS 直到 2023 年初 才支持 TCP。
  • 解析器与权威名称服务器之间的对话。如果你能够神奇地获得一个准确记录你的请求期间向下游查询的每个权威名称服务器以及它们的响应的追踪,我认为很多 DNS 问题将变得非常简单。(比如,如果你能运行 dig +debug google.com 并获得一些额外的调试信息会怎么样?)

如何和隐藏系统打交道

以下是几个处理隐藏系统的方法:

  • 向人们传授隐藏系统的知识会产生重大影响。很长一段时间里,我不知道我的计算机有多个不同的 DNS 库,它们在不同情况下使用,我对此感到困惑了好几年。这是我的重要排错方法。
  • 通过 Mess With DNS,我们尝试了一种“鱼缸”的方法,展示了通常隐藏的系统(与解析器和权威名称服务器的对话)的一些部分。
  • 我觉得将 DNS 扩展以包括一个“调试信息”部分会非常酷。(注:似乎这已经有了!它被称为“扩展 DNS 错误”,即 EDE,各种工具正在逐渐添加对它的支持。)

扩展 DNS 错误看起来不错

扩展 DNS 错误是 DNS 服务器提供额外调试信息的一种新方式。以下是一个示例:

$ dig @8.8.8.8 xjwudh.com
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NXDOMAIN, id: 39830
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
; EDE: 12 (NSEC Missing): (Invalid denial of existence of xjwudh.com/a)
;; QUESTION SECTION:
;xjwudh.com.            IN    A

;; AUTHORITY SECTION:
com.            900    IN    SOA    a.gtld-servers.net. nstld.verisign-grs.com. 1690634120 1800 900 604800 86400

;; Query time: 92 msec
;; SERVER: 8.8.8.8#53(8.8.8.8) (UDP)
;; WHEN: Sat Jul 29 08:35:45 EDT 2023
;; MSG SIZE  rcvd: 161

这里我请求了一个不存在的域名,并收到了扩展错误信息 EDE: 12 (NSEC Missing): (Invalid denial of existence of xjwudh.com/a)。我不太确定这是什么意思(它与 DNSSEC 有关),但能看到这样额外的调试信息真的很酷。

为了能看到上述内容,我确实需要安装更新版本的 dig

令人困惑的工具

尽管很多 DNS 的细节被隐藏起来,但你可以通过使用 dig 工具来找出发生了什么事情。

例如,你可以使用 dig +norecurse 来确定给定的 DNS 解析器是否在其缓存中具有特定的记录。如果响应没有被缓存,8.8.8.8 看起来会返回 SERVFAIL 响应。

以下是对 google.com 进行该操作的示例:

$ dig +norecurse  @8.8.8.8 google.com
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 11653
;; flags: qr ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;google.com.            IN  A

;; ANSWER SECTION:
google.com.     21  IN  A   172.217.4.206

;; Query time: 57 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Fri Jul 28 10:50:45 EDT 2023
;; MSG SIZE  rcvd: 55

这是对 homestarrunner.com 的示例:

$ dig +norecurse  @8.8.8.8 homestarrunner.com
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL, id: 55777
;; flags: qr ra; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 1

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 512
;; QUESTION SECTION:
;homestarrunner.com.        IN    A

;; Query time: 52 msec
;; SERVER: 8.8.8.8#53(8.8.8.8)
;; WHEN: Fri Jul 28 10:51:01 EDT 2023
;; MSG SIZE  rcvd: 47

在这里,你可以看到我们对于 google.com 得到了一个正常的 NOERROR 响应(8.8.8.8 的缓存中有该记录),但对于 homestarrunner.com 得到了 SERVFAIL 响应(没有缓存)。这并不意味着 homestarrunner.com 没有 DNS 记录(实际上有!),它只是没有被缓存。

但如果你不熟悉这样的输出,它确实很难阅读!以下是我认为其中一些奇怪的地方:

  1. 标题很奇怪(有 ->>HEADER<<-flags:OPT PSEUDOSECTION:QUESTION SECTION:ANSWER SECTION:)。
  2. 空格排版很奇怪(OPT PSEUDOSECTIONQUESTION SECTION 之间为什么没有换行符?)。
  3. MSG SIZE rcvd: 47 很奇怪(MSG SIZE 中是否还有其他字段,而不仅仅是 rcvd?它们是什么?)。
  4. 它说有 1 个记录在 ADDITIONAL 部分,但没有显示它,你必须以某种方式神奇地知道OPT PSEUDOSECTION 记录实际上在 ADDITIONAL 部分。

总的来说,dig 的输出给人的感觉是一个以临时方式编写并随着时间的推移逐渐发展起来的脚本,而不是经过有意设计的东西。

处理令人困惑的工具的一些想法:

  • 解释输出结果。例如,我写了一篇 如何使用 dig 的文章,解释了 dig 的输出结果以及如何配置它以默认给出更简短的输出。
  • 创建新的、更友好的工具。例如,在 DNS 方面,有 dogdoggo我的 DNS 查询工具。我认为这些工具非常酷,但我个人不使用它们,因为有时我想做一些稍微高级一点的操作(比如使用 +norecurse),据我所知,无论是 dog 还是 doggo 都不支持 +norecurse。我更愿意使用一个工具来完成所有任务,所以我坚持使用 dig。要替换 dig,其功能广度是一项庞大的工作。
  • 使 dig 的输出更加友好。如果我在 C 编程方面更好一些,我可能会尝试编写一个 dig 的拉取请求,添加一个 +human 标志以以更结构化和易读的方式格式化长格式的输出,可能类似于以下形式:
$ dig +human +norecurse  @8.8.8.8 google.com 
HEADER:
  opcode: QUERY
  status: NOERROR
  id: 11653
  flags: qr ra
  records: QUESTION: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1

QUESTION SECTION:
  google.com.            IN    A

ANSWER SECTION:
  google.com.        21    IN    A    172.217.4.206
  
ADDITIONAL SECTION:
  EDNS: version: 0, flags:; udp: 512

EXTRA INFO:
  Time: Fri Jul 28 10:51:01 EDT 2023
  Elapsed: 52 msec
  Server: 8.8.8.8:53
  Protocol: UDP
  Response size: 47 bytes

这样可以更清晰地呈现 DNS 响应的结构-包括标题、问题、答案和附加部分。

而且它并不是“简化”了什么!它是完全相同的信息,只是以更结构化的方式进行了格式化。我对替代的 DNS 工具最大的不满是它们经常为了清晰起见而删除信息。虽然这些工具肯定有其用武之地,但我想要看到所有的信息!我只是希望它能够以清晰明了的方式呈现。

在过去的 40 年中,我们已经学到了很多关于如何设计更用户友好的命令行工具的知识,我认为将其中一些知识应用到我们那些有些陈旧的工具中将会很棒。

dig +yaml

关于 dig 的一个简单备注:较新版本的 dig 支持 +yaml 输出格式,对我来说更加清晰,但有些冗长(一个相当简单的 DNS 响应都无法在屏幕上完整显示)。

一些奇怪的陷阱

DNS 存在一些相对常见但很难通过自学了解到的奇怪问题。以下是一些例子(有更多可在 导致 DNS 中断的一些方式 中找到):

  • 负缓存:我在 这篇演讲 中提到过,我大约花了 5 年时间才意识到不应该访问没有 DNS 记录的域名,因为该记录的 不存在 信息将被缓存,并且该缓存在几个小时内不会被更新,这真的很烦人。
  • getaddrinfo 实现的差异:直到 2023 年初musl 不支持 TCP DNS。
  • 忽略 TTL 的解析器:如果你在 DNS 记录上设置了 TTL(比如“5 分钟”),一些解析器完全会忽略这些 TTL 设置,并将记录缓存更长时间,比如可能是 24 小时。
  • 如果你错误地配置了 Nginx(像这样),它将永久缓存 DNS 记录。
  • ndots 如何导致 Kubernetes DNS 缓慢。

如何应对奇怪的陷阱

对此,我没有像我希望的那样完美的答案。对奇怪陷阱的了解非常难以获得(再次强调,我花了多年的时间才弄清楚负缓存!),对我而言,人们不得不一次又一次地自己重新发现它们感觉很愚蠢。

以下是一些想法:

  • 当有人在解释一个主题时提到了一些棘手的问题,这是非常有帮助的。例如(离开 DNS 一下),Josh Comeau 的 Flexbox 入门解释了这个 最小尺寸的陷阱,在找到解释之前,我多年来遇到过很多次这个问题。
  • 我希望看到更多的社区整理的常见陷阱。比如说,对于 Bash,shellcheck 是一个非常不错的常见陷阱集合。

关于记录 DNS 陷阱的一个棘手问题是,不同的人会遇到不同的陷阱。如果你只是每三年为个人域名配置一次 DNS,你可能会遇到不同的问题,而那些管理高流量域名的人则可能会遇到其他问题。

还有一些更简单的原因:

不经常接触

很多人非常少接触 DNS。如果你只在每三年才处理一次 DNS,学习起来就会更加困难!

我认为备忘单(比如“这是更改你的名称服务器的步骤”)可以在这方面起到很大的帮助。

难以进行实验

DNS 在进行实验时可能会让人感到害怕,因为你不想搞砸自己的域名。我们建立了 Mess With DNS 来使这个过程变得更容易一些。

目前就这些

我很想听听其他关于什么让 DNS(或你最喜欢的神秘技术)难以学习的想法。

(题图:MJ/96c5d8fb-f4a5-4710-8f91-c71617120675)


via: https://jvns.ca/blog/2023/07/28/why-is-dns-still-hard-to-learn/

作者:Julia Evans 选题:lujun9972 译者:ChatGPT 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

有时我与一些害怕使用命令行的朋友交谈,我感到自己给不出好的建议(我已经使用命令行太长时间了),因此我向一些 Mastodon 上的人提出了以下问题:

如果在过去一到三年内,你刚刚不再害怕使用命令行了,是什么帮助了你?

(如果你不记得,或者你已经使用命令行舒适地工作了 15 年,则无需回答——这个问题不适用于你 ?)

这个列表还不如我希望的那么长,但我希望通过发布它来收集更多的答案。显然,并没有一个单一的方法适用于所有人,不同的人会选择不同的路径。

我认为舒适使用命令行有三个方面:减少风险动机资源。我将先谈谈减少风险,然后是一些动机,并列出一些资源。

减少风险的方式

很多人(没错!)对在命令行上意外执行了一些无法撤销的破坏性操作感到担心。

以下是一些人们提到的帮助他们减少风险的策略:

  • 定期备份(有人提到他们在上周的一个命令行错误中意外删除了整个家目录,但很幸运他们有备份)。
  • 对于代码,尽可能多地使用 git
  • rm 设置为类似 safe-rmrmtrash 这样的工具的别名,这样你就不会意外删除不应删除的内容(或者就设置别名到 rm -i)。
  • 尽量避免使用通配符,使用制表符键补全代替(我的 Shell 会使用 TAB 键补全 rm *.txt 并显示我将要删除的内容)。
  • 使用精美的终端提示符,可以显示当前目录、计算机名称、git 分支和你是否具有 root 权限。
  • 如果你计划对文件运行未经测试或危险的命令,先备份文件副本。
  • 拥有一台专用的测试机器(如便宜的旧 Linux 计算机或树莓派)进行特别危险的测试,例如测试备份软件或分区。
  • 对于危险命令,如果有的话,使用 --dry-run 选项来查看执行结果而不实际执行操作。
  • 在你的 Shell 脚本中构建自己的 --dry-run 选项。

这些策略有助于降低在命令行上引发不可逆操作的风险。

杀手级应用程序

一些人提到了一个“杀手级命令行应用程序”,这激励他们开始花更多时间在命令行上。例如:

  • ripgrep
  • jq
  • wget / curl
  • git(一些人发现他们更喜欢使用 git 命令行界面而不是使用图形界面)
  • ffmpeg(用于视频处理)
  • yt-dlp
  • 硬盘数据恢复工具(来自 这个精彩的故事

还有一些人提到他们对图形界面工具感到失望(例如使用了所有内存,并使计算机崩溃的重型集成开发环境),并因此有动机用更轻量级的命令行工具替代它们。

激发人们的命令行技巧

有人提到被其他人在命令行上展示的酷炫功能所激励,例如:

explainshell

有几个人提到了 explainshell,它可以让你粘贴任何命令行指令,并将其分解成不同的部分解释。

命令历史、制表符补全等等

有很多小技巧和窍门可以使在命令行上工作更容易,例如:

  • 使用向上箭头查看先前的命令
  • 使用 Ctrl+R 搜索你的 Bash 历史记录
  • 使用快捷键在行内导航:Ctrl+w (删除一个单词)、Ctrl+a(跳转到行首)、Ctrl+e(跳转到行尾),以及 Ctrl+left arrow / Ctrl+right arrow(向前/向后跳转一个单词)
  • 将 Bash 历史记录设置为无限制
  • 使用 cd - 返回上一个目录
  • 文件名和命令名的制表符自动补全
  • 学习如何使用像 less 这样的分页工具阅读手册页或其他大型文本文件(如搜索、滚动等)
  • 在 macOS 上使用 pbcopy/pbpaste 将剪贴板内容复制/粘贴到 stdout/stdin
  • 在编辑配置文件之前备份它们

fzf

很多人提到使用 fzf 作为模糊搜索 Shell 历史记录的更好方法。除了作为更好的模糊搜索 Shell 历史记录的工具,人们还提到了一些其他用途:

  • 选择 git 分支(git checkout $(git for-each-ref --format='%(refname:short)' refs/heads/ | fzf)
  • 快速查找要编辑的文件(nvim $(fzf)
  • 切换 Kubernetes 上下文(kubectl config use-context $(kubectl config get-contexts -o name | fzf --height=10 --prompt="Kubernetes Context> ")
  • 从测试套件中选择要运行的特定测试

一般的模式是使用 fzf 来选择某个对象(文件、git 分支、命令行参数),fzf 将所选对象输出到标准输出,然后将其插入作为另一个命令的命令行参数。

你还可以将 fzf 用作工具,自动预览输出并快速迭代,例如:

  • 自动预览 jq 的输出(echo '' | fzf --preview "jq {q} < YOURFILE.json"
  • 自动预览 sed 的输出(echo '' | fzf --preview "sed {q} YOURFILE"
  • 自动预览 awk 的输出(echo '' | fzf --preview "awk {q} YOURFILE"

你可以参考这个思路。

通常,人们会为 fzf 的使用定义别名,比如输入 gcb 或其他命令,以快速选择要检出的 git 分支。

树莓派

一些人开始使用树莓派,这样可以更安全地进行实验,而不必担心损坏计算机(只需擦除 SD 卡然后重新开始即可!)。

漂亮的 Shell 环境

很多人说,当他们开始使用像 oh-my-zshFish 这样更用户友好的 Shell 环境时,他们在命令行上感到更舒适。我非常同意这一点 – 我已经使用 Fish 十年了,我非常喜欢它。

在这里还有一些其他的事情可以做:

  • 有些人说,让他们的终端更漂亮可以帮助他们感到更舒适(“让它变成粉色!”)。
  • 设置一个漂亮的 Shell 提示符来提供更多信息(例如,当命令失败时,可以将提示符设置为红色)。特别是 transient prompts(在当前命令设置一个非常花哨的提示符,但在之前的命令中设置一个简单得多的提示符)看起来非常好。

一些用于美化终端的工具:

  • 我使用 base16-shell
  • powerlevel10k 是一个流行的漂亮的 Zsh 主题,具有 transient prompts
  • starship 是一个漂亮的提示符工具
  • 在 Mac 上,我认为 iTerm2 比默认的终端更容易自定义。

漂亮的文件管理器

一些人提到了像 rangernnn 这样的漂亮的终端文件管理器,这是我之前没有听说过的。

一个有帮助的朋友或同事

一个可以回答初学者问题并给你指点的人是无价的。

通过肩并肩地观察学习

有人提到观察更有经验的人使用终端 - 有很多经验丰富的用户甚至没有意识到自己在做什么,你可以从中学到很多小技巧。

别名

很多人说,为常用任务创建自己的别名或脚本就像是一个神奇的“灵光一现”时刻,因为:

  • 他们不必记住语法
  • 然后他们就有了一份自己常用命令的列表,可以轻松调用

查找示例的备忘单

很多手册页没有示例,例如 openssl s\_client 的手册页就没有示例。这使得起步变得更加困难!

人们提到了一些备忘单工具,比如:

  • tldr.sh
  • cheat(还可以进行编辑 - 你可以添加自己的命令以供以后参考)
  • um(一个非常精简的需要自己构建的系统)

例如,openssl 的备忘单 非常棒 - 我认为它几乎包含了我在实际中使用 openssl 时用过的所有内容(除了 openssl s_client-servername 选项)。

有人说他们配置了他们的 .bash_profile,这样每次登录时都会打印出一张备忘单。

不要试图背诵

一些人说他们需要改变自己的方法 - 他们意识到不需要试图记住所有的命令,只需按需查找命令,随着时间的推移,他们会自然而然地记住最常用的命令。

(我最近对学习阅读 x86 汇编有了完全相同的体会 - 我正在上一门课程,讲师说“是的,刚开始时可以每次都查找,最终你会记住最常见的指令。”)

还有一些人说相反的观点 - 他们使用间隔重复应用程序(如 Anki)来记忆常用的命令。

Vim

有人提到他们开始在命令行上使用 Vim 编辑文件,一旦他们开始使用终端文本编辑器,使用命令行处理其他事情也变得更自然。

此外,显然有一个名为 micro 的新编辑器,像是更好的 pico/nano,适用于那些不想学习 Emacs 或 Vim 的人。

桌面上使用 Linux

有人说他们开始使用 Linux 作为他们的日常主力系统,而需要修复 Linux 问题可以帮助他们学习。这也是我在大约 2004 年熟悉命令行的方式(我非常喜欢安装各种不同的 Linux 发行版,以找到我最喜欢的那个),但我猜这不是如今最受欢迎的策略。

被迫仅使用终端

有些人说他们参加了一门大学课程,教授让他们在终端上做所有事情,或者他们自己制定了一个规则,一段时间内必须在终端上完成所有工作。

工作坊

有几个人说像 Software Carpentry 这样的工作坊(面向科学家的命令行、Git 和 Python/R 编程简介)帮助他们更熟悉命令行。

你可以在这里查看 Software Carpentry 课程

书籍和文章

一些提到的材料:

文章:

书籍:

视频:

(题图:MJ/c0dc082a-a477-434b-b826-77a42c8f61c3)


via: https://jvns.ca/blog/2023/08/08/what-helps-people-get-comfortable-on-the-command-line-/

作者:Julia Evans 选题:lujun9972 译者:ChatGPT 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出