从module_init看内核模块
开篇
module_init是linux内核提供的一个宏, 可以用来在编写内核模块时注册一个初始化函数, 当模块被加载的时候, 内核负责执行这个初始化函数. 在编写设备驱动程序时, 使用这个宏看起来理所应当, 没什么特别的, 但毕竟我还是一个有点追求的程序员嘛:P, 这篇文章是我学习module_init相关源码的一个记录, 主要就回答了下面的3个问题, 篇幅略长, 做好准备.
Q1:
内核模块是什么?
Q2:
内核模块是怎么被加载的?
Q3:
内核怎么获取到module_init注册的初始化函数?
注: 以下回答是个人学习总结, 仅供参考.
A1:
编译好内核模块的代码, 会得到一个".ko"文件, 这个就是内核模块了. 实际上, ".ko"就是一个普通的ELF文件, 只不过可以使用insmod让内核去动态加载它. 查阅ELF格式标准可知, 主要有三种类型的ELF文件, 包括:
- relocatable file
- excutable file
- shared object file
以上三种类型的ELF, 基本上可以简单对应编译得到的".o", "a.out", ".so". 这里讨论的".ko"模块文件, 属于relocatable file类型, 可以在系统里找一个内核模块文件验证一下.
junan@ZEN2:/lib/modules/5.19.0-50-generic/kernel/drivers/char$ ls
agp applicom.ko hangcheck-timer.ko hw_random ipmi lp.ko mwave nvram.ko pcmcia ppdev.ko tlclk.ko tpm uv_mmtimer.ko xillybus
junan@ZEN2:/lib/modules/5.19.0-50-generic/kernel/drivers/char$ file lp.ko
lp.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=98c89bd841e31b1140e61559c0bf312eb5128f5c, not stripped
使用gcc编译一个c文件, 可以得到对应的.o文件, 前面说过.o文件属于relocatable类型的ELF, 如果有多个.o文件, 可以使用链接器把它们"合并"成一个.o, 就像这样:
junan@ZEN2:~$ ls
a.c a.o b.c b.o Desktop Documents Downloads Music Pictures Public snap Templates Videos
junan@ZEN2:~$ file a.o b.o
a.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
b.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
junan@ZEN2:~$ ld -r a.o b.o -o c.o
junan@ZEN2:~$ file c.o
c.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
经过ld链接之后, 得到了"c.o", 这个"c.o"相当于合并了"a.o"和"b.o", 这可以从它们各自包含的符号中看出来:
junan@ZEN2:~$ nm a.o b.o c.o
a.o:
U add
0000000000000000 T call_add
b.o:
0000000000000000 T add
c.o:
000000000000001a T add
0000000000000000 T call_add
同样的道理, 一个设备驱动, 可能包含多个源文件, 但是编译之后最终可以合并成一个".ko"文件. 总结下来, ".ko"也是一种普通的ELF文件, 可以被内核动态加载和卸载.
A2:
一个内核模块, 除了自己实现一些功能外, 通常还要引用其他人提供的api, 包括:
- 内核本身提供的api
- 其他模块提供的api
内核仅仅把".ko"文件读到自己的地址空间中, 是远远不够的, 他要像链接器一样, 帮我们的内核模块正确地处理这些符号引用关系, 并且调用我们使用module_init注册的模块初始化函数. 下面分析一下这部分的内核源码. 我们的".ko"文件是使用insmod命令才加载进内核的, insmod命令实际上是一个符号链接. 和insmod一样, rmmod也指向/bin/kmod, 当kmod被执行的时候, 可以通过args[0]区分出是执行insmod还是rmmod, 或者其他的功能.
junan@ZEN2:~$ which insmod | xargs ls -l
lrwxrwxrwx 1 root root 9 7月 22 23:20 /usr/sbin/insmod -> /bin/kmod
在内核代码中, 专门为模块的加载和卸载提供了两个系统调用, 准确说是三个, 其中两个用于加载模块, 一个用于卸载模块. linux代码中使用SYSCALL_DEFINEx这个宏定义一个系统调用的入口, 其中x代表系统调用的参数个数, 看一下内核代码就可以找到和模块的加载以及卸载相关的syscall函数, 使用正则表达式或者其他工具能很快在内核代码中找到这三个系统调用的定义.
首先是init_module和finit_module:
SYSCALL_DEFINE3(init_module, void __user *, umod,
unsigned long, len, const char __user *, uargs)
{
// ...
return load_module(&info, uargs, 0);
}
SYSCALL_DEFINE3(finit_module, int, fd, const char __user *, uargs, int, flags)
{
// ...
return load_module(&info, uargs, flags);
}
以上两个syscall负责模块的加载, 最终都调用了load_module去真正加载模块.
然后是delete_module:
SYSCALL_DEFINE2(delete_module, const char __user *, name_user,
unsigned int, flags)
{
// ...
}
这三个syscall定义在"kernel/module.c"文件中, 我使用的内核版本是5.4.250, 下面可以写一个什么都不做的内核模块, 通过gdb调试的方法, 看一下这几个syscall是怎么被调用的. 环境的准备包括:
- qemu: 启动编译好的内核镜像, 以及gdb server, 等待gdb的连接
- rootfs: 内核正常启动, 需要一个根文件系统, 使用busybox制作
- kernel Image: 编译好的内核镜像
- ko文件: 编译好的没有实际功能的内核模块
关于怎么建立内核的调试环境, 会在其他文章中说明, 这里仅通过调试内核的方法, 记录一下以上三个syscall的调用过程. 当你还不知道insmod和rmmod需要使用到这三个系统调用, 怎么样才能知道这两个命令依赖什么系统调用呢? 答案是可以使用strace去定位一个进程运行过程中用到了哪些syscall, 比如, 我的系统里有一个名字叫做lp的ko模块:
junan@ZEN:~$ lsmod | grep lp
lp 28672 0
drm_display_helper 184320 1 i915
cec 81920 2 drm_display_helper,i915
drm_kms_helper 200704 2 drm_display_helper,i915
先把这个模块卸载, 看看使用了什么syscall:
junan@ZEN:~$ sudo strace rmmod lp
...
...
close(3) = 0
openat(AT_FDCWD, "/sys/module/lp/refcnt", O_RDONLY|O_CLOEXEC) = 3
read(3, "0\n", 31) = 2
read(3, "", 29) = 0
close(3) = 0
delete_module("lp", O_NONBLOCK) = 0
exit_group(0) = ?
+++ exited with 0 +++
能看到倒数第3行, 调用了delete_module.
再重新加载这个lp模块:
junan@ZEN:/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char$ sudo strace insmod lp.ko
...
...
getcwd("/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char", 4096) = 55
newfstatat(AT_FDCWD, "/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char/lp.ko", {st_mode=S_IFREG|0644, st_size=72553, ...}, 0) = 0
openat(AT_FDCWD, "/usr/lib/modules/5.19.0-50-generic/kernel/drivers/char/lp.ko", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1", 6) = 6
lseek(3, 0, SEEK_SET) = 0
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=72553, ...}, AT_EMPTY_PATH) = 0
mmap(NULL, 72553, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f4675dac000
finit_module(3, "", 0) = 0
munmap(0x7f4675dac000, 72553) = 0
close(3) = 0
exit_group(0) = ?
+++ exited with 0 +++
倒数第5行, finit_module被调用, 用来加载lp模块. 使用这种方式, 先分析一下执行过程, 能够帮助你大致确定它是怎么实现的. 接下来开始调试内核, 看一下syscall的调用过程:
以下是对视频中调试过程的详细记录:
使用qemu加载了一个编译好的内核镜像.
- 使用arm平台
- 挂载本机目录到qemu虚拟机, 目录下包含一个等待测试的ko模块
- 开启调试选项, qemu启动之后等待外部gdb的连接
启动vscode远程调试的配置, 开始调试内核代码
- 先关闭所有断点, 加载和卸载testko模块, 得到正确的输出
- 之后开启断点, 分别break在init_module和finit_module两个syscall上
重新插入testko模块
- init_module系统调用上的断点命中
- 调用load_module函数
- 加载模块到内核
- 完成链接, 处理符号的引用关系
- 调用testko注册的初始化函数, 得到初始化函数的输出
所以, 内核模块在加载时, 需要使用init_module/finit_module系统调用, 经syscall进入内核之后, 内核会把我们的模块加载到自己的地址空间中, 然后完成原本链接器需要做的工作, 这时, 模块中引用的其他符号, 已经得到了真实的地址, 在模块加载的最后阶段, 内核调用do_one_initcall去调用我们注册的模块初始化函数. 以上就是内核模块加载的基本过程, 模块卸载的过程类似.
A3:
先找到module_init宏的实现代码, 在include/linux/module.h文件中, 能找到这个宏的定义:
#ifndef MODULE
/**
* module_init() - driver initialization entry point
* @x: function to be run at kernel boot time or module insertion
*
* module_init() will either be called during do_initcalls() (if
* builtin) or at module insertion time (if a module). There can only
* be one per module.
*/
#define module_init(x) __initcall(x);
/**
* module_exit() - driver exit entry point
* @x: function to be run when driver is removed
*
* module_exit() will wrap the driver clean-up code
* with cleanup_module() when used with rmmod when
* the driver is a module. If the driver is statically
* compiled into the kernel, module_exit() has no effect.
* There can only be one per module.
*/
#define module_exit(x) __exitcall(x);
#else /* MODULE */
/*
* In most cases loadable modules do not need custom
* initcall levels. There are still some valid cases where
* a driver may be needed early if built in, and does not
* matter when built as a loadable module. Like bus
* snooping debug drivers.
*/
#define early_initcall(fn) module_init(fn)
#define core_initcall(fn) module_init(fn)
#define core_initcall_sync(fn) module_init(fn)
#define postcore_initcall(fn) module_init(fn)
#define postcore_initcall_sync(fn) module_init(fn)
#define arch_initcall(fn) module_init(fn)
#define subsys_initcall(fn) module_init(fn)
#define subsys_initcall_sync(fn) module_init(fn)
#define fs_initcall(fn) module_init(fn)
#define fs_initcall_sync(fn) module_init(fn)
#define rootfs_initcall(fn) module_init(fn)
#define device_initcall(fn) module_init(fn)
#define device_initcall_sync(fn) module_init(fn)
#define late_initcall(fn) module_init(fn)
#define late_initcall_sync(fn) module_init(fn)
#define console_initcall(fn) module_init(fn)
/* Each module must use one module_init(). */
#define module_init(initfn) \
static inline initcall_t __maybe_unused __inittest(void) \
{ return initfn; } \
int init_module(void) __copy(initfn) __attribute__((alias(#initfn)));
/* This is only required if you want to be unloadable. */
#define module_exit(exitfn) \
static inline exitcall_t __maybe_unused __exittest(void) \
{ return exitfn; } \
void cleanup_module(void) __copy(exitfn) __attribute__((alias(#exitfn)));
#endif
可以看到根据是否定义了MODULE, module_init有两个不同的实现, 对于驱动程序的内核模块来说, 走到的是#else分支的定义, 这一点能够从编译ko时详细的编译命令中确认:
junan@ZEN:~/Documents/github/blogcodes/01-testko$ ARCH=$ARCH CROSS_COMPILE=$CROSS_COMPILE KDIR=$KDIR make -nB V=1
...
arm-linux-gnueabihf-gcc -Wp,-MD,/home/junan/Documents/github/blogcodes/01-testko/.testko.o.d -nostdinc -isystem /usr/lib/gcc-cross/arm-linux-gnueabihf/11/include -I../arch/arm/include -I./arch/arm/include/generated -I../include -I./include -I../arch/arm/include/uapi -I./arch/arm/include/generated/uapi -I../include/uapi -I./include/generated/uapi -include ../include/linux/kconfig.h -include ../include/linux/compiler_types.h -D__KERNEL__ -mlittle-endian -Wall -Wundef -Werror=strict-prototypes -Wno-trigraphs -fno-strict-aliasing -fno-common -fshort-wchar -fno-PIE -Werror=implicit-function-declaration -Werror=implicit-int -Werror=return-type -Wno-format-security -std=gnu89 -fno-dwarf2-cfi-asm -fno-ipa-sra -mabi=aapcs-linux -mfpu=vfp -funwind-tables -marm -Wa,-mno-warn-deprecated -D__LINUX_ARM_ARCH__=7 -march=armv7-a -msoft-float -Uarm -fno-delete-null-pointer-checks -Wno-frame-address -Wno-format-truncation -Wno-format-overflow -Wno-address-of-packed-member -O2 -fno-allow-store-data-races -Wframe-larger-than=1024 -fstack-protector-strong -Wimplicit-fallthrough -Wno-unused-but-set-variable -Wno-unused-const-variable -fomit-frame-pointer -fno-var-tracking-assignments -g -gdwarf-4 -Wdeclaration-after-statement -Wvla -Wno-pointer-sign -Wno-stringop-truncation -Wno-zero-length-bounds -Wno-array-bounds -Wno-stringop-overflow -Wno-restrict -Wno-maybe-uninitialized -fno-strict-overflow -fno-merge-all-constants -fmerge-constants -fno-stack-check -fconserve-stack -Werror=date-time -Werror=incompatible-pointer-types -Werror=designated-init -fmacro-prefix-map=../= -Wno-packed-not-aligned -DMODULE -DKBUILD_BASENAME='\''"testko"'\'' -DKBUILD_MODNAME='\''"testko"'\'' -c -o /home/junan/Documents/github/blogcodes/01-testko/testko.o /home/junan/Documents/github/blogcodes/01-testko/testko.c
...
从上面截取的编译命令中, 应该能找到"-DMODULE", 虽然有点多, 但仔细看还是能找到的哈, 实在没看到可以Ctrl+F搜索一下. gcc的-D参数相当于帮你在代码里#define了一个宏, 所以, 在包含module.h头文件时, module_init确实会走到#else那个分支, 在这个分支里, module_init定义了一个static inline的函数, 并且, 声明了一个名字叫做init_module的函数, 千万不要把这个函数名和之前视频里调试的syscall名字搞混了:
int init_module(void) __copy(initfn) __attribute__((alias(#initfn)));
注意, 这个函数带了一个alias属性(详细介绍, 参考官方文档), 这样init_module函数就变成了我们传递进来的initfn函数的别名, 也就是说如果调用init_module, 实际上会调用initfn. 接下来的问题是, 加载模块的时候, 内核如何能够得到模块初始化函数的地址呢? 你如果自己编译一个内核模块代码, 会发现编译完成之后, 除了".ko"还会生成很多其他东西, 比如: xxx.mod.c, 内核的构建系统给你生成了一个c文件, 看一下里面的内容:
junan@ZEN:~/Documents/github/blogcodes/01-testko$ cat testko.mod.c
#include <linux/build-salt.h>
#include <linux/module.h>
#include <linux/vermagic.h>
#include <linux/compiler.h>
BUILD_SALT;
MODULE_INFO(vermagic, VERMAGIC_STRING);
MODULE_INFO(name, KBUILD_MODNAME);
__visible struct module __this_module
__section(.gnu.linkonce.this_module) = {
.name = KBUILD_MODNAME,
.init = init_module,
#ifdef CONFIG_MODULE_UNLOAD
.exit = cleanup_module,
#endif
.arch = MODULE_ARCH_INIT,
};
#ifdef CONFIG_RETPOLINE
MODULE_INFO(retpoline, "Y");
#endif
MODULE_INFO(depends, "");
这个文件中定义了一个struct module类型的变量__this_module, 并且.init成员已经被填上了模块初始化函数的并名"init_module", .exit成员也是这样. 这样当内核把模块载入自己的地址空间, 完成链接器的工作之后, 这个.init字段就指向真实的模块初始化函数地址了, 之前的调试视频里可以看到有这样的判断:
...
if(mod->init != NULL)
ret = do_one_initcall(mod->init);
...
这里的"mod"是一个struct module*, 它实际上就是xxx.mod.c中的__this_module, 看一下代码:
static struct module *layout_and_allocate(struct load_info *info, int flags)
{
struct module *mod;
unsigned int ndx;
int err;
...
/* Determine total sizes, and put offsets in sh_entsize. For now
this is done generically; there doesn't appear to be any
special cases for the architectures. */
layout_sections(info->mod, info);
layout_symtab(info->mod, info);
/* Allocate and move to the final place */
err = move_module(info->mod, info);
if (err)
return ERR_PTR(err);
/* Module has been copied to its final place now: return it. */
mod = (void *)info->sechdrs[info->index.mod].sh_addr;
kmemleak_load_module(mod, info);
return mod;
}
总结下来, 关于内核怎么获取到我们注册的初始化函数这个问题:
- 实现模块初始化函数
- 使用module_init宏传递上述初始化函数
- 这个宏会声明一个名字叫做"init_module"的函数, 这个函数是真实的模块初始化函数的别名
- 编译模块时, 内核的构建系统生成xxx.mod.c文件, 在这个文件里
- 定义一个struct module类型的变量, __this_module
- 用"init_module"填充.init成员
- 顺便说一下, THIS_MODULE宏实际上就会展开成__this_module
- 内核载入模块
- 做一些链接器的工作(这个地方实际上有点复杂, 之后有需要可以仔细研究, 暂时就理解为内核帮你把引用的一些符号的地址都算好了, 并在引用的地方填上正确的值)
- 获取到__this_module的地址, 判断.init成员是否为空, 不为空就调用模块初始化函数
注: 当module_init宏的定义走到第一个分支时, 之后再写一篇讨论一下.
结尾
这篇文章主要讨论了3个问题:
- 内核模块是什么?
- 模块是怎么被内核加载的?
- 内核是怎么找到模块初始化函数的?
读完之后, 你应该对这3个问题有了自己的体会. 除此之外, 你还应该解到一些的技术, 它们能够帮你找到一些问题的答案:
- strace跟踪系统调用
- 使用qemu+gdb调试内核源码
- gcc的一些扩展语法, 比如给函数或者变量加属性
第1点, 比较简单, 就是一个命令的使用问题, 但是背后的原理应该不简单;
第2点, 是一个环境搭建的问题, 有时间可以发个视频详细介绍一下;
第3点, 读源码的过程中碰到了, 不懂的话查一下官方手册, 写一点代码验证一下, 就知道怎么用了. 实际上, 除了attribute, 还有很多方式能够告诉编译器, 你要做什么.
建了个QQ群: 838923389. 有想法的老铁可以加一下, 一起交流linux内核的使用和学习经验. 后续也会在b站发一些技术视频, 老铁们觉得有需要可以先关注一下, 视频和文章肯定会给各位带来一些启发和帮助.
从module_init看内核模块的更多相关文章
- (转)内核模块操作命令-lsmod+rmmod+modinfo+modprobe
原文:http://watchmen.xin/2018/07/13/IT%E7%A7%91%E5%AD%A6%E6%8A%80%E6%9C%AF%E7%9F%A5%E8%AF%86%E4%BD%93% ...
- Linux USB驱动框架分析(2)【转】
转自:http://blog.chinaunix.net/uid-23046336-id-3243543.html 看了http://blog.chinaunix.net/uid-11848011 ...
- [SPDK/NVMe存储技术分析]011 - 内核态ib_post_send()源码剖析
OFA定义了一组标准的Verbs,并在用户态提供了一个标准库libibverbs.例如将一个工作请求(WR)放置到发送队列的Verb API是ibv_post_send(), 但是在Linux内核,对 ...
- linux内核链表分析
一.常用的链表和内核链表的区别 1.1 常规链表结构 通常链表数据结构至少应包含两个域:数据域和指针域,数据域用于存储数据,指针域用于建立与下一个节点的联系.按照指针域的组织以及各个节 ...
- linux内核源码阅读之facebook硬盘加速利器flashcache
从来没有写过源码阅读,这种感觉越来越强烈,虽然劣于文笔,但还是下定决心认真写一回. 源代码下载请参见上一篇flashcache之我见 http://blog.csdn.net/liumangxiong ...
- module_init的加载和释放
转自:http://blog.csdn.net/dysh1985/article/details/7597105 像你写C程序需要包含C库的头文件那样,Linux内核编程也需要包含Kernel头文件, ...
- linux驱动的入口函数module_init的加载和释放【转】
本文转载自:http://blog.csdn.net/zhandoushi1982/article/details/4927579 就像你写C程序需要包含C库的头文件那样,Linux内核编程也需要包含 ...
- Smart210学习记录-------linux内核模块
Linux 驱动工程师需要牢固地掌握 Linux 内核的编译方法以为嵌入式系统构建可运行的Linux 操作系统映像.在编译 LDD6410 的内核时,需要配置内核,可以使用下面命令中的 一个: #ma ...
- Linux 内核模块程序结构
1.内核加载函数 即我们常说的内核入口函数,当内核被加载的时候调用,在内核入口函数中多进行设备的注册和初始化,其中最常用的莫过于module_init().insmod xxx.ko的时候调用. 通常 ...
- firefly rk3288 内核模块编译
在驱动开发的过程中,常常需要对代码进行返回的调试,如果返回的编译再烧写内核,势必会浪费开发人员大量的时间和心力,加班加点那是时常的事.为此linux提供了编译内核模块的方式,无需返回烧写内核,只需in ...
随机推荐
- 【Vue2】NavigationDuplicated: Avoided redundant navigation to current location:xxxxx
翻译过来就是,导航重复:避免了到当前位置的冗余导航. 简单来说就是重复跳转了相同路径 原因 触发这种情况是因为vue-router中引入了primise,当传递了多次重复的参数就会抛出异常,而这种问题 ...
- 2022-04-25:给定一个整数数组,返回所有数对之间的第 k 个最小距离。一对 (A, B) 的距离被定义为 A 和 B 之间的绝对差值。 输入: nums = [1,3,1] k = 1 输出:
2022-04-25:给定一个整数数组,返回所有数对之间的第 k 个最小距离.一对 (A, B) 的距离被定义为 A 和 B 之间的绝对差值. 输入: nums = [1,3,1] k = 1 输出: ...
- 2022-03-04:爱吃香蕉的珂珂。 珂珂喜欢吃香蕉。这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 H 小时后回来。 珂珂可以决定她吃香蕉的速度 K (单位:根
2022-03-04:爱吃香蕉的珂珂. 珂珂喜欢吃香蕉.这里有 N 堆香蕉,第 i 堆中有 piles[i] 根香蕉.警卫已经离开了,将在 H 小时后回来. 珂珂可以决定她吃香蕉的速度 K (单位:根 ...
- 一些JS过滤方法
一般过滤器我们都会卸载过滤filter文件内 本文这里就直接写正常methods格式的 //过滤空格 filterSpaces(data) { return data.replace(/\s+/g, ...
- Row size too large. The maximum row size for the used table type, not counting BLOBs, is 65535.
问题描述 新建表或者修改表varchar字段长度的时候,出现这个错误 Row size too large. The maximum row size for the used table type, ...
- 去掉DosBox烦人的Status Windows
首先我们上成品动态图 很干净,很清爽有没有! 步骤 1.找到并选中DosBox快捷方式,鼠标右键点击选择属性 2.修改目标后面的参数,默认是 -userconf ,再添加一个 -noconsole 就 ...
- 代码随想录算法训练营Day42 动态规划
代码随想录算法训练营 代码随想录算法训练营Day40 动态规划|01背包问题,你该了解这些! 01背包问题,你该了解这些!滚动数组 416. 分割等和子集 01背包问题,你该了解这些! 完全背包又是也 ...
- ChatGPT玩法(三):AI玩转PPT
前言 在线免费体验ChatGpt:https://www.topgpt.one 作为许多职场人士的必备工具,PPT制作一直是一个琐碎而费时的任务.但最近我发现了一个非常有用的工具网站,它可以通过人工智 ...
- CAPL 脚本对信号收发的判断
在CAPL脚本中,您可以使用条件语句和CAN消息的收发函数来进行信号的判断和处理.以下是一些常见的CAPL脚本语句用于信号收发的判断: 1.判断消息是否收到 on message can_messag ...
- 曲线艺术编程 coding curves 第十一章 玫瑰花形( ROSES)
第十一章 玫瑰花形 ROSES 原作:Keith Peters https://www.bit-101.com/blog/2022/11/coding-curves/ 译者:池中物王二狗(sheldo ...