폰트와 서식 문자열 출력 + newlib

폰트와 서식 문자열 출력 + newlib

2025년 3월 18일

폰트 #

지금까지 진행했다면 픽셀 정도는 출력이 가능했다.

사실 문자를 출력한다는 것은 문자 모양으로 픽셀을 출력하는 것이기 때문에 픽셀의 위치를 잡는 것이 중요하게 된다.

A 출력하기 #

문자열 출력은 A 하나를 출력하는 것과 다르지 않다.

아래 소스코드를 보면 16byte 짜리 배열로 문자열 A를 표시하고 있다.
당연히 0은 출력하지 않고, 1을 출력해서 A라는 문자를 표시하는 것인데 1bit 당 1 픽셀로 표현하고 있기 때문에 8x16px 크기의 문자를 16byte 만으로 만들 수 있게 된다.

 1const uint8_t kFontA[16] = {
 2  0b00000000, //
 3  0b00011000, //    **
 4  0b00011000, //    **
 5  0b00011000, //    **
 6  0b00011000, //    **
 7  0b00100100, //   *  *
 8  0b00100100, //   *  *
 9  0b00100100, //   *  *
10  0b00100100, //   *  *
11  0b01111110, //  ******
12  0b01000010, //  *    *
13  0b01000010, //  *    *
14  0b01000010, //  *    *
15  0b11100111, // ***  ***
16  0b00000000, //
17  0b00000000, //
18};
19
20void WriteAscii(PixelWriter& writer, int x, int y, char c, const PixelColor& color) {
21  if (c != 'A') {
22    return;
23  }
24  for (int dy = 0; dy < 16; ++dy) {
25    for (int dx = 0; dx < 8; ++dx) {
26      if ((kFontA[dy] << dx) & 0x80u) {
27        writer.Write(x + dx, y + dy, color);
28      }
29    }
30  }
31}

하나의 비트를 하나의 픽셀로 표현했기 때문에 각 라인의 값을 x 만큼 비트 시프트해서 맨 왼쪽 비트가 켜져있는지 확인한다.

10b00011000 << 0 & 0x80u;  // == 0b00011000  FALSE
20b00011000 << 1 & 0x80u;  // == 0b0011000   FALSE
30b00011000 << 3 & 0x80u;  // == 0b11000     TRUE
40b00011000 << 4 & 0x80u;  // == 0b1000      TRUE
50b00011000 << 5 & 0x80u;  // == 0b000       FALSE

비트맵 폰트 파일 #

폰트는 종류가 많지만 커널에서 사용할 폰트는 비트맵 폰트라는 타입을 사용할 것이다.

폰트파일 #

위에서 A를 표현한 것처럼 동일하게 .과 @로 작성되어 있고, 인덱스 값과 문자로 시작한다.

 10x41 'A'
 2........
 3...@....
 4...@....
 5..@.@...
 6..@.@...
 7..@.@...
 8.@...@..
 9.@...@..
10.@...@..
11.@@@@@..
12@.....@.
13@.....@.
14@.....@.
15@.....@.
16........
17........
18
190x42 'B'
20........
21@@@@....
22@...@...
23@....@..
24@....@..
25@....@..
26@...@...
27@@@@@...
28@....@..
29@.....@.
30@.....@.
31@.....@.
32@....@..
33@@@@@...
34........
35........
36
37// ...

폰트 txt 파일을 컴파일 #

 1# .과 @ 중 하나라도 있는 라인을 매칭하는 정규식 패턴 생성
 2BITMAP_PATTERN = re.compile(r'([.@]+)')
 3
 4def compile(src: str) -> bytes:
 5    src = src.lstrip()
 6    result = []
 7
 8    # txt 파일에서 모든 라인 가져옴
 9    for line in src.splitlines():
10        # 매치되는 라인만 다음스텝
11        m = BITMAP_PATTERN.match(line)
12        if not m:
13            continue
14
15        # 매칭된 첫번째 라인 == m.group(1)
16        # 반복문을 돌면서 . 은 0, 그게 아니라면 1 짜리 배열을 만듦
17        # ex) [0, 0, 0, 0, 1, 1, 0, 0]
18        bits = [(0 if x == '.' else 1) for x in m.group(1)] 
19        # 각 원소를 누적계산하는데, a는 누적값, b는 각 원소를 의미
20        # 그러니까 누적값에 *2를 하고 원소를 더함. -> 2진법을 10진법으로 계산
21        bits_int = functools.reduce(lambda a, b: 2*a + b, bits)
22        # 각 라인을 1byte 짜리 숫자로 만들고 result에 추가하게됨
23        result.append(bits_int.to_bytes(1, byteorder='little'))
24    # 그렇게 추가된 result를 바이트스트링으로 변경해서 출력파일에 쓴다.
25    return b''.join(result)

코드를 컴파일하고 나면 아래 이미지처럼 16바이트마다 문자 하나의 픽셀 정보가 연속적으로 포함된 바이너리 파일로 변한다.

1python3 ./tools/makefont.py ./kernel/hankaku.txt -o test.bin

22923b9f-9f27-442d-a74f-0c68e2e3d8f4


바이너리 파일을 obj 파일로 변경 #

바이너리 파일을 elf 오브젝트 파일의 .data 섹션으로 변경한다.

1objcopy -I binary -O elf64-x86-64 -B i386:x86-64 test.bin test.o

ELF 헤더를 포함해서 .data 섹션에 집어넣고, 참조할 수 있도록 _binary_test_bin_start 같은 심볼 이름을 넣어줬다.
_binary_test_bin_start 는 .data 섹션의 0x0 오프셋을 가리키고 있다.

2aafc7a5-654c-463b-be7c-3def102f71d8


폰트 오브젝트 파일 사용 #

링킹 시점에 같이 포함시켜서 빌드하면 사용할 수 있다.

아래 코드로 폰트의 시작 주소를 가져올 수 있고 16바이트 당 한 문자로 ASCII 코드에 맞춰서 정렬되어 있기 때문에 16 * ASCII 코드 로 오프셋을 알아올 수 있다.

여기에서부터 1byte씩 꺼내와서 1bit 씩 화면에 색칠하면 되는 아주 간단한 구조이다.

1const uint8_t* GetFont(char c) {
2  auto index = 16 * static_cast<unsigned int>(c);
3  if (index >= reinterpret_cast<uintptr_t>(&_binary_hankaku_bin_size)) {
4    return nullptr;
5  }
6  return &_binary_hankaku_bin_start + index;
7}

서식 문자열출력 #

그냥 문자열 출력은 위에서 구현한 WriteAscii를 반복 출력하면 된다.

문자열 서식 "%d + %d = %d" 형식을 출력하기 위해서는 직접 구현해도 좋지만, newlib의 vsprintf 같은 함수를 사용해도 좋을 것이다.

newlib #

개발을 하다보면 표준 라이브러리가 필요한데 glib은 무겁기도 하고, 이미 운영체제에 의존적인 코드가 너무 많아서 현실적으로 사용하기 어렵다.

newlib은 하드웨어, 운영체제에 의존적인 syscall 부분을 최대한 제거하고, 필요하더라도 직접 구현해야 정상 동작하기 때문에 임베디드 시스템 등에서 간단한 표준 라이브러리가 필요한 경우 자주 사용된다.

  • syscall : 유저모드의 프로그램이 운영체제의 기능을 사용하기 위한 인터페이스이다.
    • 운영체제를 통한 하드웨어 접근. 운영체제는 사실 하드웨어를 쉽게 제어할 수 있게 도와주는 프로그램이다.
    • 운영체제의 기능 사용. fork 같은 프로세스 생성, mmap 같은 메모리 관리 등의 기능
    • 권한이 필요한 작업 수행. 권한이 필요한 작업도 결국 운영체제의 기능이다.

설치 및 실행 #

  1. docker 설치 후 user에 권한 추가
    1kdh@DESKTOP-MHEA7GE:~$ groups
    2kdh adm dialout cdrom floppy sudo audio dip video plugdev netdev docker
    3
    4// 추가가 안되어있다면 직접 추가
    5sudo usermod -aG docker kdh
    
  2. 빌드스크립트 실행
    도커 안에서 빌드하고, 복사해서 min-os/devenv/x86_64-elf 폴더안에 넣어준다.
    1kdh@DESKTOP-MHEA7GE:~/min-os/build_stdlib$ ./build.sh
    2
    3kdh@DESKTOP-MHEA7GE:~/min-os/build_stdlib$ ls ../devenv/x86_64-elf/
    4LICENSE.freetype  LICENSE.libcxx  LICENSE.newlib  include  lib
    
  3. 필요한 syscall 코드 추가
    sprintf 를 사용하기 위한 sbrk 구현. 지금은 의미 없기 때문에 에러 방지용으로 추가
    1kdh@DESKTOP-MHEA7GE:~/min-os/kernel$ cat newlib_support.c
    2#include <sys/types.h>
    3
    4caddr_t sbrk(int incr) {
    5  return NULL;
    6}
    
  4. vsprintf 함수 사용
    1char buf[128];
    2sprintf(buf, "1+2=%d",1+2);
    3WriteString(*pixel_writer, 0, 300, buf, {255,255,255});
    
  5. Makefile 에서 표준 라이브러리를 포함해서 빌드하도록 추가
    newlib의 libc.so 에 구현되어 있기 때문에 -L로 경로 지정 -l 로 라이브러리이름 지정해야함
    1ELFLIBDIR = ../devenv/x86_64-elf
    2LDFLAGS += -L$(ELFLIBDIR)/lib -lc
    

잘 출력된다 ! 36088438-7a07-44d5-a9b0-ad0baf0638c0


콘솔 클래스 #

현재는 화면에 뭔가(픽셀)를 출력하고 뷰는 멈춰있는 상태이다. 하지만 보통 이런 상황에서 스크롤을 하거나 추가 로그가 출력되고 나면 기존 문자열은 한칸씩 위로 올라가야한다.
일반적으로 이 문자열 제어 테이블을 콘솔이라고 부르며, 이 클래스에서 위의 작업들이 가능하도록 구현할 것이다.

 1#pragma once
 2
 3#include "graphics.hpp"
 4
 5class Console {
 6public:
 7// 각 행은 80개의 문자를 넣을 수 있고, 25개의 행을 출력할 수 있다. 
 8  static const int kRows = 25, kColumns = 80;
 9  Console(PixelWriter& writer,
10        const PixelColor& fg_color, const PixelColor& bg_color);
11// 문자열을 출력하는 함수. '\n' 문자가 들어오면 개행이 동작한다. 
12  void PutString(const char* s);
13
14private:
15// 개행역할 담당하는 함수. 마지막줄이라면 모든 문자를 한줄씩 올려야한다. 
16  void Newline();
17
18  PixelWriter& writer_;
19  const PixelColor fg_color_, bg_color_;   // 텍스트 색, 배경 색
20  char buffer_[kRows][kColumns + 1];       // 화면의 문자 버퍼. +1은 \0 저장
21  int cursor_row_, cursor_column_;         // 현재 커서. 글자가 표시될 위치.
22};

Console 함수 #

각 문자는 결국 직접 컨트롤해서 원하는 모양을 화면에 픽셀로 찍는 것이고, 개행문자 같은 특수문자도 출력할 때 특수한 작업을 하는 것일 뿐이다.

새로운 줄을 만들때 위로 올리려면 모든 픽셀을 전부 지우고 한줄씩 올려야 한다.

 1void Console::PutString(const char* s) {
 2  while (*s) {
 3    // 개행 문자가 포함된 경우 다음라인으로 커서이동
 4    if (*s == '\n') {
 5      Newline();
 6    // 일반 문자는 Console이 표시할 수 있는 컬럼까지만 출력
 7    } else if (cursor_column_ < kColumns - 1) {
 8      WriteAscii(writer_, 8 * cursor_column_, 16 * cursor_row_, *s, fg_color_);
 9      buffer_[cursor_row_][cursor_column_] = *s;    // buffer에 문자 기록
10      ++cursor_column_;
11    }
12    ++s;
13  }
14}
15
16// 콘솔 개행 함수
17void Console::Newline() {
18  // column을 0으로 초기화. (커서를 맨 앞으로)
19  cursor_column_ = 0;
20  // 최대 row 값보다 낮다면 row를 +1
21  if (cursor_row_ < kRows - 1) {
22    ++cursor_row_;
23  // 만약 마지막 row인 경우
24  } else {
25    // 전체 row, column에 해당하는 콘솔 전체 픽셀을 배경색으로 초기화
26    for (int y = 0; y < 16 * kRows; ++y) {
27      for (int x = 0; x < 8 * kColumns; ++x) {
28        writer_.Write(x, y, bg_color_);
29      }
30    }
31    for (int row = 0; row < kRows - 1; ++row) {
32      // 기록해뒀던 문자버퍼를 한줄씩 땡겨옴
33      memcpy(buffer_[row], buffer_[row + 1], kColumns + 1);
34      // 땡겨온 문자를 출력 (한줄 위로 올림)
35      WriteString(writer_, 0, 16 * row, buffer_[row], fg_color_);
36    }
37    // 맨 마지막줄 (Newline) 은 0으로 초기화
38    memset(buffer_[kRows - 1], 0, kColumns + 1);
39  }
40}

printk #

서식 문자를 포함한 print 함수이다. 그냥 서식은 vsprintf를 사용하고, 바로 console에 출력하면 된다.

 1#include <cstdio>
 2
 3int printk(const char* format, ...) {
 4  va_list ap;
 5  int result;
 6  char s[1024];
 7
 8  // 가변인자 목록의 시작점 (마지막 고정인자)
 9  va_start(ap, format);
10  result = vsprintf(s, format, ap);
11  va_end(ap);
12
13  console->PutString(s);
14  return result;
15}
comments powered by Disqus