printf()等系統庫函數是如何實現的?

比如printf(),fopen()等,既然這些函數是庫函數,那麼他們應該是由更基本的單元組成的,那麼第一個庫函數應該是不依賴於其他庫函數構成的

但是基本元素(運算符,控制流)是如何直接/間接實現printf()這樣的功能的呢?

這些庫函數的源碼如何查看呢?

(這個是屬於哪個領域的內容?如果您願意告訴這些函數的源碼,或者實現的原理我在哪裡/哪本書里可以看到,定當感激不盡)


強行答題,我在我最近完成的玩具內核 LastAvenger/OS67 · GitHub 里也實現了 printf,題主既然沒有限定是哪個系統哪個庫的實現,那我就講講我是怎麼實現 printf 的。

完整展開可以寫半本書了……我挑重點講。

倒著說吧,依次實現:

  • putchar()
  • puts()
  • sys_puts()
  • 用戶級別的 puts()
  • sprintf()
  • printf()

在內核中實現最簡單的輸入,就是在 text mode 下往顯存寫字元,

uint16_t *vga_mem = 0xb800;
vga_mem[0][0] = "A" | ((0 &<&< 4)| 4 )&<&<8;

這兩句就能在屏幕的第一行第一列輸出一個黑底紅字的字母 『A』,更多的資料還請參考

Text UI - OSDev Wiki

在此基礎上, 處理一下游標位置和特殊字元,就可以實現一個 getchar 了:

OS67/drv/vga.c:getchar()。

接著也可以很容易地實現 puts:對一個字元串的每個元素都 putchar 一次就好了。

現在我們可以自由地列印字元串了,但只是在內核中,用戶程序對內存地址 0xb800 是沒有讀寫許可權的。所以我們要實現一個系統調用 sys_write,讓用戶傳遞一個字元串給內核,由內核再列印出來。

為什麼不是叫 sys_puts 呢?因為 sys_wirte 不止可以往屏幕寫入,往文件,往管道寫入都是可以的,往屏幕寫字串,在這個內核里被實現為往設備 con 里寫入字串,當然在這裡這不是重點,所以我們還是把它叫做 sys_puts 好了。

從內核的角度看,sys_puts 是個沒有參數也沒有返回值的函數,因為內核和用戶程序並不能簡單地通過 C 語言的調用約定來傳遞參數:它們不能共用一個棧。

所以 sys_puts 是這樣做的:它直接讀取用戶進程的用戶用戶棧,從上面取出參數,這裡至少得有兩個參數:字串指針地址 和 字元串長度。 檢查 字串地址 + 字串長度 是否還在該進程的空間內,這是必要的安全檢查。

檢查完後就可以把字元串交給 puts 了,再在該進程的中斷上下文中的 eax 中保存返回值。然後從系統調用返回。

在執行 int 0x80 和離開系統調用的時候內核做了很多工作,此處就不展開了,建議參考 xv6

在用戶程序看來,調用系統調用其實和調用普通函數並無二致,我們可以生成 C 函數到系統調用的介面:

global puts
puts:
mov eax, SYS_PUTS ; 系統調用號
int 0x80
ret

而在 C 的頭文件的加入這樣的聲明:

int puts(char *addr, uint32_t n);

只要引用這個頭文件並鏈接入上面那段彙編代碼,就可以在用戶程序像使用普通函數一樣使用 puts 了。

如何實現 printf 呢?首先要處理的是對不定長參數的處理:

C 語言有語法來實現這個:

void printk(const char *fmt, ...);

在 printf 和 puts 之間,我加了個函數 sprintf 作為中間層,而 printf 只是負責把 sprintf 的輸入傳遞給 puts 而已。

來說說 sprintf。

C 的默認調用約定是從右到左壓入參數,並由調用者清除堆棧,所以被調用者並不知道它有多少個參數要處理。

對於被調用者 printf,它唯一能確定的是第一個參數肯定是字元串指針,而這個參數的地址在往上就是可能存在的第二個參數(x86 棧從高地址向低地址增長),printf 把字元串地址和第二個參數的地址傳給 了 sprintf (第二個參數地址用宏 va_start 生成)。

sprintf 只管在字元串中尋找控制字元串(%s %d %x 等),遇到一個 %d 則從棧里取出一個整數,遇到 %s 則取出一個字串指針,然後把指針往上移動一個 sizeof (用宏 va_arg,這裡和普通 sizeof 的不同在於要考慮棧的地址對齊),對於取出來的參數,把它轉換成字元串(用整數用 itoa,浮點數用 gcvt,想辦法插入原字元串就好了。

和參數相關的三個宏如下:

#define va_start(ap, v) (ap = (va_list)v + _INTSIZEOF(v))
#define va_arg(ap,t) ( *(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)) )
#define va_end(ap) (ap = (char*) 0)

OS67/libs/vsprint.c : sprinf 類似的實現

OS67/usr/uio.c: printf 實現


標準庫函數依賴的是 system call。


很多答案都提到是調用write(),但實際上我們在程序中使用的write()僅僅是一個符合POSIX標準的入口(有時被稱作stub或者wrapper),是由libc提供的,不是真正底層的系統調用。

在Linux系統下,真正的系統調用是sys_write()。

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;

if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_write(f.file, buf, count, pos);
if (ret &>= 0)
file_pos_write(f.file, pos);
fdput_pos(f);
}

return ret;
}

新的Linux內核使用了一系列宏來把POSIX定義的介面跟實際的系統調用對應起來。上面的代碼實際上就是sys_write()的定義。

我們可以看到經過簡單的封裝後sys_write()又調用了vfs_write()。

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count,
loff_t *pos)
{
ssize_t ret;

if (!(file-&>f_mode FMODE_WRITE))
return -EBADF;
if (!(file-&>f_mode FMODE_CAN_WRITE))
return -EINVAL;
if (unlikely(!access_ok(VERIFY_READ, buf, count)))
return -EFAULT;

ret = rw_verify_area(WRITE, file, pos, count);
if (ret &>= 0) {
count = ret;
file_start_write(file);
if (file-&>f_op-&>write)
ret = file-&>f_op-&>write(file, buf, count, pos);
else if (file-&>f_op-&>aio_write)
ret = do_sync_write(file, buf, count, pos);
else
ret = new_sync_write(file, buf, count, pos);
if (ret &> 0) {
fsnotify_modify(file);
add_wchar(current, ret);
}
inc_syscw(current);
file_end_write(file);
}

return ret;
}

在這裡,vfs_write()會繼續根據寫入的文件類型來調用驅動提供的底層函數,這些函數將真正的完成寫入操作。

至於從printf()到達write()的過程在stackexchange : Where the printf() Rubber Meets the Road有詳細的介紹。


如果是在 UNIX 下,printf 實際上最後調用的就是 write 這個 syscall 。它就是往 stdout 這個文件裡面寫你要寫的那些東西,當然了,首先要把那些參數都格式化成一個字元串。


HoHo..我來說個蛋疼的方法...

喏....首先呢...你要知道printf是在哪個dll里的...

當然咯..一般是在runtime dll里..比如我這個就在MSVCR110D里...

隨便寫個小程序..掛上windbg...

x MSVCR110D!*printf*

之後發現有很多類似的函數喲..

0fa99450 MSVCR110D!_fwprintf_l (&)

0faa02d0 MSVCR110D!_vfprintf_s_l (&)

0fa994b0 MSVCR110D!_fwprintf_p_l (&)

0fab2f20 MSVCR110D!swprintf (&)

0fb163d0 MSVCR110D!_cwprintf_l (&)

0fa9e150 MSVCR110D!_snwprintf_s_l (&)

0fb178d0 MSVCR110D!_cwprintf_p_l (&)

0fa9d820 MSVCR110D!_scprintf (&)

0faa05f0 MSVCR110D!vfprintf_s (&)

0faa00f0 MSVCR110D!vprintf_helper (&

然後隨便找到你想看的函數...

uf MSVCR110D!printf

然後...怎麼實現的...隨便看...

0:000&> uf MSVCR110D!printf

MSVCR110D!printf:

0fa9ba20 55 push ebp

0fa9ba21 8bec mov ebp,esp

0fa9ba23 6afe push 0FFFFFFFEh

0fa9ba25 68487bb90f push offset MSVCR110D!_CT??_R0?AVlogic_errorstd+0x538 (0fb97b48)

0fa9ba2a 68a0cab50f push offset MSVCR110D!_except_handler4 (0fb5caa0)

0fa9ba2f 64a100000000 mov eax,dword ptr fs:[00000000h]

0fa9ba35 50 push eax

0fa9ba36 83c4e4 add esp,0FFFFFFE4h

0fa9ba39 53 push ebx

0fa9ba3a 56 push esi

0fa9ba3b 57 push edi

0fa9ba3c a160b4ba0f mov eax,dword ptr [MSVCR110D!__security_cookie (0fbab460)]

0fa9ba41 3145f8 xor dword ptr [ebp-8],eax

0fa9ba44 33c5 xor eax,ebp

0fa9ba46 50 push eax

0fa9ba47 8d45f0 lea eax,[ebp-10h]

0fa9ba4a 64a300000000 mov dword ptr fs:[00000000h],eax

0fa9ba50 837d0800 cmp dword ptr [ebp+8],0

0fa9ba54 7409 je MSVCR110D!printf+0x3f (0fa9ba5f)

.......


這門課叫——操作系統

怎麼搞你搜知乎


vs裡面有windows下msvcrt的源碼,可以稍微參考 最後肯定要用console系列的api 最後要跑到csrss里什麼的


printf要調用vprintf,把可變參數轉換成va_list。

然後調用vfprintf,標準輸出也是文件,合在一起便於處理。

之后里面就開始解析格式化字元串了,毫無疑問會用到atoi之類的轉換函數,和posix write(windows下是別的api)。

posix的函數都是系統調用,你如果調用了posix api,反編譯之後會看到一個int80,而不是通常的call(而windows下會把中斷包裝成dll)。這部分請見《CSAPP》,或者《程序員的自我修養》。


去看CSAPP吧~

http://www.cs.cmu.edu/~213


printf的原理,實際上正如蕭大所說,是操作系統的範疇.要說清這個函數的原理,大致需要明白庫函數/系統調用/中斷 的概念和原理.

1.理清兩大關係:庫函數/系統調用

首先printf和fopen等函數,屬於C語言的庫函數,任何人都可以調用;而open/wirte等函數屬於system call. 它們的關係如下:

printf是不能和內核直接打交道的,這個需要藉助system call來實現.其中一個完整的操作系統,並不包括庫函數.

2.你所不知道的事兒----中斷-彙編-bios

可能你接著要問?系統調用是如何來實現講字元輸出到屏幕或者文件的?

回想一下計算機的關鍵部件CPU,如果學過彙編的話,你可能記得由mov/add等指令,這些指令完成基本運算和數據傳輸. 如果考量到這個層次的話,你應該明白了.Printf對應的指令本質實際上是將一些數據搬運到顯存的某些位置,而屏幕是IO,IO操作是需要藉助中斷處理程序來進行的.

上一段,實際上是說的系統調用write.

3.繼續提幾個問題

如何讓printf輸出到一個磁碟文件而不是屏幕上?

為何頻繁調用printf會導致程序運行"緩慢"?或者說很多OJ上,如果不小心寫了輸出函數,會導致超時?

其他:

[1]詳細查看printf的源代碼,請看這裡:GNU Project Archives

[2]其他問題:參見操作系統設計與實現


《C標準庫》,glibc源碼等~慢慢看。。。。


整個gcc都是開源的,所以printf之類的庫函數可以在gcc源碼里找,

更底層的system call,整個Linux都是開源的,看源碼看源碼,

至於Windows,只能看看MSDN了,


一個naive的printf是C新手的分水嶺吧

痛點在參數變長,然後怎麼實現這種邊長的參數,本質還是玩指針和stack,函數傳參的時候參數的壓棧順序是什麼

自己動手寫printf -- 庫函數printf的實現

踏踏實實學好C語言

並不需要深厚的OS學問, printf的很多邏輯任務都是在user space完成的,gdb跟蹤到關鍵的文件啃代碼就是了。

我記得JOS裡面lib里同樣有類似printf函數的實現也可以看看。


又被大神叫來了~~~

這類偏向於系統底層的問題好像大家都會,但大家又都不會。其實現原理都能侃侃而談,但是它們的原理都簡單到不能用來 Pretend To Be The Letter Just After A(PBLAA)。

後來發現一個問題,你買了一個手機,認為手機就應該這樣,然後,你要做一個手機,或者研究手機的電路。。。難。。。這和一個普通(沒有鄙視的意思)程序員寫著寫著#include&或者#include&再或者import java.io.*;然後突然要分析或者實現一個I/O庫基本上是一個意思。

扯上面一段沒別的意思,是想說明這種問題的出現很可能是你處於的知識交流環境並不包含太多的相關內容,另一個,就是不要在知乎問這種問題(不是在黑zhihu,是真不行~~)下面舉個栗子:

btw:推薦你去oldlinux看看,看什麼書,看《30自製操作系統》的附帶源碼!!!!!!

把printf函數簡單化之後可以這麼寫:

int printf(FILE *stream, const char *format, va_list arg)
{
int i; //聲明一個變數,-_-
//下面申請一個空間,用來保存將帶有%d,%s之類展開之後的字元串
//例子:("Hello %d 0x%x
", 1 ,10) --&> "Hello 1 0x0a
"
char *s = malloc(4096);
//對於例子里的轉換:
//s是轉換之後字元串的指針
//format指向的是"Hello %d 0x%x
"
//arg是參數列表:1,10(就是從下標為1開始到最後的參數)
i = vsprintf(s, format, arg);
i = fwrite(s, 1, i, stream); //這是個系統調用,這個再去學習,這裡假設成將字元
//串拷貝到數組stream中
free(s); //釋放空間
return i;
}

下面是vsprintf的一個簡單實現

int vsprintf(char *s, const char *format, va_list arg)
{
UCHAR c, *t = s, *p, flag_left, flag_zero /* , flag_sign, flag_space */;
UCHAR temp[32], *q;
temp[31] = "";
int field_min, field_max, i;
long l;
static char hextable_X[16] = "0123456789ABCDEF";
static char hextable_x[16] = "0123456789abcdef";
for (;;) {
c = *format++;
if (c != "%") {
put1char:
*t++ = c;
if (c)
continue;
return t - (UCHAR *) s - 1;
}
flag_left = flag_zero = /* flag_sign = flag_space = flag_another = */ 0;
c = *format++;
for (;;) {
if (c == "-")
flag_left = 1;
else if (c == "0")
flag_zero = 1;
else
break;
c = *format++;
}
field_min = 0;
if ("1" &<= c c &<= "9") { format--; field_min = (int) strtoul0(format, 10, c); c = *format++; } else if (c == "*") { field_min = va_arg(arg, int); c = *format++; } field_max = INT_MAX; if (c == ".") { c = *format++; if ("1" &<= c c &<= "9") { format--; field_min = (int) strtoul0(format, 10, c); c = *format++; } else if (c == "*") { field_max = va_arg(arg, int); c = *format++; } } if (c == "s") { if (field_max != INT_MAX) goto mikan; p = va_arg(arg, char *); l = strlen(p); if (*p) { c = " "; copy_p2t: if (flag_left == 0) { while (l &< field_min) { *t++ = c; field_min--; } } do { *t++ = *p++; } while (*p); } while (l &< field_min) { *t++ = " "; field_min--; } continue; } if (c == "l") { c = *format++; if (c != "d" c != "x" c != "u") { format--; goto mikan; } } if (c == "u") { l = va_arg(arg, UINT); goto printf_u; } if (c == "d") { printf_d: l = va_arg(arg, long); if (l &< 0) { *t++ = "-"; l = - l; field_min--; } printf_u: if (field_max != INT_MAX) goto mikan; if (field_min &<= 0) field_min = 1; p = setdec(temp[31], l); printf_x2: c = " "; l = temp[31] - p; if (flag_zero) c = "0"; goto copy_p2t; } if (c == "i") goto printf_d; if (c == "%") goto put1char; if (c == "x") { q = hextable_x; printf_x: l = va_arg(arg, UINT); p = temp[31]; do { *--p = q[l 0x0f]; } while ((*(UINT *) l) &>&>= 4);
goto printf_x2;
}
if (c == "X") {
q = hextable_X;
goto printf_x;
}
if (c == "p") {
i = (int) va_arg(arg, void *);
p = temp[31];
for (l = 0; l &< 8; l++) { *--p = hextable_X[i 0x0f]; i &>&>= 4;
}
goto copy_p2t;
}
if (c == "o") {
l = va_arg(arg, UINT);
p = temp[31];
do {
*--p = hextable_x[l 0x07];
} while ((*(UINT *) l) &>&>= 3);
goto printf_x2;
}
if (c == "f") {
if (field_max == INT_MAX)
field_max = 6;
/* for ese */
if (field_min &< field_max + 2) field_min = field_max + 2; do { *t++ = "#"; } while (--field_min); continue; } if(c == "c"){ if (field_max == INT_MAX) field_max = 6; /* for ese */ if (field_min &< field_max + 2) field_min = field_max + 2; do { *t++ = "C"; } while (--field_min); continue; } mikan: for(;;); } }

這大概就是printf實現的基本原理,如果你想運行,添加下面的代碼

typedef unsigned char UCHAR;
typedef unsigned int UINT;

#define UINT_MAX (+0xffffffff)
#define ULONG_MAX UINT_MAX

static int prefix(int c)
{
signed char base = 0;
if ("a" &<= c c &<= "z") c += "A" - "a"; if (c == "B") base = 2; if (c == "D") base = 10; if (c == "O") base = 8; if (c == "X") base = 16; return base; } unsigned long strtoul0(const unsigned char **ps, int base, unsigned char *errflag) { const unsigned char *s = *ps; unsigned long val = 0, max; int digit; if (base == 0) { base += 10; if (*s == "0") { base = prefix(*(s + 1)); if (base == 0) base += 8; /* base = 8; */ } } if (*s == "0") { if (base == prefix(*(s + 1))) s += 2; } max = ULONG_MAX / base; *errflag = 0; for (;;) { digit = 99; if ("0" &<= *s *s &<= "9") digit = *s - "0"; if ("A" &<= *s *s &<= "Z") digit = *s - ("A" - 10); if ("a" &<= *s *s &<= "z") digit = *s - ("a" - 10); if (digit &>= base)
break;
if (val &> max)
goto err;
val *= base;
if (ULONG_MAX - val &< (unsigned long) digit) { err: *errflag = 1; val = ULONG_MAX; } else val += digit; s++; } *ps = s; return val; } static UCHAR *setdec(UCHAR *s, UINT ui) { do { *--s = (ui % 10) + "0"; } while (ui /= 10); return s; }


int write(int fd, const void *buf, int count)

{

int res = 0;

asm(
"movl $4, %%eax
"

"movl %1, %%ebx
"

"movl %2, %%ecx
"

"movl %3, %%edx
"

"int $0x80
"

"movl %%eax, %0
"

:"=m"(res)

:"m"(fd),

"m"(buf), "m"(count)
);

return res;

}

(知乎的編輯器略噁心)

printf()是用于格式化輸出的,會根據參數做各種賞心悅目的格式化,支持任意個參數(參考`man stdarg`)

不過最終會調用write()實現寫字元到屏幕的功能,後者通過0x80號中斷陷入內核。(參考貼出的代碼,網上很多,我是參考《程序員的自我修養》給出的)

可以看看《程序員的自我修養》,看完就懂了。


http://www.douban.com/doubanapp/dispatch?uri=/book/3775842/

另外注意區分標準庫和系統調用


從一個printf()函數足以窺探一下整個計算機體系的秘密。

首先推薦南七技校網路課 Linux內核分析 - 網易雲課堂 這門課講的很好,擼下來大有幫助

如果題主很貧窮(這門課內部價也不低),可以在網上搜羅這門課的上課筆記。

題主的這個問題可以在系統調用過程 - 20135124 - 博客園 領會。

基本上串聯起來就是 高級語言——系統API(運行庫)——中斷——系統調用——硬體資源

為什麼說此問題足以窺探整個計算機體系的秘密。

說到系統調用得說內核態吧,說到切換得說到中斷吧,說到中斷得說到向量表吧,說到向量表得說到BIOS吧(Linux中系統跟bios的關係其實不是特別大),說到BIOS得說到ROM吧,說到ROM得說到觸發器吧,說到觸發器得說到邏輯門吧,說到邏輯門得說到晶體管吧,說到晶體管得說到不純半導體吧。。。。。。硬體方面,南北橋結構、匯流排、埠定址、訪問控制方式。。。。。。更不用說高級語言層面的東西了。

即能由小知大、又可以由大見小。


printf本身是標準庫函數,底層實現的一定是利用system call


windows上有個標準設備,std,printf在根源上是,通過windows API GetStdHandle 來得到這個設備,然後通過WriteFile API來寫這個設備。。。printf也好,putchar 也好,他只是向一個stdout緩衝區來寫數據(這是設備系統無關的),這是C語言做的事。。。真正達到發送條件是,他是調用WriteFile來將stdout發送出去,這才是設備系統有關的,WriteFile函數是系統的,到其他環境上比如linux 會變成write,甚至到單片機上是while(ch) UARTA=*(ch++);這樣。。。設備無關的函數可以不變。。。

還有系統 系統 API是怎麼調用的,拿linux來說,linux我其實不是很熟,API都是在系統中已經完成的函數,當你調用API,他的第一步是發出CPU軟中斷和中斷號,每個API會對應一個軟中斷,軟中斷後CPU經過一系列處理最終會進入系統模式,並通過中斷號去查向量表,找到函數指針的位置,然後調用,系統這些核心API並不能算是個函數庫,他一開始就跟隨系統裝到內存里了。。。


軟體上最根本的是向顯存地址寫數據屏幕就顯示出字元了,進行這個操作的是操作系統內核,printf函數調用system call讓內核把數據寫到顯存。


推薦閱讀:

4G內存,安裝win7 32位流暢,還是安裝win7 64位好?
Windows 7 有哪些優化的方法?
CCIE,和思科認證?
中國的超級計算機被用來幹什麼了?
一個50萬人口的小城鎮在一天之內能產生超過10TB的對外網路流量嗎?

TAG:編程語言 | 計算機 | C編程語言 |