C and POSIX

장서연·2021년 6월 23일

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/24d4bd3e-49b7-4617-a77e-b40dc5fee24b/Untitled.png

목표 : 시스템 프로그래밍을 하는데 있어 필요한 기본 개념인 C언어와 POSIX API에 대해 학습한다

  • C 언어는 시스템프로그래밍을 위해 디자인된 프로그래밍 언어이다.
  • POSIX는 유닉스를 기반으로 한 표준화된 운영체제의 인터페이스이다.
  • POSIX API는 C언어로 구현되어 있다.

1. 왜 C 언어인가?

C는 여러 함수들과 다양한 데이터 타입을 지원하는 high-level 언어라고도 볼 수있으나, 동시에 굉장히 low-level의 메모리 연산을 직접적으로 수행할 수 있는 언어이다. 그렇기에, 대부분의 운영체제 커널들은 C언어로 작성되어있다.

2. POSIX란 무엇인가?

Portable Operating System Interface.

세상에는 다양한 운영체제가 존재한다. 각 운영체제마다 시스템 콜을 하는 방법이 다른데, 만약 운영체제가 제공하는 기능을 하나의 표준화된 프로그래밍 인터페이스로 만들어놓은 뒤, 이 인터페이스를 사용하여 앱을 작성한다면, 어떤 운영체제에서든 어플리케이션을 구동시킬 수 있게 될 것이다. 이러한 필요성으로 POSIX가 고안되었다. 익숙한 printf()함수는 POSIX 함수 중 하나이다. 예를들어, 우분투에서 printf()를 쓰는 프로그램을 작성했을 때, 이 프로그램은 다른 리눅스 배포판에서도 똑같이 동작할 수 있게된다.

즉, 운영체제에 맞추어서 어플리케이션을 만드는 것이 아니라, 표준화된 프로그래밍 인터페이스, 즉 POSIX에 맞추어 어플리케이션을 작성하는 것이다.

이렇게 작성된 어플리케이션은 recompile하여 여러 운영체제에서 실행이 가능하게 된다.

APPLICATIONS

 🔽

LIBRARY/SHELL INTERFACE

        🔽

OS Kernal (System call)

위 구성은 모든 운영체제에서 사용하고 있는 아주 기본적인 프로그램 스택이다. 사용자가 어떤 어플리케이션을 실행할 때, 이 앱은 내부적으로 시스템 라이브러리들이나 쉘 인터페이스를 사용해서 운영체제에서 사용하는 기능들을 쓸 수 있도록 프로그래밍 되어있다.

그런데 만약, 어떤 운영체제에서는 printf()함수가 시스템 라이브러리에 구현이 되어있지 않다면 어떻게 될까?

답은, 그 운영체제에서 이 printf()함수를 쓰는 프로그램은 동작하지 않는다.

다시한번 말하자면, POSIX는 어느 운영체제에서는 되고, 어떤 운영체제에서는 안되는 불편함을 해소하기 위해 고안되었고, 덕분에 편하게 다양한 운영체제에서 동작하는 프로그램을 작성할 수 있게 되었다.

2.1 POSIX에 대한 구체적인 설명

POSIX가 구현하고 있는 것은, 응용프로그램에서 불려질 수 있는 함수들이다. 이 함수들은 내부적으로 운영체제에서 제공하는 시스템 콜들을 이용해서 구현이 되어있다. 그렇기 떄문에, 개발자들은 여러 운영체제에서 제공하는 정확한 시스템콜 스펙들을 모르더라도 응용프로그램을 작성하는데 있어 POSIX 인터페이스를 통해 운영체제에서 제공하는 기능들을 쉽고 간편하게 사용할 수가 있는 것이다.

MicroSoft의 Windows OS의 경우, POSIX를 제공하지 않았었다. 그러나, 최근 WSL(Windows Subsystem for Linux)로 응용프로그램들에게 POSIX인터페이스를 제공하였다. 최근 MS의 행보가 꽤나 괜찮은데, 오픈소스를 진영에서 보자면 VSC를 지원, 무엇보다 MS가 깃헙 인수하고private 레포지토리 작성을 무료로 풀었다.

그래도 아쉬운 점이 윈도우는 개발환경이 구리다는 점이었는데, 윈도우에서 리눅스 명령을 사용할 수 있게 하는 WSL이 개발됨으로써, 엄청난 GAME CHANGER 인 것이다.

2.2 POSIX API Recap

응용 프로그램들에서 공통적으로 부를 수 있는 함수들의 집합이다.POSIX API들은 C언어로 구현되어있으며, 내부적으로 여러 운영체제들의 시스템콜을 이용해서 이루어져있다. 시스템콜은 운영체제에서 제공하는 함수이다. 각 운영체제들은 시스템 콜들을 서로 다른 형태로 제공하고 있는데, POSIX API를 사용하면 개발자들이 내부적으로 시스템 콜이 어떻게 구현되어있고, 또 어떻게 사용하는지를 몰라도 응용프로그램을 작성할 수 있게 한다.

따라서 이 수업에서는 시스템콜에 대해서 학습을 하기 보다는, POSIX API를 이용해서 프로그래밍을 하는 방법에 대해 공부할 것이다.

2.3 POSIX 함수의 예시

  • open(): Opens a file for reading or writing
  • fork(): Creates a new process
  • exit(): Gracefully terminates the current process (현재 프로세스를 정상적으로 종료한다)
  • etc... 이 밖에도 POSIX함수는 정말 정말 많다. 이 수업에서는 대표적인 POSIX함수만을 공부할 것이다.

3. C언어 문법

가장 대표적인 포인터에 관해서만 짚고 넘어가겠다.

pointers

  • 포인터는 메모리 주소이다
  • 중요한 특징으로, 타입을 갖는 메모리 주소라는 점인데, 정수를 가리키는 integer pointer, 문자타입을 가리키는 char pointer

C 에서 포인터를 많이 사용하는 이유는, 포인터가 가리키는 메모리에 저장된 값을 편리하게 조작하기 위해서이다.

컴퓨터 안에는 메모리가 있고, 이 메모리 안에는 여러가지 형태의 데이터가 저장되어있다. 하지만, 이 데이터들이 특정한 데이터 타입과 연결되어있지 않은 경우, 이 메모리 안의 데이터들은 단순이 01010110...의 비트 집합에 불과하다. 그러나 이 메모리 안에 저장된 데이터들이 특정한 타입과 연결이 된 경우에는 각 데이터들이 정수로도 쓰일 수 있고, 스트링으로도 쓰일 수 있게 된다. 컴퓨터에게 메모리는 단순한 비트들에 불과한 것을 프로그래머들이 이것에 의미를 부여하는데,이러한 Memory Representation을 다음 수업 주제로 다룰 것이다.

Null Pointer

특별한 포인터로, NULL포인터가 있다. 이 포인터는 정수값으로 0을 담고있는 포인터를 의미한다. 포인터는 메모리 주소이기 때문에 0이 아닌 특정한 주소값을 가지고 있어야 한다. 근데 이 값이 0이라는 것은 메모리 주소가 0이라는 것인데, 이는 무언가 잘못된 포인터라고 볼 수 있는 것이다. 즉 유효하지 않은 주소값을 갖고있는 포인터이다.

pointer syntax

char* str; // 캐릭터 타입의 데이터를 가리키는 포인터 변수 str
const char* str;

const키워드와 쓰임으로써, 이 포인터가 가리키는 데이터는 상수취급을 시킬 수 있다. 즉, 이 포인터가 가리키는 데이터는 포인터를 통해 조작이 불가하다.

int x = 12 ;
int* ptr = &x ;
*ptr = 24 ;

보통 포인터 변수를 통해 데이터에 접근할 때는 을 쓰지만, 이외에도 - > 나, [ ] 을 통해 접근할 수 있다.

int* ptr = &x ;
y = ptr[0] ; // y = *ptr
// 포인터는 배열로 접근될 수 있다.

앞으로, 포인터를 사용할 때 주로 구조체를 대상으로 포인터를 많이 사용할 것인데 구조체를 사용할 떄는 *이나 [ ] 등의 연산자보다는 - > 화살표 연산자를 이용해서 포인터를 사용하게 될 것이다.

포인터와 배열

C에서 포인터와 배열은 매우 밀접한 관계를 갖고있다.

char arr[] = "Hello World";
char* ptr = arr ;

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8c30666c-bdf5-4986-8cde-c87af0c27cb7/Untitled.png

4. C/POSIX Text I/O

C와 포직스는 텍스트에서의 다양한 입출력 기능을 제공한다. (이미지 등 바이너리 파일은 추후에 다룬다)

이들은 편리한 읽기/쓰기 기능을 제공하는데 이에 대한 API는 헤더파일인 stdio.h에 정의되어있다. 익숙한 printf()함수를 쓰기 위해서 # include <stdio.h>를 꼭 써주어야 하는 이유가 여기에 있다.

이 헤더파일에는 많은 종류의 입출력 함수들이 정의되어있다.

4.1 Standard I/O

stdio.h파일에 정의된 함수들이며, Posix의 입출력 시스템에서 가장 기본이 되는 함수들을 모아놓았다.

리눅스 시스템들, Posix시스템들은 기본적으로 모든 입출력이 파일 포인터를 이용해서 구현되어있다. 이 말은 즉, 입력과 출력하는 기능들이 특정 파일을 통해 수행된다는 것이다.

어떤 프로그램들이던, POSIX 시스템 위에서 실행하는 경우, 기본적으로

  • stdin
  • stdout
  • stderr

이 세가지의 파일들이 함께 생성이 된다. 어떤 프로세스던지, 새로 시작될때 입출력을 위해 이 세 파일들이 생성이 된다. 함수들이 stdio함수들이고, 이 함수들은 stdio.h파일에 정의되어있다.

Basic Text I/O

텍스트 I/O를 위한 몇가지 기본적인 함수들은 아래와 같다.

  • puts

    가장 먼저 살펴볼 함수는 puts이다. 이 함수는 스트링을 출력하는 기능을 하는데, 어느 파일을 통해 출력하냐면 stdout파일을 통해 텍스트를 출력한다.

    #include <stdio.h>
    int puts(const char *s); 
    int fputs(const char *s, FILE *fp);
    //fputs 는 puts와 비슷한 기능을 하는데, 한가지 다른 점은 
    // 파일 포인터를 지정해주어야 한다는 점이다.
    // 파일 포인터로 stdout을 지정하면 puts함수와 동일한 기능을 
    // 하게 된다
  • printf

    C에서 텍스트 아웃풋을 담당하는 함수 중 가장 유명한 함수이다. 특이한 점은, 지정한 포맷의 데이터 형식으로 데이터를 변환할 수 있고, 인자를 가변적으로 변환할 수 있는 가변인자함수라는 점이다.

지금부터는, 텍스트 데이터를 읽어들이는 함수에 대해 알아보자

  • fgets

    fgets함수는 지정한 파일로부터 특정 사이즈의 데이터를 한 줄씩읽어들일 때 편리하게 사용할 수 있는 함수이다. 지정한 버퍼로 읽어들일 수 있다.

    특이한 점은, 데이터를 읽어들일 때 새로운 줄을 만날때까지만 읽어들인 다는 점이다. 예를들어, fgets함수를 사용해서 10바이트씩 데이터를 읽어들이는 프로그래밍을 했다고 하자, 첫 줄에 5바이트가 있을 때, 이 프로그램은 첫 줄의 5바이트를 읽고 그 다음줄의 5바이트를 마저 읽는 것이 아닌 첫 줄의 데이터만 읽어들인다.

    따라서 이 함수는 어떤 파일을 한 줄 씩 읽어들이는 기능을 사용하고 싶을 때 용이하다.

  • scanf

4.2 Formatted I/O Example

#include <stdio.h>

int main(int argc, char *argv[]) {
  double d;
  int conversions;

  printf("Input a floating-point number: ");
  conversions = scanf("%lf", &d);

  printf("There were %d successful conversions\n", conversions);
  printf("You Entered: %f\n", d);
  return 0;
}

// scanf에서 사용자의 입력을 받는다. scanf함수는 텍스트 데이터를
// 읽어들이지만, 원하는 타입의 데이터로 텍스트를 변환하여 원하는
// 버퍼(&d)에 저장을 할 수 있다.
// 이 함수는 사용자의 입력을 double타입의 변수에 저장한다.
// scanf함수는 반환값으로 정수값을 리턴하는데,
// 이 정수 값의 의미는 '성공적으로 변환한 데이터타입의 개수'이다
// 성공적으로 데이터 타입이 변환되었다면 위 예제에서는 1이라는 값이
// conversions변수에 할당되게 된다
#include <stdio.h> 
int printf(const char *format, ...);
int fprintf(FILE *fp, const char *format, ...);
// fputs와 비슷한 맥락으로, fprintf함수는 출력할 파일을
// 지정해 주어야 한다. stdout으로 지정하면 printf함수와
// 동일한 기능을 하게 된다

4.3 More on Text I/O

posix 시스템에는, 정말 정말 많은 텍스트 입출력함수들이 존재한다. 이를 보기 위해 매뉴얼 페이지를 방문해보길 바란다. 매뉴얼 페이지에는 포직스 시스템에 구현되어있는 모든 기능들이 상세히 설명되어있다.

특히 fprintf나 scanf사용시 가능한 옵션들이 많이 있다. 매뉴얼 페이지를 통해 다양한 옵션들을 확인하기 바란다.

지금부터, C언어 프로그램이 어떻게 컴파일되고 최종적으로 실행가능한 프로그램으로 변환되는 단계를 살펴보자

C의 컴파일링 과정을 상세히 다루는 이유는, 내가 작성한 프로그램이 운영체제 위에서 어떠한 형태로 Posix API들과 상호작용하면서 동작하는지 알 수 있게 하기 때문이다

1. The C Tool chain

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b7143851-c2a4-476f-9e1b-5cb2d1fd87c5/Untitled.png

(1) 가장 먼저, 작성한 C 소스코드는 전처리기인 C preprocessor에 의해 소스코드가 변환된다. 이 변환에는 #include한 헤더파일들이 추가되어 소스코드에 붙여지는 작업 등이 진행된다.

(2) 전처리된 소스코드는 컴파일러를 통해 어셈블리 파일로 변환된다. 어셈블리파일의 확장자는 .s 이다.

(3) 어셈블리어로 되어있는 어셈블리파일은 어셈블러를 통해 오브젝트파일로 변환된다. 이 오브젝트는 '기계어 파일', '바이너리 파일'이라고도 한다.

(4) 오브젝트 파일만으로는 프로그램이 동작할 수 없다. 왜냐하면, 앞서 살펴본 Posix API를 부르는 경우도 많고, 다른 오브젝트 파일에 정의된 함수들을 부르는 경우도 있기 때문이다. 따라서 다른 오브젝트 파일이나, 외부 시스템라이브러리들을 링커를 통해 연결한다.

(5) 이제, 실행가능한 프로그램 .exe가 최종적으로 나오게되고, 실행할 수 있게 된다.

1.1 C Preprocessor

C 전처리기의 대표적인 역할은 #include의 헤더파일의 내용을 참조하여 작성한 소스코드에 붙여준다는 것이다.

1.2 C Compiler

전처리되어 변환된 소스코드들은 컴파일러를 통해 machine-dependent한 어셈블리 코드로 변환된다.

각 컴퓨터 아키텍쳐별로 사용하고 있는 어셈블리 코드의 형태와 모양은 다 다르다. 예를 들어, ARM아키텍쳐의 경우 ARM에서 정의된 어셈블리 코드를 사용하고, 인텔 아키텍쳐는 인텔 어셈블리 코드를 사용하는 것이다.

따라서 컴파일러는 일련의 명령어들을 통해 사용자(개발자)가 지정한 컴퓨터 아키텍쳐의 어셈블리 코드로 소스코드를 변환시키는 역할을 담당한다.

1.3 The Assembler

어셈블러는 어셈블리어로 이루어진 어셈블리 파일을 기계어로 변환시키는 역할을 한다. 즉 오브젝트파일로 만들어낸다.

오브젝트 파일은 다음의 정보가 들어있다.

  • Constant Data

    — 이 오브젝트 파일에서 쓰인 상수값이 들어있다

  • Static symbols

    — 이 오브젝트 파일 바깥에서는 사용되지 않는, 즉 지역적으로 정의된 변수와 함수들이 들어있다

  • Locally-defined globals

  • Unresolved symbols

    — 예를 들어, a.c와 b.c 소스코드로 이루어진 프로그램하나를 구현했다. 각 소스코드 파일은 각각의 오브젝트 파일로 변환된다.

      a.c →a.o      b.c → b.o

    만약, a.o 오브젝트 파일에서 b.o에 구현된 함수 b 를 호출한다고 가정하자. a.o에는 함수의 이름만이 기록되어있고, 위치는 기록되어있지 않다. 이러한 형태의 심볼들을 Unresolved Symbols라고 한다. 즉 함수 호출 형태는 있으나 이 함수의 정의(위치)는 외부에있는 형태이다.

    대표적인 예는, 외부 시스템라이브러리를 사용하고 있는 경우이다. 개발자는 시스템 라이브러리의 함수 이름은 알지만, 그 함수의 위치는 모른다. 이 경우 Unresolved Symbols라는 형태로 정보가 들어가 있다.

    1.4 Linker

    링커는 오브젝트 파일들을 묶어서 하나의 실행가능한 프로그램으로 만들어주는 역할을 한다. 이 과정에서 링커는 각 오브젝트 파일을 대상으로 심볼테이블이라는 것을 만든다.

    이 심볼테이블에는 Unresolved Symbols함수들과 그 함수의 위치가 적혀있다.

    링킹과정에서, 링커가 a.o 그리고 b.o 오브젝트 파일을 묶어서 a.exe라는 실행파일을 만든다고 가정하자.

    심볼테이블

만약 a.o에서 printf함수를 부른다면?

printf함수는 외부 라이브러리에 구현된 함수이다. 그래서 심볼테이블에 기록을 해두지만, 이 함수의 위치는 링킹과정 중 알수도 있고 모를수도 있다.

예를 들어, static옵션을 사용해서 외부 라이브러리들을 하나의 실행파일로 묶어 컴파일하는 경우에는 이 심볼이 resolve될 수 있지만, static옵션을 사용하지 않는 경우 링킹과정에서printf 함수의 주소를 알 수 없다.

이렇게 함수의 주소를 모르는 경우, 그냥 빈칸으로 내비둔다.

빈칸으로 내비둔 함수의 위치는 프로그램이 실행할 때 동적으로 그 라이브러리 함수의 주소를 알아와 프로그램을 실행하는데에 문제가 없게 링커가 관리한다.

2. Warning

컴파일 과정중 에러가 발생하면 컴파일러는 즉시 컴파일을 중단한다. 따라서 실행파일이 만들어지지 않는다. 하지만 에러가 아닌 경고의 경우, 실행파일이 만들어지는 경우가 있다.

경고를 하는 이유는 C표준에 맞지 않게 코드를 작성해서이다. 그러니, 경고 메시지도 유심히 보아 C 표준에 맞는 소스코드를 작성하는 습관을 들이는 것이 중요하다.

지금부터, 리눅스 시스템환경에서 가장 유명한 컴파일 도구인 gcc를 이용해 hello.c 소스코드를 hello.exe실행파일로 만들어보자

gcc -o helloworld helloworld.c
gcc -Wall -Werror -O2 -g -std=c99 -o helloworld helloworld.c

gcc 명령어는 내부적으로 전처리기, 컴파일러, 어셈블러, 링커들을 하나하나 차례대로 실행시켜 최종 형태인 실행프로그램을 만든다.

0개의 댓글