4.C 标准库的实现
背景回顾:我们在先前的课程中讲解了如何在一套十分精简的机制 (硬件、系统调用) 上构建起整个应用程序世界。为了让这个世界更丰富多彩,为开发者提供便利就是至关重要的。使 UNIX 世界蓬勃发展离不开 C 语言和它的标准库,长久以来都为系统编程树立了一套标杆。
系统调用上面的东西:C 标准库和实现。
操作系统是对象+api,在此基础上就可以实现任何程序。原则上是这样,但是太麻烦了,如果要实现一个大一点的程序,还是要做封装的。在系统调用上面封装的是 libc。
1. libc 简介
之前从最小的世界开始构建,从 minimal.s 到真实的操作系统。我们有个最小的可以启动程序的 linux,不知道知道这个 linux 内核怎么编译、怎么把目录打包的,只要知道有命令可以做到就行。我们用这个内核加载第一个程序,如果这个程序退出了,那么就 kernel panic了。在第一个进程的基础上,通过系统调用,启动整个计算机系统。
理论上说,可以用操作系统的 API 去做任何事情,构造我们看到的世界,但是这并不够美好。安装程序、改变配置,直接用系统调用是不友好的,因此有了 shell,把指令翻译成系统调用。
有了 shell、系统调用的世界,我们下面要解决一个更重要的问题,构建操作系统的生态。热议的问题:为什么不搞中国的操作系统?很大的原因是现有的操作系统有了很大的生态,生么是生态?生态就是大家所有人都遵循共同的规则,在共同的规则下办事,大家都觉得规则不错,大家都遵守,然后生态就越来越大。
unix 能够成功、生态能成功,是离不开 C 语言的。同期的语言,既能榨干性能,又能写出有趣的程序的语言就只有 C 。C 能成功,除了语法在当时比较先进(预编译),还有标准库,即 libc。
c 语言简介
从大一上完 C 语言课,到写一个窗口,中间还是有很多的路要走。事实上,通过一些列库函数的帮忙下,我们才能做到。
The C Standard Library
上大学的一个程序里就用到了 stdio.h
里的 printf
,这就是标准库。我们学了这要引入,然后这样写,就能输出字符串。
事实上还有更多的问题,比如这个 .h 文件在哪里?如果做一个全盘的查找可能也能找到,是否可以用自己的 stdio.h
呢?随着学习的深入,也会思考更深层次的问题。
为什么需要 libc ?libc 是世界上“最通用”的高级语言库函数/
C 是一种“高级汇编语言”,人肉汇编 C 语言并不困难,此外 C 语言也有极致的性能。C 对硬件的兼容性非常好,因为有许多 undefined behavior(如带符号整数溢出),没有规定这件事发生后状态机变成什么样,这使得可以把 C 代码可以无缝的翻译到各种体系结构的硬件上,这就使得实现一个自己的 CPU,做一个自己的符合标准的 编译器,就可以使用任何现有的 C 程序,这就把底层的体系结构给抽象起来了,在此基础上可以实现更高层的东西如 Java 的虚拟机。
C 是一种高级汇编语言,作为对比,C++ 更好用,但也更难移植。C 屏蔽了底层体系结构细节。
C 语言标准库成功的另外一个因素,C 语言的标准库是 C 标准(ISO IEC)的一部分。即如果声称符合 C 语言标准,就必须同时提供标准库。
此外在 C 的标准库外还有个叫 POSIX C Library,比 C 标准库多一点,多了 POSIX 系统调用的封装。
所以就是因为有这个标准,标准嘛,发出来就这样定了,也不管合不合理。(有些标准可能不合理,但也没法改了),旧的标准是不破的。所以只要写的代码符合 C99 标准,上到超级计算机,下到 1KB RAM 的微控制器,全部都能工作,这就是 C 语言。
_start
一个程序里有 _start
,如果没有这个,连接器会给一个 warning,这在 minimal 里也见过。需要一个整个程序的入口,freestanding 的程序,需要在连接的时候指定,否则会链接很多东西进来,各种 so 和 .o,也会给出链接错误。会多重定义,也就是说,gcc 默认的 libc 会提供一个 _start ,。如果试图自己实现一个 libc,
学习更简单的 libc
调试 glibc?,glibc 的代码有非常沉重的历史包袱,非常多的优化,这些是理解工作原理的阻碍。以及非常非常复杂,根本不是人读的,里面充斥着 两个下划线的变量,新手读起来体验可以说是相当差了。
但也总有办法的,比如
可以让 AI Copilot 帮忙解释代码
寻找更好的代替品,在原理上一致但是更轻量
C 是高级汇编,那 C 一定可以在内存非常小的机器上运行,那应该有专门为嵌入式设备实现的简化 libc
uclibc(ucos),newlib,bionic
今天的选择 musl
学习一个原理上一样的,但是比较简单的,比较小的来学习。可能没 glic 这么性能好,不支持多处理器,一把大锁保平安,但是一定有,有就可以拿来学。很多操作系统爱好者自己做的小操作系统移植的都是 musl,更全面,代码写的更好,足够简单,容易理解。
可以从自己最熟悉的开始看起是如何实现的,比如 printf 的代码。
编译这些代码的时候,用的不是电脑上的 gcc,使用的是源码里的 musl gcc,
这一看像人话。这里的变参数的处理,提供了一种思路。
看代码不是问题,下载下来也很容易,难的是怎么用下载的 libc 编译,如何用这套代码,打印自己的东西。怎么把自己的程序链接这个 libc ,然后调试。
问 chatgpt:我需要用musl库来代替原来的libc我该如何实现?
安装 musl 工具链
下载和编译 musl
使用 musl-gcc 编译,
这就完成了第一步,搭好了学习 libc 的环境。这时候我们就可以对我们感兴趣的东西,写个程序,然后去调试了。
可以写一个最简单的程序,开始调试,研究 crt 做了什么事情。重走一个程序从被操作系统加载到最后结束经历了什么。
2. 基础编程机制的抽象
libc 做的最主要的事情还是封装,最主要的还是计算,比计算更基础的还有一些体系结构无关的抽象。
不借助任何库函数编程是可以的,但是需要我们把常见功能做一层封装,比如说内存的分配和回收,需要我们自己去实现,再比如求字符串的长度。这些任何程序都用到到的代码(轮子),没有必要每次都重新造,不如写成一个库。
现在我们有的,系统调用和 C 语言,如何一层一层把我们的易用的编程世界构建起来?
首先要扩充一些标准类型里和机器相关的定义,如 int 类型,
sizeof(int)
是和编译器相关的,这是移植性的一个很大障碍。我们希望一套程序可以在32或64bit机器上运行。因此需要对类型的字长可控。可移植的做法是
uint8_t
这样的类型,定义在stdint.h
内,这个头文件会解决移植性的问题还有个例子
intptr_t
。问gpt:打印 intprt_t 的 printf 的格式是。给的是%PRIdPTR
,很小众的答案,但是查手册确实有。 在这种事实性的、文档里反复出现的东西,GPT 几乎不会犯错。
limits.h
里还有最大值的宏定义syscall 还用到了 stdarg.h 参数列表。printf 的声明
int printf(const char *format, ...);
这是个变参数特性32 位机器实现一个变参数特性,可以
void ** p = (void**)&format
,函数调用在栈上保存,那么p[0]
就是第一个参数,p[1]
是第二个参数,在栈上保存传递的参数的话,那么是可以这么做的,32bit x86可以这么做但是64为,或者 arm 没法这么做,因为这些架构使用寄存器传递参数
libc 还有一些好玩的事情,可以用
int64_t x=1
定义整数,printf("%ld",x);
long 是 4byte,这就不太对了,C语言也应该提供机制使得用可移植的方式实现。
标准的设计者会考虑到这些问题,然后给出相应的实现。
这些是在最底下,printf 之前,C 语言,对机器做了一层抽象。C 要做的最重要的抽象就是数据的抽象,不能再向汇编一样,从寄存器里拿数据。Freestanding 环境下也可以使用的定义,即不需要任何运行时的支持。
stddef.h -
size_t
stdint.h -
int32_t
,uint64_t
stdbool.h -
bool
,true
,false
float.h
limits.h
stdarg.h
syscall 用到了
inttypes.h
32/64bit printf 格式字符串
在数据抽象基础上,还提供了很多字符串、数组的操作
string.h
-memcpy
memmove
strcpy
stdlib
- rand abort atexit system atioi qsortmath
看 stddef.h 里有个有意思的宏,offsetof
这个宏定义可以找到结构体里的每个成员在结构体里的偏移量。有意思的实现
想象在 0 这个地址放了一个这个结构体,取出 0 地址的这个成员,然后取这个成员的地址。
这些是对基础编程机制的抽象。
此外libc还有个任务:封装纯粹的计算,比如 memset
函数,清零内存、拷贝内存。字符串转换整数 atoi,这些都是纯粹的计算。
以及,memset 的实现其实有种简单的方式。但是再深入研究,clang -O3 优化下,居然会变复杂,。以及是否考虑线程安全性的问题,memset 需要上锁吗?上谁的锁呢。和同时访问文件不同,。所以设计时这些锁由用户来上,即标准库支队“标准库内部数据”的线程安全性负责
自己写着玩这样没有任何问题,但是如果作为 libc 的作者,要考虑的更多,会被无数的人调用正确性很重要,安全性也要有,还得跑得快,简单的代码想跑得快是不容易的。这就和CPU有关系了,数据的预取、动态流水线,还得兼容各种各样不同的CPU,写库很麻烦。
此外还有排序和查找的 api。从 C 的高级汇编的角度来考虑,这个 api 已经设计的不错了
但是从今天的角度来讲,时绝对不会用的,还需要传入函数指针。
更好用的现代语言的 api
3. 系统调用与环境的抽象
libc 中有 stdout 这个东西,
比如 fprintf(stdout, "11111")
,这个东西也是定义在stdout.c 里,可以看到是一个地址,
FILE * 背后是文件描述符
stdio 封装了文件描述符上的系统调用 fseek fgetpos feof
printf 比较复杂,。还有 popen pclose 有缺陷的api,1970年 C 语言是很先进的。
C 语言一般会接收一个指针,如果得到了 NULL,而且不做处理,那么可能就出大问题。现代的编程语言,会好很多
libc 里的错误处理,perror,
最后一个例子,环境变量。
用 musl 来编译,调试这个程序。这个程序的功能是输出环境变量。问题是 谁赋值?
gdb 打个 watchpoint,
这些库,以及库函数,不需要特意去记,看源码就好了,看到了去查手册就是一个强化的过程。逐渐的就知道了什么情况该用什么类型。
## 封装2:操作系统对象
前面讲了 execve 系统调用,fork 复制一个状态机,然后 execve 重置状态机,reset 为某个程序的初始状态。其实这个东西并不好用。
比如
会报错,因为 execve 函数第一个参数需要是个路径 ,如果是 /bin/echo
就可以了,这并不好用。我们想要的是程序去 PATH 里找 echo 这个东西。
更好用的api 为 system(echo hello world)
,这是易用的,还有一个类似的 execlp("echo", "echo","hello",NULL)
这个函数需要用 NULL 结尾,如果查看系统调用,会看到这个函数去做过拼接 PATH ,直到execve成功了,状态机重置了。
所以,libc 的有一个想法:让系统调用也要更好用一些。
系统调用是操作系统 “紧凑” 的最小接口。并不是所有系统调用都像 fork 一样可以直接使用。即 libc 封装了一些系统调用以及对象。
stdio.h 里的接口,FILE *
背后实际上是个文件描述符。
用 musl 来编译,调试这个程序。这个程序的功能是输出环境变量。
stdout,C 标准库对操作系统对象做了一层封装,分装了文件描述符,以及相关的系统调用。
libc 还提供了 popen 和 pclose,可以运行另一个程序,然后把输出管道给此程序。
事实上这是一个有缺陷的 api,要么只读,要么只写,只能返回一个。
此外还有一个东西,各种各样的出错信息。
这个 “No such file or directory” 似乎见得有点多?
cat nonexist.c
,wc nonexist.c
都是同样的 error message这不是巧合!
我们也可以 “山寨” 出同样的效果
warn("%s", fname); (观察 strace)
err 可以额外退出程序
errno 是进程共享还是线程独享?
这下知道协程的轻量了吧
我们可以用 gdb 查看具体的 FILE *
例如 stdout 封装了文件描述符上的系统调用 (fseek, fgetpos, ftell, feof, ...) vprintf 系列
使用了 stdarg.h 的参数列表 int vfprintf(FILE *stream, const char *format, va_list ap); int vasprintf(char **ret, const char *format, va_list ap);
程序是个状态机,我们有 execve(pathname, argv, envp) 来重置状态机。现在我们知道有个 environ 变量在内存里,这是个指针,是谁赋值的?因为每次启动进程的时候,环境变量应该是不一样的。
4. 动态内存管理
如何进行内存的管理呢?
大段内存,要多少有多少,超过物理内存上限都行 (Demo)。
但是操作系统不支持分配一小段内存。这是应用程序自己的事情。
malloc 和 free 函数,在大区间中维护不相交的区间的集合
最后更新于