Linux kernel v4.4에서 간단한 블록 장치 드라이버 만들어보기

장치에서 페이지캐시로 데이터 읽어오기

데이터를 읽을 때 콜스택을 보면 mybrd블럭 장치를 직접 읽을 경우와 파일시스템에 존재하는 파일을 읽을 경우 모두 do_generic_file_read을 호출하는걸 알 수 있습니다. 이 함수를 간단하게 분석해보면 페이지캐시가 어떻게 동작하는지, 언제 페이지캐시에서 블럭 장치를 읽어서 페이지캐시를 추가하는지 등을 알아보겠습니다.

리눅스 소스를 보시면서 글을 읽으시길 바랍니다. 소스를 하나하나 복사하는건 의미가 없으니까요. 나중에 4.10이 되든, 5.0이 나오든 소스는 바뀝니다. 하지만 디자인과 코드의 목표는 남습니다. 페이지캐시를 구현하는 방법은 달라지지만 페이지캐시의 목적과 큰 디자인은 오래가겠지요. 그러니 소스 한줄한줄보다는 왜 이렇게 구현했는가 목적이 뭔가를 생각하는게 처음 커널을 분석할때 필요한 태도인것 같습니다. 나중에 실무에서나 취미로나 버그를 잡을 때 특정 버전의 코드를 더 깊게 봐야할때가 있겠지요.

do_generic_file_read

블럭 장치 파일(/dev/mybrd)이든 파일시스템의 파일이든 데이터를 읽을 때는 공통적으로 do_generic_file_read가 호출됩니다. 길고 복잡한 함수이므로 핵심적인 동작들만 따져보겠습니다.

함수 인자

함수인자부터 뭔지 보겠습니다.

  • struct file *filep: 읽을 파일에 해당하는 file 객체입니다. file 구조체의 f_mapping이 struct address_space 객체를 가르키는 포인터입니다.
  • loff_t *ppos: read시스템콜을 호출하기전에 lseek 시스템콜을 써서 파일의 어디부터 읽을지를 선택합니다. lseek시스템콜은 실질적으로 어떤 처리를 하는게 아니라 struct file 객체의 f_pos 필드에 위치를 기록했다가 read나 write 시스템 콜이 호출되었을 때 읽어서 사용합니다.
    • 페이지 캐시는 페이지단위로 데이터가 저장되어있습니다. 따라서 페이지캐시의 radix-tree에서 사용할 키값은 ppos 값을 페이지 크기로 나눈 값이 되겠지요.
  • struct iov_iter *iter: 파일시스템에서 사용하는 자료구조입니다. 페이지캐시에서 사용되는건 아니므로 http://revdev.tistory.com/55 를 참고하시기 바랍니다.
    • 참고 추가: https://lwn.net/Articles/625077/
  • ssize_t written: generic_file_read_iter에서 direct IO가 발생해서 읽기가 일부 처리된 경우에 얼마나 읽기가 끝났는지 알려주는 값입니다. 그냥 함수가 호출될때의 값은 0으로 생각해도 됩니다. 데이터를 읽으면서 written의 값이 증가하고 읽기가 끝나면 written 값을 반환합니다.

find_get_page (= pagecache_get_page)

find_get_page는 pagecache_get_page를 호출하는 wrapper 함수입니다.

find_get_entry함수를 호출해서 패이지캐시에 이미 해당 offset이 있는지를 찾습니다. find_get_page에서 pagecache_get_page를 호출할 때 fgp_flags 값과 gfp_mask 값을 0으로 호출했으므로 결국 페이지캐시에 해당 offset이 없으면 null을 반환합니다.

만약 fgp_flags에 FGP_CREATE 플래스가 있었다면 페이지를 할당하고, 페이지를 lru리스트에 포함합니다.

find_get_entry를 잠깐 볼까요. 가장 먼저 radix_tree_lookup_slot함수를 호출해서 radix-tree의 트리에 저장된 page의 더블 포인터를 가져오고, radix_tree_deref_slot으로 더블포인터를 포인터로 바꾸고, page_cache_get_speculative로 페이지의 참조 카운터를 증가합니다.

mybrd 드라이버에서 radix-tree에 페이지를 추가할 때 radix_tree_lookup 함수 하나만 사용했었습니다. 그런데 왜 여기에서는 radix_tree_lookup_slot을 사용할까요? 그 이유는 page_cache_get_speculative 함수의 주석에 있습니다. "This is the interesting part of the lockless pagecache"라는 설명이 있습니다. 즉 페이지를 찾아보는데 페이지를 찾는 중간에 다른 쓰레드에서 페이지를 해지하거나 다른데 사용했다면, 다시 페이지를 찾습니다. 결국 rcu_read_lock()만으로 페이지캐시를 구현하게 됩니다.

사실 저도 왜 이 코드가 동작하는지 완벽하게 이해했다고 말할 수 없을것 같습니다. 정확한 설명은page_cache_get_speculative함수의 주석을 참고하시기 바랍니다. 어쨌든 제가 말씀드리고 싶은건 페이지캐시가 lockless로 구현됐다는 것입니다.

page_cache_sync_readahead (=ondemand_readahead=__do_page_cache_readahead)

mybrd의 콜스택을 보면 page_cache_sync_readahead가 호출됩니다. 페이지 캐시에 페이지가 없었다는 뜻입니다. page_cache_sync_readahead 코드를 보면 결국 __do_page_cache_readahead함수가 핵심입니다.

__do_page_cache_readahead 함수 인자중에 몇개의 페이지를 읽을지 nr_to_read 값이 있습니다. 최초로 읽을 offset부터 미리 여러개의 페이지를 읽어놓는 것입니다. 그럼 사용자가 파일을 계속 읽을 때마다 IO가 발생하지 않고 페이지캐시에서 바로 데이터를 가져갈 수 있겠지요. radix_tree_lookup으로 해당 위치의 데이터가 페이지캐시에 있나 확인하고 없으면 page_cache_alloc_readahead 함수로 페이지를 할당합니다. 그리고 각 페이지마다 page->index 필드에 offset을 씁니다.

그리고 read_pages 함수에서 이전에 할당한 페이지들에 블럭 장치의 데이터를 읽어옵니다. read_pages를 보면 mapping->a_ops->readpages와 mapping->a_ops->readpage를 호출합니다. mybrd는 블럭 장치이므로 def_blk_aops를 확인하면 어떤 함수가 호출될지 알 수 있습니다. def_blk_aops.readpages = blkdev_readpages 함수가 등록돼있으니 blkdev_readpages가 호출되겠네요.

blk_start/finish_plug 함수는 참고 자료를 확인하세요.

  • http://nimhaplz.egloos.com/m/5598614
  • http://studyfoss.egloos.com/5585801

이제 blkdef_readpages로 넘어왔습니다. 그리고 blkdev_readpages는 mpage_readpages를 호출합니다. mpage_readpages함수를 보면 주석이 매우 깁니다. 중요한 함수라는걸 알 수 있습니다. mpages_readpages 함수는 페이지를 lru에 추가하고, 임시로 사용할 bio를 생성해서 IO를 발생시킵니다. bio를 IO 스케줄러에 전달하는 함수가 바로 submit_bio함수입니다. bio를 생성하는 함수는 do_mpage_readpage이고, submit_bio를 호출하는 함수가 mpage_bio_submit입니다.

add_to_page_cache_lru

페이지 하나를 페이지 캐시에도 넣고, lru 리스트에도 추가하는 함수입니다. 먼저 페이지를 lock합니다. 새로 할당된 페이지이므로 다른 쓰레드에서 사용할 염려가 없으므로 페이지가 잠겨있는지 확인할 필요가 없으니 __set_page_locked함수로 페이지를 잡급니다.

__add_to_page_cache_locked는 다음 순서로 동작합니다.

  1. radix_tree_maybe_preload: radix_tree_preload와 같은 일을 하지만, 페이지 플래그에 따라 radix_tree_preload를 호출하지 않을 수도 있습니다.
  2. page_cache_get: get_page와 같습니다. 페이지의 참조 카운터를 증가시킵니다.
  3. page의 mapping, index 필드 설정
  4. page_cache_tree_insert: 트리에 페이지를 넣는 함수인데 mapping->tree_lock을 잡고 있는 상태에서 왜 radix_tree_insert를 안쓰고 page_cache_tree_insert를 구현했는지를 잘 모르겠습니다. 어쨋든 page_cache_tree_insert 코드를 보면 radix_tree_insert와 유사합니다.
  5. radix_tree_preload_end: radix_tree_preload를 사용했다면 radix_tree_preload_end를 꼭 호출해야합니다.
  6. __inc_zone_page_state: 각 zone마다 몇개의 페이지가 있고 어떤 페이지들이 어떤 상태인지 /proc/zoneinfo 파일에 통계 정보를 가지고 있습니다. 이 통계 정보를 갱신하는 함수입니다. NR_FILE_PAGES는 해당 zone에서 몇개의 페이지가 페이지캐시로 사용되었는지를 알려주는 값입니다. /proc/zoneinfo 파일에서 nr_file_pages 값에 해당됩니다.

이제 페이지가 페이지캐시에 들어갔으니 lru_cache_add 함수로 페이지를 lru리스트에 추가합니다. lru_cache_add함수는 각 프로세별로 존재하는 lru_add_pvec 배열에 새로운 페이지를 추가합니다.

참고로 __add_to_page_cache_locked함수에서도 페이지의 참조 카운터를 증가시키고, lru_cache_add에서도 페이지의 참조 카운터를 증가시킵니다. 이 말은 lru 리스트와 페이지캐시가 별도로 동작한다는 것입니다. lru에서 빠진다고해도 페이지캐시에서 빠지는게 아니기 때문입니다.

do_mpage_readpage

복잡한 함수입니다만 mpage_alloc를 호출해서 가장 핵심은 bio 객체를 만든다는 것만 알면 될것같습니다.

do_mpage_readpage인자를 보면

  • struct bio *bio: 처음에는 NULL값입니다. mpage_alloc 함수로 새로 bio 객체를 만들으라는 의미입니다. 한번 bio를 만들고나면 다음 for 루프에서 계속 다음 페이지를 위한 정보를 추가합니다. 그래서 for 루프가 종료된 다음에는 모든 페이지의 IO를 위한 정보를 가지게됩니다.
  • struct page *page: 새로 할당해서 페이지캐시와 lru 리스트에 추가된 페이지입니다. 장치로부터 데이터를 읽어서 이 페이지에 저장합니다.
  • unsigned nr_pages: 남은 페이지 갯수
  • sector_t *last_block_in_bio: bio에서 처리할 마지막 블럭의 섹터 번호
  • struct buffer_head *map_bh: bio를 만들면서 생성된 버퍼 헤드
  • unsigned long *first_logical_block: 첫번째 블럭 번호
  • get_block_t get_block: 파일시스템에서 파일 오프셋을 실제 파일시스템의 블럭 번호로 바꿔서 bh->b_blocknr 필드에 저장하는 함수입니다. 파일이라는건 연속된 데이터이지만, 사실 파일이 디스크에 연속적으로 저장될 수는 없습니다. 디스크 여기저기에 데이터가 저장되고, 어떤 파일의 어떤 부분이 디스크의 어디에 저장되었는지를 관리하는게 파일시스템의 주된 역할입니다. 그러므로 파일시스템마다 다른 정보를 얻어올 수 있도록 함수포인터를 전달합니다. 블럭 장치의 경우 blkdev_get_block 함수 포인터를 전달합니다. 블럭 장치는 사실 파일시스템이 없고 디스크 전체가 연속된 데이터로 봅니다. 따라서 전달된 블럭 번호를 그대로 버퍼헤드에 기록합니다. ext2의 경우 ext2_get_block 함수가 사용되는데, 파일시스템의 슈퍼블럭을 읽는등 파일시스템 자체의 정보를 활용할 것입니다.
  • gfp_t gfp: bio객체를 할당할 때 쓸 페이지 할당 플래그

처음 do_mpage_readpage가 호출될 때는 bio가 NULL이고 map_bh의 b_state, b_size 값들도 0이므로  mpage_alloc으로 bio를 할당합니다.

그 다음 bio_add_page가 호출되면서 bio의 bi_io_vec 필드에 새로운 페이지가 추가됩니다. 우리는 현재 블럭 장치의 페이지캐시를 만들고있으므로 블럭 크기가 곧 페이지 크기가 됩니다. 따라서 bi_io_vec에 추가될 IO 길이도 모두 4096이 됩니다. 한번에 한 페이지씩 읽는 것입니다. 드라이버에서 bio의 bi_io_vec 필드를 출력해봤을때 모두 길이가 4096인걸 확인했었습니다.

mpage_readpages에서 루프를 돌면서 다시 do_mpage_readpage를 호출했을 때도 페이지의 크기와 블럭의 크기가 같으므로 사실상 버퍼헤드를 수정할 일은 없습니다. 매번 bio_add_page가 호출되면서 bio에 새로운 페이지를 추가하는 일이 사실상 전부입니다.

참고로 mpage_alloc은 단순합니다. bio_alloc으로 bio를 만들고 꼭 필요한 필드를 셋팅합니다.

  • bio_alloc: struct bio 객체 생성
  • bio->bi_bdev: IO가 발생해야할 struct block_device 객체 포인터
  • bio->bi_iter.bi_sector: 첫번째 섹터 번호

mpage_alloc은 첫번째 섹터 번호만 설정합니다. 추가 정보는 bio_add_page에서 추가합니다. bio_add_page 함수도 간단합니다. bio의 bi_io_vec 배열을 가져와서 bv_page, bv_len, bv_offset을 초기화하는데 블럭 장치는 한번에 한 페이지씩 읽으므로 bv_len은 항상 4096이되고 bv_offset은 0이 될 것입니다. bi_vcnt를 증가시켜서 bi_io_vec배열을 차례대로 초기화합니다.

mpage_bio_submit

mpage_alloc으로 생성한 bio 객체를 submit_bio 함수에 전달합니다. 결국 submit_bio는 generic_make_request를 통해서 mybrd로 넘어갑니다. bio 처리가 끝나면 호출된 bio->bi_end_io 콜백함수는 mpage_end_io입니다. add_to_page_cache_lru에서 페이지를 잠궜으므로 mpage_end_io에서는 페이지 락을 풀고 페이지를 페이지의 데이터가 막 읽혀진 상태이니 uptodate 상태로 표시합니다. 그리고 다쓴 bio를 해지합니다.

copy_page_to_iter

iter에는 유저 레벨의 버퍼에 대한 정보가 들어있습니다. page에 있는 데이터를 유저 레벨 버퍼로 복사합니다.

iov_iter_count에서 iter->count 필드가 0이되면 do_generic_file_read가 종료됩니다.

 

댓글

댓글 본문
작성자
비밀번호
버전 관리
gurugio
현재 버전
선택 버전
graphittie 자세히 보기