Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

psj2867

printf에 관하여 본문

기타

printf에 관하여

psj2867 2023. 6. 15. 15:12

환경은 linux, glibc(2.37) 기반으로 작성되었습니다.

목차
1. 작성동기
2. 전체적인 내용
3. 매우 간단한 설명
4. 간단한 설명
5. 조금 자세한 설명
6. 자세한 설명
 6.1. 코드 관점(컴파일러, 컴퓨터 구조) 
 6.2. 운영체제 관점(운영체제) 
 6.3. 프로세트 관점(컴퓨터구조)
7. 정리
8. 나중에 추가할 내용
9. 후기

1. 작성동기
대부분의 컴퓨터 언어를 배울 때 가장 먼저 해보는 것은 printf("Hello, world!") 또는 print("Hello, world!") 입니다.
많은 언어 및 프로그램에서 test 라는 문구 대신 사용하는 유명한 문장이죠.
당연히 저도 c언어를 배우고 printf를 사용했습니다.

그리고 의문이 생겼습니다.
이게 왜, 어떻게 출력되지?
사실 라이브러리라는 것이 원래도 별 문제 없이 돌아가면 대충 구조만 알고 가능한 낙관적으로 사용하는 것이 개발자 덕목이지만 그래도 궁금했습니다.
개발자는 낙관적이여도 공학자는 궁금해야죠.
이 의문을 처음 배운 순간부터 가졌고 언젠가는 해결되겠지 라는 생각을 가졌지만 약 4년이 지난 지금도 모호하게 무엇인가가 잘 해주겠지 라는 생각밖에 없습니다.
차이점이라면 무엇인가가 무엇인지 알게 되었다는 것이네요.

4년이 지난 지금도 이 의문은 가지고 있고 해결되지 않지만 컴퓨터공학은 여전히 재미있는 분야이기에 차츰 더 채워나가겠지만 우선 정리를 시작해보려고 합니다.


2. 전체적인 내용

printf를 중심으로 실행 과정을 정리할 것입니다.
처음에는 매우 간단한 설명을, 그리고 간단한 설명, 조금 자세한 설명, 자세한 설명 등등 같은 얘기를 반복해서 내용을 덧붙여 설명할 예정입니다.
주로 다루는 내용은 컴파일러, 컴퓨터 구조, 운영체제 등 컴퓨터 공학 내용입니다.


3. 매우 간단한 설명

"""
#include<stdio.h>
int main(){ printf("%d",1); }
"""
코드를 작성해서 컴퓨터가 읽을 수 있게 번역을 한 뒤에 운영체제에게 실행을 요청하면 운영체제가 실행을 해줍니다.

4. 간단한 설명

더 자세하게 풀어봅시다.

# 들어가기 전 설명
보통 프로그램이나 컴파일 관련해서 설명할 때 컴파일을 기계가 읽을 수 있는 코드로 바꾼다고 설명합니다.
너무 두루뭉실한 설명이기에 조금 더 설명을 하겠습니다.
보통 컴퓨터 구조 수업에서 다루는 내용이지만 가능한 축약하겠습니다.
컴퓨터는 기본적인 구조로 폰노이만에 의해 설계된 cpu-memory-program 구조를 가집니다.

cpu 가 할 수 있는 일은 프로그램을 읽고 써 있는 데로 연산, 이동, 제어밖에 없습니다.
cpu는 이것만 할 수 있습니다.
print(1+1) 을 cpu 는 1+1 을 연산해서 2를 연산하고 외부 모듈에 2를 이동시키면 외부 모듈, 프린트 등은 이를 출력합니다.
출력, 입력 등은 전부 외부 모듈의 역할이고 입력을 판단해서 제어하고 cpu 의 역할은 연산하여 출력으로 이동시키는 것 입니다.

실제적으로 cpu 는 memory 에서 program 을 가져와 써 있는데로 실행(연산, 이동, 제어)하는데 이 때 program이 컴파일 된 데이터입니다.
101110000000000000000000000000001110100011010110111111101111111111111111 이런 알아볼 수 없는 1과 0의 연속입니다.
이를 보기 편하게 바꾸면 밑과 같습니다.

"""    
b8 00 00 00 00 e8 d6 fe ff ff = 101110000000000000000000000000001110100011010110111111101111111111111111(2)
---
b8 00 00 00 00          mov    $0x0,%eax
e8 d6 fe ff ff          callq  1030 <printf@plt>    
"""

cpu은 architecture 에 따라 다른데 특정 길이 고정해서 가져오는 방식과 가변적으로 가져오는 방식이 있습니다.
주로 intel은 가변을 쓰고 arm 등은 고정 길이를 사용합니다.
위의 예시는 intel 이지만 우연히 길이가 같으므로 쉽게 설명하기 위해 고정 길이 방식이라고 가정하고 설명하겠습니다.

메모리에 있는 데이터 101110000000000000000000000000001110100011010110111111101111111111111111를 cpu는 32bit=4byte씩 가져옵니다.
그리고 8개, 16개, 8개로 쪼개서 명령어, 변수1, 변수2 로 여긴다고 가정하면
10111000,0000000000000000,00000000(32bit) = 10111000(mov), 0000000000000000(0), 00000000(eax) = mov(0,eax)
최종적으로 쉽게 설명하면 이런 식으로 mov(0, eax) 이해하고 cpu가 실행합니다.
이러한 코드들이 길게 적혀있고 cpu 는 특정 위치로 이동하라는 명령어가 나오지 않으면 순차적으로 끊임없이 명령어를 실행만 합니다.
실제 현대 cpu가 읽는 프로그램은 저것 보다 훨씬 이해하기 어려울 정도로 복잡합니다.
사람이 하기 어려울 정도고 cpu마다 다르기에 컴파일러가 존재합니다.

그럼에도 cpu는 마법이 아니고 그저 버튼이 눌리면 전등이 켜지는 일의 고도화입니다.
매우 간단한 cpu 를 예로 들어봅시다.

어떤 칩이 있을 때 3개의 입력이 있고 출력이 1개 있으면
1번 입력에 0 이 들어오면 2번, 3번 둘다 1이면 1(1&2 or 1*2 or AND-GATE)을 출력합니다.
0번 입력에 1 이 들어오면 2번, 3번 하나라도 1이면 1(1|2 or 1+2 or OR-GATE)을 출력합니다.

이러한 방법들을 조합하면 더하기, 곱하기, 빼기 등을 수행할 수 있습니다.
그리고 입력이 순차적으로 들어오면 간단한 cpu 가 됩니다.
위의 상황은 단순히 1번 입력에 10111000(mov) 가 들어온 상황입니다.

위의 조합을 게이트라 부르고 트랜지스터로 구현됩니다.
이런 단순한 것을 조합하고 추상화하여 게이트로, 회로로, cpu로, 컴퓨터로, 소프트웨어를 만들어 냅니다.

실제 트랜지스터 기반의 구체적인 게이트 구현 설명
게이트 기반 다양한 조합회로 종류
 

간단한 설명과 컴퓨터 구조, cpu 설명을 위해 운영체제를 배제하고 설명하겠습니다.

-1 컴파일
cpu가 읽을 수 있는 언어로 코드를 번역합니다.
-2 실행
메모리 0번 위치에 번역된 코드가 있다고 가정하고 cpu는 0번 부터 순차적으로 코드를 실행합니다.
코드에는 문자열 %d 를 1로 바꾸는 코드도 있고 완성된 문자열을 출력하는 코드도 있습니다.
-3 출력
특정 메모리 위치에 완성된 문자열을 두고 gpu에게 알려줍니다. #cpu에서 gpu로 전기를 쏴주면 gpu가 놀라서 문자열을 출력합니다.
gpu는 신호를 받고 문자열을 읽어서 연결된 화면에 그려줍니다.


5. 조금 자세한 설명

더 자세하게 풀어봅시다.
위의 설명보다는 현실적으로 운영체제를 포함해서 설명하겠습니다.

-1 컴파일
작성한 코드는 기계어로 변역하고 필요한 함수와 가지고 있는 함수를 목록화 합니다. #링크하기전 컴파일만 한 파일을 object file 등으로 불리고 .o 확장자를 사용합니다.
main.c - """ #include<stdio.h> int main(){printf("");} """ ->
main.o {필요 : [printf], 소유: [main, globalV1], 코드 : [main(b01011...)] }

-2 링커
필요한 함수 및 변수를 적절하게 연결시켜 줍니다.
main.o {필요 : [printf], 소유: [main, globalV1] } , printf.o { 필요 : [...], 소유 : [printf] } ->
a.out {
    start_address : main ,
    section(위치 주소) : [ data(start:2, length:1, mode: read-write ), text(start:3, length:1, mode: [readOnly, executable]) ] ,
    data(전역 데이터) : [ globalV1(main.c:globalV1), stdout, stderr, stdin... ] ,
    text(코드) : [ printf(printf.c:printf, b01011...), main(main.c:main, b01011...) ] ,
 }
연결이라고 했지만 조금 더 직접적으로 설명하면 call &printf 의 &printf 를 코드 주소로 변경해주는 작업입니다.
call &printf -> call 0x1030 로 변경됩니다.
# 함수의 호출은 간단하게 설명하면 단순히 cpu에게 함수의 위치부터 실행하고 돌아오라고 명령하는 것을 쉽게 사용할 뿐입니다.
예) 그 다음 명령은 printf가 위치한 위치부터 실행해 그리고 실행 다 하면 원래 위치를 앞에 저장해둘 거니까 여기로 돌아와서 실행해


-3 실행
운영체제는 a.out의 데이터를 확인후 필요한 크기 만큼의 메모리를 할당한 후 코드 및 데이터 들을 불러옵니다.
필요한 크기에는 프로그램이 사용할 힙, 스택 또는 프로그램 실행 중 운영체제가 프로그램 관련해서 사용할 메모리 등도 포함됩니다.
이때 적절히 헤더를 확인하여 메모리에 크기는 얼마인지 실행해도 되는 영역인지 수정해도 되는 영역인지 등을 확인 후 데이터에 제약을 겁니다.
대체로 a.out의 section의 mode 를 참고하여 제약을 겁니다.
예) [커널 메모리, 데이터, 코드, 힙, 스택...] 를 메모리에 할당
cpu에게 다음 실행할 코드의 위치는 main이라고 알려주면 cpu는 바로 main부터 실행합니다.
# 실제로는 기본값으로 컴파일하고 실행하면 linux에서는 __start 부터 시작합니다. main의 args등을 관리하거나 자원들을 관리합니다.
# 운영체제라고 다른 프로그램과 다르지 않습니다. 조금 더 권한을 많이 가지고 있는 프로그램입니다.


-4 출력
main을 실행하다 출력을 해야 될 상황이 오면 운영체제에게 출력을 해야할 데이터가 있다고 알려줍니다.

방식은 interrupt로 위에서 생략했지만 cpu는 명령어를 읽고 실행할 때마다 하는일이 하나가 더 있습니다.
cpu는 명령어를 읽고 실행하고 완료되면 interrupt가 발생했는지 확인합니다.
거창하게 말했지만 그냥 특정 위치의 값이 0인지 확인합니다. 0이 아니라면 각 번호에 맞는 루틴을 실행하고 돌아옵니다.
이 루틴을 Interrupt Service Routine, ISR 라고 합니다.
예) 값이 1이라면 메모리 10번 위치의 코드를 실행

이를 활용하여 main 프로그램은 interrupt 값을 변경하면 cpu가 다음 명령을 읽기전에 interrupt 를 확인하고 ISR을 실행합니다.
실행되는 코드에는 운영체제가 있습니다.
운영체제는 이후 상황에 맞춰서 출력하라는 코드면 데이터를 가져와 이동시켜 줍니다.

-5 운영체제
cpu는 interrupt 가 발생한 것을 보고 또는 프로그램의 요청으로 원래 하던 프로그램을 멈추고 ISR(os 코드 또는 커널 코드, 프로그램)을 실행합니다.
이때 cpu에는 계산하던 값, 원래 실행하던 위치, 실행하던 함수 등의 정보가 남아있는데 이를 context 라 그러고 context를 메모리에 저장합니다.
이후 CPU는 커널 프로그램이 필요한 커널의 context를 불러와 실행합니다.
커널은 자기가 왜 실행됐는지 확인하고 printf 요청이 온 것을 확인합니다.
printf 요청과 이에 맞는 인수 등을 읽고 출력을 합니다.

-6 디스플레이
화면 출력은 각자의 상황마다 다르고 복잡한 부분입니다.
기기마다 화면이 다르고 연결 방식이 다르고 fps도 다르고 사용자마다 원하는 크기도 다릅니다.
그래서 이러한 것을 해결하기 위해 운영체제가 존재합니다.
뒤에서 더 자세히 설명하겠지만 간단하게 설명하겠습니다.

"a" 만을 출력한다고 가정해보겠습니다.
모니터는 20*20 픽셀을 입력으로 받아서 출력할 수 있고 이 데이터는 메모리의 10,001번 위치에서 20*20*3(RGb) 크기만큼 11,200 까지의 데이터를 초당 30번씩 알아서 가져갑니다.
운영체제는 기본 화면은 검은색으로 만들거니 임시 위치 20,001번에부터 21,200까지를 0으로 가득 채웁니다.
이 위에 a라는 글자를 표시하기 위해 저장된 a 글꼴 데이터를 가져와 확인합니다.
a의 데이터를 0,0 번 위치에 설정된 크기만큼으로 조절하여 메모리 20,001~21200의 값을 변경합니다.
위의 작업들이 다 끝나면 메모리 10,001에 20,001의 값들을 전부 복사하면 모니터가 초당 30번에 한번씩 가져갑니다.

위의 작업은 단순한 텍스트 하나 출력하기라 작업량이 적습니다.
그러나 여러분이 보는 화면에는 텍스트 뿐이 아닌 그림, 도형 등 다양한 객체들이 있습니다.
이를 하나 하나 계산하는 작업을 초당 30번 또는 그 이상을 하면 cpu는 연산이 아닌 화면 그리기 일만을 해야합니다.
심지어 모니터가 알아서 데이터를 가져간다는 가정을 했기에 실제로는 모니터로 적절히 보내주는 작업 또한 해줘야 합니다.
이를 위해서 그래픽카드가 존재합니다.



6. 자세한 설명
6.1 코드 관점

우선 printf의 코드를 따라가보겠습니다.
계획은 코드 단위로 따라가며 syscall 까지 보는 것이였지만 코드만 많아지고 길어져서 생략합니다.

밑의 내용은 함수 단위로 실제 실행에서 이루어지는 과정입니다.
최종적으로 write 함수에서 syscall 이 이루어집니다.

"""
#0  __GI___libc_write (fd=1, buf=0x1bfd2a0, nbytes=9) at ../sysdeps/unix/sysv/linux/write.c:26
#1  0x00007fb7368b7665 in _IO_new_file_write (f=0x7fb736a0a6a0 <_IO_2_1_stdout_>, data=0x1bfd2a0, n=9) at fileops.c:1181
#2  0x00007fb7368b69d6 in new_do_write (fp=0x7fb736a0a6a0 <_IO_2_1_stdout_>, data=0x1bfd2a0 "qwer112\n\n", to_do=to_do@entry=9) at libioP.h:948
#3  0x00007fb7368b8709 in _IO_new_do_write (to_do=9, data=<optimized out>, fp=<optimized out>) at fileops.c:423
#4  _IO_new_do_write (fp=<optimized out>, data=<optimized out>, to_do=9) at fileops.c:423
#5  0x00007fb7368b7cde in _IO_new_file_xsputn (n=4, data=<optimized out>, f=<optimized out>) at libioP.h:948
#6  _IO_new_file_xsputn (f=0x7fb736a0a6a0 <_IO_2_1_stdout_>, data=<optimized out>, n=4) at fileops.c:1197
#7  0x00007fb7368a13db in __vfprintf_internal (s=0x7fb736a0a6a0 <_IO_2_1_stdout_>, format=0x402004 "qwer%d12\n\n", ap=ap@entry=0x7fffa0fb77a0, mode_flags=mode_flags@entry=0) at ../libio/libioP.h:948
#8  0x00007fb73688dd9b in __printf (format=<optimized out>) at printf.c:33
#9  0x000000000040115c in main () at main.c:6
#10 0x00007fb73685dd0a in __libc_start_main (main=0x401142 <main>, argc=1, argv=0x7fffa0fb7978, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffa0fb7968) at ../csu/libc-start.c:308
#11 0x000000000040108a in _start ()
"""

6.2 운영체제 관점

코드가 완성되고 a.out 이라는 실행할 수 있는 파일이 생겼습니다.
실행 권한을 부여하고 "chmod +x a.out" 실행할 때 운영체제에게 무엇을 요청하는지 따라가봅시다.

"""
> strace ./a.out
execve("./a.out", ["./a.out"], 0x7ffc5ece82a0 /* 26 vars */) = 0
brk(NULL)                               = 0x8a4000
brk(0x8a4c40)                           = 0x8a4c40
arch_prctl(ARCH_SET_FS, 0x8a4300)       = 0
uname({sysname="Linux", nodename="9981b6840ff7", ...}) = 0
readlink("/proc/self/exe", "/share/work/clang/print/a.out", 4096) = 29
brk(0x8c5c40)                           = 0x8c5c40
brk(0x8c6000)                           = 0x8c6000
mprotect(0x4b0000, 12288, PROT_READ)    = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0x9), ...}) = 0
write(1, "{{{{{{{{{1}}}}}}}}}", 19)     = 19
exit_group(0)                           = ?
+++ exited with 0 +++
"""

가장먼저 os 는 실행을 요청받습니다.
strace 결과에는 안 보이겠지만 execve 전에 fork로 프로세스를 새로 만들고 execve 를 실행될 것입니다.
linux 에서는 새로운 프로그램을 실행하기 위해서 우선 프로세스를 만들고 그 프로세스에 실행할 프로그램 정보를 넣어주어야합니다.
새로운 프로세스는 fork 로 만들어지고 fork 는 자신을 복사하여 자식 프로세스를 만드는 명령입니다.
strace ./a.out 로 실행하면 strace 가 이 동작을 수행하며 strace 가 부모입니다.
./a.out 로 실행하면 실행한 프로그램 bash, sh 등등이 실행 주체이고 부모입니다.
fork의 사용법의 C코드로는 아래와 같고 b.out으로 컴파일하여 실행하면 b.out 이 부모이고 a.out 이 자식인 프로세스가 실행됩니다.
이때 fork는 부모에 의해 요청되었고 exec* 부터 자식에 의해 실행되므로 strace 에는 exec* 부터 보이게 됩니다.

"""
#include <stdio.h>
#include<unistd.h>

int main(){  
    if(fork() > 0)
        printf("부모-%d", getpid());
    else{
        printf("자식-%d", getpid());
        execl("/a.out", "/a.out",NULL);
    }
}
"""

이후의 systemcall들 또한 실행과 관련된 명령이고 fstat, write가 출력과 관련된 명령입니다.
linux는 대부분의 입출력을 파일과 비슷하게 관리하기 때문에 일반적인 입출력 또한 fstat 출력 파일을 열고 write로 데이터를 보냅니다.

7. 정리
최종적으로 printf의 동작을 보면

1. printf 호출과 printf 함수의 위치를 찾아서 연결
2. 연결한 코드를 묶어서 실행파일 작성
3. os에 실행 요청
4. 실행 중 os에 출력 요청
5. 요청 받은 os 코드의 출력 실행

8. 나중에 추가할 내용

7.5의 os 코드의 출력 실행 - 시작하면 너무 오래 걸릴 것을 예상해서 안 했지만 언젠가는 추가해두겠습니다. linux 커널 출력과 stdout 부분위주로 분석해서 정리할 생각입니다.

9. 후기

매우 오래전부터 생각해왔던 주제의 글이지만 쓰고 싶은 내용이 많고 모르는 내용도 많아서 그런가 정리가 잘 안 되는 느낌이입니다.
더 오랜시간 두고 천천히 수정하고 추가하면서 고칠 예정입니다.
목표는 printf로 컴퓨터공학 학부생 과정 설명하기입니다.


'기타' 카테고리의 다른 글

alpine + web component 사용  (1) 2023.12.21
webrtc 간단한 설명  (0) 2023.11.16
punycode encoding/decoding 알고리즘(bootstring) - 2  (0) 2023.05.09
punycode encoding/decoding 알고리즘(bootstring)  (0) 2023.05.09
iptables 정리  (0) 2023.04.18
Comments