gamguma

42서울 get_next_line

42서울의 과제 중 get_next_line 을 하며 배웠던 fd, static, gcc 등등,,

⚠️

이 글은 848 전에 작성되었습니다.
최신 정보가 아닐 수 있습니다.

GNL

오늘은 42 과제 중 하나인 get_next_line 을 하며 배운 것들에 대해 작성하고자 합니다.
과제 해결은 오래전에 했으나, 리액트 맛좀 보느라 평가가 늦어졌습니다.

과제 설명

함수 프로토타입 : char *get_next_line(int fd)
fd 를 매개변수로 입력받고 fd 에서 한 줄씩 읽어 출력하는 함수입니다. 만약 읽을 것이 없거나, 오류가 발생하면 NULL 을 반환합니다.

함수 이름get_next_line
프로토타입char *get_next_line(int fd)
제출할 파일get_next_line.c, get_next_line_utils.c, get_next_line.h
매개변수읽어들일 파일의 디스크립터 (서술자)
반환값읽혀진 라인 : 한 줄이 제대로 읽힘 <br/> NULL : 읽을 라인이 더이상 없거나 에러 발생
사용가능한 외부 함수`read, malloc, free`
설명파일 디스크립터로부터 한 줄을 읽고, 반환하는 함수를 작성하시오.

사전 지식

File Descriptor

간단히 설명하자면 프로세스 내에서 열린 파일을 구분하기 위한 정수입니다.
여기서 프로세스는 하나 이상의 스레드에서 실행되는 프로그램 인스턴스의 단위로, 2개의 프로그램을 실행했을 때 2개의 프로세스가 존재한다고 할 수 있습니다.

더 자세히 들어가자면 파일 디스크립터는 프로세스 단위로 할당되며, 한 프로세스에 0 부터 OPEN_MAX 까지 존재할 수 있습니다. 파일을 닫으면 해당 파일 디스크립터는 추가로 할당이 가능합니다. 또한, fd 는 프로세스 내에서 독립적이며 고유합니다.

만약에 a라는 프로그램과 b라는 프로그램을 실행시킨 후 파일을 읽는다면 읽은 파일의 fd 는 어떻게 될까요?

// a.c
#include <fcntl.h>
 
int main(void)
{
  int fd;
  //atest.txt 를 읽음
  fd = open("./atest.txt", O_RDONLY);
  while (1){
   }
  return (0);
}
// b.c
#include <fcntl.h>
 
int main(void)
{
  int fd;
  //btest.txt 를 읽음
  fd = open("./btest.txt", O_RDONLY);
  while (1){
   }
  return (0);
}

해당 파일을 읽고 프로세스가 종료되지 않게 무한루프를 걸어놓은 상태입니다.

  • 우선 ps 명령어를 이용해 각각의 PID 를 확인합니다.
  • 그 후 lsof -p PID 명령어를 통해 해당 프로세스에서 열린 파일들의 정보, fd 를 확인할 수 있습니다.
fd_test 2284x1670

상기 사진을 통해 File Descriptor 는 각각의 프로세스에서 독립적이란 것을 확인할 수 있습니다.

참고 (OPEN_MAX 는 터미널에 ulimit -n 명령어로 확인할 수 있습니다.)

더불어 프로그램을 실행하면 fd 0, 1, 2 는 예약이 되어있습니다.

fdRepresentsPOSIX Namestdio streamExamples
0Standard inputSTDIN_FILENOstdinKeyboard, file, terminal
1Standard outputSTDOUT_FILENOstdoutScreen, database
2Standard errorSTDERR_FILENOstderrFile, terminal

요약

  • 음이 아닌 정수.
  • 프로세스 내에서 열린 파일을 고유하게 나타내는 값.
  • 해당 프로세스에 할당되지 않은 가장 작은 fd 부터 순차적으로 할당.
  • get_next_line 에선 파일을 열고 해당 파일을 기억한다고 생각하면 이해가 쉬울 것 같습니다.

static

간단 설명
static 은 말 그대로 정적 그 자체이며 아래와 같은 특징을 갖고 있습니다.

  • static 변수 는 함수 호출 사이에 값을 잃지 않습니다. 즉, 전역 변수이지만 해당 변수가 정의된 지역 함수로 범위가 지정됩니다.
  • static 전역 변수 는 정의된 C 파일 외부에서 볼 수 없습니다.
  • static 함수 는 정의된 C 파일 외부에서 볼 수 없습니다.
  • static 변수는 명시적으로 초기화하지 않은 경우 0 으로 초기화됩니다.
#include <stdio.h>
 
void print_number ()
{
  int         i = 10;
  static int  s_i = 10;
 
  i += 10; // int
  s_i += 10; // static int
 
  printf("i = %d\ts_i = %d\n\n", i, s_i);
  return;
}
 
 
int main() {
  
  print_number();   // i = 20  s_i = 20
  print_number();   // i = 20  s_i = 30
  print_number();   // i = 20  s_i = 40
  return 0;
}

상기 코드의 실행 결과를 보았을 때 static 으로 변수를 선언하면 함수를 재호출 하여도 값을 잃어버리지 않고 더해가는 것을 확인할 수 있습니다.

요약

  • static 변수는 함수 호출 사이에 값을 잃지 않는다.
  • static 변수, 전역 변수, 함수 는 정의된 C 파일이 아닌 외부에선 값을 볼 수 없다.

read

함수 : ssize_t read(int fd, void *buf, size_t count)
파일 디스크립터 fdcount 만큼 읽고 buf 에 저장하는 함수로 정상적으로 읽었다면 읽은 바이트 수, 실패하였다면 -1, eof 에 도달하였다면 0 을 반환합니다.

GCC -D

gcc -D name=definition

GCC 컴파일러의 -D 플래그 는 컴파일 할 때 name 을 predefine 하며, definition 을 적어주지 않으면 1 을 저장합니다(?)

#include <stdio.h>
 
int main()
{
  printf("%d", TEST);
  return (0);
}

gcc -D TEST=20 test.c 커맨드로 컴파일 후 실행시키면 20 이 출력됩니다.

요약

  • gcc -D name=definition name 에 원하는 변수명, definition 에 값을 적고 컴파일하면 코드 실행 전 해당 변수를 미리 정의 후 값을 할당해준다.

구현

주의사항

  • 유효한 fd 인지 검증
  • malloc null-guard 해주기
  • 댕글링 포인터 처리해주기
  • 정적 변수인 static char *backup 을 활용해 함수 호출 사이에서도 값이 누적되도록 하여 개행문자 이후의 값들을 여전히 참조할 수 있도록 활용.

우선 제가 생각한 로직은 아래와 같습니다.

ft_read_line
파일을 읽고 개행문자 줄을 추출하는 과정

  • BUFFER_SIZE 만큼 buffer 배열에 메모리 할당
  • fd 에 할당된 파일을 읽고 buffer 배열에 데이터 저장
  • backup 배열 생성 후 데이터가 저장되어있는 buffer 배열을 연결
  • 연결된 backup 배열에서 개행문자를 탐색, 있으면 2번째 섹션, 없으면 다시 읽고 backup 배열에 이어서 붙임

ft_extract
ft_read_line 작업을 거치고 넘어온 한 줄에서 개행문자의 전후를 분리 및 추출하는 작업

  • 읽어들인 한 줄에서 개행문자의 위치를 확인
  • 만일 개행문자 말고 EOF 가 발견된다면 0 반환 (출력하는 줄은 그대로)
  • 개행문자 위치 이후로부턴 backup 에 저장
  • backup 은 개행문자 이후 데이터로, backup 의 첫 문자가 EOF 라면 메모리 해제 후 NULL 반환(정확하게 개행문자까지 읽음 or 실제 파일 마지막일 수도 있기에)
  • 읽어들인 한 줄의 개행문자 위치 이전은 실질적으로 출력해야할 데이터로, 해당 줄의 개행문자 + 1 위치에 \0 저장

예시를 들어보자면

// BUFFER_SIZE = 100
char *origin = "안녕하세요!\n감구마입니다!\n감사합니다!\0"
 
// 상기 로직을 거친 후 line 과 backup 은 각각
char *line = "안녕하세요!\0";  
char *backup = "감구마입니다!\n감사합니다!\0"  
//  상태일 것입니다.
#include "get_next_line.h"
 
static char *ft_read_line(int fd, char *buf, char *backup)
{
  int   count;
  char  *temp_pointer;
 
  count = 1;
  while (count)
  {
    count = read(fd, buf, BUFFER_SIZE);
    if (count == -1)
      return (0);
    else if (count == 0)
      break ;
    buf[count] = '\0';
    if (!backup)
      backup = ft_strdup("");
    temp_pointer = backup;
    backup = ft_strjoin(temp_pointer, buf);
    if (!backup)
      return (NULL);
    free(temp_pointer);
    temp_pointer = NULL;
    if (ft_strchr(buf, '\n'))
      break ;
  }
  return (backup);
}
 
static char *ft_extract(char *line)
{
  int   i;
  char  *result;
 
  i = 0;
  while (line[i] != '\n' && line[i] != '\0')
    i++;
  if (line[i] == '\0')
    return (0);
  result = ft_substr(line, i + 1, ft_strlen(line) - i);
  if (!result)
    return (NULL);
  if (result[0] == '\0')
  {
    free(result);
    result = NULL;
    return (NULL);
  }
  line[i + 1] = '\0';
  return (result);
}
 
char  *get_next_line(int fd)
{
  char    *line;
  char    *buf;
  static char *backup;
 
  if (fd < 0 || BUFFER_SIZE <= 0)
    return (NULL);
  buf = (char *)malloc(sizeof(char) * (BUFFER_SIZE + 1));
  if (!buf)
    return (NULL);
  line = ft_read_line(fd, buf, backup);
  free(buf);
  buf = NULL;
  if (!line)
    return (NULL);
  backup = ft_extract(line);
  return (line);
}
 
#include <fcntl.h>
#include <stdio.h>
 
int main(void)
{
  int fd;
 
  fd = 0;
  fd = open("./test", O_RDONLY);
  char *line = get_next_line(fd);
  printf("%p\n", line);
  printf("%s", line);
 
  return (0);
}

Bonus

  • 보너스 문제는 간단하게 여러 fd 에 대응하게끔. 즉, 1번 파일 한 줄 읽고 2번 파일 한 줄 읽은 후 1번 파일로 다시 돌아가도 이어서 읽을 수 있어야 한다.
  • 정적 변수를 하나만 선언하여 해결하는 것이 통과 조건입니다.

기존 정적 변수인 backup 을 모든 fd 에 대응할 수 있게끔 OPEN_MAX 만큼의 포인터 배열을 생성해줍니다. static char *backup[OPEN_MAX]
물론 이와 같은 방법은 연결리스트로 구현한 것보다 메모리 낭비가 심하다고도 합니다. 이유는 char * 포인터의 크기가 8byte 이며 클러스터의 맥의 OPEN_MAX 값은 10240, 그러므로 8 * 10240 = 80kb 의 크기로 음,, 80kb 의 낭비랄까?
처음엔 char ** 처럼 이중 포인터로 했으나 해당 방식은 이중포인터 값 자체를 잃어버리지 않지만 이중포인터가 가르키고 있는 배열들의 데이터를 잃어버리기에 적용되지 않는 듯 하다. lldb 를 통해 알았습니다,,

메모리 누수

memory-leak

상기 코드 + 필요한 유틸 함수들을 gcc -Werror -Wextra -Wall -g -BUFFER_SIZE=100 로 컴파일, leaks -atExit -- ./a.out 명령어로 메모리 누수를 확인하면 누수 목록에 ROOT LEAK 이 존재한다. 물론 test 파일에 내용이 있어야한다.
main 함수에 있는 line 변수의 메모리를 해제하지 않았기 때문이며, 값만 printf 로 출력해도 메모리 릭이 발생한다.
물론 이건 line 변수에 할당된 get_next_line 의 결과가 malloc 을 통한 메모리 할당이어서 그런 것 같다,,! 너무 어렵다 C!!!
처음엔 왜 누수가 발생하지,, 생각했는데 lldb 로 디버깅과 동시에 메모리 누수를 검사하니 어디서 발생하는지 쉽게 확인할 수 있었다!

결론

  • 메모리 누수는 여러 케이스를 테스트하여 확인하자.
  • 눈과 뇌로 하는 것보다 디버깅 툴을 사용하자.
  • 이 코드에서 누수는 발견되지 않았다,,!(?)

마무리

이번 과제를 하면서 배운 것은 크게 정적변수, read, fd, 디버거 등등 여러가지를 배울 수 있었습니다. 역시 뭐든 사용하기 전은 어렵고 이해가 힘들지만 직접 사용해보면서 깨닫는 것이 많은 것 같습니다.
특히 lldb,, 이거 없었으면 보너스 문제 해결도 못 했을 거야,,

🚨

제가 과제를 해결했을 당시의 테스터와 다르게, 현재 유행하는 테스터를 돌리면 많은 부분에서 fail 받습니다!