设备驱动程序总结
自从 80386 时代以来,英特尔世界就支持一种称为虚拟寻址的技术。从 Z80 和 68000 世界走来,我对这个的第一反应是:“你可以分配比物理 RAM 更多的内存,因为一些地址将与你硬盘的部分区域相关联”。
更学术地说:程序用于访问内存的每个地址(无论是数据还是程序代码)都将被翻译——要么翻译成物理 RAM 中的物理地址,要么翻译成异常,这由操作系统处理,以便给你你需要的内存。然而,有时,访问虚拟内存中的那个位置会显示程序失序——在这种情况下,操作系统应该引发一个“真实”的异常(通常是 SIGSEGV,信号 11)。
地址转换的最小单元是页,在 Intel 架构上是 4 kB,在 Alpha 上是 8 kB(在 asm/page.h 中定义)。
当试图理解地址解析的过程时,你将进入一个由页表描述符、段描述符、页表标志和不同地址空间组成的动物园。现在,我们只说线性地址是程序使用的;使用段描述符,它被转换为逻辑地址,然后使用分页机制将其解析为物理地址(或故障)。《Linux 内核黑客指南》用 20 页的篇幅对所有这些怪物进行了相当简短的描述,我看不出有什么机会能做出更简洁的解释。
对于任何理解在使用 Linux 时页面的构建、管理和范围,以及底层技术——特别是 Intel 系列——如何工作,你必须阅读《Linux 内核黑客指南》。它可以从 tsx-11.mit.edu 的 /pub/linux/docs/LDP/ 目录通过 ftp 免费获得。尽管这本书有点老 [这是一种温和的轻描淡写——编者注],但 i386 的内部结构没有任何改变,其他处理器看起来也很相似(特别是,奔腾与 386 完全一样)。
如果你想了解页面管理,要么你现在开始阅读这本优秀的指南,要么你相信这个简短而抽象的概述
每个进程都有一个虚拟地址空间,由几个 CPU 寄存器实现,这些寄存器在上下文切换期间会发生变化(这就是选择器和页面描述指针的动物园)。通过这些寄存器,CPU 可以访问它需要的所有内存段。
使用多级转换表将进程给出的线性地址转换为 RAM 中的物理地址。转换表都驻留在内存中。它们由 CPU 硬件自动查找,但它们由操作系统构建和更新。它们被称为页描述符表。在这些表中,进程地址空间中的每个页面都有一个条目(即,“页描述符”)——我们说的是逻辑地址,也称为虚拟地址。
我们现在专注于CPU看到的页面的几个主要方面
页面可能是“present”或 not——取决于它是否在物理内存中(如果它已被换出,或者它是一个尚未加载的页面)。页描述符中的一个标志用于指示此状态。访问一个 non-present 页面被称为“major”页错误。在 Linux 中,错误由 mm/memory.c 中的函数 do_no_page() 处理。Linux 在 struct task_struct 中的字段 maj_flt 中计算每个进程的页错误。
页面可能是写保护的——任何试图写入页面的操作都会导致错误(称为“minor page fault”,在 do_wp_page() 中处理,并在 struct task_struct 的 min_flt 字段中计数)。
一个页面属于一个任务或多个任务的地址空间;每个这样的任务都保存着该页面的描述符。“Task”是微处理器技术人员所说的进程。
操作系统看到的页面的其他重要特征是
如果多个进程使用同一物理内存页面,则称它们“共享”它。进程为共享页面保存单独的页描述符,并且条目可能不同——例如,一个进程可能对页面具有写权限,而另一个进程可能没有。
页面可以标记为写时复制(在内核源代码中 grep COW)。例如,如果一个进程 fork,子进程将与父进程共享数据段,但两者都将受到写保护:页面是为读取而共享的。一旦一个程序写入一个页面,该页面就会被复制,写入程序会得到一个新的页面;另一个程序保留其旧页面,并递减“共享计数”。如果共享计数已经为 1,则当发生 minor fault 时,不会执行复制,页面只会被标记为可写。写时复制最大限度地减少了内存使用。
页面可以被锁定以防止被换出。所有内核模块和内核本身都驻留在锁定的页面中。你可能还记得上一期,用于 DMA 传输的页面必须防止被换出。
页描述符也可能指向不在物理 RAM 中,而是位于某些外围设备的 ROM、显卡 RAM 缓冲区等或 PCI 缓冲区中的地址。传统上,在 Intel 架构上,前两组的范围是从 640 kB 到 1024 kB,PCI 缓冲区的范围高于 high_memory(物理 RAM 的顶部,在 asm/pgtable.h 中定义)。从 640 KB 到 1024 kB 的范围未被 Linux 使用,并在 mem_map 结构中标记为保留。它们是“384k reserved”,出现在 BogoMips 计算后的第一个内核消息中。
虚拟内存允许非常漂亮的事情,例如
按需加载程序,而不是在启动时将其完全加载到内存中:每当你启动一个程序时,它都会获得自己的虚拟地址空间,该空间仅与文件系统上的一些块和一些变量空间相关联,但只有当你真正访问程序的各个部分时,才会分配内存并执行加载。
交换,以防你的内存变得紧张。这意味着每当 Linux 为自身或程序需要内存,并且未使用的内存变得紧张时,它将尝试缩小文件系统的缓冲区,尝试“忘记”已经为正在执行的程序代码分配的页面(无论如何它们可以随时从磁盘重新加载),或者将一些包含用户数据的页面交换到硬盘的交换分区。
内存保护。每个进程都有自己的地址空间,并且不能查看属于其他进程的内存。
内存映射:只需通过一个简单的函数调用,将你打开的文件的一部分或全部声明为你内存的一部分。
我们现在开始讨论。当考虑 mmaping(Memory Mapping;通常发音为 em-mapping)字符设备驱动程序时,你应该能够做出的第一个假设是你有一些类似编号的位置和该设备的长度。当然,你可以计算来自串行线的字符流中的第 n 个字节,但 mmap 范例更容易应用于具有明确定义的开始和结束的设备。
每当你使用 svgalib 或服务器时使用的字符“设备”是 /dev/mem:一个代表你的物理内存的设备。服务器和 svgalib 使用它将你的图形适配器的视频缓冲区映射到服务器或用户进程的用户空间。
很久以前(我那么老了吗?),人们使用 BASIC 编写像 Tetris 这样的游戏,以便在文本控制台上运行。他们倾向于直接写入视频 RAM,而不是使用 BASIC 命令的极其缓慢的方式。这与使用 mmapping 完全一样。
为了寻找一个小的例子来玩 mmap(),我编写了一个名为 nasty 的小程序。你可能知道,阿拉伯文字是从右到左书写的。尽管我想没有人会喜欢这种拉丁字母的风格,但下面的程序让你了解这种风格。请注意,nasty 仅 在带有 VGA 的 Intel 架构上运行。
如果你曾经运行过这个程序,请以 root 身份运行它(因为否则你将无法访问 /dev/mem),在文本模式下运行它(因为当使用 X 时你将看不到任何东西),并使用 VGA 或 EGA 运行它(因为该程序使用此类板卡的特定地址)。你可能什么也看不到。如果是这样,请尝试向后滚动几行(Ctrl-PageUp)到屏幕缓冲区的开头。
/* nasty.c - flips right and left on the * VGA console --- "Arabic" display */ # include <stdio.h> # include <string.h> # include <sys/mman.h> int main (int argc, char **argv) { FILE *fh; short* vid_addr, temp; int x, y, ofs; fh = fopen ("/dev/mem", "r+"); vid_addr = (short*) mmap ( /* where to map to: don't mind */ NULL, /* how many bytes ? */ 0x4000, /* want to read and write */ PROT_READ | PROT_WRITE, /* no copy on write */ MAP_SHARED, /* handle to /dev/mem */ fileno (fh), /* hopefully the Text-buffer :-)*/ 0xB8000); if (vid_addr) for (y = 0; y < 100; y++) for (x = 0; x < 40; x++) { ofs = y*80; temp = vid_addr [ofs+x]; vid_addr [ofs+x] = vid_addr [ofs+79-x]; vid_addr [ofs+79-x] = temp; } munmap ((caddr_t) vid_addr, 0x4000); fclose (fh); return 0; }
你可以在上面的 mmap() 调用中更改什么?
你可以通过删除请求读取、写入或执行(PROT_READ、PROT_WRITE 和 PROT_EXEC)映射到用户程序的数据范围的权限的 PROT 标志之一来更改映射页面的权限。
你可以决定用 MAP_PRIVATE 替换 MAP_SHARED,允许你读取页面而不写入它(将设置写时复制标志:你将能够写入文本缓冲区,但修改后的内容不会刷新回显示缓冲区,而是会进入你的页面的私有副本)。
更改 offset 参数将允许你将这个 nasty 程序适配到 Hercules 单色适配器(通过使用 0xB0000 作为文本缓冲区而不是 0xB8000)或使机器崩溃(通过使用另一个地址)。
你可以决定将 mmap() 调用应用于磁盘文件而不是系统内存,将文件的内容转换为我们的“阿拉伯”风格(确保使你 mmap 的长度和对真实文件长度的访问相匹配)。如果你的旧 mmap 手册页告诉你它是一个 BSD 页面,请不要担心——目前的问题是谁记录了 Linux 的功能,而不是谁实现了它们...
你可以指定一个你希望将页面映射到的地址,而不是将 NULL 作为第一个参数传递。使用最近的 Linux 版本,这个愿望将被忽略,除非你添加 MAP_FIXED 标志。在这种情况下,Linux 将取消映射该地址上的任何先前映射,并将其替换为你想要的 mmap。如果你使用这个(我不知道你为什么要这样做),请确保你想要的地址适合页面边界((addr & PAGE_MASK) == addr)。
最后,我们真正触及了 mmap 的最常用用途之一——特别是当你处理像数据库这样的大文件的小部分时。你会发现将整个文件映射到内存中,以便像访问真实内存一样读取和写入它,并将 Linux 的缓冲算法的所有古怪之处留给它,这将很有帮助——而且速度更快。它的工作速度将比 fread() 和 fwrite() 快得多。
必须关心这些漂亮的东西的人是你可怜的设备驱动程序编写者。虽然对文件的 mmap() 支持是由内核完成的(实际上是由每种文件系统类型完成的),但设备的映射方法必须由驱动程序直接支持,通过在 fops 结构中提供一个合适的条目,我们在 LJ 三月刊中首次介绍了该结构。
首先,我们来看看少数几个对此类支持的“真实”实现之一,讨论基于 /dev/mem 驱动程序。接下来,我们将继续讨论一种特别有用的实现,它适用于帧捕获器、具有 DMA 支持的实验室设备以及可能的其他外围设备。
首先,每当用户调用 mmap() 时,调用将到达 mm/mmap.c 文件中定义的 do_mmap()。do_mmap() 做两件重要的事情
它检查读取和写入文件句柄的权限是否符合 mmap() 的请求。此外,还执行了关于 Intel 机器上超过 4GB 限制和其他淘汰标准的测试。
如果这些都良好,则为新的虚拟内存片段生成 struct vm_area_struct 变量。每个任务可以拥有多个这些结构,“虚拟内存区域”(VMA)。
VMA 需要一些解释:它们代表用户地址空间部分的地址、方法、权限和标志。你的 mmaped 区域将在任务头中保留其自己的 vm_area_struct 条目。VMA 结构由内核维护,并在平衡树结构中排序,以实现快速访问。
VMA 的字段在 linux/mm.h 中定义。可以通过查看任何正在运行的进程的 /proc/pid/maps 来探索数量和内容,其中pid是请求进程的进程 ID。让我们对我们的小 nasty 程序(用 gcc-ELF 编译)这样做。当程序运行时,你的 /proc/pid/maps 表将看起来有点像这样(不包括注释)
# /dev/sdb2: nasty css 08000000-08001000 rwxp 00000000 08:12 36890 # /dev/sdb2: nasty dss 08001000-08002000 rw-p 00000000 08:12 36890 # bss for nasty 08002000-08008000 rwxp 00000000 00:00 0 # /dev/sda2: /lib/ld-linux.so.1.7.3 css 40000000-40005000 r-xp 00000000 08:02 38908 # /dev/sda2: /lib/ld-linux.so.1.7.3 dss 40005000-40006000 rw-p 00004000 08:02 38908 # bss for ld-linux.so 40006000-40007000 rw-p 00000000 00:00 0 # /dev/sda2: /lib/libc.so.5.2.18 css 40009000-4007f000 rwxp 00000000 08:02 38778 # /dev/sda2: /lib/libc.so.5.2.18 dss 4007f000-40084000 rw-p 00075000 08:02 38778 # bss for libc.so 40084000-400b6000 rw-p 00000000 00:00 0 # /dev/sda2: /dev/mem (our mmap) 400b6000-400c6000 rw-s 000b8000 08:02 32767 # the user stack bfffe000-c0000000 rwxp fffff000 00:00 0
每行上的前两个字段,用破折号分隔,表示数据 mmaped 到的地址。下一个字段显示这些页面的权限(r 用于读取,w 用于写入,p 用于私有,s 用于共享)。接下来给出从 mmaped 的文件中的偏移量,然后是设备和文件的 inode 号。设备号代表挂载的(硬盘)磁盘(例如,03:01 是 /dev/hda1,08:01 是 /dev/sda1)。找出给定 inode 号的文件名的最简单(且缓慢)的方法是
cd /mount/point find . -inum inode-number -print
如果你试图理解这些行及其注释,请注意 Linux 将数据分为“代码存储段”或 css,有时称为“文本”段;“数据存储段”或 dss,包含初始化的数据结构;以及“块存储段”或 bss,用于在执行时分配并初始化为零的变量的区域。由于 bss 中变量的初始值不必从磁盘加载,因此列表中的 bss 项不显示文件设备(作为主设备号的“0”是 NODEV)。这显示了 mmap 的另一种用法:你可以为文件句柄传递 MAP_ANONYMOUS,以请求程序的空闲内存部分。(实际上,某些版本的 malloc 以这种方式获取其内存。)
当你的设备驱动程序收到来自 do_mmap() 的调用时,VMA 已经为新的映射创建,但尚未插入到任务的内存结构中。
设备驱动程序函数应符合此原型
int skel_mmap (struct inode *inode, struct file *file, struct vm_area_struct *vma)
vma->vm_start 将包含要映射到的用户空间中的地址。vma->vm_end 包含其结尾,这两个元素之间的差异表示用户最初调用 mmap() 时的 length 参数。vma->vm_offset 是 mmaped 文件上的偏移量,与传递给系统调用的 offset 参数相同。
让我们探索 /dev/mem 驱动程序如何执行映射。你在 drivers/char/mem.c 的函数 mmap_mem() 中找到代码行。如果你正在寻找复杂的东西,你将会失望:它只调用 remap_page_range()。如果你想了解这里发生了什么,你真的应该阅读《内核黑客指南》中的 20 页。简而言之,为给定的进程地址空间生成页描述符,并用指向物理内存的链接填充。请注意,VMA 结构用于 Linux 内存管理,而页描述符由 CPU 直接解释用于地址解析。
如果 remap_page_range() 返回零,表示没有发生错误,则你的 mmap 处理程序也应该这样做。在这种情况下,do_mmap() 将返回页面映射到的地址。任何其他返回值都被视为错误。
很难给出不同字符驱动程序中 mmap 技术所有可能应用的代码行。我们的具体示例是一个实验室设备,它有自己的 RAM、CPU,当然还有模数转换器、数模转换器、数字输入和输出以及时钟(以及各种花哨的功能)。
我们处理的实验室设备能够稳定地采样到其内存中,并在通过字符通道询问时报告其工作状态,这是一个类似 ASCII 流的通道。基于命令的交互是通过我们实现的字符设备驱动程序及其读取和写入调用完成的。
数据的实际大量传输与此无关:通过发送类似 TOHOST interface address, length, host address 的命令,实验室设备将引发中断并告诉 PC 它想通过 DMA 将一定量的数据发送到主机上的给定地址。但是我们应该把数据放在哪里呢?我们决定不将清晰的字符通信与大量数据传输混在一起。此外,由于用户甚至可以将其自己的命令上传到设备,因此我们无法对数据的排序和含义做出任何假设。
因此,我们决定将完全控制权交给用户,并允许他请求映射到用户地址空间的 DMA 可用内存部分,并根据这些区域的列表检查来自实验室设备的每个 DMA 请求。换句话说,我们通过 ioctl() 命令实现了类似于 skel_malloc 和 skel_free 的功能,并禁止传输到任何其他区域,以保持整个事情的安全。
你可能想知道为什么我们不直接使用 mmap()。主要是因为没有等效的 munmap。当到打开文件的映射被销毁时,你的驱动程序不会收到通知。Linux 自己完成所有操作:它删除 vma 结构,销毁页描述符表,并减少共享页面的引用计数。
由于我们必须通过 kmalloc() 分配 DMA 可用缓冲区,因此我们必须通过 kfree() 释放它。当自动取消映射用户引用时,Linux 不允许我们这样做,但没有用户引用,我们不再需要缓冲区。因此,我们实现了 skel_malloc(),它实际上分配了驱动程序缓冲区并将其重新映射到用户空间,以及 skel_free(),它释放该空间并取消映射它(在检查 DMA 传输是否正在运行之后)。
我们可以通过上面 nasty 程序使用的相同方法,在我们随设备驱动程序一起发布的用户库中实现重新映射。但是,出于充分的理由,/dev/mem 只能由 root 读取和写入,并且访问设备驱动程序的程序应该也能够作为普通用户运行。
我们的驱动程序中使用了两个技巧。首先,我们修改 mem_map 数组,告知 Linux 关于我们的物理内存页面的使用情况和权限。mem_map 是 mem_map_t 结构的一个数组,用于保存有关所有物理内存的信息。
对于所有已分配的页面,我们都设置 reserved 标志。这是一种快速而dirty 的方法,但它在所有 Linux 版本(至少从 1.2.x 开始)下都达到了其目标:Linux 不会干涉我们的页面!它认为它们就像视频缓冲区、ROM 或其他任何它无法交换或释放到空闲内存中的东西。mem_map 数组本身也使用此技巧来保护自身免受渴望内存的进程的影响。
我们使用的第二个技巧是快速生成一个伪文件,该文件看起来有点像打开的 /dev/mem。我们重建了 /dev/mem 驱动程序中的 mmap_mem() 调用,特别是因为它没有在内核符号表中导出,并且只是将相同的小调用应用于 remap_page_range()。
此外,由我们的 skel_malloc() 调用分配的 DMA 缓冲区在列表中注册,以便检查 DMA 传输请求是否转到有效的内存区域。这些列表也用于在程序关闭设备而没有预先调用 skel_free() 时释放已分配的缓冲区。dma_desc 是以下行中这些列表的类型,这些行显示了用于 ioctl 包装的 skel_malloc() 和 skel_free() 的代码
/* ============================================= * * SKEL_MALLOC * * The user desires a(nother) dma-buffer, that * is allocated by kmalloc (GFP_DMA) (continuous * and in lower 16 MB). * The allocated buffer is mapped into * user-space by * a) a pseudo-file as you would get it when * opening /dev/mem * b) the buffer-pages tagged as "reserved" * in memmap * c) calling the normal entry point for * mmap-calls "do_mmap" with our pseudo-file * * 0 or <0 means an error occurred, otherwise * the user space address is returned. * This is the main basis of the Skel_Malloc * Library-Call */ * ------------------------------ * Ma's little helper replaces the mmap * file_operation for /dev/mem which is declared * static in Linux and has to be rebuilt by us. * But ain't that much work; we better drop more * comments before they exceed the code in length. */ static int skel_mmap_mem (struct inode * inode, struct file * file, struct vm_area_struct *vma) { if (remap_page_range(vma->vm_start, vma->vm_offset, vma->vm_end - vma->vm_start, vma->vm_page_prot)) return -EAGAIN; vma->vm_inode = NULL; return 0; } static unsigned long skel_malloc (struct file *file, unsigned long size) { unsigned long pAdr, uAdr; dma_desc *dpi; skel_file_info *fip; struct file_operations fops; struct file memfile; /* Our helpful pseudo-file only ... */ fops.mmap = skel_mmap_mem; /* ... support mmap */ memfile.f_op = &fops; /* and is read'n write */ memfile.f_mode = 0x3; fip = (skel_file_info*)(file->private_data); if (!fip) return 0; dpi = kmalloc (sizeof(dma_desc), GFP_KERNEL); if (!dpi) return 0; PDEBUG ("skel: Size requested: %ld\n", size); if (size <= PAGE_SIZE/2) size = PAGE_SIZE-0x10; if (size > 0x1FFF0) return 0; pAdr = (unsigned long) kmalloc (size, GFP_DMA | GFP_BUFFER); if (!pAdr) { printk ("skel: Trying to get %ld bytes" "buffer failed - no mem\n", size); kfree_s (dpi, sizeof (dma_desc)); return 0; } for (uAdr = pAdr & PAGE_MASK; uAdr < pAdr+size; uAdr += PAGE_SIZE) #if LINUX_VERSION_CODE < 0x01031D /* before 1.3.29 */ mem_map [MAP_NR (uAdr)].reserved |= MAP_PAGE_RESERVED; #elseif LINUX_VERSION_CODE < 0x01033A /* before 1.3.58 */ mem_map [MAP_NR (uAdr)].reserved = 1; #else /* most recent versions */ mem_map_reserve (MAP_NR (uAdr)); #endif uAdr = do_mmap (&memfile, 0, (size + ~PAGE_MASK) & PAGE_MASK, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_SHARED, pAdr & PAGE_MASK); if ((signed long) uAdr <= 0) { printk ("skel: A pity - " "do_mmap returned %lX\n", uAdr); kfree_s (dpi, sizeof (dma_desc)); kfree_s ((void*)pAdr, size); return uAdr; } PDEBUG ("skel: Mapped physical %lX to %lX\n", pAdr, uAdr); uAdr |= pAdr & ~PAGE_MASK; dpi->dma_adr = pAdr; dpi->user_adr = uAdr; dpi->dma_size= size; dpi->next = fip->dmabuf_info; fip->dmabuf_info = dpi; return uAdr; } /* ============================================= * * SKEL_FREE * * Releases memory previously allocated by * skel_malloc */ static int skel_free (struct file *file, unsigned long ptr) { dma_desc *dpi, **dpil; skel_file_info *fip; fip = (skel_file_info*)(file->private_data); if (!fip) return 0; dpil = &(fip-).>dmabuf_info); for (dpi = fip->dmabuf_info; dpi; dpi=dpi->next) { if (dpi->user_adr==ptr) break; dpil = &(dpi->next); } if (!dpi) return -EINVAL; PDEBUG ("skel: Releasing %lX bytes at %lX\n", dpi->dma_size, dpi->dma_adr); do_munmap (ptr & PAGE_MASK, (dpi->dma_size+(~PAGE_MASK)) & PAGE_MASK); ptr = dpi->dma_adr; do { #if LINUX_VERSION_CODE < 0x01031D /* before 1.3.29 */ mem_map [MAP_NR(ptr)] &= ~MAP_PAGE_RESERVED; #elseif LINUX_VERSION_CODE < 0x01033A /* before 1.3.58 */ mem_map [MAP_NR(ptr)].reserved = 0; #else mem_map_unreserve (MAP_NR (ptr)); #endif ptr += PAGE_SIZE; } while (ptr < dpi->dma_adr+dpi->dma_size); *dpil = dpi->next; kfree_s ((void*)dpi->dma_adr, dpi->dma_size); kfree_s (dpi, sizeof (dma_desc)); return 0; }
技术在发展,但思想往往保持不变。在旧的 ISA 世界中,外围设备将其缓冲区定位在“地址空间的非常高端”——高于 640 KB。现在许多 PCI 卡也这样做,但如今,这更像是 32 位地址空间的末尾(如 0xF0100000)。
如果你想访问这些地址的缓冲区,你必须使用 linux/mm.h 中定义的 vremap() 将此物理内存的相同页面重新映射到你自己的虚拟地址空间。
vremap() 的工作方式有点像 nasty 中的 mmap() 用户调用,但它更容易得多
void * vremap (unsigned long offset, unsigned long size);
你只需传递缓冲区的起始地址及其长度。请记住,我们始终映射页面;因此 offset 和 size 必须与页面长度对齐。如果你的缓冲区较小或未在页面边界上启动,请映射整个页面并尽量避免访问无效地址。
我个人没有尝试过这个,我不确定我上面描述的关于如何将缓冲区映射到用户空间的技术是否适用于 PCI 高内存缓冲区。如果你想尝试一下,你肯定必须删除对 mem_map 数组的“暴力”操作,因为 mem_map仅 用于物理 RAM。尝试用类似的 vremap() 调用替换 kmalloc() 和 kfree() 的东西,然后使用 do_mmap() 执行到用户空间的第二次重新映射。
但你可能已经意识到,我们已经到了本系列的结尾,现在轮到你大胆地前往 Linuxer 从未去过的地方了...
祝你好运!
George V. Zezschwitz 是一位 27 岁的 Linuxer,他喜欢深夜黑客行为,讨厌截止日期。