본문 바로가기
42Seoul/gnl

gnl개념

by 하고싶은건많은놈 2023. 2. 16.

get_next_line?

char    *get_next_line(int fd);

get_next_line, 이하 gnl은 매개변수 fd로 받아온 숫자에 해당하는 파일 디스크립터의 텍스트에서 개행문자 \n으로 구분되는 한 줄의 문자열을 반환하는 함수이다.
다소 간단해보이는 목표에 비해 이것저것 고려할게 많아 구현이 그리 쉽지만은 않았다.

gnl 과제에 필요한 개념들

  1. 파일 디스크립터
  2. read 함수
  3. 메모리 영역
  4. 정적 변수
  5. 메모리 누수
  6. 입출력 리다이렉션

File descriptor?

간략하게 짚고 넘어간 적이 있다
참고한 블로그 링크
엄청나게 정리가 잘 되어있는 블로그
File descriptor란 리눅스 / 유닉스 계열 시스템에서 사용되는 개념이다.
시스템의 프로세스(process)가 파일(file)을 다룰 때, 해당하는 특정 파일에 접근하기 위해 사용되는 추상적인 값이라고 한다. 이 때 유닉스 시스템에서 일컫는 파일이란 일반적인 파일부터 디렉토리, 소켓, 파이프 등 모든 객체들을 말한다.

fd는 정수 값으로 표시되며, 최댓값은 플랫폼에 따라 다른 OPEN_MAX라는 값이다. C에서는 limits.h에 정의되어있다.
기본적으로 프로세스 내의 fd는 중복될 수 없지만, fd는 프로세스 내부에서 사용되는 값이므로 서로 다른 프로세스에서는 같은 fd를 이용할 수 있다.

유닉스의 작동방식

유닉스는 키보드와 모니터를 포함한 모든 것들을 파일로 모델링한다고 한다.
예를들어, 디스플레이에 데이터를 출력하는 것은 디스플레이를 담당하는 어떤 파일에 데이터를 쓰는 것과 같다. 키보드에서 데이터를 읽어오는 것은 키보드를 담당하는 어떤 파일에서 데이터를 읽어오는 것과 같다.

스트림(Stream)

입력장치인 키보드의 버튼을 입력하면 신호가 컴퓨터로 전송되고, 그 신호를 처리하여 출력장치인 모니터로 보내 화면에 문자가 나타난다.
그러나 입출력장치는 키보드와 모니터만이 있는 것이 아니다. 프린터, 마우스, 네트워크, 메모리 등등 다양한 장치들이 있고, 프로그램이 각각의 장치에 직접 접근하여 작동하는 것은 상당히 비효율적이다.
때문에 모든 장치에서 읽고 써지는 데이터들을 일괄적으로 처리하는 중간자 역할로 만들어낸 것이 바로 스트림이다.
어떤 장치에서건 필요한 데이터들은 모두 입력 스트림으로 모인다. 반대로 장치로 내보내기 위한 데이터들은 모두 출력 스트림으로 모인다.

  • 물리 디스크 상의 파일, 장치들을 통일된 방식으로 다루기 위한 가상적인 개념.
  • 연속된 문자 또는 데이터. 텍스트 스트림 / 바이너리 스트림으로 구분됨.
  • 유닉스에는 미리 연결되어있는 표준 스트림(standard streams)이 존재함.

표준 스트림

일반적으로 유닉스에서 동작하는 프로그램은 실행시 세 개의 스트림이 자동으로 열리고, 종료시 자동으로 닫힌다.
C에서는 stdio.h 헤더에 정의되어있으며, 스트림도 파일로 취급하여 다뤄지기 때문에 unistd.h 헤더 안에 각각의 스트림에 해당하는 fd값이 선언되어있다.

  • stdin : Standard input, 표준 입력. fd = 0.
  • stdout : Standard output, 표준 출력. fd = 1.
  • stderr : Standard error, 표준 에러출력. fd = 2.

따라서 새롭게 사용되는 파일들은 fd값이 3부터 할당되게 된다.

fd의 처리 구조

image
  • fd table : = File Descriptor Table. file table을 참조하는 포인터를 보유하고 있는 테이블로, 프로세스 단위로 할당되어있음.
  • file table : file status flag, 현재 파일의 offset, v-node table을 참조하는 포인터를 보유하고 있는 테이블로, 머신 단위로 할당되어있음.
  • v-node table : i-node, 파일 크기 등 파일 실행을 위한 정보를 보유하고 있는 테이블.
  • i-node : 파일을 기술하는 디스크 상의 데이터 구조로, 파일에 대한 중요한 정보가 담겨있음. 각 i-node마다 고유 번호가 있어 파일 식별시 사용.

예를 들어 같은 파일을 두번 열었을 때, 각각 부여된 파일 디스크립터에 따라 서로 다른 file table이 생성된다. file table 내의 files status flag와 현재 offset은 서로 별도로 다루어지지만, v-node ptr은 같은 v-node table을 참조하여 동일한 파일 정보를 다루게 된다.

fd 변경

fd를 직접적으로 변경할 수 있는 함수는 존재하지 않고, 대신 복사하는 함수를 통해 간접적으로 변경해야한다.
fd를 변경하는 함수에는 dupdup2가 있으며, dup 함수는 현재 사용되지 않는 fd 중 가장 작은 값으로, dup2는 사용자가 지정한 fd값으로 복제한다. 이 때 지정한 fd가 이미 사용중이었다면 이미 열려있던 파일을 닫아 fd를 해제한 후 해당 fd값을 할당한다.

read?

#include <unistd.h>

    ssize_t read(int fd, void *buf, size_t count)

Linux manpage description

: read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.

해석 및 부연설명

: 파일 디스크립터 fd에서 count만큼의 바이트를 읽어 buf에 저장한다.
이 때 파일은 읽어들인 바이트만큼의 offset 값이 증가하게 되어 다음번 read시에는 그 위치부터 파일을 읽게 된다.

  • 정상적으로 작동시 : 읽어들인 바이트 수를 반환
  • 파일의 끝(EOF(End of File))에 도달했을 경우(= 더이상 읽을 데이터가 없을 경우) : 0 반환
  • 그 외 비정상적인 작동시 : -1 반환, errno 설정

반환값이 ssize_t(주로 signed int)인 이유는 함수의 작동 실패시 -1을 반환하기 위함이다.

EOF에 도달한 경우

count만큼의 바이트를 읽어오는 중간에 EOF에 도달한다면 read 함수는 거기서 작동을 멈추고 지금까지 읽은 바이트 수를 반환한다.
이후에 다시 한번 read함수가 실행된다면, offset이 EOF에 도달했으므로 0을 반환하게 된다.

block / nonblock

read 함수는 호출시 기본적으로 blocking된다. 파일에 읽을 데이터가 없는 경우(EOF를 만나는 경우와는 다름), read 함수는 읽어낼 데이터가 생길 때 까지 block되어 작동을 일시정지 상태로 바꾼다.

char	n[10];
read(0, n, 10);

위 코드의 경우 실행된 read는 표준 입력에서 10바이트를 받을 때까지 무한정 기다린다.
기다리지 않고 데이터가 없을 경우 바로 함수를 끝나는 경우가 nonblock이다.
read 함수를 nonblock으로 실행하고 싶다면 애초에 읽어들일 fd를 open 함수로 호출할 시 O_NONBLOCK 옵션을 사용하면 된다고 한다.

errno

read 함수가 -1을 반환하면서 설정한 errno는 error.h에 정의되어있다.
따라서 확인시 그냥 printf("%d", errno)로 확인해주면 된다고 한다.

  • EAGAIN (=EWOULDBLOCK) : nonblock으로 실행하였으나 읽어올 데이터가 없음
  • EBADF : 주어진 파일 디스크립터가 유효하지 않거나 읽기 가능한 모드가 아님
  • EFAULT : buf로 전달된 포인터가 호출하는 프로세스의 주소 공간 밖에 존재
  • EINTR : 시스템 콜 수행중 인터럽트가 걸려 수행이 중단됨
  • EINVAL : 파일 디스크립터가 읽기를 허용하지 않는 객체에 맵핑되어있음
  • EIO : 저수준 입출력 에러 발생
  • EISDIR : 파일 디스크립터가 디렉토리를 가리킴

메모리 영역

프로그램 / 프로세스 / 쓰레드

참고한 링크

  • 프로그램(Program)
    명령어들의 집합인 정적인 상태의 파일. CodeData로 구분되고, 프로그램 실행시 내부의 명령어들이 작동되며 무언가의 상태를 부여받게 된다.
  • 프로세스(Process)
    실행되고있는 동적인 상태의 프로그램. 프로세스가 생성되어 메모리가 부여되면 프로그램의 CodeData들이 프로세스 메모리에서 읽히게되며, 이 때 프로세스의 메모리는 Code Segment, Data Segment, Heap, Stack 의 독립된 영역을 가진다.
    각 프로세스의 메모리는 서로 독립되어있기 때문에 직접적으로 다른 프로세스에 접근할 수는 없다.
  • 쓰레드(Thread)
    프로세스가 할당받은 자원을 이용하는 기본 실행 단위. 각 쓰레드들은 Code Segment, Data Segment, Heap 메모리 영역을 공유하지만 Stack은 독립적으로 가진다. 그렇기 때문에 Stack 영역에 선언되는 지역 변수 등은 각 쓰레드마다 별도로 처리된다.

Code Segment / Data Segment / Heap / Stack

참고한 링크

image
  • Code Segment(=Text Segment)
    실행가능한 명령어와 코드를 포함한다. 프로세스가 종료될 때까지 유지되며, 프로그램이 이곳의 명령어들을 변경하지 못하도록 읽기 전용인 경우가 많다. 메모리 공간이 덮어씌워지지 않도록 일반적으로 HeapStack 메모리 공간 아래에 위치한다.
  • Data Segment
    메모리 공간을 효율적으로 사용하기 위해 Data SegmentBSS Segment로 구분한다. 이 영역들도 프로세스 종료시까지 유지된다.
    • Initialized Data Segment : 일반적으로 Data Segment라고 부르는 영역으로 초기값이 있는 전역 변수와 정적 변수를 포함한다.
      초기값을 ROM 메모리에 저장한 후, 데이터들을 활용하기 위해 RAM 메모리로 불러와 사용한다.
    • Uninitialized Data Segment : BSS(Block Started by Symbol) Segment라고 부르는 영역으로 이 영역의 데이터들은 프로그램의 실행과 함께 커널에 의해 0으로 초기화된다. 따라서 굳이 ROM에 저장하지 않고 바로 RAM 메모리로 불러와 사용한다.
      명시적 초기화가 없는 모든 전역 변수와 정적 변수를 포함하며, Data Segment공간의 끝에서 시작된다.
  • Heap
    BSS Segment의 끝 주소에서 시작한다. 일반적으로 동적 메모리 할당이 수행되며, malloc, calloc, realloc, free 함수를 통해 관리된다.
    런 타임에 크기가 결정된다.
  • Stack
    지역변수, 메개변수 등이 저장되는 임시 메모리로 LIFO(Last in First Out, 후입선출) 구조를 따른다. 컴파일 타임에 크기가 결정된다.
    함수 호출시 생성, 함수 종료시 반환되고 일반적으로 메모리 상위 주소에 위치해 Heap 영역쪽으로 데이터를 저장해나간다.
    스택 포인터와 힙 포인터가 가리키는 주소가 같아지면 사용가능한 메모리 공간이 전부 소진되었다는 의미이며, 그 이상으로 나아갈 경우 Heap Overflow(Heap 포인터가 Stack 영역을 침범) 또는 Stack Overflow(Stack 포인터가 Heap 영역을 침범)가 발생하게 된다.
    Stack의 크기는 시스템에 따라 상이하지만 일반적으로 수십Kb~Mb정도로, 너무 큰 배열이 선언될 경우 Stack Overflow문제가 발생할 수 있다. 이때는 Stack의 크기를 강제로 늘려주거나 Stack이 아닌 다른 메모리를 사용하는 형태의 데이터를 활용하면 된다.
    Stack의 크기는 ulimit -a명령어로 확인할 수 있다.

정적 변수

개념

정적 변수는 말 그대로 정적으로 할당되는 변수로, 초기화 구문이 있다면 data segement, 없다면 BSS segement 영역에 저장된다.
프로그램을 실행하기에 앞서 컴파일 시간에 메모리가 할당되며, 프로세스가 끝날 때까지 변수의 수명이 유지된다.
따라서 함수를 여러번 호출하면서 특정 변수의 값을 이어받아 사용해야하는 경우(=gnl)에 유용하게 사용할 수 있다!

특징

  • 선언만 했을 경우 자동으로 0으로 초기화된다. 단, 초기화는 한번만 이루어진다.
  • 정적 변수는 런타임 이전에 값이 결정되므로 초기화 구문 사용시 런타임에 이루어지는 함수 호출 구문을 이용할 수 없다.
  • 전역 변수로 정적 변수를 선언했을 경우 일반적인 전역 변수와는 달리 선언된 소스 파일 내에서만 사용 가능하며 파일 외부에서 extern으로 사용할 수 없다.
  • 매개변수로 정적 변수를 사용할 수 없다.

메모리 누수를 체크하는 방법

메모리 누수(memory leak)란?

위키피디아 링크
프로그램이 필요하지 않은 메모리를 계속 점유하고 있는 현상이다. 메모리 손실과는 다른 것이, 단순히 메모리를 잃는 것이 아니라 불필요한 메모리가 해제되지 않은 채 접근이 불가능해진 경우도 memory leak의 경우에 속한다.
따라서 메모리를 할당해준 경우, 반드시 정상적으로 해제하는 과정을 거쳐야 메모리 누수를 피할 수 있다.

valgrind

공식 사이트 링크
valgrind라는 별도의 프로그램을 설치해서 메모리 누수를 확인하는 방법이다.
단, 최신버전 mac os에서는 더이상 지원하지 않는다고 한다… 그래서 brew install Valgrind 명령어는 막혔지만, 다른 방법으로 우회해서 설치 후 사용할 수는 있다고 한다.
사용하기 위해선 확인하고자 하는 실행파일을 만든 후 valgrind ./a.out 형식으로 명령어를 입력하면 된다.

==1776== HEAP SUMMARY:
==1776==     in use at exit: 0 bytes in 0 blocks
==1776==   total heap usage: 21,536 allocs, 21,536 frees, 100,114,221 bytes allocated
==1776== 
==1776== All heap blocks were freed -- no leaks are possible
==1776== 
==1776== For lists of detected and suppressed errors, rerun with: -s
==1776== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

실행시 이런 식으로 결과가 출력되며, 메모리 누수가 있을 경우 자세하게 어디서 어떤 문제가 발생했는지까지 확인할 수 있다.
매우 자세하게 잡아줘서 꽤 많은 도움이 되었다.

leaks

Unix 환경에서만 실행 가능한 커멘드이다.
프로그램이 실행중인 상태로(간단히 while(1) 등으로 무한루프를 돌게 하면 된다) leaks 명령어를 입력하면 메모리 누수 여부와 위치 등을 알려준다.
또는 main함수가 리턴하기 전에 system("leaks a.out");를 추가한 상태로 컴파일한 실행파일을 실행시켜도 같은 결과를 도출해낼 수 있다.

lldb

공식 사이트 링크
위 두 경우와는 조금 달리, lldb는 디버거이다.
디버깅을 위해선 -g 옵션을 포함한 채로 컴파일해야하며, 터미널에 lldb명령어를 입력하면 디버깅모드로 진입할 수 있다.
디버깅모드에서 run a.out 또는 r a.out을 입력하면 해당 프로그램을 디버깅할 수 있는데, 그냥 실행시 프로그램이 종료될까지 바로 돌려버리기 때문에 breakpoint(중단점)을 설정하여 원하는 위치에서 프로그램이 멈추도록 설정해야한다.
중단점은 자신이 확인하고 싶은 곳에 넣으면 된다.

  • main함수에 걸고싶다면 b main
  • func함수의 10번째 줄에 걸고싶다면 b func.c:7,
  • next : step over. 한 줄 실행 후 함수가 있더라도 함수 내부로 들어가지 않음.
  • step : step into. 한 줄 실행 후 함수가 있다면 함수 내부로 들어감.
  • continue : 다음 중단점까지 실행.
  • finish : 실행중인 함수를 빠져나옴.
  • print 변수 : 변수의 값을 출력. 포인터 연산 및 구조체 관련 연산도 가능함. <br /> 그 외에도 많은 명령어들이 있다.
    명령어 모음
    lldb 튜토리얼

lldbleaks를 같이 사용하여 메모리 누수를 확인할 수 있다.
lldbrun 명령어로 프로그램을 실행시키면 나타나는 프로세스 id에 leaks를 실행하면 된다.
터미널 두개를 이용해 한쪽에는 lldb를 실행시키고, 다른 한쪽에서 while true; do leaks (lldb에 나온 프로세스 id); sleep 0.5; done 명령어를 실행하는 방법이 가능하다고 한다.


표준 입/출력으로 파일 읽고 쓰기

Redirection

redirection이란 파일로 표준 입/출력을 하는 것이다.
예를들어 test.txt라는 파일이 있을 때, 파일을 직접 읽는 것이 아니라 파일의 내용을 표준 입력으로 받고 싶을 때 사용할 수 있다.

  • > : 명령 > 파일의 형태로 사용하며, 명령의 결과를 표준 출력으로 파일에 저장한다.
  • >> : >와 같으나 명령의 결과를 기존 파일에 추가한다.
  • < : 명령 < 파일의 형태로 사용하며, 파일의 데이터를 표준입력으로 명령에 입력한다.

'42Seoul > gnl' 카테고리의 다른 글

gnl보너스  (0) 2023.02.16
gnl필수  (0) 2023.02.16

댓글