4.C 标准库的实现

背景回顾:我们在先前的课程中讲解了如何在一套十分精简的机制 (硬件、系统调用) 上构建起整个应用程序世界。为了让这个世界更丰富多彩,为开发者提供便利就是至关重要的。使 UNIX 世界蓬勃发展离不开 C 语言和它的标准库,长久以来都为系统编程树立了一套标杆。

系统调用上面的东西:C 标准库和实现。

操作系统是对象+api,在此基础上就可以实现任何程序。原则上是这样,但是太麻烦了,如果要实现一个大一点的程序,还是要做封装的。在系统调用上面封装的是 libc。

1. libc 简介

之前从最小的世界开始构建,从 minimal.s 到真实的操作系统。我们有个最小的可以启动程序的 linux,不知道知道这个 linux 内核怎么编译、怎么把目录打包的,只要知道有命令可以做到就行。我们用这个内核加载第一个程序,如果这个程序退出了,那么就 kernel panic了。在第一个进程的基础上,通过系统调用,启动整个计算机系统。

理论上说,可以用操作系统的 API 去做任何事情,构造我们看到的世界,但是这并不够美好。安装程序、改变配置,直接用系统调用是不友好的,因此有了 shell,把指令翻译成系统调用。

有了 shell、系统调用的世界,我们下面要解决一个更重要的问题,构建操作系统的生态。热议的问题:为什么不搞中国的操作系统?很大的原因是现有的操作系统有了很大的生态,生么是生态?生态就是大家所有人都遵循共同的规则,在共同的规则下办事,大家都觉得规则不错,大家都遵守,然后生态就越来越大。

unix 能够成功、生态能成功,是离不开 C 语言的。同期的语言,既能榨干性能,又能写出有趣的程序的语言就只有 C 。C 能成功,除了语法在当时比较先进(预编译),还有标准库,即 libc。

操作系统有内核,对外提供的 api 为 syscall,所有的程序都可以直接使用 syscall,其中有个特别重要的程序为 shell。shell 为其他应用程序提供了框架或者说环境,自己写的程序可以用管道连接起来,或者在图形化的shell里打开一个应用程序。

这部分来关注应用程序来怎么写。我们确实可以不用任何库函数实现一个应用,但是现在没有人这么干,都会有依赖一些库。库也是构造操作系统的要素。

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,

#include <stdio.h>
#include <stdarg.h>

int printf(const char *restrict fmt, ...)
{
	int ret;
	va_list ap;
	va_start(ap, fmt);
	ret = vfprintf(stdout, fmt, ap);
	va_end(ap);
	return ret;
}

这一看像人话。这里的变参数的处理,提供了一种思路。

看代码不是问题,下载下来也很容易,难的是怎么用下载的 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 qsort

  • math

看 stddef.h 里有个有意思的宏,offsetof 这个宏定义可以找到结构体里的每个成员在结构体里的偏移量。有意思的实现

#define offsetof(type, member) 
    ((size_t)( (char *)&(((type *)0)->member) - (char *)0 ))

想象在 0 这个地址放了一个这个结构体,取出 0 地址的这个成员,然后取这个成员的地址。

这些是对基础编程机制的抽象。

此外libc还有个任务:封装纯粹的计算,比如 memset 函数,清零内存、拷贝内存。字符串转换整数 atoi,这些都是纯粹的计算。

void *memset(void *s, int c, size_t n)
{
    // 容易的实现
    for (size_t i=0; i<n; i++)
    {
        ((char *)s)[i] = c;
    }
    return s;
}

以及,memset 的实现其实有种简单的方式。但是再深入研究,clang -O3 优化下,居然会变复杂,。以及是否考虑线程安全性的问题,memset 需要上锁吗?上谁的锁呢。和同时访问文件不同,。所以设计时这些锁由用户来上,即标准库支队“标准库内部数据”的线程安全性负责

自己写着玩这样没有任何问题,但是如果作为 libc 的作者,要考虑的更多,会被无数的人调用正确性很重要,安全性也要有,还得跑得快,简单的代码想跑得快是不容易的。这就和CPU有关系了,数据的预取、动态流水线,还得兼容各种各样不同的CPU,写库很麻烦。

此外还有排序和查找的 api。从 C 的高级汇编的角度来考虑,这个 api 已经设计的不错了

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

void *bsearch(const void *key, const void *base,
              size_t nmemb, size_t size,
              int (*compar)(const void *, const void *));

但是从今天的角度来讲,时绝对不会用的,还需要传入函数指针。

更好用的现代语言的 api

sort(xs.begin(), xs.end(), [] (auto& a, auto& b) {...});
xs.sort(lambda key=...)

3. 系统调用与环境的抽象

libc 中有 stdout 这个东西,

比如 fprintf(stdout, "11111") ,这个东西也是定义在stdout.c 里,可以看到是一个地址,

  • FILE * 背后是文件描述符

  • stdio 封装了文件描述符上的系统调用 fseek fgetpos feof

printf 比较复杂,。还有 popen pclose 有缺陷的api,1970年 C 语言是很先进的。

C 语言一般会接收一个指针,如果得到了 NULL,而且不做处理,那么可能就出大问题。现代的编程语言,会好很多

let checksum = {
  Exec::shell("find . -type f")
    | Exec::cmd("sort") | Exec::cmd("sha1sum")
}.capture()?.stdout_str();  // ()? returns "std::nullopt"

libc 里的错误处理,perror,

最后一个例子,环境变量。

#include <stdio.h>

extern char **environ;
extern void *****************************end; //可以写任何东西

int main() {
  
  for (char **env = environ; *env; env++) {
    printf("%s\n", *env);
  }

  end = NULL;
}

用 musl 来编译,调试这个程序。这个程序的功能是输出环境变量。问题是 谁赋值?

gdb 打个 watchpoint,

这些库,以及库函数,不需要特意去记,看源码就好了,看到了去查手册就是一个强化的过程。逐渐的就知道了什么情况该用什么类型。

## 封装2:操作系统对象

前面讲了 execve 系统调用,fork 复制一个状态机,然后 execve 重置状态机,reset 为某个程序的初始状态。其实这个东西并不好用。

比如

extern char **environ;
char *argv[] = { "echo", "hello", "world", NULL, };
if (execve(argv[0], argv, environ) < 0) {
  perror("exec");
}

会报错,因为 execve 函数第一个参数需要是个路径 ,如果是 /bin/echo 就可以了,这并不好用。我们想要的是程序去 PATH 里找 echo 这个东西。

更好用的api 为 system(echo hello world) ,这是易用的,还有一个类似的 execlp("echo", "echo","hello",NULL) 这个函数需要用 NULL 结尾,如果查看系统调用,会看到这个函数去做过拼接 PATH ,直到execve成功了,状态机重置了。

所以,libc 的有一个想法:让系统调用也要更好用一些。

系统调用是操作系统 “紧凑” 的最小接口。并不是所有系统调用都像 fork 一样可以直接使用。即 libc 封装了一些系统调用以及对象。

stdio.h 里的接口,FILE * 背后实际上是个文件描述符。

#include <stdio.h>

int main() {
  extern char **environ;
  for (char **env = environ; *env; env++) {
    printf("%s\n", *env);
  }
}

用 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 函数,在大区间中维护不相交的区间的集合

最后更新于