공부하는 이유
이 프로젝트는 임베디드 개발/보안 지식을 쌓기위한 첫단계이며 이후 개발한 OS를 하드웨어에 넣어보고, 빼고, 분석하는 과정까지 진행한다면 도움이 될것이라고 생각하기 때문에 시작한다.
임베디드 OS개발 이후 lighttpd 등으로 cgi 웹서버개발. (nodejs 조차 버거운 임베디드 시스템에서 주로 사용하는 방식)
이후 임베디드레시피 보면 이해 잘될듯
운영체제가 하는일
1. 프로세스관리 : CPU를 점유해야 할 프로세스를 결정, 자원을 할당. 공유자원 접근관리
2. 저장장치관리 : 메인메모리(1차저장장치) 관리는 프로세스 할당하는 메모리영역 지정, 해제 메모리 영역간 침범방지, 가상메모리 기능 등. 스토리지(2차저장장치)관리는 파일시스템을 이용
3. 네트워킹관리 : TCP/IP 기반 인터넷연결 등 네트워크 프로토콜 지원
4. 사용자 관리 : 아이디 패스워드등 계정관리, 로그기록
5. 디바이스 드라이버 : 운영체제에서 하드웨어를 관리할수 있도록 추상화
개발준비
운영체제 : 우분투
컴파일러 설치
arm 용 gcc 사용. x86에서 arm gcc를 사용하는것처럼 다른 환경의 컴파일러를 크로스컴파일러라고 함
#apt install gcc-arm-none-eabi
플랫폼(none)위치에 linux가 오면 ARM으로 동작하는 ELF파일을 만드는게 목적이된다. none은 날것 그대로 ARM 바이너리 생성해준다는 뜻
맨뒤 abi는 함수호출규약을 의미하는데 어떤레지스터를 사용할지 등을 정한 규약 eabi는 임베디드abi 즉 임베디드 환경에서의 호출규약을 의미. eabi는 정적링크만 지원한다.
정상설치 확인 (설치한 패키지 이름이랑 다름 주의)
#arm-none-eabi-gcc -v
QEMU 설치
x86, arm 등을 가상으로 돌릴수있는 에뮬레이터
#apt install qemu-system-arm
#qemu-system-arm -version - 버전확인
#qemu-system-arm -M ? - 지원하는 머신목록 확인 여기서는 realview-pb-a8 을 이용함
코드 작성 및 컴파일
arm 코어에 전원이 들어가면 가장먼저 리셋벡터(메모리주소 0번지)에 있는 명령어 32bit를 읽어 실행한다.
/* /navilos/boot/Entry.s (arm 어셈블리 소스코드) */
.text
.code 32
.global vector_start
.global vector_end
vector_start:
MOV R0, R1
vector_end:
.space 1024, 0
.end
.text는 .end가 나올때까지 text 섹션이라는 뜻이며 .code 32는 명령어의 크기가 32비트라는 뜻이다. 그리고 .global(c에서의 extern과 동일)은 vector_start와 vector_end 주소정보를 외부 파일에서 심벌로 읽을 수 있게 설정하는 역할을 한다.
vector_start, end 사이에 코드를 작성하고 .space 1024, 0 은 1024 바이트를 0으로 채우라는 의미이다.
* gcc 컴파일과정
컴파일 과정에서 볼수있듯 .s 파일은 어셈블러를 이용해서 컴파일 한다. .o도 실행파일이며 링커는 묶어주는 역할만 수행
#arm-none-eabi-as -march=armv7-a -mcpu=cortex-a8 -o Entry.o ./Entry.s
사용할 에뮬레이터(RealViewPB)의 ARM코어가 cortex-a8이라서 아키텍쳐는 armv7-a, CPU는 cortex-a8로 설정함
컴파일 결과를 확인하기 위해 헤더 등 정보를 제외하고 순수한 바이너리만 추출 후 덤프로 확인
#arm-none-eabi-objcopy -O binary Entry.s Entry.bin
#xxd Entry.bin
맨처음 4byte만 명령어(MOV R0, R1)가 입력되어있고 나머진 0으로 채워진것을 볼 수 있다.
링커를 이용해 여러 오브젝트 파일을 하나의 실행파일로 묶을 수 있는데, 크로스 컴파일 환경에서 링커를 이용하려면 펌웨어가 동작하게될 하드웨어 환경에 맞춰 링커스크립트를 작성해야 한다.
/* /navilos/navilos.ld */
ENTRY(vector_start) // 시작위치 심볼 지정
SECTIONS // 섹션 배치설정 정보 포함
{
. = 0x0; // 첫번째 섹션이 메모리주소 0x00000000에 위치한다는 뜻. 띄어쓰기 필수
.text : // text 섹션의 배치 순서 지정. 배치메모리 주소도 지정할 수 있음. 디폴트는 시작주소부터
{
*(vector_start) // 텍스트섹션이 0x0부터 시작하기 때문에 맨앞은 리셋벡터가 위치해야함
*(.text .rodata) // 리셋벡터를 제외한 텍스트 섹션 순서 전부 상관없음
}
.data : // text 섹션 이후 data, bss 섹션이 순서대로 위치하도록 설정. data : 띄어쓰기 필수
{
*(.data)
}
.bss :
{
*(.bss)
}
}
링킹
#arm-none-eabi-ld -n -T ./navilos.ld -nostlib -o navilos.axf boot/Entry.o
-n은 섹션 정렬을 자동으로 하지말라는 옵션이며 -T를 이용해 링커스크립트를 지정하고 -nostdlib 옵션으로 표준 라이브러리를 자동으로 함께 링킹하지 않도록 한다.
만들어진 실행파일 디스어셈블
#arm-none-eabi-objdump -D navilos.axf
extern 했던 심볼까지 확인 가능
make 명령을 이용하여 빌드 자동화
Makefile을 작성하고 make 명령으로 컴파일->링킹 과정을 자동화함
ARCH = armv7-a
MCPU = cortex-a8
CC = arm-none-eabi-gcc
AS = arm-none-eabi-as
LD = arm-none-eabi-ld
OC = arm-none-eabi-objcopy
LINKER_SCRIPT = ./navilos.ld
# boot/*.S 경로의 파일명을 모두 ASM_SRCS 변수에 저장
ASM_SRCS = $(wildcard boot/*.S)
# ASM_SRCS변수에 저장된 boot/~.S 값을 build/~.o 값으로 변경한다는뜻
ASM_OBJS = $(patsubst boot/%.S, build/%.o, $(ASM_SRCS))
navilos = build/navilos.axf
navilos_bin = build/navilos.bin
# make는 두번째 인자로 파일명이나 레이블이 올 수 있다.
# 레이블로 사용하기 위해 파일명에서 제외되는 이름들을 미리 정의하는 역할을 수행함
.PHONY: all clean run debug gdb kill
# 의존파일은 navilos.axf이기 때문에 이 파일도 빌드되어야 하는데 39번째 줄에 있음
all: $(navilos)
kill:
ps -ef | grep qemu | head -1 | awk '{print $$2}' | xargs kill -9
clean:
@rm -fr build
run: $(navilos)
qemu-system-arm -M realview-pb-a8 -kernel $(navilos) -nographic
debug: $(navilos)
qemu-system-arm -M realview-pb-a8 -kernel $(navilos) -nographic -S -gdb tcp::1234, ipv4
gdb:
arm-none-eabi-gdb
# navilos.axf 파일이 의존하는건 build/~.o 파일과 링커스크립트 ObJS 파일은 44번째 줄에서 빌드
$(navilos): $(ASM_OBJS) $(LINKER_SCRIPT)
$(LD) -n -T $(LINKER_SCRIPT) -o $(navilos) $(ASM_OBJS)
$(OC) -O binary $(navilos) $(navilos_bin)
# boot 경로의 전체 소스코드를 빌드해주는 역할을 수행함
build/%.o: boot/%.S
mkdir -p $(shell dirname $@)
$(AS) -march=$(ARCH) -mcpu=$(MCPU) -I -g -o $@ $<
실행
리눅스에서 실행해보려고 하면 Exec Format Error가 발생한다. ELF 파일이긴 하지만, 리눅스 커널에서 지원하지 않는 섹션 배치로 만들어져있기 때문
1. 개발보드에 다운로드해서 실행
2. QEMU로 실행
#qemu-system-arm -M realview-pb-a8 -kernel navilos.axf -S -gdb tcp::1234,ipv4
-M옵션으로 머신지정, -kernel은 리셋벡터를 코딩한 실행파일, -S 옵션은 케뮤 동작 후 즉시 일시정지(Suspend), -gdb는 디버깅을 위해 케뮤에서 gdbserver를 tcp 1234로 열어두겠다는 뜻
*** QEMU는 기본적으로 그래픽 환경에서 에뮬레이터가 동작한다. output을 현재 터미널로 하고싶다면 -nographic 옵션이나 -serial stdio 옵션을 사용하면 된다. 그래픽지원하지 않는 시스템에선 기본실행 시 에러발생 후 실행안됨
#apt install gdb-arm-none-eabi
설치안되면 arm developer에서 수동설치 후 리눅스 /usr/share로 옮겨서 압축풀고 /usr/bin/ 에 파일링크해주면됨
높은버전의 ubuntu에서는 libncurses.so.6이 설치되어있기 때문에 libncurses5 를 apt로 설치해줘야한다.
#arm-none-eabi-gdb
(gdb) target remote:1234
(gdb) x/4x 0x0
데이터시트 읽기
펌웨어는 레지스터를 통해서 하드웨어와 상호작용을 하기 때문에 레지스터 사용법을 알아야 한다.
레지스터 사용법 외에도 많은 정보가 데이터시트에 나와있기 때문에 항상 참고해서 프로그래밍 해야한다. 데이터시트에는 정보만 나와있고, 프로그래밍 예제를 보기 위해서는 Application Note라는 것을 확인할 수 도 있다.
RealViewPB 데이터 시트에 따르면 0x10000000 주소에는 하드웨어를 식별할 수 있는 32bit 크기의 ID Register가 위치하며 읽기 전용이다.
vector_start:
LDR R0, =0x10000000 // R0에 0x10000000 이라는 값을 저장
LDR R1, [R0] // R0을 참조해서 그 주소에 있는 값을 R1에 저장
vector_end:
.space 1024, 0
실행 결과 R1에 저장되는 값은 0x1780500 이며 이미지도 little endian 기준으로 그려져 있기 때문에 0 178 0 5 00 으로 읽으면 된다. 0000 0001 0111 1000 0000 0101 0000 0000 = Rev A, HBI, 0, AXI, 0
부팅
보통 시스템에 전원을 넣고 모든 초기화 작업이 끝난 뒤 펌웨어가 대기 상태가 될때까지를 부팅이라 한다.
또는 전원이 들어가고 ARM 코어가 리셋 익셉션 핸들러를 모두 처리한다음 본격적으로 C 코드로 넘어가기 직전 까지를 의미하는데, 여기서는 후자를 부팅이라 표현함
메모리설계
임베디드 시스템은 성능과 비용 사이에서 최적의 결과를 내야하기 때문에 다양한 메모리를 사용하여 복잡한 구조로 되어있다. 하지만 QEMU는 간단한 구조로 되어있고, 디폴트 값으로 128MB 크기의 메모리를 할당해준다.
빠르지만 작은 메모리, 느리지만 큰 메모리 두개가 있다는 가정하에 진행
.text : 코드가 있는 공간. 빠른 메모리에 배치
.data : 초기화한 전역 변수가 있는 공간. 일부 속도에 민감한 데이터는 빠른메모리, 나머지는 느린메모리에 배치
.bss : 초기화 되지않은 전역 변수가 있는 공간. 느려도 큰 메모리에 배치
데이터 종류 : 코드, 동작모드별 스택, 태스크 스택, 전역변수, 동적메모리 할당영역
1. 코드
.text 영역에 포함되는 공간이며 빠른 메모리공간에 1MB 할당
2. 동작모드별 스택
임베디드 시스템에서는 아키텍쳐 별로 여러 Exception을 정의해놓았고, 예외가 발생하는 경우 그에 맞는 동작모드로 빠져 코드를 실행하게 된다. 그렇기 때문에 동작모드 별로 미리 스택 메모리를 할당해야 한다.
USR+SYS(2MB), SVC(1MB), IRQ(1MB), FIQ(1MB), ABT(1MB), UND(1MB)
3. 태스크 스택
각각의 Task마다 1MB씩 최대 64개의 Task를 동시에 수행하도록 64MB 메모리를 할당한다.
4. 전역변수
.data, .bss 영역을 의미함 1MB 메모리 할당
5. 동적메모리 할당영역
나머지 모든 영역을 동적메모리 할당에 사용
익셉션 벡터 테이블
지금까지 코드를 작성한 vector_start: 부분은 익셉션 벡터 테이블 영역이라고 부르며, ARM은 전원이 들어오면 익셉션 벡터 테이블의 시작주소인 리셋 벡터를 읽게된다. 익셉션 벡터 테이블의 주소는 변경할 수 있지만 보통은 디폴트 값인 0으로 두고 0~1c 주소까지 4byte단위로 각 익셉션주소로 사용되도록 예약되어 있다.
ARM은 예외 상황이 발생하면 그에맞는 익셉션 벡터 테이블 오프셋을 PC에 넣고 바로 실행하게 되는데, 예약된 공간은 4byte 크기뿐이라 핸들러를 따로 작성해서 그 주소로 점프하도록 만들어둔다.
.vector_start:
LDR PC, reset_handler_addr // PC(다음명령)레지스터에 리셋핸들러 주소저장
LDR PC, undef_handler_addr
LDR PC, svc_handler_addr
LDR PC, pftch_abt_handler_addr
LDR PC, data_abt_handler_addr
B . // Not Assign 오프셋. 현재위치로 분기(무한루프)
LDR PC, irq_handler_addr
LDR PC, fiq_handler_addr
reset_handler_addr: .word reset_handler // 함수 주소를 넣은 변수와 같다고 보면된다.
undef_handler_addr: .word dummy_handler // 나머지 핸들러는 더미핸들러로 점프하고 무한루프에 빠진다.
svc_handler_addr: .word dummy_handler
pftch_abt_handler_addr: .word dummy_handler
data_abt_handler_addr: .word dummy_handler
irq_handler_addr: .word dummy_handler
fiq_handler_addr: .word dummy_handler
.vector_end:
reset_handler:
LDR R0, =0x10000000
LDR R1, [R0]
dummy_handler:
B .
익셉션은 프로그램 정상 실행 도중 갑작스런 상황에 따른 실행흐름 변경이기 때문에 익셉션 핸들러가 전부 수행된 이후 다시 원래 코드로 복귀해야한다. ARM 프로세서는 익셉션이 발생하면 R14_nm 레지스터에 복귀할 주소를 저장한다. 이후 개발자가 R14 레지스터를 이용하여 기존 흐름으로 복귀하는 코드를 익셉션 핸들러 마지막 부분에 작성해야 한다.
Abort : 비정상적인 동작으로 인해 프로그램 진행이 불가능한 상황일때 발생
- MPU(Memory Protection Unit)로 보호되는 메모리 영역에 접근권한 없이 접근했을때
- AMBA 메모리 버스가 에러를 응답했을때
- ECC 로직에서 에러가 발생했을때
명령어를 읽을때 Abort는 prefetch abort 익셉션이 발생하고, 데이터 읽을때 발생하면 Data abort 익셉션이 발생한다. ARM이 모르는 명령을 가져왔을때는 undefined instruction 익셉션으로 이동하는데, 익셉션 핸들러에서 명령어를 처리해주도록 코딩해서 확장 명령을 직접 만들수도 있다.
ARM에서는 익셉션과 인터럽트가 동일하게 동작하는데, 인터럽트는 소프트웨어가 아닌 외부요인으로 버튼을 누르거나 스마트폰 액정 터치 등의 이벤트가 발생했을때 동작하며 임의로 설정을 통해 발생시킬수도 있다.
하드웨어가 인터럽트를 감지해서 ARM에 인터럽트 신호가 입력되는 순간부터 인터럽트 핸들러가 수행되기 직전까지를 인터럽트 지연(latency)라고 하는데, 임베디드 시스템 목적에따라 지연시간이 문제되기 때문에 VIC 하드웨어를 이용하는 등 최대한 줄이려고 노력한다.
IRQ (Interrupt Request) : FIQ보다 우선순위가 낮은 인터럽트.
FIQ (Fast Interrupt Request) : IRQ와 동작은 같지만 우선순위도 높고 R8~R12 레지스터를 가지고있어서 인터럽트 처리용 레지스터로 설정하면 컨텍스트 스위칭 오버헤드가 줄어 속도도 빠르다.
NMFI (Non-Maskable Fast Interrupt) : NMFI를 켜면 하드웨어단에서 FIQ의 CPSR의 F비트를 0으로 클리어하여 FIQ를 비활성할 수 없도록 처리한다. 켜져있을때 F비트가 1이되는 경우는 이미 FIQ가 발생했거나 Reset익셉션이 발생했을때이다.
LIL (Low Interrupt Latency) : 인터럽트 발생 시 수행중인 명령을 취소하고 인터럽트먼저 처리한뒤 취소한 명령어 위치로 복귀하는 기능
IC (Interrupt Controller) : 인터럽트가 발생하면 IRQ인지 FIQ인지는 알수있지만 어떤 하드웨어에서 발생했는지는 인터럽트 컨트롤러에 물어봐야 알 수 있다.
* VIC : 보통 인터럽트가 발생하면 FIQ나 IRQ에 맞는 인터럽트 핸들러로 이동하게 되고, 핸들러에서 IC에 세팅된 값을 확인해서 인터럽트 소스(발생한 하드웨어)가 뭔지 알아내고 그에따른 서브루틴으로 이동하는데 VIC는 모두 하드웨어가 처리 해서 서비스 루틴의 시작주소를 저장하게 된다. 물론 사실은 주소를 저장하는건 펌웨어의 역할이다.
동작모드
USR : 일반적으로 사용하는 모드. 운영체제를 사용한다면 사용자 프로그램은 USR에서 동작하게된다.
FIQ : FIQ가 발생하는 경우 동작. ARM 모드에서만 지원함
IRQ : IRQ가 발생하는 경우. ARM, Thumb 모두 동작가능
SVC : 운영체제에서 시스템 코드를 수행하기 위한 보호모드. 보통 운영체제에서 system call을 발생시키면 SVC 모드로 전환 후 커널 동작을 수행한다. 소프트웨어에서 발생하는 익셉션
ABT : Abort가 발생하는 경우 동작 (undefined instruction 제외)
SYS : 사용자 프로세스가 임시로 커널모드를 필요로하는 경우.
UND : undefined instruction 발생
명령집합
ARM 모드 명령어 : 32bit 명령어 집합. 속도 빠르지만 크기가 크다.
Thumb 모드 명령어 : 16bit 명령어 집합. R8~12 레지스터의 사용이 몇가지 명령어 외에는 제한된다. 컴파일하면 ARM모드에 비해 바이너리 크기가 70%정도 크기가된다.
런타임도중 모드를 변경해가며 사용할 수 있기 때문에 속도가 중요한부분은 ARM, 중요하지 않은 부분은 Thumb로 작성하게 된다.
레지스터
ARM에서는 각 상태마다 레지스터를 다르게 사용하며, 분홍색 바탕으로 표시된 banked 레지스터를 제외하고는 공유해서 사용한다. (R0~15 16개, CPSR 1개, banked register 20개 총 37개)
R0~12 : 범용 레지스터. 데이터를 일반적으로 처리할 때 사용
R13 : 스택포인터 레지스터. 현재 스택 포인터의 위치를 저장함
R14 : 링크 레지스터. 함수 호출 후 복귀주소를 저장하는 레지스터. R14_xxx 는 익셉션 핸들링 이후 복귀주소를 저장함
R15 : 프로그램 카운터. 다음 명령의 주소를 저장
PSR (Program Status Register) : 프로그램 상태를 저장하는 레지스터. 현재 프로그램 상태가 세팅된 CPSR(Current PSR)과 CPSR의 백업인 SPSR(Saved PSR)이 있다.
- N : 계산결과가 음수일때 1
- Z : 계산결과가 0일때 1
- C : 계산결과 carry, borrow가 발생하면 1
- V : 오버플로가 발생하면 1
- Q : 곱셈 시 32bit가 넘어가면 올림수에 이용됨
- J : 하드웨어에서 자바 바이트코드를 실행할 수 있도록하는 Jazelle 상태로 변환 시 1
- DNM : Do Not Modify. 예약
- GE[3:0] : SIMD(Single Instruction Multi Data) 명령을 사용하여 연산 시 halfword 단위로 크거나 같은지 표시
- IT[7:2] : Thumb모드에서 if-then 명령을 처리할때 참조하는 비트
- E : 데이터의 엔디안을 표시
- A : 예측 가능한 data abort
- I : 이 비트가 1로 세팅되면 IRQ 비활성화
- F : 1로 세팅되면 FIQ 비활성화
- T : Thumb모드일때 1로 변경
- M : 모드비트 (10000:USR, 10001:FIQ, 10010:IRQ, 10011:SVC, 10111:ABT, 11011:UND, 11111:SYS)
리셋 핸들러
1. 메모리 맵 설정 : 동작모드 별 스택을 만들고 그 주소를 각 동작모드의 R13 레지스터에 설정하는 작업
/* MemoryMap.h */
#define IRQ_STACK_START 0x00400000
#define IRQ_STACK_SIZE 0x00100000
#define IRQ_STACK_TOP (IRQ_STACK_START + IRQ_STACK_SIZE - 4)
// 굳이 TOP 주소를 사용하는 이유는 스택이 높은주소에서 PUSH되면 낮은주소 방향으로 쌓이기 때문이다.
/* ARMv7AR.h */
#define ARM_MODE_BIT_IRQ 0x12
/* Entry.S */
#include MemoryMap.h
#include ARMv7AR.h
...
MRS r0, cpsr // CPSR값을 r0에 저장. (복귀할때 사용하기 위해)
BIC r1, r0, #0x1F // BIC(비트클리어)=AND+Not -> cpsr & ~00011111 -> cpsr값에서 모드비트 클리어
ORR r1, r1, #ARM_MODE_BIT_IRQ // 기존 cpsr값 가져가고 IRQ 모드비트만 포함해서 r1에 다시 저장
MSR cpsr, r1 // 기존 cpsr에 모드비트 세팅해서 해당 모드로 진입
LDR sp, =IRQ_STACK_TOP // 스택의 탑 주소를(현재는 아무것도 없어서 바닥주소) 해당 모드의 sp에 저장
...
// IRQ 외에도 모든 동작모드에 맞춰서 sp를 세팅하게 된다.
// RTOS가 SYS모드로 동작할 것이기 때문에 추가작업이 없도록 마지막에 SYS모드를 세팅한 것이다.
BL main // 세팅 후 main 함수로 들어가는 코드.
// 컴파일러가 C언어의 함수 이름은 링커가 자동으로 접근할 수 있는 전역 심벌로 만든다.
// 반대의 경우 C언어에서 어셈코드의 심벌을 참조하려면 .global을 통해 전역심벌로 만들어줘야 한다.
...
헤더를 추가했기 때문에 헤더 경로를 Makefile에서 지정해줘야한다. #define은 전처리기에 의해서 처리되기 때문에 어셈블러에 옵션을 추가하는게 아니라 컴파일러에 추가해야한다.
/* Makefile 수정 */
// 헤더추가
INC_DIRS = include
$(CC) -march=$(ARCH) -mcpu=$(MCPU) -I $(INC_DIRS) -c -g -o $@ $<
// main.c 소스 컴파일
MAP_FILE = build/navilos.map
C_SRCS = $(wildcard boot/*.c)
COBJS = $(patsubst boot/%.c, build/%.o, $(CSRCS))
// 링커에 추가
// 링커는 링킹 작업 시 심벌에 할당된 주소를 map파일에 기록한다.
$(LD) ... $(C_OBJS) -Map=$(MAP_FILE)
build/%.o: $(C_SRCS)
mkdir -p $(shell dirname $@)
$(CC) -march=$(ARCH) -mcpu=$(MCPU) -I $(INC_DIRS) -c -g -o $@ $<
2. c 코드 작성 후 리셋핸들러에서 점프
main.c 코드를 작성하고 리셋핸들러 마지막에 BL main 으로 심볼을 이용해 점프한다.
또한 작성한 c 코드까지 함께 컴파일해야 한다. (어셈, 링커 코드는 위에 함께 있음)
UART (PL011)
범용 비동기화 송수신기. 어떤 데이터 값이든 개발자가 프로그래밍한대로 UART를 통해 송수신 할 수 있다. UART도 모델별로 데이터시트가 있기 때문에 함께 확인하면서 프로그래밍 해야한다.
1. UART 하드웨어 레지스터를 코드로 작성
데이터시트를 확인하면 레지스터별로 어떻게 사용해야 하는지 설명되어 있다.
UARTDR : 데이터 레지스터. 총 16bit 크기이며, 0:7 -> 입출력데이터 한번에 1byte 통신가능. 8:11 -> 에러비트. 각 에러에 맞는 상황이 발생하면 1로 세팅, 12:15 -> 사용하지 않는 예약비트 로 구성되어있다.
매크로나 구조체로 해당 레지스터를 정의하고 c 소스에서 사용하면 된다.
/* /hal/rvpb/Uart.h */
/* 매크로방식 */
#define UARTDR_BASE_ADDR 0x10009000
#define UARTDR_OFFSET 0x00 // 오프셋은 데이터시트에 정의되어있음
#define UARTDR_DATA (0) // 0:7 입출력 레지스터
#define UARTDR_FE (8)
#define UARTDR_PE (9)
#define UARTDR_BE (10)
#define UARTDR_OE (11)
...
#define UARTCR_OFFSET 0x30 // 오프셋 0x30짜리 레지스터
/* 구조체방식 */
typedef union UARTDR_t {
uint32_t all;
struct {
uint32_t DATA:8; // 0:7. 콜론연산자. 구조체 데이터도 LE방식이라
uint32_t FE:1; // 8 메모리상에선 reserved가 뒤에있지만, 4byte로 꺼낼땐 앞자리가됨
uint32_t PE:1; // 9 (낮은주소)0xdata 0xfe 0xpe ...(높은주소) -> 0xreservedoebepefedata
uint32_t BE:1; // 10
uint32_t OE:1; // 11
uint32_t reserved:20;
} bits;
} UARTDR_t
...
tyepdef union UARTCR_t { ... } UARTCR_t
보통 이 코드는 직접 작성할 필요없이 담당부서에서 자동화 프로그램으로 생성해주거나, 하드웨어 제조사에서 제공한다.
여러 플랫폼을 지원하기 위해 /hal/[board]/Uart.h 형식으로 디렉터리를 만들어 그 안에 헤더파일을 넣는게 일반적이다.
2. 하드웨어 제어를 위한 인스턴스 생성
/* /hal/rvpb/Regs.c */
#include "stdint.h"
#include "Uart.h"
// 항상메모리에 접근하도록 volatile 선언. PL011_t는 전체 레지스터가 포함된 구조체
// UART_BASE_ADDRESS0은 UART 레지스터를 제어할 수 있는 인스턴스 주소
volatile PL011_t* Uart = (PL011_t*)UART_BASE_ADDRESS0;
3. 공용 인터페이스 제작
하드웨어는 여러 플랫폼을 통해 각자 다른 방식으로 동작하더라도 공용 인터페이스 코드를 통해 같은 방법으로 제어할 수 있어야 한다. HAL이라는 공용 API만 구현해두면 해당 API를 통해 각 하드웨어 별로 구현하는 방식을 사용한다.
/* /hal/HalUart.h */
#ifndef HAL_HALUART_H_
#define HAL_HALUART_H_
void Hal_uart_init(void);
void Hal_uart_put_char(uint8_t ch);
uint8_t Hal_uart_get_char(void);
#endif
공용 인터페이스를 위처럼 구현해두면, 각 하드웨어 폴더에서 위의 구조에 따라서 하드웨어가 동작하도록 코드를 작성해야한다. 그리고 컴파일 시에는 현재 타깃에 맞는 하드웨어 c 소스파일만 컴파일 해야한다.
혹시 몰라서 추가하는 자료형 크기
레지스터의 각 플래그값을 직접 접근해서 에러처리를 할 수 있지만, 최대한 어셈블리 명령어 실행 횟수도 줄이고 접근 속도까지 최적화 해야하기 때문에 UART레지스터에 접근하는 것보다 변수에 값을 저장해서 접근하면 스택이나 CPU 레지스터에서 가져오기 때문에 훨씬 빠르다.
/* /hal/rvpb/Uart.c */
#include "stdint.h"
#include "Uart.h"
#include "HalUart.h"
extern volatile PL011_t* Uart; // hal/rvpb/Regs.c 에 선언되어 있는 변수
void Hal_uart_init(void){
//Enable UART
Uart->uartcr.bits.UARTEN = 0; // Uart off
Uart->uartcr.bits.TXE = 1; // Uart tx enable on
Uart->uartcr.bits.RXE = 1; // Uart rx enable on
Uart->uartcr.bits.UARTEN = 1; // Uart on
}
void Hal_uart_put_char(uint8_t ch){
// 데이터 전송 FIFO가 가득찬 경우 TXFF가 1로 설정된다.
// 데이터 전송중이라면 무한루프 돌아서 전송이 완료된 후 데이터 전송을 할 수 있도록 처리한것.
while(Uart->uartfr.bits.TXFF);
// UART DataRegister. 인자로 전달받은 데이터를 레지스터에 세팅하는 과정
// FIFO가 활성화되면 기록된 데이터가 전송장치로 푸쉬된다. &0xFF는 1byte까지만 받는다는 장치
Uart->uartdr.all = (ch & 0xFF);
}
uint8_t Hal_uart_get_char(void){
uint8_t data;
// 데이터를 수신할때까지 기다림
while(Uart->uartfr.bits.RXFE);
// 데이터를 수신할땐 상태비트+데이터 총 12bit가 수신FIFO로 푸시된다.
data = Uart->uartdr.all;
// 에러가 발생한 경우 (BE, FE, OE, PE)
if(data & 0xFFFFFF00){
// 그냥 에러처리없이 상태레지스터 초기화 + 종료
Uart->uartrsr.all = 0xFF;
return 0;
}
// 데이터가 맨 마지막바이트에 있기 때문에 1byte만 추출해서 return
return (uint8_t)(data & 0xFF);
}
이 함수들을 사용하는 메인함수 작성
/* boot/Main.c */
#include "stdint.h"
#include "HalUart.h"
static void Hw_init(void);
void main(void){
Hw_init();
uint32_t i = 100;
while(i--)
Hal_uart_put_char('N');
}
static void Hw_init(void){
Hal_uart_init();
}
Makefile에서도 hal 하위경로의 파일들을 포함하도록 만들어야하고, --no-graphic 옵션을 줘서 현재 콘솔입출력을 시리얼포트 (UART) 입출력과 연결한다.
TARGET = rvpb
VPATH = boot hal/$(TARGET)
C_SRCS = $(notdir $(wildcard boot/*.c))
C_SRCS += $(notdir $(wildcard hal/$(TARGET)/*.c))
C_OBJS = $(patsubst %.c, build/%.o, $(C_SRCS))
INC_DIRS = -I include -I hal -I hal/$(TARGET)
CFLAGS = -c -g -std=c11
build/%.os: %.S
build/%.o: %.c
printf같은 라이브러리 함수는 하드웨어를 직접 건드는게 아닌 공용 인터페이스 함수를 이용하여 만들어지게 된다.
문자열의 끝은 NULL로 되어있어 전달받은 문자열을 맨앞주소부터 NULL이 반환될때까지 다음문자를 읽어오고 글자수를 리턴하게된다.
/* /lib/stdio.h */
#ifndef LIB_STDIO_H_
#define LIB_STDIO_H_
uint32_t putstr(const char *s);
#endif
/* /lib/stdio.c */
#include "stdint.h"
#include "HalUart.h"
#include "stdio.h"
uint32_t putstr(const char *s){
uint32_t c;
while(*s){
Hal_uart_put_char(*s++);
c++
}
return c;
}
가변인자 함수 작성
...을 통해 인자를 가변으로 받을 수 있으며, 이를 처리하기 위해서는 c언어 표준 라이브러리가 아니라 컴파일러에 빌트인된 함수를 사용해야 하기 때문에 컴파일러마다 지원하는 함수가 다를 수 있다.
stdarg.h 의 코드는 builtin 자료형, 함수들의 이름을 편하게 사용할 수 있도록 변경하는 코드이다.
printf 함수같은 자주 사용하는 함수의 이름은 컴파일 과정에서 최적화 진행도중 puts함수로 바꿔버릴 수 있기 때문에 사용자 함수를 제작할땐 이름을 주의해야 한다.
uint32_t debug_printf(const char* format, ...){ // 가변인자로 함수작성
va_list args; // 가변인자 리스트 포인터변수 선언. char* 형으로 되어있음
va_start(args, format); // args가 가변인자를 가리킬수 있도록 마지막 고정인자를 포함
vsprintf(printf_buf, format, args);
va_end(args); // 가변인자 사용이 끝나면 args 포인터를 null로 초기화
return putstr(printf_buf);
}
/* ---------------------------------------- */
#ifndef INCLUDE_STDARG_H_
#define INCLUDE_STDARG_H_
typedef __builtin_va_list va_list; // 가변인자 메모리주소를 저장하는 포인터
#define va_start(v,l) __builtin_va_start(v,l) // 가변인자를 가져올 수 있도록 포인터 설정
#define va_end(v) __builtin_va_end(v) // 가변인자 처리가 끝났을 때 포인터를 NULL로 초기화
#define va_arg(v,l) __builtin_va_arg(v,l) // 가변인자 포인터에서 자료형(l) 크기만큼 값을 가져온다.
// char, short 자료형은 int로 꺼내야되고, float도 double로 꺼낸 뒤 형변환 해야한다.
#endif
인터럽트 (GIC)
컴퓨터는 사람, 다른 시스템 등 외부와의 상호작용을 전부 인터럽트로 처리한다. 하드웨어 자체적으로도 발생하는 타이머 인터럽트가 존재한다.
하드웨어에서 인터럽트가 발생하면 인터럽트 컨트롤러로 신호를 보내게 되고, 인터럽트 컨트롤러가 ARM 코어로 IRQ나 FIQ 신호를 보내면 코어가 하던일을 멈추고 모드변경 후 익셉션 핸들러를 호출하여 인터럽트를 처리하게 된다.
기존 UART 입출력은 메인함수 코드에서 직접 진행한 것이고, 인터럽트를 이용하면 메인함수 코드 진행중 코드흐름을 강제로 변경하여 입출력을 수행할 수 있다.
0. 메인함수 무한루프
메인함수를 while(1); 구문으로 무한루프 처리하여 OS 가 종료되지 않게하고, 인터럽트로 키보드 입력을 받는다.
1. 레지스터 구조체 작성
rvbp에는 GIC라는 인터럽트 컨트롤러가 있고, 보드마다 다른 인터럽트 컨트롤러를 사용할 수 있기 때문에 Uart와 동일하게 /hal/rvpb 아래에 레지스터 구조체를 작성한다.
레지스터가 추가되었으니 Regs.c 에서 주소를 할당해서 인스턴스를 연결해야 한다.
2. 공용 API 설계 및 구현
#ifndef HAL_HALINTERRUPT_H_
#define HAL_HALINTERRUPT_H_
// 실제 개발에서는 메모리 낭비를 막기위해 핸들러의 크기를 필요한 만큼만 선언해야함
#define INTERRUPT_HANDLER_NUM 255
// 핸들러 함수포인터
typedef void (*InterHdlr_fptr)(void);
// 인터럽트 초기화. 내부에서 enable_irq() 호출
void Hal_interrupt_init(void);
// 인터럽트 번호를 넘기면 enable/disable
void Hal_interrupt_enable(uint32_t interrupt_num);
void Hal_interrupt_disable(uint32_t interrupt_num);
// 인터럽트 번호에 인터럽트 핸들러를 연결하는 함수
void Hal_interrupt_register_handler(InterHdlr_fptr handler, uint32_t interrupt_num);
// 인터럽트가 발생하면 인터럽트 레지스터에서 번호를 확인 후 그에 맞는 핸들러를 실행시켜준다.
void Hal_interrupt_run_handler(void);
#endif
구현은 역시 하드웨어 별로 진행된다.
인터럽트 초기화 코드에서 인터럽트 레지스터를 enable 하고, CPSR에 IRQ, FIQ 마스크를 제거하여 인터럽트가 발생하도록 한다. 코드는 순수 어셈코드로 작성해도 되지만 아래처럼 인라인 어셈을 작성하면 함수 에필로그, 프롤로그는 컴파일러가 자동으로 작성해준다.
void enable_irq(void){
__asm__ ("PUSH {r0, r1}"); // 인터럽트 전 사용하던 레지스터 백업
__asm__ ("MRS r0, cpsr"); // CPSR 레지스터 값을 r0으로 가져오기
__asm__ ("BIC r1, r0, #0x80"); // CPSR & 0111 1111 -> CPSR에서 IRQ마스크(0x80) 제거
__asm__ ("MSR cpsr, r1"); // 변경된 값을 CPSR에 저장
__asm__ ("POP {r0, r1}"); // 인터럽트 전 사용하던 레지스터 복원
}
void disable_irq(void){
...
__asm__ ("ORR r1, r0, #0x80"); // CPSR | 1000 0000 -> CPSR에서 IRQ 마스크 세팅
...
}
GIC에서는 IRQ ID 32~95까지 총 64개를 관리할 수 있는데, 32bit 레지스터인 setenable1, setenable2 레지스터의 각 bit에 ID를 연결해서 FLAG 형식으로 관리한다. UART는 IRQ ID가 44이기 때문에 setenable1 레지스터의 12번 오프셋을 1로 설정하면 enable상태가 된다.
인터럽트가 발생하게되면 GIC 레지스터중 Interrupt acknowledge 레지스터에 대기중인 IRQ ID가 세팅되기 때문에 이 값을 이용하여 핸들러를 실행하면 인터럽트를 발생시킨 하드웨어에 맞게 동작하게 할 수 있다.
인터럽트 처리 후에는 End of interrupt 레지스터에 IRQ ID를 써넣어서 인터럽트가 처리된걸 알려야 한다.
3. 인터럽트 초기화
인터럽트 초기화 코드는 최초 하드웨어 초기화 코드에서 실행시켜준다. 인터럽트를 사용할 기기보다 이전에 초기화되어야 인터럽트 기기 초기화 시 에러가 발생되지 않을 수 있다.
4. UART 인터럽트 세팅
UART장치 초기화 시 인터럽트를 사용한다는 세팅을 해줘야한다.
static void interrupt_handler(void){
// 그냥 키보드 한글자 입력받고 출력하는 함수
uint8_t ch = Hal_uart_get_char();
Hal_uart_put_char(ch);
}
void Hal_uart_init(void){
...
// UART에서 인터럽트 입력을 발생시킬건지 설정
Uart->uartimsc.bits.RXIM = 1;
// UART 인터럽트를 인터럽트 풀에 등록하고 핸들러와 연결
Hal_interrupt_enable(UART_INTERRUPT0);
Hal_interrupt_register_handler(interrupt_handler, UART_INTERRUPT0);
}
5. 익셉션 벡터에 인터럽트 연결
하드웨어에서 인터럽트가 발생하면 ARM코어는 익셉션 벡터 테이블에서 해당 익셉션에 맞는 핸들러를 실행하는게 전부이다. 그렇기 때문에 익셉션 핸들러에서 인터럽트 핸들러를 실행시키는 코드를 작성해야된다.
__attribute__ ((interrupt ("IRQ"))) void Irq_Handler(void)
{
// 인터럽트가 발생하면 ID와 연결된 핸들러 실행하는 함수
Hal_interrupt_run_handler();
}
__attribute__ ((interrupt ("FIQ"))) void Fiq_Handler(void)
{
// FIQ는 사용하지 않기 때문에 그냥 무한루프
while(true);
}
__attribute__ 는 GCC의 확장기능을 사용하겠다는 지시어이며, interrupt ("IRQ") 는 인터럽트의 리턴주소를 컴파일러가 자동으로 맞춰주는 코드이다.
익셉션이 발생하면 ARM 코어는 하던 일을 멈추고 모드변경 후 익셉션 벡터에서 모드에 맞는 핸들러를 실행하게 되는데, ARM의 파이프라인 구조(CPU 명령 사이클)에 의해 IRQ나 FIQ 같은 익셉션은 PC가 업데이트 된 이후에 처리되고, undefined instruction 익셉션은 PC가 업데이트 되기전에 처리된다.
그렇기 때문에 같은 주소로 리턴하게되면 펌웨어 실행중 오작동하게 되는데, 이것을 컴파일러가 자동으로 맞춰주도록 확장기능을 통해 편리하게 제공된다.
익셉션 벡터 테이블에서 IRQ, FIQ에 맞는 익셉션 핸들러를 연결해주면 인터럽트를 사용할 수 있게된다.
vector_start:
LDR PC, reset_handler_addr
...
reset_handler_addr: .word reset_Handler
...
irq_handler_addr: .word Irq_Handler
fiq_handler_addr: .word Fiq_Handler
vector_end:
타이머 (SP804)
1. 레지스터 추상화
목표 카운터 레지스터에 값을 설정하고 측정 카운터 레지스터의 값이 1씩 줄어들어 0이 되는 순간 인터럽트를 발생시키는 구조이다. 구현에 따라 측정 카운터가 0부터 증가하여 목표 카운터와 같아지면 인터럽트를 발생시킬 수도 있다.
typedef struct Timer_t
{
uint32_t timerxload; // 카운터 목표값 지정
uint32_t timerxvalue; // 타이머가 켜지면 목표값(timerxload)이 복사된 후 1씩 줄어듦
TimerXControl_t timerxcontrol; // 타이머 하드웨어 속성 설정
uint32_t timerxintclr; // 인터럽트 처리가 완료된 것을 타이머 하드웨어에 알려주는 역할
TimerXRIS_t timerxris;
TimerXMIS_t timerxmis;
uint32_t timerxbgload;
} Timer_t;
typedef union TimerXControl_t
{
uint32_t all;
struct {
uint32_t OneShot:1; // 1로 세팅되면 인터럽트 1번 발생 후 타이머 종료.
uint32_t TimerSize:1; // timerxload, timerxvalue 크기. 0이면 16bit, 1이면 32bit.
uint32_t TimerPre:2; // 1클럭마다 카운터 줄일지, 16, 256 마다 줄일지 선택
uint32_t Reserved0:1;
uint32_t IntEnable:1; // 타이머 하드웨어 인터럽트 enable
uint32_t TimerMode:1; // timerxload 사용여부. 사용하지 않으면(0) 최대값부터 내려감
uint32_t TimerEn:1; // 타이머 하드웨어 전체를 enable
uint32_t Reserved1:24;
} bits;
} TimerXControl_t;
TimerMode를 0으로 설정하여 timerxload 를 사용하지 않으면 timerxvalue가 최대값으로 세팅된 후 클럭 속도에 맞춰 0까지 내려가는 free running 모드로 설정되고, 1로 설정하면 timerxload에 설정된 값부터 timerxvalue 값이 시작하며 0까지 내려가는 periodic 모드로 설정된다.
2. 공용 API 작성
하드웨어 초기화 과정은 데이터시트에 적힌 절차대로 진행한 것이다. 이후 타이머를 periodic 모드로 변경 후 1ms 마다 인터럽트가 발생하도록 설정해준다.
static uint32_t internal_1ms_counter;
void Hal_timer_init(void){
Timer->timerxcontrol.bits.TimerEn = 0; // 타이머 disable
Timer->timerxcontrol.bits.TimerMode = 0; // 프리러닝 모드 설정 1
Timer->timerxcontrol.bits.OneShot = 0; // 프리러닝 모드 설정 2
Timer->timerxcontrol.bits.TimerSize = 0; // 16bit 모드로 설정
Timer->timerxcontrol.bits.TimerPre = 0; // 1클럭마다 값이 내려가게 설정
Timer->timerxcontrol.bits.IntEnable = 1; // 인터럽트 enable
Timer->timerxload = 0; // 로드레지스터에 값지정
Timer->timerxvalue = 0xFFFFFFFF; // 카운터 레지스터 0xFFFFFFFF 설정
// 타이머 설정 세팅
Timer->timerxcontrol.bits.TimerMode = TIMER_PERIODIC;
Timer->timerxcontrol.bits.TimerSize = TIMER_32BIT_COUNTER;
// 타이머에서 1ms마다 인터럽트가 발생하도록 설정
uint32_t interval_1ms = TIMER_1MZ_INTERVAL / 1000; // 1ms 시간 설정. 상수는 1MHz
Timer->timerxload = interval_1ms;
Timer->timerxcontrol.bits.TimerEn = 1;
internal_1ms_counter = 0;
Hal_interrupt_enable(TIMER_INTERRUPT);
Hal_interrupt_register_handler(interrupt_handler, TIMER_INTERRUPT);
}
static void interrupt_hnadler(void){
// 1ms 시간이 지날때마다 숫자가 올라간다
internal_1ms_counter++;
// 인터럽트가 실행(클리어)된걸 하드웨어에 알려줌. 안그러면 GIC에 계속 인터럽트 보냄
Timer->timerxintclr = 1;
}
타이머는 클럭 소스가 1MHz, 32kHz 둘 중 하나로 설정되어 있으며 보드의 데이터 시트를 확인하면 SYSCTRL0 레지스터의 15번 비트가 1일때 1MHz를 사용하는것을 확인할 수 있다. (메모리주소를 직접 읽어오면 확인가능)
그리고 인터럽트의 주기를 계산할 timerxload 값을 설정해야 하는데, 타이머 데이터시트에 계산하는 공식이 존재한다.
/* 메인함수에서 UART를 통해 출력 */
uint32_t* sysctrl0 = (uint32_t*)0x10001000;
debug_printf("SYSCTRL0 %x\n", *sysctrl0);
internal_1ms_counter 는 uint32_t 자료형이라 32bit인데, 타이머가 켜진 후 1ms당 1씩 증가하여 50일정도면 오버플로가 발생하게 된다. delay() 함수는 타이머를 사용하는데, 오버플로를 염두해 두고 에러가 발생하지 않도록 구현해야한다.
#ifndef HAL_HALTIMER_H_
#define HAL_HALTIMER_H_
void Hal_timer_init(void);
uint32_t Hal_timer_get_1ms_counter(void); // 타이머 카운터 변수값 리턴하는 함수
#endif
3. delay() 함수 제작
ms가 아무리 높아도 최대 타이머 카운터 크기이기 때문에 오버플로가 발생해도 원하는 방향으로 프로그램이 실행된다.
/* lib/stdlib.c */
#include "stdint.h"
#include "stdbool.h"
#include "HalTimer.h"
void delay(uint32_t ms){
uint32_t goal = Hal_timer_get_1ms_counter() + ms;
// while(goal > Hal_timer_get_1ms_counter()); 이 코드는 오버플로 발생 시 위험함
while(goal != Hal_timer_get_1ms_counter());
}
delay 함수는 여러곳에서 사용할 수 있기 때문에 라이브러리 형태로 따로 작성됐고, 사용하고 싶은곳에서 사용하면된다.
태스크
운영체제는 태스크의 중요도나 대기시간으로 계산된 우선순위에 맞춰 프로그램을 실행하는 것이 중요하다. 우선순위가 높아 먼저 실행되고 있던 태스크가 우선순위는 낮지만 대기시간이 길었던 태스크에게 실행 순서를 뺏길 수 있다. 이때 기존에 실행하던 태스크의 현재 상태정보를 저장하고 새로운 태스크의 정보로 실행되는 것을 컨텍스트 스위칭이라 한다
1. 태스크 추상화
태스크 컨트롤 블럭에 정보를 저장하여 관리하게 된다.
#ifndef KERNEL_TASK_H_
#define KERNEL_TASK_H_
#include "MemoryMap.h" // TASK_STACK_SIZE : 전체 스택 사이즈가 0x04000000(64MB) 으로 세팅되어있음
#define NOT_ENOUGH_TASK_NUM 0xFFFFFFFF
#define USR_TASK_STACK_SIZE 0x100000 // 개별 스택 사이즈를 0x100000(1MB)로 설정
#define MAX_TASK_NUM (TASK_STACK_SIZE / USR_TASK_STACK_SIZE) // 총 1MB 짜리 64개 태스크 가능
// 태스크의 컨텍스트 저장
typedef struct KernelTaskContext_t {
uint32_t spsr;
uint32_t r0_r12[13];
uint32_t pc;
} KernelTaskContext_t
// 태스크의 스택정보 저장. 이 구조체를 배열화 해서 태스크 컨트롤블럭처럼 사용하고 컨텍스트는 스택에 저장
typedef struct KernelTcb_t {
uint32_t sp; // 스택포인터
uint8_t* stack_base; // 태스크의 스택 베이스주소
} KernelTcb_t;
typedef void (*KernelTaskFunc_t)(void); // 함수 포인터 자료형 생성
// 커널의 태스크 관련 기능 초기화 함수
void Kernel_task_init(void);
// 태스크로 동작할 함수를 태스크 컨트롤 블록에 등록하고 커널에 생성하는 함수
uint32_t Kernel_task_create(KernelTaskFunc_t startFunc);
#endif
구현코드
static KernelTcb_t sTask_list[MAX_TASK_NUM];
static uint32_t sAllocated_tcb_index;
void Kernel_task_init(void){
sAllocated_tcb_index = 0; // 현재 생성된 태스크 수
// 전체 태스크 초기화
for(uint32_t i = 0; i<MAX_TASK_NUM; i++){
// 태스크마다 스택 베이스 주소(사실은 천장)
sTask_list[i].stack_base = (uint8_t*)(TASK_STACK_START + (i*USR_TASK_STACK_SIZE));
// 스택 포인터. 여기서부터 PUSH 하게되면 베이스 주소 방향으로 자란다.
sTask_list[i].sp = (uint32_t)sTask_list[i].stack_base + USR_TASK_STACK_SIZE - 4;
// 처음 실행 시 사용할 컨텍스트를 초기화할 공간 할당
sTask_list[i].sp -= sizeof(KernelTaskContext_t);
// 위에서 할당한 컨텍스트 공간의 pc, spsr 변수 초기화
// 변수는 낮은주소에서 높은주소로 자라는데 sp를 넣으면 스택 자라는 반대방향으로 값이 저장된다.
KernelTaskContext_t* ctx = (KernelTaskContext_t*)sTask_list[i].sp;
ctx->pc = 0;
ctx->spsr = ARM_MODE_BIT_SYS;
}
}
uint32_t KIernel_task_create(KernelTaskFunc_t startFunc){
// 초기화된 스택정보가 담긴 구조체 배열의 n번째 태스크 주소를 담는다.
KernelTcb_t* new_tcb = &sTask_list[sAllocated_tcb_index++];
// 태스크 수가 총 생성할 수 있는 태스크보다 많아진경우 생성 실패후 -1리턴
if(sAllocated_tcb_index > MAX_TASK_NUM){
return NOT_ENOUGH_TASK_NUM;
}
// 스택에 저장된 초기화한 컨텍스트를 불러와서 pc에 태스크 메인함수 연결
KernelTaskContext_t* ctx = (KernelTaskContext_t*)new_tcb->sp;
ctx->pc = (uint32_t)startFunc;
// 현재 pc까지 세팅된 태스크의 인덱스 리턴
return (sAllocated_tcb_index - 1);
}
태스크를 처음 실행할때에도 Restore 코드를 실행시키는데, tcb의 sp에서 pop을 통해 스택의 컨텍스트를 레지스터로 복원하게된다. 그래서 태스크의 초기화때 -= sizeof(KernelTaskContext_t) 하는 이유는 레지스터를 push 해뒀다고 생각하고 공간을 마련하는 과정이다.
2. 스케쥴러
현재 실행중인 태스크 다음에 실행할 태스크가 어떤건지 골라주는 역할을 수행하며, 스케쥴러를 얼마나 효율적으로 만드냐에 따라 운영체제의 성능이 좌우된다.
라운드로빈 스케줄러
모든 태스크를 같은 우선순위로 보고 순차적으로 돌아가면서 실행하는 구조
static uint32_t sCurrnet_tcb_index;
static KernelTcb_t* Scheduler_round_robin_algorithm(void);
static KernelTcb_t* Scheduler_roundrobin_algorithm(void){
// 태스크의 인덱스를 하나씩 늘려가면서 선택
sCurrent_tcb_index++;
// 현재 실행중인 전체 태스크의 크기가 넘어가면 다시 처음부터 선택하도록 함
sCurrent_tcb_index %= sAllocated_tcb_index;
// 선택된 태스크의 주소를 반환
return &sTask_list[sCurrent_tcb_index];
}
우선순위 스케줄러
태스크 컨트롤블럭에 우선순위 값을 담을 멤버 추가
태스크 생성 시 태스크에 우선순위를 전달받아서 직접지정
우선순위를 계속 비교하면서 높은거 부터 실행
추가로 대기시간 포함시키려면 확장해서 사용
태스크 스케줄링 코드
static KernelTcb_t* sCurrent_tcb;
static KernelTcb_t* sNext_tcb;
void Kernel_task_scheduler(void){
// 현재 태스크 컨트롤 블럭과 알고리즘상 다음 컨텍스트 블럭을 저장해두고 컨텍스트 스위칭
sCurrent_tcb = &sTask_list[sCurrent_tcb_index];
sNext_tcb = Scheduler_round_robin_algorithm();
Kernel_task_context_switching();
}
__attribute__ ((naked)) void Kernel_task_context_switching(void){
// 두 함수만 호출하면 컨텍스트 스위칭이 완료된다.
__asm__ ("B Save_context");
__asm__ ("B Restore_context");
}
3. 컨텍스트 스위칭
1) 현재 동작중인 태스크의 컨텍스트를 스택에 백업
static __attribute__ ((naked)) void Save_context(void){
// 레지스터 전부 스택에 푸시
__asm__ ("PUSH {lr}");
__asm__ ("PUSH {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12}");
__asm__ ("MSR r0, cpsr");
__asm__ ("PUSH {r0}");
// 지금까지 진행했던 스택 tcb에 저장. (uin32_t)(*sCurrent_tcb) = ARM코어 SP레지스터값 (첫번째 멤버가 sp)
__asm__ ("LDR r0, =sCurrent_tcb");
__asm__ ("LDR r0, [r0]");
__asm__ ("STMIA r0!, {sp}");
}
naked는 프롤로그 에필로그등 컴파일러에서 코드를 추가하지 않고 내용에 적힌 코드 그대로 컴파일한다는 뜻이다.
2) 다음 태스크블록의 스택포인터를 읽어서 스택에 저장되어있는 컨텍스트를 ARM 코어에 복구
static __attribute__ ((naked)) void Restore_context(void){
// sNext_tcb에는 다음 스케줄러에 의해 컨텍스트가 담겨져있고, 스택포인터를 복구한다.
__asm__ ("LDR r0, =sNext_tcb");
__asm__ ("LDR r0, [r0]");
__asm__ ("LDMIA r0!, {sp}");
// 나머지 레지스터도 복구
__asm__ ("POP {r0}");
__asm__ ("MSR cpsr, r0");
__asm__ ("POP {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12");
__asm__ ("POP {pc}");
}
3) PC도 복구되기 때문에 다음 태스크를 바로 실행할 수 있다.
4. 언제 스위칭이 일어나게 할것인가
태스크가 요청하기 전에 커널이 강제로 스케줄링 시키면 선점형, 태스크가 요청할때 스케줄링하는 방식이 비선점형이다.
태스크가 할일이 다 끝나면 Kernel_yield() 함수를 호출하고 그때 태스크 스케줄러를 동작시켜서 다른 태스크로 넘겨주면 된다.
5. 최초 태스크 실행
스케줄링 코드를보면 현재 태스크를 저장하는것부터 시작한다. 그렇기때문에 현재 태스크가 없는 상황에서는 커널의 레지스터와 스택포인터를 저장하게 된다.
최초 태스크를 실행할땐 세이브과정 없이 리스토어만 하면된다.
이벤트
현재까지 UART의 인터럽트 처리가 인터럽트 핸들러에서 동작하게 되어있다. 인터럽트가 발생했을때 태스크가 처리하고 싶은 경우에 이벤트를 사용하면 된다.
1. 이벤트 플래그
비트맵 형식으로 0100 0000 ... 0000 0010 = 0x40000002 = 이벤트 2개가 발생했고, 처리중(pending) 이라는 뜻
32bit 변수로는 32개의 플래그만 작성할 수 있고, 변수를 늘려 64개 이상도 가능하지만 그에맞게 처리해줘야한다.
/* kernel/event.h */
#ifndef KERNEL_EVENT_H_
#define KERNEL_EVENT_H_
typedef enum KernelEventFlag_t{
KernelEventFlag_UartIn = 0x00000001,
KernelEventFlag_Reserved01 = 0x00000002,
KernelEventFlag_Reserved02 = 0x00000004,
...
KernelEventFlag_Reserved31 = 0x80000000,
KernelEventFlag_Empty = 0x00000000,
} KernelEventFlag_t
void Kernel_event_flag_set(KernelEventFlag_t event);
void Kernel_event_flag_clear(KernelEventFlag_t event);
bool Kernel_event_flag_check(KernelEventFlag_t event);
#endif
구현코드
/* kernel/event.c */
#include "stdint.h"
#include "stdbool.h"
#include "stdio.h"
#include "event.h"
static uint32_t sEventFlag;
void Kernel_event_flag_init(void){
sEventFlag = 0;
}
void Kernel_event_flag_set(KernelEventFlag_t event){
sEventFlag |=(uint32_t)event;
}
void Kernel_event_flag_clear(KernelEventFlag_t event){
// 어셈에서 BIC연상과 동일한 연산수행.
sEventFlag &= ~((uint32_t)event);
}
void Kernel_event_flag_check(KernelEventFlag_t event){
// 이벤트 플래그가 겹치면 0이 아닌값 반환된다.
if(sEventFlag & (uint32_t)event){
Kernel_event_flag_clear(event); // clear는 여기에 작성하지 않는게 함수 이름상 알맞다
return true;
}
return false;
}
/* kernel/Kernel.c 추가 */
// 이벤트를 보낸다는 역할만 충실히 수행하는 함수. 그래야 다른 코드와 커플링을 최소화할 수 있다.
// 인터럽트 핸들러 등 이벤트가 발생하는 곳에서 호출
void Kernel_send_events(uint32_t event_list){
for (uint32_t i = 0; i<32; i++){
// 루프돌면서 어떤 비트가 세팅되어있는지 확인하는 과정
if((event_list >> i) & 1){
// 세팅되어있으면 비트 세팅해서 전역변수에 세팅
// 근데 사실 이과정을 전부 Kernel_event_flag_set(evnet_list); 로 해도되지않나?
KernelEventFlag_t sending_event = KernelEventFlag_Empty;
sending_event = (KernelEventFlag_t)SET_BIT(sending_evnet, i);
Kernel_event_flag_set(sending_event);
}
}
}
// 이벤트를 기다리는 함수. 태스크에서 호출
// 플래그에 세팅이 되어있는지 확인 후 해당 플래그 반환
KernelEvnetFlag_t Kernel_wait_events(uint32_t waiting_list){
for(uint32_t i=0; i<32; i++){
if((waiting_list >> i)&1){
KernelEventFlag_t waiting_event = KernelEventFlag_Empty;
waiting_event= (KernelEventFlag_t)SET_BIT(waiting_event, i);
if(Kernel_event_flag_check(waiting_event)){
return waiting_event;
}
}
}
}
이벤트는 비트맵형식으로 각 장치의 역할마다 다른 이벤트값을 가지고있기 때문에 이벤트가 겹치지 않는다.
Kernel_send_events(event1|event2|event3|event4); 이렇게 여러 이벤트를 한꺼번에 발생시킬 수 있다.
Task#1
Kernel_wait_evnets(event1|event3);
Task#2
Kernel_wait_evnets(event2);
그리고 이렇게 나눠서, 동시에 여러개를 처리할 수 있다.
2. 인터럽트 핸들러에 연결
UART 입력이 발생할 때 인터럽트 핸들러가 실행될때 send_event를 하고, 태스크에서 waiting_event 함수를 통해 전역 이벤트 플래그에 세팅되는 값을 확인해서 이벤트 처리를 수행하는 방식을 사용한다.
이벤트가 여러개일땐 while문을 통해 이벤트를 모두 처리한 뒤 스케줄링하면 된다.
while(true){
bool pendingEvent = true;
while(pendingEvent){
// 커널 이벤트 플래그 기다림
KernelEventFlag_t handle_event = Kernel_wait_events(event1|event2|...);
switch(handle_event){
case event1:
debug_printf("event1");
break;
case event2:
debug_printf("event1");
break;
default:
// 더이상 기다리는 이벤트가 발생하지 않을때
pendingEvent = false;
break;
}
}
// CPU 선점 끝 스케줄링 시작
Kernel_yield();
}
3. 사용자 정의 이벤트
이벤트는 꼭 인터럽트와 연관짓지 않아도 태스크끼리 이벤트 처리가 가능하다.
메시징
이벤트는 1bit를 이용하기 때문에 UART 입력에서 인터럽트가 발생했다는것을 알릴 순 있지만, 어떤 키가 입력됐는지는 알릴 수 없다. 물론 각 키별로 이벤트 처리를한다면 가능하지만 효율적이지 못하다.
1. 메시지 큐
메시징은 기본적으로 큐를 사용하고, 임베디드 시스템에서는 동적할당을 피하기위해 배열을 주로 사용한다.
임베디드 시스템에서는 적은 메모리공간, 장기간 작동되는 시스템 두가지 문제로 랜덤주소에 힙의 할당, 해제가 자주 반복되면 힙의 파편화 문제가 발생한다. (공간은 많이 남아있지만 떨어져있어서 그만큼 활용하지 못하는 경우)
배열은 일차원 배열을 사용하여 사실은 일자로 나열된 형식이지만, 큐로 사용하는건 원형이라고 생각하면 된다.
배열의 마지막 인덱스까지 데이터가 채워져 있다 하더라도 첫번째 인덱스로 돌아가서 채우면 된다.
#ifndef KERNEL_MSG_H_
#define KERNEL_MSG_H_
#define MSG_Q_SIZE_BYTE 512
typedef enum KernelMsgQ_t{
KernelMsgQ_Task0, // 태스크 0의 메세지큐 = 0
KernelMsgQ_Task1, // 태스크 1의 메세지큐 = 1
KernelMsgQ_Task2, // 태스크 2의 메세지큐 = 2
KernelMsgQ_Num // 전체 메세지 큐 크기 = 3
}KernelMsgQ_t;
// 메세지큐 한개. rear에서 데이터가 들어와서 front에서 빠져나간다. 메세지큐 수만큼 배열 전역변수로 할당
typedef struct KernelCirQ_t{
uint32_t front; // 메세지 큐의 앞 (데이터가 채워진 인덱스보다 한칸앞)
uint32_t rear; // 메세지 큐의 뒤
uint8_t Queue[MSG_Q_SIZE_BYTE]; // 배열형식 큐
} KernelCirQ_t;
// 전체 메시지 큐(3개) 모두 초기화
void Kernel_msgQ_init(void);
// 아래 함수들은 함수 성공호출 시 true 실패시 false 반환
// 메세지 큐가 비어있는지 확인.
// 1. 현재 메세지큐가 사용하는건지 확인 / 2. 메시지큐의 front, rear가 같은 값인지 확인
bool Kernel_msgQ_is_empty(KernelMsgQ_t Qname);
// 메세지 큐가 꽉차있는지 확인. rear+1 == front 인경우 큐가 꽉찼다고 볼수있다.
bool Kernel_msgQ_is_full(KernelMsgQ_t Qname);
// 큐에 데이터 입력. rear 에 데이터를 넣고 rear+1. rear가 배열 인덱스보다 커진경우 다시 0에 저장
// sMsgQ[Qname].rear = %=MSG_Q_SIZE_BYTE;
bool Kernel_msgQ_enqueue(KernelMsgQ_t Qname, uint8_t data);
// 큐 데이터를 출력. front+1 후 데이터 빼기.
bool Kernel_msgQ_dequeue(KernelMsgQ_t Qname, uint8_t* out_data);
#endif
2. 메시지 보내기 받기 코드
/* Kernel.c */
// 커널 API는 count만큼 여러 데이터를 한번에 처리하도록만들었음
bool Kernel_send_msg(KernelMsgQ_t Qname, void* data, uint32_t count){
uint8_t* d = (uint8_t*)data;
for(uint32_t i = 0; i<count; i++){
// 카운터 수만큼 데이터 주소를 +1씩 해가면서 1byte씩 큐에 저장
if(false == Kernel_msgQ_enqueue(Qname, *d){
// 이 코드는 잘못됨. 큐는 뒤에서 넣는데 왜 앞에서부터 꺼내냐
for(uint32_t j = 0; j<i; i++){
uint8_t rollback;
Kernel_msgQ_dequeue(Qname, &rollback);
}
return false;
}
d++;
}
return true;
}
uint32_t Kernel_recv_msg(KernelMsgQ_t Qname, void* out_data, uint32_t count){
// 꺼낼 데이터가 저장될 공간 주소를 d에 저장
uint8_t* d = (uint8_t*)out_data;
// 큐에서 count크기만큼 꺼낸다음 d에 1byte씩 저장
for(uint32_t i = 0; i<count; i++){
if(false==Kernel_msgQ_dequeue(Qname, d)){
return i;
}
d++;
}
return count;
}
3. 사용
/* UART 인터럽트 핸들러 */
static void interrupt_handler(void){
uint8_t ch = Hal_uart_get_char();
Hal_uart_put_char(ch);
// 키를입력 받을때마다 Task0에 한글자씩 전송, 이벤트도 함께 전달
Kernel_send_msg(KernelMsgQ_Task0, &ch, 1);
Kernel_send_events(KernelEventFlag_UartIn);
}
void User_task0(void){
uint8_t cmdBuf[16];
uint32_t cmdBufIdx =0;
uint8_t uartch =0;
while(true){
// 이벤트 대기
KernelEventFlag_t handle_event
= Kernel_wait_events(KernelEventFlag_UartIn|KernelEventFlag_CmdOut);
switch(handle_event){
case KernelEventFlag_UartIn:
// 입력이 발생한경우 메세지 큐에서 데이터 하나 꺼냄
Kernel_recv_msg(KernelMsgQ_Task0, &uartch, 1);
// 엔터인 경우
if(uartch == '\r'){
// 마지막에 \0 저장
cmdBuf[cmdBufIdx] = '\0';
// 저장한버퍼 주소 전달하고 Task1로 인덱스까지 전달, CmdIn 이벤트 전달
// 사실 여기서 데이터 전송 실패시 (큐 꽉차는경우 등) 에러처리를 해야됨
Kernel_send_msg(KernelMsgQ_Task1, &cmdBufIdx, 1);
Kernel_send_msg(KernelMsgQ_Task1, cmdBuf,cmdBufIdx);
Kernel_send_events(KernelEventFlag_CmdIn);
cmdBufIdx = 0;
} else {
// 엔터 입력될때까지 1바이트씩 입력받아서 저장해둔다
cmdBuf[cmdBufIdx] = uartch;
cmdBufIdx++;
cmdBufIdx %= 16; // 오버플로 방지.
}
break;
case KernelEventFlag_CmdOut:
debug_printf("\nCmdOut Event by Task0\n");
break;
}
Kernel_yield();
}
}
void User_task1(void){
uint8_t cmdlen = 0;
uint8_t cmd[16] = {0};
while(true){
KernelEventFlag_t handle_event = Kernel_wait_events(KernelEventFlag_CmdIn);
switch(handle_event){
// 그냥 이벤트 발생하면 메세지 읽고 끝냄
case KernelEventFlag_CmdIn:
memclr(cmd, 16);
Kernel_recv_msg(KernelMsgQ_Task1, &cmdlen, 1);
Kernel_recv_msg(KernelMsgQ_Task1, cmd, cmdlen);
debug_printf("\n Recv Cmd %s\n", cmd);
break;
}
Kernel_yield();
}
}
동기화
운영체제에서 어떤 작업을 아토믹 오퍼레이션으로 만들어준다는 뜻. 원자작업은 너무 작아서 나눠서 할수없다. 이런의미로 생각하면 된다. 아토믹 오퍼레이션은 해당 작업이 끝날때까지 컨텍스트 스위칭이 발생하지 않게된다.
어떤 작업이 아토믹하게 구현해야만 한다면 그 작업을 크리티컬 섹션이라고 부른다.
1. 세마포어
#define DEF_SEM_MAX 8
static int32_t sSemMax; // 세마포어 init 후 최대값
static int32_t sSem; // 현재 세마포어 값
void sem_init(int32_t max){
// 이것도 좀 이상함. 원래 C에선 0부터 시작되는데 왜 이렇게 작성한거지?
sSemMax = ((max <= 0) || (max >= DEF_SEM_MAX)) ? DEF_SEM_MAX : max;
sSem = sSemMax;
}
// 사용법 : while(sem_test()); 이렇게하면 세마포어가 0일땐 무한대기 그외에는 세마포어 -1 하고 접근가능
bool sem_test(void){
if(sSem<=0){
return false;
}
sSem--;
return true;
}
void lock_sem(void){
while(sem_test()){
Kernel_yield();
}
}
// 세마포어 값을 하나 늘리고 잠금을 풀어주는것. 맥스보다 많이 호출할 순 없다.
void sem_release(void){
sSem++;
if(sSem >= sSemMax){
sSem = sSemMax;
}
}
2. 뮤텍스
3. 스핀락
바쁜대기. 스케줄링을 하지 않고 CPU를 점유한 상태에서 락이 풀리는것을 의미한다.
멀티코어 환경에서는 CPU를 점유하면서 잠금이 풀리는것을 기다리는건 유용하지만, 싱글코어에서는 결국 태스크 실행 순서가 넘어와야 잠금을 풀어줄 수 있기 때문에 사용할 수 없다. (RealViewPB도 싱글코어 에뮬)
static bool sSpinLock = false;
void spin_lock(void){
while(sSpinLock);
sSpinLock = false;
}
void spin_unlock(void){
sSpinLock = true;
}
사실 바쁜대기 자체가 아토믹해야하기 때문에 배타메모리 연산을 지원하는 어셈명령으로 구현되어야 한다.