uhttpd源碼分析


uhttpd是openwrt上默認的Web服務器,支持CGI,lua腳本,以及靜態文件的服務。它是一個精簡的服務器,一般適合作為路由器這樣的嵌入式設備使用,或者Web服務器的入門學習。

uhttpd的源碼可以用svn到這里下載。

概述

uhttpd.pnguhttpd.png
首先,在uhttpd啟動的時候,它會先讀取參數,進行服務器的配置。參數可以由命令行輸入,其中port參數必須制定,其他都有默認值。

配置完參數之后,服務器會進入uh_mainloop,等待請求。

在主循環中,uhttpd采用select進行輪詢,而不是采用fork進行並發,一定程度上降低了並發能力,但是很適合這樣的小型服務器。

當select檢測到有客戶端請求,uhttpd就會先接受請求,再進行解析,之后再調用uh_dispatch_request去分發請求。其中,lua請求比較特殊,不由uh_dispatch_request分發。

在分發過程(不包括lua請求)當中,會根據path的前綴來判斷是CGI請求還是靜態文件請求,默認的CGI前綴是/cgi-bin。CGI請求進入uh_cgi_request,文件請求進入uh_file_request,lua請求則會進入lua_request。

在三種handler中,就會進行請求的處理了。lua_request會調用lua解釋器進行處理,file_request直接讀取文件並且返回,CGI請求比較復雜,之后會詳細說明。

在三種request處理之后,都會返回給客戶端。一次循環到此結束。

啟動

服務器配置

啟動入口的main函數位於uhttpd.c,它接受命令行參數,進行服務器配置,並且啟動服務器。讓我們先來看看它有哪些配置。

helphelp

其中port必須指定,別的都有默認值。一般情況下我們可以用這樣的參數來啟動服務器:

1
./uhttpd -p 8080

服務器默認是運行在后台的,可以使用“ps -A | grep uhttp”看到它的運行情況,用nmap掃描一下本地端口也可以看到它已經在監聽8080端口了。
nmapnmap

uhttpd的配置用“struct config”存儲,成員也很豐富:

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
struct config {
char docroot[PATH_MAX];
char *realm;
char *file;
char *index_file;
char *error_handler;
int no_symlinks;
int no_dirlists;
int network_timeout;
int rfc1918_filter;
int tcp_keepalive;
#ifdef HAVE_CGI
char *cgi_prefix;
#endif
#ifdef HAVE_LUA
char *lua_prefix;
char *lua_handler;
lua_State *lua_state;
lua_State * (*lua_init) ( const char *handler);
void (*lua_close) (lua_State *L);
void (*lua_request) (struct client *cl, struct http_request *req, lua_State *L);
#endif
#if defined(HAVE_CGI) || defined(HAVE_LUA)
int script_timeout;
#endif
#ifdef HAVE_TLS
char *cert;
char *key;
SSL_CTX *tls;
SSL_CTX * (*tls_init) ( void);
int (*tls_cert) (SSL_CTX *c, const char *file);
int (*tls_key) (SSL_CTX *c, const char *file);
void (*tls_free) (struct listener *l);
int (*tls_accept) (struct client *c);
void (*tls_close) (struct client *c);
int (*tls_recv) (struct client *c, void *buf, int len);
int (*tls_send) (struct client *c, void *buf, int len);
#endif
};

端口綁定

服務器的端口綁定沒有寫在config里面,而是直接用uh_socket_bind進行了端口的綁定。

1
2
3
4
5
/* bind sockets */
bound += uh_socket_bind(
&serv_fds, &max_fd, bind[ 0] ? bind : NULL, port,
&hints, (opt == 's'), &conf
);

uh_socket_bind:

1
2
3
4
static int uh_socket_bind(
fd_set *serv_fds, int *max_fd, const char *host, const char *port,
struct addrinfo *hints, int do_tls, struct config *conf
)

此函數進行端口綁定並且把listener加到了一個全局的鏈表中,於是我們可以綁定多個端口。

1
2
3
4
5
6
/* add listener to global list */
if( ! (l = uh_listener_add(sock, conf)) )
{
fprintf(stderr, "uh_listener_add(): Failed to allocate memory\n");
goto error;
}

比較有意思的是,uhttpd把一些信息存在了鏈表里,用add函數在表頭插入。C語言沒有現成的集合框架,但是自己寫一個鏈表也是很輕松的。這些工具函數都在uhttpd-utils.c里。

1
2
3
4
static struct client *uh_clients = NULL;
struct client * uh_client_add(int sock, struct listener *serv);
struct client * uh_client_lookup(int sock);
void uh_client_remove(int sock);

配置文件

光用命令行的話肯定太麻煩,uhttpd也可以用配置文件來進行配置。

1
2
/* config file */
uh_config_parse(&conf);

但是配置文件的選項好像不是很多,最好的方式還是寫一個啟動腳本。

正式啟動

在一系列的配置之后,uhttpd終於要正式啟動了。它默認是后台啟動,fork一個子進程,父進程退出,子進程帶着配置文件和服務器的FD進入了mainloop。

1
2
/* server main loop */
uh_mainloop(&conf, serv_fds, max_fd);

等待請求

uh_mainloop函數也在uhttp.c里,最外層是一個大的循環。

1
2
3
4
5
6
7
while (run) {
if( select(max_fd + 1, &read_fds, NULL, NULL, NULL) == -1 )
{
...
}
...
}

select

select函數起到了阻塞請求的作用,並且和accept不用的是,它使用輪詢機制,而不是fork,更加適合嵌入式設備。

1
2
3
4
5
if( select(max_fd + 1, &read_fds, NULL, NULL, NULL) == -1 )
{
perror( "select()");
exit(1);
}

最后一個參數是設置超時時間,如果設置成NULL,則無限超時,直到FD有變動。

獲得請求

之后會進入一個嵌套復雜的“if-else”語句,數了一下最深有六層if嵌套。主要的功能就是遍歷所有的FD,分別找到服務端和客戶端的FD,在服務端,accept並且把client加入鏈表。在客戶端的FD中,處理請求。

用uh_http_header_recv獲得請求之后,用uh_http_header_parse解析,得到一個http_request的結構體。

1
2
3
4
5
6
7
8
struct http_request {
int method;
float version;
int redirect_status;
char *url;
char *headers[UH_LIMIT_HEADERS];
struct auth_realm *realm;
};

請求分發

得到http_request之后,就可以根據URL來進行請求的分發了。帶有lua前綴的給lua_request,否則交給uh_dispatch_request。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* Lua request? */
if( conf->lua_state &&
uh_path_match(conf->lua_prefix, req->url) )
{
conf->lua_request(cl, req, conf->lua_state);
}
else
/* dispatch request */
if( (pin = uh_path_lookup(cl, req->url)) != NULL )
{
/* auth ok? */
if( !pin->redirected && uh_auth_check(cl, req, pin) )
uh_dispatch_request(cl, req, pin);
}

dispatch也只是做了一個簡單的判斷然后就交給下一級了。

1
2
3
4
5
6
7
8
9
if( uh_path_match(cl->server->conf->cgi_prefix, pin->name) ||
(ipr = uh_interpreter_lookup(pin->phys)) )
{
uh_cgi_request(cl, req, pin, ipr);
}
else
{
uh_file_request(cl, req, pin);
}

處理請求

lua請求暫時不說了,這里只說文件和CGI請求。

靜態文件

file_request在uhttpd-file.c中。成員如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
uhttpd-file.c
macro
_XOPEN_SOURCE
_BSD_SOURCE
 
function
uh_file_mime_lookup
uh_file_mktag
uh_file_date2unix
uh_file_unix2date
uh_file_header_lookup
uh_file_response_ok_hdrs
uh_file_response_200
uh_file_response_304
uh_file_response_412
uh_file_ if_match
uh_file_ if_modified_since
uh_file_ if_none_match
uh_file_ if_range
uh_file_ if_unmodified_since
uh_file_scandir_filter_dir
uh_file_dirlist
uh_file_request

既然是靜態文件請求,自然是先看看本地有沒有這個文件,有的話就讀取內容發給客戶端,沒有就404。

這里有一個有趣的函數,

1
2
3
4
5
6
/* test preconditions */
if(ok) ensure_out(uh_file_if_modified_since(cl, req, &pi->stat, &ok));
if(ok) ensure_out(uh_file_if_match(cl, req, &pi->stat, &ok));
if(ok) ensure_out(uh_file_if_range(cl, req, &pi->stat, &ok));
if(ok) ensure_out(uh_file_if_unmodified_since(cl, req, &pi->stat, &ok));
if(ok) ensure_out(uh_file_if_none_match(cl, req, &pi->stat, &ok));

處理請求的過程中大量使用了ensure_out,它應該是保證關閉FD的。如果網絡發生異常或者文件讀寫異常,需要保證FD被正確關閉。實現很簡單,一個類函數宏就搞定了。

1
2
3
4
5
6
#define ensure_out(x) \
do { if((x) < 0) goto out; } while(0)
 
out:
if( fd > -1 )
close(fd);

CGI請求

處理CGI請求稍微復雜一點。

uh_cgi_request函數位於uhttpd-cgi.c。成員如下。

1
2
3
4
5
6
uhttpd-cgi.c
function
uh_cgi_header_parse
uh_cgi_header_lookup
uh_cgi_error_500
uh_cgi_request

雖然成員很少,但是總體還是有600多行。

CGI的處理過程,基本上就是調用CGI程序,獲得它的處理結果,然后返回給客戶端。但CGI程序和主調函數,肯定是兩個進程,它們之間如何通信,如何傳遞數據,這才是關鍵。

uhttpd采用了管道和CGI程序進行通信,有兩個管道,實現雙向通信。一個管道負責從父進程寫數據到CGI程序,主要是客戶端的POST數據。另一個就是讀取CGI程序的處理結果。同時,按照CGI的標准,HTTP請求頭都是通過環境變量的方式傳給CGI程序的,CGI程序是fork和exec的,所以會繼承環境變量,達到傳遞數據的目的。

在子進程中,則用dup2進行了一個重定向,把輸入輸出流都定向到了管道。

1
2
3
/* patch stdout and stdin to pipes */
dup2(rfd[ 1], 1);
dup2(wfd[ 0], 0);

之后就用了大段的代碼設置環境變量。

1
2
3
4
5
6
setenv( "SERVER_NAME", sa_straddr(&cl->servaddr), 1);
setenv( "SERVER_ADDR", sa_straddr(&cl->servaddr), 1);
setenv( "SERVER_PORT", sa_strport(&cl->servaddr), 1);
setenv( "REMOTE_HOST", sa_straddr(&cl->peeraddr), 1);
setenv( "REMOTE_ADDR", sa_straddr(&cl->peeraddr), 1);
setenv( "REMOTE_PORT", sa_strport(&cl->peeraddr), 1);

之后才真正地調用CGI程序。

1
2
3
4
if( ip != NULL )
execl(ip->path, ip->path, pi->phys, NULL);
else
execl(pi->phys, pi->phys, NULL);

與此同時,父進程則焦急地等待着管道另一頭的回音。它等來等去等的不耐煩了,於是它又機制地給自己設置了一個timeout,過了這個時間它就離開了。

1
2
3
4
5
6
7
8
9
10
11
12
13
ensure_out(rv = select_intr(fd_max, &reader,
(content_length > - 1) ? &writer : NULL, NULL, &timeout));
......
/* read it from socket ... */
ensure_out(buflen = uh_tcp_recv(cl, buf,
min(content_length, sizeof(buf))));
.....
/* ... and write it to child's stdin */
if( write(wfd[1], buf, buflen) < 0 )
perror( "write()");
......
/* read data from child ... */
if( (buflen = read(rfd[0], buf, sizeof(buf))) > 0 )

從CGI程序讀完了數據之后,它還是不放心,又解析了一下響應頭,確認正確之后,才發給了客戶端。

到這里,整個處理過程才算結束。

總結

第一次看服務器的源碼,所以找了一個比較簡單的服務器。大致能夠理解它的原理,但是很多細節還是不明白,可能只有自己親自去實現才能對它有一個深刻的理解。uhttd的代碼並不多,其中很多的代碼都用來處理錯誤,可見處理異常情況也是很重要的。有機會的話,希望自己能親自實現一個服務器。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM