- Kernel readv/writev internal implementation (1) -


네트워크 프로그래밍을 하던, 아니면 단순한 File I/O를 수행하던 read/write함수는 한 번쯤은 사용해 보았으리라 생각한다. 그런데, 만약에 전송하는 데이터가 여러 버퍼에 나뉘어져 있다면 어떻게 해야할까. 아니면 한 데이터를 일정 크기로 끊어서 여러 버퍼에 나눠 저장해야 한다면?

물론 read/write함수를 버퍼의 개수만큼 호출하면 매우 간단할 것이다. 그런데, 만약 나뉘어진 버퍼의 개수가 천 단위를 넘어간다면 어떻게 될까? 그래도 read/write를 버퍼 개수만큼 호출하면 될까?


여기서 왜 안되는지 묻는 사람들이 있을지도 모르겠지만, 리눅스 커널은 User-mode와 Kernel-mode로 나뉘어 있다는 것을 기억하자. 비록 우리가 빌드해서 실행시키는 프로그램 자체는 User-mode에서 동작하지만, 실제 장치에 접근해야 하는 read/write함수는 Kernel-mode에서 수행될 수 밖에 없다. 즉, Syscall인 read/write함수를 호출하게 되면, User-mode에서 Kernel-mode로 Context switch가 일어나게 된다. 이 Context switch 과정에서 Register나 기타 여러 프로그램의 수행 정보를 메모리에 저장하고, 페이징 모듈은 프로세스 매핑 메모리에서 커널 메모리 영역으로의 전환을 시도하게 된다. 즉, 필연적으로 Overhead가 발생할 수 밖에 없다는 것이다.


다시 돌아와서, 만약 버퍼가 수천개가 있다고 read/write를 수천번 시도하게 된다면 Context switch도 수천번 일어나게 된다는 것. 그만큼 수행시간에 있어서 더 많은 Cost를 소모하게 된다.

즉, User-mode와 Kernel-mode사이의 전환을 최소화 하면서 동시에 여러 버퍼에 대한 작업을 할 수 있는 Syscall이 필요하게 된 것이다.

그리고 그것이 readv/writev이다.


본격적인 readv/writev에 대한 설명에 앞서, readv/writev의 효율성을 확인하기 위해 24Byte크기의 char형 버퍼 1024개를 대상으로 read-readv의 퍼포먼스 테스트를 진행해 보았다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <sys/time.h>
#include <sys/uio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
 
int main(){
    struct timeval t1, t2;
        double diffTime, finalTime = 0;
 
    char chBuf[1024][25= {0, };
    ssize_t nr;
    int fd, i, loop;
 
    printf("read() : 1024-Buffers\n");
 
    for(loop = 0; loop < 10++loop){
        gettimeofday(&t1, NULL);
 
        fd = open("txtfile", O_RDONLY);
        if (fd == -1) {
            perror ("open");
            return fd;
        }
 
        for(i = 0; i < 1024++i){
            nr = read(fd, chBuf[i], 24);
            if (nr == -1) {
                perror ("read");
                return nr;
            }
        }
 
        if(close(fd)){
            perror("close");
            return -1;
        }
 
        gettimeofday(&t2, NULL);
        diffTime = t2.tv_usec - t1.tv_usec;
        finalTime += diffTime;
        printf("[Loop %d] Elapsed Time : %fus\n", loop, diffTime);
    }
    printf("[Avarage] Elapsed Time : %fus\n", finalTime/10);
    return 0;
}
 
cs

-read 테스트 코드


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <sys/time.h>
#include <sys/uio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
 
int main(){
    struct timeval t1, t2;
        double diffTime, finalTime = 0;
 
    struct iovec iov[1024];
    char chBuf[1024][25= {0, };
    ssize_t nr;
    int fd, i, loop;
 
    printf("readv() : 1024-Buffers\n");
 
    for(loop = 0; loop < 10++loop){
        gettimeofday(&t1, NULL);
 
        fd = open("txtfile", O_RDONLY);
        if (fd == -1) {
            perror ("open");
            return fd;
        }
 
        for(i = 0; i < 1024++i){
            iov[i].iov_base = chBuf[i];
            iov[i].iov_len = 24;
        }
 
        nr = readv (fd, iov, 1024);
        if (nr == -1) {
            perror ("readv");
            return nr;
        }
 
        if(close(fd)){
            perror("close");
            return -1;
        }
 
        gettimeofday(&t2, NULL);
        diffTime = t2.tv_usec - t1.tv_usec;
        finalTime += diffTime;
        printf("[Loop %d] Elapsed Time : %fus\n", loop, diffTime);
    }
    printf("[Avarage] Elapsed Time : %fus\n", finalTime/10);
    return 0;
}
 
cs

-readv 테스트 코드


-실제 테스트 결과


실제로 위의 두 코드를 컴파일, 실행시킨 결과 약 9배 가까이의 수행시간 차이가 나는 것을 알 수 있다.


그렇다면, readv/writev가 기존의 read/write와 어느 부분에서 달라진 것일까? 바로 iovec라는 구조체이다. 기존의 read에서는 버퍼 그 자체의 포인터를 인자로서 넘겼다면, readv에서는 다수의 버퍼를 iovec[] 즉, iovec array에 저장해서 iovec array의 포인터를 인자로서 전달하게 된다. 간단하게 말해서, Syscall의 호출 전에 다수의 버퍼를 하나의 Wrapper 배열에 저장해 넘기게 된다고 보면 될 듯 하다. 쉽게 말하자면 여러 곳에 흩어져 있는 문서(입출력 버퍼)들을 서류철(iovec)에 모아 부장님(커널)께 한번에 결제 받는 것과 비슷하다고 보면 될까.


아무튼, readv와 writev에서 공통적으로 사용되는 구조체인 iovec는 매우 간단한 구조를 띄고 있다.


1
2
3
4
5
struct iovec
{
    void __user *iov_base;    
    __kernel_size_t iov_len;
};
cs


보다시피 실제 버퍼의 주소값을 담는 void __user pointer형인 iov_base와 iovec에 담긴 주소의 크기를 나타내는 __kernel_size_t형인 iov_len으로 이루어져 있다.


실제 코드에서의 사용은 다음과 같다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <sys/uio.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
 
int main(){
    struct iovec iov[3];
    char chBuf[3][65];
    ssize_t nr;
    int fd, i;
 
    fd = open("txtfile", O_RDONLY);
    if (fd == -1) {
        perror ("open");
        return fd;
    }
 
    for(i = 0; i < 3++i){
        iov[i].iov_base = chBuf[i];
        iov[i].iov_len = 64;
    }
 
    nr = readv (fd, iov, 3);
    if (nr == -1) {
        perror ("readv");
        return nr;
    }
 
    for(i = 0; i < 3++i){
        printf("Num.%d [%ld Bytes] : %s\n", i, iov[i].iov_len, (char*)iov[i].iov_base);
    }
 
    if(close(fd)){
        perror("close");
        return -1;
    }
    
    return 0;
}
cs


위와 같이 iovec배열을 생성해준 뒤, 각각의 iovec에 입출력버퍼를 할당해준다. iovec에서 iov_base에 배열의 포인터를, iov_len에 각 배열의 크기를 할당하게 된다.


위 코드에서 readv함수는 readv(fd, iov, 3)의 형태로 호출되고 있는데,

기존의 read의 형태인


1
ssize_t read(int fd, void *buf, size_t count);
cs


에서 아래와 같이


1
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
cs


의 형태로 바뀐 것이다. 자세히 보면 기존 read함수의 원형에서 크게 벗어난 부분이 없다. 기존 버퍼를 직접 받던 buf포인터가 iovec배열을 받는 iovec포인터로 바뀌었고, 버퍼의 크기가, iovec에 할당된 버퍼의 갯수, 즉 iovec배열의 크기로 바뀐 것 뿐이다.


그럼 iovec을 인자로 받은 readv/writev가 실제로 어떠한 식으로 동작하는지 코드를 보며 한 번 분석해보도록 하겠다.

먼저 readv의 내부 구현을 살펴보도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
SYSCALL_DEFINE3(readv, unsigned long, fd, const struct iovec __user *, vec,
        unsigned long, vlen)
{
    return do_readv(fd, vec, vlen, 0);
}
 
static ssize_t do_readv(unsigned long fd, const struct iovec __user *vec,
                        unsigned long vlen, int flags)
{
        struct fd f = fdget_pos(fd);
        ssize_t ret = -EBADF;
 
        if (f.file) {
                loff_t pos = file_pos_read(f.file);
                ret = vfs_readv(f.file, vec, vlen, &pos, flags);
                if (ret >= 0)
                        file_pos_write(f.file, pos);
                fdput_pos(f);
        }
 
        if (ret > 0)
                add_rchar(current, ret);
        inc_syscr(current);
        return ret;
}
cs


가장 먼저, readv()의 호출에 의해 readv() Syscall wrapper routine가 호출되게 된다. SYSCALL_DEFINE3매크로의 형태를 하고 있는 Wrapper routine는 do_readv함수를 이어 호출하게 된다.


do_readv는 Wrapper routine으로부터 fd(File descriptor - fd), vec(IO Vector - iovec), vlen(IO Vector Count - iovcnt)를 차례대로 전달 받게 된다. 이때, 마지막 인자인 flags는 readv호출 자체에서는 0으로 설정되며, readv계열 파생 함수인 preadv(2)에서 Syscall 인자로서 받게 되기에 크게 신경 쓰지 않아도 된다.


do_readv에서는 fdget_pos함수를 통해서 fd값(int)로부터 실제 File descriptor를 구해온 뒤, file_pos_read를 호출하면서 File offset 구해오게 된다.

그 뒤, 구해온 File offset을 포함해서, 입출력 작업에 필요한 인자들을 넘기면서 vfs_readv함수를 호출하게 된다.


1
2
3
4
5
6
7
8
9
10
ssize_t vfs_readv(struct file *file, const struct iovec __user *vec,
          unsigned long vlen, loff_t *pos, int flags)
{
    if (!(file->f_mode & FMODE_READ))
        return -EBADF;
    if (!(file->f_mode & FMODE_CAN_READ))
        return -EINVAL;
 
    return do_readv_writev(READ, file, vec, vlen, pos, flags);
}
cs


vfs_readv함수에서는 인자로 넘어온 파일 구조체에서 파일이 읽기 가능한 상태인지만 확인한 뒤, do_readv_writev함수을 READ Type으로 호출하게 된다. (리눅스 커널 함수들 중 입출력에 관련된 함수들은 read와 write가 같은 함수에 구현되어 있고, type인자를 통해 구분되는 경우가 많다)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static ssize_t do_readv_writev(int type, struct file *file,
                   const struct iovec __user * uvector,
                   unsigned long nr_segs, loff_t *pos,
                   int flags)
{
    size_t tot_len;
    struct iovec iovstack[UIO_FASTIOV];
    struct iovec *iov = iovstack;
    struct iov_iter iter;
    ssize_t ret;
    io_fn_t fn;
    iter_fn_t iter_fn;
 
    ret = import_iovec(type, uvector, nr_segs,
               ARRAY_SIZE(iovstack), &iov, &iter);
    if (ret < 0)
        return ret;
 
    tot_len = iov_iter_count(&iter);
    if (!tot_len)
        goto out;
    ret = rw_verify_ara(type, file, pos, tot_len);
    if (ret < 0)
        goto out;
 
    if (type == READ) {
        fn = file->f_op->read;
        iter_fn = file->f_op->read_iter;
    } else {
        fn = (io_fn_t)file->f_op->write;
        iter_fn = file->f_op->write_iter;
        file_start_write(file);
    }
 
    if (iter_fn)
        ret = do_iter_readv_writev(file, &iter, pos, iter_fn, flags);
    else
        ret = do_loop_readv_writev(file, &iter, pos, fn, flags);
 
    if (type != READ)
        file_end_write(file);
 
out:
    kfree(iov);
    if ((ret + (type == READ)) > 0) {
        if (type == READ)
            fsnotify_access(file);
        else
            fsnotify_modify(file);
    }
    return ret;
}
cs


위 코드가 do_readv_writev함수이다. 가장 먼저 import_iovec함수를 통해서 유저영역상에 iovec배열을 커널영역 iovec배열로 전달하게 된다. 먼저 import_iovec의 구현을 살펴보도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int import_iovec(int type, const struct iovec __user * uvector,
                 unsigned nr_segs, unsigned fast_segs,
                 struct iovec **iov, struct iov_iter *i)
{
        ssize_t n;
        struct iovec *p;
        n = rw_copy_check_uvector(type, uvector, nr_segs, fast_segs,
                                  *iov, &p);
        if (n < 0) {
                if (p != *iov)
                        kfree(p);
                *iov = NULL;
                return n;
        }
        iov_iter_init(i, type, p, nr_segs, n);
        *iov = p == *iov ? NULL : p;
        return 0;
}
EXPORT_SYMBOL(import_iovec);
cs


import_iovec의 모습이다. 여기서 주의해서 보아야 할 것은 fast_segs와 iov, 그리고 i이다. 이 세 변수 만은 확실히 기억하도록 하자. import_iovec함수에서는 rw_copy_check_uvector라는 함수를 통해 커널영역 iovec배열을 할당하고 유저영역 iovec의 값을 복사하게 된다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
ssize_t rw_copy_check_uvector(int type, const struct iovec __user * uvector,
                              unsigned long nr_segs, unsigned long fast_segs,
                              struct iovec *fast_pointer,
                              struct iovec **ret_pointer)
{
        unsigned long seg;
        ssize_t ret;
        struct iovec *iov = fast_pointer;
 
        /*
         * SuS says "The readv() function *may* fail if the iovcnt argument
         * was less than or equal to 0, or greater than {IOV_MAX}.  Linux has
         * traditionally returned zero for zero segments, so...
         */
        if (nr_segs == 0) {
                ret = 0;
                goto out;
        }
 
        /*
         * First get the "struct iovec" from user memory and
         * verify all the pointers
         */
        if (nr_segs > UIO_MAXIOV) {
                ret = -EINVAL;
                goto out;
        }
        if (nr_segs > fast_segs) {
                iov = kmalloc(nr_segs*sizeof(struct iovec), GFP_KERNEL);
                if (iov == NULL) {
                        ret = -ENOMEM;
                        goto out;
                }
        }
        if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) {
                ret = -EFAULT;
                goto out;
        }
 
        /*
         * According to the Single Unix Specification we should return EINVAL
         * if an element length is < 0 when cast to ssize_t or if the
         * total length would overflow the ssize_t return value of the
         * system call.
         *
         * Linux caps all read/write calls to MAX_RW_COUNT, and avoids the
         * overflow case.
         */
        ret = 0;
        for (seg = 0; seg < nr_segs; seg++) {
                void __user *buf = iov[seg].iov_base;
                ssize_t len = (ssize_t)iov[seg].iov_len;
 
                /* see if we we're about to use an invalid len or if
                 * it's about to overflow ssize_t */
                if (len < 0) {
                        ret = -EINVAL;
                        goto out;
                }
                if (type >= 0
                    && unlikely(!access_ok(vrfy_dir(type), buf, len))) {
                        ret = -EFAULT;
                        goto out;
                }
                if (len > MAX_RW_COUNT - ret) {
                        len = MAX_RW_COUNT - ret;
                        iov[seg].iov_len = len;
                }
                ret += len;
        }
out:
        *ret_pointer = iov;
        return ret;
}
cs


rw_copy_check_uvector의 28번째 줄을 보면 iovec 포인터형 지역변수인 iov에 fast_pointer를 넣어주고 있다. fast_pointer는 rw_copy_check_uvector의 5번째 매개변수로, import_iovec에서의 iov에 해당하게 된다. 그런데, 이 함수에서 44~58번째 줄을 보도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (nr_segs > UIO_MAXIOV) {
        ret = -EINVAL;
        goto out;
}
if (nr_segs > fast_segs) {
        iov = kmalloc(nr_segs*sizeof(struct iovec), GFP_KERNEL);
        if (iov == NULL) {
                ret = -ENOMEM;
                goto out;
        }
}
if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) {
        ret = -EFAULT;
        goto out;
}
cs


위 코드는 해당 부분을 잘라낸 코드이다. 여기서 5번째 if문에 의해 nr_segs가 fast_segs보다 클 경우, iov에 kmalloc을 통해 커널 메모리를 할당해주게 된다. 그런 뒤, 12번 라인에서 copy_from_user를 통해 유저영역 iovec의 값들을 iov에 복사해주게 된다. 이때, 놓치지 말아야 할 것이 있는데, iov가 5번 라인 if문을 충족시키지 않아 할당되지 않았을 때에도 copy_from_user를 수행할 수 있는가라는 의문을 가져야 한다.


이 문제의 답은 import_iovec의 호출 전, do_readv_writev함수에서 찾을 수 있다. do_readv_writev함수의 6~9번 라인과 import_iovec호출 부분을 참조하자.


1
2
3
4
5
6
7
8
9
size_t tot_len;
struct iovec iovstack[UIO_FASTIOV];
struct iovec *iov = iovstack;
struct iov_iter iter;
 
...
 
ret = import_iovec(type, uvector, nr_segs,
               ARRAY_SIZE(iovstack), &iov, &iter);
cs


해당 부분인데, rw_copy_check_uvector의 fast_pointer, 즉 import_iovec의 iov에 해당하는 값이 위의 3번 라인에서 정의되는 것을 볼 수 있다. 이때, 3번 라인에서 보이는 iovstack은 2번 라인에서 do_readv_writev의 지역변수로서 iovec[UIO_FASTIOV]라는 스택 배열이다. 또한, fast_segs에 해당하는 부분은 ARRAY_SIZE(iovstack)이다. 즉, rw_copy_check_uvector에서 iov는 비할당된 영역을 가리키지 않는다.


rw_copy_check_uvector의 28번 라인, 즉, nr_segs와 fast_segs를 비교하는 if문에서 만일 do_readv_writev에서 할당한 iovstack의 크기(여기서는 UIO_FASTIOV)보다 readv로 넘어온 총 iovec의 개수가 더 많을 경우에만 kmalloc을 통해서 별도의 영역 할당을 진행하게 된다.


여기에는 의미가 있는데, 어차피 커널영역 iovec객체인 iov는 do_readv_writev보다 하위 스택 프레임에서만 이용할 것이 보장된다. 따라서, 적은 수의 iovec을 처리할 경우에는 kmalloc을 통해 별도의 iovec들을 할당하는 것보다, 약간의 고정적 스택 낭비를 감안하더라도 do_readv_writev함수의 스택 프레임 구성시에 지역변수로서 일정크기의 iovec배열을 미리 정의하여 빠른 처리가 가능하도록 하는 것이다.


kmalloc을 통한 할당여부를 결정하는 임계값인 UIO_FASTIOV는 '#define UIO_FASTIOV 8'로, 만일 사용자가 readv로 전달해 준, iovec배열의 iovec객체가 8개보다 많을 경우에는 kmalloc을 통해 별도로 커널 메모리를 할당해주게 된다.


자, 그럼 다시 원래 rw_copy_check_uvector로 돌아와서, rw_copy_check_uvector의 소스코드를 찬찬히 분석해보도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (nr_segs == 0) {
        ret = 0;
        goto out;
}
 
/*
 * First get the "struct iovec" from user memory and
 * verify all the pointers
 */
if (nr_segs > UIO_MAXIOV) {
        ret = -EINVAL;
        goto out;
}
if (nr_segs > fast_segs) {
        iov = kmalloc(nr_segs*sizeof(struct iovec), GFP_KERNEL);
        if (iov == NULL) {
                ret = -ENOMEM;
                goto out;
        }  
}
if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) {
        ret = -EFAULT;
        goto out;
}
cs


가장 먼저 이 부분을 살펴보도록 하자. 먼저 nr_segs, 즉 readv로 넘어온 iovec의 개수가 0개일 경우에는 아무 작업을 하지 않고 바로 0를 리턴하게 된다.


그 뒤, 10~13번 라인을 살펴보도록 하자. nr_segs가 UIO_MAXIOV보다 클 경우, ret에 에러코드를 넣은 뒤 바로 함수의 마지막 부분으로 이동하게 된다. 이때, UIO_MAXIOV는 '#define UIO_MAXIOV 1024'로 이 코드를 간단하게 해석하자면 사용자가 readv로 넘겨준 iovec배열에서 iovec객체의 개수가 1024개를 넘을 경우 에러를 내게 된다.



위 사진은 실제로 1024이상의 iovec을 인자로 주어 readv를 호출하였을 경우고, 정상적으로 readv가 수행되지 않고 오류를 출력함을 볼 수 있다.


이는 실제 iovec처리를 위해서는 kmalloc을 통하여 커널 메모리를 할당 받는데, 1024와 같이 크기 제한을 두지 않을 경우 메모리 침범이 일어날 가능성이 있고, 설령 그렇지 않더라도 kmalloc플래그가 GFP_KERNEL이기 때문에 할당이 불가능한 메모리 크기를 할당받기 위해 해당 제어경로가 무한정 휴면상태에 빠질 수 있기에 1024로 iovec의 개수를 제한하지 않았나라고 생각된다.


계속 이어서, 14~24 라인은 위에서 개략적인 설명을 하였으므로 넘어가도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
        ret = 0;
        for (seg = 0; seg < nr_segs; seg++) {
                void __user *buf = iov[seg].iov_base;
                ssize_t len = (ssize_t)iov[seg].iov_len;
 
                /* see if we we're about to use an invalid len or if
                 * it's about to overflow ssize_t */
                if (len < 0) {
                        ret = -EINVAL;
                        goto out;
                }
                if (type >= 0
                    && unlikely(!access_ok(vrfy_dir(type), buf, len))) {
                        ret = -EFAULT;
                        goto out;
                }
                if (len > MAX_RW_COUNT - ret) {
                        len = MAX_RW_COUNT - ret;
                        iov[seg].iov_len = len;
                }
                ret += len;
        }
out:
        *ret_pointer = iov;
        return ret;
}
cs


위 코드에서는 상단코드 24라인까지의 모든 과정을 끝낸 뒤, 유저모드 iovec의 정보들이 모두 커널모드에 할당된 iovec으로 옮겨진 뒤 수행되는 작업이다. for문을 통해 nr_segs, 즉 iovec의 개수만큼 3~21라인까지 루프문을 진행하게 된다. 전체적으로 크게 신경 쓸 부분은 없다.


8~11라인은 iov_len이 음수일 경우, 즉 오버플로우나 기타 이유를 통해 iov_len이 비정상적으로 변경되었을 경우를 판단하게 된다.


17~21라인은 MAX_RW_COUNT - ret, 즉 커널에서 한번에 최대로 입출력 처리를 할 수 있는 바이트 수에서 이전 iovec객체까지의 길이(iov_len)총합을 뺀 것보다 현재 Index의 iovec객체의 iov_len이 더 클 경우, 해당 값을 MAX_RW_COUNT-ret으로 지정하게 된다. 쉽게 말해서, iovec이 아무리 많고, 하나의 iovec객체의 버퍼 크기가 아무리 크더라도 한번에 입출력을 수행할 수 있는 바이트 수는 MAX_RW_COUNT를 넘을 수 없다는 것이다.

이때 주의해야 하는 것은, MAX_RW_COUNT의 크기를 넘은 시점부터 그 이후의 iovec에 대해서는 입출력이 수행되지 않는다는 것이지, readv의 수행에 오류가 생기는 것은 아니다. 물론, readv에서 오류가 생기는게 아닐 뿐이지 그 전에 너무 큰 배열의 생성으로 Segmentation Fault가 발생하지 않을까 싶다.

만일 MAX_RW_COUNT 값을 넘었는지 확인하고자 한다면, readv로부터 리턴된 읽은 바이트 수를 비교하면 된다.


그럼 이어서 12~16 라인을 살펴보도록 하자. 해당 라인에서는 가장 먼저 type >= 0인지 확인하게 된다. type은 정상적인 경우 READ와 WRITE, READA의 세 type만이 존재하며, 모두 0이상 값을 가지고 있다(READ == 0). 따라서 type이 Invalid할 경우 바로 에러코드를 저장하고 함수를 나가게 된다(Short-circuit evaluation).

만일, type이 Valid할 경우, access_ok를 통해서 iovec에 할당된 영역에 접근이 가능한지(유효 메모리 범위 안에 존재하는지)를 확인하게 된다.

이때, 첫번째 인자인 type은 vrfy_dir매크로('#define vrfy_dir(type) ((type) == READ ? VERIFY_WRITE : VERIFY_READ')를 통해서 결정케 된다.


마지막 23~26 라인에서는 할당과 값 정의, 그리고 Verify과정까지 모두 거친 iov를 rw_copy_check_uvector의 마지막 매개변수인 ret_pointer에 저장한 뒤, ret(<= MAX_RW_COUNT)를 리턴해주며 다시 import_iovec으로 돌아가게 된다.

그럼, 다시 import_iovec함수를 살펴보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int import_iovec(int type, const struct iovec __user * uvector,
                 unsigned nr_segs, unsigned fast_segs,
                 struct iovec **iov, struct iov_iter *i)
{
        ssize_t n;
        struct iovec *p;
        n = rw_copy_check_uvector(type, uvector, nr_segs, fast_segs,
                                  *iov, &p);
        if (n < 0) {
                if (p != *iov)
                        kfree(p);
                *iov = NULL;
                return n;
        }
        iov_iter_init(i, type, p, nr_segs, n);
        *iov = p == *iov ? NULL : p;
        return 0;
}
EXPORT_SYMBOL(import_iovec);
cs


위에 이미 존재하는 코드지만, 스크롤을 다시 올리기는 필자도 귀찮았기에 가져왔다.

rw_copy_check_uvector의 Return(iovec의 총 크기)가 0보다 작을 경우에는 Invalid하기에 9~14라인에서 메모리 할당을 해제하고 함수를 나가게 된다.


하지만, 정상적으로 Return값이 들어왔을 경우에는 iov_iter_init함수를 호출하게 된다.

그 뒤에 16라인에서는 iov의 값을 삼항연산자로 비교, 만일 rw_copy_check_uvector에서 얻어온 return_pointer인 p가 do_readv_writev의 iovstack과 같을 경우(즉, iovec의 개수가 8개 이하라 kmalloc이 수행되지 않은 경우)에는 iovstack이 존재하기에 iov를 NULL로 초기화 하며, 그렇지 않을 경우에는 p로 초기화 해주어 할당한 메모리 영역의 주소를 알려주게 된다.


간략하게 iov_iter_init에 대해 소개해보자면, 소스코드는 다음과 같다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void iov_iter_init(struct iov_iter *i, int direction,
                        const struct iovec *iov, unsigned long nr_segs,
                        size_t count)
{
        /* It will get better.  Eventually... */
        if (segment_eq(get_fs(), KERNEL_DS)) {
                direction |= ITER_KVEC;
                i->type = direction;
                i->kvec = (struct kvec *)iov;
        } else {
                i->type = direction;
                i->iov = iov;
        }
        i->nr_segs = nr_segs;
        i->iov_offset = 0;
        i->count = count;
}
EXPORT_SYMBOL(iov_iter_init);
cs


간단하게 설명해보자면, 6번째 라인 if문에서 get_fs()로 얻어온 현재 스레드의 세그먼트 정보와 KERNEL_DS를 비교, 현재 세그먼트가 커널 데이터 세그먼트 영역이라면, 7~9 라인을 수행하게 된다.


7번 라인에서는 direction(type, 현재는 READ)에 ITER_KVEC을 OR함으로 현재 iovec Iteration이 커널 영역에서 진행됨을 알려주게 된다.

8번 라인은 else일때의 11번 라인과 동일하게 direction으로 iov_iter구조체의 type을 설정하게 된다.

9번과 12번 라인의 차이점에 주의해야 되는데. 커널 데이터 세그먼트 영역에 속할 경우, iovec이 아닌 kvec으로서 변환하여 iovec을 iov_iter에 할당하게 된다.


이때, kvec과 iovec의 변환은 비교적 자유로운 편인데,


1
2
3
4
struct kvec {
        void *iov_base;
        size_t iov_len;
};
cs


1
2
3
4
5
struct iovec
{
        void __user *iov_base;
        __kernel_size_t iov_len;
};
cs


보다시피 kvec의 경우에는 iov_base가 커널영역의 실제 주소를 가르키고, iovec은 구조체 자체는 커널 영역에 할당 되더라도 iov_base는 __user포인터이기에 유저영역의 가상 주소를 가리키게 된다. 그 외에는 큰 차이가 없다. 구조상의 차이가 없고, 다만 저장되는 주소가 커널 주소냐 유저 주소냐의 차이일 뿐.


다시 돌아가서, 14~16 라인을 통해서 마지막으로 iovec을 iov_iter구조체를 통해 Iteration할 수 있도록, 총 iovec객체의 개수와, 총 iovec의 바이트 크기를 iov_iter구조체에 저장하게 된다.


그럼, 다시 import_iovec으로 돌아와서 설명을 계속하자면, import_iovec은 유저영역에서 받아온 iovec을 커널 메모리상에 복사한 뒤, 커널에 할당된 iovec을 iov_iter구조체에 등록하는 역할까지를 수행케 된다.


이제 import_iovec에서 벗어나, do_readv_writev로 돌아가도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (ret < 0)
    return ret;
 
tot_len = iov_iter_count(&iter);
if (!tot_len)
    goto out;
ret = rw_verify_area(type, file, pos, tot_len);
if (ret < 0)
    goto out;
 
if (type == READ) {
    fn = file->f_op->read;
    iter_fn = file->f_op->read_iter;
else {
    fn = (io_fn_t)file->f_op->write;
    iter_fn = file->f_op->write_iter;
    file_start_write(file);
}
cs


import_iovec의 호출 이후 부분이다. import_iovec이 정상 수행이 되지 않은 경우, 1번 라인의 if문에서 바로 호출 종료가 되게 된다.


이어 4번 라인에서 iov_iter_count를 통해 현재 iov_iter의 총 바이트 길이를 구해오게 된다. 그 뒤, rw_verify_area를 통해서 해당 크기만큼의 공간이 파일에서 접근이 가능한지 확인하게 된다.

rw_verify_area에서는 mandatory lock의 여부와, 파일 오프셋의 Valid여부 등을 확인하게 되는데, readv의 구현과 직접 연관이 된 것이 아니므로 이런 역할을 수행한다 정도로 넘어가자.


11번 라인부터는 READ일 경우 현재 파일 구조체의 file_operations구조체에서 read함수와 read_iter함수를 얻어오게 된다. 현재 일반적으로 쓰이고 있는 Ext4 파일시스템에서는 read멤버는 존재하지 않으며, read_iter멤버만이 new_sync_read로 정의되어 있다.


14번 이하 라인에서는 READ가 아닐 경우, 즉 WRITE의 경우의 작업이며 지금은 크게 관심가질 필요 없다. file_start_write함수를 통해 현재 파일시스템에 해당 파일의 inode가 존재하는지 확인하고 존재할 경우 해당 파일의 superblock에 locking을 수행한다 정도만 숙지하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    if (iter_fn)
        ret = do_iter_readv_writev(file, &iter, pos, iter_fn, flags);
    else
        ret = do_loop_readv_writev(file, &iter, pos, fn, flags);
 
    if (type != READ)
        file_end_write(file);
 
out:
    kfree(iov);
    if ((ret + (type == READ)) > 0) {
        if (type == READ)
            fsnotify_access(file);
        else
            fsnotify_modify(file);
    }
    return ret;
}
cs


사실상 do_readv_writev의 남은 부분은 크게 중요치는 않은 부분들이다.

간단하게 iter_fn이 정의되어 있으면 iter_fn을 인자로 넘기면서 do_iter_readv_writev함수를 먼저 호출하고, 그렇지 않을 경우 do_loop_readv_writev에 read를 넘기면서 호출하게 된다.


그 이후 만일 READ가 아닐경우에는 file_end_write로 superblock lock을 unlock해주게 된다.


10번 라인부터는 할당된 iovec을 해제해주면서 fsnotify_access(READ일 경우)와 fsnotify_modify(WRITE 등)을 통해서 파일에 대한 접근 및 변경이 일어났음을 fsnotify를 통해 전달하게 된다.


그럼 여기서부터는 함수의 수행이 두 가지로 갈리게 된다. 하지만 최신 파일시스템의 대부분은 iter_read을 지원하며, 비교적 이전 파일시스템의 경우에도 iter_read과 read가 모두 존재하는 경우가 많기 때문에, do_iter_readv_writev에 대해서만 서술하도록 하겠다.

do_loop_readv_writev함수는 writev구현까지 모두 설명한 이후에 기회가 된다면 추가 포스팅을 통해 서술하도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static ssize_t do_iter_readv_writev(struct file *filp, struct iov_iter *iter,
        loff_t *ppos, iter_fn_t fn, int flags)
{
    struct kiocb kiocb;
    ssize_t ret;
 
    if (flags & ~RWF_HIPRI)
        return -EOPNOTSUPP;
 
    init_sync_kiocb(&kiocb, filp);
    if (flags & RWF_HIPRI)
        kiocb.ki_flags |= IOCB_HIPRI;
    kiocb.ki_pos = *ppos;
 
    ret = fn(&kiocb, iter);
    BUG_ON(ret == -EIOCBQUEUED);
    *ppos = kiocb.ki_pos;
    return ret;
}
cs


위의 코드가 do_iter_readv_writev의 원형이다.


먼저 flag가 0이 아닐경우 -EOPNOTSUPP으로 리턴하게 된다. 그러나 이때, RWF_HIPRI는 제외되는데(~ + AND), High-priority request는 사실상 직접적인 입출력 수행에 큰 영향을 끼치지 않으므로(Polling에 있어 우선순위 지정), 지원하지 않을 경우 무시할 수 있기 때문이다.

RWF_HIPRI플래그가 SET되어 있을 경우에는 11~12 라인을 통해 kiocb의 ki_flags에 IOCB_HIPRI가 추가되지만, 해당 플래그의 사용 여부는 프로그래머가 관심 가질 필요 없다고 할 수 있다. 쉽게 표현하자면 C++에서의 inline키워드와 비슷한 느낌이라 보면 될듯하다.


이어서 10번 라인에서 init_sync_kiocb를 통하여 kiocb구조체를 초기화 하게 된다.

kiocb구조체는 iocb(I/O control block)의 커널 구현체로, kiocb에 대한 자세한 설명은 차후 별도 포스팅으로 진행하고, 입출력 작업 자체에 필요한 정보를 저장하는 구조체라 알아두면 될 듯 하다(각 파일시스템에서 공통적으로 현재 파일의 정보를 접근 및 변경할 때 사용케 된다).


init_sync_kiocb의 구현은 다음과 같다.


1
2
3
4
5
6
7
static inline void init_sync_kiocb(struct kiocb *kiocb, struct file *filp)
{
        *kiocb = (struct kiocb) {
                .ki_filp = filp,
                .ki_flags = iocb_flags(filp),
        };
}
cs


kiocb구조체의 ki_filp멤버에 file구조체의 포인터를 전달해주고, ki_flags에는 해당 file구조체에서 IOCB_DIRECT(Direct IO)등의 파일 플래그를 받아 저장하게 된다.

iocb_flags의 구현에 대해서는 본 포스팅에서는 언급하지 않으니, 만일 관심이 있다면 http://revdev.tistory.com/54를 참조하기를 바란다.


다시 do_iter_readv_writev로 돌아와서, 13번 라인에서 앞으로 읽어야 할 File offset을 인자로 전달받은 ppos에서 kiocb구조체의 ki_pos로 넘겨주게 된다.


마지막으로, 15번 라인에서 fn, 즉 파일시스템의 read_iter함수를 호출하면서 readv를 수행하게 된다. 최신 파일시스템인 Ext4 파일시스템의 경우에는 generic_file_read_iter함수를 호출하게 된다.

(자세한 내용은 동일하게 http://revdev.tistory.com/54를 참조)


이후 16번 라인에서 BUGON으로 fn의 리턴 값을 검사한 뒤, File offset을 읽은 값만큼 변경된 kiocb구조체의 ki_pos값으로 바꾼 뒤에 읽은 바이트 수를 리턴 하는 것으로 readv의 수행은 끝나게 된다.


간략하게 정리해보자면, SYSCALL_DEFINE3(readv...를 통해 Syscall Handling을 한 후, do_readv, vfs_readv를 넘어오면서 do_readv_writev에서 New read feature(iter_read)와 Old feature(read)로 수행이 갈라지게 되며, 최종적으로는 do_iter_readv_writev와 do_loop_readv_writev모두에서 제반작업만을 수행한 뒤, 파일시스템 Native 파일입출력 함수로 넘어가게 된다.


일단, readv에 대해서는 모든 정리를 마친 듯 하다. 다음은 writev인데... 이건 다음 포스팅에 이어 쓰도록 하겠다. 솔직히 말해서 이 정도 정리도 본인에게는 힘든 편이었다.


뭐, 이대로 끝내기에는 아쉬우니까 한 가지 재미있는 점을 보도록 하자.

http://revdev.tistory.com/54를 보면, 본인이 read함수의 Internal implementation에 대해서 정리해 놓았는데. 해당 포스팅을 잘 보다보면 분명 단일 버퍼에 대한 입출력을 수행하는 read함수에서도 iovec을 사용함을 볼 수 있다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
static ssize_t new_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = buf, .iov_len = len };
    struct kiocb kiocb;
    struct iov_iter iter;
    ssize_t ret;
    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = *ppos;
    iov_iter_init(&iter, READ, &iov, 1, len);
    ret = filp->f_op->read_iter(&kiocb, &iter);
    BUG_ON(ret == -EIOCBQUEUED);
    *ppos = kiocb.ki_pos;
    return ret;
}
cs


바로 이 부분에서 말이다.


init_sync_kiocb와 iov_iter_init이 보인다. 물론 iovec iov도 보인다.

이게 어떻게 된 것일까? 간단하게 생각하면 된다.

read의 구현형식도 사실상 readv와 다를바 없다고 말이다. 다만, iovec의 개수가 1개일 뿐이고, 그에 따라 iov_iter의 구성에 필요한 제반작업이 딱히 필요 없어, 해당 부분이 빠진 것 뿐이다.


이를 알 수 있는 가장 큰 증거가 있는데, 위 코드에서 3번 라인과 9번 라인에 집중해보자.


1
2
struct iovec iov = { .iov_base = buf, .iov_len = len };
iov_iter_init(&iter, READ, &iov, 1, len);
cs


iovec구조체를 만들면서 동시에 인자로 넘겨받은 단일 버퍼와 그 크기를 바로 초기화 해주고 있다.

또한 iov_iter_init을 통해서 iov_iter를 만들되, 그 크기를 1로서 정의하고 있다(iov_iter_init의 4번째 인자 참조).

쉽게 말해서 read와 readv의 구현은 내부적으로는 같다고 보는 것이 맞을 듯하다.


어쩌면 당연한 결과이다. read와 readv 둘 모두 같은 파일시스템 입출력 함수를 사용한다는 점에서 미루어보자면 말이다.


뭐 아무튼 이번 포스팅은 이쯤에서 마무리 하도록 하겠다. 이후에 좀 더 좋은 포스팅으로 찾아오도록 하겠다.

포스팅 내용중에 애매하거나 이상한 표현이 있을 경우 지적해주면 바로 정정하겠다.




Posted by RevDev
,

- Kernel 4.6.3 read() syscall internal implementation -


이번에 Kernel 버전이 4.0으로 바뀌면서 read syscall의 커널 내부 구현이 상당수 바뀌었다. 기존에 존재하던 do_sync_read함수가 없어지고 아예 new_sync_read만이 남았다던가... 그런 식으로 말이다. 물론 3.0부터 커널 소스의 개편이 예고되었지만, 막상 바뀌고 나니까 당혹스러웠다고나 할까. 본인도 zero copy에 대해 정리하면서 read의 구현을 살펴 보고 나서야 알아차린 사실이니까 말이다. 그래서 이거 뭐가 중요하냐고? 딱히 중요한 건 아니다. 사실 이번 4.0 커널에서 새로이 수정된 내용들은 3.0 커널 때부터 천천히 바뀌어 온 내용이니까. 하지만, 기왕 바뀐 김에 이렇게 짚고 넘어가는 것도 나쁘지 않을 것 같다고나 할까나... 아무튼 간에, 비교 대상은 Kernel 4.6.3.과 Kernel 3.18이다.


가장 먼저 read() syscall을 호출하게 되면 내부적으로 Syscall Table을 참고하여 해당 syscall의 커널 래퍼 루틴을 찾게 된다. 커널 소스에서는 이런 syscall 래퍼 루틴을 SYSCALL_DEFINE(n) 매크로의 형태로 정의하게 된다. 아무튼가 read syscall의 흐름을 따라가보도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
    struct fd f = fdget_pos(fd);
    ssize_t ret = -EBADF;
    if (f.file) {
        loff_t pos = file_pos_read(f.file);
        ret = vfs_read(f.file, buf, count, &pos);
        if (ret >= 0)
            file_pos_write(f.file, pos);
        fdput_pos(f);
    }
    return ret;
}
cs


위 소스코드는 SYSCALL_DEFINE3(read...의 코드이다. 다행히 이 부분에 있어서는 크게 바뀐 점이 없다. fget_pos함수를 통해 fd구조체를 얻어오는 것부터 시작해서 file_pos_read를 통해 File Pointer를 얻어오는 것도 그렇고, 파일을 읽기 위해 vfs_read를 호출하는 것도 그렇고, 결국 'File 구조체를 넘겨 vfs_read를 호출한다'는 기본 개념에서 크게 달라진 점은 없는 것 같다. 그럼 계속 분석해 나가볼까.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)
{
    ssize_t ret;
    if (!(file->f_mode & FMODE_READ))
        return -EBADF;
    if (!(file->f_mode & FMODE_CAN_READ))
        return -EINVAL;
    if (unlikely(!access_ok(VERIFY_WRITE, buf, count)))
        return -EFAULT;
    ret = rw_verify_area(READ, file, pos, count);
    if (ret >= 0) {
        count = ret;
        ret = __vfs_read(file, buf, count, pos);
        if (ret > 0) {
            fsnotify_access(file);
            add_rchar(current, ret);
        }
        inc_syscr(current);
    }
    return ret;
}
cs


그런데 여기도 크게 달라진 점은 없는 듯하다. File을 읽을 수 있는지 검사하는 4~7번 라인과 User mode buffer가 유효 메모리 범위 안에 존재하는지 확인하는 access_ok함수도 그대로 존재한다. 동일하게, 10번 라인에 현재 파일에 락(mandatory lock)이 걸려있는지 확인하는 rw_verify_area 또한 그대로 존재한다. 언뜻 보이는 차이점이라고 하자면은... 중간에 __vfs_read라는 함수가 추가된 점?


1
2
3
4
5
6
7
8
9
10
ssize_t __vfs_read(struct file *file, char __user *buf, size_t count,
           loff_t *pos)
{
    if (file->f_op->read)
        return file->f_op->read(file, buf, count, pos);
    else if (file->f_op->read_iter)
        return new_sync_read(file, buf, count, pos);
    else
        return -EINVAL;
}
cs


__vfs_read의 구현을 살펴보았다. File System의 file_operations구조체에 등록된 read함수가 존재하는 경우 해당 함수를 가장 먼저 호출하고, 차순으로 file_operations에  read_iter함수가 정의되어 있는 경우에 new_sync_read함수를 호출하게 된다. 이도 저도 아닌, 즉 파일을 읽어 들일 수 있는 함수가 존재하지 않는 경우에는 -EINVAL 에러 코드를 리턴하게 된다.

그런데... 어디서 많이 본 구조 같은데... 아무래도 기존의 vfs_read함수 자체에 들어있던 내용을 별도의 함수로서 빼낸 것 같다. 좀 더 확실한 비교를 위해 Diffing툴을 이용해서 차이점을 확인해보도록 하겠다.



...역시나라고 해야하나. 3.0 커널에서 vfs_read함수에 존재하던 부분을 __vfs_read라는 별도의 함수로 빼낸 듯하다. 그런데 뭔가 일부 내용이 바뀐 듯 하다. 잠시 해당 부분 코드만 빼내서 비교를 해보도록 하겠다.



바뀌었다. read함수를 최우선적으로 호출하는 점에 있어서는 변하지 않았지만, 기존에 aio_read와 관련되어있던 do_sync_read함수 부분이 아예 사라진 것을 볼 수 있다. 또한 new_sync_read함수가 aio_read를 대체하고 있는 것을 볼 수 있다. 정리하자면, 과거에는 aio_read함수가 등록된 File System가 존재하면 do_sync_read 함수를 new_sync_read함수보다 먼저 호출하였다. 즉, 과거에는 최신 명세보다 기존의 aio_read의 호환성을 좀 더 신경썼다는 것. 하지만 aio_read가 존재하는지 확인하는 부분 자체가 없어지고 new_sync_read만을 남겨두었다는 뜻은...



아무래도, do_sync_read함수 자체를 아예 new_sync_read로 대체하기로 한 것이 아닐까. 그 증거라고 할까 do_sync_read의 구현 자체가 커널 소스 상에서 사라졌다. 비록 하위 호환성을 포기하는 한이 있더라도 4.0 커널에서는 new_sync_read만을 사용하기로 생각한 것 같다.

물론 이렇게 되었다고 하더라도 File System에서 지원하는 read함수를 우선 호출한다는 점에서는 변하는 것이 없지만, 가만히 생각해보면 커널 4.0부터의 Ext4의 file_operations구조체에는 .read가 따로 정의되어 있지 않다. 여기서 잠시 Kernel 4.6.3 Ext4 File System의 file_operations구조체의 구현을 확인해보도록 하자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const struct file_operations ext4_file_operations = {
    .llseek        = ext4_llseek,
    .read_iter    = generic_file_read_iter,
    .write_iter    = ext4_file_write_iter,
    .unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
    .compat_ioctl    = ext4_compat_ioctl,
#endif
    .mmap        = ext4_file_mmap,
    .open        = ext4_file_open,
    .release    = ext4_release_file,
    .fsync        = ext4_sync_file,
    .splice_read    = generic_file_splice_read,
    .splice_write    = iter_file_splice_write,
    .fallocate    = ext4_fallocate,
};
cs


보다시피, 별도의 .read멤버가 정의되어 있지 않다. 여기서 정의되어 있는 것은 .read_iter뿐이다. 즉, 4,0 커널의 vfs_read에서 Ext4 File System은 __vfs_read함수 else if문의 조건을 충족하여 new_sync_read함수를 호출하게 된다. 그럼 과거에 .read멤버가 존재했을 때는 어땠을까?



물론 .read멤버가 존재했던 Kernel 3.18에서도 ext4 file_operations의 .read에는 new_sync_read가 들어가 있었다. 이때부터 이미 new_sync_read가 쓰이고는 있었다는 것. 어쩌면 '__vfs_read에서 .read호출해서 new_sync_read가 호출되던, .read_iter가 else if문에 걸려서 new_sync_read가 호출되던 둘 다 결국에는 똑같은 상황이잖아'라는 생각에 지웠을지도 모르겠다.

아무튼 간에 이쯤 되면 Critical하지는 않지만, 무언가 세세한 부분에서 많이 바뀌었다는 것을 느낄 수 있을 것이다. 그럼, 계속해서 사실상 do_sync_read를 대신해 표준으로 바뀐 new_sync_read함수의 구현을 따라 좀 더 깊은 곳으로 뛰어들어 보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
static ssize_t new_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = buf, .iov_len = len };
    struct kiocb kiocb;
    struct iov_iter iter;
    ssize_t ret;
    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = *ppos;
    iov_iter_init(&iter, READ, &iov, 1, len);
    ret = filp->f_op->read_iter(&kiocb, &iter);
    BUG_ON(ret == -EIOCBQUEUED);
    *ppos = kiocb.ki_pos;
    return ret;
}
cs


글쎄다. 이번에도 크게 바뀐 부분은 없는 것 같다. read_iter를 호출하는 건 메인 루틴이니까 그렇다고 쳐도, init_sync_kiocb를 호출해서 kiocb구조체를 초기화하는 것도, iov_iter_init을 이용해서 io vector iterator를 초기화하는 것도 달라진 부분이 없다. 좀 더 자세히 확인하기 위해 Diffing툴을 이용해보자.



예상대로 크게 바뀐 부분은 없다. 기존에 -EIOCBQUEUED를 검사하여 예외 처리하는 루틴이 BUG_ON 매크로로 바뀐 것 뿐이다. 물론 ki_nbytes멤버를 초기화하는 부분이 사라졌지만, 전체 흐름에 있어 크게 상관 없는 부분이라 넘어가도록 하겠다.

그럼 전제적인 코드가 아니라 내부 함수까지 세세히 한번 비교해볼까. init_sync_kiocb의 구현을 살펴보도록 하자.


1
2
3
4
5
6
7
static inline void init_sync_kiocb(struct kiocb *kiocb, struct file *filp)
{
        *kiocb = (struct kiocb) {
                .ki_filp = filp,
                .ki_flags = iocb_flags(filp),
        };
}
cs


여기서는 Kernel 3.16에 비해 달라진 부분이 바로 보인다. 명확한 비교를 위해 Kernel 3.16의 소스 코드도 보도록 하자.


1
2
3
4
5
6
7
8
static inline void init_sync_kiocb(struct kiocb *kiocb, struct file *filp)
{
        *kiocb = (struct kiocb) {
                        .ki_ctx = NULL,
                        .ki_filp = filp,
                        .ki_obj.tsk = current,
                };
}
cs


보다시피, .ki_filp를 초기화 하는 부분을 제외하고는 모두 바뀌었는데, .ki_ctx의 초기화 부분이 없어졌을 뿐더러, .ki_obj.tsk의 초기화 부분은 어디 가고 .ki_flags라는 멤버를 iocb_flag라는 함수의 리턴 값으로 초기화 시키고 있다.


혹시나 해서 kiocb구조체의 명세를 살펴보았는데 상당수가 바뀌어 있는 것을 발견할 수 있었다. 특히 aio_read 관련 멤버들이 사라진 것이 눈에 띈다. 새로이 추가된 멤버로는 void* private와 int형 ki_flags가 있는데, ki_flags라... 뭔가 느낌이 오지 않는가? 위에서 본 새로운 init_sync_kiocb함수에서 해당 멤버를 iocb_flags라는 함수로 초기화 했는데, iocb_flags 함수의 구현을 보자.


1
2
3
4
5
6
7
8
9
static inline int iocb_flags(struct file *file)
{
        int res = 0;
        if (file->f_flags & O_APPEND)
                res |= IOCB_APPEND;
        if (io_is_direct(file))
                res |= IOCB_DIRECT;
        return res;
}
cs


보자마자 바로 어떤 역할을 하는지 앟 수 있다. inline함수로서, file구조체의 f_flags멤버를 검사하는 루틴이다. 두 번째 if문을 보면 io_is_direct라는 함수가 보이고, 해당 함수의 리턴 값이 true일 경우, IOCB_DIRECT를 set하는 것을 볼 수 있다. 여기서, io_is_direct함수 또한 inline함수이며,


1
return (filp->f_flags & O_DIRECT) || IS_DAX(filp->f_mapping->host);
cs


위와 같이 O_DIRECT플래그가 켜져있는지, 혹은 read 대상 파일이 DAX파일인지를 검사하게 된다. 즉, 새로운 init_sync_kiocb함수에서는 File Pointer를 초기화하고, iocb_flags함수를 이용하여 각 파일 플래그에 맞게 ki_flags멤버를 설정해주게 된다.

자, 새로운 init_sync_kiocb함수를 최종적으로 정리해볼까. 기존에 존재하던 kiocb구조체의 멤버들을 read에 필수적인 멤버들만을 남기고 가지치기 하였고, 그에 따라 init_sync_kiocb함수는 kiocb구조체에 File Pointer와 File Flags만을 저장하도록 경량화(이 표현이 맞는건지는 잘 모르겠다) 되었다는 것을 알 수 있다.


init_sync_kiocb다음으로 new_sync_read에서 호출되는 함수는 iov_iter_init함수다. 이번에는 iov_iter_init함수가 어떻게 바뀌었는지 확인하자.



이번에도 큰 흐름에서 벗어난 부분은 없다. 다만, 과거에는 segment_eq로 확인해서 포함되어있는 segment가 같을 경우 direction에 ITER_KVEC만을 set 해줬었는데, 커널 4.0부터는 kvec구조체가 iov_iter구조체에 멤버로 들어온 듯 하며, kvec구조체 또한 IO Vector인 iov를 형 변환시켜 초기화 해주고 있다.

여기서 전혀 다른 구조체인 kvec과 iovec을 어떻게 형 변환 시키냐고 생각할 수 도 있을텐데, 아실 분은 아시겠지만 사실 kvec은


1
2
3
4
struct kvec {
        void *iov_base;
        size_t iov_len;
};
cs


1
2
3
4
5
struct iovec
{
        void __user *iov_base;
        __kernel_size_t iov_len;
};
cs


보다시피 처음부터 iovec과의 호환이 가능하도록 설계되어 있기 때문에 큰 문제가 없다. 뭐, 결론적으로 iov_iter_init은 iov_iter구조체에 새로이 들어온 kvec멤버의 초기화 부분이 추가되었다는 점 빼고는 다른 부분이 없는 듯 하다.


그럼 다시 거슬러 올라가 new_sync_read를 보자. 이제는 더 비교할 부분도 없다. 또 다시 더 깊게 들어가보자. 각 File System의 file_operations에서 read_iter를 호출하게 되는데, 일반적인 경우 read_iter는 generic_file_read_iter함수로서 정의되어 있다. generic_file_read_iter를 뜯어보도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
        struct file *file = iocb->ki_filp;
        ssize_t retval = 0;
        loff_t *ppos = &iocb->ki_pos;
        loff_t pos = *ppos;
        size_t count = iov_iter_count(iter);
 
        if (!count)
                goto out; /* skip atime */
 
        if (iocb->ki_flags & IOCB_DIRECT) {
                struct address_space *mapping = file->f_mapping;
                struct inode *inode = mapping->host;
                loff_t size;
 
                size = i_size_read(inode);
                retval = filemap_write_and_wait_range(mapping, pos,
                                        pos + count - 1);
                if (!retval) {
                        struct iov_iter data = *iter;
                        retval = mapping->a_ops->direct_IO(iocb, &data, pos);
                }
 
                if (retval > 0) {
                        *ppos = pos + retval;
                        iov_iter_advance(iter, retval);
                }
 
                /*
                 * Btrfs can have a short DIO read if we encounter
                 * compressed extents, so if there was an error, or if
                 * we've already read everything we wanted to, or if
                 * there was a short read because we hit EOF, go ahead
                 * and return.  Otherwise fallthrough to buffered io for
                 * the rest of the read.  Buffered reads will not work for
                 * DAX files, so don't bother trying.
                 */
                if (retval < 0 || !iov_iter_count(iter) || *ppos >= size ||
                    IS_DAX(inode)) {
                        file_accessed(file);
                        goto out;
                }
        }
 
        retval = do_generic_file_read(file, ppos, iter, retval);
out:
        return retval;
}
EXPORT_SYMBOL(generic_file_read_iter);
cs



달라진 부분만 보자. 먼저 count가 0일 경우 모든 루틴을 스킵하는 부분이 추가되었고, Diffing 결과 1848번 라인을 보면 기존 1699번 라인에서 직접 f_flags로 Direct IO여부를 결정하던 것이 위에서 언급했던 ki_flags의 도입으로 해당 멤버가 IOCB_DIRECT로 set되어 있는지 검사하는 식으로 바뀌었다.

또한, Diffing 결과 1712번 라인에 direct_IO함수에서 rw 매개변수가 사라졌다. 여기서 잠시 address_space_operations구조체를 확인보면,



실제로 direct_IO의 Prototype에서 첫 번째 인자인 int형이 없어진 것을 볼 수 있다. 좀 더 확실히 하기 위해, direct_IO의 File System Wrapper함수들 중 가장 대표적인 예로서 ext4의 ext4_direct_IO를 비교해보았다.


Diffing 결과 24번 라인에서 29번 라인을 살펴보면 이제 매개변수로서 rw를 판단하는 것이 아닌, direct_IO함수 자체에서 iov_iter_rw를 호출해서 read/write 중 무슨 작업을 수행해야하는지 판단한다는 것을 알 수 있다. iov_iter_rw는 매크로 함수로서 다음과 같은 형태를 띄고 있다.


1
#define iov_iter_rw(i) ((0 ? (struct iov_iter *)0 : (i))->type & RW_MASK)
cs


쉽게 말하자면, iov_iter의 type에 RW_MASK를 AND연산해 R/W판단을 한다고 보면 될 듯하다. 정리하자면, 자체적으로 iov_iter_rw를 이용해 R/W판단을 수행하기에 더 이상 rw 매개변수가 필요 없다는 선택인 듯 하다. 이 이상으로 Ext4의 direct_IO를 세밀하게 분석하면 read의 분석이 아닌 Ext4의 분석이 되므로 다시 generic_file_read_iter로 돌아오도록 하자.


Direct IO에서 바뀐점을 제외하고 나면, 이제는 Diffing 결과 1875번 라인에서 DAX파일에서의 Buffered Read에 대한 패치가 적용된 정도 밖에 보이지 않는다. 슬슬 read의 내부 구현 분석도 끝이 보이는 듯 하다. 마지막으로 사실상 파일을 직접적으로 읽는 함수인 do_generic_file_read함수만을 남겨뒀는데, 안타깝게도 do_generic_file_read함수의 내부 구현을 모두 분석하기에는 너무 양이 방대해지는 관계로 이후 별도의 포스팅으로 빼내어 설명하도록 하겠다. 따라서 본 포스팅은 커널 4.0에 들어서 달라진 do_generic_file_read함수의 변경 부분만을 설명하고 끝내도록 하겠다



첫 번째 변경 부분이다. PAGE_CACHE_MASK가 PAGE_MASK로 바뀌고, PAGE_CACHE_SHIFT가 PAGE_SHIFT로 바뀌는 등, 매크로의 이름에서 CACHE가 빠졌다. 그럼 이로 인해 생기는 영향은 뭘까?


1
2
3
4
#define PAGE_CACHE_SHIFT        PAGE_SHIFT
#define PAGE_CACHE_SIZE         PAGE_SIZE
#define PAGE_CACHE_MASK         PAGE_MASK
#define PAGE_CACHE_ALIGN(addr)  (((addr)+PAGE_CACHE_SIZE-1)&PAGE_CACHE_MASK)
cs


간단하게 말하자면 '그런거 없다'. 애초에 PAGE_CACHE_SHIFT와 같이 CACHE계열 매크로들은 PAGE_SHIFT의 다른 이름에 불과했던 것이다. 루틴이 달라진 점은 없고, 단순히 불필요한 매크로의 정리 그 이상 그 이하도 아니다.



두 번째 변경 부분이다. 무언가 새로 생긴 부분이 있다. wait_on_page_locked_killable라는 함수가 중간에 추가되었고, page_ok로 분기하는 if문이 추가되었다.

일단 먼저 해당 부분이 추가된 스코프를 보자. if (!PageUptodate(page))라는 조건문에 의해 형성된 스코프이다. 해당 조건문의 PageUptodate함수는 인자로 넘겨진 페이지에 대해 test_bit함수로 플래그 검사를 수행하며, PG_uptodate플래그(현재 페이지 캐시에 저장된 파일의 내용이 Valid할 경우 set, 즉 unset시 페이지 캐시의 갱신이 필요)가 set되어 있는지 검사하게 된다. 따라서 이 스코프는 현재 페이지 캐시에 올라와 있는 파일의 내용이 Valid하지 않았을 때 수행하는 루틴이 포함된 스코프이다.

이때, wait_on_page_locked_killable함수를 수행한 뒤, PageUptodate함수로 다시 PG_uptodate를 검사해 set되어 있으면 디스크 접근 루틴을 생략하고 바로 page_ok로 분기하게 된다.

wait_on_page_locked_killable은 페이지가 Lock 되어 있는 상태일 시, 내부적으로 __wait_on_bit함수를 호출해 페이지가 Unlock(PG_locked가 해제) 될 때 까지 Waiting하게 된다. wait_on_page_locked_killable함수를 페이지가 Unlock 되기까지 기다린 후에 다시 한번 PageUptodate를 통해 Valid Check를 하게 된다.

과거의 경우, 페이지의 내용이 Valid하지 않을 경우 is_partially_uptodate등의 함수를 호출해 최소한의 추가 검증만을 수행 하고 나서, 바로 page_not_up_to_date 혹은 page_not_up_to_date_locked와 같이 페이지를 새로 읽어오기 위한 레이블로 분기했었다.

하지만, 단순 PG_uptodate플래그만 검사할 경우, 페이지가 사용 중이거나, Truncation 과정 중에 있어 Lock이 걸린 상태, 기타 Race-Condition상태 등의 일부 상황에서 페이지 내부 데이터가 Valid하더라도 페이지가 non-Valid로 판단되는 경우가 있었다. 커널 4.0에서는 해당 상황에 대해 대비하기 위해 페이지에 Lock이 걸려있을 경우에는 wait_on_page_locked_killable을 통해 Unlock이 될 때까지 기다린 후에 한번 더 PageUpdate를 수행하게 된다.

물론, Performance 부분에 있어서는 Waiting을 통해 소비되는 시간이 손해라고 생각 될 수도 있지만, wait_on_page_locked_killable이 도입된 이유는 불필요한 Serialisation을 최소화 하기 위해서였으니까 단순 Performance로만 판단하기에는 무리가 따른다. 또, wait_on_page_locked_killable의 경우 기존의 wait_on_page_locked함수와 다르게 Killable하다는 점에 있어서 특정 상황에 있어서 융통성을 가질 수 있다는 점이 다르다.



세 번째로 바뀐 부분, 기존의 page_cache_release함수가 put_page함수로 바뀌었다. 하지만 이 경우에도 처음 CACHE계열 매크로의 제거와 같은 맥락이다.


1
#define page_cache_release(page)        put_page(page)
cs


page_cache_release 또한 매크로 함수로서, 사실상 put_page와 동일했다. 즉, 이 부분도 기능적으로 바뀐 내용은 없다는 것.



마지막 바뀐 부분이다. add_to_page_cache_lru에 들어가는 인자 중, 제일 마지막 GFP_KERNEL부분이 mapping_gfp_constraint(mapping, GFP_KERNEL)로 바뀌었다.


1
2
3
4
5
6
7
8
9
10
11
static inline gfp_t mapping_gfp_mask(struct address_space * mapping)
{
        return (__force gfp_t)mapping->flags & __GFP_BITS_MASK;
}
 
/* Restricts the given gfp_mask to what the mapping allows. */
static inline gfp_t mapping_gfp_constraint(struct address_space *mapping,
                gfp_t gfp_mask)
{
        return mapping_gfp_mask(mapping) & gfp_mask;
}
cs


mapping_gfp_constraint의 구현을 보면 내부에서 mapping_gfp_mask를 mapping을 넘겨주면서 한번 더 호출하게 되며, mapping_gfp_mask는 mapping의 flags멤버에 __GFP_BITS_MASK를 AND연산하여 현재 페이지의 GFP(Get Free Page)플래그를 구하게 된다. 그리고 다시 mapping_gfp_constraint에서 전달받은 GFP_KERNEL과 AND연산을 하여 최종적인 GFP 플래그를 구하게 된다. 과거 add_to_page_cache_lru에서는 현재 페이지와 관계 없이 무조건 인자로서 넘어오는 GFP플래그를 적용시켰는데, 커널 4.0에서는 현재 페이지의 flags를 검사해서 flags에 명시되어 있는, 즉 현재 가능한 GFP플래그만을 넘기도록 강제한 것이라 보면 편할 듯 하다. 아, 그리고 'gfp_t라는 형은 뭐냐'라는 의문이 들 수도 있을텐데...


1
typedef unsigned __bitwise__ gfp_t;
cs


별거 아니고, 사실 Plain Integer다.


뭐, 아무튼간에 이렇게 해서 길다면 길고, 짧다면 짧은 커널 4.0의 read 분석이 끝났다. 물론 do_generic_file_read함수를 전부 분석하지 못한게 본인도 아쉽긴 한데... 그래도 어차피 크게 바뀐 부분도 없을 뿐더러, 나중에 별도의 포스팅에서 자세히 분석할 예정이다. 만일 포스팅 내용중에 틀린 점이나 애매하게 설명된 점이 있으면 지적해주면 바로 정정하겠다.







Posted by RevDev
,

- Linux Kernel 4.6.3 /net/ethernet/eth.c -


[ /net/ethernet/eth.c - Function 'eth_header' ]


이하의 코드는 eth_header함수의 원형이다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int eth_header(struct sk_buff *skb, struct net_device *dev,
           unsigned short type,
           const void *daddr, const void *saddr, unsigned int len)
{
    struct ethhdr *eth = (struct ethhdr *)skb_push(skb, ETH_HLEN);
 
    if (type != ETH_P_802_3 && type != ETH_P_802_2)
        eth->h_proto = htons(type);
    else
        eth->h_proto = htons(len);
 
    if (!saddr)
        saddr = dev->dev_addr;
    memcpy(eth->h_source, saddr, ETH_ALEN);
 
    if (daddr) {
        memcpy(eth->h_dest, daddr, ETH_ALEN);
        return ETH_HLEN;
    }
 
    if (dev->flags & (IFF_LOOPBACK | IFF_NOARP)) {
        eth_zero_addr(eth->h_dest);
        return ETH_HLEN;
    }
 
    return -ETH_HLEN;
}
EXPORT_SYMBOL(eth_header);
cs

[ eth.c - eth_header(...) ]


먼저 매개변수 분석부터 시작하자. 차례대로 skb, dev, type, daddr, saddr, len을 넘겨받고 있다.

skb는 sk_buff*형으로, 이더넷 헤더를 구현할 소켓 버퍼를 가리키고 있는 포인터이다.

dev는 패킷 처리를 위해 참조할 네트워크 디바이스 구조체를 가리키는 포인터.

type은 상위 프로토콜 타입을 나타내고, daddr과 saddr은 각각 Destination과 Source H/W Address를 나타내게 된다. 마지막으로 len은 패킷 길이를 나타내는 매개변수이다.

다음으로는, 루틴을 분석하도록 하겠다. 위 루틴에서 가장 첫 줄에서 ethhdr*형 eth에 skb_push의 리턴 값을 ethhdr*형으로 형 변환해서 대입하고 있는데,  skb_push의 경우 sk_buff를 인자로 받고 있다. 해당 함수는 skbuff.c에 명세 되어 있으며, 간략하게 알아보자면. skb_push함수는 내부적으로 다음과 같은 루틴을 수행하게 된다.


1
2
3
4
5
6
7
8
9
unsigned char *skb_push(struct sk_buff *skb, unsigned int len)
{
    skb->data -= len;
    skb->len  += len;
    if (unlikely(skb->data<skb->head))
        skb_under_panic(skb, len, __builtin_return_address(0));
    return skb->data;
}
EXPORT_SYMBOL(skb_push);
cs

[ skbuff.c - skb_push(...) ]


매개변수로는 sk_buff*형인 skb와 unsigned int형인 len을 받게 되며, 매개변수로 받은 skb의 data멤버(실 버퍼의 시작 위치 포인터)를 len만큼 빼게 되고 (기존 버퍼의 앞쪽에 len만큼 추가적인 공간을 확보), 해당 연산을 통해 확보한 공간을 len멤버(실 버퍼의 크기를 나타내는 멤버)에 더해주게 된다.

정리하자면, eth_header함수의 코드에서는

struct ethhdr *eth = (struct ethhdr *)skb_push(skb, ETH_HLEN);

를 호출하고 있는데 이를 skb_push()함수의 동작과 함께 정리하자면,

'sk_buff* skb를 skb_push함수에 인자로 넘겨 ETH_HLEN(이더넷 헤더 크기)만큼 추가적인 공간을 버퍼의 앞 부분에 추가적으로 확보한 뒤, 해당 버퍼 포인터를 ethhdr*형(이더넷 헤더 포인터형)으로 변환해서 eth에 저장하게 된다.

여기서 ethhdr구조체가 어떤 구조로 이루어져 있는지 짚고 넘어가도록 하겠다.


1
2
3
4
5
6
struct ethhdr {
        unsigned char   h_dest[ETH_ALEN];       /* destination eth addr */
        unsigned char   h_source[ETH_ALEN];     /* source ether addr    */
        __be16          h_proto;                /* packet type ID field */
} __attribute__((packed));
 
cs

[ if_ether.h - struct ethhdr ]


ethhdr은 다음과 같은 구조로 되어 있다. 여기서 ETH_ALEN은 #define ETH_ALEN 6으로서 정의되어 있다. 따라서 Octet * 6으로서, ETH_ALEN크기의 uchar배열 h_dest와 h_source는 H/W주소를 나타냄을 알 수 있다. 그 하단에 h_proto는 프로토콜 지정자로서 __be16 (u16 - unsigned short)형으로서, 일반적으로 2Byte를 나타낸다. 이를 실제 이더넷 패킷과 비교하면,



0~13 (Byte)Offset까지 존재하는 EthernetII 헤더와 1 : 1대응이 된다는 것을 알 수 있다.

계속 진행을 해서 다음 코드 부분을 분석해보도록 하겠다.


1
2
3
4
    if (type != ETH_P_802_3 && type != ETH_P_802_2)
        eth->h_proto = htons(type);
    else
        eth->h_proto = htons(len);
cs

[ eth.c - eth_header(...) ]


위 코드에서는 매개변수로 넘겨 받은 type에 들어있는 값이 ETH_P_802_3, ETH_P_802_2 둘 중 어느 것과도 같지 않을 경우, type을 그대로 ethhdr구조체 eth의 h_proto멤버에 대입하게 된다. 즉, 일반적인 EthernetII 헤더와 같이 type값을 넣는다는 것을 알 수 있다.

그러나 type이 ETH_P_802_3 혹은, ETH_P_802_2일 경우에는 h_proto에 len을 넘기게 되는데 이는, IEEE 802.3/802.2와 큰 연관이 있다. 많은 사람들이 IEEE 802.3 자체를 이더넷이라 아는 경우가 많은데, 실제 IEEE 802.3은 좀 더 큰 의미라고 보는 것이 맞을 듯 하다. IEEE 802.3은 EthernetII라고 불리는 DIX II (RFC 894)표준부터, RFC 1042등을 모두 포함한 표준이다. 위의 경우 RFC 1042인 IEEE 802.3 혹은, IEEE 802.2 LLC 프레임을 포함한 IEEE 802.3를 if문을 통해 EthernetII와 구분하게 된다. 이때, h_proto의 위치에 패킷의 길이를 나타내는 len이 들어가는 이유는 아래 IEEE 802.3 Frame의 그림을 보자



위와 같이 기존 EthernetII에서 EtherType에 해당하는 부분이 Length로 대체된 것을 알 수 있다. 커널 코드에서는 이를 반영해 EthernetII가 아닌, IEEE 802.3 Frame기반의 프로토콜은 해당 명세에 맞게 h_proto에 len을 저장하게 된다.

이제 남은 부분을 모두 정리하도록 하겠다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    if (!saddr)
        saddr = dev->dev_addr;
    memcpy(eth->h_source, saddr, ETH_ALEN);
 
    if (daddr) {
        memcpy(eth->h_dest, daddr, ETH_ALEN);
        return ETH_HLEN;
    }
 
    if (dev->flags & (IFF_LOOPBACK | IFF_NOARP)) {
        eth_zero_addr(eth->h_dest);
        return ETH_HLEN;
    }
 
    return -ETH_HLEN;
cs

[ eth.c - eth_header(...) ]


saddr에 별도로 정의된 값이 없을 경우, 네트워크 디바이스 구조체의 dev_addr멤버에 명시되어 있는 H/W Address를 읽어 memcpy로서 복사하게 된다. net_device는 NIC 마다 커널에 로드되는 네트워크 디바이스 드라이버 고유 구조체로서, 각 NIC마다 독립적인 구조체를 가지고 있다. 이 때 해당 net_device구조체의 dev_addr은 해당 NIC의 MAC주소를 가지게 되며, 따라서 위 코드는 네트워크 통신을 수행할 디바이스에서 직접적으로 MAC주소를 수집해 넣는 것으로 보아도 무방하다.

다음으로, daddr. daddr은 일반적인 경우 특정 값이 전달되기 때문에 그대로 memcpy를 통해 h_source에 복사하면 된다. 이 경우가 위 코드의 5번 라인에서 8번 라인에 해당하는 과정이다. 그러나 일부 특수한 경우 daddr의 값이 정해지지 않은 채로 넘어오는 경우가 있는데(null ptr), saddr의 경우 송신자가 본인인 것을 알 수 있으니까 디바이스에서 주소를 얻어올 수 있다지만 Destination이 정의되지 않은 경우에는 어쩔 수 없이 15번 라인과 같이 -ETH_HLEN이라는 에러코드를 리턴하게 된다.

하지만, 이에도 예외가 있는데 10번 라인에서 12번 라인까지를 살펴보자.


IFF_NOARP(ARP를 수행하지 않는 인터페이스, Destination Address를 설정하지 않음)혹은 IFF_LOOPBACK(루프백 인터페이스)일 경우에는 daddr이 설정되어 있지 않더라도 eth_zero_addr함수를 이용해 memset으로 0x00을 채워주게 된다.


본 함수의 경우 일반적인 루틴을 수행하고 리턴을 한 경우, 7번 라인에서 ETH_HLEN, 즉 이더넷 헤더 길이를 리턴하게 되고, 그 이외의 경우에는 -ETH_HLEN 에러코드를 리턴하여 헤더 할당 도중에 문제가 생겼음을 알린다.

최종적으로 정리하자면, 네트워크 통신을 수행할 sk_buff를 받아서 이더넷 헤더를 추가 및 값을 설정 해주는 함수라고 보면 될 듯 하다.

Posted by RevDev
,