본문 바로가기
Kernel/Documentation

[Linux Kernel] 리눅스 커널 개발 가이드: Getting the code right

by hyeyoo 2021. 8. 30.
※ 이 블로그의 글은 글쓴이가 공부하면서 정리하여 쓴 글입니다.
※ 최대한 내용을 검토하면서 글을 쓰지만 틀린 내용이 있을 수 있습니다.
※ 만약 틀린 부분이 있다면 댓글로 알려주세요.
 

4. Getting the code right — The Linux Kernel  documentation

4.1.1. Coding style The kernel has long had a standard coding style, described in Linux kernel coding style. For much of that time, the policies described in that file were taken as being, at most, advisory. As a result, there is a substantial amount of co

www.kernel.org

나도 전역하면 맥북 사야겠다

커뮤니티와 함께 설계를 하는 것도 할 이야기가 많지만, 모든 커널 개발 프로젝트의 증명은 결과적으로 코드가 한다. 다른 개발자들이 보는 것도 코드이고, 실제로 mainline에 머지가 되는 (또는 거절 되는) 것도 코드이다. 따라서 궁극적으로 프로젝트의 성공 유무를 결정하는 것도 코드의 퀄리티다.

이 섹션에서는 코딩 프로세스를 살펴볼 것이다. 우선 개발자들이 실수하기 쉬운 부분부터 살펴보자. 그 다음에는 이런 실수를 어떻게 해결하는지를 논하고, 도움이 되는 툴도 살펴볼 것이다.

4.1 Pitfalls

4.1.1 Coding Style

커널은 오랜 기간동안 유지하고 있는 코딩 스타일이 있다. 리눅스 커널 코딩 스타일에 자세하게 나와있다. 오랫동안 코딩 스타일은 잘해봐야, 그저 해도 되고 안 해도 되는 권고 사항 정도로 취급되었다. 결과적으로 코딩 스타일 가이드라인에 맞지 않는 코드가 많이 생겼다. 코딩 스타일을 준수하지 않는 코드는 두 가지의 문제가 있다.

첫 번째는 사람들이 코딩 스타일이 중요하지 않고 강제되지 않는다고 생각하는 것이다. 하지만 사실은 새로운 작성되는 코드가 코딩 스타일을 지키지 않으면 받아들여지기가 매우 힘들다. 대부분의 개발자들이 코드를 리뷰하기도 전에 코딩 스타일에 맞춰서 다시 작성해달라고 요구할 것이다. 리눅스 커널처럼 코드 베이스가 거대한 프로젝트는 코드에 통일성을 갖게 해서 개발자가 어느 코드던 빨리 이해할 수 있게 하는 게 중요하다. 따라서 이제 코딩 스타일을 준수하지 않는 코드에 대한 여지는 없다.

종종, 커널 코딩 스타일은 고용주(회사)의 스타일과 다를 때가 있다. 이런 상황에서는 코드를 머지하기 전에 항상 커널 코딩 스타일에 맞춰야 한다. 커널에 코드를 머지한다는 것은 해당 코드에 대한 권한을 일정 부분 (코딩 스타일 포함) 포기하는 것이다.

그렇다고 해서 이미 커널에 있는 코드의 스타일이 이상하다고 당장 고쳐야 하는 건 아니다. 오히려 순수하게 코딩 스타일만을 고치는 패치는 환영받지 못하므로 지양해야 한다. 자연스럽게 코드를 수정하면서 스타일도 고치는 것이 가장 좋다.

코딩 스타일 가이드는 절대적인 규칙은 아니다. 스타일 지키지 않을 정당한 이유가 있다면 (예를 들어 코딩 스타일에서는 한 라인은 최대 80자까지만 허용하는데, 80자 이상으로 두는게 훨씬 가독성이 좋은 경우) 지키지 않아도 된다.

코딩 스타일을 도와주는 도구로 clang-format이 있다. 자동으로 코드를 포매팅하거나, 코딩 스타일을 어떻게 고치면 좋을지 확인하는 용도로 사용할 수 있다. 자세한 것은 clang-format을 참고하자.

4.1.2 Abstraction layers

컴퓨터 과학 교수들은 학생들에게 유동성과 정보 은닉을 위해 추상적인 계층을 많이 만들라고 가르친다. 딱 커널이 추상화를 많이 활용한다. 그렇지 않으면 수백만 라인의 코드에서 살아남을 수 없다. 하지만 시행착오를 통해 너무 미숙한 추상화나 지나친 추상화는 나쁘다. 딱 적당한 만큼만 하고 더 많이는 하지 않는게 좋다.

간단한 예로 어떤 함수의 파라미터의 값이 항상 0이라고 해보자. 어떻게 보면 이후의 유동성을 위해서 파라미터를 그대로 둘 수도 있을 것이다. 하지만 이렇게 불필요한 파라미터이 존재하는 건 미묘하게 잘못되었을 수 있다. 왜냐하면 한 번도 사용된 적이 없기 때문이다. 커널 개발자들은 이런 불필요한 파라미터를 제거하는 패치를 주기적으로 보낸다. 애초에 이런 파라미터는 추가되면 안되는 것이다.

하드웨어의 직접적인 접근을 막고 추상 계층을 사용하는 것은 ㅡ 종종 디바이스 드라이버를 다양한 운영체제 위에서 돌아가게 해주기는 하나 ㅡ 특히 끔찍하다. 이런 추상 계층은 코드를 명확하지 않게 만들고 성능 저하가 될 수 있다. 리눅스 커널은 하드웨어를 숨기는 추상 계층은 사용하지 않는다.

반대로, 다른 서브시스템에서 코드를 빌려오는 경우가 많이 생긴다면, 그 코드를 별도의 라이브러리로 만들거나 좀 더 추상적인 기능으로 구현하는 게 좋을지를 고민해볼 필요가 있다. 커널에서 코드를 복붙해서 쓰는 것은 가치가 없다.

4.1.3 #ifdef and preprocessor use in general

C 전처리기는 소스 코드에 유동성을 부여할 수 있는 좋은 도구이다. 하지만 전처리기는 C가 아니기 때문에, 과하게 사용하면 코드의 가독성이 떨어지고 컴파일러를 혼란스럽게 만든다. 너무 전처리기에 의존하는 코드를 짜게 되면 리팩토링이 필요하다는 신호이다.

#ifdef로 조건부 컴파일을 하는 건 강력한 기능이다. 그리고 이 방법을 커널에서 많이 사용한다. 하지만 #ifdef로 떡칠된 코드를 보고 싶어하는 사람은 없을 것이다. 일반적으로 #ifdef는 가능하다면 반드시 헤더파일에서만 사용하는 것이 좋다. 조건부 컴파일이 되어서 코드가 존재하지 않는 함수가 생기면 텅 빈 함수가 만들어지고, 그러면 컴파일러가 최적화할 수 있다.

C의 전처리기 매크로는 좋기만 한 것은 아니다. 예를 들어 side effect가 있는 표현식(expression) 여러 번 계산(evaluate)할 수도 있고, 타입과 관련된 문제가 생기기 쉽다. 만약에 매크로를 작성할 상황이 생긴다면 매크로 대신 인라인 함수로 구현하는 것을 고민해보자. 인라인 함수는 더 가독성이 좋고, 인자를 여러번 계산하지 않고, 타입 관련 문제가 덜 일어난다.

4.1.4 Inline functions

하지만 인라인 함수도 좋기만 한 것은 아니다. 인라인 함수는 함수 호출을 하지 않기 때문에 더 효율적이라고 생각해서 인라인 함수로 도배를 해버리면, 똑같은 코드를 계속 복제하기 때문에 컴파일된 커널의 크기가 너무 커질 수 있다. 커널의 크기가 커지면 프로세서의 캐시에 부하를 주기 때문에 (아무래도 cache miss가 생기기 더 쉽다.) 오히려 성능이 극적으로 떨어질 수 있다. 따라서 인라인 함수는 함수가 너무 크지 않아야 하고, 자주 사용되서는 안된다. 그리고, 함수 호출의 비용은 생각보다 그렇게 비싸지 않다. 인라인 함수의 남용은 미숙한 최적화의 예이다.

일반적으로 커널 프로그래머는 캐시의 효과를 무시하는 경향이 있다. 자료구조 수업에서 배우는 시간/공간의 trade-off는 현대적인 하드웨어와는 맞지 않는다. 이러한 하드웨어에서는 공간이 곧 시간이다. (위에서 말했듯 메모리 영역을 적게 사용할수록 cache 활용하게 되므로 시간도 절약된다.)

최근의 컴파일러들은 예전보다 함수가 인라인 함수인지 아닌지를 좀 더 효과적으로 판단한다. 따라서 inline 키워드를 쓴다고 무조건 인라인 함수가 되는 건 아니다.

4.1.5 Locking

2006년 6월에 “Devicescape”라는 네트워킹 스택은 완전 잔치 분위기였다. GPL 라이선스로 릴리즈가 되었고 mainline 커널에 머지될 준비가 되었다. 당시에는 리눅스에서 무선 네트워킹 기능이 그렇게 좋지 않았기 때문에 Devicescape가 머지된다는 것은 좋은 소식이었다. 하지만 2007년까지도 머지되지 못했다. 무슨 일이 생긴걸까?

코드에선 회사 내부에서 폐쇄적으로 개발된 흔적이 많이 보였다. 하지만 가장 큰 문제점은 멀티프로세서 환경을 고려하지 않고 설계되었다는 점이다. 이 네트워킹 스택 (이제 mac80211이라고 부른다.)이 머지가 되기 전애, 멀티프로세서 환경에 맞게 락을 사용할 필요가 있었다.

옛날에는 리눅스 커널 코드가 멀티프로세서 환경에서의 동시성을 고려하지 않고 짜여졌지만, 지금 이 문서는 듀얼코어 노트북에서 쓰고 있다. 그리고 싱글 프로세서 환경이라도 이제는 동시성 문제가 생길 것이다. 이제 락 없이 코드를 짜는 시대는 지났다.

어떤 리소스든(자료구조, 하드웨어 레지스터, 등등) 동시에 둘 이상의 스레드가 접근할 가능성이 있다면 락으로 보호해야 한다. 새로운 코드를 작성할 땐 이 점을 명시해야 한다. 이미 짜여진 코드를 락을 고려해서 다시 짜는게 훨씬 어렵다. 커널 개발자들은 따라서 커널에서 어떤 락이 존재하는지 이해하고, 상황에 따라 적절하게 사용해야 한다. 동시성을 고려하지 않으면 mainline으로 가기 어렵다.

4.1.6 Regressions

마지막으로 언급할만한 위험은 regression이다. 커널에 무언가 변화를 주다가 기존의 기능을 부숴버리게 되는 경우가 있다. 이러한 변화를 “regression (회귀)”이라고 한다. regression은 mainline에서 절대로 환영받지 못한다. 몇몇 예외를 빼고는 당장 고칠 수 없는 regression이 발생한다면 바꾸기 전으로 돌려버릴 것이다. 애초에 regression이 발생하지 않도록 하는 것이 좋다.

종종, 몇몇 케이스에서 regression이 생기더라도 더 많은 사람들에게 이득을 준다면 regression을 정당화할 수 있다고 주장하는 사람들이 있다. 10개의 시스템에서는 작동하고 1개의 시스템에선 문제가 있으면 어떤가? 이 주장에 대한 가장 좋은 답변은 Linus가 2007년에 한 답변이다.

우리는 새로운 문제를 만들어가면서 버그를 고치지 않는다. 그건 미친 짓이다. 그리고 그렇게 하면 우리가 앞으로 정말 나아가고 있는 것인지 알 수가 없다. 한 걸음 앞으로 나아가고, 한 걸음 되돌아오는건가? 아니면 한 걸음 나아가고 두 걸음 되돌아오는 건가? (https://lwn.net/Articles/243460/)

regression 중에서도 가장 환영받지 못하는 것이, 사용자 공간의 ABI에 영향을 주는 것이다. 사용자 공간에 한 번 공개된 인터페이스는 영원히 제공되어야 한다. 이런 점이 사용자 공간 인터페이스를 만드는 것이 까다로운 이유이다. 나중에 바꿀 때도 호환성을 고려해야하기 때문에, 애초에 처음부터 설계를 잘 해야한다. 이러한 이유로 사용자 공간의 인터페이스는 생각을 많이 해야하고, 명확한 문서화와 폭넓인 리뷰가 필요하다.

4.2 Code checking tools

에러가 없는 코드를 작성하는 건 너무 어렵다. 하지만 그나마 해볼 수 있는 건 mainline 커널로 가는 코드의 버그를 최대한 많이 캐치하고 고치는 것이다. 버그를 잡기, 위해서 커널 개발자들은 다양한 종류의 모호한 문제들을 자동으로 잡아주는 인상적인 도구들을 많이 만들어냈다. 이러한 도구에 의해 발견된 문제들은 사용자에게 나쁜 영향을 주지 않기 때문에, 최대한 많이 쓰는 것이 좋다.

가장 첫 번째로 해볼 수 있는 건 컴파일러의 경고에 귀를 기울이는 것이다. 최근 버전의 gcc는 많은 잠재적인 오류를 탐지하고, 경고할 수 있다. 그리고 생각보다 이런 경고는 실제로 문제가 있는 부분을 경고한다. 일반적으로 리뷰를 하기 전에 컴파일러 경고가 뜨지 않도록 해야한다. 그리고 경고를 해결할 때는 단순히 경고만 안 뜨게 하는 게 아니라 경고가 뜨는 원인을 해결해야 한다.

기본적으로 컴파일러가 모든 경고를 보여주지는 않는다. make KCFLAGS=-W로 빌드를 하면 모든 경고를 보여준다.

커널엔 디버깅 기능을 활성화하는 설정(configuration)이 있다. 대부분은 “kernel hacking” 서브메뉴에서 찾을 수 있다. 몇가지 설정은 개발, 테스팅에 범용적으로 사용될 수 있다. 특히 다음 설정은 항상 켜는 것이 좋다:

- FRAME_WARN: 스택 프레임이 일정 사이즈 이상 커지면 경고한다.
- DEBUG_OBJECTS: 커널에서 생성한 객체의 생애 주기를 추적하고, 문제가 생기면 찾아준다. 만약 당신이 이런 객체를 사용하는 새로운 서브시스템을 만든다면, 객체를 디버깅하는 기반을 만들어보는 것도 좋다.  
- DEBUG_SLAB: 메모리 할당, 접근 관련 오류를 찾아준다. 대부분의 상황에서 이 설정이 필요하다.
- DEBUG_SPINLOCK, DEBUG_ATOMIC_SLEEP, DEBUG_MUTEXES: 흔하게 발생하는 락 관련 오류를 찾아준다.

위 목록 외의 옵션도 일부 이 글에서 설명할 것이다. 몇몇 옵션은 성능 저하가 심해서 항상 사용되지는 않는다. 하지만 시간을 투자해서 이 옵션들을 배워두면 금방 쓸 일이 생길 것이다.

 

제일 무거운 디버깅 툴 중 하나가 "lockdep"이다. lockdep은 락의 획득과 반환을 추적한다. 락의 획득과 해제 순서, 현재 인터럽트 상태인지 등등을 확인한다. lockdep은 락이 항상 같은 순서로 획득되는지를 확인함으로써, 데드락이 발생할 수 있는 가능성이 있다면 알려준다. 데드락이 발생하면 매우 불편하다. lockdep을 사용하면 자동으로 이런 문제를 미리 찾아낼 수 있다. 락을 사용하는 모든 중요한 코드는 lockdep으로 테스트한 후에 메일링 리스트로 보내야 한다.

 

부지런한 커널 개발자는 코드 곳곳에 어떤 연산이 실패했을 경우 (예를 들어 메모리 할당이 실패한 경우), 리턴값을 확인할 수 있다. 하지만 꼼꼼하게 코드를 작성해도, 가능한 모든 실패 루트가 테스트되지 않을 수도 있다. 그리고 테스트 되지 않은 코드는 문제를 일으키기 쉽상이다.

 

커널은 Fault Injection이라는 프레임워크를 제공해서 실패 경로를 테스트(특히 메모리 연산에 대해서)하도록 해준다. fault injection이 활성화되면, 일정 비율(직접 비율을 선택할 수 있다.)로 메모리 할당을 실패하도록 만들 수 있다. 그리고 이런 실패를 특정 범위의 코드에서만 일어나도록 정할 수 있다. fault injection을 사용하면, 오류가 발생했을 때 코드가 어떻게 처리되는지 테스트할 수 있다. 자세한 건 Fault injection capabilities infrastructure를 참고하자.

 

다른 종류의 오류 체크 툴은 "sparse"이다. sparse는 사용자 공간 - 커널 공간, 빅 엔디안 - 스몰 엔디안, 비트셋이 필요한 곳에 정수 값을 보내는 등등의 상황에서 헷갈릴 수 있는 부분들을 확인해준다. sparse는 별도로 설치를 해야한다. 설치한 후에는 make할 때 C=1를 붙여주면 작동한다.

 

"Coccinelle"은 다양한 잠재적인 코딩 문제를 찾고 그걸 해결하는 패치도 제안할 수 있다.  상당 수의 "semantic patches"는 scripts/coccinelle 디렉토리에 패키지 되어있다. make coccicheck을 실행하면 이런 semantic patch를 실행하고, 문제를 찾아낸다. 자세한 건 Coccinelle를 참고하자.

 

그 외의 이식성 오류는 코드를 다른 아키텍처로 크로스컴파일하면서 찾을 수 있다. 당신이 S/390 시스템이나 Blackfin 개발 보드를 갖고 있지 않다고 해도, 컴파일은 해볼 수 있다. https://www.kernel.org/pub/tools/crosstool/ 에서 x86 시스템을 위한 상당 수의 크로스 컴파일러를 찾을 수 있다.

 

시간을 좀 투자해서 위의 컴파일러들을 설치해놓으면, 나중에 곤란한 상황을 피할 수 있다.

 

4.3 Documentation

문서화의 경우 지금까지 커널 개발 규칙보다 예외가 많았다. 하지만 적절한 문서화는 커널 코드를 머지를 도와주고, 다른 개발자들에게 편의를 제공하며, 사용자에게도 도움이 된다. 따라서 대부분의 상황에서 문서화를 하는 것은 필수적으로 의무화되었다.

 

패치에 대한 첫 번째 문서화는 변경 기록(changelog)이다. 각각의 기록(log)은 어떤 문제를 해결하는지, 솔루션의 형식, 해당 패치를 만든 사람들, 성능상의 영향, 그리고 패치를 적용할 때 이해해야 하는 것들을 설명해야 한다. changelog는 해당 패치를 적용하는 이유를 설명해야 한다. 많은 개발자가 적용해야하는 이유를 설명하지 않는다.

 

사용자 공간 인터페이스 (sysfs, procfs 포함)를 새로 추가하는 모든 코드는 문서화를 해야한다. 그래야 사용자 공간 개발자들이 이를 활용할 수 있다. Documentation/ABI/README를 보면 이 문서화를 어떤 형식으로 해야하고, 어떤 정보를 설명해야하는지 알려준다.

 

The kernel's command-line parameters파일은 커널의 모든 부트 타임 파라미터를 설명한다. 새로운 파라미터를 추가하는 패치는 이 파일도 업데이트 해야한다.

 

커널의 설정(configruation)을 추가할 때에도 도움말을 함께 작성해서, 사용자가 쉽게 이해하고 사용하도록 해야한다.

 

많은 서브시스템에 구현된 내부 API는 특별한 형식을 가진 주석으로 문서화된다. 이러한 주석은 "kernel-doc" 스크립트로 추출된다. 당신이 kerneldoc 주석을 사용하는 서브시스템에서 작업을 하고있다면, 외부에서 사용 가능한 함수에 대하여 그 주석을 잘 관리해야 한다. 그리고 kerneldoc 주석을 아직 사용하고 있지 않더라도 추가를 하는 것이 좋다. 실제로 이렇게 주석을 추가하는 것은 커널 개발자가 개발을 시작하는 데 유용한 자료가 된다. 주석을 작성하는 방법은 Documentation/doc-guide를 참고하자.

 

커널의 코드를 많이 읽는 사람은 주석이 없는 것이 가장 눈에 띄는 경우가 많다는 점을 느낄 것이다. 예전보다 새로운 코드에 대한 기대치가 높아져서, 이제 주석이 없는 코드는 머지되기가 더 어려울 것이다. 그렇다고 주석으로 구구절절 설명하라는 뜻은 아니다. 다만 코드 자체가 읽을만할 정도로 가독성이 있어야, 미묘한 부분을 설명하는 주석이 필요하다.

 

하지만 몇몇 경우에는 반드시 주석을 작성해야 한다. 메모리 배리어는 항상 주석으로 왜 메모리 배리어를

사용하는지 설명해야 한다. 자료구조에 대해서도 이 자료구조의 락과 관련된 규칙을 어딘가에 설명해놓아야 한다. 그리고 중요 자료 구조들은 일반적으로 폭넓은 문서화가 필요하다. 또한 코드 간의 분명하지 않은 의존성이 있는 경우에도 지적을 해야한다. 또 초보자들이 오해해서 잘못 고칠법한 코드들도 주석을 달아놔야 한다.

 

4.4 Internal API changes

커널에서 외부에 공개하는 인터페이스는 몇몇 심각한 경우를 제외하고는 어렵다. 하지만 커널 내부 인터페이스는 필요에 따라 수정할 수 있다. 당신이 커널 API와 관련된 작업을 하고있다면, 또는 API가 당신이 필요한 기능을 제공하지 않아서 사용하지 않는다면 API가 바뀌어야 한다는 신호일 수 있다. 커널 개발자로서 당신은 커널 API를 변경할 수 있다.

 

물론 몇가지 조건이 있다. API의 변경은 가능은 하지만 정당한 이유가 있어야한다. 따라서 내부 API를 변경하는 모든 패치에는, 변경사항과 변경 사항이 필요한 이유도 함께 설명해야 한다. 또한 API를 변경할 때에는 모든 변경을 하나의 패치로 작성하는 게 아니라, 각각의 변경 사항으로 나눠서 여러 개의 패치로 작성해야 한다.

 

또 다른 조건은 내부 API를 변경하는 개발자는 일반적으로, 해당 API의 변경으로 인해 작동되지 않는 코드를 고쳐야 한다 널리 사용되는 API의 경우 말 그대로 수백, 수천곳을 수정할 수도 있고, 당신이 수정하는 부분이 다른 개발자와 충돌할 수 있다. 내부 API의 수정은 거대한 작업이기 때문에 내부 API 변경의 정당한 이유가 확고해야 한다. 참고로 Coccinelle는 광범위한 내부 API 변경에 도움이 될 수 있다.

 

이전 API와 호환되지 않도록 API를 변경한 경우에는, 바뀐 API로 업데이트되지 않은 코드가 컴파일러에 잡히는지 확인해야 한다. 그렇게 해야 해당 트리에서 그 API를 사용하는 모든 코드를 찾을 수 있다. 또한 out-of-tree 개발자들에게도 API가 바뀐다는 것을 경고해야 한다. out-of-tree 개발자들을 지원하는 게 커널 개발자들이 걱정할 일은 아니지만, 그렇다고 out-of-tree 개발자들을 굳이 힘들게 만들 필요도 없다.

 

 

댓글