问题复现
在MIT 6.828 Lab3中,需要在syscall.c
中实现OS的系统调用功能。在熟悉源码时,我发现一个名为cprintf
的函数调用让人非常疑惑:
// Print a string to the system console.
// The string is exactly 'len' characters long.
// Destroys the environment on memory errors.
static void
sys_cputs(const char *s, size_t len) {
cprintf("%.*s", len, s);
}
对于实现系统调用来说,OS在实际的系统调用实现中应该直接与底层硬件进行互动,而非调用一个抽象层次较高的格式化输出函数。也许是这个函数内部实现了底层IO功能?通过头文件中的声明,查看cprintf
函数实现的位置:
// lib/printf.c
int cprintf(const char *fmt, ...);
int vcprintf(const char *fmt, va_list);
查看lib/printf.c
中的cprintf
函数的实现:
// Implementation of cprintf console output for user environments,
// based on printfmt() and the sys_cputs() system call.
static void
putch(int ch, struct printbuf *b)
{
b->buf[b->idx++] = ch;
if (b->idx == 256-1) {
sys_cputs(b->buf, b->idx);
b->idx = 0;
}
b->cnt++;
}
int
vcprintf(const char *fmt, va_list ap)
{
struct printbuf b;
b.idx = 0;
b.cnt = 0;
vprintfmt((void*)putch, &b, fmt, ap);
sys_cputs(b.buf, b.idx);
return b.cnt;
}
int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;
va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);
return cnt;
}
什么情况,头部注释说cprintf
的实现使用了sys_cputs
的系统调用,但是sys_cputs
本身就调用了这个函数。出现了符号循环引用,但是编译器没有报错。系统设计角度上,系统调用的实现不应该包含一个更高层次的抽象;程序运行上,编译器没有报错。这说明可能是在程序编译链接方面欠缺了知识。
项目Makefile
在查找资料之前,我在这部分项目文件的Makefile中找到了这样一段代码:
KERN_SRCFILES := kern/entry.S \
kern/entrypgdir.c \
kern/init.c \
kern/console.c \
kern/monitor.c \
kern/pmap.c \
kern/env.c \
kern/kclock.c \
kern/picirq.c \
kern/printf.c
...
# How to build kernel object files
# 内核目标文件都由内核目录中文件构建
$(OBJDIR)/kern/%.o: kern/%.c $(OBJDIR)/.vars.KERN_CFLAGS
首先,发现在/kern
目录下也有printf.c
这个文件,并且包含签名完全相同的cprintf
函数,其中实现与目录/lib
不同,更加底层:
static void
putch(int ch, int *cnt)
{
cputchar(ch);
*cnt++;
}
...
void
cputchar(int c)
{
cons_putc(c);
}
...
// output a character to the console
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c);
cga_putc(c);
}
其次,在makefile中规定了内核目标文件的构建方式,即符号定义都来源于/kern
目录下的源码文件,那么系统调用中的函数使用了kern/printf.c
中的实现,而非头文件中lib/printf.c
的函数实现。
链接技术角度解释
在C语言的项目中,如果包含了声明了函数原型的头文件,但是在多个目录中都存在该函数的实现(定义),最终链接时使用哪一个实现取决于在编译链接过程中的指定。如上文提到的makefile中的一行:
$(OBJDIR)/kern/%.o: kern/%.c $(OBJDIR)/.vars.KERN_CFLAGS
通过这样的方式,指定了正确版本的函数实现。
之前学习CSAPP的时候,有接触过链接器算法的内容,回去复习了一遍,发现符号解析部分内容确实与今天的问题有关。一般情况下,编译器和链接器会按照你在命令行(或Makefile中)指定的顺序来搜索和链接对应实现。
链接器维护三个集合:
可重定位目标文件集合E
一个未解析符号集合U
一个在前面输入文件已经定义的符号集D
初始时,各集合全空;若扫描到目标文件f,则链接器将f添加到E,修改U和D来反应f中的符号定义和引用。
若扫描到存档文件(静态库)f,则链接器尝试匹配U中未定义的符号和由存档文件定义的符号。若某文件m定义了U中的符号,则将m添加至E,并修改U和D。对所有目标文件,依次执行上述过程。
若扫描所有输入文件后,U是非空的,则抛出异常。否则,合并和重定位E中目标文件,生成可执行目标文件。
通过链接器算法,也更好理解外部变量在项目中解析的过程,即本地文件没有定义的符号会加入集合U,希望在其他文件中寻找定义,转移到集合D。
总结
在头文件中声明的函数原型,可以通过在makefile中指定不同的文件,使最终的可执行文件选择不同的同签名函数实现。头文件中的声明并没有指定某一种函数实现的能力。
对于项目文件,我们可以更加方便地进行项目架构设计。如对于不同的权限,我们可以灵活使用相同的目录结构和声明文件,但是编译时通过不同的Makefile指定函数的实现,得到更统一的项目接口规范。